napper-sync 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.
napper_sync/cli.py ADDED
@@ -0,0 +1,389 @@
1
+ """The ``napper-sync`` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ from datetime import date, datetime, timedelta
10
+ from pathlib import Path
11
+
12
+ from . import __version__, categories, famly_mapping, notify, sources
13
+ from . import config as cfg
14
+ from .famly import FamlyAuthError, FamlyClient, FamlyError, save_token
15
+ from .napper import NapperAuthError, NapperClient, NapperError
16
+ from .sync import SyncResult, sync
17
+
18
+
19
+ class CliError(Exception):
20
+ """A user-facing error: printed without a traceback, exit code 1."""
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # helpers
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def _baby(args, conf: cfg.Config) -> str:
29
+ baby = getattr(args, "baby", None) or conf.baby_id
30
+ if not baby:
31
+ raise CliError(
32
+ "No baby ID. Pass --baby or set [napper] baby_id in "
33
+ f"{cfg.config_path()} (see `napper-sync babies`)."
34
+ )
35
+ return baby
36
+
37
+
38
+ def _child(args, conf: cfg.Config) -> str:
39
+ child = getattr(args, "child", None) or conf.famly_child_id
40
+ if not child:
41
+ raise CliError(
42
+ "No Famly child ID. Pass --child or set [famly] child_id in "
43
+ f"{cfg.config_path()} (see `napper-sync famly children`)."
44
+ )
45
+ return child
46
+
47
+
48
+ def _parse_fields(pairs: list[str]) -> dict:
49
+ fields = {}
50
+ for pair in pairs or []:
51
+ if "=" not in pair:
52
+ raise CliError(f"--set expects FIELD=VALUE, got '{pair}'")
53
+ key, value = pair.split("=", 1)
54
+ fields[key.strip()] = value.strip()
55
+ return fields
56
+
57
+
58
+ def _dump(obj) -> None:
59
+ print(json.dumps(obj, indent=2, ensure_ascii=False))
60
+
61
+
62
+ def _summary_title(result: SyncResult) -> str:
63
+ def entries(n: int) -> str:
64
+ return "entry" if n == 1 else "entries"
65
+
66
+ if result.written and result.updated:
67
+ return (
68
+ f"{result.written + result.updated} nursery entries: "
69
+ f"{result.written} imported, {result.updated} corrected"
70
+ )
71
+ if result.updated:
72
+ return f"{result.updated} nursery {entries(result.updated)} corrected"
73
+ return f"{result.written} nursery {entries(result.written)} imported"
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # commands
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def cmd_login(args, conf):
82
+ client = NapperClient()
83
+ code = args.code
84
+ if not code:
85
+ client.send_otp(args.email)
86
+ print(f"A one-time code has been sent to {args.email}.")
87
+ try:
88
+ code = input("Code: ").strip()
89
+ except EOFError as err:
90
+ raise CliError("No code entered. Re-run with --code <CODE>.") from err
91
+ tokens = client.email_login(args.email, code)
92
+ print(f"Logged in. Account {tokens.account_id}; tokens stored in {cfg.napper_tokens_path()}")
93
+
94
+
95
+ def cmd_babies(args, conf):
96
+ for baby in NapperClient().babies():
97
+ print(f"{baby['id']}\t{baby.get('name')}")
98
+
99
+
100
+ def cmd_categories(args, conf):
101
+ print(categories.overview())
102
+
103
+
104
+ def cmd_logs(args, conf):
105
+ _dump(NapperClient().logs_between(_baby(args, conf), args.start, args.end))
106
+
107
+
108
+ def cmd_today(args, conf):
109
+ _dump(NapperClient().today(_baby(args, conf)))
110
+
111
+
112
+ def cmd_add(args, conf):
113
+ start = datetime.fromisoformat(args.start).astimezone()
114
+ end = datetime.fromisoformat(args.end).astimezone() if args.end else None
115
+ result = NapperClient().create_log(
116
+ _baby(args, conf),
117
+ args.category,
118
+ start,
119
+ end,
120
+ fields=_parse_fields(args.set),
121
+ comment=args.comment,
122
+ log_id=args.id,
123
+ dry_run=not args.live,
124
+ )
125
+ _dump(result)
126
+ if not args.live:
127
+ print("\nDry run - nothing was written. Pass --live to write.", file=sys.stderr)
128
+
129
+
130
+ def cmd_delete(args, conf):
131
+ _dump(NapperClient().delete_log(_baby(args, conf), args.log_id, dry_run=not args.live))
132
+ if not args.live:
133
+ print("\nDry run - nothing was deleted. Pass --live to delete.", file=sys.stderr)
134
+
135
+
136
+ def cmd_sync_file(args, conf):
137
+ entries = sources.load(Path(args.path), args.format)
138
+ sync(NapperClient(), _baby(args, conf), entries, live=args.live)
139
+
140
+
141
+ def cmd_famly_token(args, conf):
142
+ path = save_token(args.value)
143
+ print(f"Famly token stored in {path}")
144
+
145
+
146
+ def cmd_famly_children(args, conf):
147
+ for child in FamlyClient(host=conf.famly_host).children():
148
+ print(f"{child['id']}\t{child['name']}")
149
+
150
+
151
+ def cmd_famly_calendar(args, conf):
152
+ events = FamlyClient(host=conf.famly_host).calendar(_child(args, conf), args.start, args.end)
153
+ if args.json:
154
+ _dump(events)
155
+ return
156
+ print(f"{len(events)} events")
157
+ for ev in events:
158
+ kind = (ev.get("embed") or {}).get("type") or ("SLEEP" if ev.get("to") else "?")
159
+ print(f" {ev.get('day')} {kind:<18} {str(ev.get('title'))[:70]}")
160
+
161
+
162
+ def cmd_sync_famly(args, conf) -> int:
163
+ baby = _baby(args, conf)
164
+ child = _child(args, conf)
165
+ webhook = None if args.no_notify else conf.discord_webhook
166
+ days = args.days if args.days is not None else conf.sync_days
167
+ end = date.today()
168
+ start = end - timedelta(days=days)
169
+
170
+ try:
171
+ events = FamlyClient(host=conf.famly_host).calendar(
172
+ child, start.isoformat(), end.isoformat()
173
+ )
174
+ except FamlyAuthError as exc:
175
+ print(f"Famly: {exc}", file=sys.stderr)
176
+ if args.live:
177
+ notify.send(
178
+ webhook,
179
+ "Famly token expired",
180
+ "The sync can no longer reach Famly.\n\n"
181
+ "Get a fresh token: log in to Famly in a browser, copy "
182
+ "`localStorage['famly.accessToken']`, then run\n"
183
+ "```\nnapper-sync famly token <VALUE>\n```\n"
184
+ f"Error:\n```\n{str(exc)[:600]}\n```",
185
+ notify.RED,
186
+ )
187
+ return 1
188
+ except FamlyError as exc:
189
+ print(f"Famly: {exc}", file=sys.stderr)
190
+ if args.live:
191
+ notify.send(webhook, "Famly unreachable", f"```\n{str(exc)[:900]}\n```", notify.YELLOW)
192
+ return 1
193
+
194
+ print(f"Famly: {len(events)} events from {start} to {end}")
195
+ entries, notes = famly_mapping.translate(events, marker=conf.marker, child_id=child)
196
+
197
+ counts: dict[str, int] = {}
198
+ for e in entries:
199
+ counts[e.category] = counts.get(e.category, 0) + 1
200
+ print("Translated: " + (", ".join(f"{k} x{v}" for k, v in sorted(counts.items())) or "nothing"))
201
+ if notes:
202
+ print(f"Not imported ({len(notes)}):")
203
+ for note in notes[:10]:
204
+ print(f" {note}")
205
+ if len(notes) > 10:
206
+ print(f" ... and {len(notes) - 10} more")
207
+ print()
208
+
209
+ try:
210
+ result: SyncResult = sync(NapperClient(), baby, entries, live=args.live)
211
+ except (NapperError, categories.CategoryError) as exc:
212
+ print(f"Napper: {exc}", file=sys.stderr)
213
+ if args.live:
214
+ notify.send(webhook, "Napper write failed", f"```\n{str(exc)[:900]}\n```", notify.RED)
215
+ return 1
216
+
217
+ if args.live and (result.written or result.updated):
218
+ lines = [f"· {e.label()}" for e in result.new]
219
+ lines += [f"· corrected: {e.label()} ({'; '.join(diff)})" for e, diff in result.changed]
220
+ notify.send(webhook, _summary_title(result), "\n".join(lines)[:1600], notify.GREEN)
221
+ return 0
222
+
223
+
224
+ def cmd_notify(args, conf):
225
+ if args.unit:
226
+ ok = notify.unit_failed(conf.discord_webhook, args.unit)
227
+ else:
228
+ ok = notify.send(
229
+ conf.discord_webhook,
230
+ "Test message",
231
+ "If you can read this, notifications work.",
232
+ notify.GREEN,
233
+ )
234
+ print("sent" if ok else "failed")
235
+ return 0 if ok else 1
236
+
237
+
238
+ def cmd_config(args, conf):
239
+ path = cfg.config_path()
240
+ if args.action == "path":
241
+ print(path)
242
+ return 0
243
+ if args.action == "init":
244
+ if path.exists() and not args.force:
245
+ raise CliError(f"{path} exists. Use --force to overwrite.")
246
+ cfg.ensure_home()
247
+ path.write_text(cfg.EXAMPLE_CONFIG, encoding="utf-8")
248
+ print(f"Wrote {path} - now fill in the IDs.")
249
+ return 0
250
+ if args.action == "show":
251
+ print(f"# {path}" + ("" if path.exists() else " (missing - defaults shown)"))
252
+ print(f"baby_id = {conf.baby_id or ''}")
253
+ print(f"famly.host = {conf.famly_host}")
254
+ print(f"famly.child_id = {conf.famly_child_id or ''}")
255
+ print(f"sync.days = {conf.sync_days}")
256
+ print(f"sync.marker = {conf.marker}")
257
+ print(f"discord_webhook = {'set' if conf.discord_webhook else 'not set'}")
258
+ print(f"napper tokens = {'present' if cfg.napper_tokens_path().exists() else 'missing'}")
259
+ print(f"famly token = {'present' if cfg.famly_token_path().exists() else 'missing'}")
260
+ return 0
261
+ raise CliError(f"Unknown config action {args.action}")
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # parser
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ def build_parser() -> argparse.ArgumentParser:
270
+ p = argparse.ArgumentParser(
271
+ prog="napper-sync",
272
+ description="Unofficial Napper client and Famly -> Napper sync.",
273
+ epilog="Writes are dry runs unless --live is given.",
274
+ )
275
+ p.add_argument("--version", action="version", version=f"napper-sync {__version__}")
276
+ p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
277
+ sub = p.add_subparsers(dest="cmd", required=True)
278
+
279
+ s = sub.add_parser("login", help="log in to Napper with an e-mailed one-time code")
280
+ s.add_argument("email")
281
+ s.add_argument("--code", help="the code, if you already have it (otherwise prompted)")
282
+ s.set_defaults(fn=cmd_login)
283
+
284
+ sub.add_parser("babies", help="list baby profiles and their IDs").set_defaults(fn=cmd_babies)
285
+ sub.add_parser("categories", help="list categories and their fields").set_defaults(
286
+ fn=cmd_categories
287
+ )
288
+
289
+ s = sub.add_parser("logs", help="print logs between two days as JSON")
290
+ s.add_argument("start", help="YYYY-MM-DD")
291
+ s.add_argument("end", help="YYYY-MM-DD")
292
+ s.add_argument("--baby", help="baby ID (default: config)")
293
+ s.set_defaults(fn=cmd_logs)
294
+
295
+ s = sub.add_parser("today", help="print today's logs as JSON")
296
+ s.add_argument("--baby")
297
+ s.set_defaults(fn=cmd_today)
298
+
299
+ s = sub.add_parser("add", help="add one log entry")
300
+ s.add_argument("category", help="e.g. nap, diaper, bottle - see `categories`")
301
+ s.add_argument("start", help="ISO time, e.g. 2026-09-18T13:05")
302
+ s.add_argument("end", nargs="?", help="ISO time; only for span categories")
303
+ s.add_argument(
304
+ "--set",
305
+ action="append",
306
+ metavar="FIELD=VALUE",
307
+ default=[],
308
+ help="category-specific field, repeatable",
309
+ )
310
+ s.add_argument("--comment")
311
+ s.add_argument("--id", help="overwrite an existing entry instead of creating one")
312
+ s.add_argument("--baby")
313
+ s.add_argument("--live", action="store_true", help="actually write")
314
+ s.set_defaults(fn=cmd_add)
315
+
316
+ s = sub.add_parser("delete", help="delete one log entry")
317
+ s.add_argument("log_id")
318
+ s.add_argument("--baby")
319
+ s.add_argument("--live", action="store_true", help="actually delete")
320
+ s.set_defaults(fn=cmd_delete)
321
+
322
+ s = sub.add_parser("sync-file", help="import entries from a CSV or JSON file")
323
+ s.add_argument("path")
324
+ s.add_argument("--format", choices=sorted(sources.SOURCES), help="default: by extension")
325
+ s.add_argument("--baby")
326
+ s.add_argument("--live", action="store_true", help="actually write")
327
+ s.set_defaults(fn=cmd_sync_file)
328
+
329
+ f = sub.add_parser("famly", help="Famly side: token, children, calendar")
330
+ fs = f.add_subparsers(dest="famly_cmd", required=True)
331
+ s = fs.add_parser("token", help="store the Famly access token")
332
+ s.add_argument("value")
333
+ s.set_defaults(fn=cmd_famly_token)
334
+ fs.add_parser("children", help="list children and their IDs").set_defaults(
335
+ fn=cmd_famly_children
336
+ )
337
+ s = fs.add_parser("calendar", help="show what the nursery logged")
338
+ s.add_argument("start", help="YYYY-MM-DD")
339
+ s.add_argument("end", help="YYYY-MM-DD")
340
+ s.add_argument("--child")
341
+ s.add_argument("--json", action="store_true", help="raw events as JSON")
342
+ s.set_defaults(fn=cmd_famly_calendar)
343
+
344
+ s = sub.add_parser("sync-famly", help="import the nursery's Famly entries into Napper")
345
+ s.add_argument("--baby")
346
+ s.add_argument("--child")
347
+ s.add_argument("--days", type=int, help="look-back window (default: config, 4)")
348
+ s.add_argument("--live", action="store_true", help="actually write")
349
+ s.add_argument("--no-notify", action="store_true", help="skip Discord messages")
350
+ s.set_defaults(fn=cmd_sync_famly)
351
+
352
+ s = sub.add_parser("notify", help="send a Discord test message, or a unit-failure report")
353
+ s.add_argument("--unit", help="systemd unit whose journal to attach (used by OnFailure=)")
354
+ s.set_defaults(fn=cmd_notify)
355
+
356
+ s = sub.add_parser("config", help="manage the configuration file")
357
+ s.add_argument("action", choices=["init", "show", "path"])
358
+ s.add_argument("--force", action="store_true", help="overwrite an existing config on init")
359
+ s.set_defaults(fn=cmd_config)
360
+
361
+ return p
362
+
363
+
364
+ def main(argv: list[str] | None = None) -> int:
365
+ args = build_parser().parse_args(argv)
366
+ logging.basicConfig(
367
+ level=logging.DEBUG if args.verbose else logging.INFO,
368
+ format="%(levelname)s %(name)s: %(message)s" if args.verbose else "%(message)s",
369
+ stream=sys.stderr,
370
+ )
371
+ conf = cfg.Config.load()
372
+ try:
373
+ code = args.fn(args, conf)
374
+ return int(code or 0)
375
+ except (
376
+ CliError,
377
+ NapperAuthError,
378
+ NapperError,
379
+ FamlyError,
380
+ categories.CategoryError,
381
+ ValueError,
382
+ OSError,
383
+ ) as exc:
384
+ print(f"Error: {exc}", file=sys.stderr)
385
+ return 1
386
+
387
+
388
+ if __name__ == "__main__":
389
+ raise SystemExit(main())
napper_sync/config.py ADDED
@@ -0,0 +1,140 @@
1
+ """Configuration and on-disk state for napper-sync.
2
+
3
+ Everything lives in one directory so that it is easy to back up, move to a
4
+ server, or wipe:
5
+
6
+ ~/.config/napper-sync/
7
+ ├── config.toml IDs, host, sync window, Discord webhook
8
+ ├── napper-tokens.json written by `napper-sync login`, refreshed automatically
9
+ └── famly-token written by `napper-sync famly token <value>`
10
+
11
+ The directory can be relocated with the ``NAPPER_SYNC_HOME`` environment
12
+ variable (handy for tests and for running several children side by side).
13
+
14
+ Secrets are stored as separate files rather than inside ``config.toml`` so
15
+ that the config can be shared or committed to a private dotfiles repo
16
+ without dragging credentials along.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import os
23
+ import tomllib
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ DEFAULT_FAMLY_HOST = "app.famly.de"
28
+ DEFAULT_SYNC_DAYS = 4
29
+ DEFAULT_MARKER = "(Famly)"
30
+
31
+
32
+ def home() -> Path:
33
+ """The state directory. Created lazily by whoever writes into it."""
34
+ override = os.environ.get("NAPPER_SYNC_HOME", "").strip()
35
+ if override:
36
+ return Path(override).expanduser()
37
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
38
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
39
+ return base / "napper-sync"
40
+
41
+
42
+ def config_path() -> Path:
43
+ return home() / "config.toml"
44
+
45
+
46
+ def napper_tokens_path() -> Path:
47
+ return home() / "napper-tokens.json"
48
+
49
+
50
+ def famly_token_path() -> Path:
51
+ return home() / "famly-token"
52
+
53
+
54
+ def ensure_home() -> Path:
55
+ path = home()
56
+ path.mkdir(parents=True, exist_ok=True)
57
+ with contextlib.suppress(OSError): # Windows has no POSIX permission bits
58
+ path.chmod(0o700)
59
+ return path
60
+
61
+
62
+ def write_secret(path: Path, value: str) -> Path:
63
+ """Write a credential file with owner-only permissions."""
64
+ ensure_home()
65
+ path.write_text(value.strip() + "\n", encoding="utf-8")
66
+ with contextlib.suppress(OSError):
67
+ path.chmod(0o600)
68
+ return path
69
+
70
+
71
+ @dataclass
72
+ class Config:
73
+ """Parsed ``config.toml``. Every field has a default, so a missing file
74
+ is not an error - only the commands that need an ID will complain."""
75
+
76
+ baby_id: str | None = None
77
+ famly_host: str = DEFAULT_FAMLY_HOST
78
+ famly_child_id: str | None = None
79
+ sync_days: int = DEFAULT_SYNC_DAYS
80
+ marker: str = DEFAULT_MARKER
81
+ discord_webhook: str | None = None
82
+ raw: dict = field(default_factory=dict, repr=False)
83
+
84
+ @classmethod
85
+ def load(cls, path: Path | None = None) -> Config:
86
+ path = path or config_path()
87
+ if not path.exists():
88
+ return cls()
89
+ with path.open("rb") as fh:
90
+ data = tomllib.load(fh)
91
+ napper = data.get("napper", {}) or {}
92
+ famly = data.get("famly", {}) or {}
93
+ sync = data.get("sync", {}) or {}
94
+ notify = data.get("notify", {}) or {}
95
+ return cls(
96
+ baby_id=_opt_str(napper.get("baby_id")),
97
+ famly_host=_opt_str(famly.get("host")) or DEFAULT_FAMLY_HOST,
98
+ famly_child_id=_opt_str(famly.get("child_id")),
99
+ sync_days=int(sync.get("days", DEFAULT_SYNC_DAYS)),
100
+ marker=str(sync.get("marker", DEFAULT_MARKER)),
101
+ discord_webhook=_opt_str(notify.get("discord_webhook"))
102
+ or _opt_str(os.environ.get("NAPPER_SYNC_DISCORD_WEBHOOK")),
103
+ raw=data,
104
+ )
105
+
106
+
107
+ def _opt_str(value) -> str | None:
108
+ if value is None:
109
+ return None
110
+ text = str(value).strip()
111
+ return text or None
112
+
113
+
114
+ EXAMPLE_CONFIG = """\
115
+ # napper-sync configuration
116
+ # Location: ~/.config/napper-sync/config.toml (or $NAPPER_SYNC_HOME/config.toml)
117
+
118
+ [napper]
119
+ # From `napper-sync babies`
120
+ baby_id = ""
121
+
122
+ [famly]
123
+ # Famly runs one instance per country. Yours is shown in the browser under
124
+ # localStorage["famly.existingLogins"] -> baseUrl. Common: app.famly.de, app.famly.co
125
+ host = "app.famly.de"
126
+ # From `napper-sync famly children`
127
+ child_id = ""
128
+
129
+ [sync]
130
+ # How many days back each run looks. Four covers a weekend from the Monday run.
131
+ days = 4
132
+ # Appended to the comment of every entry imported from Famly, so you can tell
133
+ # them apart in the Napper app. Set to "" to disable.
134
+ marker = "(Famly)"
135
+
136
+ [notify]
137
+ # Discord webhook for failures and successful imports. Leave empty to disable.
138
+ # Can also be given via the NAPPER_SYNC_DISCORD_WEBHOOK environment variable.
139
+ discord_webhook = ""
140
+ """
napper_sync/famly.py ADDED
@@ -0,0 +1,157 @@
1
+ """Client for the internal Famly API (the one the web app uses).
2
+
3
+ Famly's official API is a paid add-on for the nursery, not something a
4
+ parent can switch on. The web app, however, talks to a plain REST + GraphQL
5
+ backend with a single auth header::
6
+
7
+ x-famly-accesstoken: <token>
8
+
9
+ The token lives in the logged-in browser under
10
+ ``localStorage["famly.accessToken"]`` and is stored here in
11
+ ``~/.config/napper-sync/famly-token``.
12
+
13
+ Famly runs one instance per country (``app.famly.de``, ``app.famly.co``, ...).
14
+ Which one applies to you is shown in the browser under
15
+ ``localStorage["famly.existingLogins"]`` -> ``baseUrl``.
16
+
17
+ The one endpoint that matters for syncing::
18
+
19
+ GET /api/v2/calendar?type=RANGE&day=<from>&to=<to>&childId=<id>
20
+
21
+ It returns everything the nursery logs for the child: sleep, meals, diaper
22
+ changes, check-in and check-out. See ``docs/famly-api.md``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from pathlib import Path
29
+ from typing import Any
30
+ from urllib.error import HTTPError, URLError
31
+ from urllib.parse import urlencode
32
+ from urllib.request import Request, urlopen
33
+
34
+ from . import config as cfg
35
+
36
+ TIMEOUT = 25
37
+
38
+ # Famly answers 403 to requests without a browser-style User-Agent, so the
39
+ # client sends the same header the web app sends.
40
+ USER_AGENT = (
41
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
42
+ "(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
43
+ )
44
+
45
+
46
+ class FamlyError(Exception):
47
+ """Request failed."""
48
+
49
+
50
+ class FamlyAuthError(FamlyError):
51
+ """Token missing, expired or rejected."""
52
+
53
+
54
+ def save_token(token: str, path: Path | None = None) -> Path:
55
+ return cfg.write_secret(path or cfg.famly_token_path(), token)
56
+
57
+
58
+ def load_token(path: Path | None = None) -> str:
59
+ path = path or cfg.famly_token_path()
60
+ if not path.exists():
61
+ raise FamlyAuthError(
62
+ f"No Famly token in {path}. Copy localStorage['famly.accessToken'] from a "
63
+ "logged-in browser and run: napper-sync famly token <VALUE>"
64
+ )
65
+ return path.read_text(encoding="utf-8").strip()
66
+
67
+
68
+ class FamlyClient:
69
+ def __init__(
70
+ self, token: str | None = None, host: str | None = None, token_path: Path | None = None
71
+ ):
72
+ self.token = token or load_token(token_path)
73
+ self.host = host or cfg.DEFAULT_FAMLY_HOST
74
+
75
+ @property
76
+ def rest_base(self) -> str:
77
+ return f"https://{self.host}/api"
78
+
79
+ @property
80
+ def graphql_url(self) -> str:
81
+ return f"https://{self.host}/graphql"
82
+
83
+ def _headers(self) -> dict[str, str]:
84
+ return {
85
+ "x-famly-accesstoken": self.token,
86
+ "Accept": "application/json",
87
+ "Content-Type": "application/json",
88
+ "User-Agent": USER_AGENT,
89
+ }
90
+
91
+ # ---- REST -------------------------------------------------------------
92
+
93
+ def get(self, path: str, **params) -> Any:
94
+ url = f"{self.rest_base}/{path.lstrip('/')}"
95
+ if params:
96
+ url += "?" + urlencode({k: v for k, v in params.items() if v is not None})
97
+ return self._raw("GET", url)
98
+
99
+ # ---- GraphQL ----------------------------------------------------------
100
+
101
+ def graphql(self, query: str, variables: dict | None = None) -> Any:
102
+ body = {"query": query, "variables": variables or {}}
103
+ answer = self._raw("POST", self.graphql_url, body)
104
+ if isinstance(answer, dict) and answer.get("errors"):
105
+ raise FamlyError(json.dumps(answer["errors"], ensure_ascii=False)[:500])
106
+ return answer.get("data") if isinstance(answer, dict) else answer
107
+
108
+ # ---- shared -----------------------------------------------------------
109
+
110
+ def _raw(self, method: str, url: str, body: dict | None = None) -> Any:
111
+ data = json.dumps(body).encode() if body is not None else None
112
+ req = Request(url, data=data, headers=self._headers(), method=method)
113
+ try:
114
+ with urlopen(req, timeout=TIMEOUT) as resp:
115
+ raw = resp.read()
116
+ except HTTPError as err:
117
+ text = err.read().decode(errors="replace")[:500]
118
+ if err.code in (401, 403):
119
+ raise FamlyAuthError(
120
+ f"Famly at {self.host} rejected the request ({err.code}). Either the "
121
+ "token has expired, or [famly] host is not the instance your account "
122
+ "lives on - check localStorage['famly.existingLogins'] in a logged-in "
123
+ f"browser. Server said: {text}"
124
+ ) from err
125
+ raise FamlyError(f"HTTP {err.code} on {method} {url}: {text}") from err
126
+ except URLError as err:
127
+ raise FamlyError(f"Connection failed: {err}") from err
128
+ if not raw:
129
+ return {}
130
+ try:
131
+ return json.loads(raw)
132
+ except json.JSONDecodeError:
133
+ return {"_raw": raw.decode(errors="replace")[:1000]}
134
+
135
+ # ---- convenience ------------------------------------------------------
136
+
137
+ def calendar(self, child_id: str, start: str, end: str) -> list[dict]:
138
+ """The child's daily events - what the nursery logs.
139
+
140
+ ``start``/``end`` as ``YYYY-MM-DD``. Returns a flat list of events,
141
+ each enriched with a ``day`` key.
142
+ """
143
+ answer = self.get("/v2/calendar", type="RANGE", day=start, to=end, childId=child_id)
144
+ out: list[dict] = []
145
+ for block in answer if isinstance(answer, list) else []:
146
+ for day in block.get("days", []) or []:
147
+ for event in day.get("events", []) or []:
148
+ event = dict(event)
149
+ event["day"] = day.get("day_localdate") or day.get("day")
150
+ out.append(event)
151
+ return out
152
+
153
+ def children(self) -> list[dict]:
154
+ """Children attached to the account (via GraphQL)."""
155
+ data = self.graphql("{ children { results { id name { fullName } } } }")
156
+ results = ((data or {}).get("children") or {}).get("results") or []
157
+ return [{"id": c.get("id"), "name": (c.get("name") or {}).get("fullName")} for c in results]