Skip to content

Commit 05e83c4

Browse files
Add --max-depth option to control diagram complexity (#10077)
Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
1 parent 9822fe9 commit 05e83c4

File tree

12 files changed

+245
-1
lines changed

12 files changed

+245
-1
lines changed

doc/additional_tools/pyreverse/configuration.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ Filtering and Scope
5858
**Default:** ``PUB_ONLY``
5959

6060

61+
--max-depth
62+
-----------
63+
*Maximum depth in package/module hierarchy to display. A depth of 0 shows only top-level packages, 1 shows one level of subpackages, etc. If not specified, all packages/modules are shown.*
64+
65+
**Default:** ``None``
66+
67+
6168
--show-ancestors
6269
----------------
6370
*Show <ancestor> generations of ancestor classes not in <projects>.*

doc/whatsnew/fragments/10077.feature

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add --max-depth option to pyreverse to control diagram complexity. A depth of 0 shows only top-level packages, 1 shows one level of subpackages, etc.
2+
This helps manage visualization of large codebases by limiting the depth of displayed packages and classes.
3+
4+
Refs #10077

pylint/pyreverse/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@
151151
"help": "Include standard library objects in representation of classes.",
152152
},
153153
),
154+
(
155+
"max-depth",
156+
{
157+
"dest": "max_depth",
158+
"action": "store",
159+
"default": None,
160+
"metavar": "<depth>",
161+
"type": "int",
162+
"group": OPTIONS_GROUPS["FILTERING"],
163+
"help": (
164+
"Maximum depth in package/module hierarchy to display. A depth of 0 shows only "
165+
"top-level packages, 1 shows one level of subpackages, etc. If not specified, "
166+
"all packages/modules are shown."
167+
),
168+
},
169+
),
154170
# Display Options
155171
(
156172
"module-names",

pylint/pyreverse/writer.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def __init__(self, config: argparse.Namespace) -> None:
3535
self.printer: Printer # defined in set_printer
3636
self.file_name = "" # defined in set_printer
3737
self.depth = self.config.max_color_depth
38+
self.max_depth = self.config.max_depth
3839
# default colors are an adaption of the seaborn colorblind palette
3940
self.available_colors = itertools.cycle(self.config.color_palette)
4041
self.used_colors: dict[str, str] = {}
@@ -53,6 +54,38 @@ def write(self, diadefs: Iterable[ClassDiagram | PackageDiagram]) -> None:
5354
self.write_classes(diagram)
5455
self.save()
5556

57+
def should_show_node(self, qualified_name: str, is_class: bool = False) -> bool:
58+
"""Determine if a node should be shown based on depth settings.
59+
60+
Depth is calculated by counting dots in the qualified name:
61+
- depth 0: top-level packages (no dots)
62+
- depth 1: first level sub-packages (one dot)
63+
- depth 2: second level sub-packages (two dots)
64+
65+
For classes, depth is measured from their containing module, excluding
66+
the class name itself from the depth calculation.
67+
"""
68+
# If no depth limit is set ==> show all nodes
69+
if self.max_depth is None:
70+
return True
71+
72+
# For classes, we want to measure depth from their containing module
73+
if is_class:
74+
# Get the module part (everything before the last dot)
75+
last_dot = qualified_name.rfind(".")
76+
if last_dot == -1:
77+
module_path = ""
78+
else:
79+
module_path = qualified_name[:last_dot]
80+
81+
# Count module depth
82+
module_depth = module_path.count(".")
83+
return bool(module_depth <= self.max_depth)
84+
85+
# For packages/modules, count full depth
86+
node_depth = qualified_name.count(".")
87+
return bool(node_depth <= self.max_depth)
88+
5689
def write_packages(self, diagram: PackageDiagram) -> None:
5790
"""Write a package diagram."""
5891
module_info: dict[str, dict[str, int]] = {}
@@ -61,6 +94,10 @@ def write_packages(self, diagram: PackageDiagram) -> None:
6194
for module in sorted(diagram.modules(), key=lambda x: x.title):
6295
module.fig_id = module.node.qname()
6396

97+
# Filter nodes based on depth
98+
if not self.should_show_node(module.fig_id):
99+
continue
100+
64101
if self.config.no_standalone and not any(
65102
module in (rel.from_object, rel.to_object)
66103
for rel in diagram.get_relationships("depends")
@@ -83,6 +120,10 @@ def write_packages(self, diagram: PackageDiagram) -> None:
83120
from_id = rel.from_object.fig_id
84121
to_id = rel.to_object.fig_id
85122

123+
# Filter nodes based on depth ==> skip if either source or target nodes is beyond the max depth
124+
if not self.should_show_node(from_id) or not self.should_show_node(to_id):
125+
continue
126+
86127
self.printer.emit_edge(
87128
from_id,
88129
to_id,
@@ -96,6 +137,10 @@ def write_packages(self, diagram: PackageDiagram) -> None:
96137
from_id = rel.from_object.fig_id
97138
to_id = rel.to_object.fig_id
98139

140+
# Filter nodes based on depth ==> skip if either source or target nodes is beyond the max depth
141+
if not self.should_show_node(from_id) or not self.should_show_node(to_id):
142+
continue
143+
99144
self.printer.emit_edge(
100145
from_id,
101146
to_id,
@@ -115,6 +160,11 @@ def write_classes(self, diagram: ClassDiagram) -> None:
115160
# sorted to get predictable (hence testable) results
116161
for obj in sorted(diagram.objects, key=lambda x: x.title):
117162
obj.fig_id = obj.node.qname()
163+
164+
# Filter class based on depth setting
165+
if not self.should_show_node(obj.fig_id, is_class=True):
166+
continue
167+
118168
if self.config.no_standalone and not any(
119169
obj in (rel.from_object, rel.to_object)
120170
for rel_type in ("specialization", "association", "aggregation")
@@ -129,6 +179,12 @@ def write_classes(self, diagram: ClassDiagram) -> None:
129179
)
130180
# inheritance links
131181
for rel in diagram.get_relationships("specialization"):
182+
# Filter nodes based on depth setting
183+
if not self.should_show_node(
184+
rel.from_object.fig_id, is_class=True
185+
) or not self.should_show_node(rel.to_object.fig_id, is_class=True):
186+
continue
187+
132188
self.printer.emit_edge(
133189
rel.from_object.fig_id,
134190
rel.to_object.fig_id,
@@ -137,6 +193,12 @@ def write_classes(self, diagram: ClassDiagram) -> None:
137193
associations: dict[str, set[str]] = defaultdict(set)
138194
# generate associations
139195
for rel in diagram.get_relationships("association"):
196+
# Filter nodes based on depth setting
197+
if not self.should_show_node(
198+
rel.from_object.fig_id, is_class=True
199+
) or not self.should_show_node(rel.to_object.fig_id, is_class=True):
200+
continue
201+
140202
associations[rel.from_object.fig_id].add(rel.to_object.fig_id)
141203
self.printer.emit_edge(
142204
rel.from_object.fig_id,
@@ -146,6 +208,12 @@ def write_classes(self, diagram: ClassDiagram) -> None:
146208
)
147209
# generate aggregations
148210
for rel in diagram.get_relationships("aggregation"):
211+
# Filter nodes based on depth setting
212+
if not self.should_show_node(
213+
rel.from_object.fig_id, is_class=True
214+
) or not self.should_show_node(rel.to_object.fig_id, is_class=True):
215+
continue
216+
149217
if rel.to_object.fig_id in associations[rel.from_object.fig_id]:
150218
continue
151219
self.printer.emit_edge(

pylint/testutils/pyreverse.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class PyreverseConfig(
2323
The default values correspond to the defaults of the options' parser.
2424
"""
2525

26+
# pylint: disable=too-many-locals
2627
def __init__(
2728
self,
2829
*,
@@ -40,6 +41,7 @@ def __init__(
4041
output_format: str = "dot",
4142
colorized: bool = False,
4243
max_color_depth: int = 2,
44+
max_depth: int | None = None,
4345
color_palette: tuple[str, ...] = DEFAULT_COLOR_PALETTE,
4446
ignore_list: tuple[str, ...] = tuple(),
4547
project: str = "",
@@ -62,12 +64,15 @@ def __init__(
6264
self.only_classnames = only_classnames
6365
self.output_format = output_format
6466
self.colorized = colorized
67+
self.max_depth = max_depth
6568
self.max_color_depth = max_color_depth
6669
self.color_palette = color_palette
6770
self.ignore_list = ignore_list
6871
self.project = project
6972
self.output_directory = output_directory
7073

74+
# pylint: enable=too-many-locals
75+
7176

7277
class TestFileOptions(TypedDict):
7378
source_roots: list[str]

pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ missing-member-max-choices=1
393393
spelling-dict=
394394

395395
# List of comma separated words that should not be checked.
396-
spelling-ignore-words=
396+
spelling-ignore-words=subpkg,MyClass
397397

398398
# List of comma separated words that should be considered directives if they
399399
# appear and the beginning of a comment and should not be checked.

tests/pyreverse/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ def html_config() -> PyreverseConfig:
6767
)
6868

6969

70+
@pytest.fixture()
71+
def depth_limited_config(default_max_depth: int) -> PyreverseConfig:
72+
return PyreverseConfig(
73+
max_depth=default_max_depth,
74+
)
75+
76+
7077
@pytest.fixture(scope="session")
7178
def get_project() -> GetProjectCallable:
7279
def _get_project(module: str, name: str | None = "No Name") -> Project:
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
digraph "classes_depth_limited_0" {
2+
rankdir=BT
3+
charset="utf-8"
4+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
digraph "classes_depth_limited_1" {
2+
rankdir=BT
3+
charset="utf-8"
4+
"data.clientmodule_test.Ancestor" [color="black", fontcolor="black", label=<{Ancestor|attr : str<br ALIGN="LEFT"/>cls_member<br ALIGN="LEFT"/>|get_value()<br ALIGN="LEFT"/>set_value(value)<br ALIGN="LEFT"/>}>, shape="record", style="solid"];
5+
"data.suppliermodule_test.CustomException" [color="black", fontcolor="red", label=<{CustomException|<br ALIGN="LEFT"/>|}>, shape="record", style="solid"];
6+
"data.suppliermodule_test.DoNothing" [color="black", fontcolor="black", label=<{DoNothing|<br ALIGN="LEFT"/>|}>, shape="record", style="solid"];
7+
"data.suppliermodule_test.DoNothing2" [color="black", fontcolor="black", label=<{DoNothing2|<br ALIGN="LEFT"/>|}>, shape="record", style="solid"];
8+
"data.suppliermodule_test.DoSomething" [color="black", fontcolor="black", label=<{DoSomething|my_int : Optional[int]<br ALIGN="LEFT"/>my_int_2 : Optional[int]<br ALIGN="LEFT"/>my_string : str<br ALIGN="LEFT"/>|do_it(new_int: int): int<br ALIGN="LEFT"/>}>, shape="record", style="solid"];
9+
"data.suppliermodule_test.Interface" [color="black", fontcolor="black", label=<{Interface|<br ALIGN="LEFT"/>|<I>get_value</I>()<br ALIGN="LEFT"/><I>set_value</I>(value)<br ALIGN="LEFT"/>}>, shape="record", style="solid"];
10+
"data.nullable_pattern.NullablePatterns" [color="black", fontcolor="black", label=<{NullablePatterns|<br ALIGN="LEFT"/>|<I>return_nullable_1</I>(): int \| None<br ALIGN="LEFT"/><I>return_nullable_2</I>(): Optional[int]<br ALIGN="LEFT"/>}>, shape="record", style="solid"];
11+
"data.property_pattern.PropertyPatterns" [color="black", fontcolor="black", label=<{PropertyPatterns|prop1<br ALIGN="LEFT"/>prop2<br ALIGN="LEFT"/>|}>, shape="record", style="solid"];
12+
"data.clientmodule_test.Specialization" [color="black", fontcolor="black", label=<{Specialization|TYPE : str<br ALIGN="LEFT"/>relation<br ALIGN="LEFT"/>relation2<br ALIGN="LEFT"/>top : str<br ALIGN="LEFT"/>|from_value(value: int)<br ALIGN="LEFT"/>increment_value(): None<br ALIGN="LEFT"/>transform_value(value: int): int<br ALIGN="LEFT"/>}>, shape="record", style="solid"];
13+
"data.clientmodule_test.Specialization" -> "data.clientmodule_test.Ancestor" [arrowhead="empty", arrowtail="none"];
14+
"data.suppliermodule_test.DoNothing" -> "data.clientmodule_test.Ancestor" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="cls_member", style="solid"];
15+
"data.suppliermodule_test.DoNothing" -> "data.clientmodule_test.Specialization" [arrowhead="diamond", arrowtail="none", fontcolor="green", label="relation", style="solid"];
16+
"data.suppliermodule_test.DoNothing2" -> "data.clientmodule_test.Specialization" [arrowhead="odiamond", arrowtail="none", fontcolor="green", label="relation2", style="solid"];
17+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
digraph "packages_depth_limited_0" {
2+
rankdir=BT
3+
charset="utf-8"
4+
"data" [color="black", label=<data>, shape="box", style="solid"];
5+
}

0 commit comments

Comments
 (0)