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
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ description = "A text-based DSL for defining software architecture alongside cod
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.12"
dependencies = ["dash>=2.0", "pydantic>=2.0", "pyyaml>=6.0"]
dependencies = [
"dash>=2.0",
"diagrams>=0.25.1",
"pydantic>=2.0",
"pyyaml>=6.0",
]

[project.scripts]
archml = "archml.cli.main:main"
Expand Down
90 changes: 90 additions & 0 deletions src/archml/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ def main() -> None:
help="Host to bind the server to (default: 127.0.0.1)",
)

# visualize subcommand
visualize_parser = subparsers.add_parser(
"visualize",
help="Generate a diagram for a system or component",
description="Render a box diagram for the specified architecture entity.",
)
visualize_parser.add_argument(
"entity",
help="Entity path to visualize (e.g. 'SystemA' or 'SystemA::ComponentB')",
)
visualize_parser.add_argument(
"output",
help="Output file path for the rendered diagram (e.g. 'diagram.png')",
)
visualize_parser.add_argument(
"directory",
nargs="?",
default=".",
help="Directory containing the ArchML workspace (default: current directory)",
)

# sync-remote subcommand
sync_remote_parser = subparsers.add_parser(
"sync-remote",
Expand Down Expand Up @@ -132,6 +153,8 @@ def _dispatch(args: argparse.Namespace) -> int:
return _cmd_check(args)
if args.command == "serve":
return _cmd_serve(args)
if args.command == "visualize":
return _cmd_visualize(args)
if args.command == "sync-remote":
return _cmd_sync_remote(args)
if args.command == "update-remote":
Expand Down Expand Up @@ -264,6 +287,73 @@ def _cmd_check(args: argparse.Namespace) -> int:
return 0


def _cmd_visualize(args: argparse.Namespace) -> int:
"""Handle the visualize subcommand."""
from archml.views.diagram import build_diagram_data, render_diagram
from archml.views.resolver import EntityNotFoundError, resolve_entity
from archml.workspace.config import LocalPathImport

directory = Path(args.directory).resolve()

if not directory.exists():
print(f"Error: directory '{directory}' does not exist.", file=sys.stderr)
return 1

workspace_yaml = directory / ".archml-workspace.yaml"

if not workspace_yaml.exists():
print(
f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.",
file=sys.stderr,
)
return 1

try:
config = load_workspace_config(workspace_yaml)
except WorkspaceConfigError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1

build_dir = directory / config.build_directory

source_import_map: dict[SourceImportKey, Path] = {}
for imp in config.source_imports:
if isinstance(imp, LocalPathImport):
source_import_map[SourceImportKey(config.name, imp.name)] = (directory / imp.local_path).resolve()

archml_files = [f for f in directory.rglob("*.archml") if build_dir not in f.parents]
if not archml_files:
print("No .archml files found in the workspace.", file=sys.stderr)
return 1

try:
compiled = compile_files(archml_files, build_dir, source_import_map)
except CompilerError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1

try:
entity = resolve_entity(compiled, args.entity)
except EntityNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1

output_path = Path(args.output)
data = build_diagram_data(entity)

try:
render_diagram(data, output_path)
except ImportError:
print(
"Error: 'diagrams' is not installed. Run 'pip install diagrams' to enable visualization.",
file=sys.stderr,
)
return 1

print(f"Diagram written to '{output_path}'.")
return 0


def _cmd_serve(args: argparse.Namespace) -> int:
"""Handle the serve subcommand."""
from archml.workspace.config import find_workspace_root
Expand Down
191 changes: 191 additions & 0 deletions src/archml/views/diagram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Copyright 2026 ArchML Contributors
# SPDX-License-Identifier: Apache-2.0

"""Diagram generation for ArchML architecture views.

Builds a diagram representation from a resolved model entity and renders it
to an image file using the ``diagrams`` library.

The diagram shows:
- The target entity as the outer container / title.
- All direct child components and systems as inner boxes.
- The target entity's ``requires`` interfaces as incoming terminal elements.
- The target entity's ``provides`` interfaces as outgoing terminal elements.
- Connections between child entities as labelled arrows.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

from archml.model.entities import Component, InterfaceRef, System

# ###############
# Public Interface
# ###############


@dataclass
class ChildBox:
"""Represents a direct child component or system in the diagram.

Attributes:
name: Short name of the child entity.
description: Optional human-readable description.
kind: ``"component"`` or ``"system"``.
"""

name: str
description: str | None
kind: str # "component" | "system"


@dataclass
class InterfaceTerminal:
"""Represents an external interface terminal (incoming or outgoing).

Attributes:
name: Interface name (optionally with version suffix ``@vN``).
direction: ``"in"`` for ``requires``, ``"out"`` for ``provides``.
description: Optional description of the interface.
"""

name: str
direction: str # "in" | "out"
description: str | None = None


@dataclass
class ConnectionData:
"""Represents a directed connection between two child entities.

Attributes:
source: Name of the source child entity.
target: Name of the target child entity.
label: Interface name used by the connection.
"""

source: str
target: str
label: str


@dataclass
class DiagramData:
"""Full description of a diagram to be rendered.

Attributes:
title: Name of the target entity (used as the diagram title/box).
description: Optional human-readable description of the entity.
children: Direct child components and systems.
terminals: External interface terminals (in/out).
connections: Directed data-flow connections between children.
"""

title: str
description: str | None
children: list[ChildBox] = field(default_factory=list)
terminals: list[InterfaceTerminal] = field(default_factory=list)
connections: list[ConnectionData] = field(default_factory=list)


def build_diagram_data(entity: Component | System) -> DiagramData:
"""Build a :class:`DiagramData` description from a model entity.

Collects direct children, external interface terminals, and connections
from *entity* without navigating deeper into the hierarchy.

Args:
entity: The resolved component or system to visualize.

Returns:
A :class:`DiagramData` instance describing the diagram.
"""
children: list[ChildBox] = [
ChildBox(name=comp.name, description=comp.description, kind="component") for comp in entity.components
]
if isinstance(entity, System):
children += [ChildBox(name=sys.name, description=sys.description, kind="system") for sys in entity.systems]

terminals: list[InterfaceTerminal] = [
InterfaceTerminal(
name=_iref_label(ref),
direction="in",
)
for ref in entity.requires
] + [
InterfaceTerminal(
name=_iref_label(ref),
direction="out",
)
for ref in entity.provides
]

connections: list[ConnectionData] = [
ConnectionData(
source=conn.source.entity,
target=conn.target.entity,
label=_iref_label(conn.interface),
)
for conn in entity.connections
]

return DiagramData(
title=entity.name,
description=entity.description,
children=children,
terminals=terminals,
connections=connections,
)


def render_diagram(data: DiagramData, output_path: Path) -> None:
"""Render *data* to an image file at *output_path* using ``diagrams``.

The output format is determined by the file extension of *output_path*
(e.g. ``.png``, ``.svg``).

Args:
data: The diagram description to render.
output_path: Destination file path for the rendered image.

Raises:
ImportError: If the ``diagrams`` package is not installed.
"""
try:
import diagrams as _diagrams
from diagrams import Edge
from diagrams.c4 import Container, Person
except ImportError as exc:
raise ImportError("'diagrams' is not installed. Run 'pip install diagrams' to enable visualization.") from exc

output_stem = str(output_path.parent / output_path.stem)
output_format = output_path.suffix.lstrip(".") or "png"

with _diagrams.Diagram(data.title, filename=output_stem, outformat=output_format, show=False):
for terminal in data.terminals:
if terminal.direction == "in":
Person(terminal.name)

child_nodes: dict[str, object] = {}
for child in data.children:
child_nodes[child.name] = Container(child.name, technology=child.kind, description=child.description or "")

for terminal in data.terminals:
if terminal.direction == "out":
Person(terminal.name)

for conn in data.connections:
if conn.source in child_nodes and conn.target in child_nodes:
child_nodes[conn.source] >> Edge(label=conn.label) >> child_nodes[conn.target] # type: ignore[operator]


# ################
# Implementation
# ################


def _iref_label(ref: InterfaceRef) -> str:
"""Return a display label for an interface reference."""
return f"{ref.name}@{ref.version}" if ref.version else ref.name
Loading