Skip to content

Writing a geometry importer

Import is a seam, not a feature list. A converter for any map format is an installable package that registers through the osreditor.importers entry-point group and never touches editor code — the editor's own bundled converters register through that same public group, so the protocol a third party writes against is the one the editor itself lives on.

This page is the whole contract: the protocol, the payload's semantics, the notes discipline, registration, and a complete worked example. The example code is excerpted from tests/example_importer.py in the editor's repository, which the editor's own test suite imports and exercises — the code on this page passes tests, and cannot drift from the code that does.

The protocol

An importer is a small object: a format_id, a human label, a cheap sniff, and a load.

  • format_id and label are nominative — they describe the format interoperated with, never a feature of the editor. watabou-opd / "Watabou One Page Dungeon (JSON)" is the bundled precedent.
  • sniff(path) answers "does this path look like my format" at presence level. It must be bounded — an extension check, a directory-shape check, or one short read of a file's head — and it must never load. Every installed importer's sniff runs against every candidate path the user points the import dialog at, so an expensive sniff punishes everyone.
  • load(path) produces the payload, or raises ImportSourceInvalidError with a human message on anything unloadable. That message is shown to the user verbatim in the import dialog, so write it for a person: say what was wrong and, where you can, what to do about it. Any other exception escaping load is an editor bug surfaced as a server error — the structured failure mode is part of the contract.

The payload's semantics

load returns an ImportedGeometry: optional adoptable metadata plus one or more ImportedLevels. The semantics that matter:

  • An absent edge is a wall. Connectivity is the edge set: author one OPEN edge per adjacent floor pair (and a DOOR edge where a door sits), and author nothing for a cell's rock neighbours. Edge keys are osrlib-canonical — build them with osrlib.crawl.dungeon.edge_key, which normalizes direction for you; non-canonical keys are never consulted by the engine, so a hand-rolled key is a silent no-op.
  • entrance is optional. A source with no way in imports without one — note that, and the author places the entrance by hand. A level with no entrance is a diagnostic, not an invalid document.
  • Dangling transition targets are legal but linted. A TransitionSpec requires a whole destination; if your source doesn't describe one, a fabricated destination that cannot accidentally resolve (the bundled One Page Dungeon reader uses an empty dungeon id) is the honest move — it renders as a validation finding until the author resolves or drops it, so publish will not pass it silently.
  • Area ids must survive the op vocabulary. An import lands as one atomic op batch, and the create-area op rejects an empty id, a duplicate id, and (in forge-backed projects) an id containing a slash — any one of which would fail the entire import. When your format has source-authored ids, run each through repair_area_id, which returns a safe id plus the reason it had to change (a ready-made note). The worked example below sidesteps the problem by construction — its ids come from a one-character alphabet — which is also a legitimate design.

The notes contract

ImportedLevel.notes is where an importer confesses. Every guess, drop, and repair the conversion made becomes one note, and the import dialog renders all of them before anything commits — the author decides with the confession in hand, not after.

The discipline that makes notes useful:

  • One note per judgment call, counted when repeated ("imported 3 portcullis door(s) as locked doors…"), so a big source doesn't scroll the dialog with duplicates.
  • Say what was done and why the format forced it: "the editor's door model has no portcullis" teaches the author what to fix by hand.
  • Prefer a note over a refusal for anything survivable. An unknown symbol, an unmappable door type, a room the grid can't express exactly — convert what you can, note what you guessed, and reserve ImportSourceInvalidError for sources you cannot read at all.

The worked example

A complete importer for an invented plain-text format: one character per cell, # rock, . floor, @ the entrance, digits keyed areas. Small as it is, it exercises the whole contract.

Identity and sniff

class FieldMapImporter:
    """Plain-text field maps, one character per cell: `#` rock, `.` floor, `@` entrance, `1`–`9` keyed areas."""

    format_id = "fieldmap"
    label = "Field map (plain text)"

    def sniff(self, path: Path) -> bool:
        """Report whether the path looks like a field map, at presence level.

        Bounded, per the protocol's contract: the extension, then one short
        read of at most 4 KB checked against the format's alphabet — never a
        full read and never a parse. A sniff that loads punishes every probe
        of a big file; the load route is where real work happens.

        Args:
            path: The absolute source path.

        Returns:
            True when the shape matches.
        """
        if path.suffix != ".fieldmap" or not path.is_file():
            return False
        try:
            with path.open("rb") as handle:
                head = handle.read(_SNIFF_LIMIT)
        except OSError:
            return False
        try:
            text = head.decode("utf-8")
        except UnicodeDecodeError:
            return False
        return any(symbol in _FLOOR for symbol in text)

The sniff is two cheap gates: the extension, then one bounded read checked for plausibility. It never parses — the load is where real work happens, behind the user's explicit choice.

Load and the failure mode

def load(self, path: Path) -> ImportedGeometry:
    """Read one field map and convert it to a single level of editor geometry.

    Args:
        path: The absolute source path.

    Returns:
        The geometry: one level, labeled with the file's stem. A field map
        carries no title or story, so neither is offered for adoption.

    Raises:
        ImportSourceInvalidError: If the path is unreadable, is not UTF-8
            text, or describes no floor at all — always with a message a
            person can act on, because the import dialog shows it verbatim.
    """
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as error:
        raise ImportSourceInvalidError(f"cannot read {path}: {error}") from error
    except UnicodeDecodeError as error:
        raise ImportSourceInvalidError(f"{path} is not UTF-8 text: {error}") from error
    rows = text.splitlines()
    level = _convert(path, rows)
    return ImportedGeometry(levels=(level,))

Everything unreadable becomes ImportSourceInvalidError with a message a person can act on. Note what does not happen: no partial payload, no silent empty level — the importer either converts the source or refuses honestly.

The conversion

def _convert(path: Path, rows: list[str]) -> ImportedLevel:
    """Walk the grid once, producing the payload and a note for every judgment call."""
    floor: set[Position] = set()
    keyed: dict[str, list[Position]] = {}
    entrance: Position | None = None
    notes: list[str] = []
    unknown: dict[str, int] = {}

    for y, row in enumerate(rows):
        for x, symbol in enumerate(row):
            if symbol == "#":
                continue
            if symbol not in _ALPHABET:
                # A judgment call, not a failure: the format may have grown a
                # symbol this reader predates, and one counted note beats a
                # refusal — the author sees exactly what was guessed.
                unknown[symbol] = unknown.get(symbol, 0) + 1
                continue
            floor.add((x, y))
            if symbol == "@":
                if entrance is None:
                    entrance = (x, y)
                else:
                    notes.append(
                        f"kept the entrance at {entrance} and imported the '@' at {(x, y)} as ordinary floor: "
                        "a level carries one entrance"
                    )
            elif symbol != ".":
                keyed.setdefault(symbol, []).append((x, y))

    if not floor:
        raise ImportSourceInvalidError(f"{path} describes no floor: every cell is rock")
    for symbol in sorted(unknown):
        notes.append(f"treated {unknown[symbol]} {symbol!r} cell(s) as rock: this reader predates the symbol")
    if entrance is None:
        notes.append("no entrance: the map marks no '@', so the level imports without one — place it by hand")

    # Connectivity is the edge set: one OPEN edge per orthogonally adjacent
    # floor pair, canonical keys only. An absent edge is already a wall, so
    # nothing is authored for a floor cell's rock neighbours.
    edges: dict[str, Edge] = {}
    for cell in sorted(floor, key=lambda cell: (cell[1], cell[0])):
        for direction in (Direction.EAST, Direction.SOUTH):
            if step(cell, direction) in floor:
                edges[edge_key(cell, direction)] = _OPEN

    areas = tuple(
        ImportedArea(id=symbol, cells=tuple(cells)) for symbol, cells in sorted(keyed.items(), key=lambda kv: kv[0])
    )
    return ImportedLevel(
        label=path.stem,
        width=max(len(row) for row in rows),
        height=len(rows),
        edges=edges,
        areas=areas,
        entrance=entrance,
        transitions=(),
        notes=tuple(notes),
    )

Three contract points to see in the walk: the unknown symbol and the second entrance are judgment-call notes, not failures; the edge set is built with edge_key over adjacent floor pairs only, because an absent edge is already a wall; and a map with no @ imports with entrance=None plus a note telling the author to place one.

Registration

An importer ships as an ordinary package with one entry point in the osreditor.importers group. Each entry is a zero-arg callable returning an importer instance — a class object works exactly like the bundled converters':

[project]
name = "fieldmap-importer"
version = "1.0.0"
description = "Field map import for osr-editor."
requires-python = ">=3.14"
dependencies = ["osr-editor>=0.1,<1"]

[project.entry-points."osreditor.importers"]
fieldmap = "fieldmap_importer:FieldMapImporter"

[build-system]
requires = ["uv_build>=0.8.22,<0.9.0"]
build-backend = "uv_build"

Install the package into the editor's environment and the importer appears in the import dialog — no editor configuration, no registration call. Discovery is defensive on the editor's side: the editor logs a warning and skips a broken entry point (a third-party package must never break boot), and a duplicate format_id keeps the first registration, so no package can shadow another's format.

API reference

The seam's exports, rendered from the source of truth. Signatures reference osrlib's spatial types — Position, Edge, TransitionSpec — which are documented in osrlib's reference.

osreditor.importers.GeometryImporter

Bases: Protocol

A geometry importer: format identity, a cheap sniff, and a load.

sniff is presence-level and never loads — it answers "does this path look like my format". load produces the payload or raises ImportSourceInvalidError with a human message on anything unloadable.

format_id instance-attribute

format_id: str

label instance-attribute

label: str

sniff

sniff(path: Path) -> bool

Report whether the path looks like this importer's format.

Parameters:

Name Type Description Default
path Path

The absolute source path.

required

Returns:

Type Description
bool

True when the format is recognized at presence level.

load

load(path: Path) -> ImportedGeometry

Load geometry from the source path.

Parameters:

Name Type Description Default
path Path

The absolute source path.

required

Returns:

Type Description
ImportedGeometry

The imported geometry, normalized to what the op vocabulary admits.

Raises:

Type Description
ImportSourceInvalidError

On anything unloadable, with a human message.

osreditor.importers.ImportedGeometry

Bases: BaseModel

An importer's whole answer: optional adoptable metadata plus one or more levels.

title class-attribute instance-attribute

title: str | None = None

description class-attribute instance-attribute

description: str | None = None

levels class-attribute instance-attribute

levels: tuple[ImportedLevel, ...] = Field(min_length=1)

osreditor.importers.ImportedLevel

Bases: BaseModel

One level of imported geometry, normalized to what the op vocabulary admits.

label is the source-side display name (which level of which source this was). edges carries canonical keys only — the importer owns normalization. notes is the importer flagging what it guessed, dropped, or repaired, rendered in the import dialog.

label instance-attribute

label: str

width class-attribute instance-attribute

width: int = Field(ge=1)

height class-attribute instance-attribute

height: int = Field(ge=1)

edges class-attribute instance-attribute

edges: dict[str, Edge] = {}

areas class-attribute instance-attribute

areas: tuple[ImportedArea, ...] = ()

entrance class-attribute instance-attribute

entrance: Position | None = None

transitions class-attribute instance-attribute

transitions: tuple[TransitionSpec, ...] = ()

notes class-attribute instance-attribute

notes: tuple[str, ...] = ()

osreditor.importers.ImportedArea

Bases: BaseModel

One keyed area an importer offers: identity plus its cell cluster.

id instance-attribute

id: str

name class-attribute instance-attribute

name: str = ''

description class-attribute instance-attribute

description: str = ''

cells class-attribute instance-attribute

cells: tuple[Position, ...] = Field(min_length=1)

osreditor.importers.repair_area_id

repair_area_id(candidate: str, used: set[str], taken: set[str]) -> tuple[str, str | None]

The id an area may safely carry, plus the reason it differs from the source's own.

CreateArea rejects three ids at apply — an empty one, a duplicate, and (in a forge-backed project, whose area keys are <dungeon>/<level>/<key>) one carrying a slash — and an import batch is atomic, so any of the three would 422 a whole import with no path forward. Every importer meets the same three, so the repair belongs to ImportedArea's id contract rather than to any one format.

Parameters:

Name Type Description Default
candidate str

The source's own id.

required
used set[str]

The ids already assigned on this level.

required
taken set[str]

Every id the source authored, so a rename never lands on one a later area legitimately holds.

required

Returns:

Type Description
str

The id to use, and the reason it had to change — None when the

str | None

source's own id stands.

osreditor.importers.discover_importers

discover_importers() -> dict[str, GeometryImporter]

Build the importer registry from the osreditor.importers entry-point group.

Each entry point is a zero-arg callable returning an importer instance. A broken entry point logs a warning and is skipped — a third-party package must never break boot — and a duplicate format_id keeps the first registration, so no package can shadow another's format.

Returns:

Type Description
dict[str, GeometryImporter]

The registry, keyed by format_id, in entry-point order.

osreditor.errors.ImportSourceInvalidError

Bases: OsrEditorError

An importer's load could not produce geometry from the source path.

Wraps whatever the importer raised — an unreadable path, a sniff-negative source, a document that fails to load — with the importer's own message.