netviz GitHub

The canonical form, and netviz fmt

An inventory is the source of truth for a network, and it lives in review. That makes the shape of a file everybody's problem: two people writing the same switch will indent it differently, order its keys differently and quote its MAC differently, and every one of those differences shows up in a diff that is supposed to be about the network.

netviz fmt removes the question. There is one canonical form, defined below, and the tool puts a file into it — the way gofmt and ruff format do for code.

netviz fmt [OPTIONS] [PATHS]...

Formatting never changes what a document means. Every file is read back with the same strict loader validate and render use, and compared against what it said before; a file that does not survive that comparison is left exactly as it was. See Safety.


Contents


The canonical form

Layout

Rule Value
Indent two spaces per mapping level
Sequence indent four columns from the parent key; the - sits two in
Document separator --- between documents, never before the first, never ... at the end
Trailing whitespace none, on any line, including inside comments
Trailing newline exactly one; no blank lines at the end of a file
Blank line before --- removed
Byte-order mark removed
Line endings \n, on every platform

So a device reads:

apiVersion: netviz.dev/v1alpha1
kind: switch
metadata:
  name: sw-office
spec:
  interfaces:
    - name: port1
      type: ethernet

The sequence rule is the one worth stating twice, because YAML permits three spellings of it. interfaces: sits at column 2, its dashes at column 4, and the keys of each entry at column 6.

The last two rows are also stated twice, because they are the two the comparison is made in bytes for. A byte-order mark and a CRLF line ending both decode to an identical str, so a formatter comparing text would declare such a file unchanged and let it keep them forever. netviz fmt compares the encoded bytes instead, which is why it reports — and rewrites — both.

That has one consequence on Windows worth knowing before it surprises you. Git's core.autocrlf defaults to true there, so every YAML file arrives CRLF, and netviz fmt then rewrites it to LF: --check fails on a fresh clone, and git status reports every file as modified after a run. Neither is a netviz bug and neither has a fix in netviz — the fix is to stop Git translating, with a .gitattributes next to the inventory:

*.yaml text eol=lf
*.yml  text eol=lf

This repository ships exactly that file for exactly that reason.

Key order

Mapping keys are ordered to match the field order of docs/schema.md — not alphabetically. Alphabetical order would put annotations before name and bridge before interfaces, which is a consistent way of making every document harder to read than the author left it. Schema order puts identity first and detail after it.

apiVersion, kind, metadata, spec come first, in that order. Below them each mapping follows the order its section of docs/schema.md documents:

spec:                   # vendor, model, serial, location,
  vendor: Arista        # interfaces, from, bridge, vlans, forwarding
  model: 7050SX3-48YC8
  interfaces:           # name, range, type, description, enabled, mac,
    - name: Vlan10      # mtu, ipv4, ipv6, vlan, parent, members
      type: vlan
      description: Staff gateway
      ipv4:
        addresses: [10.1.10.1/24]
      vlan:
        mode: access
        access_vlan: 10
      parent: br0
  vlans:
    - id: 10
      name: staff
  forwarding:
    ipv4: true
    ipv6: true

The order is not written down a second time inside the formatter. It is read off the pydantic models, whose field declaration order docs/schema.md documents and tests/test_docs.py keeps in step — so a field added to the schema is placed without anyone teaching fmt about it.

Two keys are not model fields, because the loader expands and removes them before the models see a document. They are placed where docs/schema.md documents them: spec.from (§6.6) after interfaces, and spec.interfaces[].range (§6.5) after name.

A key the schema does not know — a typo, or a field from a future version — keeps its value and is moved to the end of its mapping, after the keys that are ordered. Trying to keep it near the neighbours it was written between is not something the output can express, and so not something a second run could reproduce; see idempotence.

Free-form mappings are never reordered. metadata.labels and metadata.annotations have keys that belong to the user, and YAML gives their order no meaning, so fmt leaves it as written rather than imposing one.

Quoting

Quote only what YAML requires, plus what a reader would otherwise misread.

What YAML requires is not a judgement call, and fmt does not make it: the emitter adds quotes whenever a plain scalar would not survive being read back — a leading -, a : , a #, a leading or trailing space, an empty string.

On top of that, two things are quoted because plain would be misleading:

Everything else is written plain, including IP addresses and prefixes (10.1.10.1, 2001:db8::1/64), speeds (10Gbps) and interface names (GigabitEthernet1/0/1). Quotes that a document had and does not need are removed.

Version numbers need no rule of their own, though it is worth saying why. 1.0 is a float to every YAML reader including netviz's, so it is never a string to begin with; '1.0' written with quotes keeps them, because dropping them would turn a string into a float. 1.2.3 is unambiguously a string to everyone. A shape rule for dotted numerals would be actively wrong — 10.1.10.1 is one too, and quoting every IP address in an inventory serves nobody.

When quotes are added they are single quotes, which carry no escape sequences: what is between them is what the value is.

Flow and block

Block style is never turned into flow. A hand-written block list is a deliberate shape — one interface per stanza, a comment against an entry — and collapsing it would destroy the grouping this formatter promises to keep.

Flow style is kept where it was used and still fits. addresses: [10.1.10.1/24] stays on one line; so does labels: {site: office}. A flow collection becomes a block when it cannot stay readable as one:

Comments and blank lines

Both are preserved. This is the point at which a YAML formatter is worth having at all: a comment explaining why an interface is disabled, and the blank line separating one interface stanza from the next, are content.

      ipv4:
        addresses: [10.1.10.51/24]
        # The Vlan10 SVI on sw-north-dist-01. 'gateway' is checked against this
        # interface's own prefixes by NV-A013; 'netviz ipam' reports it.
        gateway: 10.1.10.1

Trailing whitespace inside a comment is stripped; nothing else about it changes. Comments are never re-wrapped, re-indented relative to their key, or moved between keys. That last promise is checked on every file, not merely intended: the comment lines of the output are counted against the input's, and a format that dropped one is refused along with everything else in Safety.

Blank lines are kept as grouping, with two normalisations: two or more in a row become one, and one immediately after a --- is removed, since the separator already separates.

Comments and key order

The two can conflict, and comments win.

A YAML comment is attached to the line it follows, not the key it describes, so a comment written above a key cannot be carried along when that key moves — it would stay put and end up describing whatever landed beneath it. Rather than produce that, fmt does not reorder the keys of a block that contains a comment on a line of its own. Everything else still applies to that block: indent, quoting, styles, whitespace. Only the ordering defers, on the grounds that a comment inside a block is the author saying something about the shape they chose.

spec:
  # why this device forwards
  forwarding:
    ipv4: true
    ipv6: true
  interfaces:            # 'forwarding' would sort after this, and does not
    - name: e0
      type: ethernet

An end-of-line comment is filed against its own key and moves with it, so it does not freeze anything:

spec:
  interfaces:
    - name: e0
      type: ethernet
  forwarding:  # why this device forwards -- reordered, comment and all
    ipv4: true
    ipv6: true

Empty collections

An empty mapping is written {} and an empty sequence [], whichever way they were spelled. Nothing has one spelling too: key: and key: ~ are both written key: null, which is the form a reader cannot mistake for a line somebody forgot to finish.

An empty document in a multi-document file becomes an explicit null. The loader treats the two identically (NV-L004), so nothing downstream can tell; dropping the document instead would renumber every document after it and move the line every diagnostic points at.


Modes

$ netviz fmt                       # rewrite the inventory -i points at
$ netviz fmt inventory devices/    # rewrite these paths
$ netviz fmt --check inventory     # write nothing; exit 1 and list what differs
$ netviz fmt --diff inventory      # write nothing; print a unified diff
$ ... | netviz fmt --stdin         # format a stream onto stdout

In place is the default, and the only mode that touches the disk. Each file is written through a temporary file in the same directory and then renamed, so an interrupted run leaves either the old file or the new one and never half of either. It exits 0 when it worked, whether or not anything changed — the same as gofmt -w.

--check is the CI mode. It writes nothing, lists the files that are not canonical on stdout, one per line, and exits 1 if there are any:

$ netviz fmt --check examples
examples/campus/sites/north/hosts/hosts.yaml
examples/home-lab/switches/sw-home.yaml
2 file(s) would be reformatted, 36 already formatted

The list is stdout and the tally is stderr, so netviz fmt --check | xargs $EDITOR opens the files and nothing else.

--diff writes nothing and prints a unified diff. Paths below the working directory get git's a//b/ prefixes, so the output is a patch:

$ netviz fmt --diff examples | git apply -R    # or just read it

It exits 1 when there is a diff, so it is usable as a gate too.

--stdin (or the path -) reads a YAML stream from stdin and writes the formatted stream to stdout. Nothing else is printed on success. Discovery does not apply — a stream is not a file and has no ignore rules — and neither does in-place rewriting, so this is the mode an editor's "format buffer" command wants:

$ netviz fmt --stdin < devices/sw.yaml > devices/sw.formatted.yaml

Which files are formatted

Exactly the files the loader would read, and no others. fmt walks a folder with the same discovery validate and render use, so all of docs/schema.md §2.1 applies unchanged:

That is a deliberate limit rather than an incidental one. A file the inventory ignores may not be netviz YAML at all, and rewriting it would be the formatter exceeding its remit. A path named outright on the command line is still subject to the ignore rules of the tree it sits in.

A path may be a folder to walk or a single YAML file. The same file reached through two paths is formatted once.


Safety

Two properties are tested over every document under examples/ and tests/fixtures/, on every run of the suite (tests/test_fmt.py).

Formatting preserves meaning. Before anything is written, the formatted text is parsed again — with netviz.loader.documents, the strict loader, not the round-trip parser that produced it — and compared against the original:

If the two differ, or the output does not parse, nothing is written and the file is reported as failed. That outcome is a bug in netviz rather than in the file, and the message says so.

Comments are preserved. The whole-line comments of the output are counted against the input's, and a format that lost one is refused. Model comparison is blind to comments by construction, so without a check of its own nothing would notice them all disappearing — which is the one failure a round-trip formatter must not have.

Formatting is idempotent. Formatting twice produces the same bytes as formatting once. This is not a nicety: without it --check could fail on a file that fmt had just written, and the two modes would disagree about what canonical means.


What fmt will not do

It canonicalises documents; it does not repair them.

The clearest case is a scalar netviz already misreads. 1:02 is the integer 62 to a YAML 1.1 resolver, and so it is to netviz's — quoting it would make it the string 1:02, which is very likely what the author meant and is categorically not fmt's call to make. The same goes for a MAC that lands on the base-60 pattern. Those are findings for netviz validate, which can report them without silently rewriting them.

It also does not:


Options

Option Effect
--check Write nothing. List the files that are not canonical, and exit 1 if there are any.
--diff Write nothing. Print a unified diff of what would change, and exit 1 if there is one.
--stdin Format the stream on stdin onto stdout. The path - means the same.

--check and --diff cannot be combined.

The global -i/--inventory decides what is formatted when no PATHS are given; -q/--quiet suppresses the tally without suppressing the file list, the diff, or any error.

Exit codes

Code Meaning
0 Everything is canonical, or was made canonical.
1 --check or --diff found a file that is not canonical, or some file could not be formatted.
3 A path does not exist, or a stream on stdin is not well-formed YAML.

In CI and pre-commit

This repository gates its own examples/ tree on --check; the step is in .github/workflows/ci.yml next to ruff format --check, and prints a --diff into the log when it fails.

      - run: pip install netviz
      - run: netviz fmt --check inventory

Two pre-commit hooks are published, and they differ only in whether the files come back changed:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/blechschmidt/netviz
    rev: v0.0.2
    hooks:
      - id: netviz-fmt          # rewrites in place; git add and commit again
      - id: netviz-fmt-check    # reports only; nothing is rewritten

Both take the staged filenames rather than walking the tree — formatting is per-file, unlike validation, where a cable is only dangling when compared against the devices in the other files.

netviz-fmt rewrites and then relies on pre-commit noticing the modification, which fails the commit regardless of the exit status. That is the intended loop: the files come back fixed, and git add is the whole remedy.

For an inventory that does not sit at the repository root, restrict files rather than overriding entry — unlike netviz-validate, this hook is given the paths to work on:

      - id: netviz-fmt
        files: ^inventory/.*\.ya?ml$