douwe 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.
- douwe-0.1.0.dist-info/METADATA +104 -0
- douwe-0.1.0.dist-info/RECORD +6 -0
- douwe-0.1.0.dist-info/WHEEL +5 -0
- douwe-0.1.0.dist-info/entry_points.txt +2 -0
- douwe-0.1.0.dist-info/top_level.txt +1 -0
- douwe.py +671 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: douwe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run douwe.com projects locally from their standalone repositories.
|
|
5
|
+
Author: Douwe Osinga
|
|
6
|
+
Project-URL: Homepage, https://douwe.com/projects/douwe_runner
|
|
7
|
+
Project-URL: Repository, https://github.com/DOsinga/douwe_runner
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: Jinja2>=3.1
|
|
15
|
+
Requires-Dist: PyYAML>=6.0
|
|
16
|
+
Requires-Dist: tornado>=6.4
|
|
17
|
+
|
|
18
|
+
# Douwe Runner
|
|
19
|
+
|
|
20
|
+
Run one douwe.com project locally:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uvx douwe cambrium
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
or:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx run douwe cambrium
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A bare name like `cambrium` resolves to `github.com/DOsinga/cambrium` and caches
|
|
33
|
+
the clone under `~/.cache/douwe/projects/`.
|
|
34
|
+
|
|
35
|
+
From the main douwe.com checkout, you can also run the development copy directly:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python projects/douwe_runner/douwe.py cambrium
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Project references can be local or GitHub-backed:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python projects/douwe_runner/douwe.py cambrium
|
|
45
|
+
python projects/douwe_runner/douwe.py ./projects/cambrium
|
|
46
|
+
python projects/douwe_runner/douwe.py DOsinga/cambrium
|
|
47
|
+
python projects/douwe_runner/douwe.py https://github.com/DOsinga/cambrium
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Inside the douwe.com checkout, a bare name like `cambrium` prefers
|
|
51
|
+
`./projects/cambrium`. Outside that checkout, it resolves to
|
|
52
|
+
`github.com/DOsinga/<name>`.
|
|
53
|
+
|
|
54
|
+
Useful options:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python projects/douwe_runner/douwe.py cambrium --embed
|
|
58
|
+
python projects/douwe_runner/douwe.py cambrium --no-browser
|
|
59
|
+
python projects/douwe_runner/douwe.py cambrium --port 9000
|
|
60
|
+
python projects/douwe_runner/douwe.py cambrium --refresh
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Installed-package equivalents work the same way:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
douwe cambrium --port 9000
|
|
67
|
+
douwe cambrium --refresh
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The runner intentionally loads only the requested project. That keeps old or
|
|
71
|
+
dependency-heavy projects from breaking otherwise simple projects.
|
|
72
|
+
|
|
73
|
+
This version uses Tornado for serving and Jinja2 for project template rendering.
|
|
74
|
+
It does not call Django's renderer or configure Django settings. `{% load static
|
|
75
|
+
%}` is stripped from project templates, `{{ static }}` points at `./static/`,
|
|
76
|
+
and `{% static "..." %}` is rewritten to a shared `./_site_static/...` URL.
|
|
77
|
+
|
|
78
|
+
Projects that require WebSockets still need the full Django/Channels site for
|
|
79
|
+
now, but Tornado gives the runner a natural place to add that later.
|
|
80
|
+
|
|
81
|
+
Independent repos should eventually include their own `<project>.html` file with
|
|
82
|
+
the existing info block. During migration, if a GitHub repo has no HTML info
|
|
83
|
+
block but this checkout has `projects/<project>/<project>.html`, the runner uses
|
|
84
|
+
that local manifest and serves assets from the cloned repo first.
|
|
85
|
+
|
|
86
|
+
## Publishing
|
|
87
|
+
|
|
88
|
+
Build the package:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
python -m build
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Publish to PyPI:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python -m twine upload dist/*
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
After that, the globally runnable path is:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uvx douwe cambrium
|
|
104
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
douwe.py,sha256=YgVKKoDfIyQ-rnpEbQBiqHhS5j86o3hA5qlrKD1Tdqo,21397
|
|
2
|
+
douwe-0.1.0.dist-info/METADATA,sha256=7iIzqXl8DUxziJV3L1YTNYLZfwERhWkbdgT0eWKmfos,2954
|
|
3
|
+
douwe-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
douwe-0.1.0.dist-info/entry_points.txt,sha256=09E8qWVfNwtGeBaEUdCB72dy1lRk-f3EuYB33MXI9L4,37
|
|
5
|
+
douwe-0.1.0.dist-info/top_level.txt,sha256=qKArampajWC3X-LEyD3ncQKtZi7Mu7NSIpc4udmF4N0,6
|
|
6
|
+
douwe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
douwe
|
douwe.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import importlib.util
|
|
4
|
+
import mimetypes
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
14
|
+
|
|
15
|
+
import jinja2
|
|
16
|
+
import tornado.ioloop
|
|
17
|
+
import tornado.escape
|
|
18
|
+
import tornado.web
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
STARTUP_DELAY_SECONDS = 0.8
|
|
23
|
+
DEFAULT_GITHUB_OWNER = "DOsinga"
|
|
24
|
+
RUNNER_DIR = Path(__file__).resolve().parent
|
|
25
|
+
CACHE_DIR = Path(os.environ.get("DOUWE_CACHE_DIR", "~/.cache/douwe")).expanduser()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_site_root():
|
|
29
|
+
for candidate in (RUNNER_DIR.parent.parent, Path.cwd()):
|
|
30
|
+
if (candidate / "projects").is_dir():
|
|
31
|
+
return candidate
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ROOT = find_site_root()
|
|
36
|
+
PROJECTS_DIR = ROOT / "projects" if ROOT else None
|
|
37
|
+
PROJECT_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
38
|
+
INFO_RE = re.compile(
|
|
39
|
+
r'<script\s+type="text/markdown"\s+id="info"\s*>(.*?)</script>',
|
|
40
|
+
re.DOTALL,
|
|
41
|
+
)
|
|
42
|
+
LOAD_STATIC_RE = re.compile(r"{%\s*load\s+static\s*%}\s*")
|
|
43
|
+
CSRF_TOKEN_RE = re.compile(r"{%\s*csrf_token\s*%}\s*")
|
|
44
|
+
DJANGO_STATIC_RE = re.compile(r"{%\s*static\s+(['\"])(.*?)\1\s*%}")
|
|
45
|
+
PROJECT_STATIC = "./static/"
|
|
46
|
+
SHARED_STATIC = "./_site_static/"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RunnerProject:
|
|
50
|
+
def __init__(self, project_id, info, html_path, root_dir, source):
|
|
51
|
+
self.id = project_id
|
|
52
|
+
self.name = info.get("name", project_id)
|
|
53
|
+
self.description = info.get("description", "")
|
|
54
|
+
self.shortdescription = info.get("shortdescription") or self.description
|
|
55
|
+
self.type = info.get("type")
|
|
56
|
+
self.github = github_name(info.get("github"))
|
|
57
|
+
self.files = info.get("files") or []
|
|
58
|
+
self.pass_on_request = info.get("pass_on_request") or []
|
|
59
|
+
self.nochrome = info.get("nochrome", False)
|
|
60
|
+
self.dontrepeatintro = info.get("dontrepeatintro", False)
|
|
61
|
+
self.html_path = html_path
|
|
62
|
+
self.root_dir = root_dir
|
|
63
|
+
self.source = source
|
|
64
|
+
self.template_source = preprocess_template(strip_info_block(html_path.read_text()))
|
|
65
|
+
self.impl = None
|
|
66
|
+
|
|
67
|
+
def fill_dict(self, request, context):
|
|
68
|
+
if self.impl:
|
|
69
|
+
self.impl.fill_dict(request, context)
|
|
70
|
+
|
|
71
|
+
def handle_request(self, handler_name, request):
|
|
72
|
+
if self.impl:
|
|
73
|
+
return self.impl.handle_request(handler_name, request)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def receive(self, payload):
|
|
77
|
+
if self.impl:
|
|
78
|
+
return self.impl.receive(payload)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def thumbnail(self):
|
|
82
|
+
for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"):
|
|
83
|
+
image = self.root_dir / f"{self.id}{ext}"
|
|
84
|
+
if image.is_file():
|
|
85
|
+
return f"{PROJECT_STATIC}{self.id}{ext}"
|
|
86
|
+
return f"{SHARED_STATIC}projects/visited/visited.jpg"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RunnerRequest:
|
|
90
|
+
def __init__(self, handler):
|
|
91
|
+
parsed = urlsplit(handler.request.uri)
|
|
92
|
+
self.method = handler.request.method
|
|
93
|
+
self.path = parsed.path
|
|
94
|
+
self.body = handler.request.body or b""
|
|
95
|
+
self.GET = {
|
|
96
|
+
key: values[-1] if values else ""
|
|
97
|
+
for key, values in parse_qs(parsed.query).items()
|
|
98
|
+
}
|
|
99
|
+
self.POST = {}
|
|
100
|
+
self.FILES = {}
|
|
101
|
+
content_type = handler.request.headers.get("content-type", "")
|
|
102
|
+
if self.body and content_type.startswith(
|
|
103
|
+
"application/x-www-form-urlencoded"
|
|
104
|
+
):
|
|
105
|
+
self.POST = {
|
|
106
|
+
key: values[-1] if values else ""
|
|
107
|
+
for key, values in parse_qs(self.body.decode("utf-8")).items()
|
|
108
|
+
}
|
|
109
|
+
self.headers = handler.request.headers
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_args(argv=None):
|
|
113
|
+
parser = argparse.ArgumentParser(
|
|
114
|
+
prog="douwe",
|
|
115
|
+
description="Run one douwe.com project locally.",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"project",
|
|
119
|
+
help=(
|
|
120
|
+
"Project id, local path, GitHub owner/repo, or GitHub URL "
|
|
121
|
+
"(e.g. cambrium)"
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--host",
|
|
126
|
+
default="127.0.0.1",
|
|
127
|
+
help="Host to bind. Defaults to 127.0.0.1.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--port",
|
|
131
|
+
type=int,
|
|
132
|
+
default=8765,
|
|
133
|
+
help="Port to bind. If busy, the next open port is used.",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--embed",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Open the project body without the douwe.com page chrome.",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--no-browser",
|
|
142
|
+
action="store_true",
|
|
143
|
+
help="Start the server without opening a browser.",
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--refresh",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="Refresh a cached GitHub checkout before running it.",
|
|
149
|
+
)
|
|
150
|
+
return parser.parse_args(argv)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_open_port(host, preferred_port):
|
|
154
|
+
for port in range(preferred_port, preferred_port + 100):
|
|
155
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
156
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
157
|
+
try:
|
|
158
|
+
sock.bind((host, port))
|
|
159
|
+
except OSError:
|
|
160
|
+
continue
|
|
161
|
+
return port
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"Could not find an open port from {preferred_port} to {preferred_port + 99}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def github_name(github):
|
|
168
|
+
if not github:
|
|
169
|
+
return None
|
|
170
|
+
if github.startswith("/"):
|
|
171
|
+
return github[1:]
|
|
172
|
+
return f"DOsinga/{github}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_info_block(html):
|
|
176
|
+
match = INFO_RE.search(html)
|
|
177
|
+
if not match:
|
|
178
|
+
return None
|
|
179
|
+
return yaml.safe_load(match.group(1)) or {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def strip_info_block(html):
|
|
183
|
+
return INFO_RE.sub("", html, count=1).strip()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def preprocess_template(template_source):
|
|
187
|
+
template_source = LOAD_STATIC_RE.sub("", template_source)
|
|
188
|
+
template_source = CSRF_TOKEN_RE.sub("", template_source)
|
|
189
|
+
return DJANGO_STATIC_RE.sub(r"{{ site_static('\2') }}", template_source)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def load_project(ref, refresh=False):
|
|
193
|
+
resolved = resolve_project_ref(ref, refresh)
|
|
194
|
+
if resolved is None:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
project_id, html_path, root_dir, source = resolved
|
|
198
|
+
|
|
199
|
+
info = parse_info_block(html_path.read_text())
|
|
200
|
+
if info is None:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
project = RunnerProject(project_id, info, html_path, root_dir, source)
|
|
204
|
+
project.impl = load_project_impl(project)
|
|
205
|
+
return project
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_project_ref(ref, refresh=False):
|
|
209
|
+
local = resolve_local_ref(ref)
|
|
210
|
+
if local:
|
|
211
|
+
return local
|
|
212
|
+
|
|
213
|
+
github_ref = github_repo_ref(ref)
|
|
214
|
+
if github_ref:
|
|
215
|
+
owner, repo = github_ref
|
|
216
|
+
root_dir = ensure_github_repo(owner, repo, refresh)
|
|
217
|
+
if not root_dir:
|
|
218
|
+
return None
|
|
219
|
+
html_path = find_project_html(root_dir, repo)
|
|
220
|
+
source = f"github:{owner}/{repo}"
|
|
221
|
+
if not html_path:
|
|
222
|
+
html_path = legacy_site_manifest(repo)
|
|
223
|
+
if html_path:
|
|
224
|
+
source = f"{source} with local manifest:{html_path}"
|
|
225
|
+
if not html_path:
|
|
226
|
+
print(
|
|
227
|
+
f"No project HTML info block found in {owner}/{repo}. "
|
|
228
|
+
f"Add {repo}.html to that repo.",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
return None
|
|
232
|
+
return html_path.stem, html_path, root_dir, source
|
|
233
|
+
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_local_ref(ref):
|
|
238
|
+
path = Path(ref).expanduser()
|
|
239
|
+
if path.exists():
|
|
240
|
+
html_path = find_project_html(path)
|
|
241
|
+
if html_path:
|
|
242
|
+
return html_path.stem, html_path, html_path.parent, f"local:{html_path.parent}"
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
if PROJECTS_DIR and PROJECT_ID_RE.match(ref):
|
|
246
|
+
project_dir = PROJECTS_DIR / ref
|
|
247
|
+
html_path = find_project_html(project_dir, ref)
|
|
248
|
+
if html_path:
|
|
249
|
+
return ref, html_path, project_dir, f"local:{project_dir}"
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def find_project_html(path, preferred_id=None):
|
|
254
|
+
if path.is_file():
|
|
255
|
+
if path.suffix == ".html" and parse_info_block(path.read_text()) is not None:
|
|
256
|
+
return path
|
|
257
|
+
return None
|
|
258
|
+
if not path.is_dir():
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
candidates = []
|
|
262
|
+
if preferred_id:
|
|
263
|
+
candidates.append(path / f"{preferred_id}.html")
|
|
264
|
+
candidates.extend([path / f"{path.name}.html", path / "project.html"])
|
|
265
|
+
candidates.extend(sorted(path.glob("*.html")))
|
|
266
|
+
seen = set()
|
|
267
|
+
for candidate in candidates:
|
|
268
|
+
if candidate in seen:
|
|
269
|
+
continue
|
|
270
|
+
seen.add(candidate)
|
|
271
|
+
if candidate.is_file() and parse_info_block(candidate.read_text()) is not None:
|
|
272
|
+
return candidate
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def legacy_site_manifest(project_id):
|
|
277
|
+
if not PROJECTS_DIR:
|
|
278
|
+
return None
|
|
279
|
+
html_path = PROJECTS_DIR / project_id / f"{project_id}.html"
|
|
280
|
+
if html_path.is_file() and parse_info_block(html_path.read_text()) is not None:
|
|
281
|
+
return html_path
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def github_repo_ref(ref):
|
|
286
|
+
parsed = urlsplit(ref)
|
|
287
|
+
if parsed.scheme in {"http", "https"} and parsed.netloc == "github.com":
|
|
288
|
+
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
|
289
|
+
if len(parts) >= 2:
|
|
290
|
+
return parts[0], parts[1].removesuffix(".git")
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
if ref.startswith("github:"):
|
|
294
|
+
ref = ref.removeprefix("github:")
|
|
295
|
+
|
|
296
|
+
if "/" in ref and not ref.startswith("."):
|
|
297
|
+
parts = [p for p in ref.split("/") if p]
|
|
298
|
+
if len(parts) == 2:
|
|
299
|
+
return parts[0], parts[1].removesuffix(".git")
|
|
300
|
+
|
|
301
|
+
if PROJECT_ID_RE.match(ref):
|
|
302
|
+
return DEFAULT_GITHUB_OWNER, ref
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def ensure_github_repo(owner, repo, refresh=False):
|
|
307
|
+
target = CACHE_DIR / "projects" / owner / repo
|
|
308
|
+
if target.exists():
|
|
309
|
+
if refresh:
|
|
310
|
+
print(f"Refreshing {owner}/{repo}...", flush=True)
|
|
311
|
+
subprocess.run(["git", "-C", str(target), "pull", "--ff-only"], check=False)
|
|
312
|
+
return target
|
|
313
|
+
|
|
314
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
url = f"https://github.com/{owner}/{repo}.git"
|
|
316
|
+
print(f"Cloning {url}...", flush=True)
|
|
317
|
+
result = subprocess.run(["git", "clone", "--depth", "1", url, str(target)])
|
|
318
|
+
if result.returncode != 0:
|
|
319
|
+
return None
|
|
320
|
+
return target
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def load_project_impl(project):
|
|
324
|
+
py_file = project.root_dir / f"{project.id}.py"
|
|
325
|
+
if not py_file.is_file():
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
if ROOT:
|
|
329
|
+
root = str(ROOT)
|
|
330
|
+
if root not in sys.path:
|
|
331
|
+
sys.path.insert(0, root)
|
|
332
|
+
project_root = str(project.root_dir)
|
|
333
|
+
if project_root not in sys.path:
|
|
334
|
+
sys.path.insert(0, project_root)
|
|
335
|
+
|
|
336
|
+
module_name = f"douwe_runner_loaded.{project.id}"
|
|
337
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
338
|
+
if not spec or not spec.loader:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
module = importlib.util.module_from_spec(spec)
|
|
343
|
+
sys.modules[module_name] = module
|
|
344
|
+
spec.loader.exec_module(module)
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
print(f"Warning: could not load {py_file}: {exc}", file=sys.stderr)
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
impl_class = find_project_class(module)
|
|
350
|
+
if not impl_class:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
return impl_class(
|
|
355
|
+
project.id,
|
|
356
|
+
project.name,
|
|
357
|
+
project.description,
|
|
358
|
+
shortdescription=project.shortdescription,
|
|
359
|
+
type=project.type,
|
|
360
|
+
files=project.files,
|
|
361
|
+
github=project.github,
|
|
362
|
+
pass_on_request=project.pass_on_request,
|
|
363
|
+
nochrome=project.nochrome,
|
|
364
|
+
dontrepeatintro=project.dontrepeatintro,
|
|
365
|
+
)
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
print(f"Warning: could not initialize {py_file}: {exc}", file=sys.stderr)
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def find_project_class(module):
|
|
372
|
+
try:
|
|
373
|
+
from projects.common import Project
|
|
374
|
+
except Exception:
|
|
375
|
+
Project = None
|
|
376
|
+
|
|
377
|
+
for name in dir(module):
|
|
378
|
+
obj = getattr(module, name)
|
|
379
|
+
if not isinstance(obj, type):
|
|
380
|
+
continue
|
|
381
|
+
if Project and issubclass(obj, Project) and obj is not Project:
|
|
382
|
+
return obj
|
|
383
|
+
if name != "Project" and hasattr(obj, "fill_dict"):
|
|
384
|
+
return obj
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def project_url(project_id, embed=False):
|
|
389
|
+
path = "/"
|
|
390
|
+
if not embed:
|
|
391
|
+
return path
|
|
392
|
+
return f"{path}?{urlencode({'embed': '1'})}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def jinja_env(project):
|
|
396
|
+
env = jinja2.Environment(autoescape=False)
|
|
397
|
+
env.globals["site_static"] = lambda path: f"{SHARED_STATIC}{path}"
|
|
398
|
+
env.globals["project_static"] = lambda path: f"{PROJECT_STATIC}{path}"
|
|
399
|
+
return env
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def render_project_body(project, request):
|
|
403
|
+
context = {"static": PROJECT_STATIC, "fs": request.GET.get("fs")}
|
|
404
|
+
for key in project.pass_on_request:
|
|
405
|
+
if key in request.GET:
|
|
406
|
+
context[key] = request.GET[key]
|
|
407
|
+
project.fill_dict(request, context)
|
|
408
|
+
template = jinja_env(project).from_string(project.template_source)
|
|
409
|
+
return template.render(**context)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def render_project_page(project, request, embed=False):
|
|
413
|
+
body = render_project_body(project, request)
|
|
414
|
+
if project.nochrome or embed:
|
|
415
|
+
return body
|
|
416
|
+
|
|
417
|
+
downloads = ""
|
|
418
|
+
if project.files and not request.GET.get("fs"):
|
|
419
|
+
items = []
|
|
420
|
+
for entry in project.files:
|
|
421
|
+
filename, description = entry[0], entry[1]
|
|
422
|
+
url = f"{PROJECT_STATIC}{filename}"
|
|
423
|
+
items.append(
|
|
424
|
+
f'<dt><a href="{tornado.escape.xhtml_escape(url)}">'
|
|
425
|
+
f"{tornado.escape.xhtml_escape(filename)}</a></dt>"
|
|
426
|
+
f"<dd>{tornado.escape.xhtml_escape(description)}</dd>"
|
|
427
|
+
)
|
|
428
|
+
downloads = f"""
|
|
429
|
+
<div class="downloads">
|
|
430
|
+
<h3>Downloads</h3>
|
|
431
|
+
<dl>{''.join(items)}</dl>
|
|
432
|
+
</div>
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
intro = "" if project.dontrepeatintro else project.description
|
|
436
|
+
return f"""<!doctype html>
|
|
437
|
+
<html>
|
|
438
|
+
<head>
|
|
439
|
+
<meta charset="utf-8">
|
|
440
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
441
|
+
<title>{tornado.escape.xhtml_escape(project.name)}</title>
|
|
442
|
+
<meta name="description" content="{tornado.escape.xhtml_escape(project.shortdescription)}">
|
|
443
|
+
<meta property="og:title" content="{tornado.escape.xhtml_escape(project.name)}">
|
|
444
|
+
<meta property="og:image" content="{tornado.escape.xhtml_escape(project.thumbnail())}">
|
|
445
|
+
<style>
|
|
446
|
+
body {{ font-family: Georgia, serif; margin: 2rem auto; max-width: 900px; line-height: 1.5; padding: 0 1rem; }}
|
|
447
|
+
h1 {{ font-family: system-ui, sans-serif; }}
|
|
448
|
+
canvas {{ max-width: 100%; }}
|
|
449
|
+
.downloads {{ margin-top: 2em; padding-top: 1.5em; border-top: 1px solid #ddd; }}
|
|
450
|
+
.downloads dl {{ display: grid; grid-template-columns: minmax(140px, 220px) 1fr; gap: .5em 1.5em; }}
|
|
451
|
+
.downloads dt, .downloads dd {{ margin: 0; }}
|
|
452
|
+
</style>
|
|
453
|
+
</head>
|
|
454
|
+
<body>
|
|
455
|
+
<h1>{tornado.escape.xhtml_escape(project.name)}</h1>
|
|
456
|
+
<div class="intro">{intro}</div>
|
|
457
|
+
<article>{body}</article>
|
|
458
|
+
{downloads}
|
|
459
|
+
</body>
|
|
460
|
+
</html>"""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def response_tuple(status, body, content_type="text/html; charset=utf-8", headers=None):
|
|
464
|
+
headers = dict(headers or {})
|
|
465
|
+
headers.setdefault("content-type", content_type)
|
|
466
|
+
return status, body.encode("utf-8"), headers
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def not_found_response():
|
|
470
|
+
return response_tuple(404, "Not found", "text/plain; charset=utf-8")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def static_response(path, project):
|
|
474
|
+
prefixes = {
|
|
475
|
+
"/static/": project_static_roots(project),
|
|
476
|
+
"/_site_static/": site_static_roots(),
|
|
477
|
+
}
|
|
478
|
+
for prefix, roots in prefixes.items():
|
|
479
|
+
if not path.startswith(prefix):
|
|
480
|
+
continue
|
|
481
|
+
relative = path.removeprefix(prefix)
|
|
482
|
+
if not relative or relative.startswith("/") or ".." in Path(relative).parts:
|
|
483
|
+
return not_found_response()
|
|
484
|
+
for root in roots:
|
|
485
|
+
candidate = (root / relative).resolve()
|
|
486
|
+
try:
|
|
487
|
+
candidate.relative_to(root.resolve())
|
|
488
|
+
except ValueError:
|
|
489
|
+
continue
|
|
490
|
+
if candidate.is_file():
|
|
491
|
+
content_type = (
|
|
492
|
+
mimetypes.guess_type(candidate)[0] or "application/octet-stream"
|
|
493
|
+
)
|
|
494
|
+
return 200, candidate.read_bytes(), {"content-type": content_type}
|
|
495
|
+
return not_found_response()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def project_static_roots(project):
|
|
499
|
+
roots = [project.root_dir / "static", project.root_dir]
|
|
500
|
+
manifest_dir = project.html_path.parent
|
|
501
|
+
if manifest_dir != project.root_dir:
|
|
502
|
+
roots.extend([manifest_dir / "static", manifest_dir])
|
|
503
|
+
return roots
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def site_static_roots():
|
|
507
|
+
if not ROOT:
|
|
508
|
+
return []
|
|
509
|
+
return [ROOT / "static"]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def handler_response(project, handler_name, request):
|
|
513
|
+
response = project.handle_request(handler_name, request)
|
|
514
|
+
if response is None:
|
|
515
|
+
return response_tuple(
|
|
516
|
+
501,
|
|
517
|
+
f"{project.id}/{handler_name} is not handled by the lightweight runner.",
|
|
518
|
+
"text/plain; charset=utf-8",
|
|
519
|
+
)
|
|
520
|
+
if isinstance(response, str):
|
|
521
|
+
return response_tuple(200, response)
|
|
522
|
+
if isinstance(response, bytes):
|
|
523
|
+
return 200, response, {"content-type": "application/octet-stream"}
|
|
524
|
+
if hasattr(response, "content") and hasattr(response, "status_code"):
|
|
525
|
+
headers = {
|
|
526
|
+
key.lower(): value for key, value in getattr(response, "headers", {}).items()
|
|
527
|
+
}
|
|
528
|
+
return response.status_code, bytes(response.content), headers
|
|
529
|
+
return response_tuple(200, str(response), "text/plain; charset=utf-8")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class RootHandler(tornado.web.RequestHandler):
|
|
533
|
+
def initialize(self, project, embed):
|
|
534
|
+
self.project = project
|
|
535
|
+
self.embed = embed
|
|
536
|
+
|
|
537
|
+
def get(self):
|
|
538
|
+
request = RunnerRequest(self)
|
|
539
|
+
html = render_project_page(
|
|
540
|
+
self.project, request, self.embed or request.GET.get("embed")
|
|
541
|
+
)
|
|
542
|
+
self.set_header("content-type", "text/html; charset=utf-8")
|
|
543
|
+
self.write(html)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class ProjectRedirectHandler(tornado.web.RequestHandler):
|
|
547
|
+
def initialize(self, project, embed):
|
|
548
|
+
self.project = project
|
|
549
|
+
self.embed = embed
|
|
550
|
+
|
|
551
|
+
def get(self, handler_name=None):
|
|
552
|
+
suffix = f"?{self.request.query}" if self.request.query else ""
|
|
553
|
+
if handler_name:
|
|
554
|
+
self.redirect(f"/{handler_name}{suffix}")
|
|
555
|
+
else:
|
|
556
|
+
self.redirect(f"{project_url(self.project.id, self.embed)}{suffix}")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class StaticHandler(tornado.web.RequestHandler):
|
|
560
|
+
def initialize(self, project):
|
|
561
|
+
self.project = project
|
|
562
|
+
|
|
563
|
+
def get(self, path):
|
|
564
|
+
self.serve(path)
|
|
565
|
+
|
|
566
|
+
def head(self, path):
|
|
567
|
+
self.serve(path, include_body=False)
|
|
568
|
+
|
|
569
|
+
def serve(self, path, include_body=True):
|
|
570
|
+
prefix = "/_site_static/" if self.request.path.startswith("/_site_static/") else "/static/"
|
|
571
|
+
status, content, headers = static_response(f"{prefix}{path}", self.project)
|
|
572
|
+
self.set_status(status)
|
|
573
|
+
for key, value in headers.items():
|
|
574
|
+
self.set_header(key, value)
|
|
575
|
+
self.set_header("content-length", str(len(content)))
|
|
576
|
+
if include_body:
|
|
577
|
+
self.write(content)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class ProjectHandler(tornado.web.RequestHandler):
|
|
581
|
+
def initialize(self, project, embed):
|
|
582
|
+
self.project = project
|
|
583
|
+
self.embed = embed
|
|
584
|
+
|
|
585
|
+
def get(self, handler_name=None):
|
|
586
|
+
self.respond(handler_name)
|
|
587
|
+
|
|
588
|
+
def post(self, handler_name=None):
|
|
589
|
+
self.respond(handler_name)
|
|
590
|
+
|
|
591
|
+
def head(self, handler_name=None):
|
|
592
|
+
self.respond(handler_name, include_body=False)
|
|
593
|
+
|
|
594
|
+
def respond(self, handler_name=None, include_body=True):
|
|
595
|
+
request = RunnerRequest(self)
|
|
596
|
+
if handler_name:
|
|
597
|
+
status, content, headers = handler_response(
|
|
598
|
+
self.project, handler_name, request
|
|
599
|
+
)
|
|
600
|
+
else:
|
|
601
|
+
html = render_project_page(
|
|
602
|
+
self.project, request, self.embed or request.GET.get("embed")
|
|
603
|
+
)
|
|
604
|
+
status, content, headers = response_tuple(200, html)
|
|
605
|
+
|
|
606
|
+
self.set_status(status)
|
|
607
|
+
for key, value in headers.items():
|
|
608
|
+
self.set_header(key, value)
|
|
609
|
+
self.set_header("content-length", str(len(content)))
|
|
610
|
+
if include_body:
|
|
611
|
+
self.write(content)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def make_app(project, embed):
|
|
615
|
+
return tornado.web.Application(
|
|
616
|
+
[
|
|
617
|
+
(r"/", RootHandler, {"project": project, "embed": embed}),
|
|
618
|
+
(
|
|
619
|
+
rf"/projects/{re.escape(project.id)}",
|
|
620
|
+
ProjectRedirectHandler,
|
|
621
|
+
{"project": project, "embed": embed},
|
|
622
|
+
),
|
|
623
|
+
(
|
|
624
|
+
rf"/projects/{re.escape(project.id)}/(.*)",
|
|
625
|
+
ProjectRedirectHandler,
|
|
626
|
+
{"project": project, "embed": embed},
|
|
627
|
+
),
|
|
628
|
+
(r"/static/(.*)", StaticHandler, {"project": project}),
|
|
629
|
+
(r"/_site_static/(.*)", StaticHandler, {"project": project}),
|
|
630
|
+
(r"/(.*)", ProjectHandler, {"project": project, "embed": embed}),
|
|
631
|
+
],
|
|
632
|
+
debug=True,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def open_browser_later(url):
|
|
637
|
+
def opener():
|
|
638
|
+
time.sleep(STARTUP_DELAY_SECONDS)
|
|
639
|
+
webbrowser.open(url)
|
|
640
|
+
|
|
641
|
+
thread = threading.Thread(target=opener, daemon=True)
|
|
642
|
+
thread.start()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def main(argv=None):
|
|
646
|
+
args = parse_args(argv)
|
|
647
|
+
project = load_project(args.project, refresh=args.refresh)
|
|
648
|
+
if project is None:
|
|
649
|
+
print(f"Unknown project: {args.project}", file=sys.stderr)
|
|
650
|
+
return 2
|
|
651
|
+
|
|
652
|
+
port = find_open_port(args.host, args.port)
|
|
653
|
+
url = f"http://{args.host}:{port}{project_url(project.id, args.embed)}"
|
|
654
|
+
if port != args.port:
|
|
655
|
+
print(f"Port {args.port} is busy; using {port}.")
|
|
656
|
+
print(f"Running {project.name} at {url}")
|
|
657
|
+
|
|
658
|
+
if not args.no_browser:
|
|
659
|
+
open_browser_later(url)
|
|
660
|
+
|
|
661
|
+
app = make_app(project, args.embed)
|
|
662
|
+
app.listen(port, address=args.host)
|
|
663
|
+
try:
|
|
664
|
+
tornado.ioloop.IOLoop.current().start()
|
|
665
|
+
except KeyboardInterrupt:
|
|
666
|
+
print()
|
|
667
|
+
return 0
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
if __name__ == "__main__":
|
|
671
|
+
raise SystemExit(main())
|