MIL-003: command line and __main__ #34

Merged
Tirsvad merged 3 commits from mil-003-cli-and-main into main 2026-11-06 05:46:53 +01:00
8 changed files with 329 additions and 215 deletions
Showing only changes of commit f8a1794373 - Show all commits

No files matched your search

+8
View File
@@ -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 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 ## 📋 Requirements
- Python 3.13 or newer, because the project sets `requires-python = ">=3.13"` in `pyproject.toml`. - Python 3.13 or newer, because the project sets `requires-python = ">=3.13"` in `pyproject.toml`.
+1 -1
View File
@@ -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 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.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 ## Languages
+229 -214
View File
@@ -1,214 +1,229 @@
"""Create the lecture screenshots in docs/images/ without touching the desktop. """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 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. keyboard). Terminal screenshots render the real output of real commands.
Usage (from the repository root, with the project's .venv active): Usage (from the repository root, with the project's .venv active):
python docs/images/capture.py 1 2 3 4 5 6 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 # 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. # built from constants, sys.executable and local paths; it reads no external input.
import html import html
import os import os
import shutil import re
import subprocess import shutil
import sys import subprocess
import tempfile import sys
import time import tempfile
import urllib.request import time
from pathlib import Path import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "docs" / "images" ROOT = Path(__file__).resolve().parents[2]
SOURCES = OUT / "sources" OUT = ROOT / "docs" / "images"
BROWSERS = [ SOURCES = OUT / "sources"
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", BROWSERS = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
"microsoft-edge", r"C:\Program Files\Google\Chrome\Application\chrome.exe",
"google-chrome", "microsoft-edge",
"chromium", "google-chrome",
] "chromium",
PORT = 5057 ]
APP = "flask_day_54.presentation.hello" PORT = 5057
APP = "flask_day_54.presentation.hello"
TERMINAL_CSS = """
body{margin:0;background:#1e1e1e;font-family:Consolas,'DejaVu Sans Mono',monospace;color:#d4d4d4} TERMINAL_CSS = """
.bar{background:#333;color:#ccc;padding:8px 14px;font-size:14px} body{margin:0;background:#1e1e1e;font-family:Consolas,'DejaVu Sans Mono',monospace;color:#d4d4d4}
pre{margin:0;padding:16px 18px;font-size:15px;line-height:1.45;white-space:pre-wrap} .bar{background:#333;color:#ccc;padding:8px 14px;font-size:14px}
.p{color:#6a9955}.c{color:#9cdcfe} 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} BROWSER_CSS = """
.tab{background:#dde3ea;padding:8px 14px;font-size:13px;color:#333} body{margin:0;background:#f3f3f3;font-family:Segoe UI,Arial,sans-serif}
.url{background:#fff;margin:8px;padding:6px 12px;border:1px solid #bbb;border-radius:14px;font-size:14px} .tab{background:#dde3ea;padding:8px 14px;font-size:13px;color:#333}
.page{background:#fff;height:150px;padding:16px;font-size:16px;font-family:Times New Roman,serif} .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: def find_browser() -> str:
found = shutil.which(candidate) or (candidate if os.path.exists(candidate) else None) for candidate in BROWSERS:
if found: found = shutil.which(candidate) or (candidate if os.path.exists(candidate) else None)
return found if found:
raise SystemExit("No Edge or Chrome found for headless screenshots") 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.""" def shot(page: Path, png: Path, width: int, height: int) -> None:
target = page.as_uri() if isinstance(page, Path) else page """Take a headless screenshot of a local page or a URL."""
profile = tempfile.mkdtemp(prefix="shot-") target = page.as_uri() if isinstance(page, Path) else page
subprocess.run( # nosemgrep profile = tempfile.mkdtemp(prefix="shot-")
[ subprocess.run( # nosemgrep
find_browser(), [
"--headless=new", find_browser(),
"--disable-gpu", "--headless=new",
"--hide-scrollbars", "--disable-gpu",
f"--user-data-dir={profile}", "--hide-scrollbars",
f"--window-size={width},{height}", f"--user-data-dir={profile}",
f"--screenshot={png}", f"--window-size={width},{height}",
"--virtual-time-budget=3000", f"--screenshot={png}",
target, "--virtual-time-budget=3000",
], target,
check=True, ],
capture_output=True, check=True,
timeout=60, capture_output=True,
) timeout=60,
shutil.rmtree(profile, ignore_errors=True) )
print("wrote", png.relative_to(ROOT)) 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 def run(cmd: list[str], cwd: Path, timeout: int = 30) -> str:
return (done.stdout + done.stderr).rstrip("\n") 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.""" def terminal_page(title: str, entries: list[tuple[str, str]], png: Path, height: int) -> None:
lines = [] """Render (command, output) pairs as a terminal and take the screenshot."""
for command, output in entries: lines = []
lines.append(f'<span class="p">$</span> <span class="c">{html.escape(command)}</span>') for command, output in entries:
if output: lines.append(f'<span class="p">$</span> <span class="c">{html.escape(command)}</span>')
lines.append(html.escape(output)) if output:
page = ( lines.append(html.escape(output))
f"<!doctype html><meta charset='utf-8'><style>{TERMINAL_CSS}</style>" page = (
f"<div class='bar'>{html.escape(title)}</div><pre>" + "\n".join(lines) + "</pre>" f"<!doctype html><meta charset='utf-8'><style>{TERMINAL_CSS}</style>"
) f"<div class='bar'>{html.escape(title)}</div><pre>" + "\n".join(lines) + "</pre>"
SOURCES.mkdir(exist_ok=True) )
src = SOURCES / (png.stem + ".html") SOURCES.mkdir(exist_ok=True)
src.write_text(page, encoding="utf-8") src = SOURCES / (png.stem + ".html")
shot(src, png, 900, height) src.write_text(page, encoding="utf-8")
shot(src, png, 900, height)
def python() -> str:
return sys.executable def stop(proc: subprocess.Popen) -> None:
"""Stop a server and its reloader child."""
if os.name == "nt":
def step1() -> None: subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True) # nosemgrep
shot(SOURCES / "lecture-01.html", OUT / "lecture-01.png", 900, 400) else:
proc.kill()
proc.wait(timeout=10)
def start_server() -> subprocess.Popen:
proc = subprocess.Popen( # nosemgrep
[python(), "-m", "flask", "--app", APP, "run", "--port", str(PORT)], def python() -> str:
cwd=ROOT, return sys.executable
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True, def step1() -> None:
) shot(SOURCES / "lecture-01.html", OUT / "lecture-01.png", 900, 400)
for _ in range(40):
try:
urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).read() def start_server() -> subprocess.Popen:
return proc proc = subprocess.Popen( # nosemgrep
except OSError: [python(), "-m", "flask", "--app", APP, "run", "--port", str(PORT)],
time.sleep(0.25) # nosemgrep cwd=ROOT,
proc.kill() stdout=subprocess.PIPE,
raise SystemExit("server did not start") stderr=subprocess.STDOUT,
text=True,
)
def step2() -> None: for _ in range(40):
proc = start_server() try:
try: urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1).read()
body = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=3).read().decode() return proc
page = ( except OSError:
f"<!doctype html><meta charset='utf-8'><style>{BROWSER_CSS}</style>" time.sleep(0.25) # nosemgrep
f"<div class='tab'>127.0.0.1:5057</div>" proc.kill()
f"<div class='url'>http://127.0.0.1:{PORT}/</div>" raise SystemExit("server did not start")
f"<div class='page'>{body}</div>"
)
src = SOURCES / "lecture-02.html" def step2() -> None:
src.write_text(page, encoding="utf-8") proc = start_server()
shot(src, OUT / "lecture-02.png", 700, 230) try:
finally: body = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=3).read().decode()
proc.kill() page = (
f"<!doctype html><meta charset='utf-8'><style>{BROWSER_CSS}</style>"
f"<div class='tab'>127.0.0.1:5057</div>"
def step3() -> None: f"<div class='url'>http://127.0.0.1:{PORT}/</div>"
work = Path(tempfile.mkdtemp(prefix="cli-demo-")) f"<div class='page'>{body}</div>"
bash = shutil.which("bash") or "bash" )
entries = [] src = SOURCES / "lecture-02.html"
script = [ src.write_text(page, encoding="utf-8")
"pwd", shot(src, OUT / "lecture-02.png", 700, 230)
"ls", finally:
"mkdir my_project", stop(proc)
"cd my_project && touch hello.py && ls",
"cd .. && rm -rf my_project && ls",
] def step3() -> None:
for command in script: work = Path(tempfile.mkdtemp(prefix="cli-demo-"))
output = run([bash, "-c", command], work) bash = shutil.which("bash") or "bash"
entries.append((command, output.replace(str(work).replace("\\", "/"), "~/demo"))) entries = []
shutil.rmtree(work, ignore_errors=True) script = [
terminal_page("Terminal - lecture 3: the command line", entries, OUT / "lecture-03.png", 330) "pwd",
"ls",
"mkdir my_project",
def step4() -> None: "cd my_project && touch hello.py && ls",
entries = [] "rm -rf my_project && ls",
cmd = "python -m flask_day_54.domain.name_demo" ]
entries.append((cmd, run([python(), "-m", "flask_day_54.domain.name_demo"], ROOT))) for command in script:
imp = "python -c \"import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))\"" output = run([bash, "-c", command], work)
entries.append( entries.append((command, re.sub(r"/tmp/cli-demo-\w+", "~/demo", output)))
( shutil.rmtree(work, ignore_errors=True)
imp, terminal_page("Terminal - lecture 3: the command line", entries, OUT / "lecture-03.png", 330)
run(
[
python(), def step4() -> None:
"-c", entries = []
"import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))", cmd = "python -m flask_day_54.domain.name_demo"
], entries.append((cmd, run([python(), "-m", "flask_day_54.domain.name_demo"], ROOT)))
ROOT, imp = "python -c \"import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))\""
), entries.append(
) (
) imp,
server = subprocess.Popen( # nosemgrep run(
[python(), "-m", "flask_day_54.presentation.hello"], [
cwd=ROOT, python(),
stdout=subprocess.PIPE, "-c",
stderr=subprocess.STDOUT, "import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))",
text=True, ],
) ROOT,
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")) server = subprocess.Popen( # nosemgrep
terminal_page("Terminal - lecture 4: __name__ and __main__", entries, OUT / "lecture-04.png", 560) [python(), "-m", "flask_day_54.presentation.hello"],
cwd=ROOT,
stdout=subprocess.PIPE,
def step5() -> None: stderr=subprocess.STDOUT,
module = "flask_day_54.domain.first_class_functions" text=True,
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) time.sleep(3) # nosemgrep
stop(server)
raw = (server.stdout.read() if server.stdout else "").strip()
def step6() -> None: # Leave out the .env tip and the random debugger PIN; they say nothing about the lecture.
module = "flask_day_54.domain.decorators" kept = [
entries = [(f"python -m {module}", run([python(), "-m", module], ROOT))] line for line in raw.splitlines() if "Tip:" not in line and "Debugger PIN" not in line
terminal_page("Terminal - lecture 6: decorators and the @ syntax", entries, OUT / "lecture-06.png", 380) ]
out = "\n".join(kept)
entries.append(("python -m flask_day_54.presentation.hello", out + "\n^C"))
STEPS = {1: step1, 2: step2, 3: step3, 4: step4, 5: step5, 6: step6} terminal_page("Terminal - lecture 4: __name__ and __main__", entries, OUT / "lecture-04.png", 560)
if __name__ == "__main__":
os.environ["PYTHONPATH"] = str(ROOT / "src") def step5() -> None:
for number in [int(a) for a in sys.argv[1:]] or STEPS: module = "flask_day_54.domain.first_class_functions"
STEPS[number]() 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]()
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+12
View File
@@ -0,0 +1,12 @@
<!doctype html><meta charset='utf-8'><style>
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}
</style><div class='bar'>Terminal - lecture 3: the command line</div><pre><span class="p">$</span> <span class="c">pwd</span>
~/demo
<span class="p">$</span> <span class="c">ls</span>
<span class="p">$</span> <span class="c">mkdir my_project</span>
<span class="p">$</span> <span class="c">cd my_project &amp;&amp; touch hello.py &amp;&amp; ls</span>
hello.py
<span class="p">$</span> <span class="c">rm -rf my_project &amp;&amp; ls</span></pre>
+18
View File
@@ -0,0 +1,18 @@
<!doctype html><meta charset='utf-8'><style>
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}
</style><div class='bar'>Terminal - lecture 4: __name__ and __main__</div><pre><span class="p">$</span> <span class="c">python -m flask_day_54.domain.name_demo</span>
Running as the main program: the file was executed directly
<span class="p">$</span> <span class="c">python -c &quot;import flask_day_54.domain.name_demo as m; print(m.describe_name(m.__name__))&quot;</span>
Running as an imported module named flask_day_54.domain.name_demo
<span class="p">$</span> <span class="c">python -m flask_day_54.presentation.hello</span>
* Serving Flask app &#x27;hello&#x27;
* 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</pre>
@@ -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