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
15 changed files with 559 additions and 217 deletions

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,62 @@
# 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 | [f8a1794] |
---
## 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
[f8a1794]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/054-flask/commit/f8a1794373b21d718d756215eae34c67e873fbd2
+66
View File
@@ -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()
+32
View File
@@ -6,3 +6,35 @@ from typing import Final
## @brief The greeting shown by the first Flask route. ## @brief The greeting shown by the first Flask route.
GREETING: Final[str] = "Hello, World!" 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,
),
)
+29
View File
@@ -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()
+13 -2
View File
@@ -1,10 +1,11 @@
## @file hello.py ## @file hello.py
# @brief 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 and 2.""" """The minimal Flask application of lectures 1 to 4."""
from flask import Flask from flask import Flask
from flask_day_54.application.get_greeting import get_greeting 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. ## @brief The Flask application object.
app = Flask(__name__) app = Flask(__name__)
@@ -16,3 +17,13 @@ app = Flask(__name__)
def hello_world() -> str: def hello_world() -> str:
"""Answer the root route with the greeting.""" """Answer the root route with the greeting."""
return get_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()
+33
View File
@@ -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 == []
+39
View File
@@ -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
+17
View File
@@ -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 "