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.
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))
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.
unmatchedaccumulates on the rule set, so a run reports what it could not classify without a second pass.classifytakes the normalised name; the stripping and upper-casing belong to the XREF normaliser, which keeps each piece testable alone.yaml.safe_loadrather thanload. 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.
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.
Related Pages
- Layer Mapping Logic — parent reference on rule routing and the unmapped bucket
- Normalising XREF-Prefixed Layer Names in Python — the normalisation these rules are matched against
- Mapping CAD Layers to GIS Feature Classes in Python — the classification pipeline this configuration drives