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 f61d040..4d976ad 100644 Binary files a/docs/images/screenshot-level.png and b/docs/images/screenshot-level.png differ 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..5958e8d --- /dev/null +++ b/docs/sqa/reviews/rc-048-py-mil-009.md @@ -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 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 0000000..a9830cb Binary files /dev/null and b/src/flash_card/presentation/assets/flash_card.ico differ 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