carterkit 0.1.0__tar.gz → 0.3.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.
Files changed (87) hide show
  1. {carterkit-0.1.0 → carterkit-0.3.0}/PKG-INFO +39 -18
  2. {carterkit-0.1.0 → carterkit-0.3.0}/README.md +38 -17
  3. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/__init__.py +5 -1
  4. carterkit-0.3.0/carterkit/__main__.py +3 -0
  5. carterkit-0.3.0/carterkit/bind.py +40 -0
  6. carterkit-0.3.0/carterkit/cli.py +128 -0
  7. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/client.py +8 -0
  8. carterkit-0.3.0/carterkit/controls.py +90 -0
  9. carterkit-0.3.0/carterkit/layout.py +87 -0
  10. carterkit-0.3.0/carterkit/py.typed +0 -0
  11. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/validate.py +20 -0
  12. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit.egg-info/PKG-INFO +39 -18
  13. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit.egg-info/SOURCES.txt +14 -1
  14. carterkit-0.3.0/carterkit.egg-info/entry_points.txt +2 -0
  15. {carterkit-0.1.0 → carterkit-0.3.0}/pyproject.toml +5 -2
  16. carterkit-0.3.0/tests/test_bind.py +40 -0
  17. carterkit-0.3.0/tests/test_cli.py +53 -0
  18. carterkit-0.3.0/tests/test_client.py +32 -0
  19. carterkit-0.3.0/tests/test_controls.py +54 -0
  20. carterkit-0.3.0/tests/test_layout.py +40 -0
  21. carterkit-0.3.0/tests/test_validate_bindings.py +40 -0
  22. {carterkit-0.1.0 → carterkit-0.3.0}/LICENSE +0 -0
  23. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/buffer.py +0 -0
  24. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/catalog.py +0 -0
  25. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/codegen.py +0 -0
  26. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/accordion.md +0 -0
  27. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/actions.md +0 -0
  28. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/animations.md +0 -0
  29. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/appearance.md +0 -0
  30. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/button.md +0 -0
  31. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/cardList.md +0 -0
  32. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/carousel.md +0 -0
  33. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/chat.md +0 -0
  34. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/color-picker.md +0 -0
  35. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/control-def.md +0 -0
  36. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/date-picker.md +0 -0
  37. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/divider.md +0 -0
  38. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/flip-card.md +0 -0
  39. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/gauge.md +0 -0
  40. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/graph.md +0 -0
  41. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/group-def.md +0 -0
  42. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/haptics.md +0 -0
  43. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/image.md +0 -0
  44. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/index.md +0 -0
  45. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/joystick.md +0 -0
  46. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/label.md +0 -0
  47. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/layout-config.md +0 -0
  48. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/list.md +0 -0
  49. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/log-console.md +0 -0
  50. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/long-press.md +0 -0
  51. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/map.md +0 -0
  52. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/picker.md +0 -0
  53. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/privacy.md +0 -0
  54. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/progress-ring.md +0 -0
  55. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/pulse.md +0 -0
  56. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/qr-code.md +0 -0
  57. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/segmented.md +0 -0
  58. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/slider.md +0 -0
  59. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/spacer.md +0 -0
  60. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/sparkline.md +0 -0
  61. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/status-light.md +0 -0
  62. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/stepper.md +0 -0
  63. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/sync.md +0 -0
  64. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/terms.md +0 -0
  65. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/text-input.md +0 -0
  66. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/theming.md +0 -0
  67. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/toggle.md +0 -0
  68. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/visibility.md +0 -0
  69. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/controldocs/web-view.md +0 -0
  70. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/e2ee.py +0 -0
  71. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/grid.py +0 -0
  72. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/infer.py +0 -0
  73. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/theming.py +0 -0
  74. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit/tune.py +0 -0
  75. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit.egg-info/dependency_links.txt +0 -0
  76. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit.egg-info/requires.txt +0 -0
  77. {carterkit-0.1.0 → carterkit-0.3.0}/carterkit.egg-info/top_level.txt +0 -0
  78. {carterkit-0.1.0 → carterkit-0.3.0}/setup.cfg +0 -0
  79. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_buffer.py +0 -0
  80. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_catalog.py +0 -0
  81. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_codegen.py +0 -0
  82. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_e2ee.py +0 -0
  83. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_grid.py +0 -0
  84. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_infer.py +0 -0
  85. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_theming.py +0 -0
  86. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_tune.py +0 -0
  87. {carterkit-0.1.0 → carterkit-0.3.0}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: carterkit
3
- Version: 0.1.0
3
+ Version: 0.3.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,23 +51,45 @@ carterkit.examples("button") # documented example snippets
51
51
 
52
52
  ## Build a layout
53
53
 
54
- ```python
55
- from carterkit import LayoutBuffer, validate_layout
56
-
57
- b = LayoutBuffer.blank(name="Dashboard", columns=4, rows=4)
58
- b.add_control({"type": "gauge", "id": "cpu", "label": "CPU", "min": 0, "max": 100},
59
- default_span=[2, 2])
60
- b.add_control({"type": "button", "id": "refresh", "label": "Refresh"})
54
+ The fluent `Layout` builder composes **typed builders** (`build.<control>`) and
55
+ **binding helpers** (`bind`) — all generated from / shaped by the bundled docs, so
56
+ unknown control types and bad enum values raise instead of silently shipping a broken
57
+ layout:
61
58
 
62
- layout = b.layout
63
- findings = validate_layout(layout) # schema + grid lint against the bundled catalog
64
- print(carterkit.format_findings(findings))
59
+ ```python
60
+ from carterkit import Layout, build, bind
61
+
62
+ lay = (Layout("Dashboard", columns=4, rows=4)
63
+ .connect("ws://192.168.1.50:8765", channel="home")
64
+ .tab("Main", icon="gauge")
65
+ .add(build.gauge(id="cpu", label="CPU", min=0, max=100,
66
+ sync=[bind.listen("cpu", filter={"msg_type": "metrics"})]),
67
+ default_span=[2, 2])
68
+ .add(build.button(id="refresh", label="Refresh", action=bind.action("refresh"))))
69
+
70
+ print(lay.findings()) # schema + grid + binding lint against the bundled catalog
71
+ help(build.gauge) # ← prints the gauge documentation, straight from the docs
72
+ layout = lay.layout # the composed dict, ready to push/save
65
73
  ```
66
74
 
75
+ Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `move_control`
76
+ over a held draft; `lay.buffer` exposes it.
77
+
67
78
  `infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
68
79
  `codegen.generate_service_stub(layout)` emits a runnable MeshSocket server skeleton;
69
80
  `theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
70
81
 
82
+ ## CLI
83
+
84
+ ```bash
85
+ carterkit catalog # list every control type
86
+ carterkit doc gauge # print a control's documentation
87
+ carterkit examples button # list a control's examples (--name to print one)
88
+ carterkit validate layout.json # lint a layout (exit 1 on errors)
89
+ carterkit gen layout.json # generate a MeshSocket service stub
90
+ carterkit relay --port 8765 # run the bundled MeshSocket relay
91
+ ```
92
+
71
93
  ## Drive a device
72
94
 
73
95
  ```python
@@ -75,13 +97,12 @@ import asyncio
75
97
  from carterkit import CarterClient
76
98
 
77
99
  async def main():
78
- c = CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
79
- channel="home", role="device", name="my-hub")
80
- c.on("toggle", lambda d: {"ok": True, **d})
81
- await c.connect()
82
- await c.broadcast("reading", {"temp_c": 21.4})
83
- await asyncio.sleep(60)
84
- await c.close()
100
+ async with CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
101
+ channel="home", role="device", name="my-hub") as c:
102
+ c.on("toggle", lambda d: {"ok": True, **d})
103
+ await c.broadcast("reading", {"temp_c": 21.4})
104
+ await asyncio.sleep(60)
105
+ # leaving the `async with` disconnects automatically
85
106
 
86
107
  asyncio.run(main())
87
108
  ```
@@ -28,23 +28,45 @@ carterkit.examples("button") # documented example snippets
28
28
 
29
29
  ## Build a layout
30
30
 
31
- ```python
32
- from carterkit import LayoutBuffer, validate_layout
33
-
34
- b = LayoutBuffer.blank(name="Dashboard", columns=4, rows=4)
35
- b.add_control({"type": "gauge", "id": "cpu", "label": "CPU", "min": 0, "max": 100},
36
- default_span=[2, 2])
37
- b.add_control({"type": "button", "id": "refresh", "label": "Refresh"})
31
+ The fluent `Layout` builder composes **typed builders** (`build.<control>`) and
32
+ **binding helpers** (`bind`) — all generated from / shaped by the bundled docs, so
33
+ unknown control types and bad enum values raise instead of silently shipping a broken
34
+ layout:
38
35
 
39
- layout = b.layout
40
- findings = validate_layout(layout) # schema + grid lint against the bundled catalog
41
- print(carterkit.format_findings(findings))
36
+ ```python
37
+ from carterkit import Layout, build, bind
38
+
39
+ lay = (Layout("Dashboard", columns=4, rows=4)
40
+ .connect("ws://192.168.1.50:8765", channel="home")
41
+ .tab("Main", icon="gauge")
42
+ .add(build.gauge(id="cpu", label="CPU", min=0, max=100,
43
+ sync=[bind.listen("cpu", filter={"msg_type": "metrics"})]),
44
+ default_span=[2, 2])
45
+ .add(build.button(id="refresh", label="Refresh", action=bind.action("refresh"))))
46
+
47
+ print(lay.findings()) # schema + grid + binding lint against the bundled catalog
48
+ help(build.gauge) # ← prints the gauge documentation, straight from the docs
49
+ layout = lay.layout # the composed dict, ready to push/save
42
50
  ```
43
51
 
52
+ Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `move_control`
53
+ over a held draft; `lay.buffer` exposes it.
54
+
44
55
  `infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
45
56
  `codegen.generate_service_stub(layout)` emits a runnable MeshSocket server skeleton;
46
57
  `theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
47
58
 
59
+ ## CLI
60
+
61
+ ```bash
62
+ carterkit catalog # list every control type
63
+ carterkit doc gauge # print a control's documentation
64
+ carterkit examples button # list a control's examples (--name to print one)
65
+ carterkit validate layout.json # lint a layout (exit 1 on errors)
66
+ carterkit gen layout.json # generate a MeshSocket service stub
67
+ carterkit relay --port 8765 # run the bundled MeshSocket relay
68
+ ```
69
+
48
70
  ## Drive a device
49
71
 
50
72
  ```python
@@ -52,13 +74,12 @@ import asyncio
52
74
  from carterkit import CarterClient
53
75
 
54
76
  async def main():
55
- c = CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
56
- channel="home", role="device", name="my-hub")
57
- c.on("toggle", lambda d: {"ok": True, **d})
58
- await c.connect()
59
- await c.broadcast("reading", {"temp_c": 21.4})
60
- await asyncio.sleep(60)
61
- await c.close()
77
+ async with CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
78
+ channel="home", role="device", name="my-hub") as c:
79
+ c.on("toggle", lambda d: {"ok": True, **d})
80
+ await c.broadcast("reading", {"temp_c": 21.4})
81
+ await asyncio.sleep(60)
82
+ # leaving the `async with` disconnects automatically
62
83
 
63
84
  asyncio.run(main())
64
85
  ```
@@ -19,8 +19,11 @@ from . import catalog, grid, codegen, infer, theming, tune
19
19
  from .buffer import LayoutBuffer, BufferError
20
20
  from .validate import validate_layout as _validate_layout, format_findings
21
21
  from .client import CarterClient, notify_http, CarterNotifyError
22
+ from . import bind
23
+ from .controls import build, control
24
+ from .layout import Layout
22
25
 
23
- __version__ = "0.1.0"
26
+ __version__ = "0.2.0"
24
27
 
25
28
  #: The layout/wire protocol version carterkit emits and understands. The JSON
26
29
  #: contract — not this Python API — is the real compatibility boundary across the
@@ -65,5 +68,6 @@ __all__ = [
65
68
  "LayoutBuffer", "BufferError",
66
69
  "controls", "doc", "doc_markdown", "examples", "validate_layout",
67
70
  "format_findings", "controldocs_dir",
71
+ "build", "control", "bind", "Layout",
68
72
  "catalog", "grid", "codegen", "infer", "theming", "tune",
69
73
  ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,40 @@
1
+ """Helpers that build the (verbose, easy-to-get-wrong) sync / action / connection
2
+ dicts controls use. Shapes mirror the bundled ControlDocs (sync.md, actions.md).
3
+
4
+ from carterkit import bind
5
+ bind.listen("cpu", filter={"msg_type": "telemetry"})
6
+ bind.action("set_power", payload={"state": "{{value}}"}, mode="request")
7
+ bind.connection("ws://192.168.1.50:8765", channel="home")
8
+ """
9
+ from __future__ import annotations
10
+
11
+
12
+ def listen(value_path: str, *, event: str = "broadcast", filter: dict | None = None,
13
+ method: str = "meshsocket") -> dict:
14
+ """A `sync` entry: subscribe to `event`, match `filter`, extract `value_path`
15
+ (dot-notation). Returns one sync dict — controls take a list of them."""
16
+ s: dict = {"method": method, "type": "listen", "event": event, "valuePath": value_path}
17
+ if filter is not None:
18
+ s["filter"] = filter
19
+ return s
20
+
21
+
22
+ def action(event: str, *, payload: dict | None = None, mode: str = "broadcast",
23
+ method: str = "meshsocket") -> dict:
24
+ """An `action` dict: fire `event` on tap/change. `mode` is "broadcast" (fire and
25
+ forget) or "request" (await reply). `payload` strings support `{{value}}`."""
26
+ if mode not in ("broadcast", "request"):
27
+ raise ValueError(f"mode must be 'broadcast' or 'request', got {mode!r}")
28
+ a: dict = {"method": method, "mode": mode, "event": event}
29
+ if payload is not None:
30
+ a["payload"] = payload
31
+ return a
32
+
33
+
34
+ def connection(url: str, *, channel: str = "home", name: str = "CAR-TER",
35
+ role: str = "controller", token: str | None = None) -> dict:
36
+ """A layout `connection` block (relay URL + identity)."""
37
+ conn: dict = {"url": url, "identity": {"name": name, "channel": channel, "role": role}}
38
+ if token is not None:
39
+ conn["token"] = token
40
+ return conn
@@ -0,0 +1,128 @@
1
+ """Command-line interface: ``carterkit <command>`` (also ``python -m carterkit``).
2
+
3
+ Commands: catalog · doc · examples · validate · gen · relay · version.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+
11
+
12
+ def _cmd_catalog(args) -> int:
13
+ import carterkit
14
+ cat = carterkit.controls(types=[args.type] if args.type else None, include_theme=args.theme)
15
+ if args.json:
16
+ print(json.dumps(cat, indent=2))
17
+ else:
18
+ for t in sorted(cat):
19
+ spec = cat[t]
20
+ print(f"{t:18} {spec.get('label', ''):16} ({spec.get('category', '')})")
21
+ return 0
22
+
23
+
24
+ def _cmd_doc(args) -> int:
25
+ import carterkit
26
+ md = carterkit.doc_markdown(args.control)
27
+ if md is None:
28
+ print(f"no doc for {args.control!r}", file=sys.stderr)
29
+ return 1
30
+ print(md)
31
+ return 0
32
+
33
+
34
+ def _cmd_examples(args) -> int:
35
+ import carterkit
36
+ if args.name:
37
+ from carterkit import catalog, controldocs_dir
38
+ ex = catalog.find_example(controldocs_dir(), args.control, args.name)
39
+ if not ex:
40
+ print(f"no example {args.name!r} for {args.control!r}", file=sys.stderr)
41
+ return 1
42
+ print(ex["json"])
43
+ return 0
44
+ exs = carterkit.examples(args.control)
45
+ if not exs:
46
+ print(f"no examples for {args.control!r}", file=sys.stderr)
47
+ return 1
48
+ for ex in exs:
49
+ print("•", ex["name"])
50
+ return 0
51
+
52
+
53
+ def _cmd_validate(args) -> int:
54
+ import carterkit
55
+ with open(args.file) as f:
56
+ layout = json.load(f)
57
+ findings = carterkit.validate_layout(layout)
58
+ print(carterkit.format_findings(findings))
59
+ return 1 if any(f["severity"] == "error" for f in findings) else 0
60
+
61
+
62
+ def _cmd_gen(args) -> int:
63
+ import carterkit
64
+ with open(args.file) as f:
65
+ layout = json.load(f)
66
+ print(carterkit.codegen.generate_service_stub(layout))
67
+ return 0
68
+
69
+
70
+ def _cmd_relay(args) -> int:
71
+ import asyncio
72
+ from socket_server import MeshServer # bundled with meshsocket
73
+ print(f"MeshSocket relay on ws://{args.host}:{args.port}", file=sys.stderr)
74
+ asyncio.run(MeshServer(host=args.host, port=args.port).start())
75
+ return 0
76
+
77
+
78
+ def _cmd_version(args) -> int:
79
+ import carterkit
80
+ print(carterkit.__version__)
81
+ return 0
82
+
83
+
84
+ def build_parser() -> argparse.ArgumentParser:
85
+ p = argparse.ArgumentParser(prog="carterkit", description="Build and drive CAR-TER layouts.")
86
+ sub = p.add_subparsers(dest="cmd", required=True)
87
+
88
+ c = sub.add_parser("catalog", help="list the control catalog")
89
+ c.add_argument("--type", help="restrict to one control type")
90
+ c.add_argument("--theme", action="store_true", help="include per-control theme fields")
91
+ c.add_argument("--json", action="store_true", help="emit machine-readable JSON")
92
+ c.set_defaults(fn=_cmd_catalog)
93
+
94
+ c = sub.add_parser("doc", help="print a control's documentation")
95
+ c.add_argument("control")
96
+ c.set_defaults(fn=_cmd_doc)
97
+
98
+ c = sub.add_parser("examples", help="list a control's examples, or print one with --name")
99
+ c.add_argument("control")
100
+ c.add_argument("--name", help="print the JSON of the named example")
101
+ c.set_defaults(fn=_cmd_examples)
102
+
103
+ c = sub.add_parser("validate", help="lint a layout JSON file (exit 1 on errors)")
104
+ c.add_argument("file")
105
+ c.set_defaults(fn=_cmd_validate)
106
+
107
+ c = sub.add_parser("gen", help="generate a MeshSocket service stub from a layout file")
108
+ c.add_argument("file")
109
+ c.set_defaults(fn=_cmd_gen)
110
+
111
+ c = sub.add_parser("relay", help="run the bundled MeshSocket relay")
112
+ c.add_argument("--host", default="0.0.0.0")
113
+ c.add_argument("--port", type=int, default=8765)
114
+ c.set_defaults(fn=_cmd_relay)
115
+
116
+ c = sub.add_parser("version", help="print the carterkit version")
117
+ c.set_defaults(fn=_cmd_version)
118
+
119
+ return p
120
+
121
+
122
+ def main(argv=None) -> int:
123
+ args = build_parser().parse_args(argv)
124
+ return args.fn(args)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ raise SystemExit(main())
@@ -136,3 +136,11 @@ class CarterClient:
136
136
 
137
137
  async def close(self):
138
138
  await self._sock.stop()
139
+
140
+ async def __aenter__(self):
141
+ await self.connect()
142
+ return self
143
+
144
+ async def __aexit__(self, *exc):
145
+ await self.close()
146
+ return False
@@ -0,0 +1,90 @@
1
+ """Typed control constructors, generated at runtime from the bundled ControlDocs.
2
+
3
+ Because the catalog *is* the docs, every placeable control type is available as a
4
+ builder whose validation and `help()` come straight from its documentation:
5
+
6
+ from carterkit import build
7
+ build.gauge(id="cpu", min=0, max=100, sync=[bind.listen("cpu")])
8
+ build.color_picker(id="tint") # snake_case maps to the catalog type
9
+ help(build.gauge) # prints the gauge documentation
10
+
11
+ Unknown control types and bad enum values raise; unknown props are allowed
12
+ (forward-compatible — the device ignores props it doesn't understand).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from functools import lru_cache
17
+
18
+ from . import catalog
19
+
20
+
21
+ @lru_cache(maxsize=1)
22
+ def _catalog() -> dict:
23
+ from . import controldocs_dir
24
+ return catalog.build_catalog(controldocs_dir())
25
+
26
+
27
+ def _resolve_type(name: str) -> str | None:
28
+ """Map an attribute name to a catalog control type: exact, or snake_case→camelCase."""
29
+ cat = _catalog()
30
+ if name in cat:
31
+ return name
32
+ head, *rest = name.split("_")
33
+ camel = head + "".join(p.title() for p in rest)
34
+ return camel if camel in cat else None
35
+
36
+
37
+ def control(type: str, *, id: str, position=None, span=None, **props) -> dict:
38
+ """Build and validate one control dict. `id` is required; `position`/`span` are
39
+ optional. Raises on an unknown control type or an out-of-range enum value."""
40
+ cat = _catalog()
41
+ if type not in cat:
42
+ raise ValueError(f"unknown control type {type!r}. Known: {', '.join(sorted(cat))}")
43
+ spec = cat[type]
44
+ enums = {f["name"]: f["values"] for f in spec.get("fields", [])
45
+ if f.get("type") == "enum" and f.get("values")}
46
+ for key, val in props.items():
47
+ if key in enums and val not in enums[key]:
48
+ raise ValueError(
49
+ f"{type}.{key}={val!r} is not a valid option; choose one of {enums[key]}")
50
+ out: dict = {"type": type, "id": id}
51
+ if position is not None:
52
+ out["position"] = list(position)
53
+ if span is not None:
54
+ out["span"] = list(span)
55
+ out.update(props)
56
+ return out
57
+
58
+
59
+ class _Controls:
60
+ """Attribute access returns a builder bound to a catalog control type."""
61
+
62
+ def __getattr__(self, name: str):
63
+ ctype = _resolve_type(name)
64
+ if ctype is None:
65
+ raise AttributeError(
66
+ f"no control type {name!r}. Available: {', '.join(self.__dir__())}")
67
+
68
+ def make(*, id: str, position=None, span=None, **props) -> dict:
69
+ return control(ctype, id=id, position=position, span=span, **props)
70
+
71
+ doc = catalog.resolve_doc(_catalog_dir(), ctype)
72
+ make.__name__ = ctype
73
+ make.__qualname__ = f"controls.{ctype}"
74
+ make.__doc__ = (doc or {}).get("body") or f"Build a {ctype} control."
75
+ return make
76
+
77
+ def __dir__(self):
78
+ return sorted(_catalog())
79
+
80
+ def types(self) -> list[str]:
81
+ """All placeable control types available as builders."""
82
+ return sorted(_catalog())
83
+
84
+
85
+ def _catalog_dir():
86
+ from . import controldocs_dir
87
+ return controldocs_dir()
88
+
89
+
90
+ build = _Controls()
@@ -0,0 +1,87 @@
1
+ """A fluent layout builder — the ergonomic front door over LayoutBuffer.
2
+
3
+ from carterkit import Layout, build, bind
4
+
5
+ layout = (Layout("Dashboard", columns=4, rows=4)
6
+ .connect("ws://192.168.1.50:8765", channel="home")
7
+ .tab("Main", icon="gauge")
8
+ .add(build.gauge(id="cpu", min=0, max=100,
9
+ sync=[bind.listen("cpu")]), default_span=[2, 2])
10
+ .add(build.button(id="refresh", action=bind.action("refresh")))
11
+ .layout)
12
+
13
+ Auto-placement, id de-duplication, and grid bookkeeping come from LayoutBuffer;
14
+ `.validate()` lints against the bundled catalog. Chainable: every mutator returns self.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+
20
+ from . import bind as _bind
21
+ from . import validate as _validate
22
+ from .buffer import LayoutBuffer
23
+
24
+
25
+ class Layout:
26
+ def __init__(self, name: str = "Layout", *, columns: int = 4, rows: int = 6,
27
+ accent: str = "#667eea"):
28
+ self._buf = LayoutBuffer.blank(name=name, columns=columns, rows=rows, accent=accent)
29
+ self._tab = 0
30
+ self._first_tab_used = False
31
+
32
+ def connect(self, url: str, **identity) -> "Layout":
33
+ """Attach a `connection` block (see bind.connection for the identity kwargs)."""
34
+ self._buf.layout["connection"] = _bind.connection(url, **identity)
35
+ return self
36
+
37
+ def tab(self, title: str, *, icon: str = "square.grid.2x2",
38
+ columns: int = 4, rows: int = 6) -> "Layout":
39
+ """Start a tab and make it current. The first call configures the default tab;
40
+ later calls append new tabs."""
41
+ if not self._first_tab_used:
42
+ t = self._buf.tabs[0]
43
+ t["title"], t["icon"] = title, icon
44
+ t["grid"] = {"columns": columns, "rows": rows}
45
+ self._tab = 0
46
+ self._first_tab_used = True
47
+ else:
48
+ self._tab = self._buf.add_tab(title, icon=icon, columns=columns, rows=rows)
49
+ return self
50
+
51
+ def add(self, control: dict, *, position=None, span=None, default_span=None) -> "Layout":
52
+ """Add a control to the current tab (auto-placed unless `position` is given)."""
53
+ self._buf.add_control(control, tab_index=self._tab, position=position,
54
+ default_span=default_span or span)
55
+ return self
56
+
57
+ def group(self, group: dict, *, position=None) -> "Layout":
58
+ """Add a group container to the current tab."""
59
+ self._buf.add_group(group, tab_index=self._tab, position=position)
60
+ return self
61
+
62
+ @property
63
+ def layout(self) -> dict:
64
+ """The composed layout dict (ready to push/save)."""
65
+ return self._buf.layout
66
+
67
+ @property
68
+ def buffer(self) -> LayoutBuffer:
69
+ """The underlying LayoutBuffer, for advanced ops (update/move/remove)."""
70
+ return self._buf
71
+
72
+ def validate(self) -> list:
73
+ """Lint against the bundled control catalog."""
74
+ import carterkit
75
+ return carterkit.validate_layout(self.layout)
76
+
77
+ def findings(self) -> str:
78
+ """Human-readable lint report."""
79
+ return _validate.format_findings(self.validate())
80
+
81
+ def json(self, indent: int = 2) -> str:
82
+ return json.dumps(self.layout, indent=indent)
83
+
84
+ def __repr__(self) -> str:
85
+ n = sum(len(t.get("children", [])) for t in self._buf.tabs)
86
+ return (f"<Layout {self.layout.get('name')!r}: "
87
+ f"{len(self._buf.tabs)} tab(s), {n} control(s)>")
File without changes
@@ -116,6 +116,26 @@ def _validate_child(ch, catalog, where, findings, seen_ids):
116
116
  if v not in fd["values"]:
117
117
  findings.append(_f("error", "bad_enum", spot,
118
118
  f"{ctype}.{k} = '{v}' is not one of {fd['values']}"))
119
+ _validate_bindings(ch, ctype, spot, findings)
120
+
121
+
122
+ def _validate_bindings(ch, ctype, spot, findings):
123
+ """Shape-check the data bindings: every `sync` entry needs a `valuePath`, and
124
+ `action`/`longPressAction` need an `event` (else they silently do nothing)."""
125
+ sync = ch.get("sync")
126
+ if sync is not None:
127
+ if not isinstance(sync, list):
128
+ findings.append(_f("warn", "bad_sync", spot, f"{ctype}.sync should be a list"))
129
+ else:
130
+ for i, s in enumerate(sync):
131
+ if not isinstance(s, dict) or not s.get("valuePath"):
132
+ findings.append(_f("warn", "bad_sync", spot,
133
+ f"{ctype}.sync[{i}] is missing a 'valuePath'"))
134
+ for akey in ("action", "longPressAction"):
135
+ a = ch.get(akey)
136
+ if a is not None and (not isinstance(a, dict) or not a.get("event")):
137
+ findings.append(_f("error", "bad_action", spot,
138
+ f"{ctype}.{akey} is missing an 'event'"))
119
139
 
120
140
 
121
141
  def format_findings(findings: list[dict]) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: carterkit
3
- Version: 0.1.0
3
+ Version: 0.3.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,23 +51,45 @@ carterkit.examples("button") # documented example snippets
51
51
 
52
52
  ## Build a layout
53
53
 
54
- ```python
55
- from carterkit import LayoutBuffer, validate_layout
56
-
57
- b = LayoutBuffer.blank(name="Dashboard", columns=4, rows=4)
58
- b.add_control({"type": "gauge", "id": "cpu", "label": "CPU", "min": 0, "max": 100},
59
- default_span=[2, 2])
60
- b.add_control({"type": "button", "id": "refresh", "label": "Refresh"})
54
+ The fluent `Layout` builder composes **typed builders** (`build.<control>`) and
55
+ **binding helpers** (`bind`) — all generated from / shaped by the bundled docs, so
56
+ unknown control types and bad enum values raise instead of silently shipping a broken
57
+ layout:
61
58
 
62
- layout = b.layout
63
- findings = validate_layout(layout) # schema + grid lint against the bundled catalog
64
- print(carterkit.format_findings(findings))
59
+ ```python
60
+ from carterkit import Layout, build, bind
61
+
62
+ lay = (Layout("Dashboard", columns=4, rows=4)
63
+ .connect("ws://192.168.1.50:8765", channel="home")
64
+ .tab("Main", icon="gauge")
65
+ .add(build.gauge(id="cpu", label="CPU", min=0, max=100,
66
+ sync=[bind.listen("cpu", filter={"msg_type": "metrics"})]),
67
+ default_span=[2, 2])
68
+ .add(build.button(id="refresh", label="Refresh", action=bind.action("refresh"))))
69
+
70
+ print(lay.findings()) # schema + grid + binding lint against the bundled catalog
71
+ help(build.gauge) # ← prints the gauge documentation, straight from the docs
72
+ layout = lay.layout # the composed dict, ready to push/save
65
73
  ```
66
74
 
75
+ Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `move_control`
76
+ over a held draft; `lay.buffer` exposes it.
77
+
67
78
  `infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
68
79
  `codegen.generate_service_stub(layout)` emits a runnable MeshSocket server skeleton;
69
80
  `theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
70
81
 
82
+ ## CLI
83
+
84
+ ```bash
85
+ carterkit catalog # list every control type
86
+ carterkit doc gauge # print a control's documentation
87
+ carterkit examples button # list a control's examples (--name to print one)
88
+ carterkit validate layout.json # lint a layout (exit 1 on errors)
89
+ carterkit gen layout.json # generate a MeshSocket service stub
90
+ carterkit relay --port 8765 # run the bundled MeshSocket relay
91
+ ```
92
+
71
93
  ## Drive a device
72
94
 
73
95
  ```python
@@ -75,13 +97,12 @@ import asyncio
75
97
  from carterkit import CarterClient
76
98
 
77
99
  async def main():
78
- c = CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
79
- channel="home", role="device", name="my-hub")
80
- c.on("toggle", lambda d: {"ok": True, **d})
81
- await c.connect()
82
- await c.broadcast("reading", {"temp_c": 21.4})
83
- await asyncio.sleep(60)
84
- await c.close()
100
+ async with CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
101
+ channel="home", role="device", name="my-hub") as c:
102
+ c.on("toggle", lambda d: {"ok": True, **d})
103
+ await c.broadcast("reading", {"temp_c": 21.4})
104
+ await asyncio.sleep(60)
105
+ # leaving the `async with` disconnects automatically
85
106
 
86
107
  asyncio.run(main())
87
108
  ```
@@ -2,19 +2,26 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  carterkit/__init__.py
5
+ carterkit/__main__.py
6
+ carterkit/bind.py
5
7
  carterkit/buffer.py
6
8
  carterkit/catalog.py
9
+ carterkit/cli.py
7
10
  carterkit/client.py
8
11
  carterkit/codegen.py
12
+ carterkit/controls.py
9
13
  carterkit/e2ee.py
10
14
  carterkit/grid.py
11
15
  carterkit/infer.py
16
+ carterkit/layout.py
17
+ carterkit/py.typed
12
18
  carterkit/theming.py
13
19
  carterkit/tune.py
14
20
  carterkit/validate.py
15
21
  carterkit.egg-info/PKG-INFO
16
22
  carterkit.egg-info/SOURCES.txt
17
23
  carterkit.egg-info/dependency_links.txt
24
+ carterkit.egg-info/entry_points.txt
18
25
  carterkit.egg-info/requires.txt
19
26
  carterkit.egg-info/top_level.txt
20
27
  carterkit/controldocs/accordion.md
@@ -61,12 +68,18 @@ carterkit/controldocs/theming.md
61
68
  carterkit/controldocs/toggle.md
62
69
  carterkit/controldocs/visibility.md
63
70
  carterkit/controldocs/web-view.md
71
+ tests/test_bind.py
64
72
  tests/test_buffer.py
65
73
  tests/test_catalog.py
74
+ tests/test_cli.py
75
+ tests/test_client.py
66
76
  tests/test_codegen.py
77
+ tests/test_controls.py
67
78
  tests/test_e2ee.py
68
79
  tests/test_grid.py
69
80
  tests/test_infer.py
81
+ tests/test_layout.py
70
82
  tests/test_theming.py
71
83
  tests/test_tune.py
72
- tests/test_validate.py
84
+ tests/test_validate.py
85
+ tests/test_validate_bindings.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ carterkit = carterkit.cli:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "carterkit"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Build and drive CAR-TER layouts from Python — the control docs are the library."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,8 +30,11 @@ classifiers = [
30
30
  Homepage = "https://github.com/Mariner10/carterkit"
31
31
  Repository = "https://github.com/Mariner10/carterkit"
32
32
 
33
+ [project.scripts]
34
+ carterkit = "carterkit.cli:main"
35
+
33
36
  [tool.setuptools]
34
37
  packages = ["carterkit"]
35
38
 
36
39
  [tool.setuptools.package-data]
37
- carterkit = ["controldocs/*.md"]
40
+ carterkit = ["controldocs/*.md", "py.typed"]
@@ -0,0 +1,40 @@
1
+ """Tests for bind.py — sync/action/connection helper shapes."""
2
+
3
+ import pytest
4
+
5
+ from carterkit import bind
6
+
7
+
8
+ def test_listen_basic():
9
+ assert bind.listen("cpu") == {
10
+ "method": "meshsocket", "type": "listen", "event": "broadcast", "valuePath": "cpu"}
11
+
12
+
13
+ def test_listen_with_filter_and_event():
14
+ s = bind.listen("battery", event="metrics", filter={"msg_type": "telemetry"})
15
+ assert s["event"] == "metrics"
16
+ assert s["filter"] == {"msg_type": "telemetry"}
17
+ assert s["valuePath"] == "battery"
18
+
19
+
20
+ def test_action_broadcast_default():
21
+ assert bind.action("refresh") == {
22
+ "method": "meshsocket", "mode": "broadcast", "event": "refresh"}
23
+
24
+
25
+ def test_action_request_with_payload():
26
+ a = bind.action("set_power", payload={"state": "{{value}}"}, mode="request")
27
+ assert a["mode"] == "request"
28
+ assert a["payload"] == {"state": "{{value}}"}
29
+
30
+
31
+ def test_action_rejects_bad_mode():
32
+ with pytest.raises(ValueError):
33
+ bind.action("x", mode="nope")
34
+
35
+
36
+ def test_connection():
37
+ c = bind.connection("ws://h:8765", channel="lab", token="t")
38
+ assert c["url"] == "ws://h:8765"
39
+ assert c["identity"] == {"name": "CAR-TER", "channel": "lab", "role": "controller"}
40
+ assert c["token"] == "t"
@@ -0,0 +1,53 @@
1
+ """Tests for the carterkit CLI."""
2
+
3
+ import json
4
+
5
+ import carterkit
6
+ from carterkit import cli
7
+
8
+
9
+ def test_catalog_json(capsys):
10
+ assert cli.main(["catalog", "--json"]) == 0
11
+ cat = json.loads(capsys.readouterr().out)
12
+ assert "gauge" in cat and "button" in cat
13
+
14
+
15
+ def test_catalog_table(capsys):
16
+ assert cli.main(["catalog"]) == 0
17
+ assert "gauge" in capsys.readouterr().out
18
+
19
+
20
+ def test_doc(capsys):
21
+ assert cli.main(["doc", "gauge"]) == 0
22
+ assert "# Gauge" in capsys.readouterr().out
23
+
24
+
25
+ def test_doc_unknown(capsys):
26
+ assert cli.main(["doc", "frobnicator"]) == 1
27
+
28
+
29
+ def test_examples_list(capsys):
30
+ assert cli.main(["examples", "button"]) == 0
31
+ assert "•" in capsys.readouterr().out
32
+
33
+
34
+ def test_version(capsys):
35
+ assert cli.main(["version"]) == 0
36
+ assert capsys.readouterr().out.strip() == carterkit.__version__
37
+
38
+
39
+ def test_validate_clean(tmp_path):
40
+ b = carterkit.LayoutBuffer.blank(columns=4, rows=4)
41
+ b.add_control(carterkit.build.gauge(id="cpu", min=0, max=100), default_span=[2, 2])
42
+ f = tmp_path / "layout.json"
43
+ f.write_text(json.dumps(b.layout))
44
+ assert cli.main(["validate", str(f)]) == 0
45
+
46
+
47
+ def test_gen_emits_meshsocket_service(tmp_path, capsys):
48
+ b = carterkit.LayoutBuffer.blank(columns=4, rows=4)
49
+ b.add_control(carterkit.build.button(id="go"))
50
+ f = tmp_path / "layout.json"
51
+ f.write_text(json.dumps(b.layout))
52
+ assert cli.main(["gen", str(f)]) == 0
53
+ assert "from meshsocket import MeshSocket" in capsys.readouterr().out
@@ -0,0 +1,32 @@
1
+ """Test the CarterClient async context manager (no network — fake socket)."""
2
+
3
+ import asyncio
4
+
5
+ from carterkit import CarterClient
6
+
7
+
8
+ class _FakeSock:
9
+ def __init__(self):
10
+ self.events = []
11
+
12
+ async def start(self):
13
+ self.events.append("start")
14
+
15
+ async def wait_until_ready(self):
16
+ self.events.append("ready")
17
+
18
+ async def stop(self):
19
+ self.events.append("stop")
20
+
21
+
22
+ def test_async_context_manager_connects_and_closes():
23
+ c = CarterClient(gateway_url="ws://x", token="t", channel="home")
24
+ fake = _FakeSock()
25
+ c._sock = fake # swap the real MeshSocket for a recorder
26
+
27
+ async def run():
28
+ async with c as ctx:
29
+ assert ctx is c
30
+ return fake.events
31
+
32
+ assert asyncio.run(run()) == ["start", "ready", "stop"]
@@ -0,0 +1,54 @@
1
+ """Tests for controls.py — typed builders generated from the bundled catalog."""
2
+
3
+ import pytest
4
+
5
+ import carterkit
6
+ from carterkit import build, control
7
+
8
+
9
+ def test_build_gauge_returns_valid_control():
10
+ g = build.gauge(id="cpu", min=0, max=100, position=[0, 0], span=[2, 2])
11
+ assert g["type"] == "gauge" and g["id"] == "cpu"
12
+ assert g["position"] == [0, 0] and g["span"] == [2, 2]
13
+ assert g["min"] == 0 and g["max"] == 100
14
+
15
+
16
+ def test_snake_case_alias_maps_to_camel_type():
17
+ assert build.color_picker(id="tint")["type"] == "colorPicker"
18
+ assert build.status_light(id="s")["type"] == "statusLight"
19
+
20
+
21
+ def test_unknown_control_raises():
22
+ with pytest.raises(AttributeError):
23
+ build.frobnicator # noqa: B018
24
+
25
+
26
+ def test_bad_enum_value_raises():
27
+ with pytest.raises(ValueError):
28
+ build.gauge(id="g", gaugeStyle="triangular") # gaugeStyle is half|full
29
+
30
+
31
+ def test_good_enum_value_ok():
32
+ assert build.gauge(id="g", gaugeStyle="full")["gaugeStyle"] == "full"
33
+
34
+
35
+ def test_control_factory_unknown_type():
36
+ with pytest.raises(ValueError):
37
+ control("nope", id="x")
38
+
39
+
40
+ def test_builder_doc_comes_from_controldocs():
41
+ assert build.gauge.__doc__.startswith("# Gauge")
42
+
43
+
44
+ def test_types_listing_nonempty():
45
+ types = build.types()
46
+ assert "gauge" in types and "button" in types and "colorPicker" in types
47
+
48
+
49
+ def test_built_controls_validate_clean():
50
+ b = carterkit.LayoutBuffer.blank(columns=4, rows=4)
51
+ b.add_control(build.gauge(id="cpu", min=0, max=100), default_span=[2, 2])
52
+ b.add_control(build.button(id="go", label="Go"))
53
+ errs = [f for f in carterkit.validate_layout(b.layout) if f["severity"] == "error"]
54
+ assert errs == [], errs
@@ -0,0 +1,40 @@
1
+ """Tests for the fluent Layout builder."""
2
+
3
+ from carterkit import Layout, build, bind
4
+
5
+
6
+ def test_fluent_compose():
7
+ lay = (Layout("Dash", columns=4, rows=4)
8
+ .connect("ws://h:8765", channel="home")
9
+ .tab("Main", icon="gauge")
10
+ .add(build.gauge(id="cpu", min=0, max=100, sync=[bind.listen("cpu")]),
11
+ default_span=[2, 2])
12
+ .add(build.button(id="go", action=bind.action("go"))))
13
+ layout = lay.layout
14
+ assert layout["name"] == "Dash"
15
+ assert layout["connection"]["url"] == "ws://h:8765"
16
+ tab0 = layout["tabs"][0]
17
+ assert tab0["title"] == "Main" and tab0["icon"] == "gauge"
18
+ assert {c["id"] for c in tab0["children"]} == {"cpu", "go"}
19
+ g = next(c for c in tab0["children"] if c["id"] == "cpu")
20
+ assert g["span"] == [2, 2] and g["position"] == [0, 0]
21
+
22
+
23
+ def test_first_tab_renames_then_appends():
24
+ lay = (Layout("X")
25
+ .tab("One").add(build.button(id="a"))
26
+ .tab("Two").add(build.button(id="b")))
27
+ assert [t["title"] for t in lay.layout["tabs"]] == ["One", "Two"]
28
+ assert lay.layout["tabs"][1]["children"][0]["id"] == "b"
29
+
30
+
31
+ def test_validate_and_findings_clean():
32
+ lay = Layout("X").add(
33
+ build.gauge(id="g", min=0, max=100, sync=[bind.listen("g")]), default_span=[2, 2])
34
+ assert [f for f in lay.validate() if f["severity"] == "error"] == []
35
+ assert "No issues" in lay.findings()
36
+
37
+
38
+ def test_repr_counts_controls():
39
+ lay = Layout("X").add(build.button(id="a")).add(build.button(id="b"))
40
+ assert "2 control" in repr(lay)
@@ -0,0 +1,40 @@
1
+ """Tests for the richer sync/action binding validation."""
2
+
3
+ import carterkit
4
+ from carterkit import validate, build, bind
5
+
6
+ CAT = carterkit.controls(include_theme=True)
7
+
8
+
9
+ def _layout(children):
10
+ return {"name": "T", "version": 1,
11
+ "tabs": [{"title": "Main", "icon": "house.fill",
12
+ "grid": {"columns": 4, "rows": 4}, "children": children}]}
13
+
14
+
15
+ def _kinds(findings):
16
+ return {f["kind"] for f in findings}
17
+
18
+
19
+ def test_action_missing_event_is_error():
20
+ lay = _layout([{"type": "button", "id": "b", "position": [0, 0],
21
+ "action": {"method": "meshsocket", "mode": "broadcast"}}])
22
+ findings = validate.validate_layout(lay, CAT)
23
+ assert "bad_action" in _kinds(findings)
24
+ assert any(f["severity"] == "error" for f in findings if f["kind"] == "bad_action")
25
+
26
+
27
+ def test_sync_missing_valuepath_is_warning():
28
+ lay = _layout([{"type": "gauge", "id": "g", "position": [0, 0], "min": 0, "max": 100,
29
+ "sync": [{"method": "meshsocket", "type": "listen", "event": "broadcast"}]}])
30
+ bs = [f for f in validate.validate_layout(lay, CAT) if f["kind"] == "bad_sync"]
31
+ assert bs and bs[0]["severity"] == "warn"
32
+
33
+
34
+ def test_helper_built_bindings_are_clean():
35
+ lay = _layout([
36
+ build.button(id="b", position=[0, 0], action=bind.action("go")),
37
+ build.gauge(id="g", position=[0, 1], min=0, max=100, sync=[bind.listen("g")]),
38
+ ])
39
+ kinds = _kinds(validate.validate_layout(lay, CAT))
40
+ assert "bad_action" not in kinds and "bad_sync" not in kinds
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes