Compare commits

...
Author SHA1 Message Date
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
17 changed files with 517 additions and 12 deletions
+2
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:
+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 |
@@ -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
+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": ""}