Skip to content

Add a python equivalent of mosaic-spec #1075

Description

@dangotbanned

Mosaic has me more excited about data visualization than I have been in a while!
I've been busy experimenting on my fork and this idea fell out.

Note

Apologies in advance, but I promise this wall of text was 100% human generated ...

Background

While diving through vgplot-python, the most notable gap for me has been the typing.

Why should I care what this guy has to say?

That's where I spent a lot of my time contributing to altair and more recently in narwhals too.

I weasled my way in by in fixing typing issues.
In the process, I built up a greater understanding of how a project works and then expanded to proposing/implementing more "fun" features 😉

Naturally, I fell back into this familiar pattern again (#1067 (comment)), (#1067 (comment)), (#1068), (#1069)

I'm happy to continue down the road of incremental improvements.
But I can see an see an alternative which - while larger in scope - has clear boundaries and could even be optional for vgplot-python itself.

mosaic-spec-python

I write 0% TS/JS but have understood the majority of mosaic-spec on first look.
There are some more exotic things that haven't made their way to python yet (PEP 827 - Type Manipulation), which I needed to read up on.

However, I believe that it would be possible to define a python counterpart to mosaic-spec, primarily using TypedDict where you have Interfaces 1.

To demonstrate, I've (hand-)written vgplot/_interactors.py which semantically identical to:

There are links in that module to other resources on TypedDict 2, including an active transpiler project (ts2python) that may be of interest.

Important

While I had my fun writing the TypedDicts by hand, the proposal would be to generate them with full documentation

How can this benefit vgplot-python?

If improved typing and inline docs don't sell it, let's look at how it can simplify the runtime code too.

Consider, vg.area, which takes around 50 arguments:

Show me the goods

def area(
data: MarkData = None,
*,
aria_description: ChannelValue = UNSET,
aria_hidden: ChannelValue = UNSET,
aria_label: ChannelValue = UNSET,
channels: ChannelValue = UNSET,
clip: ChannelValue = UNSET,
curve: ChannelValue = UNSET,
dx: ChannelValue = UNSET,
dy: ChannelValue = UNSET,
facet: ChannelValue = UNSET,
facet_anchor: ChannelValue = UNSET,
fill: ChannelValue = UNSET,
fill_opacity: ChannelValue = UNSET,
filter: ChannelValue = UNSET,
fx: ChannelValue = UNSET,
fy: ChannelValue = UNSET,
href: ChannelValue = UNSET,
image_filter: ChannelValue = UNSET,
margin: ChannelValue = UNSET,
margin_bottom: ChannelValue = UNSET,
margin_left: ChannelValue = UNSET,
margin_right: ChannelValue = UNSET,
margin_top: ChannelValue = UNSET,
mix_blend_mode: ChannelValue = UNSET,
offset: ChannelValue = UNSET,
opacity: ChannelValue = UNSET,
order: ChannelValue = UNSET,
paint_order: ChannelValue = UNSET,
pointer_events: ChannelValue = UNSET,
reverse: ChannelValue = UNSET,
select: ChannelValue = UNSET,
shape_rendering: ChannelValue = UNSET,
sort: ChannelValue = UNSET,
stroke: ChannelValue = UNSET,
stroke_dasharray: ChannelValue = UNSET,
stroke_dashoffset: ChannelValue = UNSET,
stroke_linecap: ChannelValue = UNSET,
stroke_linejoin: ChannelValue = UNSET,
stroke_miterlimit: ChannelValue = UNSET,
stroke_opacity: ChannelValue = UNSET,
stroke_width: ChannelValue = UNSET,
target: ChannelValue = UNSET,
tension: ChannelValue = UNSET,
tip: ChannelValue = UNSET,
title: ChannelValue = UNSET,
x1: ChannelValue = UNSET,
x2: ChannelValue = UNSET,
y1: ChannelValue = UNSET,
y2: ChannelValue = UNSET,
z: ChannelValue = UNSET,
**options: Any,
) -> Mark:
"""The area mark."""
return _mark("area", locals())

Provided there is a TypedDict that matches AreaOptions, we can reduce the signature and implementation to 6 lines:

Showarea and friends

from __future__ import annotations

# ruff: noqa: N817, F841
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict
from typing import Literal as L

if TYPE_CHECKING:
    from typing_extensions import Unpack

PlotMarkData: TypeAlias = Any
ChannelValueSpec: TypeAlias = Any
ChannelValue: TypeAlias = Any


class MarkData(TypedDict):
    # https://github.com/uwdata/mosaic/blob/920bc67bd3134e6df947eeac30e65e2998cd2e23/packages/vgplot/spec/src/spec/marks/Marks.ts#L209-L214
    data: PlotMarkData


class MarkDataOptional(TypedDict, total=False):
    data: PlotMarkData


class AreaOptions(TypedDict, total=False):
    # https://github.com/uwdata/mosaic/blob/920bc67bd3134e6df947eeac30e65e2998cd2e23/packages/vgplot/spec/src/spec/marks/Area.ts#L6-L44
    x1: ChannelValueSpec
    x2: ChannelValueSpec
    y1: ChannelValueSpec
    y2: ChannelValueSpec
    z: ChannelValue
    # >40 inherited options
    # ...


class AreaData(MarkData, AreaOptions): ...


class Area(AreaData):
    mark: L["area"]


class AreaDataOptional(MarkDataOptional, AreaOptions):
    mark: L["area"]

# Yes, this is the whole thing
def area(
    data: PlotMarkData | None = None, **kwds: Unpack[AreaOptions]
) -> Area | AreaDataOptional:
    return (
        {"mark": "area", **kwds}
        if data is None
        else {"mark": "area", "data": data, **kwds}
    )


def area_2(**kwds: Unpack[AreaData]) -> AreaData:
    return kwds


def example() -> None:
    # Even without adding the full spec typing, this already catches things statically
    a = area()
    b = area([1, 2, 3], x1="a")
    c = area(i_dont_exist=1)  # pyright: ignore[reportCallIssue]

    unsafe_1 = a["x1"]  # pyright: ignore[reportTypedDictNotRequiredAccess]
    safe_1 = a.get("x1", {})

    # "No argument provided for required parameter `data` of function `area_2`"
    d = area_2()  # pyright: ignore[reportCallIssue] # ty: ignore[missing-argument]

Not only is the function defintion significantly shorter, but there is now no need for the UNSET sentinel.
Which currently every mark needs to check and remove from 50 fields.

If you consider now that marks.py has 64 functions, all with roughly the same length - maybe you can imagine how many LOC can be saved 😅

Why a package?

Good question, I'll start with an uno reverse card (https://idl.uw.edu/mosaic/spec/) 😄

More seriously, what I'm picturing is a lightweight python dependency for projects integrating with Mosaic.

For comparison, schema/latest.json is 8.29MB.
With that, you could get runtime only validation if you also depend on python-jsonschema/jsonschema (which has more dependencies including a rust extension module).

A package which defines the spec in python's type system 3 could be used with a runtime typing package/framework:

There's plenty on the menu

Note

That's not my area of interest, but I feel the need to show it as a possibility this unlocks

My first use case would be experimenting with an alternative fluent API, as I personally enjoy writing in that style.

Maybe I can convince you all of this hypothetical API that's worth chaining changing course for vgplot 4, but maybe there's no need?

If the representation is fully typed & documented anyone can build an API that makes them happy.
They just need to put all the pieces together and send the spec to vgplot/mosaic_widget 🙂

Footnotes

  1. Python's Protocol is conceptually an interface, but TypedDict was motivated by representing JSON objects with dict.

  2. As a bonus self-plug 😅 - the precursor idea (vega-lite-schema.json -> altair TypedDict) is here

  3. and nothing else

  4. I can't promise that I won't try

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonPull requests that update Python code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions