From c948f7b3de5b6577b857c0099dd595fa391e19c1 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:39:39 +0800 Subject: [PATCH 1/3] Guard app.run with __main__, add name_demo and cli_basics with tests Task: MIL-003#1 Task: MIL-003#2 Task: MIL-003#3 --- src/flask_day_54/domain/cli_basics.py | 66 ++++++++++++++++++++++++++ src/flask_day_54/domain/constants.py | 32 +++++++++++++ src/flask_day_54/domain/name_demo.py | 29 +++++++++++ src/flask_day_54/presentation/hello.py | 15 +++++- tests/e2e/test_hello_main.py | 33 +++++++++++++ tests/unit/test_cli_basics.py | 39 +++++++++++++++ tests/unit/test_name_demo.py | 17 +++++++ 7 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/flask_day_54/domain/cli_basics.py create mode 100644 src/flask_day_54/domain/name_demo.py create mode 100644 tests/e2e/test_hello_main.py create mode 100644 tests/unit/test_cli_basics.py create mode 100644 tests/unit/test_name_demo.py diff --git a/src/flask_day_54/domain/cli_basics.py b/src/flask_day_54/domain/cli_basics.py new file mode 100644 index 0000000..b223472 --- /dev/null +++ b/src/flask_day_54/domain/cli_basics.py @@ -0,0 +1,66 @@ +## @file cli_basics.py +# @brief Lecture 3 command line cheat sheet. +"""Command line cheat sheet with Windows equivalents.""" + +from dataclasses import dataclass + +from flask_day_54.domain.constants import CLI_COMMANDS + + +## @brief One command of the cheat sheet. +@dataclass(frozen=True, slots=True) +class CommandInfo: + """A shell command, its purpose, Windows equivalent and risk.""" + + name: str + purpose: str + windows_equivalent: str + is_destructive: bool + warning: str + + +def _all_commands() -> tuple[CommandInfo, ...]: + return tuple(CommandInfo(*row) for row in CLI_COMMANDS) + + +## @brief Look up one command by name. +# @param name Command name such as "ls" or "rm -rf". +# @return The command information. +# @throws ValueError When the command is not in the cheat sheet. +def describe_command(name: str) -> CommandInfo: + """Return the cheat sheet entry for a command name.""" + for command in _all_commands(): + if command.name == name: + return command + raise ValueError(f"Unknown command: {name!r}") + + +## @brief List the commands that remove data without asking. +def destructive_commands() -> tuple[CommandInfo, ...]: + """Return the destructive commands.""" + return tuple(c for c in _all_commands() if c.is_destructive) + + +## @brief Format the cheat sheet as text, one command per line. +def format_cheat_sheet() -> str: + """Return the cheat sheet as multi-line text.""" + lines: list[str] = [] + for command in _all_commands(): + line = ( + f"{command.name:<8} {command.purpose} " + f"(Windows: {command.windows_equivalent})" + ) + if command.is_destructive: + line += f" WARNING: {command.warning}" + lines.append(line) + return "\n".join(lines) + + +## @brief Print the cheat sheet. +def main() -> None: + """Print the cheat sheet; the output is the lecture's result.""" + print(format_cheat_sheet()) + + +if __name__ == "__main__": + main() diff --git a/src/flask_day_54/domain/constants.py b/src/flask_day_54/domain/constants.py index fdaae9a..2756463 100644 --- a/src/flask_day_54/domain/constants.py +++ b/src/flask_day_54/domain/constants.py @@ -6,3 +6,35 @@ from typing import Final ## @brief The greeting shown by the first Flask route. GREETING: Final[str] = "Hello, World!" + +## @brief Value of `__name__` when a file is executed directly. +MAIN_MODULE_NAME: Final[str] = "__main__" + +## @brief Sentence shown when the file runs as the main program. +MAIN_PROGRAM_SENTENCE: Final[str] = ( + "Running as the main program: the file was executed directly" +) + +## @brief Sentence template shown when the file is imported; `{name}` is the module. +IMPORTED_MODULE_SENTENCE: Final[str] = "Running as an imported module named {name}" + +## @brief Warning shown for commands that remove data without asking. +NO_CONFIRMATION_WARNING: Final[str] = "No confirmation is asked - double-check the path" + +## @brief Command line cheat sheet rows. +# Each row is (name, purpose, Windows equivalent, is destructive, warning). +CLI_COMMANDS: Final[tuple[tuple[str, str, str, bool, str], ...]] = ( + ("pwd", "Print the current working directory", "cd", False, ""), + ("ls", "List the files in a directory", "dir", False, ""), + ("cd", "Change the current directory", "cd", False, ""), + ("mkdir", "Create a new directory", "mkdir", False, ""), + ("touch", "Create an empty file", "type nul > file", False, ""), + ("rm", "Remove a file", "del file", True, NO_CONFIRMATION_WARNING), + ( + "rm -rf", + "Remove a directory and everything in it", + "rmdir /s /q", + True, + NO_CONFIRMATION_WARNING, + ), +) diff --git a/src/flask_day_54/domain/name_demo.py b/src/flask_day_54/domain/name_demo.py new file mode 100644 index 0000000..9a75e11 --- /dev/null +++ b/src/flask_day_54/domain/name_demo.py @@ -0,0 +1,29 @@ +## @file name_demo.py +# @brief Lecture 4 demo of the `__name__ == "__main__"` check. +"""Shows whether a file runs as the main program or as an imported module.""" + +from flask_day_54.domain.constants import ( + IMPORTED_MODULE_SENTENCE, + MAIN_MODULE_NAME, + MAIN_PROGRAM_SENTENCE, +) + + +## @brief Describe how a module is running from its `__name__`. +# @param name The value of `__name__`. +# @return A short sentence. +def describe_name(name: str) -> str: + """Return a sentence telling how the module with this name is running.""" + if name == MAIN_MODULE_NAME: + return MAIN_PROGRAM_SENTENCE + return IMPORTED_MODULE_SENTENCE.format(name=name) + + +## @brief Print the description of this module's own `__name__`. +def main() -> None: + """Print how this file is running; the output is the demo's result.""" + print(describe_name(__name__)) + + +if __name__ == "__main__": + main() diff --git a/src/flask_day_54/presentation/hello.py b/src/flask_day_54/presentation/hello.py index 4483206..329dfec 100644 --- a/src/flask_day_54/presentation/hello.py +++ b/src/flask_day_54/presentation/hello.py @@ -1,10 +1,11 @@ ## @file hello.py -# @brief The minimal Flask application of lectures 1 and 2. -"""The minimal Flask application of lectures 1 and 2.""" +# @brief The minimal Flask application of lectures 1 to 4. +"""The minimal Flask application of lectures 1 to 4.""" from flask import Flask from flask_day_54.application.get_greeting import get_greeting +from flask_day_54.infrastructure.constants import DEBUG, HOST, PORT ## @brief The Flask application object. app = Flask(__name__) @@ -16,3 +17,13 @@ app = Flask(__name__) def hello_world() -> str: """Answer the root route with the greeting.""" return get_greeting() + + +## @brief Start the Flask development server with the configured settings. +def main() -> None: + """Run the development server using the infrastructure settings.""" + app.run(host=HOST, port=PORT, debug=DEBUG) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/test_hello_main.py b/tests/e2e/test_hello_main.py new file mode 100644 index 0000000..85affce --- /dev/null +++ b/tests/e2e/test_hello_main.py @@ -0,0 +1,33 @@ +import importlib + +import pytest + +flask = pytest.importorskip("flask") + +from flask_day_54.infrastructure.constants import DEBUG, HOST, PORT # noqa: E402 + + +def test_main_runs_app_with_configured_host_port_and_debug( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from flask_day_54.presentation import hello + + calls: list[dict[str, object]] = [] + monkeypatch.setattr(hello.app, "run", lambda **kwargs: calls.append(kwargs)) + + hello.main() + + assert calls == [{"host": HOST, "port": PORT, "debug": DEBUG}] + + +def test_importing_module_does_not_start_server( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from flask_day_54.presentation import hello + + calls: list[object] = [] + monkeypatch.setattr(flask.Flask, "run", lambda *args, **kwargs: calls.append(args)) + + importlib.reload(hello) + + assert calls == [] diff --git a/tests/unit/test_cli_basics.py b/tests/unit/test_cli_basics.py new file mode 100644 index 0000000..f7593ef --- /dev/null +++ b/tests/unit/test_cli_basics.py @@ -0,0 +1,39 @@ +import pytest + +from flask_day_54.domain.cli_basics import ( + describe_command, + destructive_commands, + format_cheat_sheet, +) + + +def test_describe_command_returns_windows_equivalent_when_known() -> None: + assert describe_command("ls").windows_equivalent == "dir" + assert describe_command("touch").windows_equivalent == "type nul > file" + assert describe_command("pwd").windows_equivalent == "cd" + + +def test_describe_command_raises_value_error_when_unknown() -> None: + with pytest.raises(ValueError, match="Unknown command: 'foo'"): + describe_command("foo") + + +def test_destructive_commands_contains_only_rm_variants() -> None: + names = {c.name for c in destructive_commands()} + + assert names == {"rm", "rm -rf"} + + +def test_recursive_remove_has_warning_and_windows_equivalent() -> None: + command = describe_command("rm -rf") + + assert command.windows_equivalent == "rmdir /s /q" + assert command.warning == "No confirmation is asked - double-check the path" + + +def test_format_cheat_sheet_lists_every_command_and_warns_on_rm() -> None: + text = format_cheat_sheet() + + for name in ("pwd", "ls", "cd", "mkdir", "touch", "rm", "rm -rf"): + assert name in text + assert text.count("WARNING") == 2 diff --git a/tests/unit/test_name_demo.py b/tests/unit/test_name_demo.py new file mode 100644 index 0000000..a138483 --- /dev/null +++ b/tests/unit/test_name_demo.py @@ -0,0 +1,17 @@ +from flask_day_54.domain.name_demo import describe_name + + +def test_describe_name_reports_main_program_when_name_is_main() -> None: + assert describe_name("__main__") == ( + "Running as the main program: the file was executed directly" + ) + + +def test_describe_name_reports_imported_module_when_name_is_other() -> None: + assert describe_name("flask_day_54.domain.name_demo") == ( + "Running as an imported module named flask_day_54.domain.name_demo" + ) + + +def test_describe_name_reports_imported_module_when_name_is_empty() -> None: + assert describe_name("") == "Running as an imported module named " -- 2.54.0 From f8a1794373b21d718d756215eae34c67e873fbd2 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:39:43 +0800 Subject: [PATCH 2/3] Add screenshots for lectures 3 and 4, README section and code review RC-017 --- README.md | 8 + docs/artifact-registry.md | 2 +- docs/images/capture.py | 443 +++++++++--------- docs/images/lecture-03.png | Bin 0 -> 10115 bytes docs/images/lecture-04.png | Bin 0 -> 25689 bytes docs/images/sources/lecture-03.html | 12 + docs/images/sources/lecture-04.html | 18 + .../rc-017-code-mil-003-cli-and-main.md | 61 +++ 8 files changed, 329 insertions(+), 215 deletions(-) create mode 100644 docs/images/lecture-03.png create mode 100644 docs/images/lecture-04.png create mode 100644 docs/images/sources/lecture-03.html create mode 100644 docs/images/sources/lecture-04.html create mode 100644 docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md diff --git a/README.md b/README.md index 3effca8..7e5962e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ Each lecture has its own screenshot in `docs/images/` (made by `docs/images/capt ![Lecture 2: Hello, World! in the browser](docs/images/lecture-02.png) +**Lecture 3: the command line.** `pwd`, `ls`, `cd`, `mkdir`, `touch` and `rm` (with `rm -rf` as the command to double-check). `flask_day_54.domain.cli_basics` lists them with their Windows equivalents: `python -m flask_day_54.domain.cli_basics`. The screenshot shows the real commands run in a temporary folder. + +![Lecture 3: the command line](docs/images/lecture-03.png) + +**Lecture 4: `__name__` and `__main__`.** A file run directly has `__name__ == "__main__"`; an imported file has its module name. `app.run()` sits under that check in `hello.py`, so there are two ways to start the server: `flask --app flask_day_54.presentation.hello run` or `python -m flask_day_54.presentation.hello`. Try `python -m flask_day_54.domain.name_demo`. + +![Lecture 4: __name__ and __main__](docs/images/lecture-04.png) + ## 📋 Requirements - Python 3.13 or newer, because the project sets `requires-python = ">=3.13"` in `pyproject.toml`. diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index e4857fb..4235e9a 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 | 017 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 018 | ## Languages diff --git a/docs/images/capture.py b/docs/images/capture.py index 9d27ca8..b49f322 100644 --- a/docs/images/capture.py +++ b/docs/images/capture.py @@ -1,214 +1,229 @@ -"""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]() +"""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 re +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 stop(proc: subprocess.Popen) -> None: + """Stop a server and its reloader child.""" + if os.name == "nt": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True) # nosemgrep + else: + proc.kill() + proc.wait(timeout=10) + + +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: + stop(proc) + + +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", + "rm -rf my_project && ls", + ] + for command in script: + output = run([bash, "-c", command], work) + entries.append((command, re.sub(r"/tmp/cli-demo-\w+", "~/demo", output))) + 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 + stop(server) + raw = (server.stdout.read() if server.stdout else "").strip() + # Leave out the .env tip and the random debugger PIN; they say nothing about the lecture. + kept = [ + line for line in raw.splitlines() if "Tip:" not in line and "Debugger PIN" not in line + ] + out = "\n".join(kept) + 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-03.png b/docs/images/lecture-03.png new file mode 100644 index 0000000000000000000000000000000000000000..a6be2a5f71a343e7eeb7579b6849a54a1e7dbf54 GIT binary patch literal 10115 zcmeHtXF!wNx@Oe9L8VFWpmdNTpcH9J73sbAYA6AfPC&N;(wp=q9YXIVG(mb35s(@X zDWSI{gb=tNo-=pmoS8Fo@BElQbANqVeXVz`^}Nsftb}W8s*v1fxD5hViNw z-h)8bp5MF<ofK(Nq>icHyECxI48(mx;)>)mY7=@*l2p<|TJXTQEo0)d} zfV?eF5$>)Vq2|i!@Nrh4`x8ir{ihPg3C7wxIV^bR$=V#8+{FfcJozzbONWLqZ8SSL&;+g{ zliG@(T3NNcwi;A*D!Ad}ZxgqVkE=mfX-lLaV{ ztNvO@3l0kGi{fEp9GB_p?eLs!v*UeVN2?JdO92A4HGjL6zb<6SuGW4wd2UZ3s>aqN z56OIhfam92C}szep7P%S#YLDWA@j2!6aEe*ga>;r>w4>yML&#uK8!FWiY0!ct5ol^P@PA+HcX}I5qi;k96N_Rpm{$N5n{(um=OdUrH$qetIpgcRi0kC&6O zhm)%s8Dc{M_`F8%eC)^$Yx(<}j~``?j>3!CC~wIPD-UZyn||i+>{Ek4YATvs4+asn z4Q1}ZsG4t=zLP4KKPdgcb``hvz_{^Yv0afS$N~pD?5t(_95dy!1FqZY6txszJz#V( zD2kQ~mEI8a9Fkb=w_2phKtsW_={wkdl?)b4m}xBx?cB`CXZ?PEsGl|CR!?(ZmAh6c z2L&7|I4lOWx9nVitV;DwY&5T^&YjKmmF9G)$!!l<${x3;MVM;#^dJ9S<6l2cyGxW9d1z3r|;JBBZmLBsON&+CHO3( z-d(+1ZLf9thfF+jm@wNPi#fe8w$-Nn@4G}!wwD^p1b0QTUzim7@`FR|GBNO+qT~u+ zgUx%#i{toQ`*oQMi-*_S>pb~h8r4#CTnu=P`SnmH_-&{udH)qei{M}va}W=FbWYRX zM)sod!OEzJc&Hqk(*-@gPU*dn?OZR%h4tu}s_H?D?nYEw#_>H|z@MNlio{3}eA)eE zfk=G_$plMo&VFccO&EdT-Yn-!_R4gIfT1X|goy=~fC;`Syvy_giKBzj64jxvG%ae{ z*~rnC>Swm3yRU8hVI*G?i$FT&`9T@7!DBijDN!mlQETYj@6oOgCFo5dhl-tY4(^$_ zfd+MLJv}{T>g+GlH{hvm)o6_);P+`2~cm}M=y*x{D1k$+MovOq^ z@zJ)&U|uU$)9Q&!E5G|`wnav=Qb1XVu<`M*kRDSZkEmUNZuTS%SDT44r_|$Qy+`M8 zVoI~2oR6tZTp^fy;;-=tLeQI#d)zYlwbz9%4jt;hrRuaxIf^vs)Mvt*Hb+9{_XDY5 zF_(cj^mneh&ijpQF47spA=;Ep+TNA7{idJg$}a4^n70$!7?HzS;Y7ZlenqEh$YC&- z=2Dp2B;?}3PCwgw-`E`gjZKx@$ua1shjJ9RAcp744FZXw<43A#4U5A2Jw{E4S#iu~ zC+ycEtjSpKB@_fQF2B&$(YXhJ7Kr)O-T(gr-@&8qbhM8E^9Qv*r0-KGEPoTHa(Hh4*c`eu&(yE!T6($RB=LF7U_hUUVsKr z$2#0s)qZoWR%@wVraj=|L&aUrmJ?T3@;S*C4fyt`-#wkK97YAQXZNLc}fMCYIQ>T7kN?OFHb6(AbTJ)O+unCzX zJj-6JDH*+r3^bqEg;XnL(~|@J8_SDicb(X8ZU^XLIj0DpCl_`k7Q$55C(?C|qK6sl z#a{c|o)DE4!yEM8Kfc~cN+cRiL&i`g9u?&dXF8sVTQn0 z-wlB(`y=M1U@Rvm7IfsjjMGHI=xzIpp_ev_;|k2t!a{q2Jz_{1a0km(AKHy-QWeIi z{?cvnaf}R2Ra-Lpx>j~p(HiQg0!mc83#b@J!2t5kG% zyQ%$mKA@NCdi%#2L5p+g1K$g8n7REtM6q{JP5ab}`JR*Xv3J5xj8j@XPj$Z92(j!K zT{}_kTIC@jMVIZ$KvlL$8zi$>iWywzWSc80w6E51YjBG(Ri6W2)|~#6R_KuWIgt4w z#Uic4*^goS@znu36uEe;-9v)FGG5V-0d;SOtGjZSNYZ%j)>Q`af9i0L_|Un@F3tF~ z`K_aOD@CDB=JNny09jfeGbv|W+LQQSE(!qYH)-pSqTGFUs|ohaTludt{;;GtcEZJo z3=eQRsnaC&Vh!P(=XwuTD4#<5S?pL%RuG3@q}-d#)*9=4hP;PqqcSTl>jTc>*#dyp z)}DiHPWsUVNo+Ud*2WLtJ;}IqY>GM;GR+e|T0NchR|peNJq@p0MNbcS@}q0HnU4x- zaC?8Lp`29SqqIgReLf&=0Nq4BE3&D_p6;Ds7LJgP+IhJT`AC{`nzYm!6O)onD(d06h#V_+-#m3=3LZR|W zU+v&eeT-EEr~2Pd)?QcpX;;c!4#%B&tBC*EYyO?IrUi3^KQs(@nihB$m_>D}J)N?U z6vkZhL}G+VFnx~Se`a5mF zT9zY8H`>uig|L<22<>G z`Q0gb_1#iT-Jcaw@>=GkDQ7()XZDk;YoreSIJPAG0M(U=`2~Y{czMZN3eRMS1mnqo zrN*i64WCRJy}mj&j>2RzS6AQ(cpVEzUfE%m{67PY8l>Hutz=Dx?pKKMq_y^pfQw!M z5yW|}u)NgO8H9DKO!3*nNIV~%A#I6iK;Hyvwfzv;qldQaBvwEo;Ty3FP@ZgTM~Av2J@T}< zb&l4zFRi)AoOHj1U-n7K-T^oy$oNQvB>(5&*BK0$^rJqjcD|C5H(zTOTDYmKMF}Wh zR!Zn~JtOS&jWK6#zEJ;_q95FWS|lS<6mvEE&7Lj!X9aibZKqg^D-qHNjB(qMX&;fa zg^S=W%7jrmsQ7$;0g1XYpwN4!a#02*oW+9gxwgI1x=xDZjHUh~FQ2O@p$$|^oG-Ld z;VUL7n0)E5%8<)qU*Qy}el>$X)s3ngy;G6gHQh zuu1s-Y;VuM8b-~^5Ybh@hP3LP`Zc_Gb>?r`8x;wec3kxZq93aK?bZh5NgXGz3e)aI z0j-`ecJly<`hT4md`f45S(zRZxEkY`k)e%#-&Y?w#2Na&tp>3N_Q=!SpMySpC|8L7DZO zh01m`XGV~fHn|I;mAHS`<#24O=N&f50Bc(?Bf!99NX{gExU@7uFH~p7)xMj7Bltc} zy?8-j_G>o&YiDg!l?IE-AzV#MnZ2~!Wkv>kDbJKl7%!(Bo0!UfvY&nsV=(K+_TU#f zN$Qtn-?cSQCaglGU4_g#(TvXmn2$AkV$OKIRH)MZ)yF<@^8B< z&eA9{9?0||Q?qd>u#k5hwSu#g&h*CE?uEDMv_EQ?v2FY@e~xZEt-G=eh&~0GLkxvN z_&N*Do($H0#PC+|x9P5;iClUr!zp*)T~otmc&^#8zld(JcqFV^?|Y!AQL1*a!4lr# zVZAThpu++#k`Bn26x&IFXVOuidx~cwB~4(iovoV08tX=n4Xt=~QQ_a39y_GicP{8! z(k?aC0TT?iiQ!<7Y#7Ne>?`F~V-{vNcOxxUI7xSFlpOJ_RHn$q88{qQbK*o+i6?Lu25V_Po`hZ*v$GNpw_*!|C#B8W!IRvYvNMAD*N(C-TTLvfXBY6 zX5*QV5^FAeY~oSfsTPHQ{KB0X?=6v@MCQ&*%`R8)fTL@lw0Co2Ix+^ci=_6;`LhB651|EY%IK$WZ`+Mz4CU0JqIlXw zOHGOKAE9A@x9qY_bfZi9%dRuS_yO#{X#tUpv8>Zo6eik>X&AKRy!$QT4W99h%{dG^ z3EbYu0n6AJfB(@7!zP1->=V31|9yShQVp;8(6GW}fVLd`UwxQ9!qdhc&+hl+}o z+`T{iw~^NJF#m)sg=L1(N3KLw3J*OdXh-UHABO5^W=tLjzRo&|fMLd`)>I;qrMh0n z%Lk(^x?ba>D}(1l&|J^LsbYlIV}66hJ1z=&4k`}rG%L9!qUcuEJ1g@5URQV7x7APn z1FPAY@nLXVT$ta5I3Le@!#s2TJA-*Od&OYB2Ab?}yk0qwVm^MTYQEk(zLV6AbAzb} zK4Qo}U8uw)ln+t0{_vQU2{L)}WfvKs_2Jb~sDwNt8|4-pY$3mnI+)E@&<`-0iLO=`zS0Sfg)a*UiFf%QgCr?PtSOQSH6OPGl6jEqy+Vc;!c4e;J-LI;Udt9 zqwh0brh)oMbNdHba%$UBnkrz-yleNRj4HtR`AudutXo&4a*!w3KLw-}4U)&~ob-B0 z$UYPftznd6q8gW~H1fU4fW?1sFe)MhE^=+$n(J?R_3A&Uph(`#-dha5n7O zMn<8?=`%XW+e4lUm|cg$wq)niSGR*#XQB*C0}Q~@&ynzEm=-IzY)A`_`i(o7s19@V z;B-^(;<3t}EY*0Rm6c3RBuB)9H2HHJS<%^6gnKb`WEaNk)j$?8G`>~Vqs6oXfn{zc z&AXUe>Y##D?7%9=QgEqWTki@J%a_RT+`$*~DrVwk@Lw$CL}BW2G4c>N*rRHE?oRvh z5gD<{MclBfM`?a18#0#w&7!y8FfRn$$cG|$_cO0Qw~u;zE@jE{o_r`BB)m>8a!ck? zu&Jx6Cp_n-X4EZy56aaWz|VzkfJgAZNDG}H>?8~^KHg{;?hoNSn4kQJiJaD2y*)I+ zS4WYFqpO{%-kbRPzWp0tWOaMb8cFTJ)+=~=*wj;dLYr4=n<20>-u3c6*l}gYvVXmc za;2(x@?q#tn?l$TcY36oMuMyD9MZ#|>5wGGZL6ROTIZL1 z%FyZ`?I!V9(>l4y?8`#sX70KnQ)zYoQgmwfN&Vq1WJKw5@k^dng&#qlUS@&x7ErO< zc9qU&B&ycSL7ytXW-6-<0XPQNBX`B6&Fn+rV(1JKWr1;FHk>UezvvEc(IImAa2*iU zQeMzeYlU`n7`^&baQ0@aUDcD7S|fr`>$^JR`3xN+TeL^w$umE1`ZsgjOOP_KG19xG zNrKsoPf-mK0`*2cdEY%*`7tOY<#A@W#nx@kTPCY}x43V)yzAc*&(;;#-)rMCqkGO+ z*yo}@oVD2P><75#<~x}Ou&F7(GbPtH#tfQ=qpJnt)i3K&MEjCX@A`SGW~eg)2+$W! z8x>q(oxBpqjoj8~B#a>PMMVSz*4EW%CeLvzzOHTYd1=1pQcvQIJ{QuQ4#~K3H=5Px zWFO@azW$4M0$XEr*X}P1$`tJ-wAqa*FtgQ3gE=O`m(oZ3a0x$ik9OR0%e3LcBK}6Z z4V8LJ7Mk;8%EHz=0UlPjgKyMl>a$uG*S+LecoXL`>W*#FPE_Gsv@;+m2=xo1bx*E#M19GH z+$)zElFsT5&j&Ams3MvM^TUZfa!0rm6(2xQ+rmz_`k&6Ccp}8hQ?6|blpR%izIunf+B&>&UONS6^p=-!H?_lo_M=x*6*%Ol`>1a9fcWQ;U zblbAOlpN2p6#C}0pzc+s(HE^7?^0>u=m#eW^5Ge<#07nEHKPzw&kzSM=Lzx1d2W5* z_DLRgU-qzsAX(E#gsO&rZ7Hs1togmo)myonx|PdjWYbm=q698+2jG6Qqpx_aZy}hp zRJ>Pb6Qgna_@>BaK-5aE2snTV+d+<<6{{3)xA`KP)X~t~f9(b+8RnYeZCbMxt2YD5 z-Vfln^b;7ou2n_5&dtPF+_(`DLB1|z!Cq>vUU(rDP5+uWF*b-;Mwik`pR={5L?Y;Q z(a>kd+i6Ou(7>sXbsztc%e=yK6laJe{nwU`{7QX?hPgz50A;Ldhiw@f+L4S({ICqD zon0d_WgN>gOoQwMaZzp+cly-+A_lJkUiAkcsRL2w3bpYA^1yg&H9|(Ui|s< zVseFi)`5qEd)`fQfmNi1VzDHkHyiA$B2ayg9Usrt!=5t(Rm9iAWtD@N6dhqv*3`52P8*jEMNMOjFvogXQXPx23mOV}IXd9m&^DqMwLQ|OL0 zh=+td#v^!LTvR$rG1@I|qA@Ha2LvKgOqThl5M)G&+W2@U%;{se9eBGY@tMfOYD~m< z>+XURx~3kkWI&rwhqW2A`UWd^EhDPR`rcwS$}joIiP=y&QPD_1o^~nDmz)mm;(nF3_5G(chB8RcdA$d_tFSPD=HBvdhQ2DN1Ix2f357c zU(Di7u0I(RIX-+_A$E9rMs8J$%JM&3 zqWqazH@GSuOge5N#_d5T+V9XZYw@FA&0b->mmqBF5(;B>tR>hwk(kQYcf`J)%I0aA z`o7-ObSr+2c+X|DFmIlwdfvm#`u=UrWBVAn*(C ztWn4_?RH}U-lW&0kCGoeih4?X5e9?-Pj1&{biMBJxd=7p>24T68VsB=I1rnhoy-3k z>RLJ^JJ*aol$d2e8N<3G&tp%Ds$us5EHvQm_<#Hskzy=sE?ORjJrF4;^rpi6=#Id1&CQh4&5-^Ej9Tyi8XU52ziDDLg5 zcSYzph7U$9UZM#E9HbJ8CgSUIFZ5Oya+SR8A=l! z5;BZJ-oIb{;Se`5;)}g#uBQ4JUjN=lTW4B5pD!}T+;&J7h)J%;t))(++1so83>KR# z_)$xZ=MDrmN6U9nFtk z)kZMP-ASKh5$(175nY@p?K#_0tS-x`hOY@z?PGxVWdC5cS6^~#h%wJI18BMR+fxNq z)o^2x1pe7C5t_gT5DiP8L<#DT_WPJQIPqRlck9o-`5S)Hr&H51d{x+gzh zp!o0Y{)cn(uM_{Qd_*n{(*d%_3hQ+Y!3Am*?IGGDPp&ryrscBMn-LR<=s;wVn zXvG}#(>hphO|h!AP_U}RzcXJLnOpg&3>R$c47@$Rp|vbRXmtLQpu&HBit(toV1K?a z?x{m|KnyhR-dn3MN;&dOx~u2lw1J0FvZpdUU()5OEMx`w-y*V$>SK2z$^yP$gh;p= z25|qDQ=-`Rhio98Nei1k@Q@xIh9Ev@xK{j%XDHWBlA+1-J!U~joP%@D{FHS0^Qqc* z)h(PS-S6<4VX1ZIm9Y6Tp=Ordbe&a0JA&I+thYig{xkMCm)ZtA>l=0j}6EKRX;Uyp&!z02$T zB?@;a5Qkr^rj9kmXoH+#3xl zI<)7uDGN#IIW3l~v)!DIz>uxtIKLU>q12SK4z}D%27O}0A)|@?5{~}-rVwVcrj!z( z_@hz(1Hj|fZ|K?K1zoH+^}lHj>4uHu!^8w)6IsnxX{HHc6`H5?+GSl7e2V9a5}b3J z7Jb{xp|nz&>)sPDYx@>TVkiStto2GkAi}#>{|g9ATDc7Ya-v95HtRefaBztZV8s1=+kQ!(&hK@aF%+g{FCJ>pp%GtZ%>+dMLLc}Y zC*}Sp9fnUzsTKY=nWd)Xk;D&#gY<#L4&x^Mc;%+lF;#v=#^~pmYg0yG$E;s9gTbFO z%|wy-A|=#ini<^vGz3NRs>A`1RuXg%{YOj|9G~c0#OC?FiyEVb zm_*~OW#t4exV~dU-M$4pd`RhBkFu=~A;gp}4cR{x{j*loAaw5U1UG8?H^G%`E`8a( zPq|)U*m7xp3W*{%2Hsz201>n|{zaOsYcEVr)1nheKY!qkcS$mEP zMSqXKZkfVtJj>8F+b<#$jAVtm_^WvvG?pVwvRO5vUknJv3y-<}`jyYBz-VA=cq!Il z!hgL-IA7F&R8S~^+^vzaw5Hmx&@Cvy>~#j>6af%WHi=C9n=ur#SK;T+l%oMEQ(v$5 z=aM#!w~bE~Hw`i^Sk8-oYPAMP=w1>DD?DfR4?jOb&DRUy+ZF?_ECFW*+Km63&hvL| zXFNWcUb<)k5_ZF_wyd_M#N0QwXdY?YF-Jj}LK(;0qY2-F5(l(Duu_j2C-)=VBb?)c zhAa2OI#!mI*$$Y3VQ^5@`%r1w3Du8T8N3qW@ZKog*)4$ZR}#11JQC~ zV_7VAE8HMZaw|V=d+aBHB2ulb9OAeC=6r2=xNM|n3q?C9U! zmRHazW(=aDDuj4NP?cOUIbq_v>g(1vi;jXD1@4gp)Q_A2KyL$ySkAq0)3D$A_IG}T z$TLRpa@$+FdKlvo#dCkAS|MNiXKuywd(Uue?{33nbSO#xsLJqzxfeV;i!<`ZK={ zSozZOM{8WJ>(jfDHi)@zhRi){0`C0(poJ?{FF-3ftpFW}q^O=`!#^qSAp@{K)gFLs zBV?LA)~cx%%9n*(9GAIim{UmxwMj75eSYd5rwHJ)ZOY;wMT{NCgGJ*)yen0WMuo?f zf6P0jo-_vEd+A`ke@Yls-0z#|8V%?^ZDeUb3F@^nKd!YX+`QN=v x{Fin9f3gnt61cUOmn+NxvA_?4D_?m7RA6JE9`a-PEsz6JRnko{3Op~}*KOEoCTog!{7~<)X`B~k~ zdn1djQla+414N$nNVV%YUqpUbC&ti|u3_HJuo=OPa33H_yJOS+)?){{0}xj$goW@K z(s-23!1`=)?5m?mi@%L~wm5i+d@#G(`h60e;3zO`#zc*=dRpA1;(2ai;ItgRqY3Kk!b{1hM0AAy~mc!S)^fhs;OF8>KaRSTc z|BkpzK6TpZCc6;x2plYUHotr8Cs^J~mZD|nf!Z^4r+LOhIe(%Ze_+mskYDU{ly&={ z^J?=Bj%@ZuFX<^#Ow~nC4kmJoWJWIK=OlG`+1{l%_)~cBOn$%&RaW-w*v4mD)>Zjp(#pmJQl@hT629@mwkM(Lgx(K zwls$&?pv5Tupp6J%x4tfL+b-vxR&FgR9tPc-PR z_xwp?yD9v9w~eNEWFS14iv3XeX7Pu1)$bb-loZdVueedhH{E7y!jtQccf$}v5l2wP zxjMF21)q3u1V6C!*>@Zvn5OTx4l!VefY&nZE=*cAizOL%q1j&*m`^WASSD+PW4G>{ zZ~9>)LJ^HW=)RC&095jfBo2FwXFE&OqN7)O3E$T^x4itmF&gEEm!;=FHjQ=1x0zN= z3nuyPQv2>aXJzZXE{{OK_k1_30R(6!DR*w)9V_WQRO-F)_x1|qkaD=Xnm%75>ty!_ zm~9{?;8+WBp254bz*rI}4H3@!^1{yyE5}-Vt?(rUMUM0BR$B~p<~~Py!o*x;;nBP} z;Ku2y-_pD*B5iTy+o4@C9%SFf=Zh#+KV@Zc;maIxc$8! z1}?RaH*^TTI$ktelFlbO11tcqN+LAmL$f7bvGqPM+^{w}5v$`r@5?%so3_rQ)1d<; z;g0xfp3gh39&=H)DrJ9_h*Uz^gn3K%wE*8xSuOw8&vW#!-lHh~(Y z2F{9^cYtzN!##1~?#Qrxo{P!a6KSZ8e9y(m5czu3F*v4aq+o`C!@GG;ii7i%akY?>m zVfm9IJ;eF$!fodb@FT2?xEtTa=jntK9wDvkI`hOg=TY6xv0O~k0SS$dXPp05)bjcU zhnk>!7&!GuwH?i?qHA$rYAtQ$G3n*~un%DIR_}-{6jAjbgvT@-~!ZNT1ZBg_5*Ip0>o)&N%%3o#lW)|ouhN%_&#>nah_k+dq|jtBXTL9> zmfKM9Ks*HR4AS9{8{0iU%{dMvWgm=!y-Y7qJa{`wNmi`~BZuV5{>Mu8zvYj;!FBn| z-&UxaS^m!?@c)-?{yz%|eKb3aKi@qe#{L!E2c@vb+}!d|OD4iZ;Od#%!2~0T7)W|h z+eWE^y*~p!SrwFqsBJ$GlynaW=q2p++WRbC-fO4Hly)_%3;1B+iAHK=+r;XK-lB#N z0AB&{hIZK1eQ*Rc?9G0SUIWs5ud0SRo!vIWXMe$nL%inu1{B622`&@PoMRg4t($oU zxK8o0^(1U#I8)>NVEu?hN|Gk!O`Z$V%oAe4x#&(yKVJOv%Q)p+^t&HnO-Bn7OtR2;AnLR;R>5LE`K&NyQvM(xkH;ZG-g zHo$@zaO~%VmoY3Ak-0CX@g@Q8_<&>}D8qn0)XJb#j7r*Nt8am70?PZF+7ke$V6kfaM_Z?*PXtqCF zqIaStm2u}Lt2EH)}I8E2GH5=h2fk+qTjr?o6h)2xnm^1WEIw3 zLoU!zck@g^X)q%#1vMm%5W>s(UmF~N$e(pP@1qsgT2O+L4)8tw-0!ev!&+2Cm>gDb zF5%G~cmvu)yU%YuC>I(5n%crkpvUkmr6zue|^! znNj3OyKbpd&mu?^VI`WLTJUMKH6?jeVyI(D& zz4%YZ2U9t!)&qufQW+7Tn@i43q{A9&Jy2($p-n((x0yu^=dW9MWv5nFO0D#&(+tRb zYBR%SwLkDY&f!bTHZ=aOC=6e5)`hK_r8jlH%`v{Kj7gxKd2sjjFCNl}xt+2cMxf^> zYi7S|f)u`+$tNXfiMa3ln6(ZN*voKsZ5(BD7Z;=_3I+6~No>>-rUQY^JV#OB*oxWo zS_>n&ud=baJgpo+ZzL-Mzf;X&AM3j`G_QIPRn=`Iy>V2--I((!^)$qt5w6BOuUWKV z6wUs!#_5#zB6*6!`BTR4Sl~$=q0*-vCtGx;DW``d#X#S^9bxLN*Xt?GLs9`UA+(@P z>RlH2CFMbU5q{|c_{t}fMk3aTT1#deeb)J5 zIIeEa??L|3oZOpvB7Qr9Jnz_Z=n$#{|5r20W z{q4_~Y33~V(*Qt!aKM?w)8w6rF%Qd?2?sU8!EA%YX^3jAuls#@n^J8Koc=18pj;yD zJk8?_>59Fmjdg~!qzv&aYxl%bEG6&GRx?-EWihs-Hj7Lj4|MO8-YBjpCVs3_mlMT7 zcQs1838_-TPVTr&2GK1-H?=3>kPC@tpIp76r}z0JH$?+3&>KaHjY#5oXK^&nJwby5 zYyl6-Y|rcJW9r$-625tmg%51rvIuOd;gKTL(F7P5u;209TnGr10IuriXZWsT@hDps z2KdnyOSX~MNbX3lAB}kYjJCw2+DFU-URAKg#`*y3tyly9LYs+@d_>hWSp;0TITeOk zt*ojk!E)i;kG+byI}KA`dzzUQvUfY4PGkn!$0oI?t&s=*`gS#zuSQTK0kV0Tik7P7 zF-?1<_;e-@NwB$ksZ(SWi-Y=%qLE_YLF``Qd&)^Fx21m5N6ZXPeh*sW;nFTG=-XT2*I9~JJGF98 z({z8$&I&d)5^U|7wIxhPiVf#}NL#{bYXsUCnrg}kLnl6`4Y&1l2aS`lM~^Nr*HiBq z_OQrfyksGkiaj768RwXr;53-gmgVp%27tX~mQQRW@9lmKO_-X!f4GSX&wPB=rXsP< z0?}Ef>5OWANGyF!LE*BF7F3fa4c{H~jL0RiX|xmPm}q%TQqggL*I{#qV9L=My?`|I z(aCLfw7BqQlvMQ`Y)2`u?rRlqa@oya;Z*vUzS;TR`)Ctfa*@K!hFlvBx!_^eAnfk~ z?&Cb||7-jBAIqS;bu*9JruPR1z}sz=<*&#cI2m1#uwU}bHWdF;sc7rUth=v+$9)lk zxBZMplg7RIAZLd{G1qlk*4n)XHTwZ z0Kf5Fg#4sZ>^#(05T1~!{Sw9h#dX&VP`*d;Q;_nSf=%B^iLEZ*ksx2kCN4K8*LMcA zKLZJHuyZQW?=Awblot_`yT!(SH$(x8@2l!O>)QM`3)ue>H@0Qrpkm~6YI2_HW;Jf_ zI6s|zoOJc>a$wGky?>^_n0-i0f*MRMoR?$QmuU`->-1JH%Qq;E_+&+s2ife;^Lm-aLdlhvWc;OnJ_aZi!c@cCq=y$zKAu zUJ8y3;tkhb_x-e`Q7=^I)HEQ4^)lc5mB@rga#@~IwuEi+sby*=IT!wz*=A5`pzdbs z?iH|d>w>4*9<#Xm6ga*~-zw#|t~JXqKT~>_FsJHr(?ma`!IE2z=rB@z51jY#Prs*8 zbb_Xih^KpbGh5Hf_4EYKvU^@r%ORJ1Ivv%^6cfJ*bYs5p^{KAgEF02}2FJ~CG`i&X z0TR0zR;((~s*~l{@pVs!nkJ*~*5+-j$sZ|7=n77GZq6?FuI}`YSM=n;ON&z*%#&^) zB+s=jGgL+gh5sz9+S~L>BOv>al2(o#9USKDJ850roSmQk^0D(e?pm5=qFeIX zy|^bI<3vA-=h&_BixeMOsXacDSKvB=!D+Q>J=M#grFHGcpQZ3ck&esNo<+J)ZhcLw zVab})iC;K|=|`p8zoYKIkgzl5Lg$Jwzwh^mRG1qt?28oa4RetHJ`bC1oIgo&k&VI$ zz+9#z7ELIL{gJ4*=?)PNK<&Uu6|UVDnEf&z^-wkVpBZPIh1rh&12*zDfw>azGE=FM~Jjg7*T6_^v$^SXx_L4=F3 zQb^JHRRU01ulNb!r<<=SduY;+TuQU*n-A0k2lr31C9+bamwVg;9)d03jPZ?&lp^)9 zjjBeylgx`>bF5@;g-#mGSXaiOgsr(d5x0jUK8!HxZH?H`R9yod%{m4c zSC2#B(5GFazTuP6V-1h{Umq+LHe?!zfr^~;a^GWLzIZEGRA%~vIq6~C_4h0HLXcq{Y^`3ad6jPQ^1?lSY?k`

eKh5dM<;jcMnXn)+$uNuAnBtVVi{#RW7cY( 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

Terminal - lecture 3: the command line
$ pwd
+~/demo
+$ ls
+$ mkdir my_project
+$ cd my_project && touch hello.py && ls
+hello.py
+$ rm -rf my_project && ls
\ No newline at end of file diff --git a/docs/images/sources/lecture-04.html b/docs/images/sources/lecture-04.html new file mode 100644 index 0000000..4df7a9c --- /dev/null +++ b/docs/images/sources/lecture-04.html @@ -0,0 +1,18 @@ +
Terminal - lecture 4: __name__ and __main__
$ python -m flask_day_54.domain.name_demo
+Running as the main program: the file was executed directly
+$ python -c "import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))"
+Running as an imported module named flask_day_54.domain.name_demo
+$ python -m flask_day_54.presentation.hello
+ * Serving Flask app 'hello'
+ * Debug mode: on
+WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
+ * Running on http://127.0.0.1:5000
+Press CTRL+C to quit
+ * Restarting with stat
+ * Debugger is active!
+^C
\ No newline at end of file diff --git a/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md b/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md new file mode 100644 index 0000000..ffae7a6 --- /dev/null +++ b/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md @@ -0,0 +1,61 @@ +# Review of MIL-003 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-017 | +| CrossReference | [MIL-003], [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-003] +- Checklist used: [QC-PY-001] +- Scope: full review of the source and tests delivered by MIL-003 (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 | N-A | Console output of the demo entry points (print under the __main__ guard) is the program's result, not diagnostic logging; 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-003]: ../../milestones/mil-003-cli-and-main.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md -- 2.54.0 From e389b2f8e6db6e36ab3fc2587284f696f648d8b4 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Fri, 6 Nov 2026 12:39:45 +0800 Subject: [PATCH 3/3] Resolve pending commit links --- docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md b/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md index ffae7a6..133ec1b 100644 --- a/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.md +++ b/docs/sqa/reviews/rc-017-code-mil-003-cli-and-main.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 | [f8a1794] | --- @@ -59,3 +59,4 @@ Go — All applicable mandatory criteria pass. [MIL-003]: ../../milestones/mil-003-cli-and-main.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[f8a1794]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/054-flask/commit/f8a1794373b21d718d756215eae34c67e873fbd2 -- 2.54.0