synodic-client 0.0.1.dev28__tar.gz → 0.0.1.dev29__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/pyproject.toml +1 -1
- synodic_client-0.0.1.dev29/synodic_client/_version.py +1 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/action_card.py +80 -36
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/install.py +7 -3
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/screen.py +5 -3
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/tray.py +7 -4
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/updater.py +14 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_action_card.py +38 -20
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_install_preview.py +152 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_updater.py +28 -0
- synodic_client-0.0.1.dev28/synodic_client/_version.py +0 -1
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/README.md +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/qt.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/theme.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/test_startup.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.0.1.dev29'
|
|
@@ -14,11 +14,13 @@ from __future__ import annotations
|
|
|
14
14
|
import html as html_mod
|
|
15
15
|
import logging
|
|
16
16
|
|
|
17
|
+
from porringer.backend.command.core.action_builder import PHASE_ORDER
|
|
17
18
|
from porringer.schema import SetupAction, SetupActionResult, SkipReason
|
|
18
19
|
from porringer.schema.plugin import PluginKind
|
|
19
20
|
from PySide6.QtCore import QRect, Qt, QTimer, Signal
|
|
20
21
|
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QTextCursor
|
|
21
22
|
from PySide6.QtWidgets import (
|
|
23
|
+
QApplication,
|
|
22
24
|
QCheckBox,
|
|
23
25
|
QFrame,
|
|
24
26
|
QHBoxLayout,
|
|
@@ -26,6 +28,7 @@ from PySide6.QtWidgets import (
|
|
|
26
28
|
QScrollArea,
|
|
27
29
|
QSizePolicy,
|
|
28
30
|
QTextEdit,
|
|
31
|
+
QToolButton,
|
|
29
32
|
QVBoxLayout,
|
|
30
33
|
QWidget,
|
|
31
34
|
)
|
|
@@ -53,6 +56,10 @@ from synodic_client.application.theme import (
|
|
|
53
56
|
ACTION_CARD_STYLE,
|
|
54
57
|
ACTION_CARD_TYPE_BADGE_STYLE,
|
|
55
58
|
ACTION_CARD_VERSION_STYLE,
|
|
59
|
+
COPY_BTN_SIZE,
|
|
60
|
+
COPY_BTN_STYLE,
|
|
61
|
+
COPY_FEEDBACK_MS,
|
|
62
|
+
COPY_ICON,
|
|
56
63
|
LOG_COLOR_ERROR,
|
|
57
64
|
LOG_COLOR_PHASE,
|
|
58
65
|
LOG_COLOR_STDERR,
|
|
@@ -74,19 +81,9 @@ _SPINNER_INTERVAL = 50
|
|
|
74
81
|
_SPINNER_ARC = 90
|
|
75
82
|
|
|
76
83
|
|
|
77
|
-
#: Sort priority
|
|
78
|
-
#:
|
|
79
|
-
|
|
80
|
-
#: so that cards are displayed in the same order they execute:
|
|
81
|
-
#: runtime → package → tool → project → SCM.
|
|
82
|
-
_KIND_ORDER: dict[PluginKind | None, int] = {
|
|
83
|
-
PluginKind.RUNTIME: 0,
|
|
84
|
-
PluginKind.PACKAGE: 1,
|
|
85
|
-
PluginKind.TOOL: 2,
|
|
86
|
-
PluginKind.PROJECT: 3,
|
|
87
|
-
PluginKind.SCM: 4,
|
|
88
|
-
None: 99, # bare commands are excluded from ActionCardList anyway
|
|
89
|
-
}
|
|
84
|
+
#: Sort priority derived from porringer's execution phase order so the
|
|
85
|
+
#: display order always matches the order actions actually execute.
|
|
86
|
+
_KIND_ORDER: dict[PluginKind | None, int] = {kind: i for i, kind in enumerate(PHASE_ORDER)}
|
|
90
87
|
|
|
91
88
|
|
|
92
89
|
def action_key(action: SetupAction) -> tuple[object, ...]:
|
|
@@ -102,17 +99,17 @@ def action_key(action: SetupAction) -> tuple[object, ...]:
|
|
|
102
99
|
return (action.kind, action.installer, pkg_name, pt_name, cmd)
|
|
103
100
|
|
|
104
101
|
|
|
105
|
-
def action_sort_key(action: SetupAction) ->
|
|
106
|
-
"""Return a sort key
|
|
102
|
+
def action_sort_key(action: SetupAction) -> int:
|
|
103
|
+
"""Return a sort key that groups cards by execution phase.
|
|
107
104
|
|
|
108
|
-
The ordering
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
The ordering is derived from :data:`porringer.backend.command.core.
|
|
106
|
+
action_builder.PHASE_ORDER` so that displayed cards appear in the
|
|
107
|
+
same sequence as they execute. Within a phase group the original
|
|
108
|
+
order from porringer is preserved (Python sort is stable), which
|
|
109
|
+
respects dependency ordering (e.g. a tool must be installed before
|
|
110
|
+
its plugins).
|
|
112
111
|
"""
|
|
113
|
-
|
|
114
|
-
pkg_name = str(action.package.name).lower() if action.package else ''
|
|
115
|
-
return (kind_order, pkg_name)
|
|
112
|
+
return _KIND_ORDER.get(action.kind, len(PHASE_ORDER))
|
|
116
113
|
|
|
117
114
|
|
|
118
115
|
def _format_command(action: SetupAction) -> str:
|
|
@@ -284,7 +281,13 @@ class ActionCard(QFrame):
|
|
|
284
281
|
outer.setContentsMargins(6, 6, 6, 6)
|
|
285
282
|
outer.setSpacing(2)
|
|
286
283
|
|
|
287
|
-
|
|
284
|
+
outer.addLayout(self._build_top_row())
|
|
285
|
+
outer.addWidget(self._build_description_row())
|
|
286
|
+
outer.addWidget(self._build_command_row())
|
|
287
|
+
outer.addWidget(self._build_log_output())
|
|
288
|
+
|
|
289
|
+
def _build_top_row(self) -> QHBoxLayout:
|
|
290
|
+
"""Build the top row: type badge | package name ... version | status/spinner | prerelease."""
|
|
288
291
|
top = QHBoxLayout()
|
|
289
292
|
top.setSpacing(8)
|
|
290
293
|
|
|
@@ -317,24 +320,45 @@ class ActionCard(QFrame):
|
|
|
317
320
|
self._prerelease_cb.hide()
|
|
318
321
|
top.addWidget(self._prerelease_cb)
|
|
319
322
|
|
|
320
|
-
|
|
323
|
+
return top
|
|
321
324
|
|
|
322
|
-
|
|
325
|
+
def _build_description_row(self) -> QLabel:
|
|
326
|
+
"""Build the description label."""
|
|
323
327
|
self._desc_label = QLabel()
|
|
324
328
|
self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE)
|
|
325
329
|
self._desc_label.setWordWrap(True)
|
|
326
|
-
|
|
330
|
+
return self._desc_label
|
|
331
|
+
|
|
332
|
+
def _build_command_row(self) -> QWidget:
|
|
333
|
+
"""Build the CLI command row with copy button."""
|
|
334
|
+
self._command_row = QWidget()
|
|
335
|
+
cmd_layout = QHBoxLayout(self._command_row)
|
|
336
|
+
cmd_layout.setContentsMargins(0, 0, 0, 0)
|
|
337
|
+
cmd_layout.setSpacing(4)
|
|
327
338
|
|
|
328
|
-
# --- CLI command row (always visible, muted monospace) ---
|
|
329
339
|
self._command_label = QLabel()
|
|
330
340
|
self._command_label.setStyleSheet(ACTION_CARD_COMMAND_STYLE)
|
|
331
341
|
self._command_label.setTextInteractionFlags(
|
|
332
342
|
Qt.TextInteractionFlag.TextSelectableByMouse,
|
|
333
343
|
)
|
|
334
|
-
self._command_label
|
|
335
|
-
|
|
344
|
+
cmd_layout.addWidget(self._command_label)
|
|
345
|
+
|
|
346
|
+
self._copy_btn = QToolButton()
|
|
347
|
+
self._copy_btn.setText(COPY_ICON)
|
|
348
|
+
self._copy_btn.setToolTip('Copy to clipboard')
|
|
349
|
+
self._copy_btn.setFixedSize(*COPY_BTN_SIZE)
|
|
350
|
+
self._copy_btn.setStyleSheet(COPY_BTN_STYLE)
|
|
351
|
+
self._copy_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
352
|
+
self._copy_btn.clicked.connect(self._copy_command)
|
|
353
|
+
cmd_layout.addWidget(self._copy_btn)
|
|
354
|
+
|
|
355
|
+
cmd_layout.addStretch()
|
|
356
|
+
|
|
357
|
+
self._command_row.hide()
|
|
358
|
+
return self._command_row
|
|
336
359
|
|
|
337
|
-
|
|
360
|
+
def _build_log_output(self) -> QTextEdit:
|
|
361
|
+
"""Build the inline log body (hidden by default)."""
|
|
338
362
|
self._log_output = QTextEdit()
|
|
339
363
|
self._log_output.setReadOnly(True)
|
|
340
364
|
self._log_output.setFont(QFont(MONOSPACE_FAMILY, MONOSPACE_SIZE))
|
|
@@ -343,16 +367,19 @@ class ActionCard(QFrame):
|
|
|
343
367
|
self._log_output.setMaximumHeight(250)
|
|
344
368
|
self._log_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
345
369
|
self._log_output.hide()
|
|
346
|
-
|
|
370
|
+
return self._log_output
|
|
347
371
|
|
|
348
372
|
# ------------------------------------------------------------------
|
|
349
373
|
# Mouse events (toggle log)
|
|
350
374
|
# ------------------------------------------------------------------
|
|
351
375
|
|
|
352
|
-
def mousePressEvent(self,
|
|
376
|
+
def mousePressEvent(self, event: object) -> None: # noqa: N802
|
|
353
377
|
"""Toggle the inline log body on click."""
|
|
354
378
|
if self._is_skeleton or not hasattr(self, '_log_output'):
|
|
355
379
|
return
|
|
380
|
+
# Don't toggle the log when clicking the copy button
|
|
381
|
+
if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
|
|
382
|
+
return
|
|
356
383
|
self._toggle_log()
|
|
357
384
|
|
|
358
385
|
def _toggle_log(self) -> None:
|
|
@@ -360,6 +387,23 @@ class ActionCard(QFrame):
|
|
|
360
387
|
self._log_expanded = not self._log_expanded
|
|
361
388
|
self._log_output.setVisible(self._log_expanded)
|
|
362
389
|
|
|
390
|
+
def _copy_command(self) -> None:
|
|
391
|
+
"""Copy the command label text to the clipboard with brief feedback."""
|
|
392
|
+
clipboard = QApplication.clipboard()
|
|
393
|
+
if clipboard:
|
|
394
|
+
clipboard.setText(self._command_label.text())
|
|
395
|
+
self._copy_btn.setText('\u2713')
|
|
396
|
+
self._copy_btn.setToolTip('Copied!')
|
|
397
|
+
|
|
398
|
+
def _restore() -> None:
|
|
399
|
+
try:
|
|
400
|
+
self._copy_btn.setText(COPY_ICON)
|
|
401
|
+
self._copy_btn.setToolTip('Copy to clipboard')
|
|
402
|
+
except RuntimeError:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
QTimer.singleShot(COPY_FEEDBACK_MS, _restore)
|
|
406
|
+
|
|
363
407
|
# ------------------------------------------------------------------
|
|
364
408
|
# Public API — populate from action data
|
|
365
409
|
# ------------------------------------------------------------------
|
|
@@ -409,9 +453,9 @@ class ActionCard(QFrame):
|
|
|
409
453
|
cmd_text = _format_command(action)
|
|
410
454
|
if cmd_text:
|
|
411
455
|
self._command_label.setText(cmd_text)
|
|
412
|
-
self.
|
|
456
|
+
self._command_row.show()
|
|
413
457
|
else:
|
|
414
|
-
self.
|
|
458
|
+
self._command_row.hide()
|
|
415
459
|
|
|
416
460
|
# Version — populated later by set_check_result()
|
|
417
461
|
|
|
@@ -467,9 +511,9 @@ class ActionCard(QFrame):
|
|
|
467
511
|
cmd_text = _format_command(action)
|
|
468
512
|
if cmd_text:
|
|
469
513
|
self._command_label.setText(cmd_text)
|
|
470
|
-
self.
|
|
514
|
+
self._command_row.show()
|
|
471
515
|
else:
|
|
472
|
-
self.
|
|
516
|
+
self._command_row.hide()
|
|
473
517
|
|
|
474
518
|
def initial_status(self) -> str:
|
|
475
519
|
"""Return the initial status text set during :meth:`populate`."""
|
|
@@ -400,9 +400,9 @@ class SetupPreviewWidget(QWidget):
|
|
|
400
400
|
self._card_list.prerelease_toggled.connect(self._on_prerelease_row_toggled)
|
|
401
401
|
outer.addWidget(self._card_list, stretch=1)
|
|
402
402
|
|
|
403
|
-
# Post-install section lives below the card list but still scrolls
|
|
403
|
+
# Post-install section lives below the card list but still scrolls.
|
|
404
|
+
# It starts hidden and is inserted into the layout after populate().
|
|
404
405
|
self._post_install_section = PostInstallSection()
|
|
405
|
-
self._card_list._layout.insertWidget(self._card_list._layout.count() - 1, self._post_install_section)
|
|
406
406
|
|
|
407
407
|
# --- Button bar (fixed at bottom) ---
|
|
408
408
|
button_bar = self._init_button_bar()
|
|
@@ -653,8 +653,12 @@ class SetupPreviewWidget(QWidget):
|
|
|
653
653
|
if installer_missing:
|
|
654
654
|
self._action_statuses[i] = 'Not installed'
|
|
655
655
|
|
|
656
|
-
# Populate
|
|
656
|
+
# Populate post-install commands and place them after all cards.
|
|
657
657
|
self._post_install_section.populate(preview.actions)
|
|
658
|
+
self._card_list._layout.insertWidget(
|
|
659
|
+
self._card_list._layout.count() - 1,
|
|
660
|
+
self._post_install_section,
|
|
661
|
+
)
|
|
658
662
|
|
|
659
663
|
self._install_btn.setEnabled(True)
|
|
660
664
|
|
|
@@ -753,12 +753,14 @@ class ProjectsView(QWidget):
|
|
|
753
753
|
manifest_key = normalize_manifest_key(str(selected_path))
|
|
754
754
|
overrides = set((self._config.prerelease_packages or {}).get(manifest_key, []))
|
|
755
755
|
|
|
756
|
-
#
|
|
757
|
-
#
|
|
756
|
+
# For file paths, use the parent directory so the dry-run
|
|
757
|
+
# can detect already-cloned repositories on disk. The final
|
|
758
|
+
# project directory may still be overridden once porringer
|
|
759
|
+
# returns ``root_directory`` in the preview result.
|
|
758
760
|
preview_worker = PreviewWorker(
|
|
759
761
|
self._porringer,
|
|
760
762
|
str(selected_path),
|
|
761
|
-
project_directory=selected_path if selected_path.is_dir() else
|
|
763
|
+
project_directory=selected_path if selected_path.is_dir() else selected_path.parent,
|
|
762
764
|
detect_updates=self._config.detect_updates,
|
|
763
765
|
prerelease_packages=overrides or None,
|
|
764
766
|
)
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -485,11 +485,14 @@ class TrayScreen:
|
|
|
485
485
|
|
|
486
486
|
if result.error:
|
|
487
487
|
if not silent:
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
488
|
+
# Distinguish informational messages (no releases for channel)
|
|
489
|
+
# from genuine failures.
|
|
490
|
+
is_no_releases = 'No releases found' in result.error
|
|
491
|
+
title = 'No Updates Available' if is_no_releases else 'Update Check Failed'
|
|
492
|
+
icon = (
|
|
493
|
+
QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning
|
|
492
494
|
)
|
|
495
|
+
self.tray.showMessage(title, result.error, icon)
|
|
493
496
|
else:
|
|
494
497
|
logger.warning('Automatic update check failed: %s', result.error)
|
|
495
498
|
return
|
|
@@ -193,6 +193,20 @@ class Updater:
|
|
|
193
193
|
return self._update_info
|
|
194
194
|
|
|
195
195
|
except Exception as e:
|
|
196
|
+
if '404' in str(e):
|
|
197
|
+
channel = self._config.channel_name
|
|
198
|
+
msg = (
|
|
199
|
+
f"No releases found for the '{channel}' channel. "
|
|
200
|
+
"Try switching to the 'Development' channel in Settings \u2192 Channel."
|
|
201
|
+
)
|
|
202
|
+
logger.debug('No releases for channel %s: %s', channel, e)
|
|
203
|
+
self._state = UpdateState.NO_UPDATE
|
|
204
|
+
return UpdateInfo(
|
|
205
|
+
available=False,
|
|
206
|
+
current_version=self._current_version,
|
|
207
|
+
error=msg,
|
|
208
|
+
)
|
|
209
|
+
|
|
196
210
|
logger.exception('Failed to check for updates')
|
|
197
211
|
self._state = UpdateState.FAILED
|
|
198
212
|
return UpdateInfo(
|
|
@@ -664,7 +664,7 @@ class TestActionCardCommandLabel:
|
|
|
664
664
|
action = _make_action(package='ruff', installer='pip')
|
|
665
665
|
card.populate(action)
|
|
666
666
|
assert card._command_label.text() == 'pip install ruff'
|
|
667
|
-
assert not card.
|
|
667
|
+
assert not card._command_row.isHidden()
|
|
668
668
|
|
|
669
669
|
@staticmethod
|
|
670
670
|
def test_explicit_cli_command() -> None:
|
|
@@ -694,22 +694,22 @@ class TestActionCardCommandLabel:
|
|
|
694
694
|
resolved = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
|
|
695
695
|
card.update_command(resolved)
|
|
696
696
|
assert card._command_label.text() == 'uv tool install ruff'
|
|
697
|
-
assert not card.
|
|
697
|
+
assert not card._command_row.isHidden()
|
|
698
698
|
|
|
699
699
|
@staticmethod
|
|
700
700
|
def test_update_command_hides_label_when_empty() -> None:
|
|
701
|
-
"""update_command hides the
|
|
701
|
+
"""update_command hides the row when the resolved action has no command."""
|
|
702
702
|
card = ActionCard()
|
|
703
703
|
action = _make_action(package='ruff', installer='pip')
|
|
704
704
|
card.populate(action)
|
|
705
|
-
assert not card.
|
|
705
|
+
assert not card._command_row.isHidden()
|
|
706
706
|
|
|
707
707
|
empty_action = _make_action(kind=PluginKind.RUNTIME)
|
|
708
708
|
empty_action.cli_command = None
|
|
709
709
|
empty_action.command = None
|
|
710
710
|
empty_action.package = None
|
|
711
711
|
card.update_command(empty_action)
|
|
712
|
-
assert card.
|
|
712
|
+
assert card._command_row.isHidden()
|
|
713
713
|
|
|
714
714
|
@staticmethod
|
|
715
715
|
def test_update_command_noop_on_skeleton() -> None:
|
|
@@ -719,6 +719,29 @@ class TestActionCardCommandLabel:
|
|
|
719
719
|
# Should not raise — skeleton simply returns early
|
|
720
720
|
card.update_command(action)
|
|
721
721
|
|
|
722
|
+
@staticmethod
|
|
723
|
+
def test_copy_button_copies_command(monkeypatch: object) -> None:
|
|
724
|
+
"""Clicking the copy button copies the command text to the clipboard."""
|
|
725
|
+
card = ActionCard()
|
|
726
|
+
action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
|
|
727
|
+
card.populate(action)
|
|
728
|
+
|
|
729
|
+
clipboard = QApplication.clipboard()
|
|
730
|
+
assert clipboard is not None
|
|
731
|
+
clipboard.clear()
|
|
732
|
+
card._copy_btn.click()
|
|
733
|
+
assert clipboard.text() == 'uv tool install ruff'
|
|
734
|
+
|
|
735
|
+
@staticmethod
|
|
736
|
+
def test_copy_button_shows_feedback() -> None:
|
|
737
|
+
"""Clicking copy shows a check-mark on the button."""
|
|
738
|
+
card = ActionCard()
|
|
739
|
+
action = _make_action(cli_command=['uv', 'tool', 'install', 'ruff'])
|
|
740
|
+
card.populate(action)
|
|
741
|
+
|
|
742
|
+
card._copy_btn.click()
|
|
743
|
+
assert card._copy_btn.text() == '\u2713'
|
|
744
|
+
|
|
722
745
|
|
|
723
746
|
# ---------------------------------------------------------------------------
|
|
724
747
|
# ActionCard — per-card spinner
|
|
@@ -806,18 +829,11 @@ class TestActionSortKey:
|
|
|
806
829
|
assert action_sort_key(package) < action_sort_key(tool)
|
|
807
830
|
|
|
808
831
|
@staticmethod
|
|
809
|
-
def
|
|
810
|
-
"""Same-kind actions
|
|
832
|
+
def test_same_kind_returns_equal_key() -> None:
|
|
833
|
+
"""Same-kind actions get equal sort keys so stable sort preserves order."""
|
|
811
834
|
alpha = _make_action(package='alpha')
|
|
812
835
|
beta = _make_action(package='beta')
|
|
813
|
-
assert action_sort_key(alpha)
|
|
814
|
-
|
|
815
|
-
@staticmethod
|
|
816
|
-
def test_case_insensitive() -> None:
|
|
817
|
-
"""Package name comparison is case-insensitive."""
|
|
818
|
-
upper = _make_action(package='Alpha')
|
|
819
|
-
lower = _make_action(package='alpha')
|
|
820
|
-
assert action_sort_key(upper) == action_sort_key(lower)
|
|
836
|
+
assert action_sort_key(alpha) == action_sort_key(beta)
|
|
821
837
|
|
|
822
838
|
|
|
823
839
|
# ---------------------------------------------------------------------------
|
|
@@ -829,19 +845,21 @@ class TestActionCardListOrdering:
|
|
|
829
845
|
"""Tests for card ordering in ActionCardList."""
|
|
830
846
|
|
|
831
847
|
@staticmethod
|
|
832
|
-
def
|
|
833
|
-
"""Cards are
|
|
848
|
+
def test_cards_grouped_by_kind_preserving_order() -> None:
|
|
849
|
+
"""Cards are grouped by execution phase, preserving porringer order within."""
|
|
834
850
|
card_list = ActionCardList()
|
|
835
851
|
a_pkg_b = _make_action(kind=PluginKind.PACKAGE, package='beta')
|
|
836
852
|
a_tool = _make_action(kind=PluginKind.TOOL, package='ruff')
|
|
837
853
|
a_pkg_a = _make_action(kind=PluginKind.PACKAGE, package='alpha')
|
|
838
854
|
a_runtime = _make_action(kind=PluginKind.RUNTIME, package='python')
|
|
839
855
|
|
|
840
|
-
# Populate in
|
|
856
|
+
# Populate in porringer's execution order
|
|
841
857
|
card_list.populate([a_pkg_b, a_tool, a_pkg_a, a_runtime])
|
|
842
858
|
|
|
843
|
-
#
|
|
844
|
-
|
|
859
|
+
# Grouped by phase: RUNTIME(0) → PACKAGE(1) → TOOL(2)
|
|
860
|
+
# Within PACKAGE group, original (porringer) order is preserved:
|
|
861
|
+
# beta came before alpha in the input list.
|
|
862
|
+
expected = ['python', 'beta', 'alpha', 'ruff']
|
|
845
863
|
assert card_list.card_count() == len(expected)
|
|
846
864
|
for i, name in enumerate(expected):
|
|
847
865
|
card = card_list.card_at(i)
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/qt/test_install_preview.py
RENAMED
|
@@ -842,6 +842,158 @@ class TestNormalizeManifestKey:
|
|
|
842
842
|
assert Path(result).is_absolute()
|
|
843
843
|
|
|
844
844
|
|
|
845
|
+
class TestPreviewWorkerProjectDirectory:
|
|
846
|
+
"""Tests for project_directory forwarding to the dry-run."""
|
|
847
|
+
|
|
848
|
+
@staticmethod
|
|
849
|
+
def test_file_path_forwards_parent_as_project_directory(tmp_path: Path) -> None:
|
|
850
|
+
"""When a manifest *file* is selected, its parent dir is forwarded."""
|
|
851
|
+
manifest = tmp_path / 'porringer.json'
|
|
852
|
+
manifest.write_text('{}')
|
|
853
|
+
|
|
854
|
+
porringer = MagicMock()
|
|
855
|
+
preview = SetupResults(actions=[])
|
|
856
|
+
manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
|
|
857
|
+
|
|
858
|
+
captured_params: list[Any] = []
|
|
859
|
+
|
|
860
|
+
async def mock_stream(params: Any) -> Any:
|
|
861
|
+
captured_params.append(params)
|
|
862
|
+
yield manifest_event
|
|
863
|
+
|
|
864
|
+
porringer.sync.execute_stream = mock_stream
|
|
865
|
+
|
|
866
|
+
worker = PreviewWorker(
|
|
867
|
+
porringer,
|
|
868
|
+
str(manifest),
|
|
869
|
+
project_directory=manifest.parent,
|
|
870
|
+
)
|
|
871
|
+
worker.run()
|
|
872
|
+
|
|
873
|
+
assert len(captured_params) == 1
|
|
874
|
+
assert captured_params[0].project_directory == tmp_path
|
|
875
|
+
|
|
876
|
+
@staticmethod
|
|
877
|
+
def test_directory_path_forwarded_directly(tmp_path: Path) -> None:
|
|
878
|
+
"""When a directory is selected its path is forwarded as-is."""
|
|
879
|
+
manifest = tmp_path / 'porringer.json'
|
|
880
|
+
manifest.write_text('{}')
|
|
881
|
+
|
|
882
|
+
porringer = MagicMock()
|
|
883
|
+
preview = SetupResults(actions=[])
|
|
884
|
+
manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
|
|
885
|
+
|
|
886
|
+
captured_params: list[Any] = []
|
|
887
|
+
|
|
888
|
+
async def mock_stream(params: Any) -> Any:
|
|
889
|
+
captured_params.append(params)
|
|
890
|
+
yield manifest_event
|
|
891
|
+
|
|
892
|
+
porringer.sync.execute_stream = mock_stream
|
|
893
|
+
|
|
894
|
+
worker = PreviewWorker(
|
|
895
|
+
porringer,
|
|
896
|
+
str(tmp_path),
|
|
897
|
+
project_directory=tmp_path,
|
|
898
|
+
)
|
|
899
|
+
worker.run()
|
|
900
|
+
|
|
901
|
+
assert len(captured_params) == 1
|
|
902
|
+
assert captured_params[0].project_directory == tmp_path
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
class TestSCMPreviewActions:
|
|
906
|
+
"""Tests for SCM (git clone) actions in the preview dry-run flow."""
|
|
907
|
+
|
|
908
|
+
@staticmethod
|
|
909
|
+
def _make_scm_action(description: str = 'Clone repo') -> MagicMock:
|
|
910
|
+
action = MagicMock()
|
|
911
|
+
action.kind = PluginKind.SCM
|
|
912
|
+
action.description = description
|
|
913
|
+
action.installer = 'git'
|
|
914
|
+
action.package = None
|
|
915
|
+
action.command = None
|
|
916
|
+
action.cli_command = None
|
|
917
|
+
return action
|
|
918
|
+
|
|
919
|
+
def test_scm_already_installed_emits_correct_result(self, tmp_path: Path) -> None:
|
|
920
|
+
"""An SCM action with ALREADY_INSTALLED is surfaced via action_checked."""
|
|
921
|
+
manifest = tmp_path / 'porringer.json'
|
|
922
|
+
manifest.write_text('{}')
|
|
923
|
+
|
|
924
|
+
porringer = MagicMock()
|
|
925
|
+
action = self._make_scm_action()
|
|
926
|
+
preview = SetupResults(actions=[action])
|
|
927
|
+
|
|
928
|
+
manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
|
|
929
|
+
result = SetupActionResult(
|
|
930
|
+
action=action,
|
|
931
|
+
success=True,
|
|
932
|
+
skipped=True,
|
|
933
|
+
skip_reason=SkipReason.ALREADY_INSTALLED,
|
|
934
|
+
)
|
|
935
|
+
completed_event = ProgressEvent(
|
|
936
|
+
kind=ProgressEventKind.ACTION_COMPLETED,
|
|
937
|
+
action=action,
|
|
938
|
+
result=result,
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
async def mock_stream(*args: Any, **kwargs: Any) -> Any:
|
|
942
|
+
yield manifest_event
|
|
943
|
+
yield completed_event
|
|
944
|
+
|
|
945
|
+
porringer.sync.execute_stream = mock_stream
|
|
946
|
+
|
|
947
|
+
worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path)
|
|
948
|
+
|
|
949
|
+
checked: list[tuple[int, SetupActionResult]] = []
|
|
950
|
+
worker.action_checked.connect(lambda row, r: checked.append((row, r)))
|
|
951
|
+
worker.run()
|
|
952
|
+
|
|
953
|
+
assert len(checked) == 1
|
|
954
|
+
assert checked[0] == (0, result)
|
|
955
|
+
assert checked[0][1].skipped is True
|
|
956
|
+
assert checked[0][1].skip_reason == SkipReason.ALREADY_INSTALLED
|
|
957
|
+
|
|
958
|
+
def test_scm_needed_emits_correct_result(self, tmp_path: Path) -> None:
|
|
959
|
+
"""An SCM action that is *not* installed is surfaced as Needed."""
|
|
960
|
+
manifest = tmp_path / 'porringer.json'
|
|
961
|
+
manifest.write_text('{}')
|
|
962
|
+
|
|
963
|
+
porringer = MagicMock()
|
|
964
|
+
action = self._make_scm_action()
|
|
965
|
+
preview = SetupResults(actions=[action])
|
|
966
|
+
|
|
967
|
+
manifest_event = ProgressEvent(kind=ProgressEventKind.MANIFEST_LOADED, manifest=preview)
|
|
968
|
+
result = SetupActionResult(
|
|
969
|
+
action=action,
|
|
970
|
+
success=True,
|
|
971
|
+
skipped=False,
|
|
972
|
+
skip_reason=None,
|
|
973
|
+
)
|
|
974
|
+
completed_event = ProgressEvent(
|
|
975
|
+
kind=ProgressEventKind.ACTION_COMPLETED,
|
|
976
|
+
action=action,
|
|
977
|
+
result=result,
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
async def mock_stream(*args: Any, **kwargs: Any) -> Any:
|
|
981
|
+
yield manifest_event
|
|
982
|
+
yield completed_event
|
|
983
|
+
|
|
984
|
+
porringer.sync.execute_stream = mock_stream
|
|
985
|
+
|
|
986
|
+
worker = PreviewWorker(porringer, str(manifest), project_directory=tmp_path)
|
|
987
|
+
|
|
988
|
+
checked: list[tuple[int, SetupActionResult]] = []
|
|
989
|
+
worker.action_checked.connect(lambda row, r: checked.append((row, r)))
|
|
990
|
+
worker.run()
|
|
991
|
+
|
|
992
|
+
assert len(checked) == 1
|
|
993
|
+
assert checked[0][1].skipped is False
|
|
994
|
+
assert checked[0][1].skip_reason is None
|
|
995
|
+
|
|
996
|
+
|
|
845
997
|
class TestPrereleaseCheckboxLock:
|
|
846
998
|
"""Tests for the pre-release checkbox lock/unlock decision logic.
|
|
847
999
|
|
|
@@ -150,6 +150,34 @@ class TestUpdaterCheckForUpdate:
|
|
|
150
150
|
assert info.error == 'Network error'
|
|
151
151
|
assert updater.state == UpdateState.FAILED
|
|
152
152
|
|
|
153
|
+
@staticmethod
|
|
154
|
+
def test_check_404_returns_friendly_message(updater: Updater) -> None:
|
|
155
|
+
"""Verify a 404 from GitHub returns a friendly no-releases message."""
|
|
156
|
+
mock_manager = MagicMock()
|
|
157
|
+
mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404')
|
|
158
|
+
|
|
159
|
+
with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
|
|
160
|
+
info = updater.check_for_update()
|
|
161
|
+
|
|
162
|
+
assert info.available is False
|
|
163
|
+
assert info.error is not None
|
|
164
|
+
assert 'No releases found' in info.error
|
|
165
|
+
assert updater._config.channel_name in info.error
|
|
166
|
+
# A missing channel is informational, not a hard failure
|
|
167
|
+
assert updater.state == UpdateState.NO_UPDATE
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def test_check_non_404_http_error_is_failed(updater: Updater) -> None:
|
|
171
|
+
"""Verify non-404 HTTP errors still produce FAILED state."""
|
|
172
|
+
mock_manager = MagicMock()
|
|
173
|
+
mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 500')
|
|
174
|
+
|
|
175
|
+
with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
|
|
176
|
+
info = updater.check_for_update()
|
|
177
|
+
|
|
178
|
+
assert info.available is False
|
|
179
|
+
assert updater.state == UpdateState.FAILED
|
|
180
|
+
|
|
153
181
|
|
|
154
182
|
class TestUpdaterDownloadUpdate:
|
|
155
183
|
"""Tests for download_update method."""
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.0.1.dev28'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/bootstrap.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/screen/card.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/synodic_client/application/theme.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev28 → synodic_client-0.0.1.dev29}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|