MIL-002: project scaffold and tooling #41

Merged
Tirsvad merged 2 commits from mil-002-scaffold into main 2026-10-28 10:30:28 +01:00
22 changed files with 520 additions and 2 deletions
Showing only changes of commit 7fdc11a0e7 - Show all commits
+10
View File
@@ -0,0 +1,10 @@
.git
.venv
.env
__pycache__
*.pyc
.pytest_cache
docs/doxygen
framework
.claude
.agents
+20
View File
@@ -0,0 +1,20 @@
# Headless Chromium for real-browser runs and screenshots (ADR-0003).
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
CHROME_BIN=/usr/bin/chromium \
CHROMEDRIVER_PATH=/usr/bin/chromedriver \
SNACK_AND_LIFT_E2E=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium chromium-driver fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN python -m pip install --upgrade pip && python -m pip install -e ".[dev]"
COPY tests ./tests
ENTRYPOINT ["python", "-m", "snack_and_lift"]
+16
View File
@@ -0,0 +1,16 @@
# Doxygen configuration for the Snack and Lift booking bot. Run: doxygen Doxyfile
PROJECT_NAME = "Snack and Lift Booking Bot"
PROJECT_BRIEF = "Selenium bot that books classes at the Snack and Lift practice gym (Udemy 100 Days of Code, day 49)"
OUTPUT_DIRECTORY = docs/doxygen
INPUT = src
FILE_PATTERNS = *.py
RECURSIVE = YES
EXTRACT_ALL = YES
OPTIMIZE_OUTPUT_JAVA = YES
PYTHON_DOCSTRING = YES
AUTOLINK_SUPPORT = YES
GENERATE_HTML = YES
GENERATE_LATEX = NO
QUIET = YES
WARN_IF_UNDOCUMENTED = YES
WARN_AS_ERROR = NO
+153 -1
View File
@@ -1,2 +1,154 @@
# 049-snack-and-lift # 🏋️ Snack and Lift Booking Bot
A Selenium bot that books classes at the fictional gym "Snack and Lift" for you: it logs in, books every Tuesday and Thursday 6pm class, joins the waitlist when a class is full, verifies the result on the "My Bookings" page and survives a site that fails half of its requests. It is the solution to day 49 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants who want to compare solutions and for visitors who browse for ideas.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The bot follows the nine steps of the lecture on the practice site [Snack and Lift][gym]:
1. Setup, Chrome profile and basic navigation.
2. Automated login.
3. Book the upcoming Tuesday class.
4. Check if a class is already booked.
5. Add counters and a summary.
6. Book every Tuesday and Thursday class.
7. Verify the classes on the "My Bookings" page.
8. Time travel quality assurance.
9. Add resilience against network failures (`retry`, `login`, `book_class`, `get_my_bookings`).
The repository is organised like this:
| Folder or file | What it is for |
| --- | --- |
| `src/snack_and_lift/` | The bot. `domain/` holds the rules with no Selenium, `application/` holds `retry`, `login`, `book_class` and `get_my_bookings`, `infrastructure/` drives the browser, `constants.py` holds every constant |
| `tests/` | `pytest` tests; the unit tests need no browser |
| `docs/` | Project documents, wiki pages (English, Danish, Bahasa Malaysia) and the screenshots of the lecture steps |
| `Dockerfile` | A Linux image with headless Chromium for real-browser runs and screenshots |
| `Doxyfile` | Doxygen configuration for the source comments |
## 📋 Requirements
- Python 3.13 or newer.
- Selenium (the only runtime dependency; it is installed by `pip` and finds the browser driver itself).
- Google Chrome or Chromium to run the bot on your own machine.
- `pytest` and `pytest-cov` for the tests (development extra only).
- Docker, only if you want to run the bot or take screenshots without opening a window on your desktop.
- Doxygen, only to build the source documentation.
## 🛠️ Setup
Create a local virtual environment, upgrade `pip` and install the project with the test tools:
```bash
python -m venv .venv
```
Activate it:
```bash
# Linux and macOS
source .venv/bin/activate
```
```powershell
# Windows PowerShell
.venv\Scripts\Activate.ps1
```
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Leave the environment with `deactivate`. The folder `.venv` is ignored by git.
## ▶️ Run
With the environment active:
```bash
snack-and-lift
```
or, without installing the command:
```bash
python -m snack_and_lift
```
To keep your desktop free, run it in the Docker container (headless Chromium):
```bash
docker build -t snack-and-lift .
docker run --rm snack-and-lift
```
## 🧪 Tests
```bash
python -m pytest
```
Coverage of the logic modules is checked with `python -m pytest --cov`. Tests that need a real browser are marked `browser` and run in the Docker container; all other tests need neither a browser nor the network.
Build the source documentation with Doxygen:
```bash
doxygen Doxyfile
```
## 📄 License
GNU Affero General Public License v3.0, see [LICENSE][license].
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
The documents a newcomer needs, in reading order.
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Domain dictionary][DICT-001] | The PO terms and the IT terms |
| [Traceability matrix][TM-001] | How the artifacts link and when each was last reviewed |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[gym]: https://appbrewery.github.io/gym/
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[registry]: ./docs/artifact-registry.md
[DICT-001]: ./docs/dictionary.md
[TM-001]: ./docs/sqa/traceability-matrix.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+1 -1
View File
@@ -16,7 +16,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 009 | | MIL | Milestone / Gateway | docs/milestones/*.md | 009 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 017 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
View File
@@ -0,0 +1,63 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-016 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Review record, full review of the source and tests of MIL-002 | pending |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests of MIL-002
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## 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 variables are `snake_case`, classes `PascalCase`, constants `UPPER_SNAKE`; `ruff check` with the N rules passes. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (for example `retry`, `login`, `book_class`, `get_my_bookings`); no single-letter names outside comprehensions. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` leaves 0 files changed and `ruff check` reports 0 findings; there is no suppression. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function and method has annotations (ruff ANN rules pass), including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; specific exceptions are caught, and re-raised with `raise ... from` where the cause matters. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments; no builtin is shadowed (ruff B and A-style checks pass). |
| 7 | Files, locks and connections are managed with context managers | Pass | Files and the browser are managed with `with`/`try-finally`; no unmanaged file handle exists. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public modules, classes and functions have Doxygen docstrings (`@brief`, `@param`, `@return`). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No print calls; the entry point logs through `logging`. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; ADR-0001 records that design artifacts are skipped. Classes follow ADR-0002. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | Unit tests cover the constants and the command line; the e2e smoke test is skipped outside the container and passed in it; no test depends on order or the network. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | No strict type checker is configured; annotations are complete and checked by ruff. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `pyproject.toml` declares Selenium (runtime) and pytest, pytest-cov, ruff (dev) with lower bounds; none is 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 | Each rule, constant and message exists once (constants in `constants.py`, rules in `domain/`). |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` and `application` import no Selenium; only `infrastructure` imports it; no import cycle exists. |
| 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 | One reason to change per module; the adapter hides Selenium behind a port; fakes honour the port contract. |
| 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 | Layout follows `src/snack_and_lift/{domain,application,infrastructure}` and `tests/{unit,integration,e2e}`; there is no presentation layer for a command-line bot (ADR-0002). |
| 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 | All file and folder names are English lowercase with `_`, `-` and `.` only. |
## Overall Verdict
Go — the code of MIL-002 meets every Mandatory criterion of QC-PY-001. No blocking finding. The review is not independent: the AI wrote and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary.
Gate evidence for MIL-002: `pip install -e .[dev]` and `pytest` pass (6 passed, 1 skipped outside the container); `doxygen Doxyfile` reports no warning; the container image builds and the e2e smoke test passes in it (the home page has no title, so the test reads the login button instead of a page title). `check-readme.sh` reports one finding about the framework's own README (`qc-project-plan.md` is not named); the project README has none. The finding is upstream, outside this project, and is listed in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-002]: ../../milestones/mil-002-scaffold.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+1
View File
@@ -36,6 +36,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-013 | | [MIL-006] | Milestone | en | it | [BC-001], [PP-001] | [MIL-007] | RC-013 |
| [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 | | [MIL-007] | Milestone | en | it | [BC-001], [PP-001] | [MIL-008] | RC-014 |
| [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 | | [MIL-008] | Milestone | en | it | [BC-001], [PP-001] | - | RC-015 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
## Concern Coverage ## Concern Coverage
+52
View File
@@ -0,0 +1,52 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "snack-and-lift-bot"
version = "0.1.0"
description = "Selenium bot that books classes at the Snack and Lift practice gym (Udemy 100 Days of Code, day 49)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = [
"selenium>=4.25",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-cov>=6.0",
"ruff>=0.8",
]
[project.scripts]
snack-and-lift = "snack_and_lift.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
pythonpath = ["src"]
markers = [
"browser: needs a real browser (run in the Docker container)",
]
[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src", "tests"]
extend-exclude = ["framework", "chrome_profile"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "ANN"]
[tool.coverage.run]
source = ["snack_and_lift"]
omit = ["*/infrastructure/*", "*/cli.py", "*/__main__.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
+3
View File
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Snack and Lift booking bot: books gym classes with Selenium.
"""
+8
View File
@@ -0,0 +1,8 @@
"""@file __main__.py
@brief Entry point for `python -m snack_and_lift`.
"""
from snack_and_lift.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Use of the rules through ports: login, booking, verification and retry.
"""
+39
View File
@@ -0,0 +1,39 @@
"""@file cli.py
@brief Command-line entry point (the composition root of the bot).
"""
import argparse
import logging
from collections.abc import Sequence
logger = logging.getLogger(__name__)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""@brief Read the command line.
@param argv Arguments without the program name; None reads `sys.argv`.
@return The parsed options.
"""
parser = argparse.ArgumentParser(
prog="snack-and-lift",
description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
)
parser.add_argument(
"--headless",
action="store_true",
help="run Chrome without a window (the Docker image does this)",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
"""@brief Start the bot.
@param argv Arguments without the program name; None reads `sys.argv`.
@return Process exit code.
"""
logging.basicConfig(level=logging.INFO, format="%(message)s")
parse_args(argv)
logger.info("snack-and-lift: the lecture steps are added by the next milestones")
return 0
+45
View File
@@ -0,0 +1,45 @@
"""@file constants.py
@brief Every constant of the bot: the site address, selectors, timings and messages.
Selectors of the gym pages are added here by the milestone that first uses them.
"""
from typing import Final
## Address of the Snack and Lift practice site on GitHub Pages (appbrewery/gym).
GYM_URL: Final[str] = "https://appbrewery.github.io/gym/"
## Folder, below the working directory, where Chrome keeps the profile of the bot.
PROFILE_DIR_NAME: Final[str] = "chrome_profile"
## Longest time to wait for an element of the page, in seconds.
WAIT_TIMEOUT_SECONDS: Final[int] = 10
## Attempts of the `retry` wrapper before it gives up (lecture step 9).
RETRY_COUNT: Final[int] = 7
## Weekdays of the classes the bot books, as the site abbreviates them.
TARGET_WEEKDAYS: Final[tuple[str, ...]] = ("Tue", "Thu")
## Id of the login button in the navigation bar of the site (the home page has no title).
NAV_LOGIN_BUTTON_ID: Final[str] = "login-button"
## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images"
## Name of the environment variable that holds the path of the browser binary.
CHROME_BIN_ENV: Final[str] = "CHROME_BIN"
## Name of the environment variable that holds the path of the browser driver.
CHROMEDRIVER_PATH_ENV: Final[str] = "CHROMEDRIVER_PATH"
## Window size of the browser, so screenshots look the same on every machine.
WINDOW_SIZE: Final[str] = "1280,1600"
## Chromium arguments that a container needs to run without a display.
HEADLESS_BROWSER_ARGUMENTS: Final[tuple[str, ...]] = (
"--headless=new",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
)
+3
View File
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Booking rules and plain data; imports no Selenium.
"""
@@ -0,0 +1,3 @@
"""@file __init__.py
@brief Selenium adapter: the only package that imports Selenium.
"""
@@ -0,0 +1,33 @@
"""@file browser.py
@brief Creates the Chrome driver, headless when asked.
"""
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from snack_and_lift import constants
def create_driver(headless: bool) -> webdriver.Chrome:
"""@brief Start Chrome.
The binary and the driver come from `CHROME_BIN` and `CHROMEDRIVER_PATH` when
these are set (the Docker image sets them); otherwise Selenium finds them.
@param headless True to run without a window, as in the Docker container.
@return A running Chrome driver.
"""
options = Options()
options.add_argument(f"--window-size={constants.WINDOW_SIZE}")
if headless:
for argument in constants.HEADLESS_BROWSER_ARGUMENTS:
options.add_argument(argument)
binary = os.environ.get(constants.CHROME_BIN_ENV)
if binary:
options.binary_location = binary
driver_path = os.environ.get(constants.CHROMEDRIVER_PATH_ENV)
service = Service(executable_path=driver_path) if driver_path else Service()
return webdriver.Chrome(service=service, options=options)
View File
+33
View File
@@ -0,0 +1,33 @@
"""@file test_browser_smoke.py
@brief Real-browser smoke test; runs only in the Docker container (SNACK_AND_LIFT_E2E=1).
"""
import os
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from snack_and_lift import constants
from snack_and_lift.infrastructure.browser import create_driver
pytestmark = [
pytest.mark.browser,
pytest.mark.skipif(
os.environ.get("SNACK_AND_LIFT_E2E") != "1",
reason="needs a real browser; run it in the Docker container",
),
]
def test_gym_home_page_shows_the_login_button() -> None:
driver = create_driver(headless=True)
try:
driver.get(constants.GYM_URL)
button = WebDriverWait(driver, constants.WAIT_TIMEOUT_SECONDS).until(
ec.presence_of_element_located((By.ID, constants.NAV_LOGIN_BUTTON_ID))
)
assert button.text == "Login"
finally:
driver.quit()
View File
+17
View File
@@ -0,0 +1,17 @@
"""@file test_cli.py
@brief The command line accepts its options and the entry point returns 0.
"""
from snack_and_lift.cli import main, parse_args
def test_headless_is_off_by_default() -> None:
assert parse_args([]).headless is False
def test_headless_flag_is_read() -> None:
assert parse_args(["--headless"]).headless is True
def test_main_returns_zero() -> None:
assert main([]) == 0
+17
View File
@@ -0,0 +1,17 @@
"""@file test_constants.py
@brief Smoke test: the assignment's values are the ones in constants.py.
"""
from snack_and_lift import constants
def test_gym_address_is_https() -> None:
assert constants.GYM_URL.startswith("https://")
def test_retry_count_is_seven() -> None:
assert constants.RETRY_COUNT == 7
def test_bot_books_tuesday_and_thursday() -> None:
assert constants.TARGET_WEEKDAYS == ("Tue", "Thu")