diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 9f633ef..5b3d158 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -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 diff --git a/docs/sqa/reviews/rc-009-code-mil-002.md b/docs/sqa/reviews/rc-009-code-mil-002.md new file mode 100644 index 0000000..15cf5b0 --- /dev/null +++ b/docs/sqa/reviews/rc-009-code-mil-002.md @@ -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 diff --git a/src/higher_lower/constants.py b/src/higher_lower/constants.py index 2dead5b..b03a30b 100644 --- a/src/higher_lower/constants.py +++ b/src/higher_lower/constants.py @@ -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 diff --git a/src/higher_lower/presentation/lectures/__init__.py b/src/higher_lower/presentation/lectures/__init__.py new file mode 100644 index 0000000..e0d9b4b --- /dev/null +++ b/src/higher_lower/presentation/lectures/__init__.py @@ -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.`. +""" diff --git a/src/higher_lower/presentation/lectures/step_02_url_paths.py b/src/higher_lower/presentation/lectures/step_02_url_paths.py new file mode 100644 index 0000000..5e3b957 --- /dev/null +++ b/src/higher_lower/presentation/lectures/step_02_url_paths.py @@ -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/") +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//") +def greet_with_number(name: str, number: int) -> str: + """Greet the user and use the converter 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) diff --git a/src/higher_lower/presentation/lectures/step_03_html.py b/src/higher_lower/presentation/lectures/step_03_html.py new file mode 100644 index 0000000..ce17e7e --- /dev/null +++ b/src/higher_lower/presentation/lectures/step_03_html.py @@ -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 ( + "

Hello, World!

" + "

This is a paragraph.

" + f"" + f"" + ) + + +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) diff --git a/src/higher_lower/presentation/lectures/step_04_decorators.py b/src/higher_lower/presentation/lectures/step_04_decorators.py new file mode 100644 index 0000000..a59fc9a --- /dev/null +++ b/src/higher_lower/presentation/lectures/step_04_decorators.py @@ -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)}" + + return wrapper + + +def make_bold(function: Callable[..., str]) -> Callable[..., str]: + """Wrap the text returned by a function in 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 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 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) diff --git a/src/higher_lower/presentation/lectures/step_05_authenticated.py b/src/higher_lower/presentation/lectures/step_05_authenticated.py new file mode 100644 index 0000000..a3d51e6 --- /dev/null +++ b/src/higher_lower/presentation/lectures/step_05_authenticated.py @@ -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"

{escape(name)} is {state}: " + f"{escape(post) if post else 'no post created'}

" + ) + return "

is_authenticated

" + "".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) diff --git a/tests/unit/test_step_02_url_paths.py b/tests/unit/test_step_02_url_paths.py new file mode 100644 index 0000000..544f5af --- /dev/null +++ b/tests/unit/test_step_02_url_paths.py @@ -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/").get_data(as_text=True) + assert "" 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 diff --git a/tests/unit/test_step_03_html.py b/tests/unit/test_step_03_html.py new file mode 100644 index 0000000..7696811 --- /dev/null +++ b/tests/unit/test_step_03_html.py @@ -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 "

Hello, World!

" in body + + +def test_home_returns_paragraph_and_two_images() -> None: + body = create_app().test_client().get("/").get_data(as_text=True) + assert "

This is a paragraph.

" in body + assert body.count(" str: + return "Bye" + + +def test_make_bold_wraps_in_b() -> None: + assert make_bold(_text)() == "Bye" + + +def test_make_emphasis_wraps_in_em() -> None: + assert make_emphasis(_text)() == "Bye" + + +def test_make_underlined_wraps_in_u() -> None: + assert make_underlined(_text)() == "Bye" + + +def test_decorator_forwards_arguments() -> None: + def greet(name: str, punctuation: str = "!") -> str: + return f"Hi {name}{punctuation}" + + assert make_bold(greet)("Ann", punctuation="?") == "Hi Ann?" + + +def test_bye_route_is_bold_emphasised_and_underlined() -> None: + body = create_app().test_client().get("/bye").get_data(as_text=True) + assert body == "Bye" diff --git a/tests/unit/test_step_05_authenticated.py b/tests/unit/test_step_05_authenticated.py new file mode 100644 index 0000000..2436922 --- /dev/null +++ b/tests/unit/test_step_05_authenticated.py @@ -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