diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ddcd7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Generated output and input data files live in data/ directories +data/ +**/data/ + +# Local environment files (contain secrets like Google Sheet IDs) +.env +**/.env + +# IDE files +.idea/ + +# Python caches and OS metadata +__pycache__/ +*.pyc +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..af84e55 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# Core-Components-Working-Group + +This repository contains tooling for the Translator platform's Core Components Working Group. + +## Subdirectories + +| Directory | Purpose | +|-----------|---------| +| `translator-components-diagram/` | Generates Graphviz dependency diagrams from the Translator components Google Sheet. See its own `CLAUDE.md` for full details. | + +## Workflow notes for Claude + +- After making code changes in `translator-components-diagram/`, do **not** run `uv run generate-diagram` — the user will run the script themselves. Only run `uv run pytest` to check for test failures. +- The active branch for diagram work is `add-translator-components-diagrams-code`; PRs target `main`. diff --git a/translator-components-diagram/CLAUDE.md b/translator-components-diagram/CLAUDE.md new file mode 100644 index 0000000..a80a3f5 --- /dev/null +++ b/translator-components-diagram/CLAUDE.md @@ -0,0 +1,118 @@ +# translator-components-diagram + +Generates Graphviz dependency diagrams for Translator platform components from a Google Sheet CSV. + +> **Note for Claude:** After making code changes, do not run `uv run generate-diagram` yourself — the user will run it. Only run `uv run pytest` to check for test failures. + +## Quick start + +```bash +# Download from Google Sheet and render (most common) +uv run generate-diagram --google-sheet + +# From a local CSV +uv run generate-diagram --input data/components.csv + +# Include all components (not just active refactor statuses) +uv run generate-diagram --google-sheet --all + +# Left-to-right layout, PDF output +uv run generate-diagram --google-sheet --direction LR --format pdf + +# Run tests +uv run pytest +``` + +## Script layout (`generate_diagram.py`) + +| Lines | What's there | +|-------|-------------| +| 17–36 | Global constants: `DEFAULT_STATUSES`, `FALLBACK_COLORS`, color constants for planned/ghost/external nodes | +| 39–61 | `ColorAssigner` — maps owners to fill colors, falls back to rotating palette | +| 63–69 | `text_color_for` — picks black/white text for contrast against a fill hex | +| 72–103 | `Component` dataclass — one CSV row after parsing | +| 106–154 | CSV parsing utilities: `_parse_bool`, `parse_id_list`, `parse_externals` | +| 157–213 | Data loading: `load_owner_colors`, `load_components`, `index_by_id` | +| 216–258 | `validate` — duplicate ID detection, unknown reference checking | +| 261–284 | `write_json` — serializes all components to `components.json` | +| 290–711 | Graph construction: `_compute_*`, `_add_*`, `build_graph` (see table below) | +| 714–891 | CLI: `@click.option` decorators + `main` | + +### Graph construction helpers (290–711) + +| Function | Lines | Purpose | +|----------|-------|---------| +| `_compute_active_set` | 290–296 | IDs to render based on refactor status filter | +| `_compute_ghost_ids` | 299–316 | IDs of excluded-but-referenced components (shown dimmed) | +| `_emit_component_node` | 319–341 | Renders one component node (used for primary nodes and ubiquitous clones) | +| `_compute_groups` | 343–355 | Groups nodes by `Part of` label | +| `_add_active_nodes` | 358–372 | Emits all non-grouped, non-ubiquitous active nodes | +| `_add_ghost_nodes` | 375–394 | Emits dimmed nodes for excluded-but-referenced components | +| `_add_group_clusters` | 397–440 | Wraps `Part of` groups in labeled dotted-border subgraphs | +| `_add_edges` | 443–500 | Emits all dependency edges (solid/dashed, implemented/planned) | +| `_ext_node_id` | 503–506 | Stable node ID from an external-entity name | +| `_add_external_nodes_and_edges` | 509–568 | Emits external source/sink nodes from the `Externals` column | +| `_owner_legend_html` | 571–593 | Builds HTML-table label for the owner-color legend | +| `_add_legend` | 596–658 | Assembles the full legend (owner swatches + edge style examples) | +| `build_graph` | 661–711 | Top-level assembler — calls all the above in order | + +## Data model + +CSV column → `Component` field: + +| CSV column | Field | Notes | +|-----------|-------|-------| +| `id` | `id` | Unique identifier; case-insensitive for references | +| `Name` | `name` | Display name; falls back to `id` if blank | +| `Owner` | `owner` | Defaults to `"None"` if blank | +| `Component in ITRB` | `itrb` | Informational only | +| `Refactor status` | `refactor_status` | Drives active-set filtering | +| `Gets results from` | `depends_on` / `depends_on_planned` | Comma-separated IDs; `~` prefix = planned | +| `Calls` | `uses` / `uses_planned` | Comma-separated IDs; `~` prefix = planned | +| `Notes` | `notes` | Informational only | +| `Ubiquitous` | `ubiquitous` | TRUE/yes/1 → render as per-caller clones | +| `Hide` | `hide` | TRUE/yes/1 → suppress entirely (not even as ghost) | +| `Part of` | `part_of` | Groups node into a named cluster subgraph | +| `Hosted at` | `hosted_at` | Deployment location; `ITRB` is default (no label shown); others get a third label line, e.g. `Hosted at: RENCI 🌐` | +| `Externals` | `externals` | `Sink` = data out | + +## Common change patterns + +**Change owner node colors** → edit `owner-colors.csv` (no code change). Row order = legend order. + +**Change ghost/external node colors** → constants `GHOST_FILL_COLOR`, `GHOST_BORDER_COLOR`, `GHOST_FONT_COLOR`, `EXTERNAL_FILL_COLOR` at lines 31–36. + +**Change planned-edge color** → `PLANNED_EDGE_COLOR` constant at line 30. + +**Change active refactor statuses** → `DEFAULT_STATUSES` list at line 17. + +**Change node label format** → `_emit_component_node` (line 319). Active node labels are `display_name\nid` plus an optional third line for non-ITRB hosts. Emoji mapping lives in `HOSTED_AT_EMOJI` at line ~37. + +**Change node shape or border style** → `_emit_component_node` (line 319) for active nodes; `_add_ghost_nodes` (line 375) for ghost nodes. The `is_new` bold border is set at line 339. + +**Change edge styles** (solid/dashed/color) → `_add_edges` (line 443). Each of the four dependency lists (`depends_on`, `depends_on_planned`, `uses`, `uses_planned`) has its own `dot.edge(...)` call (lines 483–500). + +**Change external node shapes** → `_add_external_nodes_and_edges` (line 509). Sources use `shape="cylinder"`, sinks use `shape="oval", peripheries="2"`. + +**Change graph layout settings** (dpi, ranksep, splines) → `build_graph` `graph_attr` dict at line 673. + +**Add a new CSV column** → three places: +1. `load_components` (line 175) — read from `row` +2. `Component` dataclass (line 72) — add the field +3. `write_json` (line 261) — add to the export dict + +**Add a new CLI flag** → add `@click.option` before `main` (line 714) and add the parameter to the `main` signature. + +**Change the legend** → `_add_legend` (line 596) for structure; `_owner_legend_html` (line 571) for the owner-color table HTML. + +## Special features + +**Ubiquitous components** (e.g. telemetry, logging): Set `Ubiquitous=TRUE` in the CSV. Instead of one central node, a per-caller clone is emitted inline next to each caller. No central node is created. Logic lives in `edge_target()` inside `_add_edges` (line 457). These components are excluded from `_add_active_nodes` and `_compute_ghost_ids`. + +**Ghost nodes**: When an active component references one that is filtered out (wrong refactor status), the excluded component appears dimmed with `(excluded)` in its label. Computed by `_compute_ghost_ids` (line 299). + +**Planned edges** (`~id` in `Gets results from` or `Calls`): Parsed as `depends_on_planned` / `uses_planned` by `parse_id_list` (line 111). Rendered in red in `_add_edges` (lines 488–500). Solid red for "Gets results from", dashed red for "Calls". + +**`--concentrate` flag**: Merges partially-parallel edges. Off by default because it can visually blend solid and dashed edges between nearby nodes. + +**Google Sheet download**: Checks `Content-Type: text/csv` to catch the case where a private/missing sheet returns an HTML login page instead of CSV (line 826). diff --git a/translator-components-diagram/README.md b/translator-components-diagram/README.md new file mode 100644 index 0000000..466379a --- /dev/null +++ b/translator-components-diagram/README.md @@ -0,0 +1,200 @@ +# Translator Components Diagram + +A Python CLI tool that reads a spreadsheet of Translator platform components, +validates their dependency declarations, and produces Graphviz diagrams showing +how data flows through the system and which services call each other. + +## Purpose + +The Translator platform comprises many components maintained by different teams. +This tool makes the overall architecture visible by turning a human-maintained +Google Sheet into a shareable diagram. The default view filters to components +that are active in the current refactor ("Continues into Refactor" and +"New in Refactor"), so the diagram stays focused on what is currently relevant. + +## Quick start + +Requires Python ≥ 3.11 and [uv](https://docs.astral.sh/uv/). +The [Graphviz](https://graphviz.org/) system package must also be installed +(`brew install graphviz` on macOS). + +```bash +cd translator-components-diagram +uv sync # first-time setup; creates .venv/ + +# Download latest data from the Google Sheet and regenerate +uv run generate-diagram --google-sheet + +# Use a locally cached CSV instead +uv run generate-diagram + +# Include all components, not just the refactor-active ones +uv run generate-diagram --all + +# Also produce a PDF (useful for presentations) +uv run generate-diagram --google-sheet --format pdf +``` + +## Input data + +### Google Sheet + +The canonical source of truth is a world-readable Google Sheet. Its ID is +stored in `.env` (gitignored; never committed): + +``` +# translator-components-diagram/.env +GOOGLE_SHEET_ID= +``` + +Run with `--google-sheet` to download the latest CSV export into `data/` and +use it immediately. The downloaded file is also gitignored. + +### CSV format + +The sheet must have these columns (order does not matter): + +| Column | Description | +|---|---| +| `id` | Unique machine-readable identifier (kebab-case preferred) | +| `Name` | Human-readable display name shown in the diagram | +| `Owner` | Team that owns the component; controls node colour | +| `Component in ITRB` | ITRB category (informational only) | +| `Refactor status` | Lifecycle status — see filtering below | +| `Gets results from` | Comma-separated IDs this component receives data from | +| `Calls` | Comma-separated IDs this component makes optional API calls to | +| `Ubiquitous` | `TRUE` to render this component as a per-caller clone (see below) | +| `Notes` | Free-text notes (not used by the tool) | + +#### Planned (not-yet-implemented) relationships + +Prefix any ID in `Gets results from` or `Calls` with `~` to mark it as +planned but not yet implemented: + +``` +Gets results from: nodenorm-es, ~new-service +Calls: ars, ~future-api +``` + +Planned edges render in gray; implemented edges render in black. + +#### Ubiquitous components + +Cross-cutting infrastructure that nearly every component depends on +(telemetry, name resolution, logging…) creates long converging edges in +the diagram that obscure the real data-flow structure. Marking such a +component `TRUE` in the `Ubiquitous` column renders it as a small copy +next to each caller instead of as a single central node — the underlying +data stays normalised, only the visual layout duplicates. Jaeger (OTel) +is the canonical example. + +## Output files + +All outputs go to `data/` (gitignored) by default. + +| File | Always? | Description | +|---|---|---| +| `data/diagram.png` | yes | Main shareable diagram | +| `data/diagram.dot` | yes | Graphviz source — useful for debugging or tweaking | +| `data/components.json` | yes | All components parsed (all statuses, not filtered) | +| `data/diagram.pdf` | `--format pdf` | Vector format for presentations | +| `data/diagram.svg` | `--format svg` | Vector format for web embedding | + +> The `.dot` and `.json` files are intended to eventually be committed to the +> repo so people can inspect the data without running the tool. + +## Diagram conventions + +### Node colours (by Owner) + +Owner-to-colour mappings live in [`owner-colors.csv`](owner-colors.csv) +(two columns: `owner`, `color`). Edit that file to add a new owner, +re-order the legend, or change a colour — no Python edit required. + +New owners not listed there receive fallback colours automatically. + +### Node border weight + +- **Bold border** — component is "New in Refactor" +- **Normal border** — component "Continues into Refactor" + +### Edge types + +| Style | Meaning | +|---|---| +| Solid black arrow B → A | B provides results to A ("Gets results from") | +| Indigo dashed arrow B → A | Same, but planned / not yet implemented | +| Dotted black arrow A → B | A makes an optional API call to B ("Calls") | +| Indigo dotted arrow A → B | Same, but planned / not yet implemented | + +Planned-edge indigo is distinct from the gray used for ghost-node borders, +so the two encodings don't blur together visually. + +### Special nodes + +- **External data sources** (cylinder, gray) — entry point; represents all + upstream data stores that feed into `kgx-storage-pipeline` +- **User** (double-border oval, gray) — exit point; the human end-consumer + who receives results from the UI + +### Ghost nodes + +Components that are referenced by an active component but are themselves +outside the current filter (e.g. "Removed after Refactor") appear as gray +dashed boxes labelled `(excluded)`. This keeps cross-boundary edges visible +without cluttering the main diagram. + +## All CLI options + +``` +uv run generate-diagram [OPTIONS] + + --input PATH Local CSV file [default: data/components.csv] + --google-sheet Download CSV from Google Sheet (reads GOOGLE_SHEET_ID + from .env) instead of using --input + --sheet-gid INTEGER Google Sheet tab GID (0 = first tab) [default: 0] + --output-dir PATH Directory for output files [default: data] + --output-name TEXT Base filename for outputs [default: diagram] + --refactor-status TEXT Comma-separated Refactor status values to include + [default: "Continues into Refactor,New in Refactor"] + --all Include all components regardless of Refactor status + --format [pdf|svg] Additional output format beyond PNG (PNG is + always produced; can be repeated) + --direction [LR|TB] Graphviz layout direction [default: TB] + --help Show this message and exit. +``` + +## Repository layout + +``` +translator-components-diagram/ +├── generate_diagram.py # The tool +├── owner-colors.csv # Owner → fill colour mapping (edit me) +├── tests/ # pytest suite for the pure functions +├── pyproject.toml # uv/hatchling project metadata and dependencies +├── uv.lock # Pinned dependency versions +├── .env # GOOGLE_SHEET_ID — gitignored, fill in locally +├── README.md # This file +└── data/ # Gitignored — all inputs and outputs go here + ├── components.csv # Downloaded from Google Sheet + ├── components.json # Parsed component data (all statuses) + ├── diagram.dot # Graphviz source + └── diagram.png # Rendered diagram +``` + +## Possible future improvements + +- **Commit `.dot` and `.json` to Git** — move these outputs outside `data/` so + they are version-controlled and reviewable without running the tool. +- **Interactive SVG or HTML output** — embed tooltips (owner, notes, status) + using Graphviz's `tooltip` attribute or a post-processing step with a library + like `d3-graphviz`. +- **Grouping / filtering by ITRB category** — the `Component in ITRB` column + is loaded but not currently used; it could drive an alternative colour scheme + or a `--group-by itrb` flag. +- **Cycle detection** — the validator checks for unknown IDs but does not yet + detect dependency cycles, which would be a useful integrity check. +- **Multiple sheet tabs** — `--sheet-gid` already supports non-default tabs; + a `--all-tabs` mode could merge or overlay multiple views. +- **Diff mode** — compare two runs of the tool (e.g. before and after a sprint) + and highlight added, removed, or changed components and edges. diff --git a/translator-components-diagram/env.default b/translator-components-diagram/env.default new file mode 100644 index 0000000..1f21e16 --- /dev/null +++ b/translator-components-diagram/env.default @@ -0,0 +1,2 @@ +# The Google Sheet ID to download the component information from +GOOGLE_SHEET_ID= diff --git a/translator-components-diagram/generate_diagram.py b/translator-components-diagram/generate_diagram.py new file mode 100644 index 0000000..929b4e6 --- /dev/null +++ b/translator-components-diagram/generate_diagram.py @@ -0,0 +1,1174 @@ +"""Generate dependency diagrams for Translator platform components.""" + +import csv +import html +import json +import os +import re +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path + +import click +import graphviz +from dotenv import load_dotenv + +# Refactor status values that indicate active components +DEFAULT_STATUSES = ["Continues into Refactor", "New in Refactor"] + +# Owner → fill color mapping lives in owner-colors.csv (alongside the script) +# so non-Python edits can change it without touching code. Row order in the +# CSV doubles as legend order in the diagram. +DEFAULT_OWNER_COLORS_PATH = Path(__file__).parent / "owner-colors.csv" + +FALLBACK_COLORS = [ + "#B0BEC5", "#BCAAA4", "#CE93D8", "#80CBC4", + "#EF9A9A", "#FFCC80", "#C5E1A5", "#80DEEA", +] +# Soft indigo for planned edges — distinct from the ghost-node gray +# (#999999) so planned edges don't visually blur with excluded-node borders. +PLANNED_EDGE_COLOR = "#7986CB" +GHOST_BORDER_COLOR = "#999999" +GHOST_FILL_COLOR = "#D3D3D3" +GHOST_FONT_COLOR = "#666666" +# Warm amber for external-entity nodes (sources and sinks) so they stand out +# clearly against the component fill colors. +EXTERNAL_FILL_COLOR = "#FFE082" +# Emoji labels for non-default hosting locations (ITRB is the default and shown as nothing). +HOSTED_AT_EMOJI: dict[str, str] = {"RENCI": "🌐", "Scripps": "🌐", "Local": "💻", "Unknown": "❓"} +# Bold border penwidth for in-layer nodes in per-layer sub-figures. +IN_LAYER_PENWIDTH = "4.0" + + +class ColorAssigner: + """Assigns fill colors to owners, falling back to a rotating palette.""" + + def __init__(self, base_colors: dict[str, str], fallback_colors: list[str]): + self.color_map: dict[str, str] = dict(base_colors) + self.fallback_colors = fallback_colors + self.next_fallback = 0 + self._used: set[str] = set() + + def get(self, owner: str) -> str: + if owner not in self.color_map: + self.color_map[owner] = self.fallback_colors[ + self.next_fallback % len(self.fallback_colors) + ] + self.next_fallback += 1 + self._used.add(owner) + return self.color_map[owner] + + @property + def used_colors(self) -> dict[str, str]: + """Color map restricted to owners actually rendered, in original order.""" + return {k: v for k, v in self.color_map.items() if k in self._used} + + +def text_color_for(fill_hex: str) -> str: + """Return "black" or "white" for adequate contrast against a hex fill.""" + h = fill_hex.lstrip("#") + r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)) + # Rec. 709 perceptual luminance + luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return "black" if luminance > 0.5 else "white" + + +@dataclass +class Component: + """A single row of the components CSV after parsing.""" + + id: str + name: str + owner: str + itrb: str + refactor_status: str + notes: str + ubiquitous: bool = False + hide: bool = False + part_of: str = "" + hosted_at: str = "" + layer: str = "" + externals: list[tuple[str, str]] = field(default_factory=list) + depends_on: list[str] = field(default_factory=list) + depends_on_planned: list[str] = field(default_factory=list) + uses: list[str] = field(default_factory=list) + uses_planned: list[str] = field(default_factory=list) + + @property + def display_name(self) -> str: + # Fall back to id when Name is missing — otherwise the label + # starts with a blank line. + return self.name or self.id + + def all_refs(self) -> list[str]: + return ( + self.depends_on + + self.depends_on_planned + + self.uses + + self.uses_planned + ) + + +def _parse_bool(value: str) -> bool: + """Parse a CSV boolean cell — accepts TRUE/yes/1 (case-insensitive).""" + return value.strip().lower() in ("true", "yes", "y", "1") + + +def parse_id_list(field_value: str) -> tuple[list[str], list[str]]: + """Split a comma-separated field into (implemented_ids, planned_ids). + + IDs prefixed with '~' are planned-but-not-yet-implemented. + """ + implemented, planned = [], [] + for part in field_value.split(","): + part = part.strip() + if not part: + continue + if part.startswith("~"): + planned.append(part[1:].strip()) + else: + implemented.append(part) + return implemented, planned + + +def parse_externals(field_value: str) -> list[tuple[str, str]]: + """Parse the Externals column into a list of (direction, name) pairs. + + Values are standard CSV (commas as separators, double-quotes for names + that contain commas). Each token must start with '<' (external source + that sends data *into* this component) or '>' (external sink that + receives data *from* this component). + + Examples + -------- + ``User`` + ``"Researcher`` + """ + if not field_value.strip(): + return [] + result = [] + reader = csv.reader([field_value]) + for row in reader: + for token in row: + token = token.strip() + if not token: + continue + if token.startswith("<"): + result.append(("in", token[1:].strip())) + elif token.startswith(">"): + result.append(("out", token[1:].strip())) + return result + + +def load_owner_colors(path: Path = DEFAULT_OWNER_COLORS_PATH) -> dict[str, str]: + """Load the owner→color mapping from a CSV with columns owner,color. + + Order is preserved from the file; that order also determines legend order. + """ + if not path.exists(): + raise click.ClickException(f"Owner-colors file not found: {path}") + with path.open(newline="", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + missing_cols = {"owner", "color"} - set(reader.fieldnames or []) + if missing_cols: + raise click.ClickException( + f"{path} is missing required columns: " + + ", ".join(sorted(missing_cols)) + ) + return {row["owner"].strip(): row["color"].strip() for row in reader} + + +def load_components(csv_path: Path, layer_column: str = "") -> list[Component]: + """Parse the CSV into a sorted list of Components. + + Sorted by lowercase id for deterministic .dot / .json output across CSV + row reorderings. + """ + # utf-8-sig strips a UTF-8 BOM if present (Excel-resaved or Windows-edited + # files), otherwise the first header would read as "id" and KeyError. + with csv_path.open(newline="", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + rows: list[Component] = [] + for row in reader: + depends_on, depends_on_planned = parse_id_list( + row.get("Gets results from", "") + ) + uses, uses_planned = parse_id_list(row.get("Calls", "")) + rows.append(Component( + id=row.get("id", "").strip(), + name=row.get("Name", "").strip(), + owner=(row.get("Owner") or "None").strip() or "None", + itrb=row.get("Component in ITRB", "").strip(), + refactor_status=row.get("Refactor status", "").strip(), + notes=row.get("Notes", "").strip(), + ubiquitous=_parse_bool(row.get("Ubiquitous", "")), + hide=_parse_bool(row.get("Hide", "")), + part_of=row.get("Part of", "").strip(), + hosted_at=row.get("Hosted at", "").strip(), + layer=row.get(layer_column, "").strip() if layer_column else "", + externals=parse_externals(row.get("Externals", "")), + depends_on=depends_on, + depends_on_planned=depends_on_planned, + uses=uses, + uses_planned=uses_planned, + )) + rows.sort(key=lambda c: c.id.lower()) + return rows + + +def index_by_id(components: list[Component]) -> dict[str, Component]: + """Case-insensitive lookup from lower(id) to Component.""" + return {c.id.lower(): c for c in components} + + +def validate(components: list[Component]) -> bool: + """Print messages for any reference issues. + + Returns False on hard errors (duplicate ids, unknown referenced ids). + Case-mismatch references are informational and do not flip the return + value, because the case-insensitive lookup in build_graph still resolves + them to the canonical component. + """ + ok = True + + # Hard error: duplicate ids (case-insensitive). The index below would + # silently keep only the last duplicate, so detect them up front. + seen: dict[str, str] = {} + for comp in components: + key = comp.id.lower() + if key in seen: + click.echo( + f"ERROR: duplicate id (case-insensitive): " + f"'{seen[key]}' and '{comp.id}'", + err=True, + ) + ok = False + else: + seen[key] = comp.id + + index = index_by_id(components) + for comp in components: + for ref in comp.all_refs(): + match = index.get(ref.lower()) + if match is None: + click.echo( + f"ERROR: '{comp.id}' references unknown id '{ref}' " + f"in Gets results from/Calls", + err=True, + ) + ok = False + elif match.id != ref: + click.echo( + f"WARNING: '{comp.id}' references '{ref}' but the actual id " + f"is '{match.id}' (case mismatch)", + err=True, + ) + return ok + + +def write_json(components: list[Component], out_path: Path) -> None: + """Serialise components to JSON, preserving the original CSV column names.""" + exportable = [ + { + "id": c.id, + "Name": c.name, + "Owner": c.owner, + "Component in ITRB": c.itrb, + "Refactor status": c.refactor_status, + "Notes": c.notes, + "Ubiquitous": c.ubiquitous, + "Hide": c.hide, + "Part of": c.part_of, + "Hosted at": c.hosted_at, + "Layer": c.layer, + "Externals": [{"direction": d, "name": n} for d, n in c.externals], + "depends_on": c.depends_on, + "depends_on_planned": c.depends_on_planned, + "uses": c.uses, + "uses_planned": c.uses_planned, + } + for c in components + ] + with out_path.open("w", encoding="utf-8") as f: + json.dump(exportable, f, indent=2, ensure_ascii=False) + click.echo(f"Wrote {out_path}") + + +# --- Graph construction helpers -------------------------------------------- + + +def _compute_active_set( + components: list[Component], + active_statuses: set[str] | None, +) -> set[str]: + if active_statuses is None: + return {c.id for c in components if not c.hide} + return {c.id for c in components if c.refactor_status in active_statuses and not c.hide} + + +def _compute_ghost_ids( + components: list[Component], + index: dict[str, Component], + active_set: set[str], +) -> set[str]: + ghost: set[str] = set() + for comp in components: + if comp.id not in active_set or comp.ubiquitous: + continue + for ref in comp.all_refs(): + match = index.get(ref.lower()) + if match is None or match.ubiquitous or match.hide: + # Ubiquitous targets render as per-caller clones, never as ghosts. + # Hidden components are suppressed entirely — not even as ghosts. + continue + if match.id not in active_set: + ghost.add(match.id) + return ghost + + +def _emit_component_node( + dot: graphviz.Digraph, + comp: Component, + node_id: str, + colors: ColorAssigner, + penwidth: str | None = None, +) -> None: + """Render a Component as a graphviz node at the given id. + + Used both for primary node placement and for per-caller ubiquitous clones + (which use a synthetic id like "{caller}__{target}"). + penwidth overrides the default (2.0 for "New in Refactor", 1.0 otherwise). + """ + fill = colors.get(comp.owner) + is_new = comp.refactor_status == "New in Refactor" + # Owner is encoded by node color and shown in the legend, not in the label. + label = f"{comp.display_name}\n{comp.id}" + if comp.hosted_at and comp.hosted_at != "ITRB": + emoji = HOSTED_AT_EMOJI.get(comp.hosted_at, "") + suffix = f" {emoji}" if emoji else "" + label += f"\nHosted at: {comp.hosted_at}{suffix}" + dot.node( + node_id, + label=label, + fillcolor=fill, + fontcolor=text_color_for(fill), + penwidth=penwidth if penwidth is not None else ("2.0" if is_new else "1.0"), + ) + + +def _compute_groups( + components: list[Component], + active_set: set[str], + ghost_ids: set[str], +) -> dict[str, list[str]]: + """Map Part-of label → node ids for active (non-ubiquitous) and ghost nodes.""" + groups: dict[str, list[str]] = {} + for comp in components: + if not comp.part_of or comp.ubiquitous: + continue + if comp.id in active_set or comp.id in ghost_ids: + groups.setdefault(comp.part_of, []).append(comp.id) + return groups + + +def _add_active_nodes( + dot: graphviz.Digraph, + components: list[Component], + active_set: set[str], + colors: ColorAssigner, + skip_ids: set[str] | None = None, +) -> None: + for comp in components: + if comp.id not in active_set or comp.ubiquitous: + # Ubiquitous components don't get a central node — they're emitted + # per-caller from _add_edges. + continue + if skip_ids and comp.id in skip_ids: + continue + _emit_component_node(dot, comp, comp.id, colors) + + +def _add_ghost_nodes( + dot: graphviz.Digraph, + ghost_ids: set[str], + index: dict[str, Component], + skip_ids: set[str] | None = None, +) -> None: + for ghost_id in sorted(ghost_ids): + if skip_ids and ghost_id in skip_ids: + continue + comp = index.get(ghost_id.lower()) + name = comp.display_name if comp else ghost_id + label = f"{name}\n{ghost_id}\n(excluded)" + dot.node( + ghost_id, + label=label, + fillcolor=GHOST_FILL_COLOR, + style="filled,rounded,dashed", + fontcolor=GHOST_FONT_COLOR, + color=GHOST_BORDER_COLOR, + ) + + +def _add_group_clusters( + dot: graphviz.Digraph, + groups: dict[str, list[str]], + components: list[Component], + active_set: set[str], + ghost_ids: set[str], + index: dict[str, Component], + colors: ColorAssigner, +) -> None: + """Wrap each Part-of group in a labeled dotted-border cluster subgraph.""" + for group_label, node_ids in sorted(groups.items()): + safe = group_label.lower().replace(" ", "_").replace("/", "_") + with dot.subgraph(name=f"cluster_group_{safe}") as sg: + tab_label = ( + f'<' + f'
' + f'{html.escape(group_label)}' + f'
>' + ) + sg.attr( + label=tab_label, + labelloc="t", + style="filled", + fillcolor="#DDDDDD", + color="#555555", + fontname="Helvetica", + penwidth="1.5", + bgcolor="transparent", + ) + for node_id in sorted(node_ids): + comp = index.get(node_id.lower()) + if comp and node_id in active_set: + _emit_component_node(sg, comp, node_id, colors) + elif node_id in ghost_ids: + name = comp.display_name if comp else node_id + label = f"{name}\n{node_id}\n(excluded)" + sg.node( + node_id, + label=label, + fillcolor=GHOST_FILL_COLOR, + style="filled,rounded,dashed", + fontcolor=GHOST_FONT_COLOR, + color=GHOST_BORDER_COLOR, + ) + + +def _add_edges( + dot: graphviz.Digraph, + components: list[Component], + index: dict[str, Component], + active_set: set[str], + ghost_ids: set[str], + colors: ColorAssigner, +) -> None: + emitted_clones: set[str] = set() + # Track (src, dst) pairs that already have a solid edge so that a + # dashed edge between the same two nodes — which concentrate=true + # would merge, losing the solid style — is suppressed in favour of solid. + solid_edges: set[tuple[str, str]] = set() + + def edge_target(caller_id: str, ref: str) -> str | None: + """Return the graphviz node id to draw an edge to, or None to skip. + + For ubiquitous targets, emit (idempotently) a per-caller clone node and + return its synthetic id. The clone uses the same visual style as the + original so callers can recognise it. + """ + match = index.get(ref.lower()) + if match is None: + return None + if match.hide: + return None + if match.ubiquitous: + clone_id = f"{caller_id}__{match.id}" + if clone_id not in emitted_clones: + _emit_component_node(dot, match, clone_id, colors) + emitted_clones.add(clone_id) + return clone_id + if match.id in active_set or match.id in ghost_ids: + return match.id + return None + + for comp in components: + if comp.id not in active_set or comp.ubiquitous: + continue + for ref in comp.depends_on: + t = edge_target(comp.id, ref) + if t is not None: + dot.edge(t, comp.id) # B → A: B provides results to A + solid_edges.add((t, comp.id)) + for ref in comp.depends_on_planned: + t = edge_target(comp.id, ref) + if t is not None and (t, comp.id) not in solid_edges: + # Planned/in-development "Gets results from" — solid red to stand out + dot.edge(t, comp.id, style="solid", color="red") + for ref in comp.uses: + t = edge_target(comp.id, ref) + if t is not None and (comp.id, t) not in solid_edges: + dot.edge(comp.id, t, style="dashed") # A --→ B: API call + for ref in comp.uses_planned: + t = edge_target(comp.id, ref) + if t is not None and (comp.id, t) not in solid_edges: + # Planned/in-development "Calls" — dashed red to stand out + dot.edge(comp.id, t, style="dashed", color="red") + + +def _ext_node_id(name: str) -> str: + """Stable graphviz node ID derived from an external-entity name.""" + safe = "".join(c if c.isalnum() else "_" for c in name.lower()) + return f"_ext_{safe}" + + +def _add_external_nodes_and_edges( + dot: graphviz.Digraph, + components: list[Component], + active_set: set[str], +) -> None: + """Emit external-entity nodes and their edges from the Externals column. + + Sources (direction "in") become cylinder nodes at rank=min; sinks + (direction "out") become double-oval nodes at rank=max. Multiple + components can reference the same external name — one node is emitted + and one edge per referencing component is drawn. + """ + in_nodes: dict[str, str] = {} # node_id → display name + out_nodes: dict[str, str] = {} # node_id → display name + in_edges: list[tuple[str, str]] = [] # (ext_id, comp_id) + out_edges: list[tuple[str, str]] = [] # (comp_id, ext_id) + + for comp in components: + if comp.id not in active_set or comp.ubiquitous: + continue + for direction, name in comp.externals: + nid = _ext_node_id(name) + if direction == "in": + in_nodes[nid] = name + in_edges.append((nid, comp.id)) + else: + out_nodes[nid] = name + out_edges.append((comp.id, nid)) + + if not in_nodes and not out_nodes: + return + + ext_attrs = dict( + style="filled", + fillcolor=EXTERNAL_FILL_COLOR, + fontname="Helvetica", + fontsize="13", + penwidth="2.5", + ) + + for nid, name in in_nodes.items(): + dot.node(nid, label=name, shape="cylinder", **ext_attrs) + for nid, name in out_nodes.items(): + dot.node(nid, label=name, shape="oval", peripheries="2", **ext_attrs) + + if in_nodes: + with dot.subgraph() as s: + s.attr(rank="min") + for nid in in_nodes: + s.node(nid) + if out_nodes: + with dot.subgraph() as s: + s.attr(rank="max") + for nid in out_nodes: + s.node(nid) + + for src, dst in in_edges: + dot.edge(src, dst) + for src, dst in out_edges: + dot.edge(src, dst) + + +_LEGEND_CLUSTER_ATTRS = dict( + style="filled,rounded", + fillcolor="#FAFAFA", + color="#AAAAAA", + fontname="Helvetica", + fontsize="11", + margin="12", +) + + +def _owner_legend_html(colors: ColorAssigner) -> str: + """Build an HTML-table label listing every owner and its fill color. + + Two-column layout: a colored swatch on the left, the owner name on a + neutral background on the right. This keeps text contrast uniform + regardless of how dark the swatch is. + """ + rows = [] + for owner, fill in colors.used_colors.items(): + rows.append( + f'' + f' ' + f'{html.escape(owner)}' + f'' + ) + table = ( + '' + + "".join(rows) + + '
' + ) + # Python graphviz treats labels starting with '<' as HTML-like — the + # outer angle brackets are the marker, inner is the table. + return f"<{table}>" + + +def _add_owner_cluster(dot: graphviz.Digraph, colors: ColorAssigner) -> None: + """Add the owner-color key cluster to dot.""" + with dot.subgraph(name="cluster_legend_owners") as own: + own.attr(label="Owner", **_LEGEND_CLUSTER_ATTRS) + own.node("_leg_owners", label=_owner_legend_html(colors), shape="plain") + + +def _add_edge_cluster(dot: graphviz.Digraph) -> None: + """Add the edge-style example cluster to dot.""" + with dot.subgraph(name="cluster_legend") as leg: + leg.attr(label="Legend", **_LEGEND_CLUSTER_ATTRS) + + leg.node("_leg_p", label="Producer", fillcolor="white", penwidth="1.0") + leg.node("_leg_c", label="Consumer", fillcolor="white", penwidth="1.0") + leg.edge("_leg_p", "_leg_c", xlabel="Results", minlen="5") + + leg.node("_leg_a", label="Component", fillcolor="white", penwidth="1.0") + leg.node("_leg_b", label="Service", fillcolor="white", penwidth="1.0") + leg.edge("_leg_a", "_leg_b", xlabel="API call", style="dashed", minlen="5") + + _ext = dict( + fillcolor=EXTERNAL_FILL_COLOR, style="filled", + fontname="Helvetica", fontsize="13", penwidth="2.5", + ) + leg.node("_leg_src", label="Database", shape="cylinder", **_ext) + leg.node("_leg_sink", label="User/agent", shape="oval", + peripheries="2", **_ext) + + with leg.subgraph() as row1: + row1.attr(rank="same") + row1.node("_leg_p") + row1.node("_leg_c") + with leg.subgraph() as row2: + row2.attr(rank="same") + row2.node("_leg_a") + row2.node("_leg_b") + with leg.subgraph() as row3: + row3.attr(rank="same") + row3.node("_leg_src") + row3.node("_leg_sink") + leg.edge("_leg_p", "_leg_a", style="invis") + leg.edge("_leg_a", "_leg_src", style="invis") + + +def _add_legend(dot: graphviz.Digraph, colors: ColorAssigner) -> None: + """Add both legend clusters to dot (for the combined main diagram).""" + _add_owner_cluster(dot, colors) + _add_edge_cluster(dot) + + # Pin the owner legend to the bottom; the edge-style legend floats freely + # (its internal rank="same" rows keep it compact wherever it lands). + with dot.subgraph() as s: + s.attr(rank="max") + s.node("_leg_owners") + + +def _build_owners_graph(colors: ColorAssigner) -> graphviz.Digraph: + """Standalone diagram containing only the owner-color legend.""" + dot = graphviz.Digraph( + name="owners_legend", + graph_attr={"fontname": "Helvetica", "fontsize": "11", "dpi": "150"}, + ) + _add_owner_cluster(dot, colors) + return dot + + +def _build_edge_legend_graph() -> graphviz.Digraph: + """Standalone diagram containing only the edge-style legend.""" + dot = graphviz.Digraph( + name="edge_legend", + graph_attr={ + "fontname": "Helvetica", "fontsize": "11", "dpi": "150", + "rankdir": "TB", "newrank": "true", + }, + node_attr={ + "fontname": "Helvetica", "fontsize": "11", + "style": "filled,rounded", "shape": "box", + }, + edge_attr={"fontname": "Helvetica", "fontsize": "9"}, + ) + _add_edge_cluster(dot) + return dot + + + +def build_graph( + components: list[Component], + active_statuses: set[str] | None, + direction: str, + colors: ColorAssigner, + concentrate: bool = False, + include_legend: bool = True, +) -> graphviz.Digraph: + """Assemble the full graph from the parsed component list.""" + index = index_by_id(components) + active_set = _compute_active_set(components, active_statuses) + ghost_ids = _compute_ghost_ids(components, index, active_set) + + dot = graphviz.Digraph( + name="translator_components", + graph_attr={ + "rankdir": direction, + "fontname": "Helvetica", + "fontsize": "12", + # splines=true gives graphviz freedom to route edges as smooth + # curves around nodes; concentrate merges partially-parallel edges + # to pack the layout tighter (disable if mixed solid/dashed edges + # render incorrectly merged). + "splines": "true", + "concentrate": "true" if concentrate else "false", + "nodesep": "0.3", + "ranksep": "0.5", + # Required for rank=same to work correctly across cluster + # boundaries (e.g. keeping both legend clusters level). + "newrank": "true", + "dpi": "150", + }, + node_attr={ + "fontname": "Helvetica", + "fontsize": "11", + "style": "filled,rounded", + "shape": "box", + }, + edge_attr={"fontname": "Helvetica", "fontsize": "9"}, + ) + + groups = _compute_groups(components, active_set, ghost_ids) + grouped_ids = {nid for ids in groups.values() for nid in ids} + + _add_group_clusters(dot, groups, components, active_set, ghost_ids, index, colors) + _add_active_nodes(dot, components, active_set, colors, skip_ids=grouped_ids) + _add_ghost_nodes(dot, ghost_ids, index, skip_ids=grouped_ids) + _add_edges(dot, components, index, active_set, ghost_ids, colors) + _add_external_nodes_and_edges(dot, components, active_set) + if include_legend: + _add_legend(dot, colors) + + return dot + + +def _layer_filename(layer: str) -> str: + """Convert a layer label to a safe filename stem.""" + safe = re.sub(r"[^\w\s-]", "", layer.lower()) + safe = re.sub(r"[\s-]+", "_", safe).strip("_") + return safe or "layer" + + +def build_layer_subgraph( + components: list[Component], + layer_value: str, + active_set: set[str], + index: dict[str, Component], + direction: str, + colors: ColorAssigner, +) -> graphviz.Digraph: + """Build a legend-free sub-diagram showing one layer and its direct neighbors.""" + in_layer = { + c.id for c in components + if c.id in active_set and c.layer == layer_value and not c.ubiquitous and not c.hide + } + + # Direct neighbors (both directions) that are outside this layer + out_of_layer: set[str] = set() + for comp in components: + if comp.id not in in_layer: + continue + for ref in comp.all_refs(): + match = index.get(ref.lower()) + if ( + match + and match.id not in in_layer + and match.id in active_set + and not match.ubiquitous + and not match.hide + ): + out_of_layer.add(match.id) + for comp in components: + if comp.ubiquitous or comp.hide or comp.id in in_layer or comp.id not in active_set: + continue + for ref in comp.all_refs(): + match = index.get(ref.lower()) + if match and match.id in in_layer: + out_of_layer.add(comp.id) + break + + visible = in_layer | out_of_layer + + dot = graphviz.Digraph( + name=f"layer_{_layer_filename(layer_value)}", + graph_attr={ + "rankdir": direction, + "fontname": "Helvetica", + "fontsize": "12", + "splines": "true", + "concentrate": "false", + "nodesep": "0.3", + "ranksep": "0.5", + "newrank": "true", + "dpi": "150", + }, + node_attr={ + "fontname": "Helvetica", + "fontsize": "11", + "style": "filled,rounded", + "shape": "box", + }, + edge_attr={"fontname": "Helvetica", "fontsize": "9"}, + ) + + # Clusters for in-layer nodes that have a Part-of group + groups: dict[str, list[str]] = {} + for comp in components: + if not comp.part_of or comp.ubiquitous or comp.id not in in_layer: + continue + groups.setdefault(comp.part_of, []).append(comp.id) + grouped_in_layer = {nid for ids in groups.values() for nid in ids} + + for group_label, node_ids in sorted(groups.items()): + safe = group_label.lower().replace(" ", "_").replace("/", "_") + with dot.subgraph(name=f"cluster_group_{safe}") as sg: + tab_label = ( + f'<' + f"
" + f'{html.escape(group_label)}' + f"
>" + ) + sg.attr( + label=tab_label, + labelloc="t", + style="filled", + fillcolor="#DDDDDD", + color="#555555", + fontname="Helvetica", + penwidth="1.5", + bgcolor="transparent", + ) + for node_id in sorted(node_ids): + comp = index.get(node_id.lower()) + if comp: + _emit_component_node(sg, comp, node_id, colors, penwidth=IN_LAYER_PENWIDTH) + + # Ungrouped in-layer nodes + for comp in components: + if comp.id not in in_layer or comp.ubiquitous or comp.id in grouped_in_layer: + continue + _emit_component_node(dot, comp, comp.id, colors, penwidth=IN_LAYER_PENWIDTH) + + # Out-of-layer neighbors — full owner colors, default border weight + for ool_id in sorted(out_of_layer): + comp = index.get(ool_id.lower()) + if comp: + _emit_component_node(dot, comp, ool_id, colors) + + # Edges — only those with at least one in-layer endpoint + emitted_clones: set[str] = set() + solid_edges: set[tuple[str, str]] = set() + + def _sub_target(caller_id: str, ref: str) -> str | None: + match = index.get(ref.lower()) + if match is None or match.hide: + return None + if match.ubiquitous: + if caller_id in in_layer: + clone_id = f"{caller_id}__{match.id}" + if clone_id not in emitted_clones: + _emit_component_node(dot, match, clone_id, colors) + emitted_clones.add(clone_id) + return clone_id + return None + return match.id if match.id in visible else None + + for comp in components: + if comp.id not in visible or comp.ubiquitous: + continue + for ref in comp.depends_on: + t = _sub_target(comp.id, ref) + if t is not None and (t in in_layer or comp.id in in_layer): + dot.edge(t, comp.id) + solid_edges.add((t, comp.id)) + for ref in comp.depends_on_planned: + t = _sub_target(comp.id, ref) + if t is not None and (t in in_layer or comp.id in in_layer) and (t, comp.id) not in solid_edges: + dot.edge(t, comp.id, style="solid", color="red") + for ref in comp.uses: + t = _sub_target(comp.id, ref) + if t is not None and (comp.id in in_layer or t in in_layer) and (comp.id, t) not in solid_edges: + dot.edge(comp.id, t, style="dashed") + solid_edges.add((comp.id, t)) + for ref in comp.uses_planned: + t = _sub_target(comp.id, ref) + if t is not None and (comp.id in in_layer or t in in_layer) and (comp.id, t) not in solid_edges: + dot.edge(comp.id, t, style="dashed", color="red") + + _add_external_nodes_and_edges(dot, components, in_layer) + + return dot + + +@click.command() +@click.option( + "--input", "input_path", + default="data/components.csv", + show_default=True, + type=click.Path(dir_okay=False, path_type=Path), + help="CSV input file (used unless --google-sheet is set).", +) +@click.option( + "--google-sheet", "google_sheet", + is_flag=True, + default=False, + help="Download CSV from Google Sheet instead of reading a local file. " + "Reads GOOGLE_SHEET_ID from .env (cwd, then the script directory).", +) +@click.option( + "--sheet-gid", "sheet_gid", + default=0, + show_default=True, + help="Google Sheet tab GID (0 = first tab).", +) +@click.option( + "--output-dir", "output_dir", + default="data", + show_default=True, + type=click.Path(file_okay=False, path_type=Path), + help="Directory for output files.", +) +@click.option( + "--output-name", "output_name", + default="diagram", + show_default=True, + help="Base filename for output files (without extension).", +) +@click.option( + "--refactor-status", "refactor_status", + default=",".join(DEFAULT_STATUSES), + show_default=True, + help="Comma-separated list of Refactor status values to include.", +) +@click.option( + "--all", "include_all", + is_flag=True, + default=False, + help="Include all components regardless of Refactor status.", +) +@click.option( + "--format", "extra_formats", + multiple=True, + type=click.Choice(["pdf", "svg"]), + help="Additional output formats beyond PNG (PNG is always produced). " + "Can be repeated.", +) +@click.option( + "--direction", + default="TB", + show_default=True, + type=click.Choice(["LR", "TB"]), + help="Graph layout direction.", +) +@click.option( + "--concentrate/--no-concentrate", + default=False, + show_default=True, + help="Merge partially-parallel edges (concentrate=true). Disable if solid " + "and dashed edges between nearby nodes render incorrectly merged.", +) +@click.option( + "--split-legends/--no-split-legends", "split_legends", + default=True, + show_default=True, + help="Write owner and edge-style legends as separate PNGs " + "({output_name}_owners.png / {output_name}_legend.png) and omit " + "them from the main diagram. Use --no-split-legends to embed them.", +) +@click.option( + "--layer-column", "layer_column", + default="", + show_default=True, + help="CSV column name to use for layer-based sub-figures (e.g. 'Layer'). " + "When set, one PNG sub-figure is written per distinct value found in " + "that column, showing in-layer nodes at full color and direct " + "neighbors from other layers greyed out. Leave empty to skip.", +) +def main( + input_path: Path, + google_sheet: bool, + sheet_gid: int, + output_dir: Path, + output_name: str, + refactor_status: str, + include_all: bool, + extra_formats: tuple[str, ...], + direction: str, + concentrate: bool, + split_legends: bool, + layer_column: str, +) -> None: + """Validate components CSV and generate a Graphviz dependency diagram.""" + output_dir.mkdir(parents=True, exist_ok=True) + + if google_sheet: + # Look for .env in cwd first (standard dotenv behavior, walks up the + # tree), then fall back to one next to the script for users who run + # the tool from a different directory. override=False keeps the cwd + # value winning when both files exist. + load_dotenv() + load_dotenv(Path(__file__).parent / ".env", override=False) + sheet_id = os.environ.get("GOOGLE_SHEET_ID", "").strip() + if not sheet_id: + raise click.ClickException( + "GOOGLE_SHEET_ID is not set. Add it to .env in the current " + f"directory or next to {Path(__file__).name}." + ) + url = ( + f"https://docs.google.com/spreadsheets/d/{sheet_id}" + f"/export?format=csv&gid={sheet_gid}" + ) + download_path = output_dir / "components.csv" + click.echo(f"Downloading CSV from Google Sheet to {download_path} ...") + # Use urlopen + content-type check rather than urlretrieve: a private + # or missing sheet redirects to a 200 HTML login page, which would + # otherwise be silently saved as components.csv. + try: + with urllib.request.urlopen(url) as response: + content_type = response.headers.get("Content-Type", "") + body = response.read() + except urllib.error.URLError as exc: + raise click.ClickException( + f"Failed to download Google Sheet ({url}): {exc}" + ) from exc + if "text/csv" not in content_type.lower(): + raise click.ClickException( + f"Google Sheet response was not CSV (Content-Type: " + f"{content_type or 'unset'}). The sheet may be private, " + f"the ID may be wrong, or the gid may not exist. URL: {url}" + ) + download_path.write_bytes(body) + input_path = download_path + elif not input_path.exists(): + raise click.ClickException(f"Input file not found: {input_path}") + + click.echo(f"Loading {input_path} ...") + components = load_components(input_path, layer_column=layer_column) + click.echo(f"Loaded {len(components)} components.") + + click.echo("Validating references ...") + if not validate(components): + raise click.ClickException( + "Validation failed; fix the errors above and re-run." + ) + + # Write JSON (all components, regardless of filter) + json_path = output_dir / "components.json" + write_json(components, json_path) + + # Determine active statuses + active_statuses: set[str] | None + if include_all: + active_statuses = None + click.echo("Including all components (no filter).") + else: + active_statuses = { + s.strip() for s in refactor_status.split(",") if s.strip() + } + active_count = sum( + 1 for c in components if c.refactor_status in active_statuses + ) + click.echo( + f"Filtering to {active_count} components with status: " + + ", ".join(sorted(active_statuses)) + ) + + colors = ColorAssigner(load_owner_colors(), FALLBACK_COLORS) + dot = build_graph( + components, active_statuses, direction, colors, + concentrate=concentrate, include_legend=not split_legends, + ) + + # Save .dot source + dot_path = output_dir / f"{output_name}.dot" + dot_path.write_text(dot.source, encoding="utf-8") + click.echo(f"Wrote {dot_path}") + + # Render PNG (always) plus any extra formats. cleanup=True removes the + # intermediate extension-less dot source that render() writes alongside + # the rendered file — we already keep the canonical copy in {output_name}.dot. + formats_to_render = {"png"} | set(extra_formats) + for fmt in sorted(formats_to_render): + dot.render( + filename=str(output_dir / output_name), + format=fmt, + cleanup=True, + ) + click.echo(f"Wrote {output_dir / f'{output_name}.{fmt}'}") + + # Separate legend files + if split_legends: + for legend_stem, legend_dot in [ + (f"{output_name}_owners", _build_owners_graph(colors)), + (f"{output_name}_legend", _build_edge_legend_graph()), + ]: + legend_dot.render( + filename=str(output_dir / legend_stem), + format="png", + cleanup=True, + ) + click.echo(f"Wrote {output_dir / f'{legend_stem}.png'}") + + # Per-layer sub-figures + if layer_column: + _index = index_by_id(components) + _active_set = _compute_active_set(components, active_statuses) + layers = sorted({c.layer for c in components if c.layer}) + if not layers: + click.echo( + f"Note: no values found in '{layer_column}' column; " + "no layer sub-figures written." + ) + else: + click.echo( + f"Generating {len(layers)} layer sub-figure(s) " + f"from '{layer_column}' column ..." + ) + for layer_value in layers: + in_layer_count = sum( + 1 for c in components + if c.id in _active_set + and c.layer == layer_value + and not c.ubiquitous + and not c.hide + ) + if in_layer_count == 0: + click.echo( + f" Skipping '{layer_value}' " + "(no active non-ubiquitous components)." + ) + continue + layer_dot = build_layer_subgraph( + components, layer_value, _active_set, _index, direction, colors + ) + stem = f"{output_name}_{_layer_filename(layer_value)}" + layer_dot_path = output_dir / f"{stem}.dot" + layer_dot_path.write_text(layer_dot.source, encoding="utf-8") + layer_dot.render( + filename=str(output_dir / stem), + format="png", + cleanup=True, + ) + click.echo(f" Wrote {output_dir / f'{stem}.png'}") + + +if __name__ == "__main__": + main() diff --git a/translator-components-diagram/owner-colors.csv b/translator-components-diagram/owner-colors.csv new file mode 100644 index 0000000..ab45f7e --- /dev/null +++ b/translator-components-diagram/owner-colors.csv @@ -0,0 +1,11 @@ +owner,color +NCATS,#EF5350 +UI,#EC407A +DOGSLED,#42A5F5 +DOGSURF,#66BB6A +CATRAX,#FFA726 +Core Components WG,#AB47BC +DINGO,#26C6DA +Shepherd,#D4E157 +Retriever,#8D6E63 +None,#E8E8E8 diff --git a/translator-components-diagram/pyproject.toml b/translator-components-diagram/pyproject.toml new file mode 100644 index 0000000..599a235 --- /dev/null +++ b/translator-components-diagram/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "translator-components-diagram" +version = "0.1.0" +description = "Generate dependency diagrams for Translator platform components" +requires-python = ">=3.11" +dependencies = [ + "click>=8.0", + "graphviz>=0.20", + "python-dotenv>=1.0", +] + +[project.scripts] +generate-diagram = "generate_diagram:main" + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/translator-components-diagram/tests/__init__.py b/translator-components-diagram/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/translator-components-diagram/tests/test_generate_diagram.py b/translator-components-diagram/tests/test_generate_diagram.py new file mode 100644 index 0000000..19a076d --- /dev/null +++ b/translator-components-diagram/tests/test_generate_diagram.py @@ -0,0 +1,339 @@ +"""Tests for the pure functions in generate_diagram.""" + +import textwrap + +import click +import pytest + +from generate_diagram import ( + Component, + ColorAssigner, + FALLBACK_COLORS, + _parse_bool, + index_by_id, + load_components, + load_owner_colors, + parse_id_list, + text_color_for, + validate, +) + + +# --- parse_id_list --------------------------------------------------------- + + +class TestParseIdList: + def test_empty(self): + assert parse_id_list("") == ([], []) + + def test_single_implemented(self): + assert parse_id_list("foo") == (["foo"], []) + + def test_single_planned(self): + assert parse_id_list("~foo") == ([], ["foo"]) + + def test_mixed(self): + assert parse_id_list("foo, ~bar, baz") == (["foo", "baz"], ["bar"]) + + def test_strips_whitespace(self): + assert parse_id_list(" foo , ~ bar ") == (["foo"], ["bar"]) + + def test_skips_empty_entries(self): + assert parse_id_list("foo,,bar,") == (["foo", "bar"], []) + + def test_tilde_followed_by_space(self): + assert parse_id_list("~ foo") == ([], ["foo"]) + + +# --- ColorAssigner --------------------------------------------------------- + + +class TestColorAssigner: + def test_known_owner_returns_base_color(self): + base = {"FooTeam": "#ABCDEF", "BarTeam": "#012345"} + ca = ColorAssigner(base, FALLBACK_COLORS) + assert ca.get("FooTeam") == "#ABCDEF" + assert ca.get("BarTeam") == "#012345" + + def test_unknown_owner_gets_first_fallback(self): + ca = ColorAssigner({}, FALLBACK_COLORS) + assert ca.get("MysteryTeam") == FALLBACK_COLORS[0] + + def test_unknown_owners_rotate_through_fallback_palette(self): + ca = ColorAssigner({}, FALLBACK_COLORS) + assigned = [ca.get(f"team{i}") for i in range(len(FALLBACK_COLORS) + 2)] + # First N pick palette in order, then wrap around. + assert assigned[: len(FALLBACK_COLORS)] == FALLBACK_COLORS + assert assigned[len(FALLBACK_COLORS)] == FALLBACK_COLORS[0] + assert assigned[len(FALLBACK_COLORS) + 1] == FALLBACK_COLORS[1] + + def test_same_unknown_owner_keeps_same_color(self): + ca = ColorAssigner({}, FALLBACK_COLORS) + first = ca.get("teamA") + _ = ca.get("teamB") + assert ca.get("teamA") == first + + def test_state_does_not_leak_between_instances(self): + # Regression: a previous version used a module-level _color_index + # global, which leaked state across runs. + ca1 = ColorAssigner({}, FALLBACK_COLORS) + _ = ca1.get("teamA") + _ = ca1.get("teamB") + ca2 = ColorAssigner({}, FALLBACK_COLORS) + assert ca2.get("teamC") == FALLBACK_COLORS[0] + + +# --- text_color_for -------------------------------------------------------- + + +class TestTextColorFor: + def test_pure_white_picks_black(self): + assert text_color_for("#FFFFFF") == "black" + + def test_pure_black_picks_white(self): + assert text_color_for("#000000") == "white" + + def test_light_yellow_picks_black(self): + # D4E157 (lime) is very light + assert text_color_for("#D4E157") == "black" + + def test_dark_brown_picks_white(self): + # 8D6E63 (brown) is moderately dark + assert text_color_for("#8D6E63") == "white" + + def test_accepts_hex_without_hash(self): + assert text_color_for("FFFFFF") == "black" + + +# --- index_by_id ----------------------------------------------------------- + + +def _comp(id_: str, **kwargs) -> Component: + """Build a Component with sensible defaults for the optional fields.""" + return Component( + id=id_, + name=kwargs.get("name", id_), + owner=kwargs.get("owner", "None"), + itrb=kwargs.get("itrb", ""), + refactor_status=kwargs.get("refactor_status", "Continues into Refactor"), + notes=kwargs.get("notes", ""), + ubiquitous=kwargs.get("ubiquitous", False), + depends_on=kwargs.get("depends_on", []), + depends_on_planned=kwargs.get("depends_on_planned", []), + uses=kwargs.get("uses", []), + uses_planned=kwargs.get("uses_planned", []), + ) + + +class TestIndexById: + def test_lookup_is_case_insensitive(self): + index = index_by_id([_comp("Foo"), _comp("bar")]) + assert index["foo"].id == "Foo" + assert index["bar"].id == "bar" + + def test_missing_returns_none(self): + index = index_by_id([_comp("foo")]) + assert index.get("nope") is None + + +# --- validate -------------------------------------------------------------- + + +class TestValidate: + def test_clean_input_returns_true(self): + components = [ + _comp("a", depends_on=["b"]), + _comp("b"), + ] + assert validate(components) is True + + def test_unknown_ref_is_hard_error(self, capsys): + components = [_comp("a", depends_on=["ghost"])] + assert validate(components) is False + assert "unknown id 'ghost'" in capsys.readouterr().err + + def test_unknown_planned_ref_is_hard_error(self): + components = [_comp("a", depends_on_planned=["ghost"])] + assert validate(components) is False + + def test_duplicate_id_is_hard_error(self, capsys): + components = [_comp("foo"), _comp("foo")] + assert validate(components) is False + assert "duplicate id" in capsys.readouterr().err + + def test_case_insensitive_duplicate_is_hard_error(self): + components = [_comp("Foo"), _comp("foo")] + assert validate(components) is False + + def test_case_mismatch_is_warning_not_error(self, capsys): + # case-mismatch is informational only — build_graph resolves + # case-insensitively. The return must stay True. + components = [ + _comp("foo"), + _comp("a", depends_on=["FOO"]), + ] + assert validate(components) is True + assert "case mismatch" in capsys.readouterr().err + + +# --- Component ------------------------------------------------------------- + + +class TestComponent: + def test_display_name_falls_back_to_id_when_name_empty(self): + c = _comp("foo", name="") + assert c.display_name == "foo" + + def test_display_name_uses_name_when_present(self): + c = _comp("foo", name="Foo Service") + assert c.display_name == "Foo Service" + + def test_all_refs_concatenates_all_four_lists(self): + c = _comp( + "x", + depends_on=["a"], + depends_on_planned=["b"], + uses=["c"], + uses_planned=["d"], + ) + assert c.all_refs() == ["a", "b", "c", "d"] + + +# --- load_components ------------------------------------------------------- + + +CSV_FIXTURE = textwrap.dedent("""\ + id,Name,Owner,Component in ITRB,Refactor status,Gets results from,Calls,Notes + bbb,Beta,DOGSLED,cat,Continues into Refactor,aaa,~ccc, + aaa,Alpha,NCATS,cat,New in Refactor,,,first note +""") + + +class TestLoadComponents: + def test_parses_csv_and_sorts_by_id(self, tmp_path): + csv_path = tmp_path / "components.csv" + csv_path.write_text(CSV_FIXTURE, encoding="utf-8") + components = load_components(csv_path) + assert [c.id for c in components] == ["aaa", "bbb"] + assert components[0].name == "Alpha" + assert components[0].refactor_status == "New in Refactor" + assert components[1].depends_on == ["aaa"] + assert components[1].uses_planned == ["ccc"] + + def test_tolerates_utf8_bom(self, tmp_path): + # An Excel resave can prepend a UTF-8 BOM. With plain utf-8 the + # first header would become "id" and KeyError on c.id. + csv_path = tmp_path / "components.csv" + csv_path.write_bytes("".encode("utf-8") + CSV_FIXTURE.encode("utf-8")) + components = load_components(csv_path) + assert components[0].id == "aaa" + + def test_empty_owner_becomes_none(self, tmp_path): + csv_path = tmp_path / "components.csv" + csv_path.write_text( + "id,Name,Owner,Component in ITRB,Refactor status," + "Gets results from,Calls,Notes\n" + "x,Ex,,,New in Refactor,,,\n", + encoding="utf-8", + ) + components = load_components(csv_path) + assert components[0].owner == "None" + + +# --- load_owner_colors ----------------------------------------------------- + + +class TestLoadOwnerColors: + def test_parses_owner_color_csv(self, tmp_path): + path = tmp_path / "owner-colors.csv" + path.write_text( + "owner,color\nNCATS,#EF5350\nUI,#EC407A\n", + encoding="utf-8", + ) + assert load_owner_colors(path) == { + "NCATS": "#EF5350", + "UI": "#EC407A", + } + + def test_preserves_row_order(self, tmp_path): + path = tmp_path / "owner-colors.csv" + path.write_text( + "owner,color\nzeta,#111\nalpha,#222\nbeta,#333\n", + encoding="utf-8", + ) + # Order matters for legend layout — must match CSV order, not sorted. + assert list(load_owner_colors(path)) == ["zeta", "alpha", "beta"] + + def test_strips_whitespace(self, tmp_path): + path = tmp_path / "owner-colors.csv" + path.write_text( + "owner,color\n NCATS , #EF5350 \n", + encoding="utf-8", + ) + assert load_owner_colors(path) == {"NCATS": "#EF5350"} + + def test_missing_file_raises_clickexception(self, tmp_path): + with pytest.raises(click.ClickException, match="not found"): + load_owner_colors(tmp_path / "missing.csv") + + def test_missing_column_raises_clickexception(self, tmp_path): + path = tmp_path / "owner-colors.csv" + path.write_text("owner,hue\nNCATS,red\n", encoding="utf-8") + with pytest.raises(click.ClickException, match="missing required columns"): + load_owner_colors(path) + + def test_default_file_loads(self): + # The repo-shipped owner-colors.csv must always be loadable. + result = load_owner_colors() + assert "NCATS" in result + assert result["NCATS"].startswith("#") + + +# --- _parse_bool ----------------------------------------------------------- + + +class TestParseBool: + @pytest.mark.parametrize("value", ["TRUE", "true", "True", "yes", "Y", "1"]) + def test_truthy_values(self, value): + assert _parse_bool(value) is True + + @pytest.mark.parametrize("value", ["FALSE", "false", "no", "", "0", " "]) + def test_falsy_values(self, value): + assert _parse_bool(value) is False + + def test_strips_whitespace(self): + assert _parse_bool(" TRUE ") is True + + +# --- Ubiquitous column in load_components ---------------------------------- + + +class TestUbiquitousColumn: + def test_ubiquitous_column_parsed(self, tmp_path): + csv_path = tmp_path / "components.csv" + csv_path.write_text( + "id,Name,Owner,Component in ITRB,Refactor status," + "Gets results from,Calls,Ubiquitous,Notes\n" + "jaeger,Jaeger,DOGSLED,obs,Continues into Refactor,,,TRUE,\n" + "ars,ARS,NCATS,svc,New in Refactor,,,,\n", + encoding="utf-8", + ) + components = load_components(csv_path) + by_id = {c.id: c for c in components} + assert by_id["jaeger"].ubiquitous is True + assert by_id["ars"].ubiquitous is False + + def test_missing_ubiquitous_column_defaults_false(self, tmp_path): + # Older sheets without the column should still parse cleanly. + csv_path = tmp_path / "components.csv" + csv_path.write_text( + "id,Name,Owner,Component in ITRB,Refactor status," + "Gets results from,Calls,Notes\n" + "foo,Foo,NCATS,svc,New in Refactor,,,\n", + encoding="utf-8", + ) + components = load_components(csv_path) + assert components[0].ubiquitous is False + + def test_dataclass_default_is_false(self): + assert _comp("foo").ubiquitous is False diff --git a/translator-components-diagram/uv.lock b/translator-components-diagram/uv.lock new file mode 100644 index 0000000..f904599 --- /dev/null +++ b/translator-components-diagram/uv.lock @@ -0,0 +1,119 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "translator-components-diagram" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "graphviz" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0" }, + { name = "graphviz", specifier = ">=0.20" }, + { name = "python-dotenv", specifier = ">=1.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }]