Model editing

Small, verified mutations for UI inspectors: rename with a full reference cascade (or an honest refusal), value edits that validate unit semantics before mutating (a fake or wrong-dimension unit is refused, never stored) and accept the compact quantity form the inspector displays (17 g, and prefix-composed symbols like 17 mg – resolved through the model’s own unit vocabulary and stored as the canonical bracket expression), documentation edits, and a change-tracking seam – every operation keeps the textual export parseable and at a fixpoint.

Model editing: small mutations with round-trip guarantees.

This is the seam UI inspectors mutate a model through: each operation takes the model plus an element (or its ::-qualified name), validates its input precisely, applies the smallest possible change, and leaves the model in a state that still exports to parseable text (longeron.to_sysml()), still resolves, and still validates. No operation inserts or removes siblings of existing elements (set_doc appends or edits in place), so index-path element ids – the longeron.ecore projection ids derived from member positions – stay stable across edits.

The operations:

import longeron
from longeron import edit

model = longeron.loads("package P { part def Vehicle; part v : Vehicle; }")
tracker = edit.track(model)
edit.rename(model, "P::Vehicle", "Car")     # cascades into 'v : Car'
edit.set_doc(model, "P::v", "The prototype.")
tracker.dirty                                # True
tracker.changes                              # [(op, qname, detail), ...]

The honest-refusal rename philosophy. Renaming an element changes the qualified names of its whole subtree, and every textual reference in the model that reaches the element – or its descendants – through the old name must be rewritten with it: typings, subsets, redefines, connector ends, satisfy targets, exposes, imports, aliases, dependency ends, metadata prefixes, and the name references inside owned expressions. rename() resolves every reference site through the same machinery longeron.validate() uses, rewrites exactly the segments that resolve to the renamed element, and then re-resolves every site to prove that nothing changed meaning. Whatever cannot be proven safe is refused: references in positions that cannot be statically resolved (member access on a computed value, e.g. seq#(1).mass) raise EditError listing the offending sites, and a rename that would silently re-bind any reference (name capture through shadowing) is rolled back and refused. A rename that silently breaks references is worse than no rename.

Value writes validate semantics, not just syntax. set_attribute_value() applies the same philosophy to units: a new expression carrying a measurement reference that does not resolve (0.42 [SI::kgg]), or whose dimension contradicts the attribute’s quantity typing (0.42 [SI::s] on a MassValue) or – when the typing pins nothing – the current value’s own unit (0.42 [SI::s] replacing 0.38 [SI::kg]), is refused before anything mutates – through the very machinery validate’s dimensional lint uses, so the edit seam and the lint share one truth. The compact quantity form the inspector displays commits too: 17 g (or 17g) resolves the symbol through the same derived unit table the display uses and is rewritten to the canonical bracket expression (17 [SI::g]) for storage; a symbol the model never names but that decomposes through the model’s own prefix vocabulary (17 mg) rescales into the prefix’s reference unit (0.017 [SI::g]) – model-derived, never invented – and an ambiguous or unknown symbol is refused, never guessed. validate=False is the documented escape hatch for deliberate unchecked writes (including deliberate re-dimensioning).

Known blind spots, matching validate’s own: metadata value bodies (level = 3; inside @Safety) resolve against the metadata definition only one level deep, and references that reach an element purely through an Alias are not rewritten when the alias itself is renamed (the post-verification refuses such renames rather than break them).

Change tracking. track() registers a Tracker in a module-level weakref.WeakKeyDictionary keyed by the model object itself, so trackers die with their models and no wrapper type is needed. Every edit.* operation that mutates a tracked model appends a Change record and fires the tracker’s callbacks; dirty is simply “there are recorded changes since the last Tracker.mark_saved()”.

class longeron.edit.Change(op: str, qname: str, detail: dict[str, Any])[source]

Bases: NamedTuple

One recorded edit: unpacks as (op, qname, detail).

op: str

“rename” | “set_value” | “set_doc” | “add_metadata”

qname: str

qualified name of the edited element (after the edit)

detail: dict[str, Any]

op-specific payload (old/new values, …)

exception longeron.edit.EditError[source]

Bases: SysMLError

Raised when a model edit is invalid or would corrupt references.

longeron.edit refuses any mutation it cannot prove safe – a rename that would break or silently re-bind references raises this error (listing the affected sites) instead of corrupting the model.

class longeron.edit.Tracker[source]

Bases: object

Accumulates Change records for one tracked model.

dirty is derived: True whenever changes is non-empty. mark_saved() clears both – changes is defined as “the edits since the last save”, which is exactly what an app needs to decide whether to prompt, and what to write into a commit message. Callbacks registered with on_change() fire synchronously, once per change, after the change is appended; exceptions propagate to the caller of the edit operation.

longeron.edit.add_metadata(model, element_or_qname, typed_by, values=None)[source]

Append a metadata annotation (@TypedBy { key = value; ... }).

typed_by is the annotation’s (possibly qualified) metadata definition name; values maps body member names to literal values (strings, numbers, booleans), each stored as a MetadataValue in the given order. The usage is appended to the target’s members – never inserted – so existing siblings keep their index-path element ids (the set_doc rule). Like every operation here, a refusal (EditError) mutates nothing and records nothing on the tracker. Returns the new metadata usage.

Return type:

MetadataUsage

longeron.edit.rename(model, element_or_qname, new_name)[source]

Rename an element, rewriting every textual reference that reaches it.

Validates that new_name is a legal name (non-empty, no :: or . separators, no $, no control characters – anything else exports through the quoted-name form) and that no sibling already answers to it. Every reference site in the model is then resolved (with the same stdlib-aware machinery validate uses), the segments that resolve to the renamed element are rewritten, and the whole model is re-resolved to prove that every site still means what it meant before. Names in positions that cannot be statically resolved (member access on computed values) are refused up front when they mention the old name, and any silent re-binding (name capture) is rolled back – both raise EditError listing the affected references. See the module docstring for the philosophy.

Renaming to the current name is a no-op. Returns the element.

Return type:

Element

longeron.edit.set_attribute_value(model, attr_or_qname, text, *, validate=True)[source]

Set (or clear) a usage’s value from expression text.

text is parsed with the package’s expression parser (longeron.parse_expression()); a syntax error raises EditError carrying the parse diagnostics.

Semantics are validated before anything mutates (the module’s honest-refusal philosophy, applied to values): every measurement reference the new expression carries must resolve against the model’s unit vocabulary – 0.42 [SI::kgg] raises EditError naming the fake unit and the nearest real spellings – and when the usage’s typing pins a quantity dimension (payload : MassValue) the new value’s derived dimension must agree: 0.42 [SI::s] on a mass-typed attribute is refused stating both dimensions, while 0.42 [SI::g] is a real mass unit and passes. When the typing pins nothing (mass : Real) but the CURRENT value carries a resolvable unit, that unit’s dimension is the pin instead – replacing a [SI::kg] value with a [SI::s] one is refused stating both dimensions and the validate=False override (a deliberate re-dimensioning is legitimate, a silent one is corruption). A unit on a previously unit-less attribute is accepted as long as it resolves (adding units is legitimate), and a bare number always passes – its dimension is unknown, exactly as validate treats it. The model is untouched by any refusal, and refused attempts record nothing on the tracker. validate=False skips this semantic gate for deliberate unchecked writes (syntax is still required).

The compact quantity form the inspector displays commits as well: 17 g / 17g – a number, optional space, one unit symbol – resolves the symbol through the model’s derived unit table (the very table the display reads) and stores the canonical bracket expression 17 [SI::g]; the dimension gates above apply to it unchanged. A symbol the model does not name but that decomposes through the model’s own prefix vocabulary (17 mg) is rescaled into the prefix’s reference unit and stored as 0.017 [SI::g]. An ambiguous decomposition is refused naming every candidate, and an unknown symbol is refused with the nearest real spellings. The rewrite is form normalization, so it applies under validate=False too.

The existing value’s default = / := flags are preserved; a usage without a value gets a plain = binding. None (or blank text) removes the value entirely. Returns the usage.

Return type:

Usage

longeron.edit.set_doc(model, element_or_qname, text)[source]

Create, update, or remove an element’s documentation.

With text: the element’s first doc member is updated in place (keeping its member position, and collapsing any additional doc members into it), or a new one is appended – never inserted – so existing siblings keep their index-path element ids. With None or an empty string: the doc members are removed (the one edit that shrinks a member list; siblings after the doc get new index paths). Bodies are written in the canonical comment form (longeron.export.doc_comment_body()), which round-trips multi-line text through export/parse at a fixpoint. Per the comment convention, leading * decoration and per-line indentation are not part of the text. Returns the documentation element (None after a removal).

Return type:

Documentation | None

longeron.edit.track(model)[source]

Start (or continue) tracking edits to model; returns its tracker.

Idempotent: repeated calls return the same Tracker. The registry holds the model weakly – dropping the model drops the tracker.

Return type:

Tracker

longeron.edit.untrack(model)[source]

Stop tracking model (a no-op when it was never tracked).

Return type:

None