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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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).
16 changes: 10 additions & 6 deletions synodic_client/application/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
13 changes: 4 additions & 9 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 17 additions & 6 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -221,6 +222,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

Expand Down Expand Up @@ -314,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()
Expand Down Expand Up @@ -388,10 +398,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:
Expand Down
1 change: 1 addition & 0 deletions synodic_client/application/screen/spinner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
13 changes: 7 additions & 6 deletions synodic_client/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 4 additions & 10 deletions tests/unit/qt/test_gather_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand All @@ -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."""
Expand Down
9 changes: 4 additions & 5 deletions tests/unit/qt/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
29 changes: 25 additions & 4 deletions tests/unit/qt/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions tests/unit/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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)
Expand All @@ -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."""
Expand Down
Loading