carterkit 0.3.1__tar.gz → 0.4.0__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.
- {carterkit-0.3.1 → carterkit-0.4.0}/PKG-INFO +63 -18
- carterkit-0.4.0/README.md +141 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/__init__.py +14 -5
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/index.md +4 -4
- carterkit-0.4.0/carterkit/declare.py +280 -0
- carterkit-0.4.0/carterkit/dynamic.py +99 -0
- carterkit-0.4.0/carterkit/layout.py +444 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit.egg-info/PKG-INFO +63 -18
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit.egg-info/SOURCES.txt +5 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/pyproject.toml +1 -1
- carterkit-0.4.0/tests/test_client.py +153 -0
- carterkit-0.4.0/tests/test_declare.py +109 -0
- carterkit-0.4.0/tests/test_dynamic.py +67 -0
- carterkit-0.4.0/tests/test_ui.py +92 -0
- carterkit-0.3.1/README.md +0 -96
- carterkit-0.3.1/carterkit/layout.py +0 -87
- carterkit-0.3.1/tests/test_client.py +0 -32
- {carterkit-0.3.1 → carterkit-0.4.0}/LICENSE +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/__main__.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/bind.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/buffer.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/catalog.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/cli.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/client.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/codegen.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/accordion.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/actions.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/animations.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/appearance.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/button.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/cardList.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/carousel.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/chat.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/color-picker.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/control-def.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/date-picker.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/divider.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/flip-card.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/gauge.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/graph.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/group-def.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/haptics.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/image.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/joystick.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/label.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/layout-config.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/list.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/log-console.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/long-press.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/map.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/picker.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/privacy.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/progress-ring.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/pulse.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/qr-code.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/segmented.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/slider.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/spacer.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/sparkline.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/status-light.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/stepper.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/sync.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/terms.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/text-input.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/theming.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/toggle.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/visibility.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controldocs/web-view.md +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/controls.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/e2ee.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/grid.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/infer.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/py.typed +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/theming.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/tune.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit/validate.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit.egg-info/dependency_links.txt +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit.egg-info/entry_points.txt +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit.egg-info/requires.txt +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/carterkit.egg-info/top_level.txt +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/setup.cfg +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_bind.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_buffer.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_catalog.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_cli.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_codegen.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_controls.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_e2ee.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_grid.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_infer.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_layout.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_theming.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_tune.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_validate.py +0 -0
- {carterkit-0.3.1 → carterkit-0.4.0}/tests/test_validate_bindings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: carterkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Build and drive CAR-TER layouts from Python — the control docs are the library.
|
|
5
5
|
Author: Carter Beaudoin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -51,25 +51,70 @@ carterkit.examples("button") # documented example snippets
|
|
|
51
51
|
|
|
52
52
|
## Build a layout
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
layout:
|
|
54
|
+
Controls are **methods on the layout**, ids are positional, tabs and groups are context
|
|
55
|
+
managers, and bindings fold into kwargs. Each control method returns a **handle** you can
|
|
56
|
+
use as a binding target or patch later. Unknown control types and bad enum values raise
|
|
57
|
+
instead of silently shipping a broken layout:
|
|
58
58
|
|
|
59
59
|
```python
|
|
60
|
-
from carterkit import Layout
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
print(
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
from carterkit import Layout
|
|
61
|
+
|
|
62
|
+
with Layout("Dashboard", cols=4, rows=4) as ui:
|
|
63
|
+
ui.connect("ws://192.168.1.50:8765", channel="home")
|
|
64
|
+
with ui.tab("Main", icon="gauge"):
|
|
65
|
+
cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
|
|
66
|
+
listen="cpu", when={"msg_type": "metrics"})
|
|
67
|
+
ui.status_light("warn", visible=cpu > 90) # handle → visibility condition
|
|
68
|
+
ui.button("refresh", label="Refresh", send="refresh", request=True)
|
|
69
|
+
|
|
70
|
+
print(ui.findings()) # schema + grid + binding lint against the bundled catalog
|
|
71
|
+
ui.save("dashboard.json") # the composed layout, ready to push/load
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Binding sugar: `listen=`/`when=`/`event=` build a `sync`, and `send=`/`request=`/`payload=`
|
|
75
|
+
build an `action`; pass `sync=[...]`/`action={...}` (via `carterkit.bind`) for anything
|
|
76
|
+
fancier. A handle comparison (`cpu > 90`) becomes a real visibility condition; `==`/`!=`
|
|
77
|
+
stay normal Python, so use `.eq()`/`.neq()`. `help(carterkit.build.gauge)` prints any
|
|
78
|
+
control's documentation, straight from the bundled docs.
|
|
79
|
+
|
|
80
|
+
**Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
|
|
81
|
+
from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
|
|
82
|
+
builder reads better for generated ones):
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from carterkit.declare import Screen, Tab, Connect, Gauge, Button, StatusLight
|
|
86
|
+
|
|
87
|
+
class Dashboard(Screen, cols=4, rows=4):
|
|
88
|
+
relay = Connect("ws://192.168.1.50:8765", channel="home")
|
|
89
|
+
class Main(Tab, icon="gauge"):
|
|
90
|
+
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu")
|
|
91
|
+
warn = StatusLight(visible=cpu > 90)
|
|
92
|
+
refresh = Button(label="Refresh", send="refresh", request=True)
|
|
93
|
+
|
|
94
|
+
Dashboard.save("dashboard.json")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Dynamic groups
|
|
98
|
+
|
|
99
|
+
Generate controls in `for`/`if` loops (auto-placed in the group's own grid), or mark a
|
|
100
|
+
group `dynamic="event"` and replace its children live at runtime. Build that replacement
|
|
101
|
+
payload with `Fragment`, then lint it against the broadcasts your server actually emits —
|
|
102
|
+
catching events that never arrive, missing `children` arrays, and off-grid/invalid
|
|
103
|
+
injected controls before they ship:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import carterkit
|
|
107
|
+
from carterkit import Fragment
|
|
108
|
+
|
|
109
|
+
ui.group("Now Playing", span=(3, 4), cols=4, rows=3, dynamic="player_state")
|
|
110
|
+
|
|
111
|
+
frag = Fragment(cols=4, rows=3)
|
|
112
|
+
frag.label("title", text="Song", span=(1, 4))
|
|
113
|
+
frag.button("play", label="Play", send="play")
|
|
114
|
+
# your server broadcasts frag.payload("player_state") == {"msg_type": ..., "children": [...]}
|
|
115
|
+
|
|
116
|
+
print(carterkit.format_findings(
|
|
117
|
+
carterkit.lint_dynamic_traffic(ui.layout, [frag.payload("player_state")])))
|
|
73
118
|
```
|
|
74
119
|
|
|
75
120
|
Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `move_control`
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# carterkit
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/carterkit/)
|
|
4
|
+
[](https://pepy.tech/project/carterkit)
|
|
5
|
+
[](https://pypi.org/project/carterkit/)
|
|
6
|
+
|
|
7
|
+
Build and drive [CAR-TER](https://carterbeaudoin.net/CAR-TER) layouts from Python.
|
|
8
|
+
|
|
9
|
+
**The control docs are the library.** Every control's schema, fields, and examples
|
|
10
|
+
are parsed at runtime from the ControlDocs markdown bundled inside the package — the
|
|
11
|
+
exact same docs the CAR-TER app renders — so the catalog never drifts from the
|
|
12
|
+
definitions.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install carterkit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Explore the controls (zero config)
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import carterkit
|
|
22
|
+
|
|
23
|
+
carterkit.controls() # {type: schema} for every placeable control
|
|
24
|
+
carterkit.doc("gauge") # full parsed doc: fields, themeFields, examples
|
|
25
|
+
print(carterkit.doc_markdown("gauge")) # the rendered documentation prose
|
|
26
|
+
carterkit.examples("button") # documented example snippets
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Build a layout
|
|
30
|
+
|
|
31
|
+
Controls are **methods on the layout**, ids are positional, tabs and groups are context
|
|
32
|
+
managers, and bindings fold into kwargs. Each control method returns a **handle** you can
|
|
33
|
+
use as a binding target or patch later. Unknown control types and bad enum values raise
|
|
34
|
+
instead of silently shipping a broken layout:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from carterkit import Layout
|
|
38
|
+
|
|
39
|
+
with Layout("Dashboard", cols=4, rows=4) as ui:
|
|
40
|
+
ui.connect("ws://192.168.1.50:8765", channel="home")
|
|
41
|
+
with ui.tab("Main", icon="gauge"):
|
|
42
|
+
cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
|
|
43
|
+
listen="cpu", when={"msg_type": "metrics"})
|
|
44
|
+
ui.status_light("warn", visible=cpu > 90) # handle → visibility condition
|
|
45
|
+
ui.button("refresh", label="Refresh", send="refresh", request=True)
|
|
46
|
+
|
|
47
|
+
print(ui.findings()) # schema + grid + binding lint against the bundled catalog
|
|
48
|
+
ui.save("dashboard.json") # the composed layout, ready to push/load
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Binding sugar: `listen=`/`when=`/`event=` build a `sync`, and `send=`/`request=`/`payload=`
|
|
52
|
+
build an `action`; pass `sync=[...]`/`action={...}` (via `carterkit.bind`) for anything
|
|
53
|
+
fancier. A handle comparison (`cpu > 90`) becomes a real visibility condition; `==`/`!=`
|
|
54
|
+
stay normal Python, so use `.eq()`/`.neq()`. `help(carterkit.build.gauge)` prints any
|
|
55
|
+
control's documentation, straight from the bundled docs.
|
|
56
|
+
|
|
57
|
+
**Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
|
|
58
|
+
from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
|
|
59
|
+
builder reads better for generated ones):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from carterkit.declare import Screen, Tab, Connect, Gauge, Button, StatusLight
|
|
63
|
+
|
|
64
|
+
class Dashboard(Screen, cols=4, rows=4):
|
|
65
|
+
relay = Connect("ws://192.168.1.50:8765", channel="home")
|
|
66
|
+
class Main(Tab, icon="gauge"):
|
|
67
|
+
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu")
|
|
68
|
+
warn = StatusLight(visible=cpu > 90)
|
|
69
|
+
refresh = Button(label="Refresh", send="refresh", request=True)
|
|
70
|
+
|
|
71
|
+
Dashboard.save("dashboard.json")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Dynamic groups
|
|
75
|
+
|
|
76
|
+
Generate controls in `for`/`if` loops (auto-placed in the group's own grid), or mark a
|
|
77
|
+
group `dynamic="event"` and replace its children live at runtime. Build that replacement
|
|
78
|
+
payload with `Fragment`, then lint it against the broadcasts your server actually emits —
|
|
79
|
+
catching events that never arrive, missing `children` arrays, and off-grid/invalid
|
|
80
|
+
injected controls before they ship:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import carterkit
|
|
84
|
+
from carterkit import Fragment
|
|
85
|
+
|
|
86
|
+
ui.group("Now Playing", span=(3, 4), cols=4, rows=3, dynamic="player_state")
|
|
87
|
+
|
|
88
|
+
frag = Fragment(cols=4, rows=3)
|
|
89
|
+
frag.label("title", text="Song", span=(1, 4))
|
|
90
|
+
frag.button("play", label="Play", send="play")
|
|
91
|
+
# your server broadcasts frag.payload("player_state") == {"msg_type": ..., "children": [...]}
|
|
92
|
+
|
|
93
|
+
print(carterkit.format_findings(
|
|
94
|
+
carterkit.lint_dynamic_traffic(ui.layout, [frag.payload("player_state")])))
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `move_control`
|
|
98
|
+
over a held draft; `lay.buffer` exposes it.
|
|
99
|
+
|
|
100
|
+
`infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
|
|
101
|
+
`codegen.generate_service_stub(layout)` emits a runnable MeshSocket server skeleton;
|
|
102
|
+
`theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
|
|
103
|
+
|
|
104
|
+
## CLI
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
carterkit catalog # list every control type
|
|
108
|
+
carterkit doc gauge # print a control's documentation
|
|
109
|
+
carterkit examples button # list a control's examples (--name to print one)
|
|
110
|
+
carterkit validate layout.json # lint a layout (exit 1 on errors)
|
|
111
|
+
carterkit gen layout.json # generate a MeshSocket service stub
|
|
112
|
+
carterkit relay --port 8765 # run the bundled MeshSocket relay
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Drive a device
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from carterkit import CarterClient
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
async with CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
|
|
123
|
+
channel="home", role="device", name="my-hub") as c:
|
|
124
|
+
c.on("toggle", lambda d: {"ok": True, **d})
|
|
125
|
+
await c.broadcast("reading", {"temp_c": 21.4})
|
|
126
|
+
await asyncio.sleep(60)
|
|
127
|
+
# leaving the `async with` disconnects automatically
|
|
128
|
+
|
|
129
|
+
asyncio.run(main())
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when you
|
|
133
|
+
pass an `e2ee_key`. Send a push to every device on a Connect+ account with
|
|
134
|
+
`CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
|
|
135
|
+
|
|
136
|
+
## Built on
|
|
137
|
+
|
|
138
|
+
[`meshsocket`](https://pypi.org/project/meshsocket/) — the WebSocket mesh transport.
|
|
139
|
+
|
|
140
|
+
The ControlDocs are vendored from the CAR-TER app repo; refresh them with
|
|
141
|
+
`scripts/sync-controldocs.sh`.
|
|
@@ -7,21 +7,24 @@ renders. So the docs never drift from the definitions; they are one and the same
|
|
|
7
7
|
|
|
8
8
|
Quick map:
|
|
9
9
|
- ``controls()`` / ``doc()`` / ``examples()`` — the docs-as-catalog surface
|
|
10
|
+
- ``Layout`` / ``Fragment`` — the flat builder: controls as methods, live handles,
|
|
11
|
+
context-manager tabs/groups (``declare.Screen`` is the declarative-class veneer)
|
|
10
12
|
- ``LayoutBuffer`` — incrementally build a layout (auto-placement, dedupe)
|
|
11
13
|
- ``validate_layout()`` — schema + grid lint against the bundled catalog
|
|
14
|
+
- ``lint_dynamic_traffic()`` — check ``dynamic=`` groups against observed broadcasts
|
|
12
15
|
- ``infer`` / ``codegen`` / ``theming`` / ``tune`` — generate layouts, servers, themes
|
|
13
16
|
- ``CarterClient`` / ``notify_http`` — connect over MeshSocket, push, send alerts
|
|
14
17
|
"""
|
|
15
18
|
from importlib.resources import files
|
|
16
19
|
from pathlib import Path
|
|
17
20
|
|
|
18
|
-
from . import catalog, grid, codegen, infer, theming, tune
|
|
21
|
+
from . import catalog, grid, codegen, infer, theming, tune, dynamic
|
|
19
22
|
from .buffer import LayoutBuffer, BufferError
|
|
20
23
|
from .validate import validate_layout as _validate_layout, format_findings
|
|
21
24
|
from .client import CarterClient, notify_http, CarterNotifyError
|
|
22
25
|
from . import bind
|
|
23
26
|
from .controls import build, control
|
|
24
|
-
from .layout import Layout
|
|
27
|
+
from .layout import Layout, Fragment, Control, Condition
|
|
25
28
|
|
|
26
29
|
try:
|
|
27
30
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
@@ -66,12 +69,18 @@ def validate_layout(layout: dict, catalog_: dict = None) -> list:
|
|
|
66
69
|
return _validate_layout(layout, catalog_ if catalog_ is not None else controls(include_theme=True))
|
|
67
70
|
|
|
68
71
|
|
|
72
|
+
def lint_dynamic_traffic(layout: dict, observed, catalog_: dict = None) -> list:
|
|
73
|
+
"""Lint a layout's `dynamic=` groups against observed broadcast payloads. Returns
|
|
74
|
+
`validate`-style findings (see `dynamic.lint_dynamic_traffic`)."""
|
|
75
|
+
return dynamic.lint_dynamic_traffic(layout, observed, catalog=catalog_)
|
|
76
|
+
|
|
77
|
+
|
|
69
78
|
__all__ = [
|
|
70
79
|
"__version__", "PROTOCOL_VERSION",
|
|
71
80
|
"CarterClient", "notify_http", "CarterNotifyError",
|
|
72
81
|
"LayoutBuffer", "BufferError",
|
|
73
82
|
"controls", "doc", "doc_markdown", "examples", "validate_layout",
|
|
74
|
-
"format_findings", "controldocs_dir",
|
|
75
|
-
"build", "control", "bind", "Layout",
|
|
76
|
-
"catalog", "grid", "codegen", "infer", "theming", "tune",
|
|
83
|
+
"lint_dynamic_traffic", "format_findings", "controldocs_dir",
|
|
84
|
+
"build", "control", "bind", "Layout", "Fragment", "Control", "Condition",
|
|
85
|
+
"catalog", "grid", "codegen", "infer", "theming", "tune", "dynamic",
|
|
77
86
|
]
|
|
@@ -133,9 +133,9 @@ Tap any node in the graph to explore the full documentation for each control typ
|
|
|
133
133
|
|
|
134
134
|
1. **Explore the docs** — tap nodes in the graph to learn about each control
|
|
135
135
|
2. **Write a layout** — create a JSON file following the [[layout-config]] schema
|
|
136
|
-
3. **Run a server** —
|
|
136
|
+
3. **Run a server** — drive your layout from Python with `pip install carterkit`, or
|
|
137
|
+
speak the MeshSocket protocol directly from any language
|
|
137
138
|
4. **Connect** — load your layout and watch it come alive
|
|
138
139
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
[github.com/carterbeaudoin/MeshSocket](https://github.com/carterbeaudoin/MeshSocket)
|
|
140
|
+
Full developer docs — building servers, the wire protocol, and the `carterkit`
|
|
141
|
+
library — live at **carterbeaudoin.net/CAR-TER**.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Declarative layouts — a class veneer over the flat builder.
|
|
2
|
+
|
|
3
|
+
For static, hand-written dashboards this reads like a schema: a control's id is its
|
|
4
|
+
attribute name, tabs and groups are nested classes, and cross-control visibility falls
|
|
5
|
+
out of comparing handles. It compiles to exactly the same `Layout`/`LayoutBuffer` the
|
|
6
|
+
flat builder uses (so `.layout` is an ordinary layout dict) — pick whichever front door
|
|
7
|
+
fits: generated/dynamic UIs read better flat, fixed ones read better here.
|
|
8
|
+
|
|
9
|
+
from carterkit.declare import Screen, Tab, Group, Connect, Ref, Gauge, Button, Slider, StatusLight
|
|
10
|
+
|
|
11
|
+
class Bench(Screen, cols=4, rows=4):
|
|
12
|
+
relay = Connect("ws://192.168.1.50:8765", channel="lab")
|
|
13
|
+
|
|
14
|
+
class Main(Tab, icon="gauge"):
|
|
15
|
+
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2),
|
|
16
|
+
listen="cpu", when={"msg_type": "metrics"})
|
|
17
|
+
warn = StatusLight(visible=cpu > 90) # handle -> visibility cond
|
|
18
|
+
refresh = Button(label="Refresh", send="refresh", request=True)
|
|
19
|
+
|
|
20
|
+
class Motors(Group, span=(2, 2), cols=2, rows=2):
|
|
21
|
+
m0 = Slider(min=0, max=255)
|
|
22
|
+
m1 = Slider(min=0, max=255)
|
|
23
|
+
|
|
24
|
+
layout = Bench.layout # the dict
|
|
25
|
+
Bench.save("bench.json")
|
|
26
|
+
|
|
27
|
+
Control classes (`Gauge`, `Button`, `StatusLight`, `ColorPicker`, …) are generated from
|
|
28
|
+
the bundled catalog: a PascalCase name maps to its control type, so `from
|
|
29
|
+
carterkit.declare import *` (or attribute access) exposes one per placeable control.
|
|
30
|
+
Kwargs are the same sugar the flat builder takes (`listen=/when=/send=/request=/span=/
|
|
31
|
+
visible=`). For a reference to a control by literal id (forward refs, other tabs) use
|
|
32
|
+
`Ref("id") > 90`. `==`/`!=` keep normal Python semantics — use `.eq()`/`.neq()`.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from functools import lru_cache
|
|
37
|
+
|
|
38
|
+
from . import controls as _controls
|
|
39
|
+
from .layout import Layout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DeclareError(Exception):
|
|
43
|
+
"""Raised when a declarative Screen is shaped in a way that can't compile."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ─── references & conditions ─────────────────────────────────────────────────
|
|
47
|
+
class _Cmp:
|
|
48
|
+
"""A pending visibility condition: a handle/Ref compared to a value, resolved to
|
|
49
|
+
`{when, operator, value}` once attribute-name ids are known."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, target, op: str, value):
|
|
52
|
+
self.target, self.op, self.value = target, op, value
|
|
53
|
+
|
|
54
|
+
def resolve(self, idmap: dict) -> dict:
|
|
55
|
+
if isinstance(self.target, Ref):
|
|
56
|
+
when = self.target.id
|
|
57
|
+
else:
|
|
58
|
+
when = idmap.get(id(self.target))
|
|
59
|
+
if when is None:
|
|
60
|
+
raise DeclareError(
|
|
61
|
+
"a visibility condition references a control that isn't a named "
|
|
62
|
+
"attribute in this Screen — use Ref('id') to point at it by id")
|
|
63
|
+
return {"when": when, "operator": self.op, "value": self.value}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Comparable:
|
|
67
|
+
def __gt__(self, o): return _Cmp(self, "gt", o)
|
|
68
|
+
def __ge__(self, o): return _Cmp(self, "gte", o)
|
|
69
|
+
def __lt__(self, o): return _Cmp(self, "lt", o)
|
|
70
|
+
def __le__(self, o): return _Cmp(self, "lte", o)
|
|
71
|
+
def eq(self, o): return _Cmp(self, "eq", o)
|
|
72
|
+
def neq(self, o): return _Cmp(self, "neq", o)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Ref(_Comparable):
|
|
76
|
+
"""Reference another control by literal id, for visibility conditions:
|
|
77
|
+
``hidden = Label(visible=Ref("power").eq(True))``."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, id: str):
|
|
80
|
+
self.id = id
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Connect:
|
|
84
|
+
"""A connection declaration: ``relay = Connect(url, channel=..., role=..., token=...)``."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, url: str, **identity):
|
|
87
|
+
self.url, self.identity = url, identity
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ─── control specs ───────────────────────────────────────────────────────────
|
|
91
|
+
class _Spec(_Comparable):
|
|
92
|
+
"""A deferred control: its catalog `_ctype` plus the kwargs to build it. The id is
|
|
93
|
+
filled in from the attribute name at compile time."""
|
|
94
|
+
|
|
95
|
+
_ctype: str = ""
|
|
96
|
+
|
|
97
|
+
def __init__(self, **kwargs):
|
|
98
|
+
self._kwargs = kwargs
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@lru_cache(maxsize=None)
|
|
102
|
+
def _spec_class(ctype: str, clsname: str) -> type:
|
|
103
|
+
return type(clsname, (_Spec,), {"_ctype": ctype,
|
|
104
|
+
"__doc__": f"Declarative {ctype} control."})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@lru_cache(maxsize=1)
|
|
108
|
+
def _catalog() -> dict:
|
|
109
|
+
from . import controldocs_dir
|
|
110
|
+
from . import catalog as _catmod
|
|
111
|
+
return _catmod.build_catalog(controldocs_dir())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _type_for(clsname: str):
|
|
115
|
+
"""PascalCase class name -> catalog control type, or None if not a control."""
|
|
116
|
+
if not clsname[:1].isupper():
|
|
117
|
+
return None
|
|
118
|
+
ctype = clsname[0].lower() + clsname[1:]
|
|
119
|
+
return ctype if ctype in _catalog() else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ─── compile helpers ─────────────────────────────────────────────────────────
|
|
123
|
+
def _members(klass):
|
|
124
|
+
"""Ordered (name, value) of a class body, skipping dunders and internals."""
|
|
125
|
+
for name, value in vars(klass).items():
|
|
126
|
+
if name.startswith("_"):
|
|
127
|
+
continue
|
|
128
|
+
yield name, value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_sub(value, base) -> bool:
|
|
132
|
+
return isinstance(value, type) and issubclass(value, base)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _collect_ids(klass, idmap: dict) -> None:
|
|
136
|
+
"""Map every spec object (by identity) to its attribute-name id, recursively."""
|
|
137
|
+
for name, value in _members(klass):
|
|
138
|
+
if isinstance(value, _Spec):
|
|
139
|
+
idmap[id(value)] = name
|
|
140
|
+
elif _is_sub(value, (Tab, Group)):
|
|
141
|
+
_collect_ids(value, idmap)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resolve_kwargs(kwargs: dict, idmap: dict) -> dict:
|
|
145
|
+
return {k: (v.resolve(idmap) if isinstance(v, _Cmp) else v) for k, v in kwargs.items()}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _populate(scope, klass, idmap: dict) -> None:
|
|
149
|
+
"""Materialise a class body into a layout scope (a tab or group handle)."""
|
|
150
|
+
for name, value in _members(klass):
|
|
151
|
+
if isinstance(value, _Spec):
|
|
152
|
+
make = getattr(scope, value._ctype)
|
|
153
|
+
make(name, **_resolve_kwargs(value._kwargs, idmap))
|
|
154
|
+
elif _is_sub(value, Group):
|
|
155
|
+
gm = value._group_meta
|
|
156
|
+
gh = scope.group(gm["label"], id=name, span=gm["span"], cols=gm["cols"],
|
|
157
|
+
rows=gm["rows"], dynamic=gm["dynamic"], pulse=gm["pulse"],
|
|
158
|
+
hide_background=gm["hide_background"],
|
|
159
|
+
visible=(gm["visible"].resolve(idmap)
|
|
160
|
+
if isinstance(gm["visible"], _Cmp) else gm["visible"]))
|
|
161
|
+
with gh:
|
|
162
|
+
_populate(gh, value, idmap)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ─── declarative bases ───────────────────────────────────────────────────────
|
|
166
|
+
class Tab:
|
|
167
|
+
"""Base for a nested tab class: ``class Main(Tab, icon="gauge", cols=4, rows=6): ...``"""
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def __init_subclass__(cls, *, title: str = None, icon: str = "square.grid.2x2",
|
|
171
|
+
cols: int = None, rows: int = 6, columns: int = None, **rest):
|
|
172
|
+
super().__init_subclass__(**rest)
|
|
173
|
+
cls._tab_meta = {"title": title or cls.__name__, "icon": icon,
|
|
174
|
+
"cols": cols if cols is not None else (columns or 4), "rows": rows}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Group:
|
|
178
|
+
"""Base for a nested group class. Class kwargs mirror `Layout.group`:
|
|
179
|
+
``class Motors(Group, span=(2,2), cols=2, rows=2, dynamic="motor_state"): ...``"""
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def __init_subclass__(cls, *, label: str = None, span=None, cols: int = 4, rows: int = 4,
|
|
183
|
+
dynamic: str = None, visible=None, pulse=None,
|
|
184
|
+
hide_background: bool = None, **rest):
|
|
185
|
+
super().__init_subclass__(**rest)
|
|
186
|
+
cls._group_meta = {"label": label if label is not None else cls.__name__,
|
|
187
|
+
"span": span, "cols": cols, "rows": rows, "dynamic": dynamic,
|
|
188
|
+
"visible": visible, "pulse": pulse, "hide_background": hide_background}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _ScreenMeta(type):
|
|
192
|
+
@property
|
|
193
|
+
def layout(cls) -> dict:
|
|
194
|
+
return cls.build().layout
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Screen(metaclass=_ScreenMeta):
|
|
198
|
+
"""Base for a declarative layout. Subclass kwargs: ``name=``, ``cols=``, ``rows=``,
|
|
199
|
+
``accent=``. Access the result via ``.layout`` / ``.build()`` / ``.json()`` /
|
|
200
|
+
``.save(path)`` / ``.validate()`` / ``.findings()``."""
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def __init_subclass__(cls, *, name: str = None, cols: int = None, rows: int = 6,
|
|
204
|
+
columns: int = None, accent: str = "#667eea", **rest):
|
|
205
|
+
super().__init_subclass__(**rest)
|
|
206
|
+
cls._screen_meta = {"name": name or cls.__name__,
|
|
207
|
+
"cols": cols if cols is not None else (columns or 4),
|
|
208
|
+
"rows": rows, "accent": accent}
|
|
209
|
+
cls._compiled = None
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def _compile(cls) -> Layout:
|
|
213
|
+
meta = cls._screen_meta
|
|
214
|
+
lay = Layout(meta["name"], cols=meta["cols"], rows=meta["rows"], accent=meta["accent"])
|
|
215
|
+
|
|
216
|
+
idmap: dict = {}
|
|
217
|
+
_collect_ids(cls, idmap)
|
|
218
|
+
|
|
219
|
+
for _n, value in _members(cls):
|
|
220
|
+
if isinstance(value, Connect):
|
|
221
|
+
lay.connect(value.url, **value.identity)
|
|
222
|
+
|
|
223
|
+
tabs = [(n, v) for n, v in _members(cls) if _is_sub(v, Tab)]
|
|
224
|
+
loose = [(n, v) for n, v in _members(cls)
|
|
225
|
+
if isinstance(v, _Spec) or _is_sub(v, Group)]
|
|
226
|
+
if tabs:
|
|
227
|
+
if any(isinstance(v, _Spec) for _n, v in loose):
|
|
228
|
+
raise DeclareError(
|
|
229
|
+
"controls are defined directly on the Screen alongside Tab classes — "
|
|
230
|
+
"move them inside a Tab (controls must live in a tab)")
|
|
231
|
+
for _n, tabcls in tabs:
|
|
232
|
+
tm = tabcls._tab_meta
|
|
233
|
+
handle = lay.tab(tm["title"], icon=tm["icon"], cols=tm["cols"], rows=tm["rows"])
|
|
234
|
+
with handle:
|
|
235
|
+
_populate(handle, tabcls, idmap)
|
|
236
|
+
else:
|
|
237
|
+
_populate(lay, cls, idmap)
|
|
238
|
+
return lay
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def build(cls) -> Layout:
|
|
242
|
+
"""Compile (once) to a `Layout`. Cached on the class."""
|
|
243
|
+
if cls.__dict__.get("_compiled") is None:
|
|
244
|
+
cls._compiled = cls._compile()
|
|
245
|
+
return cls._compiled
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def json(cls, indent: int = 2) -> str:
|
|
249
|
+
return cls.build().json(indent)
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def save(cls, path: str, indent: int = 2) -> str:
|
|
253
|
+
return cls.build().save(path, indent)
|
|
254
|
+
|
|
255
|
+
@classmethod
|
|
256
|
+
def validate(cls) -> list:
|
|
257
|
+
return cls.build().validate()
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def findings(cls) -> str:
|
|
261
|
+
return cls.build().findings()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ─── module-level control-class access (PEP 562) ─────────────────────────────
|
|
265
|
+
_STATIC = {"Screen", "Tab", "Group", "Connect", "Ref", "DeclareError"}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def __getattr__(name: str):
|
|
269
|
+
ctype = _type_for(name)
|
|
270
|
+
if ctype is not None:
|
|
271
|
+
return _spec_class(ctype, name)
|
|
272
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def __dir__():
|
|
276
|
+
pascal = [t[0].upper() + t[1:] for t in _catalog()]
|
|
277
|
+
return sorted(_STATIC | set(pascal))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
__all__ = sorted(_STATIC | {t[0].upper() + t[1:] for t in _catalog()})
|