Skip to content

Commit 66058ae

Browse files
committed
feat: Add automatic cs.Part creation when creating a cs.Component
- Added an optional `class_` parameter to RoleTagAccessor such that `.parts` wouldn't include `Port`s in its list due to matching role-tag "ownedFeatures". - Added a test case for component creation audit events. - Also fxed `cs.Part`s name: Now these take their name from their `.type` instead of their own `name` attribute in the XML code.
1 parent 24e8b2c commit 66058ae

File tree

9 files changed

+169
-21
lines changed

9 files changed

+169
-21
lines changed

capellambse/model/common/accessors.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,18 +1249,19 @@ def __get__(self, obj, objtype=None):
12491249
return self._make_list(obj, matches)
12501250

12511251

1252-
class RoleTagAccessor(PhysicalAccessor):
1252+
class RoleTagAccessor(DirectProxyAccessor[T]):
12531253
__slots__ = ("role_tag",)
12541254

12551255
def __init__(
12561256
self,
12571257
role_tag: str,
1258+
class_: type[T] | None = None,
12581259
*,
12591260
aslist: type[element.ElementList[T]] | None = None,
12601261
list_extra_args: dict[str, t.Any] | None = None,
12611262
) -> None:
12621263
super().__init__(
1263-
element.GenericElement,
1264+
class_ or element.GenericElement,
12641265
(),
12651266
aslist=aslist,
12661267
list_extra_args=list_extra_args,
@@ -1273,12 +1274,48 @@ def __get__(self, obj, objtype=None):
12731274
return self
12741275

12751276
elts = list(obj._element.iterchildren(self.role_tag))
1277+
if self.class_ is not element.GenericElement:
1278+
xtype = build_xtype(self.class_)
1279+
elts = [elt for elt in elts if elt.get(helpers.ATT_XT) == xtype]
1280+
12761281
rv = self._make_list(obj, elts)
12771282
if obj._constructed:
12781283
sys.audit("capellambse.read_attribute", obj, self.__name__, rv)
12791284
sys.audit("capellambse.getattr", obj, self.__name__, rv)
12801285
return rv
12811286

1287+
def create(
1288+
self,
1289+
elmlist: element.GenericElement | ElementListCouplingMixin,
1290+
/,
1291+
*type_hints: str | None,
1292+
**kw: t.Any,
1293+
) -> T:
1294+
if self.aslist is None:
1295+
raise TypeError("Cannot create multiple of this object")
1296+
1297+
assert isinstance(elmlist, ElementListCouplingMixin)
1298+
assert isinstance(elmlist._parent, element.GenericElement)
1299+
1300+
if not type_hints:
1301+
if not self.class_:
1302+
raise ValueError("Need type hint for creating object")
1303+
type_hints = (self.class_.__name__,)
1304+
1305+
kw["_xmltag"] = self.role_tag
1306+
return super().create(elmlist, *type_hints, **kw)
1307+
1308+
def insert(
1309+
self,
1310+
elmlist: ElementListCouplingMixin,
1311+
index: int,
1312+
value: element.ModelObject,
1313+
) -> None:
1314+
if self.class_:
1315+
assert isinstance(value, self.class_)
1316+
assert value._element.tag == self.role_tag
1317+
super().insert(elmlist, index, value)
1318+
12821319

12831320
def no_list(
12841321
desc: Accessor,

capellambse/model/common/element.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,12 @@ def __init__(
199199

200200
super().__init__()
201201
if self._xmltag is None:
202-
raise TypeError(
203-
f"Cannot instantiate {type(self).__name__} directly"
204-
)
202+
try:
203+
self._xmltag = kw.pop("_xmltag")
204+
except KeyError as error:
205+
raise TypeError(
206+
f"Cannot instantiate {type(self).__name__} directly"
207+
) from error
205208
self._constructed = False
206209
self._model = model
207210
self._element: etree._Element = etree.Element(self._xmltag)

capellambse/model/crosslayer/cs.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
import operator
1717
import typing as t
1818

19+
from lxml import etree
20+
1921
from .. import common as c
2022
from . import capellacommon, fa, information
2123

2224
if t.TYPE_CHECKING:
23-
pass
25+
import capellambse
2426

2527
XT_DEPLOY_LINK = (
2628
"org.polarsys.capella.core.data.pa.deployment:PartDeploymentLink"
@@ -36,6 +38,10 @@ class Part(c.GenericElement):
3638

3739
deployed_parts: c.Accessor
3840

41+
@property
42+
def name(self) -> str: # type: ignore[override]
43+
return self.type.name
44+
3945

4046
@c.xtype_handler(None)
4147
class ExchangeItemAllocation(c.GenericElement):
@@ -127,7 +133,9 @@ class Component(c.GenericElement):
127133
)
128134
ports = c.DirectProxyAccessor(fa.ComponentPort, aslist=c.ElementList)
129135
physical_ports = c.DirectProxyAccessor(PhysicalPort, aslist=c.ElementList)
130-
parts = c.RoleTagAccessor("ownedFeatures", aslist=c.ElementList)
136+
parts = c.RoleTagAccessor[Part](
137+
"ownedFeatures", Part, aslist=c.ElementList
138+
)
131139
representing_parts = c.ReferenceSearchingAccessor(
132140
Part, "type", aslist=c.ElementList
133141
)
@@ -144,6 +152,17 @@ class Component(c.GenericElement):
144152
aslist=c.ElementList,
145153
)
146154

155+
def __init__(
156+
self,
157+
model: capellambse.MelodyModel,
158+
parent: etree._Element,
159+
/,
160+
**kw: t.Any,
161+
) -> None:
162+
super().__init__(model, parent, **kw)
163+
164+
self.parent.parts.create(name=self.name, type=self)
165+
147166

148167
@c.xtype_handler(None)
149168
class ComponentRealization(c.GenericElement):
@@ -158,7 +177,7 @@ class ComponentPkg(c.GenericElement):
158177
exchanges = c.DirectProxyAccessor(
159178
fa.ComponentExchange, aslist=c.ElementList
160179
)
161-
parts = c.RoleTagAccessor("ownedParts", aslist=c.ElementList)
180+
parts = c.RoleTagAccessor[Part]("ownedParts", Part, aslist=c.ElementList)
162181
state_machines = c.DirectProxyAccessor(
163182
capellacommon.StateMachine, aslist=c.ElementList
164183
)

capellambse/model/crosslayer/information/__init__.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,14 @@ class Property(c.GenericElement):
116116
"aggregationKind", modeltypes.AggregationKind, default="UNSET"
117117
)
118118
type = c.AttrProxyAccessor(c.GenericElement, "abstractType")
119-
default_value = c.RoleTagAccessor("ownedDefaultValue")
120-
min = c.RoleTagAccessor("ownedMinValue")
121-
max = c.RoleTagAccessor("ownedMaxValue")
122-
null_value = c.RoleTagAccessor("ownedNullValue")
123-
min_card = c.RoleTagAccessor("ownedMinCard")
124-
max_card = c.RoleTagAccessor("ownedMaxCard")
119+
default_value = c.RoleTagAccessor[datavalue.LiteralValue](
120+
"ownedDefaultValue"
121+
)
122+
min = c.RoleTagAccessor[datavalue.LiteralValue]("ownedMinValue")
123+
max = c.RoleTagAccessor[datavalue.LiteralValue]("ownedMaxValue")
124+
null_value = c.RoleTagAccessor[datavalue.LiteralValue]("ownedNullValue")
125+
min_card = c.RoleTagAccessor[datavalue.LiteralValue]("ownedMinCard")
126+
max_card = c.RoleTagAccessor[datavalue.LiteralValue]("ownedMaxCard")
125127
association = c.ReferenceSearchingAccessor(Association, "roles")
126128

127129

capellambse/model/crosslayer/information/datavalue.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class ValuePart(c.GenericElement):
3535
referenced_property = c.AttrProxyAccessor(
3636
c.GenericElement, "referencedProperty"
3737
)
38-
value = c.RoleTagAccessor("ownedValue")
38+
value = c.RoleTagAccessor[LiteralValue]("ownedValue")
3939

4040

4141
@c.xtype_handler(None)

capellambse/model/layers/pa.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def deployed_components(
8484
) -> c.ElementList[PhysicalComponent]:
8585
items = [
8686
cmp.type._element
87-
for part in self.parts
87+
for part in self.representing_parts
8888
for cmp in part.deployed_parts
8989
]
9090
return c.ElementList(self._model, items, PhysicalComponent)

tests/data/melodymodel/6_0/Melody Model Test.capella

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2390,6 +2390,8 @@ The predator is far away</bodies>
23902390
name="R. Weasley" abstractType="#ff7b8672-84db-4b93-9fea-22a410907fb1"/>
23912391
<ownedParts xsi:type="org.polarsys.capella.core.data.cs:Part" id="249fed97-366f-4240-9e20-101cef32cda2"
23922392
name="LogicalActor 5" abstractType="#3e0ee19f-0e3f-49d4-ae99-29bd4a3260c5"/>
2393+
<ownedParts xsi:type="org.polarsys.capella.core.data.cs:Part" id="b01f70e0-f8fc-4a2d-855d-cc00ebcbf39d"
2394+
name="Hogwarts" abstractType="#0d2edb8f-fa34-4e73-89ec-fb9a63001440"/>
23932395
<ownedComponentExchanges xsi:type="org.polarsys.capella.core.data.fa:ComponentExchange"
23942396
id="c0bc49e1-8043-4418-8c0a-de6c6b749eab" name="Headmaster Responsibilities"
23952397
convoyedInformations="#408f9055-bdcf-4181-865c-db3e823bd93d" source="#fcbf6881-720c-421f-9fe9-12fc3dfefe9c"

tests/test_auditing.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,12 +334,12 @@ def test_attribute_assignment_fires_exactly_one_setattr_event(
334334
["obj_id", "attr", "args_factory", "accessor_type"],
335335
[
336336
pytest.param(
337-
"0d2edb8f-fa34-4e73-89ec-fb9a63001440",
338-
"components",
337+
"21bc1668-0632-4287-b126-ea9a01635c8d",
338+
"functions",
339339
lambda m: {
340-
"name": "Unfair advantages",
341-
"allocated_functions": [
342-
m.by_uuid("c1a42acc-1f53-42bb-8404-77a5c08c414b")
340+
"name": "New Function",
341+
"functions": [
342+
m.by_uuid("edbd1ad4-31c0-4d53-b856-3ffa60e0e99b")
343343
],
344344
},
345345
common.DirectProxyAccessor,
@@ -387,3 +387,42 @@ def test_creating_objects_fires_exactly_one_create_event(
387387
assert ev[0] == obj
388388
assert ev[1] == attr
389389
assert ev[2] is new_obj
390+
391+
392+
def test_creating_components_fires_exactly_two_create_events(
393+
model: capellambse.MelodyModel, audit_events: list[tuple[t.Any, ...]]
394+
) -> None:
395+
obj = model.by_uuid("0d2edb8f-fa34-4e73-89ec-fb9a63001440")
396+
attr = "components"
397+
descriptor = getattr(type(obj), attr, None)
398+
assert descriptor is not None, f"{type(obj).__name__} has no {attr}"
399+
assert isinstance(
400+
descriptor, common.DirectProxyAccessor
401+
), "Bad descriptor type"
402+
create_args = {
403+
"name": "Unfair advantages",
404+
"allocated_functions": [
405+
model.by_uuid("c1a42acc-1f53-42bb-8404-77a5c08c414b")
406+
],
407+
}
408+
target = getattr(obj, attr)
409+
audit_events.clear()
410+
411+
with prohibit_events("capellambse.insert", "capellambse.setattr"):
412+
new_obj = target.create(**create_args)
413+
414+
event_filter = {
415+
"capellambse.create",
416+
"capellambse.insert",
417+
"capellambse.setattr",
418+
}
419+
events = [i for i in audit_events if i[0] in event_filter]
420+
assert len(events) == 2
421+
assert set(i[0] for i in events) == {"capellambse.create"}
422+
for attr, event, new_obj in zip(
423+
("parts", attr), events, (new_obj.representing_parts[0], new_obj)
424+
):
425+
_, *ev = event
426+
assert ev[0] == obj
427+
assert ev[1] == attr
428+
assert ev[2] == new_obj

tests/test_xlayer_cs.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# SPDX-FileCopyrightText: Copyright DB Netz AG and the capellambse contributors
22
# SPDX-License-Identifier: Apache-2.0
3+
import pytest
34

45
from capellambse import MelodyModel
6+
from capellambse.model.crosslayer import cs
7+
8+
HOGWARTS_UUID = "0d2edb8f-fa34-4e73-89ec-fb9a63001440"
59

610

711
def test_PhysicalPath_has_ordered_list_of_involved_items(model: MelodyModel):
@@ -74,3 +78,45 @@ def test_PhysicalLink_setting_source_and_target(model: MelodyModel):
7478
assert source_pp == link.source
7579
assert target_pp == link.ends[1]
7680
assert target_pp == link.target
81+
82+
83+
def test_Component_parts(model: MelodyModel):
84+
comp = model.by_uuid(HOGWARTS_UUID)
85+
86+
for part in comp.parts:
87+
assert isinstance(part, cs.Part)
88+
89+
90+
@pytest.mark.parametrize(
91+
"uuid",
92+
[
93+
pytest.param(HOGWARTS_UUID, id="Component"),
94+
pytest.param(
95+
"84c0978d-9a32-4f5b-8013-5b0b6adbfd73", id="ComponentPkg"
96+
),
97+
],
98+
)
99+
def test_component_creation_also_creates_a_part(model: MelodyModel, uuid: str):
100+
name = "Test"
101+
logical_parts = model.search("Part", below=model.la).by_name
102+
assert name not in logical_parts, "Part already exists" # type: ignore[operator]
103+
obj = model.by_uuid(uuid)
104+
105+
comp = obj.components.create(name=name)
106+
107+
assert (part := model.search("Part", below=model.la).by_name(name))
108+
assert isinstance(part, cs.Part)
109+
assert part in comp.representing_parts
110+
assert part.type == comp
111+
112+
113+
def test_component_modification_also_modifies_parts(model: MelodyModel):
114+
name = "Test"
115+
116+
comp = model.by_uuid(HOGWARTS_UUID)
117+
assert comp.representing_parts
118+
comp.name = name
119+
comp.allocated_functions.append(model.la.root_function)
120+
121+
assert (part := comp.representing_parts[0]).name == name
122+
assert part.type.allocated_functions == comp.allocated_functions

0 commit comments

Comments
 (0)