Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0834f55
feat: added support for the libraries linking and referencing from mo…
wingedfox Dec 22, 2025
95876e0
feat: add Optional accessor to ensure optional nature of certain part…
wingedfox Dec 22, 2025
642dc10
fix: SystemEngineering incorrectly represented RPL catalogs and links…
wingedfox Dec 22, 2025
689caf9
fix: rec catalog origin is a single entity, not a list. Past accessor…
wingedfox Dec 26, 2025
afa4254
fix: CompliancyDefinition and DefaultCompliancyDefinition are attribu…
wingedfox Jan 4, 2026
1c909f0
fix: source, target and origin are attributes, not the lists, must be…
wingedfox Jan 4, 2026
48f4663
fix: explicit check for emptyness in condition to fix python deprecat…
wingedfox Jan 5, 2026
e16380a
fix: fixed misspelled resource tag
wingedfox Jan 5, 2026
09d5b1b
fix: check model fragment existence before loading to avoid re-init
wingedfox Jan 7, 2026
29ca09b
fix: intentionally hidden exception commented, operate on cached obje…
wingedfox Jan 7, 2026
a24c1b9
fix: optional wrapper may be configured to re-throw caught exception
wingedfox Jan 7, 2026
bf61bcc
fix: updated function doc to match numpy guidelines
wingedfox Jan 7, 2026
c31203a
fix: physical functions are mapped to the functions, instead of compo…
wingedfox Jan 14, 2026
7a47cb1
rollback: optional accessor removed, because filtering extension invo…
wingedfox Jan 14, 2026
0ffe456
fix: don't use cross-package linking to the private resources, update…
wingedfox Jan 14, 2026
51ff19d
rollback: extensions from ModelElement work as intended, no need to o…
wingedfox Jan 14, 2026
35447dc
fix: physical functions childred and ownedFunctions, not ownedPhysica…
wingedfox Jan 19, 2026
ba96398
fix: operands and mission package are singles, not lists
wingedfox Jan 20, 2026
cb6ed11
Merge branch 'master' of https://github.com/dbinfrago/py-capellambse …
wingedfox Jan 22, 2026
f7050f5
fix: Physical functions accessor for ownedFunctions
wingedfox Jan 22, 2026
a6a3312
fix: Review fix, link_library made public
wingedfox Jan 22, 2026
70b3180
fix: Simplified accessors based on review
wingedfox Jan 22, 2026
6af3dc1
fix: Removed extra assignment without specific value
wingedfox Jan 25, 2026
ee38b5c
feat: added owned_traces for TransfoLink collection
wingedfox Feb 23, 2026
9ac0245
fix: message ends and event boundaries are singles
wingedfox Feb 23, 2026
6f0ae2c
fix: check for project root on root collections may fail matching act…
wingedfox Feb 23, 2026
af6deb0
fix: sanity check for explicit None match
wingedfox Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/capellambse/aird/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
"/xmi:XMI/viewpoint:DAnalysis/ownedViews[viewpoint]",
namespaces=_n.NAMESPACES,
)
XP_DANALYSIS = etree.XPath(
"/xmi:XMI/viewpoint:DAnalysis",
namespaces=_n.NAMESPACES,
)
ELEMENT = builder.ElementMaker(nsmap={"xmi": str(_n.NAMESPACES["xmi"])})


Expand Down
120 changes: 115 additions & 5 deletions src/capellambse/loader/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
METADATA_TAG = f"{{{_n.NAMESPACES['metadata']}}}Metadata"
_ROOT_NS = "org.polarsys.capella.core.data.capellamodeller"

XP_SEMANTIC_RESOURCES = etree.XPath(
"/viewpoint:DAnalysis/semanticResources",
namespaces=_n.NAMESPACES,
)


def _derive_entrypoint(
path: str | os.PathLike | filehandler.FileHandler,
Expand Down Expand Up @@ -590,6 +595,106 @@ def check_duplicate_uuids(self) -> None:
" - check the 'resources' for duplicate models"
)

def __get_metadata(self, afm: ModelFile) -> etree._Element:
"""Return metadata from given model.

Parameters
----------
afm
model metadata file

Returns
-------
etree._Element
metadata element if found

Raises
------
RuntimeError
If metadata could not be found
"""
metadata = next(afm.root.iter(METADATA_TAG), None)
if metadata is None:
raise RuntimeError("Cannot find <Metadata> in primary .afm file")
Comment on lines +617 to +618

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception message doesn't quite fit anymore, since this function is also used for auxiliary .afm files. Let's simply show the filename instead:

Suggested change
if metadata is None:
raise RuntimeError("Cannot find <Metadata> in primary .afm file")
if metadata is None:
raise RuntimeError("Cannot find <Metadata> in {afm.filename}")

LOGGER.debug("Found <Metadata> with ID %s", metadata.get("id"))
return metadata

def link_library(self, lib: pathlib.PurePosixPath) -> None:
"""Link library into the project tree.

Parameters
----------
lib
path to a library .aird file

Description
-----------
Method updates .aird, .capella, .afm to correcrly reflect library in target model

When you need to refere to external library (or reuse one in a project)
```
p = "..." #path to library
model._loader.link_library(p)

lib = model.project.extensions[0].reference.library
```
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs build is failing because sphinx doesn't like this docstring in particular.

  • There's no "Description" heading. The full description goes right after the one-line summary, separated by a single blank line and no extra headings in between.

  • Can you move the code example into an "Examples" section after "Parameters"? I think this is where it'd fit best in this case.

  • Could you please also add a test case (or multiple) which exercises this new code? This would also serve nicely as secondary documentation. We already have a few test models in the conftest.Models dict which should come in handy here.

You can test the docs build locally as well (which should be much faster than waiting for CI) with a simple make docs.

handler = self.resources[str(lib)]
_h, filename = _derive_entrypoint(handler)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_h, filename = _derive_entrypoint(handler)
_, filename = _derive_entrypoint(handler)


frag_path = lib.joinpath(filename)
if self.trees.get(frag_path) is not None:
# for already loaded fragments skip library loading
return

frag = ModelFile(
filename, handler, ignore_uuid_dups=self.__ignore_uuid_dups
)
Comment thread
Wuestengecko marked this conversation as resolved.

self.trees[frag_path] = frag
for ref in _find_refs(frag.root):
ref_name = helpers.normalize_pure_path(
_unquote_ref(ref), base=frag_path.parent
)
self.__load_referenced_files(ref_name)

if ref_name.suffix == ".afm":
meta_lib = self.__get_metadata(self.trees[ref_name])
meta_self = self.__find_metadata()

if not next(
filter(
lambda el: re.search(str(ref_name), el.attrib["href"]),
meta_self.iterchildren("additionalResources"),
),
None,
):
Comment on lines +665 to +671

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're doing a simple substring match here, no need to use a regex. (Same applies a few lines further down for .capella files.)

Also, the next(filter(lambda el: ..., ...), None) construct can be rewritten using any() and a generator expression, which saves a little bit of overhead.

Suggested change
if not next(
filter(
lambda el: re.search(str(ref_name), el.attrib["href"]),
meta_self.iterchildren("additionalResources"),
),
None,
):
if not any(
str(ref_name) in el.attrib["href"]
for el in meta_self.iterchildren("additionalResources")
):

ael = meta_self.makeelement(
"additionalMetadata",
href=f"../{ref_name}#{meta_lib.attrib['id']}",
)
meta_self.append(ael)
elif ref_name.suffix == ".capella":
aird_self = self.trees[
pathlib.PurePosixPath(f"\x00/{self.entrypoint}")
]

last = next(
filter(
lambda el: re.search(str(ref_name), el.text),
XP_SEMANTIC_RESOURCES(aird_self.root),
),
None,
)
Comment on lines +682 to +688

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same points as above also apply here (regex for substring match and filter+lambda overhead). Also, here we're doing the XPath lookup twice, which I think we could combine into a single iteration like this (notice that the else belongs to the loop itself, not the enclosed if):

for el in XP_...:
    if str(ref_name) in el.text:
        break
else:
    sr = el.makeelement(...) # etc.

if last is not None:
for r in XP_SEMANTIC_RESOURCES(aird_self.root):
last = r

if last is not None:
sr = last.makeelement("semanticResources")
sr.text = f"platform:/resource/{ref_name}"
last.addnext(sr)

def __load_referenced_files(
self, resource_path: pathlib.PurePosixPath
) -> None:
Expand Down Expand Up @@ -1302,6 +1407,15 @@ def _find_fragment(
for fragment, tree in self.trees.items():
if tree.root is root:
return (fragment, tree)
# fallback here is necessary because
# working with root collections, i.e. key_value_pairs,
# causes project root being represented by two entities, i.e.
# <Element {http://www.polarsys.org/capella/core/modeller/7.0.0}Project at 0x7feced455dc0>
# <Element {http://www.polarsys.org/capella/core/modeller/7.0.0}Project at 0x7fecee0f6640>
# they do not pass "is" check but have the same IDs
for fragment, tree in self.trees.items():
if tree.root.attrib.get("id") == root.attrib.get("id"):
return (fragment, tree)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds odd. There should only ever be exactly one LXML entity per model element, especially the project root (under which everything else is nested).

  • Does this refer to the root of the (newly linked) library project? In this case, we should find it in self.trees - if not, we're missing the entry here.

  • If it actually is the same model, we have accidentally duplicated the project tree somehow (though it's not obvious to me how that would happen right now), and are now mixing elements from both tree instances.

  • Could this be due to some buggy interaction (or perhaps race) with the namespace map update code? (Be aware that capellambse currently isn't thread-safe, so this might be caused by multi-threaded downstream code.)

All of these could easily lead to corrupted models and data loss, so this needs to be addressed before merging. If you can, could you add a test case which hits this case?

raise ValueError("Element is not contained in any fragment")

def _follow_href(self, element: etree._Element) -> etree._Element:
Expand Down Expand Up @@ -1345,11 +1459,7 @@ def __find_metadata(self) -> etree._Element:
)
if afm is None:
raise RuntimeError("Cannot find .afm file in primary resource")
metadata = next(afm.root.iter(METADATA_TAG), None)
if metadata is None:
raise RuntimeError("Cannot find <Metadata> in primary .afm file")
LOGGER.debug("Found <Metadata> with ID %s", metadata.get("id"))
return metadata
return self.__get_metadata(afm)

def referenced_viewpoints(self) -> cabc.Iterator[tuple[str, str]]:
metadata = self.__find_metadata()
Expand Down
3 changes: 3 additions & 0 deletions src/capellambse/metamodel/capellacore.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class CapellaElement(
features = m.Association["EnumerationPropertyLiteral"](
(NS, "EnumerationPropertyLiteral"), "features"
)
owned_traces = m.Containment["capellacommon.TransfoLink"](
"ownedTraces", (ns.CAPELLACOMMON, "TransfoLink")
)


class NamedElement(
Expand Down
1 change: 1 addition & 0 deletions src/capellambse/metamodel/capellamodeller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import capellambse.model as m
from capellambse.metamodel import re

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why Github isn't running CI for this PR. Could you please make sure you run all the pre-commit hooks locally with pre-commit run -a ? Here we have an unused import which the hooks would warn about (and automatically remove for you).


from . import capellacore
from . import namespaces as ns
Expand Down
8 changes: 4 additions & 4 deletions src/capellambse/metamodel/information/datavalue.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,11 @@ class AbstractExpressionValue(

class BinaryExpression(AbstractExpressionValue):
operator = m.EnumPOD("operator", BinaryOperator)
left_operand = m.Containment["DataValue"](
"ownedLeftOperand", (NS, "DataValue")
left_operand = m.Single["DataValue"](
m.Containment("ownedLeftOperand", (NS, "DataValue"))
)
right_operand = m.Containment["DataValue"](
"ownedRightOperand", (NS, "DataValue")
right_operand = m.Single["DataValue"](
m.Containment("ownedRightOperand", (NS, "DataValue"))
)


Expand Down
17 changes: 9 additions & 8 deletions src/capellambse/metamodel/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ class SequenceMessage(capellacore.NamedElement):
exchange_context = m.Association["capellacore.Constraint"](
(ns.CAPELLACORE, "Constraint"), "exchangeContext"
)
sending_end = m.Association["MessageEnd"]((NS, "MessageEnd"), "sendingEnd")
receiving_end = m.Association["MessageEnd"](
(NS, "MessageEnd"), "receivingEnd"
sending_end = m.Single(
m.Association["MessageEnd"]((NS, "MessageEnd"), "sendingEnd")
)
receiving_end = m.Single(
m.Association["MessageEnd"]((NS, "MessageEnd"), "receivingEnd")
)
Comment on lines +86 to 91

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move the bracketed type annotation up a level to the m.Single instance instead of whatever it wraps. This helps keep things nice and consistent (i.e. it's always the outermost descriptor that has the annotation).

-m.Single(
-    m.Association["Annotation"](...)
+m.Single["Annotation"](
+    m.Assocation(...)
 )

Same also in the other metamodel files you modified.

exchanged_items = m.Association["information.ExchangeItem"](
(ns.INFORMATION, "ExchangeItem"), "exchangedItems"
Expand Down Expand Up @@ -218,14 +220,13 @@ class _EventOperation(Event):


class EventReceiptOperation(_EventOperation):
operation = m.Association["information.AbstractEventOperation"](
(ns.INFORMATION, "AbstractEventOperation"), "operation"
operation = m.Single(
m.Association["information.AbstractEventOperation"]((ns.INFORMATION, "AbstractEventOperation"), "operation")
)


class EventSentOperation(_EventOperation):
operation = m.Association["information.AbstractEventOperation"](
(ns.INFORMATION, "AbstractEventOperation"), "operation"
operation = m.Single(
m.Association["information.AbstractEventOperation"]((ns.INFORMATION, "AbstractEventOperation"), "operation")
)


Expand Down
2 changes: 1 addition & 1 deletion src/capellambse/metamodel/pa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ class PhysicalFunction(fa.AbstractFunction):
"realized_functions"
)
functions = m.Containment["fa.AbstractFunction"](
"ownedPhysicalComponents", (NS, "PhysicalComponent")
"ownedFunctions", (NS, "PhysicalFunction")
)
packages = m.Containment["PhysicalFunctionPkg"](
"ownedPhysicalFunctionPkgs", (NS, "PhysicalFunctionPkg")
Expand Down
30 changes: 20 additions & 10 deletions src/capellambse/metamodel/re.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,16 @@ class GroupingElementPkg(CatalogElementPkg):


class CatalogElementLink(ReAbstractElement):
source = m.Association["CatalogElement"]((NS, "CatalogElement"), "source")
target = m.Association["m.ModelElement"](
(ns.MODELLINGCORE, "ModelElement"), "target"
source = m.Single["CatalogElement"](
m.Association((NS, "CatalogElement"), "source")
)
origin = m.Association["CatalogElementLink"](
(NS, "CatalogElementLink"), "origin"
target = m.Single["m.ModelElement"](
m.Association((ns.MODELLINGCORE, "ModelElement"), "target")
)
origin = m.Single(
m.Association["CatalogElementLink"](
(NS, "CatalogElementLink"), "origin"
)
)
unsynchronized_features = m.StringPOD("unsynchronizedFeatures")
is_suffixed = m.BoolPOD("suffixed")
Expand All @@ -75,12 +79,18 @@ class CatalogElement(ReDescriptionElement, ReElementContainer):
is_read_only = m.BoolPOD("readOnly")
version = m.StringPOD("version")
tags = m.StringPOD("tags")
origin = m.Association["CatalogElement"]((NS, "CatalogElement"), "origin")
current_compliancy = m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "currentCompliancy"
origin = m.Single(
m.Association["CatalogElement"]((NS, "CatalogElement"), "origin")
)
current_compliancy = m.Single(
m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "currentCompliancy"
)
)
default_replica_compliancy = m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "defaultReplicaCompliancy"
default_replica_compliancy = m.Single(
m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "defaultReplicaCompliancy"
)
)
links = m.Containment["CatalogElementLink"](
"ownedLinks", (NS, "CatalogElementLink")
Expand Down
4 changes: 2 additions & 2 deletions src/capellambse/metamodel/sa.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ class SystemAnalysis(cs.ComponentArchitecture):
component_pkg = m.Single["SystemComponentPkg"](
m.Containment("ownedSystemComponentPkg", (NS, "SystemComponentPkg"))
)
mission_pkg = m.Containment["MissionPkg"](
"ownedMissionPkg", (NS, "MissionPkg")
mission_pkg = m.Single["MissionPkg"](
m.Containment["MissionPkg"]("ownedMissionPkg", (NS, "MissionPkg"))
)
operational_analysis_realizations = m.Containment[
"OperationalAnalysisRealization"
Expand Down
1 change: 0 additions & 1 deletion src/capellambse/model/_pods.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,6 @@ def __setitem__(
it = self._parent.iterchildren(self._tag)
elems = list(itertools.islice(it, idx.start, idx.stop, idx.step))
values = [i.text for i in elems]
values[idx] = value
for e, v in zip(elems, values, strict=True):
e.text = v
Comment on lines 496 to 500

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this is/was indeed bugged. But I think the fix is incomplete: Now the for loop is just setting the current values instead of the new ones. Instead, we can simply iterate over the islice directly, i.e.:

elems = itertools.islice(it, idx.start, idx.stop, idx.step)
for e, v in zip(elems, value, strict=True):
    e.text = v


Expand Down