From e094874571d3e942a28c8404c7776931b87edc76 Mon Sep 17 00:00:00 2001 From: Dmitrii Krosh Date: Mon, 25 May 2026 02:25:02 +0300 Subject: [PATCH] remove example --- .github/workflows/generate-uml.yml | 70 +++++++++ UML/UML.d2 | 113 +++++++-------- UML/UML.svg | 173 +++++++++++------------ examples/3hosts/table.csv | 5 - scripts/generate_uml.py | 220 +++++++++++++++++++++++++++++ 5 files changed, 434 insertions(+), 147 deletions(-) create mode 100644 .github/workflows/generate-uml.yml delete mode 100644 examples/3hosts/table.csv create mode 100644 scripts/generate_uml.py diff --git a/.github/workflows/generate-uml.yml b/.github/workflows/generate-uml.yml new file mode 100644 index 0000000..6c3968f --- /dev/null +++ b/.github/workflows/generate-uml.yml @@ -0,0 +1,70 @@ +name: Generate UML + +# Trigger whenever the domain model or the generator script itself changes. +# "workflow_dispatch" lets you re-run it manually from the GitHub UI. +on: + push: + paths: + - "src/netdiag/domain/models.py" + - "scripts/generate_uml.py" + workflow_dispatch: + +# The job needs write access so it can push the generated files back. +permissions: + contents: write + +jobs: + generate-uml: + runs-on: ubuntu-latest + + steps: + # ── 1. check out the repository ──────────────────────────────────────── + - uses: actions/checkout@v4 + + # ── 2. set up Python (stdlib only – no pip packages needed) ──────────── + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # ── 3. generate UML/UML.d2 from models.py ────────────────────────────── + - name: Generate D2 source + run: python scripts/generate_uml.py src/netdiag/domain/models.py UML/UML.d2 + + # ── 4. install the D2 CLI ─────────────────────────────────────────────── + # + # The official install script detects the platform and drops the binary + # into one of several locations depending on how the shell is invoked. + # We add all common candidates to GITHUB_PATH so the next step finds it + # regardless of where the script chose to install. + # + # Pin a specific version to keep renders reproducible. + # Bump this when you want to adopt a newer D2 release. + - name: Install D2 + env: + D2_VERSION: "0.7.1" + run: | + curl -fsSL \ + "https://github.com/terrastruct/d2/releases/download/v${D2_VERSION}/d2-v${D2_VERSION}-linux-amd64.tar.gz" \ + | tar -xz -C /tmp + sudo install -m 0755 \ + "/tmp/d2-v${D2_VERSION}-linux-amd64/bin/d2" \ + /usr/local/bin/d2 + d2 --version + + # ── 5. render SVG ─────────────────────────────────────────────────────── + - name: Render UML/UML.svg + run: d2 UML/UML.d2 UML/UML.svg + + # ── 6. commit generated files back to the repo ───────────────────────── + # + # stefanzweifel/git-auto-commit-action is a thin wrapper around + # "git add / git commit / git push". It silently skips the commit when + # nothing has changed, so re-running the workflow is always safe. + # + # The "[skip ci]" suffix prevents this commit from re-triggering CI + # (GitHub Actions honors this convention natively). + - name: Commit generated UML files + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: auto-generate UML diagrams [skip ci]" + file_pattern: "UML/UML.d2 UML/UML.svg" diff --git a/UML/UML.d2 b/UML/UML.d2 index f457c56..a62add3 100644 --- a/UML/UML.d2 +++ b/UML/UML.d2 @@ -1,38 +1,22 @@ direction: down -Topology: { +Interface: { shape: class - devices: "Dict[str, Device]" - networks: "Dict[str, Network]" - __init__(self) - "add_device(self, device: Device)" - "rm_device(self, device: Device)" - "add_network(self, network: Network)" - "rm_network(self, network: Network)" - __repr__(self): str -} -Network: { - shape:class name: str - interfaces: "List[Interface]" - vlan: "Optional[str]" - network_ip: "Optional[str]" - subnet_mask: "Optional[str]" - "__init__(self, ...)" - "add_interface(self, interface: Interface)" - "rm_interface(self, interface: Interface)" - __repr__(self): str -} + itype: "[str]" + adapter: "[str]" + slave_interfaces: "[List[str]]" + parent_interface: "[str]" + ip_address: "[str]" + network: "[str]" + subnet_mask: "[str]" + default_gateway: "[str]" + vlan: "[str]" + device: Device -Host: { - shape: class -} -Router: { - shape: class -} -Switch: { - shape: class + "__init__(name, ...)" + "__repr__(): str" } Device: { @@ -41,27 +25,56 @@ Device: { name: str role: str interfaces: "Dict[str, Interface]" - "__init__(self, name: str)" - "add_interface(self, interface: Interface)" - "rm_interface(self, interface: Interface)" - __repr__(self): str + + "__init__(name: str)" + "add_interface(interface: Interface)" + "rm_interface(interface: Interface)" + "__repr__(): str" } -VirtualInterface: { +Host: { shape: class + + role: str } -Interface: { +Router: { + shape: class + + role: str +} + +Switch: { + shape: class + + role: str +} + +Network: { shape: class name: str - ip_address: "Optional[str]" - network: "Optional[str]" - default_gateway: "Optional[str]" - device: Device + interfaces: "List[Interface]" + network_ip: "[str]" - "__init__(self, ...)" - __repr__(self): str + "__init__(name: str, [vlan: [str]], [network_ip: [str]])" + "add_interface(interface: Interface)" + "rm_interface(interface: Interface)" + "__repr__(): str" +} + +Topology: { + shape: class + + devices: "Dict[str, Device]" + networks: "Dict[str, Network]" + + "__init__()" + "add_device(device: Device)" + "rm_device(device: Device)" + "add_network(network: Network)" + "rm_network(network: Network)" + "__repr__(): str" } Host -> Device: { @@ -79,32 +92,22 @@ Switch -> Device: { target-arrowhead.style.filled: false } -VirtualInterface -> Interface: { - target-arrowhead.shape: triangle - target-arrowhead.style.filled: false -} - -Device -- Interface: has { +Device -- Interface: interfaces { source-arrowhead: 1..* target-arrowhead: 1 } -Device -- VirtualInterface: has { +Network -- Interface: interfaces { source-arrowhead: 1..* target-arrowhead: 1 } -Topology -- Network: has { +Topology -- Device: devices { source-arrowhead: 1..* target-arrowhead: 1 } -Topology -- Device: has { - source-arrowhead: 1..* - target-arrowhead: 1 -} - -Network -- Interface: has { +Topology -- Network: networks { source-arrowhead: 1..* target-arrowhead: 1 } diff --git a/UML/UML.svg b/UML/UML.svg index b28cd1e..6979430 100644 --- a/UML/UML.svg +++ b/UML/UML.svg @@ -1,17 +1,17 @@ -Topology+devicesDict[str, Device]+networksDict[str, Network]+__init__(self)void+add_device(self, device: Device)void+rm_device(self, device: Device)void+add_network(self, network: Network)void+rm_network(self, network: Network)void+__repr__(self)strNetwork+namestr+interfacesList[Interface]+vlanOptional[str]+network_ipOptional[str]+subnet_maskOptional[str]+__init__(self, ...)void+add_interface(self, interface: Interface)void+rm_interface(self, interface: Interface)void+__repr__(self)strHostRouterSwitchDevice+namestr+rolestr+interfacesDict[str, Interface]+__init__(self, name: str)void+add_interface(self, interface: Interface)void+rm_interface(self, interface: Interface)void+__repr__(self)strVirtualInterfaceInterface+namestr+ip_addressOptional[str]+networkOptional[str]+default_gatewayOptional[str]+deviceDevice+__init__(self, ...)void+__repr__(self)str has1..*1has1..*1has1..*1has1..*1has1..*1 - - - - - - + .d2-1106624178 .fill-N1{fill:#0A0F25;} + .d2-1106624178 .fill-N2{fill:#676C7E;} + .d2-1106624178 .fill-N3{fill:#9499AB;} + .d2-1106624178 .fill-N4{fill:#CFD2DD;} + .d2-1106624178 .fill-N5{fill:#DEE1EB;} + .d2-1106624178 .fill-N6{fill:#EEF1F8;} + .d2-1106624178 .fill-N7{fill:#FFFFFF;} + .d2-1106624178 .fill-B1{fill:#0D32B2;} + .d2-1106624178 .fill-B2{fill:#0D32B2;} + .d2-1106624178 .fill-B3{fill:#E3E9FD;} + .d2-1106624178 .fill-B4{fill:#E3E9FD;} + .d2-1106624178 .fill-B5{fill:#EDF0FD;} + .d2-1106624178 .fill-B6{fill:#F7F8FE;} + .d2-1106624178 .fill-AA2{fill:#4A6FF3;} + .d2-1106624178 .fill-AA4{fill:#EDF0FD;} + .d2-1106624178 .fill-AA5{fill:#F7F8FE;} + .d2-1106624178 .fill-AB4{fill:#EDF0FD;} + .d2-1106624178 .fill-AB5{fill:#F7F8FE;} + .d2-1106624178 .stroke-N1{stroke:#0A0F25;} + .d2-1106624178 .stroke-N2{stroke:#676C7E;} + .d2-1106624178 .stroke-N3{stroke:#9499AB;} + .d2-1106624178 .stroke-N4{stroke:#CFD2DD;} + .d2-1106624178 .stroke-N5{stroke:#DEE1EB;} + .d2-1106624178 .stroke-N6{stroke:#EEF1F8;} + .d2-1106624178 .stroke-N7{stroke:#FFFFFF;} + .d2-1106624178 .stroke-B1{stroke:#0D32B2;} + .d2-1106624178 .stroke-B2{stroke:#0D32B2;} + .d2-1106624178 .stroke-B3{stroke:#E3E9FD;} + .d2-1106624178 .stroke-B4{stroke:#E3E9FD;} + .d2-1106624178 .stroke-B5{stroke:#EDF0FD;} + .d2-1106624178 .stroke-B6{stroke:#F7F8FE;} + .d2-1106624178 .stroke-AA2{stroke:#4A6FF3;} + .d2-1106624178 .stroke-AA4{stroke:#EDF0FD;} + .d2-1106624178 .stroke-AA5{stroke:#F7F8FE;} + .d2-1106624178 .stroke-AB4{stroke:#EDF0FD;} + .d2-1106624178 .stroke-AB5{stroke:#F7F8FE;} + .d2-1106624178 .background-color-N1{background-color:#0A0F25;} + .d2-1106624178 .background-color-N2{background-color:#676C7E;} + .d2-1106624178 .background-color-N3{background-color:#9499AB;} + .d2-1106624178 .background-color-N4{background-color:#CFD2DD;} + .d2-1106624178 .background-color-N5{background-color:#DEE1EB;} + .d2-1106624178 .background-color-N6{background-color:#EEF1F8;} + .d2-1106624178 .background-color-N7{background-color:#FFFFFF;} + .d2-1106624178 .background-color-B1{background-color:#0D32B2;} + .d2-1106624178 .background-color-B2{background-color:#0D32B2;} + .d2-1106624178 .background-color-B3{background-color:#E3E9FD;} + .d2-1106624178 .background-color-B4{background-color:#E3E9FD;} + .d2-1106624178 .background-color-B5{background-color:#EDF0FD;} + .d2-1106624178 .background-color-B6{background-color:#F7F8FE;} + .d2-1106624178 .background-color-AA2{background-color:#4A6FF3;} + .d2-1106624178 .background-color-AA4{background-color:#EDF0FD;} + .d2-1106624178 .background-color-AA5{background-color:#F7F8FE;} + .d2-1106624178 .background-color-AB4{background-color:#EDF0FD;} + .d2-1106624178 .background-color-AB5{background-color:#F7F8FE;} + .d2-1106624178 .color-N1{color:#0A0F25;} + .d2-1106624178 .color-N2{color:#676C7E;} + .d2-1106624178 .color-N3{color:#9499AB;} + .d2-1106624178 .color-N4{color:#CFD2DD;} + .d2-1106624178 .color-N5{color:#DEE1EB;} + .d2-1106624178 .color-N6{color:#EEF1F8;} + .d2-1106624178 .color-N7{color:#FFFFFF;} + .d2-1106624178 .color-B1{color:#0D32B2;} + .d2-1106624178 .color-B2{color:#0D32B2;} + .d2-1106624178 .color-B3{color:#E3E9FD;} + .d2-1106624178 .color-B4{color:#E3E9FD;} + .d2-1106624178 .color-B5{color:#EDF0FD;} + .d2-1106624178 .color-B6{color:#F7F8FE;} + .d2-1106624178 .color-AA2{color:#4A6FF3;} + .d2-1106624178 .color-AA4{color:#EDF0FD;} + .d2-1106624178 .color-AA5{color:#F7F8FE;} + .d2-1106624178 .color-AB4{color:#EDF0FD;} + .d2-1106624178 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-1106624178);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-1106624178);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-1106624178);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-1106624178);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-1106624178);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-1106624178);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-1106624178);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-1106624178);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]>Interface+namestr+itype[str]+adapter[str]+slave_interfaces[List[str]]+parent_interface[str]+ip_address[str]+network[str]+subnet_mask[str]+default_gateway[str]+vlan[str]+deviceDevice+__init__(name, ...)void+__repr__(): strvoidDevice+namestr+rolestr+interfacesDict[str, Interface]+__init__(name: str)void+add_interface(interface: Interface)void+rm_interface(interface: Interface)void+__repr__(): strvoidHost+rolestrRouter+rolestrSwitch+rolestrNetwork+namestr+interfacesList[Interface]+network_ip[str]+__init__(name: str, [vlan: [str]], [network_ip: [str]])void+add_interface(interface: Interface)void+rm_interface(interface: Interface)void+__repr__(): strvoidTopology+devicesDict[str, Device]+networksDict[str, Network]+__init__()void+add_device(device: Device)void+rm_device(device: Device)void+add_network(network: Network)void+rm_network(network: Network)void+__repr__(): strvoid interfaces1..*1interfaces1..*1devices1..*1networks1..*1 + + + + + diff --git a/examples/3hosts/table.csv b/examples/3hosts/table.csv deleted file mode 100644 index 28350c3..0000000 --- a/examples/3hosts/table.csv +++ /dev/null @@ -1,5 +0,0 @@ -Name,Role,Adapter,Interface,Network,Network IP,Mask,Device IP -PC1,Host,Adapter1,uplink,main,10.0.12.0,/24,10.0.12.1 -PC1,Host,,lo,,127.0.0.0,/8,127.1.1.1 -PC2,Host,Adapter1,uplink,main,10.0.12.0,/24,10.0.12.2 -PC3,Host,Adapter1,uplink,main,10.0.12.0,/24,10.0.12.3 diff --git a/scripts/generate_uml.py b/scripts/generate_uml.py new file mode 100644 index 0000000..55da34b --- /dev/null +++ b/scripts/generate_uml.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +Auto-generate a D2 UML class diagram from a Python source file using AST parsing. + +Usage: + python scripts/generate_uml.py [input.py] [output.d2] + +Defaults: + input → src/netdiag/domain/models.py + output → UML/UML.d2 +""" + +import ast +import sys +from pathlib import Path + +# ── helpers ─── + + +def _unparse(node: ast.expr) -> str: + """Return a clean string for an AST annotation, stripping forward-ref quotes.""" + return ast.unparse(node).strip("'\"") + + +def _d2_quote(s: str) -> str: + """ + Wrap *s* in D2 double-quotes only when it contains characters that the D2 + parser would otherwise misinterpret (colons inside arg lists, brackets, …). + """ + needs_quoting = any(c in s for c in '():[],<> "') + return f'"{s}"' if needs_quoting else s + + +# ── parsing ── + + +def parse_classes(source: str) -> list[dict]: + """ + Walk the top-level statements of *source* and return one dict per class: + + { + "name": str, + "bases": list[str], # base-class names + "attrs": list[(name, type)], # class-level annotated attributes + "methods": list[(name, args_str, return_str)], + } + """ + tree = ast.parse(source) + classes: list[dict] = [] + + for node in tree.body: # top-level only – no nested classes + if not isinstance(node, ast.ClassDef): + continue + + bases = [_unparse(b) for b in node.bases] + attrs: list[tuple[str, str]] = [] + methods: list[tuple[str, str, str]] = [] + + for item in node.body: + + # ── class-level type annotation ─── + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + attrs.append((_unparse(item.target), _unparse(item.annotation))) + + # ── method definition ─── + elif isinstance(item, ast.FunctionDef): + # Build the argument string, skipping 'self' + func_args: list[str] = [] + for arg in item.args.args: + if arg.arg == "self": + continue + if arg.annotation: + func_args.append(f"{arg.arg}: {_unparse(arg.annotation)}") + else: + func_args.append(arg.arg) + + # Mark arguments that have defaults as [optional] + n_required = len(func_args) - len(item.args.defaults) + for i in range(n_required, len(func_args)): + func_args[i] = f"[{func_args[i]}]" + + args_str = ", ".join(func_args) + ret_str = _unparse(item.returns) if item.returns else "" + methods.append((item.name, args_str, ret_str)) + + classes.append( + {"name": node.name, "bases": bases, "attrs": attrs, "methods": methods} + ) + + return classes + + +# ── relationship detection ─── + + +def detect_associations(classes: list[dict]) -> list[tuple[str, str, str, str]]: + """ + Detect associations between classes by scanning type annotations for + references to other known class names. + + Returns a list of (src, dst, label, multiplicity) where multiplicity is + "many" (Dict / List) or "one". + + When the same class pair appears with both a "many" and a "one" reference + (e.g. Device.interfaces and Interface.device), the "many" side wins so the + diagram shows the semantically richer direction. + """ + all_names: set[str] = {c["name"] for c in classes} + + # Keyed by the canonical (alphabetically sorted) class pair so that A→B + # and B→A collapse into a single relationship. + raw: dict[tuple[str, str], tuple[str, str, str, str]] = {} + + for c in classes: + src = c["name"] + for attr_name, attr_type in c["attrs"]: + for other in all_names: + if other == src: + continue + if other not in attr_type: + continue + + many = any(kw in attr_type for kw in ("Dict", "List")) + mult = "many" if many else "one" + + key = tuple(sorted([src, other])) + existing = raw.get(key) # type: ignore[assignment] + + # Always prefer the "many" direction; only add "one" if nothing + # has been recorded yet for this pair. + if existing is None or (mult == "many" and existing[3] == "one"): + raw[key] = (src, other, attr_name, mult) # type: ignore[assignment] + + return list(raw.values()) # type: ignore[return-value] + + +# ── D2 rendering ─── + +_INHERIT = """\ +{child} -> {parent}: {{ + target-arrowhead.shape: triangle + target-arrowhead.style.filled: false +}}""" + +_ASSOC_MANY = """\ +{src} -- {dst}: {label} {{ + source-arrowhead: 1..* + target-arrowhead: 1 +}}""" + +_ASSOC_ONE = """\ +{src} -- {dst}: {label} {{ + source-arrowhead: 1 + target-arrowhead: 1 +}}""" + + +def to_d2(classes: list[dict]) -> str: + all_names = {c["name"] for c in classes} + lines: list[str] = ["direction: down", ""] + + # ── class blocks ─── + for c in classes: + lines.append(f"{c['name']}: {{") + lines.append(" shape: class") + + if c["attrs"]: + lines.append("") + for name, typ in c["attrs"]: + lines.append(f" {name}: {_d2_quote(typ)}") + + if c["methods"]: + lines.append("") + for mname, args, ret in c["methods"]: + sig = f"{mname}({args})" + if ret: + sig += f": {ret}" + lines.append(f" {_d2_quote(sig)}") + + lines += ["}", ""] + + # ── inheritance arrows ─── + for c in classes: + for base in c["bases"]: + if base in all_names: + lines.append(_INHERIT.format(child=c["name"], parent=base)) + lines.append("") + + # ── association edges ─── + for src, dst, label, mult in detect_associations(classes): + tmpl = _ASSOC_MANY if mult == "many" else _ASSOC_ONE + lines.append(tmpl.format(src=src, dst=dst, label=label)) + lines.append("") + + return "\n".join(lines) + + +# ── entry point ─── + + +def main() -> None: + models_path = ( + Path(sys.argv[1]) if len(sys.argv) > 1 else Path("src/netdiag/domain/models.py") + ) + output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("UML/UML.d2") + + if not models_path.exists(): + sys.exit(f"Error: {models_path} not found") + + source = models_path.read_text() + classes = parse_classes(source) + d2_content = to_d2(classes) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(d2_content) + print(f"Generated {output_path} ({len(classes)} classes)") + + +if __name__ == "__main__": + main()