The Python client API¶
What opensysml costs, the typed classes it can generate for a model, and the modules behind
them. Using the client as a task is guide chapter 9.
Latency¶
Measured on this repo's benchmark (python/scripts/bench_latency.py, 8-core
x86-64 Linux, loopback, 20 part definitions / 808 bytes, 200 iterations, warm
Connection):
| operation | p50 | p95 | p99 |
|---|---|---|---|
load / load_from_content, cache miss |
35 ms | 56 ms | 60 ms |
load / load_from_content, cache hit |
0.5 ms | 1.0 ms | 1.0 ms |
eval("2 + 2") on a cached model |
0.4 ms | 1.0 ms | 1.2 ms |
convert sysml → sysml |
0.7 ms | 1.2 ms | 2.0 ms |
convert sysml → ttl |
1.1 ms | 1.4 ms | 1.5 ms |
convert ttl → sysml |
1.2 ms | 1.5 ms | 2.7 ms |
Reproduce with make build-grpc && bin/sysml-grpc and
python python/scripts/bench_latency.py --iterations 200.
The shape matters more than the absolute numbers: a parse costs two orders of magnitude more than a query on the parsed result, because it loads the standard library into a fresh symbol index and runs the semantic passes. Everything else here is around a millisecond, of which the RPC itself — protobuf encode/decode plus loopback — is a few hundred microseconds.
Starting the service¶
A Connection that starts sysml-grpc itself probes the service immediately and
then on a doubling backoff (10 ms, 20 ms, 40 ms … capped at 250 ms), so a service
that answers in milliseconds costs milliseconds: ~17 ms on the machine above,
against a fixed 500 ms wait before 0.0.9. That wait is bounded by
opensysml.connection.START_TIMEOUT (2.5 s) — sleeping and probing together, since
no probe is given more than what is left of it — after which ConnectionError is
raised. A probe of a port nothing listens on is refused in a few milliseconds
rather than spending the per-probe RPC timeout, and a port that accepts without
answering costs the remaining bound, not another whole timeout on top of it. The
probe deciding whether to adopt a service already listening is made before that
wait starts and has its own 5 s timeout, so an address held by something that
never answers is reported in ~7.5 s. Connection(auto_start=False)
costs ~0.3 ms and the first RPC on it ~1 ms; import opensysml is ~120 ms, mostly
grpc (~48 ms), filelock and the generated protobuf modules.
Real-time analytics¶
This is a request/response service over gRPC, not a hard real-time engine. It gives no deadline guarantee, and nothing in it is scheduled: the runtime's step and time budgets bound how long an execution may run, which caps a worst case rather than promising one. Tails come from the Go garbage collector, the scheduler, TCP, and — for a cache miss — a parse whose cost scales with the document. Treat the p99 above as a soft budget, and measure your own p99 on your model sizes, cache state and concurrency before trusting one.
What that means in practice:
- Reuse one
Connection. Channel setup plus the first parse is tens of milliseconds; the module-level functions already share a singleton. - Parse once, then query by hash. Repeated
loadof unchanged content hits the service cache and costs ~0.5 ms, but asking the model itself (model.eval,model.instantiate,model.execute_*, which pass its hash) skips even that. The cache holds 100 models (-cache-size) and evicts least-recently-used, so a stream of distinct sources will evict a model you still hold a hash for. - Batch. One RPC carrying many samples beats one RPC per sample; at ~0.5 ms of overhead per call, a per-sample loop tops out in the low thousands of calls per second per connection.
- Keep the hot loop local. Filtering, thresholding and windowing over a telemetry stream belong in NumPy in your own process. Use the service for the coarse-grained step — resolving a model question, instantiating, running an action or state machine, converting a model — not for every sample.
- Convert off the hot path. Conversion re-parses its input on every call; it
is not cached by content the way
loadis.
Generated typed classes¶
Instance is dynamic, so an editor cannot complete inst.mass and a type checker
cannot reject inst.mas. opensysml.generate emits a Python class per SysML
definition, so both can:
python -m opensysml.generate internal/repl/testdata/vehicle_package.sysml -o demo_types.py
opensysml-generate model.sysml -o model_types.py # same thing, as a console script
import opensysml
from demo_types import Vehicle
model = opensysml.load("internal/repl/testdata/vehicle_package.sysml")
inst = model.instantiate("Demo::Vehicle")
v: Vehicle = Vehicle.from_instance(inst) # a typed view over the Instance
v.mass # 1500.0, typed float
v.engine.power # 300.0, through the generated Engine
v.instance # the underlying Instance
mypy (or pyright) then reports v.mas as an unknown attribute and v.mass + "x"
as an unsupported operand pair. opensysml ships a py.typed marker, so its own
annotations are used too.
from_instance rejects an instance of another definition, naming both types,
rather than failing later at attribute access. An instance of a definition that
specializes the expected one is accepted, since its generated class derives from
the expected class. An instance whose type no generated class describes is
accepted: instantiating a usage reports the usage's own FQN (Demo::myCar, not
Demo::SportsCar), which the client cannot relate to a definition, so rejecting
it would break the ordinary way to obtain an instance. Vehicle.unchecked(inst)
is the explicit escape hatch for a deliberately unchecked view.
Keeping a generated module honest¶
A generated module records what it came from, so a stale one can be detected rather than discovered at attribute access (or never, when a removed feature keeps type-checking):
SYSML_GENERATOR_VERSION = "1" # emission schema of this generator
SYSML_MODEL_HASH = "sha256:…" # hash of the model source it was generated from
--check regenerates in memory and compares, writing nothing:
It exits non-zero when the module is missing or would change, naming the command that regenerates it, which makes it usable as a CI or pre-commit gate.
Generation requires a service that reports the type_facts capability, which it
asks for over GetServerInfo. A service too old to answer that RPC, or one that
answers without the capability, does not populate SymbolInfo.type_info, and
generating against it would type every feature object — indistinguishable from a
feature that is genuinely untyped. Generation therefore fails, naming the service
in use, where it came from, and how to replace it, rather than emitting a silently
useless module.
The generator emits a runtime .py, not a .py + .pyi pair: each feature is
a property that carries the annotation and performs the delegation, so the types
and the code that implements them cannot drift apart, and there is one artifact to
commit. Output is deterministic — definitions ordered by fully-qualified name (base
classes first), nothing environment-dependent written — so it can be committed and
diffed; python/tests/golden/vehicle_types.py is exactly that.
Generated classes are views, not copies: attribute access goes to the underlying
feature value on every read, and Tier 1 behaviour is preserved. A feature value that
failed to evaluate raises FeatureValueError; one holding a value of another type than the model
declared raises TypeMismatchError rather than returning a wrongly typed value.
SysML → Python mapping¶
| SysML | Python |
|---|---|
Real, Rational |
float |
Integer, Natural |
int |
Boolean |
bool |
String |
str |
usage typed by a definition that reduces to a library scalar (attribute def Celsius :> Real) |
that scalar (float) |
usage typed by an enum def |
EnumLiteral, the identity of the literal held |
| usage typed by any other definition in the model | that definition's generated class |
multiplicity 1, 1..1, or undeclared |
X |
multiplicity 0..1 |
X \| None |
*, 0..*, n..m with upper > 1 |
list[X] |
Complex, Number |
object, with a comment naming the type |
| a type resolved outside the model (e.g. a library type) | object, with a comment naming its FQN |
| an unresolved or absent type | object, with a comment naming what was written |
specializes, subsets or redefines a definition in the model |
Python base class |
The fallback is always object and always says why in the property's docstring;
no feature is given a type the model does not support, and Any is never used to
dodge one.
Known limitations¶
- Behavioral and connector usages. Only structural usages (attribute, part, item, occurrence, individual, port, enum) become properties. Action, state, calc, constraint, requirement, connection, flow, interface, allocation and case usages are not instance feature values and are skipped.
- Redefinition narrowing. A redefinition reuses the redefined feature's name, so its property overrides the base class's, and takes over the type and multiplicity it does not restate; a redefinition that narrows the type is emitted with its own declared type, which Python does not check against the base property.
- Multiple inheritance. Emitted in declaration order, a target named twice
appearing once, and a base another declared base already specializes is left
implicit (
Hybrid :> Vehicle, ElectricwithElectric :> Vehicleemitsclass Hybrid(Electric)). A hierarchy that linearizes no way at all keeps the bases it can and names the left-out edge in a comment, rather than emitting a module that fails to import. - Generics and enumerations. No generic parameters. An
enumDefbecomes a plain class rather than a PythonEnum; a usage typed by it isEnumLiteral, which carries the literal's declaration identity but does not enumerate its siblings. - Name collisions. Two definitions with the same simple name both get
path-qualified class names (
A_Thing,B_Thing). A feature named like a memberTypedObjectprovides (instance,from_instance,sysml_id) gets a trailing underscore (instance_); the SysML feature name it reads is unchanged.
opensysml.connect(host, port, auto_start=True) returns a Connection when you
want to manage the service yourself; the module-level functions share a lazily
created singleton connection instead. A host:port address written as the host
is read as one — connect("localhost:50123") reaches port 50123 — and a port
named twice with two values raises ValueError naming the disagreement rather
than timing out against an address nobody asked for. The helpers taking
host/port (load, evaluate, convert, instantiate) read it the same way.
opensysml never stops a service it did not start. A service it starts is
reference-counted within the process that started it and stopped when the last
connection holding it is closed or the interpreter exits; a connection that
attaches to a service already listening takes no reference and leaves it running,
whatever it does. The service is recorded in ~/.opensysml/sysml-grpc-<port>.pid
($OPENSYSML_STATE_DIR overrides the directory) as its pid and process start time
plus the pid and start time of the process that started it, and every one of
those pids is re-checked against the start time written for it — a pid the
operating system has reused is a stale record, cleaned up rather than signalled,
and a command line that merely looks like sysml-grpc is not identity. A service
that crashes leaves such a record; the next connection removes it and starts one
of its own.
A service already listening on the port is checked the way the cached binary
is: it is asked what it is with GetServerInfo, and a release other than the one
asked for raises StaleServiceError naming the mismatch and the remedy, instead
of serving an old build whose first newer call fails as a
MissingCapabilityError. connect(version=…, require_capabilities=[…]) asks
explicitly; OPENSYSML_GRPC_VERSION asks for a release for the binary cache and
the running service alike, and with neither set whatever answers is accepted.
Such a service is stopped only when this process started it, no connection of its
own still holds it, and the binary that would be started in its place can be
shown to be the release asked for — a service you are running deliberately is
never killed, and one is never stopped only to start the same build again, so
the remedy asks you to stop it, name another port, or accept what is running. A
service that only lacks a required capability is therefore always reported as
MissingCapabilityError, never replaced: capabilities come with a release, so
restarting the same binary would report the same ones, and the class you catch
does not depend on who started the service. auto_start=False checks the release
too — reporting a mismatch stops nothing, so it needs no ownership — but stays
lazy: a service of yours that is not listening yet is checked once it answers.
Development¶
pytest python/tests/ # unit tests
pytest -m integration python/tests/ # needs a running sysml-grpc
# Tests needing a service skip without one; CI sets this so an absent service
# fails the run instead of quietly passing.
OPENSYSML_REQUIRE_SERVICE=1 pytest python/tests/
# Regenerate the committed golden generated file (needs a running sysml-grpc)
python -m opensysml.generate internal/repl/testdata/vehicle_package.sysml \
-o python/tests/golden/vehicle_types.py
# Regenerate protobuf bindings (from the repository root)
pip install grpcio-tools
make python-proto
Modules¶
binary.py— locates, downloads and checksum-verifiessysml-grpcconnection.py— gRPC channel, service lifecycle, ownership of services it startedmodel.py— a parsed model: root symbol and diagnosticssymbol.py— lazy symbol proxy, fetches children on demandinstance.py— instantiated object and its feature valuesconversion.py— a written model, its formats, extension inference, and theExperimentalFeatureWarningan RDF conversion raisesquery.py— the standard's Query payload, translated and its answersverdict.py— a verification's answer and what a calculation computederrors.py— the exception hierarchy and the gRPC status translationcapabilities.py— what the connected service reports it supportstypefacts.py— a symbol's static type, multiplicity and supertypestyped.py— base class and feature-value decoders the generated classes are built ongenerate.py— emits typed classes from a parsed modeldiagnostic.py— one diagnostic with its source locationproto/— generated message classes and stubs