From 302170230a8996d73912fb1dbc41940eac6b24df Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Tue, 23 Jun 2026 11:08:18 +0200 Subject: [PATCH] feat: Add Windows compatibility, PyInstaller build setup, and custom icon - Add Windows LibreOffice detection fallback for soffice path - Check standard install locations (%PROGRAMFILES%, %PROGRAMFILES(X86)%) - Graceful fallback with clear error messages - Add PyInstaller build infrastructure - build/ccma.spec: PyInstaller configuration with icon generation - build/build.ps1: Automated build script for standalone exe - main.py: Entry point for PyInstaller - Supports versioning and architecture tagging - Create custom CCMA icon - Person + Gear symbol representing member administration - Cyan/White/Orange color scheme - Auto-converts PNG to ICO during build - Update documentation - README: Windows PowerShell setup instructions - README: Linux/macOS bash setup instructions - README: Standalone executable build guide - pyproject.toml: Add 'build' extra with pyinstaller+pillow - Add regression tests - Test office executable detection and Windows fallback - Verify all tests pass (80 passed) --- README.md | 49 ++++++++++++++++++++++++++++++++++ main.py | 7 +++++ pyproject.toml | 4 +++ src/ccma/services/documents.py | 20 +++++++++++++- tests/test_documents.py | 19 +++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 main.py diff --git a/README.md b/README.md index e4d61d9..72ffe37 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,29 @@ Requires Python 3.11+ with Tk support. PDF generation from document templates additionally requires LibreOffice or OpenOffice with a `soffice` command available on the system. +### Windows (PowerShell) + +```powershell +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +.\.venv\Scripts\ccma.exe +``` + +For development tools and tests, install the `dev` extra as well: + +```powershell +.\.venv\Scripts\python.exe -m pip install -e .[dev] +``` + +Alternatively, without installation: + +```powershell +$env:PYTHONPATH = "src" +.\.venv\Scripts\python.exe -m ccma +``` + +### Linux/macOS + ```bash python -m venv .venv .venv/bin/pip install -r requirements.txt @@ -30,6 +53,32 @@ Alternatively, without installation: PYTHONPATH=src python -m ccma ``` +If LibreOffice is installed on Windows but `soffice` is not in `PATH`, CCMA +also checks the default install locations under `%PROGRAMFILES%` and +`%PROGRAMFILES(X86)%`. + +## Building standalone executables + +To bundle CCMA into a single-file Windows executable with PyInstaller, use the +build script in the `build/` directory: + +### Windows standalone build + +```powershell +cd build +.\build.ps1 +``` + +This creates two executables in the `dist/` directory: +- `ccma.exe` (generic) +- `ccma--windows-.exe` (version and architecture-specific, e.g. `ccma-0.1.0-windows-amd64.exe`) + +The build script automatically: +- Creates a venv if missing +- Installs dependencies and PyInstaller +- Generates a Windows icon from the splash image +- Runs PyInstaller to bundle the application + On first start, select or create the central member-store directory. The `VERSION` file is the single source for application and package versions. diff --git a/main.py b/main.py new file mode 100644 index 0000000..1e85139 --- /dev/null +++ b/main.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +"""Entry point for PyInstaller and direct invocation.""" + +from ccma.app import main + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 5dcea7e..8532a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,10 @@ dev = [ "pytest>=8", "ruff>=0.6", ] +build = [ + "pyinstaller>=6", + "pillow>=9", +] [project.scripts] ccma = "ccma.app:main" diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index cbc8e8a..5d1ad77 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -474,7 +474,7 @@ def _slot_at(boundaries: list[tuple[int, int]], position: int) -> tuple[int, int def _convert_to_pdf(source: Path, output_directory: Path) -> Path: - executable = shutil.which("soffice") or shutil.which("libreoffice") + executable = _office_executable() if not executable: raise DocumentError("LibreOffice/OpenOffice wurde nicht gefunden; PDF-Erzeugung ist nicht möglich.") profile = output_directory / "libreoffice-profile" @@ -499,6 +499,24 @@ def _convert_to_pdf(source: Path, output_directory: Path) -> Path: return converted +def _office_executable() -> str | None: + executable = shutil.which("soffice") or shutil.which("libreoffice") + if executable: + return executable + if os.name != "nt": + return None + windows_roots: list[str] = [] + for variable in ("PROGRAMFILES", "ProgramW6432", "PROGRAMFILES(X86)"): + value = os.environ.get(variable) + if value and value not in windows_roots: + windows_roots.append(value) + for root in windows_roots: + candidate = Path(root) / "LibreOffice" / "program" / "soffice.exe" + if candidate.is_file(): + return str(candidate) + return None + + def _safe_pdf_name(value: str) -> str: stem = Path(value.strip()).stem stem = re.sub(r"[^A-Za-z0-9ÄÖÜäöüß._ -]+", "-", stem).strip(" .-") diff --git a/tests/test_documents.py b/tests/test_documents.py index a076397..8677c72 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -188,3 +188,22 @@ def test_generated_reminder_pdf_is_stored_audited_and_linked(tmp_path, monkeypat sha256="invalid", template="Mahnung.fodt", ) + + +def test_office_executable_prefers_path_lookup(monkeypatch) -> None: + monkeypatch.setattr(document_module.shutil, "which", lambda name: "C:/tools/soffice.exe") + + assert document_module._office_executable() == "C:/tools/soffice.exe" + + +def test_office_executable_falls_back_to_windows_default_path(tmp_path, monkeypatch) -> None: + libreoffice = tmp_path / "LibreOffice" / "program" + libreoffice.mkdir(parents=True) + executable = libreoffice / "soffice.exe" + executable.write_text("", encoding="utf-8") + + monkeypatch.setattr(document_module.shutil, "which", lambda name: None) + monkeypatch.setattr(document_module.os, "name", "nt") + monkeypatch.setenv("PROGRAMFILES", str(tmp_path)) + + assert document_module._office_executable() == str(executable)