MIL-002 Lecture steps: URL paths, HTML, decorators, authentication #24

Merged
Tirsvad merged 3 commits from mil-002-lecture-steps into main 2026-11-06 05:47:50 +01:00
12 changed files with 486 additions and 1 deletions

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 005 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 009 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 |
## Languages
+62
View File
@@ -0,0 +1,62 @@
# RC-009 Code review of MIL-002
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-009 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-11-06 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [6562eae] |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-PY-001]
- Scope: Code of MIL-002: `src/higher_lower/presentation/lectures/` and `tests/unit/test_step_0*.py`
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `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 and functions use snake_case, classes PascalCase, constants UPPER_SNAKE |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`compare_guess`, `GuessResult`, `HOME_HEADING`); no unexplained abbreviations |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` report no finding; no suppressions |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` reports no issue |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; no exception is swallowed |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is 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 | Public modules, classes and functions have Doxygen-style docstrings; the python-developer agent reviewed them |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print`; no secret in the code; `.env` is neither read nor imported |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No DCD exists: the milestone tasks are plain technical tasks (see PP-001 Open Issues) |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | pytest tests are named for behaviour, run in any order and use no network |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes; no `Any` |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Flask (runtime) and pytest, ruff, mypy (extras); none unused |
| 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 | Headings, colours, GIFs and range are defined once in `constants.py` |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | Lecture modules (presentation) import only `constants`, Flask and markupsafe; nothing imports the lectures; no cycle |
| 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 | Each module has one reason to change; behaviour is added by extension |
| 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 | Lecture modules sit in `presentation/lectures/`; tests in `tests/unit` |
| 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 | File and folder names are English with letters, digits and `_` only |
## Overall Verdict
Go — All criteria pass or are not applicable; no blocking finding.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | 2026-11-06 |
---
[MIL-002]: ../../docs/milestones/mil-002-lecture-steps.md
[QC-PY-001]: ../../framework/qc/code/qc-programming-python.md
[6562eae]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/055-higher-lower/commit/6562eae9efad20fcadbb4a92f7b3a2d5fdb21c90
+5
View File
@@ -43,3 +43,8 @@ CORRECT_GIF: str = "https://media.giphy.com/media/4T7e4DmcrP9du/giphy.gif"
HOST: str = "127.0.0.1"
## Port the development server listens on.
PORT: int = 5000
## Still picture used by the HTML rendering lecture step.
STILL_IMAGE: str = "https://media.giphy.com/media/3o7aCSPqXE5C6T8tBC/giphy_s.gif"
## Width in pixels of the pictures in the HTML rendering lecture step.
LECTURE_IMAGE_WIDTH: int = 200
@@ -0,0 +1,5 @@
"""Small Flask programs of the Day 55 lectures.
@brief One module per lecture step, each runnable on its own with
`python -m higher_lower.presentation.lectures.<module>`.
"""
@@ -0,0 +1,61 @@
"""Lecture step 2: URL paths, variable sections and the Flask debugger.
@brief Routes that read the URL path, including a variable section and a URL
converter.
"""
from flask import Blueprint, Flask
from markupsafe import escape
from higher_lower.constants import HOST, PORT
## Blueprint that holds the views of this lecture step.
_blueprint = Blueprint("step_02_url_paths", __name__)
@_blueprint.route("/")
def home() -> str:
"""Answer the home route."""
return "Hello, World!"
@_blueprint.route("/bye")
def say_bye() -> str:
"""Answer the fixed route /bye."""
return "Bye"
@_blueprint.route("/username/<name>")
def greet(name: str) -> str:
"""Greet the user whose name is the variable section of the URL.
@param name The text found in the URL after /username/.
@return The greeting, with the name escaped so it cannot inject HTML.
"""
return f"Hello there {escape(name)}!"
@_blueprint.route("/username/<name>/<int:number>")
def greet_with_number(name: str, number: int) -> str:
"""Greet the user and use the converter <int:number> for a number.
@param name The text found in the URL after /username/.
@param number The integer found after the name; other text gives 404.
@return The greeting that repeats the name and the number.
"""
return f"Hello there {escape(name)}, you are {number} years old!"
def create_app() -> Flask:
"""Build the Flask application of this lecture step.
@return The configured Flask application.
"""
app = Flask(__name__)
app.register_blueprint(_blueprint)
return app
if __name__ == "__main__":
# Debug mode reloads the server on every code change and shows the debugger.
create_app().run(host=HOST, port=PORT, debug=True)
@@ -0,0 +1,46 @@
"""Lecture step 3: rendering HTML elements with Flask.
@brief A route that returns a heading, a paragraph and two images as HTML.
"""
from flask import Blueprint, Flask
from higher_lower.constants import HOST, LECTURE_IMAGE_WIDTH, PORT, STILL_IMAGE
## Animated version of the still picture.
ANIMATED_IMAGE: str = STILL_IMAGE.replace("_s.gif", ".gif")
## Blueprint that holds the views of this lecture step.
_blueprint = Blueprint("step_03_html", __name__)
@_blueprint.route("/")
def home() -> str:
"""Return structured HTML instead of a plain string.
The Python string uses double quotes and the HTML attributes use single
quotes, so the two never end each other.
@return A centred heading, a paragraph, a still picture and a GIF.
"""
return (
"<h1 style='text-align: center'>Hello, World!</h1>"
"<p>This is a paragraph.</p>"
f"<img src='{STILL_IMAGE}' width={LECTURE_IMAGE_WIDTH}>"
f"<img src='{ANIMATED_IMAGE}' width={LECTURE_IMAGE_WIDTH}>"
)
def create_app() -> Flask:
"""Build the Flask application of this lecture step.
@return The configured Flask application.
"""
app = Flask(__name__)
app.register_blueprint(_blueprint)
return app
if __name__ == "__main__":
create_app().run(host=HOST, port=PORT, debug=True)
@@ -0,0 +1,87 @@
"""Lecture step 4: decorators that wrap text in HTML tags.
@brief The make_bold, make_emphasis and make_underlined decorators, used on
the /bye route.
"""
from collections.abc import Callable
from functools import wraps
from flask import Blueprint, Flask
from higher_lower.constants import HOST, PORT
def _wrap_in_tag(function: Callable[..., str], tag: str) -> Callable[..., str]:
"""Wrap the text a function returns in an HTML tag.
@param function The function that returns text.
@param tag The tag name, for example b.
@return The decorated function.
"""
@wraps(function)
def wrapper(*args: object, **kwargs: object) -> str:
return f"<{tag}>{function(*args, **kwargs)}</{tag}>"
return wrapper
def make_bold(function: Callable[..., str]) -> Callable[..., str]:
"""Wrap the text returned by a function in <b> tags.
@param function The function that returns text.
@return The decorated function.
"""
return _wrap_in_tag(function, "b")
def make_emphasis(function: Callable[..., str]) -> Callable[..., str]:
"""Wrap the text returned by a function in <em> tags.
@param function The function that returns text.
@return The decorated function.
"""
return _wrap_in_tag(function, "em")
def make_underlined(function: Callable[..., str]) -> Callable[..., str]:
"""Wrap the text returned by a function in <u> tags.
@param function The function that returns text.
@return The decorated function.
"""
return _wrap_in_tag(function, "u")
## Blueprint that holds the views of this lecture step.
_blueprint = Blueprint("step_04_decorators", __name__)
@_blueprint.route("/")
def home() -> str:
"""Answer the home route."""
return "Hello, World!"
@_blueprint.route("/bye")
@make_bold
@make_emphasis
@make_underlined
def say_bye() -> str:
"""Answer /bye with bold, emphasised and underlined text."""
return "Bye"
def create_app() -> Flask:
"""Build the Flask application of this lecture step.
@return The configured Flask application.
"""
app = Flask(__name__)
app.register_blueprint(_blueprint)
return app
if __name__ == "__main__":
create_app().run(host=HOST, port=PORT, debug=True)
@@ -0,0 +1,91 @@
"""Lecture step 5: advanced decorators with *args and **kwargs.
@brief The User class and the is_authenticated decorator, which runs a function
only for a logged-in user.
"""
from collections.abc import Callable
from functools import wraps
from flask import Blueprint, Flask
from markupsafe import escape
from higher_lower.constants import HOST, PORT
class User:
"""A user with a name and a logged-in flag."""
def __init__(self, name: str) -> None:
"""Create a user who is not logged in.
@param name The user name.
"""
self.name = name
self.is_logged_in = False
def is_authenticated(function: Callable[..., str]) -> Callable[..., str | None]:
"""Run the function only when its user is logged in.
The wrapper accepts any positional and keyword arguments (*args and
**kwargs) and forwards them. The user is the first positional argument, or
the keyword argument named user.
@param function The function to protect.
@return The decorated function; it returns None for a user who is not
logged in.
"""
@wraps(function)
def wrapper(*args: object, **kwargs: object) -> str | None:
user = args[0] if args else kwargs.get("user")
if isinstance(user, User) and user.is_logged_in:
return function(*args, **kwargs)
return None
return wrapper
@is_authenticated
def create_blog_post(user: User) -> str:
"""Create a blog post for the user.
@param user The author of the post.
@return The text of the new blog post.
"""
return f"This is {user.name}'s new blog post."
## Blueprint that holds the views of this lecture step.
_blueprint = Blueprint("step_05_authenticated", __name__)
@_blueprint.route("/")
def home() -> str:
"""Show the result of create_blog_post for a user in and out."""
lines = []
for name, logged_in in (("Angela", True), ("Bob", False)):
user = User(name)
user.is_logged_in = logged_in
state = "logged in" if logged_in else "logged out"
post = create_blog_post(user)
lines.append(
f"<p>{escape(name)} is {state}: "
f"{escape(post) if post else 'no post created'}</p>"
)
return "<h1>is_authenticated</h1>" + "".join(lines)
def create_app() -> Flask:
"""Build the Flask application of this lecture step.
@return The configured Flask application.
"""
app = Flask(__name__)
app.register_blueprint(_blueprint)
return app
if __name__ == "__main__":
create_app().run(host=HOST, port=PORT, debug=True)
+36
View File
@@ -0,0 +1,36 @@
"""Tests of lecture step 2."""
from flask.testing import FlaskClient
from higher_lower.presentation.lectures.step_02_url_paths import create_app
def _client() -> FlaskClient:
return create_app().test_client()
def test_home_says_hello() -> None:
assert _client().get("/").get_data(as_text=True) == "Hello, World!"
def test_bye_route_says_bye() -> None:
assert _client().get("/bye").get_data(as_text=True) == "Bye"
def test_variable_section_is_used_in_greeting() -> None:
body = _client().get("/username/Angela").get_data(as_text=True)
assert body == "Hello there Angela!"
def test_variable_section_is_escaped() -> None:
body = _client().get("/username/<b>").get_data(as_text=True)
assert "<b>" not in body
def test_int_converter_accepts_a_number() -> None:
body = _client().get("/username/Angela/25").get_data(as_text=True)
assert body == "Hello there Angela, you are 25 years old!"
def test_int_converter_rejects_text() -> None:
assert _client().get("/username/Angela/old").status_code == 404
+16
View File
@@ -0,0 +1,16 @@
"""Tests of lecture step 3."""
from higher_lower import constants
from higher_lower.presentation.lectures.step_03_html import create_app
def test_home_returns_centred_heading() -> None:
body = create_app().test_client().get("/").get_data(as_text=True)
assert "<h1 style='text-align: center'>Hello, World!</h1>" in body
def test_home_returns_paragraph_and_two_images() -> None:
body = create_app().test_client().get("/").get_data(as_text=True)
assert "<p>This is a paragraph.</p>" in body
assert body.count("<img ") == 2
assert f"width={constants.LECTURE_IMAGE_WIDTH}" in body
+36
View File
@@ -0,0 +1,36 @@
"""Tests of lecture step 4."""
from higher_lower.presentation.lectures.step_04_decorators import (
create_app,
make_bold,
make_emphasis,
make_underlined,
)
def _text() -> str:
return "Bye"
def test_make_bold_wraps_in_b() -> None:
assert make_bold(_text)() == "<b>Bye</b>"
def test_make_emphasis_wraps_in_em() -> None:
assert make_emphasis(_text)() == "<em>Bye</em>"
def test_make_underlined_wraps_in_u() -> None:
assert make_underlined(_text)() == "<u>Bye</u>"
def test_decorator_forwards_arguments() -> None:
def greet(name: str, punctuation: str = "!") -> str:
return f"Hi {name}{punctuation}"
assert make_bold(greet)("Ann", punctuation="?") == "<b>Hi Ann?</b>"
def test_bye_route_is_bold_emphasised_and_underlined() -> None:
body = create_app().test_client().get("/bye").get_data(as_text=True)
assert body == "<b><em><u>Bye</u></em></b>"
+40
View File
@@ -0,0 +1,40 @@
"""Tests of lecture step 5."""
from higher_lower.presentation.lectures.step_05_authenticated import (
User,
create_app,
create_blog_post,
is_authenticated,
)
def test_logged_in_user_can_create_a_post() -> None:
user = User("Angela")
user.is_logged_in = True
assert create_blog_post(user) == "This is Angela's new blog post."
def test_logged_out_user_gets_no_post() -> None:
assert create_blog_post(User("Bob")) is None
def test_user_can_be_passed_as_keyword() -> None:
user = User("Angela")
user.is_logged_in = True
assert create_blog_post(user=user) == "This is Angela's new blog post."
def test_wrapper_forwards_extra_arguments() -> None:
@is_authenticated
def shout(user: User, word: str, times: int = 1) -> str:
return f"{user.name}: {word * times}"
user = User("Ann")
user.is_logged_in = True
assert shout(user, "hi", times=2) == "Ann: hihi"
def test_page_shows_both_users() -> None:
body = create_app().test_client().get("/").get_data(as_text=True)
assert "Angela is logged in: This is Angela&#39;s new blog post." in body
assert "Bob is logged out: no post created" in body