Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
210 changes: 210 additions & 0 deletions src/daqpytools/apps/generate_uml.py
Original file line number Diff line number Diff line change
@@ -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()
55 changes: 11 additions & 44 deletions src/daqpytools/logging/formatter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import configparser
import logging
import os
import re
Expand All @@ -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:
Expand All @@ -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}

Expand Down
Empty file added src/daqpytools/uml/__init__.py
Empty file.
87 changes: 87 additions & 0 deletions src/daqpytools/uml/dot_parsing.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading