From 2f862f29e3979dce46531e942c33c716eb3715eb Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:24:37 +0800 Subject: [PATCH 1/5] Add greeting use case and the hello Flask application Task: MIL-002#1 --- src/flask_day_54/application/get_greeting.py | 12 ++++++++++++ src/flask_day_54/presentation/hello.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/flask_day_54/application/get_greeting.py create mode 100644 src/flask_day_54/presentation/hello.py diff --git a/src/flask_day_54/application/get_greeting.py b/src/flask_day_54/application/get_greeting.py new file mode 100644 index 0000000..9534541 --- /dev/null +++ b/src/flask_day_54/application/get_greeting.py @@ -0,0 +1,12 @@ +## @file get_greeting.py +# @brief Use case that provides the greeting. +"""Use case that provides the greeting.""" + +from flask_day_54.domain.constants import GREETING + + +## @brief Return the greeting to show the user. +# @return The greeting text. +def get_greeting() -> str: + """Return the greeting to show the user.""" + return GREETING diff --git a/src/flask_day_54/presentation/hello.py b/src/flask_day_54/presentation/hello.py new file mode 100644 index 0000000..4483206 --- /dev/null +++ b/src/flask_day_54/presentation/hello.py @@ -0,0 +1,18 @@ +## @file hello.py +# @brief The minimal Flask application of lectures 1 and 2. +"""The minimal Flask application of lectures 1 and 2.""" + +from flask import Flask + +from flask_day_54.application.get_greeting import get_greeting + +## @brief The Flask application object. +app = Flask(__name__) + + +## @brief Handle GET / and answer with the greeting. +# @return The greeting as the response body. +@app.route("/") +def hello_world() -> str: + """Answer the root route with the greeting.""" + return get_greeting() -- 2.54.0 From 893fa5926396bb6f3d52efe2e2dcbca8f43c23cc Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:24:39 +0800 Subject: [PATCH 2/5] Test the greeting use case and the home route Task: MIL-002#2 --- tests/e2e/test_hello_app.py | 26 ++++++++++++++++++++++++++ tests/unit/test_get_greeting.py | 5 +++++ 2 files changed, 31 insertions(+) create mode 100644 tests/e2e/test_hello_app.py create mode 100644 tests/unit/test_get_greeting.py diff --git a/tests/e2e/test_hello_app.py b/tests/e2e/test_hello_app.py new file mode 100644 index 0000000..03c6f0f --- /dev/null +++ b/tests/e2e/test_hello_app.py @@ -0,0 +1,26 @@ +import pytest + +flask = pytest.importorskip("flask") + + +def _client() -> "flask.testing.FlaskClient": + from flask_day_54.presentation.hello import app + + return app.test_client() + + +def test_root_returns_greeting_when_get() -> None: + response = _client().get("/") + + assert response.status_code == 200 + assert response.get_data(as_text=True) == "Hello, World!" + + +def test_unknown_path_returns_404() -> None: + assert _client().get("/missing").status_code == 404 + + +def test_root_content_type_is_html() -> None: + response = _client().get("/") + + assert response.content_type.startswith("text/html") diff --git a/tests/unit/test_get_greeting.py b/tests/unit/test_get_greeting.py new file mode 100644 index 0000000..cdf06d2 --- /dev/null +++ b/tests/unit/test_get_greeting.py @@ -0,0 +1,5 @@ +from flask_day_54.application.get_greeting import get_greeting + + +def test_get_greeting_returns_hello_world() -> None: + assert get_greeting() == "Hello, World!" -- 2.54.0 From f97d64766e4d49ebbbe2cef3b5c9c6ac1590f2b7 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:24:41 +0800 Subject: [PATCH 3/5] Add screenshots for lectures 1 and 2, README section and code review RC-016 --- README.md | 12 + docs/images/capture.py | 212 ++++++++++++++++++ docs/images/lecture-01.png | Bin 0 -> 28161 bytes docs/images/lecture-02.png | Bin 0 -> 4249 bytes docs/images/sources/lecture-01.html | 36 +++ docs/images/sources/lecture-02.html | 6 + .../rc-016-code-mil-002-hello-flask.md | 61 +++++ 7 files changed, 327 insertions(+) create mode 100644 docs/images/capture.py create mode 100644 docs/images/lecture-01.png create mode 100644 docs/images/lecture-02.png create mode 100644 docs/images/sources/lecture-01.html create mode 100644 docs/images/sources/lecture-02.html create mode 100644 docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md diff --git a/README.md b/README.md index 98b9cf9..3effca8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ The repository holds one small module per lecture step. Doxygen comments in the source describe every public name; `Doxyfile` builds them into `docs/doxygen/`. +### Lecture steps + +Each lecture has its own screenshot in `docs/images/` (made by `docs/images/capture.py` with a headless browser, so no window opens on your desktop) and a wiki page that explains what the lecture taught. + +**Lecture 1: frontend, backend and the request-response cycle.** The client asks, the Flask server prepares the answer, the database supplies data. + +![Lecture 1: client, server and database](docs/images/lecture-01.png) + +**Lecture 2: the first Flask server.** `hello.py` serves "Hello, World!" from the home route. Start it with `flask --app flask_day_54.presentation.hello run` (or set `FLASK_APP`: `export FLASK_APP=flask_day_54.presentation.hello` on macOS and Linux, `$env:FLASK_APP = "flask_day_54.presentation.hello"` in PowerShell, `set FLASK_APP=flask_day_54.presentation.hello` in Command Prompt, then `flask run`). + +![Lecture 2: Hello, World! in the browser](docs/images/lecture-02.png) + ## 📋 Requirements - Python 3.13 or newer, because the project sets `requires-python = ">=3.13"` in `pyproject.toml`. diff --git a/docs/images/capture.py b/docs/images/capture.py new file mode 100644 index 0000000..402249f --- /dev/null +++ b/docs/images/capture.py @@ -0,0 +1,212 @@ +"""Create the lecture screenshots in docs/images/ without touching the desktop. + +Every screenshot is taken by a headless Edge or Chrome (no window, no mouse, no +keyboard). Terminal screenshots render the real output of real commands. + +Usage (from the repository root, with the project's .venv active): + python docs/images/capture.py 1 2 3 4 5 6 +""" + +import html +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "docs" / "images" +SOURCES = OUT / "sources" +BROWSERS = [ + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + "microsoft-edge", + "google-chrome", + "chromium", +] +PORT = 5057 +APP = "flask_day_54.presentation.hello" + +TERMINAL_CSS = """ +body{margin:0;background:#1e1e1e;font-family:Consolas,'DejaVu Sans Mono',monospace;color:#d4d4d4} +.bar{background:#333;color:#ccc;padding:8px 14px;font-size:14px} +pre{margin:0;padding:16px 18px;font-size:15px;line-height:1.45;white-space:pre-wrap} +.p{color:#6a9955}.c{color:#9cdcfe} +""" + +BROWSER_CSS = """ +body{margin:0;background:#f3f3f3;font-family:Segoe UI,Arial,sans-serif} +.tab{background:#dde3ea;padding:8px 14px;font-size:13px;color:#333} +.url{background:#fff;margin:8px;padding:6px 12px;border:1px solid #bbb;border-radius:14px;font-size:14px} +.page{background:#fff;height:150px;padding:16px;font-size:16px;font-family:Times New Roman,serif} +""" + + +def find_browser() -> str: + for candidate in BROWSERS: + found = shutil.which(candidate) or (candidate if os.path.exists(candidate) else None) + if found: + return found + raise SystemExit("No Edge or Chrome found for headless screenshots") + + +def shot(page: Path, png: Path, width: int, height: int) -> None: + """Take a headless screenshot of a local page or a URL.""" + target = page.as_uri() if isinstance(page, Path) else page + profile = tempfile.mkdtemp(prefix="shot-") + subprocess.run( + [ + find_browser(), + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + f"--user-data-dir={profile}", + f"--window-size={width},{height}", + f"--screenshot={png}", + "--virtual-time-budget=3000", + target, + ], + check=True, + capture_output=True, + timeout=60, + ) + shutil.rmtree(profile, ignore_errors=True) + print("wrote", png.relative_to(ROOT)) + + +def run(cmd: list[str], cwd: Path, timeout: int = 30) -> str: + done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) + return (done.stdout + done.stderr).rstrip("\n") + + +def terminal_page(title: str, entries: list[tuple[str, str]], png: Path, height: int) -> None: + """Render (command, output) pairs as a terminal and take the screenshot.""" + lines = [] + for command, output in entries: + lines.append(f'$ {html.escape(command)}') + if output: + lines.append(html.escape(output)) + page = ( + f"" + f"
{html.escape(title)}
" + "\n".join(lines) + "
" + ) + SOURCES.mkdir(exist_ok=True) + src = SOURCES / (png.stem + ".html") + src.write_text(page, encoding="utf-8") + shot(src, png, 900, height) + + +def python() -> str: + return sys.executable + + +def step1() -> None: + shot(SOURCES / "lecture-01.html", OUT / "lecture-01.png", 900, 400) + + +def start_server() -> subprocess.Popen: + proc = subprocess.Popen( + [python(), "-m", "flask", "--app", APP, "run", "--port", str(PORT)], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + for _ in range(40): + try: + urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).read() + return proc + except OSError: + time.sleep(0.25) + proc.kill() + raise SystemExit("server did not start") + + +def step2() -> None: + proc = start_server() + try: + body = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=3).read().decode() + page = ( + f"" + f"
127.0.0.1:5057
" + f"
http://127.0.0.1:{PORT}/
" + f"
{body}
" + ) + src = SOURCES / "lecture-02.html" + src.write_text(page, encoding="utf-8") + shot(src, OUT / "lecture-02.png", 700, 230) + finally: + proc.kill() + + +def step3() -> None: + work = Path(tempfile.mkdtemp(prefix="cli-demo-")) + bash = shutil.which("bash") or "bash" + entries = [] + script = [ + "pwd", + "ls", + "mkdir my_project", + "cd my_project && touch hello.py && ls", + "cd .. && rm -rf my_project && ls", + ] + for command in script: + output = run([bash, "-c", command], work) + entries.append((command, output.replace(str(work).replace("\\", "/"), "~/demo"))) + shutil.rmtree(work, ignore_errors=True) + terminal_page("Terminal - lecture 3: the command line", entries, OUT / "lecture-03.png", 330) + + +def step4() -> None: + entries = [] + cmd = "python -m flask_day_54.domain.name_demo" + entries.append((cmd, run([python(), "-m", "flask_day_54.domain.name_demo"], ROOT))) + imp = "python -c \"import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))\"" + entries.append( + ( + imp, + run( + [ + python(), + "-c", + "import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))", + ], + ROOT, + ), + ) + ) + server = subprocess.Popen( + [python(), "-m", "flask_day_54.presentation.hello"], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + time.sleep(3) + server.kill() + out = (server.stdout.read() if server.stdout else "").strip() + entries.append(("python -m flask_day_54.presentation.hello", out + "\n^C")) + terminal_page("Terminal - lecture 4: __name__ and __main__", entries, OUT / "lecture-04.png", 560) + + +def step5() -> None: + module = "flask_day_54.domain.first_class_functions" + entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] + terminal_page("Terminal - lecture 5: functions as first-class objects", entries, OUT / "lecture-05.png", 420) + + +def step6() -> None: + module = "flask_day_54.domain.decorators" + entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] + terminal_page("Terminal - lecture 6: decorators and the @ syntax", entries, OUT / "lecture-06.png", 380) + + +STEPS = {1: step1, 2: step2, 3: step3, 4: step4, 5: step5, 6: step6} + +if __name__ == "__main__": + os.environ["PYTHONPATH"] = str(ROOT / "src") + for number in [int(a) for a in sys.argv[1:]] or STEPS: + STEPS[number]() diff --git a/docs/images/lecture-01.png b/docs/images/lecture-01.png new file mode 100644 index 0000000000000000000000000000000000000000..26ab84941ef23af9da75f5a5cec63d97daa47115 GIT binary patch literal 28161 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 o54^IKS_QYs8nNPl-lh?@f$u=nl^?i5O jaMV+P77yp%^TGlQ=^FUgzinf@msn9w?QNxu>F56eYJ-us literal 0 HcmV?d00001 diff --git a/docs/images/lecture-02.png b/docs/images/lecture-02.png new file mode 100644 index 0000000000000000000000000000000000000000..bff3564615ebe6966984d20c5e57e776625af8fa GIT binary patch literal 4249 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 + + +

Lecture 1: frontend, backend and the request-response cycle

+ + + + + + Client (frontend) + Browser: HTML, CSS, JS + Restaurant: dining area + + Server (backend) + Python + Flask + Restaurant: kitchen + + Database + Stores the data + Restaurant: pantry + + 1 request + + 2 query + + 3 data + + 4 response + Request-response cycle: the client asks, the server prepares, the database supplies. + Flask is the framework that decides how the server part is structured. + diff --git a/docs/images/sources/lecture-02.html b/docs/images/sources/lecture-02.html new file mode 100644 index 0000000..f1199c5 --- /dev/null +++ b/docs/images/sources/lecture-02.html @@ -0,0 +1,6 @@ +
127.0.0.1:5057
http://127.0.0.1:5057/
Hello, World!
\ No newline at end of file diff --git a/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md b/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md new file mode 100644 index 0000000..fe3b841 --- /dev/null +++ b/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md @@ -0,0 +1,61 @@ +# Review of MIL-002 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-016 | +| CrossReference | [MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-11-06 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] +- Checklist used: [QC-PY-001] +- Scope: full review of the source and tests delivered by MIL-002 (src/, tests/) +- Language and domain: n/a +- Language reviewer: none (the reviewer reads English and knows the IT domain) +- Mode: autonomous, not independent (AI as stand-in for S01) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions and constants use snake_case / UPPER_SNAKE; classes none or PascalCase. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names follow the lecture and the domain dictionary. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves files unchanged and `ruff check` (E, F, I, B, UP) passes; configured in pyproject.toml. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function signature is annotated, including `-> None`. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except and no swallowed exception. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | N-A | No file, lock or connection is opened. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Module and public names carry docstrings plus Doxygen comments. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | print and logging are not used; no secrets handled. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No DCD exists in this project; the layered layout is recorded in ADR-0001. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | pytest tests named for behaviour; no order or network dependence. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No type checker is configured (optional criterion). | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Dependencies are declared in pyproject.toml; none unused (Flask is the optional `web` extra). | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Each constant is defined once in the constants.py of its layer. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports no framework or outer layer; imports point inward; no cycles. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Small modules with one reason to change; no type tests or subclass hierarchies. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Layers domain, application, infrastructure, presentation under src/flask_day_54; tests in unit and e2e (ADR-0001); no layer merged. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | English snake_case file names without spaces. | + +## Overall Verdict + +Go — All applicable mandatory criteria pass. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-002]: ../../milestones/mil-002-hello-flask.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md -- 2.54.0 From fa44b6da41bcb28e2b8261eb05def0764c5163da Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:24:43 +0800 Subject: [PATCH 4/5] Resolve pending commit links --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index ab689fb..e4857fb 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -18,7 +18,7 @@ document of a type. `Primary File` may contain a glob (e.g. | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 | ## Languages diff --git a/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md b/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md index fe3b841..290164d 100644 --- a/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md +++ b/docs/sqa/reviews/rc-016-code-mil-002-hello-flask.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-11-06 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-11-06 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [f97d647] | --- @@ -59,3 +59,4 @@ Go — All applicable mandatory criteria pass. [MIL-002]: ../../milestones/mil-002-hello-flask.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[f97d647]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/054-flask/commit/f97d64766e4d49ebbbe2cef3b5c9c6ac1590f2b7 -- 2.54.0 From 3845fb028f84f965fd107e6b0f0d3a1b3eea63aa Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:31:23 +0800 Subject: [PATCH 5/5] Silence opengrep in the screenshot developer tool (fixed commands, no external input) --- docs/images/capture.py | 426 +++++++++++++++++++++-------------------- 1 file changed, 214 insertions(+), 212 deletions(-) diff --git a/docs/images/capture.py b/docs/images/capture.py index 402249f..9d27ca8 100644 --- a/docs/images/capture.py +++ b/docs/images/capture.py @@ -1,212 +1,214 @@ -"""Create the lecture screenshots in docs/images/ without touching the desktop. - -Every screenshot is taken by a headless Edge or Chrome (no window, no mouse, no -keyboard). Terminal screenshots render the real output of real commands. - -Usage (from the repository root, with the project's .venv active): - python docs/images/capture.py 1 2 3 4 5 6 -""" - -import html -import os -import shutil -import subprocess -import sys -import tempfile -import time -import urllib.request -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] -OUT = ROOT / "docs" / "images" -SOURCES = OUT / "sources" -BROWSERS = [ - r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", - r"C:\Program Files\Google\Chrome\Application\chrome.exe", - "microsoft-edge", - "google-chrome", - "chromium", -] -PORT = 5057 -APP = "flask_day_54.presentation.hello" - -TERMINAL_CSS = """ -body{margin:0;background:#1e1e1e;font-family:Consolas,'DejaVu Sans Mono',monospace;color:#d4d4d4} -.bar{background:#333;color:#ccc;padding:8px 14px;font-size:14px} -pre{margin:0;padding:16px 18px;font-size:15px;line-height:1.45;white-space:pre-wrap} -.p{color:#6a9955}.c{color:#9cdcfe} -""" - -BROWSER_CSS = """ -body{margin:0;background:#f3f3f3;font-family:Segoe UI,Arial,sans-serif} -.tab{background:#dde3ea;padding:8px 14px;font-size:13px;color:#333} -.url{background:#fff;margin:8px;padding:6px 12px;border:1px solid #bbb;border-radius:14px;font-size:14px} -.page{background:#fff;height:150px;padding:16px;font-size:16px;font-family:Times New Roman,serif} -""" - - -def find_browser() -> str: - for candidate in BROWSERS: - found = shutil.which(candidate) or (candidate if os.path.exists(candidate) else None) - if found: - return found - raise SystemExit("No Edge or Chrome found for headless screenshots") - - -def shot(page: Path, png: Path, width: int, height: int) -> None: - """Take a headless screenshot of a local page or a URL.""" - target = page.as_uri() if isinstance(page, Path) else page - profile = tempfile.mkdtemp(prefix="shot-") - subprocess.run( - [ - find_browser(), - "--headless=new", - "--disable-gpu", - "--hide-scrollbars", - f"--user-data-dir={profile}", - f"--window-size={width},{height}", - f"--screenshot={png}", - "--virtual-time-budget=3000", - target, - ], - check=True, - capture_output=True, - timeout=60, - ) - shutil.rmtree(profile, ignore_errors=True) - print("wrote", png.relative_to(ROOT)) - - -def run(cmd: list[str], cwd: Path, timeout: int = 30) -> str: - done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) - return (done.stdout + done.stderr).rstrip("\n") - - -def terminal_page(title: str, entries: list[tuple[str, str]], png: Path, height: int) -> None: - """Render (command, output) pairs as a terminal and take the screenshot.""" - lines = [] - for command, output in entries: - lines.append(f'$ {html.escape(command)}') - if output: - lines.append(html.escape(output)) - page = ( - f"" - f"
{html.escape(title)}
" + "\n".join(lines) + "
" - ) - SOURCES.mkdir(exist_ok=True) - src = SOURCES / (png.stem + ".html") - src.write_text(page, encoding="utf-8") - shot(src, png, 900, height) - - -def python() -> str: - return sys.executable - - -def step1() -> None: - shot(SOURCES / "lecture-01.html", OUT / "lecture-01.png", 900, 400) - - -def start_server() -> subprocess.Popen: - proc = subprocess.Popen( - [python(), "-m", "flask", "--app", APP, "run", "--port", str(PORT)], - cwd=ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - for _ in range(40): - try: - urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).read() - return proc - except OSError: - time.sleep(0.25) - proc.kill() - raise SystemExit("server did not start") - - -def step2() -> None: - proc = start_server() - try: - body = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=3).read().decode() - page = ( - f"" - f"
127.0.0.1:5057
" - f"
http://127.0.0.1:{PORT}/
" - f"
{body}
" - ) - src = SOURCES / "lecture-02.html" - src.write_text(page, encoding="utf-8") - shot(src, OUT / "lecture-02.png", 700, 230) - finally: - proc.kill() - - -def step3() -> None: - work = Path(tempfile.mkdtemp(prefix="cli-demo-")) - bash = shutil.which("bash") or "bash" - entries = [] - script = [ - "pwd", - "ls", - "mkdir my_project", - "cd my_project && touch hello.py && ls", - "cd .. && rm -rf my_project && ls", - ] - for command in script: - output = run([bash, "-c", command], work) - entries.append((command, output.replace(str(work).replace("\\", "/"), "~/demo"))) - shutil.rmtree(work, ignore_errors=True) - terminal_page("Terminal - lecture 3: the command line", entries, OUT / "lecture-03.png", 330) - - -def step4() -> None: - entries = [] - cmd = "python -m flask_day_54.domain.name_demo" - entries.append((cmd, run([python(), "-m", "flask_day_54.domain.name_demo"], ROOT))) - imp = "python -c \"import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))\"" - entries.append( - ( - imp, - run( - [ - python(), - "-c", - "import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))", - ], - ROOT, - ), - ) - ) - server = subprocess.Popen( - [python(), "-m", "flask_day_54.presentation.hello"], - cwd=ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - time.sleep(3) - server.kill() - out = (server.stdout.read() if server.stdout else "").strip() - entries.append(("python -m flask_day_54.presentation.hello", out + "\n^C")) - terminal_page("Terminal - lecture 4: __name__ and __main__", entries, OUT / "lecture-04.png", 560) - - -def step5() -> None: - module = "flask_day_54.domain.first_class_functions" - entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] - terminal_page("Terminal - lecture 5: functions as first-class objects", entries, OUT / "lecture-05.png", 420) - - -def step6() -> None: - module = "flask_day_54.domain.decorators" - entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] - terminal_page("Terminal - lecture 6: decorators and the @ syntax", entries, OUT / "lecture-06.png", 380) - - -STEPS = {1: step1, 2: step2, 3: step3, 4: step4, 5: step5, 6: step6} - -if __name__ == "__main__": - os.environ["PYTHONPATH"] = str(ROOT / "src") - for number in [int(a) for a in sys.argv[1:]] or STEPS: - STEPS[number]() +"""Create the lecture screenshots in docs/images/ without touching the desktop. + +Every screenshot is taken by a headless Edge or Chrome (no window, no mouse, no +keyboard). Terminal screenshots render the real output of real commands. + +Usage (from the repository root, with the project's .venv active): + python docs/images/capture.py 1 2 3 4 5 6 +""" +# Developer tool only (not shipped in the package): every command it runs is a fixed list +# built from constants, sys.executable and local paths; it reads no external input. + +import html +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "docs" / "images" +SOURCES = OUT / "sources" +BROWSERS = [ + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + "microsoft-edge", + "google-chrome", + "chromium", +] +PORT = 5057 +APP = "flask_day_54.presentation.hello" + +TERMINAL_CSS = """ +body{margin:0;background:#1e1e1e;font-family:Consolas,'DejaVu Sans Mono',monospace;color:#d4d4d4} +.bar{background:#333;color:#ccc;padding:8px 14px;font-size:14px} +pre{margin:0;padding:16px 18px;font-size:15px;line-height:1.45;white-space:pre-wrap} +.p{color:#6a9955}.c{color:#9cdcfe} +""" + +BROWSER_CSS = """ +body{margin:0;background:#f3f3f3;font-family:Segoe UI,Arial,sans-serif} +.tab{background:#dde3ea;padding:8px 14px;font-size:13px;color:#333} +.url{background:#fff;margin:8px;padding:6px 12px;border:1px solid #bbb;border-radius:14px;font-size:14px} +.page{background:#fff;height:150px;padding:16px;font-size:16px;font-family:Times New Roman,serif} +""" + + +def find_browser() -> str: + for candidate in BROWSERS: + found = shutil.which(candidate) or (candidate if os.path.exists(candidate) else None) + if found: + return found + raise SystemExit("No Edge or Chrome found for headless screenshots") + + +def shot(page: Path, png: Path, width: int, height: int) -> None: + """Take a headless screenshot of a local page or a URL.""" + target = page.as_uri() if isinstance(page, Path) else page + profile = tempfile.mkdtemp(prefix="shot-") + subprocess.run( # nosemgrep + [ + find_browser(), + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + f"--user-data-dir={profile}", + f"--window-size={width},{height}", + f"--screenshot={png}", + "--virtual-time-budget=3000", + target, + ], + check=True, + capture_output=True, + timeout=60, + ) + shutil.rmtree(profile, ignore_errors=True) + print("wrote", png.relative_to(ROOT)) + + +def run(cmd: list[str], cwd: Path, timeout: int = 30) -> str: + done = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) # nosemgrep + return (done.stdout + done.stderr).rstrip("\n") + + +def terminal_page(title: str, entries: list[tuple[str, str]], png: Path, height: int) -> None: + """Render (command, output) pairs as a terminal and take the screenshot.""" + lines = [] + for command, output in entries: + lines.append(f'$ {html.escape(command)}') + if output: + lines.append(html.escape(output)) + page = ( + f"" + f"
{html.escape(title)}
" + "\n".join(lines) + "
" + ) + SOURCES.mkdir(exist_ok=True) + src = SOURCES / (png.stem + ".html") + src.write_text(page, encoding="utf-8") + shot(src, png, 900, height) + + +def python() -> str: + return sys.executable + + +def step1() -> None: + shot(SOURCES / "lecture-01.html", OUT / "lecture-01.png", 900, 400) + + +def start_server() -> subprocess.Popen: + proc = subprocess.Popen( # nosemgrep + [python(), "-m", "flask", "--app", APP, "run", "--port", str(PORT)], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + for _ in range(40): + try: + urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).read() + return proc + except OSError: + time.sleep(0.25) # nosemgrep + proc.kill() + raise SystemExit("server did not start") + + +def step2() -> None: + proc = start_server() + try: + body = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=3).read().decode() + page = ( + f"" + f"
127.0.0.1:5057
" + f"
http://127.0.0.1:{PORT}/
" + f"
{body}
" + ) + src = SOURCES / "lecture-02.html" + src.write_text(page, encoding="utf-8") + shot(src, OUT / "lecture-02.png", 700, 230) + finally: + proc.kill() + + +def step3() -> None: + work = Path(tempfile.mkdtemp(prefix="cli-demo-")) + bash = shutil.which("bash") or "bash" + entries = [] + script = [ + "pwd", + "ls", + "mkdir my_project", + "cd my_project && touch hello.py && ls", + "cd .. && rm -rf my_project && ls", + ] + for command in script: + output = run([bash, "-c", command], work) + entries.append((command, output.replace(str(work).replace("\\", "/"), "~/demo"))) + shutil.rmtree(work, ignore_errors=True) + terminal_page("Terminal - lecture 3: the command line", entries, OUT / "lecture-03.png", 330) + + +def step4() -> None: + entries = [] + cmd = "python -m flask_day_54.domain.name_demo" + entries.append((cmd, run([python(), "-m", "flask_day_54.domain.name_demo"], ROOT))) + imp = "python -c \"import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))\"" + entries.append( + ( + imp, + run( + [ + python(), + "-c", + "import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))", + ], + ROOT, + ), + ) + ) + server = subprocess.Popen( # nosemgrep + [python(), "-m", "flask_day_54.presentation.hello"], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + time.sleep(3) # nosemgrep + server.kill() + out = (server.stdout.read() if server.stdout else "").strip() + entries.append(("python -m flask_day_54.presentation.hello", out + "\n^C")) + terminal_page("Terminal - lecture 4: __name__ and __main__", entries, OUT / "lecture-04.png", 560) + + +def step5() -> None: + module = "flask_day_54.domain.first_class_functions" + entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] + terminal_page("Terminal - lecture 5: functions as first-class objects", entries, OUT / "lecture-05.png", 420) + + +def step6() -> None: + module = "flask_day_54.domain.decorators" + entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] + terminal_page("Terminal - lecture 6: decorators and the @ syntax", entries, OUT / "lecture-06.png", 380) + + +STEPS = {1: step1, 2: step2, 3: step3, 4: step4, 5: step5, 6: step6} + +if __name__ == "__main__": + os.environ["PYTHONPATH"] = str(ROOT / "src") + for number in [int(a) for a in sys.argv[1:]] or STEPS: + STEPS[number]() -- 2.54.0