Skip to content

Util

util

General-purpose utilities.

Geometry/WKT/SRS/BBOX helpers, UUID and XPlanung art xpath parsing/serialization, external-reference (URL and raster) validation, and version-migration path computation.

ExternalReferenceUtil(ref_url, georef_url=None)

Utility class to validate external references.

Attributes:

Name Type Description
ref_url AnyUrl

The reference URL stored as an AnyUrl object.

georef_url AnyUrl

The URL of a georeference sidecar file.

Source code in xplan_tools/util/__init__.py
def __init__(self, ref_url: str, georef_url: str | None = None) -> None:
    self.ref_url = self._parse_url(ref_url)
    self.georef_url = self._parse_url(georef_url)

MigrationPath(from_version, to_version)

Computes migration path between two XPlanung versions.

Source code in xplan_tools/util/__init__.py
def __init__(self, from_version: _Versions, to_version: _Versions):
    self.from_version = from_version
    self.to_version = to_version

    self.edges = {
        "4": _Versions._5_4,
        "5": _Versions._6_0,
        "60": [_Versions._6_1, _Versions._plu],
        "61": _Versions._plu,
    }

path property

Returns migration path, given the initial and the target version of the plan.

RasterReferenceUtil(ref_url, georef_url=None)

Bases: ExternalReferenceUtil

Utility class to validate external raster data references.

Provides the raster_data_valid() method for an in-depth check regarding projection data etc.

Attributes:

Name Type Description
ref_url AnyUrl

The reference URL.

georef_url AnyUrl

The URL of a georeference sidecar file.

Source code in xplan_tools/util/__init__.py
def __init__(self, ref_url: str, georef_url: str | None = None) -> None:
    self.ref_url = self._parse_url(ref_url)
    self.georef_url = self._parse_url(georef_url)

raster_data_valid()

Validate raster data referenced via URL.

The raster file is checked for projection data using GDAL. \n If GDAL detects a georeference file that was not initially provided, it is added to the georef_url attribute.

Returns:

Name Type Description
bool bool

True if successful, False otherwise.

Source code in xplan_tools/util/__init__.py
def raster_data_valid(self) -> bool:
    r"""Validate raster data referenced via URL.

    The raster file is checked for projection data using GDAL. \n
    If GDAL detects a georeference file that was not initially provided, it is added to the georef_url attribute.

    Returns:
        bool: True if successful, False otherwise.

    """

    def _check_validity(check_url: AnyUrl) -> bool:
        url = (
            check_url.path
            if check_url.scheme == "file"
            else f"/vsicurl/{check_url}"
        )

        try:
            gdal_info = gdal.Info(url, format="json")
            if gdal_info is None:
                logger.error(f"Failed to open the dataset {self.ref_url}")
                return False

            elif not gdal_info.get("geoTransform", None):
                logger.error(f"No projection data found for {self.ref_url}")
                return False
            if not gdal_info["stac"].get("proj:epsg", None):
                logger.warning(f"No EPSG code found for for {self.ref_url}")
            if len(gdal_info["files"]) > 1 and not self.georef_url:
                self.georef_url = self._parse_url(
                    next(
                        filter(
                            lambda x: (
                                os.path.splitext(x)[1]
                                in [".tfw", ".tifw", ".jgw", ".pgw", ".wld"]
                            ),
                            gdal_info["files"],
                        )
                    ).replace("/vsicurl/", "")
                )
            return True
        except Exception as e:
            logger.error(f"An error occurred while processing {self.ref_url}: {e}")
            return False

    if not self.georef_content:
        return _check_validity(self.ref_url)
    else:
        _, file_ext = os.path.splitext(os.path.basename(str(self.ref_url)))
        if not file_ext:
            logger.error(f"Could not determine file extension of {self.ref_url}")
            return False
        vsimem_path = f"/vsimem/raster{file_ext}"
        gdal.FileFromMemBuffer(vsimem_path, self.ref_content)
        gdal.FileFromMemBuffer(
            "/vsimem/raster.wld", self.georef_content or io.BytesIO().getvalue()
        )
        return _check_validity(AnyUrl(f"file://{vsimem_path}"))

cast_geom_to_multi(geom)

Cast a single geometry to its multi variant.

Source code in xplan_tools/util/__init__.py
def cast_geom_to_multi(geom: str) -> str:
    """Cast a single geometry to its multi variant."""
    ogr_geom = ogr.CreateGeometryFromWkt(geom)
    geom_type_name = ogr.GeometryTypeToName(ogr_geom.GetGeometryType())
    match geom_type_name:
        case "Polygon" | "Curve Polygon":
            ogr_geom = ogr.ForceToMultiPolygon(ogr_geom)
        case "Line String" | "Circular String" | "Compound Curve":
            ogr_geom = ogr.ForceToMultiLineString(ogr_geom)
        case "Point":
            ogr_geom = ogr.ForceToMultiPoint(ogr_geom)
    wkt = ogr_geom.ExportToWkt()
    ogr_geom = None
    return wkt

cast_geom_to_single(geom)

Cast a multi geometry to its single variant.

Source code in xplan_tools/util/__init__.py
def cast_geom_to_single(geom: str) -> str:
    """Cast a multi geometry to its single variant."""
    ogr_geom = ogr.CreateGeometryFromWkt(geom)
    geom_type_name = ogr.GeometryTypeToName(ogr_geom.GetGeometryType())
    match geom_type_name:
        case "Multi Polygon" | "Multi Surface":
            ogr_geom = ogr.ForceToPolygon(ogr_geom)
        case "Multi Line String" | "Multi Curve":
            ogr_geom = ogr.ForceToLineString(ogr_geom)
        case "Multi Point":
            if ogr_geom.GetGeometryCount() == 1:
                ogr_geom = ogr_geom.GetGeometryRef(0)
    wkt = ogr_geom.ExportToWkt()
    ogr_geom = None
    return wkt

enrich_attr_tuple(obj, art_tuple)

Return feature property information for construction of xpath expression.

Source code in xplan_tools/util/__init__.py
def enrich_attr_tuple(
    obj: "BaseFeature", art_tuple: tuple
) -> tuple[tuple, "BaseFeature", "PropertyInfo"]:
    """Return feature property information for construction of xpath expression."""
    result = []

    current_obj = obj

    # iterate in pairs: (attr, index)
    for attr, idx in zip(art_tuple[::2], art_tuple[1::2]):
        result.extend([attr, idx])
        property_info = current_obj.get_property_info(attr)

        if property_info.list:
            if idx is None:
                current_obj = getattr(current_obj, attr)[0]
            else:
                current_obj = getattr(current_obj, attr)[idx]
        else:
            current_obj = getattr(current_obj, attr)
        if isinstance(property_info.typename, list):
            current_type = current_obj.get_name()
        else:
            current_type = property_info.typename

        if property_info.stereotype == "DataType":
            result.extend([current_type, None])

    return tuple(result), current_obj, property_info

format_srs(srid, fmt='url')

Formats an EPSG SRID as an SRS string.

Parameters:

Name Type Description Default
srid int

The EPSG SRID.

required
fmt Literal['short', 'url']

"short" for EPSG:<code> or "url" for the OGC URL form.

'url'

Returns:

Type Description
str

The formatted SRS string.

Source code in xplan_tools/util/__init__.py
def format_srs(srid: int, fmt: Literal["short", "url"] = "url") -> str:
    """Formats an EPSG SRID as an SRS string.

    Args:
        srid: The EPSG SRID.
        fmt: ``"short"`` for ``EPSG:<code>`` or ``"url"`` for the OGC URL form.

    Returns:
        The formatted SRS string.
    """
    sr = osr.SpatialReference()
    sr.ImportFromEPSG(int(srid))
    auth, code = sr.GetAuthorityName(None), sr.GetAuthorityCode(None)
    match fmt:
        case "short":
            return f"{auth}:{code}"
        case "url":
            return f"http://www.opengis.net/def/crs/{auth}/0/{code}"

get_envelope(geoms)

Return a BBOX for a list of geometries.

Parameters:

Name Type Description Default
geoms list[str]

A list of WKT strings.

required

Returns:

Name Type Description
tuple tuple[float]

The BBOX coordinates in the format min_X, max_X, min_Y, max_Y.

Source code in xplan_tools/util/__init__.py
def get_envelope(geoms: list[str]) -> tuple[float]:
    """Return a BBOX for a list of geometries.

    Args:
        geoms: A list of WKT strings.

    Returns:
        tuple: The BBOX coordinates in the format min_X, max_X, min_Y, max_Y.
    """
    ogr_geom = ogr.CreateGeometryFromWkt(geoms.pop(0))
    for geom in geoms:
        ogr_geom = ogr_geom.Union(ogr.CreateGeometryFromWkt(geom))
    bbox = ogr_geom.GetEnvelope()
    ogr_geom = None
    return bbox

get_geometry_type_from_wkt(geom)

Derives the geometry type from a WKT string.

Source code in xplan_tools/util/__init__.py
def get_geometry_type_from_wkt(geom: str):
    """Derives the geometry type from a WKT string."""
    # Imported lazily: util is imported during model package init (via orm),
    # so a module-level import of definitions would be circular.
    from xplan_tools.model.appschema.definitions import (
        Line,
        MultiLine,
        MultiPoint,
        MultiPolygon,
        Point,
        Polygon,
    )

    for geom_model in (Line, MultiLine, MultiPoint, Point, Polygon, MultiPolygon):
        if re.match(geom_model.model_fields["wkt"].metadata[0].pattern, geom):
            return geom_model

is_uuid(value, exact=False)

Check whether a given string is - or, unless exact, contains - a valid UUID.

Source code in xplan_tools/util/__init__.py
def is_uuid(value: str | None, exact: bool = False) -> bool:
    """Check whether a given string is - or, unless `exact`, contains - a valid UUID."""
    lookup = _UUID_RE.fullmatch if exact else _UUID_RE.search
    return lookup(value or "") is not None

linearize_geom(geom)

Returns the linearized WKT string.

Source code in xplan_tools/util/__init__.py
def linearize_geom(geom: str) -> str:
    """Returns the linearized WKT string."""
    split_geom = geom.split(";")
    return f"{split_geom[0]};{ogr.CreateGeometryFromWkt(split_geom[1]).GetLinearGeometry().ExportToWkt()}"

parse_art_xpath(xpath)

Parse an xpath expression into a tuple of feature property information and corresponding indices.

Source code in xplan_tools/util/__init__.py
def parse_art_xpath(xpath: str) -> tuple:
    """Parse an xpath expression into a tuple of feature property information and corresponding indices."""
    result = []

    for segment in xpath.split("/"):
        match = _SEGMENT_PATTERN.match(segment)
        if not match:
            raise ValueError(f"Invalid segment: {segment}")

        name, index = match.groups()

        # Skip class markers like BP_KomplexeSondernutzung
        if _CLASS_PATTERN.match(name):
            continue

        result.append(name)
        result.append(max(int(index) - 1, 0) if index is not None else None)

    return tuple(result)

parse_srs(srs)

Returns the EPSG SRID for an SRS reference.

Accepts any notation OGR's SetFromUserInput understands (EPSG:25832, urn:ogc:def:crs:EPSG::25832, the OGC URL form, WKT) as well as the JSON-FG coordRefSys object form ({"type": "Reference", "href": ...}). CRS84 and its OGC URI/URN aliases resolve to 4326. The JSON-FG array (compound CRS) form is not supported. Returns None if the SRS cannot be resolved.

Parameters:

Name Type Description Default
srs str | dict | None

The SRS reference, as a string, a JSON-FG coordRefSys object, or None.

required

Returns:

Type Description
int | None

The EPSG SRID, or None if it could not be determined.

Source code in xplan_tools/util/__init__.py
def parse_srs(srs: str | dict | None) -> int | None:
    """Returns the EPSG SRID for an SRS reference.

    Accepts any notation OGR's ``SetFromUserInput`` understands (``EPSG:25832``,
    ``urn:ogc:def:crs:EPSG::25832``, the OGC URL form, WKT) as well as the JSON-FG
    ``coordRefSys`` object form (``{"type": "Reference", "href": ...}``). CRS84 and
    its OGC URI/URN aliases resolve to 4326. The JSON-FG array (compound CRS) form
    is not supported. Returns ``None`` if the SRS cannot be resolved.

    Args:
        srs: The SRS reference, as a string, a JSON-FG ``coordRefSys`` object, or None.

    Returns:
        The EPSG SRID, or None if it could not be determined.
    """
    if isinstance(srs, dict):
        srs = srs.get("href")
    if not srs:
        return None
    srs = srs.strip()
    # OGC WGS 84 lon/lat alias (bare "CRS84", "OGC:CRS84", urn/URL forms): not
    # resolvable to an EPSG code via OGR, but equivalent to EPSG:4326.
    if "CRS84" in srs:
        return 4326
    sr = osr.SpatialReference()
    try:
        sr.SetFromUserInput(srs)
        code = sr.GetAuthorityCode(None)
        if not (code and code.isdigit()):
            sr.AutoIdentifyEPSG()
            code = sr.GetAuthorityCode(None)
    except RuntimeError:
        return None
    return int(code) if code and code.isdigit() else None

parse_uuid(value, exact=False, raise_exception=False)

Return the UUID a given string contains, or None.

Source code in xplan_tools/util/__init__.py
def parse_uuid(
    value: str | None, exact: bool = False, raise_exception: bool = False
) -> UUID | None:
    """Return the UUID a given string contains, or None."""
    if not is_uuid(value, exact):
        if raise_exception:
            raise ValueError(f"{value} is not a valid UUID")
        return None
    return UUID(_UUID_RE.search(value).group())

serialize_art_xpath(t, prefix='xplan')

Construct xpath expression from tuple of feature property information.

Source code in xplan_tools/util/__init__.py
def serialize_art_xpath(t: tuple, prefix: str = "xplan") -> str:
    """Construct xpath expression from tuple of feature property information."""
    if len(t) % 2 != 0:
        raise ValueError("Tuple must contain (name, index) pairs")

    parts = []

    for i in range(0, len(t), 2):
        name = t[i]
        index = t[i + 1]

        if not isinstance(name, str):
            raise TypeError(f"Expected str at position {i}, got {type(name)}")

        name = f"{prefix}:{name}"
        if index is None:
            parts.append(name)
        elif isinstance(index, int):
            parts.append(f"{name}[{index + 1}]")
        else:
            raise TypeError(
                f"Expected int or None at position {i + 1}, got {type(index)}"
            )

    return "/".join(parts)

serialize_style_rules(format)

Serializes the style rules for XPlanung presentational objects in the selected format.

Parameters:

Name Type Description Default
format Literal['json', 'yaml']

The format to serialize to.

required
Source code in xplan_tools/util/__init__.py
def serialize_style_rules(format: Literal["json", "yaml"]):
    """Serializes the style rules for XPlanung presentational objects in the selected format.

    Args:
        format: The format to serialize to.
    """
    return (
        json.dumps(RULES, indent=2)
        if format == "json"
        else yaml.dump(RULES, allow_unicode=True, sort_keys=False)
    )

db

SQLAlchemy statement builders and helpers for coretable DB functions.

Each function returns a Select that invokes the corresponding PostgreSQL function; execute it against a sync Session or an async AsyncSession. The function's schema is resolved at execution time via the session's search_path, so the statements are not schema-qualified.

add_navigable_role(source_featuretype, navigable_role, target_featuretype, appschema, appschema_version, rel_direction, dependent_part=None)

Build a statement that idempotently registers a navigable role.

Parameters:

Name Type Description Default
source_featuretype str

Owning/source feature type name.

required
navigable_role str

Role (association) name linking source to target.

required
target_featuretype str

Referenced/target feature type name.

required
appschema str

Appschema prefix (e.g. "xplan").

required
appschema_version str

Appschema version (e.g. "6.1").

required
rel_direction Literal['forward', 'inverse']

Whether the refs edge runs "forward" or "inverse".

required
dependent_part Literal['source', 'target'] | None

Ownership marker; "target" means source existentially owns target, "source" means target existentially owns source, None for a non-ownership role.

None

Returns:

Type Description
Select[tuple[bool]]

A Select yielding the function's BOOLEAN result; execute with scalar_one().

Source code in xplan_tools/util/db.py
def add_navigable_role(
    source_featuretype: str,
    navigable_role: str,
    target_featuretype: str,
    appschema: str,
    appschema_version: str,
    rel_direction: Literal["forward", "inverse"],
    dependent_part: Literal["source", "target"] | None = None,
) -> Select[tuple[bool]]:
    """Build a statement that idempotently registers a navigable role.

    Args:
        source_featuretype: Owning/source feature type name.
        navigable_role: Role (association) name linking source to target.
        target_featuretype: Referenced/target feature type name.
        appschema: Appschema prefix (e.g. ``"xplan"``).
        appschema_version: Appschema version (e.g. ``"6.1"``).
        rel_direction: Whether the refs edge runs ``"forward"`` or ``"inverse"``.
        dependent_part: Ownership marker; ``"target"`` means source existentially owns target,
            ``"source"`` means target existentially owns source, ``None`` for a non-ownership role.

    Returns:
        A ``Select`` yielding the function's ``BOOLEAN`` result; execute with ``scalar_one()``.
    """
    return select(
        func.add_navigable_role(
            source_featuretype,
            navigable_role,
            target_featuretype,
            appschema,
            appschema_version,
            rel_direction,
            dependent_part,
        )
    )

coretable_delete_object_recursive(start_id, dry_run=False)

Build a statement that recursively deletes start_id and its safe cascade closure.

Warning

Executing the returned statement performs the deletion unless dry_run is True; run it inside a transaction. The affected rows are captured before removal, so the statement still yields them after the delete.

Parameters:

Name Type Description Default
start_id UUID

Root coretable id to delete.

required
dry_run bool

When True, return the closure without deleting anything.

False

Returns:

Type Description
Select[tuple[Feature]]

A Select yielding the deleted (or, for dry_run, would-be-deleted) ORM Feature

Select[tuple[Feature]]

rows; execute with scalars().all().

Source code in xplan_tools/util/db.py
def coretable_delete_object_recursive(
    start_id: UUID,
    dry_run: bool = False,
) -> Select[tuple[Feature]]:
    """Build a statement that recursively deletes ``start_id`` and its safe cascade closure.

    Warning:
        Executing the returned statement performs the deletion unless ``dry_run`` is ``True``; run
        it inside a transaction. The affected rows are captured before removal, so the statement
        still yields them after the delete.

    Args:
        start_id: Root coretable id to delete.
        dry_run: When ``True``, return the closure without deleting anything.

    Returns:
        A ``Select`` yielding the deleted (or, for ``dry_run``, would-be-deleted) ORM ``Feature``
        rows; execute with ``scalars().all()``.
    """
    deleted = aliased(
        Feature,
        func.coretable_delete_object_recursive(start_id, dry_run).table_valued(
            *Feature.__table__.c
        ),
    )
    return select(deleted)

coretable_delete_orphans_recursive()

Build a statement that deletes dependent-part objects no longer owned by any whole.

Warning

Executing the returned statement performs the deletion; run it inside a transaction.

Returns:

Type Description
Select[tuple[int]]

A Select yielding the total number of deleted rows as an INTEGER; execute with

Select[tuple[int]]

scalar_one().

Source code in xplan_tools/util/db.py
def coretable_delete_orphans_recursive() -> Select[tuple[int]]:
    """Build a statement that deletes dependent-part objects no longer owned by any whole.

    Warning:
        Executing the returned statement performs the deletion; run it inside a transaction.

    Returns:
        A ``Select`` yielding the total number of deleted rows as an ``INTEGER``; execute with
        ``scalar_one()``.
    """
    return select(func.coretable_delete_orphans_recursive())

coretable_feature_graph(start_id, depth_limit=3, include_forward=True, include_backward=True)

Return a chainable Select of Features reachable from start_id via the role graph.

Parameters:

Name Type Description Default
start_id UUID

Root coretable id to traverse from.

required
depth_limit int

Maximum BFS depth; defaults to 3 = the current appschemas' maximum depth.

3
include_forward bool

Traverse navigable roles in the source->target direction.

True
include_backward bool

Traverse navigable roles in the target->source direction.

True

Returns:

Type Description
Select[tuple[Feature]]

A chainable Select yielding ORM Feature rows.

Source code in xplan_tools/util/db.py
def coretable_feature_graph(
    start_id: UUID,
    depth_limit: int = 3,
    include_forward: bool = True,
    include_backward: bool = True,
) -> Select[tuple[Feature]]:
    """Return a chainable Select of Features reachable from ``start_id`` via the role graph.

    Args:
        start_id: Root coretable id to traverse from.
        depth_limit: Maximum BFS depth; defaults to 3 = the current appschemas' maximum depth.
        include_forward: Traverse navigable roles in the source->target direction.
        include_backward: Traverse navigable roles in the target->source direction.

    Returns:
        A chainable ``Select`` yielding ORM ``Feature`` rows.
    """
    graph_ids = func.coretable_role_graph_ids(
        start_id, depth_limit, include_forward, include_backward, False
    ).table_valued("id")
    return select(Feature).where(exists().where(Feature.id == graph_ids.c.id))

coretable_role_graph(start_id, depth_limit=0, include_forward=True, include_backward=True, dependent_parts=False)

Build a statement returning the Feature rows reachable from start_id.

Parameters:

Name Type Description Default
start_id UUID

Root coretable id to traverse from.

required
depth_limit int

Maximum BFS depth; 0 means unbounded.

0
include_forward bool

Traverse navigable roles in the source->target direction.

True
include_backward bool

Traverse navigable roles in the target->source direction.

True
dependent_parts bool

Collect the transitive existential dependent-part (ownership) closure instead of a general traversal; both directions are followed, ignoring the include_* flags.

False

Returns:

Type Description
Select[tuple[Feature]]

A Select yielding ORM Feature rows; execute with scalars().all().

Source code in xplan_tools/util/db.py
def coretable_role_graph(
    start_id: UUID,
    depth_limit: int = 0,
    include_forward: bool = True,
    include_backward: bool = True,
    dependent_parts: bool = False,
) -> Select[tuple[Feature]]:
    """Build a statement returning the ``Feature`` rows reachable from ``start_id``.

    Args:
        start_id: Root coretable id to traverse from.
        depth_limit: Maximum BFS depth; ``0`` means unbounded.
        include_forward: Traverse navigable roles in the source->target direction.
        include_backward: Traverse navigable roles in the target->source direction.
        dependent_parts: Collect the transitive existential dependent-part (ownership) closure
            instead of a general traversal; both directions are followed, ignoring the include_*
            flags.

    Returns:
        A ``Select`` yielding ORM ``Feature`` rows; execute with ``scalars().all()``.
    """
    graph = aliased(
        Feature,
        func.coretable_role_graph(
            start_id, depth_limit, include_forward, include_backward, dependent_parts
        ).table_valued(*Feature.__table__.c),
    )
    return select(graph)

coretable_role_graph_cascade(start_id, depth_limit=0)

Build a statement returning the Feature rows safe to cascade-delete with start_id.

The result is the dependent-part closure of start_id minus any part whose owning whole lies outside that closure (transitively), i.e. the objects that can be removed together with start_id without orphaning a part still owned from elsewhere.

Parameters:

Name Type Description Default
start_id UUID

Root coretable id whose cascade closure is collected.

required
depth_limit int

Maximum BFS depth; 0 means unbounded.

0

Returns:

Type Description
Select[tuple[Feature]]

A Select yielding ORM Feature rows; execute with scalars().all().

Source code in xplan_tools/util/db.py
def coretable_role_graph_cascade(
    start_id: UUID,
    depth_limit: int = 0,
) -> Select[tuple[Feature]]:
    """Build a statement returning the ``Feature`` rows safe to cascade-delete with ``start_id``.

    The result is the dependent-part closure of ``start_id`` minus any part whose owning whole lies
    outside that closure (transitively), i.e. the objects that can be removed together with
    ``start_id`` without orphaning a part still owned from elsewhere.

    Args:
        start_id: Root coretable id whose cascade closure is collected.
        depth_limit: Maximum BFS depth; ``0`` means unbounded.

    Returns:
        A ``Select`` yielding ORM ``Feature`` rows; execute with ``scalars().all()``.
    """
    cascade = aliased(
        Feature,
        func.coretable_role_graph_cascade(start_id, depth_limit).table_valued(
            *Feature.__table__.c
        ),
    )
    return select(cascade)

coretable_role_graph_ids(start_id, depth_limit=0, include_forward=True, include_backward=True, dependent_parts=False)

Build a statement returning the ids reachable from start_id via navigable roles.

Parameters:

Name Type Description Default
start_id UUID

Root coretable id to traverse from.

required
depth_limit int

Maximum BFS depth; 0 means unbounded.

0
include_forward bool

Traverse navigable roles in the source->target direction.

True
include_backward bool

Traverse navigable roles in the target->source direction.

True
dependent_parts bool

Collect the transitive existential dependent-part (ownership) closure instead of a general traversal; both directions are followed, ignoring the include_* flags.

False

Returns:

Type Description
Select[tuple[UUID]]

A Select yielding one UUID per reachable id (empty when start_id does not

Select[tuple[UUID]]

exist); execute with scalars().all().

Source code in xplan_tools/util/db.py
def coretable_role_graph_ids(
    start_id: UUID,
    depth_limit: int = 0,
    include_forward: bool = True,
    include_backward: bool = True,
    dependent_parts: bool = False,
) -> Select[tuple[UUID]]:
    """Build a statement returning the ids reachable from ``start_id`` via navigable roles.

    Args:
        start_id: Root coretable id to traverse from.
        depth_limit: Maximum BFS depth; ``0`` means unbounded.
        include_forward: Traverse navigable roles in the source->target direction.
        include_backward: Traverse navigable roles in the target->source direction.
        dependent_parts: Collect the transitive existential dependent-part (ownership) closure
            instead of a general traversal; both directions are followed, ignoring the include_*
            flags.

    Returns:
        A ``Select`` yielding one ``UUID`` per reachable id (empty when ``start_id`` does not
        exist); execute with ``scalars().all()``.
    """
    graph_ids = func.coretable_role_graph_ids(
        start_id, depth_limit, include_forward, include_backward, dependent_parts
    ).table_valued("id")
    return select(graph_ids.c.id)

list_navigable_roles()

Build a statement returning every configured navigable role.

Returns:

Type Description
Select[tuple[str, str, str, str, str, str, str | None]]

A Select yielding one row per configured role with columns source_featuretype,

Select[tuple[str, str, str, str, str, str, str | None]]

navigable_role, target_featuretype, appschema, appschema_version,

Select[tuple[str, str, str, str, str, str, str | None]]

rel_direction and dependent_part; execute with all().

Source code in xplan_tools/util/db.py
def list_navigable_roles() -> Select[tuple[str, str, str, str, str, str, str | None]]:
    """Build a statement returning every configured navigable role.

    Returns:
        A ``Select`` yielding one row per configured role with columns ``source_featuretype``,
        ``navigable_role``, ``target_featuretype``, ``appschema``, ``appschema_version``,
        ``rel_direction`` and ``dependent_part``; execute with ``all()``.
    """
    # The function projects the business columns of navigable_roles_config, dropping the
    # surrogate `id` and the `created_at` audit column that the ORM model also carries.
    role_columns = [
        column
        for column in NavigableRolesConfig.__table__.c
        if column.name not in ("id", "created_at")
    ]
    roles = func.list_navigable_roles().table_valued(*role_columns)
    return select(roles)

list_top_level_featuretypes()

Build a statement returning the top-level (containment/deletion root) feature types.

Returns:

Type Description
Select[tuple[str, str, str]]

A Select yielding one row per appschema/version with columns featuretype,

Select[tuple[str, str, str]]

appschema and appschema_version; execute with all().

Source code in xplan_tools/util/db.py
def list_top_level_featuretypes() -> Select[tuple[str, str, str]]:
    """Build a statement returning the top-level (containment/deletion root) feature types.

    Returns:
        A ``Select`` yielding one row per appschema/version with columns ``featuretype``,
        ``appschema`` and ``appschema_version``; execute with ``all()``.
    """
    top_level = func.list_top_level_featuretypes().table_valued(
        "featuretype",
        "appschema",
        "appschema_version",
    )
    return select(top_level)

pg_array(values, type_)

Bind a Python sequence as a single PostgreSQL array parameter.

Keeps a statement's bind-parameter count independent of len(values): asyncpg refuses more than 32767 parameters per statement, and a bind list whose length varies also defeats the driver's prepared-statement cache, since it caches by SQL text.

Parameters:

Name Type Description Default
values Sequence[Any]

The values to bind; element bind processing runs through type_.

required
type_ TypeEngine

The array's item type, e.g. Refs.base_id.type.

required

Returns:

Type Description
Cast

A CAST(:param AS <type>[]) expression carrying exactly one bind parameter. The

Cast

cast is explicit because PostgreSQL cannot infer a polymorphic function's argument

Cast

type from an untyped parameter (see unnest_rows); SQLAlchemy's own array bind cast

Cast

may render a second, redundant ::<type>[] alongside it.

Source code in xplan_tools/util/db.py
def pg_array(values: Sequence[Any], type_: TypeEngine) -> Cast:
    """Bind a Python sequence as a single PostgreSQL array parameter.

    Keeps a statement's bind-parameter count independent of ``len(values)``: asyncpg refuses
    more than 32767 parameters per statement, and a bind list whose length varies also defeats
    the driver's prepared-statement cache, since it caches by SQL text.

    Args:
        values: The values to bind; element bind processing runs through ``type_``.
        type_: The array's item type, e.g. ``Refs.base_id.type``.

    Returns:
        A ``CAST(:param AS <type>[])`` expression carrying exactly one bind parameter. The
        cast is explicit because PostgreSQL cannot infer a polymorphic function's argument
        type from an untyped parameter (see `unnest_rows`); SQLAlchemy's own array bind cast
        may render a second, redundant ``::<type>[]`` alongside it.
    """
    # `values` is already a fresh list at every call site; copying it again doubles the
    # transient allocation of a batch that can hold tens of thousands of rows per column
    return cast(
        literal(values if isinstance(values, list) else list(values), ARRAY(type_)),
        ARRAY(type_),
    )

unnest_rows(rows, columns, *, types=None)

Expose row dicts as a table-valued unnest over one array parameter per column.

The PostgreSQL counterpart to an inline VALUES list: instead of one bind parameter per cell it binds one array per column, so the parameter count stays constant (see pg_array).

Parameters:

Name Type Description Default
rows Sequence[dict]

Row dicts keyed by column name; every column in columns must be present.

required
columns Sequence[Column]

The columns to emit, in order.

required
types Mapping[str, TypeEngine] | None

Array item types overriding a column's own type, keyed by column name. Needed where the column type is not what the driver can encode an array of — geometry columns travel as TEXT[] of eWKT and are converted in the select list.

None

Returns:

Type Description
TableValuedAlias

A derived table aliased with columns' names, addressable via .c.<name>.

Source code in xplan_tools/util/db.py
def unnest_rows(
    rows: Sequence[dict],
    columns: Sequence[Column],
    *,
    types: Mapping[str, TypeEngine] | None = None,
) -> TableValuedAlias:
    """Expose row dicts as a table-valued ``unnest`` over one array parameter per column.

    The PostgreSQL counterpart to an inline ``VALUES`` list: instead of one bind parameter per
    cell it binds one array per column, so the parameter count stays constant (see `pg_array`).

    Args:
        rows: Row dicts keyed by column name; every column in ``columns`` must be present.
        columns: The columns to emit, in order.
        types: Array item types overriding a column's own type, keyed by column name. Needed
            where the column type is not what the driver can encode an array of — geometry
            columns travel as ``TEXT[]`` of eWKT and are converted in the select list.

    Returns:
        A derived table aliased with ``columns``' names, addressable via ``.c.<name>``.
    """
    types = types or {}
    return (
        func.unnest(
            *[
                pg_array([r[c.name] for r in rows], types.get(c.name, c.type))
                for c in columns
            ]
        )
        .table_valued(*[c.name for c in columns])
        # Multi-argument unnest names every output column "unnest", so the derived column
        # alias list is what makes `.c.<name>` resolvable.
        .render_derived()
    )

streams

Reading a datasource as a binary stream.

A datasource reaches this package as a file path or as a file-like object, and every reader needs the same thing from it: bytes, from the start, without consuming a buffer its caller still holds. That normalisation lives here rather than in any one reader.

Not named io: importing xplan_tools.util.io would bind that name on the package and shadow the standard library io that xplan_tools/util/__init__.py imports.

as_binary_stream(source)

Yields source as a binary stream positioned at the start.

A path is opened and closed here. A seekable binary stream is rewound first and put back where it was found afterwards, so a caller's buffer comes back as it was handed over. Anything else - a text stream, a non-seekable one - is copied into a BytesIO, since a reader must not consume a stream the caller still needs. Copying a text stream also lets a StringIO carrying an encoding declaration be read, where etree.parse refuses it.

The copy made for a non-seekable stream serves that one read: the source is consumed either way, so a caller that reads twice has to hold the bytes itself. Datasource.buffer is what does that.

Parameters:

Name Type Description Default
source str | BytesIO | StringIO

A file path, or a binary or text file-like object.

required

Raises:

Type Description
UnsupportedDatasourceError

source is neither a path nor a readable file-like object.

OSError

The path could not be opened.

Yields:

Type Description
IO[bytes]

A binary stream.

Source code in xplan_tools/util/streams.py
@contextmanager
def as_binary_stream(source: str | io.BytesIO | io.StringIO) -> Iterator[IO[bytes]]:
    """Yields `source` as a binary stream positioned at the start.

    A path is opened and closed here. A seekable binary stream is rewound first and put
    back where it was found afterwards, so a caller's buffer comes back as it was handed
    over. Anything else - a text stream, a non-seekable one - is copied into a `BytesIO`,
    since a reader must not consume a stream the caller still needs. Copying a text
    stream also lets a `StringIO` carrying an encoding declaration be read, where
    `etree.parse` refuses it.

    The copy made for a non-seekable stream serves that one read: the source is consumed
    either way, so a caller that reads twice has to hold the bytes itself.
    [`Datasource.buffer`][xplan_tools.interface.datasource.Datasource] is what does that.

    Args:
        source: A file path, or a binary or text file-like object.

    Raises:
        UnsupportedDatasourceError: `source` is neither a path nor a readable file-like
            object.
        OSError: The path could not be opened.

    Yields:
        A binary stream.
    """
    if isinstance(source, str):
        with open(source, "rb") as file:
            yield file
        return

    if not hasattr(source, "read"):
        raise UnsupportedDatasourceError(
            f"unsupported datasource {type(source).__name__!r}"
        )

    if not source.seekable():
        data = source.read()
        yield io.BytesIO(data.encode("utf-8") if isinstance(data, str) else data)
        return

    position = source.tell()
    source.seek(0)
    try:
        if isinstance(source.read(0), str):  # a seekable text stream
            source.seek(0)
            yield io.BytesIO(source.read().encode("utf-8"))
        else:
            source.seek(0)
            yield source
    finally:
        source.seek(position)

style

Derive XPlanung styling for presentational objects from the rule set.

Matches a presentational object's art property references against the rules in :data:xplan_tools.resources.styles.RULES to populate stylesheetId and schriftinhalt.

add_style_properties_to_feature(obj, ref_obj, to_text=False, always_populate_schriftinhalt=False)

Add styling properties to presentational objects.

This method parses object (dientZurDarstellungVon) and property (art) references from presentational objects and derives styling information (stylesheetId, schriftinhalt) based on a set of defined rules.

Parameters:

Name Type Description Default
obj BaseFeature

The presentational object.

required
ref_obj BaseFeature

The object referenced by the presentational object.

required
to_text bool

Whether to convert symbolic presentational objects to textual ones. Defaults to False.

False
always_populate_schriftinhalt bool

Populate schriftinhalt even if a rule has no text template.

False
Source code in xplan_tools/util/style.py
def add_style_properties_to_feature(
    obj: "BaseFeature",
    ref_obj: "BaseFeature",
    to_text: bool = False,
    always_populate_schriftinhalt: bool = False,
) -> "BaseFeature":
    """Add styling properties to presentational objects.

    This method parses object (dientZurDarstellungVon) and property (art) references from
    presentational objects and derives styling information (stylesheetId, schriftinhalt)
    based on a set of defined rules.

    Args:
        obj: The presentational object.
        ref_obj: The object referenced by the presentational object.
        to_text: Whether to convert symbolic presentational objects to textual ones. Defaults to False.
        always_populate_schriftinhalt: Populate `schriftinhalt` even if a rule has no text template.
    """
    uom_map = {"m2": "m²", "m3": "m³", "grad": "°"}

    def parse_art(ref_obj: "BaseFeature", art: str) -> dict:
        def parse_value(value: Any) -> dict:
            if prop_info.stereotype == "Measure":
                value = value.value
            if prop_info.typename == "Boolean":
                value = str(value).lower()

            if name.startswith("Z"):
                text = toRoman(value)
            elif prop_info.stereotype == "Enumeration":
                member = prop_info.enum(value)
                text = member.token or member.alias or member.label
            elif prop_info.stereotype == "Codelist":
                text = str(value)
                if text.startswith("urn:"):
                    text = text.split(f"urn:xplan:{prop_info.typename}:")[1]
            elif prop_info.stereotype == "Measure":
                text = f"{value:n} {uom_map.get(prop_info.uom, prop_info.uom)}"
            else:
                text = value

            return {
                "value": value,
                "text": text,
            }

        xpath_input_tuple = parse_art_xpath(art)
        enriched_tuple, value, prop_info = enrich_attr_tuple(ref_obj, xpath_input_tuple)
        name = enriched_tuple[::2][-1]

        data = {
            "name": name,
            "data": parse_value(value),
            "type": prop_info.typename,
        }
        return data

    # TODO use for XPlanung v6.1 with addition attribute massstabFaktor
    # def set_scale(obj):
    #     bereich = self.root.get(str(obj.gehoertZuBereich))
    #     plan = self.root.get(str(bereich.gehoertZuPlan))
    #     default_scale = SCALES.get(plan.get_name(), 1000)
    #     actual_scale = (
    #         bereich.erstellungsMassstab or plan.erstellungsMassstab or default_scale
    #     )
    #     if obj.skalierung <= 3:
    #         obj.skalierung = float(obj.skalierung * actual_scale / 1000)

    obj = deepcopy(obj)
    logger.debug(f"Feature {obj.id}: adding style properties")
    version = obj.appschema().version
    if to_text and (old_type := obj.get_name()) == "XP_PPO":
        new_type = "XP_PTO"
        obj = obj.appschema().model_factory(new_type).model_validate(obj.model_dump())
        logger.info(f"Feature {obj.id}: converted {old_type} to {new_type}")
    # TODO use for XPlanung v6.1 with addition attribute massstabFaktor
    # if hasattr(obj, "skalierung"):
    #     set_scale(obj)

    logger.debug(
        f"parsing properties {obj.art} for referenced feature {ref_obj.get_name()} with ID {ref_obj.id}"
    )
    selectors = {}
    for art in obj.art:
        try:
            parsed_art = parse_art(ref_obj, art)
            selectors[parsed_art.pop("name")] = parsed_art
        except Exception:
            logger.error(f"Feature {obj.id}: art '{art}' could not be parsed")
    valid_rules = []
    for rule_id, rule in RULES.items():
        versioned_rule = rule["versions"].get(version, {"selector": {}})
        if isinstance(
            versioned_rule, str
        ):  # use other versioned rule referenced by string
            versioned_rule = rule["versions"][versioned_rule]
        valid = versioned_rule["selector"].keys() == selectors.keys() and (
            all(
                (
                    filter.get("value", None) == ["*"]
                    or selectors.get(attr, {}).get("data", {}).get("value", None)
                    in filter.get("value", False)
                )
                and (
                    selectors.get(attr, {}).get("type", None)
                    == filter.get("type", False)
                )
                for attr, filter in versioned_rule["selector"].items()
            )
            if versioned_rule.get("selector", None)
            else False
        )
        if valid:
            valid_rules.append(rule_id)
            texts = {attr: data["data"]["text"] for attr, data in selectors.items()}
            obj.stylesheetId = AnyUrl(
                f"https://registry.gdi-de.org/codelist/de.xleitstelle.xplanung/XP_StylesheetListe/{rule_id}"
            )
            if (text := versioned_rule.get("text", None)) and hasattr(
                obj, "schriftinhalt"
            ):
                obj.schriftinhalt = text.format(**texts)
            elif always_populate_schriftinhalt and hasattr(obj, "schriftinhalt"):
                obj.schriftinhalt = " ".join(
                    [str(data["data"]["text"]) for data in selectors.values()]
                ).strip()
    if not valid_rules:
        logger.warning(f"No rule found for feature {obj.id}")
        obj.stylesheetId = None
        if hasattr(obj, "schriftinhalt"):
            obj.schriftinhalt = " ".join(
                [str(data["data"]["text"]) for data in selectors.values()]
            ).strip()
            logger.debug(f"Feature {obj.id}: schriftinhalt set to {obj.schriftinhalt}")
        # if all(
        #     data["type"] in ["CharacterString", "Integer", "Decimal", "Length"]
        #     for data in selectors.values()
        # ):
        #     obj.stylesheetId = "81e52187-a33b-4340-9d6e-f25533e01aa3"
        #     if hasattr(obj, "schriftinhalt"):
        #         obj.schriftinhalt = " ".join(
        #             [str(data["data"]["text"]) for data in selectors.values()]
        #         ).strip()
        #         logger.debug(
        #             f"Feature {obj.id}: schriftinhalt set to {obj.schriftinhalt}"
        #         )
        # else:
        #     logger.warning(f"No rule found for feature {obj.id}")
    if len(valid_rules) > 1:
        raise ValueError(f"More than one rules valid: {', '.join(valid_rules)}")
    else:
        logger.debug(f"Feature {obj.id}: stylesheetId set to {obj.stylesheetId}")
    return obj

validate

This module contains a method to validate Feature Collections with the official XPlanValidator.

save_validation_reports(report_dict, output='report.json')

Save validation reports to JSON files.

Source code in xplan_tools/util/validate.py
def save_validation_reports(
    report_dict: dict[str, ResultReport],
    output: str = "report.json",
):
    """Save validation reports to JSON files."""
    if report_dict is None:
        return

    output_path = Path(output)

    for plan_id in report_dict.keys():
        if len(output_path.parents) > 1:
            output_path.parent.mkdir(parents=True, exist_ok=True)
        report_path = str(output_path.parent / f"{output_path.stem}_{plan_id}.json")
        with open(report_path, "wb") as f:
            f.write(to_json(report_dict[plan_id].validation_report, indent=4))
        logger.info(f"Validation report saved as {report_path}")

xplan_validate(collection, input='xplan.gml', single_plans=False, validator_url='https://www.xplanungsplattform.de/xplan-api-validator/xvalidator/api/v1/') async

Validate a Feature Collection with the official XPlanValidator.

Parameters:

Name Type Description Default
collection BaseCollection

A BaseCollection instance.

required
input str

An optional input file name to use in the validation report.

'xplan.gml'
single_plans bool

Whether to validate plans in the collection individually.

False
validator_url str

The base URL of the XPlanValidator instance. Must have a trailing slash.

'https://www.xplanungsplattform.de/xplan-api-validator/xvalidator/api/v1/'
Source code in xplan_tools/util/validate.py
async def xplan_validate(
    collection: BaseCollection,
    input: str = "xplan.gml",
    single_plans: bool = False,
    validator_url: str = "https://www.xplanungsplattform.de/xplan-api-validator/xvalidator/api/v1/",
) -> dict[str, ResultReport]:
    """Validate a Feature Collection with the official XPlanValidator.

    Args:
        collection: A BaseCollection instance.
        input: An optional input file name to use in the validation report.
        single_plans: Whether to validate plans in the collection individually.
        validator_url: The base URL of the XPlanValidator instance. Must have a trailing slash.
    """
    input_path = Path(input)
    report_dict: dict[str, ResultReport] = {}

    plans = (
        collection.get_single_plans(with_name=True)
        if single_plans
        else [(input_path.stem, collection)]
    )
    async with httpx2.AsyncClient(timeout=10) as client:
        for plan_name, plan in plans:
            plan_id = getattr(
                next(
                    filter(
                        lambda value: "_Plan" in value.get_name(),
                        plan.features.values(),
                    )
                ),
                "id",
            )

            headers = {
                "accept": "application/json",
                "x-filename": f"{plan_name}{input_path.suffix}",
                "content-type": "application/gml+xml",
            }
            params = {"name": "report.json"}
            report = ResultReport()
            with io.BytesIO() as buffer:
                GMLRepository(buffer).save_all(plan)
                logger.debug(
                    f"Sending validation request for plan '{plan_name}' to XPlanValidator API @ {validator_url}"
                )
                try:
                    response = await client.post(
                        validator_url + "validate",
                        headers=headers,
                        params=params,
                        content=buffer.getvalue(),
                    )
                    response.raise_for_status()
                except httpx2.HTTPError:
                    logger.exception(
                        f"Error while requesting validation for plan '{plan_name}', skipping"
                    )
                    report.add_error(
                        code=_ErrorCodes.ERR_VALIDATION_REQUEST_FAILED,
                        message=f"Error while requesting validation for plan '{plan_name}', skipping",
                        location=plan_name,
                    )
                    # keep the failed report: the caller decides success from this dict, so
                    # dropping it would report a clean validation for a plan the validator
                    # never saw
                    report_dict[plan_id] = report
                    continue

            validation_report = from_json(response.content)
            report.evaluate_validation_report(validation_report)
            report_dict[plan_id] = report

    return report_dict

xml

Hardened XML reading.

Every XML document this package reads goes through here, so that the parser configuration lives in exactly one place.

lxml already refuses external entities and remote document URLs by default, and libxml2 already caps entity amplification. HARDENED_PARSER_OPTIONS pins that behaviour rather than relying on defaults that may change, and adds what the defaults do not cover:

  • preflight_root streams just the first start event, so a document with a DOCTYPE or an unexpected root element is rejected after a single ~32 KB read instead of after a full parse.
  • resolve_entities=False leaves entity references unexpanded, which would silently drop element text - hence a DOCTYPE is refused outright rather than tolerated.

ALLOWED_ROOTS = {'XPlanAuszug': None, 'FeatureCollection': frozenset({'http://www.opengis.net/wfs/2.0', 'http://www.opengis.net/ogcapi-features-1/1.0/sf'})} module-attribute

Root elements a GML datasource may start with.

HARDENED_PARSER_OPTIONS = {'resolve_entities': False, 'no_network': True, 'load_dtd': False, 'dtd_validation': False, 'attribute_defaults': False, 'huge_tree': False, 'collect_ids': False, 'recover': False} module-attribute

Parser flags applied to every parse of an XML document.

RootInfo(namespace, localname, nsmap, schema_location) dataclass

What the preflight learned about a document's root element.

Attributes:

Name Type Description
namespace str | None

The root element's namespace URI, if it has one.

localname str

The root element's local name.

nsmap dict[str | None, str]

The namespace bindings declared on the root element.

schema_location str

The root's xsi:schemaLocation, or an empty string.

hardened_parser()

Returns a new parser configured with HARDENED_PARSER_OPTIONS.

A fresh instance per call: an lxml parser holds state and is not safe to share across threads.

Source code in xplan_tools/util/xml.py
def hardened_parser() -> etree.XMLParser:
    """Returns a new parser configured with `HARDENED_PARSER_OPTIONS`.

    A fresh instance per call: an `lxml` parser holds state and is not safe to share
    across threads.
    """
    return etree.XMLParser(**HARDENED_PARSER_OPTIONS)

parse_hardened(source)

Parses an XML document with the hardened parser.

Parameters:

Name Type Description Default
source str | BytesIO | StringIO

A file path, or a binary or text file-like object.

required

Raises:

Type Description
XMLParseError

The document is not well-formed.

UnsupportedDatasourceError

source is not a path or a file-like object.

OSError

The path could not be opened.

Returns:

Type Description
_ElementTree

The parsed tree.

Source code in xplan_tools/util/xml.py
def parse_hardened(source: str | io.BytesIO | io.StringIO) -> etree._ElementTree:
    """Parses an XML document with the hardened parser.

    Args:
        source: A file path, or a binary or text file-like object.

    Raises:
        XMLParseError: The document is not well-formed.
        UnsupportedDatasourceError: `source` is not a path or a file-like object.
        OSError: The path could not be opened.

    Returns:
        The parsed tree.
    """
    with as_binary_stream(source) as stream:
        try:
            return etree.parse(stream, parser=hardened_parser())
        except etree.XMLSyntaxError as e:
            raise XMLParseError(f"could not parse XML: {e}") from e

preflight_root(source, allowed=ALLOWED_ROOTS)

Reads a document's root element without parsing the rest of it.

Streams up to the first start event - roughly one 32 KB read - and rejects the document there if it declares a DOCTYPE or opens with an element this package does not read. A document that ends mid-tree still passes; parse_hardened catches that.

Parameters:

Name Type Description Default
source str | BytesIO | StringIO

A file path, or a binary or text file-like object.

required
allowed dict[str, frozenset[str] | None]

Accepted root elements, as local name to accepted namespaces (None accepts any namespace).

ALLOWED_ROOTS

Raises:

Type Description
ForbiddenDoctypeError

The document declares an internal or external DTD.

UnsupportedRootElementError

The root element is not in allowed.

XMLParseError

The document is not well-formed, or is empty.

UnsupportedDatasourceError

source is not a path or a file-like object.

OSError

The path could not be opened.

Returns:

Type Description
RootInfo

The root element's name, namespace bindings and xsi:schemaLocation.

Source code in xplan_tools/util/xml.py
def preflight_root(
    source: str | io.BytesIO | io.StringIO,
    allowed: dict[str, frozenset[str] | None] = ALLOWED_ROOTS,
) -> RootInfo:
    """Reads a document's root element without parsing the rest of it.

    Streams up to the first `start` event - roughly one 32 KB read - and rejects the
    document there if it declares a DOCTYPE or opens with an element this package does
    not read. A document that ends mid-tree still passes; `parse_hardened` catches that.

    Args:
        source: A file path, or a binary or text file-like object.
        allowed: Accepted root elements, as local name to accepted namespaces (`None`
            accepts any namespace).

    Raises:
        ForbiddenDoctypeError: The document declares an internal or external DTD.
        UnsupportedRootElementError: The root element is not in `allowed`.
        XMLParseError: The document is not well-formed, or is empty.
        UnsupportedDatasourceError: `source` is not a path or a file-like object.
        OSError: The path could not be opened.

    Returns:
        The root element's name, namespace bindings and `xsi:schemaLocation`.
    """
    with as_binary_stream(source) as stream:
        context = etree.iterparse(stream, events=("start",), **HARDENED_PARSER_OPTIONS)
        try:
            for _, element in context:
                docinfo = element.getroottree().docinfo
                if docinfo.internalDTD is not None or docinfo.externalDTD is not None:
                    raise ForbiddenDoctypeError(
                        f"document declares a DOCTYPE ({docinfo.doctype!r}); "
                        "XPlanGML and INSPIRE GML do not use one"
                    )
                try:
                    qname = etree.QName(element)
                except ValueError as e:
                    # an undeclared prefix still yields a start event, with the raw
                    # 'prefix:name' as the tag
                    raise XMLParseError(
                        f"invalid root element name {element.tag!r}: {e}"
                    ) from e
                namespaces = allowed.get(qname.localname, ...)
                if namespaces is ... or (
                    namespaces is not None and qname.namespace not in namespaces
                ):
                    raise UnsupportedRootElementError(
                        f"unsupported root element {qname.text!r}; expected one of "
                        f"{', '.join(sorted(allowed))}"
                    )
                return RootInfo(
                    namespace=qname.namespace,
                    localname=qname.localname,
                    nsmap=dict(element.nsmap),
                    schema_location=element.get(XSI_SCHEMA_LOCATION, ""),
                )
        except etree.XMLSyntaxError as e:
            raise XMLParseError(f"could not parse XML: {e}") from e
        finally:
            del context
    raise XMLParseError("document contains no root element")