opensysml
Python client for OpenSysML: parse, inspect and execute SysML v2 models over the
sysml-grpc service.
pip install opensysml # from PyPI
pip install -e python/ # or from a checkout, at the repository root
import opensysml
model = opensysml.load("model.sysml", strict=True) # raises on error diagnostics
print(model.eval("1 + 2 * 3")) # 7
print(model.eval("mass", subject="Demo::sedan")) # 1200.0 — that object, not the default
# requires the service's evaluate_subject
# capability; the client checks it first
vehicle = model["Vehicle"] # by short name or FQN
vehicle.attributes() # own and inherited, with resolved facts
inst = model.instantiate("Demo::Vehicle")
inst.mass # 1500.0 [kg] — a Quantity
model.verify_satisfaction() # every assert satisfy … by …
model.save("model.ttl") # RDF Turtle (experimental)
Declarations can be authored from notation strings while preserving the untouched source:
model.edit().add_part_def("", "Vehicle").apply()
model.edit().add_part("Vehicle", "engine", type="Engine").apply()
Use opensysml.loads(text, language="kerml") for inline KerML content.
Every call goes through the sysml-grpc service, which opensysml starts for you from
~/.opensysml/bin/sysml-grpc; the guide below says how to put it there.
Service ownership
opensysml uses a service of its own, and never stops a service it did not
start.
- A connection made without naming a service starts a private child of this interpreter. The child binds port 0, so the kernel assigns the port, and it reports the address it was given on its stdout — no port is chosen, probed or retried by the client, and two interpreters starting at once cannot collide.
- One private child serves every connection of an interpreter that needs the same service release. The first of them starts it; it stops when the last one closes, or when the interpreter exits. Sharing it shares its parse cache, which is what makes a second connection cheap (see below).
- Its lifetime is this interpreter's. Nothing is recorded on disk about it, no other process adopts it, and a service another process left listening is neither reused nor cleaned up.
- Connecting to a service
opensysmldid not start is explicit: pass a host and port (opensysml.connect("localhost", 50051), orconnect("localhost:50051")), set$OPENSYSML_SERVICE=host:port, or passauto_start=Falseto require a service the caller manages. Closing such a connection leaves it running.
No orphans
The client holds the write end of the child's stdin pipe and never writes to
it; the child reads its stdin and shuts down on end of file. Nothing else holds
that write end, so the pipe closes when the owning process goes away — and it is
the kernel that closes it, not any code of ours. That survives what an atexit
hook or a supervisor thread does not: SIGKILL, os._exit, a fatal interpreter
error, and a crash during shutdown. On an orderly close the client also closes
stdin itself and then signals the child, so exit is prompt rather than eventual.
The client signals only through the Popen object of the child it started, so no
pid it did not start — including one the operating system has since reused —
can be signalled. That guarantee no longer needs a start-time check to hold,
because there is no pid on disk to re-authenticate.
Per platform:
- Linux and macOS: the child is started in a session of its own
(
start_new_session=True), so aSIGINTorSIGHUPsent to the client's process group does not reach it; stdin is what ends it. Other children the client spawns do not inherit the write end, since CPython closes descriptors acrosssubprocessby default, so it has exactly one holder. - Windows: the operating system closes the same anonymous pipe when the
owning process exits, however it exits, so the guarantee is unchanged. Windows
has no
fork(), so the case below cannot arise there. fork(): the forked child inherits the write end, which would hold the service open past its owner. Anos.register_at_forkhook therefore disowns the inherited services in the new process and closes its copy of the pipe: the service stays tied to the process that started it, and a forked child that connects starts one of its own.
The single limitation is deliberate: a service reached explicitly is not tied to the client's lifetime, because the client does not own it.
Cost of a private child
Measured on Linux with python/scripts/measure_private_service.py (n=20):
| p50 | p95 | |
|---|---|---|
| first connection: spawn, bind, report, handshake | 7.0 ms | 9.1 ms |
| a later connection joining this interpreter's child | 0.6 ms | 1.0 ms |
| a child per connection, rather than one shared | 29.6 ms | 54.6 ms |
| parsing a model the shared child has already parsed | 0.3 ms | 1.2 ms |
| the same parse in a child of that connection's own | 139.8 ms | 269.6 ms |
The last two rows are why the child is per interpreter rather than per connection: a child per connection would not only spawn N times, it would parse each model N times, against a cache hit some 500x cheaper.
Pinned release digests
A download is verified against PINNED_SHA256 in opensysml/binary.py, which
pins the SHA-256 of every asset of a release. The .sha256 served beside a
binary comes from whoever served the binary, so it detects corruption but not a
republished release; a pinned digest is independent of that origin. A download
with no pin fails with a message naming the version, rather than falling back to
the served checksum — $OPENSYSML_ALLOW_UNPINNED_DOWNLOAD=<owner/repo> (or =1 for
any repository) accepts same-origin trust explicitly for what it names, with a
warning.
At release time, after the service binaries are published and final:
export GITHUB_TOKEN=... # the release API rate-limits unauthenticated calls
python scripts/pin_release_checksums.py --version v0.0.9 --write
git commit -am 'chore(python): pin release digests for v0.0.9'
The script downloads every sysml-grpc-* asset of that release, hashes what it
downloaded, refuses the release if a .sha256 sidecar disagrees with the asset
it describes, and rewrites the table in place. --check re-hashes the assets of
every pinned release and fails on any disagreement, catching a release
republished with another binary. A opensysml release therefore pins the service
releases published before it; asking for a newer one needs a newer opensysml (or
the explicit opt-in above), and leaves an already-downloaded binary serving
rather than refusing to start — only a digest that contradicts a pin is
treated as tampering and refuses to fall back.
Version
opensysml/_version.py is the only declaration: the packaging metadata reads it,
opensysml.__version__ reports the installed distribution's version, and
scripts/check_version.py fails a release whose tag names another version. The
version tests therefore require the tree under test to be the installed
distribution — pip install -e python/. A wheel of another version installed
beside the source tree makes them fail with that remedy: the artifact is what is
stale, not the declaration.
Generated typed classes
python -m opensysml.generate model.sysml -o model_types.py emits one class per
SysML definition, and the generated hierarchy follows the model's
generalization edges: specializes, subsets and redefines all become base
classes, because Python has a single notion of inheritance. What tells the two
apart is the members, not the bases:
- a redefinition reuses the redefined feature's name, so its property
overrides the base class's property of that name, and takes over the type and
multiplicity it does not restate (
attribute :>> mass = 2.0;staysfloat, and a redefined0..*feature stays alist[...]); - a subset under a new name adds a property beside the base class's one, and likewise inherits the type and multiplicity it leaves out.
With multiple supertypes, bases are emitted in declaration order, a target
named twice appearing once, and Python resolves members left to right by its
usual MRO. A base another declared base already specializes is left implicit —
Hybrid :> Vehicle, Electric where Electric :> Vehicle emits
class Hybrid(Electric), which Python can linearize and which keeps both
relationships and Electric's properties. Where no order linearizes at all
(two bases specializing a shared pair in opposite orders), rather than emit a
module that fails to import, the generator keeps the bases it can and records
what it left out as a comment on the class, naming the edge:
class Both(One):
# specializes Demo::Two, left out: Python cannot linearize it with the bases above
A base outside the generated model is reported the same way. Both are the model's
hierarchy being wider than Python's, not facts being discarded — the service
reports every edge, and Symbol.specializations still carries them all.
Limitation, unchanged: only structural usages (attribute, part, item,
occurrence, individual, port, enum) become properties. Behavioral and
connector usages — action, state, calc, constraint, requirement,
connection, flow, interface, allocation, case — are not instance feature values,
so a generated class has no member for them; reach them through
model["Demo::Vehicle"], verify_constraint and verify_satisfaction.
Names that shadow builtins
Neither builtin name is a live part of the API any more: the module-level
evaluation function is opensysml.evaluate, and the execution error is
opensysml.ExecutionError. opensysml.eval and opensysml.errors.RuntimeError
remain as deprecated aliases that warn on use, out of their modules' __all__,
so a star-import binds neither.
import opensysml
opensysml.evaluate("1 + 2", file_path="model.sysml") # opensysml.eval warns
model.eval("mass", subject="Demo::sedan") # a method shadows nothing
from opensysml import eval # shadows the builtin in this module — don't
Guidance for this package and for code around it:
- Call
opensysml.evaluate.opensysml.evalstill works and returns the same result, warningDeprecationWarning; it goes away in 1.0.0. - Import the package, not its names, for anything named like a builtin.
- Catch
opensysml.ExecutionError(or its baseopensysml.OpenSysMLError), neveropensysml.errors.RuntimeError, which warns and is due for removal. - Do not name a new public function or exception after a builtin.
0.2.0 therefore publishes evaluate as the name to write, with both builtin
names deprecated rather than removed, so code written against 0.1.x keeps
running until 1.0.0.
Running the tests
make build # builds bin/sysml-grpc
pip install -e python/ && pip install pytest pytest-mock
python -m pytest python/tests/ -q # service-backed tests skip
Tests that need a service skip when none answers on localhost:50051 and no
binary is available to spawn one. Where a service is provided — as in CI —
export OPENSYSML_REQUIRE_SERVICE=1, and its absence fails instead of skipping.
Documentation
- Using the client: docs/guide/09-python.md — installing the service binary, loading a model, instances, verification, conversion and queries
- The API surface, generated typed classes, latency and the module map: docs/reference/python-api.md
- Installing from source and running the tests: INSTALL.md
Release files for opensysml 0.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| opensysml-0.3.2.tar.gz | 196.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| opensysml-0.3.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 307.7 kB
Release files / opensysml-0.3.2.tar.gz
| Download URL | opensysml-0.3.2.tar.gz |
|---|---|
| Size | 196.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4313feb20b46bc41e6333f2a86a46b3b751e1fef8dab5f45d3c9039e7e9a578f
|
|
BLAKE2b-256 checksum How to use checksums |
e458ef2d870231888967c8d7cf4d5dff972086691bd5137d63df158cf6251a39
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.16
|
Release files / opensysml-0.3.2-py3-none-any.whl
| Download URL | opensysml-0.3.2-py3-none-any.whl |
|---|---|
| Size | 111.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1e74948348644613933cf9fb2ee9acc25fc70a5dfa8a773527729294b0efa853
|
|
BLAKE2b-256 checksum How to use checksums |
40fa147dfc3e0b1c266a74b67786c05b6650d3f9f92be8e105f15d327419ecba
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.16
|