Skip to content
dysonsphere

Writing an extension

An extension is an ordinary Python distribution that registers one entry point. Do that, build your charts on the public dysonsphere.ext surface, and they resolve under ds.<name> and behave like built-ins. This guide covers packaging, entry point registration, the ext primitives, and tagging generated data.

Ship a normal top-level package with its own dependencies. dysonsphere is a dependency, not something you vendor:

dysonsphere-yourname/
├── pyproject.toml
└── dysonsphere_yourname/
├── __init__.py
└── yourchart.py
dysonsphere_yourname/__init__.py
from .yourchart import yourchart
__all__ = ["yourchart"]

Cap the core dependency below the next major so a future breaking core release fails the resolver loudly rather than breaking your charts silently:

pyproject.toml
[project]
name = "dysonsphere-yourname"
dependencies = [
"dysonsphere>=3.0.0,<4",
"altair>=5.5.0",
"polars[pyarrow]>=1.19.0",
# your domain deps here - lazy-import the heavy/optional ones (see below)
]

One table in pyproject.toml registers your package under the dysonsphere.extensions group, keyed by the name you want on ds:

[project.entry-points."dysonsphere.extensions"]
yourname = "dysonsphere_yourname"

Now pip install dysonsphere-yourname makes ds.yourname.yourchart(...) work, and ds.extensions() lists yourname - with no change to core. That’s the entire integration contract.

Your chart is just a function that returns an Altair object. To make it a first-class dysonsphere chart - themed, darkmode-aware, and correctly handled by ds.read() - use the stable primitive surface, never _-private internals:

import altair as alt
import polars as pl
import dysonsphere as ds
from dysonsphere import ext
def yourchart(df, *, xCol="x", yCol="y") -> ext.AltairChart:
data = ds.ensure_polars(df) # accept polars or pandas
darkmode = bool(ext.opt("darkmode")) # read the active theme
accent = ds.colors["blues2"][10 if darkmode else 2]
points = alt.Chart(data).mark_point().encode(
x=alt.X(f"{xCol}:Q"),
y=alt.Y(f"{yCol}:Q"),
)
# A generated helper layer (e.g. a reference band) - TAG it (see step 4).
band = alt.Chart(ext.internal_data([{"lo": 0, "hi": 1}])).mark_rect(
color=accent, opacity=0.1,
).encode(y="lo:Q", y2="hi:Q")
return band + points

The ext surface is deliberately minimal - promoted primitive by primitive as real extensions need them. Today it is exactly three names:

  • ext.AltairChart - the chart-object union (Chart | LayerChart | FacetChart | VConcatChart | HConcatChart | ConcatChart). Use it as your return annotation, matching core’s own save().
  • ext.opt(key) - read an active-theme option (opt("markSize"), opt("chartWidth"), opt("darkmode"), …). It falls back to the derived built-in default when called before any ds.theme(), so your styling code never sees None sentinels. This is the only supported way to read theme options outside core.
  • ext.internal_data(data) - tag a generated sidecar dataset (next section).

Because colors resolve from the theme at call time, a chart exported for both light and dark must be built inside a ds.save() callable so it re-runs per variant:

ds.save(lambda: ds.yourname.yourchart(df), "figure")

Composite charts often generate their own small “sidecar” datasets - reference bands, computed label coordinates, fit lines. Altair inlines each as a separate dataset, so ds.read(what="data") needs to tell your generated data from the user’s frame.

The rule: route every dataset you computed through ext.internal_data(...), and never tag the frame the caller handed you.

# generated data -> tag it, so read(what="data") filters it out
alt.Chart(ext.internal_data(computed_rows))
# the user's own frame -> DO NOT tag it (tagging hides their data from read())
alt.Chart(ds.ensure_polars(df))

internal_data accepts a list[dict] (→ alt.Data) or a polars/pandas DataFrame (→ a tagged polars frame). Miss a sidecar and it leaks as a phantom “user” dataframe - a false multi-frame error from read, or the wrong frame returned. Tag the user’s frame and you hide their real data. So: data you computed → tag it; data the caller passed in → leave it.

Domain packages often need heavyweight, optional libraries (genome-file parsers, image stacks). Import them inside the function, not at module top level, with a clear error - so import dysonsphere_yourname stays cheap and only the charts that need the dependency require it:

def yourchart(df, ...):
try:
import pyBigWig
except ImportError as e:
raise ImportError("yourchart() needs pyBigWig: pip install pyBigWig") from e
...

dysonsphere-biology is the worked example - its volcano() is built entirely on core + ext, tags its label/threshold layers through internal_data, resolves colors from the theme, and lives in-repo as a uv workspace member. Read its source alongside this guide.