From 6312a0afc0303beb997e6d7ea9f6e5d0efb508a8 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 6 Mar 2026 08:38:31 -0800 Subject: [PATCH 1/4] Fix Startup Gate --- synodic_client/application/init.py | 16 ++++++---- synodic_client/application/screen/settings.py | 12 +++++--- tests/unit/qt/test_settings.py | 29 ++++++++++++++++--- tests/unit/test_init.py | 26 +++++++++++++++-- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/synodic_client/application/init.py b/synodic_client/application/init.py index cc8442d..1de4043 100644 --- a/synodic_client/application/init.py +++ b/synodic_client/application/init.py @@ -53,12 +53,16 @@ def run_startup_preamble(exe_path: str | None = None) -> None: # Seed user config from the build config (one-time propagation). seed_user_config_from_build() - register_protocol(exe_path) + frozen = getattr(sys, 'frozen', False) + + if frozen: + register_protocol(exe_path) config = resolve_config() - if config.auto_start: - register_startup(exe_path) - else: - remove_startup() + if frozen: + if config.auto_start: + register_startup(exe_path) + else: + remove_startup() - logger.info('Startup preamble complete (auto_start=%s)', config.auto_start) + logger.info('Startup preamble complete (auto_start=%s, frozen=%s)', config.auto_start, frozen) diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index f63127c..80731db 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -221,6 +221,9 @@ def _build_startup_section(self) -> CardFrame: card = CardFrame('Startup') self._auto_start_check = QCheckBox('Start with Windows') self._auto_start_check.toggled.connect(self._on_auto_start_changed) + if not getattr(sys, 'frozen', False): + self._auto_start_check.setEnabled(False) + self._auto_start_check.setToolTip('Auto-start is only available for installed builds') card.content_layout.addWidget(self._auto_start_check) return card @@ -388,10 +391,11 @@ def _on_auto_apply_changed(self, checked: bool) -> None: def _on_auto_start_changed(self, checked: bool) -> None: self._config = update_user_config(auto_start=checked) - if checked: - register_startup(sys.executable) - else: - remove_startup() + if getattr(sys, 'frozen', False): + if checked: + register_startup(sys.executable) + else: + remove_startup() self.settings_changed.emit(self._config) def _on_debug_logging_changed(self, checked: bool) -> None: diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index 33d3b08..a87dfac 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -272,8 +272,8 @@ def test_detect_updates_change() -> None: signal_spy.assert_called_once_with(new_config) @staticmethod - def test_auto_start_registers_startup() -> None: - """Enabling auto-start calls register_startup.""" + def test_auto_start_registers_startup_when_frozen() -> None: + """Enabling auto-start calls register_startup in frozen builds.""" config = _make_config() window = _make_window(config) @@ -282,14 +282,15 @@ def test_auto_start_registers_startup() -> None: patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config), patch('synodic_client.application.screen.settings.register_startup') as mock_register, patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False), + patch('synodic_client.application.screen.settings.getattr', return_value=True), ): window._auto_start_check.setChecked(True) mock_register.assert_called_once() @staticmethod - def test_auto_start_removes_startup() -> None: - """Disabling auto-start calls remove_startup.""" + def test_auto_start_removes_startup_when_frozen() -> None: + """Disabling auto-start calls remove_startup in frozen builds.""" config = _make_config(auto_start=True) window = _make_window(config) # Manually set initial state without triggering signals @@ -301,11 +302,31 @@ def test_auto_start_removes_startup() -> None: with ( patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config), patch('synodic_client.application.screen.settings.remove_startup') as mock_remove, + patch('synodic_client.application.screen.settings.getattr', return_value=True), ): window._auto_start_check.setChecked(False) mock_remove.assert_called_once() + @staticmethod + def test_auto_start_skips_registry_when_not_frozen() -> None: + """Auto-start toggle persists config but skips registry in non-frozen builds.""" + config = _make_config() + window = _make_window(config) + + new_config = _make_config(auto_start=True) + with ( + patch('synodic_client.application.screen.settings.update_user_config', return_value=new_config), + patch('synodic_client.application.screen.settings.register_startup') as mock_register, + patch('synodic_client.application.screen.settings.remove_startup') as mock_remove, + patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False), + patch('synodic_client.application.screen.settings.getattr', return_value=False), + ): + window._auto_start_check.setChecked(True) + + mock_register.assert_not_called() + mock_remove.assert_not_called() + # --------------------------------------------------------------------------- # sync_from_config does not emit signals diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index fec8059..0cae519 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -28,6 +28,7 @@ def test_calls_seed_and_register_protocol() -> None: patch(f'{_MODULE}.resolve_config') as mock_resolve, patch(f'{_MODULE}.register_startup'), patch(f'{_MODULE}.remove_startup'), + patch(f'{_MODULE}.getattr', return_value=True), ): mock_resolve.return_value = MagicMock(auto_start=True) run_startup_preamble(r'C:\app\synodic.exe') @@ -38,13 +39,14 @@ def test_calls_seed_and_register_protocol() -> None: @staticmethod def test_registers_startup_when_auto_start_true() -> None: - """register_startup is called when auto_start is True.""" + """register_startup is called when auto_start is True and frozen.""" with ( patch(f'{_MODULE}.seed_user_config_from_build'), patch(f'{_MODULE}.register_protocol'), patch(f'{_MODULE}.resolve_config') as mock_resolve, patch(f'{_MODULE}.register_startup') as mock_register, patch(f'{_MODULE}.remove_startup') as mock_remove, + patch(f'{_MODULE}.getattr', return_value=True), ): mock_resolve.return_value = MagicMock(auto_start=True) run_startup_preamble(r'C:\app\synodic.exe') @@ -54,13 +56,14 @@ def test_registers_startup_when_auto_start_true() -> None: @staticmethod def test_removes_startup_when_auto_start_false() -> None: - """remove_startup is called when auto_start is False.""" + """remove_startup is called when auto_start is False and frozen.""" with ( patch(f'{_MODULE}.seed_user_config_from_build'), patch(f'{_MODULE}.register_protocol'), patch(f'{_MODULE}.resolve_config') as mock_resolve, patch(f'{_MODULE}.register_startup') as mock_register, patch(f'{_MODULE}.remove_startup') as mock_remove, + patch(f'{_MODULE}.getattr', return_value=True), ): mock_resolve.return_value = MagicMock(auto_start=False) run_startup_preamble(r'C:\app\synodic.exe') @@ -78,6 +81,7 @@ def test_defaults_exe_path_to_sys_executable() -> None: patch(f'{_MODULE}.register_startup') as mock_register, patch(f'{_MODULE}.remove_startup'), patch(f'{_MODULE}.sys') as mock_sys, + patch(f'{_MODULE}.getattr', return_value=True), ): mock_sys.executable = r'C:\Python\python.exe' mock_resolve.return_value = MagicMock(auto_start=True) @@ -86,6 +90,24 @@ def test_defaults_exe_path_to_sys_executable() -> None: mock_proto.assert_called_once_with(r'C:\Python\python.exe') mock_register.assert_called_once_with(r'C:\Python\python.exe') + @staticmethod + def test_skips_registry_when_not_frozen() -> None: + """Protocol and startup registration are skipped in non-frozen builds.""" + with ( + patch(f'{_MODULE}.seed_user_config_from_build'), + patch(f'{_MODULE}.register_protocol') as mock_proto, + patch(f'{_MODULE}.resolve_config') as mock_resolve, + patch(f'{_MODULE}.register_startup') as mock_register, + patch(f'{_MODULE}.remove_startup') as mock_remove, + patch(f'{_MODULE}.getattr', return_value=False), + ): + mock_resolve.return_value = MagicMock(auto_start=True) + run_startup_preamble(r'C:\Python\python.exe') + + mock_proto.assert_not_called() + mock_register.assert_not_called() + mock_remove.assert_not_called() + @staticmethod def test_idempotent_on_second_call() -> None: """A second call is a no-op; the preamble runs only once.""" From 848acf6a0d557c9fd646dc5f8610de7fc48f7e69 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 6 Mar 2026 08:42:40 -0800 Subject: [PATCH 2/4] Fix Settings Auto-height --- synodic_client/application/screen/settings.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 80731db..3e8e083 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -120,6 +120,7 @@ def _init_ui(self) -> None: scroll.setWidget(container) self.setCentralWidget(scroll) + self._scroll_content = container def _build_updates_section(self) -> CardFrame: """Construct the *Updates* settings card.""" @@ -317,8 +318,14 @@ def show_restart_button(self) -> None: def show(self) -> None: """Sync controls from config, size to content, then show the window.""" self.sync_from_config() - # Let the layout determine the ideal size, clamped to the minimum. - self.adjustSize() + # QScrollArea doesn't propagate its content's sizeHint, so + # adjustSize() only reaches the minimum. Compute the ideal + # height from the content widget directly. + content_hint = self._scroll_content.sizeHint() + margins = self._scroll_content.layout().contentsMargins() + ideal_w = max(content_hint.width() + margins.left() + margins.right(), self.minimumWidth()) + ideal_h = max(content_hint.height() + margins.top() + margins.bottom(), self.minimumHeight()) + self.resize(ideal_w, ideal_h) super().show() self.raise_() self.activateWindow() From 8457676ba60eae7098c26c6aa857439cd623f3f2 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 6 Mar 2026 10:42:52 -0800 Subject: [PATCH 3/4] Remove Project Dependencies --- AGENTS.md | 17 +++++++++++++++++ synodic_client/application/screen/screen.py | 13 ++++--------- synodic_client/logging.py | 13 +++++++------ tests/unit/qt/test_gather_packages.py | 14 ++++---------- tests/unit/qt/test_logging.py | 9 ++++----- 5 files changed, 36 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06b1b6d..2594b24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,20 @@ # AGENTS.md This repository doesn't contain any agent specific instructions other than its [README.md](README.md), required development documentation, and its linked resources. + +## Logging + +Application logs are written to a deterministic path under the OS config directory: + +| Mode | Path | +|----------------|------------------------------------------------------| +| Production | `%LOCALAPPDATA%\Synodic\logs\synodic.log` | +| Dev (`--dev`) | `%LOCALAPPDATA%\Synodic-Dev\logs\synodic-dev.log` | + +Resolve the current log path programmatically: + +```shell +python -c "from synodic_client.logging import log_path; print(log_path())" +``` + +Logs use rotating file handlers (1 MB max, 3 backups). diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 0bd13c9..a41aa80 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -315,11 +315,10 @@ async def _gather_refresh_data(self) -> RefreshData: pkg_tasks: dict[str, asyncio.Task] = {} for plugin in updatable_plugins: if plugin.name in runtime_probed: - # Only gather venv/project packages (skip global) - if directories: - pkg_tasks[plugin.name] = tg.create_task( - self._gather_packages(plugin.name, directories, skip_global=True), - ) + # Runtime-probed plugins show only per-runtime + # packages; venv/project packages belong in + # ProjectsView and are intentionally skipped here. + continue else: pkg_tasks[plugin.name] = tg.create_task( self._gather_packages( @@ -390,10 +389,6 @@ def _build_widget_tree(self, data: RefreshData) -> None: for plugin in kind_buckets[kind]: if plugin.name in data.runtime_packages: self._build_runtime_sections(plugin, data, auto_update_map) - # Also emit venv packages (if any) as a separate - # provider header without a runtime tag. - if data.packages_map.get(plugin.name): - self._build_plugin_section(plugin, data, auto_update_map) else: self._build_plugin_section(plugin, data, auto_update_map) diff --git a/synodic_client/logging.py b/synodic_client/logging.py index 3077012..9492ecd 100644 --- a/synodic_client/logging.py +++ b/synodic_client/logging.py @@ -6,15 +6,14 @@ import logging import sys -import tempfile from logging.handlers import RotatingFileHandler from pathlib import Path -from synodic_client.config import is_dev_mode +from synodic_client.config import config_dir, is_dev_mode _LOG_FILENAME = 'synodic.log' _LOG_FILENAME_DEV = 'synodic-dev.log' -_MAX_BYTES = 5_242_880 # 5 MB +_MAX_BYTES = 1_048_576 # 1 MB _BACKUP_COUNT = 3 _FORMAT = '%(asctime)s [%(levelname)s] %(name)s: %(message)s' @@ -24,13 +23,13 @@ def log_path() -> Path: """Return the path to the application log file. - The file lives in the system temp directory so it is cleaned up - automatically by the OS and avoids permission issues. + The file lives under ``config_dir() / 'logs'`` so that agents and + developers can find it at a deterministic, well-known location. Returns: Path to the log file. """ - return Path(tempfile.gettempdir()) / (_LOG_FILENAME_DEV if is_dev_mode() else _LOG_FILENAME) + return config_dir() / 'logs' / (_LOG_FILENAME_DEV if is_dev_mode() else _LOG_FILENAME) class EagerRotatingFileHandler(RotatingFileHandler): @@ -68,6 +67,8 @@ def configure_logging(*, debug: bool = False) -> None: logging.basicConfig(level=logging.INFO) + log_path().parent.mkdir(parents=True, exist_ok=True) + handler = EagerRotatingFileHandler( str(log_path()), maxBytes=_MAX_BYTES, diff --git a/tests/unit/qt/test_gather_packages.py b/tests/unit/qt/test_gather_packages.py index 32061da..8e33075 100644 --- a/tests/unit/qt/test_gather_packages.py +++ b/tests/unit/qt/test_gather_packages.py @@ -922,7 +922,6 @@ def test_runtime_global_packages_are_global() -> None: # Expected widget counts (avoids PLR2004) _EXPECTED_RUNTIME_PROVIDERS = 2 -_EXPECTED_RUNTIME_PROVIDERS_WITH_VENV = 3 _EXPECTED_DEFAULT_RT_PACKAGES = 2 _EXPECTED_NON_DEFAULT_RT_PACKAGES = 1 @@ -1050,8 +1049,8 @@ def test_non_default_runtime_packages_displayed(self) -> None: assert len(non_default_rows) == _EXPECTED_NON_DEFAULT_RT_PACKAGES assert non_default_rows[0]._package_name == 'django' - def test_venv_packages_separate_from_runtime(self) -> None: - """Venv packages appear in a separate section without runtime tag.""" + def test_venv_packages_excluded_for_runtime_probed_plugin(self) -> None: + """Venv packages must not appear in ToolsView for runtime-probed plugins.""" view = ToolsView(_make_porringer(), _make_config()) default_exe = Path('C:/Python314/python.exe') plugin = self._pip_plugin() @@ -1078,13 +1077,8 @@ def test_venv_packages_separate_from_runtime(self) -> None: view._build_widget_tree(data) providers = [w for w in view._section_widgets if isinstance(w, PluginProviderHeader)] - # 2 runtime providers + 1 venv provider = 3 - assert len(providers) == _EXPECTED_RUNTIME_PROVIDERS_WITH_VENV - - # The last provider should NOT have a runtime tag - last_provider = providers[-1] - runtime_tags = [w for w in last_provider.findChildren(QLabel) if 'Python' in w.text()] - assert len(runtime_tags) == 0, 'Venv provider should not have a runtime tag' + # Only the 2 runtime providers — no extra venv provider + assert len(providers) == _EXPECTED_RUNTIME_PROVIDERS def test_runtime_tag_uses_default_style(self) -> None: """The default runtime tag uses the green highlight style.""" diff --git a/tests/unit/qt/test_logging.py b/tests/unit/qt/test_logging.py index 98fa158..ba52085 100644 --- a/tests/unit/qt/test_logging.py +++ b/tests/unit/qt/test_logging.py @@ -2,12 +2,11 @@ import logging import sys -import tempfile from pathlib import Path from unittest.mock import patch from synodic_client.application.screen.settings import SettingsWindow -from synodic_client.config import set_dev_mode +from synodic_client.config import config_dir, set_dev_mode from synodic_client.logging import ( EagerRotatingFileHandler, configure_logging, @@ -19,10 +18,10 @@ class TestLogPath: """Tests for log_path().""" @staticmethod - def test_returns_path_in_temp_dir() -> None: - """log_path() should resolve inside the system temp directory.""" + def test_returns_path_in_config_logs_dir() -> None: + """log_path() should resolve inside config_dir() / 'logs'.""" path = log_path() - assert path.parent == Path(tempfile.gettempdir()) + assert path.parent == config_dir() / 'logs' @staticmethod def test_filename() -> None: From eef70d1c210fb97a11f0a4487f966aafb4a50f83 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Fri, 6 Mar 2026 10:53:52 -0800 Subject: [PATCH 4/4] Fix Spinner Background --- synodic_client/application/screen/spinner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synodic_client/application/screen/spinner.py b/synodic_client/application/screen/spinner.py index 6969732..40260d0 100644 --- a/synodic_client/application/screen/spinner.py +++ b/synodic_client/application/screen/spinner.py @@ -128,6 +128,7 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None: # Auto-overlay: track parent geometry via event filter if parent is not None: self.setAutoFillBackground(True) + self.setStyleSheet('background: palette(window);') self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) parent.installEventFilter(self) self.setGeometry(parent.rect())