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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions eufy_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 5 additions & 2 deletions eufy_sync/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
]

Expand Down
58 changes: 58 additions & 0 deletions tests/test_update_check.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import json
import os
import subprocess
import sys
import time
from pathlib import Path
Expand Down Expand Up @@ -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
Loading