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:
2026-10-15 00:32:14 +08:00
parent 5934c396c2
commit bc513810ec
23 changed files with 631 additions and 21 deletions
+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())