Compare commits

...
Author SHA1 Message Date
Tirsvad 6962decc9f Fetch the framework over HTTPS in the milestone workflow 2026-10-28 23:15:36 +08:00
Tirsvad 332d65255c Merge pull request 'MIL-010: gym accounts from a credentials file' (#59) from mil-010-credentials-file into main
Publish accepted milestones / publish (push) Failing after 4s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 16s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
Publish wiki / publish-wiki (push) Successful in 14s
Publish wiki / wiki (push) Successful in 14s
2026-10-28 15:26:25 +01:00
Tirsvad da3f999c6f Resolve pending commit links 2026-10-28 22:26:18 +08:00
Tirsvad 42f05bdd0f Load the gym accounts from the .env.gym file
Task: MIL-010#1
Task: MIL-010#2
Task: MIL-010#3
2026-10-28 22:26:03 +08:00
Tirsvad 434bdafefd Plan MIL-010: gym accounts from a credentials file 2026-10-28 22:22:00 +08:00
Tirsvad a248541e58 Merge pull request 'MIL-009: screenshots after the fix' (#55) from mil-009-screenshots into main
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 12s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Publish wiki / publish-wiki (push) Successful in 10s
Publish wiki / wiki (push) Successful in 11s
2026-10-28 14:48:45 +01:00
Tirsvad 034f792c1a Add step 8 screenshot after the verification fix (MIL-009) 2026-10-28 21:48:36 +08:00
Tirsvad fedbc39165 Merge pull request 'MIL-009: verification fix' (#54) from mil-009-verification-fix into main
Publish accepted milestones / publish (push) Failing after 4s
Publish wiki / publish-wiki (push) Successful in 15s
Publish wiki / wiki (push) Successful in 15s
TirSystem/git-workflows: Publish wiki / publish-wiki (push) Successful in 11s
TirSystem/git-workflows: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
2026-10-28 14:46:15 +01:00
24 changed files with 534 additions and 18 deletions
+13 -2
View File
@@ -53,12 +53,23 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out the project and its framework submodule
- name: Check out the project
uses: actions/checkout@v4.2.2
with:
submodules: recursive
token: ${{ secrets.SYNC_TOKEN }}
# The framework submodule is registered with an SSH address, which the runner
# cannot use. The address is rewritten to HTTPS for this step only, and the token
# goes in through the environment, never on a command line. .gitmodules stays.
- name: Fetch the framework submodule over HTTPS
env:
GIT_CONFIG_COUNT: "2"
GIT_CONFIG_KEY_0: url.https://git.tirsystem.com/.insteadOf
GIT_CONFIG_VALUE_0: ssh://git@git.tirsystem.com:10022/
GIT_CONFIG_KEY_1: http.https://git.tirsystem.com/.extraheader
GIT_CONFIG_VALUE_1: "Authorization: token ${{ secrets.SYNC_TOKEN }}"
run: git submodule update --init --recursive
- name: Make sure curl and jq are available
run: bash framework/scripts/ci-publish-milestones.sh ensure-tools
+3 -1
View File
@@ -92,6 +92,8 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
Instead of setting variables by hand, you can put the same lines in a file named `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`, one per line). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. The file `.env.gym` is ignored by git: never commit it. Two optional lines, `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD`, hold the admin account of the practice site; the bot loads them but does not use them yet.
### Start the bot
With the environment active:
@@ -115,7 +117,7 @@ docker run --rm snack-and-lift
## 📸 Lecture steps and screenshots
Each step has a wiki page that explains what it teaches, in [English](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home), [Dansk](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home-da) and [Bahasa Melayu](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home-ms). All nine images are screenshots of a real run against the live site. The step 8 image was taken before a fix: it ends in MISMATCH because the My Bookings page also lists bookings from earlier runs. The check now compares only the days of the run, so the same run ends in SUCCESS.
Each step has a wiki page that explains what it teaches, in [English](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home), [Dansk](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home-da) and [Bahasa Melayu](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/wiki/Home-ms). All nine images are screenshots of a real run against the live site. Step 8 ends in SUCCESS; the My Bookings page also lists bookings from earlier runs, which the check prints as "Also booked (not checked)".
| Step | Wiki page | Screenshot |
| --- | --- | --- |
+2 -2
View File
@@ -14,9 +14,9 @@ document of a type. `Primary File` may contain a glob (e.g.
| BC | Business Case | docs/business-case.md | 002 |
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 010 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 011 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 024 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 026 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,74 @@
# MIL-010 Gym accounts from a credentials file
## Metadata
| Key | Value |
| --- | --- |
| ID | MIL-010 |
| CrossReference | [BC-001], [MIL-003] |
| Language | en |
| Domain | it |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S02 | Initial version, requested by S01 in chat | [434bdaf] |
---
## Purpose
Decide whether the bot can read the student and the admin account of the practice site from the personal file `.env.gym`, so that S01 does not set environment variables by hand before each run, while the file stays out of the repository and the tests.
## Deliverable
`credentials.py` that loads the student account and, load-only, the admin account from the environment, plus a small reader of the `.env.gym` file in the infrastructure layer that the command line merges under the real environment, unit tests with dummy values only, and README and wiki instructions.
## Go / No-Go Criteria
| # | Criterion (objectively checkable) | Go | No-Go |
| --- | --- | --- | --- |
| 1 | Unit tests show that the student and the admin account load from file values, and that a real environment variable wins over the file | 0 failures | A test fails or is missing |
| 2 | Unit tests show that a missing or empty variable raises an error that names the variable and never prints a value, and that the admin account is optional | 0 failures | A test fails or is missing |
| 3 | No test reads `.env` or `.env.gym`; `.env.gym` is in `.gitignore`; no password appears in the repository | `git grep` finds no real credential; tests use `tmp_path` | A real file is read or a credential is committed |
| 4 | All earlier tests pass with at least 80% coverage of the logic modules | Both true | Either false |
| 5 | The code review against `qc-programming-python.md` is `Go` | `RC-*` is `Go` | `No-Go` |
## Dependencies
| Depends on | Reason |
| --- | --- |
| MIL-003 | Changes the credentials handling that MIL-003 built |
## Traceability
| Business Case objective / KPI / user story | Reference |
| --- | --- |
| O2, O7 | [BC-001] |
| CN-004, CN-005, CN-009 | [SA-001] |
## Ownership
| Role | Stakeholder ID (SA) |
| --- | --- |
| Owner | S01 |
| Approving reviewer | S02 (AI as stand-in, autonomous mode, not independent) |
## Target Date
2026-11-06 — inside the planned window 2026-10-28 to 2026-11-06 of [PP-001]; the Business Case sets no deadline.
## Tasks
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
| --- | --- | --- | --- | --- |
| 1 | Load the student and the admin account | In `credentials.py` read `SNACK_AND_LIFT_EMAIL` and `SNACK_AND_LIFT_PASSWORD` (required) and `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` (optional, load-only; nothing uses the admin yet). Errors name the variables, never a value. | No | O2 |
| 2 | Read the `.env.gym` file | Add a small reader (no new dependency) in `infrastructure/` for `KEY=VALUE` lines; the command line merges the file under the real environment, and a missing file is not an error. | No | O2, O7 |
| 3 | Test and document | Unit tests with dummy values in `tmp_path`; update the README and the Getting Started wiki pages (three languages) and keep `.env.gym` in `.gitignore`. | No | O7, O8 |
---
[BC-001]: ../business-case.md
[MIL-003]: ./mil-003-login.md
[PP-001]: ../project-plan.md
[SA-001]: ../stakeholder-analysis.md
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
+7 -4
View File
@@ -4,21 +4,21 @@
| Key | Value |
| --- | --- |
| ID | PP-001 |
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009] |
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010] |
| Language | en |
| Domain | it |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version: eight gateways | [50e8331] |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-009, a verification fix found in a real run (S01 accepted the plan in chat) | [3b9e381] |
| 2026-10-28 | Accepted | Jens Tirsvad Nielsen | S01 | Add MIL-010, gym accounts from a credentials file (S01 asked for it in chat) | [434bdaf] |
---
## Purpose
This plan schedules nine gateways (`MIL-001` to `MIL-009`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
This plan schedules ten gateways (`MIL-001` to `MIL-010`) that take the Snack and Lift booking bot from an accepted baseline to a documented, tested and published repository. The Business Case sets no deadline, so the window 2026-10-28 to 2026-11-06 is a planning assumption of this plan. Each gateway ends in a decision and a deliverable and is delivered by one pull request on its own branch.
## Planning Assumptions
@@ -41,6 +41,7 @@ This plan schedules nine gateways (`MIL-001` to `MIL-009`) that take the Snack a
| Lecture step 9 | [MIL-007] | 2026-11-02 to 2026-11-04 | 2026-11-04 | S01 | none (technical tasks) | `retry`, resilient `login`, `book_class`, `get_my_bookings`, screenshot 9 | |
| Documentation, wiki and release | [MIL-008] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (documentation) | README, Doxygen, wiki in en, da and ms, final checks | |
| Verification fix | [MIL-009] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | Verification compares only the window of the run; new screenshots 7 to 9 | |
| Gym accounts from a file | [MIL-010] | 2026-11-04 to 2026-11-06 | 2026-11-06 | S01 | none (technical tasks) | `.env.gym` credentials for the student and the admin account | |
```plantuml
@startgantt
@@ -79,7 +80,7 @@ Project starts 2026-10-28
## Dependencies
```
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005 → MIL-006 → MIL-007 → MIL-008 → MIL-009 → MIL-010
```
A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed. The end date then moves by the days spent on the rework; S01 decides a move of the end date.
@@ -112,5 +113,7 @@ A No-Go on a gateway stops all later gateways until it is fixed and re-reviewed.
[MIL-007]: ./milestones/mil-007-resilience.md
[MIL-008]: ./milestones/mil-008-documentation.md
[MIL-009]: ./milestones/mil-009-verification-fix.md
[MIL-010]: ./milestones/mil-010-credentials-file.md
[50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
[3b9e381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/3b9e381cd42c56cc4543869c5b80c8e0014b4948
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
@@ -0,0 +1,65 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-025 |
| CrossReference | [MIL-010], [QC-MIL-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 | [434bdaf] |
---
## Artifact Under Review
- Instance reviewed: [MIL-010]
- Checklist used: [QC-MIL-001] (`QC-MIL-001`)
- Scope: full review
- Language and domain: en / it
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S02`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete files, runs or documents. |
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has objectively checkable criteria with Go and No-Go columns. |
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table names the preceding milestone and the reason. |
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability cites O2 and O7 of BC-001 and the concerns of SA-001. |
| 5 | Milestone owner and approving reviewer are identified | Pass | Ownership names S01 as owner and S02 (AI as stand-in) as approving reviewer. |
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target Date lies inside the window 2026-10-28 to 2026-11-06 of PP-001. |
## Language and Domain Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Metadata has `Language` = en and `Domain` = it. |
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells of MIL-010 are English (stand-in assessment, see the verdict). |
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register of the type is given in the registry (Languages); the text keeps to it. |
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | Terms follow DICT-001: bot, booking, verification (stand-in assessment, see the verdict). |
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Keys, headings, IDs and statuses are English. |
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists in `docs/`. |
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | N-A | First version; no language change. |
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | The stand-in reviewer reads English and checked the IT terms in context (stand-in assessment, see the verdict). |
| 10 | Abbreviations are spelled out on first use, in the stated language | Pass | Abbreviations are spelled out or defined where first used, or are the framework's own IDs. |
## Overall Verdict
Go — MIL-010 meets every Mandatory criterion. No blocking finding. The review is not independent: the AI authored and reviewed it as stand-in for S02, and S01 can challenge it in the end-of-run summary. Criteria 3, 5 and 9 of QC-LANG-001 were assessed by the stand-in only; the framework would hold them `Pending` for an independent language reviewer. S01 is asked to confirm them in the end-of-run summary.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-010]: ../../milestones/mil-010-credentials-file.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[434bdaf]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/434bdafefdf0a95eed175dc89f27fbd91b19c91f
@@ -0,0 +1,62 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-026 |
| CrossReference | [MIL-010], [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-010 | [42f05bd] |
---
## Artifact Under Review
- Instance reviewed: [MIL-010]
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review of the source and tests of MIL-010
- 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; messages go 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 | 273 unit tests need no browser or network (new: the admin account, half-set variables, the env file parser, precedence of the environment, `--env-file`); an autouse fixture keeps every CLI test in `tmp_path`, so no test can read `.env` or `.env.gym`; 21 container tests pass; coverage of the non-infrastructure modules is 100%. |
| 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-010 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.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-010]: ../../milestones/mil-010-credentials-file.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[42f05bd]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/42f05bdd0f5c300fbd162e8cf50075fc98964e1b
+3 -1
View File
@@ -23,7 +23,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| --- | --- | --- | --- | --- | --- | --- |
| [BC-001] | Business Case | en | it | - | [SA-001], [PP-001] | RC-001 |
| [SA-001] | Stakeholder Analysis | en | it | [BC-001] | [PP-001] | RC-002 |
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-009] | RC-003 |
| [PP-001] | Project Plan | en | it | [BC-001], [SA-001] | [MIL-001] to [MIL-010] | RC-003 |
| [DICT-001] | Domain Dictionary | en | it | [BC-001], [SA-001] | - | RC-004 |
| [ADR-0001] | ADR | - | - | [BC-001] | [PP-001] | RC-005 |
| [ADR-0002] | ADR | - | - | [BC-001], [SA-001] | code from MIL-002 onward | RC-006 |
@@ -37,6 +37,7 @@ Tracks backward and forward links between artifact instances so that the Busines
| [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-009] | Milestone | en | it | [BC-001], [MIL-006] | - | RC-023 |
| [MIL-010] | Milestone | en | it | [BC-001], [MIL-003] | - | RC-025 |
| Source and tests of [MIL-002] | Python source code | - | - | [MIL-002], [ADR-0002], [ADR-0003] | - | RC-016 |
| Source and tests of [MIL-003] | Python source code | - | - | [MIL-003], [ADR-0002], [ADR-0003] | - | RC-017 (Go-with-conditions) |
| Source and tests of [MIL-004] | Python source code | - | - | [MIL-004], [ADR-0002] | - | RC-018 (Go-with-conditions) |
@@ -77,4 +78,5 @@ Tracks backward and forward links between artifact instances so that the Busines
[MIL-007]: ../milestones/mil-007-resilience.md
[MIL-008]: ../milestones/mil-008-documentation.md
[MIL-009]: ../milestones/mil-009-verification-fix.md
[MIL-010]: ../milestones/mil-010-credentials-file.md
[50e8331]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/commit/50e8331bd21910a72ae56b74c9ff6a3677a709b3
+4
View File
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
## Kontofilen
I stedet for at sætte variabler i hånden kan du skrive de samme linjer i en fil `.env.gym` i projektmappen (`SNACK_AND_LIFT_EMAIL=...` og `SNACK_AND_LIFT_PASSWORD=...`). Botten læser den ved start; `--env-file PATH` peger på en anden fil, og en rigtig miljøvariabel vinder over filen. Git ignorerer `.env.gym`: læg den aldrig i repositoriet. De valgfrie `SNACK_AND_LIFT_ADMIN_EMAIL` og `SNACK_AND_LIFT_ADMIN_PASSWORD` gemmer øvelsessidens admin-konto; botten indlæser dem, men bruger dem ikke endnu.
## Kør og test
```bash
+4
View File
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
## Fail akaun
Daripada menetapkan pemboleh ubah secara manual, letakkan baris yang sama dalam fail `.env.gym` di folder projek (`SNACK_AND_LIFT_EMAIL=...` dan `SNACK_AND_LIFT_PASSWORD=...`). Bot membacanya semasa bermula; `--env-file PATH` menamakan fail lain, dan pemboleh ubah persekitaran sebenar mengatasi fail. Git mengabaikan `.env.gym`: jangan sekali-kali commit. `SNACK_AND_LIFT_ADMIN_EMAIL` dan `SNACK_AND_LIFT_ADMIN_PASSWORD` yang pilihan menyimpan akaun admin laman latihan; bot memuatkannya tetapi belum menggunakannya.
## Jalankan dan uji
```bash
+4
View File
@@ -39,6 +39,10 @@ $env:SNACK_AND_LIFT_EMAIL = "yourname@test.com"
$env:SNACK_AND_LIFT_PASSWORD = "your password"
```
## The account file
Instead of setting variables by hand, put the same lines in a file `.env.gym` in the project folder (`SNACK_AND_LIFT_EMAIL=...` and `SNACK_AND_LIFT_PASSWORD=...`). The bot reads it at start; `--env-file PATH` names another file, and a real environment variable wins over the file. Git ignores `.env.gym`: never commit it. The optional `SNACK_AND_LIFT_ADMIN_EMAIL` and `SNACK_AND_LIFT_ADMIN_PASSWORD` hold the practice site's admin account; the bot loads them but does not use them yet.
## Run and test
```bash
+1 -1
View File
@@ -14,6 +14,6 @@ Du lærer at datoer er den almindelige fejlkilde: sidens admin-knap "advance 3 d
![Trin 8: Kvalitetssikring med tidsrejse](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png)
> Dette billede er taget før en rettelse: siden My Bookings viser også bookinger fra tidligere kørsler uden for tidsplanens næste 7 dage, og kontrollen endte så i MISMATCH. Kontrollen sammenligner nu kun kørslens dage og skriver de øvrige bookinger som "Also booked (not checked)", så samme kørsel ender i SUCCESS.
> Siden My Bookings viser også bookinger fra tidligere kørsler uden for denne kørsels dage. Kontrollen sammenligner kun kørslens dage og skriver de øvrige som "Also booked (not checked)".
[Forrige: 7](Step-7-da) · [Forside](Home-da) · [Næste: 9](Step-9-da)
+1 -1
View File
@@ -14,6 +14,6 @@ Anda belajar bahawa tarikh ialah punca pepijat biasa: kawalan admin laman "advan
![Langkah 8: Jaminan kualiti dengan perjalanan masa](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png)
> Imej ini diambil sebelum pembaikan: halaman My Bookings turut menyenaraikan tempahan daripada larian terdahulu di luar 7 hari jadual seterusnya, dan semakan berakhir dengan MISMATCH. Semakan kini hanya membandingkan hari larian dan mencetak tempahan lain sebagai "Also booked (not checked)", jadi larian yang sama berakhir dengan SUCCESS.
> Halaman My Bookings turut menyenaraikan tempahan daripada larian terdahulu di luar hari larian ini. Semakan hanya membandingkan hari larian dan mencetak yang lain sebagai "Also booked (not checked)".
[Sebelumnya: 7](Step-7-ms) · [Utama](Home-ms) · [Seterusnya: 9](Step-9-ms)
+1 -1
View File
@@ -14,6 +14,6 @@ You learn that dates are the usual bug: the site's admin control "advance 3 days
![Step 8: Time travel quality assurance](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/049-snack-and-lift/raw/branch/main/docs/images/step-8.png)
> This image was taken before a fix: the My Bookings page also lists bookings from earlier runs outside the schedule's next 7 days, and the check then ended in MISMATCH. The check now compares only the days of the run and prints the other bookings as "Also booked (not checked)", so the same run ends in SUCCESS.
> The My Bookings page also lists bookings from earlier runs outside the days of this run. The check compares only the days of the run and prints the others as "Also booked (not checked)".
[Previous: 7](Step-7) · [Home](Home) · [Next: 9](Step-9)
+29 -1
View File
@@ -30,6 +30,34 @@ def load_credentials(environment: Mapping[str, str]) -> Credentials:
if not email or not password:
raise MissingCredentialsError(
f"Set {constants.ACCOUNT_EMAIL_ENV} and {constants.ACCOUNT_PASSWORD_ENV} "
"in the environment (they must not be empty)"
"in the environment or the env file (they must not be empty)"
)
return Credentials(email=email, password=password)
def load_admin_credentials(environment: Mapping[str, str]) -> Credentials | None:
"""@brief Read the optional admin account from the environment.
@param environment The environment variables, for example `os.environ`.
@return The credentials, or None when neither variable is set.
@exception MissingCredentialsError Only one of the two variables is set; the message
names the missing variable and never a value.
"""
email = environment.get(constants.ADMIN_EMAIL_ENV, "").strip()
password = environment.get(constants.ADMIN_PASSWORD_ENV, "")
if not email and not password:
return None
if not email:
raise MissingCredentialsError(_admin_missing(constants.ADMIN_EMAIL_ENV))
if not password:
raise MissingCredentialsError(_admin_missing(constants.ADMIN_PASSWORD_ENV))
return Credentials(email=email, password=password)
def _admin_missing(name: str) -> str:
"""@brief Build the message for a half-set admin account.
@param name The variable that is missing or empty.
@return The message; it names the variable only.
"""
return constants.MESSAGE_ADMIN_HALF_SET.format(name=name)
+21 -3
View File
@@ -12,7 +12,11 @@ from pathlib import Path
from snack_and_lift import constants
from snack_and_lift.application.booking import book_next_tuesday, book_target_classes
from snack_and_lift.application.credentials import Credentials, load_credentials
from snack_and_lift.application.credentials import (
Credentials,
load_admin_credentials,
load_credentials,
)
from snack_and_lift.application.ports import GymPort
from snack_and_lift.application.retry import Sleep
from snack_and_lift.application.session import login_with_retry
@@ -23,6 +27,7 @@ from snack_and_lift.domain.report import format_detail_list, format_summary
from snack_and_lift.domain.verification import format_verification
from snack_and_lift.infrastructure.browser import create_driver, default_profile_dir
from snack_and_lift.infrastructure.console_recorder import ConsoleRecorder
from snack_and_lift.infrastructure.env_file import read_env_file
from snack_and_lift.infrastructure.selenium_gym import SeleniumGym
## Builds the gym site from (headless, profile folder, keep open).
@@ -69,7 +74,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.",
epilog=(
"exit codes: 0 done, 1 a step failed (also when every retry failed), "
"2 credentials missing, 3 the My Bookings page does not match the bookings made"
"2 credentials missing in the environment or the env file, "
"3 the My Bookings page does not match the bookings made"
),
)
parser.add_argument(
@@ -110,6 +116,16 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
f"drops a request (default: {constants.RETRY_COUNT}, at least 1)"
),
)
parser.add_argument(
"--env-file",
type=Path,
default=Path(constants.ENV_FILE_NAME),
metavar="PATH",
help=(
"file with the gym accounts as KEY=VALUE lines "
f"(default: ./{constants.ENV_FILE_NAME}); a real environment variable wins"
),
)
args = parser.parse_args(argv)
if args.keep_open is None:
args.keep_open = not args.headless
@@ -242,7 +258,9 @@ def _main(
_use_utf8_output()
args = parse_args(argv)
try:
credentials = load_credentials(os.environ)
environment = {**read_env_file(args.env_file), **os.environ}
credentials = load_credentials(environment)
load_admin_credentials(environment) # load-only for now; nothing uses it yet
except MissingCredentialsError as error:
logger.error("%s", error)
return 2
+13
View File
@@ -70,6 +70,19 @@ ACCOUNT_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_EMAIL"
## Name of the environment variable that holds the account password.
ACCOUNT_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_PASSWORD"
## Names of the environment variables that hold the admin account (read, not used yet).
ADMIN_EMAIL_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_EMAIL"
ADMIN_PASSWORD_ENV: Final[str] = "SNACK_AND_LIFT_ADMIN_PASSWORD"
## Error for an admin account with one variable only; {name} is the missing variable.
MESSAGE_ADMIN_HALF_SET: Final[str] = (
"Set {name} too: the admin account needs both "
"SNACK_AND_LIFT_ADMIN_EMAIL and SNACK_AND_LIFT_ADMIN_PASSWORD"
)
## Default file with the gym accounts, relative to the current directory.
ENV_FILE_NAME: Final[str] = ".env.gym"
## Folder where screenshots of a container run are written.
SCREENSHOT_DIR: Final[str] = "docs/images"
@@ -0,0 +1,44 @@
"""@file env_file.py
@brief Reads `KEY=VALUE` lines from an env file (never logs a value).
"""
from pathlib import Path
## Prefix a line may carry so the file can also be sourced by a shell.
_EXPORT_PREFIX = "export "
## Quote characters that may surround a value.
_QUOTES = ("'", '"')
def _unquote(value: str) -> str:
"""@brief Strip one pair of matching surrounding quotes.
@param value The text after the `=`.
@return The value without the surrounding pair, otherwise unchanged.
"""
if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTES:
return value[1:-1]
return value
def read_env_file(path: Path) -> dict[str, str]:
"""@brief Read the variables of an env file.
Blank lines, `#` comments, lines without `=` and an empty key are skipped.
@param path The file.
@return The variables; empty when the file does not exist.
"""
if not path.is_file():
return {}
variables: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
key = key.strip().removeprefix(_EXPORT_PREFIX).strip()
if key:
variables[key] = _unquote(value.strip())
return variables
+71
View File
@@ -18,6 +18,12 @@ DUMMY_EMAIL = "dummy@example.invalid"
DUMMY_PASSWORD = "dummy-password"
@pytest.fixture(autouse=True)
def isolated_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Run from an empty folder so the default env file is never a real one."""
monkeypatch.chdir(tmp_path)
@pytest.fixture
def credentials_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
@@ -324,3 +330,68 @@ def test_main_passes_options_to_the_factory(tmp_path: Path) -> None:
main(["--headless", "--profile-dir", str(tmp_path)], factory)
assert received == [(True, tmp_path, False)]
def _write_env_file(path: Path, email: str, password: str) -> Path:
path.write_text(
f"{constants.ACCOUNT_EMAIL_ENV}={email}\n"
f"export {constants.ACCOUNT_PASSWORD_ENV}='{password}'\n",
encoding="utf-8",
)
return path
def test_env_file_defaults_to_the_gym_file_name() -> None:
assert parse_args([]).env_file == Path(constants.ENV_FILE_NAME)
def test_env_file_option_is_read(tmp_path: Path) -> None:
assert parse_args(["--env-file", str(tmp_path / "x.env")]).env_file == tmp_path / "x.env"
def test_main_reads_the_account_from_the_env_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass")
gym = FakeGym()
code = main(["--env-file", str(env_file)], lambda *_: gym)
assert code == 0
assert gym.logins == [("file@example.invalid", "file-pass")]
def test_main_lets_a_real_environment_variable_win_over_the_env_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
env_file = _write_env_file(tmp_path / "accounts.env", "file@example.invalid", "file-pass")
gym = FakeGym()
code = main(["--env-file", str(env_file)], lambda *_: gym)
assert code == 0
assert gym.logins == [(DUMMY_EMAIL, DUMMY_PASSWORD)]
def test_main_exits_2_when_neither_environment_nor_file_has_the_account(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv(constants.ACCOUNT_EMAIL_ENV, raising=False)
monkeypatch.delenv(constants.ACCOUNT_PASSWORD_ENV, raising=False)
assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2
def test_main_exits_2_when_the_admin_account_is_half_set(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv(constants.ACCOUNT_EMAIL_ENV, DUMMY_EMAIL)
monkeypatch.setenv(constants.ACCOUNT_PASSWORD_ENV, DUMMY_PASSWORD)
monkeypatch.setenv(constants.ADMIN_EMAIL_ENV, "admin@example.invalid")
monkeypatch.delenv(constants.ADMIN_PASSWORD_ENV, raising=False)
assert main(["--env-file", str(tmp_path / "absent.env")], lambda *_: FakeGym()) == 2
+53 -1
View File
@@ -5,7 +5,11 @@
import pytest
from snack_and_lift import constants
from snack_and_lift.application.credentials import Credentials, load_credentials
from snack_and_lift.application.credentials import (
Credentials,
load_admin_credentials,
load_credentials,
)
from snack_and_lift.domain.errors import MissingCredentialsError
DUMMY_PASSWORD = "dummy-secret-value"
@@ -42,3 +46,51 @@ def test_load_credentials_rejects_missing_or_empty(environment: dict[str, str])
def test_repr_hides_the_password() -> None:
assert DUMMY_PASSWORD not in repr(Credentials("dummy@example.invalid", DUMMY_PASSWORD))
def test_load_admin_credentials_reads_both_variables() -> None:
environment = {
constants.ADMIN_EMAIL_ENV: " admin@example.invalid ",
constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD,
}
assert load_admin_credentials(environment) == Credentials(
"admin@example.invalid", DUMMY_PASSWORD
)
@pytest.mark.parametrize(
"environment",
[
{},
{constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: ""},
{constants.ADMIN_EMAIL_ENV: " "},
],
)
def test_load_admin_credentials_is_none_when_not_set(environment: dict[str, str]) -> None:
assert load_admin_credentials(environment) is None
@pytest.mark.parametrize(
("environment", "missing"),
[
({constants.ADMIN_EMAIL_ENV: "admin@example.invalid"}, constants.ADMIN_PASSWORD_ENV),
(
{constants.ADMIN_EMAIL_ENV: "admin@example.invalid", constants.ADMIN_PASSWORD_ENV: ""},
constants.ADMIN_PASSWORD_ENV,
),
({constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD}, constants.ADMIN_EMAIL_ENV),
(
{constants.ADMIN_EMAIL_ENV: "", constants.ADMIN_PASSWORD_ENV: DUMMY_PASSWORD},
constants.ADMIN_EMAIL_ENV,
),
],
)
def test_load_admin_credentials_rejects_half_set(environment: dict[str, str], missing: str) -> None:
with pytest.raises(MissingCredentialsError) as caught:
load_admin_credentials(environment)
message = str(caught.value)
assert message.startswith(f"Set {missing} too")
assert DUMMY_PASSWORD not in message
assert "admin@example.invalid" not in message
+59
View File
@@ -0,0 +1,59 @@
"""@file test_env_file.py
@brief The env file reader parses KEY=VALUE lines (dummy values in tmp_path only).
"""
from pathlib import Path
from snack_and_lift.infrastructure.env_file import read_env_file
def _read(tmp_path: Path, text: str) -> dict[str, str]:
path = tmp_path / "accounts.env"
path.write_text(text, encoding="utf-8")
return read_env_file(path)
def test_read_env_file_returns_empty_when_file_is_missing(tmp_path: Path) -> None:
assert read_env_file(tmp_path / "absent.env") == {}
def test_read_env_file_reads_pairs(tmp_path: Path) -> None:
assert _read(tmp_path, "A=1\nB=two\n") == {"A": "1", "B": "two"}
def test_read_env_file_skips_blank_lines_comments_and_lines_without_equals(
tmp_path: Path,
) -> None:
text = "\n# comment\n # indented comment\nnot a pair\nA=1\n"
assert _read(tmp_path, text) == {"A": "1"}
def test_read_env_file_accepts_export_prefix(tmp_path: Path) -> None:
assert _read(tmp_path, "export A=1\n") == {"A": "1"}
def test_read_env_file_strips_whitespace_around_key(tmp_path: Path) -> None:
assert _read(tmp_path, " A =1\n") == {"A": "1"}
def test_read_env_file_strips_one_pair_of_quotes(tmp_path: Path) -> None:
text = "A=\"dummy one\"\nB='dummy two'\nC=\"'both'\"\n"
assert _read(tmp_path, text) == {"A": "dummy one", "B": "dummy two", "C": "'both'"}
def test_read_env_file_keeps_unmatched_quotes_and_lone_quote(tmp_path: Path) -> None:
assert _read(tmp_path, 'A="dummy\'\nB="\n') == {"A": "\"dummy'", "B": '"'}
def test_read_env_file_keeps_equals_and_hash_inside_value(tmp_path: Path) -> None:
assert _read(tmp_path, "A=x=y#z\n") == {"A": "x=y#z"}
def test_read_env_file_ignores_empty_key(tmp_path: Path) -> None:
assert _read(tmp_path, "=value\n") == {}
def test_read_env_file_keeps_empty_value(tmp_path: Path) -> None:
assert _read(tmp_path, "A=\n") == {"A": ""}