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 +17 -0
- pyrite/browser.py +105 -0
- pyrite/core.py +337 -0
- pyrite/live.py +75 -0
- pyrite/requests.py +61 -0
- pyrite/styles.py +112 -0
- pyrite_web-0.1.0.dist-info/METADATA +219 -0
- pyrite_web-0.1.0.dist-info/RECORD +11 -0
- pyrite_web-0.1.0.dist-info/WHEEL +5 -0
- pyrite_web-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyrite_web-0.1.0.dist-info/top_level.txt +1 -0
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,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
|