diff --git a/README.md b/README.md index bbc0391..4544c21 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Syncs body composition from a Eufy smart scale to Garmin Connect, plus current w | Target | What syncs | |--------|------------| -| Garmin Connect | Full body composition: weight, body fat, muscle mass, bone mass, hydration, BMR, visceral fat, metabolic age | +| Garmin Connect | Full body composition: weight, BMI, body fat, muscle mass, bone mass, hydration, BMR, visceral fat, metabolic age | | Strava | Current weight | | Zwift (experimental, opt-in) | Current weight, verified after saving | diff --git a/eufy_sync/__init__.py b/eufy_sync/__init__.py index ebc3551..cb51b90 100644 --- a/eufy_sync/__init__.py +++ b/eufy_sync/__init__.py @@ -1,6 +1,6 @@ """Sync Eufy smart scale body composition data to Garmin Connect and Strava.""" -__version__ = "1.15.2" +__version__ = "1.15.3" # Public API for programmatic use from eufy_sync.eufy_client import EufyClient, EufyMeasurement diff --git a/eufy_sync/cli/app.py b/eufy_sync/cli/app.py index c46ce58..36837ed 100644 --- a/eufy_sync/cli/app.py +++ b/eufy_sync/cli/app.py @@ -109,6 +109,20 @@ def _sync_with_network_retry(user, state, **kwargs): sleep(NETWORK_RETRY_DELAY) +def _repair_agent() -> None: + """Heal an automatic-sync registration that can no longer start eufy-sync. + + The broken case (Windows: a relative or missing program path) fails every + scheduled run before eufy-sync starts, so nothing reports it; a run the + user starts is the only chance to notice. A problem here never stops the + sync.""" + try: + if platform_support.repair_agent(): + logger.info("Automatic sync could not start eufy-sync; rewrote its wrapper with the full program path") + except Exception as e: + logger.debug("Could not check the automatic sync registration: %s", e) + + def _exit_could_not_start(error: Exception) -> None: msg = f"eufy-sync could not start: {error}" print(msg) @@ -382,6 +396,7 @@ def _main() -> None: _check_target(args, config) updater._check_for_updates() + _repair_agent() backfill = args.backfill_days if first_run and backfill is None: diff --git a/eufy_sync/cli/updater.py b/eufy_sync/cli/updater.py index 81efc06..818dd82 100644 --- a/eufy_sync/cli/updater.py +++ b/eufy_sync/cli/updater.py @@ -80,11 +80,19 @@ def _check_for_updates() -> None: pass # never let update check break a sync +# Right after a release, PyPI's JSON API can name a version that its package +# index does not serve yet, and the pinned install fails as unsatisfiable for up +# to about a minute (seen 2026-10-02 and 2026-11-05). One retry after this wait +# covers it; a failure for any other reason costs one extra minute. +UPDATE_RETRY_DELAY = 60 + + def _quote_cmd(cmd: list[str]) -> str: """Join an argv into one command string, double-quoting any element that contains a space so a path like C:\\Program Files\\... survives as a single - argument when the string is handed to cmd.exe.""" - return " ".join(f'"{part}"' if " " in part else part for part in cmd) + argument when the string is handed to cmd.exe. A parenthesis is quoted too: + the Windows retry runs inside a ( ) block, which a bare ) would close.""" + return " ".join(f'"{part}"' if any(c in part for c in " ()") else part for part in cmd) def _self_update() -> None: @@ -120,9 +128,21 @@ def _self_update() -> None: flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr( subprocess, "CREATE_NEW_PROCESS_GROUP", 0 ) - inner = "timeout /t 2 /nobreak >nul & " + _quote_cmd(cmd) + " & echo. & pause" + # cmd binds || tighter than &, so the retry runs only when the first + # install fails, and the pause runs either way. The echoed text must + # stay free of the characters cmd treats as syntax. + install_line = _quote_cmd(cmd) + retry = ( + f"echo Update failed. The new version may not be ready. Trying again in {UPDATE_RETRY_DELAY} seconds." + f" & timeout /t {UPDATE_RETRY_DELAY} /nobreak >nul & {install_line}" + ) + inner = f"timeout /t 2 /nobreak >nul & {install_line} || ({retry}) & echo. & pause" + # One string, not an argv list: for a list, Windows subprocess escapes + # the quotes around a spaced installer path as \", which cmd.exe does + # not understand. As a string, cmd sees plain quotes, and the operators + # in the inner line stay inside them until the new console runs it. subprocess.Popen( - ["cmd", "/c", "start", "eufy-sync update", "cmd", "/c", inner], + f'cmd /c start "eufy-sync update" cmd /c "{inner}"', creationflags=flags, ) print(f"Updating eufy-sync from v{__version__} to v{latest} in a new window...") @@ -131,6 +151,13 @@ def _self_update() -> None: print(f"Updating eufy-sync from v{__version__} to v{latest}...") result = subprocess.run(cmd) + if result.returncode != 0: + print( + "Update failed; the new version may not be available for download yet. " + f"Trying again in {UPDATE_RETRY_DELAY} seconds." + ) + time.sleep(UPDATE_RETRY_DELAY) + result = subprocess.run(cmd) if result.returncode == 0: print(f"Updated to v{latest}.") else: diff --git a/eufy_sync/platform_support/__init__.py b/eufy_sync/platform_support/__init__.py index b95ad29..89156f2 100644 --- a/eufy_sync/platform_support/__init__.py +++ b/eufy_sync/platform_support/__init__.py @@ -60,3 +60,9 @@ def agent_installed() -> bool: def purge_agent() -> None: _impl().purge_agent() + + +def repair_agent() -> bool: + """Fix an automatic-sync registration that can no longer start eufy-sync. + True when something was rewritten.""" + return _impl().repair_agent() diff --git a/eufy_sync/platform_support/generic.py b/eufy_sync/platform_support/generic.py index a0182e5..14b6d8a 100644 --- a/eufy_sync/platform_support/generic.py +++ b/eufy_sync/platform_support/generic.py @@ -38,3 +38,8 @@ def agent_installed() -> bool: def purge_agent() -> None: pass + + +def repair_agent() -> bool: + """Nothing to repair here; see the Windows implementation.""" + return False diff --git a/eufy_sync/platform_support/macos.py b/eufy_sync/platform_support/macos.py index ab5546f..3753b51 100644 --- a/eufy_sync/platform_support/macos.py +++ b/eufy_sync/platform_support/macos.py @@ -288,3 +288,8 @@ def uninstall_agent() -> None: def offer_agent() -> None: _offer_launch_agent() + + +def repair_agent() -> bool: + """Nothing to repair here; see the Windows implementation.""" + return False diff --git a/eufy_sync/platform_support/windows.py b/eufy_sync/platform_support/windows.py index ac61d8b..d850f46 100644 --- a/eufy_sync/platform_support/windows.py +++ b/eufy_sync/platform_support/windows.py @@ -9,14 +9,17 @@ binary does not require re-registering the task. The public entry points (notify, install_agent, uninstall_agent, offer_agent, -agent_status, agent_installed, purge_agent) are what the platform_support -package dispatches to. notify raises a native toast through PowerShell WinRT, +agent_status, agent_installed, purge_agent, repair_agent) are what the +platform_support package dispatches to. notify raises a native toast through PowerShell WinRT, fire-and-forget. """ from __future__ import annotations import base64 +import ntpath +import os import platform +import re import shutil import subprocess import sys @@ -116,9 +119,34 @@ def _write_wrapper(binary_path: str): return wrapper_path +# The program path inside a wrapper written by _wrapper_content, where every +# quote of the inner cmd line is doubled. A Windows path cannot hold a quote. +_WRAPPED_BINARY = re.compile(r'cmd /c """"(.+?)"" --headless') + + +def _binary() -> str | None: + """The full path of eufy-sync on PATH, or None. + + On Windows, shutil.which searches the current folder first and answers + ".\\eufy-sync.EXE" when run from the folder that holds the program. Task + Scheduler starts the wrapper somewhere else, where that relative path finds + nothing, so every scheduled run would fail before eufy-sync starts and + nothing would report it. Christopher (smithbuilt on GitHub) found this in + HomeVitals, his fork (issue #77). + """ + found = shutil.which("eufy-sync") + if not found: + return None + # Always resolved: Python 3.12's isabs accepts \Tools\eufy-sync.exe, which + # has no drive and points elsewhere when the task starts on another one. + # ntpath rather than os.path, so tests on macOS and Linux treat a path like + # C:\Tools\eufy-sync.exe as Windows does. On Windows they are one module. + return ntpath.abspath(found) + + def _install_scheduled_task() -> None: """Register the per-user scheduled task for automatic sync.""" - binary = shutil.which("eufy-sync") + binary = _binary() if not binary: print("Warning: could not find eufy-sync on PATH. Skipping auto-sync setup.") return @@ -200,7 +228,7 @@ def agent_status() -> dict | None: if result.returncode != 0: return {"status": "WARN", "label": "scheduled task", "detail": "not installed", "fix": "eufy-sync --install-agent"} - binary = shutil.which("eufy-sync") + binary = _binary() if binary and wrapper_path.read_bytes() != _wrapper_content(binary).encode("utf-8"): return {"status": "WARN", "label": "scheduled task", "detail": "outdated registration", "fix": "eufy-sync --install-agent"} @@ -258,6 +286,36 @@ def notify(title: str, message: str, command: str | None = None) -> None: pass +def repair_agent() -> bool: + """Rewrite the wrapper when the program path in it can no longer start. + + Covers a wrapper registered with a relative path (see _binary) and one + whose program has since moved or been removed. A wrapper that names a + program which exists is left alone, even if PATH now finds a different + copy first, so running a test build from another folder never repoints + automatic sync; so is a wrapper whose program path cannot be read. Only + the wrapper file changes: the task runs the wrapper by a path that stays + the same. True when the wrapper was rewritten. + """ + wrapper_path = shared.DATA_DIR / WRAPPER_NAME + if not wrapper_path.exists(): + return False + match = _WRAPPED_BINARY.search(wrapper_path.read_bytes().decode("utf-8", "replace")) + if not match: + # Not a wrapper this code wrote, so nothing shows its program is + # broken. --doctor still reports it; --install-agent replaces it. + return False + if os.path.isabs(match.group(1)) and os.path.isfile(match.group(1)): + return False + binary = _binary() + if not binary: + return False + if wrapper_path.read_bytes() == _wrapper_content(binary).encode("utf-8"): + return False + _write_wrapper(binary) + return True + + def install_agent() -> None: _install_scheduled_task() diff --git a/eufy_sync/sync.py b/eufy_sync/sync.py index 4b45c0f..0172526 100644 --- a/eufy_sync/sync.py +++ b/eufy_sync/sync.py @@ -12,6 +12,7 @@ from eufy_sync import state as state_module from eufy_sync.config import UserConfig from eufy_sync.eufy_client import AmbiguousProfileError, EufyClient, EufyMeasurement +from eufy_sync.network import is_transient_network_error from eufy_sync.reporting import SyncReport from eufy_sync.state import SyncState from eufy_sync.transform import transform @@ -211,7 +212,12 @@ def sync_user(user: UserConfig, state: SyncState, backfill_days: int | None = No client.authenticate() targets.append((target_name, client)) except Exception as e: - logger.error("Authentication failed for %s/%s: %s", user.name, target_name, e) + if is_transient_network_error(str(e)): + # No connection is not a sign-in problem; calling it one + # sends a reader of the log after the wrong fix. + logger.warning("Could not reach %s for %s: %s", target_name, user.name, e) + else: + logger.error("Authentication failed for %s/%s: %s", user.name, target_name, e) errors[target_name] = str(e) if first_exception is None: first_exception = e diff --git a/eufy_sync/transform.py b/eufy_sync/transform.py index 1eb0346..7fee7a6 100644 --- a/eufy_sync/transform.py +++ b/eufy_sync/transform.py @@ -40,6 +40,12 @@ def transform(measurement: EufyMeasurement) -> GarminBodyComposition | None: if not MIN_WEIGHT_KG <= measurement.weight_kg <= MAX_WEIGHT_KG: return None + # Garmin does not work BMI out from the profile height for uploaded + # weigh-ins: an account with a height set had no BMI on any of 137 of + # them. So send the figure Eufy calculated, rounded to the FIT field's + # 0.1 resolution because the encoder truncates rather than rounds. + bmi = _clamp_or_none(measurement.bmi, 10.0, 70.0) + return GarminBodyComposition( # Before garminconnect 0.3.17 (PR #438), add_body_composition encoded # the FIT timestamp with mktime(timetuple()), which reads the @@ -57,5 +63,5 @@ def transform(measurement: EufyMeasurement) -> GarminBodyComposition | None: muscle_mass=_clamp_or_none(measurement.muscle_mass_kg, 10.0, 120.0), basal_met=_clamp_or_none(measurement.bmr_kcal, 500, 5000), metabolic_age=_clamp_or_none(measurement.metabolic_age, 10, 120), - bmi=None, # Let Garmin calculate from weight + height + bmi=round(bmi, 1) if bmi is not None else None, ) diff --git a/pyproject.toml b/pyproject.toml index 07f5405..a8dcbf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" -version = "1.15.2" +version = "1.15.3" description = "Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift" readme = "README.md" license = "MIT" diff --git a/tests/test_garmin_client.py b/tests/test_garmin_client.py index e0cf46e..30b8aa8 100644 --- a/tests/test_garmin_client.py +++ b/tests/test_garmin_client.py @@ -34,7 +34,7 @@ def test_upload_maps_fields_to_add_body_composition(): muscle_mass=45.2, basal_met=1650, metabolic_age=28, - bmi=None, + bmi=23.1, ) client.upload_body_composition(bc) kwargs = fake.add_body_composition.call_args.kwargs @@ -43,7 +43,7 @@ def test_upload_maps_fields_to_add_body_composition(): assert kwargs["percent_fat"] == 18.5 assert kwargs["visceral_fat_rating"] == 8.0 assert kwargs["basal_met"] == 1650 - assert kwargs["bmi"] is None + assert kwargs["bmi"] == 23.1 def test_has_weight_on_date_true_with_daily_summaries_key(): diff --git a/tests/test_platform_support.py b/tests/test_platform_support.py index d39f260..38e7070 100644 --- a/tests/test_platform_support.py +++ b/tests/test_platform_support.py @@ -41,3 +41,24 @@ def test_macos_agent_status_warns_when_not_installed(tmp_path): assert status["label"] == "launch agent" assert status["detail"] == "not installed" assert status["fix"] == "eufy-sync --install-agent" + + +def test_repair_agent_is_a_no_op_off_windows(): + assert generic.repair_agent() is False + assert macos.repair_agent() is False + + +def test_repair_agent_dispatches_to_the_active_platform(): + with patch.object(platform_support, "_impl", return_value=generic): + assert platform_support.repair_agent() is False + + +def test_a_failing_repair_never_stops_the_sync(monkeypatch): + from eufy_sync.cli import app + + def boom(): + raise OSError("wrapper is locked") + + monkeypatch.setattr(app.platform_support, "repair_agent", boom) + app._repair_agent() # must not raise + diff --git a/tests/test_platform_windows.py b/tests/test_platform_windows.py index 4435568..9447cb5 100644 --- a/tests/test_platform_windows.py +++ b/tests/test_platform_windows.py @@ -8,6 +8,11 @@ from __future__ import annotations import base64 +import ntpath +import os +import shutil +import subprocess +import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -361,3 +366,173 @@ def test_offer_skips_without_a_tty(data_dir): windows.offer_agent() install.assert_not_called() + + +# --- Full program path (issue #77) ------------------------------------------- + +RELATIVE = ".\\eufy-sync.EXE" # what shutil.which answers from the program's folder + + +def _wrapper_bytes(data_dir): + return (data_dir / windows.WRAPPER_NAME).read_bytes() + + +def test_install_stores_the_full_path_when_which_answers_a_relative_one(data_dir): + """Task Scheduler starts the wrapper outside the program's folder, where a + relative path finds nothing and every scheduled run fails unreported.""" + with patch.object(windows.shutil, "which", return_value=RELATIVE), \ + patch.object(windows.subprocess, "run", return_value=_ok()): + windows.install_agent() + + content = _wrapper_bytes(data_dir).decode("utf-8") + assert content == windows._wrapper_content(ntpath.abspath(RELATIVE)) + assert f'""""{RELATIVE}""' not in content + + +def test_binary_keeps_a_full_path_as_it_is(): + with patch.object(windows.shutil, "which", return_value="C:\\Tools\\eufy-sync.exe"): + assert windows._binary() == "C:\\Tools\\eufy-sync.exe" + + +def test_agent_status_compares_against_the_full_path(data_dir): + _write_current_wrapper(data_dir, ntpath.abspath(RELATIVE)) + + with patch.object(windows.shutil, "which", return_value=RELATIVE), \ + patch.object(windows.subprocess, "run", return_value=_ok()): + status = windows.agent_status() + + assert status["status"] == "PASS" + + +def test_repair_rewrites_a_wrapper_holding_a_relative_path(data_dir): + _write_current_wrapper(data_dir, RELATIVE) + full = "C:\\Tools\\eufy-sync.exe" + + with patch.object(windows.shutil, "which", return_value=full): + assert windows.repair_agent() is True + assert _wrapper_bytes(data_dir) == windows._wrapper_content(full).encode("utf-8") + assert windows.repair_agent() is False # already right: nothing to do + + +def test_repair_points_the_wrapper_at_the_program_when_its_old_path_is_gone(data_dir, tmp_path): + _write_current_wrapper(data_dir, str(tmp_path / "removed" / "eufy-sync.exe")) + current = tmp_path / "current" / "eufy-sync.exe" + current.parent.mkdir() + current.write_text("") + + with patch.object(windows.shutil, "which", return_value=str(current)): + assert windows.repair_agent() is True + + # _binary resolves with Windows path rules, which on macOS and Linux also + # turn the separators around; on Windows the path comes back unchanged. + expected = windows._wrapper_content(ntpath.abspath(str(current))) + assert _wrapper_bytes(data_dir) == expected.encode("utf-8") + + +def test_repair_leaves_a_working_wrapper_alone_when_path_finds_another_copy(data_dir, tmp_path): + """Running a test build from another folder must not repoint automatic + sync: the wrapper's program still exists, so it stays.""" + installed = tmp_path / "installed" / "eufy-sync.exe" + installed.parent.mkdir() + installed.write_text("") + _write_current_wrapper(data_dir, str(installed)) + before = _wrapper_bytes(data_dir) + + with patch.object(windows.shutil, "which", return_value=str(tmp_path / "dev" / "eufy-sync.exe")): + assert windows.repair_agent() is False + + assert _wrapper_bytes(data_dir) == before + + +def test_repair_leaves_a_wrapper_it_cannot_read_alone(data_dir): + """Nothing shows an unrecognized wrapper is broken, so it is not + repointed at whatever PATH finds; --doctor reports it instead.""" + (data_dir / windows.WRAPPER_NAME).write_text("a wrapper from somewhere else") + before = _wrapper_bytes(data_dir) + + with patch.object(windows.shutil, "which", return_value="C:\\Tools\\eufy-sync.exe"): + assert windows.repair_agent() is False + + assert _wrapper_bytes(data_dir) == before + + +def test_binary_resolves_a_path_without_a_drive(): + """Python 3.12 calls \\Tools\\eufy-sync.exe absolute, but it has no drive + and means another place when the task starts on a different one.""" + with patch.object(windows.shutil, "which", return_value="\\Tools\\eufy-sync.exe"): + assert windows._binary() == ntpath.abspath("\\Tools\\eufy-sync.exe") + + +def test_repair_does_nothing_without_automatic_sync(data_dir): + with patch.object(windows.shutil, "which", return_value="C:\\Tools\\eufy-sync.exe"): + assert windows.repair_agent() is False + assert not (data_dir / windows.WRAPPER_NAME).exists() + + +def test_repair_does_nothing_when_eufy_sync_is_not_on_path(data_dir): + _write_current_wrapper(data_dir, RELATIVE) + before = _wrapper_bytes(data_dir) + + with patch.object(windows.shutil, "which", return_value=None): + assert windows.repair_agent() is False + + assert _wrapper_bytes(data_dir) == before + + +# --- The real wrapper under Windows Script Host ------------------------------- + +windows_only = pytest.mark.skipif(sys.platform != "win32", reason="runs the real wrapper on Windows") + + +def _run_wrapper(data_dir, program): + wrapper = data_dir / windows.WRAPPER_NAME + wrapper.write_bytes(windows._wrapper_content(str(program)).encode("utf-8")) + # cscript prints a VBScript runtime error to the console where wscript, + # which Task Scheduler uses, would open a dialog. + return subprocess.run(["cscript", "//nologo", str(wrapper)], capture_output=True, text=True, timeout=120) + + +@windows_only +def test_wrapper_hands_back_the_programs_exit_code(data_dir, tmp_path): + program = tmp_path / "eufy-sync.cmd" + program.write_bytes(b"@echo ran %*\r\n@exit /b 7\r\n") + + result = _run_wrapper(data_dir, program) + + assert result.returncode == 7 + assert "ran --headless" in (data_dir / "sync.log").read_text() + + +@windows_only +def test_wrapper_fails_with_an_exit_code_not_a_script_error_when_the_program_is_missing(data_dir, tmp_path): + """HomeVitals starts its program directly, so a missing or locked program + raises inside VBScript and leaves an error box that blocks every later + scheduled run. This wrapper starts it through cmd, which turns the same + failure into an exit code. Keep it that way.""" + result = _run_wrapper(data_dir, tmp_path / "missing" / "eufy-sync.exe") + + assert result.returncode != 0 + assert "runtime error" not in (result.stdout + result.stderr).lower() + + +@windows_only +def test_binary_is_a_full_path_when_run_from_the_programs_folder(tmp_path, monkeypatch): + (tmp_path / "eufy-sync.cmd").write_bytes(b"@exit /b 0\r\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", os.path.join(os.environ.get("SystemRoot", "C:\\Windows"), "System32")) + monkeypatch.delenv("NoDefaultCurrentDirectoryInExePath", raising=False) + if shutil.which("eufy-sync") is None: + pytest.skip("this Python's shutil.which does not search the current folder") + + binary = windows._binary() + + assert ntpath.isabs(binary) + assert os.path.isfile(binary) + + +@windows_only +def test_binary_adds_the_drive_on_windows(): + with patch.object(windows.shutil, "which", return_value="\\Tools\\eufy-sync.exe"): + drive, _ = ntpath.splitdrive(windows._binary()) + assert drive + diff --git a/tests/test_sync.py b/tests/test_sync.py index 1b80e11..922a948 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -424,6 +424,59 @@ def test_one_target_auth_failure_does_not_block_the_other(tmp_path: Path): state.close() +def test_network_failure_at_sign_in_is_not_logged_as_an_auth_failure(tmp_path: Path, caplog, monkeypatch): + """With no connection, a target's sign-in fails with a DNS error. The log + must say the service could not be reached, not that authentication + failed, or a reader goes after the wrong fix.""" + import httpx + + caplog.set_level("INFO", logger="eufy_sync") + monkeypatch.setattr(logging.getLogger("eufy_sync"), "handlers", [caplog.handler]) + state = SyncState(tmp_path / "test.db") + user = _garmin_and_strava_user() + + fake_eufy = MagicMock() + fake_eufy.fetch_measurements.return_value = [] + fake_garmin = MagicMock() + fake_strava = MagicMock() + fake_strava.authenticate.side_effect = httpx.ConnectError( + "[Errno 8] nodename nor servname provided, or not known" + ) + + with patch("eufy_sync.sync.EufyClient", return_value=fake_eufy), \ + patch("eufy_sync.garmin_client.GarminClient", return_value=fake_garmin), \ + patch("eufy_sync.strava_client.StravaClient", return_value=fake_strava), \ + patch("eufy_sync.sync.time.sleep"): + _, errors = sync_user(user, state, backfill_days=7) + + assert "nodename nor servname" in errors["strava"] + assert "Could not reach strava for" in caplog.text + assert "Authentication failed" not in caplog.text + state.close() + + +def test_sign_in_failure_that_is_not_the_network_is_still_an_auth_failure(tmp_path: Path, caplog, monkeypatch): + caplog.set_level("INFO", logger="eufy_sync") + monkeypatch.setattr(logging.getLogger("eufy_sync"), "handlers", [caplog.handler]) + state = SyncState(tmp_path / "test.db") + user = _garmin_and_strava_user() + + fake_eufy = MagicMock() + fake_eufy.fetch_measurements.return_value = [] + fake_strava = MagicMock() + fake_strava.authenticate.side_effect = RuntimeError("Strava token revoked") + + with patch("eufy_sync.sync.EufyClient", return_value=fake_eufy), \ + patch("eufy_sync.garmin_client.GarminClient", return_value=MagicMock()), \ + patch("eufy_sync.strava_client.StravaClient", return_value=fake_strava), \ + patch("eufy_sync.sync.time.sleep"): + sync_user(user, state, backfill_days=7) + + assert "Authentication failed for" in caplog.text + assert "Strava token revoked" in caplog.text + state.close() + + def test_all_targets_auth_failure_raises(tmp_path: Path): """If every target fails to authenticate, sync_user raises (whole-user failure) rather than silently doing nothing.""" diff --git a/tests/test_transform.py b/tests/test_transform.py index 7060c06..62d94c9 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -36,7 +36,8 @@ def test_basic_transform(): assert result.muscle_mass == 45.2 assert result.basal_met == 1650 assert result.metabolic_age == 28 - assert result.bmi is None # We skip BMI, let Garmin calculate + # Garmin does not calculate BMI for uploads, so Eufy's figure is sent. + assert result.bmi == 23.1 def test_timestamp_is_iso(): @@ -100,3 +101,19 @@ def test_transform_drops_implausible_bmr(): result = transform(m) assert result is not None assert result.basal_met is None + + +def test_transform_rounds_bmi_to_the_fit_resolution(): + """The FIT encoder multiplies BMI by 10 and truncates, so an unrounded + 26.66 would arrive as 26.6. Rounding first stores the nearest tenth.""" + result = transform(_make_measurement(bmi=26.66)) + assert result is not None + assert result.bmi == 26.7 + + +def test_transform_drops_implausible_bmi(): + for bad in (0, 4.2, 95.0): + result = transform(_make_measurement(bmi=bad)) + assert result is not None + assert result.bmi is None + diff --git a/tests/test_update_check.py b/tests/test_update_check.py index e0b186b..85a2c8a 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -30,6 +30,15 @@ def _mock_pypi_response(version: str) -> MagicMock: return resp +_CONSOLE_PREFIX = 'cmd /c start "eufy-sync update" cmd /c "' + + +def _inner_of(line: str) -> str: + """The command line the detached update console runs.""" + assert line.startswith(_CONSOLE_PREFIX) and line.endswith('"') + return line[len(_CONSOLE_PREFIX):-1] + + def test_skips_when_cache_is_fresh(tmp_path: Path): cache_file = tmp_path / "update_check" cache_file.write_text(str(time.time())) # just checked @@ -215,9 +224,14 @@ def test_self_update_windows_quotes_spaced_executable_path(): patch("eufy_sync.cli.updater.subprocess.Popen") as mock_popen: _self_update() - inner = mock_popen.call_args.args[0][-1] + line = mock_popen.call_args.args[0] + inner = _inner_of(line) assert '"C:\\Program Files\\Python\\python.exe"' in inner assert "eufy-sync==9.9.9" in inner + # Handed over as one string, so no layer escapes those quotes as \", + # which cmd.exe would read literally. + assert isinstance(line, str) + assert '\\"' not in line def test_self_update_uses_pip_when_no_pipx(): @@ -255,6 +269,7 @@ def test_self_update_reports_failed_install(capsys): patch("eufy_sync.__version__", "1.0.0"), \ patch("eufy_sync.cli.updater.platform.system", return_value="Darwin"), \ patch("eufy_sync.install.shutil.which", return_value="/usr/local/bin/pipx"), \ + patch("eufy_sync.cli.updater.time.sleep"), \ patch("eufy_sync.cli.updater.subprocess.run", return_value=MagicMock(returncode=1)): _self_update() @@ -277,12 +292,11 @@ def test_self_update_on_windows_uses_detached_console(): mock_run.assert_not_called() mock_popen.assert_called_once() - argv = mock_popen.call_args.args[0] - assert argv[:4] == ["cmd", "/c", "start", "eufy-sync update"] + line = mock_popen.call_args.args[0] + assert line.startswith('cmd /c start "eufy-sync update" cmd /c "') # The pinned version is what the detached console actually installs. - inner = argv[-1] - assert "eufy-sync==9.9.9" in inner + assert "eufy-sync==9.9.9" in _inner_of(line) def test_self_update_on_darwin_runs_inline(): @@ -390,3 +404,116 @@ def test_update_runs_when_garminconnect_and_curl_cffi_cannot_load(tmp_path): assert result.returncode == 0, result.stderr assert "to v99.0.0" in result.stdout + + +# --- Retry when PyPI's index lags a fresh release ------------------------------ + +def _inline_update(run_results): + """Run _self_update on the inline (non-Windows) path with the given + install return codes; returns (run mock, sleep mock).""" + from eufy_sync.cli.updater import _self_update + with patch("eufy_sync.cli.updater._latest_pypi_version", return_value="9.9.9"), \ + patch("eufy_sync.__version__", "1.0.0"), \ + patch("eufy_sync.cli.updater.platform.system", return_value="Darwin"), \ + patch("eufy_sync.install.shutil.which", return_value="/usr/local/bin/pipx"), \ + patch("eufy_sync.cli.updater.time.sleep") as sleep, \ + patch("eufy_sync.cli.updater.subprocess.run", + side_effect=[MagicMock(returncode=rc) for rc in run_results]) as run: + _self_update() + return run, sleep + + +def test_self_update_retries_once_when_the_first_install_fails(capsys): + from eufy_sync.cli.updater import UPDATE_RETRY_DELAY + run, sleep = _inline_update([1, 0]) + + assert run.call_count == 2 + assert run.call_args_list[0] == run.call_args_list[1] + sleep.assert_called_once_with(UPDATE_RETRY_DELAY) + out = capsys.readouterr().out + assert f"Trying again in {UPDATE_RETRY_DELAY} seconds" in out + assert "Updated to v9.9.9." in out + + +def test_self_update_gives_the_manual_command_after_the_retry_fails(capsys): + run, _ = _inline_update([1, 1]) + + assert run.call_count == 2 + assert "Update failed. Run manually: pipx install --force eufy-sync==9.9.9" in capsys.readouterr().out + + +def test_self_update_does_not_wait_when_the_install_works(capsys): + run, sleep = _inline_update([0]) + + assert run.call_count == 1 + sleep.assert_not_called() + + +def _windows_inner(install_argv): + from eufy_sync.cli.updater import _self_update + with patch("eufy_sync.cli.updater._latest_pypi_version", return_value="9.9.9"), \ + patch("eufy_sync.__version__", "1.0.0"), \ + patch("eufy_sync.cli.updater.platform.system", return_value="Windows"), \ + patch("eufy_sync.install.install_argv", return_value=install_argv), \ + patch("eufy_sync.cli.updater.subprocess.Popen") as popen: + _self_update() + return _inner_of(popen.call_args.args[0]) + + +def test_windows_update_console_retries_only_after_a_failure(): + from eufy_sync.cli.updater import UPDATE_RETRY_DELAY + inner = _windows_inner(["uv", "tool", "install", "--force", "eufy-sync==9.9.9"]) + + first, _, rest = inner.partition(" || (") + assert first.endswith("uv tool install --force eufy-sync==9.9.9") + assert f"timeout /t {UPDATE_RETRY_DELAY} /nobreak >nul & uv tool install --force eufy-sync==9.9.9)" in rest + assert inner.endswith(") & echo. & pause") + + +def test_windows_retry_message_is_safe_to_echo_in_cmd(): + inner = _windows_inner(["uv", "tool", "install", "--force", "eufy-sync==9.9.9"]) + message = inner.split("echo ", 1)[1].split(" & timeout", 1)[0] + + assert not any(c in message for c in '()&|<>^%"!') + + +def test_quote_cmd_quotes_parentheses_so_the_retry_block_stays_closed(): + from eufy_sync.cli.updater import _quote_cmd + + assert _quote_cmd(["C:\\Tools(x)\\python.exe", "-m", "pip"]) == '"C:\\Tools(x)\\python.exe" -m pip' + + +@pytest.mark.skipif(sys.platform != "win32", reason="runs the real cmd chain on Windows") +@pytest.mark.parametrize("fails_first, expected_attempts", [(True, 2), (False, 1)]) +def test_windows_update_console_really_retries(tmp_path, monkeypatch, fails_first, expected_attempts): + """Run the console's command line under cmd with a stand-in installer that + fails once, to prove the || ( ) chain does what the string tests say.""" + from eufy_sync.cli import updater + + monkeypatch.setattr(updater, "UPDATE_RETRY_DELAY", 1) + attempts = tmp_path / "attempts.log" + marker = tmp_path / "failed-once" + if not fails_first: + marker.write_text("") + # A folder name with a space and parentheses, the hardest case for cmd. + folder = tmp_path / "Program Files (x86)" + folder.mkdir() + fake = folder / "fake-install.cmd" + fake.write_bytes( + ( + "@echo off\r\n" + f'echo attempt>>"{attempts}"\r\n' + f'if exist "{marker}" exit /b 0\r\n' + f'type nul>"{marker}"\r\n' + "exit /b 1\r\n" + ).encode() + ) + + inner = _windows_inner([str(fake), "eufy-sync==9.9.9"]) + # Two cmd layers, as in the real launch (cmd /c start ... cmd /c "inner"), + # minus start's new window. No console here: timeout and pause return at + # once with stdin closed. + subprocess.run(f'cmd /c cmd /c "{inner}"', stdin=subprocess.DEVNULL, capture_output=True, timeout=120) + + assert attempts.read_text().count("attempt") == expected_attempts +