View persistence

Saving diagrams as SysML v2 views, and restoring them. Design rationale and the adopted two-tier scheme: Saving diagrams as SysML v2 views.

Persist diagrams as SysML v2 views (and restore them).

Design: docs/design/view-persistence.md – the ratified two-tier scheme. The standard tier writes a ViewUsage into the model itself: typed by the matching StandardViewDefinitions view definition, exposing the shown elements through expose relationships, and naming the spec rendering with a render reference. That tier travels through .sysml text, the Systems Modeling API, and every conformant tool. The sidecar tier is a small versioned JSON file (.longeron/views.json next to the model sources) carrying only what the standard cannot express: layout direction, edge routing, collapse state, and the longeron diagram kind – keyed by the view usage’s qualified name, so a model without the sidecar still restores correctly with default presentation.

import longeron
from longeron import views

model = longeron.load("rig.sysml")
widget = longeron.diagrams.structure_diagram(model.find("Rig"))
views.save_view(model, widget, name="axle structure",
                sidecar=views.sidecar_path(model))
# ... later, or in another tool entirely ...
views.restore_view(model, "Rig::axle structure")

Geometry is deliberately persisted nowhere: ELK re-derives layout from the persisted inputs (exposed elements, direction, routing), so a saved view never rots into stale pixel coordinates.

Restore resolves the expose closure through the resolver (expose_closure() is the exact machinery, exposed for reuse): membership exposes yield the named element (plus its subtree when recursive), namespace exposes yield the target’s members, and filter conditions restrict the closure. Metaclass filters (@SysML::PartUsage, the dominant spec idiom) evaluate against longeron’s kind vocabulary; arbitrary model-level filter expressions are preserved and exported but not applied to the closure (the design doc’s scope fence). A dangling expose warns and is skipped – never an exception – and the same condition surfaces in longeron.validate() as the dangling-expose diagnostic.

Saving is append-only and idempotent: a new view usage is appended to the scope’s owning package (keeping index-path element ids stable), and saving under an existing view name replaces that view usage’s recipe (exposes, filters, render) and its sidecar entry in place.

longeron.views.VIEW_KINDS: tuple[Literal['structure', 'state', 'action', 'requirements'], ...] = ('structure', 'state', 'action', 'requirements')

the runtime table ViewKind projects to (the model.py house pattern: the Literal is the authority, so the two cannot drift)

class longeron.views.ViewInfo(element, qualified_name, kind, exposes=<factory>)[source]

Bases: object

One view usage and its persistence-relevant metadata.

kind: str | None

longeron diagram kind from the typing (None = unknown)

longeron.views.ViewKind

the longeron diagram kinds a view can persist (mirrors explorer.DIAGRAM_KINDS; asserted equal by the test suite)

alias of Literal[‘structure’, ‘state’, ‘action’, ‘requirements’]

longeron.views.capture_presentation(widget)[source]

The live presentation of a diagram widget, in sidecar vocabulary.

Reads the CURRENT layout direction and edge routing off the widget’s source tree (the toolbar tools re-apply their traits there, so live toggles are captured) and the collapse state: the structure view’s per-node levels and per-compartment folds (longeron.diagrams.CollapseTool) plus any nodes whose children are all hidden (the state/action widgets’ stock collapse, the legacy collapsed key). Only deviations from the defaults are returned – absent sidecar keys mean defaults, so the file only grows when a user actually deviates from them.

Return type:

dict[str, Any]

longeron.views.expose_closure(model, view, *, resolver=None)[source]

The elements a view usage exposes (its exposedElement set).

Each expose resolves through the resolver: a membership expose yields the named element (plus its whole subtree when recursive, X::**), a namespace expose yields the target’s members (X::*; all nested members when recursive). Filter conditions – the expose’s own bracket filters, the view’s filter members, and conditions inherited from an in-model view definition – restrict the closure; metaclass filters evaluate, anything else is preserved but not applied (module docstring). A dangling expose warns and is skipped, never raises. Order is expose order, then tree order; duplicates are dropped.

Return type:

list[Element]

longeron.views.list_views(model)[source]

Every view usage in model, with kind and expose metadata.

The exposed closure of a view is computed on demand by expose_closure() (it needs a resolver walk); this listing stays cheap and never warns.

Return type:

list[ViewInfo]

longeron.views.load_sidecar(path)[source]

Read a sidecar file; returns the per-view entries keyed by view qualified name ({} when the file does not exist).

Forward-compatible by contract: any version >= 1 is accepted and unknown per-view keys are preserved (they ride through save_sidecar() untouched). A file that is not a longeron views sidecar raises SysMLError – silently treating it as empty would overwrite foreign data on the next save.

Return type:

dict[str, dict[str, Any]]

longeron.views.restore_view(model, view, *, sidecar=None)[source]

Rebuild the diagram a view usage describes; returns the widget.

Three steps, per the design doc: the view’s typing picks the diagram builder (untyped views fall back to the render reference, then the sidecar kind, then structure – unknown view definitions warn and fall back), the expose closure yields the diagram scope (expose_closure(); dangling exposes warn and are skipped, a fully dangling view restores to an empty diagram), and the sidecar entry re-applies presentation: direction, routing, builder options, and the collapse state (structure/requirements widgets rebuild per-node levels and per-compartment folded through structure_diagram(levels=..., folded=...); a legacy flat collapsed list maps to the smallest rendition, level "collapsed"; state/action widgets keep the stock hidden-children collapse). No sidecar entry means spec content with default presentation – the degraded mode IS the standard mode.

sidecar may be a path, an already-loaded entries mapping, or None to auto-discover the workspace sidecar next to the model’s sources (silently absent for in-memory models). Needs the diagram toolchain (the vendored ipyelk), like longeron.diagrams.

Return type:

Any

longeron.views.save_sidecar(path, views, *, model=None)[source]

Write the sidecar file (schema longeron/views, version 1).

Entries are written under sorted qualified-name keys with stable two-space indentation – small, diffable, merge-friendly. When model is given, entries whose qualified name no longer resolves in it are PRUNED (the design doc’s orphan rule: a view deleted by another tool cannot wedge the sidecar). Returns the file path.

Return type:

Path

longeron.views.save_view(model, exposed, *, name=None, kind=None, options=None, sidecar=None)[source]

Write a diagram into model as a SysML v2 view usage.

exposed names what the view shows: a longeron diagram widget (its root element, diagram kind, and non-default builder options are read off the widget – including live toolbar direction/routing and collapse state), a model element, a qualified name, or a list of elements/names. Each exposed element becomes one recursive membership expose (expose X::** – the element and its subtree).

The view usage is typed by the matching StandardViewDefinitions view definition and carries a render reference to Views::asInterconnectionDiagram (the design doc’s mapping table); kind defaults to the widget’s diagram kind, else it is inferred from the first exposed element (state/action elements pick their machine views, everything else structure). name defaults to "<element> <kind>".

Append-only, idempotent semantics (ratified): a NEW view is appended to the scope’s owning package – appending keeps existing index-path element ids stable – while saving under an existing view name REPLACES that view usage’s recipe (typing, exposes, filters, render) in place.

options seeds the sidecar entry: the presentation keys (direction, routing, collapsed) plus any diagram-builder kwargs (membership, submachine_depth, lanes, …). When sidecar is a path, the entry is written there under the view’s qualified name (see save_sidecar()); with sidecar=None the model edit alone is performed and the caller owns any sidecar write.

Returns the view usage element (freshly appended or replaced).

Return type:

Usage

longeron.views.sidecar_path(source)[source]

The workspace sidecar location for a model or a source path.

One JSON file per workspace, .longeron/views.json, next to the .sysml sources: for a file that is <dir>/.longeron/views.json beside it, for a directory it lives inside the directory. A model resolves through its source_name; models not loaded from disk (loads text, merged multi-path models) return None.

Return type:

Path | None

longeron.views.view_kind(view)[source]

The longeron diagram kind stated by a view usage’s typing.

Matches the last segment of each declared type against the StandardViewDefinitions mapping table (InterconnectionView -> structure, StateTransitionView -> state, …); None when the view is untyped or typed by an unknown view definition.

Return type:

Optional[Literal['structure', 'state', 'action', 'requirements']]