MIL-002 Lecture steps: URL paths, HTML, decorators, authentication #24
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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 | pending |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -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,54 @@
|
||||
"""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 Flask
|
||||
from markupsafe import escape
|
||||
|
||||
from higher_lower.constants import HOST, PORT
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Build the Flask application of this lecture step.
|
||||
|
||||
@return The configured Flask application.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def home() -> str:
|
||||
"""Answer the home route."""
|
||||
return "Hello, World!"
|
||||
|
||||
@app.route("/bye")
|
||||
def say_bye() -> str:
|
||||
"""Answer the fixed route /bye."""
|
||||
return "Bye"
|
||||
|
||||
@app.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)}!"
|
||||
|
||||
@app.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!"
|
||||
|
||||
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,41 @@
|
||||
"""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 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")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Build the Flask application of this lecture step.
|
||||
|
||||
@return The configured Flask application.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.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}>"
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_app().run(host=HOST, port=PORT, debug=True)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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 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")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Build the Flask application of this lecture step.
|
||||
|
||||
@return The configured Flask application.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def home() -> str:
|
||||
"""Answer the home route."""
|
||||
return "Hello, World!"
|
||||
|
||||
@app.route("/bye")
|
||||
@make_bold
|
||||
@make_emphasis
|
||||
@make_underlined
|
||||
def say_bye() -> str:
|
||||
"""Answer /bye with bold, emphasised and underlined text."""
|
||||
return "Bye"
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_app().run(host=HOST, port=PORT, debug=True)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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 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."
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Build the Flask application of this lecture step.
|
||||
|
||||
@return The configured Flask application.
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.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)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_app().run(host=HOST, port=PORT, debug=True)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
@@ -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's new blog post." in body
|
||||
assert "Bob is logged out: no post created" in body
|
||||
Reference in new issue
Block a user