From 1397b5fa16f9e9d95f8a67c4f471046dd39af699 Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 23 Oct 2026 10:54:24 -0400 Subject: [PATCH 1/3] Add validate, format, raw D2, label and icon positions, legends, variables and render options Co-Authored-By: Claude Opus 5.5 --- README.md | 47 +++++++++- examples/README.md | 7 +- examples/c4_and_sequence.py | 34 +++++++ examples/fixed_positions.py | 27 ++++++ examples/labels_and_positions.py | 40 ++++++++ examples/raw_and_validation.py | 29 ++++++ examples/render_options.py | 30 ++++++ examples/variables_and_legend.py | 39 ++++++++ pyproject.toml | 2 + src/d2/__init__.py | 13 ++- src/d2/_render.py | 71 +++++++++++--- src/d2/connection.py | 42 ++++++++- src/d2/diagram.py | 63 ++++++++++++- src/d2/raw.py | 25 +++++ src/d2/shape.py | 112 +++++++++++++++-------- src/d2/sql_table.py | 7 +- src/d2/text.py | 35 +++++++ tests/test_d2/test_d2_positions.py | 105 +++++++++++++++++++++ tests/test_d2/test_d2_vars_and_legend.py | 58 ++++++++++++ tests/test_d2/test_raw.py | 57 ++++++++++++ tests/test_d2/test_render.py | 77 ++++++++++++++++ tests/test_d2/test_validate_format.py | 88 ++++++++++++++++++ 22 files changed, 946 insertions(+), 62 deletions(-) create mode 100644 examples/c4_and_sequence.py create mode 100644 examples/fixed_positions.py create mode 100644 examples/labels_and_positions.py create mode 100644 examples/raw_and_validation.py create mode 100644 examples/render_options.py create mode 100644 examples/variables_and_legend.py create mode 100644 src/d2/raw.py create mode 100644 src/d2/text.py create mode 100644 tests/test_d2/test_d2_positions.py create mode 100644 tests/test_d2/test_d2_vars_and_legend.py create mode 100644 tests/test_d2/test_raw.py create mode 100644 tests/test_d2/test_validate_format.py diff --git a/README.md b/README.md index 28449ab..e8f2536 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,43 @@ with open("graph.svg", "wb") as f: print(d2.render(diagram, "txt").decode()) ``` -The format can be `"svg"` (the default), `"png"`, `"pdf"` or `"txt"`, which is ASCII art. You can also pass `layout="dagre" | "elk" | "tala"` and `theme=`. For a diagram with layers, scenarios or steps, `target=` picks the board: the root by default, or a path such as `"layers.detail"`. A source string works in place of a diagram. If d2 fails, `d2.D2Error` is raised with the message from d2. PNG and PDF make d2 download a headless browser the first time you use them. +The format can be `"svg"` (the default), `"png"`, `"pdf"`, `"gif"`, `"pptx"` or `"txt"`, which is ASCII art. You can also pass `layout="dagre" | "elk" | "tala"` and `theme=`. Other keyword options are `dark_theme`, `sketch`, `pad`, `scale`, `center`, `omit_version`, `no_xml_tag`, `salt`, `animate_interval`, `ascii_mode` and `timeout`. An option you leave out is not sent to d2. For a diagram with layers, scenarios or steps, `target=` picks the board: the root by default, or a path such as `"layers.detail"`. A source string works in place of a diagram. If d2 fails, `d2.D2Error` is raised with the message from d2. d2 0.9 renders PNG, PDF, GIF and PPTX itself. Older d2 versions download a headless browser for PNG and PDF the first time. + +## Validating and formatting + +`d2.validate` checks D2 source with `d2 validate` and raises `d2.D2Error` with d2's message when the source is wrong. `d2.format` returns the source formatted by `d2 fmt`. Both take a `Diagram` or a string, so they suit code or an LLM that writes D2 text and needs to check it. + +```python +import d2 + +d2.validate("a -> b") + +try: + d2.validate("a -> {") +except d2.D2Error as error: + print(error) + +print(d2.format("a->b")) +``` + +`validate` checks syntax and meaning, but does not lay the diagram out. Some errors, such as `top` and `left` with the dagre layout, only show when you render. + +## Raw D2 + +Anything the objects do not cover can be written as D2 text with `raw=`. It takes a string or a `d2.Raw`, and is dedented for you. On a `Diagram` it is added at the end of the file (or with `add_raw`), on a `Shape` or `Connection` it goes inside the block. + +```python +diagram = d2.Diagram( + shapes=[d2.Shape(name="a"), d2.Shape(name="b")], + connections=[d2.Connection("a", "b")], + raw=""" + *.style.fill: "#dbeafe" + (a -> b)[0].style.stroke: red + """, +) +``` + +Use it for globs, imports, `suspend`, connection references and anything newer than this library. See the [tests](/tests/test_d2) for more detailed usage examples. @@ -116,6 +152,15 @@ See the [tests](/tests/test_d2) for more detailed usage examples. - [x] Shape tooltips, size, grids, direction and classes - [x] Diagram direction, classes and `d2-config` vars - [x] Scenarios and steps +- [x] `d2.validate` and `d2.format` through the d2 command +- [x] Raw D2 on diagrams, shapes and connections +- [x] Markdown, LaTeX or code as shape and connection labels (`label=d2.Text(...)`) +- [x] `label_near` and `icon_near` positions, `top` and `left`, grid `vertical_gap` and `horizontal_gap` +- [x] Connection icons, links and classes +- [x] `c4-person` shape and sequence diagrams +- [x] Variables (`variables=`) with `${name}` substitution, and gradient fills +- [x] Legends and board labels +- [x] More render options and gif and pptx output ## More features diff --git a/examples/README.md b/examples/README.md index 0d813a6..f402908 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,4 +23,9 @@ uv run python examples/shapes.py | [boards.py](boards.py) | Layers, scenarios and steps, rendering the root board and one layer | | [config.py](config.py) | Layout engine, theme, direction and sketch mode through `vars` | | [ascii.py](ascii.py) | ASCII output printed with `d2.render(diagram, "txt")` | - +| [raw_and_validation.py](raw_and_validation.py) | Raw D2 for globs and connection references, `d2.validate` and `d2.format` | +| [labels_and_positions.py](labels_and_positions.py) | Markdown labels on shapes and connections, `label_near`, `icon_near`, connection icons and links | +| [fixed_positions.py](fixed_positions.py) | `top` and `left` positions (tala) and grid `vertical_gap` and `horizontal_gap` | +| [c4_and_sequence.py](c4_and_sequence.py) | The `c4-person` shape and a sequence diagram | +| [variables_and_legend.py](variables_and_legend.py) | `variables` with `${name}` substitution, gradients, a legend and a board label | +| [render_options.py](render_options.py) | Sketch, dark theme, pad, scale, salt, gif and pptx output, ASCII mode | diff --git a/examples/c4_and_sequence.py b/examples/c4_and_sequence.py new file mode 100644 index 0000000..f8f3dfc --- /dev/null +++ b/examples/c4_and_sequence.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + c4 = d2.Diagram( + shapes=[ + d2.Shape(name="customer", label="Customer", shape=d2.ShapeType.c4_person), + d2.Shape(name="shop", label="Web shop"), + ], + connections=[d2.Connection("customer", "shop", "buys")], + ) + Path("c4.svg").write_bytes(d2.render(c4)) + + sequence = d2.Diagram( + shapes=[ + d2.Shape( + name="checkout", + shape=d2.ShapeType.sequence_diagram, + shapes=[d2.Shape(name="user"), d2.Shape(name="shop"), d2.Shape("bank")], + connections=[ + d2.Connection("user", "shop", "pay"), + d2.Connection("shop", "bank", "charge"), + d2.Connection("bank", "shop", "ok", style=d2.Style(stroke_dash=3)), + ], + ) + ] + ) + Path("sequence.svg").write_bytes(d2.render(sequence)) + + +if __name__ == "__main__": + main() diff --git a/examples/fixed_positions.py b/examples/fixed_positions.py new file mode 100644 index 0000000..009d281 --- /dev/null +++ b/examples/fixed_positions.py @@ -0,0 +1,27 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + vars=d2.Config(layout_engine="tala"), + shapes=[ + d2.Shape(name="left_box", top=0, left=0), + d2.Shape(name="right_box", top=120, left=300), + d2.Shape( + name="table", + grid_columns=2, + vertical_gap=40, + horizontal_gap=10, + top=300, + left=100, + shapes=[d2.Shape(name=name) for name in "abcd"], + ), + ], + ) + Path("fixed_positions.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/labels_and_positions.py b/examples/labels_and_positions.py new file mode 100644 index 0000000..bba816a --- /dev/null +++ b/examples/labels_and_positions.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import d2 + +ICON = "https://icons.terrastruct.com/essentials%2F112-server.svg" + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape( + name="api", + label=d2.Text("# API\n\nPublic **REST** endpoint", "md"), + width=260, + height=160, + icon=ICON, + icon_near="border-top-left", + ), + d2.Shape( + name="db", + label="Database", + shape=d2.ShapeType.cylinder, + label_near="outside-bottom-center", + ), + ], + connections=[ + d2.Connection( + "api", + "db", + d2.Text("`SELECT *`\nover **TLS**", "md"), + icon=ICON, + link="https://d2lang.com", + ) + ], + ) + Path("labels_and_positions.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/raw_and_validation.py b/examples/raw_and_validation.py new file mode 100644 index 0000000..76a2713 --- /dev/null +++ b/examples/raw_and_validation.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[d2.Shape(name="api"), d2.Shape(name="db"), d2.Shape(name="cache")], + connections=[d2.Connection("api", "db"), d2.Connection("api", "cache")], + raw=""" + # D2 the object model does not cover yet: a glob and a connection reference + *.style.fill: "#dbeafe" + (api -> db)[0].style.stroke: red + """, + ) + + d2.validate(diagram) + print(d2.format(diagram)) + + try: + d2.validate("api -> {") + except d2.D2Error as error: + print(f"invalid: {error}") + + Path("raw_and_validation.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/render_options.py b/examples/render_options.py new file mode 100644 index 0000000..cd28301 --- /dev/null +++ b/examples/render_options.py @@ -0,0 +1,30 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[d2.Shape(name="a"), d2.Shape(name="b")], + connections=[d2.Connection("a", "b", "hello")], + ) + Path("render_options.svg").write_bytes( + d2.render( + diagram, + theme=4, + dark_theme=200, + sketch=True, + pad=20, + scale=1, + salt="one", + omit_version=True, + no_xml_tag=True, + ) + ) + Path("render_options.pptx").write_bytes(d2.render(diagram, "pptx")) + Path("render_options.gif").write_bytes(d2.render(diagram, "gif")) + print(d2.render(diagram, "txt", ascii_mode="standard").decode()) + + +if __name__ == "__main__": + main() diff --git a/examples/variables_and_legend.py b/examples/variables_and_legend.py new file mode 100644 index 0000000..4b47a27 --- /dev/null +++ b/examples/variables_and_legend.py @@ -0,0 +1,39 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + variables={"env": "production", "primary": "#dbeafe"}, + shapes=[ + d2.Shape( + name="api", label="API (${env})", style=d2.Style(fill="${primary}") + ), + d2.Shape( + name="db", + label="Database", + style=d2.Style(fill="linear-gradient(#f69d3c, #3f87a6)"), + ), + ], + connections=[d2.Connection("api", "db", style=d2.Style(stroke="red"))], + legend=d2.Legend( + label="Key", + shapes=[d2.Shape(name="gradient", label="Storage")], + connections=[ + d2.Connection("a", "b", "critical", style=d2.Style(stroke="red")) + ], + ), + scenarios=[ + d2.Layer( + "db_down", + d2.Diagram(shapes=[d2.Shape(name="db", style=d2.Style(fill="red"))]), + label="Database down", + ) + ], + ) + Path("variables_and_legend.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 066b459..c8019d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,10 +92,12 @@ ignore = [ "S606", # execs the resolved d2 binary ] "src/d2/_render.py" = [ + "A001", # public `format` function "A002", # public `format` parameter "PLC0415", # d2_bin is an optional dependency imported on use "S603", # runs the resolved d2 binary ] +"src/d2/__init__.py" = ["A004"] # public `format` function "src/d2/diagram.py" = ["A002"] # public `vars` parameter "src/d2/style.py" = ["FBT001"] # public positional bool parameters diff --git a/src/d2/__init__.py b/src/d2/__init__.py index 7a884be..e5ae94e 100644 --- a/src/d2/__init__.py +++ b/src/d2/__init__.py @@ -1,9 +1,11 @@ -from ._render import D2Error, render +from ._render import D2Error, format, render, validate from .class_shape import ClassField, ClassMethod, ClassShape from .connection import Arrowhead, ArrowheadShape, Connection, Direction -from .diagram import Config, Diagram, Layer -from .shape import Shape, ShapeType, Text +from .diagram import Config, Diagram, Layer, Legend +from .raw import Raw +from .shape import LabelPosition, Shape, ShapeType from .style import Style +from .text import Text __all__ = [ "Arrowhead", @@ -16,10 +18,15 @@ "D2Error", "Diagram", "Direction", + "LabelPosition", "Layer", + "Legend", + "Raw", "Shape", "ShapeType", "Style", "Text", + "format", "render", + "validate", ] diff --git a/src/d2/_render.py b/src/d2/_render.py index 2861811..50ac85a 100644 --- a/src/d2/_render.py +++ b/src/d2/_render.py @@ -4,6 +4,8 @@ from d2.diagram import Diagram +RenderFormat = Literal["svg", "png", "pdf", "txt", "gif", "pptx"] + class D2Error(Exception): pass @@ -22,24 +24,69 @@ def find_d2() -> str: return find_d2_bin() +def run_d2(command: list[str], source: Diagram | str) -> bytes: + result = subprocess.run( + [find_d2(), *command], + input=str(source).encode(), + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise D2Error(result.stderr.decode(errors="replace")) + return result.stdout + + +def flag(name: str, *, value: str | float | bool | None) -> list[str]: + if value is None: + return [] + if isinstance(value, bool): + return [f"--{name}={'true' if value else 'false'}"] + return [f"--{name}", str(value)] + + def render( diagram: Diagram | str, - format: Literal["svg", "png", "pdf", "txt"] = "svg", + format: RenderFormat = "svg", *, layout: Literal["dagre", "elk", "tala"] | None = None, theme: int | None = None, target: str = "", + dark_theme: int | None = None, + sketch: bool | None = None, + pad: int | None = None, + scale: float | None = None, + center: bool | None = None, + omit_version: bool | None = None, + no_xml_tag: bool | None = None, + salt: str | None = None, + animate_interval: int | None = None, + ascii_mode: Literal["standard", "extended"] | None = None, + timeout: int | None = None, ) -> bytes: - command = [find_d2()] - if layout is not None: - command += ["--layout", layout] - if theme is not None: - command += ["--theme", str(theme)] + options = ( + ("layout", layout), + ("theme", theme), + ("dark-theme", dark_theme), + ("sketch", sketch), + ("pad", pad), + ("scale", scale), + ("center", center), + ("omit-version", omit_version), + ("no-xml-tag", no_xml_tag), + ("salt", salt), + ("animate-interval", animate_interval), + ("ascii-mode", ascii_mode), + ("timeout", timeout), + ) + command = [part for name, value in options for part in flag(name, value=value)] command += ["--target", target, "--stdout-format", format, "-", "-"] + return run_d2(command, diagram) - result = subprocess.run( - command, input=str(diagram).encode(), capture_output=True, check=False - ) - if result.returncode != 0: - raise D2Error(result.stderr.decode(errors="replace")) - return result.stdout + +def validate(source: Diagram | str) -> None: + run_d2(["validate", "-"], source) + + +def format(source: Diagram | str) -> str: + formatted = run_d2(["fmt", "-"], source).decode() + return formatted if formatted != "" else str(source) diff --git a/src/d2/connection.py b/src/d2/connection.py index 1795e7f..f9aa987 100644 --- a/src/d2/connection.py +++ b/src/d2/connection.py @@ -5,7 +5,15 @@ from enum import Enum, StrEnum from typing import TYPE_CHECKING -from d2.helpers import add_label_and_properties, indent_lines, non_empty, quote_label +from d2.helpers import ( + add_label_and_properties, + indent_lines, + non_empty, + quote_label, + quote_name, +) +from d2.raw import Raw +from d2.text import Text if TYPE_CHECKING: from d2.style import Style @@ -56,13 +64,17 @@ def __init__( self, shape_1: str, shape_2: str, - label: str | None = None, + label: str | Text | None = None, direction: Direction = Direction.TO, *, style: Style | None = None, source_arrowhead: Arrowhead | None = None, target_arrowhead: Arrowhead | None = None, tooltip: str | None = None, + icon: str | None = None, + link: str | None = None, + classes: list[str] | None = None, + raw: Raw | str | None = None, ) -> None: self.shape_1 = shape_1 self.shape_2 = shape_2 @@ -72,6 +84,10 @@ def __init__( self.source_arrowhead = source_arrowhead self.target_arrowhead = target_arrowhead self.tooltip = tooltip + self.icon = icon + self.link = link + self.classes = classes or [] + self.raw = None if raw is None else Raw.parse(raw) def lines(self) -> list[str]: properties: list[str] = [] @@ -83,9 +99,23 @@ def lines(self) -> list[str]: properties += self.target_arrowhead.lines("target-arrowhead") if self.style is not None: properties += self.style.lines() - + if (icon := non_empty(self.icon)) is not None: + properties.append(f"icon: {icon}") + if (link := non_empty(self.link)) is not None: + properties.append(f"link: {link}") + if self.classes: + names = [quote_name(name) for name in self.classes] + joined = names[0] if len(names) == 1 else f"[{'; '.join(names)}]" + properties.append(f"class: {joined}") + if self.raw is not None: + properties += self.raw.lines() + + label = self.label + if isinstance(label, Text): + properties = [*label.block_lines("label"), *properties] + label = None base = f"{self.shape_1} {self.direction.value} {self.shape_2}" - return add_label_and_properties(base, non_empty(self.label), properties) + return add_label_and_properties(base, non_empty(label), properties) @override def __repr__(self) -> str: @@ -101,6 +131,10 @@ def _key(self) -> tuple[object, ...]: self.source_arrowhead, self.target_arrowhead, self.tooltip, + self.icon, + self.link, + self.classes, + self.raw, ) @override diff --git a/src/d2/diagram.py b/src/d2/diagram.py index d41bb99..9c3763d 100644 --- a/src/d2/diagram.py +++ b/src/d2/diagram.py @@ -1,12 +1,21 @@ from __future__ import annotations import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal -from d2.helpers import add_label_and_properties, indent, indent_lines, quote_name +from d2.helpers import ( + add_label_and_properties, + indent, + indent_lines, + quote_label, + quote_name, +) +from d2.raw import Raw if TYPE_CHECKING: + from collections.abc import Mapping + from d2.connection import Connection from d2.shape import LayoutDirection, Shape from d2.style import Style @@ -49,13 +58,48 @@ def lines(self) -> list[str]: ] +VarValue = str | int | float | bool + + +def var_lines(variables: Mapping[str, VarValue]) -> list[str]: + entries = [ + f"{quote_name(name)}: " + + ( + str(value).lower() + if isinstance(value, bool) + else ('""' if value == "" else quote_label(str(value))) + ) + for name, value in variables.items() + ] + return ["vars: {", *indent_lines(entries), "}"] if entries else [] + + +@dataclass(frozen=True, kw_only=True) +class Legend: + shapes: list[Shape] = field(default_factory=list) + connections: list[Connection] = field(default_factory=list) + label: str | None = None + near: str | None = None + + def lines(self) -> list[str]: + properties = [f"near: {self.near}"] if self.near is not None else [] + properties += [line for shape in self.shapes for line in shape.lines()] + properties += [ + line for connection in self.connections for line in connection.lines() + ] + return add_label_and_properties("legend", self.label, properties) + + class Layer: def __init__( self, name: str, diagram: Diagram | None = None, + *, + label: str | None = None, ) -> None: self.name = name + self.label = label self.diagram = Diagram() if diagram is None else diagram def set_diagram(self, diagram: Diagram) -> None: @@ -70,8 +114,9 @@ def lines(self, depth: int = 1) -> list[str]: outer_indent_size = depth * 2 inner_indent_size = outer_indent_size + 2 + title = f" {quote_label(self.label)}" if self.label is not None else "" return [ - indent(f"{quote_name(self.name)}: {{", outer_indent_size), + indent(f"{quote_name(self.name)}:{title} {{", outer_indent_size), *indent_lines(lines, inner_indent_size), indent("}", outer_indent_size), ] @@ -101,6 +146,9 @@ def __init__( scenarios: list[Layer] | None = None, steps: list[Layer] | None = None, comments: list[str] | None = None, + variables: Mapping[str, VarValue] | None = None, + legend: Legend | None = None, + raw: Raw | str | None = None, ) -> None: self.shapes = shapes or [] self.connections = connections or [] @@ -111,6 +159,9 @@ def __init__( self.scenarios = scenarios or [] self.steps = steps or [] self.comments = comments or [] + self.variables = dict(variables) if variables is not None else {} + self.legend = legend + self.raws: list[Raw] = [] if raw is None else [Raw.parse(raw)] def add_shape(self, shape: Shape) -> None: self.shapes.append(shape) @@ -130,6 +181,9 @@ def add_step(self, step: Layer) -> None: def add_comment(self, comment: str) -> None: self.comments.append(comment) + def add_raw(self, raw: Raw | str) -> None: + self.raws.append(Raw.parse(raw)) + def class_lines(self) -> list[str]: classes = [ line @@ -155,13 +209,16 @@ def lines(self) -> list[str]: return ( comments + config + + var_lines(self.variables) + direction + self.class_lines() + shapes + connections + + (self.legend.lines() if self.legend is not None else []) + board_lines("layers", self.layers) + board_lines("scenarios", self.scenarios) + board_lines("steps", self.steps) + + [line for raw in self.raws for line in raw.lines()] ) @override diff --git a/src/d2/raw.py b/src/d2/raw.py new file mode 100644 index 0000000..8e74766 --- /dev/null +++ b/src/d2/raw.py @@ -0,0 +1,25 @@ +import sys +import textwrap +from dataclasses import dataclass + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + + +@dataclass(frozen=True) +class Raw: + source: str + + @classmethod + def parse(cls, value: "Raw | str") -> "Raw": + return cls(value) if isinstance(value, str) else value + + def lines(self) -> list[str]: + body = textwrap.dedent(self.source).strip("\n") + return body.split("\n") if body.strip() else [] + + @override + def __str__(self) -> str: + return "\n".join(self.lines()) diff --git a/src/d2/shape.py b/src/d2/shape.py index ecfea83..06b93d8 100644 --- a/src/d2/shape.py +++ b/src/d2/shape.py @@ -7,11 +7,12 @@ from d2.helpers import ( add_label_and_properties, flatten, - indent_lines, non_empty, quote_label, quote_name, ) +from d2.raw import Raw +from d2.text import Text if TYPE_CHECKING: from d2.connection import Connection @@ -50,29 +51,50 @@ class ShapeType(Enum): image = "image" classs = "class" sequence_diagram = "sequence_diagram" - - -class Text: - def __init__( - self, - # The actual text body (multiline is fine) - text: str, - # The format, eg) md, tex, html, css etc - formatting: str, - # The number of pipes to use - pipes: int = 1, - ) -> None: - self.text = text - self.formatting = formatting - self.pipes = pipes - - def lines(self) -> list[str]: - sep = "|" * self.pipes - return [f"{sep}{self.formatting}", *self.text.split("\n"), sep] - - @override - def __repr__(self) -> str: - return "\n".join(self.lines()) + c4_person = "c4-person" + + +LabelPosition = Literal[ + "outside-top-left", + "outside-top-center", + "outside-top-right", + "outside-left-top", + "outside-left-center", + "outside-left-bottom", + "outside-right-top", + "outside-right-center", + "outside-right-bottom", + "outside-bottom-left", + "outside-bottom-center", + "outside-bottom-right", + "border-top-left", + "border-top-center", + "border-top-right", + "border-left-top", + "border-left-center", + "border-left-bottom", + "border-right-top", + "border-right-center", + "border-right-bottom", + "border-bottom-left", + "border-bottom-center", + "border-bottom-right", + "inside-top-left", + "inside-top-center", + "inside-top-right", + "inside-middle-left", + "inside-middle-center", + "inside-middle-right", + "inside-bottom-left", + "inside-bottom-center", + "inside-bottom-right", +] + + +def label_parts(label: str | Text | None) -> tuple[str | None, list[str]]: + if isinstance(label, Text): + return None, label.block_lines("label") + return label, [] class Shape: @@ -80,7 +102,7 @@ def __init__( self, name: str, # The label of this shape - label: str | None = None, + label: str | Text | None = None, # The actual 2D shape of this shape shape: ShapeType | None = None, # A list of child shapes (when this shape is a container) @@ -104,6 +126,13 @@ def __init__( grid_gap: int | None = None, direction: LayoutDirection | None = None, classes: list[str] | None = None, + label_near: LabelPosition | None = None, + icon_near: LabelPosition | None = None, + top: int | None = None, + left: int | None = None, + vertical_gap: int | None = None, + horizontal_gap: int | None = None, + raw: Raw | str | None = None, **kwargs: Text, ) -> None: self.name = name @@ -123,6 +152,13 @@ def __init__( self.grid_gap = grid_gap self.direction = direction self.classes = classes or [] + self.label_near = label_near + self.icon_near = icon_near + self.top = top + self.left = left + self.vertical_gap = vertical_gap + self.horizontal_gap = horizontal_gap + self.raw = None if raw is None else Raw.parse(raw) self.kwargs = kwargs def add_shape(self, shape: Shape) -> None: @@ -143,6 +179,12 @@ def attribute_lines(self) -> list[str]: ("grid-columns", self.grid_columns), ("grid-gap", self.grid_gap), ("direction", self.direction), + ("vertical-gap", self.vertical_gap), + ("horizontal-gap", self.horizontal_gap), + ("top", self.top), + ("left", self.left), + ("label.near", self.label_near), + ("icon.near", self.icon_near), ): if value is not None: properties.append(f"{key}: {value}") @@ -182,17 +224,15 @@ def lines(self) -> list[str]: properties += self.attribute_lines() for key, value in self.kwargs.items(): - other_property = value.lines() - other_property_line_1 = other_property[0] - other_property_lines_other = other_property[1:-1] - other_property_line_end = other_property[-1] - properties += [ - f"{key}: {other_property_line_1}", - *indent_lines(other_property_lines_other), - other_property_line_end, - ] - - return add_label_and_properties(quote_name(self.name), self.label, properties) + properties += value.block_lines(key) + + if self.raw is not None: + properties += self.raw.lines() + + label, label_lines = label_parts(self.label) + return add_label_and_properties( + quote_name(self.name), label, [*label_lines, *properties] + ) @override def __repr__(self) -> str: diff --git a/src/d2/sql_table.py b/src/d2/sql_table.py index b78ff07..9d39e94 100644 --- a/src/d2/sql_table.py +++ b/src/d2/sql_table.py @@ -6,7 +6,7 @@ from d2.connection import Connection, Direction from d2.helpers import add_label_and_properties, non_empty, quote_name -from d2.shape import Shape, ShapeType +from d2.shape import Shape, ShapeType, label_parts if TYPE_CHECKING: from collections.abc import Mapping @@ -137,7 +137,10 @@ def lines(self) -> list[str]: for connection in self.connections: properties.extend(connection.lines()) - return add_label_and_properties(quote_name(self.name), self.label, properties) + label, label_lines = label_parts(self.label) + return add_label_and_properties( + quote_name(self.name), label, [*label_lines, *properties] + ) def create_foreign_key_connection( diff --git a/src/d2/text.py b/src/d2/text.py new file mode 100644 index 0000000..becfb3a --- /dev/null +++ b/src/d2/text.py @@ -0,0 +1,35 @@ +import sys + +from d2.helpers import indent_lines + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + + +class Text: + def __init__( + self, + # The actual text body (multiline is fine) + text: str, + # The format, eg) md, tex, html, css etc + formatting: str, + # The number of pipes to use + pipes: int = 1, + ) -> None: + self.text = text + self.formatting = formatting + self.pipes = pipes + + def lines(self) -> list[str]: + sep = "|" * self.pipes + return [f"{sep}{self.formatting}", *self.text.split("\n"), sep] + + def block_lines(self, key: str) -> list[str]: + lines = self.lines() + return [f"{key}: {lines[0]}", *indent_lines(lines[1:-1]), lines[-1]] + + @override + def __repr__(self) -> str: + return "\n".join(self.lines()) diff --git a/tests/test_d2/test_d2_positions.py b/tests/test_d2/test_d2_positions.py new file mode 100644 index 0000000..c7b3c94 --- /dev/null +++ b/tests/test_d2/test_d2_positions.py @@ -0,0 +1,105 @@ +import d2 +from d2.sql_table import SQLTable + + +def test_shape__label_and_icon_near() -> None: + shape = d2.Shape( + name="a", + icon="https://x.test/i.svg", + label_near="outside-top-left", + icon_near="border-top-center", + ) + + assert str(shape).split("\n") == [ + "a: {", + " icon: https://x.test/i.svg", + " label.near: outside-top-left", + " icon.near: border-top-center", + "}", + ] + + +def test_shape__top_left_and_gaps() -> None: + shape = d2.Shape(name="a", top=10, left=0, vertical_gap=4, horizontal_gap=6) + + assert str(shape).split("\n") == [ + "a: {", + " vertical-gap: 4", + " horizontal-gap: 6", + " top: 10", + " left: 0", + "}", + ] + + +def test_shape__c4_person() -> None: + shape = d2.Shape(name="user", shape=d2.ShapeType.c4_person) + + assert "shape: c4-person" in str(shape) + + +def test_shape__defaults_add_nothing() -> None: + assert str(d2.Shape(name="a")) == "a" + + +def test_shape__text_label() -> None: + shape = d2.Shape(name="a", label=d2.Text("# Title", "md"), width=100) + + assert str(shape).split("\n") == [ + "a: {", + " label: |md", + " # Title", + " |", + " width: 100", + "}", + ] + + +def test_connection__text_label() -> None: + connection = d2.Connection("a", "b", d2.Text("**calls**", "md")) + + assert str(connection).split("\n") == [ + "a -> b: {", + " label: |md", + " **calls**", + " |", + "}", + ] + + +def test_connection__icon_link_and_classes() -> None: + connection = d2.Connection( + "a", + "b", + icon="https://x.test/i.svg", + link="https://x.test", + classes=["hot", "wide"], + ) + + assert str(connection).split("\n") == [ + "a -> b: {", + " icon: https://x.test/i.svg", + " link: https://x.test", + " class: [hot; wide]", + "}", + ] + + +def test_connection__equality_includes_new_fields() -> None: + assert d2.Connection("a", "b", icon="x") != d2.Connection("a", "b") + assert d2.Connection("a", "b", link="x") == d2.Connection("a", "b", link="x") + + +def test_layer__label_is_the_board_title() -> None: + diagram = d2.Diagram( + scenarios=[d2.Layer("slow", d2.Diagram(raw="a"), label="Slow path")] + ) + + assert str(diagram) == "scenarios: {\n slow: Slow path {\n a\n }\n}" + + +def test_sql_table__text_label() -> None: + table = SQLTable("t", {"id": "int"}, label=None) + table.label = d2.Text("# t", "md") + + assert str(table).split("\n")[1] == " label: |md" diff --git a/tests/test_d2/test_d2_vars_and_legend.py b/tests/test_d2/test_d2_vars_and_legend.py new file mode 100644 index 0000000..c072381 --- /dev/null +++ b/tests/test_d2/test_d2_vars_and_legend.py @@ -0,0 +1,58 @@ +import d2 + + +def test_diagram__variables() -> None: + diagram = d2.Diagram( + variables={"env": "prod", "replicas": 3, "ha": True, "empty": ""}, + shapes=[d2.Shape(name="svc", label="${env} service")], + ) + + assert str(diagram).split("\n") == [ + "vars: {", + " env: prod", + " replicas: 3", + " ha: true", + ' empty: ""', + "}", + 'svc: "${env} service"', + ] + + +def test_diagram__variables_follow_config() -> None: + diagram = d2.Diagram(vars=d2.Config(sketch=True), variables={"x": "1"}) + + assert str(diagram).split("\n") == [ + "vars: {", + " d2-config: {", + " sketch: true", + " }", + "}", + "vars: {", + " x: 1", + "}", + ] + + +def test_diagram__legend() -> None: + diagram = d2.Diagram( + legend=d2.Legend( + label="Key", + near="bottom-center", + shapes=[d2.Shape(name="k", label="Service")], + connections=[d2.Connection("k", "k", "calls")], + ) + ) + + assert str(diagram).split("\n") == [ + "legend: Key {", + " near: bottom-center", + " k: Service", + " k -> k: calls", + "}", + ] + + +def test_diagram__legend_without_label() -> None: + diagram = d2.Diagram(legend=d2.Legend(shapes=[d2.Shape(name="k")])) + + assert str(diagram) == "legend: {\n k\n}" diff --git a/tests/test_d2/test_raw.py b/tests/test_d2/test_raw.py new file mode 100644 index 0000000..8d0061d --- /dev/null +++ b/tests/test_d2/test_raw.py @@ -0,0 +1,57 @@ +import d2 + + +def test_raw__dedents_and_trims() -> None: + raw = d2.Raw(""" + a -> b + style.stroke: red + """) + + assert raw.lines() == ["a -> b", " style.stroke: red"] + + +def test_raw__blank_source_has_no_lines() -> None: + assert d2.Raw(" \n ").lines() == [] + + +def test_raw__parse_keeps_raw_and_wraps_str() -> None: + raw = d2.Raw("x") + + assert d2.Raw.parse(raw) is raw + assert d2.Raw.parse("x") == raw + + +def test_diagram__raw_is_appended_last() -> None: + diagram = d2.Diagram( + shapes=[d2.Shape(name="a"), d2.Shape(name="b")], + connections=[d2.Connection("a", "b")], + raw="(a -> b)[0].style.stroke: red", + ) + diagram.add_raw(d2.Raw("a.style.fill: blue")) + + assert str(diagram).split("\n") == [ + "a", + "b", + "a -> b", + "(a -> b)[0].style.stroke: red", + "a.style.fill: blue", + ] + + +def test_shape__raw_goes_inside_the_shape() -> None: + shape = d2.Shape(name="a", raw="style.fill: red\nstyle.stroke: blue") + + assert str(shape) == "a: {\n style.fill: red\n style.stroke: blue\n}" + + +def test_connection__raw_goes_inside_the_connection() -> None: + connection = d2.Connection("a", "b", raw="style.stroke: red") + + assert str(connection) == "a -> b: {\n style.stroke: red\n}" + + +def test_layer__raw_diagram_is_nested() -> None: + layer = d2.Layer("x", d2.Diagram(raw="a -> b")) + diagram = d2.Diagram(layers=[layer]) + + assert str(diagram) == "layers: {\n x: {\n a -> b\n }\n}" diff --git a/tests/test_d2/test_render.py b/tests/test_d2/test_render.py index 1b16e3d..7d0b308 100644 --- a/tests/test_d2/test_render.py +++ b/tests/test_d2/test_render.py @@ -93,3 +93,80 @@ def test_render__hex_colors_and_keyword_names() -> None: shapes=[d2.Shape(name="shadow", style=d2.Style(fill="#dbeafe"))] ) assert d2.render(diagram).startswith(b" None: + seen: list[list[str]] = [] + + def fake_run(command: list[str], source: d2.Diagram | str) -> bytes: + seen.append(command) + return str(source).encode() + + monkeypatch.setattr("d2._render.run_d2", fake_run) + + d2.render( + "a", + "pptx", + layout="elk", + theme=1, + dark_theme=200, + sketch=False, + pad=10, + scale=0.5, + center=True, + omit_version=True, + no_xml_tag=True, + salt="s", + animate_interval=500, + ascii_mode="standard", + timeout=30, + ) + + assert seen == [ + [ + "--layout", + "elk", + "--theme", + "1", + "--dark-theme", + "200", + "--sketch=false", + "--pad", + "10", + "--scale", + "0.5", + "--center=true", + "--omit-version=true", + "--no-xml-tag=true", + "--salt", + "s", + "--animate-interval", + "500", + "--ascii-mode", + "standard", + "--timeout", + "30", + "--target", + "", + "--stdout-format", + "pptx", + "-", + "-", + ] + ] + + +def test_render__defaults_send_only_target_and_format( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[list[str]] = [] + + def fake_run(command: list[str], source: d2.Diagram | str) -> bytes: + seen.append(command) + return str(source).encode() + + monkeypatch.setattr("d2._render.run_d2", fake_run) + + d2.render("a") + + assert seen == [["--target", "", "--stdout-format", "svg", "-", "-"]] diff --git a/tests/test_d2/test_validate_format.py b/tests/test_d2/test_validate_format.py new file mode 100644 index 0000000..441ab13 --- /dev/null +++ b/tests/test_d2/test_validate_format.py @@ -0,0 +1,88 @@ +import pytest + +import d2 +from d2._render import RenderFormat +from tests.test_d2.test_render import requires_d2 + + +@requires_d2 +def test_validate__accepts_valid_source() -> None: + d2.validate("a -> b") + + +@requires_d2 +def test_validate__accepts_a_diagram() -> None: + d2.validate(d2.Diagram(shapes=[d2.Shape(name="a")], raw="a.style.fill: red")) + + +@requires_d2 +def test_validate__raises_with_the_d2_message() -> None: + with pytest.raises(d2.D2Error, match="maps must be terminated"): + d2.validate("a -> {") + + +@requires_d2 +def test_validate__rejects_bad_raw_source() -> None: + with pytest.raises(d2.D2Error): + d2.validate(d2.Diagram(raw="a -> ")) + + +@requires_d2 +def test_format__normalizes_source() -> None: + assert d2.format("a->b\nx:{y}").split("\n")[:2] == ["a -> b", "x: {y}"] + + +@requires_d2 +def test_format__is_idempotent() -> None: + once = d2.format("a->b") + + assert d2.format(once) == once + + +@requires_d2 +def test_format__returns_already_formatted_source_unchanged() -> None: + assert d2.format("a -> b\n") == "a -> b\n" + + +@requires_d2 +def test_format__raises_on_invalid_source() -> None: + with pytest.raises(d2.D2Error): + d2.format("a -> {") + + +@requires_d2 +def test_render__new_features_compile_with_d2() -> None: + diagram = d2.Diagram( + variables={"who": "world"}, + legend=d2.Legend(shapes=[d2.Shape(name="k", label="Service")]), + shapes=[ + d2.Shape( + name="a", + label="hello ${who}", + shape=d2.ShapeType.c4_person, + label_near="outside-top-center", + ), + d2.Shape(name="b", label=d2.Text("**bold**", "md")), + ], + connections=[d2.Connection("a", "b", d2.Text("*calls*", "md"))], + raw="(a -> b)[0].style.stroke: red", + ) + + assert b" None: + diagram = d2.Diagram(shapes=[d2.Shape(name="a", top=10, left=10)]) + + assert b" None: + assert d2.render("a -> b", format_) From 7570541fb89c1df9f8d5e8160f6c83abe56029f9 Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 23 Oct 2026 10:54:53 -0400 Subject: [PATCH 2/3] Drop raw D2 embedding, type legend near as a Literal Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +------ examples/README.md | 2 +- .../{raw_and_validation.py => validation.py} | 14 ++--- src/d2/__init__.py | 5 +- src/d2/connection.py | 6 -- src/d2/diagram.py | 11 +--- src/d2/raw.py | 25 -------- src/d2/shape.py | 18 ++++-- tests/test_d2/test_d2_positions.py | 4 +- tests/test_d2/test_raw.py | 57 ------------------- tests/test_d2/test_validate_format.py | 9 +-- 11 files changed, 29 insertions(+), 141 deletions(-) rename examples/{raw_and_validation.py => validation.py} (51%) delete mode 100644 src/d2/raw.py delete mode 100644 tests/test_d2/test_raw.py diff --git a/README.md b/README.md index e8f2536..7b22330 100644 --- a/README.md +++ b/README.md @@ -110,23 +110,6 @@ print(d2.format("a->b")) `validate` checks syntax and meaning, but does not lay the diagram out. Some errors, such as `top` and `left` with the dagre layout, only show when you render. -## Raw D2 - -Anything the objects do not cover can be written as D2 text with `raw=`. It takes a string or a `d2.Raw`, and is dedented for you. On a `Diagram` it is added at the end of the file (or with `add_raw`), on a `Shape` or `Connection` it goes inside the block. - -```python -diagram = d2.Diagram( - shapes=[d2.Shape(name="a"), d2.Shape(name="b")], - connections=[d2.Connection("a", "b")], - raw=""" - *.style.fill: "#dbeafe" - (a -> b)[0].style.stroke: red - """, -) -``` - -Use it for globs, imports, `suspend`, connection references and anything newer than this library. - See the [tests](/tests/test_d2) for more detailed usage examples. @@ -153,7 +136,7 @@ See the [tests](/tests/test_d2) for more detailed usage examples. - [x] Diagram direction, classes and `d2-config` vars - [x] Scenarios and steps - [x] `d2.validate` and `d2.format` through the d2 command -- [x] Raw D2 on diagrams, shapes and connections +- [x] Glob shapes such as `Shape(name="*")` to style every shape - [x] Markdown, LaTeX or code as shape and connection labels (`label=d2.Text(...)`) - [x] `label_near` and `icon_near` positions, `top` and `left`, grid `vertical_gap` and `horizontal_gap` - [x] Connection icons, links and classes diff --git a/examples/README.md b/examples/README.md index f402908..edd1151 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,7 @@ uv run python examples/shapes.py | [boards.py](boards.py) | Layers, scenarios and steps, rendering the root board and one layer | | [config.py](config.py) | Layout engine, theme, direction and sketch mode through `vars` | | [ascii.py](ascii.py) | ASCII output printed with `d2.render(diagram, "txt")` | -| [raw_and_validation.py](raw_and_validation.py) | Raw D2 for globs and connection references, `d2.validate` and `d2.format` | +| [validation.py](validation.py) | `d2.validate`, `d2.format` and a glob shape that styles every shape | | [labels_and_positions.py](labels_and_positions.py) | Markdown labels on shapes and connections, `label_near`, `icon_near`, connection icons and links | | [fixed_positions.py](fixed_positions.py) | `top` and `left` positions (tala) and grid `vertical_gap` and `horizontal_gap` | | [c4_and_sequence.py](c4_and_sequence.py) | The `c4-person` shape and a sequence diagram | diff --git a/examples/raw_and_validation.py b/examples/validation.py similarity index 51% rename from examples/raw_and_validation.py rename to examples/validation.py index 76a2713..708a346 100644 --- a/examples/raw_and_validation.py +++ b/examples/validation.py @@ -5,13 +5,13 @@ def main() -> None: diagram = d2.Diagram( - shapes=[d2.Shape(name="api"), d2.Shape(name="db"), d2.Shape(name="cache")], + shapes=[ + d2.Shape(name="*", style=d2.Style(fill="#dbeafe")), + d2.Shape(name="api"), + d2.Shape(name="db"), + d2.Shape(name="cache"), + ], connections=[d2.Connection("api", "db"), d2.Connection("api", "cache")], - raw=""" - # D2 the object model does not cover yet: a glob and a connection reference - *.style.fill: "#dbeafe" - (api -> db)[0].style.stroke: red - """, ) d2.validate(diagram) @@ -22,7 +22,7 @@ def main() -> None: except d2.D2Error as error: print(f"invalid: {error}") - Path("raw_and_validation.svg").write_bytes(d2.render(diagram)) + Path("validation.svg").write_bytes(d2.render(diagram)) if __name__ == "__main__": diff --git a/src/d2/__init__.py b/src/d2/__init__.py index e5ae94e..ed74641 100644 --- a/src/d2/__init__.py +++ b/src/d2/__init__.py @@ -2,8 +2,7 @@ from .class_shape import ClassField, ClassMethod, ClassShape from .connection import Arrowhead, ArrowheadShape, Connection, Direction from .diagram import Config, Diagram, Layer, Legend -from .raw import Raw -from .shape import LabelPosition, Shape, ShapeType +from .shape import LabelPosition, NearPosition, Shape, ShapeType from .style import Style from .text import Text @@ -21,7 +20,7 @@ "LabelPosition", "Layer", "Legend", - "Raw", + "NearPosition", "Shape", "ShapeType", "Style", diff --git a/src/d2/connection.py b/src/d2/connection.py index f9aa987..2056260 100644 --- a/src/d2/connection.py +++ b/src/d2/connection.py @@ -12,7 +12,6 @@ quote_label, quote_name, ) -from d2.raw import Raw from d2.text import Text if TYPE_CHECKING: @@ -74,7 +73,6 @@ def __init__( icon: str | None = None, link: str | None = None, classes: list[str] | None = None, - raw: Raw | str | None = None, ) -> None: self.shape_1 = shape_1 self.shape_2 = shape_2 @@ -87,7 +85,6 @@ def __init__( self.icon = icon self.link = link self.classes = classes or [] - self.raw = None if raw is None else Raw.parse(raw) def lines(self) -> list[str]: properties: list[str] = [] @@ -107,8 +104,6 @@ def lines(self) -> list[str]: names = [quote_name(name) for name in self.classes] joined = names[0] if len(names) == 1 else f"[{'; '.join(names)}]" properties.append(f"class: {joined}") - if self.raw is not None: - properties += self.raw.lines() label = self.label if isinstance(label, Text): @@ -134,7 +129,6 @@ def _key(self) -> tuple[object, ...]: self.icon, self.link, self.classes, - self.raw, ) @override diff --git a/src/d2/diagram.py b/src/d2/diagram.py index 9c3763d..71e78c8 100644 --- a/src/d2/diagram.py +++ b/src/d2/diagram.py @@ -11,13 +11,12 @@ quote_label, quote_name, ) -from d2.raw import Raw if TYPE_CHECKING: from collections.abc import Mapping from d2.connection import Connection - from d2.shape import LayoutDirection, Shape + from d2.shape import LayoutDirection, NearPosition, Shape from d2.style import Style if sys.version_info >= (3, 12): @@ -79,7 +78,7 @@ class Legend: shapes: list[Shape] = field(default_factory=list) connections: list[Connection] = field(default_factory=list) label: str | None = None - near: str | None = None + near: NearPosition | None = None def lines(self) -> list[str]: properties = [f"near: {self.near}"] if self.near is not None else [] @@ -148,7 +147,6 @@ def __init__( comments: list[str] | None = None, variables: Mapping[str, VarValue] | None = None, legend: Legend | None = None, - raw: Raw | str | None = None, ) -> None: self.shapes = shapes or [] self.connections = connections or [] @@ -161,7 +159,6 @@ def __init__( self.comments = comments or [] self.variables = dict(variables) if variables is not None else {} self.legend = legend - self.raws: list[Raw] = [] if raw is None else [Raw.parse(raw)] def add_shape(self, shape: Shape) -> None: self.shapes.append(shape) @@ -181,9 +178,6 @@ def add_step(self, step: Layer) -> None: def add_comment(self, comment: str) -> None: self.comments.append(comment) - def add_raw(self, raw: Raw | str) -> None: - self.raws.append(Raw.parse(raw)) - def class_lines(self) -> list[str]: classes = [ line @@ -218,7 +212,6 @@ def lines(self) -> list[str]: + board_lines("layers", self.layers) + board_lines("scenarios", self.scenarios) + board_lines("steps", self.steps) - + [line for raw in self.raws for line in raw.lines()] ) @override diff --git a/src/d2/raw.py b/src/d2/raw.py deleted file mode 100644 index 8e74766..0000000 --- a/src/d2/raw.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys -import textwrap -from dataclasses import dataclass - -if sys.version_info >= (3, 12): - from typing import override -else: - from typing_extensions import override - - -@dataclass(frozen=True) -class Raw: - source: str - - @classmethod - def parse(cls, value: "Raw | str") -> "Raw": - return cls(value) if isinstance(value, str) else value - - def lines(self) -> list[str]: - body = textwrap.dedent(self.source).strip("\n") - return body.split("\n") if body.strip() else [] - - @override - def __str__(self) -> str: - return "\n".join(self.lines()) diff --git a/src/d2/shape.py b/src/d2/shape.py index 06b93d8..7e9d032 100644 --- a/src/d2/shape.py +++ b/src/d2/shape.py @@ -11,7 +11,6 @@ quote_label, quote_name, ) -from d2.raw import Raw from d2.text import Text if TYPE_CHECKING: @@ -91,6 +90,18 @@ class ShapeType(Enum): ] +NearPosition = Literal[ + "top-left", + "top-center", + "top-right", + "center-left", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right", +] + + def label_parts(label: str | Text | None) -> tuple[str | None, list[str]]: if isinstance(label, Text): return None, label.block_lines("label") @@ -132,7 +143,6 @@ def __init__( left: int | None = None, vertical_gap: int | None = None, horizontal_gap: int | None = None, - raw: Raw | str | None = None, **kwargs: Text, ) -> None: self.name = name @@ -158,7 +168,6 @@ def __init__( self.left = left self.vertical_gap = vertical_gap self.horizontal_gap = horizontal_gap - self.raw = None if raw is None else Raw.parse(raw) self.kwargs = kwargs def add_shape(self, shape: Shape) -> None: @@ -226,9 +235,6 @@ def lines(self) -> list[str]: for key, value in self.kwargs.items(): properties += value.block_lines(key) - if self.raw is not None: - properties += self.raw.lines() - label, label_lines = label_parts(self.label) return add_label_and_properties( quote_name(self.name), label, [*label_lines, *properties] diff --git a/tests/test_d2/test_d2_positions.py b/tests/test_d2/test_d2_positions.py index c7b3c94..a5abfe3 100644 --- a/tests/test_d2/test_d2_positions.py +++ b/tests/test_d2/test_d2_positions.py @@ -92,7 +92,9 @@ def test_connection__equality_includes_new_fields() -> None: def test_layer__label_is_the_board_title() -> None: diagram = d2.Diagram( - scenarios=[d2.Layer("slow", d2.Diagram(raw="a"), label="Slow path")] + scenarios=[ + d2.Layer("slow", d2.Diagram(shapes=[d2.Shape(name="a")]), label="Slow path") + ] ) assert str(diagram) == "scenarios: {\n slow: Slow path {\n a\n }\n}" diff --git a/tests/test_d2/test_raw.py b/tests/test_d2/test_raw.py deleted file mode 100644 index 8d0061d..0000000 --- a/tests/test_d2/test_raw.py +++ /dev/null @@ -1,57 +0,0 @@ -import d2 - - -def test_raw__dedents_and_trims() -> None: - raw = d2.Raw(""" - a -> b - style.stroke: red - """) - - assert raw.lines() == ["a -> b", " style.stroke: red"] - - -def test_raw__blank_source_has_no_lines() -> None: - assert d2.Raw(" \n ").lines() == [] - - -def test_raw__parse_keeps_raw_and_wraps_str() -> None: - raw = d2.Raw("x") - - assert d2.Raw.parse(raw) is raw - assert d2.Raw.parse("x") == raw - - -def test_diagram__raw_is_appended_last() -> None: - diagram = d2.Diagram( - shapes=[d2.Shape(name="a"), d2.Shape(name="b")], - connections=[d2.Connection("a", "b")], - raw="(a -> b)[0].style.stroke: red", - ) - diagram.add_raw(d2.Raw("a.style.fill: blue")) - - assert str(diagram).split("\n") == [ - "a", - "b", - "a -> b", - "(a -> b)[0].style.stroke: red", - "a.style.fill: blue", - ] - - -def test_shape__raw_goes_inside_the_shape() -> None: - shape = d2.Shape(name="a", raw="style.fill: red\nstyle.stroke: blue") - - assert str(shape) == "a: {\n style.fill: red\n style.stroke: blue\n}" - - -def test_connection__raw_goes_inside_the_connection() -> None: - connection = d2.Connection("a", "b", raw="style.stroke: red") - - assert str(connection) == "a -> b: {\n style.stroke: red\n}" - - -def test_layer__raw_diagram_is_nested() -> None: - layer = d2.Layer("x", d2.Diagram(raw="a -> b")) - diagram = d2.Diagram(layers=[layer]) - - assert str(diagram) == "layers: {\n x: {\n a -> b\n }\n}" diff --git a/tests/test_d2/test_validate_format.py b/tests/test_d2/test_validate_format.py index 441ab13..25438ef 100644 --- a/tests/test_d2/test_validate_format.py +++ b/tests/test_d2/test_validate_format.py @@ -12,7 +12,7 @@ def test_validate__accepts_valid_source() -> None: @requires_d2 def test_validate__accepts_a_diagram() -> None: - d2.validate(d2.Diagram(shapes=[d2.Shape(name="a")], raw="a.style.fill: red")) + d2.validate(d2.Diagram(shapes=[d2.Shape(name="a")])) @requires_d2 @@ -21,12 +21,6 @@ def test_validate__raises_with_the_d2_message() -> None: d2.validate("a -> {") -@requires_d2 -def test_validate__rejects_bad_raw_source() -> None: - with pytest.raises(d2.D2Error): - d2.validate(d2.Diagram(raw="a -> ")) - - @requires_d2 def test_format__normalizes_source() -> None: assert d2.format("a->b\nx:{y}").split("\n")[:2] == ["a -> b", "x: {y}"] @@ -65,7 +59,6 @@ def test_render__new_features_compile_with_d2() -> None: d2.Shape(name="b", label=d2.Text("**bold**", "md")), ], connections=[d2.Connection("a", "b", d2.Text("*calls*", "md"))], - raw="(a -> b)[0].style.stroke: red", ) assert b" Date: Fri, 23 Oct 2026 10:56:01 -0400 Subject: [PATCH 3/3] Rename d2.format to d2.fmt Matches the d2 fmt command and stops shadowing the format builtin, which removes two lint suppressions. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- examples/README.md | 2 +- examples/validation.py | 2 +- pyproject.toml | 2 -- src/d2/__init__.py | 4 ++-- src/d2/_render.py | 2 +- tests/test_d2/test_validate_format.py | 10 +++++----- 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7b22330..abc7f1d 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The format can be `"svg"` (the default), `"png"`, `"pdf"`, `"gif"`, `"pptx"` or ## Validating and formatting -`d2.validate` checks D2 source with `d2 validate` and raises `d2.D2Error` with d2's message when the source is wrong. `d2.format` returns the source formatted by `d2 fmt`. Both take a `Diagram` or a string, so they suit code or an LLM that writes D2 text and needs to check it. +`d2.validate` checks D2 source with `d2 validate` and raises `d2.D2Error` with d2's message when the source is wrong. `d2.fmt` returns the source formatted by `d2 fmt`. Both take a `Diagram` or a string, so they suit code or an LLM that writes D2 text and needs to check it. ```python import d2 @@ -105,7 +105,7 @@ try: except d2.D2Error as error: print(error) -print(d2.format("a->b")) +print(d2.fmt("a->b")) ``` `validate` checks syntax and meaning, but does not lay the diagram out. Some errors, such as `top` and `left` with the dagre layout, only show when you render. @@ -135,7 +135,7 @@ See the [tests](/tests/test_d2) for more detailed usage examples. - [x] Shape tooltips, size, grids, direction and classes - [x] Diagram direction, classes and `d2-config` vars - [x] Scenarios and steps -- [x] `d2.validate` and `d2.format` through the d2 command +- [x] `d2.validate` and `d2.fmt` through the d2 command - [x] Glob shapes such as `Shape(name="*")` to style every shape - [x] Markdown, LaTeX or code as shape and connection labels (`label=d2.Text(...)`) - [x] `label_near` and `icon_near` positions, `top` and `left`, grid `vertical_gap` and `horizontal_gap` diff --git a/examples/README.md b/examples/README.md index edd1151..ab6c389 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,7 +23,7 @@ uv run python examples/shapes.py | [boards.py](boards.py) | Layers, scenarios and steps, rendering the root board and one layer | | [config.py](config.py) | Layout engine, theme, direction and sketch mode through `vars` | | [ascii.py](ascii.py) | ASCII output printed with `d2.render(diagram, "txt")` | -| [validation.py](validation.py) | `d2.validate`, `d2.format` and a glob shape that styles every shape | +| [validation.py](validation.py) | `d2.validate`, `d2.fmt` and a glob shape that styles every shape | | [labels_and_positions.py](labels_and_positions.py) | Markdown labels on shapes and connections, `label_near`, `icon_near`, connection icons and links | | [fixed_positions.py](fixed_positions.py) | `top` and `left` positions (tala) and grid `vertical_gap` and `horizontal_gap` | | [c4_and_sequence.py](c4_and_sequence.py) | The `c4-person` shape and a sequence diagram | diff --git a/examples/validation.py b/examples/validation.py index 708a346..4d80356 100644 --- a/examples/validation.py +++ b/examples/validation.py @@ -15,7 +15,7 @@ def main() -> None: ) d2.validate(diagram) - print(d2.format(diagram)) + print(d2.fmt(diagram)) try: d2.validate("api -> {") diff --git a/pyproject.toml b/pyproject.toml index c8019d6..066b459 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,12 +92,10 @@ ignore = [ "S606", # execs the resolved d2 binary ] "src/d2/_render.py" = [ - "A001", # public `format` function "A002", # public `format` parameter "PLC0415", # d2_bin is an optional dependency imported on use "S603", # runs the resolved d2 binary ] -"src/d2/__init__.py" = ["A004"] # public `format` function "src/d2/diagram.py" = ["A002"] # public `vars` parameter "src/d2/style.py" = ["FBT001"] # public positional bool parameters diff --git a/src/d2/__init__.py b/src/d2/__init__.py index ed74641..3a36490 100644 --- a/src/d2/__init__.py +++ b/src/d2/__init__.py @@ -1,4 +1,4 @@ -from ._render import D2Error, format, render, validate +from ._render import D2Error, fmt, render, validate from .class_shape import ClassField, ClassMethod, ClassShape from .connection import Arrowhead, ArrowheadShape, Connection, Direction from .diagram import Config, Diagram, Layer, Legend @@ -25,7 +25,7 @@ "ShapeType", "Style", "Text", - "format", + "fmt", "render", "validate", ] diff --git a/src/d2/_render.py b/src/d2/_render.py index 50ac85a..f50dc4e 100644 --- a/src/d2/_render.py +++ b/src/d2/_render.py @@ -87,6 +87,6 @@ def validate(source: Diagram | str) -> None: run_d2(["validate", "-"], source) -def format(source: Diagram | str) -> str: +def fmt(source: Diagram | str) -> str: formatted = run_d2(["fmt", "-"], source).decode() return formatted if formatted != "" else str(source) diff --git a/tests/test_d2/test_validate_format.py b/tests/test_d2/test_validate_format.py index 25438ef..89e65bf 100644 --- a/tests/test_d2/test_validate_format.py +++ b/tests/test_d2/test_validate_format.py @@ -23,25 +23,25 @@ def test_validate__raises_with_the_d2_message() -> None: @requires_d2 def test_format__normalizes_source() -> None: - assert d2.format("a->b\nx:{y}").split("\n")[:2] == ["a -> b", "x: {y}"] + assert d2.fmt("a->b\nx:{y}").split("\n")[:2] == ["a -> b", "x: {y}"] @requires_d2 def test_format__is_idempotent() -> None: - once = d2.format("a->b") + once = d2.fmt("a->b") - assert d2.format(once) == once + assert d2.fmt(once) == once @requires_d2 def test_format__returns_already_formatted_source_unchanged() -> None: - assert d2.format("a -> b\n") == "a -> b\n" + assert d2.fmt("a -> b\n") == "a -> b\n" @requires_d2 def test_format__raises_on_invalid_source() -> None: with pytest.raises(d2.D2Error): - d2.format("a -> {") + d2.fmt("a -> {") @requires_d2