diff --git a/pyproject.toml b/pyproject.toml
index 8e44708..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"
@@ -41,7 +43,7 @@ docs = [
"mkdocs-section-index",
"mkdocs-exclude",
]
-test = ["pytest", "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"
diff --git a/src/daqpytools/apps/generate_uml.py b/src/daqpytools/apps/generate_uml.py
index 42654a4..77faf5e 100644
--- a/src/daqpytools/apps/generate_uml.py
+++ b/src/daqpytools/apps/generate_uml.py
@@ -20,11 +20,23 @@
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
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_color_theme_config,
+ load_link_config,
+ load_style_config,
+ vprint,
+)
def validate_output_directory(
@@ -60,15 +72,50 @@ 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,
+ 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)
patched_path = dot_path.with_name(f"{dot_path.stem}_styled.dot")
patched_path.write_text(patched, encoding="utf-8")
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(
@@ -136,6 +183,21 @@ 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.",
+)
+@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,
@@ -148,6 +210,9 @@ def main(
classes: tuple[str, ...],
verbose: bool,
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)
@@ -158,6 +223,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 = 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}",
+ )
+
+ 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(
@@ -167,7 +249,15 @@ 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,
+ color_context=color_context,
+ link_context=link_context,
+ )
+ )
split_dot_files: list[Path] = []
if split:
@@ -187,6 +277,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(
@@ -199,9 +297,35 @@ 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)")
+ # 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
+ )
+ 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=<",
+ ' ',
+ ' | Directory |
',
+ *(f" {row}" for row in rows),
+ "
",
+ " >",
+ " ];",
+ "}",
+ ]
+ )
+
+
+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/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..c5a50f9
--- /dev/null
+++ b/src/daqpytools/uml/github_links.py
@@ -0,0 +1,108 @@
+"""Resolve GitHub source links for UML nodes and inject them into dot files."""
+
+import re
+import shutil
+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``."""
+ git_executable = shutil.which("git")
+ if git_executable is None:
+ return default_ref
+
+ try:
+ result = subprocess.run( # noqa: S603
+ [git_executable, "-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/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]
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/src/daqpytools/uml/uml_format.ini b/src/daqpytools/uml/uml_format.ini
index 40acd65..196ef5e 100644
--- a/src/daqpytools/uml/uml_format.ini
+++ b/src/daqpytools/uml/uml_format.ini
@@ -19,6 +19,15 @@ pad = 0.5
nodesep = 0.6
ranksep = 0.9
+[uml_links]
+# GitHub linking for --generate-linkable
+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 81278b3..acfc11a 100644
--- a/src/daqpytools/uml/utils.py
+++ b/src/daqpytools/uml/utils.py
@@ -127,3 +127,24 @@ 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"],
+ }
+
+
+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()]
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