From bc513810ece2506d5ab63c1d4c603803c2ad415e Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 15 Oct 2026 00:32:14 +0800 Subject: [PATCH 1/2] MIL-009: Executable with PyInstaller Review record RC-048 (Go, autonomous mode, not independent). Task: MIL-009#1 Task: MIL-009#2 Task: MIL-009#3 Task: MIL-009#4 Task: MIL-009#5 Task: MIL-009#6 Task: MIL-009#7 Task: MIL-009#8 --- .gitignore | 3 + README.md | 12 +- docs/artifact-registry.md | 2 +- docs/images/screenshot-level.png | Bin 12278 -> 13001 bytes docs/sqa/reviews/rc-048-py-mil-009.md | 63 +++++++++++ docs/sqa/traceability-matrix.md | 3 + docs/wiki/Install-da.md | 14 +++ docs/wiki/Install-ms.md | 14 +++ docs/wiki/Install.md | 14 +++ pyproject.toml | 4 +- src/flash_card/constants.py | 24 +++- src/flash_card/presentation/app.py | 28 ++++- .../presentation/assets/flash_card.ico | Bin 0 -> 4193 bytes .../presentation/features/level/level_view.py | 2 + src/flash_card/runtime.py | 72 ++++++++++++ tests/e2e/test_level_window.py | 16 +++ tests/unit/test_app.py | 47 ++++++++ tests/unit/test_build_executable.py | 94 +++++++++++++++ tests/unit/test_constants.py | 24 ++++ tests/unit/test_generate_images.py | 16 +++ tests/unit/test_runtime.py | 45 ++++++++ tools/build_executable.py | 107 ++++++++++++++++++ tools/generate_images.py | 48 ++++++-- 23 files changed, 631 insertions(+), 21 deletions(-) create mode 100644 docs/sqa/reviews/rc-048-py-mil-009.md create mode 100644 src/flash_card/presentation/assets/flash_card.ico create mode 100644 src/flash_card/runtime.py create mode 100644 tests/unit/test_build_executable.py create mode 100644 tests/unit/test_runtime.py create mode 100644 tools/build_executable.py diff --git a/.gitignore b/.gitignore index 95b423f..7411f57 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ dmypy.json *.swp .DS_Store Thumbs.db + +# PyInstaller spec files +*.spec diff --git a/README.md b/README.md index 094c764..f235e20 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The program first asks for a level: the first 100 words, the first 1000 words or | `tests/` | `pytest` tests: `unit`, `integration`, `e2e` | | `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` | | `docs/` | Project documents (business case, plan, design, review records), `docs/wiki/` and `docs/images/` | -| `tools/` | Helper scripts: `generate_images.py` and `capture_screenshots.py` | +| `tools/` | Helper scripts: `generate_images.py`, `capture_screenshots.py` and `build_executable.py` | | `Doxyfile` | Doxygen configuration for the source code documentation | The core program uses only the Python standard library. Spoken Malay words are an optional extra. @@ -58,6 +58,7 @@ The wiki ([English][wiki-en], [Dansk][wiki-da], [Bahasa Malaysia][wiki-ms]) expl | Python 3.13 or later, with Tkinter | The program and its window (Tkinter is part of the standard Python installers for Windows and macOS; on Debian or Ubuntu install `python3-tk`) | | `git` | To clone the repository | | Doxygen (optional) | To build the source code documentation | +| `pyinstaller` (optional, extra `build`) | To build a single executable file (see Run) | | `gTTS` and `playsound==1.2.2` (optional, extra `speech`) | To hear the Malay word; needs a network connection | ## 🛠️ Setup @@ -103,6 +104,15 @@ The program is run from the activated `.venv`. The window opens with the level s Known words stay known at every level: the level only limits which of the words you do not know yet come up. To start again, delete `data/words_to_learn.csv`. +### Executable (Windows, no Python needed to run it) + +```powershell +python -m pip install -e ".[build,speech]" +python tools/build_executable.py +``` + +The script builds `dist/flash-card.exe`: one file, no console window, with the images, the word list and the spoken word inside. Open it by double-clicking. It keeps your progress in a `data/` folder next to the executable (`data/words_to_learn.csv`); a `data/ms-da.csv` next to it replaces the word list inside. Add `--no-speech` to the build script to leave the speech packages out. The executable is a build output and is not committed; build it on the system you want to run it on (Windows, Linux or macOS). + ## 🧪 Tests ```bash diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index e782aa8..7d1a48d 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g. | DCD | Design Class Diagram | docs/dcd.md | 002 | | DICT | Domain Dictionary | docs/dictionary.md | 002 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0005 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 048 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 049 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/images/screenshot-level.png b/docs/images/screenshot-level.png index f61d040c2eb23ee534c7febcc115b3fe9470d1f0..4d976ada18d9822bd3dbf31f7223a9af985f90da 100644 GIT binary patch literal 13001 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%0ADByFyTNs>e|dMcPoVPSwCdozjNDzfwn=JrZT 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 V7P)-q-{%Jy>6_oGy5Su6KLG6(pT7VA diff --git a/docs/sqa/reviews/rc-048-py-mil-009.md b/docs/sqa/reviews/rc-048-py-mil-009.md new file mode 100644 index 0000000..412b7da --- /dev/null +++ b/docs/sqa/reviews/rc-048-py-mil-009.md @@ -0,0 +1,63 @@ +# RC-048: Review of PY-MIL-009 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-048 | +| CrossReference | [PY-MIL-009], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [PY-MIL-009] +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type) +- 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 | PEP 8 casing holds in `runtime.py`, `build_executable.py` and the changed modules; ruff rule set N passes. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`resource_root`, `executable_dir`, `build_arguments`, `resolve_data_files`). | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 71 files with no suppressions. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 45 source files and on the three tool scripts. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | `main` of the build tool returns 2 with a message when PyInstaller is not installed; no bare `except`; no swallowed exception. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. | +| 7 | Files, locks and connections are managed with context managers | Pass | No new file handling beyond the existing context managers; the ICO file is written with `Path.write_bytes`. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New and changed members have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | No `print` in `src`; the tools write results with `sys.stdout.write`; no secret. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | The program classes are unchanged (DCD-001); `runtime.py` and `tools/` are packaging support outside the design classes, as ADR-0004 and the Scope note of DCD-001 state; `resolve_data_files` is a function of the composition root. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 216 tests (3 consecutive runs green): pure runtime helpers for the frozen and source cases, the argument list of the build, the ICO file, the bundled word list fallback, and the level screen window title; none starts PyInstaller or uses the network. The real executable was built and smoke-tested separately (note below). | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; `sys._MEIPASS` is read with `getattr` and no `Any` is used. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` is still empty; PyInstaller is only in the extra `build` and Pillow only in `tools`. | +| 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 frozen rule is one definition (`runtime.py`) used by `constants.py`; file names and sizes stay in `constants.py`. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; `runtime.py` imports only the standard library. | +| 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 | The build tool has one purpose and a pure `build_arguments` that is tested; the composition root chooses the word list file. | +| 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 | `tools/build_executable.py` and `runtime.py` sit outside the four layers as packaging support; layers and test folders are 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 follow the rule. | + +## Overall Verdict + +Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author. + +Milestone MIL-009 Go criteria: 1 `python tools/build_executable.py` ended with exit code 0 and wrote `dist/flash-card.exe` (25.8 MB) with PyInstaller 6.22.3 on the Windows Store Python 3.13.14; 2 started from a temporary folder without the virtual environment, the executable showed a window titled `Flashy` after about 2 seconds and has no console (windowed bootloader); 3 with `data/ms-da.csv` beside it (3 words) one tick left 2 cards in the progress file, without it the embedded list was used (1000 words); 4 one tick wrote `data/words_to_learn.csv` next to the executable with 999 cards at level 100; 5 a captured screenshot of the executable shows the card and both button images; 6 unit tests of the frozen rule pass; 7 `dependencies` empty, extra `build`; 8 BC-001, DCD-001, ADR-0004, PP-001, US-001 and MIL-009 were reviewed Go (RC-042 to RC-047), README and the Install page of the wiki in three languages describe the build; 9 this record, `pytest` 216 passed, ruff, mypy, lint-imports and doxygen clean. Defects found by the smoke test and fixed in this milestone: the level screen window had the default title `tk` and no padding because `LevelView` did not call `configure_window`. Notes: the one-file build starts a second process (the bootloader), so a tool that watches a window must not match the first process id; the first start after a build took over 20 seconds on this machine (unpacking and a virus scan) and later starts about 2 seconds; the spoken word was not tested in the executable (the speech packages are collected into it, the network call was not exercised). All Go. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| none | - | - | + +--- + +[PY-MIL-009]: ../../../src/flash_card +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 1b0c2d1..12f1fe5 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -58,6 +58,7 @@ updated whenever an artifact instance is created or reviewed. | PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] | | PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] | | PY-MIL-008 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-041] | +| PY-MIL-009 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-048] | ## Coverage Notes @@ -150,3 +151,5 @@ updated whenever an artifact instance is created or reviewed. [MIL-009]: ../milestones/mil-009-executable.md [ADR-0004]: ../adr/adr-0004-pyinstaller-executable.md [768ca34]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/768ca344c95d4f21e565e37ac96661c2f8dc8f54 +[RC-048]: ./reviews/rc-048-py-mil-009.md +[PY-MIL-009]: ../../src/flash_card diff --git a/docs/wiki/Install-da.md b/docs/wiki/Install-da.md index b58c6ab..6b07bfe 100644 --- a/docs/wiki/Install-da.md +++ b/docs/wiki/Install-da.md @@ -56,6 +56,20 @@ python -m pytest Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm. +## Byg en programfil (Windows) + +```powershell +python -m pip install -e ".[build,speech]" +python tools/build_executable.py +``` + +Scriptet bruger PyInstaller og skriver `dist/flash-card.exe`: én fil uden konsolvindue, med billeder, ordliste og udtalen indeni. Dobbeltklik for at starte; Python er ikke nødvendigt for at køre den. + +- Din fremgang gemmes i en `data/`-mappe ved siden af programfilen (`data/words_to_learn.csv`). +- En `data/ms-da.csv` ved siden af programfilen erstatter ordlisten indeni. +- `python tools/build_executable.py --no-speech` udelader talepakkerne; `--clean` bygger forfra. +- Programfilen er ikke i repositoryet. Byg den på det system, hvor den skal køre; Windows kan vise en SmartScreen-advarsel, fordi filen ikke er signeret. + ## Dokumentation af kildekoden ```bash diff --git a/docs/wiki/Install-ms.md b/docs/wiki/Install-ms.md index d457ad5..18ef8b4 100644 --- a/docs/wiki/Install-ms.md +++ b/docs/wiki/Install-ms.md @@ -56,6 +56,20 @@ python -m pytest Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan. +## Bina fail boleh laku (Windows) + +```powershell +python -m pip install -e ".[build,speech]" +python tools/build_executable.py +``` + +Skrip menggunakan PyInstaller dan menulis `dist/flash-card.exe`: satu fail tanpa tetingkap konsol, dengan imej, senarai perkataan dan suara di dalamnya. Klik dua kali untuk memulakan; Python tidak diperlukan untuk menjalankannya. + +- Kemajuan anda disimpan dalam folder `data/` di sebelah fail boleh laku (`data/words_to_learn.csv`). +- `data/ms-da.csv` di sebelah fail boleh laku menggantikan senarai perkataan di dalamnya. +- `python tools/build_executable.py --no-speech` meninggalkan pakej suara; `--clean` membina semula dari awal. +- Fail boleh laku tidak disimpan dalam repositori. Bina di sistem tempat ia akan dijalankan; Windows mungkin menunjukkan amaran SmartScreen kerana fail tidak ditandatangani. + ## Dokumentasi kod sumber ```bash diff --git a/docs/wiki/Install.md b/docs/wiki/Install.md index 45a04f4..e597f8a 100644 --- a/docs/wiki/Install.md +++ b/docs/wiki/Install.md @@ -56,6 +56,20 @@ python -m pytest The window test in `tests/e2e/` is skipped when no display is available. +## Build an executable (Windows) + +```powershell +python -m pip install -e ".[build,speech]" +python tools/build_executable.py +``` + +The script uses PyInstaller and writes `dist/flash-card.exe`: one file, no console window, with the images, the word list and the spoken word inside. Double-click it to start; Python is not needed to run it. + +- Your progress is kept in a `data/` folder next to the executable (`data/words_to_learn.csv`). +- A `data/ms-da.csv` next to the executable replaces the word list inside it. +- `python tools/build_executable.py --no-speech` leaves the speech packages out; `--clean` rebuilds from scratch. +- The executable is not committed to the repository. Build it on the system you want to run it on; Windows may show a SmartScreen warning because the file is not signed. + ## Documentation of the source code ```bash diff --git a/pyproject.toml b/pyproject.toml index f6e8932..6174659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [] speech = ["gTTS>=2.5", "playsound==1.2.2"] # Only for tools/capture_screenshots.py; not needed to run the program. tools = ["pillow>=10"] +# Only for tools/build_executable.py; not needed to run the program. +build = ["pyinstaller>=6"] dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"] [project.scripts] @@ -35,7 +37,7 @@ flash-card = "flash_card.presentation.app:run" where = ["src"] [tool.setuptools.package-data] -flash_card = ["presentation/assets/*.png"] +flash_card = ["presentation/assets/*.png", "presentation/assets/*.ico"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/flash_card/constants.py b/src/flash_card/constants.py index 70975ce..5232e88 100644 --- a/src/flash_card/constants.py +++ b/src/flash_card/constants.py @@ -9,6 +9,8 @@ from collections.abc import Mapping from pathlib import Path from typing import Final +from flash_card.runtime import executable_dir, is_frozen, resource_root + ## @brief Window background colour. BACKGROUND_COLOR: Final[str] = "#B1DDC6" ## @brief Window title. @@ -77,12 +79,24 @@ VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv" ## @brief File name of the words still to learn. PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv" -## @brief Root folder of the project. +## @brief Root folder of the project, for a source run. PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] -## @brief Default folder of the data files. -DEFAULT_DATA_DIR: Final[Path] = PROJECT_ROOT / "data" -## @brief Folder of the image assets. -ASSETS_DIR: Final[Path] = Path(__file__).resolve().parent / "presentation" / "assets" +## @brief Folder of the image assets, inside the bundle when frozen. +ASSETS_DIR: Final[Path] = ( + resource_root() / "flash_card" / "presentation" / "assets" + if is_frozen() + else Path(__file__).resolve().parent / "presentation" / "assets" +) +## @brief Folder of the word list delivered with the program. +BUNDLED_DATA_DIR: Final[Path] = resource_root() / "data" +## @brief The word list delivered with the program. +BUNDLED_VOCABULARY_PATH: Final[Path] = BUNDLED_DATA_DIR / VOCABULARY_FILE_NAME +## @brief Default folder of the data files, next to the executable when frozen. +DEFAULT_DATA_DIR: Final[Path] = executable_dir() / "data" +## @brief File name of the program icon. +ICON_FILE_NAME: Final[str] = "flash_card.ico" +## @brief Name of the executable without extension. +EXECUTABLE_NAME: Final[str] = "flash-card" ## @brief File name of the card front image. CARD_FRONT_IMAGE_NAME: Final[str] = "card_front.png" ## @brief File name of the card back image. diff --git a/src/flash_card/presentation/app.py b/src/flash_card/presentation/app.py index 6d1002c..ddcc50e 100644 --- a/src/flash_card/presentation/app.py +++ b/src/flash_card/presentation/app.py @@ -16,6 +16,7 @@ from typing import NoReturn from flash_card.application.ports.speech_port import SpeechPort from flash_card.application.use_cases.open_study_session import open_study_session from flash_card.constants import ( + BUNDLED_VOCABULARY_PATH, DEFAULT_DATA_DIR, LEVEL_ARGUMENT_VALUES, PROGRESS_FILE_NAME, @@ -151,6 +152,20 @@ class Application: self.root.destroy() +def resolve_data_files(data_dir: Path | None) -> tuple[Path, Path]: + """Find the word list and the progress file. + + @param data_dir Folder given by the user, or None for the default folder. + @return The vocabulary path and the progress path. Without a given folder, + the delivered word list is used when the default folder has none. + """ + folder = DEFAULT_DATA_DIR if data_dir is None else data_dir + vocabulary = folder / VOCABULARY_FILE_NAME + if data_dir is None and not vocabulary.exists(): + vocabulary = BUNDLED_VOCABULARY_PATH + return vocabulary, folder / PROGRESS_FILE_NAME + + def create_application( data_dir: Path | None = None, speech_enabled: bool = True, @@ -158,19 +173,20 @@ def create_application( ) -> Application: """Create the window and show the level screen, or start at the given level. - @param data_dir Folder of the data files, or None for the default folder. + @param data_dir Folder of the data files, or None for the default folder + with the delivered word list as fallback. @param speech_enabled False to run without spoken words. @param level The level to start at, or None to show the level screen. @return The application, its window in the root attribute. @throws VocabularyError When a level is given and the word list or saved progress is unusable. """ - folder = DEFAULT_DATA_DIR if data_dir is None else data_dir + vocabulary_path, progress_path = resolve_data_files(data_dir) root = tk.Tk() application = Application( root, - CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME), - CsvProgressRepository(folder / PROGRESS_FILE_NAME), + CsvVocabularyRepository(vocabulary_path), + CsvProgressRepository(progress_path), build_speech(speech_enabled), ) if level is None: @@ -209,8 +225,8 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument( "--data-dir", type=Path, - default=DEFAULT_DATA_DIR, - help="folder of the data files", + default=None, + help="folder of the data files (default: data next to the program)", ) parser.add_argument( "--no-speech", diff --git a/src/flash_card/presentation/assets/flash_card.ico b/src/flash_card/presentation/assets/flash_card.ico new file mode 100644 index 0000000000000000000000000000000000000000..a9830cb9d124c96d4b49526f2f2fbfd5afddf246 GIT binary patch literal 4193 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 SIQWwQFfp_=_;A5B`hNgmrsS#s literal 0 HcmV?d00001 diff --git a/src/flash_card/presentation/features/level/level_view.py b/src/flash_card/presentation/features/level/level_view.py index 2c1ae95..224558d 100644 --- a/src/flash_card/presentation/features/level/level_view.py +++ b/src/flash_card/presentation/features/level/level_view.py @@ -21,6 +21,7 @@ from flash_card.presentation.features.level.level_view_model import ( LevelChoice, LevelViewModel, ) +from flash_card.presentation.shared.window import configure_window class LevelView: @@ -40,6 +41,7 @@ class LevelView: """ ## @brief Called with the level the user picked. self._on_choose = on_choose + configure_window(root) ## @brief Frame that holds the whole screen. self.frame = tk.Frame( root, bg=BACKGROUND_COLOR, width=CARD_WIDTH, height=LEVEL_SCREEN_HEIGHT diff --git a/src/flash_card/runtime.py b/src/flash_card/runtime.py new file mode 100644 index 0000000..559309d --- /dev/null +++ b/src/flash_card/runtime.py @@ -0,0 +1,72 @@ +"""Locate resources and the executable folder, frozen or from source. + +@file runtime.py +@brief Packaging support: tells where bundled files and the executable are. +Imports only the standard library; it is not one of the layers. +""" + +import sys +from pathlib import Path + +## @brief Number of folders from this file up to the project root. +_ROOT_LEVELS = 2 + + +def is_frozen() -> bool: + """Tell whether the program runs as a PyInstaller executable. + + @return True when frozen. + """ + return bool(getattr(sys, "frozen", False)) + + +def source_root() -> Path: + """Return the project root of a source run. + + @return The folder that holds src. + """ + return Path(__file__).resolve().parents[_ROOT_LEVELS] + + +def pick_resource_root(frozen: bool, bundle_dir: Path | None, root: Path) -> Path: + """Choose the folder that holds the bundled resources. + + @param frozen True when running as an executable. + @param bundle_dir The unpack folder of the executable, when frozen. + @param root The project root of a source run. + @return The bundle folder when frozen and known, else the project root. + """ + if frozen and bundle_dir is not None: + return bundle_dir + return root + + +def pick_executable_dir(frozen: bool, executable: Path, root: Path) -> Path: + """Choose the folder next to the executable. + + @param frozen True when running as an executable. + @param executable Path of the running executable. + @param root The project root of a source run. + @return The folder of the executable when frozen, else the project root. + """ + if frozen: + return executable.parent + return root + + +def resource_root() -> Path: + """Return the folder that holds the bundled resources. + + @return The PyInstaller unpack folder when frozen, else the project root. + """ + bundle = getattr(sys, "_MEIPASS", None) + bundle_dir = Path(bundle) if bundle else None + return pick_resource_root(is_frozen(), bundle_dir, source_root()) + + +def executable_dir() -> Path: + """Return the folder of the running executable. + + @return The folder of the executable when frozen, else the project root. + """ + return pick_executable_dir(is_frozen(), Path(sys.executable), source_root()) diff --git a/tests/e2e/test_level_window.py b/tests/e2e/test_level_window.py index ba9181f..b7be1cd 100644 --- a/tests/e2e/test_level_window.py +++ b/tests/e2e/test_level_window.py @@ -105,6 +105,22 @@ def test_level_screen_shows_title_with_font_on_window_background( assert level_view.frame.cget("bg") == BACKGROUND_COLOR +def test_level_screen_sets_window_title_background_and_padding( + tk_root: tk.Tk, +) -> None: + tk_root.title("tk") + tk_root.config(padx=0, pady=0, bg="white") + + view = LevelView(tk_root, LevelViewModel(), lambda level: None) + try: + assert tk_root.title() == WINDOW_TITLE + assert tk_root.cget("bg") == BACKGROUND_COLOR + assert int(str(tk_root.cget("padx"))) == WINDOW_PADDING + assert int(str(tk_root.cget("pady"))) == WINDOW_PADDING + finally: + view.destroy() + + def test_level_screen_frame_has_the_card_window_size(level_view: LevelView) -> None: assert level_view.frame.winfo_width() == CARD_WIDTH assert level_view.frame.winfo_height() == LEVEL_SCREEN_HEIGHT diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 4846a44..64bc186 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -241,3 +241,50 @@ def test_start_raises_vocabulary_error_for_the_caller(fake_views: None) -> None: application = _application(_FakeRoot(), error=True) with pytest.raises(VocabularyNotFoundError): application.start(Level(100)) + + +def test_resolve_data_files_uses_bundled_list_when_default_folder_has_none( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + bundled = tmp_path / "bundle" / "ms-da.csv" + monkeypatch.setattr(app, "DEFAULT_DATA_DIR", tmp_path / "data") + monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", bundled) + vocabulary, progress = app.resolve_data_files(None) + assert vocabulary == bundled + assert progress == tmp_path / "data" / "words_to_learn.csv" + + +def test_resolve_data_files_prefers_the_default_folder_list( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + (tmp_path / "ms-da.csv").write_text("x", encoding="utf-8") + monkeypatch.setattr(app, "DEFAULT_DATA_DIR", tmp_path) + monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", tmp_path / "other.csv") + vocabulary, _ = app.resolve_data_files(None) + assert vocabulary == tmp_path / "ms-da.csv" + + +def test_resolve_data_files_uses_a_given_folder_alone( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", tmp_path / "other.csv") + vocabulary, progress = app.resolve_data_files(tmp_path / "given") + assert vocabulary == tmp_path / "given" / "ms-da.csv" + assert progress == tmp_path / "given" / "words_to_learn.csv" + + +def test_create_application_draws_first_card_from_bundled_list( + fake_views: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + bundled = tmp_path / "bundle" / "ms-da.csv" + bundled.parent.mkdir() + bundled.write_text("ms;da\nsaya;jeg\n", encoding="utf-8") + data = tmp_path / "data" + monkeypatch.setattr(app.tk, "Tk", _FakeRoot) + monkeypatch.setattr(app, "DEFAULT_DATA_DIR", data) + monkeypatch.setattr(app, "BUNDLED_VOCABULARY_PATH", bundled) + application = app.create_application(None, False, Level(None)) + view = cast(_FakeFlashCardView, application.view) + assert view.view_model.text == "saya" + view.view_model.is_known() + assert (data / "words_to_learn.csv").is_file() diff --git a/tests/unit/test_build_executable.py b/tests/unit/test_build_executable.py new file mode 100644 index 0000000..ee95a5b --- /dev/null +++ b/tests/unit/test_build_executable.py @@ -0,0 +1,94 @@ +"""Tests of the executable build tool. + +@file test_build_executable.py +@brief Checks the PyInstaller arguments and the missing PyInstaller message. +""" + +import importlib +import importlib.util +import os +from pathlib import Path +from types import ModuleType + +import pytest + +from flash_card.constants import EXECUTABLE_NAME, ICON_FILE_NAME + +_TOOL_PATH = Path(__file__).resolve().parents[2] / "tools" / "build_executable.py" +ROOT = Path("project") +ASSETS = ROOT / "src" / "flash_card" / "presentation" / "assets" + + +def _load_tool() -> ModuleType: + spec = importlib.util.spec_from_file_location("build_executable", _TOOL_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _option(arguments: list[str], name: str) -> str: + return arguments[arguments.index(name) + 1] + + +def test_build_arguments_start_with_the_packaging_flags() -> None: + arguments = _load_tool().build_arguments(ROOT) + assert arguments[:3] == ["--onefile", "--windowed", "--noconfirm"] + + +def test_build_arguments_name_icon_and_folders() -> None: + arguments = _load_tool().build_arguments(ROOT) + assert _option(arguments, "--name") == EXECUTABLE_NAME == "flash-card" + assert _option(arguments, "--icon") == str(ASSETS / ICON_FILE_NAME) + assert _option(arguments, "--paths") == str(ROOT / "src") + assert _option(arguments, "--distpath") == str(ROOT / "dist") + assert _option(arguments, "--workpath") == str(ROOT / "build") + assert _option(arguments, "--specpath") == str(ROOT / "build") + + +def test_build_arguments_add_assets_and_word_list() -> None: + arguments = _load_tool().build_arguments(ROOT) + pairs = [ + arguments[i + 1] for i, value in enumerate(arguments) if value == "--add-data" + ] + assert pairs == [ + f"{ASSETS}{os.pathsep}flash_card/presentation/assets", + f"{ROOT / 'data' / 'ms-da.csv'}{os.pathsep}data", + ] + + +def test_build_arguments_end_with_the_entry_script() -> None: + arguments = _load_tool().build_arguments(ROOT, clean=True) + assert arguments[-1] == str(ROOT / "src" / "flash_card" / "__main__.py") + + +def test_build_arguments_include_speech_packages_by_default() -> None: + arguments = _load_tool().build_arguments(ROOT) + assert arguments.count("--collect-all") == 2 + assert "gtts" in arguments + assert "playsound" in arguments + + +def test_build_arguments_leave_out_speech_packages_without_speech() -> None: + arguments = _load_tool().build_arguments(ROOT, speech=False) + assert "--collect-all" not in arguments + assert "--hidden-import" not in arguments + + +def test_build_arguments_clean_only_when_asked() -> None: + tool = _load_tool() + assert "--clean" not in tool.build_arguments(ROOT) + assert "--clean" in tool.build_arguments(ROOT, clean=True) + + +def test_main_returns_two_and_explains_when_pyinstaller_is_missing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def refuse(name: str, package: str | None = None) -> ModuleType: + raise ImportError(name) + + tool = _load_tool() + monkeypatch.setattr(importlib, "import_module", refuse) + assert tool.main([]) == 2 + assert 'pip install -e ".[build]"' in capsys.readouterr().err diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py index 20a8f01..a6b4adb 100644 --- a/tests/unit/test_constants.py +++ b/tests/unit/test_constants.py @@ -35,3 +35,27 @@ def test_project_root_contains_pyproject() -> None: def test_csv_encoding_drops_byte_order_mark() -> None: assert constants.CSV_ENCODING == "utf-8-sig" + + +def test_assets_dir_holds_the_four_images_and_the_icon() -> None: + names = [ + constants.CARD_FRONT_IMAGE_NAME, + constants.CARD_BACK_IMAGE_NAME, + constants.RIGHT_IMAGE_NAME, + constants.WRONG_IMAGE_NAME, + constants.ICON_FILE_NAME, + ] + assert len(set(names)) == 5 + assert all((constants.ASSETS_DIR / name).is_file() for name in names) + + +def test_bundled_vocabulary_is_the_word_list_of_the_bundled_data_dir() -> None: + assert constants.BUNDLED_VOCABULARY_PATH == ( + constants.BUNDLED_DATA_DIR / constants.VOCABULARY_FILE_NAME + ) + assert constants.BUNDLED_VOCABULARY_PATH.is_file() + + +def test_executable_and_icon_names() -> None: + assert constants.EXECUTABLE_NAME == "flash-card" + assert constants.ICON_FILE_NAME == "flash_card.ico" diff --git a/tests/unit/test_generate_images.py b/tests/unit/test_generate_images.py index ef166ae..0347bb0 100644 --- a/tests/unit/test_generate_images.py +++ b/tests/unit/test_generate_images.py @@ -16,6 +16,7 @@ from flash_card.constants import ( CARD_FRONT_IMAGE_NAME, CARD_HEIGHT, CARD_WIDTH, + ICON_FILE_NAME, RIGHT_IMAGE_NAME, WRONG_IMAGE_NAME, ) @@ -56,3 +57,18 @@ def test_generate_images_writes_png_with_expected_size( assert data.startswith(_SIGNATURE) assert data[12:16] == b"IHDR" assert struct.unpack(">II", data[16:24]) == (width, height) + + +def test_generate_images_writes_icon_with_one_256_pixel_png(generated: Path) -> None: + data = (generated / ICON_FILE_NAME).read_bytes() + reserved, kind, count = struct.unpack("II", png[16:24]) == (256, 256) diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py new file mode 100644 index 0000000..10415ff --- /dev/null +++ b/tests/unit/test_runtime.py @@ -0,0 +1,45 @@ +"""Tests of the runtime helpers. + +@file test_runtime.py +@brief Checks where resources and the executable are, frozen or from source. +""" + +from pathlib import Path + +from flash_card import runtime + +BUNDLE = Path("bundle") +EXECUTABLE = Path("apps") / "flash-card.exe" +ROOT = Path("project") + + +def test_pick_resource_root_returns_bundle_when_frozen() -> None: + assert runtime.pick_resource_root(True, BUNDLE, ROOT) == BUNDLE + + +def test_pick_resource_root_returns_root_when_not_frozen() -> None: + assert runtime.pick_resource_root(False, BUNDLE, ROOT) == ROOT + + +def test_pick_resource_root_returns_root_when_bundle_is_unknown() -> None: + assert runtime.pick_resource_root(True, None, ROOT) == ROOT + + +def test_pick_executable_dir_returns_executable_folder_when_frozen() -> None: + assert runtime.pick_executable_dir(True, EXECUTABLE, ROOT) == EXECUTABLE.parent + + +def test_pick_executable_dir_returns_root_when_not_frozen() -> None: + assert runtime.pick_executable_dir(False, EXECUTABLE, ROOT) == ROOT + + +def test_is_frozen_is_false_in_a_test_run() -> None: + assert runtime.is_frozen() is False + + +def test_resource_root_is_project_root_in_a_source_run() -> None: + assert (runtime.resource_root() / "pyproject.toml").is_file() + + +def test_executable_dir_is_project_root_in_a_source_run() -> None: + assert runtime.executable_dir() == runtime.source_root() diff --git a/tools/build_executable.py b/tools/build_executable.py new file mode 100644 index 0000000..a85f362 --- /dev/null +++ b/tools/build_executable.py @@ -0,0 +1,107 @@ +"""Build the single file Windows executable with PyInstaller. + +@file build_executable.py +@brief Runs PyInstaller with the arguments of the flash card program. +Uses the standard library; PyInstaller is imported only when a build runs. +""" + +import argparse +import importlib +import os +import sys +from collections.abc import Sequence +from pathlib import Path + +_SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(_SRC_DIR) not in sys.path: + sys.path.insert(0, str(_SRC_DIR)) + +from flash_card.constants import ( # noqa: E402 + EXECUTABLE_NAME, + ICON_FILE_NAME, + VOCABULARY_FILE_NAME, +) + +_ASSETS_RELATIVE = Path("src") / "flash_card" / "presentation" / "assets" +_ASSETS_TARGET = "flash_card/presentation/assets" +_DATA_RELATIVE = Path("data") +_SPEECH_PACKAGES = ("gtts", "playsound") +_INSTALL_HINT = 'python -m pip install -e ".[build]"' +_EXIT_MISSING_PYINSTALLER = 2 + + +def _add_data(source: Path, target: str) -> list[str]: + """Return the --add-data option of one source and target. + + @param source File or folder to bundle. + @param target Folder inside the bundle. + @return The option and its value. + """ + return ["--add-data", f"{source}{os.pathsep}{target}"] + + +def build_arguments( + root: Path, *, speech: bool = True, clean: bool = False +) -> list[str]: + """Return the PyInstaller argument list. + + @param root Project root folder. + @param speech True to bundle the optional gtts and playsound packages. + @param clean True to clear the PyInstaller cache first. + @return The arguments, the entry script last. + """ + arguments = [ + "--onefile", + "--windowed", + "--noconfirm", + "--name", + EXECUTABLE_NAME, + "--icon", + str(root / _ASSETS_RELATIVE / ICON_FILE_NAME), + "--paths", + str(root / "src"), + "--distpath", + str(root / "dist"), + "--workpath", + str(root / "build"), + "--specpath", + str(root / "build"), + ] + arguments += _add_data(root / _ASSETS_RELATIVE, _ASSETS_TARGET) + arguments += _add_data(root / _DATA_RELATIVE / VOCABULARY_FILE_NAME, "data") + if speech: + for package in _SPEECH_PACKAGES: + arguments += ["--hidden-import", package, "--collect-all", package] + if clean: + arguments.append("--clean") + arguments.append(str(root / "src" / "flash_card" / "__main__.py")) + return arguments + + +def main(argv: Sequence[str] | None = None) -> int: + """Build the executable from the command line. + + @param argv Command line arguments, or None for sys.argv. + @return 0 on success, 2 when PyInstaller is not installed. + """ + parser = argparse.ArgumentParser(description="Build the flash card executable.") + parser.add_argument( + "--no-speech", action="store_true", help="leave out gtts and playsound" + ) + parser.add_argument("--clean", action="store_true", help="clear the cache first") + args = parser.parse_args(argv) + try: + pyinstaller = importlib.import_module("PyInstaller.__main__") + except ImportError: + sys.stderr.write( + f"PyInstaller is not installed; run {_INSTALL_HINT} and try again\n" + ) + return _EXIT_MISSING_PYINSTALLER + root = Path(__file__).resolve().parents[1] + pyinstaller.run(build_arguments(root, speech=not args.no_speech, clean=args.clean)) + sys.stdout.write(f"built {root / 'dist' / (EXECUTABLE_NAME + '.exe')}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/generate_images.py b/tools/generate_images.py index 16f3a84..6c571c5 100644 --- a/tools/generate_images.py +++ b/tools/generate_images.py @@ -1,7 +1,8 @@ """Generate the PNG assets of the flash card program. @file generate_images.py -@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs. +@brief Draws card_front, card_back, right and wrong as antialiased RGBA PNGs, +plus the program icon flash_card.ico. Uses the standard library only; run it to regenerate the assets. """ @@ -23,6 +24,7 @@ from flash_card.constants import ( # noqa: E402 CARD_FRONT_IMAGE_NAME, CARD_HEIGHT, CARD_WIDTH, + ICON_FILE_NAME, RIGHT_IMAGE_NAME, WRONG_IMAGE_NAME, ) @@ -57,6 +59,10 @@ _CROSS_SEGMENTS: tuple[Segment, ...] = ( ((68.0, 32.0), (32.0, 68.0)), ) _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +_ICON_SIZE = 256 +_ICON_HEADER_SIZE = 6 +_ICON_ENTRY_SIZE = 16 +_ICON_BITS_PER_PIXEL = 32 class _Canvas: @@ -309,20 +315,27 @@ def _render_card(fill: Color) -> _Canvas: def _render_button( - gradient: tuple[Color, Color], segments: Sequence[Segment] + gradient: tuple[Color, Color], + segments: Sequence[Segment], + size: int = _BUTTON_SIZE, ) -> _Canvas: """Draw a rounded gradient square with a white symbol. @param gradient Top and bottom colour. - @param segments Line segments of the symbol. + @param segments Line segments of the symbol, drawn for a 100 px button. + @param size Side of the square in pixels; the design is scaled to it. @return The canvas. """ - size = _BUTTON_SIZE + scale = size / _BUTTON_SIZE + scaled = [ + ((a[0] * scale, a[1] * scale), (b[0] * scale, b[1] * scale)) + for a, b in segments + ] canvas = _Canvas(size, size) box: Box = (0.0, 0.0, float(size), float(size)) - square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS) + square = _rounded_rect_mask(size, size, box, _BUTTON_RADIUS * scale) canvas.paint(square, _vertical_gradient(gradient[0], gradient[1], size)) - symbol = _stroke_mask(size, size, segments, _STROKE_WIDTH) + symbol = _stroke_mask(size, size, scaled, _STROKE_WIDTH * scale) canvas.paint(symbol, _solid(_WHITE)) return canvas @@ -351,8 +364,24 @@ def encode_png(width: int, height: int, rgba: bytes) -> bytes: ) +def encode_ico(png: bytes, size: int) -> bytes: + """Wrap one PNG image in an ICO container. + + @param png The PNG file content. + @param size Image side in pixels; 256 is stored as 0. + @return The ICO file content. + """ + stored = 0 if size >= _ICON_SIZE else size + offset = _ICON_HEADER_SIZE + _ICON_ENTRY_SIZE + header = struct.pack(" list[Path]: - """Write the four PNG assets into a folder. + """Write the four PNG assets and the icon into a folder. @param output_dir Folder to write into; created when missing. @return The paths of the written files. @@ -370,6 +399,11 @@ def generate_images(output_dir: Path) -> list[Path]: data = encode_png(canvas.width, canvas.height, canvas.to_rgba_bytes()) path.write_bytes(data) written.append(path) + icon = _render_button(_RIGHT_GRADIENT, _CHECK_SEGMENTS, _ICON_SIZE) + png = encode_png(icon.width, icon.height, icon.to_rgba_bytes()) + icon_path = output_dir / ICON_FILE_NAME + icon_path.write_bytes(encode_ico(png, _ICON_SIZE)) + written.append(icon_path) return written From 4ae4ece10fa3fd57a49a6a1c8b3e1746404af325 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Thu, 15 Oct 2026 00:32:30 +0800 Subject: [PATCH 2/2] Resolve pending commit links of MIL-009 --- docs/sqa/reviews/rc-048-py-mil-009.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-048-py-mil-009.md b/docs/sqa/reviews/rc-048-py-mil-009.md index 412b7da..5958e8d 100644 --- a/docs/sqa/reviews/rc-048-py-mil-009.md +++ b/docs/sqa/reviews/rc-048-py-mil-009.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-15 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [bc51381] | --- @@ -61,3 +61,4 @@ Milestone MIL-009 Go criteria: 1 `python tools/build_executable.py` ended with e [PY-MIL-009]: ../../../src/flash_card [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[bc51381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/bc513810ece2506d5ab63c1d4c603803c2ad415e