Author SHA1 Message Date
Tirsvad 02300cc5a7 Merge pull request 'docs: correct the verdict text of RC-011' (#28) from docs/rc-011-verdict-fix into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 11s
Reviewed-on: #28
2026-10-14 15:50:00 +02:00
Tirsvad 9b1aa19140 docs: resolve pending commit link of RC-011
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Skipped
2026-10-14 21:49:12 +08:00
Tirsvad edaf5a801e docs: correct the verdict text and action item of RC-011
The checklist results already showed criterion 3 as Pass; the verdict still said Go-with-conditions.
2026-10-14 21:49:11 +08:00
Tirsvad a3745fb60c Merge pull request 'docs: RC-010 conditions closed, verdict Go' (#27) from docs/rc-010-go into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
Reviewed-on: #27
2026-10-14 15:37:26 +02:00
Tirsvad 9615becb77 docs: resolve pending commit link of RC-010
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Skipped
2026-10-14 21:34:05 +08:00
Tirsvad fa34e1835f docs: RC-010 conditions closed, verdict Go
Refs #20
2026-10-14 21:34:04 +08:00
Tirsvad 9ccccc082d Merge pull request 'docs: record the first publish-wiki run in RC-010' (#26) from docs/rc-010-run-evidence into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 9s
2026-10-14 15:32:55 +02:00
Tirsvad 5f4d276b63 docs: record the first publish-wiki run in RC-010
Refs #20
2026-10-14 21:32:48 +08:00
Tirsvad cc60aefca1 Merge pull request 'MIL-003 Documentation and Publishing' (#25) from mil-003-documentation-and-publishing into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Publish wiki / publish-wiki (push) Successful in 16s
Publish wiki / Publish wiki pages (push) Successful in 16s
2026-10-14 15:32:06 +02:00
Tirsvad c3481e7706 docs: resolve pending commit links of MIL-003 2026-10-14 21:31:48 +08:00
Tirsvad eb7b7dcb17 docs: review records RC-009 to RC-011 and traceability rows
Task: MIL-003#7
2026-10-14 21:31:45 +08:00
Tirsvad f07210ed82 ci: publish-wiki workflow pinned to v1.2.2
Task: MIL-003#6
2026-10-14 21:31:44 +08:00
Tirsvad 06811e6b5a docs: Bahasa Malaysia wiki
Task: MIL-003#5
2026-10-14 21:31:44 +08:00
Tirsvad 40d653113f docs: Danish wiki
Task: MIL-003#4
2026-10-14 21:31:43 +08:00
Tirsvad 0ccd21d23d docs: English wiki
Task: MIL-003#3
2026-10-14 21:31:42 +08:00
Tirsvad a24e7cce6f docs: show screenshots in the README
Task: MIL-003#2
2026-10-14 21:31:41 +08:00
Tirsvad 96c7e4a6d3 feat: screenshot capture tool and screenshots of the real window
Task: MIL-003#1
2026-10-14 21:31:40 +08:00
Tirsvad 66c88be9e7 Merge pull request 'MIL-002 Password Manager Features' (#24) from mil-002-password-manager-features into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
2026-10-14 15:26:29 +02:00
Tirsvad 5350bde2f2 docs: resolve pending commit links of MIL-002 2026-10-14 21:26:16 +08:00
Tirsvad 463ffc3d4a docs: code review RC-008, Doxygen limited to src
Task: MIL-002#7
2026-10-14 21:26:15 +08:00
Tirsvad 16b265205f test: unit, integration and e2e tests
Task: MIL-002#6
2026-10-14 21:26:13 +08:00
Tirsvad d096481453 feat: Tkinter window with MVVM view model and composition root
Task: MIL-002#5
2026-10-14 21:26:11 +08:00
Tirsvad 239f8163fe feat: domain, use cases and JSON repository with file error handling
Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
2026-10-14 21:26:08 +08:00
Tirsvad e0df8be0cb Merge pull request 'MIL-001 Project Foundation' (#23) from mil-001-project-foundation into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 15:22:04 +02:00
53 changed files with 2190 additions and 5 deletions
+43
View File
@@ -0,0 +1,43 @@
# Publish the project's wiki pages to the git host's wiki.
#
# Purpose: copies the Markdown pages of docs/wiki/ (English, Danish `-da` and
# Bahasa Malaysia `-ms`) to the wiki of this repository.
# Triggers: a push to main that changes docs/wiki/ (no manual trigger: the run
# changes the wiki, and the called workflow has no dry-run input).
# Inputs: none; wiki_dir and prune are fixed below.
# Secrets: TOKEN_FOR_GITEA_WIKI (organization secret, scope write:repository).
# Changes outside the repository: creates, updates and, because prune is
# "true", deletes pages of this repository's wiki so the wiki equals
# docs/wiki/.
# Decision: milestone MIL-003, task "Add the publish-wiki workflow".
# Host: Gitea Actions only (the called workflow uses `gitea.*` contexts).
# It needs a runner registered for the repository or organization.
# Pinning: the called workflow and its script are pinned to the same release
# (workflow ref and tools-ref are both v1.2.2).
---
name: Publish wiki
on:
push:
branches: [main]
paths:
- "docs/wiki/**"
permissions:
contents: read
concurrency:
group: publish-wiki
cancel-in-progress: false
jobs:
wiki:
name: Publish wiki pages
uses: TirSystem/github-action/.gitea/workflows/publish-wiki-pages.yml@v1.2.2
with:
wiki_dir: docs/wiki
prune: "true"
tools-ref: v1.2.2
secrets:
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
+8
View File
@@ -0,0 +1,8 @@
---
# yamllint configuration of this repository (yamllint 1.38.0).
extends: default
rules:
line-length:
max: 100
truthy:
check-keys: false
+3 -3
View File
@@ -130,10 +130,10 @@ WARN_LOGFILE =
#--------------------------------------------------------------------------- #---------------------------------------------------------------------------
# Configuration options related to the input files # Configuration options related to the input files
#--------------------------------------------------------------------------- #---------------------------------------------------------------------------
INPUT = src README.md INPUT = src
INPUT_ENCODING = UTF-8 INPUT_ENCODING = UTF-8
INPUT_FILE_ENCODING = INPUT_FILE_ENCODING =
FILE_PATTERNS = *.py *.md FILE_PATTERNS = *.py
RECURSIVE = YES RECURSIVE = YES
EXCLUDE = EXCLUDE =
EXCLUDE_SYMLINKS = NO EXCLUDE_SYMLINKS = NO
@@ -147,7 +147,7 @@ INPUT_FILTER =
FILTER_PATTERNS = FILTER_PATTERNS =
FILTER_SOURCE_FILES = NO FILTER_SOURCE_FILES = NO
FILTER_SOURCE_PATTERNS = FILTER_SOURCE_PATTERNS =
USE_MDFILE_AS_MAINPAGE = README.md USE_MDFILE_AS_MAINPAGE =
IMPLICIT_DIR_DOCS = YES IMPLICIT_DIR_DOCS = YES
FORTRAN_COMMENT_AFTER = 72 FORTRAN_COMMENT_AFTER = 72
#--------------------------------------------------------------------------- #---------------------------------------------------------------------------
+12 -1
View File
@@ -31,11 +31,22 @@ The program follows the three lectures of the assignment:
The assignment's function names are kept: `generate_password`, `save` and The assignment's function names are kept: `generate_password`, `save` and
`find_password`. `find_password`.
![The Password Manager window after Generate Password](docs/images/main-window.png)
| Search finds a saved website | Search for an unknown website |
| --- | --- |
| ![Search finds a saved website](docs/images/search-found.png) | ![Search for an unknown website](docs/images/search-not-found.png) |
The screenshots are captured from the real window by
`tools/capture_screenshots.py` (Windows). The wiki in English, Danish and Bahasa
Malaysia is in `docs/wiki/`.
| Path | What it holds | | Path | What it holds |
| --- | --- | | --- | --- |
| `src/password_manager/` | The program: domain rules, use cases, JSON storage and the Tkinter window | | `src/password_manager/` | The program: domain rules, use cases, JSON storage and the Tkinter window |
| `src/password_manager/constants.py` | Every constant (file name, labels, messages, password length bounds) | | `src/password_manager/constants.py` | Every constant (file name, labels, messages, password length bounds) |
| `tests/` | The pytest tests | | `tests/` | The pytest tests (`unit`, `integration`, `e2e`) |
| `tools/` | `capture_screenshots.py`, the screenshot capture script |
| `docs/` | Project documents, the Doxygen output folder and the wiki in `docs/wiki/` | | `docs/` | Project documents, the Doxygen output folder and the wiki in `docs/wiki/` |
| `Doxyfile` | Doxygen configuration for the source comments | | `Doxyfile` | Doxygen configuration for the source comments |
| `pyproject.toml` | Project configuration (Python version, entry point, pytest) | | `pyproject.toml` | Project configuration (Python version, entry point, pytest) |
+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 | 004 | | MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
| DICT | Domain Dictionary | docs/dictionary.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages ## Languages
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+64
View File
@@ -0,0 +1,64 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-008 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [463ffc3] |
---
# Code review for MIL-002
## Artifact Under Review
- Instance reviewed: `src/password_manager/` and `tests/` (19 modules, 37 tests) as of the MIL-002 pull request, delivering [MIL-002]
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Packages and modules are snake_case, classes PascalCase (`JsonPasswordRepository`, `PasswordEntry`), constants UPPER_SNAKE. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`EntryNotFoundError`, `find_password`, `confirm_save`); no cryptic abbreviations. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format --check` reports 26 files already formatted; `ruff check` reports 0 findings and the sources contain no `noqa`. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | ruff rule set `ANN` is enabled and passes, so every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; repository and view model catch the narrowest domain errors and re-raise with `raise ... from error`. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; ruff `B` rules pass. |
| 7 | Files, locks and connections are managed with context managers | Pass | The repository opens the JSON file with `with self._path.open(...)`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every public module, class and function has a Doxygen docstring (`@brief`, `@param`, `@return`, `@throws`); `doxygen Doxyfile` runs without warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` and no logging; passwords are shown only in the window messages the course requires. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No DCD exists (no use case modelled, see Project Plan Open Issues); the class set follows the clean-architecture layers and is recorded in MIL-002 tasks. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | `python -m pytest` runs 37 tests (unit, integration, e2e) named for behaviour, using `tmp_path` and fakes; none touch the network or depend on order. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | No type checker is configured. The single `Any` (JSON object values) has a justifying comment. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []`; pytest and ruff are the only dev extras and both are used. |
| 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 | Constants, messages and JSON keys are defined once in `constants.py`; validation of empty fields is in the use cases only. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports only `constants` and the standard library; `application` imports `domain`; `presentation` imports no `infrastructure` module (grep); only `main.py` composes them. No import cycle. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | Each class has one job (entry, generator, use cases, JSON repository, view model, view); the use cases depend on the `PasswordRepository` protocol and receive the implementation. |
| 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 | `domain`, `application`, `infrastructure`, `presentation` packages under `src/password_manager/` and `unit`, `integration`, `e2e` under `tests/`; imports point inward. |
| 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 snake_case with no spaces. |
## Overall Verdict
Go — every Mandatory criterion passes.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-002]: ../../milestones/mil-002-password-manager-features.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[463ffc3]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/463ffc3d4a477affa3ac45f1c33c4241a62677e1
@@ -0,0 +1,64 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-009 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [eb7b7dc] |
---
# Code review of tools/capture_screenshots.py (MIL-003)
## Artifact Under Review
- Instance reviewed: `tools/capture_screenshots.py` as of the MIL-003 pull request, delivering [MIL-003]
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Names in `tools/capture_screenshots.py` follow PEP 8 (`render_window`, `STATES`, `BitmapInfoHeader`). |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose; the only short names are `x`, `y` in pixel loops. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format --check` and `ruff check` pass for `tools`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | ruff rule set `ANN` passes, so every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No `except` clauses; no bare handlers. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults and no shadowed builtins. |
| 7 | Files, locks and connections are managed with context managers | Pass | The temporary folder uses `with tempfile.TemporaryDirectory()`; GDI handles are released explicitly. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every public function and class has a Doxygen docstring. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` for diagnostics beyond the progress line `wrote <file>` of a command line tool; no secrets. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | A tool script, no design class; it traces to MIL-003 task 1. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | The tool is exercised by the capture run itself and its output was inspected; it is not unit tested because it needs a Windows desktop (stated in its docstring). |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | No type checker configured; `Any` is not used. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Standard library only; no dependency declared. |
| 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 | The sample values and state names are defined once at the top of the file. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | A tool outside `src/`; it imports the application only through its public layers. |
| 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 | Functions each have one job (render, enumerate, compose, write PNG, run a state). |
| 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 | Placed in `tools/`, outside the layers, as MIL-003 and the wiki state; `src/` layout unchanged. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names are English, snake_case, no spaces. |
## Overall Verdict
Go — every Mandatory criterion passes.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-003]: ../../milestones/mil-003-documentation-and-publishing.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[eb7b7dc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/eb7b7dcb17cb96535db0812c26252e9db0174d73
@@ -0,0 +1,76 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-010 |
| CrossReference | [MIL-003], [QC-WF-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [eb7b7dc] |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Conditions closed: criteria 12 and 22 pass, verdict Go | [fa34e18] |
---
# Review of the publish-wiki workflow (MIL-003)
## Artifact Under Review
- Instance reviewed: `.gitea/workflows/publish-wiki.yml` as of the MIL-003 pull request, delivering [MIL-003]
- Checklist used: [QC-WF-001]
- Scope: full review
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The file meets the YAML checklist ([QC-YML-001]) | Pass | See the YAML record [RC-011]; all Mandatory criteria pass. |
| 2 | The file is in `.gitea/workflows/` or `.github/workflows/` with the extension `.yml` or `.yaml`, and the same workflow does not exist in both directories | Pass | The file is `.gitea/workflows/publish-wiki.yml`; no copy exists under `.github/workflows/`. |
| 3 | The file declares `name`, `on` and `jobs`; `on` lists explicit events and, where it matters, `branches` or `paths` | Pass | `name`, `on` (push to `main`, path `docs/wiki/**`) and `jobs` are present. |
| 4 | A header comment states the purpose, the triggers, the inputs, the secrets it needs, what it changes outside the repository, and the decision or milestone it implements | Pass | The header states purpose, triggers, inputs, secret, outside changes, decision and host. |
| 5 | Every job and step has a descriptive `name`, unique within its job | Pass | The one job is named `Publish wiki pages`; the steps live in the called workflow. |
| 6 | The file passes `actionlint` (or the project's workflow validation test) and the tool version is stated | Pass | `actionlint` 1.7.12 exits 0 on the file. |
| 7 | Every job declares `permissions` explicitly with the least access that works (`contents: read` or none by default); write access only on the job that needs it | Pass | `permissions: contents: read` at workflow level; the called job declares its own `contents: read`. |
| 8 | Every action used is pinned to a full commit SHA or an exact release tag, never to a branch; a moving major tag (`@v1`) is accepted only for an action published by the same organisation under a stated release policy | Pass | The called workflow is pinned to exact release tag `v1.2.2`; the nested `actions/checkout@v4.2.2` is also an exact release tag. |
| 9 | No secret is written in the file; a secret is read as `secrets.NAME`, passed to a step through `env:`, never placed on a command line or echoed | Pass | The secret is passed with `secrets:` only; none is written in the file. |
| 10 | Untrusted text (`inputs.*`, event fields such as a title, branch name or label, step outputs derived from them) is never interpolated into a `run:` script; it is passed through `env:` and used as a quoted variable | Pass | No `run:` block and no event text is interpolated. |
| 11 | A trigger that gives a run secrets or a write token on code from a fork or an untrusted branch (`pull_request_target`, `workflow_run`) does not check out or execute that code | Pass | No `pull_request_target` or `workflow_run` trigger. |
| 12 | A workflow that changes state outside the repository (release, tag, publish, sync, deploy) is started by hand or by a protected branch, and a manual run defaults to a dry run with an explicit input to apply | Pass | Started only by a push to `main`; `main` is protected on the host since 2026-10-14 (direct and force pushes blocked), so only merged pull requests reach it. The manual trigger was removed. |
| 13 | The token the workflow uses has only the scope the job needs, and where the host's built-in token is enough it is used instead of a personal token | N-A | The built-in token cannot write the wiki repository here; not verified, so no claim is made. |
| 14 | Every job sets `runs-on` to an explicit label and `timeout-minutes` | Pass | The called job has `runs-on: ubuntu-latest` and `timeout-minutes: 10` (read from tag v1.2.2). |
| 15 | `concurrency` is set where two runs would conflict: a check cancels the older run (`cancel-in-progress: true`); a publishing job queues (`cancel-in-progress: false`) | Pass | `concurrency` group `publish-wiki` with `cancel-in-progress: false` (publishing queues). |
| 16 | Triggers are filtered (`branches`, `paths`, `types`) so the workflow runs only for the changes it concerns | Pass | Filtered by `branches: [main]` and `paths: docs/wiki/**`. |
| 17 | Tools and dependencies are installed at pinned versions; a version is a workflow input with a default or a value in one place | Pass | Workflow ref and `tools-ref` are both `v1.2.2`. |
| 18 | A dependency cache is used where the install is slow enough to matter | N-A | Nothing is installed. |
| 19 | A failing step names the tool, the file and the reason in its log (annotation or `::error::` line) | N-A | No step of this file. |
| 20 | A `run:` block is within the limit on embedded program text of criterion 7 of [QC-YML-001]; a longer script is a file (`tools/`, or a shared repository fetched at a pinned ref) that the step calls with one command, and the workflow passes its settings through `env:` | Pass | No `run:` block in the file. |
| 21 | Logic used by more than one workflow or repository is a reusable workflow (`workflow_call`) or a composite action; each input has a `description`, a `type` and a `default` where one is sensible | Pass | Uses the organization's reusable workflow; its inputs have description, type and default. |
| 22 | Every workflow feature used is verified on the instance that runs it, and the host and version are recorded; a feature the target hosts differ on is noted in the header | Pass | Verified on Gitea 1.27.3: run 1582 on `main` (merge of PR 25) concluded `success` and the host wiki holds the 12 pages plus sidebar and footer. |
| 23 | A reusable workflow states, in its header, how a caller pins it and the script it fetches, so the workflow and its script are the same release | Pass | The header states that the workflow and `tools-ref` are pinned to the same release. |
| 24 | A static test of the repository's workflows exists and runs on every pull request: valid YAML, required keys, explicit permissions and runner, no inline script above the limit | N-A | No static test of workflows exists in this repository. |
## Overall Verdict
Go — the conditions are closed: criterion 12 (`main` is protected) and criterion 22 (first run succeeded on Gitea 1.27.3). Every Mandatory criterion now passes.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Protect `main` on the host so only reviewed pull requests change the wiki | S01 | Closed 2026-10-14: branch protection `main` set (no direct or force push) |
| Register a runner and confirm the first run on `main` publishes the wiki | S01 | Closed 2026-10-14: run 1582 concluded `success`; the host wiki lists the pages |
| Suggestion, not a condition: pin the nested `actions/checkout` to a commit SHA in the shared workflow (upstream) | S01 | - |
---
[MIL-003]: ../../milestones/mil-003-documentation-and-publishing.md
[QC-WF-001]: ../../../framework/qc/code/qc-workflow.md
[QC-YML-001]: ../../../framework/qc/code/qc-yaml.md
[RC-011]: ./rc-011-publish-wiki-yaml.md
[eb7b7dc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/eb7b7dcb17cb96535db0812c26252e9db0174d73
[fa34e18]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/fa34e1835f4fb00a1cf74978d773cd75e23ba188
@@ -0,0 +1,63 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-011 |
| CrossReference | [MIL-003], [QC-YML-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-14 | Deprecated | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [eb7b7dc] |
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Verdict text and action item corrected to match the checklist results (criterion 3 passes) | [edaf5a8] |
---
# YAML review of the publish-wiki workflow (MIL-003)
## Artifact Under Review
- Instance reviewed: `.gitea/workflows/publish-wiki.yml` as of the MIL-003 pull request, delivering [MIL-003]
- Checklist used: [QC-YML-001]
- Scope: full review
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The file parses with a safe YAML loader and has no duplicate keys in any mapping | Pass | `actionlint` parses it; `yamllint` 1.38.0 reports no duplicate keys. |
| 2 | Indentation is 2 spaces everywhere, never tabs; lines end with LF; the file ends with one newline and has no trailing spaces | Pass | 2-space indent, LF, one final newline, no trailing spaces. |
| 3 | Passes `yamllint` (or the project's YAML linter) with its configuration committed next to the code and no unexplained `disable` comments | Pass | `yamllint` 1.38.0 with the committed `.yamllint` reports no finding. |
| 4 | A scalar that YAML would coerce to another type is quoted: `yes`, `no`, `on`, `off`, `null`, `~`, numbers with a leading zero, versions such as `1.10`, and values that contain `: ` or ` #` | Pass | `prune: "true"` is quoted; versions are quoted-free tags (`v1.2.2`), which are strings. |
| 5 | Only safe, plain types are used: no language-specific tags (`!!python/object`, `!ruby/object`) and no `!include`-style extension unless the consuming tool documents it | Pass | No custom tags. |
| 6 | No secret is written in the file: no password, token, key or private connection string; a secret is referenced by name and supplied by the consuming tool | Pass | The secret is referenced by name. |
| 7 | Embedded program text (shell, Python, SQL, `jq`, and so on) in a value is at most five lines; a longer one is moved to a file of its own, in its own language, and the YAML calls it | Pass | No embedded program text. |
| 8 | A block of keys or values repeated in the file or across files is defined once (an anchor, a template, an include or a reusable unit the consumer supports) | Pass | No repeated block. |
| 9 | Anchors, aliases and merge keys (`&`, `*`, `<<`) are used only where the consuming tool is known to support them | Pass | No anchors or aliases. |
| 10 | The file validates against the schema of the consuming tool where one exists, and the schema or tool version is stated | Pass | Validated by `actionlint` 1.7.12 against the workflow schema. |
| 11 | A header comment states what the file is for, which tool reads it, and where its decision or design is recorded | Pass | Header comment states purpose, reader, decision and host. |
| 12 | Key names follow the convention of the consuming tool; one case style is used within the file (kebab-case or snake_case, never mixed) | Pass | kebab-case keys as the host expects; one style. |
| 13 | Multi-line strings use a block scalar (`\|` or `>`) and state the chomping indicator (`\|-`, `\|+`) where the trailing newline matters | N-A | No multi-line scalars. |
| 14 | The file name is lowercase kebab-case with one extension style (`.yml` or `.yaml`) used throughout the repository | Pass | `publish-wiki.yml`, kebab-case, `.yml`. |
| 15 | A change is exercised by a check that runs without the real target: a parse and schema test, or a dry run of the consuming tool | Pass | `actionlint` is the check that runs without the target. |
## Overall Verdict
Go — every Mandatory criterion passes. Criterion 3 passes because `.yamllint` is committed next to the workflow and `yamllint` 1.38.0 reports no finding.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-003]: ../../milestones/mil-003-documentation-and-publishing.md
[QC-YML-001]: ../../../framework/qc/code/qc-yaml.md
[eb7b7dc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/eb7b7dcb17cb96535db0812c26252e9db0174d73
[edaf5a8]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/edaf5a801e37d86adadc79a637ec457bbd19b206
+7
View File
@@ -31,6 +31,9 @@ an artifact instance is created or reviewed.
| [MIL-002] | MIL | en | it | [BC-001], [MIL-001] | [MIL-003] | [RC-005] | | [MIL-002] | MIL | en | it | [BC-001], [MIL-001] | [MIL-003] | [RC-005] |
| [MIL-003] | MIL | en | it | [BC-001], [MIL-002] | - | [RC-006] | | [MIL-003] | MIL | en | it | [BC-001], [MIL-002] | - | [RC-006] |
| `src/`, `tests/` (MIL-001 code) | PY | - | - | [MIL-001] | - | [RC-007] | | `src/`, `tests/` (MIL-001 code) | PY | - | - | [MIL-001] | - | [RC-007] |
| `src/`, `tests/` (MIL-002 code) | PY | - | - | [MIL-002] | - | [RC-008] |
| `tools/capture_screenshots.py` | PY | - | - | [MIL-003] | - | [RC-009] |
| `.gitea/workflows/publish-wiki.yml` | WF, YML | - | - | [MIL-003] | - | [RC-010], [RC-011] |
## Coverage Notes ## Coverage Notes
@@ -58,3 +61,7 @@ an artifact instance is created or reviewed.
[RC-006]: ./reviews/rc-006-mil-003.md [RC-006]: ./reviews/rc-006-mil-003.md
[53f56ee]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/53f56eeb143d810b91da23f743a53a32207f81bb [53f56ee]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/commit/53f56eeb143d810b91da23f743a53a32207f81bb
[RC-007]: ./reviews/rc-007-mil-001-code.md [RC-007]: ./reviews/rc-007-mil-001-code.md
[RC-008]: ./reviews/rc-008-mil-002-code.md
[RC-009]: ./reviews/rc-009-mil-003-tools-code.md
[RC-010]: ./reviews/rc-010-publish-wiki-workflow.md
[RC-011]: ./reviews/rc-011-publish-wiki-yaml.md
+47
View File
@@ -0,0 +1,47 @@
[English](Development) · **Dansk** · [Bahasa Malaysia](Development-ms)
# Udvikling
## Opbygning
| Mappe | Indhold |
| --- | --- |
| `src/password_manager/domain` | Post, adgangskodegenerator og fejl; ingen ind- eller udlæsning |
| `src/password_manager/application` | Use cases og lagerporten |
| `src/password_manager/infrastructure` | JSON-lageret |
| `src/password_manager/presentation` | MVVM-visningen og visningsmodellen |
| `src/password_manager/constants.py` | Alle konstanter og beskeder |
| `tests/` | `unit`-, `integration`- og `e2e`-tests |
| `tools/` | `capture_screenshots.py` |
Importer peger indad; kun `main.py` forbinder lagene.
## Tests og lint
```bash
python -m pytest
python -m ruff format src tests tools
python -m ruff check src tests tools
```
## Kildedokumentation
Kommentarerne er i Doxygen-stil. Byg HTML med `doxygen Doxyfile`; den åbnes på
`docs/doxygen/html/index.html`.
## Skærmbilleder
På Windows med det virtuelle miljø aktivt:
```bash
python tools/capture_screenshots.py
```
Billederne skrives til `docs/images/` fra det rigtige vindue og en midlertidig
datafil.
## Projektdokumenter
Projektet følger en planlæg-først-arbejdsgang: Business Case, interessentanalyse,
projektplan og milepæle ligger i `docs/`. Arbejdet udføres på én gren og én pull
request pr. milepæl.
+47
View File
@@ -0,0 +1,47 @@
[English](Development) · [Dansk](Development-da) · **Bahasa Malaysia**
# Pembangunan
## Susun atur
| Folder | Kandungan |
| --- | --- |
| `src/password_manager/domain` | Rekod, penjana kata laluan dan ralat; tiada input atau output |
| `src/password_manager/application` | Kes guna dan port repositori |
| `src/password_manager/infrastructure` | Repositori JSON |
| `src/password_manager/presentation` | Paparan MVVM dan model paparan |
| `src/password_manager/constants.py` | Semua pemalar dan mesej |
| `tests/` | Ujian `unit`, `integration` dan `e2e` |
| `tools/` | `capture_screenshots.py` |
Import menuju ke dalam; hanya `main.py` menghubungkan lapisan.
## Ujian dan lint
```bash
python -m pytest
python -m ruff format src tests tools
python -m ruff check src tests tools
```
## Dokumentasi kod sumber
Komen ditulis dalam gaya Doxygen. Bina HTML dengan `doxygen Doxyfile`; ia dibuka
di `docs/doxygen/html/index.html`.
## Tangkapan skrin
Pada Windows dengan persekitaran maya aktif:
```bash
python tools/capture_screenshots.py
```
Imej ditulis ke `docs/images/` daripada tetingkap sebenar dan fail data
sementara.
## Dokumen projek
Projek mengikut aliran kerja perancangan dahulu: Business Case, analisis
pemegang taruh, pelan projek dan pencapaian berada dalam `docs/`. Kerja
dilakukan pada satu cabang dan satu pull request bagi setiap pencapaian.
+47
View File
@@ -0,0 +1,47 @@
**English** · [Dansk](Development-da) · [Bahasa Malaysia](Development-ms)
# Development
## Layout
| Folder | Holds |
| --- | --- |
| `src/password_manager/domain` | Entry, password generator and errors; no input or output |
| `src/password_manager/application` | Use cases and the repository port |
| `src/password_manager/infrastructure` | The JSON repository |
| `src/password_manager/presentation` | The MVVM view and view model |
| `src/password_manager/constants.py` | Every constant and message |
| `tests/` | `unit`, `integration` and `e2e` tests |
| `tools/` | `capture_screenshots.py` |
Imports point inward; only `main.py` connects the layers.
## Tests and lint
```bash
python -m pytest
python -m ruff format src tests tools
python -m ruff check src tests tools
```
## Source documentation
Comments are Doxygen style. Build the HTML with `doxygen Doxyfile`; it opens at
`docs/doxygen/html/index.html`.
## Screenshots
On Windows, with the virtual environment active:
```bash
python tools/capture_screenshots.py
```
The images are written to `docs/images/` from the real window and a temporary
data file.
## Project documents
The project follows a planning-first workflow: Business Case, Stakeholder
Analysis, Project Plan and milestones are in `docs/`. Work is done on one branch
and one pull request per milestone.
+27
View File
@@ -0,0 +1,27 @@
[English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
# Password Manager
En lille Tkinter-adgangskodemanager fra dag 30 af Udemys "100 Days of Code: The
Complete Python Pro Bootcamp". Den genererer adgangskoder, gemmer dem i en
JSON-fil og søger efter dem på websted.
![Hovedvinduet](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/main-window.png)
## Hvad den gør
- **Genererer** en tilfældig adgangskode og kopierer den til udklipsholderen.
- **Tilføjer** et websted, en e-mailadresse og en adgangskode til `data.json`.
- **Søger** efter et websted og viser den gemte e-mailadresse og adgangskode.
- Kører videre, når `data.json` mangler, er tom eller er beskadiget.
> **Sikkerhedsnote.** Adgangskoderne gemmes som almindelig JSON og er ikke
> krypterede. Det er et læringsprojekt; brug det ikke til rigtige adgangskoder.
## Sider
- [Installation](Installation-da)
- [Brug](Usage-da)
- [Udvikling](Development-da)
Kildekode og opgaver: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager).
+29
View File
@@ -0,0 +1,29 @@
[English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
# Password Manager
Pengurus kata laluan Tkinter yang kecil daripada hari ke-30 kursus Udemy "100
Days of Code: The Complete Python Pro Bootcamp". Ia menjana kata laluan,
menyimpannya dalam fail JSON dan mencarinya mengikut laman web.
![Tetingkap utama](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/main-window.png)
## Fungsinya
- **Menjana** kata laluan rawak dan menyalinnya ke papan keratan.
- **Menambah** laman web, alamat e-mel dan kata laluan ke dalam `data.json`.
- **Mencari** laman web dan memaparkan alamat e-mel dan kata laluan yang
disimpan.
- Terus berjalan apabila `data.json` tiada, kosong atau rosak.
> **Nota keselamatan.** Kata laluan disimpan sebagai JSON biasa dan tidak
> disulitkan. Ini projek pembelajaran; jangan gunakannya untuk kata laluan
> sebenar.
## Halaman
- [Pemasangan](Installation-ms)
- [Penggunaan](Usage-ms)
- [Pembangunan](Development-ms)
Kod sumber dan isu: [repositori](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager).
+27
View File
@@ -0,0 +1,27 @@
**English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
# Password Manager
A small Tkinter password manager from day 30 of Udemy's "100 Days of Code: The
Complete Python Pro Bootcamp". It generates passwords, saves them in a JSON
file and searches them by website.
![The main window](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/main-window.png)
## What it does
- **Generate** a random password and copy it to the clipboard.
- **Add** a website, an e-mail address and a password to `data.json`.
- **Search** a website and see the saved e-mail address and password.
- Keeps running when `data.json` is missing, empty or damaged.
> **Security note.** The passwords are stored as plain JSON and are not
> encrypted. This is a learning project; do not use it for real passwords.
## Pages
- [Installation](Installation)
- [Usage](Usage)
- [Development](Development)
Source code and issues: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager).
+39
View File
@@ -0,0 +1,39 @@
[English](Installation) · **Dansk** · [Bahasa Malaysia](Installation-ms)
# Installation
## Krav
- Python 3.13 eller nyere med Tkinter. På Debian eller Ubuntu installeres
`python3-tk`.
- `venv` og `pip`, som begge er en del af Python.
- Ingen afhængigheder i drift.
## Opret et lokalt virtuelt miljø
Windows (PowerShell):
```powershell
python -m venv .venv
.venv/Scripts/Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux og macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Tillægget `[dev]` installerer pytest og ruff. Udelad det
(`python -m pip install -e .`), hvis du kun vil køre programmet.
## Kontrollér installationen
```bash
python -m pytest
```
+39
View File
@@ -0,0 +1,39 @@
[English](Installation) · [Dansk](Installation-da) · **Bahasa Malaysia**
# Pemasangan
## Keperluan
- Python 3.13 atau lebih baharu dengan Tkinter. Pada Debian atau Ubuntu pasang
`python3-tk`.
- `venv` dan `pip`, kedua-duanya sebahagian daripada Python.
- Tiada kebergantungan semasa dijalankan.
## Cipta persekitaran maya tempatan
Windows (PowerShell):
```powershell
python -m venv .venv
.venv/Scripts/Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux dan macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Tambahan `[dev]` memasang pytest dan ruff. Tinggalkannya
(`python -m pip install -e .`) jika anda hanya mahu menjalankan program.
## Semak pemasangan
```bash
python -m pytest
```
+38
View File
@@ -0,0 +1,38 @@
**English** · [Dansk](Installation-da) · [Bahasa Malaysia](Installation-ms)
# Installation
## Requirements
- Python 3.13 or newer with Tkinter. On Debian or Ubuntu install `python3-tk`.
- `venv` and `pip`, both part of Python.
- No runtime dependencies.
## Create a local virtual environment
Windows (PowerShell):
```powershell
python -m venv .venv
.venv/Scripts/Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux and macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
The `[dev]` extra installs pytest and ruff. Leave it out
(`python -m pip install -e .`) if you only want to run the program.
## Check the installation
```bash
python -m pytest
```
+50
View File
@@ -0,0 +1,50 @@
[English](Usage) · **Dansk** · [Bahasa Malaysia](Usage-ms)
# Brug
Start programmet med det virtuelle miljø aktivt:
```bash
python -m password_manager
```
Programmet gemmer sine data i `data.json` i den mappe, du starter det fra.
## Generér og tilføj en adgangskode
1. Skriv webstedet.
2. Tryk på **Generate Password**. En adgangskode indsættes og kopieres til
udklipsholderen.
3. Kontrollér e-mailadressen og tryk på **Add**. Bekræft dialogen, så gemmes
posten.
![Hovedvinduet efter Generate Password](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/main-window.png)
![Bekræftelsen før en post gemmes](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/add-confirmation.png)
Hvis et felt er tomt, vises en advarsel, og intet gemmes.
## Søg
Skriv et websted og tryk på **Search**. Den gemte e-mailadresse og adgangskode
vises.
![Søgningen finder et gemt websted](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/search-found.png)
Hvis der ikke er gemt noget for webstedet, eller datafilen endnu ikke findes,
fortæller en besked det.
![Søgning efter et ukendt websted](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/search-not-found.png)
## Datafilen
`data.json` indeholder én post pr. websted:
```json
{
"example.com": {
"email": "jens@example.com",
"password": "Sample#Pass123"
}
}
```
+49
View File
@@ -0,0 +1,49 @@
[English](Usage) · [Dansk](Usage-da) · **Bahasa Malaysia**
# Penggunaan
Mulakan program dengan persekitaran maya aktif:
```bash
python -m password_manager
```
Program menyimpan datanya dalam `data.json` di folder tempat anda memulakannya.
## Jana dan tambah kata laluan
1. Taip laman web.
2. Tekan **Generate Password**. Kata laluan diisi dan disalin ke papan
keratan.
3. Semak alamat e-mel dan tekan **Add**. Sahkan dialog dan rekod disimpan.
![Tetingkap utama selepas Generate Password](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/main-window.png)
![Pengesahan sebelum rekod disimpan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/add-confirmation.png)
Jika medan dibiarkan kosong, amaran dipaparkan dan tiada apa yang disimpan.
## Carian
Taip laman web dan tekan **Search**. Alamat e-mel dan kata laluan yang disimpan
dipaparkan.
![Carian menemui laman web yang disimpan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/search-found.png)
Jika tiada apa yang disimpan untuk laman web itu, atau fail data belum wujud,
satu mesej memberitahunya.
![Carian untuk laman web yang tidak dikenali](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/search-not-found.png)
## Fail data
`data.json` mengandungi satu rekod bagi setiap laman web:
```json
{
"example.com": {
"email": "jens@example.com",
"password": "Sample#Pass123"
}
}
```
+50
View File
@@ -0,0 +1,50 @@
**English** · [Dansk](Usage-da) · [Bahasa Malaysia](Usage-ms)
# Usage
Start the program with the virtual environment active:
```bash
python -m password_manager
```
The program keeps its data in `data.json` in the folder you start it from.
## Generate and add a password
1. Type the website.
2. Press **Generate Password**. A password is filled in and copied to the
clipboard.
3. Check the e-mail address and press **Add**. Confirm the dialog and the entry
is saved.
![The main window after Generate Password](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/main-window.png)
![The confirmation before an entry is saved](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/add-confirmation.png)
Leaving a field empty shows a warning and saves nothing.
## Search
Type a website and press **Search**. The saved e-mail address and password are
shown.
![Search finds a saved website](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/search-found.png)
If nothing is saved for the website, or no data file exists yet, a message says
so.
![Search for an unknown website](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager/raw/branch/main/docs/images/search-not-found.png)
## The data file
`data.json` holds one record per website:
```json
{
"example.com": {
"email": "jens@example.com",
"password": "Sample#Pass123"
}
}
```
+1
View File
@@ -0,0 +1 @@
Password Manager · day 30 of 100 Days of Code · [Repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/030-password-manager)
+23
View File
@@ -0,0 +1,23 @@
**Password Manager**
**English**
- [Home](Home)
- [Installation](Installation)
- [Usage](Usage)
- [Development](Development)
**Dansk**
- [Hjem](Home-da)
- [Installation](Installation-da)
- [Brug](Usage-da)
- [Udvikling](Development-da)
**Bahasa Malaysia**
- [Laman Utama](Home-ms)
- [Pemasangan](Installation-ms)
- [Penggunaan](Usage-ms)
- [Pembangunan](Development-ms)
+8
View File
@@ -0,0 +1,8 @@
"""@brief Entry point for `python -m password_manager`.
@file __main__.py
"""
from password_manager.main import main
main()
@@ -0,0 +1 @@
"""@brief Package marker."""
+31
View File
@@ -0,0 +1,31 @@
"""@brief Ports the use cases need from the outside.
@file ports.py
"""
from typing import Protocol
from password_manager.domain.password_entry import PasswordEntry
class PasswordRepository(Protocol):
"""@brief Storage of password entries."""
def add(self, entry: PasswordEntry) -> None:
"""@brief Store an entry, keeping every other entry.
@param entry The entry to store; replaces one for the same website.
@throws InvalidDataFileError The stored data has an unexpected format.
"""
...
def find(self, website: str) -> PasswordEntry:
"""@brief Look an entry up.
@param website The website to look for.
@return The stored entry.
@throws DataFileNotFoundError There is no storage yet.
@throws EntryNotFoundError The website has no entry.
@throws InvalidDataFileError The stored data has an unexpected format.
"""
...
@@ -0,0 +1,76 @@
"""@brief Use cases of the password manager.
@file use_cases.py
"""
import random
from password_manager.application.ports import PasswordRepository
from password_manager.domain.errors import EmptyFieldError
from password_manager.domain.password_entry import PasswordEntry
from password_manager.domain.password_generator import generate_password
class SavePasswordEntry:
"""@brief Validate and store a password entry."""
def __init__(self, repository: PasswordRepository) -> None:
"""@brief Create the use case.
@param repository Where entries are stored.
"""
self._repository = repository
def execute(self, website: str, email: str, password: str) -> None:
"""@brief Store the entry.
@param website Website of the entry.
@param email E-mail address or user name.
@param password The password.
@throws EmptyFieldError A field is empty.
"""
if not (website.strip() and email.strip() and password.strip()):
raise EmptyFieldError
self._repository.add(PasswordEntry(website.strip(), email, password))
class FindPasswordEntry:
"""@brief Look up the entry of a website."""
def __init__(self, repository: PasswordRepository) -> None:
"""@brief Create the use case.
@param repository Where entries are stored.
"""
self._repository = repository
def execute(self, website: str) -> PasswordEntry:
"""@brief Find the entry.
@param website Website to look for.
@return The stored entry.
@throws EmptyFieldError The website is empty.
@throws DataFileNotFoundError There is no storage yet.
@throws EntryNotFoundError The website has no entry.
"""
if not website.strip():
raise EmptyFieldError
return self._repository.find(website.strip())
class GeneratePassword:
"""@brief Produce a new random password."""
def __init__(self, rng: random.Random | None = None) -> None:
"""@brief Create the use case.
@param rng Random source; secure by default.
"""
self._rng = rng
def execute(self) -> str:
"""@brief Generate a password.
@return The new password.
"""
return generate_password(self._rng)
+14
View File
@@ -16,6 +16,7 @@ DATA_FILE_PATH = Path(
JSON_INDENT = 4 ##< Indentation of the written JSON, for readability. JSON_INDENT = 4 ##< Indentation of the written JSON, for readability.
KEY_EMAIL = "email" ##< JSON key of an entry's e-mail address. KEY_EMAIL = "email" ##< JSON key of an entry's e-mail address.
KEY_PASSWORD = "password" ##< JSON key of an entry's password. KEY_PASSWORD = "password" ##< JSON key of an entry's password.
FILE_ENCODING = "utf-8" ##< Text encoding of the data file.
# Password generator # Password generator
LETTERS = string.ascii_letters ##< Letters a password may use. LETTERS = string.ascii_letters ##< Letters a password may use.
@@ -36,6 +37,17 @@ DEFAULT_EMAIL = "name@example.com" ##< E-mail address the form starts with.
ENTRY_WIDTH_WIDE = 35 ##< Width in characters of the e-mail and add fields. ENTRY_WIDTH_WIDE = 35 ##< Width in characters of the e-mail and add fields.
ENTRY_WIDTH_NARROW = 21 ##< Width in characters of the website and password fields. ENTRY_WIDTH_NARROW = 21 ##< Width in characters of the website and password fields.
# Logo (a padlock drawn with canvas shapes)
LOGO_BACKGROUND = "white" ##< Canvas background colour.
LOGO_BODY_COLOR = "#2b6cb0" ##< Fill of the padlock body.
LOGO_SHACKLE_COLOR = "#4a5568" ##< Outline colour of the padlock shackle.
LOGO_KEYHOLE_COLOR = "white" ##< Fill of the keyhole.
LOGO_SHACKLE_WIDTH = 12 ##< Line width of the shackle, in pixels.
LOGO_BODY_BOX = (50, 100, 150, 180) ##< Padlock body rectangle (x0, y0, x1, y1).
LOGO_SHACKLE_BOX = (65, 30, 135, 170) ##< Bounding box of the shackle arc.
LOGO_KEYHOLE_BOX = (92, 125, 108, 141) ##< Bounding box of the keyhole circle.
LOGO_KEYHOLE_SLOT = (97, 138, 103, 160) ##< Rectangle of the keyhole slot.
# Labels and messages # Labels and messages
LABEL_WEBSITE = "Website:" LABEL_WEBSITE = "Website:"
LABEL_EMAIL = "Email/Username:" LABEL_EMAIL = "Email/Username:"
@@ -54,3 +66,5 @@ MESSAGE_CONFIRM_SAVE = (
"Password: {password}\nIs it ok to save?" "Password: {password}\nIs it ok to save?"
) )
MESSAGE_DETAILS = "Email: {email}\nPassword: {password}" MESSAGE_DETAILS = "Email: {email}\nPassword: {password}"
MESSAGE_INVALID_DATA_FILE = "The data file has an unexpected format."
MESSAGE_SAVED = "Details for {website} were saved."
+1
View File
@@ -0,0 +1 @@
"""@brief Package marker."""
+32
View File
@@ -0,0 +1,32 @@
"""@brief Errors raised by the business rules of the password manager.
@file errors.py
"""
class PasswordManagerError(Exception):
"""@brief Base class of every error the password manager raises on purpose."""
class EmptyFieldError(PasswordManagerError):
"""@brief A required field was left empty."""
class DataFileNotFoundError(PasswordManagerError):
"""@brief The data file does not exist, so nothing can be looked up."""
class InvalidDataFileError(PasswordManagerError):
"""@brief The data file exists but does not hold the expected structure."""
class EntryNotFoundError(PasswordManagerError):
"""@brief No entry is stored for a website."""
def __init__(self, website: str) -> None:
"""@brief Create the error.
@param website The website that was looked up.
"""
super().__init__(website)
self.website = website
@@ -0,0 +1,15 @@
"""@brief The password entry of the domain.
@file password_entry.py
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class PasswordEntry:
"""@brief The credentials stored for one website."""
website: str ##< The website the credentials belong to.
email: str ##< E-mail address or user name.
password: str ##< The password.
@@ -0,0 +1,29 @@
"""@brief Generation of random passwords.
@file password_generator.py
"""
import random
from password_manager import constants
def generate_password(rng: random.Random | None = None) -> str:
"""@brief Build a shuffled password of letters, symbols and digits.
@param rng Random source; a cryptographically secure one when omitted.
@return The generated password; each class count is within its bounds.
"""
source = rng if rng is not None else random.SystemRandom()
characters = (
_pick(source, constants.LETTERS, constants.MIN_LETTERS, constants.MAX_LETTERS)
+ _pick(source, constants.SYMBOLS, constants.MIN_SYMBOLS, constants.MAX_SYMBOLS)
+ _pick(source, constants.DIGITS, constants.MIN_DIGITS, constants.MAX_DIGITS)
)
source.shuffle(characters)
return "".join(characters)
def _pick(source: random.Random, alphabet: str, low: int, high: int) -> list[str]:
count = source.randint(low, high)
return [source.choice(alphabet) for _ in range(count)]
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1,79 @@
"""@brief Password repository that keeps entries in a JSON file.
@file json_password_repository.py
"""
import json
from pathlib import Path
from typing import Any
from password_manager import constants
from password_manager.domain.errors import (
DataFileNotFoundError,
EntryNotFoundError,
InvalidDataFileError,
)
from password_manager.domain.password_entry import PasswordEntry
class JsonPasswordRepository:
"""@brief Stores entries as {website: {"email": ..., "password": ...}}."""
def __init__(self, path: Path) -> None:
"""@brief Create the repository.
@param path Location of the JSON file.
"""
self._path = path
def add(self, entry: PasswordEntry) -> None:
"""@brief Store an entry; a missing or empty file is created or rewritten.
@param entry The entry to store.
@throws InvalidDataFileError The file holds JSON that is not an object.
"""
try:
data = self._load()
except (FileNotFoundError, json.JSONDecodeError):
data = {}
data[entry.website] = {
constants.KEY_EMAIL: entry.email,
constants.KEY_PASSWORD: entry.password,
}
with self._path.open("w", encoding=constants.FILE_ENCODING, newline="\n") as f:
json.dump(data, f, indent=constants.JSON_INDENT)
def find(self, website: str) -> PasswordEntry:
"""@brief Look an entry up.
@param website Website to look for.
@return The stored entry.
@throws DataFileNotFoundError The file does not exist.
@throws EntryNotFoundError The website has no entry (or the file is empty).
@throws InvalidDataFileError The file has an unexpected structure.
"""
try:
data = self._load()
except FileNotFoundError as error:
raise DataFileNotFoundError from error
except json.JSONDecodeError as error:
raise EntryNotFoundError(website) from error
if website not in data:
raise EntryNotFoundError(website)
details = data[website]
try:
return PasswordEntry(
website,
details[constants.KEY_EMAIL],
details[constants.KEY_PASSWORD],
)
except (KeyError, TypeError) as error:
raise InvalidDataFileError from error
# Any: the values of a JSON object are unchecked until `find` validates them.
def _load(self) -> dict[str, Any]:
with self._path.open("r", encoding=constants.FILE_ENCODING) as f:
data = json.load(f)
if not isinstance(data, dict):
raise InvalidDataFileError
return data
+39
View File
@@ -0,0 +1,39 @@
"""@brief Composition root: wires the layers together and runs the window.
@file main.py
"""
import tkinter as tk
from password_manager import constants
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.infrastructure.persistence.json_password_repository import (
JsonPasswordRepository,
)
from password_manager.presentation.features.password_manager import (
password_manager_view as view_module,
)
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
def main() -> None:
"""@brief Build the application and run the Tk main loop."""
repository = JsonPasswordRepository(constants.DATA_FILE_PATH)
view_model = view_model_module.PasswordManagerViewModel(
SavePasswordEntry(repository),
FindPasswordEntry(repository),
GeneratePassword(),
)
root = tk.Tk()
view_module.PasswordManagerView(root, view_model)
root.mainloop()
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1 @@
"""@brief Package marker."""
@@ -0,0 +1,156 @@
"""@brief Tkinter view of the password manager screen.
@file password_manager_view.py
"""
import tkinter as tk
from tkinter import messagebox
from password_manager import constants
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
class PasswordManagerView:
"""@brief The window: shows the fields and raises events to the view model."""
def __init__(
self, root: tk.Tk, view_model: view_model_module.PasswordManagerViewModel
) -> None:
"""@brief Build the widgets on a root window.
@param root The Tk root window.
@param view_model The view model the events are forwarded to.
"""
self._root = root
self._view_model = view_model
self._view_model.bind_ui(self._copy_to_clipboard, self._confirm_save)
root.title(constants.WINDOW_TITLE)
root.config(padx=constants.WINDOW_PADDING, pady=constants.WINDOW_PADDING)
self._build_logo()
self._build_form()
@property
def website_entry(self) -> tk.Entry:
"""@return The website input."""
return self._website_entry
@property
def email_entry(self) -> tk.Entry:
"""@return The e-mail input."""
return self._email_entry
@property
def password_entry(self) -> tk.Entry:
"""@return The password input."""
return self._password_entry
def generate_password(self) -> None:
"""@brief Fill the password field with a new password (and copy it)."""
password = self._view_model.generate_password()
self._password_entry.delete(0, tk.END)
self._password_entry.insert(0, password)
def save(self) -> None:
"""@brief Ask the view model to store the entered details."""
result = self._view_model.save(
self._website_entry.get(),
self._email_entry.get(),
self._password_entry.get(),
)
if result is None:
return
self._show(result)
if result.clear_fields:
self._clear_fields()
def find_password(self) -> None:
"""@brief Show the stored details of the entered website."""
self._show(self._view_model.find_password(self._website_entry.get()))
def _build_logo(self) -> None:
size = constants.LOGO_SIZE
canvas = tk.Canvas(
self._root,
width=size,
height=size,
bg=constants.LOGO_BACKGROUND,
highlightthickness=0,
)
canvas.create_arc(
*constants.LOGO_SHACKLE_BOX,
start=0,
extent=180,
style=tk.ARC,
outline=constants.LOGO_SHACKLE_COLOR,
width=constants.LOGO_SHACKLE_WIDTH,
)
canvas.create_rectangle(
*constants.LOGO_BODY_BOX,
fill=constants.LOGO_BODY_COLOR,
outline=constants.LOGO_BODY_COLOR,
)
canvas.create_oval(
*constants.LOGO_KEYHOLE_BOX,
fill=constants.LOGO_KEYHOLE_COLOR,
outline=constants.LOGO_KEYHOLE_COLOR,
)
canvas.create_rectangle(
*constants.LOGO_KEYHOLE_SLOT,
fill=constants.LOGO_KEYHOLE_COLOR,
outline=constants.LOGO_KEYHOLE_COLOR,
)
canvas.grid(row=0, column=1)
def _build_form(self) -> None:
root = self._root
tk.Label(root, text=constants.LABEL_WEBSITE).grid(row=1, column=0)
tk.Label(root, text=constants.LABEL_EMAIL).grid(row=2, column=0)
tk.Label(root, text=constants.LABEL_PASSWORD).grid(row=3, column=0)
self._website_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW)
self._website_entry.grid(row=1, column=1, sticky="EW")
self._website_entry.focus()
self._email_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_WIDE)
self._email_entry.grid(row=2, column=1, columnspan=2, sticky="EW")
self._email_entry.insert(0, constants.DEFAULT_EMAIL)
self._password_entry = tk.Entry(root, width=constants.ENTRY_WIDTH_NARROW)
self._password_entry.grid(row=3, column=1, sticky="EW")
self._search_button = tk.Button(
root, text=constants.BUTTON_SEARCH, command=self.find_password
)
self._search_button.grid(row=1, column=2, sticky="EW")
self._generate_button = tk.Button(
root, text=constants.BUTTON_GENERATE, command=self.generate_password
)
self._generate_button.grid(row=3, column=2, sticky="EW")
self._add_button = tk.Button(
root,
text=constants.BUTTON_ADD,
width=constants.ENTRY_WIDTH_WIDE,
command=self.save,
)
self._add_button.grid(row=4, column=1, columnspan=2, sticky="EW")
def _copy_to_clipboard(self, text: str) -> None:
self._root.clipboard_clear()
self._root.clipboard_append(text)
@staticmethod
def _confirm_save(message: str) -> bool:
return bool(
messagebox.askokcancel(title=constants.WINDOW_TITLE, message=message)
)
@staticmethod
def _show(result: view_model_module.ActionResult) -> None:
if result.is_error:
messagebox.showwarning(title=result.title, message=result.message)
else:
messagebox.showinfo(title=result.title, message=result.message)
def _clear_fields(self) -> None:
self._website_entry.delete(0, tk.END)
self._password_entry.delete(0, tk.END)
@@ -0,0 +1,154 @@
"""@brief View model of the password manager screen (no tkinter here).
@file password_manager_view_model.py
"""
from collections.abc import Callable
from dataclasses import dataclass
from password_manager import constants
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.domain.errors import (
DataFileNotFoundError,
EmptyFieldError,
EntryNotFoundError,
InvalidDataFileError,
)
@dataclass(frozen=True, slots=True)
class ActionResult:
"""@brief What the view shows after an action."""
title: str ##< Title of the message box.
message: str ##< Text of the message box.
is_error: bool ##< True when the box is a warning rather than information.
clear_fields: bool = False ##< True when the view clears website and password.
def _ignore_clipboard(text: str) -> None:
"""@brief Default clipboard: discards the text until the view binds one."""
def _decline_save(message: str) -> bool:
"""@brief Default confirmation: declines until the view binds a dialog."""
return False
class PasswordManagerViewModel:
"""@brief Data and commands of the password manager screen."""
def __init__(
self,
save_entry: SavePasswordEntry,
find_entry: FindPasswordEntry,
generate: GeneratePassword,
copy_to_clipboard: Callable[[str], None] = _ignore_clipboard,
confirm_save: Callable[[str], bool] = _decline_save,
) -> None:
"""@brief Create the view model.
@param save_entry Use case that stores an entry.
@param find_entry Use case that looks an entry up.
@param generate Use case that generates a password.
@param copy_to_clipboard Puts text on the clipboard.
@param confirm_save Asks the user to confirm a save; True means ok.
"""
self._save_entry = save_entry
self._find_entry = find_entry
self._generate = generate
self._copy_to_clipboard = copy_to_clipboard
self._confirm_save = confirm_save
def bind_ui(
self,
copy_to_clipboard: Callable[[str], None],
confirm_save: Callable[[str], bool],
) -> None:
"""@brief Hand the view's clipboard and confirmation dialog to the model.
@param copy_to_clipboard Puts text on the clipboard.
@param confirm_save Asks the user to confirm a save; True means ok.
"""
self._copy_to_clipboard = copy_to_clipboard
self._confirm_save = confirm_save
def generate_password(self) -> str:
"""@brief Generate a password and copy it to the clipboard.
@return The generated password.
"""
password = self._generate.execute()
self._copy_to_clipboard(password)
return password
def save(self, website: str, email: str, password: str) -> ActionResult | None:
"""@brief Confirm and store the entry.
@param website Website field.
@param email E-mail field.
@param password Password field.
@return A result to show, or None when the user cancelled.
"""
if not (website.strip() and email.strip() and password.strip()):
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
)
question = constants.MESSAGE_CONFIRM_SAVE.format(email=email, password=password)
if not self._confirm_save(question):
return None
try:
self._save_entry.execute(website, email, password)
except EmptyFieldError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
)
except InvalidDataFileError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True
)
else:
return ActionResult(
constants.TITLE_SAVED,
constants.MESSAGE_SAVED.format(website=website.strip()),
False,
clear_fields=True,
)
def find_password(self, website: str) -> ActionResult:
"""@brief Look up the credentials of a website.
@param website Website field.
@return The result to show.
"""
try:
entry = self._find_entry.execute(website)
except EmptyFieldError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_EMPTY_FIELDS, True
)
except DataFileNotFoundError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_NO_DATA_FILE, True
)
except EntryNotFoundError as error:
return ActionResult(
constants.TITLE_OOPS,
constants.MESSAGE_NO_DETAILS.format(website=error.website),
True,
)
except InvalidDataFileError:
return ActionResult(
constants.TITLE_OOPS, constants.MESSAGE_INVALID_DATA_FILE, True
)
return ActionResult(
entry.website,
constants.MESSAGE_DETAILS.format(
email=entry.email, password=entry.password
),
False,
)
+65
View File
@@ -0,0 +1,65 @@
import tkinter as tk
from collections.abc import Iterator
from pathlib import Path
import pytest
from password_manager import constants
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.infrastructure.persistence.json_password_repository import (
JsonPasswordRepository,
)
from password_manager.presentation.features.password_manager import (
password_manager_view as view_module,
)
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
@pytest.fixture
def root() -> Iterator[tk.Tk]:
try:
window = tk.Tk()
except tk.TclError:
pytest.skip("no display available")
window.withdraw()
yield window
window.destroy()
def test_save_then_search_through_window(
root: tk.Tk, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
shown: list[str] = []
monkeypatch.setattr(view_module.messagebox, "askokcancel", lambda **_: True)
monkeypatch.setattr(
view_module.messagebox, "showinfo", lambda **kw: shown.append(kw["message"])
)
repository = JsonPasswordRepository(tmp_path / "data.json")
view = view_module.PasswordManagerView(
root,
view_model_module.PasswordManagerViewModel(
SavePasswordEntry(repository),
FindPasswordEntry(repository),
GeneratePassword(),
),
)
view.website_entry.insert(0, "example.com")
view.generate_password()
password = view.password_entry.get()
assert root.clipboard_get() == password
view.save()
assert view.website_entry.get() == ""
assert view.password_entry.get() == ""
view.website_entry.insert(0, "example.com")
view.find_password()
assert shown[-1] == constants.MESSAGE_DETAILS.format(
email=constants.DEFAULT_EMAIL, password=password
)
@@ -0,0 +1,111 @@
import json
from pathlib import Path
import pytest
from password_manager.domain.errors import (
DataFileNotFoundError,
EntryNotFoundError,
InvalidDataFileError,
)
from password_manager.domain.password_entry import PasswordEntry
from password_manager.infrastructure.persistence.json_password_repository import (
JsonPasswordRepository,
)
ENTRY = PasswordEntry("example.com", "me@x.org", "pw1")
def test_add_writes_nested_json_and_reads_back(tmp_path: Path) -> None:
path = tmp_path / "data.json"
repository = JsonPasswordRepository(path)
repository.add(ENTRY)
assert json.loads(path.read_text("utf-8")) == {
"example.com": {"email": "me@x.org", "password": "pw1"}
}
assert repository.find("example.com") == ENTRY
def test_add_uses_indent_of_four(tmp_path: Path) -> None:
path = tmp_path / "data.json"
JsonPasswordRepository(path).add(ENTRY)
assert '\n "example.com"' in path.read_text("utf-8")
def test_add_keeps_existing_entries(tmp_path: Path) -> None:
repository = JsonPasswordRepository(tmp_path / "data.json")
other = PasswordEntry("other.org", "o@x.org", "pw2")
repository.add(ENTRY)
repository.add(other)
assert repository.find("example.com") == ENTRY
assert repository.find("other.org") == other
def test_add_creates_missing_file(tmp_path: Path) -> None:
path = tmp_path / "data.json"
assert not path.exists()
JsonPasswordRepository(path).add(ENTRY)
assert path.exists()
def test_add_rewrites_empty_file(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text("", encoding="utf-8")
JsonPasswordRepository(path).add(ENTRY)
assert JsonPasswordRepository(path).find("example.com") == ENTRY
def test_add_rewrites_invalid_json(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text("{not json", encoding="utf-8")
JsonPasswordRepository(path).add(ENTRY)
assert JsonPasswordRepository(path).find("example.com") == ENTRY
def test_add_refuses_json_that_is_not_an_object(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text("[1, 2]", encoding="utf-8")
with pytest.raises(InvalidDataFileError):
JsonPasswordRepository(path).add(ENTRY)
assert path.read_text("utf-8") == "[1, 2]"
def test_find_raises_when_file_missing(tmp_path: Path) -> None:
with pytest.raises(DataFileNotFoundError):
JsonPasswordRepository(tmp_path / "data.json").find("example.com")
def test_find_raises_when_website_absent(tmp_path: Path) -> None:
repository = JsonPasswordRepository(tmp_path / "data.json")
repository.add(ENTRY)
with pytest.raises(EntryNotFoundError):
repository.find("nope.com")
def test_find_raises_not_found_on_empty_file(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text("", encoding="utf-8")
with pytest.raises(EntryNotFoundError):
JsonPasswordRepository(path).find("example.com")
def test_find_raises_when_entry_lacks_keys(tmp_path: Path) -> None:
path = tmp_path / "data.json"
path.write_text('{"example.com": {"email": "e"}}', encoding="utf-8")
with pytest.raises(InvalidDataFileError):
JsonPasswordRepository(path).find("example.com")
+68
View File
@@ -0,0 +1,68 @@
import random
import pytest
from conftest import FakePasswordRepository
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.domain.errors import (
DataFileNotFoundError,
EmptyFieldError,
EntryNotFoundError,
)
from password_manager.domain.password_entry import PasswordEntry
def test_save_stores_entry(repository: FakePasswordRepository) -> None:
SavePasswordEntry(repository).execute("example.com", "me@x.org", "pw")
assert repository.entries["example.com"] == PasswordEntry(
"example.com", "me@x.org", "pw"
)
@pytest.mark.parametrize(
("website", "email", "password"),
[("", "e", "p"), ("w", "", "p"), ("w", "e", ""), (" ", "e", "p")],
)
def test_save_rejects_empty_field(
repository: FakePasswordRepository, website: str, email: str, password: str
) -> None:
with pytest.raises(EmptyFieldError):
SavePasswordEntry(repository).execute(website, email, password)
assert repository.entries == {}
def test_find_returns_entry(repository: FakePasswordRepository) -> None:
SavePasswordEntry(repository).execute("example.com", "me@x.org", "pw")
entry = FindPasswordEntry(repository).execute("example.com")
assert entry.email == "me@x.org"
def test_find_raises_when_website_unknown(
repository: FakePasswordRepository,
) -> None:
with pytest.raises(EntryNotFoundError):
FindPasswordEntry(repository).execute("nope.com")
def test_find_raises_when_no_data_file() -> None:
with pytest.raises(DataFileNotFoundError):
FindPasswordEntry(FakePasswordRepository(has_file=False)).execute("a.com")
def test_find_rejects_empty_website(repository: FakePasswordRepository) -> None:
with pytest.raises(EmptyFieldError):
FindPasswordEntry(repository).execute("")
def test_generate_uses_given_random_source() -> None:
first = GeneratePassword(random.Random(1)).execute()
second = GeneratePassword(random.Random(1)).execute()
assert first == second
+30
View File
@@ -0,0 +1,30 @@
"""Shared fakes of the ports for the unit tests."""
import pytest
from password_manager.domain.errors import DataFileNotFoundError, EntryNotFoundError
from password_manager.domain.password_entry import PasswordEntry
class FakePasswordRepository:
"""In-memory PasswordRepository; `has_file` False mimics a missing file."""
def __init__(self, has_file: bool = True) -> None:
self.entries: dict[str, PasswordEntry] = {}
self.has_file = has_file
def add(self, entry: PasswordEntry) -> None:
self.entries[entry.website] = entry
self.has_file = True
def find(self, website: str) -> PasswordEntry:
if not self.has_file:
raise DataFileNotFoundError
if website not in self.entries:
raise EntryNotFoundError(website)
return self.entries[website]
@pytest.fixture
def repository() -> FakePasswordRepository:
return FakePasswordRepository()
@@ -0,0 +1,35 @@
import random
from password_manager import constants
from password_manager.domain.password_generator import generate_password
def _count(password: str, alphabet: str) -> int:
return sum(1 for char in password if char in alphabet)
def test_generate_password_length_within_bounds() -> None:
low = constants.MIN_LETTERS + constants.MIN_SYMBOLS + constants.MIN_DIGITS
high = constants.MAX_LETTERS + constants.MAX_SYMBOLS + constants.MAX_DIGITS
for seed in range(50):
assert low <= len(generate_password(random.Random(seed))) <= high
def test_generate_password_uses_all_three_classes_within_bounds() -> None:
for seed in range(50):
password = generate_password(random.Random(seed))
letters = _count(password, constants.LETTERS)
symbols = _count(password, constants.SYMBOLS)
digits = _count(password, constants.DIGITS)
assert constants.MIN_LETTERS <= letters <= constants.MAX_LETTERS
assert constants.MIN_SYMBOLS <= symbols <= constants.MAX_SYMBOLS
assert constants.MIN_DIGITS <= digits <= constants.MAX_DIGITS
assert letters + symbols + digits == len(password)
def test_generate_password_is_reproducible_with_same_seed() -> None:
assert generate_password(random.Random(7)) == generate_password(random.Random(7))
def test_generate_password_default_source_works() -> None:
assert generate_password()
@@ -0,0 +1,121 @@
import random
from conftest import FakePasswordRepository
from password_manager import constants
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
class FakeUi:
"""Records clipboard writes and questions, answers with a fixed choice."""
def __init__(self, answer: bool = True) -> None:
self.answer = answer
self.clipboard: list[str] = []
self.questions: list[str] = []
def copy(self, text: str) -> None:
self.clipboard.append(text)
def confirm(self, message: str) -> bool:
self.questions.append(message)
return self.answer
def _make(
repository: FakePasswordRepository, ui: FakeUi
) -> view_model_module.PasswordManagerViewModel:
return view_model_module.PasswordManagerViewModel(
SavePasswordEntry(repository),
FindPasswordEntry(repository),
GeneratePassword(random.Random(3)),
ui.copy,
ui.confirm,
)
def test_generate_password_copies_to_clipboard(
repository: FakePasswordRepository,
) -> None:
ui = FakeUi()
password = _make(repository, ui).generate_password()
assert ui.clipboard == [password]
def test_save_stores_and_clears_fields_after_confirmation(
repository: FakePasswordRepository,
) -> None:
ui = FakeUi(answer=True)
result = _make(repository, ui).save("a.com", "me@x.org", "pw")
assert result is not None
assert not result.is_error
assert result.clear_fields
assert "a.com" in repository.entries
assert "me@x.org" in ui.questions[0]
def test_save_does_nothing_when_user_cancels(
repository: FakePasswordRepository,
) -> None:
result = _make(repository, FakeUi(answer=False)).save("a.com", "e", "pw")
assert result is None
assert repository.entries == {}
def test_save_refuses_empty_field_without_asking(
repository: FakePasswordRepository,
) -> None:
ui = FakeUi()
result = _make(repository, ui).save("", "e", "pw")
assert result is not None
assert result.is_error
assert result.message == constants.MESSAGE_EMPTY_FIELDS
assert not result.clear_fields
assert ui.questions == []
def test_find_password_found(repository: FakePasswordRepository) -> None:
model = _make(repository, FakeUi())
model.save("a.com", "me@x.org", "pw")
result = model.find_password("a.com")
assert not result.is_error
assert result.message == constants.MESSAGE_DETAILS.format(
email="me@x.org", password="pw"
)
def test_find_password_website_not_present(
repository: FakePasswordRepository,
) -> None:
result = _make(repository, FakeUi()).find_password("b.com")
assert result.is_error
assert result.message == constants.MESSAGE_NO_DETAILS.format(website="b.com")
def test_find_password_no_data_file() -> None:
model = _make(FakePasswordRepository(has_file=False), FakeUi())
assert model.find_password("a.com").message == constants.MESSAGE_NO_DATA_FILE
def test_find_password_empty_website(repository: FakePasswordRepository) -> None:
result = _make(repository, FakeUi()).find_password(" ")
assert result.message == constants.MESSAGE_EMPTY_FIELDS
+266
View File
@@ -0,0 +1,266 @@
"""@brief Capture the README and wiki screenshots of the real Tkinter window.
@file capture_screenshots.py
@details Windows only (GDI through ctypes), standard library only. Each state
runs in its own process, uses a temporary data file and never touches the
user's `data.json`. Only the windows of the program itself are rendered
(`PrintWindow`) onto a neutral canvas, so nothing from the desktop appears.
Usage, from the repository root with the venv active:
python tools/capture_screenshots.py # writes docs/images/*.png
python tools/capture_screenshots.py --out DIR # another output folder
"""
import argparse
import ctypes
import os
import struct
import subprocess
import sys
import tempfile
import tkinter as tk
import zlib
from ctypes import wintypes
from pathlib import Path
STATES = {
"main-window": "Window after Generate Password",
"add-confirmation": "Confirmation before an entry is saved",
"search-found": "Search finds a saved website",
"search-not-found": "Search for an unknown website",
}
SAMPLE_WEBSITE = "example.com"
SAMPLE_EMAIL = "jens@example.com"
SAMPLE_PASSWORD = "Sample#Pass123"
UNKNOWN_WEBSITE = "unknown.org"
PADDING = 24
CANVAS_PIXEL = bytes([0xEE, 0xEE, 0xEE, 0xFF])
CAPTURE_DELAY_MS = 900
PW_RENDERFULLCONTENT = 2
DWMWA_EXTENDED_FRAME_BOUNDS = 9
class BitmapInfoHeader(ctypes.Structure):
"""@brief Win32 BITMAPINFOHEADER."""
_fields_ = [
("biSize", wintypes.DWORD),
("biWidth", wintypes.LONG),
("biHeight", wintypes.LONG),
("biPlanes", wintypes.WORD),
("biBitCount", wintypes.WORD),
("biCompression", wintypes.DWORD),
("biSizeImage", wintypes.DWORD),
("biXPelsPerMeter", wintypes.LONG),
("biYPelsPerMeter", wintypes.LONG),
("biClrUsed", wintypes.DWORD),
("biClrImportant", wintypes.DWORD),
]
def write_png(path: Path, width: int, height: int, bgra: bytes) -> None:
"""@brief Write a BGRA pixel buffer as an RGB PNG file.
@param path Output file.
@param width Image width in pixels.
@param height Image height in pixels.
@param bgra Raw pixels, 4 bytes each, top row first.
"""
stride = width * 4
raw = bytearray()
for y in range(height):
row = bgra[y * stride : (y + 1) * stride]
raw.append(0)
raw += bytes(
channel
for x in range(width)
for channel in (row[x * 4 + 2], row[x * 4 + 1], row[x * 4])
)
def chunk(kind: bytes, data: bytes) -> bytes:
body = kind + data
checksum = zlib.crc32(body) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + body + struct.pack(">I", checksum)
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ chunk(b"IEND", b"")
)
path.write_bytes(png)
def render_window(hwnd: int, width: int, height: int) -> bytes:
"""@brief Render one window's own pixels (never the desktop behind it).
@param hwnd Window handle.
@param width Window width in pixels.
@param height Window height in pixels.
@return Raw BGRA pixels, top row first.
"""
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
screen_dc = user32.GetDC(0)
memory_dc = gdi32.CreateCompatibleDC(screen_dc)
bitmap = gdi32.CreateCompatibleBitmap(screen_dc, width, height)
gdi32.SelectObject(memory_dc, bitmap)
user32.PrintWindow(hwnd, memory_dc, PW_RENDERFULLCONTENT)
info = BitmapInfoHeader(
ctypes.sizeof(BitmapInfoHeader), width, -height, 1, 32, 0, 0, 0, 0, 0, 0
)
buffer = ctypes.create_string_buffer(width * height * 4)
gdi32.GetDIBits(memory_dc, bitmap, 0, height, buffer, ctypes.byref(info), 0)
gdi32.DeleteObject(bitmap)
gdi32.DeleteDC(memory_dc)
user32.ReleaseDC(0, screen_dc)
return buffer.raw
def own_windows() -> list[int]:
"""@brief Visible top-level windows of this process, topmost first.
@return Window handles.
"""
user32 = ctypes.windll.user32
found: list[int] = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def visit(hwnd: int, _lparam: int) -> bool:
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value == os.getpid() and user32.IsWindowVisible(hwnd):
found.append(hwnd)
return True
user32.EnumWindows(callback_type(visit), 0)
return found
def frame_and_window(hwnd: int) -> tuple[wintypes.RECT, wintypes.RECT]:
"""@brief Visible frame and full window rectangle of a window.
@param hwnd Window handle.
@return The extended frame bounds and the GetWindowRect rectangle.
"""
frame, outer = wintypes.RECT(), wintypes.RECT()
ctypes.windll.dwmapi.DwmGetWindowAttribute(
hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, ctypes.byref(frame), ctypes.sizeof(frame)
)
ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(outer))
return frame, outer
def compose_windows() -> tuple[int, int, bytes]:
"""@brief Render this process's windows side by side on a neutral canvas.
@return Canvas width, height and BGRA pixels.
"""
pieces = []
for hwnd in own_windows():
frame, outer = frame_and_window(hwnd)
width, height = outer.right - outer.left, outer.bottom - outer.top
pixels = render_window(hwnd, width, height)
offset_x, offset_y = frame.left - outer.left, frame.top - outer.top
piece_width = frame.right - frame.left
piece_height = frame.bottom - frame.top
rows = [
pixels[((offset_y + y) * width + offset_x) * 4 :][: piece_width * 4]
for y in range(piece_height)
]
pieces.append((frame.left, frame.top, piece_width, piece_height, rows))
left = min(piece[0] for piece in pieces) - PADDING
top = min(piece[1] for piece in pieces) - PADDING
right = max(piece[0] + piece[2] for piece in pieces) + PADDING
bottom = max(piece[1] + piece[3] for piece in pieces) + PADDING
width, height = right - left, bottom - top
canvas = [bytearray(CANVAS_PIXEL * width) for _ in range(height)]
for x, y, piece_width, _piece_height, rows in reversed(pieces):
for index, row in enumerate(rows):
start = (x - left) * 4
canvas[y - top + index][start : start + piece_width * 4] = row
return width, height, b"".join(bytes(row) for row in canvas)
def run_state(state: str, out_file: Path) -> None:
"""@brief Show the window in one state, capture it and exit the process.
@param state One of the keys of STATES.
@param out_file PNG file to write.
"""
ctypes.windll.user32.SetProcessDPIAware()
from password_manager.application.use_cases import (
FindPasswordEntry,
GeneratePassword,
SavePasswordEntry,
)
from password_manager.domain.password_entry import PasswordEntry
from password_manager.infrastructure.persistence.json_password_repository import (
JsonPasswordRepository,
)
from password_manager.presentation.features.password_manager import (
password_manager_view as view_module,
)
from password_manager.presentation.features.password_manager import (
password_manager_view_model as view_model_module,
)
with tempfile.TemporaryDirectory() as folder:
repository = JsonPasswordRepository(Path(folder) / "data.json")
repository.add(PasswordEntry(SAMPLE_WEBSITE, SAMPLE_EMAIL, SAMPLE_PASSWORD))
view_model = view_model_module.PasswordManagerViewModel(
SavePasswordEntry(repository),
FindPasswordEntry(repository),
GeneratePassword(),
)
root = tk.Tk()
view = view_module.PasswordManagerView(root, view_model)
root.geometry("+120+120")
def capture() -> None:
width, height, pixels = compose_windows()
write_png(out_file, width, height, pixels)
os._exit(0)
def act() -> None:
website = UNKNOWN_WEBSITE if state == "search-not-found" else SAMPLE_WEBSITE
view.website_entry.insert(0, website)
if state == "main-window":
view.generate_password()
root.after(CAPTURE_DELAY_MS, capture)
return
root.after(CAPTURE_DELAY_MS, capture)
if state == "add-confirmation":
view.password_entry.insert(0, SAMPLE_PASSWORD)
view.save()
else:
view.find_password()
root.after(400, act)
root.mainloop()
def main() -> None:
"""@brief Capture every state, each in a child process."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=Path("docs/images"))
parser.add_argument("--state", choices=sorted(STATES), help="internal use")
parser.add_argument("--file", type=Path, help="internal use")
args = parser.parse_args()
if args.state:
run_state(args.state, args.file)
return
args.out.mkdir(parents=True, exist_ok=True)
for state in STATES:
target = args.out / f"{state}.png"
subprocess.run(
[sys.executable, __file__, "--state", state, "--file", str(target)],
check=True,
timeout=60,
)
print(f"wrote {target}")
if __name__ == "__main__":
main()