diff --git a/.github/agents/code-quality.agent.md b/.github/agents/code-quality.agent.md
index 0b73e0a..0becf8f 100644
--- a/.github/agents/code-quality.agent.md
+++ b/.github/agents/code-quality.agent.md
@@ -1,26 +1,26 @@
---
name: code-quality-agent
-description: Fixes linting, formatting, and type checking issues by running make commands until all checks pass
+description: Fixes linting, formatting, and type checking issues by running invoke commands until all checks pass
tools: ['execute/getTerminalOutput', 'execute/runInTerminal', 'execute/runTests', 'read', 'edit', 'search']
---
# Code Quality Agent
-Fix all code quality issues by running make commands iteratively and addressing any remaining problems until all checks pass.
+Fix all code quality issues by running invoke commands iteratively and addressing any remaining problems until all checks pass.
## Workflow
-1. Run `make format` then `make lint` (auto-fixes 60-80% of issues)
-2. Run `make mypy-check` to find type errors
+1. Run `invoke format` then `invoke lint` (auto-fixes 60-80% of issues)
+2. Run `invoke mypy-check` to find type errors
3. Fix remaining issues manually
4. Verify: All checks must pass with exit code 0
## Commands
```bash
-make format # Auto-format with Ruff
-make lint # Auto-fix linting with Ruff
-make mypy-check # Type check with mypy
+invoke format # Auto-format with Ruff
+invoke lint # Auto-fix linting with Ruff
+invoke mypy-check # Type check with mypy
```
## Common Mypy Fixes
@@ -71,7 +71,7 @@ class Child(Parent):
## Success Criteria
```bash
-make format-check # No changes needed
-make lint-check # No issues found
-make mypy-check # No type errors
+invoke format-check # No changes needed
+invoke lint-check # No issues found
+invoke mypy-check # No type errors
```
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 65a55bd..5d0d2f3 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -17,86 +17,86 @@ jobs:
formatting:
name: Code Formatting
runs-on: ubuntu-latest
-
+
steps:
- name: Checkout code
uses: actions/checkout@v4
-
+
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
-
+
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
shell: bash
-
+
- name: Configure Git for private repos
run: |
git config --global url."https://x-access-token:${{ secrets.PRIVATE_REPO_TOKEN }}@github.com/".insteadOf "https://github.com/"
-
+
- name: Install dependencies
run: uv sync
-
+
- name: Check code formatting
- run: make format-check
+ run: uv run invoke format-check
linting:
name: Linting
runs-on: ubuntu-latest
-
+
steps:
- name: Checkout code
uses: actions/checkout@v4
-
+
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
-
+
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
shell: bash
-
+
- name: Configure Git for private repos
run: |
git config --global url."https://x-access-token:${{ secrets.PRIVATE_REPO_TOKEN }}@github.com/".insteadOf "https://github.com/"
-
+
- name: Install dependencies
run: uv sync
-
+
- name: Check linting
- run: make lint-check
+ run: uv run invoke lint-check
type-checking:
name: Type Checking
runs-on: ubuntu-latest
-
+
steps:
- name: Checkout code
uses: actions/checkout@v4
-
+
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
-
+
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
shell: bash
-
+
- name: Configure Git for private repos
run: |
git config --global url."https://x-access-token:${{ secrets.PRIVATE_REPO_TOKEN }}@github.com/".insteadOf "https://github.com/"
-
+
- name: Install dependencies
run: uv sync
-
+
- name: Check type hints
- run: make mypy-check
+ run: uv run invoke mypy-check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 00a5676..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,125 +0,0 @@
-name: Release Build
-
-on:
- push:
- tags:
- - 'v*'
-
-permissions:
- contents: write
-
-jobs:
- build-and-release:
- runs-on: macos-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install uv
- run: |
- curl -LsSf https://astral.sh/uv/install.sh | sh
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
-
- - name: Configure Git for private repos
- run: |
- git config --global url."https://x-access-token:${{ secrets.PRIVATE_REPO_TOKEN }}@github.com/".insteadOf "https://github.com/"
-
- - name: Clear uv cache
- run: |
- uv cache clean
-
- - name: Install dependencies
- env:
- GIT_CREDENTIALS: ${{ secrets.PRIVATE_REPO_TOKEN }}
- run: |
- uv sync
-
- - name: Build .app bundle
- run: make build
-
- - name: Verify build contents
- run: |
- echo "Checking VoxKit.app structure..."
- ls -la dist/VoxKit.app/Contents/
- echo "Checking for Qt plugins..."
- ls -la dist/VoxKit.app/Contents/MacOS/_internal/PyQt6/Qt6/plugins/ || echo "Qt plugins not found!"
- echo "Checking for Qt platform plugin..."
- ls -la dist/VoxKit.app/Contents/MacOS/_internal/PyQt6/Qt6/plugins/platforms/ || echo "Platform plugin not found!"
-
- - name: Get version from tag
- id: get_version
- run: |
- VERSION=${GITHUB_REF#refs/tags/}
- echo "version=$VERSION" >> $GITHUB_OUTPUT
- echo "Building version: $VERSION"
-
- - name: Create DMG
- run: |
- # Create a simple DMG for easier distribution
- mkdir -p dmg_contents
- cp -R dist/VoxKit.app dmg_contents/
- hdiutil create -volname "VoxKit" -srcfolder dmg_contents -ov -format UDZO VoxKit.dmg
- continue-on-error: true
-
- - name: Zip .app for release
- run: |
- cd dist
- zip -r VoxKit.app.zip VoxKit.app
- cd ..
-
- - name: Create Release
- id: create_release
- uses: actions/create-release@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- tag_name: ${{ steps.get_version.outputs.version }}
- release_name: VoxKit ${{ steps.get_version.outputs.version }} (macOS)
- body: |
- **VoxKit Release ${{ steps.get_version.outputs.version }}**
-
- π **Platform:** macOS (Apple Silicon & Intel)
-
- ## Installation
- 1. Download VoxKit-macOS.app.zip
- 2. Unzip the file
- 3. Move VoxKit.app to your Applications folder
- 4. Right-click and select "Open" the first time (macOS security)
-
- ## System Requirements
- - macOS 10.13 or later
- - Works on both Intel and Apple Silicon Macs
-
- ## Changes
- - Built from commit: ${{ github.sha }}
-
- draft: false
- prerelease: true
-
- - name: Upload .app.zip to Release
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./dist/VoxKit.app.zip
- asset_name: VoxKit-macOS.app.zip
- asset_content_type: application/zip
-
- - name: Upload DMG to Release (if created)
- if: success()
- uses: actions/upload-release-asset@v1
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ./VoxKit.dmg
- asset_name: VoxKit-macOS.dmg
- asset_content_type: application/x-apple-diskimage
- continue-on-error: true
diff --git a/.github/workflows/tests-macos.yml b/.github/workflows/tests-macos.yml
index 1f35015..d53ce2b 100644
--- a/.github/workflows/tests-macos.yml
+++ b/.github/workflows/tests-macos.yml
@@ -40,4 +40,4 @@ jobs:
- name: Run tests
run: |
- make run-tests
+ uv run invoke run-tests
diff --git a/.github/workflows/tests-ubuntu.yml b/.github/workflows/tests-ubuntu.yml
index e60562c..0401d15 100644
--- a/.github/workflows/tests-ubuntu.yml
+++ b/.github/workflows/tests-ubuntu.yml
@@ -40,14 +40,14 @@ jobs:
- name: Run tests
run: |
- make run-tests
+ uv run invoke run-tests
- name: Run linting
run: |
- make lint-check
+ uv run invoke lint-check
continue-on-error: true
- name: Run type checking
run: |
- make mypy-check
+ uv run invoke mypy-check
continue-on-error: true
diff --git a/.github/workflows/tests-windows.yml b/.github/workflows/tests-windows.yml
index 9fca071..1eb5895 100644
--- a/.github/workflows/tests-windows.yml
+++ b/.github/workflows/tests-windows.yml
@@ -40,4 +40,4 @@ jobs:
- name: Run tests
run: |
- make run-tests
+ uv run invoke run-tests
diff --git a/.gitignore b/.gitignore
index 49df1cc..0a9e218 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ QUICKSTART_STARTUP_SCRIPT.md
# OS files
.DS_Store
Thumbs.db
+
+# Robust
+.git/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 077dee3..3da7c96 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,4 +1,12 @@
repos:
+ - repo: local
+ hooks:
+ - id: shredguard-check
+ name: shredguard check
+ entry: shredguard check
+ language: system
+ types: [text]
+
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
hooks:
@@ -6,10 +14,14 @@ repos:
args: [--fix]
- id: ruff-format
- - repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.19.1
+ - repo: local
hooks:
- id: mypy
+ name: mypy
+ entry: uv run --only-group dev mypy .
+ language: system
+ types: [python]
+ pass_filenames: false
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..3853fb7
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,77 @@
+# AGENTS.md
+
+Onboarding guide for coding agents working in this repository.
+
+## Project
+
+VoxKit is a PyQt6 desktop application for speech pathology research β a GUI front-end over multiple speech toolkits (alignment, training, transcription). Package lives at `src/voxkit/`. Python 3.11+, managed with `uv`.
+
+## Repository Layout
+
+```
+src/voxkit/
+βββ gui/ # PyQt6 (pages, components, frameworks, workers)
+βββ engines/ # Speech toolkit backends (abstract base + implementations)
+βββ analyzers/ # Dataset metadata extractors (CSV summaries)
+βββ storage/ # Persistence/CRUD for datasets, models, alignments
+βββ services/ # External subprocess integrations
+βββ config/ # App configuration and startup
+tests/ # Pytest suite (unit + GUI via pytest-qt)
+docs/ # ARCHITECTURE.md, CONTRIBUTING.md, RESEARCH.md
+hooks/ # Pre-commit hooks
+scripts/ # Dev scripts
+main.py # Entry point
+```
+
+Runtime data lives at `~/.voxkit/` (datasets, models, alignments, engine settings).
+
+## Architecture
+
+Hybrid "unstructured state + signals" PyQt pattern. See `docs/ARCHITECTURE.md` for the full picture before making structural changes. Key points:
+
+- **Views access storage directly** β no controller intermediary. `storage/` is the model layer.
+- **Async work runs in QThread workers** that emit `pyqtSignal` back to views.
+- **Cross-page state** refreshes via parent window calling `reload()` on tab switch.
+- **Engines** and **analyzers** each have an abstract base class and a singleton manager for discovery.
+
+## Setup & Common Commands
+
+Use `invoke` (pyinvoke, tasks defined in `tasks.py`) β do not invoke tools directly unless you need a flag the task doesn't expose.
+
+| Command | Purpose |
+|---|---|
+| `invoke setup` | Install deps + pre-commit hooks (run first) |
+| `invoke dev` | Launch the app in dev mode |
+| `invoke run-tests` | Unit + GUI tests |
+| `invoke test-coverage` | Coverage for core modules |
+| `invoke lint` / `invoke lint-check` | Ruff lint (fix / check) |
+| `invoke format` / `invoke format-check` | Ruff format |
+| `invoke mypy-check` | Type check |
+| `invoke build` | Standalone executable (PyInstaller) |
+| `invoke clean` | Remove build artifacts |
+| `invoke --list` | Full list |
+
+## Code Standards
+
+- **Ruff**: line length 100, double quotes, isort-managed imports. Lints: `E`, `F`, `I`, `S` (bandit). Per-file ignores in `pyproject.toml`.
+- **Mypy**: Python 3.11, `warn_return_any=true`. `tests/` and `main.py` excluded.
+- **Coverage targets**: 70β80% on new business logic in `storage/`, `config/`, `analyzers/`. GUI, engines, and services are deliberately omitted from coverage.
+- Pre-commit runs on every commit β don't bypass with `--no-verify`.
+
+## Testing
+
+- Framework: `pytest`, with `pytest-qt` for GUI and `pytest-asyncio` for async.
+- Write tests for new business logic in `storage/`, `config/`, `analyzers/`. GUI components are excluded from coverage metrics but still testable with `pytest-qt` when useful.
+- Run `invoke run-tests` before reporting a task complete. For UI changes, also launch `invoke dev` and exercise the feature β type checks don't verify user-facing behavior.
+
+## Commit & PR Conventions
+
+Format: `: ` where type β `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Keep commits small and logical. See `docs/CONTRIBUTING.md` for the review process.
+
+## Gotchas for Agents
+
+- Two test directories exist at repo root: `tests/` (the real suite, in `pyproject.toml` config) and `test/` (untracked scratch). Put new tests in `tests/`.
+- The `pyproject.toml` `name` is still `pypllr-gui` (legacy) but the package is `voxkit`. Don't "fix" this without asking.
+- `main.py`, `build.py`, `_frozen_patch.py` are excluded from lint/mypy/coverage β they're build/entry shims.
+- Engines and services wrap external binaries; changes there are hard to unit-test and are omitted from coverage by design.
+- Dependencies pin `torch==2.8.0` and pull several packages from Git SHAs β don't loosen these casually.
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 80757a4..0000000
--- a/Makefile
+++ /dev/null
@@ -1,102 +0,0 @@
-.PHONY: help
-.DEFAULT_GOAL := help
-
-# Colors for output
-BLUE := \033[36m
-GREEN := \033[32m
-YELLOW := \033[33m
-RED := \033[31m
-RESET := \033[0m
-
-help:
- @echo "$(BLUE)VoxKit - Dev Commands$(RESET)"
- @echo "======================================"
- @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-15s$(RESET) %s\n", $$1, $$2}'
-
-setup: ## Install dependencies and setup pre-commit hooks
- @echo "$(BLUE)Installing dependencies with uv sync...$(RESET)"
- uv sync
- @echo "$(BLUE)Installing pre-commit hooks...$(RESET)"
- uv run pre-commit install
- @echo "$(GREEN)Setup completed successfully!$(RESET)"
-
-watch: ## Watch for file changes and restart dev server (requires entr)
- @if command -v entr >/dev/null 2>&1; then \
- echo "$(BLUE)Watching for changes... Press Ctrl+C to stop$(RESET)"; \
- find src/ -name "*.py" | entr -r uv run main.py; \
- else \
- echo "$(RED)entr not installed. Install with: brew install entr$(RESET)"; \
- echo "$(YELLOW)Falling back to single run...$(RESET)"; \
- uv run main.py; \
- fi
-
-dev: ## Run the development server
- @echo "$(BLUE)Starting development server...$(RESET)"
- uv run main.py
-
-build: clean ## Build standalone executable for current platform
- @echo "$(BLUE)Building VoxKit for macOS...$(RESET)"
- uv run --group installation python scripts/build.py build --entry main.py --name VoxKit --icon ./assets/voxkit.icns --windowed
-
-build-info: ## Show information about the built app
- @echo "$(BLUE)Checking build output...$(RESET)"
- @if [ -d "dist/VoxKit" ]; then \
- echo "$(GREEN)Found: dist/VoxKit/$(RESET)"; \
- ls -lh dist/VoxKit/; \
- echo ""; \
- echo "$(BLUE)Checking executable...$(RESET)"; \
- file dist/VoxKit/VoxKit; \
- echo ""; \
- echo "$(BLUE)Checking library dependencies...$(RESET)"; \
- otool -L dist/VoxKit/VoxKit | head -10; \
- else \
- echo "$(RED)dist/VoxKit not found. Run 'make build' first.$(RESET)"; \
- fi
-
-clean: ## Clean build artifacts
- @echo "$(BLUE)Cleaning build artifacts...$(RESET)"
- @rm -rf build/ dist/ *.spec
- @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
- @find . -type f -name "*.pyc" -delete 2>/dev/null || true
- @echo "$(GREEN)Cleanup completed$(RESET)"
-
-format: ## Format code with Ruff
- @echo "$(BLUE)Formatting code with Ruff...$(RESET)"
- uv run --only-group dev ruff format .
-
-format-check: ## Check code formatting with Ruff
- @echo "$(BLUE)Checking code formatting with Ruff...$(RESET)"
- uv run --only-group dev ruff format --check .
-
-lint: ## Lint code with Ruff
- @echo "$(BLUE)Linting with Ruff...$(RESET)"
- uv run --only-group dev ruff check --fix .
-
-lint-check: ## Check linting with Ruff
- @echo "$(BLUE)Checking linting with Ruff...$(RESET)"
- uv run --only-group dev ruff check .
-
-mypy-check: ## Run mypy for type checking
- @echo "$(BLUE)Running mypy for type checking...$(RESET)"
- uv run --only-group dev mypy .
-
-fresh-slate: ## Remove virtual environment and lock file
- @echo "$(BLUE)Removing virtual environment and lock file...$(RESET)"
- @read -p "Are you sure you want to proceed? [y/N] " confirm && [ $${confirm} = "y" ] || [ $${confirm} = "Y" ] && rm -rf uv.lock .venv || echo "Aborted."
-
-run-tests: ## Run all tests (unit + GUI)
- uv run pytest tests/
-
-test-coverage: ## Run tests with detailed coverage report for core modules
- @echo "$(BLUE)Running tests with coverage (core modules only)...$(RESET)"
- uv run pytest --cov=voxkit --cov-report=term-missing --cov-report=html tests/
- @echo "$(GREEN)Coverage report generated in htmlcov/index.html$(RESET)"
-
-generate-coverage-badge:
- @echo "$(BLUE)Generating coverage badge...$(RESET)"
- uv run pytest --cov=voxkit --cov-report=xml tests/
- uv run genbadge coverage -i coverage.xml -o assets/coverage.svg
- @echo "$(GREEN)Coverage badge updated: assets/coverage.svg$(RESET)"
-
-generate-documentation: ## Generate API documentation
- uv run --group docs pdoc -o docs src/voxkit
diff --git a/README.md b/README.md
index 2abab69..2cbaaed 100644
--- a/README.md
+++ b/README.md
@@ -3,17 +3,37 @@
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
> [!IMPORTANT]
@@ -27,6 +47,7 @@
## Project Structure
+
```
src/voxkit/
βββ gui/ # PyQt6 interface (pages, components, workers)
@@ -40,9 +61,9 @@ src/voxkit/
## Developers
**Prerequisites:**
-- [python](https://www.python.org/downloads/release/python-31114/) code language
-- [uv](https://docs.astral.sh/uv/) package manager
-- [git](https://git-scm.com/install/) version tracking
+- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) β Version control
+- [uv](https://docs.astral.sh/uv/getting-started/installation/) β Python package manager
+- [invoke](https://www.pyinvoke.org/installing.html) β Task runner (installed via `uv tool install invoke`)
**Getting-started:**
```bash
@@ -53,13 +74,13 @@ cd voxkit-desktop
# As easy as...
# (1) Browse developer commands
-make help
+invoke --list
# (2) Install precommit and initialize environment
-make setup
+invoke setup
# (3) Start app (developer mode)
-make dev
+invoke dev
```
---
diff --git a/config/app_info.yaml b/config/app_info.yaml
index 550d696..5555953 100644
--- a/config/app_info.yaml
+++ b/config/app_info.yaml
@@ -6,6 +6,10 @@ version: "0.1.0"
description: "AI/ML Research -> Clinical Applications (Speech Pathology)"
help_url: "https://voxkit-web.vercel.app/help"
+# Logging: rolling file at ~/.voxkit/logs/voxkit.log
+log_max_bytes: 5242880 # 5 MB per file before rotation
+log_backup_count: 3 # number of rotated files to retain
+
# Introduction text displayed to users
introduction: |
VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
diff --git a/config/profiles/default/app_info.yaml b/config/profiles/default/app_info.yaml
index 945fa67..a1882f8 100644
--- a/config/profiles/default/app_info.yaml
+++ b/config/profiles/default/app_info.yaml
@@ -6,6 +6,10 @@ version: "0.1.0"
description: "AI/ML Research -> Clinical Applications (Speech Pathology)"
help_url: "https://voxkit-web.vercel.app/help"
+# Logging: rolling file at ~/.voxkit/logs/voxkit.log
+log_max_bytes: 5242880 # 5 MB per file before rotation
+log_backup_count: 3 # number of rotated files to retain
+
# Introduction text displayed to users
introduction: |
VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
diff --git a/config/profiles/explanatory/app_info.yaml b/config/profiles/explanatory/app_info.yaml
index 550d696..5555953 100644
--- a/config/profiles/explanatory/app_info.yaml
+++ b/config/profiles/explanatory/app_info.yaml
@@ -6,6 +6,10 @@ version: "0.1.0"
description: "AI/ML Research -> Clinical Applications (Speech Pathology)"
help_url: "https://voxkit-web.vercel.app/help"
+# Logging: rolling file at ~/.voxkit/logs/voxkit.log
+log_max_bytes: 5242880 # 5 MB per file before rotation
+log_backup_count: 3 # number of rotated files to retain
+
# Introduction text displayed to users
introduction: |
VoxKit bridges advanced ML alignment tools and clinical speech pathology research.
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index add1311..143e180 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -50,14 +50,14 @@ If youβre unsure which path applies, or you aren't part of the team on Jira, o
1. Make your changes in small, logical commits
2. Write clear, descriptive commit messages
3. **Test your changes thoroughly** - see [TESTING.md](./TESTING.md) for guidelines
-4. Run `make test-coverage` to verify tests pass for core modules
+4. Run `invoke test-coverage` to verify tests pass for core modules
5. Update documentation as needed
### Testing Guidelines
- Write tests for new business logic in `storage/`, `config/`, and `analyzers/` modules
- GUI components are excluded from coverage metrics
-- Run `make test-coverage` to see coverage for testable modules
+- Run `invoke test-coverage` to see coverage for testable modules
- Aim for 70-80% coverage on new business logic
### Commit Message Format
diff --git a/main.py b/main.py
index b8c9467..c478f1e 100644
--- a/main.py
+++ b/main.py
@@ -1,5 +1,6 @@
import sys
import faulthandler
+import logging
import os
import multiprocessing
@@ -8,7 +9,8 @@
import _frozen_patch
from voxkit.config.pipeline_config import PipelineConfig
-from voxkit.config.app_config import AppConfig, get_profile_config_path
+from voxkit.config.app_config import AppConfig, get_app_config, get_profile_config_path
+from voxkit.config.logging_config import setup_logging
# Disable Qt emoji support to prevent crashes in frozen builds
@@ -91,10 +93,29 @@
def main():
+ # Initialize logging as early as possible so startup work is captured.
+ # Use config values when available; fall back to defaults otherwise.
+ try:
+ _cfg = get_app_config()
+ setup_logging(
+ max_bytes=_cfg.log_max_bytes,
+ backup_count=_cfg.log_backup_count,
+ )
+ except Exception:
+ setup_logging()
+
+ # Attach the Qt-aware log handler so live viewers can subscribe.
+ from voxkit.gui.components.log_handler import get_gui_log_handler
+ get_gui_log_handler()
+
+ log = logging.getLogger("voxkit.main")
+ log.info("VoxKit starting (frozen=%s)", bool(getattr(sys, "frozen", False)))
+
app = QApplication(sys.argv)
app.setStyle("Fusion")
# Execute startup script on first launch (before GUI initialization)
+ log.info("Running startup script")
execute_startup_script(STARTUP_SCRIPT, app)
app_config = None
@@ -109,6 +130,7 @@ def main():
window = AlignmentGUI(pipeline_config=pipeline_config, app_config=app_config)
window.show()
+ log.info("Main window shown, entering Qt event loop")
sys.exit(app.exec())
diff --git a/pyproject.toml b/pyproject.toml
index 7722cb5..fe5d70d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,8 @@ dev = [
"mypy>=1.18.2",
"types-PyYAML>=6.0.0",
"genbadge[coverage]>=1.1.3",
+ "invoke>=2.2.0",
+ "shred-guard>=1.1.0",
]
docs = [
"pdoc>=16.0.0",
@@ -203,3 +205,13 @@ where = ["src"]
[tool.uv.sources]
alignment-comparison-plots = { git = "https://github.com/WISCLab/alignment-comparison-plots" }
+
+[tool.shredguard]
+
+[[tool.shredguard.patterns]]
+regex = "\\b\\d{10}\\b|\\b\\d{3}[-.\\s]?\\d{3}[-.\\s]?\\d{4}\\b"
+description = "Phone numbers (10 digits)"
+
+[[tool.shredguard.patterns]]
+regex = "\\d{3,5}_[MF]_+"
+description = "Patient ID"
diff --git a/src/voxkit/config.py b/src/voxkit/config.py
deleted file mode 100644
index 4b099be..0000000
--- a/src/voxkit/config.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import time
-from typing import Callable, Literal
-
-from voxkit.services.mfa import download_acoustic_model
-from voxkit.storage import models
-from voxkit.storage.config import MODELS_ROOT
-from voxkit.storage.utils import get_storage_root
-
-AppName = "VoxKit"
-Dimensions = {"min_width": 200, "min_height": 800, "max_width": 500, "max_height": None}
-Defaults = {
- "mode": "W2TGENGINE",
- "output_path": "/path/to/output",
- "audio_path": "/path/to/audio",
- "textgrid_path": "/path/to/textgrids",
- "num_epochs": 10,
-}
-
-Mode = Literal["MFAENGINE", "W2TGENGINE"]
-HELP_URL = "http://localhost:3000/help"
-
-
-def startup_routine():
- """Example startup routine to be executed on first launch."""
- print("[STARTUP] Initializing VoxKit...")
- time.sleep(1) # Simulate initialization
-
- storage_root = get_storage_root()
- print(f"[STARTUP] Storage root: {storage_root}")
-
- print("[STARTUP] Creating required directories...")
- (storage_root / "computed-likelihoods").mkdir(parents=True, exist_ok=True)
- (storage_root / "custom-likelihoods").mkdir(parents=True, exist_ok=True)
- time.sleep(1) # Simulate directory setup
-
- # Download MFA models
- print("[STARTUP] Downloading MFA models...")
- mfa_models = [
- "acoustic-english_us_arpa-v3.0.0/english_us_arpa.zip",
- "acoustic-spanish_mfa-v3.3.0/spanish_mfa.zip",
- ]
- mfa_models_path = storage_root / "MFAENGINE" / MODELS_ROOT
- mfa_models_path.mkdir(parents=True, exist_ok=True)
- for model in mfa_models:
- success, metadata = models.create_model(
- "MFAENGINE", model.split("/")[1].replace(".zip", "")
- )
- if not success:
- print(f"[STARTUP] Failed to create model metadata for {model}. {metadata}")
- continue
- model_dest = metadata.get("model_path")
- if not model_dest:
- print(f"[STARTUP] Model path not found in metadata for {model}.")
- continue
-
- # Remove last part of path and relace with .zip
- output_file = model_dest.parent / model.split("/")[1]
-
- try:
- download_acoustic_model(model, str(output_file))
- # Update metadata to reflect downloaded file
- print(f"[STARTUP] MFA model {model} downloaded to: {output_file}")
- success, message = models.update_model_metadata(
- "MFAENGINE", metadata["id"], {"model_path": str(output_file)}
- )
-
- if not success:
- print(f"[STARTUP] Failed to update model metadata for {model}. {message}")
-
- print(f"[STARTUP] MFA model downloaded to: {output_file}")
- except Exception as e:
- print(f"[STARTUP] Failed to download MFA model {model}. Error: {e}")
-
- # # Download W2TG model from HuggingFace
- # print("[STARTUP] Downloading W2TG model from HuggingFace...")
- # # Create folder for W2TG model
- # w2tg_path = storage_root / "W2TGENGINE" / MODELS_ROOT
- # w2tg_path.mkdir(parents=True, exist_ok=True)
- # success, metadata = models.create_model("W2TGENGINE", "prads_model")
- # if not success:
- # print(f"[STARTUP] Failed to create model metadata. {metadata}")
- # return
- # model_dest = metadata.get("model_path")
- # if not model_dest:
- # print("[STARTUP] Model path not found in metadata.")
- # return
- # result = download_and_copy_huggingface_model(
- # model_path="pkadambi/Wav2TextGrid",
- # destination=str(model_dest),
- # )
- # if result:
- # print(f"[STARTUP] W2TG model downloaded to: {result}")
- # else:
- # print("[STARTUP] Failed to download W2TG model.")
-
- try:
- import nltk
-
- nltk.download("averaged_perceptron_tagger_eng")
-
- except Exception as e:
- print(f"[STARTUP] Failed to download NLTK resources. Error: {e}")
-
- print("[STARTUP] Initialization complete!")
-
-
-# Startup script configuration
-# Set this to a callable function to run on first launch, or None to disable
-STARTUP_SCRIPT: Callable[[], None] | None = startup_routine
diff --git a/src/voxkit/config/app_config.py b/src/voxkit/config/app_config.py
index 1fce3c2..300169f 100644
--- a/src/voxkit/config/app_config.py
+++ b/src/voxkit/config/app_config.py
@@ -128,9 +128,11 @@ class AppConfig:
version: str
description: str
introduction: str
- help_url: str = "http://localhost:3000/help"
+ help_url: str = "https://voxkit-web.vercel.app/help"
release_date: Optional[str] = None
release_notes: Optional[str] = None
+ log_max_bytes: int = 5 * 1024 * 1024
+ log_backup_count: int = 3
@classmethod
def from_yaml(cls, config_path: Path) -> "AppConfig":
@@ -157,9 +159,11 @@ def from_yaml(cls, config_path: Path) -> "AppConfig":
version=data.get("version", "0.0.0"),
description=data.get("description", ""),
introduction=data.get("introduction", ""),
- help_url=data.get("help_url", "http://localhost:3000/help"),
+ help_url=data.get("help_url", "https://voxkit-web.vercel.app/help"),
release_date=data.get("release_date"),
release_notes=data.get("release_notes"),
+ log_max_bytes=int(data.get("log_max_bytes", 5 * 1024 * 1024)),
+ log_backup_count=int(data.get("log_backup_count", 3)),
)
@classmethod
diff --git a/src/voxkit/config/logging_config.py b/src/voxkit/config/logging_config.py
new file mode 100644
index 0000000..a94a75d
--- /dev/null
+++ b/src/voxkit/config/logging_config.py
@@ -0,0 +1,81 @@
+"""Application logging setup.
+
+Configures a rotating file log at ``~/.voxkit/logs/voxkit.log`` and exposes
+a hook for attaching a GUI handler. ``VOXKIT_DEBUG=1`` in the environment
+raises the file log level to DEBUG.
+"""
+
+import logging
+import os
+from logging.handlers import RotatingFileHandler
+from pathlib import Path
+from typing import Optional
+
+DEFAULT_MAX_BYTES = 5 * 1024 * 1024
+DEFAULT_BACKUP_COUNT = 3
+
+LOG_DIR = Path.home() / ".voxkit" / "logs"
+LOG_FILE = LOG_DIR / "voxkit.log"
+
+_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
+_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
+
+_configured = False
+
+
+def setup_logging(
+ max_bytes: int = DEFAULT_MAX_BYTES,
+ backup_count: int = DEFAULT_BACKUP_COUNT,
+ log_file: Optional[Path] = None,
+) -> RotatingFileHandler:
+ """Configure the root logger with a rotating file handler.
+
+ Idempotent β calling more than once has no effect beyond the first call.
+
+ Args:
+ max_bytes: Max size in bytes before rotation.
+ backup_count: Number of rotated files to retain.
+ log_file: Override the log file path (primarily for tests).
+
+ Returns:
+ The installed RotatingFileHandler.
+ """
+ global _configured
+
+ target = log_file or LOG_FILE
+ target.parent.mkdir(parents=True, exist_ok=True)
+
+ root = logging.getLogger()
+ debug_enabled = os.environ.get("VOXKIT_DEBUG") == "1"
+ root.setLevel(logging.DEBUG if debug_enabled else logging.INFO)
+
+ if _configured:
+ for handler in root.handlers:
+ if isinstance(handler, RotatingFileHandler):
+ return handler
+
+ handler = RotatingFileHandler(
+ target,
+ maxBytes=max_bytes,
+ backupCount=backup_count,
+ encoding="utf-8",
+ )
+ handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
+ handler.setLevel(logging.DEBUG if debug_enabled else logging.INFO)
+ root.addHandler(handler)
+
+ _configured = True
+ logging.getLogger(__name__).info(
+ "Logging initialized (debug=%s, file=%s)", debug_enabled, target
+ )
+ return handler
+
+
+def reset_logging() -> None:
+ """Remove handlers installed by :func:`setup_logging`. Test helper."""
+ global _configured
+ root = logging.getLogger()
+ for handler in list(root.handlers):
+ root.removeHandler(handler)
+ handler.close()
+ _configured = False
diff --git a/src/voxkit/gui/__init__.py b/src/voxkit/gui/__init__.py
index d5d0eea..3f8e3d8 100644
--- a/src/voxkit/gui/__init__.py
+++ b/src/voxkit/gui/__init__.py
@@ -21,20 +21,31 @@
- Styling is centralized in the styles module for consistency
"""
+import logging
import webbrowser
from typing import Optional
+from PyQt6.QtCore import Qt
from PyQt6.QtGui import QAction, QIcon
-from PyQt6.QtWidgets import QHBoxLayout, QMainWindow, QStackedWidget, QToolBar, QWidget
+from PyQt6.QtWidgets import (
+ QHBoxLayout,
+ QMainWindow,
+ QStackedWidget,
+ QToolBar,
+ QToolButton,
+ QWidget,
+)
from rich import print as rprint
from voxkit.config.app_config import AppConfig, get_app_config
from voxkit.config.pipeline_config import PipelineConfig, get_pipeline_config
-from voxkit.gui.components import DNAStrandWidget
+from voxkit.gui.components import DNAStrandWidget, LogViewerDialog
from voxkit.gui.pages.datasets import DatasetsPage
from voxkit.gui.pages.models import ManageAlignersWidget
from voxkit.gui.pages.pipeline import PipelineFormStack as PipelineContainer
+logger = logging.getLogger(__name__)
+
GlobalStyleSheet = """
QMainWindow {
background-color: transparent;
@@ -174,6 +185,12 @@ def __init__(
self.app_config = app_config or get_app_config()
self.pipeline_config = pipeline_config or get_pipeline_config()
+ logger.info(
+ "AlignmentGUI initialized: app=%s version=%s",
+ self.app_config.app_name,
+ self.app_config.version,
+ )
+
# DEBUG
rprint("[bold green]App Configuration:[/bold green]")
rprint(self.app_config)
@@ -295,7 +312,7 @@ def update_active_tab_style(self, active_button):
def open_datasets(self):
"""Switch to Datasets view"""
- print("Open Datasets...")
+ logger.info("Navigate: Datasets page")
# Remember current pipeline page
self.last_pipeline_page = self.pipeline_container.get_current_page_index()
self.pipeline_container.menu_list.setVisible(False)
@@ -307,7 +324,7 @@ def open_datasets(self):
def open_models_dashboard(self):
"""Switch to Pipeline view with menu and stacked pages"""
- print("Open Models Dashboard...")
+ logger.info("Navigate: Pipeline page")
self.pipeline_container.reload() # Ensure models are reloaded
self.pipeline_container.menu_list.setVisible(True)
self.content_stack.setCurrentIndex(0) # Show pipeline stack
@@ -318,7 +335,7 @@ def open_models_dashboard(self):
def open_preferences(self):
"""Switch to Manage view with CategoricalListWidget"""
- print("Open Preferences...")
+ logger.info("Navigate: Models page")
self.pipeline_container.reload() # Ensure models are reloaded
# Remember current pipeline page
self.last_pipeline_page = self.pipeline_container.get_current_page_index()
@@ -328,8 +345,8 @@ def open_preferences(self):
self.update_active_tab_style("manage")
def open_help(self):
+ logger.info("Opening help URL: %s", self.app_config.help_url)
webbrowser.open(self.app_config.help_url)
- print("Open Help...")
def init_ui(self):
self.setWindowTitle(self.app_config.app_name)
@@ -371,5 +388,66 @@ def init_ui(self):
# Set initial active tab style
self.update_active_tab_style("pipeline")
+ # Subtle status-bar entry point for the log viewer
+ self._init_log_status_entry()
+
+ def _init_log_status_entry(self) -> None:
+ """Attach a low-visibility log viewer button floating in the bottom-right."""
+ central = self.centralWidget()
+ if central is None:
+ return
+
+ self._log_button = QToolButton(central)
+ self._log_button.setText("\u2630") # trigram glyph β subtle, monochrome
+ self._log_button.setToolTip("View application log")
+ self._log_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self._log_button.setStyleSheet(
+ "QToolButton {"
+ " background: transparent;"
+ " color: #9aa0a6;"
+ " border: none;"
+ " padding: 0 4px;"
+ " font-size: 12px;"
+ "}"
+ "QToolButton:hover { color: #5f6368; }"
+ )
+ self._log_button.clicked.connect(self._open_log_viewer)
+ self._log_button.adjustSize()
+ self._log_button.raise_()
+ self._log_viewer: Optional[LogViewerDialog] = None
+
+ central.installEventFilter(self)
+ self._reposition_log_button()
+
+ def _reposition_log_button(self) -> None:
+ central = self.centralWidget()
+ if central is None or not hasattr(self, "_log_button"):
+ return
+ btn = self._log_button
+ btn.adjustSize()
+ # Bottom-right, aligned to the existing 20px content margin.
+ x = central.width() - btn.width() - 4
+ y = central.height() - btn.height() - 4
+ btn.move(max(0, x), max(0, y))
+
+ def eventFilter(self, obj, event): # noqa: N802 (Qt API)
+ from PyQt6.QtCore import QEvent
+
+ if obj is self.centralWidget() and event.type() in (
+ QEvent.Type.Resize,
+ QEvent.Type.Show,
+ ):
+ self._reposition_log_button()
+ return super().eventFilter(obj, event)
+
+ def _open_log_viewer(self) -> None:
+ logger.info("Opening log viewer")
+ if self._log_viewer is None or not self._log_viewer.isVisible():
+ self._log_viewer = LogViewerDialog(self)
+ self._log_viewer.show()
+ else:
+ self._log_viewer.raise_()
+ self._log_viewer.activateWindow()
+
__all__ = ["AlignmentGUI"]
diff --git a/src/voxkit/gui/components/__init__.py b/src/voxkit/gui/components/__init__.py
index bbc9911..40dcbb2 100644
--- a/src/voxkit/gui/components/__init__.py
+++ b/src/voxkit/gui/components/__init__.py
@@ -29,6 +29,7 @@
from .grip_splitter import GripSplitter
from .huggingface_button import HuggingFaceButton
from .loading_dialog import LoadingDialog
+from .log_viewer_dialog import LogViewerDialog
from .model_selection_panel import ModelSelectionPanel
from .overlay_effects import OverlayWidget
from .toggle_switch import ToggleSwitch
@@ -40,6 +41,7 @@
"GripSplitter",
"HuggingFaceButton",
"LoadingDialog",
+ "LogViewerDialog",
"ModelSelectionPanel",
"MultiColumnComboBox",
"OverlayWidget",
diff --git a/src/voxkit/gui/components/log_handler.py b/src/voxkit/gui/components/log_handler.py
new file mode 100644
index 0000000..1ee5eac
--- /dev/null
+++ b/src/voxkit/gui/components/log_handler.py
@@ -0,0 +1,45 @@
+"""Qt-aware logging handler that emits records as a pyqtSignal.
+
+Bridges stdlib :mod:`logging` into Qt's signal/slot system so dialogs and
+widgets can subscribe to live log output without polling the log file.
+"""
+
+import logging
+
+from PyQt6.QtCore import QObject, pyqtSignal
+
+
+class QObjectLogHandler(logging.Handler, QObject):
+ """Logging handler that re-emits formatted records via a Qt signal."""
+
+ record_emitted = pyqtSignal(str)
+
+ def __init__(self, level: int = logging.INFO) -> None:
+ logging.Handler.__init__(self, level=level)
+ QObject.__init__(self)
+ self.setFormatter(
+ logging.Formatter(
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+ )
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ message = self.format(record)
+ except Exception:
+ self.handleError(record)
+ return
+ self.record_emitted.emit(message)
+
+
+_global_handler: QObjectLogHandler | None = None
+
+
+def get_gui_log_handler() -> QObjectLogHandler:
+ """Return the process-wide GUI log handler, attaching it on first call."""
+ global _global_handler
+ if _global_handler is None:
+ _global_handler = QObjectLogHandler()
+ logging.getLogger().addHandler(_global_handler)
+ return _global_handler
diff --git a/src/voxkit/gui/components/log_viewer_dialog.py b/src/voxkit/gui/components/log_viewer_dialog.py
new file mode 100644
index 0000000..0aafe48
--- /dev/null
+++ b/src/voxkit/gui/components/log_viewer_dialog.py
@@ -0,0 +1,94 @@
+"""Log Viewer Dialog.
+
+Read-only dialog that shows the tail of the rolling log file and appends
+new records live via :class:`QObjectLogHandler`. Designed to be reusable
+from other surfaces (Dataset page, per-process views) by swapping the
+source file.
+"""
+
+from collections import deque
+from pathlib import Path
+from typing import Optional
+
+from PyQt6.QtGui import QFont
+from PyQt6.QtWidgets import (
+ QDialog,
+ QHBoxLayout,
+ QPlainTextEdit,
+ QPushButton,
+ QVBoxLayout,
+)
+
+from voxkit.config.logging_config import LOG_FILE
+from voxkit.gui.components.log_handler import get_gui_log_handler
+
+_TAIL_LINES = 500
+
+
+class LogViewerDialog(QDialog):
+ """Dialog showing recent log output with live updates."""
+
+ def __init__(
+ self,
+ parent=None,
+ log_path: Optional[Path] = None,
+ ) -> None:
+ super().__init__(parent)
+ self.log_path = log_path or LOG_FILE
+ self.setWindowTitle("Application Log")
+ self.setModal(False)
+ self.resize(900, 500)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(12, 12, 12, 12)
+ layout.setSpacing(8)
+
+ self.text = QPlainTextEdit(self)
+ self.text.setReadOnly(True)
+ self.text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
+ mono = QFont("Menlo")
+ mono.setStyleHint(QFont.StyleHint.Monospace)
+ mono.setPointSize(11)
+ self.text.setFont(mono)
+ layout.addWidget(self.text, stretch=1)
+
+ button_row = QHBoxLayout()
+ button_row.addStretch(1)
+ self.close_button = QPushButton("Close", self)
+ self.close_button.clicked.connect(self.accept)
+ button_row.addWidget(self.close_button)
+ layout.addLayout(button_row)
+
+ self._load_tail()
+
+ self._handler = get_gui_log_handler()
+ self._handler.record_emitted.connect(self._append_line)
+
+ def _load_tail(self) -> None:
+ if not self.log_path.exists():
+ self.text.setPlainText("(no log file yet)")
+ return
+ try:
+ with open(self.log_path, "r", encoding="utf-8", errors="replace") as f:
+ tail = deque(f, maxlen=_TAIL_LINES)
+ except OSError as exc:
+ self.text.setPlainText(f"(failed to read log file: {exc})")
+ return
+ self.text.setPlainText("".join(tail).rstrip("\n"))
+ self._scroll_to_end()
+
+ def _append_line(self, line: str) -> None:
+ self.text.appendPlainText(line)
+ self._scroll_to_end()
+
+ def _scroll_to_end(self) -> None:
+ bar = self.text.verticalScrollBar()
+ if bar is not None:
+ bar.setValue(bar.maximum())
+
+ def closeEvent(self, event) -> None: # noqa: N802 (Qt API)
+ try:
+ self._handler.record_emitted.disconnect(self._append_line)
+ except TypeError:
+ pass
+ super().closeEvent(event)
diff --git a/src/voxkit/gui/frameworks/categorical_table/categorical_table.py b/src/voxkit/gui/frameworks/categorical_table/categorical_table.py
index 6f7a588..feca954 100644
--- a/src/voxkit/gui/frameworks/categorical_table/categorical_table.py
+++ b/src/voxkit/gui/frameworks/categorical_table/categorical_table.py
@@ -291,7 +291,6 @@ def update_display(self):
button_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
view_btn = QPushButton("View")
- view_btn.setFixedSize(60, 24)
view_btn.setStyleSheet(Buttons.TABLE_VIEW)
view_btn.clicked.connect(lambda checked, idx=row_idx: self.view_item_details(idx))
button_layout.addWidget(view_btn)
diff --git a/src/voxkit/gui/pages/datasets/datasets_page.py b/src/voxkit/gui/pages/datasets/datasets_page.py
index 0cd3537..d58593c 100644
--- a/src/voxkit/gui/pages/datasets/datasets_page.py
+++ b/src/voxkit/gui/pages/datasets/datasets_page.py
@@ -22,6 +22,7 @@
QLabel,
QMessageBox,
QPushButton,
+ QSizePolicy,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@@ -253,6 +254,7 @@ def _create_list_section(self):
helper_label = QLabel("π‘ Select a dataset to view its alignments below")
helper_label.setStyleSheet(Containers.HELPER_TEXT)
+ helper_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout.addWidget(helper_label)
# Container for table and empty label
@@ -326,10 +328,11 @@ def _create_list_section(self):
)
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_label.setStyleSheet(Containers.EMPTY_STATE)
+ self.empty_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.empty_label.hide() # Hidden by default
list_container_layout.addWidget(self.empty_label)
- layout.addWidget(list_container)
+ layout.addWidget(list_container, 1)
group.setLayout(layout)
return group
@@ -536,18 +539,15 @@ def _create_alignment_action_buttons(self, alignment: alignments.AlignmentMetada
layout.setContentsMargins(5, 2, 5, 2)
layout.setSpacing(5)
- button_style = Buttons.SUCCESS_SMALL
-
# Delete button
delete_btn = QPushButton("Delete")
- delete_btn.setMaximumWidth(60)
delete_btn.setStyleSheet(Buttons.DELETE_SMALL)
delete_btn.clicked.connect(lambda: self._delete_alignment(alignment))
layout.addWidget(delete_btn)
# View button
view_btn = QPushButton("View")
- view_btn.setStyleSheet(button_style)
+ view_btn.setStyleSheet(Buttons.TABLE_VIEW)
view_btn.clicked.connect(lambda: self._view_alignment(alignment))
layout.addWidget(view_btn)
@@ -807,15 +807,13 @@ def _create_dataset_action_buttons(self, dataset_meta: DatasetMetadata):
"""
widget = QWidget()
layout = QHBoxLayout(widget)
- layout.setContentsMargins(0, 0, 0, 0)
+ layout.setContentsMargins(5, 2, 5, 2)
+ layout.setSpacing(5)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- button_style = Buttons.TABLE_VIEW
-
# Details button
details_btn = QPushButton("Details")
- details_btn.setFixedSize(80, 24)
- details_btn.setStyleSheet(button_style)
+ details_btn.setStyleSheet(Buttons.TABLE_VIEW)
details_btn.clicked.connect(lambda: self._view_dataset_details(dataset_meta))
layout.addWidget(details_btn)
diff --git a/src/voxkit/gui/styles/__init__.py b/src/voxkit/gui/styles/__init__.py
index 383e5ec..b0abc8c 100644
--- a/src/voxkit/gui/styles/__init__.py
+++ b/src/voxkit/gui/styles/__init__.py
@@ -257,6 +257,8 @@ class Buttons:
font-size: 12px;
font-weight: bold;
color: {Colors.TEXT_SECONDARY};
+ min-width: 60px;
+ min-height: 24px;
}}
QPushButton:hover {{
background-color: {Colors.LIGHT_GRAY};
diff --git a/src/voxkit/gui/workers/startup.py b/src/voxkit/gui/workers/startup.py
index 461aa4e..3fb4acf 100644
--- a/src/voxkit/gui/workers/startup.py
+++ b/src/voxkit/gui/workers/startup.py
@@ -8,6 +8,7 @@
- **execute_startup_script**: Execute startup script on first launch
"""
+import logging
from typing import Callable
from PyQt6.QtCore import QThread, pyqtSignal
@@ -16,6 +17,8 @@
from voxkit.gui.components import LoadingDialog
from voxkit.storage.utils import is_first_launch, mark_first_launch_complete
+logger = logging.getLogger(__name__)
+
class StartupScriptWorker(QThread):
"""Worker thread for executing the startup script without blocking the UI.
@@ -35,9 +38,12 @@ def __init__(self, script: Callable[[], None]):
def run(self):
"""Execute the startup script."""
try:
+ logger.info("Startup script running")
self.script()
+ logger.info("Startup script finished")
self.finished.emit()
except Exception as e:
+ logger.exception("Startup script failed")
self.error.emit(str(e))
@@ -59,8 +65,11 @@ def execute_startup_script(script: Callable[[], None] | None, app: QApplication)
return
if not is_first_launch():
+ logger.info("Skipping startup script (not first launch)")
return
+ logger.info("First launch detected, executing startup script")
+
# Create and show the loading dialog
loading_dialog = LoadingDialog("Retrieving assets...")
loading_dialog.show()
@@ -86,14 +95,10 @@ def on_finished():
loading_dialog.close_gracefully()
def on_error(error_msg: str):
- print(f"[ERROR] Startup script failed: {error_msg}")
+ logger.error("Startup script failed: %s", error_msg)
loading_dialog.update_message(f"Error: {error_msg}")
app.processEvents()
- # Still mark as complete to avoid running again
- mark_first_launch_complete()
- # Wait a bit to show error before closing
- from PyQt6.QtCore import QTimer
-
+ # Do NOT mark first launch complete on error β allow retry on next launch
QTimer.singleShot(2000, loading_dialog.close_gracefully)
worker.finished.connect(on_finished)
diff --git a/src/voxkit/storage/datasets.py b/src/voxkit/storage/datasets.py
index 88a619b..6d2d2a3 100644
--- a/src/voxkit/storage/datasets.py
+++ b/src/voxkit/storage/datasets.py
@@ -419,6 +419,45 @@ def export_dataset(dataset_id: str, output_root: Path) -> Tuple[bool, str]:
return False, f"Failed to export dataset: {str(e)}"
+def _rewrite_imported_alignments(new_dataset_path: Path) -> None:
+ """Rewrite alignment metadata paths after importing a dataset to a new location.
+
+ When a dataset is imported, its directory is copied to a new location under a
+ new dataset id. Any ``local`` alignment has a ``tg_path`` that lives inside
+ the dataset directory and still references the source location. For each such
+ alignment, rewrite ``tg_path`` to ``/alignments//
+ textgrids``. Non-local alignments (``local == False``) store TextGrids at the
+ dataset's ``original_path``, which is unchanged by import, so they are left
+ alone.
+ """
+ alignments_dir = new_dataset_path / ALIGNMENTS_ROOT
+ if not alignments_dir.is_dir():
+ return
+
+ for alignment_dir in alignments_dir.iterdir():
+ if not alignment_dir.is_dir():
+ continue
+ metadata_file = alignment_dir / "voxkit_alignment.json"
+ if not metadata_file.exists():
+ continue
+ try:
+ with open(metadata_file, "r") as f:
+ alignment_metadata = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ print(f"Skipping alignment metadata rewrite for '{metadata_file}': {e}")
+ continue
+
+ if not alignment_metadata.get("local"):
+ continue
+
+ alignment_metadata["tg_path"] = str(alignment_dir / "textgrids")
+ try:
+ with open(metadata_file, "w") as f:
+ json.dump(alignment_metadata, f, indent=4)
+ except OSError as e:
+ print(f"Failed to rewrite alignment metadata '{metadata_file}': {e}")
+
+
def import_dataset(dataset_path: Path) -> Tuple[bool, str]:
"""Import an existing dataset into VoxKit storage.
@@ -489,6 +528,8 @@ def import_dataset(dataset_path: Path) -> Tuple[bool, str]:
with open(metadata_path, "w") as f:
json.dump(dataset_metadata, f, indent=2)
+ _rewrite_imported_alignments(dataset_dest)
+
return True, "Dataset imported successfully."
except Exception as e:
@@ -510,6 +551,7 @@ def validate_dataset(dataset_path: Path) -> Tuple[bool, str]:
- Each speaker directory contains audio files (.wav, .flac, .mp3, .ogg, .m4a)
- Each speaker directory contains label files (.lab, .txt)
- Number of audio files matches number of label files per speaker
+ - Each audio file has a matching label file with the same stem name
Expected structure:
@@ -562,15 +604,9 @@ def validate_dataset(dataset_path: Path) -> Tuple[bool, str]:
audio_files = [
f
for f in os.listdir(speaker_path)
- if f.endswith(".wav")
- or f.endswith(".flac")
- or f.endswith(".mp3")
- or f.endswith(".ogg")
- or f.endswith(".m4a")
- ]
- label_files = [
- f for f in os.listdir(speaker_path) if f.endswith(".lab") or f.endswith(".txt")
+ if f.endswith((".wav", ".flac", ".mp3", ".ogg", ".m4a"))
]
+ label_files = [f for f in os.listdir(speaker_path) if f.endswith((".lab", ".txt"))]
if not audio_files:
return False, f"No audio files found in speaker directory '{speaker_path}'."
@@ -585,4 +621,14 @@ def validate_dataset(dataset_path: Path) -> Tuple[bool, str]:
f"directory '{speaker_path}'.",
)
+ audio_stems = {Path(f).stem for f in audio_files}
+ label_stems = {Path(f).stem for f in label_files}
+ unmatched = audio_stems.symmetric_difference(label_stems)
+ if unmatched:
+ return (
+ False,
+ f"Unpaired audio/label files in speaker directory '{speaker_path}': "
+ f"{', '.join(sorted(unmatched))}.",
+ )
+
return True, "Dataset is valid."
diff --git a/src/voxkit/storage/models.py b/src/voxkit/storage/models.py
index eb5089d..6aa9b58 100644
--- a/src/voxkit/storage/models.py
+++ b/src/voxkit/storage/models.py
@@ -401,8 +401,12 @@ def delete_model(engine_id: str, model_id: str) -> Tuple[bool, str]:
Notes:
- This operation is irreversible
- Removes the entire model directory tree
+ - Validates that engine_id and model_id are not empty before proceeding
"""
+ if not engine_id or not model_id:
+ return False, "Engine ID and Model ID cannot be empty."
+
print(f"Attempting to delete model: engine_id={engine_id}, model_id={model_id}")
model_path = _get_model_root(engine_id, model_id)
diff --git a/src/voxkit/storage/utils.py b/src/voxkit/storage/utils.py
index 116a44b..c0460e6 100644
--- a/src/voxkit/storage/utils.py
+++ b/src/voxkit/storage/utils.py
@@ -63,12 +63,22 @@ def generate_unique_id(prefix: str | None = None) -> str:
def readable_from_unique_id(date_str: str) -> str:
"""Convert a unique ID timestamp to a human-readable format.
+ Accepts both plain timestamps (YYYYMMDD_HHMMSS_ffffff) and prefixed IDs
+ produced by generate_unique_id (e.g. prefix_YYYYMMDD_HHMMSS_ffffff).
+
Args:
- date_str: Timestamp string in format YYYYMMDD_HHMMSS_ffffff
+ date_str: Unique ID string, optionally prefixed as [prefix_]YYYYMMDD_HHMMSS_ffffff
Returns:
Human-readable date string (e.g., "January 01, 2024 at 12:00:00 PM")
"""
+ parts = date_str.split("_")
+ for i, part in enumerate(parts):
+ if len(part) == 8 and part.isdigit():
+ date_str = "_".join(parts[i:])
+ break
+ else:
+ raise ValueError(f"No valid timestamp found in unique ID: {date_str!r}")
dt = datetime.strptime(date_str, "%Y%m%d_%H%M%S_%f")
return dt.strftime("%B %d, %Y at %I:%M:%S %p")
diff --git a/tasks.py b/tasks.py
new file mode 100644
index 0000000..f132e3c
--- /dev/null
+++ b/tasks.py
@@ -0,0 +1,175 @@
+"""Invoke task definitions for VoxKit. Run with `invoke ` or `invoke --list`."""
+
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from invoke import task
+
+BLUE = "\033[36m"
+GREEN = "\033[32m"
+YELLOW = "\033[33m"
+RED = "\033[31m"
+RESET = "\033[0m"
+
+ROOT = Path(__file__).parent
+
+
+def _log(msg: str, color: str = BLUE) -> None:
+ print(f"{color}{msg}{RESET}")
+
+
+def _rmtree(path: Path) -> None:
+ if path.is_symlink() or path.is_file():
+ path.unlink(missing_ok=True)
+ elif path.is_dir():
+ shutil.rmtree(path, ignore_errors=True)
+
+
+@task
+def setup(c):
+ """Install dependencies and setup pre-commit hooks."""
+ _log("Installing dependencies with uv sync...")
+ c.run("uv sync")
+ _log("Installing pre-commit hooks...")
+ c.run("uv run pre-commit install")
+ _log("Setup completed successfully!", GREEN)
+
+
+@task
+def dev(c):
+ """Run the development server."""
+ _log("Starting development server...")
+ c.run("uv run main.py", pty=sys.platform != "win32")
+
+
+@task
+def watch(c):
+ """Watch for file changes and restart dev server (requires entr on Unix)."""
+ if sys.platform == "win32":
+ _log("watch is not supported on Windows (requires entr). Falling back to dev.", YELLOW)
+ c.run("uv run main.py")
+ return
+ if not shutil.which("entr"):
+ _log("entr not installed. Install with: brew install entr (macOS) or apt install entr", RED)
+ _log("Falling back to single run...", YELLOW)
+ c.run("uv run main.py", pty=True)
+ return
+ _log("Watching for changes... Press Ctrl+C to stop")
+ files = [str(p) for p in (ROOT / "src").rglob("*.py")]
+ if not files:
+ _log("No Python files found under src/", RED)
+ return
+ subprocess.run( # noqa: S603
+ ["entr", "-r", "uv", "run", "main.py"], # noqa: S607
+ input="\n".join(files),
+ text=True,
+ check=False,
+ )
+
+
+@task
+def clean(c):
+ """Clean build artifacts."""
+ _log("Cleaning build artifacts...")
+ for name in ("build", "dist"):
+ _rmtree(ROOT / name)
+ for spec in ROOT.glob("*.spec"):
+ spec.unlink(missing_ok=True)
+ for pycache in ROOT.rglob("__pycache__"):
+ if ".venv" in pycache.parts:
+ continue
+ _rmtree(pycache)
+ for pyc in ROOT.rglob("*.pyc"):
+ if ".venv" in pyc.parts:
+ continue
+ pyc.unlink(missing_ok=True)
+ _log("Cleanup completed", GREEN)
+
+
+@task(pre=[clean])
+def build(c):
+ """Build standalone executable for current platform."""
+ _log("Building VoxKit...")
+ c.run(
+ "uv run --group installation python scripts/build.py build "
+ "--entry main.py --name VoxKit --icon ./assets/voxkit.icns --windowed"
+ )
+
+
+@task
+def format(c):
+ """Format code with Ruff."""
+ _log("Formatting code with Ruff...")
+ c.run("uv run --only-group dev ruff format .")
+
+
+@task(name="format-check")
+def format_check(c):
+ """Check code formatting with Ruff."""
+ _log("Checking code formatting with Ruff...")
+ c.run("uv run --only-group dev ruff format --check .")
+
+
+@task
+def lint(c):
+ """Lint code with Ruff (auto-fix)."""
+ _log("Linting with Ruff...")
+ c.run("uv run --only-group dev ruff check --fix .")
+
+
+@task(name="lint-check")
+def lint_check(c):
+ """Check linting with Ruff."""
+ _log("Checking linting with Ruff...")
+ c.run("uv run --only-group dev ruff check .")
+
+
+@task(name="mypy-check")
+def mypy_check(c):
+ """Run mypy for type checking."""
+ _log("Running mypy for type checking...")
+ c.run("uv run --only-group dev mypy .")
+
+
+@task(name="fresh-slate")
+def fresh_slate(c):
+ """Remove virtual environment and lock file."""
+ _log("Removing virtual environment and lock file...")
+ confirm = input("Are you sure you want to proceed? [y/N] ").strip().lower()
+ if confirm != "y":
+ print("Aborted.")
+ return
+ _rmtree(ROOT / ".venv")
+ (ROOT / "uv.lock").unlink(missing_ok=True)
+ _log("Removed .venv and uv.lock", GREEN)
+
+
+@task(name="run-tests")
+def run_tests(c):
+ """Run all tests (unit + GUI)."""
+ c.run("uv run pytest tests/")
+
+
+@task(name="test-coverage")
+def test_coverage(c):
+ """Run tests with detailed coverage report for core modules."""
+ _log("Running tests with coverage (core modules only)...")
+ c.run("uv run pytest --cov=voxkit --cov-report=term-missing --cov-report=html tests/")
+ _log("Coverage report generated in htmlcov/index.html", GREEN)
+
+
+@task(name="generate-coverage-badge")
+def generate_coverage_badge(c):
+ """Generate coverage badge."""
+ _log("Generating coverage badge...")
+ c.run("uv run pytest --cov=voxkit --cov-report=xml tests/")
+ c.run("uv run genbadge coverage -i coverage.xml -o assets/coverage.svg")
+ _log("Coverage badge updated: assets/coverage.svg", GREEN)
+
+
+@task(name="generate-documentation")
+def generate_documentation(c):
+ """Generate API documentation."""
+ c.run("uv run --group docs pdoc -o docs src/voxkit")
diff --git a/tests/config/test_app_config.py b/tests/config/test_app_config.py
index af20bf6..e808e12 100644
--- a/tests/config/test_app_config.py
+++ b/tests/config/test_app_config.py
@@ -52,11 +52,7 @@ def test_dataclass_fields(self):
assert config.version == "1.0.0"
assert config.description == "Test description"
assert config.introduction == "Test intro"
- assert config.help_url == "http://localhost:3000/help"
- assert config.release_date is None
- assert config.release_notes is None
-
- def test_dataclass_with_optional_fields(self):
+ assert config.help_url == "https://voxkit-web.vercel.app/help"
config = AppConfig(
app_name="TestApp",
version="2.0.0",
@@ -107,11 +103,7 @@ def test_from_yaml_with_defaults(self, tmp_path):
assert config.version == "0.0.0"
assert config.description == ""
assert config.introduction == ""
- assert config.help_url == "http://localhost:3000/help"
- assert config.release_date is None
- assert config.release_notes is None
-
- def test_from_yaml_file_not_found(self, tmp_path):
+ assert config.help_url == "https://voxkit-web.vercel.app/help"
nonexistent_file = tmp_path / "nonexistent.yaml"
with pytest.raises(FileNotFoundError) as exc_info:
diff --git a/tests/config/test_logging_config.py b/tests/config/test_logging_config.py
new file mode 100644
index 0000000..ef3e6d5
--- /dev/null
+++ b/tests/config/test_logging_config.py
@@ -0,0 +1,59 @@
+import logging
+from logging.handlers import RotatingFileHandler
+
+import pytest
+
+from voxkit.config import logging_config
+from voxkit.config.logging_config import reset_logging, setup_logging
+
+
+@pytest.fixture(autouse=True)
+def _clean_logging(monkeypatch):
+ reset_logging()
+ monkeypatch.delenv("VOXKIT_DEBUG", raising=False)
+ yield
+ reset_logging()
+
+
+def test_setup_logging_creates_rotating_file_handler(tmp_path):
+ log_file = tmp_path / "voxkit.log"
+ handler = setup_logging(max_bytes=1024, backup_count=2, log_file=log_file)
+
+ assert isinstance(handler, RotatingFileHandler)
+ assert handler.maxBytes == 1024
+ assert handler.backupCount == 2
+ assert log_file.parent.exists()
+
+
+def test_setup_logging_is_idempotent(tmp_path):
+ log_file = tmp_path / "voxkit.log"
+ first = setup_logging(max_bytes=1024, backup_count=2, log_file=log_file)
+ second = setup_logging(max_bytes=9999, backup_count=9, log_file=log_file)
+ assert first is second
+ root_handlers = [h for h in logging.getLogger().handlers if isinstance(h, RotatingFileHandler)]
+ assert len(root_handlers) == 1
+
+
+def test_setup_logging_default_level_is_info(tmp_path):
+ setup_logging(log_file=tmp_path / "voxkit.log")
+ assert logging.getLogger().level == logging.INFO
+
+
+def test_voxkit_debug_env_raises_level_to_debug(tmp_path, monkeypatch):
+ monkeypatch.setenv("VOXKIT_DEBUG", "1")
+ setup_logging(log_file=tmp_path / "voxkit.log")
+ assert logging.getLogger().level == logging.DEBUG
+
+
+def test_log_records_are_written_to_file(tmp_path):
+ log_file = tmp_path / "voxkit.log"
+ setup_logging(log_file=log_file)
+ logging.getLogger("voxkit.test").info("hello-world-marker")
+ for h in logging.getLogger().handlers:
+ h.flush()
+ assert "hello-world-marker" in log_file.read_text()
+
+
+def test_default_constants_match_story_values():
+ assert logging_config.DEFAULT_MAX_BYTES == 5 * 1024 * 1024
+ assert logging_config.DEFAULT_BACKUP_COUNT == 3
diff --git a/tests/storage/test_datasets.py b/tests/storage/test_datasets.py
index 1f5c621..ddc85a3 100644
--- a/tests/storage/test_datasets.py
+++ b/tests/storage/test_datasets.py
@@ -463,6 +463,74 @@ def test_import_dataset_success(self, monkeypatch):
assert imp_success is True
assert "imported successfully" in imp_message
+ def test_import_dataset_rewrites_alignment_paths(self, monkeypatch):
+ import json
+
+ from voxkit.storage import datasets
+ from voxkit.storage.datasets import (
+ _get_datasets_root,
+ create_dataset,
+ export_dataset,
+ import_dataset,
+ )
+
+ monkeypatch.setattr(datasets, "get_storage_root", mock_get_storage_root)
+
+ success, message = create_dataset(
+ name="dataset_align_import",
+ description="Testing alignment path rewrite on import",
+ original_path=valid_dataset_path,
+ cached=True,
+ anonymize=False,
+ transcribed=True,
+ )
+ assert success is True
+ assert isinstance(message, dict)
+ source_id = message["id"]
+
+ source_root = _get_datasets_root() / source_id
+ alignment_id = "test_alignment_001"
+ alignment_dir = source_root / "alignments" / alignment_id
+ tg_dir = alignment_dir / "textgrids"
+ tg_dir.mkdir(parents=True, exist_ok=True)
+
+ alignment_metadata = {
+ "id": alignment_id,
+ "engine_id": "mfa",
+ "model_metadata": {},
+ "local": True,
+ "alignment_date": "2026-04-14T00:00:00",
+ "status": "completed",
+ "tg_path": str(tg_dir),
+ }
+ alignment_json = alignment_dir / "voxkit_alignment.json"
+ with open(alignment_json, "w") as f:
+ json.dump(alignment_metadata, f)
+
+ export_path = mock_get_storage_root()
+ export_dataset(source_id, export_path)
+
+ exported_dir = export_path / Path(message["name"] + "_" + str(source_id))
+ imp_success, _ = import_dataset(exported_dir)
+ assert imp_success is True
+
+ # Find the newly imported dataset (id differs from source_id).
+ imported_ids = [
+ p.name for p in _get_datasets_root().iterdir() if p.is_dir() and p.name != source_id
+ ]
+ assert len(imported_ids) == 1
+ new_id = imported_ids[0]
+ new_root = _get_datasets_root() / new_id
+
+ new_alignment_json = new_root / "alignments" / alignment_id / "voxkit_alignment.json"
+ assert new_alignment_json.exists()
+ with open(new_alignment_json, "r") as f:
+ rewritten = json.load(f)
+
+ expected_tg_path = str(new_root / "alignments" / alignment_id / "textgrids")
+ assert rewritten["tg_path"] == expected_tg_path
+ assert str(source_root) not in rewritten["tg_path"]
+
def test_import_dataset_nonexistent(self, monkeypatch):
from voxkit.storage import datasets
from voxkit.storage.datasets import import_dataset
@@ -813,3 +881,19 @@ def test_validate_dataset_mismatched_counts(self, monkeypatch):
assert is_valid is False
assert "Mismatch" in msg
+
+ def test_validate_dataset_unpaired_stems(self, monkeypatch):
+ from voxkit.storage.datasets import validate_dataset
+
+ # Create a dataset where counts match but stems do not
+ # (e.g. recording_A.wav paired with recording_B.lab)
+ unpaired_path = mock_get_storage_root() / "fake_datasets" / "unpaired_stems"
+ speaker_path = unpaired_path / "speaker_1"
+ speaker_path.mkdir(parents=True, exist_ok=True)
+ (speaker_path / "recording_A.wav").touch()
+ (speaker_path / "recording_B.lab").touch()
+
+ is_valid, msg = validate_dataset(unpaired_path)
+
+ assert is_valid is False
+ assert "Unpaired" in msg
diff --git a/tests/storage/test_models.py b/tests/storage/test_models.py
index d975fb7..8cb7846 100644
--- a/tests/storage/test_models.py
+++ b/tests/storage/test_models.py
@@ -297,6 +297,29 @@ def test_delete_model_invalid_engine(self, monkeypatch):
assert success is False
assert "not found" in msg
+ def test_delete_model_empty_ids(self, monkeypatch):
+ from voxkit.storage import models
+ from voxkit.storage.models import delete_model
+
+ monkeypatch.setattr(models, "get_storage_root", mock_get_storage_root)
+
+ engine_id = ENGINE_IDS[0]
+
+ # Empty model_id
+ success, msg = delete_model(engine_id=engine_id, model_id="")
+ assert success is False
+ assert "cannot be empty" in msg
+
+ # Empty engine_id
+ success, msg = delete_model(engine_id="", model_id="some_model_id")
+ assert success is False
+ assert "cannot be empty" in msg
+
+ # Both empty
+ success, msg = delete_model(engine_id="", model_id="")
+ assert success is False
+ assert "cannot be empty" in msg
+
class TestGetModelMetadata:
def test_get_model_metadata_success(self, monkeypatch):
from voxkit.storage import models
diff --git a/tests/storage/test_utils.py b/tests/storage/test_utils.py
index e02a89f..fe0ec0d 100644
--- a/tests/storage/test_utils.py
+++ b/tests/storage/test_utils.py
@@ -185,3 +185,23 @@ def test_readable_from_unique_id_format(self):
assert " at " in readable
# Should contain AM/PM
assert "AM" in readable or "PM" in readable
+
+ def test_readable_from_unique_id_with_prefix(self):
+ """Test that readable_from_unique_id handles prefixed IDs from generate_unique_id."""
+ prefixed_id = generate_unique_id(prefix="test")
+ readable = readable_from_unique_id(prefixed_id)
+
+ assert " at " in readable
+ assert "AM" in readable or "PM" in readable
+
+ def test_readable_from_unique_id_roundtrip_with_prefix(self):
+ """Test round-trip: a prefixed ID produces the same readable output as unprefixed."""
+ base_id = "20240115_143022_123456"
+ prefixed_id = f"myprefix_{base_id}"
+
+ assert readable_from_unique_id(base_id) == readable_from_unique_id(prefixed_id)
+
+ def test_readable_from_unique_id_invalid_raises(self):
+ """Test that an ID with no valid timestamp raises ValueError."""
+ with pytest.raises(ValueError, match="No valid timestamp found"):
+ readable_from_unique_id("not_a_timestamp")
diff --git a/uv.lock b/uv.lock
index f5e8ee3..c71caec 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1273,6 +1273,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "invoke"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" },
+]
+
[[package]]
name = "jaraco-classes"
version = "3.4.0"
@@ -2978,6 +2987,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "genbadge", extra = ["coverage"] },
+ { name = "invoke" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" },
@@ -2985,6 +2995,7 @@ dev = [
{ name = "pytest-cov" },
{ name = "pytest-qt" },
{ name = "ruff" },
+ { name = "shred-guard" },
{ name = "types-pyyaml" },
]
docs = [
@@ -3017,6 +3028,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "genbadge", extras = ["coverage"], specifier = ">=1.1.3" },
+ { name = "invoke", specifier = ">=2.2.0" },
{ name = "mypy", specifier = ">=1.18.2" },
{ name = "pre-commit", specifier = ">=4.3.0" },
{ name = "pytest", specifier = ">=8.4.2" },
@@ -3024,6 +3036,7 @@ dev = [
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-qt", specifier = ">=4.4.0" },
{ name = "ruff", specifier = ">=0.14.0" },
+ { name = "shred-guard", specifier = ">=1.1.0" },
{ name = "types-pyyaml", specifier = ">=6.0.0" },
]
docs = [{ name = "pdoc", specifier = ">=16.0.0" }]
@@ -3713,6 +3726,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
+[[package]]
+name = "shred-guard"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "pathspec" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/e7/ed7dc67ec3fb503a1f8a75f768c56c0011c8dfc35314dd014bbc97db1104/shred_guard-1.1.0.tar.gz", hash = "sha256:ef618030f7275e566ae51f071a470620656a3c6aeb48ed81d09be3d9a34cae66", size = 795302, upload-time = "2026-03-13T22:02:26.682Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/ce/26aeb40edea3f8dcbad3ba41aec82e07943fe7f3bf84a74b966fbeae0f48/shred_guard-1.1.0-py3-none-any.whl", hash = "sha256:1b4a10b335fc13ed800d2f26720373f3a4a1875985d4d889fc9e7fdc5c9b8351", size = 24311, upload-time = "2026-03-13T22:02:25.55Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"