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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ douwe = douwe:main
@@ -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())