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"), ]