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 expressions for the coretable PostgreSQL functions.

Every builder here returns an expression, never a statement: a set-returning function's image is a from-item and a scalar function's is a column element, and the caller writes the select() around it. That is what lets the caller choose the projection -- select(g) for whole rows, select(g.id) for ids alone -- and what lets a traversal go inside a join or an exists(). The functions' schema is resolved at execution time via the session's search_path, so the expressions are not schema-qualified.

coretable_feature_graph is the one exception and returns a Select, for the reason given in its docstring. coretable_delete_objects_recursive and coretable_delete_orphans_recursive delete when the caller's statement runs; see their warnings, in particular why narrowing the delete's output does not narrow the delete.

Every builder taking start accepts one root coretable id or a sequence of them, a bare id being equivalent to a one-element sequence. Ids absent from coretable, None elements and repeats are ignored; an empty sequence yields no rows.

MAX_BFS_DEPTH = 5 module-attribute

The maximum BFS traversal depth needed to reach everything belonging to a plan.

A literal rather than Appschema.max_containment_depth taken over the supported appschemas, which is what it equals: deriving it here would import every appschema module (~1.3 s) on every import of this module, and nothing else in it needs them. The test suite holds the two equal.

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

Build the add_navigable_role call, which 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
Function[bool]

The function's BOOLEAN result as a column element; the function is scalar, so it belongs

Function[bool]

in a select list rather than a FROM. Execute with

Function[bool]

select(add_navigable_role(...)) and 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,
) -> Function[bool]:
    """Build the ``add_navigable_role`` call, which 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:
        The function's ``BOOLEAN`` result as a column element; the function is scalar, so it belongs
        in a select list rather than a ``FROM``. Execute with
        ``select(add_navigable_role(...))`` and ``scalar_one()``.
    """
    return func.add_navigable_role(
        source_featuretype,
        navigable_role,
        target_featuretype,
        appschema,
        appschema_version,
        rel_direction,
        dependent_part,
        type_=Boolean(),
    )

coretable_delete_objects_recursive(start, dry_run=False)

Build the coretable_delete_objects_recursive call, which recursively deletes start.

Deletes each root together with its safe cascade closure (see coretable_role_graph_cascade). Pass every root of a batch in one call rather than looping: the closure is computed against the union of all roots, and the candidates are locked in a single ORDER BY id FOR UPDATE batch -- one ordered batch is what makes the delete deadlock-safe, and N calls in one transaction would take N separately ordered batches that can interleave.

Warning

Executing any statement built over this expression 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.

Projecting is safe; restricting rows is not. select(g.id) deletes exactly what select(g) deletes, and is the cheaper way to learn what was removed. But the function is plpgsql and materializes its whole result set before any WHERE/LIMIT/OFFSET of yours is applied, so a narrowed statement destroys the entire closure while reporting a subset of it. Never paginate a preview -- use dry_run=True.

Parameters:

Name Type Description Default
start UUID | Sequence[UUID]

One root coretable id to delete or a sequence of them; see the module docstring.

required
dry_run bool

When True, return the closure without deleting anything.

False

Returns:

Type Description
type[Feature]

Feature aliased onto the function scan, yielding the deleted (or, for dry_run,

type[Feature]

would-be-deleted) rows. Use select(g) for the rows or select(g.id) for their ids.

Source code in xplan_tools/util/db.py
def coretable_delete_objects_recursive(
    start: UUID | Sequence[UUID],
    dry_run: bool = False,
) -> type[Feature]:
    """Build the ``coretable_delete_objects_recursive`` call, which recursively deletes ``start``.

    Deletes each root together with its safe cascade closure (see `coretable_role_graph_cascade`).
    Pass every root of a batch in one call rather than looping: the closure is computed against the
    union of all roots, and the candidates are locked in a single ``ORDER BY id FOR UPDATE`` batch --
    one ordered batch is what makes the delete deadlock-safe, and N calls in one transaction would
    take N separately ordered batches that can interleave.

    Warning:
        Executing any statement built over this expression 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.

        **Projecting is safe; restricting rows is not.** ``select(g.id)`` deletes exactly what
        ``select(g)`` deletes, and is the cheaper way to learn what was removed. But the function is
        ``plpgsql`` and materializes its whole result set before any ``WHERE``/``LIMIT``/``OFFSET``
        of yours is applied, so a narrowed statement destroys the entire closure while reporting a
        subset of it. Never paginate a preview -- use ``dry_run=True``.

    Args:
        start: One root coretable id to delete or a sequence of them; see the module docstring.
        dry_run: When ``True``, return the closure without deleting anything.

    Returns:
        ``Feature`` aliased onto the function scan, yielding the deleted (or, for ``dry_run``,
        would-be-deleted) rows. Use ``select(g)`` for the rows or ``select(g.id)`` for their ids.
    """
    return aliased(
        Feature,
        func.coretable_delete_objects_recursive(_root_ids(start), dry_run).table_valued(
            *Feature.__table__.c
        ),
    )

coretable_delete_orphans_recursive()

Build the coretable_delete_orphans_recursive call.

Deletes dependent-part objects no longer owned by any whole.

Warning

Executing the caller's statement performs the deletion; run it inside a transaction.

Returns:

Type Description
Function[int]

The total number of deleted rows as an INTEGER column element; the function is scalar,

Function[int]

so it belongs in a select list rather than a FROM. Execute with

Function[int]

select(coretable_delete_orphans_recursive()) and scalar_one().

Source code in xplan_tools/util/db.py
def coretable_delete_orphans_recursive() -> Function[int]:
    """Build the ``coretable_delete_orphans_recursive`` call.

    Deletes dependent-part objects no longer owned by any whole.

    Warning:
        Executing the caller's statement performs the deletion; run it inside a transaction.

    Returns:
        The total number of deleted rows as an ``INTEGER`` column element; the function is scalar,
        so it belongs in a select list rather than a ``FROM``. Execute with
        ``select(coretable_delete_orphans_recursive())`` and ``scalar_one()``.
    """
    return func.coretable_delete_orphans_recursive(type_=Integer())

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

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

The role-graph traversal under the plan-graph defaults: one root and a bounded depth, with the ownership closure off.

This is the module's one exception to returning an expression, and the one traversal written over coretable rather than over a function scan. DBRepository._get_feature_graph dispatches between this builder and _collect_feature_graph_python, whose file-based branch returns select(Feature).where(Feature.id.in_(...)). get_plan_by_id then appends .where(Feature.id != plan_id) to whichever branch it got, so both must be chainable with the same Feature.… predicate -- which is what selecting from real coretable here buys. Use coretable_role_graph instead where that constraint does not apply.

Parameters:

Name Type Description Default
start_id UUID

Root coretable id to traverse from.

required
depth_limit int

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

MAX_BFS_DEPTH
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 = MAX_BFS_DEPTH,
    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.

    The role-graph traversal under the plan-graph defaults: one root and a bounded depth, with the
    ownership closure off.

    This is the module's one exception to returning an expression, and the one traversal written over
    ``coretable`` rather than over a function scan. `DBRepository._get_feature_graph` dispatches
    between this builder and ``_collect_feature_graph_python``, whose file-based branch returns
    ``select(Feature).where(Feature.id.in_(...))``. ``get_plan_by_id`` then appends
    ``.where(Feature.id != plan_id)`` to whichever branch it got, so both must be chainable with the
    same ``Feature.…`` predicate -- which is what selecting from real ``coretable`` here buys. Use
    `coretable_role_graph` instead where that constraint does not apply.

    Args:
        start_id: Root coretable id to traverse from.
        depth_limit: Maximum BFS depth; defaults to 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 = coretable_role_graph_ids(
        start_id, depth_limit, include_forward, include_backward, False
    )
    return select(Feature).where(exists().where(Feature.id == graph_ids.c.id))

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

Build the coretable_role_graph call, the Feature rows reachable from start.

The SQL function is a thin SETOF wrapper joining coretable against coretable_role_graph_ids, so this hydrates exactly the traversal whose ids coretable_role_graph_ids returns.

Parameters:

Name Type Description Default
start UUID | Sequence[UUID]

One root coretable id or a sequence of them; see the module docstring.

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
type[Feature]

Feature aliased onto the function scan. Select whole rows with select(g), ids alone

type[Feature]

with select(g.id), and filter with select(g).where(g.… ) -- predicates must go

type[Feature]

through the returned alias, since Feature.… would name coretable and add it to the

type[Feature]

FROM clause a second time as a cross join.

type[Feature]

The annotation is type[Feature] rather than AliasedClass[Feature] because that is

type[Feature]

how SQLAlchemy declares aliased() over a mapped class (its private AliasedType

type[Feature]

alias), which is what makes attribute access on the result check against Feature. The

type[Feature]

runtime object is an AliasedClass.

Source code in xplan_tools/util/db.py
def coretable_role_graph(
    start: UUID | Sequence[UUID],
    depth_limit: int = 0,
    include_forward: bool = True,
    include_backward: bool = True,
    dependent_parts: bool = False,
) -> type[Feature]:
    """Build the ``coretable_role_graph`` call, the ``Feature`` rows reachable from ``start``.

    The SQL function is a thin SETOF wrapper joining ``coretable`` against
    ``coretable_role_graph_ids``, so this hydrates exactly the traversal whose ids
    `coretable_role_graph_ids` returns.

    Args:
        start: One root coretable id or a sequence of them; see the module docstring.
        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:
        ``Feature`` aliased onto the function scan. Select whole rows with ``select(g)``, ids alone
        with ``select(g.id)``, and filter with ``select(g).where(g.… )`` -- predicates must go
        through the returned alias, since ``Feature.…`` would name ``coretable`` and add it to the
        ``FROM`` clause a second time as a cross join.

        The annotation is ``type[Feature]`` rather than ``AliasedClass[Feature]`` because that is
        how SQLAlchemy declares ``aliased()`` over a mapped class (its private ``AliasedType``
        alias), which is what makes attribute access on the result check against ``Feature``. The
        runtime object is an ``AliasedClass``.
    """
    return aliased(
        Feature,
        func.coretable_role_graph(
            _root_ids(start),
            depth_limit,
            include_forward,
            include_backward,
            dependent_parts,
        ).table_valued(*Feature.__table__.c),
    )

coretable_role_graph_cascade(start, depth_limit=0)

Build the coretable_role_graph_cascade call, the rows safe to delete with start.

The result is the dependent-part closure of start minus any part whose owning whole lies outside that closure (transitively), i.e. the objects that can be removed together with start without orphaning a part still owned from elsewhere. Every root is exempt from that exclusion, so a root that is itself an owned part is still returned.

Warning

The closure is not compositional, so a batch must be passed in one call. For a part owned by both a and b, neither cascade(a) nor cascade(b) contains it -- it is external to each closure taken alone -- and deleting both roots one at a time would leave it ownerless. cascade([a, b]) computes the closure against the union, where the part is external to neither.

Parameters:

Name Type Description Default
start UUID | Sequence[UUID]

One root coretable id or a sequence of them; see the module docstring.

required
depth_limit int

Maximum BFS depth; 0 means unbounded.

0

Returns:

Type Description
type[Feature]

Feature aliased onto the function scan; see coretable_role_graph on selecting and

type[Feature]

filtering through the alias.

Source code in xplan_tools/util/db.py
def coretable_role_graph_cascade(
    start: UUID | Sequence[UUID],
    depth_limit: int = 0,
) -> type[Feature]:
    """Build the ``coretable_role_graph_cascade`` call, the rows safe to delete with ``start``.

    The result is the dependent-part closure of ``start`` minus any part whose owning whole lies
    outside that closure (transitively), i.e. the objects that can be removed together with
    ``start`` without orphaning a part still owned from elsewhere. Every root is exempt from that
    exclusion, so a root that is itself an owned part is still returned.

    Warning:
        The closure is not compositional, so a batch must be passed in one call. For a part owned
        by both ``a`` and ``b``, neither ``cascade(a)`` nor ``cascade(b)`` contains it -- it is
        external to each closure taken alone -- and deleting both roots one at a time would leave
        it ownerless. ``cascade([a, b])`` computes the closure against the union, where the part is
        external to neither.

    Args:
        start: One root coretable id or a sequence of them; see the module docstring.
        depth_limit: Maximum BFS depth; ``0`` means unbounded.

    Returns:
        ``Feature`` aliased onto the function scan; see `coretable_role_graph` on selecting and
        filtering through the alias.
    """
    return aliased(
        Feature,
        func.coretable_role_graph_cascade(_root_ids(start), depth_limit).table_valued(
            *Feature.__table__.c
        ),
    )

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

Build the coretable_role_graph_ids call, the ids reachable from start.

The cheap half of coretable_role_graph: it yields ids without joining coretable, so prefer it wherever the rows are not needed. The edge set the traversal walks is bounded by |refs| and independent of the roots, so one call over N roots builds it once where N calls build it N times.

Parameters:

Name Type Description Default
start UUID | Sequence[UUID]

One root coretable id or a sequence of them; see the module docstring.

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
TableValuedAlias

A derived table with a single id column: select(coretable_role_graph_ids(x).c.id)

TableValuedAlias

yields the ids, and the same expression goes inside an exists() or a join.

Source code in xplan_tools/util/db.py
def coretable_role_graph_ids(
    start: UUID | Sequence[UUID],
    depth_limit: int = 0,
    include_forward: bool = True,
    include_backward: bool = True,
    dependent_parts: bool = False,
) -> TableValuedAlias:
    """Build the ``coretable_role_graph_ids`` call, the ids reachable from ``start``.

    The cheap half of `coretable_role_graph`: it yields ids without joining ``coretable``, so prefer
    it wherever the rows are not needed. The edge set the traversal walks is bounded by ``|refs|``
    and independent of the roots, so one call over N roots builds it once where N calls build it N
    times.

    Args:
        start: One root coretable id or a sequence of them; see the module docstring.
        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 derived table with a single ``id`` column: ``select(coretable_role_graph_ids(x).c.id)``
        yields the ids, and the same expression goes inside an ``exists()`` or a join.
    """
    return func.coretable_role_graph_ids(
        _root_ids(start),
        depth_limit,
        include_forward,
        include_backward,
        dependent_parts,
    ).table_valued("id")

list_navigable_roles()

Build the list_navigable_roles call, which returns every configured navigable role.

Returns:

Type Description
TableValuedAlias

A derived table with one row per configured role and columns source_featuretype,

TableValuedAlias

navigable_role, target_featuretype, appschema, appschema_version,

TableValuedAlias

rel_direction and dependent_part; select from it with

TableValuedAlias

select(list_navigable_roles()).

Source code in xplan_tools/util/db.py
def list_navigable_roles() -> TableValuedAlias:
    """Build the ``list_navigable_roles`` call, which returns every configured navigable role.

    Returns:
        A derived table with one row per configured role and columns ``source_featuretype``,
        ``navigable_role``, ``target_featuretype``, ``appschema``, ``appschema_version``,
        ``rel_direction`` and ``dependent_part``; select from it with
        ``select(list_navigable_roles())``.
    """
    # 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")
    ]
    return func.list_navigable_roles().table_valued(*role_columns)

list_top_level_featuretypes()

Build the list_top_level_featuretypes call.

Returns:

Type Description
TableValuedAlias

A derived table with one row per appschema/version and columns featuretype,

TableValuedAlias

appschema and appschema_version; select from it with

TableValuedAlias

select(list_top_level_featuretypes()).

Source code in xplan_tools/util/db.py
def list_top_level_featuretypes() -> TableValuedAlias:
    """Build the ``list_top_level_featuretypes`` call.

    Returns:
        A derived table with one row per appschema/version and columns ``featuretype``,
        ``appschema`` and ``appschema_version``; select from it with
        ``select(list_top_level_featuretypes())``.
    """
    return func.list_top_level_featuretypes().table_valued(
        "featuretype",
        "appschema",
        "appschema_version",
    )

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.
    """
    # dimensions=1 is what every array here is, and saying so halves the copying: with the
    # dimensions unset, `ARRAY._apply_item_processor` cannot know the sequence is flat, so it
    # materialises `list(values)` just to look at `values[0]` before building the list it
    # returns. It does not change the rendered SQL or the bound value.
    array_type = ARRAY(type_, dimensions=1)
    return cast(
        literal(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")