Skip to content

Model

etcion.metamodel.model

Model container for ArchiMate Concepts.

:class:Model is the top-level container for all Concepts (elements, relationships, and relationship connectors) belonging to a single ArchiMate architecture description.

Reference: ADR-010.

Model(concepts=None)

Top-level container for an ArchiMate model.

Concepts are added via :meth:add and retrieved by ID via model[id]. Filtered views are available via :attr:elements and :attr:relationships.

Example::

model = Model()
actor = BusinessActor(name="Alice")
model.add(actor)
assert model[actor.id] is actor
Source code in src/etcion/metamodel/model.py
42
43
44
45
46
47
48
49
50
51
def __init__(self, concepts: list[Concept] | None = None) -> None:
    self._concepts: dict[str, Concept] = {}
    self._profiles: list[Profile] = []
    self._specialization_registry: dict[str, type[Element]] = {}
    self._custom_rules: list[ValidationRule] = []
    # Graph cache — invalidated by add() (ADR-041).
    self._nx_graph: object | None = None
    if concepts is not None:
        for concept in concepts:
            self.add(concept)

profiles property

Read-only list of applied profiles.

concepts property

All concepts in insertion order.

elements property

All Element instances in insertion order.

relationships property

All Relationship instances in insertion order.

Excludes :class:~etcion.metamodel.concepts.RelationshipConnector instances (e.g. Junction) because connectors are not relationships.

add(concept)

Add a Concept to the model.

:raises TypeError: if concept is not a :class:Concept instance. :raises ValueError: if a concept with the same id already exists.

Source code in src/etcion/metamodel/model.py
53
54
55
56
57
58
59
60
61
62
63
64
def add(self, concept: Concept) -> None:
    """Add a Concept to the model.

    :raises TypeError: if *concept* is not a :class:`Concept` instance.
    :raises ValueError: if a concept with the same ``id`` already exists.
    """
    if not isinstance(concept, Concept):
        raise TypeError(f"Expected an instance of Concept, got {type(concept).__name__}")
    if concept.id in self._concepts:
        raise ValueError(f"Duplicate concept ID: '{concept.id}'")
    self._concepts[concept.id] = concept
    self._nx_graph = None  # invalidate graph cache (ADR-041)

apply_profile(profile)

Register a Profile with this model.

:raises TypeError: if profile is not a Profile instance. :raises ValueError: if a specialization name is already registered.

Source code in src/etcion/metamodel/model.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def apply_profile(self, profile: Profile) -> None:
    """Register a Profile with this model.

    :raises TypeError: if *profile* is not a Profile instance.
    :raises ValueError: if a specialization name is already registered.
    """
    if not isinstance(profile, Profile):
        raise TypeError(f"Expected a Profile, got {type(profile).__name__}")
    for base_type, names in profile.specializations.items():
        for name in names:
            if name in self._specialization_registry:
                raise ValueError(f"Duplicate specialization name: '{name}'")
            self._specialization_registry[name] = base_type
    self._profiles.append(profile)

add_validation_rule(rule)

Register a custom validation rule.

:param rule: An object implementing the :class:~etcion.validation.rules.ValidationRule protocol. :raises TypeError: If rule does not implement the protocol.

Source code in src/etcion/metamodel/model.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def add_validation_rule(self, rule: ValidationRule) -> None:
    """Register a custom validation rule.

    :param rule: An object implementing the
        :class:`~etcion.validation.rules.ValidationRule` protocol.
    :raises TypeError: If *rule* does not implement the protocol.
    """
    from etcion.validation.rules import ValidationRule as _VR

    if not isinstance(rule, _VR):
        raise TypeError(
            f"Expected an object implementing ValidationRule protocol, "
            f"got {type(rule).__name__}"
        )
    self._custom_rules.append(rule)

remove_validation_rule(rule)

Remove a previously registered custom validation rule.

:param rule: The exact rule instance to remove (identity match). :raises ValueError: If rule is not currently registered.

Source code in src/etcion/metamodel/model.py
106
107
108
109
110
111
112
113
114
115
def remove_validation_rule(self, rule: ValidationRule) -> None:
    """Remove a previously registered custom validation rule.

    :param rule: The exact rule instance to remove (identity match).
    :raises ValueError: If *rule* is not currently registered.
    """
    try:
        self._custom_rules.remove(rule)
    except ValueError:
        raise ValueError("Rule is not registered on this model") from None

elements_of_type(cls)

Return elements that are instances of cls (includes subclasses).

Source code in src/etcion/metamodel/model.py
141
142
143
def elements_of_type(self, cls: type[Element]) -> list[Element]:
    """Return elements that are instances of *cls* (includes subclasses)."""
    return [e for e in self.elements if isinstance(e, cls)]

elements_by_layer(layer)

Return elements whose class-level layer ClassVar matches.

Source code in src/etcion/metamodel/model.py
145
146
147
def elements_by_layer(self, layer: Layer) -> list[Element]:
    """Return elements whose class-level ``layer`` ClassVar matches."""
    return [e for e in self.elements if getattr(type(e), "layer", None) is layer]

elements_by_aspect(aspect)

Return elements whose class-level aspect ClassVar matches.

Source code in src/etcion/metamodel/model.py
149
150
151
def elements_by_aspect(self, aspect: Aspect) -> list[Element]:
    """Return elements whose class-level ``aspect`` ClassVar matches."""
    return [e for e in self.elements if getattr(type(e), "aspect", None) is aspect]

elements_by_name(pattern, *, regex=False)

Return elements whose name contains pattern (substring).

When regex is True, uses re.search(pattern, name) instead.

Source code in src/etcion/metamodel/model.py
153
154
155
156
157
158
159
160
161
def elements_by_name(self, pattern: str, *, regex: bool = False) -> list[Element]:
    """Return elements whose name contains *pattern* (substring).

    When *regex* is ``True``, uses ``re.search(pattern, name)`` instead.
    """
    if regex:
        compiled = re.compile(pattern)
        return [e for e in self.elements if e.name and compiled.search(e.name)]
    return [e for e in self.elements if e.name and pattern in e.name]

relationships_of_type(cls)

Return relationships that are instances of cls (includes subclasses).

Source code in src/etcion/metamodel/model.py
163
164
165
def relationships_of_type(self, cls: type[Relationship]) -> list[Relationship]:
    """Return relationships that are instances of *cls* (includes subclasses)."""
    return [r for r in self.relationships if isinstance(r, cls)]

to_networkx()

Convert this model to a networkx MultiDiGraph.

Nodes represent Elements and RelationshipConnectors (Junctions). Edges represent Relationships. Results are cached; the cache is invalidated whenever :meth:add is called.

Node attributes: type (Python class), name, layer, aspect, concept (back-reference to the Concept instance).

Edge attributes: type (Python class), name, rel_id (the relationship's own ID), relationship (back-reference).

Requires the graph extra::

pip install etcion[graph]

:raises ImportError: If networkx is not installed. :returns: A networkx.MultiDiGraph instance.

Source code in src/etcion/metamodel/model.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def to_networkx(self) -> object:
    """Convert this model to a networkx MultiDiGraph.

    Nodes represent Elements and RelationshipConnectors (Junctions).
    Edges represent Relationships.  Results are cached; the cache is
    invalidated whenever :meth:`add` is called.

    Node attributes: ``type`` (Python class), ``name``, ``layer``,
    ``aspect``, ``concept`` (back-reference to the Concept instance).

    Edge attributes: ``type`` (Python class), ``name``, ``rel_id``
    (the relationship's own ID), ``relationship`` (back-reference).

    Requires the ``graph`` extra::

        pip install etcion[graph]

    :raises ImportError: If ``networkx`` is not installed.
    :returns: A ``networkx.MultiDiGraph`` instance.
    """
    if self._nx_graph is not None:
        return self._nx_graph

    try:
        import networkx as nx
    except ImportError:
        raise ImportError(
            "networkx is required for graph operations. "
            "Install it with: pip install etcion[graph]"
        ) from None

    from etcion.metamodel.concepts import RelationshipConnector

    g: object = nx.MultiDiGraph()

    # Add nodes: Elements and RelationshipConnectors (e.g. Junction).
    for concept in self._concepts.values():
        if isinstance(concept, (Element, RelationshipConnector)):
            cls = type(concept)
            attrs = {
                "type": cls,
                "name": getattr(concept, "name", None),
                "layer": getattr(cls, "layer", None),
                "aspect": getattr(cls, "aspect", None),
                "concept": concept,
            }
            g.add_node(concept.id, **attrs)  # type: ignore[attr-defined]

    # Add edges: one directed edge per Relationship.
    for rel in self.relationships:
        g.add_edge(  # type: ignore[attr-defined]
            rel.source.id,
            rel.target.id,
            type=type(rel),
            name=getattr(rel, "name", None),
            rel_id=rel.id,
            relationship=rel,
        )

    self._nx_graph = g
    return g

connected_to(concept)

Return all relationships where concept is source or target (identity check).

Source code in src/etcion/metamodel/model.py
229
230
231
def connected_to(self, concept: Concept) -> list[Relationship]:
    """Return all relationships where *concept* is source or target (identity check)."""
    return [r for r in self.relationships if r.source is concept or r.target is concept]

sources_of(concept)

Return source concepts of all relationships targeting concept.

Source code in src/etcion/metamodel/model.py
233
234
235
def sources_of(self, concept: Concept) -> list[Concept]:
    """Return source concepts of all relationships targeting *concept*."""
    return [r.source for r in self.relationships if r.target is concept]

targets_of(concept)

Return target concepts of all relationships sourced from concept.

Source code in src/etcion/metamodel/model.py
237
238
239
def targets_of(self, concept: Concept) -> list[Concept]:
    """Return target concepts of all relationships sourced from *concept*."""
    return [r.target for r in self.relationships if r.source is concept]

validate(*, strict=False)

Run all model-level validation rules.

Iterates all relationships and checks each against is_permitted().

:param strict: If True, raise on the first error instead of collecting. :returns: List of all :class:~etcion.exceptions.ValidationError instances found. :raises ValidationError: If strict is True and any violation is found.

Source code in src/etcion/metamodel/model.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
def validate(self, *, strict: bool = False) -> list[ValidationError]:
    """Run all model-level validation rules.

    Iterates all relationships and checks each against ``is_permitted()``.

    :param strict: If ``True``, raise on the first error instead of collecting.
    :returns: List of all :class:`~etcion.exceptions.ValidationError` instances found.
    :raises ValidationError: If *strict* is ``True`` and any violation is found.
    """
    from etcion.exceptions import ValidationError
    from etcion.metamodel.concepts import RelationshipConnector
    from etcion.validation.permissions import is_permitted

    errors: list[ValidationError] = []

    # Standard permission checks (skip Junction-connected relationships).
    junction_rels: dict[str, list[Relationship]] = {}
    for rel in self.relationships:
        src_is_junc = isinstance(rel.source, RelationshipConnector)
        tgt_is_junc = isinstance(rel.target, RelationshipConnector)
        # Track Junction adjacency for later validation.
        if src_is_junc:
            junction_rels.setdefault(rel.source.id, []).append(rel)
        if tgt_is_junc:
            junction_rels.setdefault(rel.target.id, []).append(rel)
        # Skip standard permission check for Junction-connected rels.
        if src_is_junc or tgt_is_junc:
            continue
        source_type = type(rel.source)
        target_type = type(rel.target)
        if not is_permitted(type(rel), source_type, target_type):  # type: ignore[arg-type]
            err = ValidationError(
                f"Relationship '{rel.id}' ({type(rel).__name__}: "
                f"{source_type.__name__} -> {target_type.__name__}) "
                f"is not permitted"
            )
            if strict:
                raise err
            errors.append(err)

    # FEAT-15.4: Junction validation.
    for jid, rels in junction_rels.items():
        # 1. Homogeneity: all rels must be the same concrete type.
        rel_types = {type(r) for r in rels}
        if len(rel_types) > 1:
            type_names = sorted(t.__name__ for t in rel_types)
            err = ValidationError(f"Junction '{jid}': mixed relationship types {type_names}")
            if strict:
                raise err
            errors.append(err)
            continue  # skip endpoint check if types are mixed

        # 2. Endpoint permissions: collect non-junction source/target endpoints.
        rel_type = next(iter(rel_types))
        sources: list[type] = []
        targets: list[type] = []
        for r in rels:
            if isinstance(r.source, RelationshipConnector):
                # Junction is source -> r.target is the real target endpoint
                targets.append(type(r.target))
            else:
                # Junction is target -> r.source is the real source endpoint
                sources.append(type(r.source))
        for src in sources:
            for tgt in targets:
                if not is_permitted(rel_type, src, tgt):
                    err = ValidationError(
                        f"Junction '{jid}': {rel_type.__name__} from "
                        f"{src.__name__} to {tgt.__name__} is not permitted"
                    )
                    if strict:
                        raise err
                    errors.append(err)

    # FEAT-18.3: Profile validation.
    for elem in self.elements:
        # Check specialization string
        if elem.specialization is not None:
            if elem.specialization not in self._specialization_registry:
                err = ValidationError(
                    f"Element '{elem.id}': specialization "
                    f"'{elem.specialization}' is not declared in any profile"
                )
                if strict:
                    raise err
                errors.append(err)
            else:
                expected_base = self._specialization_registry[elem.specialization]
                if not isinstance(elem, expected_base):
                    err = ValidationError(
                        f"Element '{elem.id}': specialization "
                        f"'{elem.specialization}' requires base type "
                        f"{expected_base.__name__}, got {type(elem).__name__}"
                    )
                    if strict:
                        raise err
                    errors.append(err)

        # Check extended_attributes against profile declarations
        if elem.extended_attributes:
            # Build allowed attrs for this element's type from all profiles
            allowed: dict[str, type] = {}
            for prof in self._profiles:
                for prof_type, attrs in prof.attribute_extensions.items():
                    if isinstance(elem, prof_type):
                        allowed.update(attrs)
            for attr_name, attr_value in elem.extended_attributes.items():
                if attr_name not in allowed:
                    err = ValidationError(
                        f"Element '{elem.id}': extended attribute "
                        f"'{attr_name}' is not declared in any profile"
                    )
                    if strict:
                        raise err
                    errors.append(err)
                elif not isinstance(attr_value, allowed[attr_name]):
                    err = ValidationError(
                        f"Element '{elem.id}': extended attribute "
                        f"'{attr_name}' expected type "
                        f"{allowed[attr_name].__name__}, "
                        f"got {type(attr_value).__name__}"
                    )
                    if strict:
                        raise err
                    errors.append(err)

    # Custom validation rules (ADR-038 / FEAT-25.2).
    for rule in self._custom_rules:
        custom_errors = rule.validate(self)
        if strict and custom_errors:
            raise custom_errors[0]
        errors.extend(custom_errors)

    return errors