Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
8 changes: 8 additions & 0 deletions src/capellambse/aird/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@
"/xmi:XMI/viewpoint:DAnalysis/ownedViews[viewpoint]",
namespaces=_n.NAMESPACES,
)
XP_DANALYSIS = etree.XPath(
"/xmi:XMI/viewpoint:DAnalysis",
namespaces=_n.NAMESPACES,
)
XP_SEMANTIC_RESOURCES = etree.XPath(
"/viewpoint:DAnalysis/semanticResources",
namespaces=_n.NAMESPACES,
)
ELEMENT = builder.ElementMaker(nsmap={"xmi": str(_n.NAMESPACES["xmi"])})


Expand Down
6 changes: 4 additions & 2 deletions src/capellambse/extensions/filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,10 @@ class IntersectionFilteringResultSet(FilteringResultSet):


def init() -> None:
capellamodeller.SystemEngineering.filtering_model = m.Single(
m.Filter("extensions", (NS, "FilteringModel"))
capellamodeller.SystemEngineering.filtering_model = m.Optional(
m.Single(
m.Filter("extensions", (NS, "FilteringModel"))
)
)
Comment thread
wingedfox marked this conversation as resolved.
m.MelodyModel.filtering_model = property( # type: ignore[attr-defined]
operator.attrgetter("project.model_root.filtering_model")
Expand Down
86 changes: 81 additions & 5 deletions src/capellambse/loader/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

import capellambse._namespaces as _n
from capellambse import filehandler, helpers
from capellambse.aird._common import XP_SEMANTIC_RESOURCES
Comment thread
wingedfox marked this conversation as resolved.
Outdated
from capellambse.loader import exs
from capellambse.loader.modelinfo import ModelInfo

Expand Down Expand Up @@ -590,6 +591,85 @@ 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
return
metadata element if found
Comment thread
wingedfox marked this conversation as resolved.
Outdated

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
Comment thread
wingedfox marked this conversation as resolved.
Outdated
) -> None:
Comment thread
wingedfox marked this conversation as resolved.
Outdated
"""Link library into the project tree, updates .aird, .capella, .afm to correcrly reflect library in target model.

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


Description
-----------
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 #no longer crashes, all fragments are in place
Comment thread
wingedfox marked this conversation as resolved.
Outdated

```
"""

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)
frag = ModelFile(
filename, handler, ignore_uuid_dups=self.__ignore_uuid_dups
)
Comment thread
Wuestengecko marked this conversation as resolved.

p = lib.joinpath(filename)
self.trees[p] = frag
for ref in _find_refs(frag.root):
ref_name = helpers.normalize_pure_path(
_unquote_ref(ref), base=p.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):
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 thread
wingedfox marked this conversation as resolved.
Outdated
if not last:
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 @@ -1345,11 +1425,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
5 changes: 5 additions & 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 Expand Up @@ -57,6 +58,10 @@ class SystemEngineering(capellacore.AbstractModellingStructure, ModelRoot):
[source:MIL-STD 499B standard]
"""

extensions = m._descriptors.Containment[re.ReElementContainer](
"ownedExtensions", (ns.RE, "RecCatalog")
)
Comment thread
wingedfox marked this conversation as resolved.
Outdated

@property
def oa(self) -> oa.OperationalAnalysis:
from . import oa # noqa: PLC0415
Expand Down
32 changes: 22 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(m.Association["CatalogElement"]((NS, "CatalogElement"), "source"))
target = m.Single(
m.Association["m.ModelElement"](
(ns.MODELLINGCORE, "ModelElement"), "target"
)
Comment thread
wingedfox marked this conversation as resolved.
Outdated
)
origin = m.Association["CatalogElementLink"](
(NS, "CatalogElementLink"), "origin"
origin = m.Single(
m.Association["CatalogElementLink"](
(NS, "CatalogElementLink"), "origin"
)
)
unsynchronized_features = m.StringPOD("unsynchronizedFeatures")
is_suffixed = m.BoolPOD("suffixed")
Expand All @@ -75,12 +79,20 @@ 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"
)
)
default_replica_compliancy = m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "defaultReplicaCompliancy"
current_compliancy = m.Single(
m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "currentCompliancy"
)
)
default_replica_compliancy = m.Single(
m.Association["CompliancyDefinition"](
(NS, "CompliancyDefinition"), "defaultReplicaCompliancy"
)
)
links = m.Containment["CatalogElementLink"](
"ownedLinks", (NS, "CatalogElementLink")
Expand Down
85 changes: 85 additions & 0 deletions src/capellambse/model/_descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"MissingValueError",
"NewObject",
"NonUniqueMemberError",
"Optional",
"ParentAccessor",
"PhysicalAccessor",
"PhysicalLinkEndsAccessor",
Expand Down Expand Up @@ -363,6 +364,90 @@ def __repr__(self) -> str:
f" use {self.alternative!r} instead>"
)

class Optional(Accessor[T_co | "_obj.ElementList[T_co]" | None ], t.Generic[T_co]):
"""An Accessor wrapper that makes result optional.

This Accessor is used to wrap other Accessors and make faults optional.
It catches wrapped accessor faults, hides them and intentionally
returns empty result effectively preventing runtime errors when accessing
optional model elements (i.e. extensions)

Parameters
----------
wrapped
The accessor to wrap. This accessor must return a list (i.e. it
is not possible to nest *Single* descriptors). The instance
passed here should also not be used anywhere else.

optional
Marker of optional element, when set - hides the errors

Defaults to True.

Examples
--------
>>> class Foo(capellacore.CapellaElement):
... bar = Optional["Bar"](Containment("bar", (NS, "Bar")))
"""

def __init__(
self,
wrapped: Accessor[T_co | _obj.ElementList[T_co] | None ],
optional: bool = False
) -> None:
"""Create a new optional descriptor."""
self.wrapped: t.Final = wrapped
self.optional: t.Final = optional
Comment thread
wingedfox marked this conversation as resolved.
Outdated

@t.overload
def __get__(self, obj: None, objtype: type[t.Any]) -> te.Self: ...
@t.overload
def __get__(
self, obj: _obj.ModelObject, objtype: type[t.Any] | None = None
) -> T_co | None: ...
def __get__(
self, obj: _obj.ModelObject | None, objtype: t.Any | None = None
) -> te.Self | T_co | None:
"""Retrieve the value of the attribute."""
if obj is None:
return self

objs: t.Any = None
try:
objs = self.wrapped.__get__(obj, type(obj))
finally:
self.__objs = objs
return objs
Comment thread
wingedfox marked this conversation as resolved.
Outdated


def __set__(
self, obj: _obj.ModelObject, value: _obj.ModelObject | None
) -> None:
"""Set the value of the attribute."""
if self.__objs:
self.wrapped.__set__(obj, [value])

def __delete__(self, obj: _obj.ModelObject) -> None:
"""Delete the attribute."""
if self.objs:
self.wrapped.__delete__(obj)

def __set_name__(self, owner: type[_obj.ModelObject], name: str) -> None:
"""Set the name and owner of the descriptor."""
self.wrapped.__set_name__(owner, name)
super().__set_name__(owner, name)

def __repr__(self) -> str:
wrapped = repr(self.wrapped).replace(" " + repr(self._qualname), "")
return f"<Optional {self._qualname!r} of {wrapped}>"

def purge_references(
self, obj: _obj.ModelObject, target: _obj.ModelObject
) -> contextlib.AbstractContextManager[None]:
if hasattr(self.wrapped, "purge_references"):
return self.wrapped.purge_references(obj, target)
return contextlib.nullcontext(None)


class Single(Accessor[T_co | None], t.Generic[T_co]):
"""An Accessor wrapper that ensures there is exactly one value.
Expand Down
Loading