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.
@@ -0,0 +1,7 @@
1
+ """napper-sync: unofficial Napper API client and Famly -> Napper synchronisation."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .categories import CATEGORIES, CategoryError # noqa: E402,F401
6
+ from .napper import NapperAuthError, NapperClient, NapperError # noqa: E402,F401
7
+ from .sync import Entry, SyncResult, sync # noqa: E402,F401
@@ -0,0 +1,314 @@
1
+ """What Napper expects for each log category.
2
+
3
+ Derived from several thousand real log entries read back through the API.
4
+ Two things set the categories apart:
5
+
6
+ 1. **ID scheme.** ``BED_TIME`` and ``WOKE_UP`` carry the *date* in their ID
7
+ (``LOG_2026-09-14_WOKE_UP``), so there can be at most one per day - a
8
+ second write for the same day overwrites the first. Every other category
9
+ uses a UUID and may occur any number of times per day.
10
+
11
+ 2. **Point vs. span.** A point event (diaper, bottle, temperature, ...) still
12
+ has an ``end`` - it is simply equal to ``start``. Only ``NAP``,
13
+ ``NURSING``, ``NIGHT_WAKING`` and occasionally ``BED_TIME`` have a real
14
+ duration.
15
+
16
+ The enum values below are the ones observed in real data. The app may know
17
+ more; an unknown value is rejected *here*, not by the API. If you are sure a
18
+ value is valid, add it to this file.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+
25
+
26
+ class CategoryError(ValueError):
27
+ """Unknown category or a field value that does not fit it."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Field:
32
+ name: str
33
+ kind: str # "enum" | "enum_list" | "int" | "float"
34
+ values: tuple[str, ...] = () # for enum / enum_list
35
+ range: tuple | None = None # for int / float: (min, max)
36
+ note: str = ""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Category:
41
+ name: str
42
+ label: str
43
+ id_scheme: str # "uuid" (any number per day) | "date" (one per day)
44
+ span: bool # real duration, or a point in time
45
+ fields: tuple[Field, ...] = ()
46
+ note: str = ""
47
+
48
+ def field(self, name: str) -> Field | None:
49
+ for f in self.fields:
50
+ if f.name == name:
51
+ return f
52
+ return None
53
+
54
+
55
+ # --- shared value lists -----------------------------------------------------
56
+
57
+ HOW_SLEPT = ("IN_BED", "NEXT_TO_ME", "NURSING", "WORN_OR_HELD", "SWING", "STROLLER", "CAR")
58
+ HOW_WOKE = ("WOKE_UP_NATURALLY", "WOKE_UP_CHILD")
59
+ MOOD_ON_SLEEP = ("LONG_TIME_TO_FALL_ASLEEP", "UPSET")
60
+
61
+ F_HOW_SLEPT = Field("howBabySlept", "enum", HOW_SLEPT, note="how the child fell asleep")
62
+ F_HOW_WOKE = Field("howBabyWoke", "enum", HOW_WOKE, note="woke naturally or was woken")
63
+ F_MOOD_SLEEP = Field("babyMoodOnSleep", "enum_list", MOOD_ON_SLEEP, note="list, several allowed")
64
+ F_MOOD_WAKE = Field("babyMoodOnWakeUp", "int", range=(1, 5), note="mood after waking, 1-5")
65
+
66
+
67
+ # --- the categories ---------------------------------------------------------
68
+
69
+ CATEGORIES: dict[str, Category] = {
70
+ c.name: c
71
+ for c in [
72
+ # ---- sleep ----
73
+ Category(
74
+ "NAP",
75
+ "Nap",
76
+ "uuid",
77
+ span=True,
78
+ fields=(F_HOW_SLEPT, F_HOW_WOKE, F_MOOD_SLEEP, F_MOOD_WAKE),
79
+ note="Daytime sleep. Without an end time the entry stays open.",
80
+ ),
81
+ Category(
82
+ "BED_TIME",
83
+ "Bedtime",
84
+ "date",
85
+ span=False,
86
+ fields=(
87
+ Field(
88
+ "startMinutesAfterMidnight",
89
+ "int",
90
+ range=(0, 1439),
91
+ note="0 unless bedtime was after midnight",
92
+ ),
93
+ F_HOW_SLEPT,
94
+ F_MOOD_SLEEP,
95
+ ),
96
+ note="Marker: night begins. At most one per day.",
97
+ ),
98
+ Category(
99
+ "WOKE_UP",
100
+ "Woke up",
101
+ "date",
102
+ span=False,
103
+ fields=(F_HOW_WOKE, F_MOOD_WAKE),
104
+ note="Marker: night ends. At most one per day.",
105
+ ),
106
+ Category(
107
+ "NIGHT_WAKING",
108
+ "Night waking",
109
+ "uuid",
110
+ span=True,
111
+ fields=(F_MOOD_WAKE,),
112
+ note="Awake phase during the night, with start and end.",
113
+ ),
114
+ # ---- feeding ----
115
+ Category(
116
+ "NURSING",
117
+ "Nursing",
118
+ "uuid",
119
+ span=True,
120
+ fields=(
121
+ Field(
122
+ "breastLeftMinutes", "int", range=(0, 100_000), note="SECONDS despite the name"
123
+ ),
124
+ Field(
125
+ "breastRightMinutes", "int", range=(0, 100_000), note="SECONDS despite the name"
126
+ ),
127
+ Field("activeBreast", "enum", ("LEFT", "RIGHT"), note="side last active"),
128
+ ),
129
+ note=(
130
+ "The *Minutes fields count seconds: a six-minute session shows "
131
+ "up as 346 in real data."
132
+ ),
133
+ ),
134
+ Category(
135
+ "BOTTLE",
136
+ "Bottle",
137
+ "uuid",
138
+ span=False,
139
+ fields=(
140
+ Field("bottleFeedingType", "enum", ("FORMULA", "BREAST_MILK")),
141
+ Field("feedingAmount", "int", range=(0, 1000), note="ml"),
142
+ ),
143
+ ),
144
+ Category(
145
+ "SOLIDS",
146
+ "Solids",
147
+ "uuid",
148
+ span=False,
149
+ fields=(Field("feedingAmount", "int", range=(0, 1000), note="ml or g"),),
150
+ note="What was eaten goes into the comment.",
151
+ ),
152
+ Category(
153
+ "PUMPING",
154
+ "Pumping",
155
+ "uuid",
156
+ span=False,
157
+ fields=(
158
+ Field("amountPumpedLeft", "int", range=(0, 1000), note="ml"),
159
+ Field("amountPumpedRight", "int", range=(0, 1000), note="ml"),
160
+ ),
161
+ ),
162
+ # ---- care and health ----
163
+ Category(
164
+ "CHANGED_DIAPER",
165
+ "Diaper change",
166
+ "uuid",
167
+ span=False,
168
+ fields=(Field("diaperContent", "enum", ("DRY", "WET", "DIRTY", "MIXED")),),
169
+ ),
170
+ Category(
171
+ "TEMPERATURE",
172
+ "Temperature",
173
+ "uuid",
174
+ span=False,
175
+ fields=(Field("temperature", "float", range=(30.0, 45.0), note="degrees Celsius"),),
176
+ ),
177
+ Category(
178
+ "MEDICINE",
179
+ "Medicine",
180
+ "uuid",
181
+ span=False,
182
+ note="No dedicated field for the drug - put its name in the comment.",
183
+ ),
184
+ ]
185
+ }
186
+
187
+ # Short names accepted on the command line and in data files (English and
188
+ # German, because the tool grew up in a German nursery).
189
+ ALIASES = {
190
+ "nap": "NAP",
191
+ "sleep": "NAP",
192
+ "nickerchen": "NAP",
193
+ "schlaf": "NAP",
194
+ "bedtime": "BED_TIME",
195
+ "bed": "BED_TIME",
196
+ "schlafenszeit": "BED_TIME",
197
+ "bett": "BED_TIME",
198
+ "wokeup": "WOKE_UP",
199
+ "woke": "WOKE_UP",
200
+ "wake": "WOKE_UP",
201
+ "aufgewacht": "WOKE_UP",
202
+ "nightwaking": "NIGHT_WAKING",
203
+ "night": "NIGHT_WAKING",
204
+ "nachtwach": "NIGHT_WAKING",
205
+ "nursing": "NURSING",
206
+ "breastfeeding": "NURSING",
207
+ "stillen": "NURSING",
208
+ "bottle": "BOTTLE",
209
+ "flasche": "BOTTLE",
210
+ "flaeschchen": "BOTTLE",
211
+ "fläschchen": "BOTTLE",
212
+ "solids": "SOLIDS",
213
+ "food": "SOLIDS",
214
+ "meal": "SOLIDS",
215
+ "brei": "SOLIDS",
216
+ "essen": "SOLIDS",
217
+ "pumping": "PUMPING",
218
+ "pump": "PUMPING",
219
+ "pumpen": "PUMPING",
220
+ "abpumpen": "PUMPING",
221
+ "diaper": "CHANGED_DIAPER",
222
+ "nappy": "CHANGED_DIAPER",
223
+ "windel": "CHANGED_DIAPER",
224
+ "temperature": "TEMPERATURE",
225
+ "temp": "TEMPERATURE",
226
+ "fever": "TEMPERATURE",
227
+ "temperatur": "TEMPERATURE",
228
+ "fieber": "TEMPERATURE",
229
+ "medicine": "MEDICINE",
230
+ "medication": "MEDICINE",
231
+ "medizin": "MEDICINE",
232
+ "medikament": "MEDICINE",
233
+ }
234
+
235
+ SLEEP_CATEGORIES = ("NAP", "BED_TIME", "WOKE_UP", "NIGHT_WAKING")
236
+
237
+
238
+ def get(name: str) -> Category:
239
+ """Look up a category by API name or alias (case-insensitive)."""
240
+ key = name.strip()
241
+ key = ALIASES.get(key.lower(), key.upper().replace("-", "_").replace(" ", "_"))
242
+ if key not in CATEGORIES:
243
+ known = ", ".join(sorted(CATEGORIES))
244
+ raise CategoryError(f"Unknown category '{name}'. Known: {known}")
245
+ return CATEGORIES[key]
246
+
247
+
248
+ def validate(category: str, values: dict | None) -> dict:
249
+ """Check extra fields against the category; return them properly typed."""
250
+ cat = get(category)
251
+ out: dict = {}
252
+ for name, value in (values or {}).items():
253
+ fld = cat.field(name)
254
+ if fld is None:
255
+ allowed = ", ".join(f.name for f in cat.fields) or "none"
256
+ raise CategoryError(f"{cat.name} has no field '{name}'. Allowed: {allowed}")
257
+ out[name] = _coerce(cat, fld, value)
258
+ return out
259
+
260
+
261
+ def _coerce(cat: Category, fld: Field, value):
262
+ if fld.kind == "enum":
263
+ v = str(value).strip().upper()
264
+ if v not in fld.values:
265
+ raise CategoryError(
266
+ f"{cat.name}.{fld.name}: '{value}' not recognised. Options: {', '.join(fld.values)}"
267
+ )
268
+ return v
269
+ if fld.kind == "enum_list":
270
+ if isinstance(value, str):
271
+ value = [t for t in value.replace(";", ",").split(",") if t.strip()]
272
+ out = []
273
+ for item in value:
274
+ v = str(item).strip().upper()
275
+ if v not in fld.values:
276
+ raise CategoryError(
277
+ f"{cat.name}.{fld.name}: '{item}' not recognised. "
278
+ f"Options: {', '.join(fld.values)}"
279
+ )
280
+ out.append(v)
281
+ return out
282
+ if fld.kind in ("int", "float"):
283
+ try:
284
+ number = int(value) if fld.kind == "int" else float(value)
285
+ except (TypeError, ValueError) as err:
286
+ raise CategoryError(f"{cat.name}.{fld.name}: '{value}' is not a number") from err
287
+ if fld.range and not (fld.range[0] <= number <= fld.range[1]):
288
+ raise CategoryError(
289
+ f"{cat.name}.{fld.name}: {number} outside {fld.range[0]}-{fld.range[1]}"
290
+ )
291
+ return number
292
+ raise CategoryError(f"Unknown field kind {fld.kind}")
293
+
294
+
295
+ def overview() -> str:
296
+ """Human-readable listing - what `napper-sync categories` prints."""
297
+ lines = []
298
+ for cat in CATEGORIES.values():
299
+ kind = "span" if cat.span else "point"
300
+ per_day = "one per day" if cat.id_scheme == "date" else "any number"
301
+ lines.append(f"{cat.name:<15} {cat.label:<14} {kind:<6} {per_day}")
302
+ for f in cat.fields:
303
+ if f.kind in ("enum", "enum_list"):
304
+ detail = " | ".join(f.values)
305
+ elif f.range:
306
+ detail = f"{f.range[0]} to {f.range[1]}"
307
+ else:
308
+ detail = f.kind
309
+ note = f" ({f.note})" if f.note else ""
310
+ lines.append(f" --set {f.name}=<{detail}>{note}")
311
+ if cat.note:
312
+ lines.append(f" {cat.note}")
313
+ lines.append("")
314
+ return "\n".join(lines)