Skip to content

Commit 2f5bc4e

Browse files
committed
Introduce composition
1 parent c555bf4 commit 2f5bc4e

File tree

4 files changed

+66
-10
lines changed

4 files changed

+66
-10
lines changed

pylint/pyreverse/diagrams.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,14 @@ def extract_relationships(self) -> None:
234234
except KeyError:
235235
continue
236236

237-
# associations & aggregations links
237+
# Composition links
238+
for name, values in list(node.compositions_type.items()):
239+
for value in values:
240+
self.assign_association_relationship(
241+
value, obj, name, "composition"
242+
)
243+
244+
# Aggregation links
238245
for name, values in list(node.aggregations_type.items()):
239246
for value in values:
240247
if not self.show_attr(name):
@@ -244,8 +251,8 @@ def extract_relationships(self) -> None:
244251
value, obj, name, "aggregation"
245252
)
246253

254+
# Association links
247255
associations = node.associations_type.copy()
248-
249256
for name, values in node.locals_type.items():
250257
if name not in associations:
251258
associations[name] = values

pylint/pyreverse/inspector.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,14 @@ def __init__(self, project: Project, tag: bool = False) -> None:
121121
self.tag = tag
122122
# visited project
123123
self.project = project
124-
self.associations_handler = AggregationsHandler()
125-
self.associations_handler.set_next(OtherAssociationsHandler())
124+
125+
# Chain: Composition → Aggregation → Association
126+
self.associations_handler = CompositionsHandler()
127+
aggregation_handler = AggregationsHandler()
128+
association_handler = AssociationsHandler()
129+
130+
self.associations_handler.set_next(aggregation_handler)
131+
aggregation_handler.set_next(association_handler)
126132

127133
def visit_project(self, node: Project) -> None:
128134
"""Visit a pyreverse.utils.Project node.
@@ -166,6 +172,7 @@ def visit_classdef(self, node: nodes.ClassDef) -> None:
166172
specializations.append(node)
167173
baseobj.specializations = specializations
168174
# resolve instance attributes
175+
node.compositions_type = collections.defaultdict(list)
169176
node.instance_attrs_type = collections.defaultdict(list)
170177
node.aggregations_type = collections.defaultdict(list)
171178
node.associations_type = collections.defaultdict(list)
@@ -326,28 +333,50 @@ def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
326333
self._next_handler.handle(node, parent)
327334

328335

336+
class CompositionsHandler(AbstractAssociationHandler):
337+
"""Handle composition relationships where parent creates child objects."""
338+
339+
def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
340+
if not isinstance(node.parent, (nodes.AnnAssign, nodes.Assign)):
341+
super().handle(node, parent)
342+
return
343+
344+
value = node.parent.value
345+
346+
# Composition: parent creates child (self.x = P())
347+
if isinstance(value, nodes.Call):
348+
current = set(parent.compositions_type[node.attrname])
349+
parent.compositions_type[node.attrname] = list(
350+
current | utils.infer_node(node)
351+
)
352+
return
353+
354+
# Not a composition, pass to next handler
355+
super().handle(node, parent)
356+
357+
329358
class AggregationsHandler(AbstractAssociationHandler):
359+
"""Handle aggregation relationships where parent receives child objects."""
360+
330361
def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
331-
# Check if we're not in an assignment context
332362
if not isinstance(node.parent, (nodes.AnnAssign, nodes.Assign)):
333363
super().handle(node, parent)
334364
return
335365

336366
value = node.parent.value
337367

338-
# Handle direct name assignments
368+
# Aggregation: parent receives child (self.x = x)
339369
if isinstance(value, astroid.node_classes.Name):
340370
current = set(parent.aggregations_type[node.attrname])
341371
parent.aggregations_type[node.attrname] = list(
342372
current | utils.infer_node(node)
343373
)
344374
return
345375

346-
# Handle comprehensions
376+
# Aggregation: comprehensions (self.x = [P() for ...])
347377
if isinstance(
348378
value, (nodes.ListComp, nodes.DictComp, nodes.SetComp, nodes.GeneratorExp)
349379
):
350-
# Determine the type of the element in the comprehension
351380
if isinstance(value, nodes.DictComp):
352381
element_type = safe_infer(value.value)
353382
else:
@@ -357,12 +386,23 @@ def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
357386
parent.aggregations_type[node.attrname] = list(current | {element_type})
358387
return
359388

360-
# Fallback to parent handler
389+
# Type annotation only (x: P) defaults to aggregation
390+
if isinstance(node.parent, nodes.AnnAssign) and node.parent.value is None:
391+
current = set(parent.aggregations_type[node.attrname])
392+
parent.aggregations_type[node.attrname] = list(
393+
current | utils.infer_node(node)
394+
)
395+
return
396+
397+
# Not an aggregation, pass to next handler
361398
super().handle(node, parent)
362399

363400

364-
class OtherAssociationsHandler(AbstractAssociationHandler):
401+
class AssociationsHandler(AbstractAssociationHandler):
402+
"""Handle regular association relationships."""
403+
365404
def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
405+
# Everything else is a regular association
366406
current = set(parent.associations_type[node.attrname])
367407
parent.associations_type[node.attrname] = list(current | utils.infer_node(node))
368408

pylint/pyreverse/printer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class NodeType(Enum):
2222

2323
class EdgeType(Enum):
2424
INHERITS = "inherits"
25+
COMPOSITION = "composition"
2526
ASSOCIATION = "association"
2627
AGGREGATION = "aggregation"
2728
USES = "uses"

pylint/pyreverse/writer.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ def write_classes(self, diagram: ClassDiagram) -> None:
146146
label=rel.name,
147147
type_=EdgeType.ASSOCIATION,
148148
)
149+
# generate compositions
150+
for rel in diagram.get_relationships("composition"):
151+
self.printer.emit_edge(
152+
rel.from_object.fig_id,
153+
rel.to_object.fig_id,
154+
label=rel.name,
155+
type_=EdgeType.COMPOSITION,
156+
)
149157
# generate aggregations
150158
for rel in diagram.get_relationships("aggregation"):
151159
if rel.to_object.fig_id in associations[rel.from_object.fig_id]:

0 commit comments

Comments
 (0)