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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.18
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ crono = cronopy.cli:main
3
+