From 473fe675a6652173b086f697a7cc2b07928a16e2 Mon Sep 17 00:00:00 2001 From: Beckett Frey Date: Mon, 13 Apr 2026 15:05:33 -0500 Subject: [PATCH 01/14] fixes #64: add ai optimized documentation file --- AGENTS.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33b4b5d --- /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 `make` — do not invoke tools directly unless you need a flag `make` doesn't expose. + +| Command | Purpose | +|---|---| +| `make setup` | Install deps + pre-commit hooks (run first) | +| `make dev` | Launch the app in dev mode | +| `make run-tests` | Unit + GUI tests | +| `make test-coverage` | Coverage for core modules | +| `make lint` / `make lint-check` | Ruff lint (fix / check) | +| `make format` / `make format-check` | Ruff format | +| `make mypy-check` | Type check | +| `make build` | Standalone executable (PyInstaller) | +| `make clean` | Remove build artifacts | +| `make help` | 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 `make run-tests` before reporting a task complete. For UI changes, also launch `make 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. From c0024c0306fea147a9cea1c90a8eec1fb0d25f99 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:26:54 -0500 Subject: [PATCH 02/14] fix: replace localhost help_url defaults with production URL (#92) * Initial plan * fix: replace localhost help_url defaults with production URL Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/496f7ed1-fdaa-4df9-b574-5ab55254c136 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --- src/voxkit/config.py | 2 +- src/voxkit/config/app_config.py | 4 ++-- tests/config/test_app_config.py | 12 ++---------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/voxkit/config.py b/src/voxkit/config.py index 4b099be..fc7214d 100644 --- a/src/voxkit/config.py +++ b/src/voxkit/config.py @@ -17,7 +17,7 @@ } Mode = Literal["MFAENGINE", "W2TGENGINE"] -HELP_URL = "http://localhost:3000/help" +HELP_URL = "https://voxkit-web.vercel.app/help" def startup_routine(): diff --git a/src/voxkit/config/app_config.py b/src/voxkit/config/app_config.py index 1fce3c2..d28b3ce 100644 --- a/src/voxkit/config/app_config.py +++ b/src/voxkit/config/app_config.py @@ -128,7 +128,7 @@ 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 @@ -157,7 +157,7 @@ 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"), ) 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: From d126e22b496145c1096f323113a220a8308efc1b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:01:00 -0500 Subject: [PATCH 03/14] fix: dataset panel empty state non-responsive when splitter resized (#94) * Initial plan * fix: prevent helper_label and empty_label from resizing with splitter Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/ede8fa76-ccf4-4940-bd76-7f46adde44c5 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --- src/voxkit/gui/pages/datasets/datasets_page.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/voxkit/gui/pages/datasets/datasets_page.py b/src/voxkit/gui/pages/datasets/datasets_page.py index 0cd3537..e667e4e 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 From e6b4bddd3319aa4886f8780a5f89e8c3f65cf185 Mon Sep 17 00:00:00 2001 From: Beckett <83560790+BeckettFrey@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:02:24 -0500 Subject: [PATCH 04/14] setup-logging (#95) --- config/app_info.yaml | 4 + config/profiles/default/app_info.yaml | 4 + config/profiles/explanatory/app_info.yaml | 4 + main.py | 24 ++++- src/voxkit/config/app_config.py | 4 + src/voxkit/config/logging_config.py | 81 ++++++++++++++++ src/voxkit/gui/__init__.py | 90 ++++++++++++++++-- src/voxkit/gui/components/__init__.py | 2 + src/voxkit/gui/components/log_handler.py | 45 +++++++++ .../gui/components/log_viewer_dialog.py | 94 +++++++++++++++++++ src/voxkit/gui/workers/startup.py | 11 ++- tests/config/test_logging_config.py | 59 ++++++++++++ 12 files changed, 414 insertions(+), 8 deletions(-) create mode 100644 src/voxkit/config/logging_config.py create mode 100644 src/voxkit/gui/components/log_handler.py create mode 100644 src/voxkit/gui/components/log_viewer_dialog.py create mode 100644 tests/config/test_logging_config.py 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/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/src/voxkit/config/app_config.py b/src/voxkit/config/app_config.py index d28b3ce..300169f 100644 --- a/src/voxkit/config/app_config.py +++ b/src/voxkit/config/app_config.py @@ -131,6 +131,8 @@ class AppConfig: 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": @@ -160,6 +162,8 @@ def from_yaml(cls, config_path: Path) -> "AppConfig": 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/workers/startup.py b/src/voxkit/gui/workers/startup.py index 461aa4e..f85bf74 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,7 +95,7 @@ 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 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 From aa76b5d32864932ccad5ae466e571fdb11055037 Mon Sep 17 00:00:00 2001 From: Beckett <83560790+BeckettFrey@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:00:35 -0500 Subject: [PATCH 05/14] Refactor configuration and update release workflow process (#96) * refactor: remove shadowed config * replace: move to invoke for os agnostic clarity * remove release workflow in favor of more manual steps --- .github/agents/code-quality.agent.md | 20 +-- .github/workflows/code-quality.yml | 42 +++---- .github/workflows/release.yml | 125 ------------------- .github/workflows/tests-macos.yml | 2 +- .github/workflows/tests-ubuntu.yml | 6 +- .github/workflows/tests-windows.yml | 2 +- AGENTS.md | 24 ++-- Makefile | 102 ---------------- README.md | 12 +- docs/CONTRIBUTING.md | 4 +- pyproject.toml | 1 + src/voxkit/config.py | 109 ----------------- tasks.py | 175 +++++++++++++++++++++++++++ uv.lock | 11 ++ 14 files changed, 243 insertions(+), 392 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 Makefile delete mode 100644 src/voxkit/config.py create mode 100644 tasks.py 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/AGENTS.md b/AGENTS.md index 33b4b5d..3853fb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,20 +36,20 @@ Hybrid "unstructured state + signals" PyQt pattern. See `docs/ARCHITECTURE.md` f ## Setup & Common Commands -Use `make` — do not invoke tools directly unless you need a flag `make` doesn't expose. +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 | |---|---| -| `make setup` | Install deps + pre-commit hooks (run first) | -| `make dev` | Launch the app in dev mode | -| `make run-tests` | Unit + GUI tests | -| `make test-coverage` | Coverage for core modules | -| `make lint` / `make lint-check` | Ruff lint (fix / check) | -| `make format` / `make format-check` | Ruff format | -| `make mypy-check` | Type check | -| `make build` | Standalone executable (PyInstaller) | -| `make clean` | Remove build artifacts | -| `make help` | Full list | +| `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 @@ -62,7 +62,7 @@ Use `make` — do not invoke tools directly unless you need a flag `make` doesn' - 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 `make run-tests` before reporting a task complete. For UI changes, also launch `make dev` and exercise the feature — type checks don't verify user-facing behavior. +- 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 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..d3318b2 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,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 +53,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/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/pyproject.toml b/pyproject.toml index 7722cb5..536b08e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dev = [ "mypy>=1.18.2", "types-PyYAML>=6.0.0", "genbadge[coverage]>=1.1.3", + "invoke>=2.2.0", ] docs = [ "pdoc>=16.0.0", diff --git a/src/voxkit/config.py b/src/voxkit/config.py deleted file mode 100644 index fc7214d..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 = "https://voxkit-web.vercel.app/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/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/uv.lock b/uv.lock index f5e8ee3..35f12a1 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" }, @@ -3017,6 +3027,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" }, From 4eab307dd87119fe01315879d710084cfe05b7b2 Mon Sep 17 00:00:00 2001 From: Beckett <83560790+BeckettFrey@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:54:03 -0500 Subject: [PATCH 06/14] Configure shredguard for blocking regex patterns (#98) --- .gitignore | 3 +++ .pre-commit-config.yaml | 8 ++++++++ README.md | 35 ++++++++++++++++++++++++++++------- pyproject.toml | 11 +++++++++++ uv.lock | 15 +++++++++++++++ 5 files changed, 65 insertions(+), 7 deletions(-) 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..f544bc6 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: diff --git a/README.md b/README.md index d3318b2..2cbaaed 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,37 @@

- Release - Downloads + + Release + + + Downloads + Coverage

- Ubuntu Tests - macOS Tests - Windows Tests - Code Quality - Jira + + ShredGuard + +

+ +

+ + Ubuntu Tests + + + macOS Tests + + + Windows Tests + + + Code Quality + + + Jira +

> [!IMPORTANT] @@ -27,6 +47,7 @@ ## Project Structure + ``` src/voxkit/ ├── gui/ # PyQt6 interface (pages, components, workers) diff --git a/pyproject.toml b/pyproject.toml index 536b08e..fe5d70d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "types-PyYAML>=6.0.0", "genbadge[coverage]>=1.1.3", "invoke>=2.2.0", + "shred-guard>=1.1.0", ] docs = [ "pdoc>=16.0.0", @@ -204,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/uv.lock b/uv.lock index 35f12a1..c71caec 100644 --- a/uv.lock +++ b/uv.lock @@ -2995,6 +2995,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-qt" }, { name = "ruff" }, + { name = "shred-guard" }, { name = "types-pyyaml" }, ] docs = [ @@ -3035,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" }] @@ -3724,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" From 58a41f4bae98fc130704d5550679f8a8af779582 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:33:35 -0500 Subject: [PATCH 07/14] fix: don't mark first launch complete on startup script error (#99) * Initial plan * fix: don't mark first launch complete on startup script error Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/e51a7557-a81e-4972-a7e6-45133b115413 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --- src/voxkit/gui/workers/startup.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/voxkit/gui/workers/startup.py b/src/voxkit/gui/workers/startup.py index f85bf74..3fb4acf 100644 --- a/src/voxkit/gui/workers/startup.py +++ b/src/voxkit/gui/workers/startup.py @@ -98,11 +98,7 @@ def on_error(error_msg: str): 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) From 2b0d5e2d51341476c47f3a06ebc29e7d17b7b265 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:40:19 -0500 Subject: [PATCH 08/14] fix: readable_from_unique_id handles prefixed IDs from generate_unique_id (#101) * Initial plan * fix: handle prefixed IDs in readable_from_unique_id Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/f6936450-f9a2-4df8-b75a-b403d98db565 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> * fix: raise descriptive ValueError when no timestamp found in readable_from_unique_id Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/f6936450-f9a2-4df8-b75a-b403d98db565 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --- src/voxkit/storage/utils.py | 12 +++++++++++- tests/storage/test_utils.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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/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") From b9ab4a0de0def5ea32b92fe5f9fa3aa736c0a5cf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:48:53 -0500 Subject: [PATCH 09/14] Initial plan (#103) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> From 756c58e7a28f50461025b5b8f337f83a8fa6305e Mon Sep 17 00:00:00 2001 From: Beckett <83560790+BeckettFrey@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:52:02 -0500 Subject: [PATCH 10/14] Revert "Initial plan (#103)" (#106) This reverts commit b9ab4a0de0def5ea32b92fe5f9fa3aa736c0a5cf. From 0f28905aab4f4ced7c8b62cc81a9594cdc45d8e6 Mon Sep 17 00:00:00 2001 From: Beckett <83560790+BeckettFrey@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:11:54 -0500 Subject: [PATCH 11/14] Fix problem with dedicated internal function (#107) --- src/voxkit/storage/datasets.py | 41 ++++++++++++++++++++ tests/storage/test_datasets.py | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/voxkit/storage/datasets.py b/src/voxkit/storage/datasets.py index 88a619b..5ec0cad 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: diff --git a/tests/storage/test_datasets.py b/tests/storage/test_datasets.py index 1f5c621..850690b 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 From 7ad879e71ba743c03253bd066d0dd1871939129d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:20:31 -0500 Subject: [PATCH 12/14] fix: add empty-ID guard to delete_model to prevent wiping engine models directory (#100) * Initial plan * fix: add empty-ID guard to delete_model to prevent wiping engine models directory Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/0cce5a70-83a5-4c00-b1e8-19f85cb895d1 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --- src/voxkit/storage/models.py | 4 ++++ tests/storage/test_models.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) 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/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 From 59b32d2cb02d8cfdd314eb026e2270323c805d2b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:20:56 -0500 Subject: [PATCH 13/14] fix: validate_dataset checks stem-name pairing between audio and label files (#102) * Initial plan * fix: validate_dataset checks stem-name pairing between audio and label files Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/73d34692-65d9-48a2-9621-7127982837a2 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> * chore: remove accidentally committed root conftest.py Agent-Logs-Url: https://github.com/BrainBehaviorAnalyticsLab/voxkit-desktop/sessions/46f1cfe0-1564-47a8-85ea-4c404b6b23f8 Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BeckettFrey <83560790+BeckettFrey@users.noreply.github.com> --- src/voxkit/storage/datasets.py | 21 +++++++++++++-------- tests/storage/test_datasets.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/voxkit/storage/datasets.py b/src/voxkit/storage/datasets.py index 5ec0cad..6d2d2a3 100644 --- a/src/voxkit/storage/datasets.py +++ b/src/voxkit/storage/datasets.py @@ -551,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: @@ -603,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}'." @@ -626,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/tests/storage/test_datasets.py b/tests/storage/test_datasets.py index 850690b..ddc85a3 100644 --- a/tests/storage/test_datasets.py +++ b/tests/storage/test_datasets.py @@ -881,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 From 0c7814b8158506d3defdb0e581c2ba8aa8138c50 Mon Sep 17 00:00:00 2001 From: Beckett <83560790+BeckettFrey@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:21:18 -0500 Subject: [PATCH 14/14] 82/fix view btn width (#105) * Fix button width inconsistency * Migrate to table style --- .pre-commit-config.yaml | 8 ++++++-- .../categorical_table/categorical_table.py | 1 - src/voxkit/gui/pages/datasets/datasets_page.py | 13 ++++--------- src/voxkit/gui/styles/__init__.py | 2 ++ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f544bc6..3da7c96 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,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/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 e667e4e..d58593c 100644 --- a/src/voxkit/gui/pages/datasets/datasets_page.py +++ b/src/voxkit/gui/pages/datasets/datasets_page.py @@ -539,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) @@ -810,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};