Skip to content

Commit

Permalink
feat: Implement new Namespace-based class discovery
Browse files Browse the repository at this point in the history
This commit reworks the discovery of metamodel classes and namespaces.
It introduces the `Namespace` object, whose instances keep track of the
contained classes. Namespace objects are discovered through the Python
entrypoint system. This makes extending the metamodel significantly
easier. It's no longer necessary to add weirdly formatted entries into
barely documented dictionaries, with errors only appearing at some
arbitrary later point.

Discovery of classes now happens via a custom metaclass by simply
inheriting from `ModelElement`. This also allows additional metadata to
be specified at subclassing time, which is especially relevant for
namespace versioning. Due to this, the following decorators are now no
longer needed and have been deprecated:

- `@xtype_handler`: Was used to register classes for discovery, which is
  now done by the metaclass.
- `@attr_equal`: This overwrites the `__eq__` method to compare against
  an attribute. Now handled by the `eq=...` class keyword argument.
  • Loading branch information
Wuestengecko committed Dec 10, 2024
1 parent 2e65e49 commit 36233ac
Show file tree
Hide file tree
Showing 50 changed files with 3,282 additions and 1,605 deletions.
38 changes: 17 additions & 21 deletions capellambse/extensions/filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@
import capellambse.model as m
from capellambse import _native, helpers

VIEWPOINT: t.Final = "org.polarsys.capella.filtering"
NAMESPACE: t.Final = "http://www.polarsys.org/capella/filtering/6.0.0"
SYMBOLIC_NAME: t.Final = "filtering"

_LOGGER = logging.getLogger(__name__)

m.XTYPE_ANCHORS[__name__] = SYMBOLIC_NAME
NS = m.Namespace(
"http://www.polarsys.org/capella/filtering/{VERSION}",
"filtering",
"org.polarsys.capella.filtering",
"7.0.0",
)

VIEWPOINT: t.Final = NS.viewpoint
NAMESPACE: t.Final = NS.uri.format(VERSION="6.0.0")
SYMBOLIC_NAME: t.Final = NS.alias


@m.xtype_handler(None)
class FilteringCriterion(m.ModelElement):
"""A single filtering criterion."""

Expand All @@ -40,17 +44,15 @@ class FilteringCriterion(m.ModelElement):
)


@m.xtype_handler(None)
class FilteringCriterionPkg(m.ModelElement):
"""A package containing multiple filtering criteria."""

_xmltag = "ownedFilteringCriterionPkgs"

criteria = m.DirectProxyAccessor(FilteringCriterion, aslist=m.ElementList)
packages: m.Accessor[FilteringCriterionPkg]
packages: m.Accessor[m.ElementList[FilteringCriterionPkg]]


@m.xtype_handler(None)
class FilteringModel(m.ModelElement):
"""A filtering model containing criteria to filter by."""

Expand Down Expand Up @@ -83,7 +85,7 @@ def __get__(

loader = obj._model._loader
try:
xt_critset = f"{SYMBOLIC_NAME}:AssociatedFilteringCriterionSet"
xt_critset = f"{NS.alias}:AssociatedFilteringCriterionSet"
critset = next(loader.iterchildren_xt(obj._element, xt_critset))
except StopIteration:
elems = []
Expand All @@ -94,32 +96,26 @@ def __get__(
return self._make_list(obj, elems)


@m.xtype_handler(None)
class FilteringResult(m.ModelElement):
"""A filtering result."""


@m.xtype_handler(None)
class ComposedFilteringResult(m.ModelElement):
"""A composed filtering result."""


def init() -> None:
m.set_accessor(
mm.capellamodeller.SystemEngineering,
"filtering_model",
m.DirectProxyAccessor(FilteringModel),
mm.capellamodeller.SystemEngineering.filtering_model = (
m.DirectProxyAccessor(FilteringModel)
)
m.MelodyModel.filtering_model = property( # type: ignore[attr-defined]
operator.attrgetter("project.model_root.filtering_model")
)
m.set_accessor(
m.ModelElement, "filtering_criteria", AssociatedCriteriaAccessor()
)
m.ModelElement.filtering_criteria = AssociatedCriteriaAccessor()


m.set_self_references(
(FilteringCriterionPkg, "packages"),
FilteringCriterionPkg.packages = m.DirectProxyAccessor(
FilteringCriterionPkg, aslist=m.ElementList
)

try:
Expand Down
2 changes: 1 addition & 1 deletion capellambse/extensions/pvmt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def _get_pvmt_configuration(model: m.MelodyModel) -> PVMTConfiguration:
def init() -> None:
"""Initialize the PVMT extension."""
m.MelodyModel.pvmt = property(_get_pvmt_configuration) # type: ignore[attr-defined]
m.set_accessor(m.ModelElement, "pvmt", m.AlternateAccessor(ObjectPVMT))
m.ModelElement.pvmt = m.AlternateAccessor(ObjectPVMT)


if not t.TYPE_CHECKING:
Expand Down
60 changes: 42 additions & 18 deletions capellambse/extensions/pvmt/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
r"(?m)\[(?P<key>\w+)\]\s*(?P<value>.+?)\s*\[/(?P=key)\]"
)

NS = m.Namespace(
m.VIRTUAL_NAMESPACE_PREFIX + "pvmt",
"capellambse.virtual.pvmt",
)


class ScopeError(m.InvalidModificationError):
"""Raised when trying to apply a PVMT group to an out-of-scope element."""
Expand Down Expand Up @@ -173,13 +178,40 @@ def _to_xml(self, value: SelectorRules | str, /) -> str:
return value


class ManagedGroup(m.ModelElement):
class _PVMTBase:
_model: capellambse.MelodyModel
_element: etree._Element

uuid = m.StringPOD("id")
name = m.StringPOD("name")
parent = m.ParentAccessor()
property_values: m.Accessor[m.ElementList[mm.capellacore.PropertyValue]]

def __init__(self, *_args, **_kw) -> None:
raise TypeError("Use 'model.pvmt' to access PVMT configuration")

@classmethod
def from_model(
cls, model: capellambse.MelodyModel, element: etree._Element
) -> te.Self:
self = cls.__new__(cls)
self._model = model
self._element = element
return self

def _short_repr_(self) -> str:
return f"<{type(self).__name__} {self.name!r}>"


_PVMTBase.property_values = mm.modellingcore.ModelElement.property_values


class ManagedGroup(mm.capellacore.PropertyValueGroup):
"""A managed group of property values."""

_required_attrs = frozenset({"name"})

selector = PVMTDescriptionProperty("description")
description = m.Alias("selector", dirhide=True) # type: ignore[assignment]

@property
def fullname(self) -> str:
Expand Down Expand Up @@ -236,7 +268,9 @@ def apply(self, obj: m.ModelObject) -> mm.capellacore.PropertyValueGroup:

groupobj = obj.property_value_groups.create(
name=groupname,
applied_property_value_groups=[self],
applied_property_value_groups=[
m.wrap_xml(self._model, self._element)
],
)
for propdef in self.property_values:
groupobj.property_values.create(
Expand All @@ -257,7 +291,7 @@ def _short_html_(self) -> markupsafe.Markup:
)


class ManagedDomain(m.ModelElement):
class ManagedDomain(mm.capellacore.PropertyValuePkg):
"""A "domain" in the property value management extension."""

_required_attrs = frozenset({"name"})
Expand All @@ -269,18 +303,12 @@ class ManagedDomain(m.ModelElement):
"ownedEnumerationPropertyTypes",
mm.capellacore.EnumerationPropertyType,
)
groups = m.Containment(
groups = m.Containment[mm.capellacore.PropertyValueGroup](
"ownedPropertyValueGroups",
mm.capellacore.PropertyValueGroup,
aslist=m.ElementList,
mapkey="name",
alternate=ManagedGroup,
)
enumeration_property_types = m.Containment(
"ownedEnumerationPropertyTypes",
mm.capellacore.EnumerationPropertyType,
aslist=m.ElementList,
)

def __init__(
self,
Expand All @@ -297,7 +325,7 @@ def __init__(
def from_model(
cls, model: capellambse.MelodyModel, element: etree._Element
) -> te.Self:
self = super().from_model(model, element)
self = m.wrap_xml(model, element, cls)
try:
version = self.property_values.by_name("version").value
except Exception:
Expand All @@ -313,16 +341,12 @@ def from_model(
return self


class PVMTConfiguration(m.ModelElement):
class PVMTConfiguration(mm.capellacore.PropertyValuePkg):
"""Provides access to the model-wide PVMT configuration."""

def __init__(self, *_args, **_kw) -> None:
raise TypeError("Use 'model.pvmt' to access PVMT configuration")

domains = m.Containment(
domains = m.Containment[mm.capellacore.PropertyValuePkg](
"ownedPropertyValuePkgs",
mm.capellacore.PropertyValuePkg,
aslist=m.ElementList,
mapkey="name",
alternate=ManagedDomain,
)
3 changes: 3 additions & 0 deletions capellambse/extensions/pvmt/_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from capellambse.metamodel import capellacore

from . import _config
from ._config import NS as NS

e = markupsafe.escape

Expand Down Expand Up @@ -142,6 +143,7 @@ def __repr__(self) -> str:
for prop in group.property_values:
fragments.append(f"\n - {prop.name}: ")
if hasattr(prop.value, "_short_repr_"):
assert prop.value is not None
fragments.append(prop.value._short_repr_())
else:
fragments.append(repr(prop.value))
Expand Down Expand Up @@ -181,6 +183,7 @@ def __html__(self) -> markupsafe.Markup:
else:
actual = e(actual_val)
if hasattr(prop.value, "_short_html_"):
assert prop.value is not None
default = prop.value._short_html_()
else:
default = e(prop.value)
Expand Down
10 changes: 9 additions & 1 deletion capellambse/extensions/reqif/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@
from ._glue import *
from ._requirements import *

from ._capellareq import NS as CapellaRequirementsNS # isort: skip
from ._requirements import NS as RequirementsNS # isort: skip

if not t.TYPE_CHECKING:
from ._capellareq import __all__ as _cr_all
from ._requirements import __all__ as _rq_all

__all__ = [*_cr_all, *_rq_all]
__all__ = [
"CapellaRequirementsNS",
"RequirementsNS",
*_cr_all,
*_rq_all,
]
del _cr_all, _rq_all
del t
27 changes: 16 additions & 11 deletions capellambse/extensions/reqif/_capellareq.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@
from . import _requirements as rq
from . import exporter

m.XTYPE_ANCHORS[__name__] = "CapellaRequirements"
NS = m.Namespace(
"http://www.polarsys.org/capella/requirements",
"CapellaRequirements",
"org.polarsys.capella.vp.requirements",
)


@m.xtype_handler(None)
class CapellaModule(rq.ReqIFElement):
"""A ReqIF Module that bundles multiple Requirement folders."""

_xmltag = "ownedExtensions"

folders = m.DirectProxyAccessor(rq.Folder, aslist=m.ElementList)
requirements = m.DirectProxyAccessor(rq.Requirement, aslist=m.ElementList)
type = m.Association(rq.ModuleType, "moduleType")
type = m.Single(m.Association(rq.ModuleType, "moduleType"))
attributes = rq.AttributeAccessor()

def to_reqif(
Expand Down Expand Up @@ -81,24 +84,21 @@ def to_reqif(
)


@m.xtype_handler(None)
class CapellaIncomingRelation(rq.AbstractRequirementsRelation):
"""A Relation between a requirement and an object."""

_xmltag = "ownedRelations"


@m.xtype_handler(None)
class CapellaOutgoingRelation(rq.AbstractRequirementsRelation):
"""A Relation between an object and a requirement."""

_xmltag = "ownedExtensions"

source = m.Association(rq.Requirement, "target")
target = m.Association(m.ModelElement, "source")
source = m.Single(m.Association(rq.Requirement, "target"))
target = m.Single(m.Association(m.ModelElement, "source"))


@m.xtype_handler(None)
class CapellaTypesFolder(rq.ReqIFElement):
_xmltag = "ownedExtensions"

Expand Down Expand Up @@ -214,6 +214,11 @@ def insert(
raise NotImplementedError("Cannot insert new objects yet")

if isinstance(value, CapellaOutgoingRelation):
if not value.target:
raise RuntimeError(
"Cannot insert outgoing relation without target:"
f" {value._short_repr_()}"
)
parent = value.target._element
else:
assert isinstance(
Expand Down Expand Up @@ -262,7 +267,7 @@ def __init__(
source: m.ModelObject,
) -> None:
del elemclass
super().__init__(model, elements, rq.AbstractRequirementsRelation)
super().__init__(model, elements)
self._source = source

@t.overload
Expand Down Expand Up @@ -364,5 +369,5 @@ def _make_list(self, parent_obj, elements):
)


m.set_accessor(rq.Requirement, "relations", RequirementsRelationAccessor())
m.set_accessor(rq.Requirement, "related", ElementRelationAccessor())
rq.Requirement.relations = RequirementsRelationAccessor()
rq.Requirement.related = ElementRelationAccessor()
Loading

0 comments on commit 36233ac

Please sign in to comment.