pyrite-web 0.1.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.
pyrite/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Websites in Python, without handwritten JavaScript or CSS."""
2
+
3
+ from .core import App, Button, Card, Code, Column, Details, Heading, Link, Nav, Page, Row, Section, Text
4
+ from .core import Form, Input, Select, Slider, Submit, query
5
+ from .styles import Theme
6
+ from .core import Image, Motion
7
+ from . import requests
8
+ from .browser import BrowserPlayground
9
+ from .styles import Gradient
10
+ from .live import Chat, LiveForm
11
+
12
+ __all__ = ["App", "Button", "Card", "Code", "Column", "Details", "Heading", "Link", "Nav", "Page", "Row", "Section", "Text"]
13
+ __all__ += ["Form", "Input", "Select", "Slider", "Submit", "query"]
14
+ __all__ += ["Theme"]
15
+ __all__ += ["Image", "Motion", "requests"]
16
+ __all__ += ["BrowserPlayground"]
17
+ __all__ += ["Gradient", "Chat", "LiveForm"]
pyrite/browser.py ADDED
@@ -0,0 +1,105 @@
1
+ """A browser-only Python editor, isolated from the application server."""
2
+ import json
3
+ from html import escape
4
+ from pathlib import Path
5
+
6
+ from .core import Element
7
+
8
+
9
+ STARTER = '''from pyrite import Page, Column, Heading, Text, Card, Theme, Motion
10
+
11
+ theme = Theme(
12
+ background="#111820", text="#eef3f8",
13
+ accent="#baf26b", heading_font="sans",
14
+ )
15
+
16
+ page = Page(
17
+ Motion(
18
+ Heading("I made this in Python."),
19
+ Card(Text("Change the words. Change the theme. Hit Run.")),
20
+ effect="rise",
21
+ ),
22
+ theme=theme,
23
+ )
24
+ '''
25
+
26
+
27
+ class BrowserPlayground(Element):
28
+ """One editor per page. Pyodide runs user code in a disposable Web Worker."""
29
+
30
+ def __init__(self):
31
+ super().__init__("div", ())
32
+
33
+ def render(self):
34
+ # Ship this exact installed framework, rather than a second implementation.
35
+ sources = {path.name: path.read_text() for path in Path(__file__).parent.glob("*.py") if path.name != "browser.py"}
36
+ # The browser doesn't need to import its editor or the server-side HTTP client.
37
+ sources["__init__.py"] = sources["__init__.py"].replace("from .browser import BrowserPlayground", "")
38
+ sources["__init__.py"] = sources["__init__.py"].replace('__all__ += ["BrowserPlayground"]', "")
39
+ payload = json.dumps(sources).replace("<", "\\u003c")
40
+ return '''<div class="column">
41
+ <label for="pyrite-editor">Python source</label>
42
+ <textarea id="pyrite-editor" spellcheck="false">''' + escape(STARTER) + r'''</textarea>
43
+ <div class="row"><button class="button" id="pyrite-run" type="button">Run Python</button>
44
+ <button class="button" id="pyrite-reset" type="button">Reset example</button></div>
45
+ <p id="pyrite-status" role="status">Ready. First run downloads Python from jsDelivr.</p>
46
+ <iframe class="preview" id="pyrite-preview" title="Your Pyrite website" sandbox=""></iframe>
47
+ <noscript>This editor needs JavaScript to run Python in your browser. All other pages work without it.</noscript>
48
+ </div><script>
49
+ (() => {
50
+ const sources = ''' + payload + r''';
51
+ const editor = document.getElementById('pyrite-editor');
52
+ const initial = editor.value;
53
+ const run = document.getElementById('pyrite-run');
54
+ const status = document.getElementById('pyrite-status');
55
+ const preview = document.getElementById('pyrite-preview');
56
+ let worker, timer;
57
+ const workerSource = `
58
+ importScripts('https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js');
59
+ let runtime;
60
+ self.onmessage = async ({data}) => {
61
+ try {
62
+ if (!runtime) {
63
+ runtime = await loadPyodide();
64
+ runtime.FS.mkdirTree('/home/pyodide/pyrite');
65
+ for (const [name, source] of Object.entries(data.sources)) {
66
+ runtime.FS.writeFile('/home/pyodide/pyrite/' + name, source);
67
+ }
68
+ }
69
+ self.postMessage({ready: true});
70
+ runtime.globals.set('_editor_source', data.code);
71
+ const html = await runtime.runPythonAsync(\`
72
+ from pyrite import Page
73
+ _editor_scope = {"__name__": "__playground__"}
74
+ exec(compile(_editor_source, "playground.py", "exec"), _editor_scope)
75
+ _editor_page = _editor_scope.get("page")
76
+ if not isinstance(_editor_page, Page):
77
+ raise TypeError("Create a Page and assign it to a variable named page. Do not call app.run().")
78
+ _editor_page.render()
79
+ \`);
80
+ self.postMessage({html});
81
+ } catch (error) { self.postMessage({error: String(error)}); }
82
+ };`;
83
+ function stop() { if (worker) worker.terminate(); clearTimeout(timer); run.disabled = false; }
84
+ run.onclick = () => {
85
+ stop();
86
+ const url = URL.createObjectURL(new Blob([workerSource], {type: 'text/javascript'}));
87
+ worker = new Worker(url); URL.revokeObjectURL(url);
88
+ run.disabled = true; status.textContent = 'Loading Python…';
89
+ timer = setTimeout(() => { stop(); status.textContent = 'Python download timed out. Check your connection and try again.'; }, 120000);
90
+ worker.onmessage = ({data}) => {
91
+ if (data.ready) {
92
+ clearTimeout(timer); status.textContent = 'Running Python…';
93
+ timer = setTimeout(() => { stop(); status.textContent = 'Stopped: execution exceeded 10 seconds.'; }, 10000);
94
+ return;
95
+ }
96
+ if (data.html) { preview.srcdoc = data.html; status.textContent = 'Rendered. Edit and run again.'; }
97
+ else status.textContent = data.error;
98
+ stop();
99
+ };
100
+ worker.onerror = (event) => { status.textContent = event.message || 'Unable to load Python. Check your connection.'; stop(); };
101
+ worker.postMessage({sources, code: editor.value});
102
+ };
103
+ document.getElementById('pyrite-reset').onclick = () => { stop(); editor.value = initial; preview.srcdoc = ''; status.textContent = 'Example reset.'; };
104
+ })();
105
+ </script>'''
pyrite/core.py ADDED
@@ -0,0 +1,337 @@
1
+ """Small, dependency-free HTML components and HTTP application."""
2
+
3
+ from dataclasses import dataclass
4
+ from contextvars import ContextVar
5
+ from html import escape
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from typing import Callable
8
+ from urllib.parse import parse_qs, urlsplit
9
+ from .styles import Theme
10
+ import json
11
+
12
+
13
+ _query: ContextVar[dict] = ContextVar("pyrite_query", default={})
14
+
15
+
16
+ def query(name: str, default: str = "") -> str:
17
+ """Read the first GET parameter for the current request."""
18
+ return _query.get().get(name, [default])[0]
19
+
20
+
21
+ def _url(value: str) -> str:
22
+ # Reject executable schemes, including schemes disguised with whitespace.
23
+ normalized = "".join(value.split())
24
+ if urlsplit(normalized).scheme.lower() not in ("", "http", "https", "mailto", "tel"):
25
+ raise ValueError("Links require a relative, http, https, mailto, or tel URL")
26
+ return value
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Element:
31
+ tag: str
32
+ children: tuple
33
+ attributes: tuple = ()
34
+
35
+ def render(self) -> str:
36
+ attributes = "".join(
37
+ f' {name}="{escape(str(value), quote=True)}"'
38
+ for name, value in self.attributes
39
+ )
40
+ content = "".join(
41
+ child.render() if isinstance(child, Element) else escape(str(child))
42
+ for child in self.children
43
+ if child is not None
44
+ )
45
+ if self.tag in ("input", "img"):
46
+ return f"<{self.tag}{attributes}>"
47
+ return f"<{self.tag}{attributes}>{content}</{self.tag}>"
48
+
49
+
50
+ def Text(value: object) -> Element:
51
+ return Element("p", (value,))
52
+
53
+
54
+ def Heading(value: object, *, level: int = 1) -> Element:
55
+ if type(level) is not int or not 1 <= level <= 6:
56
+ raise ValueError("Heading level must be an integer from 1 to 6")
57
+ return Element(f"h{level}", (value,))
58
+
59
+
60
+ def Column(*children: object) -> Element:
61
+ return Element("div", children, (("class", "column"),))
62
+
63
+
64
+ def Row(*children: object) -> Element:
65
+ return Element("div", children, (("class", "row"),))
66
+
67
+
68
+ def Card(*children: object) -> Element:
69
+ return Element("section", children, (("class", "card"),))
70
+
71
+
72
+ def Link(label: str, href: str) -> Element:
73
+ return Element("a", (label,), (("href", _url(href)),))
74
+
75
+
76
+ def Button(label: str, href: str) -> Element:
77
+ """A navigation action styled as a button; no client-side runtime required."""
78
+ return Element("a", (label,), (("href", _url(href)), ("class", "button")))
79
+
80
+
81
+ def Section(*children: object, id: str) -> Element:
82
+ return Element("section", children, (("id", id), ("class", "section column")))
83
+
84
+
85
+ def Nav(*children: object) -> Element:
86
+ return Element("nav", children, (("aria-label", "Main navigation"), ("class", "navigation")))
87
+
88
+
89
+ def Code(source: str, *, label: str = "Python") -> Element:
90
+ return Element("figure", (Element("figcaption", (label,)), Element("pre", (Element("code", (source,)),))), (("class", "code"),))
91
+
92
+
93
+ def Details(question: str, *children: object) -> Element:
94
+ return Element("details", (Element("summary", (question,)), *children))
95
+
96
+
97
+ def Image(src: str, alt: str) -> Element:
98
+ if urlsplit(src).scheme not in ("", "http", "https"):
99
+ raise ValueError("Images require a relative, http, or https URL")
100
+ return Element("img", (), (("src", src), ("alt", alt), ("loading", "lazy"), ("width", "1400"), ("height", "800"), ("class", "image")))
101
+
102
+
103
+ def Motion(*children: object, effect: str = "rise") -> Element:
104
+ if effect not in ("rise", "fade", "float"):
105
+ raise ValueError("Motion effect must be rise, fade, or float")
106
+ return Element("div", children, (("class", f"column motion motion-{effect}"),))
107
+
108
+
109
+ def Form(*children: object, action: str = "") -> Element:
110
+ """Submit read-only parameters with a native GET form."""
111
+ return Element("form", children, (("action", _url(action)), ("method", "get"), ("class", "column")))
112
+
113
+
114
+ def Input(name: str, label: str, *, value: str = "", kind: str = "text") -> Element:
115
+ if kind not in ("text", "email", "number", "search", "url", "date"):
116
+ raise ValueError("Unsupported input kind")
117
+ return Element("label", (label, Element("input", (), (("name", name), ("type", kind), ("value", value)))))
118
+
119
+
120
+ def Slider(name: str, label: str, *, value: int = 5, minimum: int = 1, maximum: int = 10) -> Element:
121
+ if not minimum <= value <= maximum:
122
+ raise ValueError("Slider value must be between minimum and maximum")
123
+ return Element("label", (label, Element("input", (), (("type", "range"), ("name", name), ("min", minimum), ("max", maximum), ("value", value)))))
124
+
125
+
126
+ def Select(name: str, label: str, options: dict[str, str], *, value: str = "") -> Element:
127
+ return Element("label", (label, Element("select", tuple(
128
+ Element("option", (text,), (("value", key),) + ((("selected", "selected"),) if key == value else ()))
129
+ for key, text in options.items()
130
+ ), (("name", name),))))
131
+
132
+
133
+ def Submit(label: str = "Submit") -> Element:
134
+ return Element("button", (label,), (("type", "submit"), ("class", "button")))
135
+
136
+
137
+ _STYLE = """
138
+ :root { color-scheme: light; font-family: system-ui, sans-serif; color: #25251f;
139
+ background: #f6f5ef; line-height: 1.6; }
140
+ * { box-sizing: border-box; }
141
+ body { margin: 0; }
142
+ main { max-width: 960px; margin: auto; padding: 64px 24px; }
143
+ h1,h2,h3,h4,h5,h6,p { margin: 0; }
144
+ h1 { font-size: clamp(2.5rem, 7vw, 5rem); line-height: 1.08; letter-spacing: -.05em; }
145
+ h2 { font-size: 1.7rem; line-height: 1.2; }
146
+ p { max-width: 65ch; }
147
+ a { color: inherit; text-underline-offset: .2em; }
148
+ .column { display: flex; flex-direction: column; gap: 24px; }
149
+ .row { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; }
150
+ .row > .card { flex: 1 1 240px; }
151
+ .card { padding: 28px; border: 1px solid #d8d7cc; border-radius: 12px; }
152
+ .card > * + * { margin-top: 12px; }
153
+ .button { display: inline-block; padding: 12px 22px; background: #393e24;
154
+ color: white; border-radius: 6px; text-decoration: none; font-weight: 600; }
155
+ .button:hover { background: #525a33; }
156
+ a:focus-visible { outline: 3px solid #8b6720; outline-offset: 5px; }
157
+ .navigation { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 24px; padding-bottom: 32px; }
158
+ .navigation > a:first-child { font: bold 32px Georgia, serif; text-decoration: none; }
159
+ .section { padding: 64px 0; scroll-margin-top: 24px; }
160
+ .section > h1 { font-family: Georgia, serif; font-weight: 400; font-size: clamp(2.5rem, 8vw, 7rem); max-width: 14ch; }
161
+ .section > p { font-size: 20px; max-width: 48ch; }
162
+ .code { margin: 0; background: #172c24; color: #f3f3dd; overflow: hidden; }
163
+ .code figcaption { padding: 18px 26px; color: #d4deae; }
164
+ .code pre { margin: 0; padding: 8px 26px 28px; overflow-x: auto; font: 15px/1.8 ui-monospace, monospace; tab-size: 4; }
165
+ #demo { background: #e5e9d7; padding: 40px; }
166
+ #demo > .row { align-items: stretch; }
167
+ #demo > .row > * { flex: 1 1 280px; min-width: 0; }
168
+ #demo .card { background: #f6f5ef; border: 0; border-radius: 0; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
169
+ details { background: #e5e9d7; padding: 24px; }
170
+ summary { cursor: pointer; font-size: 20px; font-weight: 600; }
171
+ details p { padding-top: 16px; }
172
+ .button { transition: transform .18s ease, background .18s ease; }
173
+ .button:hover { transform: translateY(-2px); }
174
+ .button:active { transform: translateY(1px); }
175
+ @media (max-width: 600px) {
176
+ main { padding: 24px 20px; }
177
+ .navigation { padding-bottom: 24px; gap: 16px; }
178
+ .section { padding: 40px 0; }
179
+ #demo { padding: 24px 16px; }
180
+ .code pre { font-size: 14px; padding-inline: 16px; }
181
+ }
182
+ @media (prefers-reduced-motion: reduce) { .button { transition: none; } }
183
+ label { display: flex; flex-direction: column; gap: 8px; font-weight: 600; }
184
+ input, select { font: inherit; color: inherit; background: #fff; padding: 12px;
185
+ border: 1px solid #8c9181; border-radius: 6px; min-width: 0; width: 100%; }
186
+ input[type=range] { padding: 12px 0; accent-color: #393e24; }
187
+ button { font: inherit; border: 0; cursor: pointer; align-self: flex-start; }
188
+ input:focus-visible, select:focus-visible, button:focus-visible, summary:focus-visible {
189
+ outline: 3px solid #8b6720; outline-offset: 5px; }
190
+ """
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class Page:
195
+ content: Element
196
+ title: str = "Pyrite"
197
+ language: str = "en"
198
+ theme: Theme = Theme()
199
+
200
+ def render(self) -> str:
201
+ return (
202
+ f'<!doctype html><html lang="{escape(self.language, quote=True)}">'
203
+ '<head><meta charset="utf-8">'
204
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
205
+ f"<title>{escape(self.title)}</title><style>{_STYLE}{self.theme.css()}</style></head>"
206
+ f"<body><main>{self.content.render()}</main></body></html>"
207
+ )
208
+
209
+
210
+ class App:
211
+ """Exact-path GET routes. Handlers return a Page."""
212
+
213
+ def __init__(self) -> None:
214
+ self._routes: dict[str, Callable[[], Page]] = {}
215
+ self._actions: dict[str, Callable[[dict], dict]] = {}
216
+
217
+ def action(self, path: str) -> Callable:
218
+ """Register a JSON POST callback. Return a JSON-serializable dictionary."""
219
+ if not path.startswith("/") or path.startswith("//") or "?" in path or "#" in path:
220
+ raise ValueError("Action must be a same-origin absolute path")
221
+
222
+ def register(handler):
223
+ if path in self._actions:
224
+ raise ValueError(f"Duplicate action: {path}")
225
+ self._actions[path] = handler
226
+ return handler
227
+
228
+ return register
229
+
230
+ def page(self, path: str) -> Callable:
231
+ if not path.startswith("/") or "?" in path or "#" in path:
232
+ raise ValueError("Route must be an absolute path without query or fragment")
233
+
234
+ def register(handler: Callable[[], Page]) -> Callable[[], Page]:
235
+ if path in self._routes:
236
+ raise ValueError(f"Duplicate route: {path}")
237
+ self._routes[path] = handler
238
+ return handler
239
+
240
+ return register
241
+
242
+ def handler(self) -> type[BaseHTTPRequestHandler]:
243
+ app = self
244
+
245
+ class Handler(BaseHTTPRequestHandler):
246
+ def do_POST(self) -> None:
247
+ action = app._actions.get(urlsplit(self.path).path)
248
+ if action is None:
249
+ self.send_error(404)
250
+ return
251
+ origin = self.headers.get("Origin")
252
+ if self.headers.get("Sec-Fetch-Site") == "cross-site" or (
253
+ origin and (urlsplit(origin).scheme not in ("http", "https") or urlsplit(origin).netloc != self.headers.get("Host"))
254
+ ):
255
+ self.send_error(403)
256
+ return
257
+ if self.headers.get("Content-Type", "").split(";")[0].strip().lower() != "application/json":
258
+ self.send_error(415)
259
+ return
260
+ try:
261
+ length = int(self.headers.get("Content-Length", "0"))
262
+ except ValueError:
263
+ self.send_error(400)
264
+ return
265
+ if not 0 < length <= 16384:
266
+ self.send_error(413)
267
+ return
268
+ try:
269
+ self.connection.settimeout(10)
270
+ data = json.loads(self.rfile.read(length))
271
+ if not isinstance(data, dict):
272
+ raise ValueError("Action payload must be an object")
273
+ except (ValueError, UnicodeError):
274
+ self.send_error(400)
275
+ return
276
+ except OSError:
277
+ self.send_error(408)
278
+ return
279
+ try:
280
+ result = action(data)
281
+ if not isinstance(result, dict):
282
+ raise TypeError("Actions must return dictionaries")
283
+ body = json.dumps(result, ensure_ascii=False, allow_nan=False).encode()
284
+ except ValueError:
285
+ self.send_error(400)
286
+ return
287
+ except Exception:
288
+ self.log_error("Action failed")
289
+ self.send_error(500)
290
+ return
291
+ self.send_response(200)
292
+ self.send_header("Content-Type", "application/json; charset=utf-8")
293
+ self.send_header("Cache-Control", "no-store")
294
+ self.send_header("X-Content-Type-Options", "nosniff")
295
+ self.send_header("Content-Length", str(len(body)))
296
+ self.end_headers()
297
+ self.wfile.write(body)
298
+
299
+ def do_GET(self) -> None:
300
+ route = app._routes.get(urlsplit(self.path).path)
301
+ if route is None:
302
+ self.send_error(404)
303
+ return
304
+ try:
305
+ params = parse_qs(urlsplit(self.path).query, keep_blank_values=True, max_num_fields=100)
306
+ token = _query.set(params)
307
+ try:
308
+ page = route()
309
+ if not isinstance(page, Page):
310
+ raise TypeError("Route handlers must return a Page")
311
+ body = page.render().encode("utf-8")
312
+ finally:
313
+ _query.reset(token)
314
+ except ValueError:
315
+ self.send_error(400)
316
+ return
317
+ except Exception:
318
+ self.log_error("Route failed")
319
+ self.send_error(500)
320
+ return
321
+ self.send_response(200)
322
+ self.send_header("Content-Type", "text/html; charset=utf-8")
323
+ self.send_header("Content-Length", str(len(body)))
324
+ self.send_header("X-Content-Type-Options", "nosniff")
325
+ self.end_headers()
326
+ self.wfile.write(body)
327
+
328
+ return Handler
329
+
330
+ def run(self, host: str = "127.0.0.1", port: int = 8000) -> None:
331
+ """Run the local development server until interrupted."""
332
+ with ThreadingHTTPServer((host, port), self.handler()) as server:
333
+ print(f"Pyrite running at http://{host}:{server.server_port}", flush=True)
334
+ try:
335
+ server.serve_forever()
336
+ except KeyboardInterrupt:
337
+ pass
pyrite/live.py ADDED
@@ -0,0 +1,75 @@
1
+ """Components that call Python actions without navigating away."""
2
+ from .core import Element, Input, Submit
3
+
4
+
5
+ _RUNTIME = r'''<script>
6
+ (() => {
7
+ const root = document.currentScript.previousElementSibling;
8
+ const form = root.querySelector('form');
9
+ const output = root.querySelector('.live-output');
10
+ const status = root.querySelector('[role=status]');
11
+ const button = form.querySelector('button[type=submit]');
12
+ const chat = root.dataset.chat === 'true';
13
+ let history = [];
14
+ form.addEventListener('submit', async event => {
15
+ event.preventDefault();
16
+ if (button.disabled) return;
17
+ const payload = Object.fromEntries(new FormData(form));
18
+ if (chat) {
19
+ payload.message = payload.message.trim();
20
+ if (!payload.message) { status.textContent = 'Write a message first.'; return; }
21
+ payload.history = history;
22
+ }
23
+ button.disabled = true; status.textContent = 'Waiting for Python…';
24
+ root.setAttribute('aria-busy', 'true');
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), 30000);
27
+ try {
28
+ const response = await fetch(form.action, {
29
+ method: 'POST', headers: {'Content-Type': 'application/json'},
30
+ body: JSON.stringify(payload), signal: controller.signal,
31
+ });
32
+ if (!response.ok) throw new Error('Request failed (' + response.status + '). Please try again.');
33
+ const data = await response.json();
34
+ const text = chat ? data.reply : data.text;
35
+ if (typeof text !== 'string') throw new Error('Python must return ' + (chat ? 'reply' : 'text') + ' as a string.');
36
+ function append(text) { const p = document.createElement('p'); p.textContent = text; output.appendChild(p); }
37
+ if (chat) {
38
+ append('You: ' + payload.message); append('Pyrite: ' + text);
39
+ history = [...history, {role: 'user', content: payload.message}, {role: 'assistant', content: text}].slice(-20);
40
+ while (output.children.length > 20) output.firstElementChild.remove();
41
+ if (form.elements.message.value.trim() === payload.message) form.elements.message.value = '';
42
+ } else { output.replaceChildren(); append(text); }
43
+ status.textContent = 'Updated.';
44
+ } catch (error) {
45
+ status.textContent = error.name === 'AbortError' ? 'Request timed out. Try again.' : error.message;
46
+ } finally {
47
+ clearTimeout(timer); button.disabled = false; root.removeAttribute('aria-busy');
48
+ }
49
+ });
50
+ })();
51
+ </script>'''
52
+
53
+
54
+ class LiveForm(Element):
55
+ """Submit inputs to an @app.action endpoint returning {'text': '...'}.
56
+
57
+ JavaScript is supplied by Pyrite. The application remains Python-only.
58
+ """
59
+ def __init__(self, *children, action: str, chat: bool = False):
60
+ if not action.startswith("/") or action.startswith("//") or "?" in action or "#" in action:
61
+ raise ValueError("Live forms require a same-origin absolute action path")
62
+ super().__init__("section", (
63
+ Element("div", (), (("class", "live-output"), ("role", "log"), ("aria-live", "polite"))),
64
+ Element("form", children, (("action", action), ("method", "post"), ("class", "column"))),
65
+ Element("p", ("Ready.",), (("role", "status"),)),
66
+ Element("noscript", ("This live component requires JavaScript. Pyrite supplies it automatically.",)),
67
+ ), (("class", "column"), ("data-chat", str(chat).lower())))
68
+
69
+ def render(self):
70
+ return super().render() + _RUNTIME
71
+
72
+
73
+ def Chat(*, action: str, placeholder: str = "Your message") -> LiveForm:
74
+ """Conversation UI. Action receives message/history and returns {'reply': '...'}."""
75
+ return LiveForm(Input("message", placeholder), Submit("Send message"), action=action, chat=True)
pyrite/requests.py ADDED
@@ -0,0 +1,61 @@
1
+ """Small standard-library HTTP client for server-side Python."""
2
+ import json as _json
3
+ from dataclasses import dataclass
4
+ from urllib.error import HTTPError, URLError
5
+ from urllib.parse import urlencode, urlsplit
6
+ from urllib.request import Request, urlopen
7
+
8
+
9
+ class RequestError(RuntimeError):
10
+ """A network, HTTP, response-size, or decoding failure."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Response:
15
+ status_code: int
16
+ text: str
17
+
18
+ def json(self):
19
+ try:
20
+ return _json.loads(self.text)
21
+ except ValueError as error:
22
+ raise RequestError("Response was not valid JSON") from error
23
+
24
+
25
+ def request(method, url, *, params=None, json=None, headers=None, timeout=8):
26
+ """Send an HTTP request. URLs must come from trusted application code."""
27
+ if urlsplit(url).scheme not in ("http", "https"):
28
+ raise ValueError("HTTP requests require an http or https URL")
29
+ if params:
30
+ url += ("&" if "?" in url else "?") + urlencode(params)
31
+ body = None if json is None else _json.dumps(json).encode()
32
+ request_headers = {"User-Agent": "Pyrite/0.1", **(headers or {})}
33
+ if body is not None:
34
+ request_headers.setdefault("Content-Type", "application/json")
35
+ try:
36
+ with urlopen(Request(url, data=body, headers=request_headers, method=method), timeout=timeout) as response:
37
+ data = response.read(1_048_577)
38
+ if len(data) > 1_048_576:
39
+ raise RequestError("Response exceeded 1 MiB")
40
+ return Response(response.status, data.decode(response.headers.get_content_charset() or "utf-8"))
41
+ except HTTPError as error:
42
+ error.close()
43
+ raise RequestError(f"HTTP request failed: {error}") from error
44
+ except (URLError, TimeoutError, OSError, UnicodeError) as error:
45
+ raise RequestError(f"HTTP request failed: {error}") from error
46
+
47
+
48
+ def get(url, **kwargs):
49
+ return request("GET", url, **kwargs)
50
+
51
+
52
+ def post(url, **kwargs):
53
+ return request("POST", url, **kwargs)
54
+
55
+
56
+ def put(url, **kwargs):
57
+ return request("PUT", url, **kwargs)
58
+
59
+
60
+ def delete(url, **kwargs):
61
+ return request("DELETE", url, **kwargs)
pyrite/styles.py ADDED
@@ -0,0 +1,112 @@
1
+ """Validated theme settings for Pyrite's built-in components."""
2
+ from dataclasses import dataclass
3
+ import re
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Gradient:
8
+ colors: tuple[str, ...] = ("#142a35", "#234936", "#111820")
9
+ angle: int = 120
10
+ moving: bool = False
11
+ duration: int = 12
12
+
13
+ def css(self) -> str:
14
+ if not 2 <= len(self.colors) <= 8 or any(not re.fullmatch(r"#[0-9a-fA-F]{6}", c) for c in self.colors):
15
+ raise ValueError("Gradient requires 2–8 six-digit hex colors")
16
+ if type(self.angle) is not int or not 0 <= self.angle <= 360:
17
+ raise ValueError("Gradient angle must be 0–360")
18
+ if type(self.moving) is not bool or type(self.duration) is not int or not 1 <= self.duration <= 120:
19
+ raise ValueError("Gradient moving must be boolean; duration must be 1–120 seconds")
20
+ return f"linear-gradient({self.angle}deg, {', '.join(self.colors)})"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Theme:
25
+ background: str = "#f6f5ef"
26
+ text: str = "#25251f"
27
+ surface: str = "#e5e9d7"
28
+ accent: str = "#393e24"
29
+ accent_hover: str = "#525a33"
30
+ on_accent: str = "#ffffff"
31
+ code_background: str = "#172c24"
32
+ code_text: str = "#f3f3dd"
33
+ border: str = "#8c9181"
34
+ focus: str = "#8b6720"
35
+ font: str = "sans"
36
+ heading_font: str = "serif"
37
+ width: int = 1100
38
+ radius: int = 8
39
+ spacing: int = 24
40
+ background_image: str = ""
41
+ motion: bool = True
42
+ motion_duration: int = 700
43
+ button_radius: int | None = None
44
+ input_radius: int | None = None
45
+ gradient: Gradient | None = None
46
+
47
+ def css(self) -> str:
48
+ for value in (self.button_radius, self.input_radius):
49
+ if value is not None and (type(value) is not int or not 0 <= value <= 999):
50
+ raise ValueError("Corner radii must be integers from 0 to 999")
51
+ if self.background_image and not re.fullmatch(r"(?:https?://|/)[a-zA-Z0-9/_.?=&%:+~-]+", self.background_image):
52
+ raise ValueError("background_image requires a safe http, https, or absolute-path URL")
53
+ if type(self.motion) is not bool:
54
+ raise ValueError("motion must be True or False")
55
+ colors = ("background", "text", "surface", "accent", "accent_hover",
56
+ "on_accent", "code_background", "code_text", "border", "focus")
57
+ for name in colors:
58
+ if not re.fullmatch(r"#[0-9a-fA-F]{6}", getattr(self, name)):
59
+ raise ValueError(f"{name} must be a six-digit hex color")
60
+ fonts = {"sans": "system-ui, sans-serif", "serif": "Georgia, serif", "mono": "ui-monospace, monospace"}
61
+ if self.font not in fonts or self.heading_font not in fonts:
62
+ raise ValueError("Fonts must be sans, serif, or mono")
63
+ for name, low, high in (("width", 320, 2000), ("radius", 0, 48), ("spacing", 8, 64), ("motion_duration", 100, 5000)):
64
+ value = getattr(self, name)
65
+ if type(value) is not int or not low <= value <= high:
66
+ raise ValueError(f"{name} must be an integer between {low} and {high}")
67
+ variables = ";".join(f"--{name.replace('_', '-')}: {getattr(self, name)}" for name in colors)
68
+ background = f'body {{ background-image: url("{self.background_image}"); background-size: cover; background-attachment: fixed; }}' if self.background_image else ""
69
+ gradient = ""
70
+ if self.gradient is not None:
71
+ if not isinstance(self.gradient, Gradient):
72
+ raise ValueError("gradient must be a Gradient")
73
+ # A fixed layer keeps animation independent of page length and images.
74
+ gradient = f"body::before {{ content: ''; position: fixed; inset: 0; z-index: -1; background: {self.gradient.css()}; }} body {{ isolation: isolate; }}"
75
+ if self.gradient.moving and self.motion:
76
+ gradient += f"body::before {{ background-size: 300% 300%; animation: pyrite-gradient {self.gradient.duration}s ease-in-out infinite; }}"
77
+ motion = f"""
78
+ .motion-rise {{ animation: pyrite-rise {self.motion_duration}ms ease both; }}
79
+ .motion-fade {{ animation: pyrite-fade {self.motion_duration}ms ease both; }}
80
+ .motion-float {{ animation: pyrite-float 4s ease-in-out infinite; }}
81
+ """ if self.motion else ".motion { animation: none; }"
82
+ return f"""
83
+ :root {{ {variables}; color-scheme: normal; background: var(--background); color: var(--text); font-family: {fonts[self.font]}; }}
84
+ main {{ max-width: {self.width}px; }}
85
+ h1,h2,h3,h4,h5,h6,.section > h1,.navigation > a:first-child {{ font-family: {fonts[self.heading_font]}; }}
86
+ .column,.row {{ gap: {self.spacing}px; }}
87
+ .button {{ background: var(--accent); color: var(--on-accent); border-radius: {self.radius}px; }}
88
+ .button:hover {{ background: var(--accent-hover); }}
89
+ .button {{ border-radius: {self.button_radius if self.button_radius is not None else self.radius}px; }}
90
+ .card,#demo .card {{ background: var(--background); border-color: var(--border); border-radius: {self.radius}px; }}
91
+ #demo,details {{ background: var(--surface); }}
92
+ .code {{ background: var(--code-background); color: var(--code-text); border-radius: {self.radius}px; }}
93
+ .code figcaption {{ color: var(--code-text); }}
94
+ input,select {{ background: var(--background); color: var(--text); border-color: var(--border); border-radius: {self.input_radius if self.input_radius is not None else self.radius}px; }}
95
+ input[type=range] {{ accent-color: var(--accent); }}
96
+ :is(a,input,select,button,summary):focus-visible {{ outline-color: var(--focus); }}
97
+ .image {{ display: block; width: 100%; height: auto; border-radius: {self.radius}px; }}
98
+ textarea {{ width: 100%; min-height: 340px; padding: 20px; background: var(--code-background); color: var(--code-text); border: 1px solid var(--border); border-radius: {self.radius}px; font: 15px/1.7 ui-monospace, monospace; resize: vertical; }}
99
+ iframe.preview {{ width: 100%; height: 500px; border: 1px solid var(--border); background: white; }}
100
+ details,#demo,iframe.preview {{ border-radius: {self.radius}px; }}
101
+ .live-output {{ display: flex; flex-direction: column; gap: 16px; overflow-wrap: anywhere; }}
102
+ .live-output p {{ white-space: pre-wrap; background: var(--surface); padding: 16px; border-radius: {self.radius}px; }}
103
+ button:disabled {{ opacity: .6; cursor: wait; }}
104
+ @keyframes pyrite-rise {{ from {{ opacity: 0; transform: translateY(24px); }} to {{ opacity: 1; transform: translateY(0); }} }}
105
+ @keyframes pyrite-fade {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}
106
+ @keyframes pyrite-float {{ 50% {{ transform: translateY(-10px); }} }}
107
+ @keyframes pyrite-gradient {{ 0%,100% {{ background-position: 0% 50%; }} 50% {{ background-position: 100% 50%; }} }}
108
+ {background}
109
+ {gradient}
110
+ {motion}
111
+ @media (prefers-reduced-motion: reduce) {{ .motion, body::before {{ animation: none; }} }}
112
+ """
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrite-web
3
+ Version: 0.1.0
4
+ Summary: Code websites in Python 3, without writing JavaScript or CSS.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # Pyrite
12
+
13
+ Code websites in Python 3, for JavaScript and CSS haters.
14
+
15
+ Pyrite is a small, dependency-free starting point: compose Python components,
16
+ return a page, and serve HTML with built-in responsive styling. Requires Python 3.10+.
17
+
18
+ ## Run the example
19
+
20
+ ```sh
21
+ python3 example.py
22
+ ```
23
+
24
+ Open http://127.0.0.1:8000. The example is Pyrite's first website: a homepage
25
+ with a code/output comparison, quickstart, expandable FAQ, and a component
26
+ guide at `/guide`. Try `/playground` for a Python-powered calculator.
27
+ Its application source uses only Python components.
28
+ No installation is required when running from this repository.
29
+
30
+ ## Build a page
31
+
32
+ ```python
33
+ from pyrite import App, Column, Heading, Page, Text
34
+
35
+ app = App()
36
+
37
+ @app.page("/")
38
+ def home():
39
+ return Page(
40
+ Column(Heading("Hello, Pyrite."), Text("Just Python.")),
41
+ title="My website",
42
+ )
43
+
44
+ if __name__ == "__main__":
45
+ app.run()
46
+ ```
47
+
48
+ Available components: `Heading`, `Text`, `Column`, `Row`, `Card`, `Link`, and
49
+ `Button`, `Section`, `Nav`, `Code`, and `Details`. Nest components directly.
50
+ `Section(..., id="start")` creates an anchor target. `Code(source, label="Python")`
51
+ displays escaped source. `Details(question, ...)` provides a native expandable answer.
52
+ `Heading` accepts `level=1` through `6`.
53
+ `Link(label, href)` and `Button(label, href)` navigate to another URL.
54
+ Text and HTML attributes are escaped automatically.
55
+
56
+ Register exact paths with `@app.page("/about")`. Handlers take no arguments and
57
+ return a `Page`. Query strings do not affect route matching. `Page` accepts
58
+ `title` and `language`; `app.run` accepts `host` and `port`.
59
+
60
+ ## Current scope
61
+
62
+ This version serves server-rendered pages with native GET forms and JSON POST
63
+ callbacks through `LiveForm` and `Chat`. It does not include server sessions,
64
+ token streaming, live reload, or a production server. The built-in
65
+ HTTP server is for local development. Styling is supplied by Pyrite; applications
66
+ do not need CSS files or JavaScript tooling.
67
+
68
+ ## Styling in Python
69
+
70
+ Edit the root `styles.py` to change the example website's shared theme. Restart
71
+ the server after editing. `Theme` supports six-digit hex colors, `sans`, `serif`,
72
+ or `mono` fonts, page width, corner radius, and spacing. Apply a theme to any page:
73
+
74
+ ```python
75
+ from pyrite import Theme
76
+
77
+ theme = Theme(background="#111820", text="#eef3f8", heading_font="sans")
78
+ page = Page(Heading("Your style."), theme=theme)
79
+ ```
80
+
81
+ ## Forms without JavaScript
82
+
83
+ ```python
84
+ from pyrite import Form, Input, Submit, query
85
+
86
+ @app.page("/hello")
87
+ def hello():
88
+ return Page(Column(
89
+ Form(Input("name", "Your name", value=query("name")), Submit("Say hello")),
90
+ Text(f"Hello, {query('name', 'world')}!"),
91
+ ))
92
+ ```
93
+
94
+ `Slider(name, label, value=5, minimum=1, maximum=10)` and
95
+ `Select(name, label, {"value": "Label"}, value="value")` also work in forms.
96
+ Submission reloads the page; `query` reads request-local URL parameters. GET forms
97
+ are for read-only calculations and searches; their values appear in the URL.
98
+
99
+ ## Try Pyrite in Browser
100
+
101
+ Visit `/try` to edit Python and render a real `Page` without installing Python.
102
+ Assign your result to `page`; do not call `app.run()`. The editor loads Pyodide
103
+ from jsDelivr and runs code in a disposable Web Worker, with a 10-second execution
104
+ limit and an isolated preview. First download can take up to two minutes.
105
+ Code is not executed on the application server. Browser networking follows CORS
106
+ rules; the standard-library HTTP client below is for server-side Python.
107
+
108
+ ## Images and motion
109
+
110
+ ```python
111
+ from pyrite import Image, Motion
112
+ from styles import hero_image
113
+
114
+ banner = Motion(Image(hero_image, "Mountain landscape"), effect="rise")
115
+ ```
116
+
117
+ Choose `rise`, `fade`, or `float`. In `styles.py`, set `motion=False` to disable
118
+ component animations, or change `motion_duration` (100–5000 milliseconds) for
119
+ entrances. Reduced-motion browser preferences disable animations automatically.
120
+ `Theme(background_image="https://example.com/background.jpg")` attaches a page
121
+ background. `Image` accepts relative or HTTP(S) URLs and requires alt text.
122
+ External images load directly from their host; local file serving is not included.
123
+
124
+ ## Requests and changing API data
125
+
126
+ ```python
127
+ from pyrite import requests
128
+
129
+ data = requests.get(
130
+ "https://api.mymemory.translated.net/get",
131
+ params={"q": "Good morning", "langpair": "en|uk"},
132
+ ).json()
133
+
134
+ # For an API you control, with the authentication it requires:
135
+ # requests.post("https://your-api.example/items", json={"name": "New"})
136
+ # requests.put("https://your-api.example/items/1", json={"name": "Changed"})
137
+ # requests.delete("https://your-api.example/items/1")
138
+ ```
139
+
140
+ The dependency-free client supports `get`, `post`, `put`, and `delete`, optional
141
+ headers, JSON bodies, query parameters, and a timeout (8 seconds by default).
142
+ Responses expose `status_code`, `text`, and `json()`. Catch `requests.RequestError`
143
+ for HTTP/network/decoding failures. Responses are limited to 1 MiB. Supply trusted
144
+ URLs in application code; do not forward visitor-provided URLs to this client.
145
+ GET page handlers should only read data. POST actions can make remote writes;
146
+ add application authentication and authorization before exposing private data or
147
+ operations. Pyrite's local development server does not provide authentication.
148
+
149
+ `/translate` demonstrates input, a real free MyMemory request, and updated page
150
+ content. No API key required; availability, translation quality, and quotas depend
151
+ on MyMemory. Submitted text is sent to that service.
152
+
153
+ ## Corners and gradients
154
+
155
+ ```python
156
+ from pyrite import Gradient, Theme
157
+
158
+ theme = Theme(
159
+ radius=20, # Panels, code blocks, images, details
160
+ button_radius=999, # Pill buttons; 0 gives square corners
161
+ input_radius=12, # Inputs and dropdowns
162
+ gradient=Gradient(
163
+ colors=("#101923", "#214a42", "#17283d"),
164
+ angle=120,
165
+ moving=True,
166
+ duration=14, # Seconds per cycle
167
+ ),
168
+ )
169
+ ```
170
+
171
+ `button_radius` and `input_radius` inherit `radius` when omitted. Gradients accept
172
+ 2–8 hex colors; set `moving=False` for a static gradient or `gradient=None` for
173
+ the usual background. A fixed background layer animates independently of page
174
+ length. `motion=False` or the browser's reduced-motion preference stops its movement.
175
+ The gradient layer sits over a background image when both are configured.
176
+
177
+ ## In-page requests and chat
178
+
179
+ ```python
180
+ from pyrite import App, Chat, Page
181
+
182
+ app = App()
183
+
184
+ @app.action("/api/reply")
185
+ def reply(data):
186
+ message = data["message"]
187
+ history = data.get("history", [])
188
+ # Call a model here with pyrite.requests; keep API keys on the server.
189
+ return {"reply": "You said: " + message}
190
+
191
+ @app.page("/")
192
+ def home():
193
+ return Page(Chat(action="/api/reply"))
194
+ ```
195
+
196
+ Visit `/chat` for a working local rule-based chatbot and a live translation form.
197
+ `Chat` sends `{message, history}` and displays `reply` without a page reload.
198
+ History stays in this page's browser memory (last 20 messages), is sent with each
199
+ request, and clears on reload. It is untrusted input; validate it in callbacks.
200
+ This is completed-response chat, not token streaming. The demo uses no AI service.
201
+
202
+ For other interfaces, use `LiveForm(Input(...), Submit(...), action="/api/action")`
203
+ and return `{"text": "Updated result"}` from the action. All field values arrive
204
+ as strings. Pyrite supplies the JavaScript, pending state, errors, 30-second
205
+ client timeout, and safe text rendering. The callback can use `requests` to reach
206
+ external APIs without exposing credentials to the browser. Aborting a request
207
+ does not cancel Python work already running.
208
+
209
+ Actions accept JSON objects up to 16 KiB and reject cross-origin browser requests.
210
+ These checks do not authenticate users. Use application validation and authorization
211
+ for sensitive actions. Live components require JavaScript; ordinary pages and GET
212
+ forms continue working without it. Browser playground previews remain isolated and
213
+ cannot call server actions.
214
+
215
+ ## Tests
216
+
217
+ ```sh
218
+ python3 -m unittest discover -s tests -v
219
+ ```
@@ -0,0 +1,11 @@
1
+ pyrite/__init__.py,sha256=ShUfS3EgLTJu2prtAI6oSE5EYZxZgjrAKaf6KG0ZB_k,749
2
+ pyrite/browser.py,sha256=8KXYnRTH1paKkg1Kd0BcjItwpFo6yzE6n7ctgIXj2Lw,4551
3
+ pyrite/core.py,sha256=Eti4EzBBxKRerIfix8qhdjSRckn_fqjhrWRAX2sXBGw,14289
4
+ pyrite/live.py,sha256=QtqRptfn4i8rxsp7Iegqgh4OBDGrqB-tbBaBJ116m3Y,3611
5
+ pyrite/requests.py,sha256=zaWnO6_lckkCyY1cZnQ8iooNTBanmJx_tt2nU6ZAZIo,2125
6
+ pyrite/styles.py,sha256=YNy3QzhblkGe7N5FhA9cLFp0Phc2pHkcAqqX-ll8SCE,6781
7
+ pyrite_web-0.1.0.dist-info/licenses/LICENSE,sha256=M4sUwAu6voRvOQrGbExBNqseFcaMgBqOOd-IIpDhGAo,1076
8
+ pyrite_web-0.1.0.dist-info/METADATA,sha256=dGjWHp03sPRR9ifs9TRz82Svkjv8Q8wcn3nwjfVzDD8,8262
9
+ pyrite_web-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ pyrite_web-0.1.0.dist-info/top_level.txt,sha256=lhj7_y_qa1CX0aM9Dj2y8mw-6fBxyTJTPMr7F-cfe94,7
11
+ pyrite_web-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pyrite contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pyrite