Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [ main, development ]
pull_request:
branches: [ main, development ]

jobs:
test:
name: test (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, windows-latest ]
python-version: [ "3.11", "3.12" ]

steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: "pyproject.toml"

- name: Install Linux dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libportaudio2 libasound2-dev

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev,nlp,whisper]

- name: Run Ruff Linter
run: ruff check src/

- name: Run Mypy Type Checker
run: mypy src/

- name: Run Safety Check
run: safety check

- name: Run Pytest with Coverage
# GUI tests might require mock display on Ubuntu. Let's use xvfb on Ubuntu runner.
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install -y xvfb
xvfb-run --server-args="-screen 0 1024x768x24" pytest tests/unit/ -v --cov=src --cov-fail-under=80 --cov-report=xml

- name: Run Pytest with Coverage (Windows)
if: matrix.os == 'windows-latest'
run: pytest tests/unit/ -v --cov=src --cov-fail-under=80 --cov-report=xml
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,8 @@ models/
logs/
*.log
.gemini/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Taskify Makefile target shortcuts

.PHONY: dist clean test lint typecheck

dist:
python packaging/build.py

clean:
python -c "import shutil; [shutil.rmtree(p, ignore_errors=True) for p in ('build', 'dist')]"

test:
pytest tests/unit/ -v

lint:
ruff check src/ --fix

typecheck:
mypy src/
45 changes: 45 additions & 0 deletions packaging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Taskify standalone packaging instructions

This directory holds the specifications and tooling to bundle the Python codebase, CustomTkinter frontend styling, and dependency components into a single standalone binary.

## Requirements

Ensure development dependencies are installed:
```bash
pip install -e .[dev]
```

## Building stand-alone executables

To compile the binary for your active platform:

```bash
python packaging/build.py
```

Or run via the Makefile:
```bash
make dist
```

Upon success, PyInstaller generates output directories:
- `build/`: Temporary files generated during build time.
- `dist/`: Stands for distribution. Contains the single executable `taskify` (or `taskify.exe` on Windows).

## Local model dependency setup

Because Taskify is a fully offline application, speech-to-text models must be available locally. The compiled binary does not include model directories by default to save executable file footprint.

If model directories are missing, the application downloads defaults to the user directory on first launch, or they can be downloaded manually via:

```bash
# Download default Vosk english model
dist/taskify download-model --engine vosk

# Download default Whisper model
dist/taskify download-model --engine whisper
```

Offline models are stored at:
- **Vosk:** `~/.local/share/taskify/models/vosk/` (on Windows `~` refers to `C:\Users\<username>`)
- **Whisper:** standard cache path managed by `huggingface_hub`.
55 changes: 55 additions & 0 deletions packaging/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Programmatic execution of PyInstaller builds for standalone executables."""

import os
import subprocess
import sys
from pathlib import Path


def main() -> None:
"""Execute the PyInstaller command pointing to packaging spec file."""
workspace_dir = Path(__file__).parent.parent.resolve()
spec_file = workspace_dir / "packaging" / "taskify.spec"

if not spec_file.exists():
print(f"Error: Specification file not found at: {spec_file}", file=sys.stderr)
sys.exit(1)

print("=== Taskify Packaging Builder ===")
print(f"Targeting Specification: {spec_file}")
print(f"Working Directory: {workspace_dir}")

# Build the PyInstaller command list
cmd = [
"pyinstaller",
"--clean",
"--noconfirm",
"--distpath",
str(workspace_dir / "dist"),
"--workpath",
str(workspace_dir / "build"),
str(spec_file),
]

print(f"Executing: {' '.join(cmd)}")
try:
# Run pyinstaller build process
result = subprocess.run(
cmd, cwd=str(workspace_dir), check=True, capture_output=False
)
print("=== Build process finished successfully. ===")
sys.exit(result.returncode)
except subprocess.CalledProcessError as e:
print(f"Error: PyInstaller build failed with status {e.returncode}", file=sys.stderr)
sys.exit(e.returncode)
except FileNotFoundError:
print(
"Error: PyInstaller command not found. Please install the dev packages: "
"pip install -e .[dev]",
file=sys.stderr,
)
sys.exit(127)


if __name__ == "__main__":
main()
95 changes: 95 additions & 0 deletions packaging/taskify.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# -*- mode: python ; coding: utf-8 -*-
import os
import sys
from PyInstaller.utils.hooks import collect_data_files, collect_submodules

block_cipher = None

# Locate customtkinter package installation path programmatically
import customtkinter
customtkinter_path = os.path.dirname(customtkinter.__file__)

# Collect taskify assets (default configurations, schemas, sql scripts)
taskify_datas = collect_data_files("taskify")

# Aggregate all data files to bundle inside the executable
datas = taskify_datas + [
(customtkinter_path, "customtkinter")
]

# Ensure dynamic imports are collected by PyInstaller
hiddenimports = collect_submodules("taskify") + [
"sounddevice",
"soundfile",
"numpy",
"vosk",
"click",
"customtkinter",
]

# Resolve script entrypoint relative to workspace root
entrypoint = os.path.abspath(
os.path.join(os.path.dirname("__file__"), "src", "taskify", "__main__.py")
)

a = Analysis(
[entrypoint],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
"torch",
"tensorflow",
"scipy",
"pandas",
"matplotlib",
"pygame",
"moviepy",
"sklearn",
"lxml",
"nltk",
"pyarrow",
"numba",
"llvmlite",
"openpyxl",
"yt_dlp",
"IPython",
"jedi",
"PyQt5",
"PyQt6",
"PySide2",
"PySide6",
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="taskify",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True, # Standard console is required for Click CLI commands output
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dev = [
"ruff>=0.1.0",
"mypy>=1.5.0",
"pyinstaller>=6.1.0",
"safety>=2.3.0",
]

[project.scripts]
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pytest-cov>=4.1.0
ruff>=0.1.0
mypy>=1.5.0
pyinstaller>=6.1.0
safety>=2.3.0
Loading
Loading