reflex-xy 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ target/
2
+ .venv/
3
+ smoke/
4
+ dist/
5
+ __pycache__/
6
+ *.pyc
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ node_modules/
10
+ *.egg-info/
11
+ .web/
12
+ **/assets/xy/*.xyf
13
+ # Generated render-client bundles (§33): built by `node js/build.mjs` and
14
+ # force-included into the wheel/sdist by hatch_build.py, never committed.
15
+ python/xy/static/index.js
16
+ python/xy/static/standalone.js
17
+ **/assets/external/
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to **reflex-xy** (the Reflex adapter for xy) are
4
+ documented here. The format follows
5
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [0.0.1] — 2026-07-24
8
+
9
+ ### Added
10
+ - First packaged release line of the adapter: `reflex_xy.chart()` components,
11
+ `@reflex_xy.figure` state vars with rebuild-from-state recovery,
12
+ `XYPlugin`/`setup()` app wiring, the `/_xy` socket.io data-plane namespace
13
+ (binary columns on the app's own websocket), semantic hover/click/select/
14
+ view events, fixed-data tiers (direct `Chart` + `inline()` tokens), and
15
+ streaming `append`.
16
+ - The distribution version is derived from `reflex-xy-vX.Y.Z` git tags
17
+ (uv-dynamic-versioning with `pattern-prefix = "reflex-xy-"`) instead of a
18
+ number in `pyproject.toml`; canonical pre-release tags (`aN`/`bN`/`rcN`)
19
+ publish too, and builds between tags are versioned `<next>.devN+<commit>`,
20
+ which PyPI rejects by design. The adapter and core
21
+ tag namespaces are mutually invisible by pattern anchoring: this derivation
22
+ never sees bare `v*` tags, and the core's never sees `reflex-xy-*` ones.
23
+ Releases publish via the dedicated
24
+ `.github/workflows/release-reflex-xy.yml` workflow — deliberately separate
25
+ from the core's cross-compile pipeline, since the adapter is one pure wheel
26
+ plus one sdist with no build matrix — verified by
27
+ `scripts/verify_reflex_xy_dist.py`, gated on this changelog, published with
28
+ PyPI trusted publishing, and validated alongside the other workflows by
29
+ `make check-ci`. `reflex_xy.__version__` now reports the installed
30
+ distribution's version (an uninstalled tree reports `0.0.0`).
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: reflex-xy
3
+ Version: 0.0.1
4
+ Summary: Reflex integration for xy: WebGL charts over the app's own websocket
5
+ Project-URL: Repository, https://github.com/reflex-dev/xy
6
+ Project-URL: Changelog, https://github.com/reflex-dev/xy/blob/main/python/reflex-xy/CHANGELOG.md
7
+ License: Apache-2.0
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: reflex>=0.9.6
10
+ Requires-Dist: xy
11
+ Provides-Extra: dev
12
+ Requires-Dist: aiohttp>=3.9; extra == 'dev'
13
+ Requires-Dist: uvicorn>=0.23; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # reflex-xy
17
+
18
+ [xy](https://github.com/reflex-dev/xy) figures as first-class
19
+ [Reflex](https://reflex.dev) components: WebGL rendering, million-point
20
+ interactivity, and streaming updates — with chart data riding the app's
21
+ **existing websocket**, not a sidecar API.
22
+
23
+ ## How it works
24
+
25
+ - **Control plane (Reflex-native).** The only chart state is a token string,
26
+ minted by a `@reflex_xy.figure` computed var. Semantic events —
27
+ `on_point_hover(event)`, `on_point_click(event)`, `on_select_end(event)`, and
28
+ `on_view_change(event)` — arrive as ordinary Reflex
29
+ event handlers with small JSON payloads.
30
+ - **Data plane (xy-native).** A second socket.io namespace (`/_xy`)
31
+ multiplexed onto the app's own engine.io websocket ships the spec as JSON
32
+ and every data column as a binary frame (no JSON numbers, no base64, no
33
+ extra endpoints to reverse-proxy). Pan/zoom/hover round-trips go straight
34
+ to the figure kernel and never touch Reflex state.
35
+ - **No figure server.** Figures live in a per-process registry as
36
+ *rebuildable caches*: the token encodes `(client, state, var)`, so any
37
+ backend worker can re-run the builder against Reflex state (already
38
+ distributed via redis in prod) when a reconnect lands on it.
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ # rxconfig.py
44
+ import reflex as rx
45
+ import reflex_xy
46
+
47
+ config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
48
+ ```
49
+
50
+ ```python
51
+ # dash/dash.py
52
+ import numpy as np
53
+ import reflex as rx
54
+ import xy
55
+ import reflex_xy
56
+
57
+
58
+ class Dash(rx.State):
59
+ points: int = 200_000
60
+ hovered: dict = {}
61
+
62
+ @reflex_xy.figure
63
+ def chart(self) -> xy.Chart:
64
+ rng = np.random.default_rng(7)
65
+ xs = rng.normal(size=self.points)
66
+ ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
67
+ return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)
68
+
69
+ @rx.event
70
+ def on_hover(self, row: dict):
71
+ self.hovered = row
72
+
73
+
74
+ def index() -> rx.Component:
75
+ return rx.vstack(
76
+ reflex_xy.chart(Dash.chart, on_point_hover=Dash.on_hover, height="460px"),
77
+ rx.text(Dash.hovered.to_string()),
78
+ width="100%",
79
+ )
80
+
81
+
82
+ app = rx.App()
83
+ ```
84
+
85
+ Change `points` in an event handler and the chart re-publishes itself to
86
+ every subscriber — the token never changes, so nothing re-renders except
87
+ pixels.
88
+
89
+ Builders can be `async def` (they become reflex `AsyncComputedVar`s, same
90
+ rule as `rx.var`) — await a database, HTTP endpoint, or dataframe store:
91
+
92
+ ```python
93
+ @reflex_xy.figure
94
+ async def remote(self) -> xy.Chart:
95
+ rows = await fetch_rows(self.query)
96
+ return xy.line_chart(xy.line(rows.t, rows.value))
97
+ ```
98
+
99
+ Streaming: `reflex_xy.append(token, x=[...], y=[...])` from any handler or
100
+ background task pushes an incremental update over the same socket.
101
+
102
+ ## Events and cross-filtering
103
+
104
+ Live token-backed charts emit versioned, bounded dictionaries with a stable
105
+ token and canonical row IDs. Selection rows can update ordinary State, which
106
+ causes dependent `@reflex_xy.figure` builders to republish without changing
107
+ their tokens or remounting the chart:
108
+
109
+ ```python
110
+ class Dash(rx.State):
111
+ selected_groups: list[str] = []
112
+
113
+ @rx.event
114
+ def filter_groups(self, event: dict):
115
+ selection = event["selection"]
116
+ self.selected_groups = [] if selection["cleared"] else sorted({
117
+ row["color_category"] for row in selection["rows"]
118
+ })
119
+
120
+ def index():
121
+ return rx.grid(
122
+ reflex_xy.chart(Dash.groups, on_select_end=Dash.filter_groups),
123
+ reflex_xy.chart(Dash.filtered_detail),
124
+ )
125
+ ```
126
+
127
+ ## Fixed-data charts
128
+
129
+ For a chart that doesn't depend on state, skip the state var entirely —
130
+ pass the Chart straight in:
131
+
132
+ ```python
133
+ def index() -> rx.Component:
134
+ return reflex_xy.chart(
135
+ xy.line_chart(xy.line(t, np.sin(t)), width="100%", height=220),
136
+ height="220px",
137
+ )
138
+ ```
139
+
140
+ That compiles the figure to a content-addressed binary asset at page build
141
+ and renders it with **zero backend involvement** — client-side hover,
142
+ pan/zoom, and density re-bin, same as `Figure.to_html()` exports; works
143
+ under `reflex export`. When a fixed chart still needs kernel round-trips
144
+ (deep drilldown into millions of points), register it once at module scope
145
+ instead:
146
+
147
+ ```python
148
+ cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) # module scope
149
+
150
+ def index() -> rx.Component:
151
+ return reflex_xy.chart(cloud, height="460px")
152
+ ```
153
+
154
+ `inline()` tokens are content-addressed, so every backend worker derives
155
+ the same one — no state, no coordination. The escalation path is:
156
+ direct Chart (static) → `inline()` (fixed data, live kernel) →
157
+ `@reflex_xy.figure` (per-session, state-driven).
@@ -0,0 +1,142 @@
1
+ # reflex-xy
2
+
3
+ [xy](https://github.com/reflex-dev/xy) figures as first-class
4
+ [Reflex](https://reflex.dev) components: WebGL rendering, million-point
5
+ interactivity, and streaming updates — with chart data riding the app's
6
+ **existing websocket**, not a sidecar API.
7
+
8
+ ## How it works
9
+
10
+ - **Control plane (Reflex-native).** The only chart state is a token string,
11
+ minted by a `@reflex_xy.figure` computed var. Semantic events —
12
+ `on_point_hover(event)`, `on_point_click(event)`, `on_select_end(event)`, and
13
+ `on_view_change(event)` — arrive as ordinary Reflex
14
+ event handlers with small JSON payloads.
15
+ - **Data plane (xy-native).** A second socket.io namespace (`/_xy`)
16
+ multiplexed onto the app's own engine.io websocket ships the spec as JSON
17
+ and every data column as a binary frame (no JSON numbers, no base64, no
18
+ extra endpoints to reverse-proxy). Pan/zoom/hover round-trips go straight
19
+ to the figure kernel and never touch Reflex state.
20
+ - **No figure server.** Figures live in a per-process registry as
21
+ *rebuildable caches*: the token encodes `(client, state, var)`, so any
22
+ backend worker can re-run the builder against Reflex state (already
23
+ distributed via redis in prod) when a reconnect lands on it.
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ # rxconfig.py
29
+ import reflex as rx
30
+ import reflex_xy
31
+
32
+ config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
33
+ ```
34
+
35
+ ```python
36
+ # dash/dash.py
37
+ import numpy as np
38
+ import reflex as rx
39
+ import xy
40
+ import reflex_xy
41
+
42
+
43
+ class Dash(rx.State):
44
+ points: int = 200_000
45
+ hovered: dict = {}
46
+
47
+ @reflex_xy.figure
48
+ def chart(self) -> xy.Chart:
49
+ rng = np.random.default_rng(7)
50
+ xs = rng.normal(size=self.points)
51
+ ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
52
+ return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)
53
+
54
+ @rx.event
55
+ def on_hover(self, row: dict):
56
+ self.hovered = row
57
+
58
+
59
+ def index() -> rx.Component:
60
+ return rx.vstack(
61
+ reflex_xy.chart(Dash.chart, on_point_hover=Dash.on_hover, height="460px"),
62
+ rx.text(Dash.hovered.to_string()),
63
+ width="100%",
64
+ )
65
+
66
+
67
+ app = rx.App()
68
+ ```
69
+
70
+ Change `points` in an event handler and the chart re-publishes itself to
71
+ every subscriber — the token never changes, so nothing re-renders except
72
+ pixels.
73
+
74
+ Builders can be `async def` (they become reflex `AsyncComputedVar`s, same
75
+ rule as `rx.var`) — await a database, HTTP endpoint, or dataframe store:
76
+
77
+ ```python
78
+ @reflex_xy.figure
79
+ async def remote(self) -> xy.Chart:
80
+ rows = await fetch_rows(self.query)
81
+ return xy.line_chart(xy.line(rows.t, rows.value))
82
+ ```
83
+
84
+ Streaming: `reflex_xy.append(token, x=[...], y=[...])` from any handler or
85
+ background task pushes an incremental update over the same socket.
86
+
87
+ ## Events and cross-filtering
88
+
89
+ Live token-backed charts emit versioned, bounded dictionaries with a stable
90
+ token and canonical row IDs. Selection rows can update ordinary State, which
91
+ causes dependent `@reflex_xy.figure` builders to republish without changing
92
+ their tokens or remounting the chart:
93
+
94
+ ```python
95
+ class Dash(rx.State):
96
+ selected_groups: list[str] = []
97
+
98
+ @rx.event
99
+ def filter_groups(self, event: dict):
100
+ selection = event["selection"]
101
+ self.selected_groups = [] if selection["cleared"] else sorted({
102
+ row["color_category"] for row in selection["rows"]
103
+ })
104
+
105
+ def index():
106
+ return rx.grid(
107
+ reflex_xy.chart(Dash.groups, on_select_end=Dash.filter_groups),
108
+ reflex_xy.chart(Dash.filtered_detail),
109
+ )
110
+ ```
111
+
112
+ ## Fixed-data charts
113
+
114
+ For a chart that doesn't depend on state, skip the state var entirely —
115
+ pass the Chart straight in:
116
+
117
+ ```python
118
+ def index() -> rx.Component:
119
+ return reflex_xy.chart(
120
+ xy.line_chart(xy.line(t, np.sin(t)), width="100%", height=220),
121
+ height="220px",
122
+ )
123
+ ```
124
+
125
+ That compiles the figure to a content-addressed binary asset at page build
126
+ and renders it with **zero backend involvement** — client-side hover,
127
+ pan/zoom, and density re-bin, same as `Figure.to_html()` exports; works
128
+ under `reflex export`. When a fixed chart still needs kernel round-trips
129
+ (deep drilldown into millions of points), register it once at module scope
130
+ instead:
131
+
132
+ ```python
133
+ cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y))) # module scope
134
+
135
+ def index() -> rx.Component:
136
+ return reflex_xy.chart(cloud, height="460px")
137
+ ```
138
+
139
+ `inline()` tokens are content-addressed, so every backend worker derives
140
+ the same one — no state, no coordination. The escalation path is:
141
+ direct Chart (static) → `inline()` (fixed data, live kernel) →
142
+ `@reflex_xy.figure` (per-session, state-driven).
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling", "uv-dynamic-versioning>=0.11"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reflex-xy"
7
+ dynamic = ["version"]
8
+ description = "Reflex integration for xy: WebGL charts over the app's own websocket"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ dependencies = [
13
+ "xy",
14
+ # Prototype dependency surface. The adapter needs: Component/Var/EventHandler
15
+ # (reflex-base), plus App/State/state_manager access, which today live in the
16
+ # full `reflex` distribution. Revisit when the reflex-base split grows a
17
+ # public state/app surface (spec/design/reflex-integration.md §4).
18
+ "reflex>=0.9.6",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ # tests/reflex_adapter drives the data plane over a real websocket:
23
+ # uvicorn serves the socket app, aiohttp backs socketio.AsyncClient.
24
+ dev = [
25
+ "aiohttp>=3.9",
26
+ "uvicorn>=0.23",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/reflex-dev/xy"
31
+ Changelog = "https://github.com/reflex-dev/xy/blob/main/python/reflex-xy/CHANGELOG.md"
32
+
33
+ # Same tag-derived versioning policy as the xy core (see the root
34
+ # pyproject.toml for the full rationale), in a tag namespace of its own:
35
+ # the adapter releases from `reflex-xy-vX.Y.Z` tags, cut by
36
+ # .github/workflows/release-reflex-xy.yml. `pattern-prefix` inserts
37
+ # `reflex-xy-` after the default pattern's `^` anchor, which keeps the two
38
+ # release lines disjoint in both directions — this derivation only sees
39
+ # `reflex-xy-v*` tags, while the core's bare-`v` pattern (and the docs-deploy
40
+ # CalVer tags) can never match them.
41
+ [tool.hatch.version]
42
+ source = "uv-dynamic-versioning"
43
+
44
+ [tool.uv-dynamic-versioning]
45
+ vcs = "git"
46
+ style = "pep440"
47
+ pattern-prefix = "reflex-xy-"
48
+ # Builds off a tagged commit get the bare version; anything else gets
49
+ # `<next>.devN+<commit>`, unpublishable to PyPI on purpose — only a tag can
50
+ # produce an uploadable version.
51
+ bump = true
52
+ # Only reached by a source tree with neither git metadata nor PKG-INFO; such
53
+ # a build still succeeds, at an obviously unreal version rather than a
54
+ # plausible-but-wrong one (unpacked sdists resolve from their own PKG-INFO).
55
+ fallback-version = "0.0.0"
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["reflex_xy"]
59
+ # reflex_xy/assets/XYChart.jsx ships as ordinary package data; the render
60
+ # client is NOT packaged — it links out of the installed xy wheel at
61
+ # app compile time (see reflex_xy/assets/__init__.py), so it can never
62
+ # drift from the kernel that produces its payloads.
@@ -0,0 +1,166 @@
1
+ """reflex-xy: xy figures as first-class Reflex components.
2
+
3
+ The integration in one paragraph (full design:
4
+ spec/design/reflex-integration.md in the xy repo): chart data rides
5
+ the app's *existing* websocket as a second socket.io namespace — binary
6
+ columns, no JSON numbers, no extra endpoints to proxy. Figures live in a
7
+ per-process registry keyed by tokens; the tokens live in Reflex state. A
8
+ `@reflex_xy.figure` state method is both the chart definition and the
9
+ recovery recipe: any worker can rebuild the figure from state when a
10
+ reconnect lands somewhere new, so there is no central figure store to
11
+ operate.
12
+
13
+ Quickstart::
14
+
15
+ # rxconfig.py
16
+ config = rx.Config(app_name="dash", plugins=[reflex_xy.XYPlugin()])
17
+
18
+ # dash/dash.py
19
+ import numpy as np
20
+ import reflex as rx
21
+ import xy
22
+ import reflex_xy
23
+
24
+ class Dash(rx.State):
25
+ points: int = 200_000
26
+
27
+ @reflex_xy.figure
28
+ def chart(self) -> xy.Chart:
29
+ rng = np.random.default_rng(7)
30
+ xs = rng.normal(size=self.points)
31
+ ys = xs * 0.6 + rng.normal(scale=0.6, size=self.points)
32
+ return xy.scatter_chart(xy.scatter(xs, ys), width="100%", height=460)
33
+
34
+ def index() -> rx.Component:
35
+ return reflex_xy.chart(Dash.chart, height="460px")
36
+
37
+ app = rx.App()
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import json
44
+ from typing import Any
45
+
46
+ from .app import XYPlugin, append, clear_selection, reset_view, select, set_view, setup
47
+ from .component import chart
48
+ from .events import (
49
+ CanonicalRowIdGroup,
50
+ DataBounds,
51
+ Modifiers,
52
+ PointClickEvent,
53
+ PointData,
54
+ PointHoverEvent,
55
+ ScreenPoint,
56
+ SelectEndEvent,
57
+ SelectionPayload,
58
+ ViewChangeEvent,
59
+ )
60
+ from .namespace import XY_NAMESPACE, XYNamespace
61
+ from .registry import FigureRegistry, _figure_of, registry
62
+ from .selections import resolve_selection
63
+ from .vars import AsyncFigureVar, FigureVar, figure
64
+
65
+ __all__ = [
66
+ "XY_NAMESPACE",
67
+ "AsyncFigureVar",
68
+ "CanonicalRowIdGroup",
69
+ "DataBounds",
70
+ "FigureRegistry",
71
+ "FigureVar",
72
+ "Modifiers",
73
+ "PointClickEvent",
74
+ "PointData",
75
+ "PointHoverEvent",
76
+ "ScreenPoint",
77
+ "SelectEndEvent",
78
+ "SelectionPayload",
79
+ "ViewChangeEvent",
80
+ "XYNamespace",
81
+ "XYPlugin",
82
+ "append",
83
+ "chart",
84
+ "clear_selection",
85
+ "figure",
86
+ "inline",
87
+ "register",
88
+ "registry",
89
+ "release",
90
+ "reset_view",
91
+ "resolve_selection",
92
+ "select",
93
+ "set_view",
94
+ "setup",
95
+ ]
96
+
97
+
98
+ def __getattr__(name: str) -> str:
99
+ """Resolve ``__version__`` lazily from the installed distribution.
100
+
101
+ The version is not written down in the source tree — it is derived from
102
+ the latest ``reflex-xy-vX.Y.Z`` git tag at build time (pyproject's
103
+ uv-dynamic-versioning config) and baked into the wheel's METADATA, so
104
+ package metadata is the only place that can answer this at runtime. An
105
+ uninstalled source tree reports the same unreal ``0.0.0`` the build-time
106
+ fallback uses.
107
+ """
108
+ if name == "__version__":
109
+ from importlib.metadata import PackageNotFoundError
110
+ from importlib.metadata import version as _distribution_version
111
+
112
+ try:
113
+ value = _distribution_version("reflex-xy")
114
+ except PackageNotFoundError:
115
+ value = "0.0.0"
116
+ globals()["__version__"] = value
117
+ return value
118
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
119
+
120
+
121
+ def register(chart_or_figure: Any) -> str:
122
+ """Imperatively register a chart; returns an opaque token for state.
123
+
124
+ Dev-tier API: the figure lives only in this process and cannot be
125
+ rebuilt after a worker restart or on another node — prefer
126
+ `@reflex_xy.figure` for anything long-lived (see the module doc).
127
+ """
128
+ return registry.register(_figure_of(chart_or_figure))
129
+
130
+
131
+ def inline(chart_or_figure: Any) -> str:
132
+ """Register a fixed, kernel-backed chart at module scope; returns its token.
133
+
134
+ For charts whose data never changes but which still want server-side
135
+ drilldown/picks on the shared websocket. Call at **module scope** so the
136
+ registration side effect runs in every backend worker (page bodies only
137
+ run where the frontend compiles)::
138
+
139
+ cloud = reflex_xy.inline(xy.scatter_chart(xy.scatter(x, y)))
140
+
141
+ def index():
142
+ return reflex_xy.chart(cloud, height="460px")
143
+
144
+ The token is content-addressed — every worker independently derives the
145
+ same one, so the frontend's baked-in token resolves everywhere without
146
+ state or rebuild hooks. The entry is pinned (exempt from the TTL sweep):
147
+ there is no recipe to rebuild it from, so it lives with the process.
148
+
149
+ Shared by design: one figure object serves every viewer, so kernel-side
150
+ drill state is shared too (like N notebook views of one widget). Data
151
+ depending on who's looking belongs in `@reflex_xy.figure`; data needing
152
+ no kernel at all can be passed straight to `reflex_xy.chart()` (static
153
+ payload tier).
154
+ """
155
+ fig = _figure_of(chart_or_figure)
156
+ spec, blob = fig.build_payload()
157
+ canonical = json.dumps(spec, sort_keys=True, separators=(",", ":")).encode()
158
+ digest = hashlib.sha256(canonical + blob).hexdigest()[:20]
159
+ token = f"xyin-{digest}"
160
+ registry.publish(token, fig, broadcast=False, pinned=True)
161
+ return token
162
+
163
+
164
+ def release(token: str) -> None:
165
+ """Drop a registered figure (idempotent)."""
166
+ registry.release(token)