diff --git a/pyproject.toml b/pyproject.toml index f213efe..d0bd0b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/archml/cli/main.py b/src/archml/cli/main.py index 207cb24..6ab802d 100644 --- a/src/archml/cli/main.py +++ b/src/archml/cli/main.py @@ -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", @@ -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": @@ -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 diff --git a/src/archml/views/diagram.py b/src/archml/views/diagram.py new file mode 100644 index 0000000..bec20db --- /dev/null +++ b/src/archml/views/diagram.py @@ -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 diff --git a/src/archml/views/resolver.py b/src/archml/views/resolver.py new file mode 100644 index 0000000..57a744f --- /dev/null +++ b/src/archml/views/resolver.py @@ -0,0 +1,104 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Entity resolution for ArchML views. + +Resolves ``::``-delimited entity paths (e.g. ``SystemA::ComponentB::Sub``) +to the corresponding :class:`~archml.model.entities.Component` or +:class:`~archml.model.entities.System` in a compiled model. +""" + +from __future__ import annotations + +from archml.model.entities import ArchFile, Component, System + +# ############### +# Public Interface +# ############### + + +class EntityNotFoundError(Exception): + """Raised when an entity path cannot be resolved in the model.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + + +def resolve_entity( + arch_files: dict[str, ArchFile], + path: str, +) -> Component | System: + """Resolve a ``::``-delimited entity path to a model entity. + + The first path segment identifies a top-level system or component (by + name) across all provided *arch_files*. Subsequent segments navigate + into nested systems or components of the previously found entity. + + Examples:: + + resolve_entity(files, "SystemA") + resolve_entity(files, "SystemA::Worker") + resolve_entity(files, "SystemA::SubSystem::ComponentX") + + Args: + arch_files: Mapping from canonical file key to compiled + :class:`~archml.model.entities.ArchFile`. + path: ``::``-delimited path string identifying the target entity. + + Returns: + The resolved :class:`~archml.model.entities.Component` or + :class:`~archml.model.entities.System`. + + Raises: + EntityNotFoundError: If any segment along the path cannot be found. + """ + segments = [s.strip() for s in path.split("::")] + if not segments or not any(segments): + raise EntityNotFoundError(f"Invalid entity path: '{path}'") + + first = segments[0] + entity: Component | System | None = _find_top_level(arch_files, first) + if entity is None: + raise EntityNotFoundError(f"Top-level entity '{first}' not found in any architecture file") + + for segment in segments[1:]: + entity = _find_child(entity, segment) + if entity is None: + raise EntityNotFoundError(f"Entity '{segment}' not found in path '{path}'") + + return entity + + +# ################ +# Implementation +# ################ + + +def _find_top_level( + arch_files: dict[str, ArchFile], + name: str, +) -> Component | System | None: + """Search all arch files for a top-level component or system by name.""" + for arch_file in arch_files.values(): + for system in arch_file.systems: + if system.name == name: + return system + for component in arch_file.components: + if component.name == name: + return component + return None + + +def _find_child( + entity: Component | System, + name: str, +) -> Component | System | None: + """Search the direct children of *entity* for a member named *name*.""" + for comp in entity.components: + if comp.name == name: + return comp + if isinstance(entity, System): + for sub in entity.systems: + if sub.name == name: + return sub + return None diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 86db75a..16fd4b4 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -428,6 +428,15 @@ def test_serve_custom_host_and_port(tmp_path: Path, monkeypatch: pytest.MonkeyPa mock_app.run.assert_called_once_with(host="0.0.0.0", port=9000, debug=False) +# -------- visualize tests -------- + + +def test_visualize_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """visualize exits with error code 1 when directory does not exist.""" + missing = tmp_path / "nonexistent" + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "SystemA", "out.png", str(missing)]) + + # -------- sync-remote tests -------- _COMMIT_40 = "a" * 40 @@ -450,6 +459,36 @@ def test_sync_remote_fails_if_directory_does_not_exist(tmp_path: Path, monkeypat assert exc_info.value.code == 1 +def test_visualize_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """visualize exits with error code 1 when no workspace file is found.""" + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "SystemA", "out.png", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_visualize_fails_if_no_archml_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """visualize exits with error code 1 when no .archml files are found.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "SystemA", "out.png", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + +def test_visualize_fails_if_entity_not_found( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """visualize exits with error code 1 when entity path is not found.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / "arch.archml").write_text("component Worker {}\n") + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "NonExistent", "out.png", str(tmp_path)]) + with pytest.raises(SystemExit): + main() + + def test_sync_remote_autodetects_workspace_in_parent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: @@ -606,6 +645,24 @@ def test_sync_remote_reports_error_on_clone_failure( assert "Error" in captured.err +def test_visualize_succeeds_with_mocked_renderer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """visualize exits with code 0 and writes the diagram when entity is found.""" + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) + (tmp_path / "arch.archml").write_text("component Worker {}\n") + out_file = tmp_path / "diagram.png" + monkeypatch.setattr(sys, "argv", ["archml", "visualize", "Worker", str(out_file), str(tmp_path)]) + with patch("archml.views.diagram.render_diagram") as mock_render, pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + mock_render.assert_called_once() + captured = capsys.readouterr() + assert "diagram.png" in captured.out + + def test_sync_remote_uses_custom_sync_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote uses the remote-sync-directory from workspace config.""" (tmp_path / ".archml-workspace.yaml").write_text( diff --git a/tests/views/test_diagram.py b/tests/views/test_diagram.py new file mode 100644 index 0000000..ee0965b --- /dev/null +++ b/tests/views/test_diagram.py @@ -0,0 +1,280 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for diagram data builder and renderer.""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from archml.model.entities import Component, Connection, ConnectionEndpoint, InterfaceRef, System +from archml.views.diagram import ( + ChildBox, + ConnectionData, + DiagramData, + InterfaceTerminal, + build_diagram_data, + render_diagram, +) + +# ############### +# Helpers +# ############### + + +def _iref(name: str, version: str | None = None) -> InterfaceRef: + return InterfaceRef(name=name, version=version) + + +def _conn(source: str, target: str, interface: str) -> Connection: + return Connection( + source=ConnectionEndpoint(entity=source), + target=ConnectionEndpoint(entity=target), + interface=InterfaceRef(name=interface), + ) + + +# ############### +# build_diagram_data — title and description +# ############### + + +def test_build_title_from_component() -> None: + """Diagram title is the component name.""" + comp = Component(name="Worker") + data = build_diagram_data(comp) + assert data.title == "Worker" + + +def test_build_title_from_system() -> None: + """Diagram title is the system name.""" + sys_a = System(name="SystemA") + data = build_diagram_data(sys_a) + assert data.title == "SystemA" + + +def test_build_description_propagated() -> None: + """Description is forwarded to DiagramData.""" + comp = Component(name="Worker", description="Does the work") + data = build_diagram_data(comp) + assert data.description == "Does the work" + + +def test_build_description_none_when_absent() -> None: + """Description is None when the entity has no description.""" + comp = Component(name="Worker") + data = build_diagram_data(comp) + assert data.description is None + + +# ############### +# build_diagram_data — children +# ############### + + +def test_build_children_from_component_with_sub_components() -> None: + """Sub-components are listed as ChildBox entries with kind='component'.""" + child_a = Component(name="Alpha", description="First") + child_b = Component(name="Beta") + parent = Component(name="Parent", components=[child_a, child_b]) + data = build_diagram_data(parent) + assert data.children == [ + ChildBox(name="Alpha", description="First", kind="component"), + ChildBox(name="Beta", description=None, kind="component"), + ] + + +def test_build_children_from_system_includes_components_and_subsystems() -> None: + """System children include both components and sub-systems.""" + comp = Component(name="Worker") + sub = System(name="SubSys") + parent = System(name="Root", components=[comp], systems=[sub]) + data = build_diagram_data(parent) + names_kinds = [(c.name, c.kind) for c in data.children] + assert ("Worker", "component") in names_kinds + assert ("SubSys", "system") in names_kinds + + +def test_build_no_children_for_leaf() -> None: + """A leaf entity (no sub-components or sub-systems) produces no children.""" + comp = Component(name="Leaf") + data = build_diagram_data(comp) + assert data.children == [] + + +# ############### +# build_diagram_data — terminals +# ############### + + +def test_build_requires_terminals() -> None: + """Requires interfaces appear as incoming terminals.""" + comp = Component(name="C", requires=[_iref("DataFeed")]) + data = build_diagram_data(comp) + assert InterfaceTerminal(name="DataFeed", direction="in") in data.terminals + + +def test_build_provides_terminals() -> None: + """Provides interfaces appear as outgoing terminals.""" + comp = Component(name="C", provides=[_iref("Result")]) + data = build_diagram_data(comp) + assert InterfaceTerminal(name="Result", direction="out") in data.terminals + + +def test_build_versioned_interface_terminal() -> None: + """Versioned interfaces include the version suffix in their label.""" + comp = Component(name="C", provides=[_iref("API", version="v2")]) + data = build_diagram_data(comp) + assert InterfaceTerminal(name="API@v2", direction="out") in data.terminals + + +def test_build_no_terminals_for_leaf() -> None: + """An entity with no requires or provides has no terminals.""" + comp = Component(name="Isolated") + data = build_diagram_data(comp) + assert data.terminals == [] + + +# ############### +# build_diagram_data — connections +# ############### + + +def test_build_connections() -> None: + """Connections are translated to ConnectionData entries.""" + child_a = Component(name="A") + child_b = Component(name="B") + conn = _conn("A", "B", "IFace") + parent = Component(name="Parent", components=[child_a, child_b], connections=[conn]) + data = build_diagram_data(parent) + assert ConnectionData(source="A", target="B", label="IFace") in data.connections + + +def test_build_no_connections_for_leaf() -> None: + """A leaf entity has no connections.""" + comp = Component(name="Leaf") + data = build_diagram_data(comp) + assert data.connections == [] + + +# ############### +# render_diagram — mocked diagrams +# ############### + + +def _make_diagrams_mock() -> tuple[MagicMock, MagicMock]: + """Return (mock_diagrams_module, mock_diagrams_c4_module).""" + mock_diagrams = MagicMock() + mock_c4 = MagicMock() + return mock_diagrams, mock_c4 + + +def _diagrams_patch(mock_diagrams: MagicMock, mock_c4: MagicMock) -> ...: # type: ignore[type-arg] + return patch.dict(sys.modules, {"diagrams": mock_diagrams, "diagrams.c4": mock_c4}) + + +def test_render_diagram_calls_diagrams(tmp_path: Path) -> None: + """render_diagram imports diagrams and calls Diagram with the entity title.""" + mock_diagrams, mock_c4 = _make_diagrams_mock() + data = DiagramData(title="SystemA", description="Test system") + + with _diagrams_patch(mock_diagrams, mock_c4): + render_diagram(data, tmp_path / "out.png") + + mock_diagrams.Diagram.assert_called_once_with( + "SystemA", + filename=str(tmp_path / "out"), + outformat="png", + show=False, + ) + + +def test_render_diagram_adds_terminals(tmp_path: Path) -> None: + """render_diagram creates a Person node for each terminal.""" + mock_diagrams, mock_c4 = _make_diagrams_mock() + + data = DiagramData( + title="S", + description=None, + terminals=[ + InterfaceTerminal(name="Input", direction="in"), + InterfaceTerminal(name="Output", direction="out"), + ], + ) + + with _diagrams_patch(mock_diagrams, mock_c4): + render_diagram(data, tmp_path / "out.png") + + calls = [str(c) for c in mock_c4.Person.call_args_list] + assert any("Input" in c for c in calls) + assert any("Output" in c for c in calls) + + +def test_render_diagram_adds_children(tmp_path: Path) -> None: + """render_diagram creates a Container node for each child box.""" + mock_diagrams, mock_c4 = _make_diagrams_mock() + + data = DiagramData( + title="S", + description=None, + children=[ + ChildBox(name="Alpha", description="first", kind="component"), + ChildBox(name="Beta", description=None, kind="system"), + ], + ) + + with _diagrams_patch(mock_diagrams, mock_c4): + render_diagram(data, tmp_path / "out.png") + + assert mock_c4.Container.call_count == 2 + + +def test_render_diagram_adds_connections(tmp_path: Path) -> None: + """render_diagram creates an Edge for each connection.""" + mock_diagrams, mock_c4 = _make_diagrams_mock() + + data = DiagramData( + title="S", + description=None, + children=[ + ChildBox(name="A", description=None, kind="component"), + ChildBox(name="B", description=None, kind="component"), + ], + connections=[ConnectionData(source="A", target="B", label="IFace")], + ) + + with _diagrams_patch(mock_diagrams, mock_c4): + render_diagram(data, tmp_path / "out.png") + + mock_diagrams.Edge.assert_called_once_with(label="IFace") + + +def test_render_diagram_uses_output_path(tmp_path: Path) -> None: + """render_diagram passes the correct filename stem and format to Diagram.""" + mock_diagrams, mock_c4 = _make_diagrams_mock() + + out = tmp_path / "diagram.svg" + data = DiagramData(title="S", description=None) + + with _diagrams_patch(mock_diagrams, mock_c4): + render_diagram(data, out) + + mock_diagrams.Diagram.assert_called_once_with( + "S", + filename=str(tmp_path / "diagram"), + outformat="svg", + show=False, + ) + + +def test_render_diagram_raises_import_error_without_diagrams(tmp_path: Path) -> None: + """render_diagram raises ImportError when diagrams is not installed.""" + data = DiagramData(title="S", description=None) + # Setting diagrams to None in sys.modules makes `import diagrams` raise ImportError. + with ( + patch.dict(sys.modules, {"diagrams": None}), # type: ignore[dict-item] + pytest.raises(ImportError), + ): + render_diagram(data, tmp_path / "out.png") diff --git a/tests/views/test_resolver.py b/tests/views/test_resolver.py new file mode 100644 index 0000000..035b985 --- /dev/null +++ b/tests/views/test_resolver.py @@ -0,0 +1,134 @@ +# Copyright 2026 ArchML Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the entity path resolver.""" + +import pytest + +from archml.model.entities import ArchFile, Component, System +from archml.views.resolver import EntityNotFoundError, resolve_entity + +# ############### +# Public Interface +# ############### + + +def _make_files(**entities: Component | System) -> dict[str, ArchFile]: + """Build a minimal arch_files dict with the given top-level entities.""" + components = [e for e in entities.values() if isinstance(e, Component)] + systems = [e for e in entities.values() if isinstance(e, System)] + return {"main": ArchFile(components=components, systems=systems)} + + +# -------- top-level resolution -------- + + +def test_resolve_top_level_system() -> None: + """Resolving a bare system name returns that system.""" + sys_a = System(name="SystemA") + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, "SystemA") + assert result is sys_a + + +def test_resolve_top_level_component() -> None: + """Resolving a bare component name returns that component.""" + comp = Component(name="Worker") + files = _make_files(Worker=comp) + result = resolve_entity(files, "Worker") + assert result is comp + + +def test_resolve_top_level_system_across_multiple_files() -> None: + """Entity is found when it lives in the second of two arch files.""" + comp = Component(name="Auth") + files = { + "file_a": ArchFile(components=[Component(name="Other")]), + "file_b": ArchFile(components=[comp]), + } + result = resolve_entity(files, "Auth") + assert result is comp + + +# -------- nested resolution -------- + + +def test_resolve_nested_component_in_system() -> None: + """Resolving 'SystemA::Worker' returns the Worker component inside SystemA.""" + worker = Component(name="Worker") + sys_a = System(name="SystemA", components=[worker]) + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, "SystemA::Worker") + assert result is worker + + +def test_resolve_three_level_path() -> None: + """Resolving 'SystemA::Sub::Leaf' navigates two levels deep.""" + leaf = Component(name="Leaf") + sub = Component(name="Sub", components=[leaf]) + sys_a = System(name="SystemA", components=[sub]) + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, "SystemA::Sub::Leaf") + assert result is leaf + + +def test_resolve_nested_system_in_system() -> None: + """Resolving 'Outer::Inner' where Inner is a sub-system.""" + inner = System(name="Inner") + outer = System(name="Outer", systems=[inner]) + files = _make_files(Outer=outer) + result = resolve_entity(files, "Outer::Inner") + assert result is inner + + +def test_resolve_component_in_nested_system() -> None: + """Component inside a nested system is reachable via multi-segment path.""" + comp = Component(name="Worker") + inner = System(name="Inner", components=[comp]) + outer = System(name="Outer", systems=[inner]) + files = _make_files(Outer=outer) + result = resolve_entity(files, "Outer::Inner::Worker") + assert result is comp + + +# -------- whitespace tolerance -------- + + +def test_resolve_path_with_spaces_around_separator() -> None: + """Segments are stripped of surrounding whitespace.""" + worker = Component(name="Worker") + sys_a = System(name="SystemA", components=[worker]) + files = _make_files(SystemA=sys_a) + result = resolve_entity(files, " SystemA :: Worker ") + assert result is worker + + +# -------- error cases -------- + + +def test_resolve_unknown_top_level_raises() -> None: + """An unknown top-level name raises EntityNotFoundError.""" + files = _make_files(Foo=Component(name="Foo")) + with pytest.raises(EntityNotFoundError, match="Bar"): + resolve_entity(files, "Bar") + + +def test_resolve_unknown_child_raises() -> None: + """An unknown child segment raises EntityNotFoundError.""" + sys_a = System(name="SystemA", components=[Component(name="Worker")]) + files = _make_files(SystemA=sys_a) + with pytest.raises(EntityNotFoundError, match="Missing"): + resolve_entity(files, "SystemA::Missing") + + +def test_resolve_empty_path_raises() -> None: + """An empty path string raises EntityNotFoundError.""" + files: dict[str, ArchFile] = {} + with pytest.raises(EntityNotFoundError): + resolve_entity(files, "") + + +def test_resolve_empty_files_raises() -> None: + """Raises EntityNotFoundError when arch_files is empty.""" + with pytest.raises(EntityNotFoundError): + resolve_entity({}, "SystemA") diff --git a/uv.lock b/uv.lock index c1fb728..b43b58f 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "dash" }, + { name = "diagrams" }, { name = "pydantic" }, { name = "pyyaml" }, ] @@ -32,6 +33,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dash", specifier = ">=2.0" }, + { name = "diagrams", specifier = ">=0.25.1" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] @@ -63,6 +65,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -245,6 +256,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8c/dd63d210b28a7589f4bc1e84880525368147425c717d12834ab562f52d14/dash-4.0.0-py3-none-any.whl", hash = "sha256:e36b4b4eae9e1fa4136bf4f1450ed14ef76063bc5da0b10f8ab07bd57a7cb1ab", size = 7247521 }, ] +[[package]] +name = "diagrams" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "jinja2" }, + { name = "pre-commit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/91/af38c647ba20a7b74c23ebb0b56e000f5f8fe80fefb21251e1d0445ac5b5/diagrams-0.25.1.tar.gz", hash = "sha256:87480ba0b2d26987a69f7d553aa0cb1cebf1406adc03fef7031bdfff026bfc51", size = 33317533 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/60/83bb3b6c9ed994e2e6829c01261b64fdcbc0b7e239434be5c417a5e05bb2/diagrams-0.25.1-py3-none-any.whl", hash = "sha256:90698eaa74c027dfffd8306ba7f08ee543959205024050a6357f34eae9961345", size = 34537406 }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427 }, +] + [[package]] name = "flask" version = "3.1.3" @@ -262,6 +305,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424 }, ] +[[package]] +name = "graphviz" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126 }, +] + +[[package]] +name = "identify" +version = "2.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382 }, +] + [[package]] name = "idna" version = "3.11" @@ -403,6 +464,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438 }, +] + [[package]] name = "packaging" version = "26.0" @@ -412,6 +482,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, ] +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168 }, +] + [[package]] name = "plotly" version = "6.5.2" @@ -434,6 +513,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437 }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -563,6 +658,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, ] +[[package]] +name = "python-discovery" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687 }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -721,6 +829,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] +[[package]] +name = "virtualenv" +version = "21.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072 }, +] + [[package]] name = "werkzeug" version = "3.1.6"