The mibcorpus tool¶
mibdump compiles the modules it is given. Building a corpus is a
different shape of job: it takes a declared set of source namespaces – a
vendor’s directory of ASN.1 files, the standard modules pysmi bundles – and
produces every artifact the corpus publishes, with the property that two runs
over the same inputs produce the same bytes.
mibcorpus is that job, and pysmi.corpus is the library beneath
it.
$ mibcorpus --help
Synopsis:
Build a MIB corpus from a declared set of source namespaces
Documentation:
https://github.com/pysnmp/pysmi
Usage: mibcorpus [--help]
[--version]
[--quiet]
[--debug=<all|borrower|codegen|compiler|grammar|lexer|parser|reader|searcher|writer>]
[--manifest=<FILE>]
[--namespace=<TIER>:<NAME>:<SOURCE>]
[--output-directory=<DIRECTORY>]
[--frozen-index=<FILE>]
[--parse-cache=<DIRECTORY>]
[--emit=<ARTIFACT>[:<PATH>]]
[--corpus-version=<VERSION>]
[--corpus-id=<NAME>]
[--no-bundled-mibs]
[--fail-on-errors]
The input set¶
A corpus is built from source namespaces, declared in a manifest:
{
"version": 1,
"namespaces": [
{"name": "standard", "source": "package:pysmi.mibs.asn1",
"tier": "standard"},
{"include": "src/vendor/*", "tier": "vendor"}
]
}
Each namespace has a name, a source and a tier. The source is a directory, or
package: and a dotted package name for the modules a Python package ships.
An include entry stands for every directory matching a glob, expanded in
sorted order, so a corpus of three hundred vendor directories does not have to
list them by hand and still gets them in one order. Paths are relative to the
manifest’s own directory.
The tier is one of standard, draft and vendor, and it is what tells
the OID index that a standard module owns an arc a vendor module also defines.
It is declared rather than inferred from where a file sits, because a tier
is a statement about a source, and the source is what the manifest names.
A namespace may also declare "publish": false. It is then a resolution
source: it supplies what the published modules import and contributes nothing
to the output. Nothing of its is staged, compiled or indexed, and it is stubbed
in every output format so that it cannot arrive as a dependency either. On the
command line that is --resolve-namespace rather than --namespace.
The order matters. Two namespaces can hold a module of the same name and
exactly one copy of it can be published; the newest MODULE-IDENTITY revision
wins and source order breaks the tie, which is the rule
compile() documents and applies.
A corpus of a few modules¶
"publish": false narrows a build by namespace. --publish-only narrows
it by module, and everything outside the selection becomes a resolution source
in exactly the same sense: read, available to resolve an import, and carried
nowhere.
mibcorpus --manifest=corpus.json --output-directory=preview \
--emit=site --emit=report \
--publish-only=CISCO-IPMCAST-MIB \
--publish-only=TEL2N-MIB
This exists for a question a namespace cannot express: what do the modules
this change touched look like? Neither obvious answer works. A build over the
changed files alone fails on every import they make – a vendor module that
imports SNMPv2-TC needs SNMPv2-TC to be there. A build over the whole
corpus answers in five minutes and buries the two pages that were asked about
in seven thousand that were not. So the input set stays whole and the output
set narrows.
What makes the result worth looking at is that it is the same corpus:
module built narrowed == module built whole
byte for byte, in the ASN.1 and in the JSON, because it is the same compile
over the same resolved sources. This is the property "publish": false
rests on as well, and tests/test_corpus_select.py holds the narrowed build
to it.
--publish-only-from=FILE reads the same names from a file, one per line,
blanks and # comments ignored, for a caller that computes the set rather
than typing it.
Two refusals, both because the quiet alternative is worse:
A selected module no publishing namespace holds is an error naming every one that missed. A caller that named three modules wants three, and a preview that silently publishes two has answered a question nobody asked. A namespace holds what its files declare, not what they are named, so a file too damaged to lex a module header out of holds no module and is reported here rather than as an empty page.
An empty selection is an error rather than a whole corpus. Naming no selection at all is a distribution build; computing one and computing it empty is a request for a corpus of nothing, and the two must not collapse into each other – a pull request that changed no MIB would otherwise publish the whole corpus.
report.json records what was asked for under selected:
{
"selected": {
"requested": ["CISCO-IPMCAST-MIB", "TEL2N-MIB"],
"published": ["TEL2N-MIB"],
"failed": ["CISCO-IPMCAST-MIB"]
}
}
which is what tells a check that a module was asked for and did not compile,
as against never having been asked for. --fail-on-errors turns that into a
non-zero exit.
What the corpus is¶
A manifest also says which artifacts the corpus carries and what must be true of the result. Without those two keys the definition of a distribution is split between a JSON file pysmi reads and a build script pysmi has never seen, and a second publisher reproducing the corpus starts by copying the build script – which means copying decisions that were never theirs.
{
"version": 1,
"emit": ["asn1", "index-v2", "report"],
"expect": {
"modules": {"min": 8000},
"failures": {"max": 12},
"namespaces-present": ["iana", "ietf"]
},
"namespaces": [
{"include": "src/vendor/*", "tier": "vendor"}
]
}
emit takes the same artifact names --emit does, a path of its own
included – "json:build/jsondoc". --emit on the command line overrides
it, as a flag overrides a file. With it declared,
mibcorpus --manifest=M --output-directory=D is the whole build, and the
reason a corpus carries what it carries sits next to the set itself.
expect is checked after the build, against the same numbers
report.json records:
Expectation |
What it bounds |
|---|---|
|
How many modules the corpus publishes, counted once however many namespaces hold one. Independent of the emit set. |
|
How many of those failed to compile, counted once however many output formats they failed in. |
|
Namespaces that must be in the build and published. |
modules and failures take min, max or both.
An expectation missed prints the field, the bound and the actual value, and
exits 65. An expectation the build does not report – a misspelling among them
– is refused when the manifest is read, since one silently ignored leaves the
publisher believing a check is being made that never is.
What it produces¶
Artifact |
What it is |
|---|---|
|
One file per module name, flat, named for the module. Staged from what the compile resolved, so the ASN.1 beside a compiled module is the text it was compiled from. Its naming is a contract – see How the ASN.1 tree is named. |
|
pysnmp modules without DESCRIPTION and the other texts. |
|
pysnmp modules with them, in their original layout. |
|
jsondoc documents. Without DESCRIPTION and the other texts unless the build asks – see Prose in the JSON. |
|
The ranked OID index: for every OID, the one module that owns it. |
|
The legacy OID index, which replays |
|
The modules from every |
|
Per module, the files a consumer needs in order to load it, and anything it needs that the corpus does not hold. See The files a module needs. |
|
Which enterprise arcs the corpus registers under, who the registry
says holds each, and who to report a problem to. Asked for by name,
and wants |
|
What every arc the corpus reaches is called, and which authority says so. Asked for by name. See What an arc is called. |
|
What the build did: the failure inventory, the modules more than one namespace holds, the node counts, which JSON implementation wrote the artifacts, and how long each phase took. |
The two databases and the site are not in that layout and have to be asked for by name:
Artifact |
What it is |
|---|---|
|
The corpus database: every node the corpus defines, keyed for lookup by OID and by name, and ordered for GETNEXT. See Corpus database schema. Building it costs a pass nothing else needs, which is why it is opt-in rather than part of the default layout. |
|
The same schema at a seventeenth the size: everything but |
|
The corpus as pages: one per module, per registrant and per node of the registration tree. Asked for by name, since a corpus published as files rather than as a site has no use for it. See The browsable site. |
--emit narrows this to the artifacts named, so a build can ask for just the
index or just the JSON.
Both databases and the two indexes are projections of the jsondoc tree, which
is a dependency of pysmi’s own rather than of the caller’s: a build asking for
one of them without asking for json gets a tree staged in a temporary
directory and removed when the build ends, raise or return. The corpus carries what was
named and nothing else.
The site and the entity index read a tree with the texts in it. A module page
carries each definition’s DESCRIPTION, and a registrant is named from the
module’s own ORGANIZATION and CONTACT-INFO before the registry is consulted;
JsonCodeGen gates all three behind one switch, so a lean tree holds none of
them. A build rendering either gets a texts-carrying tree staged the same way
– even where it named a lean one, since the lean tree cannot answer. That
costs a render pass rather than a parse, since the parse is shared, and the
tree the build publishes is untouched: a publisher asking for a lean json
still gets a lean json.
Over pysnmp/mibs the prose is 90.2 MB of the module pages, 85% of definitions
carrying one. Emit json-texts to keep the tree that produced it.
mibcorpus --manifest=corpus.json --output-directory=output \
--emit=asn1 --emit=index-v2 --emit=core-db
Emit json to keep the tree, and give it a path of its own to say where it
goes.
Every artifact but report.json is byte-reproducible. The report is the
build’s log and records elapsed time.
One build, more than one tree¶
A distribution is not always one tree. The same corpus goes out as the data a runtime fetches and as the pages a reader browses, and those may belong on different hosts with different limits. Building it twice parses every module twice to reach the same answer.
A manifest declaring publications instead of emit says a build writes
several trees:
{
"version": 1,
"namespaces": [{"include": "src/*", "tier": "vendor"}],
"publications": [
{"name": "data", "emit": ["asn1", "json", "index-v2"], "output": "data"},
{"name": "pages", "emit": ["site", "search-db"], "output": "pages"}
]
}
Each publication names what it carries and where it goes, under
--output-directory. The corpus is parsed once for all of them: the parse
cache is shared across the plan, and parsing is about three quarters of a
pass.
emit and publications are alternatives, not layers. A manifest
declaring both is refused, because the artifacts would belong to no tree in
particular and a reader could not tell which by looking. A publication naming
no output writes into the build directory itself, which is the ordinary
single-tree build said the long way.
--emit on the command line collapses the plan to the one tree it names,
as a flag overrides a file everywhere else here.
Every publication is summarized and held to the manifest’s expect: a build
that wrote two trees and checked one has not checked the build.
Keeping the parse trees¶
--parse-cache=<DIRECTORY> keeps parse trees between runs, so a rebuild of
a mostly unchanged corpus reparses only what changed. The key is the module’s
own text together with the identity of the parser, so an unedited module keeps
its entry and an edited one cannot match a stale tree. Without it the trees
are held in memory and discarded with the process, which is right for a
one-off build and wasteful for a nightly.
It stores pickles, and reading one reconstructs arbitrary Python objects. Name a directory the build owns, never one written by anything you would not run, and never one shared across trust domains. A damaged, truncated or unreadable entry is a miss rather than an error: the cache is an optimisation and never a source of truth.
How the JSON is written¶
json/ and the JSON index are written compactly – one line per document,
no space after a separator, UTF-8 written as itself rather than escaped. They
are generated trees that nobody reads by eye, and indenting them costs about
30% of the tree on disk: roughly 50 MB over a corpus the size of the one
pysnmp/mibs publishes. Gzipped the difference is about 8%, because gzip
already eats indentation, so this is a saving on a checkout, an image and a
Pages site rather than on bandwidth.
report.json is not written that way and stays indented. It is the build’s
log, read by a person looking at a failed build.
A faster JSON implementation is used when one is installed:
pip install 'pysnmp-pysmi[fast]'
That pulls in orjson; msgspec is taken instead if a caller already has it. Either is worth about 4% of a build, because SMI parsing dominates everything else, which is why it is an extra rather than a dependency – 4% does not buy a wheel with a compiled extension in it for everyone.
What is installed does not change a published byte. The standard library
is the reference, the faster encoders are used only where they agree with it,
and tests/test_jsonio.py asserts that agreement over every document the
bundled corpus produces rather than trusting that it holds. A build with the
extra and one without are the same corpus. report.json records which
implementation ran, as json, so two builds being compared can each say
what they resolved.
The files a module needs¶
core.db’s import table holds the direct edges – this module imports
that symbol from that module. The question consumers actually arrive with is
the closure: which files do I need in order to load this module?
closure.json answers it per module:
{
"meta": {"schema": 1, "modules": 5347, "incomplete": 0},
"closure": {
"IF-MIB": {
"files": ["IANAifType-MIB", "IF-MIB", "SNMPv2-CONF",
"SNMPv2-MIB", "SNMPv2-SMI", "SNMPv2-TC"],
"missing": []
}
}
}
(Re-indented to be read; the file is written on one line.)
The build resolves every one of those edges in order to compile, so having each consumer re-walk a table to recover a fact the compiler established is the pattern this exists to avoid. Three arrive with the question: a page answering “the files you need”, anyone packaging a subset – an air-gapped install, an image carrying only what one product needs – and pysnmp, deciding what to preload.
Two things the artifact settles rather than leaving to the caller:
A module is in its own closure.
IF-MIBneeds six files and one of them isIF-MIB. That makes the list directly usable as a file list, which is what most callers want.A dependency the corpus does not hold is recorded, not dropped. It goes in
missingrather than quietly out offiles, so a caller can tell “this module needs nothing else” from “something it needs is not here”. A module is listed however deep it was reached from: a hole anywhere below a module is a hole for that module, which still cannot be loaded. The build report counts them, asclosure.incomplete.
The names are module names, which are the names of the files in asn1/ –
see How the ASN.1 tree is named – so a caller holding a closure holds the file list
and can build the URLs itself.
Prose in the JSON¶
A published jsondoc carries names, OIDs, syntax, access and status, and no
prose. IF-MIB.json has no
description on ifOperStatus, though the module’s text describes all seven
of its enumerated states. Measured over a 300-file sample,
DESCRIPTION, REFERENCE and CONTACT-INFO are 38% of the text of a
MIB – a large part of the module the JSON rendering omits, and a consumer
that wants it has to fetch and parse the ASN.1, which means a second SMI parser
for prose the compiler already read.
json-texts is a jsondoc tree with the texts in it:
mibcorpus --manifest=corpus.json --output-directory=output --emit=json-texts
That writes json/ – one tree, carrying the prose, at one compile pass.
It is a destination of its own rather than a flag on json, so a build may
also have both:
mibcorpus --manifest=corpus.json --output-directory=output \
--emit=json --emit=json-texts:build/full
which publishes the lean tree and keeps a complete one for something that needs the prose – a site generator rendering descriptions into its pages, say – without the published artifact growing. That is two passes, because it is two trees. Naming both at one path is refused: the second pass would overwrite the first, and which of them survived would depend on the order the emit list was read in.
The texts cost roughly 70% more on disk, which over a corpus is a tree half
again as large. That is a decision for the build, which is the argument for
asking rather than assuming, and json-texts is not in the default
layout.
What genTexts gates is more than descriptions:
JsonCodeGen.gen_module_identity puts organization and contactinfo
behind the same switch. CISCO-ENTITY-ALARM-MIB carries a full
CONTACT-INFO block in its ASN.1 – Cisco Systems, Customer Service, a
postal address, a phone number and cs-snmp@cisco.com – and none of it
reaches the published JSON. Over a 500-module sample, 90% of modules carry
ORGANIZATION and CONTACT-INFO, and 66% of those carry an email. That is
the publisher’s own statement of where to report a problem, and it is what
The mibcorpus tool cannot show a reader until this is turned on.
keepTextsLayout is not turned on with it. The two are separate for the
pysnmp destinations and stay separate here: a JSON consumer generally wants the
text normalised rather than the publisher’s line breaks preserved.
Who registered an arc¶
A corpus knows that CISCO-ENTITY-ALARM-MIB registers under
1.3.6.1.4.1.9. It does not know that 1.3.6.1.4.1.9 is Cisco: nothing in
the MIB text says so in a form anything can rely on, and the directory a file
sits in is a filing convention rather than a registration. Over pysnmp/mibs the
two disagree in practice – the aironet directory holds Cisco modules, and
src/vendor/cisco/ALTIGA-* registers under Altiga’s arc.
The registration is a published fact. --oid-registry supplies it and
--emit=entity writes the projection:
mibcorpus --manifest=corpus.json --output-directory=output \
--emit=entity --oid-registry=pen-snapshot.csv
{
"meta": {"schema": 1, "arcs": 351, "named": 350, "unregistered": 1},
"entity": {
"1.3.6.1.4.1.9": {
"number": 9,
"organization": "Cisco Systems, Inc.",
"modules": ["CISCO-AAA-CLIENT-MIB", "..."],
"contacts": [
{"source": "module", "organization": "Cisco Systems, Inc.",
"contact": "Cisco Systems\n Customer Service\n ...",
"email": "", "module": "CISCO-ENTITY-ALARM-MIB", "authority": ""},
{"source": "registry", "organization": "Cisco Systems, Inc.",
"contact": "Dave J", "email": "davej&cisco.com", "module": "",
"authority": "https://www.iana.org/assignments/enterprise-numbers#9"}
]
}
}
}
Over pysnmp/mibs the distinct enterprise arcs outnumber the vendor
directories by a fifth, and the registry names all but a handful of them. That
gap is the argument for driving navigation from the registry rather than from
the tree; report.json carries the run’s own counts under entity.
An arc the registry does not name is reported as unregistered, never guessed
at. pysnmp/mibs has exactly one, 1.3.6.1.4.1.1004849, above anything IANA
has allocated. The build report counts them, as entity.unregistered.
The index groups by arc, not by company. The registry maps arcs to
registrants and a company can hold several: Cisco modules sit under
1.3.6.1.4.1.9 and, from the Altiga acquisition, under
1.3.6.1.4.1.3076, which IANA still lists as “Altiga Networks, Inc.”.
Nothing in the registry models an acquisition and PySMI does not infer one.
entity.json is not in the default layout: it is only worth having with a
registry to name its arcs, and that is an input the caller supplies.
Owner contact¶
Each arc carries who to report a problem to, best source first, each naming its source so a reader can weigh a vendor’s current support address against an undated registration:
The module’s own ``CONTACT-INFO`` – a corporate block with a role mailbox, and the publisher’s current statement of where to report a problem. It reaches the corpus only when the build carries texts, since
JsonCodeGengatesorganizationandcontactinfobehind the same switch asdescription– see pysnmp/pysmi#277. Where an arc has many modules, the one with the newestLAST-UPDATEDspeaks for it, with the module name breaking a tie so that two builds agree.The IANA registration – registrant, contact name, contact email. Second because it is undated and often stale, and carrying the authority link, because a correction to a registration belongs at IANA.
Each is rendered as its source publishes it. An email from the registry is
davej&cisco.com because that is what the registry says; a module’s block is
reproduced whole rather than picked apart for an address, since a block holds a
company, a postal address, a phone number and a mailbox and choosing between
them is not the build’s decision.
Remediation is precedence rather than a suppression list. A registrant who does
not want an undated personal registration standing as the contact for their arc
publishes a module carrying current ORGANIZATION and CONTACT-INFO, and
source 1 displaces source 2 on the next build.
The registry file¶
Taken as an input. Never bundled, never fetched. A corpus build resolves nothing over the network – a build with the network unplugged produces the same corpus as one without – and a registry that changes daily, fetched at build time, would end that. 5.1 MB that changes daily is also not a thing to vendor into a compiler.
--oid-registry is repeatable and reads two registries: the Private
Enterprise Numbers registry, in either the published four-line-record format or
a reduced CSV, and IANA’s smi-numbers XML (see What an arc is called). Which one
a file is comes from its content rather than from its name, since a snapshot a
repository commits is called whatever that repository calls it. A repository that keeps a snapshot reduces a download once:
python -m pysmi.registry enterprise-numbers.txt > pen-snapshot.csv
The reduced form keeps the whole registry, whole records, by default.
--fields narrows the record and --only/--only-from narrow the rows.
What leaves IANA’s copy is the committing repository’s decision rather than
PySMI’s, so both are arguments rather than a hard-coded projection. Nothing is
normalised: the output is a rendering of IANA’s record rather than a corrected
version of it.
The registry runs to tens of thousands of registrations and IANA revises it daily, so a repository committing all of it commits a large file and re-diffs the whole of it every month. The other choice is a snapshot of the registrants this corpus’s own arcs use, which is a few hundred rows rather than the whole registry:
python -m pysmi.registry --only-from=arcs.txt enterprise-numbers.txt \
> pen-snapshot.csv
--only-from reads enterprise numbers one per line, blanks and #
comments ignored, which is what a corpus can write out of its own index. The
cost is staleness – a module arriving later under an arc the snapshot predates
goes nameless – and the build says when that has happened rather than leaving
it to be found on the site.
What an arc is called¶
The OID index ranks modules to decide which one owns an arc. The rule is total and works for arcs a module actually registers; for the arcs above those it has nothing good to choose from, so it picks whichever module happened to mention the arc on its way somewhere else. Measured against pysnmp/mibs:
arc |
is |
attributed to |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
ISO member-body |
|
|
ITU-T recommendation |
|
A tree that says snmpModules belongs to a Nortel enterprise MIB is wrong in
a way that matters, and no ranking over MIB text can fix it, because the fact
is not in the MIB text. Twenty-five arcs are claimed by nothing at all, and a
tree still has to render a path through them – 1.3.6.1.4.1.9 among them,
since no module registers Cisco’s bare arc, only what hangs beneath it.
--emit=arcs writes the answer, from the registries the build was given:
mibcorpus --manifest=corpus.json --output-directory=output --emit=arcs \
--oid-registry=smi-numbers.xml --oid-registry=pen-snapshot.csv
{
"meta": {"schema": 1, "arcs": 6694,
"by-source": {"module": 5900, "registry": 700,
"standard": 15, "unnamed": 79}},
"arc": {
"1.3.6.1.6.3": {"name": "snmpModules", "source": "registry",
"reference": "https://www.iana.org/assignments/smi-numbers"},
"1.0.8802": {"name": "iso8802", "source": "standard",
"reference": "ISO-IEC 8802"}
}
}
Every name says which kind of fact it is¶
source is rendered, because an IANA registration, a cited standard and a
name read off whichever MIB mentioned an arc are three different things and a
reader has to be able to tell them apart. Strongest first:
registryIANA.
smi-numbersnames the1.3.6.1subtree – and, through the dotted names in its own registry descriptions,1 isodown to1.3.6.1 internet, which nothing else in the file names. The Private Enterprise Numbers registry names a bare enterprise arc.standardA standard, cited. ITU-T’s OID registry does not answer, and IEEE publishes landing pages and PDFs, so there is no feed for
0,1.0,1.2,1.3.111or1.0.8802– the chains IEEE registers its 802 MIBs under, andLLDP-MIBsits at1.0.8802.1.1.2. Those are defined in ITU-T X.660 and ISO/IEC 9834-1 and have not changed in decades, so PySMI carries a small table with a reference per entry. A cited name is not a registration and is labelled as what it is.moduleA module’s own descriptor, which is what the index has always used. Still here, still useful, and now labelled rather than presented as though it were an authority’s answer.
An arc nothing names says so, with an empty name rather than a borrowed one. The build report counts them.
When a committed PEN snapshot has gone stale¶
A repository that commits the whole Private Enterprise Numbers registry commits tens of thousands of registrants and re-diffs all of them every time IANA moves, which is daily. Committing only the registrants its own arcs use is a far smaller file and a far smaller monthly diff, at the cost of going stale: the first module the corpus gains under a newer enterprise arc has no registrant in the snapshot, and that registrant’s page renders nameless.
Nothing about the build would otherwise say so – the arc is still in the index, still reachable, still rendered, just blank. So the build says it:
WARNING 3 enterprise arc(s) no PEN registrant names: 62373, 99999, 100001
and report.json carries the count as arcs.unregistered-enterprises, so
a build nobody watched can still be asked. arcs.json carries every one of
them, since a log line is not the artifact.
It is a warning and never a failure. An arc can be registered to nobody, and IANA’s registry has gaps of its own. An arc a module names is still counted: the module’s own descriptor says what the vendor calls its subtree, not who registered it, and it is the registrant a refresh would supply.
pysmi.corpus.arcs.unregistered() is the same answer as data, for a
build that wants to act on it.
What is in the inventory¶
The arc set comes from the OID index and every prefix of it – the registration tree – rather than from every OID a module defines, which is tens of thousands of arcs against one per defined OID. The difference is objects, and an object’s arc is a thing inside a module rather than a node anybody navigates to.
It is not filtered to one subtree. Nearly every index row of a vendor corpus
sits under 1.3.6.1.4.1 and most of the rest under 1.3.6.1.2.1, but
IEEE publishes its 802.1 MIBs under 1.3.111.2.802.1 and LLDP-MIB
registers under 1.0.8802.1.1.2. A 1.3.6.1 filter drops both, and LLDP
is among the most widely polled MIBs there is. Arc depth runs from 2 to 20.
That inventory counts an arc whether or not anything would render a page for
it. Which of them a site gives a page to is a narrower question, and
pysmi.corpus.pages answers it: the arcs above the modules, which is
a small fraction of the arc index and two orders of magnitude below a page per
defined OID. An arc at a module’s anchor is the module, and an arc below one
is an object the module page already renders.
The browsable site¶
--emit=site writes the corpus as pages, from the same jsondoc tree the
indexes and the database are projections of. Three trees and an entry point,
named so that nothing collides with a corpus path:
|
one module: identity, provenance, the repair and its reason, the load order, imports, what imports it, the arcs below it, and every object, notification, textual convention and conformance statement with its OID, syntax, access, status and description |
|
one registrant: who the registry says holds the arc, who to report a problem to, and every module the corpus holds under it |
|
one node of the registration tree, and its children |
|
the entry point: what the corpus holds in figures, the most recently revised modules of each tier, and the module list |
mibcorpus --manifest=corpus.json --output-directory=output \
--emit=site --emit=json \
--oid-registry=smi-numbers.xml --oid-registry=pen-snapshot.csv \
--site-name="pysnmp/mibs"
Over a corpus of thousands of modules that is thousands of pages and a few hundred megabytes, in seconds rather than minutes on top of the build that produced the corpus.
What the entry point states¶
browse/ leads with the module count, then a tile per figure the build
already computed: modules per tier, enterprise arcs registered under, nodes in
the arc index, objects, notifications and textual conventions defined, modules
served with a patch applied to the publisher’s text, and modules whose import
closure names something the corpus does not hold. A figure of zero gets no
tile, so a build given no arc registry states nothing about arcs rather than
stating none.
Under the figures, one table per tier – standard, Internet-Draft, vendor – gives the most recently revised modules of that tier, by the LAST-UPDATED or REVISION date the module itself carries. Split by tier because a corpus of thousands of vendor modules and a few hundred standard ones has no recent standard revision in a single list, and the standard modules are what a reader resolving an OID is looking at.
What the distribution changed, beside what the publishers did¶
Every date in a MIB is its publisher’s, so those tables answer what has the industry published lately and cannot answer what has this distribution done lately. A module taken today may carry a revision from decades back, and a module corrected today carries whatever date its publisher last set.
That second date is a fact about a distribution rather than about a MIB, so it
arrives from one. site.changed names a JSON file of module to
YYYY-MM-DD and what to call it:
{
"site": {
"changed": {
"label": "Added or updated here",
"dates": "output/module-dates.json"
}
}
}
{"IF-MIB": "2026-09-17", "CISCO-IPMCAST-MIB": "2026-04-02"}
Given one, the entry point offers both orderings as tabs – the publishers’ revision dates and the distribution’s own – and a reader switches between them without a fetch and without a script: each tab is a label pointing at a radio, and the stylesheet shows the panel whose radio is checked. Both panels are in the markup either way, so a crawler reads all of it and a reader with no CSS gets the two lists one after the other under their own headings.
How a distribution knows the date is its own business. pysnmp/mibs reads the commit that last touched each module’s file, which is close enough to “when we published it” for a repository that commits what it publishes.
A module the file does not name has no such date and is listed under none, which is what lets a distribution date the part of the corpus it tracks and stay quiet about the rest – pysnmp/mibs tracks the modules in its own tree and takes the standard ones from pysmi, so the standard tier has no such date and gets no such table. The note under each table counts the pool it came from for that reason – “of the vendor modules this distribution tracks” rather than “of the ones this corpus holds”, which over a corpus whose standard tier comes from elsewhere are different numbers and different claims.
A date the lists cannot sort by – anything but YYYY-MM-DD – fails the
build rather than reaching a page, and so does a named file that will not
read. A site that quietly published an empty list here would state that the
distribution has changed nothing, which is a different claim from “this build
was not told”.
Every figure is a sum over the module pages the build renders, taken as it renders them, so the entry point costs no second pass over the corpus. The figures are in the bytes like everything else here: the front page is the first thing a crawler reads.
Where the repairs come from¶
A module page says whether the text served here differs from the publisher’s,
and why. That panel is filled by --patch-directory:
mibcorpus --manifest=corpus.json --output-directory=output \
--emit=site --patch-directory=scripts/mib-patches
The directory is read recursively and the file name before .patch is the
module, wherever in the tree it sits – a corpus large enough to file its
repairs per vendor keeps scripts/mib-patches/cisco/CISCO-IPMCAST-MIB.patch
and this reads it. Two files naming one module is an error: two repairs for
one module are two different opinions, and applying whichever the filesystem
offered second is how a corpus comes to publish text nobody chose.
Nothing is applied. The sources are read exactly as they stand; this is a description of a repair already in them, which is the arrangement The mibpatch tool describes – the checked-in text is the publisher’s text plus the patch, so the patch is the record of the difference rather than a step in the build.
Both halves of the header reach the page. The Defect: lines become links
into whichever catalogue the patch cites – Defects a patch repairs for pysmi’s own
identifiers, a downstream catalogue for a downstream corpus’s – and the note
under them stays with the diff, because the catalogue says what the defect is
and only the note says why this module has it.
Why the generator is here¶
The site is a function of a corpus, so an enterprise pointing mibcorpus at
its own manifest – a private collection, a downstream project vendoring
additions – gets the same site over its own modules. The alternative is a
second implementation in whichever repository publishes the corpus, which is
the shape pysnmp/mibs#365 removed: that repository’s compiler, dependency
resolver and OID indexer were each a second implementation of PySMI’s, and
each had drifted.
The module set is the corpus¶
Pages are written for what the manifest declares and nothing else. A directory
sitting beside the sources is not a namespace, so it is not in the corpus and
gets no pages; and a namespace declared for resolution only – publish
false, which is how a compact corpus resolves against the standard modules
without carrying them – contributes none either.
Everything is in the bytes¶
No page fetches anything: not the object table, not the description, not the navigation. #284 measured the alternative and it costs the same on disk, while being unreadable to the AI crawlers that do not run JavaScript – to those, a client-rendered object table is a page about a MIB module that does not say what the module defines.
Links are relative to the site root, so a site published under a path on a project host works without being told where it lives, and so does one opened from a local directory.
The page count is bounded¶
Three rules keep it that way, and each is its own module:
the OID tree stops at the modules, which
pysmi.corpus.pagesdecides. An arc at a module’s anchor is that module and resolves to its page; an arc below one is an object the module page already renders in context.long lists split by range key rather than page number (Splitting a long list page), so adding one module does not renumber every page after it.
page-sizesets how long a list gets first, as one number for every list or as an object naming them –{"browse": 500, "entity": 200}. The module list is the site’s front door and wants few pages; a registrant’s list is reached by somebody already narrowed to one vendor, and the largest vendors hold enough modules that the same size gives them several pages of 500 links each.the list of modules importing a given one is capped. It is the one list on a module page with no natural bound: a large fraction of a corpus imports
IF-MIB, and nearly all of it importsSNMPv2-SMI. The count is the fact worth stating.
No page is an orphan. The case that breaks this is a structural arc whose parent is a module anchor: the parent has no arc page, so only the module page can link down to it, and it does – see Under this module’s arcs.
The crawl surface¶
Declaring a base-url says this build is producing a distribution site
rather than a subtree somebody else will assemble, and the whole crawl surface
is written with it: a canonical link and a meta description per page,
JSON-LD, sitemap.xml as an index, robots.txt and llms.txt. Without
it there is nothing to put in a canonical link, and guessing an origin would
publish a site claiming to live somewhere it does not.
data-url is the second origin, for a distribution that serves its pages
and its downloads from two hosts. This one does: a page URL is a directory
that a host resolves to index.html, and object storage serves the key it
is given, so the pages and the files cannot share an origin without something
on the serving path rewriting every request. Only the llms.txt bulk links
read it. A canonical link and a sitemap entry describe a page, and a page is
always on base-url. Omitted, one origin serves both.
{
"site": {
"base-url": "https://mibsdepot.com",
"data-url": "https://data.mibsdepot.com",
"name": "MIBs Depot",
"description": "SNMP MIB modules from their publishers.",
"crawl": {
"*": {"disallow": ["asn1", "json", "index-v2", "core-db"]},
"ClaudeBot": {"allow": ["asn1", "json", "index-v2"]}
}
}
}
A crawl policy names artifacts, not paths. A distribution saying “keep
search engines out of the raw JSON” should not have to know whether that tree
is called json or something this release renamed. An artifact the policy
names that the build did not produce is skipped rather than written: a rule
about a tree that is not there would read as though the tree existed.
The policy is per-agent because a search engine and an agent want opposite
things from the same tree. To the first, the hundreds of megabytes of
asn1/ and json/ are crawl budget spent on files with no indexing
value; to the second they are the point, and llms.txt sends it there
deliberately.
The agent list is configuration because crawler names change faster than
releases.
llms.txt is a convention rather than a mechanism – no major AI crawler is
documented as consuming it. It is emitted and nothing depends on it. What
reaches an AI crawler is server-rendered HTML and a robots.txt that admits
it.
PySMI cannot decide where robots.txt lands. On GitHub Pages a project site cannot serve its own: crawlers read it only from the host root, which belongs to a different repository. So the file is produced here and the deployment places it.
lastmod is a date the content states¶
A corpus rebuilt on every push that stamps every page with today’s date
teaches a crawler the field is noise. A module page’s lastmod is the
module’s own newest REVISION or LAST-UPDATED, which is the date its
content last changed; a page listing modules takes the newest among them.
So no build clock reaches a sitemap, and two builds of one corpus produce
the same one. Over thousands of URLs the dates are the publishers’ own, and
today’s is not among them. A page describing something with no
readable date is listed without a lastmod at all – which says “I do not
know”, where the build date would say something false.
A stamp shaped like a date and not one is dropped rather than ranked, for the
reason the index ranker gives: HPR-MIB carries 970514000000Z, month
14, which compares above every real date there will ever be.
What the JSON-LD says, and what it costs¶
A MIB module is a published artifact with an identifier, a publisher and a
revision date, which defines a vocabulary of named terms – DefinedTermSet
crossed with Dataset. The identifiers are the OIDs, which is the
point: an OID is a globally unique identifier that already exists, and a
consumer holding one should be able to match it without reading prose.
It is not free. Enumerating every definition a corpus holds adds about 40% to
the site. A first cut also carried each term’s description and an
inDefinedTermSet back-reference, which took it half again beyond that;
both are gone,
the descriptions because they are already in the HTML the JSON-LD sits in, and
the back-reference because nesting under hasDefinedTerm already says a term
belongs to the set.
Theming¶
--site-template and --site-stylesheet replace the page frame and the
stylesheet, so a distribution publishing this beside its own documentation
makes it look like the rest of that documentation without forking the
generator. $name substitution, and an unknown placeholder renders as
itself rather than raising partway through a build of thousands of pages –
a page with $oops on it is something somebody sees.
A named file that cannot be read is refused rather than ignored: falling back would publish a whole site in the wrong skin and say nothing.
What a replacement stylesheet reaches¶
The definition tables carry no element inside a cell. A corpus holds hundreds of thousands of definitions, and a wrapper per cell was 61 bytes a row over them – a quarter of the module pages – so the cells hold their text and the stylesheet reaches them by position:
selector |
the column |
|---|---|
|
a definition table. |
|
the five-column shape: Name, OID, Syntax, Access,
Status. Without |
|
the descriptor. |
|
the OID. |
|
STATUS, in either shape. |
|
the macro a definition came from, and its DESCRIPTION where the build carries texts. The one element still written inside a cell, because it is a block under the name rather than a column. |
Each row carries the descriptor as its id, so mib/IF-MIB/#ifTable is a
link to one definition. Style :target to show which one was asked for.
Splitting a long list page¶
A site rendering a corpus has lists too long for one page: every module it
holds, every module one registrant published, the children of a wide OID node.
pysmi.corpus.buckets decides what the pieces are called.
Not first letters. A to Z is the obvious index and it fails on MIB names,
which are dominated by vendor prefixes rather than spread across the alphabet.
Over a vendor corpus the letter C takes a third of the modules by itself,
on the strength of CISCO- alone, and most letters take almost none.
Lengthening the prefix does not rescue it: inside a registrant essentially
every name shares one, so no fixed length gives even buckets, and the length
that would work differs between the global list and one registrant.
--emit=report states what a given corpus splits into.
Not page numbers. page/4/ is stable only while the list is. Adding one
module shifts the contents of every later page, so every already-crawled URL
past the insertion point serves different content and reports a fresh
lastmod. A range key disturbs only the bucket the new entry lands in.
A range key, derived. The sorted list is partitioned into equal-count runs of the page size, and each run is labelled with the shortest prefix distinguishing its ends from its neighbours’ – so the label length falls out of the local density:
AT..CISCO-DIAMETER-SG-C 200
CISCO-DIAMETER-SG-M..CISCO-HC 200
CISCO-HE..CISCO-LICENSE-MG 200
CISCO-LICENSE-MI..CISCO-PRI 200
CISCO-PRO..CISCO-TM 200
CISCO-TN..CISCO-WDS-IDS-C 200
CISCO-WDS-IDS-M..RP 153
The separator is .. rather than an en dash: an en dash percent-encodes to
%E2%80%93, which works and is a needless hazard in a string that crawlers,
server logs, shell history, spreadsheets and copy-paste all handle. A plain
- is ambiguous in names full of hyphens. .. occurs in neither a module
name nor an arc number.
The sort is case-sensitive byte order, which is what the labels imply; a display sort that differed would put entries in buckets whose range excludes them. A wide OID node’s children sort numerically instead, and take their numbers whole as labels – the shortest distinguishing prefix of a decimal number is not a number.
Four things the generator needs beyond the split:
locateWhich bucket a name belongs to, including a name the list did not have when the buckets were built. This is what the browser-side navigation resolves against.
successorsA bucket that outgrows the page size splits, which renames a key – the one case where a range URL moves. Static hosting serves no redirects, so the retired key is emitted as a small page carrying a canonical link and a meta refresh to whatever now covers it.
retirednames those keys.abbreviateEvery bucket is listed on every bucket page, so any bucket is one hop from any other and crawl depth does not grow with the corpus. That works only if the strip fits, and a label runs to 27 characters. The URL keeps the whole label; the rendering is clipped.
- A single bucket
A list no longer than the page size comes back as one bucket with no key: it keeps its unbucketed URL and renders no key strip.
How the ASN.1 tree is named¶
The names in asn1/ are a contract rather than an implementation detail.
pysnmp resolves a dependency by substituting a module name into a source
template, and splunk-connect-for-snmp runs that path in production:
addMibCompiler() against https://.../asn1/@mib@, compiling ASN.1 per
MIB on demand. There is no directory listing and no second guess – the name
is the whole of the request.
So:
A file is named for the module it defines, taken from the
DEFINITIONS ::= BEGINheader, not from the file it was read from. A vendor shippingTEST-TC-MIBintc.mibpublishes it asasn1/TEST-TC-MIB.The name is the module’s own spelling, not a case variant of it or of the source file’s.
There is no extension.
@mib@is replaced with a bare module name, so anything appended makes the file unreachable.One name per module. A source file defining two modules is published under both names, since either one may be what a consumer asks for.
A module the corpus only resolves against is not there at all. A namespace declared
"publish": falsereaches no output tree.Only what compiled is there. A module the build could not compile is left out, and so is anything that imports it. Serving its ASN.1 buys a consumer nothing – it fetches the text, compiles it with the same pysmi and fails where this build failed, one round trip later.
asn1/,standard.txt, both indexes andcore.dbtherefore carry one module set rather than several. What was dropped is named inreport.json, with the error that dropped it.
tests/test_corpus_asn1_contract.py pins this, and the consumer layer
compiles a module out of an emitted tree over the @mib@ template itself.
Why it is deterministic¶
The shell pipeline this replaces – scripts/vendor.sh in pysnmp/mibs –
ran mibdump once per vendor directory under GNU parallel, each
invocation reading output/asn1 as a dependency source while every other
invocation copied its own sources into that same directory on the way out.
Four properties made its output depend on something other than its input, and
this tool answers each of them (pysnmp/pysmi#182):
Nothing is written into a directory that is read. Every namespace is a
source for the whole build, so a module resolves the same way whatever order
the namespaces are processed in, and the driver refuses to start if an output
directory is, contains or is contained by a source. Cross-namespace resolution
was the sharp end of this: fifteen of the modules the old build could only
resolve through output/asn1 live in a different vendor directory from
the one importing them, so whether they resolved at all depended on which
parallel job finished first.
Nothing is fetched. Sources are local directories and packages, and no
borrowers are configured. The old build passed
--mib-source=https://pysnmp.github.io/mibs/asn1/@mib@, resolving missing
dependencies from its own last publish – so the corpus was not reproducible
from the repository alone, and a bad publish perpetuated itself. Measured
against the published corpus, dropping that source costs nothing: everything
it serves that the build uses is already in the tree.
One error policy. Every output format compiles the same module set and
keeps going past a defective module, and the run reports what failed. The old
build set --ignore-errors on the jsondoc pass alone, and
MibCompiler.compile() was all-or-nothing at the time, so a single
defective module removed its entire namespace from notexts/ and texts/
while json/ kept it: 41% of the vendor corpus was published with JSON
output and no Python. A corpus of MIBs nobody controls will always carry some
that do not compile; what it owes is an inventory of them, which
report.json is.
The index is decided by rule. pysmi.corpus.index ranks the
modules defining an OID and takes the best one, term by term:
a module that still defines something current, over one whose objects are all obsolete;
tier – standard, then Internet-Draft, then vendor;
the module that registers an arc with its MODULE-IDENTITY, over one that merely names it with an OBJECT-IDENTITY;
the newest MODULE-IDENTITY revision, read as a date by
normalise_revision– so a stamp that is not one, likeHPR-MIB’s970514000000Z, is refused rather than sorting above every date there will ever be;the later RFC, then the module name, which settles the SMI root arcs and makes the rule total.
gen_index() is unchanged and
still records the complete fact – every module defining a given OID. This is
the projection of it a consumer that has to load exactly one module needs.
Two corpora from one source set¶
"publish": false is what lets one set of sources produce two corpora that
differ in what they carry rather than in how they were built.
The full corpus carries the standard modules, because its consumers fetch
them from it: splunk-connect-for-snmp resolves asn1/@mib@ against the
published tree for every module it meets, standard ones included. This is what
pysnmp/mibs serves today and what must keep working unchanged.
{"version": 1, "namespaces": [
{"name": "standard", "source": "package:pysmi.mibs.asn1", "tier": "standard"},
{"include": "src/vendor/*", "tier": "vendor"}]}
The compact corpus is the same source set with the standard namespace declared unpublished. It carries only what a runtime that already has the standard modules does not have – pysmi bundles 210 of them and its wheel ships their compiled form, so restating them costs size and says nothing new:
{"version": 1, "namespaces": [
{"name": "standard", "source": "package:pysmi.mibs.asn1", "tier": "standard",
"publish": false},
{"include": "src/vendor/*", "tier": "vendor"}]}
Leaving the standard namespace out altogether is not the same thing: every
vendor module that imports SNMPv2-SMI would fail to compile. The difference
between the two manifests is what is carried, not what is resolved, which is
why the compact corpus is a subset of the full one and not a second rendering
of it – each module it carries is byte-identical to the same module in the
full corpus, and its index covers exactly the modules it carries, so a runtime
can merge it with whatever it already knows about the standard tree.
A build for the compact corpus normally asks for a subset of the artifacts too,
since standard.txt and the frozen legacy index are compatibility surfaces
of the published one:
$ mibcorpus --manifest=corpus-compact.json --output-directory=compact \
--emit=asn1 --emit=notexts --emit=texts --emit=json \
--emit=index-v2 --emit=report
Packaging is the caller’s: the driver writes a directory, and what a release
does with it – an archive, a container image built from scratch for a
runtime to mount – belongs to the repository publishing it, not to the
compiler driver. What the driver owes such a consumer is that the directory is
the same bytes every time, which is the property the tests above pin.
One driver, both corpora¶
pysmi’s own wheel is built with this driver. hatch_build.py compiles the
486 bundled ASN.1 modules into pysmi/mibs/pysnmp/ while the wheel is
built, and it does that by running CorpusDriver
over a corpus of one namespace – the bundle – rather than by keeping a
compile loop of its own. A difference between how pysmi builds the base layer
and how pysnmp/mibs builds on top of it would be a difference nobody chose.
The one thing the wheel configures differently is the stub list. A corpus
published beside pysnmp does not restate what pysnmp already implements, so
the default is mibdump’s: the base MIBs. The wheel is that base layer,
and only three of those modules genuinely cannot be generated – the ones the
generator emits an unconditional import from, which would become an import
from itself. The other 11 generate perfectly well and consumers want them
(pysnmp/pysmi#196), so the wheel passes the narrower list through the
stubs argument.
Compatibility¶
--frozen-index is what keeps a published index answering what it has
always answered. The legacy index is not regenerated: it replays the snapshot,
drops rows naming a module the corpus no longer carries, and adds OIDs the
snapshot never had. Corrections land in index-v2.csv.