remove example
This commit is contained in:
@@ -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"
|
||||||
+58
-55
@@ -1,38 +1,22 @@
|
|||||||
direction: down
|
direction: down
|
||||||
|
|
||||||
Topology: {
|
Interface: {
|
||||||
shape: class
|
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
|
name: str
|
||||||
interfaces: "List[Interface]"
|
itype: "[str]"
|
||||||
vlan: "Optional[str]"
|
adapter: "[str]"
|
||||||
network_ip: "Optional[str]"
|
slave_interfaces: "[List[str]]"
|
||||||
subnet_mask: "Optional[str]"
|
parent_interface: "[str]"
|
||||||
"__init__(self, ...)"
|
ip_address: "[str]"
|
||||||
"add_interface(self, interface: Interface)"
|
network: "[str]"
|
||||||
"rm_interface(self, interface: Interface)"
|
subnet_mask: "[str]"
|
||||||
__repr__(self): str
|
default_gateway: "[str]"
|
||||||
}
|
vlan: "[str]"
|
||||||
|
device: Device
|
||||||
|
|
||||||
Host: {
|
"__init__(name, ...)"
|
||||||
shape: class
|
"__repr__(): str"
|
||||||
}
|
|
||||||
Router: {
|
|
||||||
shape: class
|
|
||||||
}
|
|
||||||
Switch: {
|
|
||||||
shape: class
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Device: {
|
Device: {
|
||||||
@@ -41,27 +25,56 @@ Device: {
|
|||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
interfaces: "Dict[str, Interface]"
|
interfaces: "Dict[str, Interface]"
|
||||||
"__init__(self, name: str)"
|
|
||||||
"add_interface(self, interface: Interface)"
|
"__init__(name: str)"
|
||||||
"rm_interface(self, interface: Interface)"
|
"add_interface(interface: Interface)"
|
||||||
__repr__(self): str
|
"rm_interface(interface: Interface)"
|
||||||
|
"__repr__(): str"
|
||||||
}
|
}
|
||||||
|
|
||||||
VirtualInterface: {
|
Host: {
|
||||||
shape: class
|
shape: class
|
||||||
|
|
||||||
|
role: str
|
||||||
}
|
}
|
||||||
|
|
||||||
Interface: {
|
Router: {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
role: str
|
||||||
|
}
|
||||||
|
|
||||||
|
Switch: {
|
||||||
|
shape: class
|
||||||
|
|
||||||
|
role: str
|
||||||
|
}
|
||||||
|
|
||||||
|
Network: {
|
||||||
shape: class
|
shape: class
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
ip_address: "Optional[str]"
|
interfaces: "List[Interface]"
|
||||||
network: "Optional[str]"
|
network_ip: "[str]"
|
||||||
default_gateway: "Optional[str]"
|
|
||||||
device: Device
|
|
||||||
|
|
||||||
"__init__(self, ...)"
|
"__init__(name: str, [vlan: [str]], [network_ip: [str]])"
|
||||||
__repr__(self): 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: {
|
Host -> Device: {
|
||||||
@@ -79,32 +92,22 @@ Switch -> Device: {
|
|||||||
target-arrowhead.style.filled: false
|
target-arrowhead.style.filled: false
|
||||||
}
|
}
|
||||||
|
|
||||||
VirtualInterface -> Interface: {
|
Device -- Interface: interfaces {
|
||||||
target-arrowhead.shape: triangle
|
|
||||||
target-arrowhead.style.filled: false
|
|
||||||
}
|
|
||||||
|
|
||||||
Device -- Interface: has {
|
|
||||||
source-arrowhead: 1..*
|
source-arrowhead: 1..*
|
||||||
target-arrowhead: 1
|
target-arrowhead: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Device -- VirtualInterface: has {
|
Network -- Interface: interfaces {
|
||||||
source-arrowhead: 1..*
|
source-arrowhead: 1..*
|
||||||
target-arrowhead: 1
|
target-arrowhead: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Topology -- Network: has {
|
Topology -- Device: devices {
|
||||||
source-arrowhead: 1..*
|
source-arrowhead: 1..*
|
||||||
target-arrowhead: 1
|
target-arrowhead: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
Topology -- Device: has {
|
Topology -- Network: networks {
|
||||||
source-arrowhead: 1..*
|
|
||||||
target-arrowhead: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Network -- Interface: has {
|
|
||||||
source-arrowhead: 1..*
|
source-arrowhead: 1..*
|
||||||
target-arrowhead: 1
|
target-arrowhead: 1
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-87
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
@@ -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
|
|
||||||
|
@@ -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()
|
||||||
Reference in New Issue
Block a user