Building a vendor extension¶
This guide builds a new vendor extension from scratch. The example vendor is
fake_inst, with two operations: a real-time fake_inst.beep(bus, duration)
and a host-side-only fake_inst.set_threshold(bus, value). The same template
applies to any vendor package.
Every Python snippet below is a source file inside the vendor package, apart
from the end-user script under
Combining with other vendors. A vendor
package is ordinary external code, so it reaches QProgram through
import qprogram as qp and refers to its own modules by their real dotted
paths. The in-tree extension guides (Adding an operation,
Adding a waveform) look different because a module inside
src/qprogram/ cannot import qprogram without closing an import cycle.
For a working reference, the test suite's tests/_dummy_vendor.py implements
steps 1 through 5 in a single module: operations (one of them a measurement
op), a namespace, a mixin, a pre-combined QProgram, capability tokens, and a
profile. Two things on this page it does not cover. It is not an installed
distribution, so it declares no qprogram.vendors entry point;
tests/test_vendor_discovery.py exercises step 6 against a stub one instead.
And it registers no vendor block; tests/test_registry.py and
tests/test_writer.py cover that.
Package layout¶
A vendor extension is its own package, in its own repository, depending on
qprogram:
qprogram-fakeinst/
├── pyproject.toml
├── src/qprogram_fakeinst/
│ ├── __init__.py # registration + pre-combined QProgram
│ ├── operations.py # Operation subclasses (AST nodes)
│ ├── namespace.py # FakeInstNamespace (typed methods)
│ ├── mixin.py # FakeInstMixin (typed @property)
│ └── profiles.py # capability tokens + Profile bundles
└── tests/
├── conftest.py
├── test_operations.py
├── test_namespace.py
├── test_mixin.py
├── test_registration.py
├── test_serialization.py
└── test_profile.py
Five source files, in increasing order of glue:
operations.pydefines the AST node classes.namespace.pydefines the typed methods.mixin.pydefines the typed@property.profiles.pydeclares the vendor's capability tokens and ships one or moreProfilebundles.__init__.pyregisters everything and ships a pre-combinedQProgram.
Step 1: the operation classes¶
# qprogram-fakeinst/src/qprogram_fakeinst/operations.py
from __future__ import annotations
from typing import ClassVar
import qprogram as qp
class Beep(qp.operations.Operation):
"""Real-time beep on a single bus."""
BUS_ATTRS: ClassVar[tuple[str, ...]] = ("bus",)
def __init__(self, bus: str, duration: int | qp.Expression) -> None:
self.bus = bus
self.duration = duration
def required_capabilities(self) -> set[str]:
return {"vendor.fake_inst.beep"} | qp.protocol.expression_tokens(
self.duration,
)
class SetThreshold(qp.operations.Operation):
"""Host-side-only threshold setter (no sequencer footprint)."""
BUS_ATTRS: ClassVar[tuple[str, ...]] = ("bus",)
def __init__(self, bus: str, value: float | qp.Expression) -> None:
self.bus = bus
self.value = value
def required_capabilities(self) -> set[str]:
return {"vendor.fake_inst.set_threshold"} | qp.protocol.expression_tokens(
self.value,
)
Four class attributes control how the validator sees an operation, and all four have defaults that suit the common case:
| Attribute | Default | What it controls |
|---|---|---|
BUS_ATTRS |
("bus",) |
Which __init__ parameters hold bus references. buses() reads these, and rebind rewrites them. Declare it when the bus lives under another name or the op touches several buses. |
WAVEFORM_ATTRS |
() |
Which parameters carry waveform values, for waveforms(). None values from optional parameters are skipped. |
BROADCASTS_WHEN_NO_BUS |
False |
When True and BUS_ATTRS resolve to no buses, the op is routed across every bus in the program rather than the default-bus slot. Core Sync(targets=None) is the case this exists for. |
AFFECTS_AVERAGING |
False |
Whether the op gates the execution domain of an enclosing average block. Only ops producing the results an average accumulates should set it; MeasurementOperation sets it True. |
The base Operation class supplies variables(), buses(), waveforms(),
walk(), and structural equality and hashing, so a subclass normally overrides
nothing but required_capabilities(). Because equality and hashing are
structural, an instance must not be mutated after it has been used as a set
member or dict key; QProgram.rebind respects this by rewriting a fresh
deepcopy.
The constructor signature is part of the wire format, not an implementation
detail. The default serializer walks inspect.signature(cls.__init__): a
parameter with no default is emitted positionally in declaration order, a
parameter with a default is emitted as name=value only when the stored value
differs from that default, and a parameter with no matching attribute on the
instance is skipped. The parser binds the tokens back by the same names and
constructs the class with keyword arguments only. Three consequences follow.
Each parameter name must match the attribute that holds its value, or the value
is dropped on write. Reordering required parameters silently changes what older
files mean. Renaming a parameter breaks every file that used it as a keyword,
which is a major version bump.
Only the value types the writer knows how to render can appear in an operation
attribute: expressions, waveforms, bus references, measurement handles, sweep
sources, strings, booleans, None, numbers including numpy scalars, lists and
tuples and 1-D arrays, and string-keyed dicts. Anything else raises
SerializationError rather than being coerced into a token the parser would
mis-type on reload.
If the operation produces a measurement, subclass MeasurementOperation
(reachable as qp.operations.operation.MeasurementOperation) instead. Such a
class must set self.handle to the MeasurementHandle it is given and
self.fields to the result of normalize_fields(...), which canonically sorts
and deduplicates the requested field names and rejects a name that has no
measure.fields.<name> token registered. The base
MeasurementOperation.required_capabilities() returns one
measure.fields.<name> token per requested field, so a subclass unions that
with its own identity token via super(). The namespace method calls
_append_measurement rather than _append, and registration needs the
measurement-aware serializer and parser described in step 5.
Step 2: the typed namespace¶
# qprogram-fakeinst/src/qprogram_fakeinst/namespace.py
from __future__ import annotations
import qprogram as qp
from qprogram_fakeinst.operations import Beep, SetThreshold
class FakeInstNamespace(qp.VendorNamespace):
"""Typed methods for the fake_inst vendor."""
def beep(self, bus: str, duration: int | qp.Expression) -> None:
"""Beep on a bus for the given duration in ns."""
self._append(Beep(bus=bus, duration=duration))
def set_threshold(self, bus: str, value: float | qp.Expression) -> None:
"""Set the discrimination threshold (host-side-only)."""
self._append(SetThreshold(bus=bus, value=value))
VendorNamespace._append does two things. It walks
vars(operation).values(), runs every BusRef it finds through the program's
_validate_bus, and does the same for BusRef items one level inside a list,
so a vendor op cannot smuggle in a bus from a different BusSchema. Then it
appends the operation to the program's active block. The walk is shallow: plain
strings are never validated, and a BusRef hidden inside a dict or a tuple is
not reached, so an operation that needs a bus checked should hold it as a plain
attribute or a list.
_append_measurement(op_cls, *, bus, name=None, **kwargs) is the measurement
counterpart. It allocates the handle name from the program's per-bus counter,
the same counter QProgram.measure uses, so vendor and core measurements on
one bus never collide; constructs op_cls(bus=bus, handle=handle, **kwargs);
appends the result through _append; and returns the handle. The measurement
operation's __init__ therefore has to accept bus and handle as keywords.
A name that is empty, not a string, or already taken raises ValidationError
at the call site.
Step 3: the mixin¶
# qprogram-fakeinst/src/qprogram_fakeinst/mixin.py
from __future__ import annotations
from qprogram_fakeinst.namespace import FakeInstNamespace
class FakeInstMixin:
@property
def fake_inst(self) -> FakeInstNamespace:
try:
return object.__getattribute__(self, "_fake_inst_ns")
except AttributeError:
pass
ns = FakeInstNamespace(self)
object.__setattr__(self, "_fake_inst_ns", ns)
return ns
The mixin exists for static typing and IDE autocomplete. Without it,
program.fake_inst still resolves, because QProgram.__getattr__ looks the
name up in the vendor registry and caches the namespace on the instance under
the vendor name itself. With the mixin, the property is found by normal
attribute lookup and __getattr__ never runs, which is why the cache goes into
a separate _fake_inst_ns slot.
That interaction decides where registration happens. register_vendor refuses
a name that hasattr finds on the class it is called on, so calling it on the
pre-combined subclass, whose mixin already defines the property, fails with
vendor name 'fake_inst' collides with a QProgram attribute; the namespace
would be unreachable because normal attribute lookup wins over vendor
dispatch. Register on the base QProgram; the registry is a class-level dict,
so the entry is visible from every subclass.
Step 4: capability tokens and a profile¶
A vendor extension ships a capability profile: a named bundle listing which DSL features the backend supports, the numeric limits the hardware imposes, and the predicates that check context-sensitive constraints. The validator consumes the profile to answer whether a given program can run on this platform.
# qprogram-fakeinst/src/qprogram_fakeinst/profiles.py
from __future__ import annotations
from typing import TYPE_CHECKING
import qprogram as qp
from qprogram_fakeinst.operations import Beep
if TYPE_CHECKING:
from collections.abc import Iterable
# Register the vendor's capability tokens *before* the Profile is
# constructed: `Profile.__post_init__` rejects unknown tokens.
qp.register_capability_tokens(
"vendor.fake_inst.beep",
"vendor.fake_inst.set_threshold",
)
def _reject_zero_duration_beep(
node: qp.operations.Operation | qp.blocks.Block,
ctx: qp.ValidationContext, # noqa: ARG001
) -> Iterable[qp.Diagnostic]:
"""Reject a fake_inst.beep whose duration is zero."""
if isinstance(node, Beep) and isinstance(node.duration, int) and node.duration == 0:
yield qp.Diagnostic(
severity="error",
code="fake_inst.zero-beep",
message="Beep duration must be > 0 ns",
node=node,
)
FAKE_INST_DEFAULT_V1 = qp.Profile(
name="fake_inst-default-v1",
version=(0, 1, 0),
extends=None,
capabilities=frozenset(
{
# bus-touching ops the backend can run (block.* / expr.* / sweep.*
# live on the platform-level slot, see notes below).
"op.play",
"op.measure",
"op.wait",
"op.sync",
"op.set_frequency",
"op.set_phase",
"waveform.single",
"waveform.iq",
"waveform.alias",
"waveform.square",
"waveform.gaussian",
"waveform.iq_drag",
"waveform.iq_pair",
"measure.fields.iq",
# vendor ops
"vendor.fake_inst.beep",
"vendor.fake_inst.set_threshold",
}
),
limits={
"min_wait_duration_ns": 4, # per-Wait limits live on the bus slot
},
predicates=(_reject_zero_duration_beep,),
vendor_versions={"fake_inst": (0, 1, 0)},
)
def _register() -> None:
"""Idempotently register the profile on the global registry."""
qp.register_profile(FAKE_INST_DEFAULT_V1)
Profile is a frozen dataclass with four required fields, name, version
as a (major, minor, patch) tuple, extends, and capabilities, plus three
fields that default to empty: limits, predicates, and
vendor_versions. Its __post_init__ validates every capability token against
the global registry, so a typo fails at construction with
Unknown capability token(s): ['vendor.fake_inst.bep']. Register via
qprogram.protocol.register_capability_tokens before use. rather than surfacing
as a mysterious validation result later. register_profile is idempotent for an
equal profile, so an import-time side effect that runs twice is safe whether the
bundle is a module constant or built fresh each time, and raises
Profile 'fake_inst-default-v1' is already registered with different content
only when a profile with different content claims the name. Of an equal pair
the registry keeps the first object, so treat the profile you just registered as
possibly not the one resolve_profile returns.
Bus-touching ops, waveform tokens, and measure.fields.* all belong on a bus
profile, because the nodes that carry them route to a (bus, domain) slot.
Block, expression, and sweep tokens belong on the platform-level slot, which a
platform materializes from core qprogram-base-v1; see
Building CompilerCapabilities from a profile.
Core op.set_parameter and op.get_parameter are bus-touching but host-side
only, and their tokens are not in qprogram-base-v1, so a platform that
supports them opts them into a bus slot's host half explicitly.
Per-class waveform tokens (waveform.square, waveform.iq_drag, ...) refine
the channel-kind tokens waveform.single and waveform.iq. List a token for
every waveform class the compiler can lower. Omitting a token your backend can
actually run makes any program using it fail validation with one
missing-capability diagnostic per node, reading
'Play' requires capability 'waveform.iq_drag' which is not supported by
'fake_inst-default-v1' (rt).
The validator understands four limit keys: max_loop_nesting,
max_parallel_loops, and max_measurements are read from the platform slot,
and min_wait_duration_ns from the bus slot the Wait routes to. Other keys
are ignored, which lets a profile declare a limit an older validator has no
check for. Platform-level limits are applied through limit_overrides= when
the platform materializes its platform slot, not by listing them on a bus
profile where nothing reads them.
A predicate is a callable (node, ctx) -> Iterable[Diagnostic |
DomainConstraint], run against every visited node. Use one for a check that
depends on more than the node in front of it; the canonical example is "this
op's variable argument must be bound by a linear loop", which ctx can answer
and the node alone cannot. A predicate carried by both halves of a slot runs
once per (domain, bus) pair, so twice for a single-bus node and twice more
for each extra bus a multi-bus op touches. The validator discards duplicate
outputs, which is why a predicate has to be cheap and free of side effects.
Capability protocol internals has the full predicate
and ValidationContext reference.
For a tiered family of profiles (-base-v1, -adaptive-v1, ...) set
extends="<parent-name>". Capabilities and predicates accumulate parent to
child; limits inherit and may be overridden. Start with a single
<vendor>-default-v1 and split only when a real device demands it, because a
proliferation of near-identical profiles is the classic way this kind of
protocol becomes unusable.
Step 5: the __init__.py glue¶
# qprogram-fakeinst/src/qprogram_fakeinst/__init__.py
from importlib.metadata import PackageNotFoundError, version
import qprogram as qp
from qprogram_fakeinst.mixin import FakeInstMixin
from qprogram_fakeinst.namespace import FakeInstNamespace
from qprogram_fakeinst.operations import Beep, SetThreshold
from qprogram_fakeinst.profiles import (
FAKE_INST_DEFAULT_V1,
_register as _register_fake_inst_profile,
)
try:
__version__ = version("qprogram-fakeinst")
except PackageNotFoundError:
__version__ = "0.0.0"
# 1. Runtime namespace, registered on the base class.
qp.QProgram.register_vendor("fake_inst", FakeInstNamespace)
# 2. Protocol version (from pyproject.toml).
qp.register_vendor_version("fake_inst", __version__)
# 3. Operations with the .qp serializer.
qp.register_vendor_operation("fake_inst", "beep", Beep)
qp.register_vendor_operation("fake_inst", "set_threshold", SetThreshold)
# 4. Capability profile. (Tokens were registered by `profiles.py` at import.)
_register_fake_inst_profile()
# 5. Pre-combined typed QProgram.
class QProgram(FakeInstMixin, qp.QProgram):
pass
__all__ = [
"Beep",
"FAKE_INST_DEFAULT_V1",
"FakeInstMixin",
"FakeInstNamespace",
"QProgram",
"SetThreshold",
]
Four registration steps run on import, five calls since each operation is
registered on its own line, plus the capability-token registration that happens
as a side effect of importing profiles.py. Profile registration is a separate
call so the order stays explicit. The last piece, the qprogram.vendors entry
point in pyproject.toml, is what lets qp.loads()
trigger this whole import on demand, so a .qp file requiring the vendor loads
without an explicit import.
register_vendor(name, namespace_cls) rejects three kinds of name. A reserved
one, meaning any of the reserved keywords or the
"core" sentinel, raises vendor name 'core' is reserved (see
qprogram.RESERVED_KEYWORDS plus the 'core' sentinel); pick a different
namespace for this vendor extension. A name that collides with a QProgram
attribute, whether a method such as play, a public instance attribute
(label, description), or a mixin property already on the class, raises the
collision message from step 3. A name already held by a different namespace
class raises rather than replacing it, since silently taking over another
vendor's namespace would be a supply-chain hazard. Re-registering the same
class under the same name is a no-op.
register_vendor_version(vendor, version) takes a semver string with at least
integer major.minor; "0.1" and "0.1.0" are both accepted and the patch
component is informational. A version with fewer components raises vendor
version '1' must have at least major.minor components, and a non-integer
component raises vendor version '0.x' has non-integer major/minor
components. Reading the value from importlib.metadata keeps a single source
of truth in pyproject.toml, but note what the fallback does: when the package
is not installed as a distribution, __version__ becomes "0.0.0" and the
extension advertises major 0, minor 0, so any file written as
require fake_inst 0.1 is rejected as "minor version too old". Registering the
version is also what marks the vendor as active, which is the check
try_activate_vendor makes.
register_vendor_operation(vendor, name, cls, *, serialize=None, parse=None)
keys on (vendor, name). Re-registering the same class refreshes its
callbacks; a different class under a taken pair raises operation
'fake_inst.beep' is already registered to pkg.Beep; refusing to replace it with
other.Beep. A measurement operation passes the two callbacks from
qprogram.serialization._specs, measurement_op_serialize and
make_measurement_op_parse(cls), so the parser reconstructs the one canonical
MeasurementHandle instance that every MeasurementRef naming it shares.
Profile.vendor_versions records (major, minor, patch) tuples and is
informational. Only the string registered with register_vendor_version
decides whether a .qp file loads.
Step 6: pyproject.toml¶
The [project.entry-points."qprogram.vendors"] table is what makes the
extension discoverable without an import. When a .qp file declares
require fake_inst <ver> and the package is installed but not yet imported,
qp.loads(...) imports the module named here on demand, and its import-time
side effects run the registration steps above. The entry-point name is the
vendor namespace; the value is the module that self-registers. The group name
is exactly qprogram.vendors, and nothing else is scanned.
[project]
name = "qprogram-fakeinst"
version = "0.1.0"
description = "Fake-instrument vendor extension for QProgram"
requires-python = ">=3.11"
dependencies = ["qprogram>=0.1.0"]
[project.entry-points."qprogram.vendors"]
fake_inst = "qprogram_fakeinst"
[dependency-groups]
dev = [
"pytest>=9.0",
"pytest-cov>=7.0",
"pytest-mock>=3.15",
"ruff>=0.16",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
minversion = "9.0"
addopts = ["-ra", "--strict-markers", "--strict-config"]
testpaths = ["tests"]
filterwarnings = ["error"]
xfail_strict = true
[tool.coverage.run]
source = ["qprogram_fakeinst"]
branch = true
try_activate_vendor(vendor) is what performs the activation. It returns
True immediately when a protocol version is already registered, without
scanning anything. Otherwise it looks the vendor up in the entry-point map and
returns False when no installed distribution claims it, leaving the caller to
decide whether that is an error. When an entry point is found it calls
ep.load() and raises VendorActivationError in two cases: the import itself
failing, reported as vendor extension for 'fake_inst' is installed (entry
point 'qprogram_fakeinst') but failed to import: ..., and an import that
succeeds without registering a version, reported as ... imported from entry
point 'qprogram_fakeinst' but did not register a protocol version; the package
must call register_vendor_version('fake_inst', '<x.y.z>') on import. The
second is the failure mode of a package that ships the entry point but forgets
step 5.
The entry-point scan is memoized for the life of the process, so a
distribution installed after the first lookup stays invisible until
qp.serialization.registry.clear_vendor_discovery_cache() is called; tests
that inject entry points need that call in teardown. If two distributions
declare the same vendor name, the first one discovered wins, which makes the
outcome deterministic rather than correct.
The dependency on qprogram is a normal version constraint against the
published package. To develop against a local checkout of the core instead,
point uv at it for the duration:
That table only redirects local resolution with uv; the dependency the
package publishes stays the qprogram>=0.1.0 constraint above, so an
installing user always resolves the core from the index.
Step 7: tests¶
A vendor package's tests/ folder typically mirrors this layout:
test_operations.pycovers each Operation class: construction, introspection (buses(),waveforms(),variables()), structural equality, andrequired_capabilities()(instance-aware).test_namespace.pycovers each method on the namespace: it appends the right op, validates buses, and uses the right naming scheme for measurement ops.test_mixin.pycovers the mixin: returns aFakeInstNamespace, caches per instance, composes with multiple vendors.test_registration.pyconfirms the registration calls succeed.test_serialization.pyexercises every operation throughdumpsandloads, including therequireline. The default serializer walks__init__'s parameters and reads each value off the instance under the parameter's own name, skipping any parameter the instance has no attribute for, so a parameter renamed without renaming the attribute drops out of the written line and the reload fails withcannot construct 'Beep' from the given arguments.test_profile.pyconfirms the profile is registered, that representative programs validate clean, and that each predicate fires on the cases it should and stays quiet on the cases it should not.
Registration mutates process-global registries, so tests need an activate and
deactivate pair behind a fixture rather than an import side effect;
tests/_dummy_vendor.py in this repository shows the shape, popping each
registry entry it added.
How serialization works for vendor ops¶
The writer looks up each operation instance in the registry, by exact class
rather than by inheritance, to find its (vendor, name) pair, and emits
<vendor>.<name> followed by the arguments the constructor signature dictates.
Beep registered under ("fake_inst", "beep") writes as
fake_inst.beep "drive_q0" 100. A class that was never registered raises
Cannot serialize operation class 'Beep': it is not registered with the .qp
serializer. before anything is written.
The require lines come from the same lookup. The writer collects the vendors
referenced anywhere in the program body and in every fragment body, so an
operation reachable only through a Call still gets its line, then emits one
require <vendor> <major.minor> per vendor with the patch component truncated,
since compatibility is defined at major.minor. A vendor used in the program
with no registered version raises Cannot serialize: vendor 'fake_inst' is used
in the program but no version is registered.
A complete file for a two-operation program looks like this:
#!QProgram 1.0
require fake_inst 0.1
body:
fake_inst.beep "drive_q0" 100
fake_inst.set_threshold "readout_q0" 0.5
The parser reverses the lookup. It reads the require lines that immediately
follow the header, checks each against the installed extension, and then
resolves every fake_inst.<op> in the body through ("fake_inst", op). A
require line further down the file is not treated as a declaration, because
the scan skips blank lines and stops at the first other line that is not one.
No writer or parser changes are needed for a new vendor operation; both sides
drive themselves from the registry.
Adding a control-flow block¶
An extension is not limited to operations. It can add a block, a container with
its own header keyword, by subclassing Block and registering it with
register_vendor_block, in a sixth module:
# qprogram-fakeinst/src/qprogram_fakeinst/blocks.py
from typing import ClassVar
import qprogram as qp
class Forever(qp.blocks.Block):
"""Repeat the body until the host stops it."""
REPEATS: ClassVar[bool] = True # occupies a repetition level
def required_capabilities(self) -> set[str]:
return {"vendor.fake_inst.forever"}
qp.register_vendor_block("fake_inst", "forever", Forever)
The wire form is fake_inst.forever: followed by an indented suite:
The block registry keys on the qualified keyword, so a vendor block can reuse a
core keyword (fake_inst.block and core block coexist) and can never collide
with one. Four things to get right:
REPEATS. Set itTrueif the block re-runs its body. That is what makes it count towardmax_loop_nesting: the validator reads the marker rather than testing for the concrete core loop classes, so a vendor block counts toward the limit without a change to the core. Leave itFalse, the default, for a block that merely groups.- Capability slot. Blocks route to the platform slot, not a per-bus one,
so the token goes on your platform-slot profile. A block whose token is in
neither half of that slot draws an
empty-domaindiagnostic saying the platform slot supports none of the domains for the block's required tokens. A token present only inhostmakes the block host-only, so an op-child that can run only in real time leaves it with no executable domain:own slot supports ['host'] but op-children consensus is ['rt']. - Opening it. Add a namespace method returning a context manager that
appends the block and pushes it onto the program's block stack, mirroring
core's
block()andaverage():program._append_to_active(blk)on entry,program._block_stack.append(blk), and a matchingpop()on exit.VendorNamespace._appendcovers operations only; there is no block equivalent. register_vendor_block, notregister_block. The vendor wrapper records the vendor on the spec, which is what puts arequire fake_inst 0.1line in any file containing the block, even one with no vendor operations. Without it the file would not auto-activate your package on load.
A vendor block is deliberately not a Sweep: it binds no variable, reports no
num_iterations(), cannot compose under |, and adds no result dimension.
Those are sweep properties, not repetition properties.
Versioning: major and minor¶
The protocol version (require fake_inst 0.1) describes the operation set, not
the package release. Bump the minor when you add operations or add
backwards-compatible keyword arguments. Bump the major when you remove or
rename an operation, rename or reorder a constructor parameter, or change
semantics in a way that would break older files.
The parser enforces exactly two conditions, on major.minor with the patch component ignored: the majors must match, and the installed minor must be at least the file's. The two failures read:
Line 3: file requires fake_inst 1.0 (major 1); installed fake_inst is 0.1.0 (major 0) — major versions must match
Line 3: file requires fake_inst 0.9 or compatible; installed fake_inst is 0.1.0 — minor version too old
An existing file therefore keeps parsing as long as you only add to the operation set on the same major, and a newer extension on that major always reads older files.
When the vendor is not registered at all, the message depends on whether
auto-activation is on. The default suggests installing the package that
declares the entry point; under qp.loads(..., auto_activate=False) it says
auto-activation is disabled and names the import to add. Both are ParseError
carrying the line number of the require line.
Combining with other vendors¶
End users combine multiple vendors with multiple inheritance:
import qprogram as qp
from qprogram_fakeinst import FakeInstMixin
from qprogram_othervendor import OtherVendorMixin
class QProgram(OtherVendorMixin, FakeInstMixin, qp.QProgram):
pass
A platform library that already depends on several vendor extensions usually
provides this combined class so end users do not write the inheritance
themselves. The dynamic program.fake_inst.* resolution works either way; the
mixin exists for IDE autocomplete.
Failure modes¶
The registries are global and populated at import time, which puts most of the mistakes in a vendor package on the path between installed and usable.
| Mistake | What you see |
|---|---|
No qprogram.vendors entry point |
A .qp file using fake_inst.* loads only in a process that already imported the package. Elsewhere: no matching extension is registered in this environment |
Entry point present, no register_vendor_version call |
VendorActivationError: ... imported from entry point 'qprogram_fakeinst' but did not register a protocol version |
register_vendor called on the pre-combined class |
ValueError: vendor name 'fake_inst' collides with a QProgram attribute |
| Package not installed as a distribution | __version__ falls back to "0.0.0", so every require fake_inst 0.1 fails as "minor version too old" |
| Operation class not registered | SerializationError: Cannot serialize operation class 'Beep': it is not registered with the .qp serializer. |
| Constructor parameter renamed without a major bump | Older files fail to parse, or bind the value to the wrong parameter |
| Constructor parameter name differs from the attribute | The value is silently omitted from the written file |
| Capability token missing from the profile | One missing-capability diagnostic per node using it |
REPEATS left False on a repeating block |
The block does not count toward max_loop_nesting, so a program that exceeds the hardware's loop depth validates clean |
register_block used instead of register_vendor_block |
No require line for a file whose only vendor content is the block, so it does not auto-activate the package |
Two more, which the registries cannot catch for you. Do not re-export
qp.QProgram unchanged as your package's QProgram: the pre-combined class
should subclass the mixin, or users lose the static typing that is the mixin's
only purpose. And keep bus attributes plain: QProgram.rebind re-resolves an
operation's buses by rewriting the attributes named in BUS_ATTRS, and it can
only do that when they hold str or BusRef values, or a list of those,
directly.
Name the distribution to install in your README. The parser's error names the
vendor namespace, fake_inst, which is not necessarily the package name a
reader has to type into pip install.
Related pages¶
The .qp format is the grammar the writer and
parser implement on both sides of the registry lookup above.