Metadata-Version: 2.4
Name: easydot
Version: 0.4.0
Summary: Tiny browser-side Graphviz DOT rendering helpers for notebooks and web views
Keywords: dot,graphviz,notebook,wasm
Author: Pablo Rodriguez Mier
Author-email: Pablo Rodriguez Mier <pablo.rodriguez.mier@gmail.com>
License-Expression: BSD-3-Clause
License-File: LICENSE
License-File: src/easydot/assets/LICENSE.hpcc-js-wasm
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Dist: pydot>=4,<5 ; extra == 'pydot'
Requires-Dist: wasi-graphviz>=0.1.0 ; extra == 'wasm'
Requires-Dist: wasmtime>=44 ; extra == 'wasm'
Requires-Python: >=3.11
Project-URL: Homepage, https://github.com/pablormier/easydot
Project-URL: Repository, https://github.com/pablormier/easydot
Project-URL: Issues, https://github.com/pablormier/easydot/issues
Provides-Extra: pydot
Provides-Extra: wasm
Description-Content-Type: text/markdown

<div align="center">

<img src="assets/easydot-logo.png" alt="easydot" width="300">

**High-quality Graphviz plots from Python, with browser, WASM, and native backends.**

[![pip install easydot](https://img.shields.io/badge/pip%20install-easydot-blue?logo=pypi&logoColor=white)](https://pypi.org/project/easydot/)
[![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-green)](LICENSE)
[![marimo](https://marimo.io/shield.svg)](https://marimo.app/l/939bsu)
[![Open in molab](https://molab.marimo.io/molab-shield.svg)](https://molab.marimo.io/notebooks/nb_mqYSyytiDeXt2eKhJyEkcE)

</div>

```bash
pip install easydot
```

```python
import easydot

easydot.render("digraph { A -> B -> C }")
```

### Themes

`Theme` supplies Graphviz defaults for a graph, its nodes, and its edges. The
theme statements are inserted immediately inside the root graph, so normal
Graphviz source-order rules apply: later DOT default statements affect elements
created after them, and explicit per-element attributes win for those elements.
Themes do not retroactively restyle earlier DOT statements.

```python
from easydot import Theme

theme = Theme(
    graph={"rankdir": "LR", "bgcolor": "transparent"},
    node={"shape": "box", "style": "rounded,filled", "fillcolor": "#eef2ff"},
    edge={"color": "#64748b", "arrowsize": 0.7},
)

dot = "digraph { A -> B -> C }"
easydot.plot(dot, theme=theme)

# Inspect or pass the prepared DOT to another Graphviz tool.
styled_dot = theme.apply(dot)
```

Themes can be extended without modifying the original:

```python
publication = theme.extend(node={"fontname": "Helvetica", "fontsize": 10})
```

Themes can also expose generic named styles for callers that build individual
nodes or edges programmatically. A named lookup returns only that style (plus
any overrides); the global `node` and `edge` mappings remain fallback defaults
applied by `theme=`.

```python
biology = theme.extend(
    nodes={"gene": {"shape": "box", "fillcolor": "#dbeafe"}},
    edges={"activation": {"color": "#15803d", "arrowhead": "normal"}},
)

gene_attrs = biology.node_attrs("gene", label="TP53")
edge_attrs = biology.edge_attrs("activation")
```

Named styles can be expanded explicitly by callers without requiring `pydot`.
For example, with optional `pydot` installed, a caller can use a named style
while building a graph:

```python
# Optional dependency: pip install pydot
import pydot

graph = pydot.Dot(graph_type="digraph")
graph.add_node(pydot.Node("TP53", **biology.node_attrs("gene")))
easydot.plot(graph, theme=biology)
```

### Preset vocabularies

The optional `easydot.themes` module provides small, ordinary `Theme`
instances for common research diagrams:

- `biology.nodes`: `gene`, `rna`, `protein`, `transcription_factor`
- `biology.edges`: `interaction`, `activation`, `inhibition`
- `signaling` adds `receptor`, `complex`, `small_molecule`, `phenotype`, and
  `phosphorylation`
- `gene_regulation` adds `promoter` and `transcription`

The specialized presets inherit the global defaults and all styles from
`biology`:

```python
from easydot.themes import biology, gene_regulation, signaling

graph.add_node(pydot.Node("EGFR", **signaling.node_attrs("receptor")))
graph.add_edge(pydot.Edge("TP53", "EGFR", **signaling.edge_attrs("activation")))
easydot.plot(graph, theme=signaling)
```

Presets are data, so domain-specific builders can extend them directly:

```python
metabolism = biology.extend(
    nodes={"metabolite": {"shape": "circle", "fillcolor": "#E0F2FE"}},
    edges={"conversion": {"arrowhead": "normal"}},
)
```

Named style attributes are materialized when a concrete node or edge is built;
`theme=` supplies only the graph/node/edge defaults and does not infer roles
from DOT. If a preset or renderer theme changes, rebuild programmatically
constructed elements to update their named-style appearance. Explicit DOT
attributes still win according to normal Graphviz source-order rules.

## Example

<img src="assets/example.png" alt="easydot example" width="800px">

---

## 💡 Why easydot

Graphviz is the best way to lay out DOT graphs, but the right runtime depends
on where your code is running. Native `dot` is great when it is installed;
browser rendering is better in notebooks and sandboxed frontends; server-side
WASM is useful when you want static SVGs without system binaries.

`easydot` gives all three paths a small Python API.

- **One entry point.** `easydot.render(...)` returns a rich notebook display object; `easydot.to_string(...)` returns raw HTML or SVG.
- **Three backends.** `browser` uses JS/WASM in the frontend, `wasm` uses `wasi-graphviz` in Python, and `native` shells to installed Graphviz executables.
- **Pip-installable default.** The browser backend has no Python dependencies and does not require `brew`, `conda`, `apt-get`, or Dockerfile changes.
- **Tiny notebook outputs.** The WASM bundle is vendored and served once over loopback instead of inlined into every cell.
- **Offline-capable.** Browser assets ship in the package; server-side backends do not need browser network access.

## 🔤 Why DOT

DOT is a small text format for graph diagrams. Many Python libraries and build tools can generate it.

- **Common output format.** [NetworkX](https://networkx.org/), [pydot](https://pypi.org/project/pydot/), [pygraphviz](https://pygraphviz.github.io/), [scikit-learn](https://scikit-learn.org) decision trees, [PyTorch](https://pytorch.org) and [TensorFlow](https://www.tensorflow.org) model viz, [Dask](https://www.dask.org/) task graphs, [Airflow](https://airflow.apache.org/) DAGs, Terraform, Bazel, Ninja, `gprof2dot`, and other tools can emit DOT.
- **LLM-friendly.** Models can usually generate DOT for architecture diagrams, state machines, and dependency graphs.
- **Plain text.** Diffs cleanly, templates easily, pipes nicely.
- **Graphviz features.** Five layout engines (`dot`, `neato`, `fdp`, `circo`, `twopi`), clusters, HTML-like labels, and styling.

## 🚀 Usage

### Quick start

`render()` is the main interface. It returns a `Graph` object that displays
in Jupyter, marimo, and other rich-output environments. Use `backend="auto"`
(the default) to select the first working backend, or pick one explicitly.

```python
import easydot

# Auto-select the best available backend (native → wasm → browser)
easydot.render("digraph { A -> B -> C }")

# Explicit backends
easydot.render("digraph { A -> B -> C }", backend="browser")   # browser JS/WASM
easydot.render("digraph { A -> B -> C }", backend="wasm")      # server-side WASM
easydot.render("digraph { A -> B -> C }", backend="native")    # native Graphviz

# Fit and scale work on all backends
easydot.render("digraph { A -> B -> C }", fit="horizontal")
easydot.render("digraph { A -> B -> C }", fit="both", scale=1.5)

# Raw output
easydot.svg("digraph { A -> B -> C }")                       # SVG string (wasm/native)
easydot.html("digraph { A -> B -> C }", fit="horizontal")    # display-ready HTML
easydot.native("digraph { A -> B -> C }", format="png")      # PNG bytes
easydot.plot("digraph { A -> B -> C }")                       # static SVG display
```

### SVG glyphs

`SvgGlyph` lets you draw a node with your own self-contained SVG while Graphviz
still lays out the graph and clips edges to a built-in ellipse or rectangular
box. Give the proxy node an explicit DOT `id`; easydot overlays the glyph on
that proxy in the final SVG.

```python
from easydot import SvgGlyph

dot = '''digraph {
  cell [id="cell-glyph", shape=ellipse, fixedsize=true,
        width=1.7, height=1.2, label="", color=transparent]
  cell -> next
}'''
glyphs = {"cell-glyph": SvgGlyph.from_file("cell.svg")}

easydot.render(dot, glyphs=glyphs)  # works with browser, WASM, and native SVG
```

The glyph is stretched to the proxy's bounds, so design its SVG canvas to match
the proxy aspect ratio. Edges connect to the ellipse or box boundary, not to an
arbitrary outline inside the artwork. Custom glyphs currently require SVG
output; `plot(..., format="png", glyphs=...)` is unsupported.

### Backend guide

| Backend   | Runtime             | Fit/scale | Best for                                      |
| --------- | ------------------- | --------- | --------------------------------------------- |
| `browser` | frontend JS/WASM    | ✓         | notebooks, marimo, JupyterLite, Pyodide       |
| `wasm`    | Python WASI runtime | ✓         | saved notebooks, GitHub, CI without Graphviz  |
| `native`  | Graphviz executable | ✓         | local/conda/server environments with Graphviz |

Check what works in the current runtime:

```python
caps = easydot.capabilities()
caps["browser"].available   # True if local or CDN browser assets are reachable
caps["wasm"].available      # True if wasi-graphviz can render a probe graph
caps["native"].available    # True if native dot can render a probe graph

# Format-aware synchronous backends for plot()
easydot.static_capabilities(format="png")
```

`backend="auto"` uses these probes and chooses `native`, then `wasm`, then
`browser` with CDN assets, then `browser` with local assets.
Probe results are cached in-process; pass `refresh_capabilities=True` to
`render(..., backend="auto")` or `refresh=True` to `capabilities()` if the
runtime changes after startup.

### Server-side WASM

For static SVG output that works in saved notebooks and GitHub without a live browser runtime:

```bash
pip install easydot[wasm]
```

```python
import easydot

# Raw SVG string
svg = easydot.svg("digraph { A -> B -> C }", backend="wasm")

# Rich display object for notebooks — fit and scale work the same as browser
easydot.render("digraph { A -> B -> C }", backend="wasm", fit="horizontal")

# Display-ready HTML with fit/scale
html = easydot.html("digraph { A -> B -> C }", backend="wasm", fit="both")
```

### Static notebook plots

Use `plot()` when the notebook output must be produced synchronously and
embedded as a self-contained image, including notebooks executed with
Papermill. It prefers native Graphviz and falls back to the Python WASM
backend; it never uses the asynchronous browser backend.

```python
import easydot

# SVG is the default and remains sharp when displayed in a notebook.
easydot.plot("digraph { A -> B -> C }")

# Request a raster image when a PNG-capable backend is available, typically native Graphviz.
easydot.plot("digraph { A -> B -> C }", format="png")
```

PNG support depends on the selected Graphviz build. If neither native
Graphviz nor the Python WASM build supports raster output, `plot(format="png")`
fails clearly; use the default SVG output in that environment.

### Native Graphviz

If Graphviz executables are installed and available on `PATH`, `easydot` can
render through the native toolchain:

```python
import easydot

svg = easydot.svg("digraph { A -> B -> C }", backend="native")
easydot.render("digraph { A -> B -> C }", backend="native", fit="horizontal")

# Non-SVG formats: native() returns bytes for binary formats
png_bytes = easydot.native("digraph { A -> B -> C }", format="png")
pdf_bytes = easydot.native("digraph { A -> B -> C }", format="pdf")
```

The native backend shells to the selected Graphviz engine, such as `dot` or
`neato`, and fails if the executable is missing or Graphviz returns an error.

### pydot

```bash
pip install easydot[pydot]
```

```python
import easydot, pydot

graph = pydot.Dot("example", graph_type="digraph")
graph.add_edge(pydot.Edge("A", "B"))

easydot.render(graph)
```

### NetworkX

```python
import easydot, networkx as nx
from networkx.drawing.nx_pydot import to_pydot

G = nx.DiGraph([("A", "B"), ("B", "C"), ("A", "C")])
easydot.render(to_pydot(G))
```

### CLI

```bash
# HTML output (default) — fit and scale work on all backends
echo 'digraph { A -> B }' | easydot                              # browser backend HTML
echo 'digraph { A -> B }' | easydot --backend auto              # best available backend
echo 'digraph { A -> B }' | easydot --backend wasm --fit horizontal   # WASM with fit
echo 'digraph { A -> B }' | easydot --backend native --scale 1.5      # native with scale

# Raw SVG (wasm or native only)
echo 'digraph { A -> B }' | easydot --format svg --backend wasm
echo 'digraph { A -> B }' | easydot --format svg --backend native

# Binary formats (native only)
echo 'digraph { A -> B }' | easydot --format png --backend native > graph.png
echo 'digraph { A -> B }' | easydot --format pdf --backend native > graph.pdf

easydot --urls                                                    # print asset server URLs
```

## 🔀 Source Modes

By default, `easydot` tries a pinned CDN URL first and falls back to the local server.
Known hosted notebook environments skip the local server probe, because a
Python-side `127.0.0.1` server is not browser-reachable there.

| Mode    | Local | CDN | Best for                                               |
| ------- | :---: | :-: | ------------------------------------------------------ |
| `auto`  |  yes  | yes | Most setups (default; CDN first, then local fallback)  |
| `local` |  yes  | no  | Offline environments with no internet access           |
| `cdn`   |  no   | yes | Remote hosts where `127.0.0.1` isn't browser-reachable |

```python
easydot.render("digraph { A -> B }", source="cdn")
```

<details>
<summary><b>Environment variables</b></summary>

Set a notebook-wide default without editing every call:

```python
import os
os.environ["EASYDOT_SOURCE"] = "cdn"   # auto | local | cdn
```

Only applies when `source="auto"`. Explicit `source=` arguments still win.

For hosted marimo environments that protect generated iframe file URLs, force a self-contained iframe:

```python
os.environ["EASYDOT_IFRAME_MODE"] = "srcdoc"   # auto | managed | srcdoc | data
```

PyCharm notebooks are detected automatically and use a `data:` iframe because
their output recycling can detach and reattach `srcdoc` iframes while scrolling.
You can force that wrapper explicitly with `EASYDOT_IFRAME_MODE="data"`.

The same modes are available per render call:

```python
easydot.render("digraph { A -> B }", iframe_mode="data")
```

</details>

## 📓 marimo

Works out of the box. `easydot` detects marimo and uses its iframe display helper automatically, since marimo doesn't execute inline scripts from plain `text/html` outputs. All source modes work.

The managed iframe mode uses the installed notebook iframe helper when
available; otherwise it falls back to `srcdoc`.

```bash
uv run marimo edit examples/demo.py                                   # edit the demo
uv run marimo run examples/demo.py --headless --port 2718 --no-token  # read-only preview
```

## ⏳ Large Graphs

Browser rendering is asynchronous relative to notebook cell execution: a cell
can finish before the browser has loaded Graphviz WASM and produced the SVG.
By default, `easydot` renders on the output iframe's main thread and shows an
in-progress indicator while the graph is rendering. You can opt into Web Worker
rendering for large graphs.

```python
easydot.render(dot, worker=False)   # default: render on the output iframe's main thread
easydot.render(dot, worker="auto")  # try a worker, visibly fall back if unavailable
easydot.render(dot, worker=True)    # require a worker; no main-thread fallback
```

If worker rendering is unavailable and `worker="auto"` is used, `easydot` shows
a warning before falling back to main-thread rendering. Large graphs may freeze
that output iframe until Graphviz finishes in fallback mode.

## 🔌 Library Integration

For libraries that generate their own HTML, use the lower-level asset API:

```python
from easydot import asset_urls

js_url = asset_urls()["js"]
```

```js
const mod = await import(jsUrl);
const graphviz = await mod.Graphviz.load();
const svg = graphviz.layout("digraph { A -> B }", "svg", "dot");
```

> Need server-side rendering to files? Use `easydot.to_string(..., backend="wasm")`
> or `easydot.to_string(..., backend="native")`.

<details>
<summary><b>Runtime model</b></summary>

The asset server is intentionally narrow:

- Binds only to `127.0.0.1`
- OS-assigned ephemeral port
- Serves only known packaged files (no directory browsing)
- Long-lived cache headers
- Shuts down automatically when the Python process exits

</details>

## 📜 License

| Component              | License                                                                                                                                      |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `easydot` Python code  | [BSD-3-Clause](LICENSE)                                                                                                                      |
| Vendored Graphviz WASM | Apache-2.0, from [`@hpcc-js/wasm-graphviz`](https://www.npmjs.com/package/@hpcc-js/wasm-graphviz). Pinned version in `src/easydot/_version.py` |
