Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ jobs:
"$project/.venv/bin/python" "$example"
echo "::endgroup::"
done
- run: uv run --locked --extra bin python docs/banner.py
263 changes: 125 additions & 138 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.<name>` 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=<id>`. 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.
21 changes: 21 additions & 0 deletions docs/banner.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading