From 8b9847b5779e50f2acd7e98ebacac133e07cdf93 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 17 Sep 2026 11:56:13 +0200 Subject: [PATCH 1/8] initial adds --- src/daqpytools/apps/generate_uml.py | 52 +++++++++++++- src/daqpytools/uml/dot_parsing.py | 47 ++++++++----- src/daqpytools/uml/github_links.py | 103 ++++++++++++++++++++++++++++ src/daqpytools/uml/uml_format.ini | 5 ++ src/daqpytools/uml/utils.py | 12 ++++ 5 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 src/daqpytools/uml/github_links.py diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index 42654a4..ac7275e 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -21,10 +21,16 @@ import click from daqpytools.uml.dot_parsing import patch_dot +from daqpytools.uml.github_links import inject_node_links, resolve_git_ref 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 +from daqpytools.uml.utils import ( + CONTEXT_SETTINGS, + load_link_config, + load_style_config, + vprint, +) def validate_output_directory( @@ -60,10 +66,18 @@ def resolve_output_directory( return cwd, resolved_output -def style_dot_file(dot_path: Path, style: dict[str, str], concise: bool) -> Path: +def style_dot_file( + dot_path: Path, + style: dict[str, str], + concise: bool, + link_context: tuple[Path, str, str, str, bool] | None = None, +) -> 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) + if link_context is not None: + cwd, org, repo, ref, link_line_number = link_context + patched = inject_node_links(patched, cwd, org, repo, ref, link_line_number) patched_path = dot_path.with_name(f"{dot_path.stem}_styled.dot") patched_path.write_text(patched, encoding="utf-8") return patched_path @@ -136,6 +150,16 @@ def style_dot_file(dot_path: Path, style: dict[str, str], concise: bool) -> Path default=None, help="Path to YAML style config file for the UML renderer.", ) +@click.option( + "--generate-linkable", + is_flag=True, + help="Add GitHub source links to nodes and render a linked SVG.", +) +@click.option( + "--link-line-number", + is_flag=True, + help="With --generate-linkable, link directly to a class's definition line.", +) def main( targets: tuple[str, ...], directory: Path | None, @@ -148,6 +172,8 @@ def main( classes: tuple[str, ...], verbose: bool, style_config: Path | None, + generate_linkable: bool, + link_line_number: bool, ) -> None: """Generate styled UML class diagrams from Python code.""" pyreverse_args = build_pyreverse_args(targets, package, classes) @@ -159,6 +185,14 @@ def main( style = load_style_config(style_config) render_format = None if output_format.lower() == "none" else output_format.lower() + link_context = None + if generate_linkable: + link_config = load_link_config() + repo = targets[0] if targets else package[0] + ref = resolve_git_ref(cwd, link_config["default_ref"]) + link_context = (cwd, link_config["github_org"], repo, ref, link_line_number) + vprint(verbose, f"[generate_uml] Linking nodes to {link_config['github_org']}/{repo}@{ref}") + vprint(verbose, f"[generate_uml] Running pyreverse in {cwd}") dot_files = run_pyreverse( pyreverse_args, resolved_output_dir, cwd=str(cwd), verbose=verbose @@ -167,7 +201,11 @@ def main( 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)) + styled_dot_files.append( + style_dot_file( + dot_path, style=style, concise=concise, link_context=link_context + ) + ) split_dot_files: list[Path] = [] if split: @@ -202,6 +240,14 @@ def main( else: vprint(verbose, "[generate_uml] Skipping rendering (--format none)") + # Linkable output must be SVG; only render it if it wasn't already produced above. + if generate_linkable and render_format != "svg": + for styled_dot in styled_dot_files: + img_path = render_dot( + styled_dot, resolved_output_dir, fmt="svg", verbose=verbose + ) + vprint(verbose, f"[generate_uml] Written linkable SVG: {img_path}") + vprint(verbose, "[generate_uml] Complete") vprint(verbose, f"[generate_uml] Output directory: {resolved_output_dir.resolve()}") diff --git a/src/daqpytools/uml/dot_parsing.py b/src/daqpytools/uml/dot_parsing.py index 5511198..f774e7f 100644 --- a/src/daqpytools/uml/dot_parsing.py +++ b/src/daqpytools/uml/dot_parsing.py @@ -46,23 +46,38 @@ def patch_dot(dot_src: str, style: dict[str, str], concise: bool = False) -> str ) +def append_bracket_attrs(line: str, new_attrs: str) -> str: + """Append attrs into a line's outermost ``[...]`` attribute list. + + Uses balanced-bracket matching rather than a naive first-match regex, + since HTML labels (e.g. ``set[HandlerType]``) can contain literal + bracket pairs before the real attribute list closes. + """ + start = line.find("[") + if start == -1: + return re.sub(r";?\s*$", f" [{new_attrs}];", line.rstrip()) + + depth = 0 + end = None + for i in range(start, len(line)): + if line[i] == "[": + depth += 1 + elif line[i] == "]": + depth -= 1 + if depth == 0: + end = i + break + + if end is None: + return re.sub(r";?\s*$", f" [{new_attrs}];", line.rstrip()) + + inner = line[start + 1 : end].strip().rstrip(",") + prefix = f"{inner}, " if inner else "" + return line[:start] + "[" + prefix + new_attrs + "]" + line[end + 1 :] + + 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()) + return append_bracket_attrs(line, new_attrs) def fix_edges(dot_src: str, style: dict[str, str]) -> str: diff --git a/src/daqpytools/uml/github_links.py b/src/daqpytools/uml/github_links.py new file mode 100644 index 0000000..7dd9926 --- /dev/null +++ b/src/daqpytools/uml/github_links.py @@ -0,0 +1,103 @@ +"""Resolve GitHub source links for UML nodes and inject them into dot files.""" + +import re +import subprocess +from pathlib import Path + +from daqpytools.uml.dot_parsing import append_bracket_attrs + +NODE_LINE_PATTERN = re.compile(r'^"([^"]+)"\s*\[') + +# Relative path templates tried in order to locate a FQN's source file. +_PATH_CANDIDATE_TEMPLATES = ( + "src/{path}.py", + "{path}.py", + "src/{path}/__init__.py", + "{path}/__init__.py", +) + + +def resolve_git_ref(cwd: Path, default_ref: str) -> str: + """Return the current commit SHA of the repo at ``cwd``, or ``default_ref``.""" + try: + result = subprocess.run( + ["git", "-C", str(cwd), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return default_ref + sha = result.stdout.strip() + return sha or default_ref + + +def resolve_source_path(fqn: str, cwd: Path) -> Path | None: + """Return the source file for ``fqn`` relative to ``cwd``, if found.""" + paths = [fqn.split(".")] + if len(paths[0]) > 1: + paths.append(paths[0][:-1]) + + for segments in paths: + path = "/".join(segments) + for template in _PATH_CANDIDATE_TEMPLATES: + candidate = cwd / template.format(path=path) + if candidate.is_file(): + return candidate + return None + + +def find_class_line(file_path: Path, class_name: str) -> int | None: + """Return the 1-based line number where ``class_name`` is defined.""" + pattern = re.compile(rf"^\s*class\s+{re.escape(class_name)}\b") + text = file_path.read_text(encoding="utf-8") + for lineno, line in enumerate(text.splitlines(), start=1): + if pattern.match(line): + return lineno + return None + + +def build_node_url( + fqn: str, + cwd: Path, + org: str, + repo: str, + ref: str, + link_line_number: bool, +) -> str | None: + """Build a GitHub blob URL for ``fqn``, or ``None`` if unresolved.""" + source_path = resolve_source_path(fqn, cwd) + if source_path is None: + return None + + rel_path = source_path.relative_to(cwd).as_posix() + url = f"https://github.com/{org}/{repo}/blob/{ref}/{rel_path}" + + if link_line_number: + class_name = fqn.rsplit(".", 1)[-1] + lineno = find_class_line(source_path, class_name) + if lineno is not None: + url += f"#L{lineno}" + + return url + + +def inject_node_links( + dot_src: str, + cwd: Path, + org: str, + repo: str, + ref: str, + link_line_number: bool = False, +) -> str: + """Add a ``URL`` attribute to each node whose source file can be resolved.""" + out = [] + for line in dot_src.splitlines(): + match = NODE_LINE_PATTERN.match(line.strip()) + if match: + url = build_node_url(match.group(1), cwd, org, repo, ref, link_line_number) + if url is not None: + line = append_bracket_attrs(line, f'URL="{url}", target="_blank"') + out.append(line) + return "\n".join(out) diff --git a/src/daqpytools/uml/uml_format.ini b/src/daqpytools/uml/uml_format.ini index 40acd65..375e386 100644 --- a/src/daqpytools/uml/uml_format.ini +++ b/src/daqpytools/uml/uml_format.ini @@ -19,6 +19,11 @@ pad = 0.5 nodesep = 0.6 ranksep = 0.9 +[uml_links] +# GitHub linking for --generate-linkable +github_org = DUNE-DAQ +default_ref = develop + [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 81278b3..7661714 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -127,3 +127,15 @@ def load_style_config(path: Path | str | None = None) -> dict[str, str]: "nodesep": graph["nodesep"], "ranksep": graph["ranksep"], } + + +def load_link_config(path: Path | str | None = None) -> dict[str, str]: + """Load GitHub linking configuration (org, fallback ref) from ini file.""" + if path is None: + path = Path(__file__).parent / "uml_format.ini" + + links = ConfigLoader(path).safe_load_config("uml_links") + return { + "github_org": links["github_org"], + "default_ref": links["default_ref"], + } From ac4523027a2de5e0c04f5c4b2579e9b193d0f037 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 17 Sep 2026 15:10:17 +0200 Subject: [PATCH 2/8] add colors --- src/daqpytools/apps/generate_uml.py | 74 ++++++++++++++++++++- src/daqpytools/uml/directory_coloring.py | 83 ++++++++++++++++++++++++ src/daqpytools/uml/uml_format.ini | 4 ++ src/daqpytools/uml/utils.py | 9 +++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/daqpytools/uml/directory_coloring.py diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index ac7275e..fb25572 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -20,6 +20,11 @@ import click +from daqpytools.uml.directory_coloring import ( + assign_directory_colors, + build_directory_color_legend_dot, + inject_directory_colors, +) from daqpytools.uml.dot_parsing import patch_dot from daqpytools.uml.github_links import inject_node_links, resolve_git_ref from daqpytools.uml.render import render_dot @@ -27,6 +32,7 @@ from daqpytools.uml.style_pyreverse import run_pyreverse from daqpytools.uml.utils import ( CONTEXT_SETTINGS, + load_color_theme_config, load_link_config, load_style_config, vprint, @@ -70,11 +76,16 @@ def style_dot_file( dot_path: Path, style: dict[str, str], concise: bool, + color_context: tuple[str, list[str]] | None = None, link_context: tuple[Path, str, str, str, bool] | None = None, ) -> 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) + if color_context is not None: + package_name, palette = color_context + directory_colors = assign_directory_colors(patched, package_name, palette) + patched = inject_directory_colors(patched, package_name, directory_colors) if link_context is not None: cwd, org, repo, ref, link_line_number = link_context patched = inject_node_links(patched, cwd, org, repo, ref, link_line_number) @@ -83,6 +94,26 @@ def style_dot_file( return patched_path +def write_directory_color_legend( + styled_dot_files: list[Path], + output_dir: Path, + color_context: tuple[str, list[str]], +) -> Path | None: + """Write a standalone directory color legend dot file.""" + package_name, palette = color_context + directory_colors: dict[str, str] = {} + for styled_dot in styled_dot_files: + dot_src = styled_dot.read_text(encoding="utf-8") + directory_colors.update(assign_directory_colors(dot_src, package_name, palette)) + + if not directory_colors: + return None + + legend_dot = output_dir / "directory_color_legend.dot" + legend_dot.write_text(build_directory_color_legend_dot(directory_colors), encoding="utf-8") + return legend_dot + + @click.command(context_settings=CONTEXT_SETTINGS) @click.argument("targets", nargs=-1, required=False) @click.option( @@ -160,6 +191,11 @@ def style_dot_file( is_flag=True, help="With --generate-linkable, link directly to a class's definition line.", ) +@click.option( + "--color-by-directory", + is_flag=True, + help="Color class/package boxes by their top-level source directory.", +) def main( targets: tuple[str, ...], directory: Path | None, @@ -174,6 +210,7 @@ def main( style_config: Path | None, generate_linkable: bool, link_line_number: bool, + color_by_directory: bool, ) -> None: """Generate styled UML class diagrams from Python code.""" pyreverse_args = build_pyreverse_args(targets, package, classes) @@ -184,14 +221,23 @@ def main( 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() + package_name = (targets[0] if targets else package[0]).split(".", maxsplit=1)[0] link_context = None if generate_linkable: link_config = load_link_config() - repo = targets[0] if targets else package[0] + repo = package_name ref = resolve_git_ref(cwd, link_config["default_ref"]) link_context = (cwd, link_config["github_org"], repo, ref, link_line_number) - vprint(verbose, f"[generate_uml] Linking nodes to {link_config['github_org']}/{repo}@{ref}") + vprint( + verbose, + f"[generate_uml] Linking nodes to {link_config['github_org']}/{repo}@{ref}", + ) + + color_context = None + if color_by_directory: + color_context = (package_name, load_color_theme_config()) + vprint(verbose, "[generate_uml] Coloring nodes by source directory") vprint(verbose, f"[generate_uml] Running pyreverse in {cwd}") dot_files = run_pyreverse( @@ -203,7 +249,11 @@ def main( vprint(verbose, f"[generate_uml] Styling {dot_path.name}") styled_dot_files.append( style_dot_file( - dot_path, style=style, concise=concise, link_context=link_context + dot_path, + style=style, + concise=concise, + color_context=color_context, + link_context=link_context, ) ) @@ -225,6 +275,14 @@ def main( verbose, f"[generate_uml] Split diagrams written to {split_output_dir}" ) + legend_dot = None + if color_context is not None: + legend_dot = write_directory_color_legend( + styled_dot_files, resolved_output_dir, color_context + ) + if legend_dot is not None: + vprint(verbose, f"[generate_uml] Written color legend: {legend_dot}") + if render_format is not None: for split_dot in split_dot_files: img_path = render_dot( @@ -237,6 +295,12 @@ def main( styled_dot, resolved_output_dir, fmt=render_format, verbose=verbose ) vprint(verbose, f"[generate_uml] Written: {img_path}") + + if legend_dot is not None: + img_path = render_dot( + legend_dot, resolved_output_dir, fmt=render_format, verbose=verbose + ) + vprint(verbose, f"[generate_uml] Written color legend: {img_path}") else: vprint(verbose, "[generate_uml] Skipping rendering (--format none)") @@ -248,6 +312,10 @@ def main( ) vprint(verbose, f"[generate_uml] Written linkable SVG: {img_path}") + if legend_dot is not None: + img_path = render_dot(legend_dot, resolved_output_dir, fmt="svg", verbose=verbose) + vprint(verbose, f"[generate_uml] Written color legend SVG: {img_path}") + vprint(verbose, "[generate_uml] Complete") vprint(verbose, f"[generate_uml] Output directory: {resolved_output_dir.resolve()}") diff --git a/src/daqpytools/uml/directory_coloring.py b/src/daqpytools/uml/directory_coloring.py new file mode 100644 index 0000000..d0972f9 --- /dev/null +++ b/src/daqpytools/uml/directory_coloring.py @@ -0,0 +1,83 @@ +"""Color UML nodes by their top-level source directory.""" + +import re +from collections.abc import Sequence + +from daqpytools.uml.dot_parsing import append_bracket_attrs +from daqpytools.uml.github_links import NODE_LINE_PATTERN + +LEGEND_NODE_NAME = "directory_color_legend" + + +def get_directory_name(node_id: str, package_name: str) -> str | None: + """Return the top-level package directory represented by ``node_id``.""" + segments = node_id.split(".") + if segments and segments[0] == "src": + segments = segments[1:] + if len(segments) < 2 or segments[0] != package_name: + return None + return segments[1] + + +def assign_directory_colors( + dot_src: str, package_name: str, palette: Sequence[str] +) -> dict[str, str]: + """Assign palette colors to directories in first-seen order.""" + colors = {} + if not palette: + return colors + + for line in dot_src.splitlines(): + match = NODE_LINE_PATTERN.match(line.strip()) + if not match: + continue + directory = get_directory_name(match.group(1), package_name) + if directory is not None and directory not in colors: + colors[directory] = palette[len(colors) % len(palette)] + return colors + + +def inject_directory_colors( + dot_src: str, package_name: str, directory_colors: dict[str, str] +) -> str: + """Apply per-directory fill colors to UML node lines.""" + out = [] + for line in dot_src.splitlines(): + match = NODE_LINE_PATTERN.match(line.strip()) + if match: + directory = get_directory_name(match.group(1), package_name) + color = directory_colors.get(directory or "") + if color is not None: + line = _replace_or_append_fillcolor(line, color) + out.append(line) + return "\n".join(out) + + +def build_directory_color_legend_dot(directory_colors: dict[str, str]) -> str: + """Build a standalone Graphviz dot file for directory colors.""" + rows = [ + f'{directory}' + for directory, color in directory_colors.items() + ] + return "\n".join( + [ + 'digraph "directory_color_legend" {', + ' graph [bgcolor="white" rankdir="TB" margin="0.1"];', + f' "{LEGEND_NODE_NAME}" [', + " shape=plain,", + " label=<", + ' ', + ' ', + *(f" {row}" for row in rows), + "
Directory
", + " >", + " ];", + "}", + ] + ) + + +def _replace_or_append_fillcolor(line: str, color: str) -> str: + if re.search(r'\bfillcolor\s*=\s*"[^"]*"', line): + return re.sub(r'\bfillcolor\s*=\s*"[^"]*"', f'fillcolor="{color}"', line) + return append_bracket_attrs(line, f'fillcolor="{color}"') diff --git a/src/daqpytools/uml/uml_format.ini b/src/daqpytools/uml/uml_format.ini index 375e386..196ef5e 100644 --- a/src/daqpytools/uml/uml_format.ini +++ b/src/daqpytools/uml/uml_format.ini @@ -24,6 +24,10 @@ ranksep = 0.9 github_org = DUNE-DAQ default_ref = develop +[uml_color_theme] +# Colors used by --color-by-directory, assigned in first-seen directory order +colors = #FFF4B8,#BFE8C2,#BFD7FF,#F6C3D5,#D7C6FF,#BFECEF,#FFD6A5,#D0D0D0,#C7E9B0,#F4B6C2,#E8DDB5,#C9E4DE,#F7C6A3,#D5E8D4,#F2C6DE,#C6D8FF,#E2F0CB,#FAD2E1,#BEE1E6,#F0E6EF + [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 7661714..acfc11a 100644 --- a/src/daqpytools/uml/utils.py +++ b/src/daqpytools/uml/utils.py @@ -139,3 +139,12 @@ def load_link_config(path: Path | str | None = None) -> dict[str, str]: "github_org": links["github_org"], "default_ref": links["default_ref"], } + + +def load_color_theme_config(path: Path | str | None = None) -> list[str]: + """Load the directory-coloring palette from ini file.""" + if path is None: + path = Path(__file__).parent / "uml_format.ini" + + colors = ConfigLoader(path).safe_load_config("uml_color_theme", "colors") + return [color.strip() for color in colors.split(",") if color.strip()] From d437de1d7381b78803cddcccd65585174642aefa Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 17 Sep 2026 16:09:11 +0200 Subject: [PATCH 3/8] fix linker with splitter --- src/daqpytools/apps/generate_uml.py | 6 ++++++ src/daqpytools/uml/split_diagram.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index fb25572..541aec0 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -306,6 +306,12 @@ def main( # Linkable output must be SVG; only render it if it wasn't already produced above. if generate_linkable and render_format != "svg": + for split_dot in split_dot_files: + img_path = render_dot( + split_dot, split_dot.parent, fmt="svg", verbose=verbose + ) + vprint(verbose, f"[generate_uml] Written linkable SVG: {img_path}") + for styled_dot in styled_dot_files: img_path = render_dot( styled_dot, resolved_output_dir, fmt="svg", verbose=verbose diff --git a/src/daqpytools/uml/split_diagram.py b/src/daqpytools/uml/split_diagram.py index bac67e3..2513cca 100644 --- a/src/daqpytools/uml/split_diagram.py +++ b/src/daqpytools/uml/split_diagram.py @@ -110,7 +110,8 @@ def module_group(node_id: str) -> str: 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))) + # Empty names would collide with Graphviz's own cleanup-managed source file. + return module_group(next(iter(node_ids))) or "misc" # Find common prefix of all node module paths all_parts = [nid.split(".") for nid in node_ids] From 4a4a0390c6589eef0881d7749402101a8386c95c Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 17 Sep 2026 17:28:39 +0200 Subject: [PATCH 4/8] sort ruff --- src/daqpytools/apps/generate_uml.py | 8 ++++++-- src/daqpytools/uml/github_links.py | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py index 541aec0..77faf5e 100644 --- a/src/daqpytools/apps/generate_uml.py +++ b/src/daqpytools/apps/generate_uml.py @@ -110,7 +110,9 @@ def write_directory_color_legend( return None legend_dot = output_dir / "directory_color_legend.dot" - legend_dot.write_text(build_directory_color_legend_dot(directory_colors), encoding="utf-8") + legend_dot.write_text( + build_directory_color_legend_dot(directory_colors), encoding="utf-8" + ) return legend_dot @@ -319,7 +321,9 @@ def main( vprint(verbose, f"[generate_uml] Written linkable SVG: {img_path}") if legend_dot is not None: - img_path = render_dot(legend_dot, resolved_output_dir, fmt="svg", verbose=verbose) + img_path = render_dot( + legend_dot, resolved_output_dir, fmt="svg", verbose=verbose + ) vprint(verbose, f"[generate_uml] Written color legend SVG: {img_path}") vprint(verbose, "[generate_uml] Complete") diff --git a/src/daqpytools/uml/github_links.py b/src/daqpytools/uml/github_links.py index 7dd9926..c5a50f9 100644 --- a/src/daqpytools/uml/github_links.py +++ b/src/daqpytools/uml/github_links.py @@ -1,6 +1,7 @@ """Resolve GitHub source links for UML nodes and inject them into dot files.""" import re +import shutil import subprocess from pathlib import Path @@ -19,9 +20,13 @@ def resolve_git_ref(cwd: Path, default_ref: str) -> str: """Return the current commit SHA of the repo at ``cwd``, or ``default_ref``.""" + git_executable = shutil.which("git") + if git_executable is None: + return default_ref + try: - result = subprocess.run( - ["git", "-C", str(cwd), "rev-parse", "HEAD"], + result = subprocess.run( # noqa: S603 + [git_executable, "-C", str(cwd), "rev-parse", "HEAD"], capture_output=True, text=True, check=True, From 07d0427fa7e04fb98515b99e2900f5f12cfb1009 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Thu, 17 Sep 2026 18:29:17 +0200 Subject: [PATCH 5/8] add temporary directory and relevant test? --- src/daqpytools/uml/style_pyreverse.py | 48 +++++++++++++++++---------- tests/uml/test_style_pyreverse.py | 37 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 tests/uml/test_style_pyreverse.py diff --git a/src/daqpytools/uml/style_pyreverse.py b/src/daqpytools/uml/style_pyreverse.py index c12d371..862b50c 100644 --- a/src/daqpytools/uml/style_pyreverse.py +++ b/src/daqpytools/uml/style_pyreverse.py @@ -28,6 +28,7 @@ from collections.abc import Iterator, Sequence from contextlib import contextmanager from pathlib import Path +from tempfile import TemporaryDirectory from pylint.pyreverse.main import Run @@ -76,24 +77,35 @@ def run_pyreverse( 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) + output_dir.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix=".pyreverse-", dir=output_dir) as staging_dir_name: + staging_dir = Path(staging_dir_name) + cmd_args = ["-o", "dot", "--output-directory", str(staging_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) + + staged_dot_files = sorted(staging_dir.glob("*.dot")) + if not staged_dot_files: + sys.stderr.write( + f"[style_pyreverse] ERROR: pyreverse produced no .dot files in " + f"{staging_dir}\n" + ) + raise SystemExit(1) + + dot_files = [] + for staged_dot_file in staged_dot_files: + dot_path = output_dir / staged_dot_file.name + staged_dot_file.replace(dot_path) + dot_files.append(dot_path) + return dot_files diff --git a/tests/uml/test_style_pyreverse.py b/tests/uml/test_style_pyreverse.py new file mode 100644 index 0000000..40abf61 --- /dev/null +++ b/tests/uml/test_style_pyreverse.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import pytest + +from daqpytools.uml import style_pyreverse + + +def test_run_pyreverse_ignores_existing_dot_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + output_dir = tmp_path / "pics" + output_dir.mkdir() + existing_dot = output_dir / "directory_color_legend.dot" + existing_dot.write_text("existing legend", encoding="utf-8") + raw_dot = output_dir / "packages.dot" + raw_dot.write_text("old packages", encoding="utf-8") + + class FakeRun: + def __init__(self, args: list[str]) -> None: + self.args = args + + def run(self) -> int: + output_index = self.args.index("--output-directory") + 1 + staging_dir = Path(self.args[output_index]) + (staging_dir / "packages.dot").write_text( + "new packages", encoding="utf-8" + ) + return 0 + + monkeypatch.setattr(style_pyreverse, "Run", FakeRun) + + dot_files = style_pyreverse.run_pyreverse(["daqpytools"], output_dir) + + assert dot_files == [raw_dot] + assert raw_dot.read_text(encoding="utf-8") == "new packages" + assert existing_dot.read_text(encoding="utf-8") == "existing legend" + assert not list(output_dir.glob(".pyreverse-*")) \ No newline at end of file From 66aba6b6cc5c433e4844705c8e08dd7c7f5e1dbe Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 21 Sep 2026 11:04:41 +0200 Subject: [PATCH 6/8] add pylint to test --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e44708..39c1628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ docs = [ "mkdocs-section-index", "mkdocs-exclude", ] -test = ["pytest", "pytest-mypy", "pytest-cov", "types-pytz"] +test = ["pytest", "pylint", "pytest-mypy", "pytest-cov", "types-pytz"] [project.scripts] daqpytools-generate-uml = "daqpytools.apps.generate_uml:main" From 5adcee670a7d2bfa086fb3201cbb5c2799b658a0 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Mon, 21 Sep 2026 11:12:42 +0200 Subject: [PATCH 7/8] ugh also graphviz --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 39c1628..ec6dbfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ docs = [ "mkdocs-section-index", "mkdocs-exclude", ] -test = ["pytest", "pylint", "pytest-mypy", "pytest-cov", "types-pytz"] +test = ["pytest", "pylint", "graphviz", "pytest-mypy", "pytest-cov", "types-pytz"] [project.scripts] daqpytools-generate-uml = "daqpytools.apps.generate_uml:main" From 466503eb7d1f656e3874255a91887f4ad25f2442 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 25 Sep 2026 16:44:30 +0200 Subject: [PATCH 8/8] Adding required dependencies to make the tool usable without install mode --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ec6dbfc..8acc828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "pytz", "rich", "sh", + "graphviz", + "pylint", ] description = "Python utils to simplify DAQ development." name = "daqpytools"