diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 852d74d..3edc248 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,3 +32,19 @@ jobs: python-version: ${{ matrix.python-version }} - run: uv sync --locked - run: uv run pytest + + examples: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + - run: uv sync --locked --extra bin + - run: | + project="$PWD" + output="$(mktemp -d)" + cd "$output" + for example in "$project"/examples/*.py; do + echo "::group::$example" + "$project/.venv/bin/python" "$example" + echo "::endgroup::" + done diff --git a/.gitignore b/.gitignore index ca9fb53..a3bfc0b 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,7 @@ _cache/ **/*/*test*.jpeg **/*/*test*.jpg **/*/*test*.png + +# example output +/examples/*.svg +/examples/*.d2 diff --git a/README.md b/README.md index a42ccad..28449ab 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,18 @@ This can be rendered using `d2 graph.d2 graph.svg && open graph.svg` or [https:/ ![example graph](/docs/images/d2.svg) +Pick a shape type with `shape=d2.ShapeType.` and add a tooltip with `tooltip=`: + +```python +db = d2.Shape( + name="db", + shape=d2.ShapeType.cylinder, + tooltip="Primary database", +) +``` + +A tooltip is shown when you hover the shape in the rendered SVG. Connections take `tooltip=` too. + ## Rendering `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. @@ -77,7 +89,7 @@ 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=`. 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"` 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. See the [tests](/tests/test_d2) for more detailed usage examples. @@ -116,7 +128,7 @@ import d2 diagram = d2.Diagram( comments=["Generated by d2-python"], direction="right", - vars=d2.Config(layout_engine="elk", theme_id=4, sketch=True), + 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"]), @@ -152,11 +164,7 @@ Use `layers`, `scenarios` and `steps` for other boards. Use `comments` (or `add_ ## Examples -See [`examples/`](examples/): - -```sh -uv run python examples/simple_sql_schema.py -``` +Runnable examples for every feature are in [`examples/`](examples/README.md). ## Development diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0d813a6 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# Examples + +Each script builds one diagram and writes it next to where you run it. Install the d2 binary with the `bin` extra, then run any example: + +```bash +uv sync --extra bin +uv run python examples/shapes.py +``` + +| Example | What it shows | +| --- | --- | +| [basic.py](basic.py) | Shapes, labels and a connection | +| [shapes.py](shapes.py) | Shape types such as cylinder, queue and person, and icons | +| [containers.py](containers.py) | Shapes inside shapes, and connections to nested shapes with dotted paths | +| [styles.py](styles.py) | Fill, stroke, font, bold, opacity, 3d, shadow and border radius | +| [connections.py](connections.py) | Labels, directions, styles and arrowheads, including crow's foot | +| [tooltips_and_links.py](tooltips_and_links.py) | Tooltips on shapes and connections, and shape links | +| [text_blocks.py](text_blocks.py) | Markdown and code text inside a shape | +| [grids.py](grids.py) | Grid layout with columns and gap | +| [classes.py](classes.py) | Reusable style classes | +| [sql_tables.py](sql_tables.py) | SQL tables with constraints and a foreign key | +| [uml_classes.py](uml_classes.py) | UML class shapes with fields, methods and inheritance | +| [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")` | + diff --git a/examples/ascii.py b/examples/ascii.py new file mode 100644 index 0000000..e1c4bf0 --- /dev/null +++ b/examples/ascii.py @@ -0,0 +1,13 @@ +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[d2.Shape(name="a"), d2.Shape(name="b")], + connections=[d2.Connection("a", "b", "hello")], + ) + print(d2.render(diagram, "txt").decode()) + + +if __name__ == "__main__": + main() diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..6436891 --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape(name="client", label="Web client"), + d2.Shape(name="server", label="API server"), + ], + connections=[d2.Connection("client", "server", "request")], + ) + Path("basic.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/boards.py b/examples/boards.py new file mode 100644 index 0000000..e7f3492 --- /dev/null +++ b/examples/boards.py @@ -0,0 +1,32 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + detail = d2.Diagram( + shapes=[d2.Shape(name="api"), d2.Shape(name="db")], + connections=[d2.Connection("api", "db")], + ) + diagram = d2.Diagram( + shapes=[d2.Shape(name="system")], + layers=[d2.Layer(name="detail", diagram=detail)], + scenarios=[ + d2.Layer( + name="db_down", + diagram=d2.Diagram( + shapes=[d2.Shape(name="db", style=d2.Style(fill="red"))] + ), + ) + ], + steps=[ + d2.Layer(name="one", diagram=d2.Diagram(shapes=[d2.Shape(name="start")])), + d2.Layer(name="two", diagram=d2.Diagram(shapes=[d2.Shape(name="finish")])), + ], + ) + Path("boards.svg").write_bytes(d2.render(diagram)) + Path("boards-detail.svg").write_bytes(d2.render(diagram, target="layers.detail")) + + +if __name__ == "__main__": + main() diff --git a/examples/classes.py b/examples/classes.py new file mode 100644 index 0000000..9cc4e20 --- /dev/null +++ b/examples/classes.py @@ -0,0 +1,23 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + classes={ + "service": d2.Style(fill="lightblue", stroke="blue", border_radius="8"), + "store": d2.Style(fill="lightgreen", stroke="green"), + }, + shapes=[ + d2.Shape(name="api", classes=["service"]), + d2.Shape(name="worker", classes=["service"]), + d2.Shape(name="db", shape=d2.ShapeType.cylinder, classes=["store"]), + ], + connections=[d2.Connection("api", "db"), d2.Connection("worker", "db")], + ) + Path("classes.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/config.py b/examples/config.py new file mode 100644 index 0000000..4877ed1 --- /dev/null +++ b/examples/config.py @@ -0,0 +1,17 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + direction="right", + vars=d2.Config(layout_engine="tala", theme_id=4, sketch=True, pad=40), + shapes=[d2.Shape(name="a"), d2.Shape(name="b"), d2.Shape(name="c")], + connections=[d2.Connection("a", "b"), d2.Connection("b", "c")], + ) + Path("config.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/connections.py b/examples/connections.py new file mode 100644 index 0000000..0b14f2a --- /dev/null +++ b/examples/connections.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + direction="right", + shapes=[d2.Shape(name=name) for name in ("a", "b", "c", "d", "e")], + connections=[ + d2.Connection("a", "b", "sends"), + d2.Connection("b", "a", direction=d2.Direction.FROM), + d2.Connection("a", "c", "both ways", direction=d2.Direction.BOTH), + d2.Connection("c", "d", direction=d2.Direction.NONE), + d2.Connection( + "d", + "e", + "styled", + style=d2.Style(stroke="red", stroke_dash=3, animated=True), + target_arrowhead=d2.Arrowhead(d2.ArrowheadShape.DIAMOND, filled=True), + ), + d2.Connection( + "e", + "a", + source_arrowhead=d2.Arrowhead(d2.ArrowheadShape.CF_ONE, label="1"), + target_arrowhead=d2.Arrowhead(d2.ArrowheadShape.CF_MANY, label="n"), + ), + ], + ) + Path("connections.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/containers.py b/examples/containers.py new file mode 100644 index 0000000..bd24bcd --- /dev/null +++ b/examples/containers.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape( + name="backend", + shapes=[ + d2.Shape(name="api"), + d2.Shape(name="worker"), + d2.Shape(name="cache", shapes=[d2.Shape(name="redis")]), + ], + connections=[d2.Connection("api", "worker", "enqueue")], + ), + d2.Shape(name="frontend"), + ], + connections=[ + d2.Connection("frontend", "backend.api", "HTTP"), + d2.Connection("backend.worker", "backend.cache.redis", "reads"), + ], + ) + Path("containers.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/grids.py b/examples/grids.py new file mode 100644 index 0000000..ac4d442 --- /dev/null +++ b/examples/grids.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape( + name="board", + grid_columns=3, + grid_gap=10, + shapes=[d2.Shape(name=f"cell{i}") for i in range(1, 7)], + ), + ], + ) + Path("grids.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/shapes.py b/examples/shapes.py new file mode 100644 index 0000000..da8aeee --- /dev/null +++ b/examples/shapes.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import d2 + +ICON = "https://icons.terrastruct.com/essentials%2F112-server.svg" + + +def main() -> None: + diagram = d2.Diagram( + direction="right", + shapes=[ + d2.Shape(name="db", label="Database", shape=d2.ShapeType.cylinder), + d2.Shape(name="jobs", shape=d2.ShapeType.queue), + d2.Shape(name="user", shape=d2.ShapeType.person), + d2.Shape(name="decision", shape=d2.ShapeType.diamond), + d2.Shape(name="internet", shape=d2.ShapeType.cloud), + d2.Shape(name="server", icon=ICON), + ], + ) + Path("shapes.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/simple.py b/examples/simple.py deleted file mode 100644 index f52d948..0000000 --- a/examples/simple.py +++ /dev/null @@ -1,22 +0,0 @@ -from pathlib import Path - -from d2.connection import Connection -from d2.diagram import Diagram -from d2.shape import Shape -from d2.style import Style - - -def main() -> None: - shapes = [ - Shape(name="shape_name1", style=Style(fill="red")), - Shape(name="shape_name2", style=Style(fill="blue")), - ] - connections = [Connection(shape_1="shape_name1", shape_2="shape_name2")] - - diagram = Diagram(shapes=shapes, connections=connections) - - Path("graph.d2").write_text(str(diagram), encoding="utf-8") - - -if __name__ == "__main__": - main() diff --git a/examples/simple_sql_schema.py b/examples/simple_sql_schema.py deleted file mode 100644 index f997f9d..0000000 --- a/examples/simple_sql_schema.py +++ /dev/null @@ -1,43 +0,0 @@ -from pathlib import Path - -from d2 import D2Error, render -from d2.diagram import Diagram -from d2.sql_table import SQLConstraint, SQLTable, create_foreign_key_connection - -FILE_NAME = "simple_sql_schema" - -# Create a new diagram -diagram = Diagram() - -# Create Users table -users = SQLTable("users") -users.add_field("id", "int", SQLConstraint.PRIMARY_KEY) -users.add_field("name", "varchar(255)") - -# Create Orders table -orders = SQLTable("orders") -orders.add_field("id", "int", SQLConstraint.PRIMARY_KEY) -orders.add_field("user_id", "int", SQLConstraint.FOREIGN_KEY) -orders.add_field("total", "decimal(10,2)") - -# Create connection -fk = create_foreign_key_connection("orders", "user_id", "users", "id") - -# Add tables and connections to the diagram -diagram.add_shape(users) -diagram.add_shape(orders) -diagram.add_connection(fk) - -# Write the diagram to a file -d2_path = Path(f"{FILE_NAME}.d2") -d2_path.write_text(str(diagram), encoding="utf-8") - -print(f"D2 diagram file created: {d2_path}") -print(str(diagram)) - -try: - svg_path = Path(f"{FILE_NAME}.svg") - svg_path.write_bytes(render(diagram, layout="elk")) - print(f"SVG diagram generated: {svg_path.resolve()}") -except (D2Error, FileNotFoundError) as e: - print(f"Error generating SVG: {e}") diff --git a/examples/sql_tables.py b/examples/sql_tables.py new file mode 100644 index 0000000..2b3bdba --- /dev/null +++ b/examples/sql_tables.py @@ -0,0 +1,25 @@ +from pathlib import Path + +import d2 +from d2.sql_table import SQLConstraint, SQLTable, create_foreign_key_connection + + +def main() -> None: + users = SQLTable("users") + users.add_field("id", "int", SQLConstraint.PRIMARY_KEY) + users.add_field("email", "varchar(255)", SQLConstraint.UNIQUE) + + orders = SQLTable("orders") + orders.add_field("id", "int", SQLConstraint.PRIMARY_KEY) + orders.add_field("user_id", "int", SQLConstraint.FOREIGN_KEY) + orders.add_field("total", "decimal(10,2)") + + diagram = d2.Diagram( + shapes=[users, orders], + connections=[create_foreign_key_connection("orders", "user_id", "users", "id")], + ) + Path("sql_tables.svg").write_bytes(d2.render(diagram, layout="elk")) + + +if __name__ == "__main__": + main() diff --git a/examples/styles.py b/examples/styles.py new file mode 100644 index 0000000..faffe2f --- /dev/null +++ b/examples/styles.py @@ -0,0 +1,33 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape( + name="colors", + style=d2.Style(fill="#fde68a", stroke="#92400e", stroke_width=3), + ), + d2.Shape( + name="text", + style=d2.Style( + font="mono", + font_size=24, + font_color="white", + bold=True, + fill="navy", + ), + ), + d2.Shape(name="faded", style=d2.Style(fill="red", opacity=0.3)), + d2.Shape(name="block", style=d2.Style(three_d=True)), + d2.Shape(name="shadowed", style=d2.Style(shadow=True)), + d2.Shape(name="rounded", style=d2.Style(border_radius="12")), + ], + ) + Path("styles.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/text_blocks.py b/examples/text_blocks.py new file mode 100644 index 0000000..920ed19 --- /dev/null +++ b/examples/text_blocks.py @@ -0,0 +1,24 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape( + name="notes", + explanation=d2.Text("# Notes\n- first\n- **second**", "md"), + ), + d2.Shape( + name="snippet", + code=d2.Text('print("hello")', "python"), + ), + ], + connections=[d2.Connection("notes", "snippet")], + ) + Path("text_blocks.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/tooltips_and_links.py b/examples/tooltips_and_links.py new file mode 100644 index 0000000..6dffd03 --- /dev/null +++ b/examples/tooltips_and_links.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + diagram = d2.Diagram( + shapes=[ + d2.Shape(name="api", tooltip="Public REST API"), + d2.Shape(name="docs", link="https://d2lang.com"), + d2.Shape(name="db", shape=d2.ShapeType.cylinder), + ], + connections=[ + d2.Connection("api", "db", tooltip="Reads and writes over TCP"), + ], + ) + Path("tooltips_and_links.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main() diff --git a/examples/uml_classes.py b/examples/uml_classes.py new file mode 100644 index 0000000..62867d9 --- /dev/null +++ b/examples/uml_classes.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import d2 + + +def main() -> None: + animal = d2.ClassShape( + "Animal", + fields=[d2.ClassField("name", "string", "+")], + methods=[d2.ClassMethod("eat", "food string", "bool", "+")], + ) + dog = d2.ClassShape( + "Dog", + fields=[d2.ClassField("breed", "string", "-")], + methods=[d2.ClassMethod("bark", visibility="+")], + ) + diagram = d2.Diagram( + shapes=[animal, dog], + connections=[ + d2.Connection( + "Dog", + "Animal", + target_arrowhead=d2.Arrowhead(d2.ArrowheadShape.TRIANGLE), + ) + ], + ) + Path("uml_classes.svg").write_bytes(d2.render(diagram)) + + +if __name__ == "__main__": + main()