Compare commits

...
Author SHA1 Message Date
Tirsvad 4ae4ece10f Resolve pending commit links of MIL-009 2026-10-15 00:32:30 +08:00
Tirsvad bc513810ec 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
2026-10-15 00:32:14 +08:00
Tirsvad 5934c396c2 Merge pull request 'MIL-009 plan and documents: executable with PyInstaller' (#55) from plan-mil-009-executable into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-14 18:17:43 +02:00
23 changed files with 632 additions and 21 deletions
+3
View File
@@ -75,3 +75,6 @@ dmypy.json
*.swp
.DS_Store
Thumbs.db
# PyInstaller spec files
*.spec
+11 -1
View File
@@ -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
+1 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+64
View File
@@ -0,0 +1,64 @@
# 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 | [bc51381] |
---
## 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
[bc51381]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/bc513810ece2506d5ab63c1d4c603803c2ad415e
+3
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+14
View File
@@ -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
+3 -1
View File
@@ -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"]
+19 -5
View File
@@ -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.
+22 -6
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -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
+72
View File
@@ -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())
+16
View File
@@ -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
+47
View File
@@ -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()
+94
View File
@@ -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
+24
View File
@@ -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"
+16
View File
@@ -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("<HHH", data[:6])
width, height, _colors, _zero, planes, bpp, size, offset = struct.unpack(
"<BBBBHHII", data[6:22]
)
assert (reserved, kind, count) == (0, 1, 1)
assert (width, height, planes, bpp) == (0, 0, 1, 32)
assert offset == 22
assert size == len(data) - 22
png = data[offset:]
assert png.startswith(_SIGNATURE)
assert struct.unpack(">II", png[16:24]) == (256, 256)
+45
View File
@@ -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()
+107
View File
@@ -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())
+41 -7
View File
@@ -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("<HHH", 0, 1, 1)
entry = struct.pack(
"<BBBBHHII", stored, stored, 0, 0, 1, _ICON_BITS_PER_PIXEL, len(png), offset
)
return header + entry + png
def generate_images(output_dir: Path) -> 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