diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3edc248..8ef7c02 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -48,3 +48,4 @@ jobs: "$project/.venv/bin/python" "$example" echo "::endgroup::" done + - run: uv run --locked --extra bin python docs/banner.py diff --git a/README.md b/README.md index 7b88114..8ac54c7 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,198 @@ # d2-python -![Banner](docs/images/banner.png) +![d2-python banner](docs/banner.svg) -An unofficial, fully typed python interface for building [.d2](https://github.com/terrastruct/d2) diagram files in python. +Build [D2](https://d2lang.com) diagrams with Python code. -This is a maintained hard fork of [MrBlenny/py-d2](https://github.com/MrBlenny/py-d2), which is no longer updated. -Coming from py-d2: import `d2` instead of `py_d2`, and drop the `D2` prefix from class names, such as `d2.Shape` for `py_d2.D2Shape`. +D2 is a language for writing diagrams as text. d2-python lets you build that text from code, so you can draw diagrams from data, loops and functions. You describe shapes and connections as typed Python objects, and the library writes the D2 and renders it to SVG, PNG, PDF or ASCII. -## Installation +This project is a maintained hard fork of [MrBlenny/py-d2](https://github.com/MrBlenny/py-d2). See [Coming from py-d2](#coming-from-py-d2) if you used it. + +## Install + +Add d2-python to your project with [uv](https://docs.astral.sh/uv/). The `bin` extra also installs the official D2 binary, so `d2.render` works without installing D2 yourself: ```bash -pip install d2-python +uv add 'd2-python[bin]' ``` -To render diagrams from Python without installing the d2 command yourself, add the `bin` extra, which brings in the official d2 binary: +Run a single script without a project: ```bash -pip install 'd2-python[bin]' -uv add 'd2-python[bin]' +uv run --with 'd2-python[bin]' diagram.py ``` -## Usage +Run the D2 command itself without installing anything, or install it once so `d2` is on your PATH: + +```bash +uvx d2-bin --version +uv tool install d2-bin +``` + +With pip, use `pip install 'd2-python[bin]'`. If you already have the `d2` command on your PATH, you can leave out the `bin` extra. + +## Quick start ```python import d2 -shapes = [ - d2.Shape(name="shape_name1", style=d2.Style(fill="red")), - d2.Shape(name="shape_name2", style=d2.Style(fill="blue")), -] -connections = [d2.Connection(shape_1="shape_name1", shape_2="shape_name2")] - -diagram = d2.Diagram(shapes=shapes, connections=connections) +diagram = d2.Diagram( + shapes=[ + d2.Shape(name="api", label="API"), + d2.Shape(name="db", label="Postgres", shape=d2.ShapeType.cylinder), + ], + connections=[d2.Connection("api", "db", "reads and writes")], +) -with open("graph.d2", "w", encoding="utf-8") as f: - f.write(str(diagram)) +print(diagram) # the D2 text +open("graph.svg", "wb").write(d2.render(diagram)) +print(d2.render(diagram, "txt").decode()) # ASCII art ``` -produces the following graph.d2 file: +`print(diagram)` prints this D2 text: ```d2 - -shape_name1: { - style: { - fill: red - } -} -shape_name2: { - style: { - fill: blue - } +api: API +db: Postgres { + shape: cylinder } -shape_name1 -> shape_name2 +api -> db: reads and writes +``` +The ASCII render looks like this: + +```text + ┌──────┐ + │ API │ + │ │ + └──────┘ + │ +reads and writes + │ + ▼ + .-‾‾‾‾-. + │╲-____-╱│ + │ │ + │Postgres│ + │ │ + ╲-____-╱ ``` -This can be rendered using `d2 graph.d2 graph.svg && open graph.svg` or [https://play.d2lang.com/](https://play.d2lang.com/) to produce +## Building diagrams -![example graph](/docs/images/d2.svg) +Every feature below has a runnable script in [examples/](examples/README.md). -Pick a shape type with `shape=d2.ShapeType.` and add a tooltip with `tooltip=`: +Shapes take a type, an icon, a tooltip and a link: ```python db = d2.Shape( name="db", shape=d2.ShapeType.cylinder, + icon="https://icons.terrastruct.com/essentials%2F117-database.svg", tooltip="Primary database", + link="https://example.com/runbook", ) ``` -A tooltip is shown when you hover the shape in the rendered SVG. Connections take `tooltip=` too. +A shape can hold other shapes. Connect nested shapes with a dotted path: -## Rendering +```python +backend = d2.Shape( + name="backend", shapes=[d2.Shape(name="api"), d2.Shape(name="worker")] +) +d2.Connection("backend.api", "backend.worker") +``` -`d2.render` runs the d2 command and returns the image as bytes. It uses the binary from `d2-bin` when that is installed, and otherwise looks for `d2` on your PATH. +Styles set colors, borders, fonts and effects: ```python -import d2 +d2.Style(fill="#dbeafe", stroke="navy", stroke_width=2, bold=True, shadow=True) +``` -diagram = d2.Diagram( - shapes=[d2.Shape(name="a"), d2.Shape(name="b")], - connections=[d2.Connection(shape_1="a", shape_2="b")], +Connections take a direction, a label, a style and arrowheads, including crow's foot ends for data models: + +```python +d2.Connection( + "orders", + "users", + "belongs to", + direction=d2.Direction.TO, + style=d2.Style(stroke="gray", animated=True), + source_arrowhead=d2.Arrowhead(d2.ArrowheadShape.CF_MANY), + target_arrowhead=d2.Arrowhead(d2.ArrowheadShape.CF_ONE_REQUIRED), ) +``` -with open("graph.svg", "wb") as f: - f.write(d2.render(diagram)) +A label can be Markdown, LaTeX or code: -print(d2.render(diagram, "txt").decode()) +```python +d2.Shape(name="notes", label=d2.Text("# Notes\nSome **bold** text", "md")) ``` -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. +Other features: -## Validating and formatting +- SQL tables with constraints, and UML class shapes with fields and methods. +- Grids, with `grid_rows`, `grid_columns` and gaps. +- Reusable style classes, set on the diagram and used by name. +- Layers, scenarios and steps, which are extra boards in one diagram. +- Diagram settings through `d2.Config`, such as the layout engine, theme and sketch mode. +- Variables with `${name}` substitution, and legends. +- Label and icon positions, and fixed `top` and `left` positions. +- Comments at the top of the file. -`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. +## Rendering -```python -import d2 +`d2.render(diagram, format)` runs d2 and returns the result as bytes. It uses the binary from `d2-bin` when that is installed, and otherwise the `d2` command on your PATH. You can also pass D2 text as a string. -d2.validate("a -> b") +| Option | Values | +|---|---| +| `format` | `"svg"` (default), `"png"`, `"pdf"`, `"gif"`, `"pptx"` or `"txt"` for ASCII | +| `layout` | `"dagre"`, `"elk"` or `"tala"` | +| `theme`, `dark_theme` | a D2 theme id | +| `target` | the board to render: the root by default, or a path such as `"layers.detail"` | +| Others | `sketch`, `pad`, `scale`, `center`, `omit_version`, `no_xml_tag`, `salt`, `animate_interval`, `ascii_mode`, `timeout` | + +If d2 fails, `render` raises `d2.D2Error` with d2's message. + +## Checking and formatting D2 -try: - d2.validate("a -> {") -except d2.D2Error as error: - print(error) +`d2.validate` runs `d2 validate` and raises `d2.D2Error` when the source is wrong. `d2.fmt` returns the source formatted by `d2 fmt`. Both take a `Diagram` or a string, so you can use them to check D2 text that a person or a language model wrote. +```python +d2.validate("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. - -See the [tests](/tests/test_d2) for more detailed usage examples. - - -## Supported Features - -- [x] Shapes (nodes) -- [x] Connections (links) -- [x] Styles -- [x] Containers (nodes/links in nodes) -- [x] Shapes in shapes -- [x] Arrow directions -- [x] Markdown / block strings / code in shapes -- [x] Icons in shapes -- [x] Support for empty labels -- [x] Shape links -- [x] SQL table shapes -- [x] Layers -- [x] Class shapes -- [x] Comments -- [x] Automatic quoting of names and labels -- [x] More style options (font, bold, italic, fill pattern and others) -- [x] Connection styles, arrowheads and tooltips -- [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 - -Names and labels that D2 cannot read bare (such as `a.b`, `x: y`, or the keyword `shape`) are quoted for you. Connection endpoints are written as given, so they can be dotted paths. -Style values you set are always written, even `0` or `False`. `None` means unset. -Colors are checked when the `Style` is built. Pass a CSS color name, `transparent`, a 3 or 6 digit hex code with or without `#`, a `linear-gradient(...)`, a `radial-gradient(...)`, a `${variable}` or a `d2.Color`. Hex codes are lowercased and written quoted, and anything else raises `ValueError`. -Numbers that D2 limits are checked too: `opacity` 0 to 1, `stroke_width` 0 to 15, `stroke_dash` 0 to 10, `font_size` 8 to 100, and non-negative `border_radius`, grid gaps, `top` and `left`. +`validate` checks the syntax and meaning of the source. It does not lay the diagram out, so a few errors only show when you render. -```python -import d2 +## How values are checked -diagram = d2.Diagram( - comments=["Generated by d2-python"], - direction="right", - vars=d2.Config(layout_engine="tala", theme_id=4, sketch=True), - classes={"hot": d2.Style(fill="orange", bold=True)}, - shapes=[ - d2.Shape(name="api", tooltip="Public API", width=200, classes=["hot"]), - d2.Shape( - name="grid", grid_columns=2, shapes=[d2.Shape(name="a"), d2.Shape(name="b")] - ), - d2.ClassShape( - "Animal", - fields=[d2.ClassField("name", "string", "+")], - methods=[d2.ClassMethod("eat", "food string", "bool", "+")], - ), - ], - connections=[ - d2.Connection( - "api", - "grid", - label="calls", - style=d2.Style(stroke="blue", animated=True), - target_arrowhead=d2.Arrowhead(d2.ArrowheadShape.DIAMOND, filled=True), - tooltip="HTTP", - ) - ], - scenarios=[ - d2.Layer(name="degraded", diagram=d2.Diagram(shapes=[d2.Shape(name="api")])) - ], - steps=[ - d2.Layer(name="step_one", diagram=d2.Diagram(shapes=[d2.Shape(name="api")])) - ], -) -``` +The library checks values when you create an object, so a mistake fails in your code instead of in d2. -Use `layers`, `scenarios` and `steps` for other boards. Use `comments` (or `add_comment`) for `# comment` lines at the top of the file. +- Names and labels that D2 cannot read as written are quoted for you, e.g. a name with a dot or a D2 keyword such as `shape`. Connection endpoints are written as given, so they can be dotted paths. +- A color can be a CSS color name, a 3 or 6 digit hex code with or without `#`, a gradient or a `${variable}`. You can pass a string or a `d2.Color`. The library writes hex codes in lowercase with `#` and in quotes, because a bare `#` starts a comment in D2. Any other value raises `ValueError`. +- Numbers that D2 limits are checked, e.g. `opacity` must be from 0 to 1. -## Examples +## Coming from py-d2 -Runnable examples for every feature are in [`examples/`](examples/README.md). +Import `d2` instead of `py_d2`, and drop the `D2` prefix from class names, e.g. `d2.Shape` instead of `py_d2.D2Shape`. The pull requests that were open on py-d2 are merged here, and most of its open issues are fixed. Issues and pull requests are welcome on this repository. ## Development -Requires [uv](https://docs.astral.sh/uv/). +You need [uv](https://docs.astral.sh/uv/). ```bash git clone git@github.com:h0rv/d2-python.git && cd d2-python -uv sync +uv sync --extra bin uv run pytest uv run ruff check uv run ruff format uv run ty check ``` + +To redraw the banner, run `uv run python docs/banner.py`. + +## License + +d2-python is MIT licensed. The `d2-bin` package contains the unmodified D2 binary, which is licensed under the MPL-2.0 by Terrastruct. diff --git a/docs/banner.py b/docs/banner.py new file mode 100644 index 0000000..6829d96 --- /dev/null +++ b/docs/banner.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import d2 + +me = Path(__file__) +svg = me.with_suffix(".svg") +blue, yellow = d2.Style(stroke="#3776ab"), d2.Style(stroke="#ffd43b") +code = d2.Shape(name="py", label=d2.Text(me.read_text(), "python"), style=blue) +image = d2.Shape(name="svg", label="", shape=d2.ShapeType.image, icon=str(svg)) +image.width, image.height = 600, 260 + +source = "" +for _ in range(3): + text = d2.Text("\n".join([*source.splitlines()[:4], "..."]), "d2") + diagram = d2.Diagram( + direction="right", + shapes=[code, d2.Shape(name="d2", label=text, style=yellow), image], + connections=[d2.Connection("py", "d2"), d2.Connection("d2", "svg")], + ) + source = str(diagram) + svg.write_bytes(d2.render(diagram, layout="elk", pad=20)) diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..14b1437 --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,84 @@ +from pathlib import Path + +import d2 + +me = Path(__file__) +svg = me.with_suffix(".svg") +blue, yellow = d2.Style(stroke="#3776ab"), d2.Style(stroke="#ffd43b") +code = d2.Shape(name="py", label=d2.Text(me.read_text(), "python"), style=blue) +image = d2.Shape(name="svg", label="", shape=d2.ShapeType.image, icon=str(svg)) +image.width, image.height = 600, 260 + +source = "" +for _ in range(3): +    text = d2.Text("\n".join([*source.splitlines()[:4], "..."]), "d2") +    diagram = d2.Diagram( +        direction="right", +        shapes=[code, d2.Shape(name="d2", label=text, style=yellow), image], +        connections=[d2.Connection("py", "d2"), d2.Connection("d2", "svg")], +    ) +    source = str(diagram) +    svg.write_bytes(d2.render(diagram, layout="elk", pad=20)) +from pathlib import Path + +import d2 + +me = Path(__file__) +svg = me.with_suffix(".svg") +blue, yellow = d2.Style(stroke="#3776ab"), d2.Style(stroke="#ffd43b") +code = d2.Shape(name="py", label=d2.Text(me.read_text(), "python"), style=blue) +image = d2.Shape(name="svg", label="", shape=d2.ShapeType.image, icon=str(svg)) +image.width, image.height = 600, 260 + +source = "" +for _ in range(3): +    text = d2.Text("\n".join([*source.splitlines()[:4], "..."]), "d2") +    diagram = d2.Diagram( +        direction="right", +        shapes=[code, d2.Shape(name="d2", label=text, style=yellow), image], +        connections=[d2.Connection("py", "d2"), d2.Connection("d2", "svg")], +    ) +    source = str(diagram) +    svg.write_bytes(d2.render(diagram, layout="elk", pad=20)) +direction: right +py: { +  label: |python +    from pathlib import Path +...direction: right +py: { +  label: |python +    from pathlib import Path +... + + + diff --git a/docs/images/banner.png b/docs/images/banner.png deleted file mode 100644 index 3472f6c..0000000 Binary files a/docs/images/banner.png and /dev/null differ diff --git a/docs/images/d2.svg b/docs/images/d2.svg deleted file mode 100644 index 2a7b0b3..0000000 --- a/docs/images/d2.svg +++ /dev/null @@ -1,31 +0,0 @@ - -node_name1node_name2 - - - diff --git a/pyproject.toml b/pyproject.toml index 066b459..ea9e0de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "d2-python" version = "1.1.0" -description = "An unofficial, fully typed python interface for building .d2 graph files in python." +description = "Build D2 diagrams with Python code." authors = [ {name = "David Revay", email = "daverevay@gmail.com"}, {name = "Robby", email = "h0rv@users.noreply.github.com"} @@ -83,6 +83,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**" = ["S101", "PLR2004"] "examples/**" = ["INP001", "T201"] +"docs/banner.py" = ["INP001"] # standalone script, not a package "packages/d2-bin/build_wheels.py" = [ "INP001", # standalone script, not a package "S310", # downloads fixed https release URLs