Loading Layer Mapping Rules from YAML in Python

A layer mapping rule table belongs in configuration rather than in code, because the people who know what C-ROAD-CNTR means are rarely the people who deploy the pipeline. Define a schema a CAD manager can read, validate it on load so a bad rule fails at start-up rather than at the first matching entity, compile the patterns once, and apply exact matches before patterns in declaration order. This page is part of Layer Mapping Logic.

What the Schema Has to Support

Three things, and no more than three, or the file stops being reviewable.

Three parts, and deliberately no more The whole schema. A declared class list makes a typo in a target a load-time error; an exact table covers the common case fastest and clearest; a pattern list covers the families where drafting conventions vary in the tail. Everything else — precedence, case handling, the unmapped bucket — is loader behaviour, because options that change how matching works turn a reviewable table into a small programming language. classes every valid target, declared once typos become load errors exact the common case, fastest and clearest name → class patterns families whose tails vary ordered

Exact names, because most rules are exact and an exact table is the fastest and clearest form. Patterns, because drafting conventions vary in their tails and a prefix rule covers a family of layers. A declared target class, because a rule that produces an arbitrary string invites typos that only surface as an empty feature class.

Everything else — precedence, case handling, the unmapped bucket — should be behaviour of the loader rather than options in the file. Options that change how matching works turn a reviewable table into a small programming language.

Production-Ready Script

# PyYAML>=6.0, Python 3.9+
from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path

import yaml

UNMAPPED = "__UNMAPPED__"


class RuleFileError(ValueError):
    pass


@dataclass(frozen=True)
class PatternRule:
    pattern: re.Pattern
    target: str
    source: str            # the raw pattern, for error messages and reports


@dataclass
class RuleSet:
    exact: dict[str, str] = field(default_factory=dict)
    patterns: list[PatternRule] = field(default_factory=list)
    classes: frozenset[str] = frozenset()
    unmatched: set[str] = field(default_factory=set)

    def classify(self, normalised_layer: str) -> str:
        """Exact first, then patterns in declaration order. Deterministic."""
        hit = self.exact.get(normalised_layer)
        if hit is not None:
            return hit
        for rule in self.patterns:
            if rule.pattern.match(normalised_layer):
                return rule.target
        self.unmatched.add(normalised_layer)
        return UNMAPPED


def load_rules(path: str | Path) -> RuleSet:
    """Load and fully validate the rule file before anything uses it."""
    data = yaml.safe_load(Path(path).read_text()) or {}

    declared = data.get("classes")
    if not isinstance(declared, list) or not declared:
        raise RuleFileError("'classes' must be a non-empty list of target class names")
    classes = frozenset(str(c) for c in declared)

    exact: dict[str, str] = {}
    for key, target in (data.get("exact") or {}).items():
        upper = str(key).upper()
        if upper in exact:
            raise RuleFileError(f"duplicate exact rule for {upper!r}")
        if target not in classes:
            raise RuleFileError(f"exact rule {upper!r} targets undeclared class {target!r}")
        exact[upper] = target

    patterns: list[PatternRule] = []
    for entry in (data.get("patterns") or []):
        raw, target = entry.get("match"), entry.get("target")
        if not raw or not target:
            raise RuleFileError(f"pattern rule missing 'match' or 'target': {entry!r}")
        if target not in classes:
            raise RuleFileError(f"pattern {raw!r} targets undeclared class {target!r}")
        try:
            compiled = re.compile(raw, re.IGNORECASE)
        except re.error as exc:
            raise RuleFileError(f"pattern {raw!r} does not compile: {exc}") from exc
        patterns.append(PatternRule(compiled, target, raw))

    if not exact and not patterns:
        raise RuleFileError("rule file declares no rules")
    return RuleSet(exact=exact, patterns=patterns, classes=classes)


if __name__ == "__main__":
    rules = load_rules("layer-rules.yaml")
    for layer in ("C-ROAD-CNTR", "A-WALL-EXTR", "RANDOM"):
        print(layer, "->", rules.classify(layer))
    print("unmatched:", sorted(rules.unmatched))
Everything that can be wrong is wrong at load time Four validations run when the file is read rather than when a layer matches. An undeclared target, a duplicate exact key and an uncompilable pattern are all detectable without seeing a single drawing, and each of them otherwise surfaces as an empty feature class or a silent misroute much later. Targets are declared against the class list 1 a typo is not an empty class No duplicate exact keys one rule per name 2 later would silently win Every pattern compiles named in the error 3 not at the first match The set is non-empty refuse a no-op file 4 everything would go unmapped

A rule file for the loader above:

# layer-rules.yaml — reviewed by the CAD manager, consumed by the pipeline
classes:
  - road_centreline
  - building_wall
  - survey_boundary

exact:
  C-ROAD-CNTR: road_centreline
  V-SURV-BNDY: survey_boundary

patterns:
  - match: "^C-ROAD-CNTR(-.+)?$"      # any status or modifier suffix
    target: road_centreline
  - match: "^A-WALL"
    target: building_wall

Key implementation notes:

  • Every target is checked against a declared class list. A typo in a target becomes a load-time error instead of an empty feature class nobody notices.
  • Patterns are compiled at load. The per-entity path does lookups only, which matters when it runs per entity on a drawing with hundreds of thousands.
  • unmatched accumulates on the rule set, so a run reports what it could not classify without a second pass.
  • classify takes the normalised name; the stripping and upper-casing belong to the XREF normaliser, which keeps each piece testable alone.
  • yaml.safe_load rather than load. A rule file is input, and input should not be able to construct arbitrary objects.

Compatibility Matrix

Component Supported range Notes
PyYAML >=6.0 safe_load
Python 3.9+ dataclasses, typing
Pattern syntax Python re compiled case-insensitively
Precedence exact, then declaration order not configurable, by design
Rule file UTF-8 non-ASCII layer names supported

Fallback Strategies

1. A pattern that does not compile. Caught at load with the offending pattern named. This is why validation is separate from use.

Precedence is declaration order, on purpose A three-way resolution. An exact match wins outright because it is unambiguous. Failing that, patterns are tried in the order they appear in the file and the first match wins. A name matching nothing is counted rather than guessed at. Sorting patterns by specificity sounds better and cannot be defined unambiguously for regular expressions. How does a name resolve? That class unambiguous exact hit First in file order reorder to change pattern hit Counted never guessed no hit

2. Two patterns both match. The first declared wins. Where that is wrong, reorder the file — which is a reviewable diff rather than a code change.

3. A large unmatched set. Either a new drafting convention or unnormalised names. Check the XREF prefix counts first; a federated drawing with unstripped prefixes produces exactly this.

4. Rules drift from the drawings. Keep a fixture list of real layer names with their expected classes and assert it in CI, as described on the parent page. The rule file and the fixture list should change together.

5. One target class swallows everything. A pattern anchored too loosely — A- rather than ^A-WALL. Anchor patterns at the start, and report per-class counts after a run so a dominant class is visible.

FAQ

Why YAML rather than code?

Because the people who know what a layer means are usually not the people who maintain the pipeline. A rule table in configuration can be reviewed, diffed and amended by a CAD manager without a release, and the pipeline can validate it before accepting it. Rules embedded in code make every convention change a code change.

How is precedence decided between two matching patterns?

Declaration order, deliberately. Sorting by specificity sounds better and is impossible to define unambiguously for regular expressions; declaration order is arbitrary but visible and reviewable. Whichever rule is written first wins, and the file is the documentation of that decision.

What should happen to a layer that matches nothing?

It should be counted and reported, never dropped and never guessed at. An unmatched layer is either a new convention worth a rule or a layer that genuinely should not be imported, and both are decisions for a person. A pipeline that silently discards them loses data with no record that it did.