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
This commit is contained in:
@@ -75,3 +75,6 @@ dmypy.json
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# PyInstaller spec files
|
||||
*.spec
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user