diff --git a/pyproject.toml b/pyproject.toml index 4fc91a3..b7eb4ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,17 +21,21 @@ where = ["src"] [tool.setuptools.package-data] "daqpytools.logging" = ["log_format.ini"] +"daqpytools.uml" = ["uml_format.ini"] [project.optional-dependencies] dev = [ "ruff", "pytest", "pytest-cov", + "pylint", + "graphviz" ] 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..42654a4 --- /dev/null +++ b/src/daqpytools/apps/generate_uml.py @@ -0,0 +1,210 @@ +"""CLI interface for generating UML class diagrams. + +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 [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 + daqpytools-generate-uml daqpytools --format none +""" + +from pathlib import Path + +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 CONTEXT_SETTINGS, load_style_config, vprint + + +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: tuple[str, ...], packages: tuple[str, ...], classes: tuple[str, ...] +) -> list[str]: + """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[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) + 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(path_type=Path, file_okay=False, dir_okay=True), + default=Path("pics"), + callback=validate_output_directory, + help="Output directory for generated diagrams. [default: pics]", +) +@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]", +) +@click.option( + "-c", + "--concise", + is_flag=True, + help="Remove type hints from class attributes and methods.", +) +@click.option( + "--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. [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( + "--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 the UML renderer.", +) +def main( + 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) + raise SystemExit(1) + + cwd, resolved_output_dir = resolve_output_directory(directory, output_directory) + style = load_style_config(style_config) + 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 + ) + + 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: 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}" + ) + + 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: + 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__": + main() 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/uml/__init__.py b/src/daqpytools/uml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/daqpytools/uml/dot_parsing.py b/src/daqpytools/uml/dot_parsing.py new file mode 100644 index 0000000..5511198 --- /dev/null +++ b/src/daqpytools/uml/dot_parsing.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Helpers for transforming pyreverse dot output.""" + +import re +from collections.abc import Sequence +from functools import partial + +from daqpytools.uml.dot_patch_config import ( + 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: Sequence[tuple[str, str]]) -> str: + """Apply regex substitutions in order.""" + for pattern, replacement in substitutions: + text = re.sub(pattern, replacement, text) + return text + + +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) + + if concise: + dot_src = strip_typehints(dot_src) + + defaults_block = build_defaults_block(style) + 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: + 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) + line = _append_edge_attrs(line, new_attrs) + out.append(line) + return "\n".join(out) diff --git a/src/daqpytools/uml/dot_patch_config.py b/src/daqpytools/uml/dot_patch_config.py new file mode 100644 index 0000000..1b66a25 --- /dev/null +++ b/src/daqpytools/uml/dot_patch_config.py @@ -0,0 +1,145 @@ +"""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 +""" + + +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"]}" ' + f'pad="{style["pad"]}" ' + f'nodesep="{style["nodesep"]}" ' + f'ranksep="{style["ranksep"]}" ' + 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/render.py b/src/daqpytools/uml/render.py new file mode 100644 index 0000000..41b359e --- /dev/null +++ b/src/daqpytools/uml/render.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Render styled UML dot files using Graphviz.""" + +from pathlib import Path + +from graphviz import Source + +from daqpytools.uml.utils import vprint + + +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=True, + quiet=not verbose, + ) + ) diff --git a/src/daqpytools/uml/split_diagram.py b/src/daqpytools/uml/split_diagram.py new file mode 100644 index 0000000..3952bf3 --- /dev/null +++ b/src/daqpytools/uml/split_diagram.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Split styled UML dot files into connected clusters. + +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] [--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 +""" + +import argparse +import re +from collections import defaultdict +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, +) -> tuple[list[str], dict[str, str], list[tuple[str, str, str]]]: + """Parse a flat dot file.""" + 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[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: str) -> str: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a: str, b: str) -> None: + 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.""" + 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]) -> 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] + common = all_parts[0] + for parts in all_parts[1:]: + 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[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" {' + # 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 += ["}"] + 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, +) -> 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") + header_lines, nodes, edges = parse_dot(dot_src) + + 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") + + singleton_groups: dict[str, set[str]] = defaultdict(set) + multi_components: list[set[str]] = [] + + 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) + + 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)", + ) + + 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") + continue + + name = cluster_name(cluster_nodes) + 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 + 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") + 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})") + + return written_dot_files + + +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 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, 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 new file mode 100644 index 0000000..c12d371 --- /dev/null +++ b/src/daqpytools/uml/style_pyreverse.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Wrap pyreverse to produce styled UML class diagrams. + +Wraps pyreverse to produce nicely styled UML class diagrams. + +Usage: + python style_pyreverse.py [pyreverse args...] [--concise] [--style-config PATH] + +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 + + # 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) +""" + +import argparse +import os +import re +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: Sequence[str]) -> list[str]: + """Remove CLI args we override when forcing pyreverse dot output.""" + filtered = [] + skip_next = False + for arg in pyreverse_args: + if skip_next: + skip_next = False + continue + if arg in ("-o", "--output"): + skip_next = True + continue + if re.match(r"^-o\w+", arg): # e.g. -opng + continue + if arg == "--colorized": # we handle colour ourselves + continue + filtered.append(arg) + return filtered + + +@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: + 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() -> None: + """Parse CLI arguments and run pyreverse.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--output-directory", default=".") + 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 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( + "--style-config", + default=None, + 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) + 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) + + # Stage 1. Run pyreverse + dot_files = run_pyreverse( + pyreverse_args, + output_dir, + cwd=cwd, + 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: + 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) + + # 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") + + # 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, "[style_pyreverse] x No format, skipping render") + + +if __name__ == "__main__": + main() 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 new file mode 100644 index 0000000..81278b3 --- /dev/null +++ b/src/daqpytools/uml/utils.py @@ -0,0 +1,129 @@ +"""Utilities for UML styling and type-hint stripping.""" + +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 + + +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( + 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: + 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.""" + 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 HTML labels in dot output.""" + lines = dot_src.splitlines() + out_lines = [] + + for line in lines: + if "label=<" not in line: + out_lines.append(line) + continue + + # Pass 1: return types. + line = re.sub(r"(?<=\)):\s*[^<}]+(?=)", "", line) + + # Pass 2: parameter types. + def _replace_params(m: re.Match[str]) -> str: + return "(" + _strip_param_types(m.group(1)) + ")" + + line = re.sub(r"\(([^)]*)\)", _replace_params, line) + + # Pass 3: attribute types. + line = re.sub(r"\s*:\s*[^<}]+(?=)", "", line) + + out_lines.append(line) + + return "\n".join(out_lines) + + +def load_style_config(path: Path | str | None = None) -> dict[str, str]: + """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" + + 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": 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"], + } 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..4cfca7b --- /dev/null +++ b/src/daqpytools/utils/config_loader.py @@ -0,0 +1,115 @@ + +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: + """Helper to read and validate INI configuration files. + + 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. + """ + 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