From bc2177c5d3d47cb36bd66ee303928e0b1fcf6bc7 Mon Sep 17 00:00:00 2001 From: Elias Date: Thu, 29 Oct 2026 10:19:04 -0400 Subject: [PATCH] Require curl_cffi 0.17 for macOS 27 and add Python 3.15; 1.15.1 On macOS 27, import curl_cffi fails on 0.15.0 and 0.16.3: the wheel never linked CoreFoundation or SystemConfiguration (lexiforest/curl_cffi#885), and python-garminconnect logs in through it. 0.17.0 links both frameworks. --update no longer imports garminconnect or curl_cffi, so a broken copy of either cannot stop the command that installs the fix. GarminAuth loads on first use and the doctor module only for --doctor. A test runs --update in a fresh interpreter where both packages refuse to import. Python 3.15 joins the CI matrix and the classifiers. --- .github/workflows/checks.yml | 2 +- eufy_sync/__init__.py | 14 +++++++-- eufy_sync/cli/app.py | 7 +++-- pyproject.toml | 5 ++-- tests/test_update_check.py | 58 ++++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6e1f873..7deac04 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 diff --git a/eufy_sync/__init__.py b/eufy_sync/__init__.py index 3600946..7f7bf67 100644 --- a/eufy_sync/__init__.py +++ b/eufy_sync/__init__.py @@ -1,13 +1,23 @@ """Sync Eufy smart scale body composition data to Garmin Connect and Strava.""" -__version__ = "1.15.0" +__version__ = "1.15.1" # Public API for programmatic use from eufy_sync.eufy_client import EufyClient, EufyMeasurement -from eufy_sync.garmin_auth import GarminAuth from eufy_sync.strava_client import StravaClient from eufy_sync.transform import GarminBodyComposition, transform + +def __getattr__(name): + # GarminAuth pulls in garminconnect, and through it curl_cffi. Importing it + # on first use keeps every command that never talks to Garmin, --update + # above all, working on a machine where either one fails to load. + if name == "GarminAuth": + from eufy_sync.garmin_auth import GarminAuth + return GarminAuth + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "GarminAuth", "EufyClient", diff --git a/eufy_sync/cli/app.py b/eufy_sync/cli/app.py index 61f8762..c46ce58 100644 --- a/eufy_sync/cli/app.py +++ b/eufy_sync/cli/app.py @@ -7,8 +7,11 @@ from pathlib import Path from time import sleep +# Nothing imported here may load garminconnect or curl_cffi (doctor does, so +# it is imported where it runs). --update has to work when either is broken, +# since updating is how a broken install gets fixed. from eufy_sync import platform_support -from eufy_sync.cli import doctor, maintenance, profiles, setup, shared, status, updater +from eufy_sync.cli import maintenance, profiles, setup, shared, status, updater from eufy_sync.reporting import SyncReport, garmin_existing_note, update_counts_summary logger = logging.getLogger("eufy_sync") @@ -197,7 +200,7 @@ def _main() -> None: # Handle doctor - must report even on a fresh install, never launch the # first-run wizard, and never traceback. if args.doctor: - from eufy_sync.cli import lock + from eufy_sync.cli import doctor, lock # Live checks can refresh tokens, so they share the sync lock. with lock.single_instance(require_lock=True) as acquired: if not acquired: diff --git a/pyproject.toml b/pyproject.toml index f62842e..9b11912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "eufy-sync" -version = "1.15.0" +version = "1.15.1" description = "Sync Eufy smart scale data to Garmin Connect, Strava, and Zwift" readme = "README.md" license = "MIT" @@ -31,12 +31,13 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", ] dependencies = [ "httpx>=0.27.0", "keyring>=25.0.0", "garminconnect>=0.3.10", - "curl_cffi>=0.15.0", + "curl_cffi>=0.17.0", "pyyaml>=6.0", ] diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 6482a44..e0b186b 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import subprocess import sys import time from pathlib import Path @@ -332,3 +334,59 @@ def test_an_install_ahead_of_pypi_is_not_offered_a_downgrade(capsys): updater._self_update() run.assert_not_called() assert "Already on the latest version" in capsys.readouterr().out + + +_UPDATE_WITHOUT_GARMIN_STACK = """ +import sys +import types + + +class _Unloadable: + # Every import of either package fails, the way curl_cffi 0.15 and 0.16 + # failed on macOS 27 (an ImportError from dlopen), and is recorded. + attempts = [] + + def find_spec(self, name, path=None, target=None): + if name.split(".")[0] in ("curl_cffi", "garminconnect"): + self.attempts.append(name) + raise ImportError(f"{name} cannot load on this machine") + return None + + +blocker = _Unloadable() +sys.meta_path.insert(0, blocker) + +from eufy_sync.cli import updater + +calls = [] + + +def record(cmd, *args, **kwargs): + calls.append(cmd) + return types.SimpleNamespace(returncode=0) + + +updater._latest_pypi_version = lambda: "99.0.0" +updater.subprocess = types.SimpleNamespace(run=record, Popen=record) +sys.argv = ["eufy-sync", "--update"] + +from eufy_sync.cli import main + +main() +assert calls, "the upgrade command never ran" +assert not blocker.attempts, f"--update imported {blocker.attempts}" +""" + + +def test_update_runs_when_garminconnect_and_curl_cffi_cannot_load(tmp_path): + # --update is how a machine with a broken curl_cffi gets the release that + # fixes it, so nothing on that path may import either package. A fresh + # interpreter, because this one already has both loaded. + env = {**os.environ, "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)} + result = subprocess.run( + [sys.executable, "-c", _UPDATE_WITHOUT_GARMIN_STACK], + capture_output=True, text=True, timeout=60, env=env, + ) + + assert result.returncode == 0, result.stderr + assert "to v99.0.0" in result.stdout