diff --git a/doc/whatsnew/fragments/9045.feature b/doc/whatsnew/fragments/9045.feature
new file mode 100644
index 0000000000..c460b4c4c2
--- /dev/null
+++ b/doc/whatsnew/fragments/9045.feature
@@ -0,0 +1,4 @@
+Enhanced pyreverse to properly distinguish between UML relationship types (association, aggregation, composition) based on object ownership semantics. Type annotations without assignment are now treated as associations, parameter assignments as aggregations, and object instantiation as compositions.
+
+Closes #9045
+Closes #9267
diff --git a/pylint/pyreverse/diagrams.py b/pylint/pyreverse/diagrams.py
index a4fb8ce130..ad074b2b77 100644
--- a/pylint/pyreverse/diagrams.py
+++ b/pylint/pyreverse/diagrams.py
@@ -226,6 +226,7 @@ def extract_relationships(self) -> None:
obj.attrs = self.get_attrs(node)
obj.methods = self.get_methods(node)
obj.shape = "class"
+
# inheritance link
for par_node in node.ancestors(recurs=False):
try:
@@ -234,27 +235,41 @@ def extract_relationships(self) -> None:
except KeyError:
continue
- # associations & aggregations links
- for name, values in list(node.aggregations_type.items()):
+ # Track processed attributes to avoid duplicates
+ processed_attrs = set()
+
+ # Process in priority order: Composition > Aggregation > Association
+
+ # 1. Composition links (highest priority)
+ for name, values in list(node.compositions_type.items()):
+ if not self.show_attr(name):
+ continue
for value in values:
- if not self.show_attr(name):
- continue
+ self.assign_association_relationship(
+ value, obj, name, "composition"
+ )
+ processed_attrs.add(name)
+ # 2. Aggregation links (medium priority)
+ for name, values in list(node.aggregations_type.items()):
+ if not self.show_attr(name) or name in processed_attrs:
+ continue
+ for value in values:
self.assign_association_relationship(
value, obj, name, "aggregation"
)
+ processed_attrs.add(name)
+ # 3. Association links (lowest priority)
associations = node.associations_type.copy()
-
for name, values in node.locals_type.items():
if name not in associations:
associations[name] = values
for name, values in associations.items():
+ if not self.show_attr(name) or name in processed_attrs:
+ continue
for value in values:
- if not self.show_attr(name):
- continue
-
self.assign_association_relationship(
value, obj, name, "association"
)
diff --git a/pylint/pyreverse/dot_printer.py b/pylint/pyreverse/dot_printer.py
index 4baed6c3c2..331a61431e 100644
--- a/pylint/pyreverse/dot_printer.py
+++ b/pylint/pyreverse/dot_printer.py
@@ -30,12 +30,18 @@ class HTMLLabels(Enum):
# pylint: disable-next=consider-using-namedtuple-or-dataclass
ARROWS: dict[EdgeType, dict[str, str]] = {
EdgeType.INHERITS: {"arrowtail": "none", "arrowhead": "empty"},
- EdgeType.ASSOCIATION: {
+ EdgeType.COMPOSITION: {
"fontcolor": "green",
"arrowtail": "none",
"arrowhead": "diamond",
"style": "solid",
},
+ EdgeType.ASSOCIATION: {
+ "fontcolor": "green",
+ "arrowtail": "none",
+ "arrowhead": "vee",
+ "style": "solid",
+ },
EdgeType.AGGREGATION: {
"fontcolor": "green",
"arrowtail": "none",
diff --git a/pylint/pyreverse/inspector.py b/pylint/pyreverse/inspector.py
index 8e69e94470..6cc63634b2 100644
--- a/pylint/pyreverse/inspector.py
+++ b/pylint/pyreverse/inspector.py
@@ -18,6 +18,7 @@
import astroid
from astroid import nodes
+from astroid.typing import InferenceResult
from pylint import constants
from pylint.checkers.utils import safe_infer
@@ -113,6 +114,9 @@ class Linker(IdGeneratorMixIn, utils.LocalsVisitor):
* aggregations_type
as instance_attrs_type but for aggregations relationships
+
+ * compositions_type
+ as instance_attrs_type but for compositions relationships
"""
def __init__(self, project: Project, tag: bool = False) -> None:
@@ -122,8 +126,14 @@ def __init__(self, project: Project, tag: bool = False) -> None:
self.tag = tag
# visited project
self.project = project
- self.associations_handler = AggregationsHandler()
- self.associations_handler.set_next(OtherAssociationsHandler())
+
+ # Chain: Composition → Aggregation → Association
+ self.compositions_handler = CompositionsHandler()
+ aggregation_handler = AggregationsHandler()
+ association_handler = AssociationsHandler()
+
+ self.compositions_handler.set_next(aggregation_handler)
+ aggregation_handler.set_next(association_handler)
def visit_project(self, node: Project) -> None:
"""Visit a pyreverse.utils.Project node.
@@ -167,15 +177,24 @@ def visit_classdef(self, node: nodes.ClassDef) -> None:
specializations.append(node)
baseobj.specializations = specializations
# resolve instance attributes
+ node.compositions_type = collections.defaultdict(list)
node.instance_attrs_type = collections.defaultdict(list)
node.aggregations_type = collections.defaultdict(list)
node.associations_type = collections.defaultdict(list)
for assignattrs in tuple(node.instance_attrs.values()):
for assignattr in assignattrs:
if not isinstance(assignattr, nodes.Unknown):
- self.associations_handler.handle(assignattr, node)
+ self.compositions_handler.handle(assignattr, node)
self.handle_assignattr_type(assignattr, node)
+ # Process class attributes
+ for local_nodes in node.locals.values():
+ for local_node in local_nodes:
+ if isinstance(local_node, nodes.AssignName) and isinstance(
+ local_node.parent, nodes.Assign
+ ):
+ self.compositions_handler.handle(local_node, node)
+
def visit_functiondef(self, node: nodes.FunctionDef) -> None:
"""Visit an astroid.Function node.
@@ -289,83 +308,227 @@ def _imported_module(
mod_paths.append(mod_path)
-class AssociationHandlerInterface(ABC):
+class RelationshipHandlerInterface(ABC):
@abstractmethod
def set_next(
- self, handler: AssociationHandlerInterface
- ) -> AssociationHandlerInterface:
+ self, handler: RelationshipHandlerInterface
+ ) -> RelationshipHandlerInterface:
pass
@abstractmethod
- def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
+ def handle(
+ self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
+ ) -> None:
pass
-class AbstractAssociationHandler(AssociationHandlerInterface):
+class AbstractRelationshipHandler(RelationshipHandlerInterface):
"""
- Chain of Responsibility for handling types of association, useful
- to expand in the future if we want to add more distinct associations.
+ Chain of Responsibility for handling types of relationships, useful
+ to expand in the future if we want to add more distinct relationships.
- Every link of the chain checks if it's a certain type of association.
- If no association is found it's set as a generic association in `associations_type`.
+ Every link of the chain checks if it's a certain type of relationship.
+ If no relationship is found it's set as a generic relationship in `relationships_type`.
The default chaining behavior is implemented inside the base handler
class.
"""
- _next_handler: AssociationHandlerInterface
+ _next_handler: RelationshipHandlerInterface
def set_next(
- self, handler: AssociationHandlerInterface
- ) -> AssociationHandlerInterface:
+ self, handler: RelationshipHandlerInterface
+ ) -> RelationshipHandlerInterface:
self._next_handler = handler
return handler
@abstractmethod
- def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
+ def handle(
+ self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
+ ) -> None:
if self._next_handler:
self._next_handler.handle(node, parent)
-class AggregationsHandler(AbstractAssociationHandler):
- def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
- # Check if we're not in an assignment context
+class CompositionsHandler(AbstractRelationshipHandler):
+ """Handle composition relationships where parent creates child objects."""
+
+ def handle(
+ self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
+ ) -> None:
+ # If the node is not part of an assignment, pass to next handler
if not isinstance(node.parent, (nodes.AnnAssign, nodes.Assign)):
super().handle(node, parent)
return
value = node.parent.value
- # Handle direct name assignments
- if isinstance(value, astroid.node_classes.Name):
- current = set(parent.aggregations_type[node.attrname])
- parent.aggregations_type[node.attrname] = list(
- current | utils.infer_node(node)
- )
+ # Extract the name to handle both AssignAttr and AssignName nodes
+ name = node.attrname if isinstance(node, nodes.AssignAttr) else node.name
+
+ # Composition: direct object creation (self.x = P())
+ if isinstance(value, nodes.Call):
+ inferred_types = utils.infer_node(node)
+ element_types = extract_element_types(inferred_types)
+
+ # Resolve nodes to actual class definitions
+ resolved_types = resolve_to_class_def(element_types)
+
+ current = set(parent.compositions_type[name])
+ parent.compositions_type[name] = list(current | resolved_types)
return
- # Handle comprehensions
+ # Composition: comprehensions with object creation (self.x = [P() for ...])
if isinstance(
value, (nodes.ListComp, nodes.DictComp, nodes.SetComp, nodes.GeneratorExp)
):
- # Determine the type of the element in the comprehension
if isinstance(value, nodes.DictComp):
- element_type = safe_infer(value.value)
+ element = value.value
else:
- element_type = safe_infer(value.elt)
- if element_type:
- current = set(parent.aggregations_type[node.attrname])
- parent.aggregations_type[node.attrname] = list(current | {element_type})
+ element = value.elt
+
+ # If the element is a Call (object creation), it's composition
+ if isinstance(element, nodes.Call):
+ inferred_types = utils.infer_node(node)
+ element_types = extract_element_types(inferred_types)
+
+ # Resolve nodes to actual class definitions
+ resolved_types = resolve_to_class_def(element_types)
+
+ current = set(parent.compositions_type[name])
+ parent.compositions_type[name] = list(current | resolved_types)
return
- # Fallback to parent handler
+ # Not a composition, pass to next handler
super().handle(node, parent)
-class OtherAssociationsHandler(AbstractAssociationHandler):
- def handle(self, node: nodes.AssignAttr, parent: nodes.ClassDef) -> None:
- current = set(parent.associations_type[node.attrname])
- parent.associations_type[node.attrname] = list(current | utils.infer_node(node))
+class AggregationsHandler(AbstractRelationshipHandler):
+ """Handle aggregation relationships where parent receives child objects."""
+
+ def handle(
+ self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
+ ) -> None:
+ # If the node is not part of an assignment, pass to next handler
+ if not isinstance(node.parent, (nodes.AnnAssign, nodes.Assign)):
+ super().handle(node, parent)
+ return
+
+ value = node.parent.value
+
+ # Extract the name to handle both AssignAttr and AssignName nodes
+ name = node.attrname if isinstance(node, nodes.AssignAttr) else node.name
+
+ # Aggregation: direct assignment (self.x = x)
+ if isinstance(value, nodes.Name):
+ inferred_types = utils.infer_node(node)
+ element_types = extract_element_types(inferred_types)
+
+ # Resolve nodes to actual class definitions
+ resolved_types = resolve_to_class_def(element_types)
+
+ current = set(parent.aggregations_type[name])
+ parent.aggregations_type[name] = list(current | resolved_types)
+ return
+
+ # Aggregation: comprehensions without object creation (self.x = [existing_obj for ...])
+ if isinstance(
+ value, (nodes.ListComp, nodes.DictComp, nodes.SetComp, nodes.GeneratorExp)
+ ):
+ if isinstance(value, nodes.DictComp):
+ element = value.value
+ else:
+ element = value.elt
+
+ # If the element is a Name, it means it's an existing object, so it's aggregation
+ if isinstance(element, nodes.Name):
+ inferred_types = utils.infer_node(node)
+ element_types = extract_element_types(inferred_types)
+
+ # Resolve nodes to actual class definitions
+ resolved_types = resolve_to_class_def(element_types)
+
+ current = set(parent.aggregations_type[name])
+ parent.aggregations_type[name] = list(current | resolved_types)
+ return
+
+ # Not an aggregation, pass to next handler
+ super().handle(node, parent)
+
+
+class AssociationsHandler(AbstractRelationshipHandler):
+ """Handle regular association relationships."""
+
+ def handle(
+ self, node: nodes.AssignAttr | nodes.AssignName, parent: nodes.ClassDef
+ ) -> None:
+ # Extract the name to handle both AssignAttr and AssignName nodes
+ name = node.attrname if isinstance(node, nodes.AssignAttr) else node.name
+
+ # Type annotation only (x: P) -> Association
+ # BUT only if there's no actual assignment (to avoid duplicates)
+ if isinstance(node.parent, nodes.AnnAssign) and node.parent.value is None:
+ inferred_types = utils.infer_node(node)
+ element_types = extract_element_types(inferred_types)
+
+ # Resolve nodes to actual class definitions
+ resolved_types = resolve_to_class_def(element_types)
+
+ current = set(parent.associations_type[name])
+ parent.associations_type[name] = list(current | resolved_types)
+ return
+
+ # Everything else is also association (fallback)
+ current = set(parent.associations_type[name])
+ inferred_types = utils.infer_node(node)
+ element_types = extract_element_types(inferred_types)
+
+ # Resolve Name nodes to actual class definitions
+ resolved_types = resolve_to_class_def(element_types)
+ parent.associations_type[name] = list(current | resolved_types)
+
+
+def resolve_to_class_def(types: set[nodes.NodeNG]) -> set[nodes.ClassDef]:
+ """Resolve a set of nodes to ClassDef nodes."""
+ class_defs = set()
+ for node in types:
+ if isinstance(node, nodes.ClassDef):
+ class_defs.add(node)
+ elif isinstance(node, nodes.Name):
+ inferred = safe_infer(node)
+ if isinstance(inferred, nodes.ClassDef):
+ class_defs.add(inferred)
+ elif isinstance(node, astroid.Instance):
+ # Instance of a class -> get the actual class
+ class_defs.add(node._proxied)
+ return class_defs
+
+
+def extract_element_types(inferred_types: set[InferenceResult]) -> set[nodes.NodeNG]:
+ """Extract element types in case the inferred type is a container.
+
+ This function checks if the inferred type is a container type (like list, dict, etc.)
+ and extracts the element type(s) from it. If the inferred type is a direct type (like a class),
+ it adds that type directly to the set of element types it returns.
+ """
+ element_types = set()
+
+ for inferred_type in inferred_types:
+ if isinstance(inferred_type, nodes.Subscript):
+ slice_node = inferred_type.slice
+
+ # Handle both Tuple (dict[K,V]) and single element (list[T])
+ elements = (
+ slice_node.elts if isinstance(slice_node, nodes.Tuple) else [slice_node]
+ )
+
+ for elt in elements:
+ if isinstance(elt, (nodes.Name, nodes.ClassDef)):
+ element_types.add(elt)
+ else:
+ element_types.add(inferred_type)
+
+ return element_types
def project_from_files(
diff --git a/pylint/pyreverse/mermaidjs_printer.py b/pylint/pyreverse/mermaidjs_printer.py
index 0f1ebd04f0..45ad91f763 100644
--- a/pylint/pyreverse/mermaidjs_printer.py
+++ b/pylint/pyreverse/mermaidjs_printer.py
@@ -21,7 +21,8 @@ class MermaidJSPrinter(Printer):
}
ARROWS: dict[EdgeType, str] = {
EdgeType.INHERITS: "--|>",
- EdgeType.ASSOCIATION: "--*",
+ EdgeType.COMPOSITION: "--*",
+ EdgeType.ASSOCIATION: "-->",
EdgeType.AGGREGATION: "--o",
EdgeType.USES: "-->",
EdgeType.TYPE_DEPENDENCY: "..>",
diff --git a/pylint/pyreverse/plantuml_printer.py b/pylint/pyreverse/plantuml_printer.py
index 379d57a4c6..98013224c4 100644
--- a/pylint/pyreverse/plantuml_printer.py
+++ b/pylint/pyreverse/plantuml_printer.py
@@ -21,7 +21,8 @@ class PlantUmlPrinter(Printer):
}
ARROWS: dict[EdgeType, str] = {
EdgeType.INHERITS: "--|>",
- EdgeType.ASSOCIATION: "--*",
+ EdgeType.ASSOCIATION: "-->",
+ EdgeType.COMPOSITION: "--*",
EdgeType.AGGREGATION: "--o",
EdgeType.USES: "-->",
EdgeType.TYPE_DEPENDENCY: "..>",
diff --git a/pylint/pyreverse/printer.py b/pylint/pyreverse/printer.py
index caa7917ca0..3ec1804897 100644
--- a/pylint/pyreverse/printer.py
+++ b/pylint/pyreverse/printer.py
@@ -22,6 +22,7 @@ class NodeType(Enum):
class EdgeType(Enum):
INHERITS = "inherits"
+ COMPOSITION = "composition"
ASSOCIATION = "association"
AGGREGATION = "aggregation"
USES = "uses"
diff --git a/pylint/pyreverse/writer.py b/pylint/pyreverse/writer.py
index e822f67096..7544be69e1 100644
--- a/pylint/pyreverse/writer.py
+++ b/pylint/pyreverse/writer.py
@@ -119,7 +119,12 @@ def write_classes(self, diagram: ClassDiagram) -> None:
if self.config.no_standalone and not any(
obj in (rel.from_object, rel.to_object)
- for rel_type in ("specialization", "association", "aggregation")
+ for rel_type in (
+ "specialization",
+ "association",
+ "aggregation",
+ "composition",
+ )
for rel in diagram.get_relationships(rel_type)
):
continue
@@ -146,6 +151,14 @@ def write_classes(self, diagram: ClassDiagram) -> None:
label=rel.name,
type_=EdgeType.ASSOCIATION,
)
+ # generate compositions
+ for rel in diagram.get_relationships("composition"):
+ self.printer.emit_edge(
+ rel.from_object.fig_id,
+ rel.to_object.fig_id,
+ label=rel.name,
+ type_=EdgeType.COMPOSITION,
+ )
# generate aggregations
for rel in diagram.get_relationships("aggregation"):
if rel.to_object.fig_id in associations[rel.from_object.fig_id]:
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation/comprehensions.mmd b/tests/pyreverse/functional/class_diagrams/aggregation/comprehensions.mmd
deleted file mode 100644
index 6994d91cbb..0000000000
--- a/tests/pyreverse/functional/class_diagrams/aggregation/comprehensions.mmd
+++ /dev/null
@@ -1,14 +0,0 @@
-classDiagram
- class Component {
- name : str
- }
- class Container {
- component_dict : dict[int, Component]
- components : list[Component]
- components_set : set[Component]
- lazy_components : Generator[Component]
- }
- Component --o Container : components
- Component --o Container : component_dict
- Component --o Container : components_set
- Component --o Container : lazy_components
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation/comprehensions.py b/tests/pyreverse/functional/class_diagrams/aggregation/comprehensions.py
deleted file mode 100644
index 7d83430d87..0000000000
--- a/tests/pyreverse/functional/class_diagrams/aggregation/comprehensions.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Test for https://github.com/pylint-dev/pylint/issues/10236
-from collections.abc import Generator
-
-
-class Component:
- """A component class."""
- def __init__(self, name: str):
- self.name = name
-
-
-class Container:
- """A container class that uses comprehension to create components."""
- def __init__(self):
- self.components: list[Component] = [Component(f"component_{i}") for i in range(3)] # list
- self.component_dict: dict[int, Component] = {i: Component(f"dict_component_{i}") for i in range(2)} # dict
- self.components_set: set[Component] = {Component(f"set_component_{i}") for i in range(2)} # set
- self.lazy_components: Generator[Component] = (Component(f"lazy_{i}") for i in range(2)) # generator
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation/fields.mmd b/tests/pyreverse/functional/class_diagrams/aggregation/fields.mmd
deleted file mode 100644
index 9901b175c8..0000000000
--- a/tests/pyreverse/functional/class_diagrams/aggregation/fields.mmd
+++ /dev/null
@@ -1,23 +0,0 @@
-classDiagram
- class A {
- x
- }
- class B {
- x
- }
- class C {
- x
- }
- class D {
- x
- }
- class E {
- x
- }
- class P {
- }
- P --* A : x
- P --* C : x
- P --* D : x
- P --* E : x
- P --o B : x
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation/fields.py b/tests/pyreverse/functional/class_diagrams/aggregation/fields.py
deleted file mode 100644
index a2afb89913..0000000000
--- a/tests/pyreverse/functional/class_diagrams/aggregation/fields.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Test for https://github.com/pylint-dev/pylint/issues/9045
-
-class P:
- pass
-
-class A:
- x: P
-
-class B:
- def __init__(self, x: P):
- self.x = x
-
-class C:
- x: P
-
- def __init__(self, x: P):
- self.x = x
-
-class D:
- x: P
-
- def __init__(self):
- self.x = P()
-
-class E:
- def __init__(self):
- self.x = P()
diff --git a/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.dot b/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.dot
index 6b0287c4a4..aeb167b0f0 100644
--- a/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.dot
+++ b/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.dot
@@ -3,4 +3,5 @@ rankdir=BT
charset="utf-8"
"attributes_annotation.Dummy" [color="black", fontcolor="black", label=<{Dummy|
|}>, shape="record", style="solid"];
"attributes_annotation.Dummy2" [color="black", fontcolor="black", label=<{Dummy2|alternative_optional : int \| None
alternative_optional_swapped : None \| int
alternative_union_syntax : str \| int
class_attr : list[Dummy]
optional : Optional[Dummy]
optional_union : Optional[int \| str]
param : str
union : Union[int, str]
|}>, shape="record", style="solid"];
+"attributes_annotation.Dummy" -> "attributes_annotation.Dummy2" [arrowhead="vee", arrowtail="none", fontcolor="green", label="optional", style="solid"];
}
diff --git a/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.mmd b/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.mmd
index aff946e7a8..e10a62cc61 100644
--- a/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.mmd
+++ b/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.mmd
@@ -11,3 +11,4 @@ classDiagram
param : str
union : Union[int, str]
}
+ Dummy --> Dummy2 : optional
diff --git a/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.puml b/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.puml
index 65bbb3755a..54ca1db05b 100644
--- a/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.puml
+++ b/tests/pyreverse/functional/class_diagrams/annotations/attributes_annotation.puml
@@ -12,4 +12,5 @@ class "Dummy2" as attributes_annotation.Dummy2 {
param : str
union : Union[int, str]
}
+attributes_annotation.Dummy --> attributes_annotation.Dummy2 : optional
@enduml
diff --git a/tests/pyreverse/functional/class_diagrams/attributes/duplicates_9267.mmd b/tests/pyreverse/functional/class_diagrams/attributes/duplicates_9267.mmd
new file mode 100644
index 0000000000..5e451ec3af
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/attributes/duplicates_9267.mmd
@@ -0,0 +1,9 @@
+classDiagram
+ class A {
+ var : int
+ }
+ class B {
+ a_obj
+ func()
+ }
+ A --* B : a_obj
diff --git a/tests/pyreverse/functional/class_diagrams/attributes/duplicates_9267.py b/tests/pyreverse/functional/class_diagrams/attributes/duplicates_9267.py
new file mode 100644
index 0000000000..73d8138646
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/attributes/duplicates_9267.py
@@ -0,0 +1,12 @@
+# Test for https://github.com/pylint-dev/pylint/issues/9267
+class A:
+ def __init__(self) -> None:
+ self.var = 2
+
+class B:
+ def __init__(self) -> None:
+ self.a_obj = A()
+
+ def func(self):
+ self.a_obj = A()
+ self.a_obj = A()
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.dot b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.dot
new file mode 100644
index 0000000000..a191dd0a64
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.dot
@@ -0,0 +1,20 @@
+digraph "classes" {
+rankdir=BT
+charset="utf-8"
+"comprehensions.AggregationContainer" [color="black", fontcolor="black", label=<{AggregationContainer|component_dict : dict[str, Component]
components : list[Component]
components_set : set[Component]
lazy_components : Generator[Component]
|}>, shape="record", style="solid"];
+"comprehensions.AssociationContainer" [color="black", fontcolor="black", label=<{AssociationContainer|component_dict : dict[int, Component]
components : list[Component]
components_set : set[Component]
lazy_components : Generator[Component]
|}>, shape="record", style="solid"];
+"comprehensions.Component" [color="black", fontcolor="black", label=<{Component|name : str
|}>, shape="record", style="solid"];
+"comprehensions.CompositionContainer" [color="black", fontcolor="black", label=<{CompositionContainer|component_dict : dict[int, Component]
components : list[Component]
components_set : set[Component]
lazy_components : Generator[Component]
|}>, shape="record", style="solid"];
+"comprehensions.Component" -> "comprehensions.AssociationContainer" [arrowhead="vee", arrowtail="none", fontcolor="green", label="components", style="solid"];
+"comprehensions.Component" -> "comprehensions.AssociationContainer" [arrowhead="vee", arrowtail="none", fontcolor="green", label="component_dict", style="solid"];
+"comprehensions.Component" -> "comprehensions.AssociationContainer" [arrowhead="vee", arrowtail="none", fontcolor="green", label="components_set", style="solid"];
+"comprehensions.Component" -> "comprehensions.AssociationContainer" [arrowhead="vee", arrowtail="none", fontcolor="green", label="lazy_components", style="solid"];
+"comprehensions.Component" -> "comprehensions.CompositionContainer" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="components", style="solid"];
+"comprehensions.Component" -> "comprehensions.CompositionContainer" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="component_dict", style="solid"];
+"comprehensions.Component" -> "comprehensions.CompositionContainer" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="components_set", style="solid"];
+"comprehensions.Component" -> "comprehensions.CompositionContainer" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="lazy_components", style="solid"];
+"comprehensions.Component" -> "comprehensions.AggregationContainer" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="components", style="solid"];
+"comprehensions.Component" -> "comprehensions.AggregationContainer" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="component_dict", style="solid"];
+"comprehensions.Component" -> "comprehensions.AggregationContainer" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="components_set", style="solid"];
+"comprehensions.Component" -> "comprehensions.AggregationContainer" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="lazy_components", style="solid"];
+}
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.mmd b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.mmd
new file mode 100644
index 0000000000..21d22c7798
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.mmd
@@ -0,0 +1,34 @@
+classDiagram
+ class AggregationContainer {
+ component_dict : dict[str, Component]
+ components : list[Component]
+ components_set : set[Component]
+ lazy_components : Generator[Component]
+ }
+ class AssociationContainer {
+ component_dict : dict[int, Component]
+ components : list[Component]
+ components_set : set[Component]
+ lazy_components : Generator[Component]
+ }
+ class Component {
+ name : str
+ }
+ class CompositionContainer {
+ component_dict : dict[int, Component]
+ components : list[Component]
+ components_set : set[Component]
+ lazy_components : Generator[Component]
+ }
+ Component --> AssociationContainer : components
+ Component --> AssociationContainer : component_dict
+ Component --> AssociationContainer : components_set
+ Component --> AssociationContainer : lazy_components
+ Component --* CompositionContainer : components
+ Component --* CompositionContainer : component_dict
+ Component --* CompositionContainer : components_set
+ Component --* CompositionContainer : lazy_components
+ Component --o AggregationContainer : components
+ Component --o AggregationContainer : component_dict
+ Component --o AggregationContainer : components_set
+ Component --o AggregationContainer : lazy_components
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.puml b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.puml
new file mode 100644
index 0000000000..2398f6633d
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.puml
@@ -0,0 +1,36 @@
+@startuml classes
+set namespaceSeparator none
+class "AggregationContainer" as comprehensions.AggregationContainer {
+ component_dict : dict[str, Component]
+ components : list[Component]
+ components_set : set[Component]
+ lazy_components : Generator[Component]
+}
+class "AssociationContainer" as comprehensions.AssociationContainer {
+ component_dict : dict[int, Component]
+ components : list[Component]
+ components_set : set[Component]
+ lazy_components : Generator[Component]
+}
+class "Component" as comprehensions.Component {
+ name : str
+}
+class "CompositionContainer" as comprehensions.CompositionContainer {
+ component_dict : dict[int, Component]
+ components : list[Component]
+ components_set : set[Component]
+ lazy_components : Generator[Component]
+}
+comprehensions.Component --> comprehensions.AssociationContainer : components
+comprehensions.Component --> comprehensions.AssociationContainer : component_dict
+comprehensions.Component --> comprehensions.AssociationContainer : components_set
+comprehensions.Component --> comprehensions.AssociationContainer : lazy_components
+comprehensions.Component --* comprehensions.CompositionContainer : components
+comprehensions.Component --* comprehensions.CompositionContainer : component_dict
+comprehensions.Component --* comprehensions.CompositionContainer : components_set
+comprehensions.Component --* comprehensions.CompositionContainer : lazy_components
+comprehensions.Component --o comprehensions.AggregationContainer : components
+comprehensions.Component --o comprehensions.AggregationContainer : component_dict
+comprehensions.Component --o comprehensions.AggregationContainer : components_set
+comprehensions.Component --o comprehensions.AggregationContainer : lazy_components
+@enduml
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.py b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.py
new file mode 100644
index 0000000000..4f088a6a9d
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.py
@@ -0,0 +1,36 @@
+# Test for https://github.com/pylint-dev/pylint/issues/10236
+from collections.abc import Generator
+from dataclasses import dataclass
+
+
+class Component:
+ """A component class."""
+ def __init__(self, name: str):
+ self.name = name
+
+class AssociationContainer:
+ """Type hints only - no ownership."""
+ def __init__(self):
+ # Association: just type hints, no actual assignment
+ self.components: list[Component]
+ self.component_dict: dict[int, Component]
+ self.components_set: set[Component]
+ self.lazy_components: Generator[Component]
+
+class AggregationContainer:
+ """Comprehensions using existing objects - aggregation."""
+ def __init__(self, existing_components: list[Component]):
+ # Aggregation: comprehensions using existing objects (not creating)
+ self.components: list[Component] = [comp for comp in existing_components]
+ self.component_dict: dict[str, Component] = {f"key_{i}": comp for i, comp in enumerate(existing_components)}
+ self.components_set: set[Component] = {comp for comp in existing_components}
+ self.lazy_components: Generator[Component] = (comp for comp in existing_components)
+
+class CompositionContainer:
+ """Comprehensions creating new objects - composition."""
+ def __init__(self):
+ # Composition: comprehensions creating new objects
+ self.components: list[Component] = [Component(f"component_{i}") for i in range(3)]
+ self.component_dict: dict[int, Component] = {i: Component(f"dict_component_{i}") for i in range(2)}
+ self.components_set: set[Component] = {Component(f"set_component_{i}") for i in range(2)}
+ self.lazy_components: Generator[Component] = (Component(f"lazy_{i}") for i in range(2))
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.rc b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.rc
new file mode 100644
index 0000000000..9e2ff3d953
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/comprehensions.rc
@@ -0,0 +1,2 @@
+[testoptions]
+output_formats=mmd,dot,puml
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/fields.dot b/tests/pyreverse/functional/class_diagrams/relationships/fields.dot
new file mode 100644
index 0000000000..dda2320d65
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/fields.dot
@@ -0,0 +1,15 @@
+digraph "classes" {
+rankdir=BT
+charset="utf-8"
+"fields.Aggregation1" [color="black", fontcolor="black", label=<{Aggregation1|x
|}>, shape="record", style="solid"];
+"fields.Aggregation2" [color="black", fontcolor="black", label=<{Aggregation2|x
|}>, shape="record", style="solid"];
+"fields.Association" [color="black", fontcolor="black", label=<{Association|x
|}>, shape="record", style="solid"];
+"fields.Composition1" [color="black", fontcolor="black", label=<{Composition1|x
|}>, shape="record", style="solid"];
+"fields.Composition2" [color="black", fontcolor="black", label=<{Composition2|x
|}>, shape="record", style="solid"];
+"fields.P" [color="black", fontcolor="black", label=<{P|
|}>, shape="record", style="solid"];
+"fields.P" -> "fields.Association" [arrowhead="vee", arrowtail="none", fontcolor="green", label="x", style="solid"];
+"fields.P" -> "fields.Composition1" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="x", style="solid"];
+"fields.P" -> "fields.Composition2" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="x", style="solid"];
+"fields.P" -> "fields.Aggregation1" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="x", style="solid"];
+"fields.P" -> "fields.Aggregation2" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="x", style="solid"];
+}
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/fields.mmd b/tests/pyreverse/functional/class_diagrams/relationships/fields.mmd
new file mode 100644
index 0000000000..5a2a70002a
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/fields.mmd
@@ -0,0 +1,23 @@
+classDiagram
+ class Aggregation1 {
+ x
+ }
+ class Aggregation2 {
+ x
+ }
+ class Association {
+ x
+ }
+ class Composition1 {
+ x
+ }
+ class Composition2 {
+ x
+ }
+ class P {
+ }
+ P --> Association : x
+ P --* Composition1 : x
+ P --* Composition2 : x
+ P --o Aggregation1 : x
+ P --o Aggregation2 : x
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/fields.puml b/tests/pyreverse/functional/class_diagrams/relationships/fields.puml
new file mode 100644
index 0000000000..1fd32259a1
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/fields.puml
@@ -0,0 +1,25 @@
+@startuml classes
+set namespaceSeparator none
+class "Aggregation1" as fields.Aggregation1 {
+ x
+}
+class "Aggregation2" as fields.Aggregation2 {
+ x
+}
+class "Association" as fields.Association {
+ x
+}
+class "Composition1" as fields.Composition1 {
+ x
+}
+class "Composition2" as fields.Composition2 {
+ x
+}
+class "P" as fields.P {
+}
+fields.P --> fields.Association : x
+fields.P --* fields.Composition1 : x
+fields.P --* fields.Composition2 : x
+fields.P --o fields.Aggregation1 : x
+fields.P --o fields.Aggregation2 : x
+@enduml
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/fields.py b/tests/pyreverse/functional/class_diagrams/relationships/fields.py
new file mode 100644
index 0000000000..be8b5c1fd9
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/fields.py
@@ -0,0 +1,25 @@
+# Test for https://github.com/pylint-dev/pylint/issues/9045
+
+class P:
+ pass
+
+class Association:
+ x: P # just type hint, no ownership → Association
+
+class Aggregation1:
+ def __init__(self, x: P):
+ self.x = x # receives object, not created → Aggregation
+
+class Aggregation2:
+ x: P
+ def __init__(self, x: P):
+ self.x = x # receives object, not created → Aggregation
+
+class Composition1:
+ x: P
+ def __init__(self):
+ self.x = P() # creates object → Composition
+
+class Composition2:
+ def __init__(self):
+ self.x = P() # creates object → Composition
diff --git a/tests/pyreverse/functional/class_diagrams/relationships/fields.rc b/tests/pyreverse/functional/class_diagrams/relationships/fields.rc
new file mode 100644
index 0000000000..9e2ff3d953
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/relationships/fields.rc
@@ -0,0 +1,2 @@
+[testoptions]
+output_formats=mmd,dot,puml
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/all.mmd b/tests/pyreverse/functional/class_diagrams/relationships_filtering/all.mmd
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/all.mmd
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/all.mmd
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/all.py b/tests/pyreverse/functional/class_diagrams/relationships_filtering/all.py
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/all.py
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/all.py
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/all.rc b/tests/pyreverse/functional/class_diagrams/relationships_filtering/all.rc
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/all.rc
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/all.rc
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/other.mmd b/tests/pyreverse/functional/class_diagrams/relationships_filtering/other.mmd
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/other.mmd
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/other.mmd
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/other.py b/tests/pyreverse/functional/class_diagrams/relationships_filtering/other.py
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/other.py
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/other.py
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/other.rc b/tests/pyreverse/functional/class_diagrams/relationships_filtering/other.rc
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/other.rc
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/other.rc
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/pub_only.mmd b/tests/pyreverse/functional/class_diagrams/relationships_filtering/pub_only.mmd
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/pub_only.mmd
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/pub_only.mmd
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/pub_only.py b/tests/pyreverse/functional/class_diagrams/relationships_filtering/pub_only.py
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/pub_only.py
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/pub_only.py
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/pub_only.rc b/tests/pyreverse/functional/class_diagrams/relationships_filtering/pub_only.rc
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/pub_only.rc
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/pub_only.rc
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/special.mmd b/tests/pyreverse/functional/class_diagrams/relationships_filtering/special.mmd
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/special.mmd
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/special.mmd
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/special.py b/tests/pyreverse/functional/class_diagrams/relationships_filtering/special.py
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/special.py
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/special.py
diff --git a/tests/pyreverse/functional/class_diagrams/aggregation_filtering/special.rc b/tests/pyreverse/functional/class_diagrams/relationships_filtering/special.rc
similarity index 100%
rename from tests/pyreverse/functional/class_diagrams/aggregation_filtering/special.rc
rename to tests/pyreverse/functional/class_diagrams/relationships_filtering/special.rc
diff --git a/tests/pyreverse/test_diadefs.py b/tests/pyreverse/test_diadefs.py
index 5da1aa1e7f..ce39cb5503 100644
--- a/tests/pyreverse/test_diadefs.py
+++ b/tests/pyreverse/test_diadefs.py
@@ -181,8 +181,8 @@ class CustomError(Exception):
class TestDefaultDiadefGenerator:
_should_rels = [
("aggregation", "DoNothing2", "Specialization"),
- ("association", "DoNothing", "Ancestor"),
- ("association", "DoNothing", "Specialization"),
+ ("composition", "DoNothing", "Ancestor"),
+ ("composition", "DoNothing", "Specialization"),
("specialization", "Specialization", "Ancestor"),
]