diff --git a/README.md b/README.md index 28449ab..abc7f1d 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,26 @@ 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.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 + +d2.validate("a -> b") + +try: + d2.validate("a -> {") +except d2.D2Error as error: + print(error) + +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. See the [tests](/tests/test_d2) for more detailed usage examples. @@ -116,6 +135,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.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` +- [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..ab6c389 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")` | - +| [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 | +| [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/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/validation.py b/examples/validation.py new file mode 100644 index 0000000..4d80356 --- /dev/null +++ b/examples/validation.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + 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")], + ) + + d2.validate(diagram) + print(d2.fmt(diagram)) + + try: + d2.validate("api -> {") + except d2.D2Error as error: + print(f"invalid: {error}") + + Path("validation.svg").write_bytes(d2.render(diagram)) + + +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/src/d2/__init__.py b/src/d2/__init__.py index 7a884be..3a36490 100644 --- a/src/d2/__init__.py +++ b/src/d2/__init__.py @@ -1,9 +1,10 @@ -from ._render import D2Error, render +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 -from .shape import Shape, ShapeType, Text +from .diagram import Config, Diagram, Layer, Legend +from .shape import LabelPosition, NearPosition, Shape, ShapeType from .style import Style +from .text import Text __all__ = [ "Arrowhead", @@ -16,10 +17,15 @@ "D2Error", "Diagram", "Direction", + "LabelPosition", "Layer", + "Legend", + "NearPosition", "Shape", "ShapeType", "Style", "Text", + "fmt", "render", + "validate", ] diff --git a/src/d2/_render.py b/src/d2/_render.py index 2861811..f50dc4e 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 fmt(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..2056260 100644 --- a/src/d2/connection.py +++ b/src/d2/connection.py @@ -5,7 +5,14 @@ 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.text import Text if TYPE_CHECKING: from d2.style import Style @@ -56,13 +63,16 @@ 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, ) -> None: self.shape_1 = shape_1 self.shape_2 = shape_2 @@ -72,6 +82,9 @@ 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 [] def lines(self) -> list[str]: properties: list[str] = [] @@ -83,9 +96,21 @@ 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}") + + 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 +126,9 @@ def _key(self) -> tuple[object, ...]: self.source_arrowhead, self.target_arrowhead, self.tooltip, + self.icon, + self.link, + self.classes, ) @override diff --git a/src/d2/diagram.py b/src/d2/diagram.py index d41bb99..71e78c8 100644 --- a/src/d2/diagram.py +++ b/src/d2/diagram.py @@ -1,14 +1,22 @@ 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, +) 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): @@ -49,13 +57,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: NearPosition | 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 +113,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 +145,8 @@ 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, ) -> None: self.shapes = shapes or [] self.connections = connections or [] @@ -111,6 +157,8 @@ 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 def add_shape(self, shape: Shape) -> None: self.shapes.append(shape) @@ -155,10 +203,12 @@ 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) diff --git a/src/d2/shape.py b/src/d2/shape.py index ecfea83..7e9d032 100644 --- a/src/d2/shape.py +++ b/src/d2/shape.py @@ -7,11 +7,11 @@ from d2.helpers import ( add_label_and_properties, flatten, - indent_lines, non_empty, quote_label, quote_name, ) +from d2.text import Text if TYPE_CHECKING: from d2.connection import Connection @@ -50,29 +50,62 @@ 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", +] + + +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") + return label, [] class Shape: @@ -80,7 +113,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 +137,12 @@ 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, **kwargs: Text, ) -> None: self.name = name @@ -123,6 +162,12 @@ 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.kwargs = kwargs def add_shape(self, shape: Shape) -> None: @@ -143,6 +188,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 +233,12 @@ 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) + + 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..a5abfe3 --- /dev/null +++ b/tests/test_d2/test_d2_positions.py @@ -0,0 +1,107 @@ +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(shapes=[d2.Shape(name="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_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..89e65bf --- /dev/null +++ b/tests/test_d2/test_validate_format.py @@ -0,0 +1,81 @@ +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")])) + + +@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_format__normalizes_source() -> None: + assert d2.fmt("a->b\nx:{y}").split("\n")[:2] == ["a -> b", "x: {y}"] + + +@requires_d2 +def test_format__is_idempotent() -> None: + once = d2.fmt("a->b") + + assert d2.fmt(once) == once + + +@requires_d2 +def test_format__returns_already_formatted_source_unchanged() -> None: + 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.fmt("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"))], + ) + + assert b" None: + diagram = d2.Diagram(shapes=[d2.Shape(name="a", top=10, left=10)]) + + assert b" None: + assert d2.render("a -> b", format_)