Skip to content

Commit 6dfc315

Browse files
cyberjunkyclaude
andcommitted
chore: add test_strategy.py login diagnostic script
Interactive tool to run each login strategy in isolation (or all of them) and see which authentication path works/fails on a given account or network. Tees console + debug output to strategy_<name>.log. Uses the Client.skip_strategies hook to force a single strategy. Useful for diagnosing 429s, Cloudflare 403/CAPTCHA challenges, MFA, and token-audience issues like #369. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f7d8c0c commit 6dfc315

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ unfixable = [] # Allow all fixes, including unsafe ones
193193
"garminconnect/workout.py" = ["D401"] # Pydantic models - docstring mood is fine
194194
"demo.py" = ["T20", "S101", "ERA", "RUF001", "PTH", "PERF401", "PERF203", "PLR0911", "D103"] # Demo script - various user-facing patterns
195195
"example.py" = ["S110", "PLR0911", "T20", "PERF203"] # Example script - simple error handling is intentional
196+
"test_strategy.py" = ["T20", "D103"] # Interactive login-strategy diagnostic script
196197

197198
[tool.coverage.run]
198199
source = ["garminconnect"]

test_strategy.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
"""Interactive login strategy tester.
3+
4+
Runs a single login strategy in isolation (or all of them) so you can see
5+
exactly which authentication path works and which fails on your account /
6+
network. Handy for diagnosing login issues: rate limits (429), Cloudflare
7+
bot challenges (403 / CAPTCHA), MFA, or token-audience rejections.
8+
9+
The five strategies, in the order the library tries them:
10+
1. mobile+cffi iOS mobile API via curl_cffi (TLS impersonation)
11+
2. mobile+requests iOS mobile API via plain requests
12+
3. widget+cffi SSO embed widget via curl_cffi
13+
4. portal+cffi Connect portal via curl_cffi
14+
5. portal+requests Connect portal via plain requests
15+
16+
Usage:
17+
python3 test_strategy.py
18+
EMAIL=you@example.com PASSWORD=secret python3 test_strategy.py
19+
GARMINTOKENS=~/.garminconnect python3 test_strategy.py
20+
21+
All output (console + debug logs) is also written to strategy_<name>.log.
22+
"""
23+
24+
import logging
25+
import os
26+
import shutil
27+
import sys
28+
from datetime import datetime
29+
from getpass import getpass
30+
from pathlib import Path
31+
32+
import garminconnect
33+
34+
ALL_STRATEGIES = [
35+
"mobile+cffi",
36+
"mobile+requests",
37+
"widget+cffi",
38+
"portal+cffi",
39+
"portal+requests",
40+
]
41+
42+
43+
class _Tee:
44+
"""Write to both a stream and a file simultaneously."""
45+
46+
def __init__(self, stream: object, file: object) -> None:
47+
self._stream = stream
48+
self._file = file
49+
50+
def write(self, data: str) -> None:
51+
self._stream.write(data)
52+
self._file.write(data)
53+
54+
def flush(self) -> None:
55+
self._stream.flush()
56+
self._file.flush()
57+
58+
def __getattr__(self, attr: str) -> object:
59+
return getattr(self._stream, attr)
60+
61+
62+
def _setup_logging(strategy: str) -> Path:
63+
slug = strategy.replace("+", "_")
64+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
65+
log_path = Path(f"strategy_{slug}_{ts}.log")
66+
log_file = log_path.open("w", buffering=1)
67+
68+
sys.stdout = _Tee(sys.__stdout__, log_file) # type: ignore[assignment]
69+
sys.stderr = _Tee(sys.__stderr__, log_file) # type: ignore[assignment]
70+
71+
logging.basicConfig(
72+
level=logging.DEBUG,
73+
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
74+
stream=sys.stderr,
75+
)
76+
return log_path
77+
78+
79+
def pick_strategy() -> str:
80+
print("\nAvailable strategies:")
81+
for i, name in enumerate(ALL_STRATEGIES, 1):
82+
print(f" {i}. {name}")
83+
print(f" {len(ALL_STRATEGIES) + 1}. Run ALL (normal login, no skipping)")
84+
while True:
85+
raw = input("\nPick a number: ").strip()
86+
if raw.isdigit():
87+
n = int(raw)
88+
if 1 <= n <= len(ALL_STRATEGIES):
89+
return ALL_STRATEGIES[n - 1]
90+
if n == len(ALL_STRATEGIES) + 1:
91+
return "all"
92+
print("Invalid choice, try again.")
93+
94+
95+
def get_credentials() -> tuple[str, str]:
96+
email = os.environ.get("EMAIL") or input("Garmin email: ").strip()
97+
password = os.environ.get("PASSWORD") or getpass("Garmin password: ")
98+
return email, password
99+
100+
101+
def get_tokenstore() -> str:
102+
default = str(Path("~/.garminconnect").expanduser())
103+
path = os.environ.get("GARMINTOKENS", default)
104+
print(f"Token store: {path}")
105+
return path
106+
107+
108+
def clear_tokens(tokenstore: str) -> None:
109+
p = Path(tokenstore).expanduser()
110+
if not p.exists():
111+
print(f"No token store at {p} (will do a fresh login anyway)")
112+
elif p.is_dir():
113+
shutil.rmtree(p)
114+
print(f"Deleted directory {p}")
115+
else:
116+
p.unlink()
117+
print(f"Deleted {p}")
118+
119+
120+
def run(strategy: str) -> None:
121+
email, password = get_credentials()
122+
tokenstore = get_tokenstore()
123+
124+
answer = (
125+
input("\nDelete existing tokens to force a fresh login? [Y/n]: ")
126+
.strip()
127+
.lower()
128+
)
129+
if answer != "n":
130+
clear_tokens(tokenstore)
131+
132+
g = garminconnect.Garmin(
133+
email, password, prompt_mfa=lambda: input("MFA code: ").strip()
134+
)
135+
136+
if strategy != "all":
137+
skip = set(ALL_STRATEGIES) - {strategy}
138+
g.client.skip_strategies = skip
139+
print(f"\nRunning ONLY: {strategy}")
140+
print(f"Skipping: {', '.join(sorted(skip))}\n")
141+
else:
142+
print("\nRunning all strategies (normal login)\n")
143+
144+
try:
145+
g.login(tokenstore)
146+
print(f"\n✓ Login succeeded via {strategy}")
147+
print(f" display_name : {g.display_name}")
148+
print(f" full_name : {g.full_name}")
149+
print(f" di_token set : {bool(g.client.di_token)}")
150+
print(f" jwt_web set : {bool(g.client.jwt_web)}")
151+
except garminconnect.GarminConnectAuthenticationError as e:
152+
print(f"\n✗ Authentication error: {e}", file=sys.stderr)
153+
except garminconnect.GarminConnectTooManyRequestsError as e:
154+
print(f"\n✗ Rate limited (429): {e}", file=sys.stderr)
155+
except garminconnect.GarminConnectConnectionError as e:
156+
print(f"\n✗ Connection/API error: {e}", file=sys.stderr)
157+
except Exception as e:
158+
print(f"\n✗ Unexpected error ({type(e).__name__}): {e}", file=sys.stderr)
159+
160+
161+
if __name__ == "__main__":
162+
# Pick strategy before setting up logging so the menu stays clean
163+
strategy = pick_strategy()
164+
log_path = _setup_logging(strategy)
165+
print(f"Logging to: {log_path}\n")
166+
run(strategy)

0 commit comments

Comments
 (0)