From 4216bc84ea5ae71dd4f46670280d3344780f0ffc Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 26 Mar 2026 11:49:00 +0100 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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}") From 0baa5fd2a6d5a04ccfc76381ed84860d5ae599e8 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 1 Apr 2026 16:45:07 +0200 Subject: [PATCH 04/19] add --concise option --- split_diagram.py | 91 ++++++++++++++++++++++++++++++++++++++++++++-- style_pyreverse.py | 88 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/split_diagram.py b/split_diagram.py index 31571fd..72666cd 100644 --- a/split_diagram.py +++ b/split_diagram.py @@ -9,10 +9,13 @@ rather than generating a file per class. Usage: - python split_diagram.py [--output-directory DIR] [--format png] + python split_diagram.py [--output-directory DIR] [--format png] [--concise] Example: python split_diagram.py pics/classes_styled.dot --output-directory pics/split + + # With --concise to remove type hints: + python split_diagram.py pics/classes_styled.dot --output-directory pics/split --concise """ import re @@ -25,6 +28,78 @@ # ── Helpers ────────────────────────────────────────────────────────────────── +def _strip_param_types(params_str: str) -> str: + """Strip type hints from a parameter list (the content between parentheses). + + Handles complex types like ``dict[str, IssueRecord]`` by counting + bracket depth so inner commas are not treated as parameter separators. + """ + if not params_str.strip(): + return params_str + + # Split on top-level commas only (not commas inside [] or {}) + params, current, depth = [], [], 0 + for ch in params_str: + if ch in '[({': + depth += 1 + current.append(ch) + elif ch in '])}': + depth -= 1 + current.append(ch) + elif ch == ',' and depth == 0: + params.append(''.join(current).strip()) + current = [] + else: + current.append(ch) + if current: + params.append(''.join(current).strip()) + + # Keep only the name (everything before the first ':') + stripped = [] + for param in params: + colon = param.find(':') + stripped.append(param[:colon].rstrip() if colon != -1 else param) + return ', '.join(stripped) + + +def strip_typehints(dot_src: str) -> str: + """Remove type annotations from class/attribute/method labels. + + Three-pass approach applied only to lines that carry an HTML label: + + 1. Return types – strips ``: ReturnType`` that follows a closing ``)``. + e.g. ``filter(record: logging.LogRecord): bool`` → ``filter(record: logging.LogRecord)`` + + 2. Parameter types – strips ``: Type`` from each parameter inside ``()``. + e.g. ``filter(record: logging.LogRecord)`` → ``filter(record)`` + Handles complex types such as ``dict[str, IssueRecord]`` correctly. + + 3. Attribute types – strips `` : Type`` from plain field entries. + e.g. ``initial_threshold : int`` → ``initial_threshold`` + """ + lines = dot_src.splitlines() + out_lines = [] + + for line in lines: + if 'label=<' not in line: + out_lines.append(line) + continue + + # Pass 1 – return types: ): ReturnType
)', '', line) + + # Pass 2 – parameter types inside () + def _replace_params(m): + return '(' + _strip_param_types(m.group(1)) + ')' + line = re.sub(r'\(([^)]*)\)', _replace_params, line) + + # Pass 3 – attribute types: name : Type
)', '', line) + + out_lines.append(line) + + return '\n'.join(out_lines) + def parse_dot(dot_src: str): """ Parse a flat (non-subgraph) dot file. @@ -128,7 +203,7 @@ def cluster_name(node_ids: set) -> str: return name or 'misc' -def build_dot(graph_name: str, header_lines: list, node_lines: list, edge_lines: list) -> str: +def build_dot(graph_name: str, header_lines: list, node_lines: list, edge_lines: list, concise: bool = False) -> 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" {' @@ -143,7 +218,13 @@ def build_dot(graph_name: str, header_lines: list, node_lines: list, edge_lines: parts += [''] parts += edge_lines parts += ['}'] - return '\n'.join(parts) + dot_content = '\n'.join(parts) + + # Strip type hints if concise mode is enabled + if concise: + dot_content = strip_typehints(dot_content) + + return dot_content def render(dot_path: Path, fmt: str = 'png') -> Path: @@ -165,6 +246,8 @@ def main(): 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)') + parser.add_argument('--concise', action='store_true', + help='Remove type hints from class attributes and methods') args = parser.parse_args() dot_path = Path(args.input_dot) @@ -213,7 +296,7 @@ def main(): if src in cluster_nodes and dst in cluster_nodes ] - dot_content = build_dot(name, header_lines, node_lines, edge_lines) + dot_content = build_dot(name, header_lines, node_lines, edge_lines, concise=args.concise) out_dot = output_dir / f'{safe_name}.dot' out_dot.write_text(dot_content, encoding='utf-8') diff --git a/style_pyreverse.py b/style_pyreverse.py index a423696..b1ada44 100644 --- a/style_pyreverse.py +++ b/style_pyreverse.py @@ -5,7 +5,7 @@ Wraps pyreverse to produce nicely styled UML class diagrams. Usage: - python style_pyreverse.py [pyreverse args...] + python style_pyreverse.py [pyreverse args...] [--concise] Example (drop-in replacement for your existing command): python style_pyreverse.py \ @@ -14,6 +14,9 @@ daqpytools \ --output-directory pics + # With --concise to remove type hints: + python style_pyreverse.py daqpytools --output-directory pics --concise + Dependencies: pip install pylint # provides pyreverse graphviz must be installed on your system (provides the 'dot' binary) @@ -55,7 +58,80 @@ def run_pyreverse(extra_args, output_dir): return dot_files -def patch_dot(dot_src): +def _strip_param_types(params_str: str) -> str: + """Strip type hints from a parameter list (the content between parentheses). + + Handles complex types like ``dict[str, IssueRecord]`` by counting + bracket depth so inner commas are not treated as parameter separators. + """ + if not params_str.strip(): + return params_str + + # Split on top-level commas only (not commas inside [] or {}) + params, current, depth = [], [], 0 + for ch in params_str: + if ch in '[({': + depth += 1 + current.append(ch) + elif ch in '])}': + depth -= 1 + current.append(ch) + elif ch == ',' and depth == 0: + params.append(''.join(current).strip()) + current = [] + else: + current.append(ch) + if current: + params.append(''.join(current).strip()) + + # Keep only the name (everything before the first ':') + stripped = [] + for param in params: + colon = param.find(':') + stripped.append(param[:colon].rstrip() if colon != -1 else param) + return ', '.join(stripped) + + +def strip_typehints(dot_src): + """Remove type annotations from class/attribute/method labels. + + Three-pass approach applied only to lines that carry an HTML label: + + 1. Return types – strips ``: ReturnType`` that follows a closing ``)``. + e.g. ``filter(record: logging.LogRecord): bool`` → ``filter(record: logging.LogRecord)`` + + 2. Parameter types – strips ``: Type`` from each parameter inside ``()``. + e.g. ``filter(record: logging.LogRecord)`` → ``filter(record)`` + Handles complex types such as ``dict[str, IssueRecord]`` correctly. + + 3. Attribute types – strips `` : Type`` from plain field entries. + e.g. ``initial_threshold : int`` → ``initial_threshold`` + """ + lines = dot_src.splitlines() + out_lines = [] + + for line in lines: + if 'label=<' not in line: + out_lines.append(line) + continue + + # Pass 1 – return types: ): ReturnType
)', '', line) + + # Pass 2 – parameter types inside () + def _replace_params(m): + return '(' + _strip_param_types(m.group(1)) + ')' + line = re.sub(r'\(([^)]*)\)', _replace_params, line) + + # Pass 3 – attribute types: name : Type
)', '', line) + + out_lines.append(line) + + return '\n'.join(out_lines) + + +def patch_dot(dot_src, concise=False): """Rewrite .dot source to apply a clean UML style.""" s = STYLE @@ -70,6 +146,10 @@ def patch_dot(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) + + # ── Concise mode: strip type hints ───────────────────────────────────── + if concise: + dot_src = strip_typehints(dot_src) # ── 2. Inject graph-level defaults right after the opening brace ───────── graph_line = ( @@ -180,6 +260,8 @@ def main(): parser.add_argument("--output-directory", default=".") parser.add_argument("--format", default="png", help="Output image format (png, svg, pdf …)") + parser.add_argument("--concise", action="store_true", + help="Remove type hints from class attributes and methods") known, pyreverse_args = parser.parse_known_args() # Strip -o / --output flags (we always force dot output) @@ -208,7 +290,7 @@ def main(): print(f"[style_pyreverse] Styling {dot_path.name} …") original = dot_path.read_text(encoding="utf-8") - patched = patch_dot(original) + patched = patch_dot(original, concise=known.concise) # Save patched .dot alongside original (useful for debugging) patched_path = dot_path.with_name(dot_path.stem + "_styled.dot") From 91c3e66e844b0e513c39138883d725e7b6460626 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 1 Apr 2026 17:06:25 +0200 Subject: [PATCH 05/19] Generate uml tool in daqpytools --- src/daqpytools/uml/__init__.py | 0 split_diagram.py => src/daqpytools/uml/split_diagram.py | 0 style_pyreverse.py => src/daqpytools/uml/style_pyreverse.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/daqpytools/uml/__init__.py rename split_diagram.py => src/daqpytools/uml/split_diagram.py (100%) rename style_pyreverse.py => src/daqpytools/uml/style_pyreverse.py (100%) diff --git a/src/daqpytools/uml/__init__.py b/src/daqpytools/uml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/split_diagram.py b/src/daqpytools/uml/split_diagram.py similarity index 100% rename from split_diagram.py rename to src/daqpytools/uml/split_diagram.py diff --git a/style_pyreverse.py b/src/daqpytools/uml/style_pyreverse.py similarity index 100% rename from style_pyreverse.py rename to src/daqpytools/uml/style_pyreverse.py From 733a2c95e308e789d6ace94bebe6d8343915787c Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 1 Apr 2026 17:10:54 +0200 Subject: [PATCH 06/19] Add skeleton of generate_uml script --- pyproject.toml | 1 + src/daqpytools/apps/generate_uml.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/daqpytools/apps/generate_uml.py diff --git a/pyproject.toml b/pyproject.toml index 8959cb9..0683a6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ test = ["pytest", "pytest-mypy", "pytest-cov", "types-pytz"] [project.scripts] daqpytools-logging-demonstrator = "daqpytools.apps.logging_demonstrator:main" +daqpytools-generate-uml = "daqpytools.apps.generate_uml:main" # Stricter linting rules than the standard (daq-deliverables #196); this is fine diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py new file mode 100644 index 0000000..8b3e79a --- /dev/null +++ b/src/daqpytools/apps/generate_uml.py @@ -0,0 +1,16 @@ +""" + +This should be code that generates uml diagrams + +eg for now it will be basically + +daqpytools-generate-uml --project daqpytools --concise etc +""" + + + +def main(): + print("Hello world!") + +if __name__ == "__main__": + main() \ No newline at end of file From 2c563627ed77965e274fd5064d31411d42fd61f6 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 15 Apr 2026 16:59:56 +0200 Subject: [PATCH 07/19] Add styling --- src/daqpytools/uml/split_diagram.py | 72 +---------------- src/daqpytools/uml/style.yaml | 16 ++++ src/daqpytools/uml/style_pyreverse.py | 89 +-------------------- src/daqpytools/uml/utils.py | 109 ++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 157 deletions(-) create mode 100644 src/daqpytools/uml/style.yaml create mode 100644 src/daqpytools/uml/utils.py diff --git a/src/daqpytools/uml/split_diagram.py b/src/daqpytools/uml/split_diagram.py index 72666cd..132345a 100644 --- a/src/daqpytools/uml/split_diagram.py +++ b/src/daqpytools/uml/split_diagram.py @@ -24,81 +24,11 @@ import argparse from pathlib import Path from collections import defaultdict +from daqpytools.uml.utils import strip_typehints # ── Helpers ────────────────────────────────────────────────────────────────── -def _strip_param_types(params_str: str) -> str: - """Strip type hints from a parameter list (the content between parentheses). - - Handles complex types like ``dict[str, IssueRecord]`` by counting - bracket depth so inner commas are not treated as parameter separators. - """ - if not params_str.strip(): - return params_str - - # Split on top-level commas only (not commas inside [] or {}) - params, current, depth = [], [], 0 - for ch in params_str: - if ch in '[({': - depth += 1 - current.append(ch) - elif ch in '])}': - depth -= 1 - current.append(ch) - elif ch == ',' and depth == 0: - params.append(''.join(current).strip()) - current = [] - else: - current.append(ch) - if current: - params.append(''.join(current).strip()) - - # Keep only the name (everything before the first ':') - stripped = [] - for param in params: - colon = param.find(':') - stripped.append(param[:colon].rstrip() if colon != -1 else param) - return ', '.join(stripped) - - -def strip_typehints(dot_src: str) -> str: - """Remove type annotations from class/attribute/method labels. - - Three-pass approach applied only to lines that carry an HTML label: - - 1. Return types – strips ``: ReturnType`` that follows a closing ``)``. - e.g. ``filter(record: logging.LogRecord): bool`` → ``filter(record: logging.LogRecord)`` - - 2. Parameter types – strips ``: Type`` from each parameter inside ``()``. - e.g. ``filter(record: logging.LogRecord)`` → ``filter(record)`` - Handles complex types such as ``dict[str, IssueRecord]`` correctly. - - 3. Attribute types – strips `` : Type`` from plain field entries. - e.g. ``initial_threshold : int`` → ``initial_threshold`` - """ - lines = dot_src.splitlines() - out_lines = [] - - for line in lines: - if 'label=<' not in line: - out_lines.append(line) - continue - - # Pass 1 – return types: ): ReturnType
)', '', line) - - # Pass 2 – parameter types inside () - def _replace_params(m): - return '(' + _strip_param_types(m.group(1)) + ')' - line = re.sub(r'\(([^)]*)\)', _replace_params, line) - - # Pass 3 – attribute types: name : Type
)', '', line) - - out_lines.append(line) - - return '\n'.join(out_lines) def parse_dot(dot_src: str): """ diff --git a/src/daqpytools/uml/style.yaml b/src/daqpytools/uml/style.yaml new file mode 100644 index 0000000..0a643b2 --- /dev/null +++ b/src/daqpytools/uml/style.yaml @@ -0,0 +1,16 @@ +# UML Diagram Style Configuration +# ────────────────────────────────────────────────────────────────────────── + +class: + fill: "#FFFDE7" # warm cream (matches Image 1) + stroke: "#8B7355" # warm brown border + font: "Helvetica" + fontsize: 10 + +relationships: + inherit_color: "#555555" # inheritance arrow color + uses_color: "#555555" # dependency/uses arrow color + +graph: + bg_color: "white" + rankdir: "BT" # bottom-to-top layout diff --git a/src/daqpytools/uml/style_pyreverse.py b/src/daqpytools/uml/style_pyreverse.py index b1ada44..1530ef0 100644 --- a/src/daqpytools/uml/style_pyreverse.py +++ b/src/daqpytools/uml/style_pyreverse.py @@ -27,20 +27,10 @@ import sys import argparse from pathlib import Path +from daqpytools.uml.utils import strip_typehints, load_style_config - -# ── 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", -} -# ──────────────────────────────────────────────────────────────────────────── +# Load UML diagram style configuration from style.yaml +STYLE = load_style_config() def run_pyreverse(extra_args, output_dir): @@ -58,79 +48,6 @@ def run_pyreverse(extra_args, output_dir): return dot_files -def _strip_param_types(params_str: str) -> str: - """Strip type hints from a parameter list (the content between parentheses). - - Handles complex types like ``dict[str, IssueRecord]`` by counting - bracket depth so inner commas are not treated as parameter separators. - """ - if not params_str.strip(): - return params_str - - # Split on top-level commas only (not commas inside [] or {}) - params, current, depth = [], [], 0 - for ch in params_str: - if ch in '[({': - depth += 1 - current.append(ch) - elif ch in '])}': - depth -= 1 - current.append(ch) - elif ch == ',' and depth == 0: - params.append(''.join(current).strip()) - current = [] - else: - current.append(ch) - if current: - params.append(''.join(current).strip()) - - # Keep only the name (everything before the first ':') - stripped = [] - for param in params: - colon = param.find(':') - stripped.append(param[:colon].rstrip() if colon != -1 else param) - return ', '.join(stripped) - - -def strip_typehints(dot_src): - """Remove type annotations from class/attribute/method labels. - - Three-pass approach applied only to lines that carry an HTML label: - - 1. Return types – strips ``: ReturnType`` that follows a closing ``)``. - e.g. ``filter(record: logging.LogRecord): bool`` → ``filter(record: logging.LogRecord)`` - - 2. Parameter types – strips ``: Type`` from each parameter inside ``()``. - e.g. ``filter(record: logging.LogRecord)`` → ``filter(record)`` - Handles complex types such as ``dict[str, IssueRecord]`` correctly. - - 3. Attribute types – strips `` : Type`` from plain field entries. - e.g. ``initial_threshold : int`` → ``initial_threshold`` - """ - lines = dot_src.splitlines() - out_lines = [] - - for line in lines: - if 'label=<' not in line: - out_lines.append(line) - continue - - # Pass 1 – return types: ): ReturnType
)', '', line) - - # Pass 2 – parameter types inside () - def _replace_params(m): - return '(' + _strip_param_types(m.group(1)) + ')' - line = re.sub(r'\(([^)]*)\)', _replace_params, line) - - # Pass 3 – attribute types: name : Type
)', '', line) - - out_lines.append(line) - - return '\n'.join(out_lines) - - def patch_dot(dot_src, concise=False): """Rewrite .dot source to apply a clean UML style.""" s = STYLE diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py new file mode 100644 index 0000000..8591725 --- /dev/null +++ b/src/daqpytools/uml/utils.py @@ -0,0 +1,109 @@ +import re +import yaml +from pathlib import Path + + +def _strip_param_types(params_str: str) -> str: + """Strip type hints from a parameter list (the content between parentheses). + + Handles complex types like ``dict[str, IssueRecord]`` by counting + bracket depth so inner commas are not treated as parameter separators. + """ + if not params_str.strip(): + return params_str + + # Split on top-level commas only (not commas inside [] or {}) + params, current, depth = [], [], 0 + for ch in params_str: + if ch in '[({': + depth += 1 + current.append(ch) + elif ch in '])}': + depth -= 1 + current.append(ch) + elif ch == ',' and depth == 0: + params.append(''.join(current).strip()) + current = [] + else: + current.append(ch) + if current: + params.append(''.join(current).strip()) + + # Keep only the name (everything before the first ':') + stripped = [] + for param in params: + colon = param.find(':') + stripped.append(param[:colon].rstrip() if colon != -1 else param) + return ', '.join(stripped) + + +def strip_typehints(dot_src): + """Remove type annotations from class/attribute/method labels. + + Three-pass approach applied only to lines that carry an HTML label: + + 1. Return types – strips ``: ReturnType`` that follows a closing ``)``. + e.g. ``filter(record: logging.LogRecord): bool`` → ``filter(record: logging.LogRecord)`` + + 2. Parameter types – strips ``: Type`` from each parameter inside ``()``. + e.g. ``filter(record: logging.LogRecord)`` → ``filter(record)`` + Handles complex types such as ``dict[str, IssueRecord]`` correctly. + + 3. Attribute types – strips `` : Type`` from plain field entries. + e.g. ``initial_threshold : int`` → ``initial_threshold`` + """ + lines = dot_src.splitlines() + out_lines = [] + + for line in lines: + if 'label=<' not in line: + out_lines.append(line) + continue + + # Pass 1 – return types: ): ReturnType
)', '', line) + + # Pass 2 – parameter types inside () + def _replace_params(m): + return '(' + _strip_param_types(m.group(1)) + ')' + line = re.sub(r'\(([^)]*)\)', _replace_params, line) + + # Pass 3 – attribute types: name : Type
)', '', line) + + out_lines.append(line) + + return '\n'.join(out_lines) + + +def load_style_config(path=None): + """Load UML style configuration from YAML file. + + Loads the style configuration (colors, fonts, layout) from a YAML file + and returns it as a flat dictionary compatible with the STYLE dict format. + + Args: + path: Path to the YAML config file. If None, uses default location + (style.yaml in the same directory as this module). + + Returns: + Dictionary with keys: class_fill, class_stroke, class_font, class_fontsize, + inherit_color, uses_color, bg_color, rankdir. + """ + if path is None: + path = Path(__file__).parent / "style.yaml" + + with open(path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + + # Flatten the nested YAML structure to match existing STYLE dict format + return { + "class_fill": config["class"]["fill"], + "class_stroke": config["class"]["stroke"], + "class_font": config["class"]["font"], + "class_fontsize": str(config["class"]["fontsize"]), + "inherit_color": config["relationships"]["inherit_color"], + "uses_color": config["relationships"]["uses_color"], + "bg_color": config["graph"]["bg_color"], + "rankdir": config["graph"]["rankdir"], + } From 95c9dfe8dc85017fd6e07d859c24cf8acafd3cf6 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 15 Apr 2026 17:00:17 +0200 Subject: [PATCH 08/19] add propre CLI tool --- src/daqpytools/apps/generate_uml.py | 218 +++++++++++++++++++++++++++- 1 file changed, 212 insertions(+), 6 deletions(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index 8b3e79a..e21b0e5 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -1,16 +1,222 @@ """ +generate_uml.py +--------------- +CLI interface for generating UML class diagrams using pyreverse. -This should be code that generates uml diagrams +This tool wraps style_pyreverse.py and split_diagram.py to produce +nicely styled UML diagrams, optionally split into multiple files by connected components. -eg for now it will be basically - -daqpytools-generate-uml --project daqpytools --concise etc +Usage: + daqpytools-generate-uml daqpytools --output-directory pics + daqpytools-generate-uml daqpytools --output-directory pics --concise + daqpytools-generate-uml -p my_package -c MyClass --output-directory pics --no-split + daqpytools-generate-uml daqpytools --format svg --min-size 2 """ +import subprocess +import sys +from pathlib import Path +import click +from daqpytools.logging.formatter import CONTEXT_SETTINGS + + +class PassthroughArgs(click.Command): + """Custom Click command that captures unknown arguments for pyreverse passthrough.""" + + def main(self, *args, **kwargs): + """Override main to collect unknown args.""" + try: + return super().main(*args, **kwargs) + except click.exceptions.UsageError as e: + # Check if this is an unrecognized option meant for pyreverse + if "no such option" in str(e): + # Let it through for passthrough handling + raise + raise + + +def validate_output_directory(ctx, param, value): + """Validate and create output directory if needed.""" + if value: + out_dir = Path(value) + out_dir.mkdir(parents=True, exist_ok=True) + return value + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.argument("targets", nargs=-1, required=False) +@click.option( + "-o", + "--output-directory", + type=click.Path(), + default=".", + callback=validate_output_directory, + help="Output directory for generated diagrams. [default: .]", +) +@click.option( + "-f", + "--format", + type=click.Choice(["png", "svg", "pdf", "jpg"]), + default="png", + help="Output image format. [default: png]", +) +@click.option( + "-c", + "--concise", + is_flag=True, + help="Remove type hints from class attributes and methods.", +) +@click.option( + "-ns", + "--no-split", + is_flag=True, + help="Do not split diagram by connected components; generate single diagram.", +) +@click.option( + "-ms", + "--min-size", + type=int, + default=1, + help="Minimum cluster size to render as separate file (used with --split). [default: 1]", +) +@click.option( + "-p", + "--package", + multiple=True, + help="Package(s) to analyze (passed to pyreverse).", +) +@click.option( + "--class", + "classes", + multiple=True, + help="Specific class(es) to include (passed to pyreverse as -c).", +) +@click.option( + "-v", + "--verbose", + is_flag=True, + help="Verbose output.", +) +def main( + targets, + output_directory, + format, + concise, + no_split, + min_size, + package, + classes, + verbose, +): + """ + Generate styled UML class diagrams from Python code. + You can specify packages/modules as TARGETS or use --package/-p. + + Examples: + # Generate diagram for daqpytools package + daqpytools-generate-uml daqpytools --output-directory pics + + # Generate with concise mode (no type hints) + daqpytools-generate-uml daqpytools --concise + + # Generate specific class diagram + daqpytools-generate-uml -p daqpytools -c MyClass + + # Skip splitting into components + daqpytools-generate-uml daqpytools --no-split + + # Generate SVG with minimum cluster size of 2 + daqpytools-generate-uml daqpytools --format svg --min-size 2 + """ + + output_dir = Path(output_directory) + + # ── Step 1: Build pyreverse command ────────────────────────────────────── + pyreverse_args = [] + + # Add targets and packages + for target in targets: + pyreverse_args.append(target) + for pkg in package: + pyreverse_args.append(pkg) + + # Add specific classes (-c flag) + for cls in classes: + pyreverse_args.extend(["-c", cls]) + + if not pyreverse_args and not targets: + click.secho("Error: No targets or packages specified.", fg="red", err=True) + sys.exit(1) + + # ── Step 2: Run style_pyreverse.py ─────────────────────────────────────── + click.secho("[generate_uml] Running style_pyreverse...", fg="cyan") + + style_cmd = [ + "python", "-m", "daqpytools.uml.style_pyreverse", + ] + pyreverse_args + [ + "--output-directory", str(output_dir), + "--format", format, + ] + + if concise: + style_cmd.append("--concise") + + if verbose: + click.echo(f" Command: {' '.join(style_cmd)}") + + result = subprocess.run(style_cmd, capture_output=not verbose, text=True) + if result.returncode != 0: + click.secho(f"Error running style_pyreverse: {result.stderr}", fg="red", err=True) + sys.exit(result.returncode) + + # ── Step 3: Find the generated .dot file ───────────────────────────────── + dot_files = sorted(output_dir.glob("*_styled.dot")) + if not dot_files: + click.secho( + "Warning: No styled .dot files found. Check style_pyreverse output.", + fg="yellow", + err=True, + ) + return + + styled_dot = dot_files[-1] # Use most recent if multiple + click.secho(f" ✓ Generated: {styled_dot.name}", fg="green") + + # ── Step 4: Run split_diagram.py (unless --no-split) ────────────────────── + if not no_split: + click.secho("[generate_uml] Running split_diagram...", fg="cyan") + + split_dir = output_dir / "split" + split_cmd = [ + "python", "-m", "daqpytools.uml.split_diagram", + str(styled_dot), + "--output-directory", str(split_dir), + "--format", format, + "--min-size", str(min_size), + ] + + if concise: + split_cmd.append("--concise") + + if verbose: + click.echo(f" Command: {' '.join(split_cmd)}") + + result = subprocess.run(split_cmd, capture_output=not verbose, text=True) + if result.returncode != 0: + click.secho(f"Error running split_diagram: {result.stderr}", fg="red", err=True) + sys.exit(result.returncode) + + click.secho(f" ✓ Split diagrams written to: {split_dir}", fg="green") + else: + click.secho("[generate_uml] Skipping split_diagram (--no-split set)", fg="yellow") + + # ── Summary ────────────────────────────────────────────────────────────── + click.secho("\n[generate_uml] ✓ Complete!", fg="green", bold=True) + click.echo(f" Output directory: {output_dir.resolve()}") + if not no_split: + click.echo(f" Split diagrams: {(output_dir / 'split').resolve()}") -def main(): - print("Hello world!") if __name__ == "__main__": main() \ No newline at end of file From 989eadf71406a4daa1a0a9b0a25c1736762c6923 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 16 Apr 2026 18:14:05 +0200 Subject: [PATCH 09/19] [to fixup] refactor --- src/daqpytools/apps/generate_uml.py | 11 ++ src/daqpytools/uml/dot_patch_config.py | 144 +++++++++++++++++++++++++ src/daqpytools/uml/style_pyreverse.py | 131 +++++++++------------- 3 files changed, 206 insertions(+), 80 deletions(-) create mode 100644 src/daqpytools/uml/dot_patch_config.py diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index e21b0e5..ecdf8b3 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -11,6 +11,7 @@ daqpytools-generate-uml daqpytools --output-directory pics --concise daqpytools-generate-uml -p my_package -c MyClass --output-directory pics --no-split daqpytools-generate-uml daqpytools --format svg --min-size 2 + daqpytools-generate-uml daqpytools --style-config ./my_style.yaml """ import subprocess @@ -97,6 +98,12 @@ def validate_output_directory(ctx, param, value): is_flag=True, help="Verbose output.", ) +@click.option( + "--style-config", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="Path to YAML style config file for style_pyreverse.", +) def main( targets, output_directory, @@ -107,6 +114,7 @@ def main( package, classes, verbose, + style_config, ): """ Generate styled UML class diagrams from Python code. @@ -161,6 +169,9 @@ def main( if concise: style_cmd.append("--concise") + + if style_config is not None: + style_cmd.extend(["--style-config", str(style_config)]) if verbose: click.echo(f" Command: {' '.join(style_cmd)}") diff --git a/src/daqpytools/uml/dot_patch_config.py b/src/daqpytools/uml/dot_patch_config.py new file mode 100644 index 0000000..8b19fb8 --- /dev/null +++ b/src/daqpytools/uml/dot_patch_config.py @@ -0,0 +1,144 @@ +"""Configuration and helpers for .dot patching rules. + +This module intentionally separates: +1) Static transformation configuration (regex substitutions + style builders) +2) Runtime execution (performed in style_pyreverse.py) + +The goal is to keep patch_dot and fix_edges small and orchestration-focused, +while this file remains the single place to edit transformation behavior. + +Substitution semantics +---------------------- +Each substitution is a tuple: (pattern, replacement), applied in order. + +Regex example notation used below: + before -> after +""" + +from typing import Dict + +Substitution = tuple[str, str] + +STRIP_NODE_ATTRS: list[Substitution] = [ + # Remove pyreverse-injected node colors/styles so global defaults can win. + # Example: + # [fontcolor="red", color="blue", style="filled", fillcolor="gray"] + # -> [] (then cleaned by CLEANUP_ATTR_LISTS) + (r',?\s*\bfontcolor\s*=\s*"[^"]*"', ''), + (r',?\s*\bcolor\s*=\s*"[^"]*"', ''), + (r',?\s*\bstyle\s*=\s*"[^"]*"', ''), + (r',?\s*\bfillcolor\s*=\s*"[^"]*"', ''), +] + +CLEANUP_ATTR_LISTS: list[Substitution] = [ + # Normalize malformed attribute lists produced by removals. + # Examples: + # [] -> "" + # [, a=b] -> [a=b] + # [a=b, ] -> [a=b] + # [a=b,,c] -> [a=b, c] + (r'\[\s*\]', ''), + (r'\[\s*,', '['), + (r',\s*\]', ']'), + (r',\s*,', ', '), +] + +STRIP_EDGE_ATTRS: list[Substitution] = [ + # Remove edge styling so we can deterministically re-apply UML arrows/colors. + # Example: + # A -> B [arrowhead="vee", style="dashed", color="red"] + # -> A -> B [] (then cleaned by CLEANUP_EDGE_ATTRS) + (r',?\s*arrowhead\s*=\s*"?[^",\]\s]+"?', ''), + (r',?\s*\bstyle\s*=\s*"[^"]*"', ''), + (r',?\s*\bcolor\s*=\s*"[^"]*"', ''), +] + +CLEANUP_EDGE_ATTRS: list[Substitution] = [ + # Same bracket normalization as node cleanup, scoped to edge lines. + # Example: + # A -> B [, label="x", ] -> A -> B [label="x"] + (r'\[\s*,', '['), + (r',\s*\]', ']'), + (r'\[\s*\]', ''), +] + +PATCH_DOT_SUBSTITUTION_PIPELINE: tuple[list[Substitution], ...] = ( + # Dot-level phases run by patch_dot in this exact order. + STRIP_NODE_ATTRS, + CLEANUP_ATTR_LISTS, +) + +EDGE_SUBSTITUTION_PIPELINE: tuple[list[Substitution], ...] = ( + # Per-edge phases run by fix_edges in this exact order. + STRIP_EDGE_ATTRS, + CLEANUP_EDGE_ATTRS, +) + +DIGRAPH_OPEN_PATTERN = r'digraph\s+\S+\s*\{' + +DEFAULT_BLOCK_BUILDERS = ( + # Build graph/node/edge default attribute blocks inserted after + # DIGRAPH_OPEN_PATTERN. Using builders keeps this declarative and easy + # to tweak without touching execution logic. + lambda style: ( + 'graph [' + f'bgcolor="{style["bg_color"]}" ' + f'fontname="{style["class_font"]}" ' + 'pad="0.5" nodesep="0.6" ranksep="0.9" ' + f'rankdir="{style["rankdir"]}"' + '];' + ), + lambda style: ( + 'node [' + 'shape=record ' + 'style="filled" ' + f'fillcolor="{style["class_fill"]}" ' + f'color="{style["class_stroke"]}" ' + f'fontname="{style["class_font"]}" ' + f'fontsize={style["class_fontsize"]}' + '];' + ), + lambda style: ( + 'edge [' + f'fontname="{style["class_font"]}" ' + 'fontsize="9" ' + f'color="{style["inherit_color"]}"' + '];' + ), +) + +EDGE_ATTR_BUILDERS = { + # True -> dependency edge (dashed, open arrow) + # False -> inheritance edge (solid, empty/hollow triangle) + True: lambda style: ( + 'arrowhead="open" ' + 'style="dashed" ' + f'color="{style["uses_color"]}"' + ), + False: lambda style: ( + 'arrowhead="empty" ' + 'style="solid" ' + f'color="{style["inherit_color"]}"' + ), +} + + +def build_defaults_block(style: Dict[str, str]) -> str: + """Return formatted graph/node/edge defaults block for a digraph body. + + Example output shape: + graph [...] + node [...] + edge [...] + """ + lines = [builder(style) for builder in DEFAULT_BLOCK_BUILDERS] + return "\n " + "\n ".join(lines) + "\n" + + +def build_edge_attrs(style: Dict[str, str], is_dashed: bool) -> str: + """Return edge attribute string chosen by edge semantic type. + + is_dashed=True -> dependency/uses style + is_dashed=False -> inheritance style + """ + return EDGE_ATTR_BUILDERS[is_dashed](style) diff --git a/src/daqpytools/uml/style_pyreverse.py b/src/daqpytools/uml/style_pyreverse.py index 1530ef0..1668aa5 100644 --- a/src/daqpytools/uml/style_pyreverse.py +++ b/src/daqpytools/uml/style_pyreverse.py @@ -5,7 +5,7 @@ Wraps pyreverse to produce nicely styled UML class diagrams. Usage: - python style_pyreverse.py [pyreverse args...] [--concise] + python style_pyreverse.py [pyreverse args...] [--concise] [--style-config PATH] Example (drop-in replacement for your existing command): python style_pyreverse.py \ @@ -28,9 +28,19 @@ import argparse from pathlib import Path from daqpytools.uml.utils import strip_typehints, load_style_config +from daqpytools.uml.dot_patch_config import ( + PATCH_DOT_SUBSTITUTION_PIPELINE, + EDGE_SUBSTITUTION_PIPELINE, + DIGRAPH_OPEN_PATTERN, + build_defaults_block, + build_edge_attrs, +) -# Load UML diagram style configuration from style.yaml -STYLE = load_style_config() +def _apply_substitutions(text: str, substitutions: list[tuple[str, str]]) -> str: + """Apply regex substitutions in order.""" + for pattern, replacement in substitutions: + text = re.sub(pattern, replacement, text) + return text def run_pyreverse(extra_args, output_dir): @@ -48,66 +58,34 @@ def run_pyreverse(extra_args, output_dir): return dot_files -def patch_dot(dot_src, concise=False): +def patch_dot(dot_src, style, concise=False): """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) + + # ── 1. Run configured dot-level substitution pipeline ──────────────────── + for substitutions in PATCH_DOT_SUBSTITUTION_PIPELINE: + dot_src = _apply_substitutions(dot_src, substitutions) # ── Concise mode: strip type hints ───────────────────────────────────── if concise: dot_src = strip_typehints(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" + defaults_block = build_defaults_block(style) 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) + dot_src = re.sub(DIGRAPH_OPEN_PATTERN, insert_defaults, dot_src, count=1) # ── 3. Fix arrowheads to proper UML style ──────────────────────────────── - dot_src = fix_edges(dot_src) + dot_src = fix_edges(dot_src, style) return dot_src -def fix_edges(dot_src): +#TODO: There is scope here to instead of 'fix' the edge to make this modifiable +# maybe not for the initial release.. +def fix_edges(dot_src, style): """ Restyle edges line-by-line: dashed → dependency/uses: arrowhead=open, style=dashed @@ -122,28 +100,10 @@ def fix_edges(dot_src): 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"] + '"' - ) + # Run configured edge-level substitution pipeline, then append style attrs + for substitutions in EDGE_SUBSTITUTION_PIPELINE: + line = _apply_substitutions(line, substitutions) + new_attrs = build_edge_attrs(style, is_dashed) if '[' in line: # Append new attrs inside the existing bracket @@ -172,16 +132,8 @@ def render_dot(dot_path, output_dir, fmt="png"): 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 …)") - parser.add_argument("--concise", action="store_true", - help="Remove type hints from class attributes and methods") - known, pyreverse_args = parser.parse_known_args() - - # Strip -o / --output flags (we always force dot output) +def _filter_pyreverse_args(pyreverse_args: list[str]) -> list[str]: + """Remove CLI args we override when forcing pyreverse dot output.""" filtered = [] skip_next = False for arg in pyreverse_args: @@ -196,7 +148,26 @@ def main(): if arg == "--colorized": # we handle colour ourselves continue filtered.append(arg) - pyreverse_args = filtered + return filtered + + +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 …)") + parser.add_argument("--concise", action="store_true", + help="Remove type hints from class attributes and methods") + parser.add_argument( + "--style-config", + default=None, + help="Path to YAML style config file (defaults to uml/style.yaml).", + ) + + known, pyreverse_args = parser.parse_known_args() + + pyreverse_args = _filter_pyreverse_args(pyreverse_args) + style = load_style_config(known.style_config) output_dir = Path(known.output_directory) output_dir.mkdir(parents=True, exist_ok=True) @@ -207,7 +178,7 @@ def main(): print(f"[style_pyreverse] Styling {dot_path.name} …") original = dot_path.read_text(encoding="utf-8") - patched = patch_dot(original, concise=known.concise) + patched = patch_dot(original, style=style, concise=known.concise) # Save patched .dot alongside original (useful for debugging) patched_path = dot_path.with_name(dot_path.stem + "_styled.dot") From 2093d944347998fc13650743c49aa757923dd3c2 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 20 Apr 2026 11:48:55 +0200 Subject: [PATCH 10/19] Refactor core code --- src/daqpytools/uml/dot_parsing.py | 71 +++++++++++ src/daqpytools/uml/render.py | 17 +++ src/daqpytools/uml/split_diagram.py | 94 +++++++------- src/daqpytools/uml/style_pyreverse.py | 168 +++++++++----------------- src/daqpytools/uml/utils.py | 6 + 5 files changed, 199 insertions(+), 157 deletions(-) create mode 100644 src/daqpytools/uml/dot_parsing.py create mode 100644 src/daqpytools/uml/render.py diff --git a/src/daqpytools/uml/dot_parsing.py b/src/daqpytools/uml/dot_parsing.py new file mode 100644 index 0000000..f677cd3 --- /dev/null +++ b/src/daqpytools/uml/dot_parsing.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import re +from daqpytools.uml.utils import strip_typehints +from daqpytools.uml.dot_patch_config import ( + PATCH_DOT_SUBSTITUTION_PIPELINE, + EDGE_SUBSTITUTION_PIPELINE, + DIGRAPH_OPEN_PATTERN, + build_defaults_block, + build_edge_attrs, +) + + +def apply_substitutions(text: str, substitutions: list[tuple[str, str]]) -> str: + """Apply regex substitutions in order.""" + for pattern, replacement in substitutions: + text = re.sub(pattern, replacement, text) + return text + + +def patch_dot(dot_src, style, concise=False): + """Rewrite .dot source to apply a clean UML style.""" + + for substitutions in PATCH_DOT_SUBSTITUTION_PIPELINE: + dot_src = apply_substitutions(dot_src, substitutions) + + if concise: + dot_src = strip_typehints(dot_src) + + defaults_block = build_defaults_block(style) + + def insert_defaults(m): + return m.group(0) + defaults_block + + dot_src = re.sub(DIGRAPH_OPEN_PATTERN, insert_defaults, dot_src, count=1) + + dot_src = fix_edges(dot_src, style) + + return dot_src + + +def fix_edges(dot_src, style): + """ + 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 + + for substitutions in EDGE_SUBSTITUTION_PIPELINE: + line = apply_substitutions(line, substitutions) + new_attrs = build_edge_attrs(style, is_dashed) + + if '[' in line: + line = re.sub( + r'\[([^\]]*)\]', + lambda m: '[' + (m.group(1).strip().rstrip(',') + ', ' if m.group(1).strip() else '') + new_attrs + ']', + line + ) + else: + line = re.sub(r';?\s*$', ' [' + new_attrs + '];', line.rstrip()) + + out.append(line) + return '\n'.join(out) diff --git a/src/daqpytools/uml/render.py b/src/daqpytools/uml/render.py new file mode 100644 index 0000000..fb6417e --- /dev/null +++ b/src/daqpytools/uml/render.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +import subprocess +import sys +from daqpytools.uml.utils import vprint + + +def render_dot(dot_path, output_dir, fmt="png", verbose=False): + """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)] + vprint(verbose, f"[style_pyreverse] Rendering: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + vprint(verbose, "[style_pyreverse] dot stderr:", result.stderr) + sys.exit(result.returncode) + return out_path diff --git a/src/daqpytools/uml/split_diagram.py b/src/daqpytools/uml/split_diagram.py index 132345a..8213233 100644 --- a/src/daqpytools/uml/split_diagram.py +++ b/src/daqpytools/uml/split_diagram.py @@ -9,7 +9,7 @@ rather than generating a file per class. Usage: - python split_diagram.py [--output-directory DIR] [--format png] [--concise] + python split_diagram.py [--output-directory DIR] [--format png] [--concise] [--suppress-verbose] Example: python split_diagram.py pics/classes_styled.dot --output-directory pics/split @@ -19,12 +19,12 @@ """ import re -import subprocess import sys import argparse from pathlib import Path from collections import defaultdict -from daqpytools.uml.utils import strip_typehints +from daqpytools.uml.utils import strip_typehints, vprint +from daqpytools.uml.render import render_dot # ── Helpers ────────────────────────────────────────────────────────────────── @@ -157,45 +157,18 @@ def build_dot(graph_name: str, header_lines: list, node_lines: list, edge_lines: return dot_content -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)') - parser.add_argument('--concise', action='store_true', - help='Remove type hints from class attributes and methods') - args = parser.parse_args() - - dot_path = Path(args.input_dot) - output_dir = Path(args.output_directory) +def split_dot_file(input_dot: Path, output_dir: Path, concise: bool = False, verbose: bool = False, min_size: int = 1): + """Split a styled .dot file into cluster-specific .dot files.""" output_dir.mkdir(parents=True, exist_ok=True) - dot_src = dot_path.read_text(encoding='utf-8') + dot_src = input_dot.read_text(encoding='utf-8') header_lines, nodes, edges = parse_dot(dot_src) - print(f'[split] Found {len(nodes)} nodes, {len(edges)} edges') + vprint(verbose, 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') + vprint(verbose, 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 = [] @@ -207,34 +180,59 @@ def main(): 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) ' + vprint(verbose, f'[split] Will generate {len(all_clusters)} file(s) ' f'({len(multi_components)} connected + {len(singleton_groups)} module groups)') - # ── 3. Render each cluster ──────────────────────────────────────────────── + written_dot_files: list[Path] = [] + for cluster_nodes in sorted(all_clusters, key=lambda c: -len(c)): + if len(cluster_nodes) < min_size: + vprint(verbose, f' - skipping {len(cluster_nodes)} node cluster below min_size={min_size}') + continue + 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, concise=args.concise) - + dot_content = build_dot(name, header_lines, node_lines, edge_lines, concise=concise) out_dot = output_dir / f'{safe_name}.dot' out_dot.write_text(dot_content, encoding='utf-8') + written_dot_files.append(out_dot) - 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})') + vprint(verbose, f' ✓ {out_dot.name} ({size_label})') + + return written_dot_files if __name__ == '__main__': - main() \ No newline at end of file + 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', choices=['png', 'svg', 'pdf', 'jpg', 'none'], help='Output image format (png, svg, pdf, jpg) or none') + parser.add_argument('--min-size', type=int, default=1, + help='Minimum cluster size to render as its own file (default: 1)') + parser.add_argument('--concise', action='store_true', + help='Remove type hints from class attributes and methods') + parser.add_argument('--suppress-verbose', dest='verbose', action='store_false', + help='Suppress split progress messages') + parser.set_defaults(verbose=True) + args = parser.parse_args() + + dot_files = split_dot_file( + input_dot=Path(args.input_dot), + output_dir=Path(args.output_directory), + concise=args.concise, + verbose=args.verbose, + min_size=args.min_size, + ) + + render_format = None if args.format == 'none' else args.format + if render_format is not None: + for dot_file in dot_files: + out_img = render_dot(dot_file, Path(args.output_directory), fmt=render_format, verbose=args.verbose) + vprint(args.verbose, f' ✓ {out_img.name} (rendered)') \ No newline at end of file diff --git a/src/daqpytools/uml/style_pyreverse.py b/src/daqpytools/uml/style_pyreverse.py index 1668aa5..6039858 100644 --- a/src/daqpytools/uml/style_pyreverse.py +++ b/src/daqpytools/uml/style_pyreverse.py @@ -22,114 +22,14 @@ graphviz must be installed on your system (provides the 'dot' binary) """ +import argparse import re import subprocess import sys -import argparse from pathlib import Path -from daqpytools.uml.utils import strip_typehints, load_style_config -from daqpytools.uml.dot_patch_config import ( - PATCH_DOT_SUBSTITUTION_PIPELINE, - EDGE_SUBSTITUTION_PIPELINE, - DIGRAPH_OPEN_PATTERN, - build_defaults_block, - build_edge_attrs, -) - -def _apply_substitutions(text: str, substitutions: list[tuple[str, str]]) -> str: - """Apply regex substitutions in order.""" - for pattern, replacement in substitutions: - text = re.sub(pattern, replacement, text) - return text - - -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, style, concise=False): - """Rewrite .dot source to apply a clean UML style.""" - - # ── 1. Run configured dot-level substitution pipeline ──────────────────── - for substitutions in PATCH_DOT_SUBSTITUTION_PIPELINE: - dot_src = _apply_substitutions(dot_src, substitutions) - - # ── Concise mode: strip type hints ───────────────────────────────────── - if concise: - dot_src = strip_typehints(dot_src) - - # ── 2. Inject graph-level defaults right after the opening brace ───────── - defaults_block = build_defaults_block(style) - - def insert_defaults(m): - return m.group(0) + defaults_block - - dot_src = re.sub(DIGRAPH_OPEN_PATTERN, insert_defaults, dot_src, count=1) - - # ── 3. Fix arrowheads to proper UML style ──────────────────────────────── - dot_src = fix_edges(dot_src, style) - - return dot_src - - -#TODO: There is scope here to instead of 'fix' the edge to make this modifiable -# maybe not for the initial release.. -def fix_edges(dot_src, style): - """ - 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 - - # Run configured edge-level substitution pipeline, then append style attrs - for substitutions in EDGE_SUBSTITUTION_PIPELINE: - line = _apply_substitutions(line, substitutions) - new_attrs = build_edge_attrs(style, is_dashed) - - 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 +from daqpytools.uml.dot_parsing import patch_dot +from daqpytools.uml.render import render_dot +from daqpytools.uml.utils import load_style_config, vprint def _filter_pyreverse_args(pyreverse_args: list[str]) -> list[str]: @@ -151,10 +51,42 @@ def _filter_pyreverse_args(pyreverse_args: list[str]) -> list[str]: return filtered + +def run_pyreverse(extra_args, output_dir, cwd=None, verbose=False): + """Run pyreverse with -o dot and return the paths to generated .dot files.""" + cmd = ["pyreverse", "-o", "dot", "--output-directory", str(output_dir)] + extra_args + vprint(verbose, f"[style_pyreverse] Running: {' '.join(cmd)}") + + result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) + + 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 main(): parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--output-directory", default=".") - parser.add_argument("--format", default="png", + parser.add_argument( + "--suppress-verbose", + dest="verbose", + action="store_false", + help="Suppress pyreverse, patching, and rendering status messages.", + ) + parser.add_argument( + "--cwd", + default=None, + help="Working directory to run pyreverse from and resolve relative output paths against.", + ) + parser.add_argument("--format", default=None, help="Output image format (png, svg, pdf …)") parser.add_argument("--concise", action="store_true", help="Remove type hints from class attributes and methods") @@ -163,19 +95,33 @@ def main(): default=None, help="Path to YAML style config file (defaults to uml/style.yaml).", ) + parser.set_defaults(verbose=True) known, pyreverse_args = parser.parse_known_args() pyreverse_args = _filter_pyreverse_args(pyreverse_args) style = load_style_config(known.style_config) + cwd = Path(known.cwd).resolve() if known.cwd else None output_dir = Path(known.output_directory) + if cwd is not None and not output_dir.is_absolute(): + output_dir = cwd / output_dir output_dir.mkdir(parents=True, exist_ok=True) - dot_files = run_pyreverse(pyreverse_args, output_dir) + + # Stage 1. Run pyreverse + dot_files = run_pyreverse( + pyreverse_args, + output_dir, + cwd=str(cwd) if cwd is not None else None, + verbose=known.verbose, + ) + # Stage 2. Patch and save the original + ## If you are running this script directly you are probably debugging + ## So we save the patched dot file along with the original for dot_path in dot_files: - print(f"[style_pyreverse] Styling {dot_path.name} …") + vprint(known.verbose, f"[style_pyreverse] Styling {dot_path.name} …") original = dot_path.read_text(encoding="utf-8") patched = patch_dot(original, style=style, concise=known.concise) @@ -184,8 +130,12 @@ def main(): 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}") + # Stage 2.5 Render the dot files + if known.format: + img_path = render_dot(patched_path, output_dir, fmt=known.format, verbose=known.verbose) + vprint(known.verbose, f"[style_pyreverse] ✓ Written: {img_path}") + else: + vprint(known.verbose, f"[style_pyreverse] x No format, skipping render") if __name__ == "__main__": diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py index 8591725..1026b1b 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -3,6 +3,12 @@ from pathlib import Path +def vprint(verbose, *args, **kwargs): + """Print only when verbose output is enabled.""" + if verbose: + print(*args, **kwargs) + + def _strip_param_types(params_str: str) -> str: """Strip type hints from a parameter list (the content between parentheses). From 85b85cf6bd3df5fc63bed6414f0cc92dced00181 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 20 Apr 2026 11:49:11 +0200 Subject: [PATCH 11/19] rewrite CLI tool --- src/daqpytools/apps/generate_uml.py | 264 ++++++++++++---------------- 1 file changed, 112 insertions(+), 152 deletions(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index ecdf8b3..9d6a11d 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -3,63 +3,88 @@ --------------- CLI interface for generating UML class diagrams using pyreverse. -This tool wraps style_pyreverse.py and split_diagram.py to produce -nicely styled UML diagrams, optionally split into multiple files by connected components. +This command calls the UML helper functions directly to: +1. run pyreverse in a chosen working directory, +2. style the generated dot files, +3. optionally render them, and +4. optionally split the diagrams into connected components. Usage: daqpytools-generate-uml daqpytools --output-directory pics - daqpytools-generate-uml daqpytools --output-directory pics --concise - daqpytools-generate-uml -p my_package -c MyClass --output-directory pics --no-split - daqpytools-generate-uml daqpytools --format svg --min-size 2 - daqpytools-generate-uml daqpytools --style-config ./my_style.yaml + daqpytools-generate-uml daqpytools --directory some/path --output-directory pics --split + daqpytools-generate-uml daqpytools --format none """ -import subprocess -import sys from pathlib import Path + import click + from daqpytools.logging.formatter import CONTEXT_SETTINGS +from daqpytools.uml.dot_parsing import patch_dot +from daqpytools.uml.render import render_dot +from daqpytools.uml.split_diagram import split_dot_file +from daqpytools.uml.style_pyreverse import run_pyreverse +from daqpytools.uml.utils import load_style_config, vprint -class PassthroughArgs(click.Command): - """Custom Click command that captures unknown arguments for pyreverse passthrough.""" - - def main(self, *args, **kwargs): - """Override main to collect unknown args.""" - try: - return super().main(*args, **kwargs) - except click.exceptions.UsageError as e: - # Check if this is an unrecognized option meant for pyreverse - if "no such option" in str(e): - # Let it through for passthrough handling - raise - raise +def validate_output_directory(ctx, param, value): + """Return the output directory path without creating it yet.""" + if value is None: + return None + return Path(value) -def validate_output_directory(ctx, param, value): - """Validate and create output directory if needed.""" - if value: - out_dir = Path(value) - out_dir.mkdir(parents=True, exist_ok=True) - return value +def build_pyreverse_args(targets, packages, classes): + """Build the pyreverse argument list from CLI inputs.""" + pyreverse_args = [] + pyreverse_args.extend(targets) + pyreverse_args.extend(packages) + for cls in classes: + pyreverse_args.extend(["-c", cls]) + return pyreverse_args + + +def resolve_output_directory(directory: Path | None, output_directory: Path) -> tuple[Path, Path]: + """Resolve the working directory and output directory consistently.""" + cwd = Path.cwd() if directory is None else Path(directory).resolve() + resolved_output = output_directory if output_directory.is_absolute() else cwd / output_directory + resolved_output.mkdir(parents=True, exist_ok=True) + return cwd, resolved_output + + +def style_dot_file(dot_path: Path, style: dict, concise: bool) -> Path: + """Patch a raw dot file and write the styled version next to it.""" + original = dot_path.read_text(encoding="utf-8") + patched = patch_dot(original, style=style, concise=concise) + patched_path = dot_path.with_name(f"{dot_path.stem}_styled.dot") + patched_path.write_text(patched, encoding="utf-8") + return patched_path @click.command(context_settings=CONTEXT_SETTINGS) @click.argument("targets", nargs=-1, required=False) +@click.option( + "-d", + "--directory", + type=click.Path(path_type=Path, file_okay=False, dir_okay=True), + default=None, + help="Working directory to run pyreverse from. [default: current directory]", +) @click.option( "-o", + "-od", "--output-directory", - type=click.Path(), - default=".", + type=click.Path(path_type=Path, file_okay=False, dir_okay=True), + default=Path("pics"), callback=validate_output_directory, - help="Output directory for generated diagrams. [default: .]", + help="Output directory for generated diagrams. [default: pics]", ) @click.option( "-f", "--format", - type=click.Choice(["png", "svg", "pdf", "jpg"]), + type=click.Choice(["png", "svg", "pdf", "jpg", "none"], case_sensitive=False), default="png", - help="Output image format. [default: png]", + help="Output image format, or 'none' to keep dot files only. [default: png]", ) @click.option( "-c", @@ -68,17 +93,16 @@ def validate_output_directory(ctx, param, value): help="Remove type hints from class attributes and methods.", ) @click.option( - "-ns", - "--no-split", - is_flag=True, - help="Do not split diagram by connected components; generate single diagram.", + "--split/--no-split", + default=False, + help="Split generated diagrams into connected components.", ) @click.option( "-ms", "--min-size", type=int, default=1, - help="Minimum cluster size to render as separate file (used with --split). [default: 1]", + help="Minimum cluster size to render as separate file. [default: 1]", ) @click.option( "-p", @@ -93,140 +117,76 @@ def validate_output_directory(ctx, param, value): help="Specific class(es) to include (passed to pyreverse as -c).", ) @click.option( - "-v", - "--verbose", - is_flag=True, - help="Verbose output.", + "--verbose/--suppress-verbose", + default=True, + help="Print progress messages.", ) @click.option( "--style-config", type=click.Path(exists=True, dir_okay=False, path_type=Path), default=None, - help="Path to YAML style config file for style_pyreverse.", + help="Path to YAML style config file for the UML renderer.", ) def main( targets, + directory, output_directory, format, concise, - no_split, + split, min_size, package, classes, verbose, style_config, ): - """ - Generate styled UML class diagrams from Python code. - - You can specify packages/modules as TARGETS or use --package/-p. - - Examples: - # Generate diagram for daqpytools package - daqpytools-generate-uml daqpytools --output-directory pics - - # Generate with concise mode (no type hints) - daqpytools-generate-uml daqpytools --concise - - # Generate specific class diagram - daqpytools-generate-uml -p daqpytools -c MyClass - - # Skip splitting into components - daqpytools-generate-uml daqpytools --no-split - - # Generate SVG with minimum cluster size of 2 - daqpytools-generate-uml daqpytools --format svg --min-size 2 - """ - - output_dir = Path(output_directory) - - # ── Step 1: Build pyreverse command ────────────────────────────────────── - pyreverse_args = [] - - # Add targets and packages - for target in targets: - pyreverse_args.append(target) - for pkg in package: - pyreverse_args.append(pkg) - - # Add specific classes (-c flag) - for cls in classes: - pyreverse_args.extend(["-c", cls]) - - if not pyreverse_args and not targets: + """Generate styled UML class diagrams from Python code.""" + + pyreverse_args = build_pyreverse_args(targets, package, classes) + if not pyreverse_args: click.secho("Error: No targets or packages specified.", fg="red", err=True) - sys.exit(1) - - # ── Step 2: Run style_pyreverse.py ─────────────────────────────────────── - click.secho("[generate_uml] Running style_pyreverse...", fg="cyan") - - style_cmd = [ - "python", "-m", "daqpytools.uml.style_pyreverse", - ] + pyreverse_args + [ - "--output-directory", str(output_dir), - "--format", format, - ] - - if concise: - style_cmd.append("--concise") - - if style_config is not None: - style_cmd.extend(["--style-config", str(style_config)]) - - if verbose: - click.echo(f" Command: {' '.join(style_cmd)}") - - result = subprocess.run(style_cmd, capture_output=not verbose, text=True) - if result.returncode != 0: - click.secho(f"Error running style_pyreverse: {result.stderr}", fg="red", err=True) - sys.exit(result.returncode) - - # ── Step 3: Find the generated .dot file ───────────────────────────────── - dot_files = sorted(output_dir.glob("*_styled.dot")) - if not dot_files: - click.secho( - "Warning: No styled .dot files found. Check style_pyreverse output.", - fg="yellow", - err=True, - ) - return - - styled_dot = dot_files[-1] # Use most recent if multiple - click.secho(f" ✓ Generated: {styled_dot.name}", fg="green") - - # ── Step 4: Run split_diagram.py (unless --no-split) ────────────────────── - if not no_split: - click.secho("[generate_uml] Running split_diagram...", fg="cyan") - - split_dir = output_dir / "split" - split_cmd = [ - "python", "-m", "daqpytools.uml.split_diagram", - str(styled_dot), - "--output-directory", str(split_dir), - "--format", format, - "--min-size", str(min_size), - ] - - if concise: - split_cmd.append("--concise") - - if verbose: - click.echo(f" Command: {' '.join(split_cmd)}") - - result = subprocess.run(split_cmd, capture_output=not verbose, text=True) - if result.returncode != 0: - click.secho(f"Error running split_diagram: {result.stderr}", fg="red", err=True) - sys.exit(result.returncode) - - click.secho(f" ✓ Split diagrams written to: {split_dir}", fg="green") + raise SystemExit(1) + + cwd, resolved_output_dir = resolve_output_directory(directory, output_directory) + style = load_style_config(style_config) + render_format = None if format.lower() == "none" else format.lower() + + vprint(verbose, f"[generate_uml] Running pyreverse in {cwd}") + dot_files = run_pyreverse(pyreverse_args, resolved_output_dir, cwd=str(cwd), verbose=verbose) + + styled_dot_files = [] + for dot_path in dot_files: + vprint(verbose, f"[generate_uml] Styling {dot_path.name}") + styled_dot_files.append(style_dot_file(dot_path, style=style, concise=concise)) + + split_dot_files = [] + if split: + split_root = resolved_output_dir / "split" + for styled_dot in styled_dot_files: + split_output_dir = split_root / styled_dot.stem + split_dot_files.extend(split_dot_file( + input_dot=styled_dot, + output_dir=split_output_dir, + concise=concise, + verbose=verbose, + min_size=min_size, + )) + vprint(verbose, f"[generate_uml] Split diagrams written to {split_output_dir}") + + if render_format is not None: + + for split_dot in split_dot_files: + img_path = render_dot(split_dot, split_dot.parent, fmt=render_format, verbose=verbose) + vprint(verbose, f"[generate_uml] Written: {img_path}") + + for styled_dot in styled_dot_files: + img_path = render_dot(styled_dot, resolved_output_dir, fmt=render_format, verbose=verbose) + vprint(verbose, f"[generate_uml] Written: {img_path}") else: - click.secho("[generate_uml] Skipping split_diagram (--no-split set)", fg="yellow") - - # ── Summary ────────────────────────────────────────────────────────────── - click.secho("\n[generate_uml] ✓ Complete!", fg="green", bold=True) - click.echo(f" Output directory: {output_dir.resolve()}") - if not no_split: - click.echo(f" Split diagrams: {(output_dir / 'split').resolve()}") + vprint(verbose, "[generate_uml] Skipping rendering (--format none)") + + vprint(verbose, "[generate_uml] Complete") + vprint(verbose, f"[generate_uml] Output directory: {resolved_output_dir.resolve()}") if __name__ == "__main__": From 4e2006e36ad39518198188ded162bb64312e7b10 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 20 Apr 2026 12:31:36 +0200 Subject: [PATCH 12/19] ruff --- src/daqpytools/apps/generate_uml.py | 93 +++++++----- src/daqpytools/uml/dot_parsing.py | 86 ++++++----- src/daqpytools/uml/dot_patch_config.py | 5 +- src/daqpytools/uml/render.py | 32 ++-- src/daqpytools/uml/split_diagram.py | 195 ++++++++++++++----------- src/daqpytools/uml/style_pyreverse.py | 105 ++++++++----- src/daqpytools/uml/utils.py | 97 ++++++------ 7 files changed, 349 insertions(+), 264 deletions(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index 9d6a11d..a237e7a 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -1,7 +1,4 @@ -""" -generate_uml.py ---------------- -CLI interface for generating UML class diagrams using pyreverse. +"""CLI interface for generating UML class diagrams. This command calls the UML helper functions directly to: 1. run pyreverse in a chosen working directory, @@ -11,7 +8,8 @@ Usage: daqpytools-generate-uml daqpytools --output-directory pics - daqpytools-generate-uml daqpytools --directory some/path --output-directory pics --split + daqpytools-generate-uml daqpytools + --directory some/path --output-directory pics --split daqpytools-generate-uml daqpytools --format none """ @@ -27,14 +25,18 @@ from daqpytools.uml.utils import load_style_config, vprint -def validate_output_directory(ctx, param, value): +def validate_output_directory( + ctx: click.Context, param: click.Parameter, value: Path | None +) -> Path | None: """Return the output directory path without creating it yet.""" if value is None: return None return Path(value) -def build_pyreverse_args(targets, packages, classes): +def build_pyreverse_args( + targets: tuple[str, ...], packages: tuple[str, ...], classes: tuple[str, ...] +) -> list[str]: """Build the pyreverse argument list from CLI inputs.""" pyreverse_args = [] pyreverse_args.extend(targets) @@ -44,15 +46,19 @@ def build_pyreverse_args(targets, packages, classes): return pyreverse_args -def resolve_output_directory(directory: Path | None, output_directory: Path) -> tuple[Path, Path]: +def resolve_output_directory( + directory: Path | None, output_directory: Path +) -> tuple[Path, Path]: """Resolve the working directory and output directory consistently.""" cwd = Path.cwd() if directory is None else Path(directory).resolve() - resolved_output = output_directory if output_directory.is_absolute() else cwd / output_directory + resolved_output = ( + output_directory if output_directory.is_absolute() else cwd / output_directory + ) resolved_output.mkdir(parents=True, exist_ok=True) return cwd, resolved_output -def style_dot_file(dot_path: Path, style: dict, concise: bool) -> Path: +def style_dot_file(dot_path: Path, style: dict[str, str], concise: bool) -> Path: """Patch a raw dot file and write the styled version next to it.""" original = dot_path.read_text(encoding="utf-8") patched = patch_dot(original, style=style, concise=concise) @@ -82,6 +88,7 @@ def style_dot_file(dot_path: Path, style: dict, concise: bool) -> Path: @click.option( "-f", "--format", + "output_format", type=click.Choice(["png", "svg", "pdf", "jpg", "none"], case_sensitive=False), default="png", help="Output image format, or 'none' to keep dot files only. [default: png]", @@ -128,20 +135,19 @@ def style_dot_file(dot_path: Path, style: dict, concise: bool) -> Path: help="Path to YAML style config file for the UML renderer.", ) def main( - targets, - directory, - output_directory, - format, - concise, - split, - min_size, - package, - classes, - verbose, - style_config, -): + targets: tuple[str, ...], + directory: Path | None, + output_directory: Path, + output_format: str, + concise: bool, + split: bool, + min_size: int, + package: tuple[str, ...], + classes: tuple[str, ...], + verbose: bool, + style_config: Path | None, +) -> None: """Generate styled UML class diagrams from Python code.""" - pyreverse_args = build_pyreverse_args(targets, package, classes) if not pyreverse_args: click.secho("Error: No targets or packages specified.", fg="red", err=True) @@ -149,38 +155,47 @@ def main( cwd, resolved_output_dir = resolve_output_directory(directory, output_directory) style = load_style_config(style_config) - render_format = None if format.lower() == "none" else format.lower() + render_format = None if output_format.lower() == "none" else output_format.lower() vprint(verbose, f"[generate_uml] Running pyreverse in {cwd}") - dot_files = run_pyreverse(pyreverse_args, resolved_output_dir, cwd=str(cwd), verbose=verbose) + dot_files = run_pyreverse( + pyreverse_args, resolved_output_dir, cwd=str(cwd), verbose=verbose + ) - styled_dot_files = [] + styled_dot_files: list[Path] = [] for dot_path in dot_files: vprint(verbose, f"[generate_uml] Styling {dot_path.name}") styled_dot_files.append(style_dot_file(dot_path, style=style, concise=concise)) - split_dot_files = [] + split_dot_files: list[Path] = [] if split: split_root = resolved_output_dir / "split" for styled_dot in styled_dot_files: split_output_dir = split_root / styled_dot.stem - split_dot_files.extend(split_dot_file( - input_dot=styled_dot, - output_dir=split_output_dir, - concise=concise, - verbose=verbose, - min_size=min_size, - )) - vprint(verbose, f"[generate_uml] Split diagrams written to {split_output_dir}") + split_dot_files.extend( + split_dot_file( + input_dot=styled_dot, + output_dir=split_output_dir, + concise=concise, + verbose=verbose, + min_size=min_size, + ) + ) + vprint( + verbose, f"[generate_uml] Split diagrams written to {split_output_dir}" + ) if render_format is not None: - for split_dot in split_dot_files: - img_path = render_dot(split_dot, split_dot.parent, fmt=render_format, verbose=verbose) + img_path = render_dot( + split_dot, split_dot.parent, fmt=render_format, verbose=verbose + ) vprint(verbose, f"[generate_uml] Written: {img_path}") for styled_dot in styled_dot_files: - img_path = render_dot(styled_dot, resolved_output_dir, fmt=render_format, verbose=verbose) + img_path = render_dot( + styled_dot, resolved_output_dir, fmt=render_format, verbose=verbose + ) vprint(verbose, f"[generate_uml] Written: {img_path}") else: vprint(verbose, "[generate_uml] Skipping rendering (--format none)") @@ -190,4 +205,4 @@ def main( if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/daqpytools/uml/dot_parsing.py b/src/daqpytools/uml/dot_parsing.py index f677cd3..5511198 100644 --- a/src/daqpytools/uml/dot_parsing.py +++ b/src/daqpytools/uml/dot_parsing.py @@ -1,26 +1,33 @@ #!/usr/bin/env python3 +"""Helpers for transforming pyreverse dot output.""" import re -from daqpytools.uml.utils import strip_typehints +from collections.abc import Sequence +from functools import partial + from daqpytools.uml.dot_patch_config import ( - PATCH_DOT_SUBSTITUTION_PIPELINE, - EDGE_SUBSTITUTION_PIPELINE, DIGRAPH_OPEN_PATTERN, + EDGE_SUBSTITUTION_PIPELINE, + PATCH_DOT_SUBSTITUTION_PIPELINE, build_defaults_block, build_edge_attrs, ) +from daqpytools.uml.utils import strip_typehints -def apply_substitutions(text: str, substitutions: list[tuple[str, str]]) -> str: +def apply_substitutions(text: str, substitutions: Sequence[tuple[str, str]]) -> str: """Apply regex substitutions in order.""" for pattern, replacement in substitutions: text = re.sub(pattern, replacement, text) return text -def patch_dot(dot_src, style, concise=False): - """Rewrite .dot source to apply a clean UML style.""" +def _insert_defaults(match: re.Match[str], defaults_block: str) -> str: + return match.group(0) + defaults_block + +def patch_dot(dot_src: str, style: dict[str, str], concise: bool = False) -> str: + """Rewrite dot source to apply a clean UML style.""" for substitutions in PATCH_DOT_SUBSTITUTION_PIPELINE: dot_src = apply_substitutions(dot_src, substitutions) @@ -28,44 +35,53 @@ def patch_dot(dot_src, style, concise=False): dot_src = strip_typehints(dot_src) defaults_block = build_defaults_block(style) - - def insert_defaults(m): - return m.group(0) + defaults_block - - dot_src = re.sub(DIGRAPH_OPEN_PATTERN, insert_defaults, dot_src, count=1) - - dot_src = fix_edges(dot_src, style) - - return dot_src - - -def fix_edges(dot_src, style): - """ - Restyle edges line-by-line: - dashed → dependency/uses: arrowhead=open, style=dashed - solid → inheritance: arrowhead=empty, style=solid (hollow triangle) + return fix_edges( + re.sub( + DIGRAPH_OPEN_PATTERN, + partial(_insert_defaults, defaults_block=defaults_block), + dot_src, + count=1, + ), + style, + ) + + +def _append_edge_attrs(line: str, new_attrs: str) -> str: + if "[" in line: + return re.sub( + r"\[([^\]]*)\]", + lambda match: ( + "[" + + ( + match.group(1).strip().rstrip(",") + ", " + if match.group(1).strip() + else "" + ) + + new_attrs + + "]" + ), + line, + ) + return re.sub(r";?\s*$", f" [{new_attrs}];", line.rstrip()) + + +def fix_edges(dot_src: str, style: dict[str, str]) -> str: + """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: + if "->" not in line: out.append(line) continue - is_dashed = 'dashed' in line + is_dashed = "dashed" in line for substitutions in EDGE_SUBSTITUTION_PIPELINE: line = apply_substitutions(line, substitutions) new_attrs = build_edge_attrs(style, is_dashed) - - if '[' in line: - line = re.sub( - r'\[([^\]]*)\]', - lambda m: '[' + (m.group(1).strip().rstrip(',') + ', ' if m.group(1).strip() else '') + new_attrs + ']', - line - ) - else: - line = re.sub(r';?\s*$', ' [' + new_attrs + '];', line.rstrip()) - + line = _append_edge_attrs(line, new_attrs) out.append(line) - return '\n'.join(out) + return "\n".join(out) diff --git a/src/daqpytools/uml/dot_patch_config.py b/src/daqpytools/uml/dot_patch_config.py index 8b19fb8..84ab753 100644 --- a/src/daqpytools/uml/dot_patch_config.py +++ b/src/daqpytools/uml/dot_patch_config.py @@ -15,7 +15,6 @@ before -> after """ -from typing import Dict Substitution = tuple[str, str] @@ -123,7 +122,7 @@ } -def build_defaults_block(style: Dict[str, str]) -> str: +def build_defaults_block(style: dict[str, str]) -> str: """Return formatted graph/node/edge defaults block for a digraph body. Example output shape: @@ -135,7 +134,7 @@ def build_defaults_block(style: Dict[str, str]) -> str: return "\n " + "\n ".join(lines) + "\n" -def build_edge_attrs(style: Dict[str, str], is_dashed: bool) -> str: +def build_edge_attrs(style: dict[str, str], is_dashed: bool) -> str: """Return edge attribute string chosen by edge semantic type. is_dashed=True -> dependency/uses style diff --git a/src/daqpytools/uml/render.py b/src/daqpytools/uml/render.py index fb6417e..a8cc473 100644 --- a/src/daqpytools/uml/render.py +++ b/src/daqpytools/uml/render.py @@ -1,17 +1,25 @@ #!/usr/bin/env python3 +"""Render styled UML dot files using Graphviz.""" + +from pathlib import Path + +from graphviz import Source -import subprocess -import sys from daqpytools.uml.utils import vprint -def render_dot(dot_path, output_dir, fmt="png", verbose=False): - """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)] - vprint(verbose, f"[style_pyreverse] Rendering: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - vprint(verbose, "[style_pyreverse] dot stderr:", result.stderr) - sys.exit(result.returncode) - return out_path +def render_dot( + dot_path: Path, output_dir: Path, fmt: str = "png", verbose: bool = False +) -> Path: + """Render a dot file to an image.""" + output_dir.mkdir(parents=True, exist_ok=True) + vprint(verbose, f"[style_pyreverse] Rendering {dot_path.name} as {fmt}") + source = Source.from_file(str(dot_path), format=fmt) + return Path( + source.render( + filename=dot_path.stem, + directory=str(output_dir), + cleanup=False, + quiet=not verbose, + ) + ) diff --git a/src/daqpytools/uml/split_diagram.py b/src/daqpytools/uml/split_diagram.py index 8213233..3952bf3 100644 --- a/src/daqpytools/uml/split_diagram.py +++ b/src/daqpytools/uml/split_diagram.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 -""" -split_diagram.py ----------------- +"""Split styled UML dot files into connected clusters. + Splits a pyreverse-generated (styled) .dot file into multiple files, one per connected cluster of classes. @@ -9,36 +8,33 @@ rather than generating a file per class. Usage: - python split_diagram.py [--output-directory DIR] [--format png] [--concise] [--suppress-verbose] + python split_diagram.py [--output-directory DIR] + [--format png] [--concise] [--suppress-verbose] Example: python split_diagram.py pics/classes_styled.dot --output-directory pics/split # With --concise to remove type hints: - python split_diagram.py pics/classes_styled.dot --output-directory pics/split --concise + python split_diagram.py pics/classes_styled.dot + --output-directory pics/split --concise """ -import re -import sys import argparse -from pathlib import Path +import re from collections import defaultdict -from daqpytools.uml.utils import strip_typehints, vprint -from daqpytools.uml.render import render_dot +from collections.abc import Sequence +from pathlib import Path +from daqpytools.uml.render import render_dot +from daqpytools.uml.utils import strip_typehints, vprint # ── 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 - """ +def parse_dot( + dot_src: str, +) -> tuple[list[str], dict[str, str], list[tuple[str, str, str]]]: + """Parse a flat dot file.""" header_lines = [] nodes = {} edges = [] @@ -51,12 +47,12 @@ def parse_dot(dot_src: str): for line in dot_src.splitlines(): stripped = line.strip() - if re.match(r'^digraph\s', stripped): + if re.match(r"^digraph\s", stripped): in_graph = True header_lines.append(line) continue - if not in_graph or stripped == '}': + if not in_graph or stripped == "}": continue em = edge_re.match(stripped) @@ -75,17 +71,19 @@ def parse_dot(dot_src: str): return header_lines, nodes, edges -def find_connected_components(node_ids: set, edges: list): - """Union-Find over node_ids using edge pairs.""" +def find_connected_components( + node_ids: set[str], edges: Sequence[tuple[str, str, str]] +) -> list[set[str]]: + """Find connected components with union-find.""" parent = {n: n for n in node_ids} - def find(x): + def find(x: str) -> str: while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x - def union(a, b): + def union(a: str, b: str) -> None: parent[find(a)] = find(b) for src, dst, _ in edges: @@ -100,40 +98,39 @@ def union(a, b): 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('.') + """Return a short group name for a node based on its module path.""" + 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',)] + 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) + 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. - """ +def cluster_name(node_ids: set[str]) -> str: + """Derive a filesystem-safe name for a cluster from its node ids.""" 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] + 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] + common = [c for c, p in zip(common, parts, strict=False) 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, concise: bool = False) -> str: + 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[str], + node_lines: list[str], + edge_lines: list[str], + concise: bool = False, +) -> 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" {' @@ -143,34 +140,40 @@ def build_dot(graph_name: str, header_lines: list, node_lines: list, edge_lines: parts = [opener] parts += defaults - parts += [''] + parts += [""] parts += node_lines - parts += [''] + parts += [""] parts += edge_lines - parts += ['}'] - dot_content = '\n'.join(parts) - + parts += ["}"] + dot_content = "\n".join(parts) + # Strip type hints if concise mode is enabled if concise: dot_content = strip_typehints(dot_content) - + return dot_content -def split_dot_file(input_dot: Path, output_dir: Path, concise: bool = False, verbose: bool = False, min_size: int = 1): - """Split a styled .dot file into cluster-specific .dot files.""" +def split_dot_file( + input_dot: Path, + output_dir: Path, + concise: bool = False, + verbose: bool = False, + min_size: int = 1, +) -> list[Path]: + """Split a styled dot file into cluster-specific dot files.""" output_dir.mkdir(parents=True, exist_ok=True) - dot_src = input_dot.read_text(encoding='utf-8') + dot_src = input_dot.read_text(encoding="utf-8") header_lines, nodes, edges = parse_dot(dot_src) - vprint(verbose, f'[split] Found {len(nodes)} nodes, {len(edges)} edges') + vprint(verbose, f"[split] Found {len(nodes)} nodes, {len(edges)} edges") components = find_connected_components(set(nodes.keys()), edges) - vprint(verbose, f'[split] Found {len(components)} connected components') + vprint(verbose, f"[split] Found {len(components)} connected components") - singleton_groups: dict[str, set] = defaultdict(set) - multi_components = [] + singleton_groups: dict[str, set[str]] = defaultdict(set) + multi_components: list[set[str]] = [] for comp in components: if len(comp) == 1: @@ -181,45 +184,71 @@ def split_dot_file(input_dot: Path, output_dir: Path, concise: bool = False, ver multi_components.append(comp) all_clusters = multi_components + list(singleton_groups.values()) - vprint(verbose, f'[split] Will generate {len(all_clusters)} file(s) ' - f'({len(multi_components)} connected + {len(singleton_groups)} module groups)') + vprint( + verbose, + f"[split] Will generate {len(all_clusters)} file(s) " + f"({len(multi_components)} connected + {len(singleton_groups)} module groups)", + ) written_dot_files: list[Path] = [] for cluster_nodes in sorted(all_clusters, key=lambda c: -len(c)): if len(cluster_nodes) < min_size: - vprint(verbose, f' - skipping {len(cluster_nodes)} node cluster below min_size={min_size}') + vprint(verbose, f" - skipping {len(cluster_nodes)} node cluster") continue name = cluster_name(cluster_nodes) - safe_name = re.sub(r'[^\w\-.]', '_', name) + safe_name = re.sub(r"[^\w\-.]", "_", name) node_lines = [nodes[n] for n in cluster_nodes if n in nodes] edge_lines = [ - raw for src, dst, raw in edges + 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, concise=concise) - out_dot = output_dir / f'{safe_name}.dot' - out_dot.write_text(dot_content, encoding='utf-8') + dot_content = build_dot( + name, header_lines, node_lines, edge_lines, concise=concise + ) + out_dot = output_dir / f"{safe_name}.dot" + out_dot.write_text(dot_content, encoding="utf-8") written_dot_files.append(out_dot) - size_label = f'{len(cluster_nodes)} class{"es" if len(cluster_nodes) != 1 else ""}' - vprint(verbose, f' ✓ {out_dot.name} ({size_label})') + size_label = ( + f"{len(cluster_nodes)} class{'es' if len(cluster_nodes) != 1 else ''}" + ) + vprint(verbose, f" ✓ {out_dot.name} ({size_label})") return written_dot_files -if __name__ == '__main__': +if __name__ == "__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', choices=['png', 'svg', 'pdf', 'jpg', 'none'], help='Output image format (png, svg, pdf, jpg) or none') - parser.add_argument('--min-size', type=int, default=1, - help='Minimum cluster size to render as its own file (default: 1)') - parser.add_argument('--concise', action='store_true', - help='Remove type hints from class attributes and methods') - parser.add_argument('--suppress-verbose', dest='verbose', action='store_false', - help='Suppress split progress messages') + 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", + choices=["png", "svg", "pdf", "jpg", "none"], + help="Output image format or none", + ) + parser.add_argument( + "--min-size", + type=int, + default=1, + help="Minimum cluster size to render as its own file (default: 1)", + ) + parser.add_argument( + "--concise", + action="store_true", + help="Remove type hints from class attributes and methods", + ) + parser.add_argument( + "--suppress-verbose", + dest="verbose", + action="store_false", + help="Suppress split progress messages", + ) parser.set_defaults(verbose=True) args = parser.parse_args() @@ -231,8 +260,10 @@ def split_dot_file(input_dot: Path, output_dir: Path, concise: bool = False, ver min_size=args.min_size, ) - render_format = None if args.format == 'none' else args.format + render_format = None if args.format == "none" else args.format if render_format is not None: for dot_file in dot_files: - out_img = render_dot(dot_file, Path(args.output_directory), fmt=render_format, verbose=args.verbose) - vprint(args.verbose, f' ✓ {out_img.name} (rendered)') \ No newline at end of file + out_img = render_dot( + dot_file, dot_file.parent, fmt=render_format, verbose=args.verbose + ) + vprint(args.verbose, f" ✓ {out_img.name} (rendered)") diff --git a/src/daqpytools/uml/style_pyreverse.py b/src/daqpytools/uml/style_pyreverse.py index 6039858..c12d371 100644 --- a/src/daqpytools/uml/style_pyreverse.py +++ b/src/daqpytools/uml/style_pyreverse.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 -""" -style_pyreverse.py ------------------- +"""Wrap pyreverse to produce styled UML class diagrams. + Wraps pyreverse to produce nicely styled UML class diagrams. Usage: @@ -23,16 +22,21 @@ """ import argparse +import os import re -import subprocess import sys +from collections.abc import Iterator, Sequence +from contextlib import contextmanager from pathlib import Path + +from pylint.pyreverse.main import Run + from daqpytools.uml.dot_parsing import patch_dot from daqpytools.uml.render import render_dot from daqpytools.uml.utils import load_style_config, vprint -def _filter_pyreverse_args(pyreverse_args: list[str]) -> list[str]: +def _filter_pyreverse_args(pyreverse_args: Sequence[str]) -> list[str]: """Remove CLI args we override when forcing pyreverse dot output.""" filtered = [] skip_next = False @@ -43,36 +47,58 @@ def _filter_pyreverse_args(pyreverse_args: list[str]) -> list[str]: if arg in ("-o", "--output"): skip_next = True continue - if re.match(r'^-o\w+', arg): # e.g. -opng + if re.match(r"^-o\w+", arg): # e.g. -opng continue - if arg == "--colorized": # we handle colour ourselves + if arg == "--colorized": # we handle colour ourselves continue filtered.append(arg) return filtered - -def run_pyreverse(extra_args, output_dir, cwd=None, verbose=False): - """Run pyreverse with -o dot and return the paths to generated .dot files.""" - cmd = ["pyreverse", "-o", "dot", "--output-directory", str(output_dir)] + extra_args - vprint(verbose, f"[style_pyreverse] Running: {' '.join(cmd)}") - - result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) - - if result.returncode != 0: - print("[style_pyreverse] pyreverse stderr:", result.stderr) - sys.exit(result.returncode) +@contextmanager +def _pushd(directory: Path | None) -> Iterator[None]: + previous_directory = Path.cwd() + if directory is None: + yield + return + + os.chdir(directory) + try: + yield + finally: + os.chdir(previous_directory) + + +def run_pyreverse( + extra_args: Sequence[str], + output_dir: Path, + cwd: Path | None = None, + verbose: bool = False, +) -> list[Path]: + """Run pyreverse and return the generated dot files.""" + cmd_args = ["-o", "dot", "--output-directory", str(output_dir), *extra_args] + vprint(verbose, f"[style_pyreverse] Running: pyreverse {' '.join(cmd_args)}") + + with _pushd(cwd): + result = Run(cmd_args).run() + + if result != 0: + sys.stderr.write( + f"[style_pyreverse] pyreverse failed with exit code {result}\n" + ) + raise SystemExit(result) 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) + sys.stderr.write( + f"[style_pyreverse] ERROR: pyreverse produced no .dot files in " + f"{output_dir}\n" + ) + raise SystemExit(1) return dot_files - - - -def main(): +def main() -> None: + """Parse CLI arguments and run pyreverse.""" parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--output-directory", default=".") parser.add_argument( @@ -84,19 +110,21 @@ def main(): parser.add_argument( "--cwd", default=None, - help="Working directory to run pyreverse from and resolve relative output paths against.", + help="Working directory for pyreverse and relative output paths.", + ) + parser.add_argument("--format", default=None, help="Output image format.") + parser.add_argument( + "--concise", + action="store_true", + help="Remove type hints from class attributes and methods", ) - parser.add_argument("--format", default=None, - help="Output image format (png, svg, pdf …)") - parser.add_argument("--concise", action="store_true", - help="Remove type hints from class attributes and methods") parser.add_argument( "--style-config", default=None, - help="Path to YAML style config file (defaults to uml/style.yaml).", + help="Path to YAML style config file.", ) parser.set_defaults(verbose=True) - + known, pyreverse_args = parser.parse_known_args() pyreverse_args = _filter_pyreverse_args(pyreverse_args) @@ -108,23 +136,22 @@ def main(): output_dir = cwd / output_dir output_dir.mkdir(parents=True, exist_ok=True) - # Stage 1. Run pyreverse dot_files = run_pyreverse( pyreverse_args, output_dir, - cwd=str(cwd) if cwd is not None else None, + cwd=cwd, verbose=known.verbose, ) - # Stage 2. Patch and save the original + # Stage 2. Patch and save the original ## If you are running this script directly you are probably debugging ## So we save the patched dot file along with the original for dot_path in dot_files: vprint(known.verbose, f"[style_pyreverse] Styling {dot_path.name} …") original = dot_path.read_text(encoding="utf-8") - patched = patch_dot(original, style=style, concise=known.concise) + patched = patch_dot(original, style=style, concise=known.concise) # Save patched .dot alongside original (useful for debugging) patched_path = dot_path.with_name(dot_path.stem + "_styled.dot") @@ -132,11 +159,13 @@ def main(): # Stage 2.5 Render the dot files if known.format: - img_path = render_dot(patched_path, output_dir, fmt=known.format, verbose=known.verbose) + img_path = render_dot( + patched_path, output_dir, fmt=known.format, verbose=known.verbose + ) vprint(known.verbose, f"[style_pyreverse] ✓ Written: {img_path}") else: - vprint(known.verbose, f"[style_pyreverse] x No format, skipping render") + vprint(known.verbose, "[style_pyreverse] x No format, skipping render") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py index 1026b1b..818d926 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -1,105 +1,92 @@ +"""Utilities for UML styling and type-hint stripping.""" + import re -import yaml +import sys from pathlib import Path +from typing import TextIO + +import yaml -def vprint(verbose, *args, **kwargs): +def vprint( + verbose: bool, + *args: object, + sep: str = " ", + end: str = "\n", + file: TextIO | None = None, + flush: bool = False, +) -> None: """Print only when verbose output is enabled.""" if verbose: - print(*args, **kwargs) + stream = sys.stdout if file is None else file + stream.write(sep.join(str(arg) for arg in args) + end) + if flush: + stream.flush() def _strip_param_types(params_str: str) -> str: - """Strip type hints from a parameter list (the content between parentheses). - - Handles complex types like ``dict[str, IssueRecord]`` by counting - bracket depth so inner commas are not treated as parameter separators. - """ + """Strip type hints from a parameter list.""" if not params_str.strip(): return params_str # Split on top-level commas only (not commas inside [] or {}) params, current, depth = [], [], 0 for ch in params_str: - if ch in '[({': + if ch in "[({": depth += 1 current.append(ch) - elif ch in '])}': + elif ch in "])}": depth -= 1 current.append(ch) - elif ch == ',' and depth == 0: - params.append(''.join(current).strip()) + elif ch == "," and depth == 0: + params.append("".join(current).strip()) current = [] else: current.append(ch) if current: - params.append(''.join(current).strip()) + params.append("".join(current).strip()) # Keep only the name (everything before the first ':') stripped = [] for param in params: - colon = param.find(':') + colon = param.find(":") stripped.append(param[:colon].rstrip() if colon != -1 else param) - return ', '.join(stripped) - - -def strip_typehints(dot_src): - """Remove type annotations from class/attribute/method labels. + return ", ".join(stripped) - Three-pass approach applied only to lines that carry an HTML label: - 1. Return types – strips ``: ReturnType`` that follows a closing ``)``. - e.g. ``filter(record: logging.LogRecord): bool`` → ``filter(record: logging.LogRecord)`` - - 2. Parameter types – strips ``: Type`` from each parameter inside ``()``. - e.g. ``filter(record: logging.LogRecord)`` → ``filter(record)`` - Handles complex types such as ``dict[str, IssueRecord]`` correctly. - - 3. Attribute types – strips `` : Type`` from plain field entries. - e.g. ``initial_threshold : int`` → ``initial_threshold`` - """ +def strip_typehints(dot_src: str) -> str: + """Remove type annotations from HTML labels in dot output.""" lines = dot_src.splitlines() out_lines = [] for line in lines: - if 'label=<' not in line: + if "label=<" not in line: out_lines.append(line) continue - # Pass 1 – return types: ): ReturnType
)', '', line) - - # Pass 2 – parameter types inside () - def _replace_params(m): - return '(' + _strip_param_types(m.group(1)) + ')' - line = re.sub(r'\(([^)]*)\)', _replace_params, line) + # Pass 1: return types. + line = re.sub(r"(?<=\)):\s*[^<}]+(?=)", "", line) - # Pass 3 – attribute types: name : Type
)', '', line) + # Pass 2: parameter types. + def _replace_params(m: re.Match[str]) -> str: + return "(" + _strip_param_types(m.group(1)) + ")" - out_lines.append(line) - - return '\n'.join(out_lines) + line = re.sub(r"\(([^)]*)\)", _replace_params, line) + # Pass 3: attribute types. + line = re.sub(r"\s*:\s*[^<}]+(?=)", "", line) -def load_style_config(path=None): - """Load UML style configuration from YAML file. + out_lines.append(line) - Loads the style configuration (colors, fonts, layout) from a YAML file - and returns it as a flat dictionary compatible with the STYLE dict format. + return "\n".join(out_lines) - Args: - path: Path to the YAML config file. If None, uses default location - (style.yaml in the same directory as this module). - Returns: - Dictionary with keys: class_fill, class_stroke, class_font, class_fontsize, - inherit_color, uses_color, bg_color, rankdir. - """ +def load_style_config(path: Path | str | None = None) -> dict[str, str]: + """Load UML style configuration from YAML.""" if path is None: path = Path(__file__).parent / "style.yaml" - with open(path, 'r', encoding='utf-8') as f: + with open(path, encoding="utf-8") as f: config = yaml.safe_load(f) # Flatten the nested YAML structure to match existing STYLE dict format From f7eca883cb43f751d84b99650626c9691e639fdb Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 20 Apr 2026 12:54:54 +0200 Subject: [PATCH 13/19] fix cleanup --- src/daqpytools/uml/render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daqpytools/uml/render.py b/src/daqpytools/uml/render.py index a8cc473..41b359e 100644 --- a/src/daqpytools/uml/render.py +++ b/src/daqpytools/uml/render.py @@ -19,7 +19,7 @@ def render_dot( source.render( filename=dot_path.stem, directory=str(output_dir), - cleanup=False, + cleanup=True, quiet=not verbose, ) ) From 4c22a9792a071f3f4362c2ee7a92935dc556cafa Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 20 Apr 2026 13:00:16 +0200 Subject: [PATCH 14/19] minor doc patch --- src/daqpytools/apps/generate_uml.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index a237e7a..4fe6afb 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -7,6 +7,9 @@ 4. optionally split the diagrams into connected components. Usage: + daqpytools-generate-uml [package name] --directory [package directory] + --output-directory [dir] --split + daqpytools-generate-uml daqpytools --output-directory pics daqpytools-generate-uml daqpytools --directory some/path --output-directory pics --split From 399637c371e8a0c5721199a7d34366cece950169 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 30 Apr 2026 15:46:06 +0200 Subject: [PATCH 15/19] refactor formatter --- src/daqpytools/logging/formatter.py | 55 +++---------- src/daqpytools/utils/__init__.py | 0 src/daqpytools/utils/config_loader.py | 106 ++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 44 deletions(-) create mode 100644 src/daqpytools/utils/__init__.py create mode 100644 src/daqpytools/utils/config_loader.py diff --git a/src/daqpytools/logging/formatter.py b/src/daqpytools/logging/formatter.py index ff5d692..b14324c 100644 --- a/src/daqpytools/logging/formatter.py +++ b/src/daqpytools/logging/formatter.py @@ -1,4 +1,3 @@ -import configparser import logging import os import re @@ -10,54 +9,22 @@ from rich.theme import Theme from daqpytools.logging.exceptions import LoggerConfigurationError +from daqpytools.utils.config_loader import ConfigLoader DAQPYTOOLS_LOGGING_ROOT = Path(os.path.abspath(__file__)).parent CONFIGURATION_FILE = DAQPYTOOLS_LOGGING_ROOT / "log_format.ini" -CONFIG: configparser.ConfigParser = configparser.ConfigParser() -if not CONFIG.read(CONFIGURATION_FILE): - err_msg = ( - f"Configuration file '{CONFIGURATION_FILE}' not found or could not be read." - ) - raise FileNotFoundError(err_msg) -LOG_RECORD_PADDING = {k: int(v) for k, v in CONFIG.items("padding")} -if not LOG_RECORD_PADDING: - err_msg = f"Padding configuration in '{CONFIGURATION_FILE}' is empty or invalid." - raise LoggerConfigurationError(CONFIGURATION_FILE, err_msg) +config_loader = ConfigLoader(CONFIGURATION_FILE) -LOG_FORMAT = CONFIG.get("logging", "record_format") -if not LOG_FORMAT: - err_msg = ( - f"Record format in '{CONFIGURATION_FILE}' is empty or not defined under " - "'format'." - ) - raise LoggerConfigurationError(CONFIGURATION_FILE, err_msg) - -DATE_TIME_FORMAT = CONFIG.get("logging", "date_time") -if not DATE_TIME_FORMAT: - err_msg = ( - f"Date and time format in '{CONFIGURATION_FILE}' is empty or not defined under " - "'format'." - ) - raise LoggerConfigurationError(CONFIGURATION_FILE, err_msg) - -DATE_TIME_BASE_FORMAT = CONFIG.get("logging", "date_time_base") -if not DATE_TIME_BASE_FORMAT: - err_msg = ( - f"Date and time base format in '{CONFIGURATION_FILE}' is empty or not defined " - "under 'format'." - ) - raise LoggerConfigurationError(CONFIGURATION_FILE, err_msg) - -CONSOLE_THEME = Theme(dict(CONFIG.items("theme"))) -if not CONSOLE_THEME: - err_msg = ( - f"Theme configuration in '{CONFIGURATION_FILE}' is empty or not defined under " - "'theme'." - ) - raise LoggerConfigurationError(CONFIGURATION_FILE, err_msg) +LOG_RECORD_PADDING = { + k: int(v) for k, v in config_loader.safe_load_config("padding").items() +} +LOG_FORMAT = config_loader.safe_load_config("logging", "record_format") +DATE_TIME_FORMAT = config_loader.safe_load_config("logging", "date_time") +DATE_TIME_BASE_FORMAT = config_loader.safe_load_config("logging", "date_time_base") +CONSOLE_THEME = Theme(config_loader.safe_load_config("theme")) -timezone_name = CONFIG.get("logging", "timezone") +timezone_name = config_loader.safe_load_config("logging", "timezone") try: TIME_ZONE = timezone(timezone_name) except UnknownTimeZoneError as e: @@ -67,7 +34,7 @@ ) raise LoggerConfigurationError(CONFIGURATION_FILE, err_msg) from e -help_options_str = CONFIG.get("cli", "help_option_names") +help_options_str = config_loader.safe_load_config("cli", "help_option_names") HELP_OPTION_NAMES = [opt.strip() for opt in help_options_str.split(",")] CONTEXT_SETTINGS = {"help_option_names": HELP_OPTION_NAMES} diff --git a/src/daqpytools/utils/__init__.py b/src/daqpytools/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/daqpytools/utils/config_loader.py b/src/daqpytools/utils/config_loader.py new file mode 100644 index 0000000..8338a1e --- /dev/null +++ b/src/daqpytools/utils/config_loader.py @@ -0,0 +1,106 @@ + +import configparser +from pathlib import Path +from typing import overload + +class ConfigurationError(Exception): + """Custom error for logger configuration issues.""" + + def __init__(self, configuration_file_path: str | Path, err_msg: str) -> None: + """C'tor.""" + err_msg = ( + f"Configuration file '{configuration_file_path}' could not be read or " + f"contains invalid configuration:\n {err_msg}" + ) + super().__init__(err_msg) + + +class ConfigLoader(): + + def __init__(self, config_file): + """ + Read a configuration file into a ``ConfigParser`` instance. + + Raises: + FileNotFoundError: If ``config_file`` cannot be found or read. + """ + self.config_file = config_file + self.config: configparser.ConfigParser = configparser.ConfigParser() + + if not self.config.read(self.config_file): + err_msg = ( + f"Configuration file '{self.config_file}' not found or could not be read." + ) + raise FileNotFoundError(err_msg) + + + @overload + def safe_load_config( + self, + section: str, + ) -> dict[str, str]: + ... + + + @overload + def safe_load_config( + self, + section: str, + option: str, + ) -> str: + ... + + + def safe_load_config( + self, + section: str, + option: str | None = None, + ) -> dict[str, str] | str: + """Safely load configuration content from a section or a single option. + + Behavior: + - If ``option`` is ``None``, returns all key/value pairs from ``section``. + - If ``option`` is provided, returns that single option value. + + Validation: + - Ensures the requested section exists. + - Ensures section content is non-empty. + - Ensures the requested option exists when provided. + - Ensures option values are non-empty. + + Args: + section: Configuration section name. + option: Optional option name within ``section``. + + Returns: + Either ``dict[str, str]`` for section reads or ``str`` for option reads. + + Raises: + ConfigurationError: If the section/option is missing or empty. + """ + if option is None: + if not self.config.has_section(section): + err_msg = f"Configuration section '{section}' is missing." + raise ConfigurationError(self.config_file, err_msg) + + values = dict(self.config.items(section)) + if not values: + err_msg = f"Configuration section '{section}' is empty." + raise ConfigurationError(self.config_file, err_msg) + + return values + + if not self.config.has_option(section, option): + err_msg = ( + f"Configuration option '{option}' in section '{section}' is missing." + ) + raise ConfigurationError(self.config_file, err_msg) + + value = self.config.get(section, option) + if not value: + err_msg = ( + f"Configuration option '{option}' in section '{section}' is empty." + ) + raise ConfigurationError(self.config_file, err_msg) + + return value From ca957c5c0eb5ee0950872eec78e1f7a0181c9aa1 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 30 Apr 2026 15:51:15 +0200 Subject: [PATCH 16/19] Move uml to use ini file instead --- src/daqpytools/uml/dot_patch_config.py | 2 +- src/daqpytools/uml/style.yaml | 16 -------------- src/daqpytools/uml/uml_format.ini | 24 +++++++++++++++++++++ src/daqpytools/uml/utils.py | 30 +++++++++++++++----------- 4 files changed, 42 insertions(+), 30 deletions(-) delete mode 100644 src/daqpytools/uml/style.yaml create mode 100644 src/daqpytools/uml/uml_format.ini diff --git a/src/daqpytools/uml/dot_patch_config.py b/src/daqpytools/uml/dot_patch_config.py index 84ab753..293f41e 100644 --- a/src/daqpytools/uml/dot_patch_config.py +++ b/src/daqpytools/uml/dot_patch_config.py @@ -83,7 +83,7 @@ 'graph [' f'bgcolor="{style["bg_color"]}" ' f'fontname="{style["class_font"]}" ' - 'pad="0.5" nodesep="0.6" ranksep="0.9" ' + f'pad="{style["pad"]}" nodesep="{style["nodesep"]}" ranksep="{style["ranksep"]}" ' f'rankdir="{style["rankdir"]}"' '];' ), diff --git a/src/daqpytools/uml/style.yaml b/src/daqpytools/uml/style.yaml deleted file mode 100644 index 0a643b2..0000000 --- a/src/daqpytools/uml/style.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# UML Diagram Style Configuration -# ────────────────────────────────────────────────────────────────────────── - -class: - fill: "#FFFDE7" # warm cream (matches Image 1) - stroke: "#8B7355" # warm brown border - font: "Helvetica" - fontsize: 10 - -relationships: - inherit_color: "#555555" # inheritance arrow color - uses_color: "#555555" # dependency/uses arrow color - -graph: - bg_color: "white" - rankdir: "BT" # bottom-to-top layout diff --git a/src/daqpytools/uml/uml_format.ini b/src/daqpytools/uml/uml_format.ini new file mode 100644 index 0000000..40acd65 --- /dev/null +++ b/src/daqpytools/uml/uml_format.ini @@ -0,0 +1,24 @@ +[uml_class_style] +# UML class diagram styling +# Colors use hex notation or named colors +fill = #FFFDE7 +stroke = #8B7355 +font = Helvetica +fontsize = 10 + +[uml_relationships] +# UML relationship edge colors +inherit_color = #555555 +uses_color = #555555 + +[uml_graph] +# UML graph layout and appearance +bg_color = white +rankdir = BT +pad = 0.5 +nodesep = 0.6 +ranksep = 0.9 + +[cli] +# Command-line interface options +help_option_names = -h,--help \ No newline at end of file diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py index 818d926..39ed30b 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -1,5 +1,6 @@ """Utilities for UML styling and type-hint stripping.""" +import configparser import re import sys from pathlib import Path @@ -82,21 +83,24 @@ def _replace_params(m: re.Match[str]) -> str: def load_style_config(path: Path | str | None = None) -> dict[str, str]: - """Load UML style configuration from YAML.""" + """Load UML style configuration from ini file.""" if path is None: - path = Path(__file__).parent / "style.yaml" + path = Path(__file__).parent / "uml_format.ini" - with open(path, encoding="utf-8") as f: - config = yaml.safe_load(f) + config = configparser.ConfigParser() + config.read(path, encoding="utf-8") - # Flatten the nested YAML structure to match existing STYLE dict format + # Flatten the ini sections to match existing STYLE dict format return { - "class_fill": config["class"]["fill"], - "class_stroke": config["class"]["stroke"], - "class_font": config["class"]["font"], - "class_fontsize": str(config["class"]["fontsize"]), - "inherit_color": config["relationships"]["inherit_color"], - "uses_color": config["relationships"]["uses_color"], - "bg_color": config["graph"]["bg_color"], - "rankdir": config["graph"]["rankdir"], + "class_fill": config["uml_class_style"]["fill"], + "class_stroke": config["uml_class_style"]["stroke"], + "class_font": config["uml_class_style"]["font"], + "class_fontsize": config["uml_class_style"]["fontsize"], + "inherit_color": config["uml_relationships"]["inherit_color"], + "uses_color": config["uml_relationships"]["uses_color"], + "bg_color": config["uml_graph"]["bg_color"], + "rankdir": config["uml_graph"]["rankdir"], + "pad": config["uml_graph"]["pad"], + "nodesep": config["uml_graph"]["nodesep"], + "ranksep": config["uml_graph"]["ranksep"], } From 6dcf5f8ee107ebba0cd02b589d0394021ce97102 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 30 Apr 2026 16:05:29 +0200 Subject: [PATCH 17/19] remove dependency of logging from generate_uml --- src/daqpytools/apps/generate_uml.py | 4 +-- src/daqpytools/uml/utils.py | 51 ++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index 4fe6afb..28dcc4a 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -20,12 +20,12 @@ import click -from daqpytools.logging.formatter import CONTEXT_SETTINGS + from daqpytools.uml.dot_parsing import patch_dot from daqpytools.uml.render import render_dot from daqpytools.uml.split_diagram import split_dot_file from daqpytools.uml.style_pyreverse import run_pyreverse -from daqpytools.uml.utils import load_style_config, vprint +from daqpytools.uml.utils import load_style_config, vprint, CONTEXT_SETTINGS def validate_output_directory( diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py index 39ed30b..c959eb1 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -5,8 +5,20 @@ import sys from pathlib import Path from typing import TextIO - -import yaml +from daqpytools.utils.config_loader import ConfigLoader + + +CONTEXT_SETTINGS = ( + lambda cfg_path=Path(__file__).parent / "uml_format.ini": + { + "help_option_names": [ + opt.strip() + for opt in ConfigLoader(cfg_path).safe_load_config( + "cli", "help_option_names" + ).split(",") + ] + } +)() def vprint( @@ -83,24 +95,31 @@ def _replace_params(m: re.Match[str]) -> str: def load_style_config(path: Path | str | None = None) -> dict[str, str]: - """Load UML style configuration from ini file.""" + """Load UML style configuration from ini file using ConfigLoader. + + Uses ``ConfigLoader`` so section/option presence and emptiness are validated + consistently with the rest of the codebase. + """ if path is None: path = Path(__file__).parent / "uml_format.ini" - config = configparser.ConfigParser() - config.read(path, encoding="utf-8") + loader = ConfigLoader(path) + + class_style = loader.safe_load_config("uml_class_style") + relationships = loader.safe_load_config("uml_relationships") + graph = loader.safe_load_config("uml_graph") # Flatten the ini sections to match existing STYLE dict format return { - "class_fill": config["uml_class_style"]["fill"], - "class_stroke": config["uml_class_style"]["stroke"], - "class_font": config["uml_class_style"]["font"], - "class_fontsize": config["uml_class_style"]["fontsize"], - "inherit_color": config["uml_relationships"]["inherit_color"], - "uses_color": config["uml_relationships"]["uses_color"], - "bg_color": config["uml_graph"]["bg_color"], - "rankdir": config["uml_graph"]["rankdir"], - "pad": config["uml_graph"]["pad"], - "nodesep": config["uml_graph"]["nodesep"], - "ranksep": config["uml_graph"]["ranksep"], + "class_fill": class_style["fill"], + "class_stroke": class_style["stroke"], + "class_font": class_style["font"], + "class_fontsize": class_style["fontsize"], + "inherit_color": relationships["inherit_color"], + "uses_color": relationships["uses_color"], + "bg_color": graph["bg_color"], + "rankdir": graph["rankdir"], + "pad": graph["pad"], + "nodesep": graph["nodesep"], + "ranksep": graph["ranksep"], } From 4901df9be5593b979b7d3e31dc490d2232bb3e13 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 30 Apr 2026 16:09:00 +0200 Subject: [PATCH 18/19] Ruff checks --- src/daqpytools/apps/generate_uml.py | 3 +-- src/daqpytools/uml/dot_patch_config.py | 4 +++- src/daqpytools/uml/utils.py | 3 +-- src/daqpytools/utils/config_loader.py | 19 ++++++++++++++----- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index 28dcc4a..42654a4 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -20,12 +20,11 @@ import click - from daqpytools.uml.dot_parsing import patch_dot from daqpytools.uml.render import render_dot from daqpytools.uml.split_diagram import split_dot_file from daqpytools.uml.style_pyreverse import run_pyreverse -from daqpytools.uml.utils import load_style_config, vprint, CONTEXT_SETTINGS +from daqpytools.uml.utils import CONTEXT_SETTINGS, load_style_config, vprint def validate_output_directory( diff --git a/src/daqpytools/uml/dot_patch_config.py b/src/daqpytools/uml/dot_patch_config.py index 293f41e..1b66a25 100644 --- a/src/daqpytools/uml/dot_patch_config.py +++ b/src/daqpytools/uml/dot_patch_config.py @@ -83,7 +83,9 @@ 'graph [' f'bgcolor="{style["bg_color"]}" ' f'fontname="{style["class_font"]}" ' - f'pad="{style["pad"]}" nodesep="{style["nodesep"]}" ranksep="{style["ranksep"]}" ' + f'pad="{style["pad"]}" ' + f'nodesep="{style["nodesep"]}" ' + f'ranksep="{style["ranksep"]}" ' f'rankdir="{style["rankdir"]}"' '];' ), diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py index c959eb1..89420fc 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -1,12 +1,11 @@ """Utilities for UML styling and type-hint stripping.""" -import configparser import re import sys from pathlib import Path from typing import TextIO -from daqpytools.utils.config_loader import ConfigLoader +from daqpytools.utils.config_loader import ConfigLoader CONTEXT_SETTINGS = ( lambda cfg_path=Path(__file__).parent / "uml_format.ini": diff --git a/src/daqpytools/utils/config_loader.py b/src/daqpytools/utils/config_loader.py index 8338a1e..4cfca7b 100644 --- a/src/daqpytools/utils/config_loader.py +++ b/src/daqpytools/utils/config_loader.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import overload + class ConfigurationError(Exception): """Custom error for logger configuration issues.""" @@ -15,11 +16,18 @@ def __init__(self, configuration_file_path: str | Path, err_msg: str) -> None: super().__init__(err_msg) -class ConfigLoader(): +class ConfigLoader: + """Helper to read and validate INI configuration files. - def __init__(self, config_file): - """ - Read a configuration file into a ``ConfigParser`` instance. + Provides `safe_load_config` for guarded access to sections and + options with consistent error messages. + """ + + def __init__(self, config_file: Path | str) -> None: + """Read a configuration file into a ``ConfigParser`` instance. + + Args: + config_file: Path to the configuration file. Raises: FileNotFoundError: If ``config_file`` cannot be found or read. @@ -29,7 +37,8 @@ def __init__(self, config_file): if not self.config.read(self.config_file): err_msg = ( - f"Configuration file '{self.config_file}' not found or could not be read." + f"Configuration file '{self.config_file}' " + "not found or could not be read." ) raise FileNotFoundError(err_msg) From cefebef08e14cd5ae2ef5949b3242d7fb1cbbec2 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 30 Apr 2026 16:27:48 +0200 Subject: [PATCH 19/19] package ini file --- pyproject.toml | 1 + src/daqpytools/uml/utils.py | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0683a6d..b7eb4ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ where = ["src"] [tool.setuptools.package-data] "daqpytools.logging" = ["log_format.ini"] +"daqpytools.uml" = ["uml_format.ini"] [project.optional-dependencies] dev = [ diff --git a/src/daqpytools/uml/utils.py b/src/daqpytools/uml/utils.py index 89420fc..81278b3 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -2,22 +2,27 @@ import re import sys +from importlib.resources import as_file, files from pathlib import Path from typing import TextIO from daqpytools.utils.config_loader import ConfigLoader -CONTEXT_SETTINGS = ( - lambda cfg_path=Path(__file__).parent / "uml_format.ini": - { - "help_option_names": [ - opt.strip() - for opt in ConfigLoader(cfg_path).safe_load_config( - "cli", "help_option_names" - ).split(",") - ] + +def _load_context_settings() -> dict[str, list[str]]: + """Load CLI context settings from the packaged UML configuration.""" + with as_file(files("daqpytools.uml") / "uml_format.ini") as config_path: + help_options_str = ConfigLoader(config_path).safe_load_config( + "cli", "help_option_names" + ) + + return { + "help_option_names": [opt.strip() for opt in help_options_str.split(",")] } -)() + + +CONTEXT_SETTINGS = _load_context_settings() +__all__ = ["CONTEXT_SETTINGS"] def vprint(