fitatu-api 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fitatu_api/__init__.py +61 -0
- fitatu_api/__main__.py +554 -0
- fitatu_api/_demo.py +503 -0
- fitatu_api/_files.py +27 -0
- fitatu_api/_shapes.py +39 -0
- fitatu_api/_summaries.py +170 -0
- fitatu_api/_validation.py +55 -0
- fitatu_api/agent.py +1852 -0
- fitatu_api/auth.py +318 -0
- fitatu_api/client.py +1336 -0
- fitatu_api/constants.py +22 -0
- fitatu_api/demo_data.json +52 -0
- fitatu_api/exceptions.py +219 -0
- fitatu_api/facade.py +928 -0
- fitatu_api/mcp_server.py +290 -0
- fitatu_api/planner.py +1779 -0
- fitatu_api/py.typed +0 -0
- fitatu_api/service_modules.py +724 -0
- fitatu_api-0.5.0.dist-info/METADATA +502 -0
- fitatu_api-0.5.0.dist-info/RECORD +23 -0
- fitatu_api-0.5.0.dist-info/WHEEL +4 -0
- fitatu_api-0.5.0.dist-info/entry_points.txt +3 -0
- fitatu_api-0.5.0.dist-info/licenses/LICENSE +21 -0
fitatu_api/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Public package exports for fitatu-api."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.5.0"
|
|
4
|
+
|
|
5
|
+
from .agent import FitatuTools
|
|
6
|
+
from .auth import FitatuAuthContext, FitatuTokenStore
|
|
7
|
+
from .client import FitatuApiClient
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
FitatuApiError,
|
|
10
|
+
FitatuAuthError,
|
|
11
|
+
FitatuConnectionError,
|
|
12
|
+
FitatuMaintenanceError,
|
|
13
|
+
FitatuNotFoundError,
|
|
14
|
+
FitatuPlannerSyncError,
|
|
15
|
+
FitatuRateLimitError,
|
|
16
|
+
FitatuValidationError,
|
|
17
|
+
describe_error,
|
|
18
|
+
)
|
|
19
|
+
from .facade import FitatuLibrary
|
|
20
|
+
from .planner import PlannerModule
|
|
21
|
+
from .service_modules import (
|
|
22
|
+
ActivitiesModule,
|
|
23
|
+
DietPlanModule,
|
|
24
|
+
FastingModule,
|
|
25
|
+
FoodsModule,
|
|
26
|
+
HabitsModule,
|
|
27
|
+
MeasurementsModule,
|
|
28
|
+
ResourcesModule,
|
|
29
|
+
SummaryModule,
|
|
30
|
+
UserSettingsModule,
|
|
31
|
+
WaterModule,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"FitatuTools",
|
|
36
|
+
"FitatuLibrary",
|
|
37
|
+
"FitatuApiClient",
|
|
38
|
+
"FitatuAuthContext",
|
|
39
|
+
"FitatuApiError",
|
|
40
|
+
"FitatuAuthError",
|
|
41
|
+
"FitatuConnectionError",
|
|
42
|
+
"FitatuRateLimitError",
|
|
43
|
+
"FitatuMaintenanceError",
|
|
44
|
+
"FitatuValidationError",
|
|
45
|
+
"FitatuNotFoundError",
|
|
46
|
+
"FitatuPlannerSyncError",
|
|
47
|
+
"describe_error",
|
|
48
|
+
"FitatuTokenStore",
|
|
49
|
+
"ActivitiesModule",
|
|
50
|
+
"PlannerModule",
|
|
51
|
+
"UserSettingsModule",
|
|
52
|
+
"DietPlanModule",
|
|
53
|
+
"WaterModule",
|
|
54
|
+
"MeasurementsModule",
|
|
55
|
+
"SummaryModule",
|
|
56
|
+
"FoodsModule",
|
|
57
|
+
"FastingModule",
|
|
58
|
+
"HabitsModule",
|
|
59
|
+
"ResourcesModule",
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
fitatu_api/__main__.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Command line helpers.
|
|
2
|
+
|
|
3
|
+
``python -m fitatu_api login`` (or ``fitatu-api login`` once installed) logs in with an
|
|
4
|
+
e-mail and password and writes the session file - ``~/.fitatu/session.json`` unless
|
|
5
|
+
``-o`` or ``$FITATU_SESSION`` says otherwise - that ``FitatuApiClient.from_session()``
|
|
6
|
+
loads. ``fitatu-api import-session`` writes the same file from tokens copied out of the
|
|
7
|
+
web app, for accounts that have no password (Apple or Facebook sign-in).
|
|
8
|
+
|
|
9
|
+
On that session file, ``fitatu-api tools`` lists the agent tools, ``fitatu-api call NAME
|
|
10
|
+
[JSON]`` runs one through :meth:`FitatuTools.call` and prints its envelope (only the read
|
|
11
|
+
tools, unless ``--allow-writes``), ``fitatu-api export`` saves the diary export to a
|
|
12
|
+
private file, and ``fitatu-api mcp`` runs the MCP server, like ``fitatu-mcp``.
|
|
13
|
+
``fitatu-api status`` describes the session without a request, ``doctor`` checks it with
|
|
14
|
+
Fitatu, and ``logout`` deletes the file. None of them prints a token.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import getpass
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import platform
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
import textwrap
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
from ._files import write_private_file
|
|
35
|
+
from ._shapes import as_dict
|
|
36
|
+
from .agent import READ_ONLY_TOOLS, TOOLS, FitatuTools, _selected, anthropic_tools
|
|
37
|
+
from .auth import FitatuAuthContext, jwt_payload, resolve_session_path, write_private_json
|
|
38
|
+
from .client import FitatuApiClient
|
|
39
|
+
from .exceptions import FitatuApiError, FitatuAuthError, FitatuValidationError, describe_error
|
|
40
|
+
from .service_modules import SummaryModule
|
|
41
|
+
|
|
42
|
+
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def setup_logging(level: str | None) -> None:
|
|
46
|
+
"""Log to stderr: the library from ``level`` (else ``$FITATU_LOG_LEVEL``, else WARNING),
|
|
47
|
+
other libraries from WARNING. Never stdout, which carries fitatu-mcp's protocol."""
|
|
48
|
+
level = (level or os.environ.get("FITATU_LOG_LEVEL") or "").upper()
|
|
49
|
+
if level and level not in LOG_LEVELS:
|
|
50
|
+
raise SystemExit(f"FITATU_LOG_LEVEL must be one of {', '.join(LOG_LEVELS)}, got {level!r}")
|
|
51
|
+
logging.basicConfig(stream=sys.stderr, level=logging.WARNING, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
52
|
+
if level:
|
|
53
|
+
logging.getLogger("fitatu_api").setLevel(level)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tool_names(text: str) -> list[str]:
|
|
57
|
+
names = [name.strip() for name in text.split(",") if name.strip()]
|
|
58
|
+
try:
|
|
59
|
+
_selected(names)
|
|
60
|
+
except FitatuValidationError as exc:
|
|
61
|
+
raise argparse.ArgumentTypeError(str(exc)) from None
|
|
62
|
+
return names
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _seconds(text: str) -> float:
|
|
66
|
+
seconds = float(text)
|
|
67
|
+
if seconds < 0:
|
|
68
|
+
raise argparse.ArgumentTypeError(f"must be 0 or more, got {text}")
|
|
69
|
+
return seconds
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def add_mcp_options(parser: argparse.ArgumentParser) -> None:
|
|
73
|
+
"""The MCP server's options, which fitatu-mcp and ``fitatu-api mcp`` both take."""
|
|
74
|
+
parser.add_argument("--read-only", action="store_true", help="serve only the tools that read the diary")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--demo",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="serve a small made-up diary kept in memory instead of an account: no session file, nothing sent to Fitatu",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--tools",
|
|
82
|
+
type=_tool_names,
|
|
83
|
+
metavar="NAME,...",
|
|
84
|
+
help="serve only these tools, e.g. get_today,get_day,search_food,add_food (`fitatu-api tools` lists them); "
|
|
85
|
+
"with --read-only, only the read ones of them",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--call-timeout",
|
|
89
|
+
type=_seconds,
|
|
90
|
+
default=45,
|
|
91
|
+
metavar="SECONDS",
|
|
92
|
+
help="end every tool call within this many seconds from when the host sent it, retries and waits "
|
|
93
|
+
"included, so that a host which gives up after 60 s still gets an answer; 0 for no limit (default: 45)",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _save(client: FitatuApiClient, output: str | None, *, to_stdout: bool = False) -> int:
|
|
98
|
+
session = client.auth.to_session_data(include_tokens=True)
|
|
99
|
+
if to_stdout:
|
|
100
|
+
print(json.dumps(session, indent=2))
|
|
101
|
+
print(
|
|
102
|
+
"This is a full account credential: put it in a secret store (e.g. as FITATU_SESSION_JSON), "
|
|
103
|
+
"not in a shared file, a log or shell history.",
|
|
104
|
+
file=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
return 0
|
|
107
|
+
path = resolve_session_path(output)
|
|
108
|
+
write_private_json(path, session)
|
|
109
|
+
print(f"Saved session for user {session.get('fitatu_user_id')} to {path}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _login(args: argparse.Namespace) -> int:
|
|
114
|
+
email = args.email or os.environ.get("FITATU_EMAIL")
|
|
115
|
+
if not email:
|
|
116
|
+
# On stderr, as getpass does: with --print, stdout must carry nothing but the session JSON.
|
|
117
|
+
print("Fitatu e-mail: ", end="", file=sys.stderr, flush=True)
|
|
118
|
+
email = input().strip()
|
|
119
|
+
password = os.environ.get("FITATU_PASSWORD") or getpass.getpass("Fitatu password: ")
|
|
120
|
+
if not email or not password:
|
|
121
|
+
print("E-mail and password are required.", file=sys.stderr)
|
|
122
|
+
return 2
|
|
123
|
+
try:
|
|
124
|
+
client = FitatuApiClient.login(email, password, base_url=args.base_url, persist_tokens=False)
|
|
125
|
+
except FitatuApiError as exc:
|
|
126
|
+
print(f"Login failed ({exc.status_code}): {exc}", file=sys.stderr)
|
|
127
|
+
if isinstance(exc, FitatuAuthError) and exc.hint:
|
|
128
|
+
print(exc.hint, file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
return _save(client, args.output, to_stdout=args.print)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _import_session(args: argparse.Namespace) -> int:
|
|
134
|
+
if sys.stdin.isatty():
|
|
135
|
+
print(
|
|
136
|
+
"Paste the JSON the browser snippet copied, then press Enter and Ctrl-D "
|
|
137
|
+
"(Windows: Enter, Ctrl-Z, Enter). Steps: fitatu-api import-session --help",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(sys.stdin.read())
|
|
142
|
+
except (ValueError, RecursionError): # RecursionError: nested too deep
|
|
143
|
+
print("Expected JSON on stdin: the object the browser snippet copied.", file=sys.stderr)
|
|
144
|
+
return 2
|
|
145
|
+
auth = FitatuAuthContext.from_session_data(data) if isinstance(data, dict) else FitatuAuthContext()
|
|
146
|
+
if not (isinstance(auth.bearer_token, str) and isinstance(auth.refresh_token, str) and auth.fitatu_user_id):
|
|
147
|
+
print("The JSON needs token and refresh_token as text, and id (or bearer_token, refresh_token, fitatu_user_id).", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
try:
|
|
150
|
+
client = FitatuApiClient(auth, base_url=args.base_url, persist_tokens=False)
|
|
151
|
+
# While the bearer works, only a refresh shows a dead refresh token. Safe: Fitatu does not
|
|
152
|
+
# rotate refresh tokens and older bearers stay valid; the saved bearer gets a full hour.
|
|
153
|
+
client.refresh_access_token()
|
|
154
|
+
client._read_profile(auth.fitatu_user_id, pick_host=args.base_url is None)
|
|
155
|
+
except FitatuApiError as exc:
|
|
156
|
+
advice = "; copy a fresh session from the web app" if isinstance(exc, FitatuAuthError) else ""
|
|
157
|
+
print(f"Import failed: {exc}{advice}", file=sys.stderr)
|
|
158
|
+
return 1
|
|
159
|
+
return _save(client, args.output, to_stdout=args.print)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _tools(args: argparse.Namespace) -> int:
|
|
163
|
+
if args.json:
|
|
164
|
+
print(json.dumps(anthropic_tools(), indent=2))
|
|
165
|
+
return 0
|
|
166
|
+
width = max(len(tool["name"]) for tool in TOOLS)
|
|
167
|
+
for tool in TOOLS:
|
|
168
|
+
hints = tool["annotations"]
|
|
169
|
+
kind = "read" if hints["readOnlyHint"] else "destructive" if hints["destructiveHint"] else "write"
|
|
170
|
+
first_sentence = re.split(r"(?<!e\.g)\.(?:\s|$)", tool["description"], maxsplit=1)[0]
|
|
171
|
+
print(f"{tool['name']:<{width}} {kind:<11} {textwrap.shorten(first_sentence, 100, placeholder='...')}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _json_arguments(text: str) -> Any:
|
|
176
|
+
try:
|
|
177
|
+
return json.loads(text)
|
|
178
|
+
except (ValueError, RecursionError) as exc: # RecursionError: nested too deep
|
|
179
|
+
raise argparse.ArgumentTypeError(f"not JSON: {exc}") from None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _call(args: argparse.Namespace) -> int:
|
|
183
|
+
try:
|
|
184
|
+
tools = FitatuTools.from_session(tools=None if args.allow_writes else READ_ONLY_TOOLS)
|
|
185
|
+
except FitatuApiError as exc: # e.g. no session file yet
|
|
186
|
+
result = {"ok": False, "error": describe_error(exc)}
|
|
187
|
+
else:
|
|
188
|
+
with tools:
|
|
189
|
+
result = tools.call(args.name, args.arguments)
|
|
190
|
+
print(json.dumps(result, indent=2))
|
|
191
|
+
# call() reports a write tool it may not run as unknown, as it does to a model; a person gets the flag.
|
|
192
|
+
if not args.allow_writes and args.name not in READ_ONLY_TOOLS and any(t["name"] == args.name for t in TOOLS):
|
|
193
|
+
print(f"{args.name} changes the diary: run it with --allow-writes.", file=sys.stderr)
|
|
194
|
+
return 0 if result["ok"] else 1
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _export(args: argparse.Namespace) -> int:
|
|
198
|
+
if os.path.lexists(args.output) and not args.overwrite: # checked again when the file is written
|
|
199
|
+
print(f"{args.output} exists; add --overwrite to replace it.", file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
# The file is written next to its target and swapped in, so neither can be found out after the download.
|
|
202
|
+
if os.path.isdir(args.output) or not os.path.isdir(os.path.dirname(args.output) or "."):
|
|
203
|
+
print(f"{args.output} is a directory or its directory does not exist; give a file path.", file=sys.stderr)
|
|
204
|
+
return 2
|
|
205
|
+
try:
|
|
206
|
+
with FitatuApiClient.from_session() as client:
|
|
207
|
+
user_id = client.auth.fitatu_user_id
|
|
208
|
+
if not user_id:
|
|
209
|
+
raise FitatuAuthError("the session has no Fitatu user id; log in again to get a complete session")
|
|
210
|
+
content = client.summary.export_diary(user_id, args.from_date, args.to_date, fmt=args.format)
|
|
211
|
+
if content is not None:
|
|
212
|
+
write_private_file(args.output, content, overwrite=args.overwrite)
|
|
213
|
+
except (FitatuApiError, OSError) as exc: # e.g. no session file yet, or the file appeared meanwhile
|
|
214
|
+
error = describe_error(exc)
|
|
215
|
+
print(f"Export failed: {error['message']}", file=sys.stderr)
|
|
216
|
+
if error["hint"]:
|
|
217
|
+
print(error["hint"], file=sys.stderr)
|
|
218
|
+
return 1
|
|
219
|
+
if content is None:
|
|
220
|
+
print(f"Nothing is logged from {args.from_date} to {args.to_date}; no file was written.", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
print(f"Saved the {args.format} export ({len(content)} bytes) to {args.output}")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _mcp(args: argparse.Namespace) -> int:
|
|
227
|
+
try:
|
|
228
|
+
from .mcp_server import serve
|
|
229
|
+
except ImportError as exc: # the mcp extra is not installed
|
|
230
|
+
print(exc, file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
serve(args)
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _shown(text: object) -> str:
|
|
237
|
+
"""``text`` with the home directory as ``~``, so output pasted into an issue has no user name."""
|
|
238
|
+
return str(text).replace(str(Path.home()), "~")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _session_file(client: FitatuApiClient) -> tuple[str, bool]:
|
|
242
|
+
"""Where the session came from, and whether the file is private (always, off POSIX)."""
|
|
243
|
+
if client.token_store is None:
|
|
244
|
+
return "$FITATU_SESSION_JSON (refreshed tokens are not saved)", True
|
|
245
|
+
path = client.token_store.path
|
|
246
|
+
if os.name == "nt": # no POSIX permissions to show
|
|
247
|
+
return _shown(path), True
|
|
248
|
+
mode = path.stat().st_mode & 0o777
|
|
249
|
+
if mode & 0o077:
|
|
250
|
+
return f"{_shown(path)} ({mode:04o}: not private, run chmod 600 {_shown(path)})", False
|
|
251
|
+
return f"{_shown(path)} ({mode:04o})", True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _bearer_expiry(auth: FitatuAuthContext) -> str:
|
|
255
|
+
if not auth.bearer_token:
|
|
256
|
+
return "missing"
|
|
257
|
+
expires = auth.bearer_expires_at
|
|
258
|
+
if expires is None:
|
|
259
|
+
return "no expiry in the token"
|
|
260
|
+
minutes = round((expires - datetime.now(UTC)).total_seconds() / 60)
|
|
261
|
+
if minutes <= 0:
|
|
262
|
+
return f"expired at {expires:%Y-%m-%d %H:%M} UTC; the next call refreshes it"
|
|
263
|
+
return f"valid until {expires:%Y-%m-%d %H:%M} UTC ({minutes} min)"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _status(args: argparse.Namespace) -> int:
|
|
267
|
+
try:
|
|
268
|
+
client = FitatuApiClient.from_session()
|
|
269
|
+
except FitatuApiError as exc:
|
|
270
|
+
print(_shown(exc), file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
auth, code = client.auth, 0
|
|
273
|
+
rows = [
|
|
274
|
+
("session", _session_file(client)[0]),
|
|
275
|
+
("user id", auth.fitatu_user_id or "missing: log in again"),
|
|
276
|
+
("API host", client.base_url),
|
|
277
|
+
("locales", f"{auth.app_locale}, search {auth.app_search_locale}, storage {auth.app_storage_locale}"),
|
|
278
|
+
("time zone", auth.app_timezone),
|
|
279
|
+
("bearer token", _bearer_expiry(auth)),
|
|
280
|
+
("refresh token", "present" if auth.refresh_token else "missing: log in again"),
|
|
281
|
+
("client", f"{auth.app_os} {auth.app_version}"),
|
|
282
|
+
]
|
|
283
|
+
if args.check:
|
|
284
|
+
with client:
|
|
285
|
+
try:
|
|
286
|
+
client.user_settings.get_profile(auth.fitatu_user_id or "")
|
|
287
|
+
rows.append(("Fitatu", "the session works (profile read)"))
|
|
288
|
+
except FitatuApiError as exc:
|
|
289
|
+
rows.append(("Fitatu", _explained(exc)))
|
|
290
|
+
code = 1
|
|
291
|
+
for label, value in rows:
|
|
292
|
+
print(f"{label + ':':<15}{value}")
|
|
293
|
+
return code
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _logout(args: argparse.Namespace) -> int:
|
|
297
|
+
if os.environ.get("FITATU_SESSION_JSON"):
|
|
298
|
+
print("The session comes from $FITATU_SESSION_JSON: unset it, and delete it from your secret store.", file=sys.stderr)
|
|
299
|
+
return 1
|
|
300
|
+
path = resolve_session_path()
|
|
301
|
+
if not os.path.lexists(path):
|
|
302
|
+
print(f"No session file at {path}.")
|
|
303
|
+
return 0
|
|
304
|
+
if not args.yes:
|
|
305
|
+
try:
|
|
306
|
+
answer = input(f"Delete {path}? [y/N] ")
|
|
307
|
+
except EOFError: # no terminal to ask on
|
|
308
|
+
answer = ""
|
|
309
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
310
|
+
print("Kept it; --yes deletes without asking.", file=sys.stderr)
|
|
311
|
+
return 1
|
|
312
|
+
path.unlink(missing_ok=True)
|
|
313
|
+
print(f"Deleted {path}. The library knows no call that revokes the tokens at Fitatu, so a copy kept elsewhere still works.")
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _explained(exc: FitatuApiError) -> str:
|
|
318
|
+
error = describe_error(exc)
|
|
319
|
+
return f"{error['error_type']}: {error['message']}" + (f"; {error['hint']}" if error["hint"] else "")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _doctor(args: argparse.Namespace) -> int:
|
|
323
|
+
if not (args.log_level or os.environ.get("FITATU_LOG_LEVEL")):
|
|
324
|
+
# Every failure gets a line below with the user id masked; the library's warnings would quote it.
|
|
325
|
+
logging.getLogger("fitatu_api").setLevel(logging.ERROR)
|
|
326
|
+
print(f"fitatu-api {__version__}, Python {platform.python_version()}, {platform.system()}")
|
|
327
|
+
failed, user_id = False, None
|
|
328
|
+
|
|
329
|
+
def report(level: str, check: str, text: str) -> None:
|
|
330
|
+
nonlocal failed
|
|
331
|
+
failed = failed or level == "FAIL"
|
|
332
|
+
line = _shown(text).replace(f"/{user_id}", "/<user id>") if user_id else _shown(text) # e.g. GET /users/{id} -> 401
|
|
333
|
+
print(f"{level:<5} {check + ':':<12}{line}")
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
client = FitatuApiClient.from_session()
|
|
337
|
+
except FitatuApiError as exc:
|
|
338
|
+
report("FAIL", "session", _explained(exc))
|
|
339
|
+
return 1
|
|
340
|
+
with client:
|
|
341
|
+
auth, user_id = client.auth, client.auth.fitatu_user_id
|
|
342
|
+
session, private = _session_file(client)
|
|
343
|
+
report("ok" if private else "WARN", "session", f"{session}, host {client.base_url}")
|
|
344
|
+
if not user_id:
|
|
345
|
+
report("FAIL", "user id", "the session has none; log in again")
|
|
346
|
+
# A refresh checks the refresh token, which outlives the bearer, and gives a token whose iat is Fitatu's now.
|
|
347
|
+
if not auth.refresh_token:
|
|
348
|
+
report("FAIL", "refresh", "the session has no refresh token; log in again")
|
|
349
|
+
else:
|
|
350
|
+
try:
|
|
351
|
+
client.refresh_access_token()
|
|
352
|
+
except FitatuApiError as exc:
|
|
353
|
+
report("FAIL", "refresh", f"failed: {_explained(exc)}")
|
|
354
|
+
else:
|
|
355
|
+
report("ok", "refresh", "the refresh token works")
|
|
356
|
+
issued = jwt_payload(auth.bearer_token).get("iat")
|
|
357
|
+
if isinstance(issued, int | float) and not isinstance(issued, bool):
|
|
358
|
+
skew = round(datetime.now(UTC).timestamp() - issued)
|
|
359
|
+
if abs(skew) > 60:
|
|
360
|
+
direction = "ahead of" if skew > 0 else "behind"
|
|
361
|
+
report("WARN", "clock", f"{abs(skew)} s {direction} Fitatu's; turn on automatic time")
|
|
362
|
+
else:
|
|
363
|
+
report("ok", "clock", "within a minute of Fitatu's")
|
|
364
|
+
if user_id:
|
|
365
|
+
try:
|
|
366
|
+
client.user_settings.get_profile(user_id)
|
|
367
|
+
report("ok", "profile", "read")
|
|
368
|
+
except FitatuApiError as exc:
|
|
369
|
+
report("FAIL", "profile", f"not read: {_explained(exc)}")
|
|
370
|
+
try:
|
|
371
|
+
plan = client.user_settings.get_premium(user_id)
|
|
372
|
+
except FitatuApiError as exc:
|
|
373
|
+
report("WARN", "plan", f"not read: {_explained(exc)}")
|
|
374
|
+
else: # whether, not until when: the output goes into public issues
|
|
375
|
+
report("ok", "plan", "premium" if plan["active"] else "no premium plan: premium-only features answer 403")
|
|
376
|
+
version = f"{auth.app_os} {auth.app_version}"
|
|
377
|
+
try:
|
|
378
|
+
# The catalog lists it under the app's unauthorized client, but the server wants the token (checked live).
|
|
379
|
+
reply = as_dict(client.request("GET", f"/upgrade-application/{auth.app_os}/{auth.app_version}")) or {}
|
|
380
|
+
except FitatuApiError as exc:
|
|
381
|
+
report("WARN", "client", f"{version}, not checked: {_explained(exc)}")
|
|
382
|
+
else:
|
|
383
|
+
newest = reply.get("newestApplicationVersion")
|
|
384
|
+
if newest == auth.app_version and not reply.get("upgradeApplication"):
|
|
385
|
+
report("ok", "client", f"{version} is the newest web app version")
|
|
386
|
+
else:
|
|
387
|
+
report(
|
|
388
|
+
"WARN", "client", f"Fitatu's web app is at {newest or 'an unknown version'}, this library sends {version}; "
|
|
389
|
+
"if calls fail, update fitatu-api or set FITATU_CLIENT_PROFILE (see the README)",
|
|
390
|
+
)
|
|
391
|
+
return 1 if failed else 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _add_output_options(command: argparse.ArgumentParser) -> None:
|
|
395
|
+
target = command.add_mutually_exclusive_group()
|
|
396
|
+
target.add_argument(
|
|
397
|
+
"-o",
|
|
398
|
+
"--output",
|
|
399
|
+
help="where to write the session file (default: $FITATU_SESSION, $FITATU_SESSION_PATH "
|
|
400
|
+
"or ~/.fitatu/session.json)",
|
|
401
|
+
)
|
|
402
|
+
target.add_argument(
|
|
403
|
+
"--print",
|
|
404
|
+
action="store_true",
|
|
405
|
+
help="print the session JSON to stdout instead of writing a file, e.g. for a secret store "
|
|
406
|
+
"that sets FITATU_SESSION_JSON; it is a full account credential",
|
|
407
|
+
)
|
|
408
|
+
command.add_argument(
|
|
409
|
+
"--base-url",
|
|
410
|
+
help="API host; by default the one for your account's region, "
|
|
411
|
+
"e.g. https://de-de.fitatu.com/api for de_DE",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
416
|
+
parser = argparse.ArgumentParser(prog="fitatu-api", description="Helpers for the fitatu-api library.")
|
|
417
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
418
|
+
parser.add_argument(
|
|
419
|
+
"--log-level",
|
|
420
|
+
type=str.upper,
|
|
421
|
+
choices=LOG_LEVELS,
|
|
422
|
+
help="log the library from this level to stderr (default: $FITATU_LOG_LEVEL, else WARNING); before the command",
|
|
423
|
+
)
|
|
424
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
425
|
+
|
|
426
|
+
login = commands.add_parser(
|
|
427
|
+
"login",
|
|
428
|
+
help="log in with e-mail and password and save the session file",
|
|
429
|
+
description=(
|
|
430
|
+
"Log in and save a session file (bearer token, refresh token, user id, locales "
|
|
431
|
+
"and API host). Reads FITATU_EMAIL / FITATU_PASSWORD when set, otherwise prompts. "
|
|
432
|
+
"Accounts created with Apple or Facebook have no password; for them, see "
|
|
433
|
+
"`fitatu-api import-session --help`."
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
login.add_argument("--email", help="account e-mail (prompted when omitted)")
|
|
437
|
+
_add_output_options(login)
|
|
438
|
+
login.set_defaults(run=_login)
|
|
439
|
+
|
|
440
|
+
import_session = commands.add_parser(
|
|
441
|
+
"import-session",
|
|
442
|
+
help="save the session file from tokens copied out of the web app (no password needed)",
|
|
443
|
+
# Raw, so the snippet stays on one line: a line break after "u" breaks the arrow function.
|
|
444
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
445
|
+
description=(
|
|
446
|
+
"For accounts without a password (Apple or Facebook sign-in). Log in at\n"
|
|
447
|
+
"https://www.fitatu.com/app/, open the browser's developer console there and run:\n\n"
|
|
448
|
+
" copy(JSON.stringify((u => ({token: u.token, refresh_token: u.refresh_token, id: u.id}))"
|
|
449
|
+
"(JSON.parse(localStorage.user))))\n\n"
|
|
450
|
+
"It copies only the tokens and user id to the clipboard (localStorage.user also holds\n"
|
|
451
|
+
"your e-mail, nickname and more). Then, on macOS:\n\n"
|
|
452
|
+
" pbpaste | fitatu-api import-session\n\n"
|
|
453
|
+
"Elsewhere paste it into a file, run `fitatu-api import-session < FILE` (PowerShell:\n"
|
|
454
|
+
"`Get-Content FILE | fitatu-api import-session`) and delete the file. The token is a\n"
|
|
455
|
+
"full account credential: do not share it.\n\n"
|
|
456
|
+
"Reads the JSON from stdin (the web app's localStorage.user, or a session file),\n"
|
|
457
|
+
"checks both tokens with Fitatu by refreshing the bearer once, reads the account's\n"
|
|
458
|
+
"locales and timezone like login does, and saves only the tokens, user id, locales,\n"
|
|
459
|
+
"timezone and API host."
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
_add_output_options(import_session)
|
|
463
|
+
import_session.set_defaults(run=_import_session)
|
|
464
|
+
|
|
465
|
+
tools = commands.add_parser(
|
|
466
|
+
"tools",
|
|
467
|
+
help="list the agent tools: name, read / write / destructive, what it does",
|
|
468
|
+
description="List the tools of fitatu_api.agent (FitatuTools) with their kind: read, write, or "
|
|
469
|
+
"destructive (overwrites or deletes what is logged), and the first sentence of the description "
|
|
470
|
+
"the model gets.",
|
|
471
|
+
)
|
|
472
|
+
tools.add_argument("--json", action="store_true", help="print the tools in the Anthropic API format (anthropic_tools())")
|
|
473
|
+
tools.set_defaults(run=_tools)
|
|
474
|
+
|
|
475
|
+
call = commands.add_parser(
|
|
476
|
+
"call",
|
|
477
|
+
help="run one agent tool on the session file and print its result as JSON",
|
|
478
|
+
description="Run one tool through FitatuTools.call() and print the result envelope as JSON: "
|
|
479
|
+
'{"ok": true, "data": ...} or {"ok": false, "error": ...}. Only the read tools run unless '
|
|
480
|
+
"--allow-writes is given. The session file is $FITATU_SESSION (or $FITATU_SESSION_PATH), else "
|
|
481
|
+
"~/.fitatu/session.json. Exit status: 0 when ok is true, 1 when it is false.",
|
|
482
|
+
epilog="""example: fitatu-api call get_day '{"date": "2026-10-14"}'""",
|
|
483
|
+
)
|
|
484
|
+
call.add_argument("name", help="tool name, as `fitatu-api tools` lists it")
|
|
485
|
+
call.add_argument(
|
|
486
|
+
"arguments", nargs="?", default="{}", type=_json_arguments, metavar="JSON", help="the tool's arguments as a JSON object (default: {})"
|
|
487
|
+
)
|
|
488
|
+
call.add_argument("--allow-writes", action="store_true", help="also run the tools that change the diary")
|
|
489
|
+
call.set_defaults(run=_call)
|
|
490
|
+
|
|
491
|
+
export = commands.add_parser(
|
|
492
|
+
"export",
|
|
493
|
+
help="save the food diary of a date range as csv, xls, pdf or txt",
|
|
494
|
+
description="Download Fitatu's export of the food diary for a date range and save it to a new file "
|
|
495
|
+
"readable only by you (0600). An existing file is kept unless --overwrite is given; a range with "
|
|
496
|
+
"nothing logged writes no file and exits with 1. The session file is $FITATU_SESSION (or "
|
|
497
|
+
"$FITATU_SESSION_PATH), else ~/.fitatu/session.json.",
|
|
498
|
+
)
|
|
499
|
+
export.add_argument("--from", dest="from_date", required=True, metavar="DATE", help="first day, YYYY-MM-DD")
|
|
500
|
+
export.add_argument("--to", dest="to_date", required=True, metavar="DATE", help="last day, YYYY-MM-DD")
|
|
501
|
+
export.add_argument("--format", choices=SummaryModule.EXPORT_FORMATS, default="csv", help="file format (default: csv)")
|
|
502
|
+
export.add_argument("-o", "--output", required=True, metavar="PATH", help="file to write")
|
|
503
|
+
export.add_argument("--overwrite", action="store_true", help="replace the file if it exists")
|
|
504
|
+
export.set_defaults(run=_export)
|
|
505
|
+
|
|
506
|
+
status = commands.add_parser(
|
|
507
|
+
"status",
|
|
508
|
+
help="describe the session file without sending anything; never prints a token",
|
|
509
|
+
description="Show where the session comes from (the file and who can read it, or $FITATU_SESSION_JSON), "
|
|
510
|
+
"the user id, API host, locales and time zone, when the bearer token expires, whether there is a "
|
|
511
|
+
"refresh token, and the client version sent. Sends nothing unless --check. Exit status: 1 when there "
|
|
512
|
+
"is no session or the check fails.",
|
|
513
|
+
)
|
|
514
|
+
status.add_argument("--check", action="store_true", help="also read the profile once, to see that Fitatu takes the session")
|
|
515
|
+
status.set_defaults(run=_status)
|
|
516
|
+
|
|
517
|
+
logout = commands.add_parser(
|
|
518
|
+
"logout",
|
|
519
|
+
help="delete the session file",
|
|
520
|
+
description="Delete the session file ($FITATU_SESSION, $FITATU_SESSION_PATH or ~/.fitatu/session.json) "
|
|
521
|
+
"after asking. Stop fitatu-mcp and other processes that use it first: they keep their tokens and "
|
|
522
|
+
"write refreshed ones back. The library knows no call that revokes tokens at Fitatu.",
|
|
523
|
+
)
|
|
524
|
+
logout.add_argument("-y", "--yes", action="store_true", help="delete without asking")
|
|
525
|
+
logout.set_defaults(run=_logout)
|
|
526
|
+
|
|
527
|
+
doctor = commands.add_parser(
|
|
528
|
+
"doctor",
|
|
529
|
+
help="check the session, the clock and the client version with Fitatu",
|
|
530
|
+
description="Check the setup and print one line per check (ok, WARN, FAIL): the session file and its "
|
|
531
|
+
"permissions, the refresh token (by refreshing the bearer token once, saved like any refresh), this "
|
|
532
|
+
"computer's clock against the new token's issue time, a profile read, whether a premium plan runs, and "
|
|
533
|
+
"whether Fitatu's web app is still at the version this library sends. Prints no token, user id or "
|
|
534
|
+
"personal data, so the output can go into a bug report; the library's own log lines only with "
|
|
535
|
+
"--log-level. Exit status: 1 when a check failed.",
|
|
536
|
+
)
|
|
537
|
+
doctor.set_defaults(run=_doctor)
|
|
538
|
+
|
|
539
|
+
mcp = commands.add_parser(
|
|
540
|
+
"mcp",
|
|
541
|
+
help="serve the agent tools to an MCP host over stdio, like fitatu-mcp",
|
|
542
|
+
description="The same server as fitatu-mcp, for hosts that start the package by its name "
|
|
543
|
+
'(e.g. uvx with an MCP Registry entry). Needs the mcp extra: pip install "fitatu-api[mcp]".',
|
|
544
|
+
)
|
|
545
|
+
add_mcp_options(mcp)
|
|
546
|
+
mcp.set_defaults(run=_mcp)
|
|
547
|
+
|
|
548
|
+
args = parser.parse_args(argv)
|
|
549
|
+
setup_logging(args.log_level)
|
|
550
|
+
return args.run(args)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
raise SystemExit(main())
|