refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4
backend/internal/garmin/wrapper/.gitignore
vendored
Normal file
4
backend/internal/garmin/wrapper/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
18
backend/internal/garmin/wrapper/pyproject.toml
Normal file
18
backend/internal/garmin/wrapper/pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "geniusrun-garmin-wrapper"
|
||||
version = "0.1.0"
|
||||
description = "Subprocess wrapper around garminconnect for geniusrund's internal/garmin package"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"garminconnect",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
0
backend/internal/garmin/wrapper/tests/__init__.py
Normal file
0
backend/internal/garmin/wrapper/tests/__init__.py
Normal file
293
backend/internal/garmin/wrapper/tests/test_wrapper.py
Normal file
293
backend/internal/garmin/wrapper/tests/test_wrapper.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import wrapper
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
original_state = wrapper._auth_state
|
||||
original_client = wrapper._client
|
||||
original_startup_attempted = wrapper._startup_login_attempted
|
||||
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
|
||||
while not q.empty():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
wrapper._startup_login_attempted = False
|
||||
yield
|
||||
wrapper._auth_state = original_state
|
||||
wrapper._client = original_client
|
||||
wrapper._startup_login_attempted = original_startup_attempted
|
||||
|
||||
|
||||
def test_authenticate_success():
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
resp = wrapper.dispatch({"id": 1, "cmd": "authenticate"})
|
||||
assert resp == {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}}
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_authenticate_mfa_required():
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
with patch.object(wrapper._login_result_queue, "get", side_effect=queue.Empty):
|
||||
resp = wrapper.dispatch({"id": 2, "cmd": "authenticate"})
|
||||
assert resp["result"]["status"] == "mfa_required"
|
||||
assert wrapper._auth_state == "mfa_pending"
|
||||
|
||||
|
||||
def test_authenticate_missing_credentials():
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop("GARMIN_EMAIL", None)
|
||||
os.environ.pop("GARMIN_PASSWORD", None)
|
||||
resp = wrapper.dispatch({"id": 3, "cmd": "authenticate"})
|
||||
assert resp["result"]["status"] == "failed"
|
||||
assert "GARMIN_EMAIL" in resp["result"]["message"]
|
||||
|
||||
|
||||
def test_complete_mfa_success():
|
||||
wrapper._auth_state = "mfa_pending"
|
||||
wrapper._login_result_queue.put(("success", None))
|
||||
resp = wrapper.dispatch({"id": 4, "cmd": "complete_mfa", "params": {"code": "123456"}})
|
||||
assert resp == {
|
||||
"id": 4,
|
||||
"result": {"status": "success", "message": "MFA accepted. Authenticated successfully."},
|
||||
}
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
assert wrapper._mfa_input_queue.get_nowait() == "123456"
|
||||
|
||||
|
||||
def test_complete_mfa_not_in_progress():
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
resp = wrapper.dispatch({"id": 5, "cmd": "complete_mfa", "params": {"code": "123456"}})
|
||||
assert resp["result"]["status"] == "failed"
|
||||
assert "No MFA in progress" in resp["result"]["message"]
|
||||
|
||||
|
||||
def test_call_dispatches_to_named_garminconnect_method():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_activities_by_date.return_value = [{"activityId": "111"}]
|
||||
|
||||
resp = wrapper.dispatch({
|
||||
"id": 6,
|
||||
"cmd": "call",
|
||||
"params": {
|
||||
"method": "get_activities_by_date",
|
||||
"args": {"startdate": "2026-07-01", "enddate": "2026-07-25"},
|
||||
},
|
||||
})
|
||||
|
||||
assert resp == {"id": 6, "result": [{"activityId": "111"}]}
|
||||
wrapper._client.get_activities_by_date.assert_called_once_with(
|
||||
startdate="2026-07-01", enddate="2026-07-25"
|
||||
)
|
||||
|
||||
|
||||
def test_call_unauthenticated_is_error():
|
||||
# Explicitly absent GARMIN_EMAIL/PASSWORD: this now indirectly triggers
|
||||
# the lazy _startup_login fallback (see _handle_call), which must
|
||||
# return immediately with nothing to resume, leaving this the same
|
||||
# "Not authenticated" error as before -- not dependent on whatever
|
||||
# happens to be in the ambient shell environment.
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop("GARMIN_EMAIL", None)
|
||||
os.environ.pop("GARMIN_PASSWORD", None)
|
||||
resp = wrapper.dispatch({
|
||||
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
||||
})
|
||||
assert "error" in resp
|
||||
assert "Not authenticated" in resp["error"]
|
||||
|
||||
|
||||
def test_call_unknown_method_is_error():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock(spec=["get_activities_by_date"])
|
||||
resp = wrapper.dispatch({
|
||||
"id": 8, "cmd": "call", "params": {"method": "delete_everything", "args": {}},
|
||||
})
|
||||
assert resp["id"] == 8
|
||||
assert "error" in resp
|
||||
|
||||
|
||||
def test_call_propagates_garminconnect_exception_as_error():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_activity_splits.side_effect = Exception("not found")
|
||||
resp = wrapper.dispatch({
|
||||
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
|
||||
})
|
||||
assert resp == {"id": 9, "error": "not found"}
|
||||
|
||||
|
||||
def test_dispatch_unknown_cmd_is_error():
|
||||
resp = wrapper.dispatch({"id": 10, "cmd": "not_a_real_cmd"})
|
||||
assert resp["id"] == 10
|
||||
assert "unknown cmd" in resp["error"]
|
||||
|
||||
|
||||
def test_startup_login_success():
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_startup_login_missing_credentials_returns_immediately():
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop("GARMIN_EMAIL", None)
|
||||
os.environ.pop("GARMIN_PASSWORD", None)
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
assert wrapper._client is None
|
||||
|
||||
|
||||
def test_startup_login_times_out_without_blocking():
|
||||
# _startup_login now waits on its own private, function-local queue
|
||||
# (never the shared wrapper._login_result_queue -- see the
|
||||
# no-shared-queue-leakage test below), so forcing its timeout path
|
||||
# means intercepting the queue.Queue() constructor it calls internally
|
||||
# rather than patching a module-level queue object.
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||
# Should return promptly (bounded by the 10s timeout passed to
|
||||
# queue.get, which is mocked here to raise immediately) rather
|
||||
# than blocking main()'s stdin dispatch loop from starting.
|
||||
wrapper._startup_login()
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
|
||||
|
||||
def test_startup_login_does_not_leak_into_shared_authenticate_queue():
|
||||
"""Regression test: _startup_login used to push its background thread's
|
||||
result onto the same module-level _login_result_queue that
|
||||
_handle_authenticate/_handle_complete_mfa share for the MFA handoff. A
|
||||
startup login that was still in flight when a user's first explicit
|
||||
'authenticate' call came in could have that call accidentally dequeue
|
||||
the startup thread's stale result instead of its own fresh one.
|
||||
_startup_login now uses its own private queue, so a still-in-flight
|
||||
startup attempt can never interfere with a later authenticate() call."""
|
||||
release = threading.Event()
|
||||
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
# A startup login whose underlying garminconnect call is still
|
||||
# blocked (simulating "slow to resolve") when _startup_login's
|
||||
# own bounded wait (mocked to time out immediately, so this test
|
||||
# doesn't need to sleep the real 10s) returns.
|
||||
slow_client = MagicMock()
|
||||
slow_client.login.side_effect = lambda **kwargs: release.wait(5)
|
||||
mock_garmin_cls.return_value = slow_client
|
||||
with patch("wrapper.queue.Queue") as mock_queue_cls:
|
||||
mock_queue_cls.return_value.get.side_effect = queue.Empty
|
||||
wrapper._startup_login()
|
||||
|
||||
assert wrapper._auth_state == "unauthenticated"
|
||||
# Nothing from the still-in-flight startup attempt ever touches the
|
||||
# shared queue that authenticate()/complete_mfa() rely on.
|
||||
assert wrapper._login_result_queue.empty()
|
||||
|
||||
release.set() # let the stale startup thread finish harmlessly in the background
|
||||
|
||||
# A fresh, unrelated authenticate() call must get its own result, not
|
||||
# anything left over from the startup attempt above.
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls2:
|
||||
mock_garmin_cls2.return_value = MagicMock()
|
||||
resp = wrapper.dispatch({"id": 20, "cmd": "authenticate"})
|
||||
|
||||
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
|
||||
assert wrapper._auth_state == "authenticated"
|
||||
|
||||
|
||||
def test_call_triggers_startup_login_lazily_on_first_call():
|
||||
"""A data 'call' with no prior explicit authenticate on this subprocess
|
||||
-- e.g. "Sync now" reaching an already-connected user's client right
|
||||
after a backend restart -- gets exactly one lazy attempt to silently
|
||||
resume a cached tokenstore session before giving up."""
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_activities_by_date.return_value = []
|
||||
|
||||
def fake_startup_login():
|
||||
wrapper._auth_state = "authenticated"
|
||||
|
||||
with patch("wrapper._startup_login", side_effect=fake_startup_login) as mock_startup:
|
||||
resp = wrapper.dispatch({
|
||||
"id": 30, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
|
||||
})
|
||||
|
||||
mock_startup.assert_called_once()
|
||||
assert resp == {"id": 30, "result": []}
|
||||
|
||||
|
||||
def test_call_does_not_retry_startup_login_after_first_attempt():
|
||||
"""If the lazy startup-login attempt doesn't actually authenticate, a
|
||||
second 'call' must not try it again -- one attempt per subprocess
|
||||
lifetime, not once per call."""
|
||||
wrapper._auth_state = "unauthenticated"
|
||||
|
||||
with patch("wrapper._startup_login") as mock_startup: # no-op: leaves state unauthenticated
|
||||
wrapper.dispatch({"id": 31, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||
wrapper.dispatch({"id": 32, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||
|
||||
mock_startup.assert_called_once()
|
||||
|
||||
|
||||
def test_authenticate_prevents_later_lazy_startup_login():
|
||||
"""Once authenticate has been explicitly called (regardless of
|
||||
outcome), a later 'call' must never fall back to _startup_login -- that
|
||||
would either duplicate a login authenticate already did, or waste an
|
||||
extra hit against Garmin after a failure."""
|
||||
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
|
||||
with patch.dict("os.environ", env):
|
||||
with patch("wrapper.Garmin") as mock_garmin_cls:
|
||||
mock_garmin_cls.return_value = MagicMock()
|
||||
wrapper.dispatch({"id": 33, "cmd": "authenticate"})
|
||||
|
||||
wrapper._auth_state = "unauthenticated" # simulate a later, separate call finding no session
|
||||
with patch("wrapper._startup_login") as mock_startup:
|
||||
wrapper.dispatch({"id": 34, "cmd": "call", "params": {"method": "x", "args": {}}})
|
||||
|
||||
mock_startup.assert_not_called()
|
||||
|
||||
|
||||
def test_call_marks_not_found_error_specifically():
|
||||
from garminconnect import GarminConnectNotFoundError
|
||||
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
|
||||
resp = wrapper.dispatch({
|
||||
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||
})
|
||||
assert resp == {"id": 11, "error": "API Error 404", "not_found": True}
|
||||
|
||||
|
||||
def test_call_does_not_mark_other_errors_as_not_found():
|
||||
wrapper._auth_state = "authenticated"
|
||||
wrapper._client = MagicMock()
|
||||
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
|
||||
resp = wrapper.dispatch({
|
||||
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
|
||||
})
|
||||
assert resp == {"id": 12, "error": "rate limited"}
|
||||
233
backend/internal/garmin/wrapper/wrapper.py
Normal file
233
backend/internal/garmin/wrapper/wrapper.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
|
||||
JSON on stdin/stdout by geniusrund's internal/garmin package. See
|
||||
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from garminconnect import Garmin, GarminConnectNotFoundError
|
||||
|
||||
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin"))
|
||||
|
||||
_client = None
|
||||
_auth_state = "unauthenticated"
|
||||
_mfa_input_queue = queue.Queue()
|
||||
_login_result_queue = queue.Queue()
|
||||
# Set the first time this subprocess attempts any login, whichever path
|
||||
# gets there first (see _handle_call's lazy call and _handle_authenticate) --
|
||||
# guarantees _startup_login runs at most once per subprocess lifetime, and
|
||||
# never at all once an explicit authenticate has been attempted.
|
||||
_startup_login_attempted = False
|
||||
|
||||
|
||||
def _debug(msg):
|
||||
print(f"[garmin-wrapper debug] {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _prompt_mfa():
|
||||
_debug("garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin")
|
||||
code = _mfa_input_queue.get(timeout=300)
|
||||
_debug(f"prompt_mfa() handing code of length {len(code)} back to garminconnect")
|
||||
return code
|
||||
|
||||
|
||||
def _startup_login():
|
||||
"""Silently resume a cached tokenstore session, so a freshly
|
||||
(re)spawned subprocess can already be authenticated for a data 'call'
|
||||
that never goes through the explicit authenticate command -- e.g.
|
||||
"Sync now" reaching an already-connected user's client right after a
|
||||
backend restart cleared the in-memory cache.
|
||||
|
||||
Called lazily (see _handle_call), at most once per subprocess lifetime,
|
||||
and only if nothing has explicitly called authenticate first. Running
|
||||
it unconditionally at process start used to be actively counterproductive
|
||||
whenever the very first command actually was authenticate: on success it
|
||||
just duplicated a login _handle_authenticate was about to redo anyway
|
||||
(it always rebuilds _client from scratch), and on failure it was a
|
||||
wasted, unauthenticated hit against Garmin's servers moments before the
|
||||
real attempt -- extra load that only makes rate-limiting worse.
|
||||
|
||||
Bounded to the same 10s timeout as _handle_authenticate: the actual
|
||||
login runs on a background daemon thread, and this function waits up
|
||||
to 10s for it before returning either way, so a slow/rate-limited
|
||||
Garmin login can never block the caller indefinitely -- if it times
|
||||
out, the thread keeps running and will update _auth_state whenever it
|
||||
eventually finishes (success or failure), just without wedging the
|
||||
whole subprocess unresponsive in the meantime.
|
||||
|
||||
Deliberately uses its own private, function-local result queue rather
|
||||
than the module-level _login_result_queue that _handle_authenticate and
|
||||
_handle_complete_mfa share -- those two are legitimately two halves of
|
||||
one explicit, MFA-capable login flow and must share a queue for the MFA
|
||||
handoff to work, but this is a plain background tokenstore resume with
|
||||
no MFA involved. Sharing the queue here would let this thread's stale
|
||||
result (arriving after the 10s timeout below) be dequeued by an
|
||||
unrelated, later authenticate/complete_mfa call instead of that call's
|
||||
own fresh result."""
|
||||
global _client, _auth_state
|
||||
email = os.environ.get("GARMIN_EMAIL")
|
||||
password = os.environ.get("GARMIN_PASSWORD")
|
||||
if not email or not password:
|
||||
return
|
||||
|
||||
_client = Garmin(email, password)
|
||||
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
|
||||
|
||||
def _do_login():
|
||||
try:
|
||||
_client.login(tokenstore=TOKENSTORE)
|
||||
result_queue.put(("success", None))
|
||||
except Exception as exc:
|
||||
result_queue.put(("error", str(exc)))
|
||||
|
||||
threading.Thread(target=_do_login, daemon=True).start()
|
||||
|
||||
try:
|
||||
status, err = result_queue.get(timeout=10)
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
else:
|
||||
_debug(f"startup tokenstore login failed: {err}")
|
||||
_auth_state = "unauthenticated"
|
||||
except queue.Empty:
|
||||
_debug(
|
||||
"startup tokenstore login hit the 10s timeout -- still running in the "
|
||||
"background and will update auth state whenever it finishes"
|
||||
)
|
||||
_auth_state = "unauthenticated"
|
||||
|
||||
|
||||
def _handle_authenticate(_params):
|
||||
global _client, _auth_state, _startup_login_attempted
|
||||
# An explicit authenticate is happening (successful or not) -- the lazy
|
||||
# startup-login fallback in _handle_call must never fire after this, it
|
||||
# would be redundant at best and a wasted extra hit against Garmin at
|
||||
# worst.
|
||||
_startup_login_attempted = True
|
||||
|
||||
email = os.environ.get("GARMIN_EMAIL", "")
|
||||
password = os.environ.get("GARMIN_PASSWORD", "")
|
||||
if not email or not password:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
|
||||
}
|
||||
|
||||
def _do_login():
|
||||
_debug(f"background login thread starting _client.login(tokenstore={TOKENSTORE})")
|
||||
try:
|
||||
_client.login(tokenstore=TOKENSTORE)
|
||||
_debug("_client.login() returned successfully")
|
||||
_login_result_queue.put(("success", None))
|
||||
except Exception as exc:
|
||||
_debug(f"_client.login() raised {type(exc).__name__}: {exc}")
|
||||
_debug(traceback.format_exc())
|
||||
_login_result_queue.put(("error", str(exc)))
|
||||
|
||||
_client = Garmin(email, password)
|
||||
_client.prompt_mfa = _prompt_mfa
|
||||
threading.Thread(target=_do_login, daemon=True).start()
|
||||
|
||||
try:
|
||||
status, err = _login_result_queue.get(timeout=10)
|
||||
_debug(f"authenticate got result within 10s timeout: status={status}")
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
return {"status": "success", "message": "Authenticated successfully."}
|
||||
return {"status": "failed", "message": f"Authentication failed: {err}"}
|
||||
except queue.Empty:
|
||||
_debug(
|
||||
"authenticate hit the 10s timeout with no result yet -- reporting mfa_required, "
|
||||
"but this does NOT necessarily mean prompt_mfa() was actually invoked; check "
|
||||
"whether the 'REAL MFA challenge' debug line above appears to tell real MFA "
|
||||
"apart from a merely slow login."
|
||||
)
|
||||
_auth_state = "mfa_pending"
|
||||
return {
|
||||
"status": "mfa_required",
|
||||
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
|
||||
}
|
||||
|
||||
|
||||
def _handle_complete_mfa(params):
|
||||
global _auth_state
|
||||
|
||||
if _auth_state != "mfa_pending":
|
||||
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
|
||||
|
||||
code = params["code"]
|
||||
_debug(f"complete_mfa received a code of length {len(code)}, pushing to mfa queue")
|
||||
_mfa_input_queue.put(code)
|
||||
|
||||
try:
|
||||
status, err = _login_result_queue.get(timeout=30)
|
||||
_debug(f"complete_mfa got result: status={status} err={err}")
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
|
||||
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
|
||||
except queue.Empty:
|
||||
_auth_state = "unauthenticated"
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": "Timed out waiting for authentication to complete. Call authenticate again.",
|
||||
}
|
||||
|
||||
|
||||
def _handle_call(params):
|
||||
global _startup_login_attempted
|
||||
if _auth_state != "authenticated" and not _startup_login_attempted:
|
||||
_startup_login_attempted = True
|
||||
_startup_login()
|
||||
if _auth_state != "authenticated":
|
||||
raise RuntimeError("Not authenticated. Call authenticate first.")
|
||||
method = params["method"]
|
||||
args = params.get("args") or {}
|
||||
fn = getattr(_client, method)
|
||||
return fn(**args)
|
||||
|
||||
|
||||
_HANDLERS = {
|
||||
"authenticate": _handle_authenticate,
|
||||
"complete_mfa": _handle_complete_mfa,
|
||||
"call": _handle_call,
|
||||
}
|
||||
|
||||
|
||||
def dispatch(req):
|
||||
handler = _HANDLERS.get(req.get("cmd"))
|
||||
if handler is None:
|
||||
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
|
||||
try:
|
||||
result = handler(req.get("params") or {})
|
||||
return {"id": req["id"], "result": result}
|
||||
except Exception as exc:
|
||||
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
|
||||
_debug(traceback.format_exc())
|
||||
resp = {"id": req.get("id"), "error": str(exc)}
|
||||
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
|
||||
# side after being linked to an activity) is definitive, not a
|
||||
# transient failure worth retrying forever -- marked specifically so
|
||||
# internal/garmin/client.go can tell the two apart (see
|
||||
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
|
||||
if isinstance(exc, GarminConnectNotFoundError):
|
||||
resp["not_found"] = True
|
||||
return resp
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
req = json.loads(line)
|
||||
resp = dispatch(req)
|
||||
print(json.dumps(resp), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user