pyclay 1.0.0__py3-none-any.whl

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.
pyclay/__init__.py ADDED
@@ -0,0 +1,335 @@
1
+ """
2
+ pyclay – public API
3
+ ====================
4
+ Every function appends a component dict to the runtime tree.
5
+ An optional ``style`` dict lets callers control CSS per-element.
6
+ """
7
+
8
+ __version__ = "1.0.0"
9
+
10
+ from contextlib import contextmanager
11
+ from pyclay import _runtime
12
+
13
+
14
+ # Page config
15
+
16
+ _VALID_THEMES = {"ivory", "nebula", "arctic", "obsidian"}
17
+
18
+ def page_config(*, title="My App", theme="ivory", favicon="", custom_css=""):
19
+ """Set page-level settings. Call once, at the top of your script.
20
+
21
+ Args:
22
+ title: Browser tab title.
23
+ theme: ``"ivory"``, ``"nebula"``, ``"arctic"``, or ``"obsidian"``.
24
+ favicon: URL to a favicon image (optional).
25
+ custom_css: Path or URL to a custom CSS file (optional).
26
+ """
27
+ assert theme in _VALID_THEMES, f"theme must be one of {_VALID_THEMES}"
28
+ _runtime.set_page_config(title=title, theme=theme, favicon=favicon, custom_css=custom_css)
29
+
30
+
31
+ def page(name):
32
+ """Switch context to build a new page. Subsequent elements belong to this page.
33
+
34
+ Args:
35
+ name: Unique page identifier/label (e.g. "Docs" or "Components").
36
+ """
37
+ _runtime.set_current_page(name)
38
+
39
+
40
+ # Text
41
+
42
+ def heading(text, level=1, *, style=None):
43
+ assert 1 <= level <= 6, "Heading level must be 1-6"
44
+ _runtime.add({"type": "heading", "text": text, "level": level, "style": style})
45
+
46
+
47
+ def text(content, *, style=None):
48
+ _runtime.add({"type": "paragraph", "text": content, "style": style})
49
+
50
+
51
+ def bold(content, *, style=None):
52
+ _runtime.add({"type": "bold", "text": content, "style": style})
53
+
54
+
55
+ def italic(content, *, style=None):
56
+ _runtime.add({"type": "italic", "text": content, "style": style})
57
+
58
+
59
+ def blockquote(content, *, style=None):
60
+ _runtime.add({"type": "blockquote", "text": content, "style": style})
61
+
62
+
63
+ def kbd(text_content):
64
+ """Render a keyboard-key style element."""
65
+ _runtime.add({"type": "kbd", "text": text_content})
66
+
67
+
68
+ # Lists
69
+
70
+ def bullet_list(items, *, style=None):
71
+ assert isinstance(items, list), "bullet_list expects a list"
72
+ _runtime.add({"type": "bullet_list", "items": items, "style": style})
73
+
74
+
75
+ def ordered_list(items, *, style=None):
76
+ assert isinstance(items, list), "ordered_list expects a list"
77
+ _runtime.add({"type": "ordered_list", "items": items, "style": style})
78
+
79
+
80
+ # Media / embeds
81
+
82
+ def image(src, *, alt="", width=None, height=None, style=None):
83
+ _runtime.add({"type": "image", "src": src, "alt": alt, "width": width, "height": height, "style": style})
84
+
85
+
86
+ def video(src, *, autoplay=False, loop=False, controls=True, width=None, height=None, style=None):
87
+ """Render a video element.
88
+
89
+ Args:
90
+ src: URL or path to the video (e.g. "assets/video.mp4").
91
+ autoplay: If True, play automatically (will be muted for browser compliance).
92
+ loop: If True, play in a loop.
93
+ controls: If True, display playback controls.
94
+ width: Optional width of the video.
95
+ height: Optional height of the video.
96
+ style: Optional style overrides dictionary.
97
+ """
98
+ _runtime.add({
99
+ "type": "video",
100
+ "src": src,
101
+ "autoplay": autoplay,
102
+ "loop": loop,
103
+ "controls": controls,
104
+ "width": width,
105
+ "height": height,
106
+ "style": style
107
+ })
108
+
109
+
110
+
111
+ def link(text, href, *, style=None):
112
+ _runtime.add({"type": "link", "text": text, "href": href, "style": style})
113
+
114
+
115
+ def code_block(code, *, language="", style=None):
116
+ _runtime.add({"type": "code_block", "code": code, "language": language, "style": style})
117
+
118
+
119
+ def html_raw(markup):
120
+ """Inject arbitrary HTML. Use with care."""
121
+ _runtime.add({"type": "raw", "html": markup})
122
+
123
+
124
+ # Decorative
125
+
126
+ def divider(*, style=None):
127
+ _runtime.add({"type": "divider", "style": style})
128
+
129
+
130
+ def spacer(height="1rem"):
131
+ _runtime.add({"type": "spacer", "height": height})
132
+
133
+
134
+ # Data display
135
+
136
+ def table(headers, rows, *, style=None):
137
+ """Render an HTML table.
138
+
139
+ Args:
140
+ headers: List of column header strings.
141
+ rows: List of rows, each row a list of cell values.
142
+ """
143
+ _runtime.add({"type": "table", "headers": headers, "rows": rows, "style": style})
144
+
145
+
146
+ def metric(label, value, *, delta=None, delta_color=None, style=None):
147
+ """Streamlit-style metric card.
148
+
149
+ Args:
150
+ label: Metric title.
151
+ value: Primary value to display.
152
+ delta: Optional delta string (e.g. "+12%").
153
+ delta_color: ``"green"`` (default for positive) or ``"red"``.
154
+ """
155
+ _runtime.add({
156
+ "type": "metric",
157
+ "label": label,
158
+ "value": value,
159
+ "delta": delta,
160
+ "delta_color": delta_color,
161
+ "style": style,
162
+ })
163
+
164
+
165
+ def progress_bar(value, *, max_value=100, label="", style=None):
166
+ _runtime.add({
167
+ "type": "progress_bar",
168
+ "value": value,
169
+ "max": max_value,
170
+ "label": label,
171
+ "style": style,
172
+ })
173
+
174
+
175
+ # UI elements
176
+
177
+ def badge(text, *, color="#6366f1", style=None):
178
+ _runtime.add({"type": "badge", "text": text, "color": color, "style": style})
179
+
180
+
181
+ def alert(text, *, variant="info", style=None):
182
+ """Alert box. ``variant`` is one of: info, success, warning, error."""
183
+ _runtime.add({"type": "alert", "text": text, "variant": variant, "style": style})
184
+
185
+
186
+ def button(label, *, style=None):
187
+ """Visual-only styled button (no click handler)."""
188
+ _runtime.add({"type": "button", "label": label, "style": style})
189
+
190
+
191
+ def link_button(label, href, *, style=None):
192
+ """An anchor element styled as a button. Navigates to *href* on click."""
193
+ _runtime.add({"type": "link_button", "label": label, "href": href, "style": style})
194
+
195
+
196
+ def card(title="", body="", *, style=None):
197
+ """A bordered card container. Accepts plain text title/body."""
198
+ _runtime.add({"type": "card", "title": title, "body": body, "style": style})
199
+
200
+
201
+ # Collapsible / Tabs
202
+
203
+ def accordion(items, *, style=None):
204
+ """Collapsible accordion using ``<details>/<summary>``.
205
+
206
+ Args:
207
+ items: List of ``{"title": "...", "content": "..."}`` dicts.
208
+ """
209
+ assert isinstance(items, list), "accordion expects a list of items"
210
+ _runtime.add({"type": "accordion", "items": items, "style": style})
211
+
212
+
213
+ def tabs(labels, *, style=None):
214
+ """Begin a tabbed section. Follow with ``tab_panel()`` context managers.
215
+
216
+ Args:
217
+ labels: List of tab label strings, one per panel.
218
+ """
219
+ assert isinstance(labels, list) and len(labels) >= 1, "tabs expects a list of labels"
220
+ comp = {"type": "tabs", "labels": labels, "panels": [], "style": style}
221
+ _runtime.add(comp)
222
+ _runtime._tabs_stack.append(comp)
223
+
224
+
225
+ @contextmanager
226
+ def tab_panel(*, style=None):
227
+ """Context manager for a single tab panel inside a ``tabs()`` block."""
228
+ assert _runtime._tabs_stack, "tab_panel() must be used after a tabs() call"
229
+ panel = {"type": "container", "children": [], "style": style}
230
+ _runtime._tabs_stack[-1]["panels"].append(panel)
231
+ _runtime._container_stack.append(panel["children"])
232
+ try:
233
+ yield
234
+ finally:
235
+ _runtime._container_stack.pop()
236
+ # Pop tabs_stack when all panels are defined
237
+ if len(_runtime._tabs_stack[-1]["panels"]) >= len(_runtime._tabs_stack[-1]["labels"]):
238
+ _runtime._tabs_stack.pop()
239
+
240
+
241
+ # Navbar & Footer
242
+
243
+ _VALID_NAV_VARIANTS = {"simple", "centered"}
244
+ _VALID_FOOTER_VARIANTS = {"simple", "columns"}
245
+
246
+ def navbar(title="", links=None, *, variant="simple", style=None):
247
+ """Add a navigation bar to the top of the page.
248
+
249
+ Args:
250
+ title: Brand / logo text.
251
+ links: List of ``{"text": "...", "href": "..."}`` dicts.
252
+ variant: ``"simple"`` (logo left, links right) or
253
+ ``"centered"`` (logo centred, links below).
254
+ """
255
+ assert variant in _VALID_NAV_VARIANTS, f"navbar variant must be one of {_VALID_NAV_VARIANTS}"
256
+ _runtime.add({
257
+ "type": "navbar",
258
+ "title": title,
259
+ "links": links or [],
260
+ "variant": variant,
261
+ "style": style,
262
+ })
263
+
264
+
265
+ def footer(text="", links=None, *, variant="simple", columns_data=None, style=None):
266
+ """Add a footer to the bottom of the page.
267
+
268
+ Args:
269
+ text: Main footer text (copyright line, etc.).
270
+ links: List of ``{"text": "...", "href": "..."}`` dicts.
271
+ variant: ``"simple"`` (single line) or ``"columns"`` (multi-column).
272
+ columns_data: For ``"columns"`` variant only -- list of
273
+ ``{"heading": "...", "links": [{"text": ..., "href": ...}, ...]}`` dicts.
274
+ """
275
+ assert variant in _VALID_FOOTER_VARIANTS, f"footer variant must be one of {_VALID_FOOTER_VARIANTS}"
276
+ _runtime.add({
277
+ "type": "footer",
278
+ "text": text,
279
+ "links": links or [],
280
+ "variant": variant,
281
+ "columns_data": columns_data or [],
282
+ "style": style,
283
+ })
284
+
285
+
286
+ # Layout: container & columns
287
+
288
+ @contextmanager
289
+ def container(*, style=None):
290
+ """Context manager -- wraps children in a styled ``<div>``."""
291
+ comp = {"type": "container", "children": [], "style": style}
292
+ _runtime.push_container(comp)
293
+ try:
294
+ yield
295
+ finally:
296
+ _runtime.pop_container()
297
+
298
+
299
+ @contextmanager
300
+ def columns(ratios=None, *, gap="1rem", style=None):
301
+ """Context manager -- CSS-grid columns.
302
+
303
+ ``ratios`` is a list of relative widths, e.g. ``[1, 2]`` -> ``1fr 2fr``.
304
+ Use nested ``column()`` calls inside.
305
+ """
306
+ if ratios is None:
307
+ ratios = [1, 1]
308
+ template = " ".join(f"{r}fr" for r in ratios)
309
+ merged = style or {}
310
+ merged = {
311
+ "display": "grid",
312
+ "grid_template_columns": template,
313
+ "gap": gap,
314
+ **merged,
315
+ }
316
+ comp = {"type": "container", "children": [], "style": merged}
317
+ _runtime.push_container(comp)
318
+ try:
319
+ yield
320
+ finally:
321
+ _runtime.pop_container()
322
+
323
+
324
+ @contextmanager
325
+ def column(*, style=None):
326
+ """A single column inside a ``columns()`` block."""
327
+ merged = style or {}
328
+ merged = {"min_width": "0", **merged}
329
+ comp = {"type": "container", "children": [], "style": merged}
330
+ _runtime.push_container(comp)
331
+ try:
332
+ yield
333
+ finally:
334
+ _runtime.pop_container()
335
+
pyclay/_cli.py ADDED
@@ -0,0 +1,153 @@
1
+ import sys
2
+ import os
3
+ import time
4
+ import traceback
5
+ from watchdog.observers import Observer
6
+ from watchdog.events import FileSystemEventHandler
7
+ from pyclay import _runtime, _renderer, _server
8
+
9
+ def run_script(script_path):
10
+ _runtime.reset()
11
+ try:
12
+ with open(script_path, encoding="utf-8") as f:
13
+ code = f.read()
14
+ exec(code, {"__name__": "__main__"})
15
+ html = _renderer.render_page()
16
+ return html
17
+ except Exception:
18
+ # Format a friendly error page instead of crashing
19
+ tb = traceback.format_exc()
20
+ print(f"\n{'='*60}")
21
+ print(f" ERROR in {script_path}")
22
+ print(f"{'='*60}")
23
+ print(tb)
24
+ print(f"{'='*60}\n")
25
+ error_html = f"""<!DOCTYPE html>
26
+ <html><head><meta charset="UTF-8"><title>pyclay – Error</title>
27
+ <style>
28
+ body {{ font-family: 'Inter', -apple-system, sans-serif; background: #0f0f17; color: #e4e4f0; margin: 0; padding: 2rem; }}
29
+ .error-box {{ background: #1a1a2e; border: 1px solid #ef4444; border-radius: 10px; padding: 2rem; max-width: 800px; margin: 2rem auto; }}
30
+ h1 {{ color: #ef4444; margin-top: 0; font-size: 1.4rem; }}
31
+ pre {{ background: #111; padding: 1.2rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; line-height: 1.6; color: #fca5a5; }}
32
+ p {{ color: #888; font-size: 0.9rem; }}
33
+ </style></head>
34
+ <body>
35
+ <div class="error-box">
36
+ <h1>\u26a0 Build Error</h1>
37
+ <pre>{_renderer._esc(tb)}</pre>
38
+ <p>Fix the error and save your file - the page will auto-reload.</p>
39
+ </div>
40
+ </body></html>"""
41
+ return error_html
42
+
43
+ class _ReloadHandler(FileSystemEventHandler):
44
+ def __init__(self, script_path):
45
+ self.script_path = script_path
46
+
47
+ def on_modified(self, event):
48
+ if event.src_path.endswith(self.script_path.lstrip("./")):
49
+ print(f"Change detected - reloading {self.script_path}")
50
+ html = run_script(self.script_path)
51
+ _server.update_html(html) # push new HTML, bump timestamp
52
+
53
+ def main():
54
+ if len(sys.argv) < 2:
55
+ print("Usage: pyclay <command> [args]")
56
+ print("")
57
+ print("Commands:")
58
+ print(" run <script.py> Start dev server with hot reload")
59
+ print(" build <script.py> [--out DIR] Export static HTML")
60
+ sys.exit(1)
61
+
62
+ command = sys.argv[1]
63
+
64
+ if command == "build":
65
+ if len(sys.argv) < 3:
66
+ print("Usage: pyclay build <script.py> [--out DIR]")
67
+ sys.exit(1)
68
+
69
+ script_path = sys.argv[2]
70
+
71
+ # Parse --out flag
72
+ out_dir = "dist"
73
+ if "--out" in sys.argv:
74
+ idx = sys.argv.index("--out")
75
+ if idx + 1 < len(sys.argv):
76
+ out_dir = sys.argv[idx + 1]
77
+
78
+ _runtime.reset()
79
+ try:
80
+ with open(script_path, encoding="utf-8") as f:
81
+ code = f.read()
82
+ exec(code, {"__name__": "__main__"})
83
+ html = _renderer.render_page()
84
+ except Exception:
85
+ print(f"\nBuild failed for {script_path}:")
86
+ traceback.print_exc()
87
+ sys.exit(1)
88
+
89
+ # Strip hot-reload JS from production build
90
+ import re
91
+ html = re.sub(r'<script>\s*let _pc_last.*?</script>', '', html, flags=re.DOTALL)
92
+
93
+ os.makedirs(out_dir, exist_ok=True)
94
+ out_path = os.path.join(out_dir, "index.html")
95
+ with open(out_path, "w", encoding="utf-8") as f:
96
+ f.write(html)
97
+
98
+ # Copy assets/ directory to output directory if it exists
99
+ if os.path.exists("assets"):
100
+ import shutil
101
+ out_assets_dir = os.path.join(out_dir, "assets")
102
+ try:
103
+ if os.path.exists(out_assets_dir):
104
+ shutil.rmtree(out_assets_dir)
105
+ shutil.copytree("assets", out_assets_dir)
106
+ print(f" Assets: copied assets/ -> {out_assets_dir}")
107
+ except Exception as e:
108
+ print(f" Warning: failed to copy assets folder: {e}")
109
+
110
+ abs_path = os.path.abspath(out_path)
111
+ print(f"Built successfully -> {abs_path}")
112
+ print(f" Size: {len(html):,} bytes")
113
+
114
+ elif command == "run":
115
+ if len(sys.argv) < 3:
116
+ print("Usage: pyclay run <script.py> [--port PORT]")
117
+ sys.exit(1)
118
+
119
+ script_path = sys.argv[2]
120
+
121
+ # Parse --port flag
122
+ port = 8501
123
+ if "--port" in sys.argv:
124
+ idx = sys.argv.index("--port")
125
+ if idx + 1 < len(sys.argv):
126
+ port = int(sys.argv[idx + 1])
127
+
128
+ # first render
129
+ html = run_script(script_path)
130
+ server = _server.serve(html, port=port)
131
+
132
+ # watch for file changes
133
+ handler = _ReloadHandler(script_path)
134
+ observer = Observer()
135
+ observer.schedule(handler, path=".", recursive=True)
136
+ observer.start()
137
+
138
+ print("Watching for changes... (Ctrl+C to stop)")
139
+
140
+ try:
141
+ while True:
142
+ time.sleep(1)
143
+ except KeyboardInterrupt:
144
+ print("\nStopped.")
145
+ observer.stop()
146
+ server.shutdown()
147
+
148
+ observer.join()
149
+
150
+ else:
151
+ print(f"Unknown command: {command}")
152
+ print("Available commands: run, build")
153
+ sys.exit(1)