How netviz is put together
This is the orientation note for somebody about to change the code. It says what the pipeline is, which module owns each stage, what each stage may assume about its input and what it promises the next one — so that a change can be made in one place instead of five. For how to build, test and submit it, see CONTRIBUTING.md.
Contents
- The pipeline
- Stage 1: load
- Stage 2: validate
- Stage 3: build the graph
- Stage 4: narrow and summarise
- Stage 5: render
- The side branches
- Cross-cutting pieces
- Module map
- Using it as a library
- Design rules a change must not break
- See also
The pipeline
Five stages, each a plain function, each independently testable. The type on each arrow is the whole contract between one stage and the next.
Path
│ loader.load_tree / load_stream
│ walk, parse, expand ranges, merge templates, index by qualified name
▼
Inventory ───────► validate.validate ───────► list[Finding]
│ │
│ ▼
│ diagnostics.build_report
│ (text | json | sarif | github)
│ render.graph.build_graph
│ resolve names, VLANs, tunnels, panels, prefixes — exactly once
▼
Graph
│ render.graph.filter_graph remove, …
│ render.aggregate.aggregate_graph … then summarise, in that order
▼
Graph
│ render.render(graph, format, options)
▼
str | bytes
cli._build_graph is the only place the CLI knows this order; watch.pipeline.run_cycle
and web.preview.render_source walk the same five stages for their own front ends. Nothing
else re-implements it.
Stage 1: load
Owner: src/netviz/loader/ — tree.py (the walk), documents.py (the strict YAML
parser), inventory.py (the index), cache.py (parsed files, remembered by content), plus
ranges.py, templates.py, provenance.py, ignore.py.
def load_tree(
root: Path, *, keep_provenance: bool = False, cache: DocumentCache | None = None
) -> Inventory: ...
def load_stream(
text: str, *, name: str = STREAM_NAME, keep_provenance: bool = False
) -> Inventory: ...
cache makes a repeated load incremental without making it stateful: every file is still
read and hashed on every load, so the tree on disk remains the only thing that decides the
result, and what is skipped is turning bytes that have been seen before back into elements.
A hit is indistinguishable from a parse — same elements, same diagnostics, same order — which
is what tests/test_cache.py asserts over every committed example. See
docs/configuration.md.
May assume nothing. root is checked, and a path that does not exist or is neither a
directory nor a YAML file is the only thing that raises (LoaderError). Everything a
user can get wrong inside the tree is data.
Guarantees to every later stage. Every element is a validated model, parsed against
the pydantic model of its kind before it is indexed, so a rule, a graph builder or a
renderer may rely on field types, bounds and enum membership without re-checking them; and
it is qualified, indexed under its directory plus metadata.name, so
sites/berlin/rack1/sw1.yaml becomes sites/berlin/rack1/sw1, with lookups going
namespace-first then global. Shorthands are already gone: interfaces[].range is
expanded and spec.from merged against its template during the load, so no later stage
re-parses YAML or re-expands anything — an inventory written with templates renders byte
for byte like the same inventory written out longhand. Order is deterministic — files in
byte-wise order of their relative POSIX path, documents in file order (NV-L005) — which
is what makes every rendering and every golden file stable. And loading is total, a
syntax error, schema violation, unreadable file or duplicate name being recorded on
inventory.errors as a LoadError while the walk continues, and safe, because only
StrictSafeLoader is used, so a hostile document cannot construct arbitrary Python
objects.
keep_provenance=True additionally records which file, line and column each field came
from, so a semantic finding can be reported at the line that caused it. It is off by
default because the redirect tables hold the YAML node trees alive — 18 MB retained instead
of 5 MB on a 628-element tree — and only the machine-readable validate formats need it.
Stage 2: validate
Owner: src/netviz/validate.py, with the catalogue in rules.py and the suppression
settings in config.py.
def validate(inventory: Inventory, config: ValidationConfig | None = None) -> list[Finding]: ...
May assume the loader's guarantees: every document parses and matches the schema of its
own kind, so a check never re-validates a field. What it may not assume is that the
documents agree with each other — that is the question this stage asks.
Guarantees. It never raises for an inventory problem; it reports, and the caller
decides. It never mutates the inventory — nothing in validate.py calls Inventory.add
or Inventory.record, and
tests/test_properties.py::test_validate_is_a_function_of_the_inventory_alone pins it.
One problem is one finding: a duplicate address shared by five interfaces is a single
finding naming all five, anchored at the first declaration in load order. Order is
stable — source file, then position in the file, then severity and rule id. And
suppression is a filter rather than a branch: the engine skips disabled rules and drops
annotated findings afterwards, so a check cannot behave differently when a rule is
re-graded.
Each check is a generator Callable[[_Context], Iterator[_Draft]] registered in _CHECKS, a
tuple of (rule_id, check) pairs whose order must equal rules.RULE_IDS. _build_context
resolves every cable endpoint, tunnel end, attachment and placement once, so a check is a
loop over resolved records rather than a second resolver.
Stage 3: build the graph
Owner: src/netviz/render/graph.py.
def build_graph(inventory: Inventory, *, layer: Layer = Layer.L1) -> Graph: ...
May assume an Inventory — but not that validate has run: a cable whose endpoint
does not resolve is dropped and recorded on Graph.dangling, because a half-written
inventory still has to draw.
Guarantees. Graph is frozen: nodes a mapping in load order, edges a tuple, and every
edge references two nodes that exist, so a renderer never has to check. Resolution happens
exactly once, here — name resolution, VLAN membership, adapter attachment, patch-panel
splicing, tunnel and encapsulation stacks and layer-3 prefix derivation are all this one
pass, which is why a diagram, a traced path, an export and a networkx view cannot disagree
about what is connected to what. Graph.sources says where each element was written, for
--link-template, and is deliberately not part of what a renderer draws.
Layer decides what the graph is: physical keeps patch panels and their segments, l1
splices each run into one edge, l2 annotates that with VLAN membership, l3 replaces
cables with prefixes as nodes, overlay makes tunnels nodes, and rack builds no topology
at all — one node per rack holding its elevation. rack_elevations, resolve_tunnels and
splice_patch_panels expose parts of the same pass.
Stage 4: narrow and summarise
Owners: render/graph.py (filtering) and render/aggregate.py (aggregation).
def filter_graph(graph: Graph, spec: FilterSpec) -> Graph: ...
def aggregate_graph(graph: Graph, spec: AggregateSpec | None = None) -> Graph: ...
May assume a Graph from stage 3, or from itself — both are Graph → Graph, so they
compose. Guarantees a Graph with the same invariants. The two run in this order and
only this order, and cli._build_graph says why: filtering decides what exists, aggregation
folds what is left. Reversed, --kind switch could empty a collapsed node of everything it
claims to stand for.
FilterSpec removes: namespaces, vlans, kinds, names, neighbors_of/depth and
selected. Values within one field are alternatives, different fields are combined with AND,
and every field selects elements — so a derived layer-3 subnet node survives exactly as long
as one selected element still has an address in it.
selected is the selector language, layered over this stage rather than
implemented inside it. A query can traverse the graph and can ask about interfaces, so it must
see the whole graph and needs a parser and a vocabulary the renderer has no business knowing
about; what arrives in selected is therefore the answer — a set of fully-qualified names —
computed by netviz.query against the unfiltered graph. query/apply.py is the eighty lines
that do the two in the right order, and filter_graph raises rather than proceeds if it is
handed a select nobody answered, because rendering the whole inventory for a query that
selects three devices is a silently wrong picture. AggregateSpec removes nothing: collapse and
collapse_depth replace a namespace with one node that says which elements it stands for, and
bundle folds parallel links into one edge carrying the count. aggregate_graph returns
graph itself when nothing applies, so a pipeline that never aggregates is byte-identical.
Stage 5: render
Owner: src/netviz/render/ — the registry in registry.py, the backends in dot.py,
html.py, mermaid.py and jsonexport.py, and the shared display decisions in
options.py, details.py, ids.py, links.py, icons.py, highlight.py, fragment.py.
def render(graph: Graph, format: str, options: RenderOptions | None = None) -> bytes: ...
def render_text(graph: Graph, format: str, options: RenderOptions | None = None) -> str: ...
def render_layers(
graphs: Sequence[Graph], format: str, options: RenderOptions | None = None
) -> bytes: ...
May assume a Graph and a RenderOptions, and nothing else: a backend never sees the
Inventory, never reads netviz.toml, and never decides what exists.
The stored arrangement (§18) reaches a backend on the Graph, not in the options, and
that placement is the point: geometry is a fact about the inventory rather than a
preference about the drawing, so svg, html and json agree on coordinates without any
of them being told to and none of them can be told something different. build_graph
resolves it, filter_graph narrows it to what survived, and render/dot.py turns it into
pos attributes and a Graphviz command line — see layout/geometry.py for the three
cases.
Guarantees. render always returns bytes — text formats UTF-8 encoded, image formats
as Graphviz produced them — so a caller writing to a file or stdout needs no per-format
branching; render_text is for callers that want a string and have already excluded the
binary formats. Output is byte-for-byte stable for a given graph and options, which
tests/test_golden.py asserts and which is what makes netviz render -f dot > topology.dot a file worth committing.
RENDERERS in registry.py is the single declaration of a format. Every fact a front end
could want — suffix, media type, whether the output is binary, whether it can draw icons,
a highlight, a rack elevation or several layers at once, what content-security policy it
needs, what to warn about at a given graph size — is a field on its Renderer. No front
end branches on a format name: -f's choices, the help text, the preview server's content
type and the size advisories all derive from that mapping.
The side branches
Everything else hangs off one of the five stages and adds no sixth.
| Branch | Attaches after | Entry point |
|---|---|---|
diagnostics.py |
stage 2 | build_report(inventory, findings, *, base=None), then render_report(report, output_format) |
subnets.py |
stage 1 | subnets_of(inventory) -> tuple[Subnet, ...] — the prefixes the configured addresses imply, one group per (vrf, prefix) |
ipam.py |
stage 1, via subnets.py |
build_report(inventory, config=None, *, aggregated=False); conflicts() calls validate rather than re-deriving anything |
graph.py (top level) |
stage 3 | to_networkx(source, *, layer=None) -> nx.MultiGraph, then its own filter_graph, layers, broadcast_domains, stats |
trace/ |
stage 3 | trace(inventory, source, destination, *, vlan=None, …) -> TraceResult |
testing/ |
stages 2 and 3 | run_tests(inventory, *, names=(), …) -> TestReport, then render_test_report(report, output_format) — the assertions of every kind: testsuite document, graded over the same graphs and the same trace engine everything else reads |
export/ |
stage 4 | export(export_format, context_factory) -> ExportResult, over the same filtered Graph a diagram is drawn from |
listing.py |
stage 3 | LISTINGS[subject](inventory) -> Listing — the tables netviz list prints, and the ones a report shows |
report/ |
stages 2, 3 and 4 | generate(inventory, *, options, diagnostics, …) -> (Bundle, Diagrams) — the as-built document, built from listing.py, ipam.py, export/cables.py, power.py and the layer graphs |
fmt/ |
before stage 1 | format_paths(roots, *, mode) -> Summary — its own round-trip parser, never on the loading path |
edit/ |
before stage 1, gated on 1 and 2 | EditSession(root).apply(operation), then .diff() / .commit() — the only write path, and the only thing that loads the tree as it would be through loader.Overlay |
importer/ |
before stage 1 | read_inputs → build_draft → build_files → write_files, producing a tree stage 1 then loads |
schema.py |
stage 1's models | build_schema(kind=None) -> dict[str, Any] — the JSON Schema an editor consumes |
watch/ |
all five | run_cycle(request) -> CycleResult, repeated by run_watch, published through LiveRender, served by PreviewServer |
web/ |
all five, on a string | render_source(source, view=None) -> Preview, over load_stream rather than load_tree |
Four are worth a sentence more. netviz.graph sits beside the renderers, not under
them: it hands the graph build_graph already resolved to networkx so that connectivity
questions are answered by graph algorithms, and it declares its own filter_graph — over
nx.MultiGraph, with keyword predicates rather than a FilterSpec — so check which one an
import means. fmt/ is the one part that does not share a parser with the rest:
ruamel.yaml's round-trip loader keeps the comments, blank lines and quoting style a
formatter cannot discard, every formatted document is handed back to the strict loader
before it is written (fmt/verify.py), and nothing on the loading path imports it, so
validate pays nothing for it. edit/ is the mirror of fmt/ and shares its parser for the same reason, but not its
remit: fmt rewrites whole files and edit re-emits only the documents an operation
named, so that every other byte of a file survives an edit. It is the one branch that runs
two stages of the pipeline as a check — it loads and validates the tree it would write,
through loader.Overlay, and refuses if the edit would introduce a new error. And
export/ is scoped like a render on purpose: the same
FilterSpec narrows both, so --namespace sites/north --kind switch means the same thing
to a diagram and to a hosts file, and each emitter records what it had to drop
(export/manifest.py) instead of dropping it quietly.
Cross-cutting pieces
errors.py and diagnostics.py — diagnostics. errors.py holds the exception hierarchy:
NetvizError and its subclasses ConfigurationError, LoaderError (with
SchemaError/SchemaIssue), ValidationError and RenderError. The CLI catches the
base class, so a new failure mode gets a clean message by inheriting from it rather than
by adding a try in cli.py. It also owns the text helpers every diagnostic uses —
count_text, clip_text, echo_value, compact_ids — which is why no message
concatenates an unbounded value into itself. diagnostics.py turns load errors and findings
into one sorted stream of Diagnostic records and serialises it as JSON, SARIF 2.1.0 or
GitHub workflow commands; its LOAD_RULE pseudo-rule gives a load error a rule id and
a documented section too, so no diagnostic reaches a user without one.
settings.py and config.py — netviz.toml to Click parameters. config.py reads
the file: [validate] (ignore, severity, strict) and, handing them straight over,
[render] and every [profile.<name>] block. Unknown keys inside a known table are
rejected — a misspelt ingore = [...] that silently did nothing would be worse than a
failed run — while unknown top-level tables are left alone so a file shared with a later
version still works. settings.py owns the one naming rule (a key is the long flag
without its leading dashes), the SETTINGS registry mapping each key to a Click
parameter and a parser, and the precedence ladder in resolve_settings: explicit flag,
then the selected profile, then [render], then the Click default. Each result is a
Resolution carrying its Origin, which netviz config show prints as provenance.
rules.py — the single rule catalogue. Every rule the validator can report is declared
here exactly once, with a permanent short id (E###, W###, I###), a default severity,
a one-line summary, its NV-* schema aliases and the title that Rule.anchor and
Rule.help_uri derive a deep link into validation-rules.md from.
Ids are permanent: once assigned, one is never reused, so a suppression in somebody's
inventory keeps meaning what it meant. Keeping the catalogue in its own module is what
lets config.py resolve and check rule ids without importing the validator.
Three smaller ones. console.py holds tables, colour and TTY detection, and a Console is
handed to a command rather than constructed by it, so --quiet, --verbose and --color
are honoured without each command re-deciding. httpserve.py holds what the two local
servers promise — loopback binding, the default content-security policy, security headers,
a Host check — because watch and web are different applications but what they promise
about being a local server has to be identical.
fsio.py holds the three questions about writing a file that must have the same answer
everywhere netviz runs: what a line ending is (\n, never the platform's, because a
canonical form and a golden file are defined in bytes), how a file is replaced (through a
sibling temporary and os.replace, retried for the sharing violation only Windows
raises), and what a generated file may be called (not nul.yaml, which is a device on
Windows and not a file). Each of those had two implementations and one of them was
missing the newline argument, which is the shape of bug this module exists to make
impossible: tests/test_platform.py fails if any call site goes back to
Path.write_text.
Module map
Verified against the tree: every path below exists.
| Path | What lives there |
|---|---|
docs/ |
specification, generated reference, rule, CI and YANG guides |
examples/ |
five runnable inventories, also used as golden fixtures |
schema/netviz.schema.json |
the generated JSON Schema, for editors and CI |
.github/actions/netviz-validate/ |
the composite action that runs validate in a workflow |
.pre-commit-hooks.yaml |
the netviz-validate hook, for inventory repositories |
tools/ |
doc and schema generators (checked for drift by the tests), the example checker, the icon rasteriser, the pipeline and page benchmarks |
src/netviz/query/ |
the selector language: lexer, parser, vocabulary, evaluator, and the layer over filter_graph |
src/netviz/__init__.py |
public package surface |
src/netviz/cli.py |
console-script entry point (netviz) |
src/netviz/completion.py |
shell completion: the scripts, and the value completers |
src/netviz/console.py |
terminal output: tables, colour, TTY detection |
src/netviz/errors.py |
shared exception hierarchy, and the diagnostic text helpers |
src/netviz/config.py |
per-inventory settings (netviz.toml); settings.py owns the [render] table, the named profiles and the precedence ladder |
src/netviz/scaffold.py |
the starter inventory netviz init writes |
src/netviz/httpserve.py |
what the two local servers promise: loopback, headers, host check, and the socket options that differ by platform |
src/netviz/fsio.py |
one newline policy, one atomic replace, one reserved-file-name rule, for every platform |
src/netviz/rules.py |
catalogue of validation rules and severities |
src/netviz/diagnostics.py |
validate as json, SARIF 2.1.0 and GitHub workflow commands |
src/netviz/schema.py |
JSON Schema emitted for editors (netviz schema) |
src/netviz/subnets.py |
IP prefixes derived from the configured addresses, partitioned by routing instance; ipam.py adds utilisation, free space and conflicts over them |
src/netviz/validate.py |
semantic validation engine |
src/netviz/graph.py |
the same resolved topology as a networkx.MultiGraph, for analysis |
src/netviz/models/ |
pydantic models for every element kind; fielddocs.py holds one prose description and YANG path per field, for both generators |
src/netviz/loader/ |
recursive YAML inventory loader: tree.py the walk and the two-phase build templates make necessary, documents.py the strict safe parser and the libyaml / pure-Python choice, inventory.py the index and LoadError, ranges.py bracket expansion of interfaces[].range, templates.py the registry and the spec merge, provenance.py which file and line each field came from, ignore.py .netvizignore with gitignore semantics, cache.py the content-addressed store of parsed-and-validated files |
src/netviz/render/ |
graph construction and output renderers: graph.py turns an inventory into nodes, edges, VLAN membership and subnets and filters them, aggregate.py collapses namespaces and bundles links, options.py is RenderOptions (what to draw, never what exists), registry.py is one entry per format — the CLI reads it, never a list of names |
src/netviz/render/dot.py |
Graphviz DOT and the SVG/PNG/PDF it produces, laid out by templates/graph.dot.j2; html.py the self-contained interactive page (-f html) from templates/page.html.j2; mermaid.py the flowchart exporter; jsonexport.py the canonical JSON graph |
src/netviz/render/details.py |
per-element hover records and tooltip text; ids.py the stable id each drawn node, edge and cluster carries; links.py the --link-template URL back to the document; highlight.py the emphasis a reader asked for; icons.py icon themes (a directory of images named after element kinds, the bundled ones under iconsets/) |
src/netviz/render/fragment.py |
the Graphviz SVG made embeddable, for the page and the preview; assets/ holds the style sheet, the client and the record renderer netviz web shares with it — inlined, never fetched |
src/netviz/trace/ |
reachability tracing (netviz path): endpoints.py resolves what the user typed, engine.py searches layer 2 then layer 3, model.py holds the result, report.py renders it as text or JSON |
src/netviz/testing/ |
netviz test: executable assertions (kind: testsuite). selectors.py parses an assertion's select: into the renderer's own FilterSpec, fields.py reads a value out of an element by path for unique, engine.py grades every claim against the shared graphs, model.py is the verdict a failure has to carry, report.py renders text, JSON and JUnit |
src/netviz/export/ |
netviz export: six operational artefacts, with context.py for the values every emitter reads, names.py for folding a name into each target grammar, manifest.py for what was dropped |
src/netviz/listing.py |
the tables of netviz list, in a form a report can show: headers, alignment, formatted cells and the same rows as records |
src/netviz/report/ |
netviz report: the as-built document. collect.py works out the scopes and the shared derivations, pages.py says what each page carries, model.py is the format-independent document, layout.py the file names and cross-references, diagrams.py the drawings and the links inside them, write.py the templates and the escaping, bundle.py the files and how they are written, stamp.py the timestamp and the git revision; templates/ and assets/ are the editable layout (--template DIR) |
src/netviz/fmt/ |
the canonical form of an inventory file (netviz fmt): canonical.py shapes it, order.py holds the key order, verify.py re-reads it strictly, runner.py walks the paths |
src/netviz/layout/ |
stored diagram geometry (netviz layout): geometry.py is the runtime arrangement and the three-way decision a renderer makes from it, resolve.py merges every kind: layout document into one table per view and resolves its keys, graphviz.py reads coordinates back out of a Graphviz run and puts a partially-pinned one back on the stored coordinate system, document.py writes geometry as YAML somebody can still read, seed.py is the command's engine room |
src/netviz/edit/ |
the write path (netviz edit, netviz apply, and the web editor): operations.py is the closed set of typed changes and their JSON form, apply.py turns each into a change and its inverse, roundtrip.py holds a file as documents that can be edited without touching the others, references.py reads the references off the models and re-spells them, placement.py decides where a new document goes, tree.py journals and hashes what may be written, session.py runs the validation and conflict gates |
src/netviz/plan/ |
the diff engine (netviz plan, netviz apply): address.py gives every element a stable address, document.py is the normalised form an element is compared in and the field-level diff, identity.py pairs up renamed elements by structural evidence, order.py puts the changeset in dependency order, diff.py is the pure two-inventories-in-one-plan-out function, live.py builds the target state a capture implies, state.py hashes a state so a stored plan cannot be applied to another, execute.py translates each entry into netviz.edit operations, sources.py reads a git ref without touching the working tree |
src/netviz/importer/ |
netviz import: a first inventory from live-network output. run.py reads the inputs, sniffs each dialect and writes the tree; lldp.py turns lldpctl/lldpcli neighbour records into cables, both ends at once; iproute.py turns ip -j link/addr into one host's interfaces, bridges, bonds and VLANs; csvlinks.py reads device,port,device,port rows (and says why not NetJSON); draft.py is the neutral inventory every reader appends to, and the dedup; emit.py writes it as commented YAML in docs/schema.md field order |
src/netviz/watch/ |
live re-rendering (netviz watch): pipeline.py is one load → validate → render cycle and its published state, loop.py decides what counts as a change, server.py is the loopback preview and its self-reloading page |
src/netviz/web/ |
the interactive interface (netviz web): preview.py is one pass over a document stream, svgdoc.py is render/fragment.py with the preview's answers filled in, server.py is five routes over all of it, assets/ the dependency-free client (cull.js is what keeps a thousand-device drawing interactive) |
Using it as a library
The package is typed (py.typed) and checked with mypy --strict, so the stages above are
usable directly. Nothing in them needs the CLI.
from pathlib import Path
from netviz.config import load_config
from netviz.loader import load_tree
from netviz.render import RenderOptions, build_graph, icon_theme, render
from netviz.validate import validate
root = Path("inventory")
inventory = load_tree(root)
for finding in validate(inventory, load_config(root).validation):
print(finding) # inventory/sw1.yaml#0:3: error: E002: ...
options = RenderOptions(show_ips=False, icons=icon_theme("cisco"))
svg = render(build_graph(inventory), "svg", options)
load_tree never raises for a problem inside the tree; unreadable documents are collected
on inventory.errors. validate never raises either. Text that never was a folder — a
paste, a pipe, a snippet from a ticket — goes through load_stream instead, and
render_source runs the whole of what netviz web does per keystroke in one call:
from netviz.loader import load_stream
from netviz.render import Layer
from netviz.web import ViewOptions, render_source
text = Path("topology.yaml").read_text()
inventory = load_stream(text) # same parser, same schema, same rules
preview = render_source(text, ViewOptions(layer=Layer.L2))
preview.svg # an <svg> fragment, safe to embed, with an id on every element
preview.details["node-sw-office"] # the info box, keyed by the drawn element's id
preview.problems # load errors and findings, most severe first
The keys of preview.details are the ids render/ids.py gives the drawn elements — node-
or edge- followed by a slug of the fully-qualified name, so node-sw-office and
edge-cables_cbl-rtr-sw. They are the same ids -f svg and -f json carry, which keeps a
hover, a tooltip and an export from drifting apart. And render_source never raises for
anything the text can be wrong about: a syntax error, a dangling cable and a filter that
matches nothing all come back as a preview whose status and problems say so, with
whatever resolved still drawn.
Design rules a change must not break
Each is load-bearing somewhere else in the tree, and a test names most of them.
- Models forbid unknown keys.
NetvizModelsetsextra="forbid"(models/base.py,NV-D005): silently ignoring a misspelt key would produce a diagram that disagrees with the file, which is the failure mode this tool exists to prevent. It is also what putsadditionalProperties: falseinto the JSON Schema. - The loader normalises so that no later stage re-parses. Ranges are expanded and
templates merged during the load, and nothing below
loader/andfmt/imports a YAML parser —cli.pyusesyaml.safe_dumpfor output only — so an inventory cannot mean one thing tovalidateand another torender. - Validation never mutates the inventory.
validatereads and returnslist[Finding]; a check that wrote something down would be one whose result depended on run order. - Renderers are pure functions of
(Graph, RenderOptions). A backend gets noInventory, no configuration and no filter.RenderOptionssays what to draw;FilterSpecdecided what exists before any renderer ran — so turning a label off can never change the topology a reader sees. - Every user-visible diagnostic goes through
rules.py, so it has a permanent id, a severity and atitlefrom which its section in validation-rules.md is derived. Load errors are not exempt:report.LOAD_RULEgives them the same three.tests/test_docs.pyfails when a rule has no section, a wrong severity, a missing alias or no "Suppress with" line, andtests/test_examples.pyfails when it has no fixture undertests/fixtures/invalid/. - One entry per output format, in
render/registry.py. No front end may branch on a format name; something new to know about a format is a new field onRenderer. - Output is deterministic, and identifiers are permanent. Load order, finding order,
node and edge order and every export's collation are explicitly sorted, never left to
dict order or directory traversal —
tests/test_golden.pyand the export fixtures would notice. And a rule id, aNodeType, a JSON export key or a drawn element's id appears in somebody's suppression list, style sheet or downstream tool: add, do not renumber.
See also
- CONTRIBUTING.md — the gates, the recipes, how to run CI's checks.
- Testing netviz — the example half and the property half of the suite, and the Hypothesis profiles.
- The inventory schema — the specification the models and the loader implement, section by section.
- Validation rules — the write-up behind every id in
rules.py.