cronopy 0.1.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.
- cronopy/__init__.py +29 -0
- cronopy/cli.py +174 -0
- cronopy/client.py +285 -0
- cronopy/session.py +60 -0
- cronopy-0.1.0.dist-info/METADATA +73 -0
- cronopy-0.1.0.dist-info/RECORD +8 -0
- cronopy-0.1.0.dist-info/WHEEL +4 -0
- cronopy-0.1.0.dist-info/entry_points.txt +3 -0
cronopy/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Unofficial cli for cronometer."""
|
|
2
|
+
|
|
3
|
+
from cronopy.client import (
|
|
4
|
+
CronometerClient,
|
|
5
|
+
CronometerError,
|
|
6
|
+
LoginError,
|
|
7
|
+
NotAuthenticatedError,
|
|
8
|
+
Source,
|
|
9
|
+
)
|
|
10
|
+
from cronopy.session import (
|
|
11
|
+
Session,
|
|
12
|
+
default_session_path,
|
|
13
|
+
delete_session,
|
|
14
|
+
load_session,
|
|
15
|
+
save_session,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CronometerClient",
|
|
20
|
+
"CronometerError",
|
|
21
|
+
"LoginError",
|
|
22
|
+
"NotAuthenticatedError",
|
|
23
|
+
"Session",
|
|
24
|
+
"Source",
|
|
25
|
+
"default_session_path",
|
|
26
|
+
"delete_session",
|
|
27
|
+
"load_session",
|
|
28
|
+
"save_session",
|
|
29
|
+
]
|
cronopy/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Unofficial command line interface for Cronometer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.logging import RichHandler
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from cronopy.client import CronometerClient, CronometerError, NotAuthenticatedError, Source
|
|
15
|
+
from cronopy.session import default_session_path, delete_session, load_session, save_session
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="Unofficial cli for cronometer.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
console = Console()
|
|
23
|
+
err_console = Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def _root(
|
|
28
|
+
debug: Annotated[
|
|
29
|
+
bool,
|
|
30
|
+
typer.Option(
|
|
31
|
+
"--debug",
|
|
32
|
+
"-d",
|
|
33
|
+
help="Log every HTTP request/response and internal step.",
|
|
34
|
+
envvar="CRONOPY_DEBUG",
|
|
35
|
+
),
|
|
36
|
+
] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not debug:
|
|
39
|
+
return
|
|
40
|
+
logging.basicConfig(
|
|
41
|
+
level=logging.DEBUG,
|
|
42
|
+
format="%(name)s: %(message)s",
|
|
43
|
+
datefmt="[%X]",
|
|
44
|
+
handlers=[
|
|
45
|
+
RichHandler(console=err_console, rich_tracebacks=True, show_path=False, markup=False)
|
|
46
|
+
],
|
|
47
|
+
force=True,
|
|
48
|
+
)
|
|
49
|
+
for name in ("httpx", "cronopy"):
|
|
50
|
+
logging.getLogger(name).setLevel(logging.DEBUG)
|
|
51
|
+
logging.getLogger("httpcore").setLevel(logging.INFO) # raw socket trace is too noisy
|
|
52
|
+
err_console.print("[dim]debug logging enabled[/dim]")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fail(message: str, code: int = 1) -> None:
|
|
56
|
+
err_console.print(f"[red]Error:[/red] {message}")
|
|
57
|
+
raise typer.Exit(code)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _client_from_disk() -> CronometerClient:
|
|
61
|
+
session = load_session()
|
|
62
|
+
if session is None:
|
|
63
|
+
_fail(f"Not logged in (no session at {default_session_path()}). Run `crono login`.")
|
|
64
|
+
return CronometerClient(session)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def login(
|
|
69
|
+
email: Annotated[
|
|
70
|
+
str,
|
|
71
|
+
typer.Option("--email", "-e", prompt=True, help="Cronometer account email."),
|
|
72
|
+
],
|
|
73
|
+
password: Annotated[
|
|
74
|
+
str,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--password",
|
|
77
|
+
"-p",
|
|
78
|
+
prompt=True,
|
|
79
|
+
hide_input=True,
|
|
80
|
+
help="Account password (prompted if omitted).",
|
|
81
|
+
),
|
|
82
|
+
],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Log in and store the session for later commands."""
|
|
85
|
+
try:
|
|
86
|
+
with CronometerClient(email=email, password=password) as client:
|
|
87
|
+
session = client.login()
|
|
88
|
+
except CronometerError as exc:
|
|
89
|
+
_fail(str(exc))
|
|
90
|
+
path = save_session(session)
|
|
91
|
+
console.print(f"[green]Logged in[/green] as {session.email} (user id {session.user_id}).")
|
|
92
|
+
console.print(f"Session saved to [dim]{path}[/dim]")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def logout() -> None:
|
|
97
|
+
"""Invalidate the remote session and remove the stored one."""
|
|
98
|
+
session = load_session()
|
|
99
|
+
if session is None:
|
|
100
|
+
console.print("Not logged in; nothing to do.")
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
with CronometerClient(session) as client:
|
|
104
|
+
client.logout()
|
|
105
|
+
except CronometerError as exc:
|
|
106
|
+
err_console.print(f"[yellow]Warning:[/yellow] remote logout failed: {exc}")
|
|
107
|
+
delete_session()
|
|
108
|
+
console.print("[green]Logged out.[/green] Stored session removed.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
def whoami() -> None:
|
|
113
|
+
"""Show the currently stored session."""
|
|
114
|
+
session = load_session()
|
|
115
|
+
if session is None:
|
|
116
|
+
console.print("Not logged in.")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
console.print(f"{session.email or '<unknown>'} (user id {session.user_id})")
|
|
119
|
+
console.print(f"[dim]{default_session_path()}[/dim]")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def search(
|
|
124
|
+
query: Annotated[str, typer.Argument(help="Food name to search for.")],
|
|
125
|
+
limit: Annotated[int, typer.Option("--limit", "-n", min=1, max=200, help="Max results.")] = 25,
|
|
126
|
+
sources: Annotated[
|
|
127
|
+
Source, typer.Option("--sources", "-s", help="Cronometer source filter.")
|
|
128
|
+
] = Source.ALL,
|
|
129
|
+
as_json: Annotated[
|
|
130
|
+
bool, typer.Option("--json", help="Print raw JSON instead of a table.")
|
|
131
|
+
] = False,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Search foods, recipes and meals."""
|
|
134
|
+
try:
|
|
135
|
+
with _client_from_disk() as client:
|
|
136
|
+
results = client.search(query, max_results=limit, sources=sources)
|
|
137
|
+
if (updated := client.refresh_session()) is not None:
|
|
138
|
+
save_session(updated)
|
|
139
|
+
except NotAuthenticatedError as exc:
|
|
140
|
+
_fail(str(exc))
|
|
141
|
+
except CronometerError as exc:
|
|
142
|
+
_fail(str(exc))
|
|
143
|
+
|
|
144
|
+
if as_json:
|
|
145
|
+
console.print_json(json.dumps(results))
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if not results:
|
|
149
|
+
console.print(f"No results for [bold]{query}[/bold].")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
table = Table(title=f"Results for “{query}”", show_lines=False)
|
|
153
|
+
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
|
|
154
|
+
table.add_column("Name")
|
|
155
|
+
table.add_column("Type", style="magenta")
|
|
156
|
+
table.add_column("Source", style="dim")
|
|
157
|
+
table.add_column("Measure", style="dim")
|
|
158
|
+
for item in results:
|
|
159
|
+
table.add_row(
|
|
160
|
+
str(item.get("id", "")),
|
|
161
|
+
item.get("displayString") or item.get("name", ""),
|
|
162
|
+
str(item.get("type", "")),
|
|
163
|
+
str(item.get("source", "")),
|
|
164
|
+
str(item.get("measureDisplayName", "")),
|
|
165
|
+
)
|
|
166
|
+
console.print(table)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
app()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
cronopy/client.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Minimal unofficial client for the Cronometer web API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from cronopy.session import Session
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://cronometer.com"
|
|
15
|
+
GWT_MODULE_BASE = f"{BASE_URL}/cronometer/"
|
|
16
|
+
GWT_SERVICE = "com.cronometer.shared.rpc.CronometerService"
|
|
17
|
+
log = logging.getLogger("cronopy.client")
|
|
18
|
+
|
|
19
|
+
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:155.0) Gecko/20100101 Firefox/155.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Source(StrEnum):
|
|
23
|
+
"""Food source filter accepted by the search endpoint."""
|
|
24
|
+
|
|
25
|
+
ALL = "All"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CronometerError(Exception):
|
|
29
|
+
"""Base error for the client."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoginError(CronometerError):
|
|
33
|
+
"""Raised when authentication fails."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotAuthenticatedError(CronometerError):
|
|
37
|
+
"""Raised when an action requires a session but none is available."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CronometerClient:
|
|
41
|
+
"""Client for the Cronometer web API.
|
|
42
|
+
|
|
43
|
+
Authenticate either with a previously saved ``session`` or with
|
|
44
|
+
``email``/``password``. With credentials, login happens lazily on the
|
|
45
|
+
first call that needs a session. An expired session raises
|
|
46
|
+
:class:`NotAuthenticatedError`; the caller decides whether to ``login()``
|
|
47
|
+
again.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
session: Session | None = None,
|
|
53
|
+
*,
|
|
54
|
+
email: str | None = None,
|
|
55
|
+
password: str | None = None,
|
|
56
|
+
timeout: float = 30.0,
|
|
57
|
+
) -> None:
|
|
58
|
+
if (email is None) != (password is None):
|
|
59
|
+
raise ValueError("email and password must be given together")
|
|
60
|
+
self._email = email
|
|
61
|
+
self._password = password
|
|
62
|
+
self._http = httpx.Client(
|
|
63
|
+
base_url=BASE_URL,
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
headers={"User-Agent": USER_AGENT},
|
|
66
|
+
follow_redirects=True,
|
|
67
|
+
event_hooks={"request": [self._log_request], "response": [self._log_response]},
|
|
68
|
+
)
|
|
69
|
+
self.session: Session | None = None
|
|
70
|
+
if session is not None:
|
|
71
|
+
self._restore(session)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _log_request(request: httpx.Request) -> None:
|
|
75
|
+
if not log.isEnabledFor(logging.DEBUG):
|
|
76
|
+
return
|
|
77
|
+
log.debug("--> %s %s", request.method, request.url)
|
|
78
|
+
for k, v in request.headers.items():
|
|
79
|
+
if k.lower() == "cookie":
|
|
80
|
+
v = f"<{len(v)} bytes>"
|
|
81
|
+
log.debug(" %s: %s", k, v)
|
|
82
|
+
if request.content:
|
|
83
|
+
body = request.content.decode("utf-8", "replace")
|
|
84
|
+
body = re.sub(r"(password=)[^&]*", r"\1***", body)
|
|
85
|
+
log.debug(" body: %s", body[:1000])
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _log_response(response: httpx.Response) -> None:
|
|
89
|
+
if not log.isEnabledFor(logging.DEBUG):
|
|
90
|
+
return
|
|
91
|
+
log.debug(
|
|
92
|
+
"<-- %s %s (%s)",
|
|
93
|
+
response.status_code,
|
|
94
|
+
response.url,
|
|
95
|
+
response.headers.get("content-type", ""),
|
|
96
|
+
)
|
|
97
|
+
for k, v in response.headers.multi_items():
|
|
98
|
+
if k.lower() == "set-cookie":
|
|
99
|
+
log.debug(" set-cookie: %s", v.split(";", 1)[0])
|
|
100
|
+
if not response.is_stream_consumed:
|
|
101
|
+
response.read()
|
|
102
|
+
text = response.text
|
|
103
|
+
if "javascript" in response.headers.get("content-type", ""):
|
|
104
|
+
log.debug(" body: <%d bytes of js>", len(text))
|
|
105
|
+
else:
|
|
106
|
+
log.debug(" body: %s", text[:1000])
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> CronometerClient:
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *exc: object) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
self._http.close()
|
|
116
|
+
|
|
117
|
+
def _restore(self, session: Session) -> None:
|
|
118
|
+
self.session = session
|
|
119
|
+
log.debug("restoring session user_id=%s cookies=%s", session.user_id, list(session.cookies))
|
|
120
|
+
for name, value in session.cookies.items():
|
|
121
|
+
self._http.cookies.set(name, value, domain="cronometer.com", path="/")
|
|
122
|
+
|
|
123
|
+
def _snapshot(self, email: str | None, user_id: int) -> Session:
|
|
124
|
+
return Session(
|
|
125
|
+
user_id=user_id,
|
|
126
|
+
email=email,
|
|
127
|
+
cookies={name: value for name, value in self._http.cookies.items()},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_authenticated(self) -> bool:
|
|
132
|
+
return self.session is not None
|
|
133
|
+
|
|
134
|
+
def refresh_session(self) -> Session | None:
|
|
135
|
+
"""Sync the live cookie jar back into ``self.session``.
|
|
136
|
+
|
|
137
|
+
Returns the updated session if any cookie changed (e.g. the AWS ALB
|
|
138
|
+
stickiness cookie is re-issued on every response), otherwise ``None``.
|
|
139
|
+
"""
|
|
140
|
+
if self.session is None:
|
|
141
|
+
return None
|
|
142
|
+
current = {name: value for name, value in self._http.cookies.items()}
|
|
143
|
+
if current == self.session.cookies:
|
|
144
|
+
return None
|
|
145
|
+
changed = sorted(
|
|
146
|
+
k
|
|
147
|
+
for k in set(current) | set(self.session.cookies)
|
|
148
|
+
if current.get(k) != self.session.cookies.get(k)
|
|
149
|
+
)
|
|
150
|
+
log.debug("session cookies changed: %s", changed)
|
|
151
|
+
self.session = Session(
|
|
152
|
+
user_id=self.session.user_id, email=self.session.email, cookies=current
|
|
153
|
+
)
|
|
154
|
+
return self.session
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def has_credentials(self) -> bool:
|
|
158
|
+
return self._email is not None and self._password is not None
|
|
159
|
+
|
|
160
|
+
def _require_session(self) -> Session:
|
|
161
|
+
if self.session is None and self.has_credentials:
|
|
162
|
+
return self.login()
|
|
163
|
+
if self.session is None:
|
|
164
|
+
raise NotAuthenticatedError(
|
|
165
|
+
"Not logged in. Pass a session or email/password, or run `crono login`."
|
|
166
|
+
)
|
|
167
|
+
return self.session
|
|
168
|
+
|
|
169
|
+
def _gwt_hashes(self) -> tuple[str, str]:
|
|
170
|
+
"""Fetch the current GWT permutation and RPC policy hash.
|
|
171
|
+
|
|
172
|
+
These change with every frontend deploy, so they are never persisted.
|
|
173
|
+
"""
|
|
174
|
+
nocache = self._http.get("/cronometer/cronometer.nocache.js").text
|
|
175
|
+
m = re.search(r"'([0-9A-F]{32})'", nocache)
|
|
176
|
+
if not m:
|
|
177
|
+
raise CronometerError("Could not find GWT permutation hash")
|
|
178
|
+
permutation = m.group(1)
|
|
179
|
+
cache_js = self._http.get(f"/cronometer/{permutation}.cache.js").text
|
|
180
|
+
m = re.search(r"'app','([0-9A-F]{32})'", cache_js)
|
|
181
|
+
if not m:
|
|
182
|
+
raise CronometerError("Could not find GWT policy hash")
|
|
183
|
+
log.debug("gwt permutation=%s policy_hash=%s", permutation, m.group(1))
|
|
184
|
+
return permutation, m.group(1)
|
|
185
|
+
|
|
186
|
+
def _gwt_call(self, payload: str, permutation: str) -> str:
|
|
187
|
+
resp = self._http.post(
|
|
188
|
+
"/cronometer/app",
|
|
189
|
+
content=payload,
|
|
190
|
+
headers={
|
|
191
|
+
"Content-Type": "text/x-gwt-rpc; charset=UTF-8",
|
|
192
|
+
"X-GWT-Module-Base": GWT_MODULE_BASE,
|
|
193
|
+
"X-GWT-Permutation": permutation,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
return resp.text
|
|
197
|
+
|
|
198
|
+
def login(self) -> Session:
|
|
199
|
+
"""Authenticate with the constructor credentials and return the new session."""
|
|
200
|
+
email, password = self._email, self._password
|
|
201
|
+
if email is None or password is None:
|
|
202
|
+
raise LoginError("No credentials: construct the client with email and password")
|
|
203
|
+
self._http.cookies.clear()
|
|
204
|
+
self._http.get("/login/")
|
|
205
|
+
|
|
206
|
+
# Double-submit cookie CSRF: the token is the name of a 32-char cookie.
|
|
207
|
+
csrf_token = next(
|
|
208
|
+
(name for name in self._http.cookies if len(name) == 32 and name.islower()),
|
|
209
|
+
None,
|
|
210
|
+
)
|
|
211
|
+
log.debug("csrf token cookie: %s", csrf_token)
|
|
212
|
+
if csrf_token is None:
|
|
213
|
+
raise LoginError("Could not find anti-CSRF cookie on login page")
|
|
214
|
+
|
|
215
|
+
resp = self._http.post(
|
|
216
|
+
"/login",
|
|
217
|
+
data={
|
|
218
|
+
"anticsrf": csrf_token,
|
|
219
|
+
"password": password,
|
|
220
|
+
"username": email,
|
|
221
|
+
"userCode": "",
|
|
222
|
+
},
|
|
223
|
+
headers={
|
|
224
|
+
"Referer": f"{BASE_URL}/login/",
|
|
225
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
if resp.status_code >= 400:
|
|
229
|
+
raise LoginError(f"Login request failed with HTTP {resp.status_code}")
|
|
230
|
+
if "sesnonce" not in self._http.cookies:
|
|
231
|
+
raise LoginError("Login failed: no session cookie returned (bad credentials?)")
|
|
232
|
+
|
|
233
|
+
self._http.get("/")
|
|
234
|
+
permutation, policy_hash = self._gwt_hashes()
|
|
235
|
+
auth_rpc = (
|
|
236
|
+
f"7|0|5|{GWT_MODULE_BASE}|{policy_hash}|{GWT_SERVICE}|authenticate|"
|
|
237
|
+
f"java.lang.Integer/3438268394|1|2|3|4|1|5|5|120|"
|
|
238
|
+
)
|
|
239
|
+
body = self._gwt_call(auth_rpc, permutation)
|
|
240
|
+
m = re.search(r"//OK\[(\d+),", body)
|
|
241
|
+
if not m:
|
|
242
|
+
raise LoginError(f"GWT authenticate failed: {body[:200]}")
|
|
243
|
+
|
|
244
|
+
self.session = self._snapshot(email, int(m.group(1)))
|
|
245
|
+
log.debug(
|
|
246
|
+
"authenticated user_id=%s cookies=%s", self.session.user_id, list(self.session.cookies)
|
|
247
|
+
)
|
|
248
|
+
return self.session
|
|
249
|
+
|
|
250
|
+
def logout(self) -> None:
|
|
251
|
+
session = self._require_session()
|
|
252
|
+
sesnonce = self._http.cookies.get("sesnonce") or session.cookies.get("sesnonce", "")
|
|
253
|
+
try:
|
|
254
|
+
permutation, policy_hash = self._gwt_hashes()
|
|
255
|
+
payload = (
|
|
256
|
+
f"7|0|6|{GWT_MODULE_BASE}|{policy_hash}|{GWT_SERVICE}|logout|"
|
|
257
|
+
f"java.lang.String/2004016611|{sesnonce}|1|2|3|4|1|5|6|"
|
|
258
|
+
)
|
|
259
|
+
self._gwt_call(payload, permutation)
|
|
260
|
+
finally:
|
|
261
|
+
self._http.cookies.clear()
|
|
262
|
+
self.session = None
|
|
263
|
+
|
|
264
|
+
def search(
|
|
265
|
+
self,
|
|
266
|
+
query: str,
|
|
267
|
+
max_results: int = 50,
|
|
268
|
+
sources: Source = Source.ALL,
|
|
269
|
+
) -> Any:
|
|
270
|
+
session = self._require_session()
|
|
271
|
+
resp = self._http.get(
|
|
272
|
+
f"/api/v3/user/{session.user_id}/food-search/string",
|
|
273
|
+
params={
|
|
274
|
+
"query": query,
|
|
275
|
+
"maxResults": max_results,
|
|
276
|
+
"sources": Source(sources).value,
|
|
277
|
+
"categoryId": 0,
|
|
278
|
+
"selectedTab": "ALL",
|
|
279
|
+
"type": "All",
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
if resp.status_code in (401, 403):
|
|
283
|
+
raise NotAuthenticatedError("Session expired. Log in again.")
|
|
284
|
+
resp.raise_for_status()
|
|
285
|
+
return resp.json()
|
cronopy/session.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Persistent session storage for the Cronometer CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_session_path() -> Path:
|
|
13
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
14
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
15
|
+
return root / "cronopy" / "session.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Session:
|
|
20
|
+
user_id: int
|
|
21
|
+
email: str | None = None
|
|
22
|
+
cookies: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return asdict(self)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict) -> Session:
|
|
29
|
+
return cls(
|
|
30
|
+
user_id=int(data["user_id"]),
|
|
31
|
+
email=data.get("email"),
|
|
32
|
+
cookies=dict(data.get("cookies") or {}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_session(path: Path | None = None) -> Session | None:
|
|
37
|
+
path = path or default_session_path()
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return Session.from_dict(json.loads(path.read_text()))
|
|
42
|
+
except (ValueError, KeyError, TypeError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_session(session: Session, path: Path | None = None) -> Path:
|
|
47
|
+
path = path or default_session_path()
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
path.write_text(json.dumps(session.to_dict(), indent=2))
|
|
50
|
+
with contextlib.suppress(OSError):
|
|
51
|
+
path.chmod(0o600)
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def delete_session(path: Path | None = None) -> bool:
|
|
56
|
+
path = path or default_session_path()
|
|
57
|
+
if path.exists():
|
|
58
|
+
path.unlink()
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cronopy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial cli for cronometer
|
|
5
|
+
Author: Aron Radics
|
|
6
|
+
Author-email: Aron Radics <radaron@radaron.hu>
|
|
7
|
+
Requires-Dist: beautifulsoup4>=4.15.0
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: rich>=15.0.0
|
|
10
|
+
Requires-Dist: typer>=0.27.2
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# cronopy
|
|
15
|
+
|
|
16
|
+
Unofficial cli for cronometer.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uv sync
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
crono login -e you@example.com # password is prompted (or pass -p)
|
|
28
|
+
crono search "chili" -n 10 # table output
|
|
29
|
+
crono search "chili" --json # raw JSON
|
|
30
|
+
crono whoami
|
|
31
|
+
crono logout
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The session (cookies and user id) is stored in `~/.config/cronopy/session.json`
|
|
35
|
+
(or `$XDG_CONFIG_HOME/cronopy/session.json`) and reused by every command.
|
|
36
|
+
GWT hashes are fetched on demand and never persisted, since they change with
|
|
37
|
+
each Cronometer frontend deploy.
|
|
38
|
+
|
|
39
|
+
## Library
|
|
40
|
+
|
|
41
|
+
With credentials (logs in lazily on first use):
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from cronopy import CronometerClient
|
|
45
|
+
|
|
46
|
+
with CronometerClient(email="you@example.com", password="...") as client:
|
|
47
|
+
results = client.search("chili", max_results=10)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
With a saved session (for example the one written by `crono login`):
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from cronopy import CronometerClient, load_session, save_session
|
|
54
|
+
|
|
55
|
+
with CronometerClient(load_session()) as client:
|
|
56
|
+
results = client.search("chili", max_results=10)
|
|
57
|
+
if (updated := client.refresh_session()) is not None:
|
|
58
|
+
save_session(updated)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
An expired session raises `NotAuthenticatedError`. Handle it by calling
|
|
62
|
+
`client.login()` (needs credentials) and retrying:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from cronopy import CronometerClient, NotAuthenticatedError, load_session
|
|
66
|
+
|
|
67
|
+
with CronometerClient(load_session(), email="you@example.com", password="...") as client:
|
|
68
|
+
try:
|
|
69
|
+
results = client.search("chili")
|
|
70
|
+
except NotAuthenticatedError:
|
|
71
|
+
client.login()
|
|
72
|
+
results = client.search("chili")
|
|
73
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cronopy/__init__.py,sha256=AYQJWduSA6XSovLl-N9C8JobDlhYLvdqZ7BBPkauG9I,522
|
|
2
|
+
cronopy/cli.py,sha256=D6oqNwCLb-2iqR18l6nqUfIY619p3H4iNMSZcBCwmUQ,5237
|
|
3
|
+
cronopy/client.py,sha256=ircFh5ly-97FCajRIHZ8tMLkGP-e0lSh3CmeDhg_R3g,10117
|
|
4
|
+
cronopy/session.py,sha256=h2WjEOI11w3EaPXi-9QVQ38dr0vCIZmHGgEoaTbTfg0,1590
|
|
5
|
+
cronopy-0.1.0.dist-info/WHEEL,sha256=7hzKWg-J8I3Buqyw5tBii5z_MmAVsDYXXijd_QAtNZ8,81
|
|
6
|
+
cronopy-0.1.0.dist-info/entry_points.txt,sha256=VHNPvRKDDI-MTwBENFQdiaryIC79z6IGURzFpw-DXYQ,44
|
|
7
|
+
cronopy-0.1.0.dist-info/METADATA,sha256=UG6iqJ-GyP9pZHTzqTB7AQqA33-8DoXgz_WbZ2ks3CA,1935
|
|
8
|
+
cronopy-0.1.0.dist-info/RECORD,,
|