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/__init__.py +7 -0
- napper_sync/categories.py +314 -0
- napper_sync/cli.py +389 -0
- napper_sync/config.py +140 -0
- napper_sync/famly.py +157 -0
- napper_sync/famly_mapping.py +130 -0
- napper_sync/napper.py +451 -0
- napper_sync/notify.py +94 -0
- napper_sync/sources.py +81 -0
- napper_sync/sync.py +270 -0
- napper_sync-0.1.0.dist-info/METADATA +223 -0
- napper_sync-0.1.0.dist-info/RECORD +16 -0
- napper_sync-0.1.0.dist-info/WHEEL +5 -0
- napper_sync-0.1.0.dist-info/entry_points.txt +2 -0
- napper_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- napper_sync-0.1.0.dist-info/top_level.txt +1 -0
napper_sync/__init__.py
ADDED
|
@@ -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)
|