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"] 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 new file mode 100644 index 0000000..a423696 --- /dev/null +++ b/style_pyreverse.py @@ -0,0 +1,222 @@ +#!/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 + +Dependencies: + pip install pylint # provides pyreverse + graphviz must be installed on your system (provides the 'dot' binary) +""" + +import re +import subprocess +import sys +import argparse +from pathlib import Path + + +# ── Colour palette (tweak these to your taste) ────────────────────────────── +STYLE = { + "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, 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 + 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): + """Rewrite .dot source to apply a clean UML style.""" + s = STYLE + + # ── 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) + + # 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" + + def insert_defaults(m): + return m.group(0) + defaults_block + + dot_src = re.sub(r'digraph\s+\S+\s*\{', insert_defaults, dot_src, count=1) + + # ── 3. Fix arrowheads to proper UML style ──────────────────────────────── + dot_src = fix_edges(dot_src) + + return dot_src + + +def fix_edges(dot_src): + """ + Restyle edges line-by-line: + dashed → dependency/uses: arrowhead=open, style=dashed + solid → inheritance: arrowhead=empty, style=solid (hollow triangle) + """ + lines = dot_src.splitlines() + out = [] + for line in lines: + if '->' not in line: + out.append(line) + continue + + 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 + "." + 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: + print("[style_pyreverse] dot stderr:", result.stderr) + sys.exit(result.returncode) + return out_path + + +def main(): + 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 flags (we always force dot output) + filtered = [] + skip_next = False + for arg in pyreverse_args: + if skip_next: + skip_next = False + continue + if arg in ("-o", "--output"): + skip_next = True + continue + if re.match(r'^-o\w+', arg): # e.g. -opng + continue + if arg == "--colorized": # we handle colour ourselves + continue + filtered.append(arg) + pyreverse_args = filtered + + output_dir = Path(known.output_directory) + output_dir.mkdir(parents=True, exist_ok=True) + + dot_files = run_pyreverse(pyreverse_args, output_dir) + + for dot_path in dot_files: + print(f"[style_pyreverse] Styling {dot_path.name} …") + + original = dot_path.read_text(encoding="utf-8") + patched = patch_dot(original) + + # 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") + + 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