data-hub-watcher 0.2.2__tar.gz → 0.2.4__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.
- {data_hub_watcher-0.2.2/src/data_hub_watcher.egg-info → data_hub_watcher-0.2.4}/PKG-INFO +1 -1
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/pyproject.toml +1 -1
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/service.py +89 -18
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/updater.py +30 -1
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/upgrade_worker.py +133 -1
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4/src/data_hub_watcher.egg-info}/PKG-INFO +1 -1
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_service.py +131 -12
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_updater.py +245 -2
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_upgrade_worker.py +189 -1
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/LICENSE +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/README.md +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/setup.cfg +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/__init__.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/api_client.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/cli.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/config_io.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/constants.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/events.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/heartbeat.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/models.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/monitor.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/run_detector.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/runtime.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/scheduled_task.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/self_update.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/state.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/uploader.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher/util.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/SOURCES.txt +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/dependency_links.txt +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/entry_points.txt +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/requires.txt +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/top_level.txt +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_cli_self_update.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_cli_service_reinstall.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_events.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_heartbeat.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_monitor_events.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_monitor_initial_scan.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_preview_environment.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_run_detection_config.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_run_detector.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_run_detector_hydration.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_runtime.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_scheduled_task.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_self_update.py +0 -0
- {data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/tests/test_uploader.py +0 -0
|
@@ -113,13 +113,57 @@ def _install_upgrade_worker(config_dir: Path) -> None:
|
|
|
113
113
|
request/result paths in at render time, so a mismatch here results
|
|
114
114
|
in the worker reading from one directory while the service writes
|
|
115
115
|
to another (and the auto-update silently no-ops every tick).
|
|
116
|
+
|
|
117
|
+
The operator's uv tool directories are resolved here (rather than
|
|
118
|
+
inside the worker) because the SYSTEM account that later executes
|
|
119
|
+
the worker resolves them against the wrong profile —
|
|
120
|
+
``C:\\Windows\\System32\\config\\systemprofile\\.local\\...`` rather
|
|
121
|
+
than ``C:\\Users\\<op>\\.local\\...``. We capture the operator's
|
|
122
|
+
paths once at install time and bake them into the rendered
|
|
123
|
+
template via ``UV_TOOL_DIR`` / ``UV_TOOL_BIN_DIR`` overrides; see
|
|
124
|
+
:func:`data_hub_watcher.upgrade_worker.resolve_uv_tool_dirs` for
|
|
125
|
+
the long form of the rationale.
|
|
116
126
|
"""
|
|
117
127
|
from data_hub_watcher.scheduled_task import install_upgrade_task
|
|
118
|
-
from data_hub_watcher.
|
|
128
|
+
from data_hub_watcher.self_update import (
|
|
129
|
+
UvExecutableNotFoundError,
|
|
130
|
+
_resolve_uv_executable,
|
|
131
|
+
)
|
|
132
|
+
from data_hub_watcher.upgrade_worker import (
|
|
133
|
+
UvToolDirResolutionError,
|
|
134
|
+
resolve_uv_tool_dirs,
|
|
135
|
+
write_worker_script,
|
|
136
|
+
)
|
|
119
137
|
|
|
120
|
-
|
|
138
|
+
uv_path, _tried = _resolve_uv_executable()
|
|
139
|
+
if uv_path is None:
|
|
140
|
+
# The same `UvExecutableNotFoundError` the in-process updater
|
|
141
|
+
# raises — re-uses the operator-facing message format so a
|
|
142
|
+
# ``service install`` failure here looks identical to a stuck
|
|
143
|
+
# ``self-update``, which lab-PC docs already cover.
|
|
144
|
+
raise UvExecutableNotFoundError(_tried)
|
|
145
|
+
try:
|
|
146
|
+
tool_dir, tool_bin_dir = resolve_uv_tool_dirs(uv_path)
|
|
147
|
+
except UvToolDirResolutionError:
|
|
148
|
+
# Re-raise so ``service install`` aborts loudly. Letting the
|
|
149
|
+
# install proceed with default tool dirs (i.e. omitting the
|
|
150
|
+
# env-var overrides) would just reproduce the SYSTEM-installs-
|
|
151
|
+
# to-wrong-place bug we're trying to fix.
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
script_path = write_worker_script(
|
|
155
|
+
config_dir,
|
|
156
|
+
service_name=SERVICE_NAME,
|
|
157
|
+
tool_dir=tool_dir,
|
|
158
|
+
tool_bin_dir=tool_bin_dir,
|
|
159
|
+
)
|
|
121
160
|
install_upgrade_task(script_path)
|
|
122
|
-
logger.info(
|
|
161
|
+
logger.info(
|
|
162
|
+
"Upgrade worker script + scheduled task registered under %s (uv tool dir=%s, bin dir=%s)",
|
|
163
|
+
config_dir,
|
|
164
|
+
tool_dir,
|
|
165
|
+
tool_bin_dir,
|
|
166
|
+
)
|
|
123
167
|
|
|
124
168
|
|
|
125
169
|
def _store_paths_in_registry(config_path: Path, env_path: Path) -> None:
|
|
@@ -400,25 +444,37 @@ def query_service_status() -> dict[str, Any]:
|
|
|
400
444
|
|
|
401
445
|
|
|
402
446
|
def _repair_upgrade_worker_if_missing(sm: Any, config_dir: Path) -> None:
|
|
403
|
-
"""Re-
|
|
447
|
+
"""Re-register the upgrade scheduled task on startup if it has gone missing.
|
|
404
448
|
|
|
405
449
|
Lab PCs that auto-update into the version that introduced the
|
|
406
450
|
out-of-process worker won't have run ``service install`` with the
|
|
407
451
|
new code, so the scheduled task simply isn't there — and the next
|
|
408
452
|
auto-update tick would fail loudly. Repairing on each service
|
|
409
453
|
startup means the host self-heals on the very next service restart
|
|
410
|
-
(which the auto-updater triggers anyway)
|
|
411
|
-
|
|
454
|
+
(which the auto-updater triggers anyway) for the common case where
|
|
455
|
+
the rendered worker script is still on disk and only the task
|
|
456
|
+
record was lost (e.g. an operator manually deleted it in
|
|
457
|
+
``taskschd.msc``).
|
|
458
|
+
|
|
459
|
+
Critical scope limit: this helper does NOT re-render the worker
|
|
460
|
+
template. Re-rendering requires the *operator's* uv tool
|
|
461
|
+
directories from ``uv tool dir`` / ``uv tool dir --bin``, and the
|
|
462
|
+
service runs as LocalSystem so any uv invocation from here would
|
|
463
|
+
resolve those paths against the SYSTEM profile rather than the
|
|
464
|
+
operator's. A SYSTEM-rendered template would bake in the wrong
|
|
465
|
+
``UV_TOOL_DIR`` / ``UV_TOOL_BIN_DIR`` and silently install future
|
|
466
|
+
upgrades into ``C:\\Windows\\System32\\config\\systemprofile\\
|
|
467
|
+
.local\\...`` instead of the operator's profile — exactly the bug
|
|
468
|
+
we're trying to prevent. If the rendered script is also missing
|
|
469
|
+
from *config_dir*, the only safe recovery is for the operator to
|
|
470
|
+
run ``data-hub-watcher service reinstall`` from their own shell;
|
|
471
|
+
we log a clear pointer and punt rather than render-against-SYSTEM
|
|
472
|
+
a worker that would corrupt the next auto-update.
|
|
412
473
|
|
|
413
474
|
*config_dir* must be the operator's resolved config directory (the
|
|
414
|
-
parent of the registry-stored config path) —
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
``C:\\Windows\\System32\\config\\systemprofile\\.data-hub`` rather
|
|
418
|
-
than the operator's ``~\\.data-hub``. Rendering the worker template
|
|
419
|
-
against the wrong directory bakes in a sentinel path the running
|
|
420
|
-
service will never actually write to, which silently breaks every
|
|
421
|
-
subsequent auto-update.
|
|
475
|
+
parent of the registry-stored config path) — see the comment on
|
|
476
|
+
:class:`~data_hub_watcher.runtime.WatcherRuntime.config_dir` for
|
|
477
|
+
why ``DEFAULT_CONFIG_DIR`` would resolve incorrectly here.
|
|
422
478
|
|
|
423
479
|
Failures are logged via the Windows event log but do not raise:
|
|
424
480
|
a host that can't register the task should still be able to run
|
|
@@ -432,7 +488,7 @@ def _repair_upgrade_worker_if_missing(sm: Any, config_dir: Path) -> None:
|
|
|
432
488
|
install_upgrade_task,
|
|
433
489
|
task_exists,
|
|
434
490
|
)
|
|
435
|
-
from data_hub_watcher.upgrade_worker import
|
|
491
|
+
from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
|
|
436
492
|
except Exception as exc:
|
|
437
493
|
sm.LogWarningMsg(f"Cannot import upgrade worker modules during startup: {exc}")
|
|
438
494
|
return
|
|
@@ -450,12 +506,27 @@ def _repair_upgrade_worker_if_missing(sm: Any, config_dir: Path) -> None:
|
|
|
450
506
|
if present:
|
|
451
507
|
return
|
|
452
508
|
|
|
509
|
+
script_path = upgrade_worker_script_path(config_dir)
|
|
510
|
+
if not script_path.exists():
|
|
511
|
+
# We deliberately do NOT re-render here — see the docstring
|
|
512
|
+
# for why a SYSTEM-rendered template would silently break the
|
|
513
|
+
# very thing the repair is meant to fix.
|
|
514
|
+
sm.LogWarningMsg(
|
|
515
|
+
f"Upgrade scheduled task is missing AND the rendered worker "
|
|
516
|
+
f"script is absent from {script_path}. Cannot self-repair "
|
|
517
|
+
"from a LocalSystem context. Run "
|
|
518
|
+
"'data-hub-watcher service reinstall' as Administrator to "
|
|
519
|
+
"re-render the worker against the operator's uv tool "
|
|
520
|
+
"directories and re-register the task."
|
|
521
|
+
)
|
|
522
|
+
return
|
|
523
|
+
|
|
453
524
|
sm.LogInfoMsg(
|
|
454
|
-
"Upgrade scheduled task missing
|
|
455
|
-
"
|
|
525
|
+
f"Upgrade scheduled task missing but worker script is present at "
|
|
526
|
+
f"{script_path}; re-registering the task pointing at the existing "
|
|
527
|
+
"rendered script."
|
|
456
528
|
)
|
|
457
529
|
try:
|
|
458
|
-
script_path = write_worker_script(config_dir, service_name=SERVICE_NAME)
|
|
459
530
|
install_upgrade_task(script_path)
|
|
460
531
|
except (OSError, ScheduledTaskError) as exc:
|
|
461
532
|
sm.LogWarningMsg(
|
|
@@ -85,6 +85,20 @@ class UpdaterConfig:
|
|
|
85
85
|
# reason about heartbeat timing.
|
|
86
86
|
min_run_age_seconds: float | None = None
|
|
87
87
|
run_quiet_multiplier: int = DEFAULT_RUN_QUIET_MULTIPLIER
|
|
88
|
+
# When True (the production default), the very first heartbeat tick
|
|
89
|
+
# after process startup fires an ``/update-check`` API call instead
|
|
90
|
+
# of waiting a full ``check_interval_ticks`` window — so a fresh
|
|
91
|
+
# service restart picks up a pending release within one heartbeat
|
|
92
|
+
# interval (~60 s) rather than up to an hour. The activity-window
|
|
93
|
+
# gates inside ``should_attempt_update`` still apply: if an upload
|
|
94
|
+
# or run was happening when the previous instance died, the
|
|
95
|
+
# ``last_run_age_seconds`` / ``idle_ticks_required`` checks defer
|
|
96
|
+
# the actual upgrade attempt as designed. The on-start fast path
|
|
97
|
+
# only short-circuits the *cadence* part of the gate, not the
|
|
98
|
+
# *safety* part. Tests opt out via ``check_on_start=False`` so
|
|
99
|
+
# their existing cadence assertions (``for _ in range(N): on_tick();
|
|
100
|
+
# then trigger on the (N+1)th``) keep working unchanged.
|
|
101
|
+
check_on_start: bool = True
|
|
88
102
|
|
|
89
103
|
|
|
90
104
|
@dataclass
|
|
@@ -336,7 +350,22 @@ class Updater:
|
|
|
336
350
|
self._upgrade_executor = upgrade_executor or _default_upgrade_executor
|
|
337
351
|
|
|
338
352
|
self._idle_ticks = 0
|
|
339
|
-
|
|
353
|
+
# Seed the cadence counter so the very first tick fires an
|
|
354
|
+
# ``/update-check`` API call when ``check_on_start`` is set
|
|
355
|
+
# (the production default). With ``check_interval_ticks=60``
|
|
356
|
+
# and a 60-second heartbeat, the cold-start latency for
|
|
357
|
+
# picking up a pending release drops from "up to 60 minutes"
|
|
358
|
+
# to "~60 seconds" — which matters most during testing
|
|
359
|
+
# iteration (operator runs ``service reinstall``, then sees
|
|
360
|
+
# the next attempt within one heartbeat instead of an hour)
|
|
361
|
+
# but is also a small operability win in production: a host
|
|
362
|
+
# that just rebooted picks up the latest version immediately
|
|
363
|
+
# rather than running stale code for the first hour after
|
|
364
|
+
# boot. The activity-window gates in ``should_attempt_update``
|
|
365
|
+
# still defer the actual upgrade if there's recent run /
|
|
366
|
+
# upload activity, so this short-circuits cadence only — not
|
|
367
|
+
# safety.
|
|
368
|
+
self._ticks_since_check = c.check_interval_ticks if c.check_on_start else 0
|
|
340
369
|
self._upgrade_in_progress = False
|
|
341
370
|
# Per-target memo for the "ineligible install method" refusal
|
|
342
371
|
# path: we want one ``UPDATE_FAILED`` event per *new* server
|
|
@@ -40,6 +40,7 @@ from __future__ import annotations
|
|
|
40
40
|
import json
|
|
41
41
|
import logging
|
|
42
42
|
import os
|
|
43
|
+
import subprocess
|
|
43
44
|
import sys
|
|
44
45
|
import uuid
|
|
45
46
|
from dataclasses import dataclass
|
|
@@ -355,6 +356,82 @@ def build_pkg_spec(
|
|
|
355
356
|
return spec
|
|
356
357
|
|
|
357
358
|
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# uv tool directory resolution
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class UvToolDirResolutionError(RuntimeError):
|
|
365
|
+
"""Raised when ``uv tool dir`` / ``uv tool dir --bin`` can't be resolved.
|
|
366
|
+
|
|
367
|
+
Carries the failing argv and uv's stderr so the caller can surface a
|
|
368
|
+
diagnosable error rather than silently rendering a worker template
|
|
369
|
+
against an empty path.
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
def __init__(self, argv: list[str], *, stderr: str = "", returncode: int = -1) -> None:
|
|
373
|
+
super().__init__(
|
|
374
|
+
f"`{' '.join(argv)}` failed (exit {returncode}): {stderr.strip() or '<no stderr>'}"
|
|
375
|
+
)
|
|
376
|
+
self.argv = argv
|
|
377
|
+
self.stderr = stderr
|
|
378
|
+
self.returncode = returncode
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def resolve_uv_tool_dirs(uv_executable: str) -> tuple[Path, Path]:
|
|
382
|
+
"""Return ``(tool_dir, tool_bin_dir)`` for the operator's uv install.
|
|
383
|
+
|
|
384
|
+
Captured at install time so the worker template can bake them in
|
|
385
|
+
explicitly. The worker runs as LocalSystem via the scheduled task,
|
|
386
|
+
and uv resolves its install directories from ``$env:USERPROFILE``
|
|
387
|
+
plus the platform-specific defaults — under SYSTEM that resolves
|
|
388
|
+
to ``C:\\Windows\\System32\\config\\systemprofile\\.local\\share\\
|
|
389
|
+
uv\\tools`` (and ``…\\.local\\bin``), which is **not** where the
|
|
390
|
+
running watcher service was originally installed from. Without
|
|
391
|
+
this override uv happily installs a *second* copy of the package
|
|
392
|
+
into SYSTEM's profile while the operator's profile (where the
|
|
393
|
+
Windows-service registered ``ImagePath`` actually points) keeps
|
|
394
|
+
serving the old version. The next service restart loads the old
|
|
395
|
+
code and the marker comparison fails with "expected X, running Y".
|
|
396
|
+
|
|
397
|
+
By shelling out to ``uv tool dir`` from the install context (where
|
|
398
|
+
``$env:USERPROFILE`` correctly points at the operator's home),
|
|
399
|
+
we capture the *operator's* tool directories and bake them in so
|
|
400
|
+
the SYSTEM-running worker can force ``uv`` to install in place at
|
|
401
|
+
the right location via ``UV_TOOL_DIR`` / ``UV_TOOL_BIN_DIR``.
|
|
402
|
+
|
|
403
|
+
Raises :class:`UvToolDirResolutionError` on a non-zero exit. The
|
|
404
|
+
caller (``_install_upgrade_worker``) treats this as fatal so a
|
|
405
|
+
silent miss surfaces during ``service install`` — by far the
|
|
406
|
+
cheapest place to catch it.
|
|
407
|
+
"""
|
|
408
|
+
tool_dir = _run_uv_tool_dir(uv_executable, [])
|
|
409
|
+
tool_bin_dir = _run_uv_tool_dir(uv_executable, ["--bin"])
|
|
410
|
+
return tool_dir, tool_bin_dir
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _run_uv_tool_dir(uv_executable: str, extra_args: list[str]) -> Path:
|
|
414
|
+
argv = [uv_executable, "tool", "dir", *extra_args]
|
|
415
|
+
try:
|
|
416
|
+
proc = subprocess.run(
|
|
417
|
+
argv,
|
|
418
|
+
capture_output=True,
|
|
419
|
+
text=True,
|
|
420
|
+
check=False,
|
|
421
|
+
timeout=30,
|
|
422
|
+
)
|
|
423
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
424
|
+
raise UvToolDirResolutionError(argv, stderr=str(exc)) from exc
|
|
425
|
+
|
|
426
|
+
if proc.returncode != 0:
|
|
427
|
+
raise UvToolDirResolutionError(argv, stderr=proc.stderr, returncode=proc.returncode)
|
|
428
|
+
|
|
429
|
+
raw = proc.stdout.strip()
|
|
430
|
+
if not raw:
|
|
431
|
+
raise UvToolDirResolutionError(argv, stderr="empty stdout", returncode=proc.returncode)
|
|
432
|
+
return Path(raw)
|
|
433
|
+
|
|
434
|
+
|
|
358
435
|
# ---------------------------------------------------------------------------
|
|
359
436
|
# PowerShell worker template
|
|
360
437
|
# ---------------------------------------------------------------------------
|
|
@@ -385,6 +462,18 @@ $ServiceName = "{service_name}"
|
|
|
385
462
|
$RequestPath = "{request_path}"
|
|
386
463
|
$ResultPath = "{result_path}"
|
|
387
464
|
$LogPath = "{log_path}"
|
|
465
|
+
# Operator-context paths captured at install time. The scheduled task
|
|
466
|
+
# runs as LocalSystem, so without these overrides `uv tool install`
|
|
467
|
+
# would resolve UV_TOOL_DIR / UV_TOOL_BIN_DIR against
|
|
468
|
+
# C:\\Windows\\System32\\config\\systemprofile\\.local\\... and install
|
|
469
|
+
# a SECOND copy of the package into SYSTEM's profile while the
|
|
470
|
+
# operator's tool venv (which the Windows-service ImagePath actually
|
|
471
|
+
# points at) is left at the old version. The env-var assignments
|
|
472
|
+
# below force uv to reinstall *in place* at the operator's tool dir
|
|
473
|
+
# so the existing service binary path resolves to the new wheel on
|
|
474
|
+
# the next service restart.
|
|
475
|
+
$ToolDir = "{tool_dir}"
|
|
476
|
+
$ToolBinDir = "{tool_bin_dir}"
|
|
388
477
|
|
|
389
478
|
function Write-Log($msg) {{
|
|
390
479
|
$ts = (Get-Date).ToUniversalTime().ToString("o")
|
|
@@ -393,8 +482,19 @@ function Write-Log($msg) {{
|
|
|
393
482
|
}}
|
|
394
483
|
|
|
395
484
|
function Write-ResultJson($payload) {{
|
|
485
|
+
# PowerShell 5.1 (the default on Windows 10/11) emits UTF-8 *with*
|
|
486
|
+
# a BOM when invoked as `Set-Content -Encoding UTF8`. Python's
|
|
487
|
+
# `json.loads(path.read_text(encoding="utf-8"))` then fails on the
|
|
488
|
+
# leading U+FEFF and `read_upgrade_result` silently swallows the
|
|
489
|
+
# JSONDecodeError, returning None. The post-restart inspection in
|
|
490
|
+
# runtime.py then flags `worker_result_missing=True` even though
|
|
491
|
+
# the file was written successfully — the operator sees no actual
|
|
492
|
+
# error context in the failure event and has to remote in to find
|
|
493
|
+
# it. Use the .NET API with an explicit BOM-less UTF-8 encoder so
|
|
494
|
+
# the result sentinel is readable on every supported PowerShell.
|
|
396
495
|
$tmp = "$ResultPath.tmp"
|
|
397
|
-
$payload | ConvertTo-Json -Depth 4
|
|
496
|
+
$json = $payload | ConvertTo-Json -Depth 4
|
|
497
|
+
[System.IO.File]::WriteAllText($tmp, $json, [System.Text.UTF8Encoding]::new($false))
|
|
398
498
|
Move-Item -LiteralPath $tmp -Destination $ResultPath -Force
|
|
399
499
|
}}
|
|
400
500
|
|
|
@@ -453,6 +553,16 @@ try {{
|
|
|
453
553
|
# the in-process path produce equivalent uv invocations.
|
|
454
554
|
$uvArgs = @("tool", "install", "--reinstall", "--index-url", $IndexUrl, $PkgSpec)
|
|
455
555
|
|
|
556
|
+
# Pin uv's view of the tool directories to the operator's profile,
|
|
557
|
+
# not LocalSystem's. See the comment on `$ToolDir` / `$ToolBinDir`
|
|
558
|
+
# above for why this matters. We set them on the process
|
|
559
|
+
# environment so the subprocess `uv` inherits them; setting via
|
|
560
|
+
# `Start-Process -Environment` would be cleaner but is not
|
|
561
|
+
# available on Windows PowerShell 5.1.
|
|
562
|
+
$env:UV_TOOL_DIR = $ToolDir
|
|
563
|
+
$env:UV_TOOL_BIN_DIR = $ToolBinDir
|
|
564
|
+
Write-Log "uv env: UV_TOOL_DIR=$ToolDir UV_TOOL_BIN_DIR=$ToolBinDir"
|
|
565
|
+
|
|
456
566
|
Write-Log "running: $UvExe $($uvArgs -join ' ')"
|
|
457
567
|
|
|
458
568
|
$stdoutFile = [System.IO.Path]::GetTempFileName()
|
|
@@ -531,6 +641,8 @@ def render_worker_script(
|
|
|
531
641
|
request_path: Path,
|
|
532
642
|
result_path: Path,
|
|
533
643
|
log_path: Path,
|
|
644
|
+
tool_dir: Path,
|
|
645
|
+
tool_bin_dir: Path,
|
|
534
646
|
generated_at: str | None = None,
|
|
535
647
|
) -> str:
|
|
536
648
|
"""Render :data:`WORKER_SCRIPT_TEMPLATE` with concrete paths.
|
|
@@ -540,6 +652,13 @@ def render_worker_script(
|
|
|
540
652
|
the request sentinel so a post-install relocation of ``uv.exe``
|
|
541
653
|
doesn't require regenerating the script.
|
|
542
654
|
|
|
655
|
+
*tool_dir* and *tool_bin_dir* MUST be the paths reported by
|
|
656
|
+
``uv tool dir`` / ``uv tool dir --bin`` from the operator's
|
|
657
|
+
install context. See :func:`resolve_uv_tool_dirs` for the full
|
|
658
|
+
rationale; the short version is that without these baked into
|
|
659
|
+
the SYSTEM-running worker, ``uv`` installs into the wrong profile
|
|
660
|
+
and the auto-update silently no-ops.
|
|
661
|
+
|
|
543
662
|
The ``.upgrade-in-progress`` marker is intentionally not handed to
|
|
544
663
|
the worker: its full lifecycle (write before dispatch, evaluate
|
|
545
664
|
and clear on the next process start) lives on the Python side in
|
|
@@ -551,6 +670,8 @@ def render_worker_script(
|
|
|
551
670
|
request_path=str(request_path),
|
|
552
671
|
result_path=str(result_path),
|
|
553
672
|
log_path=str(log_path),
|
|
673
|
+
tool_dir=str(tool_dir),
|
|
674
|
+
tool_bin_dir=str(tool_bin_dir),
|
|
554
675
|
)
|
|
555
676
|
|
|
556
677
|
|
|
@@ -558,12 +679,21 @@ def write_worker_script(
|
|
|
558
679
|
config_dir: Path,
|
|
559
680
|
*,
|
|
560
681
|
service_name: str,
|
|
682
|
+
tool_dir: Path,
|
|
683
|
+
tool_bin_dir: Path,
|
|
561
684
|
generated_at: str | None = None,
|
|
562
685
|
) -> Path:
|
|
563
686
|
"""Render the worker script and drop it under *config_dir*.
|
|
564
687
|
|
|
565
688
|
Returns the resulting path so callers (the service installer) can
|
|
566
689
|
hand it to ``schtasks`` as the action target.
|
|
690
|
+
|
|
691
|
+
*tool_dir* / *tool_bin_dir* are required because rendering against
|
|
692
|
+
a default that resolves under LocalSystem (the account the worker
|
|
693
|
+
later runs as) silently produces a worker that installs to the
|
|
694
|
+
wrong profile. Forcing the caller to supply them at install time
|
|
695
|
+
— when the operator-context :func:`resolve_uv_tool_dirs` lookup
|
|
696
|
+
is available — keeps the failure mode loud rather than silent.
|
|
567
697
|
"""
|
|
568
698
|
script_path = upgrade_worker_script_path(config_dir)
|
|
569
699
|
script = render_worker_script(
|
|
@@ -571,6 +701,8 @@ def write_worker_script(
|
|
|
571
701
|
request_path=upgrade_request_path(config_dir),
|
|
572
702
|
result_path=upgrade_result_path(config_dir),
|
|
573
703
|
log_path=upgrade_worker_log_path(config_dir),
|
|
704
|
+
tool_dir=tool_dir,
|
|
705
|
+
tool_bin_dir=tool_bin_dir,
|
|
574
706
|
generated_at=generated_at,
|
|
575
707
|
)
|
|
576
708
|
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -303,6 +303,21 @@ class TestInstallUpgradeWorker:
|
|
|
303
303
|
"data_hub_watcher.scheduled_task.install_upgrade_task",
|
|
304
304
|
lambda script_path: install_calls.append(script_path),
|
|
305
305
|
)
|
|
306
|
+
# `_install_upgrade_worker` resolves the operator's uv path
|
|
307
|
+
# and tool dirs at install time so the SYSTEM-running worker
|
|
308
|
+
# later forces uv to install in place at the operator's
|
|
309
|
+
# profile via UV_TOOL_DIR / UV_TOOL_BIN_DIR overrides. Stub
|
|
310
|
+
# both so the test doesn't depend on a real uv install.
|
|
311
|
+
operator_tool_dir = tmp_path / "tools"
|
|
312
|
+
operator_tool_bin_dir = tmp_path / "bin"
|
|
313
|
+
monkeypatch.setattr(
|
|
314
|
+
"data_hub_watcher.self_update._resolve_uv_executable",
|
|
315
|
+
lambda: (str(tmp_path / "uv.exe"), [str(tmp_path / "uv.exe")]),
|
|
316
|
+
)
|
|
317
|
+
monkeypatch.setattr(
|
|
318
|
+
"data_hub_watcher.upgrade_worker.resolve_uv_tool_dirs",
|
|
319
|
+
lambda _uv: (operator_tool_dir, operator_tool_bin_dir),
|
|
320
|
+
)
|
|
306
321
|
|
|
307
322
|
service_module._install_upgrade_worker(tmp_path)
|
|
308
323
|
|
|
@@ -315,19 +330,76 @@ class TestInstallUpgradeWorker:
|
|
|
315
330
|
|
|
316
331
|
script_path = upgrade_worker_script_path(tmp_path)
|
|
317
332
|
assert script_path.exists()
|
|
318
|
-
|
|
333
|
+
rendered = script_path.read_text(encoding="utf-8")
|
|
334
|
+
assert "Stop-Service" in rendered
|
|
319
335
|
# The rendered template MUST bake in sentinel paths under
|
|
320
336
|
# the supplied config dir — anything else means the running
|
|
321
337
|
# service (which writes sentinels to its own resolved
|
|
322
338
|
# ``config_dir``) would write to a different directory than
|
|
323
339
|
# the worker reads from, and every auto-update silently
|
|
324
340
|
# no-ops with "no request sentinel".
|
|
325
|
-
rendered = script_path.read_text(encoding="utf-8")
|
|
326
341
|
assert str(tmp_path / ".upgrade-request.json") in rendered
|
|
327
342
|
assert str(tmp_path / ".upgrade-result.json") in rendered
|
|
343
|
+
# The operator's tool dirs must be baked in too — without the
|
|
344
|
+
# UV_TOOL_DIR override the SYSTEM-running worker installs to
|
|
345
|
+
# the wrong profile and the upgrade silently no-ops at the
|
|
346
|
+
# next service restart even though uv exited 0.
|
|
347
|
+
assert str(operator_tool_dir) in rendered
|
|
348
|
+
assert str(operator_tool_bin_dir) in rendered
|
|
349
|
+
assert "$env:UV_TOOL_DIR" in rendered
|
|
350
|
+
assert "$env:UV_TOOL_BIN_DIR" in rendered
|
|
328
351
|
# Task installer was handed the same path.
|
|
329
352
|
assert install_calls == [script_path]
|
|
330
353
|
|
|
354
|
+
def test_install_raises_when_uv_cannot_be_located(
|
|
355
|
+
self,
|
|
356
|
+
service_module: ModuleType,
|
|
357
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
358
|
+
tmp_path: Path,
|
|
359
|
+
) -> None:
|
|
360
|
+
# A `service install` on a host without uv on PATH and without
|
|
361
|
+
# uv in any of the sys.prefix-derived candidates must fail
|
|
362
|
+
# loudly here — proceeding would render a template against
|
|
363
|
+
# default tool dirs, which under SYSTEM would silently install
|
|
364
|
+
# future upgrades into the wrong profile.
|
|
365
|
+
from data_hub_watcher.self_update import UvExecutableNotFoundError
|
|
366
|
+
|
|
367
|
+
monkeypatch.setattr(
|
|
368
|
+
"data_hub_watcher.self_update._resolve_uv_executable",
|
|
369
|
+
lambda: (None, ["/no/such/uv"]),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
with pytest.raises(UvExecutableNotFoundError):
|
|
373
|
+
service_module._install_upgrade_worker(tmp_path)
|
|
374
|
+
|
|
375
|
+
def test_install_raises_when_uv_tool_dir_lookup_fails(
|
|
376
|
+
self,
|
|
377
|
+
service_module: ModuleType,
|
|
378
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
379
|
+
tmp_path: Path,
|
|
380
|
+
) -> None:
|
|
381
|
+
# Same loud-failure rule for the second half of the lookup:
|
|
382
|
+
# if `uv tool dir` itself can't tell us where the operator's
|
|
383
|
+
# tool venvs live, we have no business rendering a worker
|
|
384
|
+
# template that pretends to know.
|
|
385
|
+
from data_hub_watcher.upgrade_worker import UvToolDirResolutionError
|
|
386
|
+
|
|
387
|
+
monkeypatch.setattr(
|
|
388
|
+
"data_hub_watcher.self_update._resolve_uv_executable",
|
|
389
|
+
lambda: (str(tmp_path / "uv.exe"), [str(tmp_path / "uv.exe")]),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def boom(_uv: str) -> tuple[Path, Path]:
|
|
393
|
+
raise UvToolDirResolutionError(["uv", "tool", "dir"], stderr="nope")
|
|
394
|
+
|
|
395
|
+
monkeypatch.setattr(
|
|
396
|
+
"data_hub_watcher.upgrade_worker.resolve_uv_tool_dirs",
|
|
397
|
+
boom,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
with pytest.raises(UvToolDirResolutionError):
|
|
401
|
+
service_module._install_upgrade_worker(tmp_path)
|
|
402
|
+
|
|
331
403
|
def test_uninstall_removes_task_and_script(
|
|
332
404
|
self,
|
|
333
405
|
service_module: ModuleType,
|
|
@@ -401,12 +473,22 @@ class TestRepairUpgradeWorkerOnStartup:
|
|
|
401
473
|
|
|
402
474
|
assert install_calls == []
|
|
403
475
|
|
|
404
|
-
def
|
|
476
|
+
def test_repair_re_registers_when_task_missing_and_script_present(
|
|
405
477
|
self,
|
|
406
478
|
service_module: ModuleType,
|
|
407
479
|
monkeypatch: pytest.MonkeyPatch,
|
|
408
480
|
tmp_path: Path,
|
|
409
481
|
) -> None:
|
|
482
|
+
# A pre-existing rendered script means a previous valid
|
|
483
|
+
# `service install` ran in operator context and captured the
|
|
484
|
+
# right uv tool dirs. Re-registering the task to point at
|
|
485
|
+
# that script is the safe self-repair — no need to re-render.
|
|
486
|
+
from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
|
|
487
|
+
|
|
488
|
+
script_path = upgrade_worker_script_path(tmp_path)
|
|
489
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
490
|
+
script_path.write_text("# previously rendered worker", encoding="utf-8")
|
|
491
|
+
|
|
410
492
|
sm = MagicMock(name="servicemanager")
|
|
411
493
|
monkeypatch.setattr("data_hub_watcher.scheduled_task.task_exists", lambda: False)
|
|
412
494
|
install_calls: list[Path] = []
|
|
@@ -417,16 +499,46 @@ class TestRepairUpgradeWorkerOnStartup:
|
|
|
417
499
|
|
|
418
500
|
service_module._repair_upgrade_worker_if_missing(sm, tmp_path)
|
|
419
501
|
|
|
420
|
-
|
|
502
|
+
# Task is re-registered against the existing script.
|
|
503
|
+
assert install_calls == [script_path]
|
|
504
|
+
# CRITICAL: existing script content is NOT overwritten — the
|
|
505
|
+
# operator's UV_TOOL_DIR / UV_TOOL_BIN_DIR baked at install
|
|
506
|
+
# time must be preserved. Re-rendering under SYSTEM (which
|
|
507
|
+
# is what runs the repair) would resolve the wrong tool dirs
|
|
508
|
+
# and silently break future auto-updates.
|
|
509
|
+
assert script_path.read_text(encoding="utf-8") == "# previously rendered worker"
|
|
510
|
+
|
|
511
|
+
def test_repair_punts_when_task_and_script_both_missing(
|
|
512
|
+
self,
|
|
513
|
+
service_module: ModuleType,
|
|
514
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
515
|
+
tmp_path: Path,
|
|
516
|
+
) -> None:
|
|
517
|
+
# Without a rendered script on disk we have no way to
|
|
518
|
+
# reconstruct the operator's UV_TOOL_DIR from a SYSTEM
|
|
519
|
+
# context. Re-rendering with whatever LocalSystem's
|
|
520
|
+
# `uv tool dir` would return bakes in the wrong profile and
|
|
521
|
+
# silently corrupts future auto-updates. The only safe move
|
|
522
|
+
# is to log a clear pointer at `service reinstall` and punt;
|
|
523
|
+
# the next auto-update tick will fail loudly with a usable
|
|
524
|
+
# error.
|
|
525
|
+
sm = MagicMock(name="servicemanager")
|
|
526
|
+
monkeypatch.setattr("data_hub_watcher.scheduled_task.task_exists", lambda: False)
|
|
527
|
+
install_calls: list[Path] = []
|
|
528
|
+
monkeypatch.setattr(
|
|
529
|
+
"data_hub_watcher.scheduled_task.install_upgrade_task",
|
|
530
|
+
lambda script_path: install_calls.append(script_path),
|
|
531
|
+
)
|
|
421
532
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
#
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
533
|
+
service_module._repair_upgrade_worker_if_missing(sm, tmp_path)
|
|
534
|
+
|
|
535
|
+
# No re-registration happened — punted to operator action.
|
|
536
|
+
assert install_calls == []
|
|
537
|
+
# …and the operator-facing message points at the recovery
|
|
538
|
+
# command, not a bare error code.
|
|
539
|
+
sm.LogWarningMsg.assert_called_once()
|
|
540
|
+
msg = sm.LogWarningMsg.call_args.args[0]
|
|
541
|
+
assert "service reinstall" in msg
|
|
430
542
|
|
|
431
543
|
def test_repair_swallows_query_errors(
|
|
432
544
|
self,
|
|
@@ -457,6 +569,13 @@ class TestRepairUpgradeWorkerOnStartup:
|
|
|
457
569
|
tmp_path: Path,
|
|
458
570
|
) -> None:
|
|
459
571
|
from data_hub_watcher.scheduled_task import ScheduledTaskError
|
|
572
|
+
from data_hub_watcher.upgrade_worker import upgrade_worker_script_path
|
|
573
|
+
|
|
574
|
+
# Need an existing script for the repair to attempt task
|
|
575
|
+
# registration; the failure is on `install_upgrade_task`.
|
|
576
|
+
script_path = upgrade_worker_script_path(tmp_path)
|
|
577
|
+
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
578
|
+
script_path.write_text("# previously rendered worker", encoding="utf-8")
|
|
460
579
|
|
|
461
580
|
sm = MagicMock(name="servicemanager")
|
|
462
581
|
monkeypatch.setattr("data_hub_watcher.scheduled_task.task_exists", lambda: False)
|
|
@@ -147,6 +147,15 @@ def _make_updater(
|
|
|
147
147
|
idle_ticks_required=2,
|
|
148
148
|
check_interval_ticks=3,
|
|
149
149
|
min_run_age_seconds=10.0,
|
|
150
|
+
# Opt out of the production-default "fire /update-check
|
|
151
|
+
# on the very first tick" fast path so the existing
|
|
152
|
+
# ``for _ in range(N): on_tick(); then trigger on the
|
|
153
|
+
# (N+1)th tick`` cadence assertions throughout this file
|
|
154
|
+
# keep working unchanged. The on-start fast path has its
|
|
155
|
+
# own dedicated test class below — pinning the default-on
|
|
156
|
+
# behaviour separately keeps each test focused on the
|
|
157
|
+
# specific behaviour it's exercising.
|
|
158
|
+
check_on_start=False,
|
|
150
159
|
),
|
|
151
160
|
upgrade_runner=upgrade_runner,
|
|
152
161
|
upgrade_executor=upgrade_executor,
|
|
@@ -328,9 +337,16 @@ class TestUpdaterOnTick:
|
|
|
328
337
|
h.request_restart.assert_not_called()
|
|
329
338
|
h.reporter.queue_event.assert_not_called()
|
|
330
339
|
|
|
331
|
-
def
|
|
340
|
+
def test_first_ticks_are_no_op_until_check_interval_when_check_on_start_disabled(
|
|
341
|
+
self, tmp_path: Path
|
|
342
|
+
) -> None:
|
|
343
|
+
# The default test helper opts out of the on-start fast path
|
|
344
|
+
# (see comment in ``_make_updater``) so this still pins the
|
|
345
|
+
# cadence-only behaviour: with ``check_interval_ticks=3``,
|
|
346
|
+
# ticks 1 and 2 are no-ops and tick 3 fires the API call.
|
|
347
|
+
# The on-start fast path's effect on this same shape is
|
|
348
|
+
# pinned in ``TestUpdaterChecksOnStart`` below.
|
|
332
349
|
h = _make_updater(tmp_path)
|
|
333
|
-
# Below check_interval_ticks=3 we should not call the API at all.
|
|
334
350
|
h.counters.last_files_uploaded = 0
|
|
335
351
|
assert h.updater.on_tick() is None
|
|
336
352
|
assert h.updater.on_tick() is None
|
|
@@ -345,6 +361,10 @@ class TestUpdaterOnTick:
|
|
|
345
361
|
idle_ticks_required=2,
|
|
346
362
|
check_interval_ticks=2,
|
|
347
363
|
min_run_age_seconds=1.0,
|
|
364
|
+
# See helper-level comment in ``_make_updater`` for
|
|
365
|
+
# why cadence-focused tests opt out of the on-start
|
|
366
|
+
# fast path.
|
|
367
|
+
check_on_start=False,
|
|
348
368
|
),
|
|
349
369
|
)
|
|
350
370
|
h.client.get_update_info.return_value = _info(latest="9.9.9")
|
|
@@ -386,6 +406,12 @@ class TestUpdaterOnTick:
|
|
|
386
406
|
idle_ticks_required=0,
|
|
387
407
|
check_interval_ticks=1, # fire on every tick
|
|
388
408
|
min_run_age_seconds=0.0,
|
|
409
|
+
# ``check_interval_ticks=1`` already fires on every
|
|
410
|
+
# tick so the on-start fast path would be a no-op
|
|
411
|
+
# here anyway, but pin the opt-out so the test's
|
|
412
|
+
# "exactly 3 invocations → exactly 3 failures"
|
|
413
|
+
# arithmetic stays explicit.
|
|
414
|
+
check_on_start=False,
|
|
389
415
|
),
|
|
390
416
|
)
|
|
391
417
|
h.client.get_update_info.side_effect = ApiError("dead", status_code=502)
|
|
@@ -414,6 +440,10 @@ class TestUpdaterOnTick:
|
|
|
414
440
|
idle_ticks_required=0,
|
|
415
441
|
check_interval_ticks=1,
|
|
416
442
|
min_run_age_seconds=0.0,
|
|
443
|
+
# See sibling test for why we pin the opt-out even
|
|
444
|
+
# though check_interval_ticks=1 makes the on-start
|
|
445
|
+
# fast path effectively a no-op here.
|
|
446
|
+
check_on_start=False,
|
|
417
447
|
),
|
|
418
448
|
)
|
|
419
449
|
# 2 failures, then a success, then 2 more failures.
|
|
@@ -769,6 +799,219 @@ class TestUpdaterOnTick:
|
|
|
769
799
|
assert all(e.details["install_method"] == "unknown" for e in emitted)
|
|
770
800
|
|
|
771
801
|
|
|
802
|
+
class TestUpdaterChecksOnStart:
|
|
803
|
+
"""Pin the production-default ``check_on_start=True`` behaviour.
|
|
804
|
+
|
|
805
|
+
Why this matters: lab PCs only run ``/update-check`` once an hour
|
|
806
|
+
by default, so a fresh ``service install`` / ``service reinstall``
|
|
807
|
+
/ boot would otherwise wait up to 60 minutes before noticing a
|
|
808
|
+
pending release. That latency is fine in steady state but
|
|
809
|
+
disastrous during fix-forward iteration on the auto-update path
|
|
810
|
+
itself: the operator pushes a release, manually reinstalls the
|
|
811
|
+
watcher, then twiddles their thumbs for an hour to see if the
|
|
812
|
+
next auto-update tick works. Seeding ``_ticks_since_check`` to
|
|
813
|
+
``check_interval_ticks`` at construction collapses that wait to
|
|
814
|
+
one heartbeat (~60 s) without disturbing the steady-state
|
|
815
|
+
cadence — and without bypassing the activity-window safety
|
|
816
|
+
gates, which still defer the actual upgrade dispatch as designed.
|
|
817
|
+
"""
|
|
818
|
+
|
|
819
|
+
def _make_updater_with_on_start(
|
|
820
|
+
self,
|
|
821
|
+
tmp_path: Path,
|
|
822
|
+
*,
|
|
823
|
+
idle_ticks_required: int = 0,
|
|
824
|
+
check_interval_ticks: int = 60,
|
|
825
|
+
last_run_age_seconds: float | None = None,
|
|
826
|
+
) -> _UpdaterHarness:
|
|
827
|
+
# ``check_on_start=True`` is the production default but the
|
|
828
|
+
# default helper opts out, so build directly here. Tests in
|
|
829
|
+
# this class want the production wiring exactly.
|
|
830
|
+
h = _make_updater(
|
|
831
|
+
tmp_path,
|
|
832
|
+
updater_cfg=UpdaterConfig(
|
|
833
|
+
idle_ticks_required=idle_ticks_required,
|
|
834
|
+
check_interval_ticks=check_interval_ticks,
|
|
835
|
+
min_run_age_seconds=10.0,
|
|
836
|
+
check_on_start=True,
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
if last_run_age_seconds is not None:
|
|
840
|
+
from datetime import datetime, timedelta, timezone
|
|
841
|
+
|
|
842
|
+
# ``state_db.last_run_reported_at`` returns an ISO-8601
|
|
843
|
+
# string (it's a SQLite TEXT column), not a datetime —
|
|
844
|
+
# ``_last_run_age_seconds`` parses it via ``fromisoformat``.
|
|
845
|
+
past = datetime.now(timezone.utc) - timedelta(seconds=last_run_age_seconds)
|
|
846
|
+
h.state_db.last_run_reported_at.return_value = past.isoformat()
|
|
847
|
+
return h
|
|
848
|
+
|
|
849
|
+
def test_default_config_enables_check_on_start(self) -> None:
|
|
850
|
+
# Pin the production default explicitly so a future refactor
|
|
851
|
+
# that flips the default to False (and only updates the
|
|
852
|
+
# docstring) trips here rather than silently regressing the
|
|
853
|
+
# cold-start latency back to ~60 minutes.
|
|
854
|
+
cfg = UpdaterConfig()
|
|
855
|
+
assert cfg.check_on_start is True
|
|
856
|
+
|
|
857
|
+
def test_first_tick_fires_update_check_immediately(self, tmp_path: Path) -> None:
|
|
858
|
+
# The headline behaviour: with the production-default
|
|
859
|
+
# ``check_on_start=True`` and the production-default
|
|
860
|
+
# ``check_interval_ticks=60``, the very first tick after
|
|
861
|
+
# construction calls ``/update-check`` rather than waiting 59
|
|
862
|
+
# more ticks. Without this, an operator who just ran
|
|
863
|
+
# ``service reinstall`` to apply a fix-forward release would
|
|
864
|
+
# have to wait up to an hour to confirm whether the fix
|
|
865
|
+
# actually unblocked the auto-update path.
|
|
866
|
+
h = self._make_updater_with_on_start(tmp_path)
|
|
867
|
+
h.client.get_update_info.return_value = _info(latest="0.0.0+unknown")
|
|
868
|
+
h.counters.last_files_uploaded = 0
|
|
869
|
+
|
|
870
|
+
result = h.updater.on_tick()
|
|
871
|
+
|
|
872
|
+
assert result is not None, (
|
|
873
|
+
"first tick must call /update-check when check_on_start=True; "
|
|
874
|
+
"got None which means we waited for the cadence window"
|
|
875
|
+
)
|
|
876
|
+
h.client.get_update_info.assert_called_once()
|
|
877
|
+
|
|
878
|
+
def test_subsequent_ticks_resume_normal_cadence(self, tmp_path: Path) -> None:
|
|
879
|
+
# The on-start fast path is one-shot: tick 1 fires, then
|
|
880
|
+
# ticks 2..(check_interval_ticks) are no-ops, then the
|
|
881
|
+
# check_interval_ticks+1-th tick fires again. Otherwise we'd
|
|
882
|
+
# be calling /update-check every single heartbeat which
|
|
883
|
+
# would 60x the load on the watcher-update endpoint.
|
|
884
|
+
h = self._make_updater_with_on_start(tmp_path, check_interval_ticks=3)
|
|
885
|
+
h.client.get_update_info.return_value = _info(latest="0.0.0+unknown")
|
|
886
|
+
h.counters.last_files_uploaded = 0
|
|
887
|
+
|
|
888
|
+
# Tick 1 — on-start fast path fires.
|
|
889
|
+
assert h.updater.on_tick() is not None
|
|
890
|
+
assert h.client.get_update_info.call_count == 1
|
|
891
|
+
|
|
892
|
+
# Ticks 2 and 3 — within the cadence window after the reset.
|
|
893
|
+
assert h.updater.on_tick() is None
|
|
894
|
+
assert h.updater.on_tick() is None
|
|
895
|
+
assert h.client.get_update_info.call_count == 1
|
|
896
|
+
|
|
897
|
+
# Tick 4 — cadence window elapsed, fires again.
|
|
898
|
+
assert h.updater.on_tick() is not None
|
|
899
|
+
assert h.client.get_update_info.call_count == 2
|
|
900
|
+
|
|
901
|
+
def test_opt_out_preserves_pre_existing_cadence(self, tmp_path: Path) -> None:
|
|
902
|
+
# The opt-out path must produce exactly the historical
|
|
903
|
+
# behaviour: ticks 1 and 2 are no-ops with check_interval=3,
|
|
904
|
+
# tick 3 fires the API call. This is what every other test
|
|
905
|
+
# class in this file relies on, so a regression here would
|
|
906
|
+
# cascade into many false failures.
|
|
907
|
+
h = _make_updater(
|
|
908
|
+
tmp_path,
|
|
909
|
+
updater_cfg=UpdaterConfig(
|
|
910
|
+
idle_ticks_required=0,
|
|
911
|
+
check_interval_ticks=3,
|
|
912
|
+
min_run_age_seconds=10.0,
|
|
913
|
+
check_on_start=False,
|
|
914
|
+
),
|
|
915
|
+
)
|
|
916
|
+
h.client.get_update_info.return_value = _info(latest="0.0.0+unknown")
|
|
917
|
+
h.counters.last_files_uploaded = 0
|
|
918
|
+
|
|
919
|
+
assert h.updater.on_tick() is None
|
|
920
|
+
assert h.updater.on_tick() is None
|
|
921
|
+
assert h.client.get_update_info.call_count == 0
|
|
922
|
+
assert h.updater.on_tick() is not None
|
|
923
|
+
assert h.client.get_update_info.call_count == 1
|
|
924
|
+
|
|
925
|
+
def test_on_start_check_still_respects_idle_gate(self, tmp_path: Path) -> None:
|
|
926
|
+
# Critical safety pin: the on-start fast path short-circuits
|
|
927
|
+
# the *cadence* window, NOT the activity-window safety gate.
|
|
928
|
+
# If the operator restarts the service while uploads are
|
|
929
|
+
# actively flowing, the first tick should DO the API call
|
|
930
|
+
# (we want the operator to see "yes, an upgrade is pending"
|
|
931
|
+
# in the logs / dashboard immediately) but DEFER the actual
|
|
932
|
+
# upgrade dispatch until the upload activity quiets down —
|
|
933
|
+
# exactly as the steady-state cadence would. Without this
|
|
934
|
+
# pin a future refactor could conflate the two and start
|
|
935
|
+
# interrupting in-flight uploads on every restart.
|
|
936
|
+
h = self._make_updater_with_on_start(
|
|
937
|
+
tmp_path,
|
|
938
|
+
idle_ticks_required=5,
|
|
939
|
+
check_interval_ticks=60,
|
|
940
|
+
)
|
|
941
|
+
h.client.get_update_info.return_value = _info(latest="9.9.9")
|
|
942
|
+
# Simulate active upload activity: the heartbeat reports
|
|
943
|
+
# files uploaded on this tick.
|
|
944
|
+
h.counters.last_files_uploaded = 3
|
|
945
|
+
|
|
946
|
+
result = h.updater.on_tick()
|
|
947
|
+
|
|
948
|
+
# API was called (the fast path's job)…
|
|
949
|
+
h.client.get_update_info.assert_called_once()
|
|
950
|
+
# …but the dispatch was deferred (the safety gate's job).
|
|
951
|
+
assert result is not None
|
|
952
|
+
assert result.attempted is False
|
|
953
|
+
assert "idle ticks" in result.reason
|
|
954
|
+
h.request_restart.assert_not_called()
|
|
955
|
+
|
|
956
|
+
def test_on_start_check_still_respects_recent_run_gate(self, tmp_path: Path) -> None:
|
|
957
|
+
# Same shape as the idle-gate pin above, but for the
|
|
958
|
+
# ``last_run_age_seconds`` half of the activity-window
|
|
959
|
+
# check. A run reported 5 seconds before service restart
|
|
960
|
+
# (operator restarted mid-experiment to apply a fix) must
|
|
961
|
+
# NOT trigger an immediate upgrade just because the on-start
|
|
962
|
+
# fast path enabled the API call.
|
|
963
|
+
h = self._make_updater_with_on_start(
|
|
964
|
+
tmp_path,
|
|
965
|
+
idle_ticks_required=0, # Idle gate disabled.
|
|
966
|
+
last_run_age_seconds=5.0, # Recent run, gate active.
|
|
967
|
+
)
|
|
968
|
+
h.client.get_update_info.return_value = _info(latest="9.9.9")
|
|
969
|
+
h.counters.last_files_uploaded = 0
|
|
970
|
+
|
|
971
|
+
result = h.updater.on_tick()
|
|
972
|
+
|
|
973
|
+
h.client.get_update_info.assert_called_once()
|
|
974
|
+
assert result is not None
|
|
975
|
+
assert result.attempted is False
|
|
976
|
+
assert "recent run" in result.reason
|
|
977
|
+
h.request_restart.assert_not_called()
|
|
978
|
+
|
|
979
|
+
def test_on_start_check_dispatches_when_safety_gates_pass(
|
|
980
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
981
|
+
) -> None:
|
|
982
|
+
# The flip side of the safety pins above: when nothing's
|
|
983
|
+
# happening (clean restart on an idle host with no recent
|
|
984
|
+
# runs and no pending uploads — the common case), the
|
|
985
|
+
# on-start fast path SHOULD result in an immediate dispatch.
|
|
986
|
+
# This is the behaviour the operator-facing testing-iteration
|
|
987
|
+
# win actually depends on.
|
|
988
|
+
runner = MagicMock(return_value=_success_completed_process())
|
|
989
|
+
h = _make_updater(
|
|
990
|
+
tmp_path,
|
|
991
|
+
updater_cfg=UpdaterConfig(
|
|
992
|
+
idle_ticks_required=0,
|
|
993
|
+
check_interval_ticks=60,
|
|
994
|
+
min_run_age_seconds=10.0,
|
|
995
|
+
check_on_start=True,
|
|
996
|
+
),
|
|
997
|
+
upgrade_runner=runner,
|
|
998
|
+
)
|
|
999
|
+
h.client.get_update_info.return_value = _info(latest="9.9.9")
|
|
1000
|
+
h.state_db.last_run_reported_at.return_value = None
|
|
1001
|
+
h.counters.last_files_uploaded = 0
|
|
1002
|
+
|
|
1003
|
+
monkeypatch.setattr(
|
|
1004
|
+
"data_hub_watcher.updater.detect_install_method",
|
|
1005
|
+
lambda: InstallMethod.UV_TOOL,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
result = h.updater.on_tick()
|
|
1009
|
+
|
|
1010
|
+
h.request_restart.assert_called_once()
|
|
1011
|
+
assert result is not None
|
|
1012
|
+
assert result.attempted is True
|
|
1013
|
+
|
|
1014
|
+
|
|
772
1015
|
class TestUpdaterAsyncDispatch:
|
|
773
1016
|
"""The upgrade subprocess runs off the heartbeat thread.
|
|
774
1017
|
|
|
@@ -10,6 +10,7 @@ that breaks the contract surfaces here rather than as a stuck
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
import json
|
|
13
|
+
import subprocess
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
import pytest
|
|
@@ -259,6 +260,8 @@ class TestRenderWorkerScript:
|
|
|
259
260
|
request_path=tmp_path / ".upgrade-request.json",
|
|
260
261
|
result_path=tmp_path / ".upgrade-result.json",
|
|
261
262
|
log_path=tmp_path / "upgrade-worker.log",
|
|
263
|
+
tool_dir=Path(r"C:\Users\op\AppData\Roaming\uv\tools"),
|
|
264
|
+
tool_bin_dir=Path(r"C:\Users\op\.local\bin"),
|
|
262
265
|
generated_at="2026-05-04T22:30:00+00:00",
|
|
263
266
|
)
|
|
264
267
|
|
|
@@ -313,7 +316,12 @@ class TestRenderWorkerScript:
|
|
|
313
316
|
assert "Start-Service" in finally_block
|
|
314
317
|
|
|
315
318
|
def test_write_worker_script_drops_file_at_expected_path(self, tmp_path: Path) -> None:
|
|
316
|
-
path = write_worker_script(
|
|
319
|
+
path = write_worker_script(
|
|
320
|
+
tmp_path,
|
|
321
|
+
service_name="DataHubWatcher",
|
|
322
|
+
tool_dir=Path(r"C:\Users\op\AppData\Roaming\uv\tools"),
|
|
323
|
+
tool_bin_dir=Path(r"C:\Users\op\.local\bin"),
|
|
324
|
+
)
|
|
317
325
|
assert path == upgrade_worker_script_path(tmp_path)
|
|
318
326
|
assert path.exists()
|
|
319
327
|
assert path.name == UPGRADE_WORKER_SCRIPT_FILENAME
|
|
@@ -370,6 +378,90 @@ class TestRenderWorkerScript:
|
|
|
370
378
|
# uv emits on the host.
|
|
371
379
|
assert "`r?`n" in script or "\\r?\\n" in script
|
|
372
380
|
|
|
381
|
+
def test_uv_tool_dir_overrides_are_baked_in(self, tmp_path: Path) -> None:
|
|
382
|
+
# Regression guard for the silent "uv installed into SYSTEM
|
|
383
|
+
# profile" failure mode: the worker runs as LocalSystem via
|
|
384
|
+
# the scheduled task, and uv resolves UV_TOOL_DIR /
|
|
385
|
+
# UV_TOOL_BIN_DIR from $env:USERPROFILE which under SYSTEM
|
|
386
|
+
# is C:\Windows\System32\config\systemprofile\.local\... —
|
|
387
|
+
# NOT where the running watcher service was originally
|
|
388
|
+
# installed from. The rendered worker MUST set both env
|
|
389
|
+
# vars to the operator-context paths captured at install
|
|
390
|
+
# time so uv reinstalls in place at the operator's tool
|
|
391
|
+
# dir; otherwise the upgrade succeeds (uv exits 0) but
|
|
392
|
+
# produces a SECOND copy of the package that the running
|
|
393
|
+
# service never loads, and the marker comparison fails
|
|
394
|
+
# with "expected X, running Y" on the next restart.
|
|
395
|
+
script = render_worker_script(
|
|
396
|
+
service_name="DataHubWatcher",
|
|
397
|
+
request_path=tmp_path / ".upgrade-request.json",
|
|
398
|
+
result_path=tmp_path / ".upgrade-result.json",
|
|
399
|
+
log_path=tmp_path / "upgrade-worker.log",
|
|
400
|
+
tool_dir=Path(r"C:\Users\op\AppData\Roaming\uv\tools"),
|
|
401
|
+
tool_bin_dir=Path(r"C:\Users\op\.local\bin"),
|
|
402
|
+
generated_at="2026-05-04T22:30:00+00:00",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# The literal paths are baked in as PowerShell variables…
|
|
406
|
+
assert r'$ToolDir = "C:\Users\op\AppData\Roaming\uv\tools"' in script
|
|
407
|
+
assert r'$ToolBinDir = "C:\Users\op\.local\bin"' in script
|
|
408
|
+
# …and assigned to the env vars uv actually consults BEFORE
|
|
409
|
+
# the uv invocation. Order matters: setting them after
|
|
410
|
+
# `Start-Process -FilePath $UvExe` would have no effect on
|
|
411
|
+
# the subprocess.
|
|
412
|
+
env_idx = script.find("$env:UV_TOOL_DIR")
|
|
413
|
+
uv_invoke_idx = script.find("Start-Process -FilePath $UvExe")
|
|
414
|
+
assert env_idx != -1, "worker must set $env:UV_TOOL_DIR"
|
|
415
|
+
assert uv_invoke_idx != -1, "worker must invoke uv via Start-Process"
|
|
416
|
+
assert env_idx < uv_invoke_idx, (
|
|
417
|
+
"$env:UV_TOOL_DIR must be set BEFORE uv is invoked, "
|
|
418
|
+
"otherwise the override has no effect on the subprocess"
|
|
419
|
+
)
|
|
420
|
+
# Both env vars present.
|
|
421
|
+
assert "$env:UV_TOOL_DIR = $ToolDir" in script
|
|
422
|
+
assert "$env:UV_TOOL_BIN_DIR = $ToolBinDir" in script
|
|
423
|
+
|
|
424
|
+
def test_result_sentinel_is_written_without_utf8_bom(self, tmp_path: Path) -> None:
|
|
425
|
+
# Regression guard for the secondary symptom of the silent
|
|
426
|
+
# auto-update failure: PowerShell 5.1's `Set-Content -Encoding
|
|
427
|
+
# UTF8` writes a UTF-8 BOM, and Python's `json.loads(
|
|
428
|
+
# path.read_text(encoding="utf-8"))` raises JSONDecodeError on
|
|
429
|
+
# the leading U+FEFF. `read_upgrade_result` then swallows the
|
|
430
|
+
# error and returns None, causing the post-restart inspection
|
|
431
|
+
# to flag `worker_result_missing=True` even though the file
|
|
432
|
+
# was written successfully — the operator sees no actual
|
|
433
|
+
# error context in the failure event.
|
|
434
|
+
#
|
|
435
|
+
# The fix uses [System.IO.File]::WriteAllText with an explicit
|
|
436
|
+
# UTF8Encoding($false) so the BOM is omitted on every supported
|
|
437
|
+
# PowerShell version. Pin the encoder choice here.
|
|
438
|
+
script = render_worker_script(
|
|
439
|
+
service_name="DataHubWatcher",
|
|
440
|
+
request_path=tmp_path / ".upgrade-request.json",
|
|
441
|
+
result_path=tmp_path / ".upgrade-result.json",
|
|
442
|
+
log_path=tmp_path / "upgrade-worker.log",
|
|
443
|
+
tool_dir=tmp_path / "tools",
|
|
444
|
+
tool_bin_dir=tmp_path / "bin",
|
|
445
|
+
generated_at="2026-05-04T22:30:00+00:00",
|
|
446
|
+
)
|
|
447
|
+
# The .NET UTF8Encoding constructor takes a bool encoderShouldEmitUTF8Identifier
|
|
448
|
+
# — `$false` means "no BOM". We pin both the type and the
|
|
449
|
+
# `$false` argument so a future refactor can't accidentally
|
|
450
|
+
# flip it back to BOM-emitting.
|
|
451
|
+
assert "[System.IO.File]::WriteAllText" in script
|
|
452
|
+
assert "[System.Text.UTF8Encoding]::new($false)" in script
|
|
453
|
+
# The original BOM-emitting `Set-Content` invocation must
|
|
454
|
+
# not appear as an actual command anywhere in the rendered
|
|
455
|
+
# template. Search line-by-line, ignoring lines that start
|
|
456
|
+
# with `#` (PowerShell comments) so the regression-context
|
|
457
|
+
# commentary describing the *old* behaviour doesn't trip
|
|
458
|
+
# the assertion.
|
|
459
|
+
for line in script.splitlines():
|
|
460
|
+
stripped = line.lstrip()
|
|
461
|
+
if stripped.startswith("#"):
|
|
462
|
+
continue
|
|
463
|
+
assert "Set-Content -LiteralPath $tmp -Encoding UTF8" not in stripped
|
|
464
|
+
|
|
373
465
|
|
|
374
466
|
# ---------------------------------------------------------------------------
|
|
375
467
|
# Filename constants
|
|
@@ -384,3 +476,99 @@ def test_sentinel_filenames_are_dotfiles_under_config_dir(tmp_path: Path) -> Non
|
|
|
384
476
|
assert upgrade_result_path(tmp_path).name == UPGRADE_RESULT_FILENAME
|
|
385
477
|
assert UPGRADE_REQUEST_FILENAME.startswith(".")
|
|
386
478
|
assert UPGRADE_RESULT_FILENAME.startswith(".")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
# resolve_uv_tool_dirs
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class TestResolveUvToolDirs:
|
|
487
|
+
"""Capture the operator's uv tool directories at install time.
|
|
488
|
+
|
|
489
|
+
Required because the worker later runs as LocalSystem and uv would
|
|
490
|
+
otherwise resolve UV_TOOL_DIR / UV_TOOL_BIN_DIR against the SYSTEM
|
|
491
|
+
profile rather than the operator's profile — silently installing
|
|
492
|
+
upgrades into the wrong location.
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
def test_returns_paths_from_uv_tool_dir_invocations(
|
|
496
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
497
|
+
) -> None:
|
|
498
|
+
from data_hub_watcher import upgrade_worker as worker_mod
|
|
499
|
+
|
|
500
|
+
invocations: list[list[str]] = []
|
|
501
|
+
|
|
502
|
+
def fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
|
|
503
|
+
invocations.append(argv)
|
|
504
|
+
if "--bin" in argv:
|
|
505
|
+
stdout = r"C:\Users\op\.local\bin"
|
|
506
|
+
else:
|
|
507
|
+
stdout = r"C:\Users\op\AppData\Roaming\uv\tools"
|
|
508
|
+
return subprocess.CompletedProcess(argv, returncode=0, stdout=stdout, stderr="")
|
|
509
|
+
|
|
510
|
+
monkeypatch.setattr(worker_mod.subprocess, "run", fake_run)
|
|
511
|
+
|
|
512
|
+
tool_dir, tool_bin_dir = worker_mod.resolve_uv_tool_dirs(r"C:\Users\op\.local\bin\uv.exe")
|
|
513
|
+
|
|
514
|
+
assert tool_dir == Path(r"C:\Users\op\AppData\Roaming\uv\tools")
|
|
515
|
+
assert tool_bin_dir == Path(r"C:\Users\op\.local\bin")
|
|
516
|
+
# Both invocations must use the supplied uv path (not e.g. a
|
|
517
|
+
# PATH-resolved fallback) so the helper reflects the same uv
|
|
518
|
+
# the worker will eventually drive.
|
|
519
|
+
assert invocations[0] == [r"C:\Users\op\.local\bin\uv.exe", "tool", "dir"]
|
|
520
|
+
assert invocations[1] == [r"C:\Users\op\.local\bin\uv.exe", "tool", "dir", "--bin"]
|
|
521
|
+
|
|
522
|
+
def test_nonzero_exit_raises_with_stderr_attached(
|
|
523
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
524
|
+
) -> None:
|
|
525
|
+
from data_hub_watcher import upgrade_worker as worker_mod
|
|
526
|
+
|
|
527
|
+
def fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
|
|
528
|
+
return subprocess.CompletedProcess(
|
|
529
|
+
argv, returncode=2, stdout="", stderr="uv: command does not exist"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
monkeypatch.setattr(worker_mod.subprocess, "run", fake_run)
|
|
533
|
+
|
|
534
|
+
with pytest.raises(worker_mod.UvToolDirResolutionError) as excinfo:
|
|
535
|
+
worker_mod.resolve_uv_tool_dirs("uv")
|
|
536
|
+
|
|
537
|
+
# Operator-facing message must include the failing argv and
|
|
538
|
+
# uv's stderr — diagnostically those are the two pieces an
|
|
539
|
+
# operator needs to recover from a stuck install.
|
|
540
|
+
msg = str(excinfo.value)
|
|
541
|
+
assert "uv tool dir" in msg
|
|
542
|
+
assert "command does not exist" in msg
|
|
543
|
+
|
|
544
|
+
def test_empty_stdout_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
545
|
+
# An exit-0 with empty stdout would otherwise silently bake
|
|
546
|
+
# `Path("")` into the worker template, which uv interprets
|
|
547
|
+
# as "use the default" — i.e. the very SYSTEM-profile path
|
|
548
|
+
# we're trying to override. Treat it as a hard failure.
|
|
549
|
+
from data_hub_watcher import upgrade_worker as worker_mod
|
|
550
|
+
|
|
551
|
+
def fake_run(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
|
|
552
|
+
return subprocess.CompletedProcess(argv, returncode=0, stdout="\n", stderr="")
|
|
553
|
+
|
|
554
|
+
monkeypatch.setattr(worker_mod.subprocess, "run", fake_run)
|
|
555
|
+
|
|
556
|
+
with pytest.raises(worker_mod.UvToolDirResolutionError):
|
|
557
|
+
worker_mod.resolve_uv_tool_dirs("uv")
|
|
558
|
+
|
|
559
|
+
def test_oserror_raises_uv_tool_dir_resolution_error(
|
|
560
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
561
|
+
) -> None:
|
|
562
|
+
# uv.exe missing from disk surfaces as FileNotFoundError from
|
|
563
|
+
# subprocess.run; the helper must convert that to its typed
|
|
564
|
+
# error so callers don't have to know about subprocess
|
|
565
|
+
# internals.
|
|
566
|
+
from data_hub_watcher import upgrade_worker as worker_mod
|
|
567
|
+
|
|
568
|
+
def boom(argv: list[str], **_kw: object) -> subprocess.CompletedProcess[str]:
|
|
569
|
+
raise FileNotFoundError("uv.exe not found")
|
|
570
|
+
|
|
571
|
+
monkeypatch.setattr(worker_mod.subprocess, "run", boom)
|
|
572
|
+
|
|
573
|
+
with pytest.raises(worker_mod.UvToolDirResolutionError):
|
|
574
|
+
worker_mod.resolve_uv_tool_dirs("uv")
|
|
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
|
{data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/requires.txt
RENAMED
|
File without changes
|
{data_hub_watcher-0.2.2 → data_hub_watcher-0.2.4}/src/data_hub_watcher.egg-info/top_level.txt
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
|