From 4216bc84ea5ae71dd4f46670280d3344780f0ffc Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 26 Mar 2026 11:49:00 +0100 Subject: [PATCH 1/3] Add basic stuff to py dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4fc91a3..8959cb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ dev = [ "ruff", "pytest", "pytest-cov", + "pylint", + "graphviz" ] test = ["pytest", "pytest-mypy", "pytest-cov", "types-pytz"] From a76a1065085507d930e4b03bd9c36d588dce3301 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 26 Mar 2026 11:49:17 +0100 Subject: [PATCH 2/3] Add first pass of style pyreverse --- style_pyreverse.py | 213 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 style_pyreverse.py diff --git a/style_pyreverse.py b/style_pyreverse.py new file mode 100644 index 0000000..d801e66 --- /dev/null +++ b/style_pyreverse.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +style_pyreverse.py +------------------ +Wraps pyreverse to produce nicely styled UML class diagrams. + +Usage: + python style_pyreverse.py [pyreverse args...] + +Example (drop-in replacement for your existing command): + python style_pyreverse.py \ + -p formatted_rich_handler \ + -c src.daqpytools.logging.filters.BaseHandlerFilter \ + daqpytools \ + --output-directory pics + +The script: + 1. Runs pyreverse with -o dot (always, regardless of what you pass) + 2. Post-processes the .dot file to apply a clean UML style + 3. Renders to PNG via Graphviz + +Dependencies: + pip install graphviz # Python graphviz bindings (optional, fallback uses subprocess) + pip install pylint # provides pyreverse + graphviz must be installed on your system (provides the 'dot' binary) +""" + +import re +import subprocess +import sys +import os +import argparse +from pathlib import Path + +# ── Colour palette (tweak these to your taste) ────────────────────────────── +STYLE = { + # Node (class box) colours + "class_fill": "#FFFDE7", # warm cream (matches Image 1) + "class_fill_dark": "#FFF9C4", # slightly deeper cream for header rows + "class_stroke": "#A0522D", # siennna-ish border + "class_font": "Helvetica", # clean sans-serif + "class_fontsize": "10", + + # Edge colours + "inherit_color": "#555555", # solid grey for inheritance + "uses_color": "#555555", # dashed grey for dependencies + + # Graph background + "bg_color": "white", + "rankdir": "BT", # bottom-to-top like a proper UML diagram +} +# ──────────────────────────────────────────────────────────────────────────── + + +def run_pyreverse(extra_args: list[str], output_dir: Path) -> list[Path]: + """Run pyreverse with -o dot and return the paths to generated .dot files.""" + cmd = [ + "pyreverse", + "-o", "dot", + "--output-directory", str(output_dir), + ] + extra_args + + print(f"[style_pyreverse] Running: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + print("[style_pyreverse] pyreverse stderr:", result.stderr) + sys.exit(result.returncode) + + dot_files = list(output_dir.glob("*.dot")) + if not dot_files: + print("[style_pyreverse] ERROR: pyreverse produced no .dot files in", output_dir) + sys.exit(1) + + return dot_files + + +def patch_dot(dot_src: str) -> str: + """ + Rewrite the .dot source to apply a clean UML style: + - cream fill for all class nodes + - proper record shape kept intact + - inheritance arrows → open hollow arrowhead (UML style) + - dependency arrows → dashed with open arrowhead + """ + s = STYLE + + # ── 1. Graph-level defaults ────────────────────────────────────────────── + graph_defaults = f""" + graph [bgcolor="{s['bg_color']}", fontname="{s['class_font']}", pad="0.5", nodesep="0.6", ranksep="0.8"]; + node [shape=record, style="filled,rounded", fillcolor="{s['class_fill']}", + color="{s['class_stroke']}", fontname="{s['class_font']}", + fontsize={s['class_fontsize']}]; + edge [fontname="{s['class_font']}", fontsize="9"]; +""" + + # Insert defaults right after the opening brace of the digraph + dot_src = re.sub( + r'(digraph\s+\S+\s*\{)', + r'\1' + graph_defaults, + dot_src, + count=1, + ) + + # ── 2. Strip pyreverse colorised per-node styles ───────────────────────── + # pyreverse --colorized adds color="..." fontcolor="..." inside each node. + # We remove those so our graph-level defaults win. + dot_src = re.sub(r'\bcolor="[^"]*"', '', dot_src) + dot_src = re.sub(r'\bfontcolor="[^"]*"', '', dot_src) + dot_src = re.sub(r'\bstyle="[^"]*"', '', dot_src) # remove per-node style too + + # ── 3. Fix arrowheads to proper UML style ──────────────────────────────── + # Inheritance (solid line, hollow triangle): arrowhead=empty + dot_src = re.sub( + r'(->.*?)\[([^\]]*)\]', + lambda m: _fix_edge(m), + dot_src, + ) + + # ── 4. Tidy up extra whitespace left by removals ───────────────────────── + dot_src = re.sub(r'\[\s*,', '[', dot_src) + dot_src = re.sub(r',\s*,', ',', dot_src) + dot_src = re.sub(r'\[\s*\]', '', dot_src) + + return dot_src + + +def _fix_edge(match: re.Match) -> str: + """ + Re-style edges: + - dashed edge → arrowhead=open, style=dashed (dependency / uses) + - solid edge → arrowhead=empty, style=solid (inheritance) + """ + full = match.group(0) + attrs = match.group(2) + + if 'style="dashed"' in attrs or "style=dashed" in attrs: + new_attrs = re.sub(r'arrowhead="[^"]*"', 'arrowhead="open"', attrs) + if 'arrowhead' not in new_attrs: + new_attrs += ', arrowhead="open"' + new_attrs += f', color="{STYLE["uses_color"]}", style="dashed"' + else: + new_attrs = re.sub(r'arrowhead="[^"]*"', 'arrowhead="empty"', attrs) + if 'arrowhead' not in new_attrs: + new_attrs += ', arrowhead="empty"' + new_attrs += f', color="{STYLE["inherit_color"]}", style="solid"' + + arrow_part = match.group(1) + return f"{arrow_part}[{new_attrs}]" + + +def render_dot(dot_path: Path, output_dir: Path, fmt: str = "png") -> Path: + """Run graphviz 'dot' to render a .dot file to an image.""" + out_path = output_dir / (dot_path.stem + f".{fmt}") + cmd = ["dot", f"-T{fmt}", str(dot_path), "-o", str(out_path)] + print(f"[style_pyreverse] Rendering: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print("[style_pyreverse] dot stderr:", result.stderr) + sys.exit(result.returncode) + return out_path + + +def main(): + # Simple arg parsing: just grab --output-directory / -o ourselves, + # pass everything else straight to pyreverse. + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--output-directory", default=".") + parser.add_argument("--format", default="png", + help="Output image format (png, svg, pdf …)") + + known, pyreverse_args = parser.parse_known_args() + + # Strip -o / --output from pyreverse args to avoid confusion + # (we always force -o dot) + filtered = [] + skip_next = False + for i, arg in enumerate(pyreverse_args): + if skip_next: + skip_next = False + continue + if arg in ("-o", "--output"): + skip_next = True # skip the value too + continue + if arg.startswith("-o") and len(arg) > 2: + continue # e.g. -opng + filtered.append(arg) + pyreverse_args = filtered + + output_dir = Path(known.output_directory) + output_dir.mkdir(parents=True, exist_ok=True) + + # 1. Generate .dot files + dot_files = run_pyreverse(pyreverse_args, output_dir) + + for dot_path in dot_files: + print(f"[style_pyreverse] Styling {dot_path.name} …") + + # 2. Read & patch + original = dot_path.read_text(encoding="utf-8") + patched = patch_dot(original) + + # Save the patched dot alongside the original for debugging + patched_path = dot_path.with_stem(dot_path.stem + "_styled") + patched_path.write_text(patched, encoding="utf-8") + + # 3. Render to image + img_path = render_dot(patched_path, output_dir, fmt=known.format) + print(f"[style_pyreverse] ✓ Written: {img_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file From 580e954eabc82db8c22c53d7328c79b22bc13588 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 26 Mar 2026 12:19:08 +0100 Subject: [PATCH 3/3] First pass at well splits --- split_diagram.py | 227 ++++++++++++++++++++++++++++++++++++++++++++ style_pyreverse.py | 229 +++++++++++++++++++++++---------------------- 2 files changed, 346 insertions(+), 110 deletions(-) create mode 100644 split_diagram.py diff --git a/split_diagram.py b/split_diagram.py new file mode 100644 index 0000000..31571fd --- /dev/null +++ b/split_diagram.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +split_diagram.py +---------------- +Splits a pyreverse-generated (styled) .dot file into multiple files, +one per connected cluster of classes. + +Isolated nodes (no edges) are grouped by their Python module path +rather than generating a file per class. + +Usage: + python split_diagram.py [--output-directory DIR] [--format png] + +Example: + python split_diagram.py pics/classes_styled.dot --output-directory pics/split +""" + +import re +import subprocess +import sys +import argparse +from pathlib import Path +from collections import defaultdict + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def parse_dot(dot_src: str): + """ + Parse a flat (non-subgraph) dot file. + Returns: + header_lines : list of str – graph/node/edge default lines + nodes : dict[id -> label_line] + edges : list of (src, dst, raw_line) + footer : str + """ + header_lines = [] + nodes = {} + edges = [] + + # Regex patterns + node_re = re.compile(r'^"([^"]+)"\s*\[') + edge_re = re.compile(r'^"([^"]+)"\s*->\s*"([^"]+)"') + + in_graph = False + for line in dot_src.splitlines(): + stripped = line.strip() + + if re.match(r'^digraph\s', stripped): + in_graph = True + header_lines.append(line) + continue + + if not in_graph or stripped == '}': + continue + + em = edge_re.match(stripped) + if em: + edges.append((em.group(1), em.group(2), line)) + continue + + nm = node_re.match(stripped) + if nm: + nodes[nm.group(1)] = line + continue + + # Graph/node/edge defaults and other directives + header_lines.append(line) + + return header_lines, nodes, edges + + +def find_connected_components(node_ids: set, edges: list): + """Union-Find over node_ids using edge pairs.""" + parent = {n: n for n in node_ids} + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a, b): + parent[find(a)] = find(b) + + for src, dst, _ in edges: + if src in parent and dst in parent: + union(src, dst) + + components = defaultdict(set) + for n in node_ids: + components[find(n)].add(n) + + return list(components.values()) + + +def module_group(node_id: str) -> str: + """ + Return a short group name for a node based on its module path. + e.g. 'src.daqpytools.logging.exceptions.ERSEnvError' -> 'logging.exceptions' + """ + parts = node_id.split('.') + # Drop 'src', top-level package, and the class name (last part) + # Keep the middle portion as the group name + filtered = [p for p in parts[:-1] if p not in ('src',)] + # Use last 2 meaningful segments + return '.'.join(filtered[-2:]) if len(filtered) >= 2 else '.'.join(filtered) + + +def cluster_name(node_ids: set) -> str: + """ + Derive a filesystem-safe name for a cluster from its node ids. + For multi-node clusters: find the longest common module prefix. + For single-node groups: use the module group name. + """ + if len(node_ids) == 1: + return module_group(next(iter(node_ids))) + + # Find common prefix of all node module paths + all_parts = [nid.split('.') for nid in node_ids] + common = all_parts[0] + for parts in all_parts[1:]: + common = [c for c, p in zip(common, parts) if c == p] + + # Drop 'src' and single-segment prefixes + common = [p for p in common if p not in ('src',)] + name = '.'.join(common[-2:]) if len(common) >= 2 else '.'.join(common) + return name or 'misc' + + +def build_dot(graph_name: str, header_lines: list, node_lines: list, edge_lines: list) -> str: + """Assemble a complete dot file from parts.""" + # The first header line is the digraph opener; rest are defaults + opener = header_lines[0] # e.g. 'digraph "classes" {' + # Replace the graph name + opener = re.sub(r'digraph\s+"[^"]*"', f'digraph "{graph_name}"', opener) + defaults = header_lines[1:] + + parts = [opener] + parts += defaults + parts += [''] + parts += node_lines + parts += [''] + parts += edge_lines + parts += ['}'] + return '\n'.join(parts) + + +def render(dot_path: Path, fmt: str = 'png') -> Path: + out_path = dot_path.with_suffix('.' + fmt) + cmd = ['dot', '-T' + fmt, str(dot_path), '-o', str(out_path)] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f' [dot error] {result.stderr.strip()}') + sys.exit(1) + return out_path + + +# ── Main ───────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('input_dot', help='Path to the styled .dot file') + parser.add_argument('--output-directory', default='.', help='Where to write output files') + parser.add_argument('--format', default='png', help='Output image format (png, svg, pdf)') + parser.add_argument('--min-size', type=int, default=1, + help='Minimum cluster size to render as its own file (default: 1)') + args = parser.parse_args() + + dot_path = Path(args.input_dot) + output_dir = Path(args.output_directory) + output_dir.mkdir(parents=True, exist_ok=True) + + dot_src = dot_path.read_text(encoding='utf-8') + header_lines, nodes, edges = parse_dot(dot_src) + + print(f'[split] Found {len(nodes)} nodes, {len(edges)} edges') + + # ── 1. Find connected components ───────────────────────────────────────── + components = find_connected_components(set(nodes.keys()), edges) + print(f'[split] Found {len(components)} connected components') + + # ── 2. Group singleton components by module ────────────────────────────── + # Singletons: components of size 1 + # Multi-node: keep as-is (they are meaningfully connected) + singleton_groups: dict[str, set] = defaultdict(set) + multi_components = [] + + for comp in components: + if len(comp) == 1: + node_id = next(iter(comp)) + group = module_group(node_id) + singleton_groups[group].add(node_id) + else: + multi_components.append(comp) + + # Merge singleton groups into the component list + all_clusters = multi_components + list(singleton_groups.values()) + print(f'[split] Will generate {len(all_clusters)} file(s) ' + f'({len(multi_components)} connected + {len(singleton_groups)} module groups)') + + # ── 3. Render each cluster ──────────────────────────────────────────────── + for cluster_nodes in sorted(all_clusters, key=lambda c: -len(c)): + name = cluster_name(cluster_nodes) + safe_name = re.sub(r'[^\w\-.]', '_', name) + + # Gather node lines + node_lines = [nodes[n] for n in cluster_nodes if n in nodes] + + # Gather edge lines that connect nodes within this cluster + edge_lines = [ + raw for src, dst, raw in edges + if src in cluster_nodes and dst in cluster_nodes + ] + + dot_content = build_dot(name, header_lines, node_lines, edge_lines) + + out_dot = output_dir / f'{safe_name}.dot' + out_dot.write_text(dot_content, encoding='utf-8') + + out_img = render(out_dot, fmt=args.format) + size_label = f'{len(cluster_nodes)} class{"es" if len(cluster_nodes) != 1 else ""}' + print(f' ✓ {out_img.name} ({size_label})') + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/style_pyreverse.py b/style_pyreverse.py index d801e66..a423696 100644 --- a/style_pyreverse.py +++ b/style_pyreverse.py @@ -14,145 +14,159 @@ daqpytools \ --output-directory pics -The script: - 1. Runs pyreverse with -o dot (always, regardless of what you pass) - 2. Post-processes the .dot file to apply a clean UML style - 3. Renders to PNG via Graphviz - Dependencies: - pip install graphviz # Python graphviz bindings (optional, fallback uses subprocess) - pip install pylint # provides pyreverse + pip install pylint # provides pyreverse graphviz must be installed on your system (provides the 'dot' binary) """ import re import subprocess import sys -import os import argparse from pathlib import Path + # ── Colour palette (tweak these to your taste) ────────────────────────────── STYLE = { - # Node (class box) colours - "class_fill": "#FFFDE7", # warm cream (matches Image 1) - "class_fill_dark": "#FFF9C4", # slightly deeper cream for header rows - "class_stroke": "#A0522D", # siennna-ish border - "class_font": "Helvetica", # clean sans-serif - "class_fontsize": "10", - - # Edge colours - "inherit_color": "#555555", # solid grey for inheritance - "uses_color": "#555555", # dashed grey for dependencies - - # Graph background - "bg_color": "white", - "rankdir": "BT", # bottom-to-top like a proper UML diagram + "class_fill": "#FFFDE7", # warm cream (matches Image 1) + "class_stroke": "#8B7355", # warm brown border + "class_font": "Helvetica", + "class_fontsize": "10", + "inherit_color": "#555555", + "uses_color": "#555555", + "bg_color": "white", + "rankdir": "BT", } # ──────────────────────────────────────────────────────────────────────────── -def run_pyreverse(extra_args: list[str], output_dir: Path) -> list[Path]: +def run_pyreverse(extra_args, output_dir): """Run pyreverse with -o dot and return the paths to generated .dot files.""" - cmd = [ - "pyreverse", - "-o", "dot", - "--output-directory", str(output_dir), - ] + extra_args - + cmd = ["pyreverse", "-o", "dot", "--output-directory", str(output_dir)] + extra_args print(f"[style_pyreverse] Running: {' '.join(cmd)}") result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: print("[style_pyreverse] pyreverse stderr:", result.stderr) sys.exit(result.returncode) - dot_files = list(output_dir.glob("*.dot")) if not dot_files: print("[style_pyreverse] ERROR: pyreverse produced no .dot files in", output_dir) sys.exit(1) - return dot_files -def patch_dot(dot_src: str) -> str: - """ - Rewrite the .dot source to apply a clean UML style: - - cream fill for all class nodes - - proper record shape kept intact - - inheritance arrows → open hollow arrowhead (UML style) - - dependency arrows → dashed with open arrowhead - """ +def patch_dot(dot_src): + """Rewrite .dot source to apply a clean UML style.""" s = STYLE - # ── 1. Graph-level defaults ────────────────────────────────────────────── - graph_defaults = f""" - graph [bgcolor="{s['bg_color']}", fontname="{s['class_font']}", pad="0.5", nodesep="0.6", ranksep="0.8"]; - node [shape=record, style="filled,rounded", fillcolor="{s['class_fill']}", - color="{s['class_stroke']}", fontname="{s['class_font']}", - fontsize={s['class_fontsize']}]; - edge [fontname="{s['class_font']}", fontsize="9"]; -""" + # ── 1. Strip all per-node colour/style attributes pyreverse injected ───── + dot_src = re.sub(r',?\s*\bfontcolor\s*=\s*"[^"]*"', '', dot_src) + dot_src = re.sub(r',?\s*\bcolor\s*=\s*"[^"]*"', '', dot_src) + dot_src = re.sub(r',?\s*\bstyle\s*=\s*"[^"]*"', '', dot_src) + dot_src = re.sub(r',?\s*\bfillcolor\s*=\s*"[^"]*"', '', dot_src) - # Insert defaults right after the opening brace of the digraph - dot_src = re.sub( - r'(digraph\s+\S+\s*\{)', - r'\1' + graph_defaults, - dot_src, - count=1, + # Clean up empty/malformed attribute lists left by the removals above + dot_src = re.sub(r'\[\s*\]', '', dot_src) + dot_src = re.sub(r'\[\s*,', '[', dot_src) + dot_src = re.sub(r',\s*\]', ']', dot_src) + dot_src = re.sub(r',\s*,', ', ', dot_src) + + # ── 2. Inject graph-level defaults right after the opening brace ───────── + graph_line = ( + 'graph [' + 'bgcolor="' + s["bg_color"] + '" ' + 'fontname="' + s["class_font"] + '" ' + 'pad="0.5" nodesep="0.6" ranksep="0.9" ' + 'rankdir="' + s["rankdir"] + '"' + '];' + ) + node_line = ( + 'node [' + 'shape=record ' + 'style="filled" ' + 'fillcolor="' + s["class_fill"] + '" ' + 'color="' + s["class_stroke"] + '" ' + 'fontname="' + s["class_font"] + '" ' + 'fontsize=' + s["class_fontsize"] + + '];' + ) + edge_line = ( + 'edge [' + 'fontname="' + s["class_font"] + '" ' + 'fontsize="9" ' + 'color="' + s["inherit_color"] + '"' + '];' ) + defaults_block = "\n " + graph_line + "\n " + node_line + "\n " + edge_line + "\n" - # ── 2. Strip pyreverse colorised per-node styles ───────────────────────── - # pyreverse --colorized adds color="..." fontcolor="..." inside each node. - # We remove those so our graph-level defaults win. - dot_src = re.sub(r'\bcolor="[^"]*"', '', dot_src) - dot_src = re.sub(r'\bfontcolor="[^"]*"', '', dot_src) - dot_src = re.sub(r'\bstyle="[^"]*"', '', dot_src) # remove per-node style too + def insert_defaults(m): + return m.group(0) + defaults_block - # ── 3. Fix arrowheads to proper UML style ──────────────────────────────── - # Inheritance (solid line, hollow triangle): arrowhead=empty - dot_src = re.sub( - r'(->.*?)\[([^\]]*)\]', - lambda m: _fix_edge(m), - dot_src, - ) + dot_src = re.sub(r'digraph\s+\S+\s*\{', insert_defaults, dot_src, count=1) - # ── 4. Tidy up extra whitespace left by removals ───────────────────────── - dot_src = re.sub(r'\[\s*,', '[', dot_src) - dot_src = re.sub(r',\s*,', ',', dot_src) - dot_src = re.sub(r'\[\s*\]', '', dot_src) + # ── 3. Fix arrowheads to proper UML style ──────────────────────────────── + dot_src = fix_edges(dot_src) return dot_src -def _fix_edge(match: re.Match) -> str: +def fix_edges(dot_src): """ - Re-style edges: - - dashed edge → arrowhead=open, style=dashed (dependency / uses) - - solid edge → arrowhead=empty, style=solid (inheritance) + Restyle edges line-by-line: + dashed → dependency/uses: arrowhead=open, style=dashed + solid → inheritance: arrowhead=empty, style=solid (hollow triangle) """ - full = match.group(0) - attrs = match.group(2) - - if 'style="dashed"' in attrs or "style=dashed" in attrs: - new_attrs = re.sub(r'arrowhead="[^"]*"', 'arrowhead="open"', attrs) - if 'arrowhead' not in new_attrs: - new_attrs += ', arrowhead="open"' - new_attrs += f', color="{STYLE["uses_color"]}", style="dashed"' - else: - new_attrs = re.sub(r'arrowhead="[^"]*"', 'arrowhead="empty"', attrs) - if 'arrowhead' not in new_attrs: - new_attrs += ', arrowhead="empty"' - new_attrs += f', color="{STYLE["inherit_color"]}", style="solid"' - - arrow_part = match.group(1) - return f"{arrow_part}[{new_attrs}]" - + lines = dot_src.splitlines() + out = [] + for line in lines: + if '->' not in line: + out.append(line) + continue -def render_dot(dot_path: Path, output_dir: Path, fmt: str = "png") -> Path: + is_dashed = 'dashed' in line + + # Strip existing arrowhead/style/color attrs from this edge line + line = re.sub(r',?\s*arrowhead\s*=\s*"?[^",\]\s]+"?', '', line) + line = re.sub(r',?\s*\bstyle\s*=\s*"[^"]*"', '', line) + line = re.sub(r',?\s*\bcolor\s*=\s*"[^"]*"', '', line) + + # Clean up any resulting empty/malformed brackets + line = re.sub(r'\[\s*,', '[', line) + line = re.sub(r',\s*\]', ']', line) + line = re.sub(r'\[\s*\]', '', line) + + if is_dashed: + new_attrs = ( + 'arrowhead="open" ' + 'style="dashed" ' + 'color="' + STYLE["uses_color"] + '"' + ) + else: + new_attrs = ( + 'arrowhead="empty" ' + 'style="solid" ' + 'color="' + STYLE["inherit_color"] + '"' + ) + + if '[' in line: + # Append new attrs inside the existing bracket + line = re.sub( + r'\[([^\]]*)\]', + lambda m: '[' + (m.group(1).strip().rstrip(',') + ', ' if m.group(1).strip() else '') + new_attrs + ']', + line + ) + else: + # No existing bracket — add one before the semicolon or at end + line = re.sub(r';?\s*$', ' [' + new_attrs + '];', line.rstrip()) + + out.append(line) + return '\n'.join(out) + + +def render_dot(dot_path, output_dir, fmt="png"): """Run graphviz 'dot' to render a .dot file to an image.""" - out_path = output_dir / (dot_path.stem + f".{fmt}") - cmd = ["dot", f"-T{fmt}", str(dot_path), "-o", str(out_path)] + out_path = output_dir / (dot_path.stem + "." + fmt) + cmd = ["dot", "-T" + fmt, str(dot_path), "-o", str(out_path)] print(f"[style_pyreverse] Rendering: {' '.join(cmd)}") result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: @@ -162,49 +176,44 @@ def render_dot(dot_path: Path, output_dir: Path, fmt: str = "png") -> Path: def main(): - # Simple arg parsing: just grab --output-directory / -o ourselves, - # pass everything else straight to pyreverse. parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--output-directory", default=".") parser.add_argument("--format", default="png", help="Output image format (png, svg, pdf …)") - known, pyreverse_args = parser.parse_known_args() - # Strip -o / --output from pyreverse args to avoid confusion - # (we always force -o dot) + # Strip -o / --output flags (we always force dot output) filtered = [] skip_next = False - for i, arg in enumerate(pyreverse_args): + for arg in pyreverse_args: if skip_next: skip_next = False continue if arg in ("-o", "--output"): - skip_next = True # skip the value too + skip_next = True + continue + if re.match(r'^-o\w+', arg): # e.g. -opng + continue + if arg == "--colorized": # we handle colour ourselves continue - if arg.startswith("-o") and len(arg) > 2: - continue # e.g. -opng filtered.append(arg) pyreverse_args = filtered output_dir = Path(known.output_directory) output_dir.mkdir(parents=True, exist_ok=True) - # 1. Generate .dot files dot_files = run_pyreverse(pyreverse_args, output_dir) for dot_path in dot_files: print(f"[style_pyreverse] Styling {dot_path.name} …") - # 2. Read & patch original = dot_path.read_text(encoding="utf-8") - patched = patch_dot(original) + patched = patch_dot(original) - # Save the patched dot alongside the original for debugging - patched_path = dot_path.with_stem(dot_path.stem + "_styled") + # Save patched .dot alongside original (useful for debugging) + patched_path = dot_path.with_name(dot_path.stem + "_styled.dot") patched_path.write_text(patched, encoding="utf-8") - # 3. Render to image img_path = render_dot(patched_path, output_dir, fmt=known.format) print(f"[style_pyreverse] ✓ Written: {img_path}")