pybabytracker 0.1.0__tar.gz
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.
- pybabytracker-0.1.0/.github/workflows/ci.yml +42 -0
- pybabytracker-0.1.0/.github/workflows/release.yml +59 -0
- pybabytracker-0.1.0/.gitignore +10 -0
- pybabytracker-0.1.0/LICENSE +21 -0
- pybabytracker-0.1.0/PKG-INFO +242 -0
- pybabytracker-0.1.0/README.md +217 -0
- pybabytracker-0.1.0/docs/icon.png +0 -0
- pybabytracker-0.1.0/pyproject.toml +63 -0
- pybabytracker-0.1.0/scripts/check_version.py +68 -0
- pybabytracker-0.1.0/src/pybabytracker/__init__.py +134 -0
- pybabytracker-0.1.0/src/pybabytracker/__main__.py +5 -0
- pybabytracker-0.1.0/src/pybabytracker/_version.py +1 -0
- pybabytracker-0.1.0/src/pybabytracker/builders.py +415 -0
- pybabytracker-0.1.0/src/pybabytracker/cli.py +366 -0
- pybabytracker-0.1.0/src/pybabytracker/client.py +287 -0
- pybabytracker-0.1.0/src/pybabytracker/engine.py +939 -0
- pybabytracker-0.1.0/src/pybabytracker/exceptions.py +63 -0
- pybabytracker-0.1.0/src/pybabytracker/models.py +599 -0
- pybabytracker-0.1.0/src/pybabytracker/py.typed +0 -0
- pybabytracker-0.1.0/src/pybabytracker/times.py +46 -0
- pybabytracker-0.1.0/tests/conftest.py +41 -0
- pybabytracker-0.1.0/tests/factories.py +146 -0
- pybabytracker-0.1.0/tests/fake_server.py +236 -0
- pybabytracker-0.1.0/tests/fixtures/captured_payloads.json +343 -0
- pybabytracker-0.1.0/tests/test_captured_models.py +429 -0
- pybabytracker-0.1.0/tests/test_check_version.py +159 -0
- pybabytracker-0.1.0/tests/test_cli.py +536 -0
- pybabytracker-0.1.0/tests/test_client.py +327 -0
- pybabytracker-0.1.0/tests/test_deleted_ids.py +141 -0
- pybabytracker-0.1.0/tests/test_engine.py +598 -0
- pybabytracker-0.1.0/tests/test_engine_tiebreak.py +425 -0
- pybabytracker-0.1.0/tests/test_models.py +199 -0
- pybabytracker-0.1.0/tests/test_own_writes.py +359 -0
- pybabytracker-0.1.0/tests/test_paging.py +339 -0
- pybabytracker-0.1.0/tests/test_pump_account_level.py +511 -0
- pybabytracker-0.1.0/tests/test_pump_timed_without_amount.py +207 -0
- pybabytracker-0.1.0/tests/test_sync_integration.py +155 -0
- pybabytracker-0.1.0/tests/test_timers.py +887 -0
- pybabytracker-0.1.0/tests/test_times.py +52 -0
- pybabytracker-0.1.0/tests/test_write_types.py +920 -0
- pybabytracker-0.1.0/tests/test_writes.py +1047 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
inputs:
|
|
9
|
+
no-cache:
|
|
10
|
+
description: Skip the pip cache (used by release runs)
|
|
11
|
+
type: boolean
|
|
12
|
+
default: false
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
test:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
strategy:
|
|
21
|
+
fail-fast: false
|
|
22
|
+
matrix:
|
|
23
|
+
python-version: ["3.12", "3.13", "3.14"]
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
26
|
+
with:
|
|
27
|
+
persist-credentials: false
|
|
28
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python-version }}
|
|
31
|
+
cache: ${{ inputs.no-cache != true && 'pip' || '' }}
|
|
32
|
+
cache-dependency-path: pyproject.toml
|
|
33
|
+
- name: Install
|
|
34
|
+
run: pip install -e ".[dev]"
|
|
35
|
+
- name: pytest
|
|
36
|
+
run: pytest
|
|
37
|
+
- name: mypy
|
|
38
|
+
run: mypy
|
|
39
|
+
- name: ruff check
|
|
40
|
+
run: ruff check .
|
|
41
|
+
- name: ruff format
|
|
42
|
+
run: ruff format --check .
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI when a version tag is pushed. Nothing else triggers it.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v[0-9]*"]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
uses: ./.github/workflows/ci.yml
|
|
14
|
+
with:
|
|
15
|
+
no-cache: true
|
|
16
|
+
|
|
17
|
+
build:
|
|
18
|
+
needs: test
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
22
|
+
with:
|
|
23
|
+
persist-credentials: false
|
|
24
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
- name: Check the tag matches the package version
|
|
28
|
+
env:
|
|
29
|
+
TAG: ${{ github.ref_name }}
|
|
30
|
+
run: python scripts/check_version.py "$TAG"
|
|
31
|
+
- name: Install build tools
|
|
32
|
+
run: pip install uv==0.13.6 twine==7.0.0
|
|
33
|
+
- name: Build sdist and wheel
|
|
34
|
+
run: uv build
|
|
35
|
+
- name: Check the artifacts
|
|
36
|
+
run: twine check --strict dist/*
|
|
37
|
+
- name: Install the wheel in a clean venv
|
|
38
|
+
run: |
|
|
39
|
+
uv venv "$RUNNER_TEMP/wheel-check"
|
|
40
|
+
uv pip install --python "$RUNNER_TEMP/wheel-check/bin/python" dist/*.whl
|
|
41
|
+
"$RUNNER_TEMP/wheel-check/bin/python" -c "import pybabytracker; print(pybabytracker.__version__)"
|
|
42
|
+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
if-no-files-found: error
|
|
47
|
+
|
|
48
|
+
publish:
|
|
49
|
+
needs: build
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
environment: pypi
|
|
52
|
+
permissions:
|
|
53
|
+
id-token: write # trusted publishing: PyPI exchanges this OIDC token, so no API token is stored
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
56
|
+
with:
|
|
57
|
+
name: dist
|
|
58
|
+
path: dist/
|
|
59
|
+
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will Adler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pybabytracker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the Nighp Baby Tracker sync service
|
|
5
|
+
Project-URL: Source, https://github.com/wtadler/pybabytracker
|
|
6
|
+
Project-URL: Issues, https://github.com/wtadler/pybabytracker/issues
|
|
7
|
+
Author: Will Adler
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: aiohttp>=3.10
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://github.com/wtadler/pybabytracker/raw/main/docs/icon.png" alt="Baby Tracker icon" width="128">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
# pybabytracker
|
|
31
|
+
|
|
32
|
+
Async Python client for the sync service of
|
|
33
|
+
[Baby Tracker](https://apps.apple.com/us/app/baby-tracker-newborn-log/id779656557), the app by
|
|
34
|
+
[Nighp](https://nighp.com), built for a Home Assistant integration. It is unofficial and
|
|
35
|
+
identifies itself honestly (`pybabytracker/<version>`).
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import aiohttp
|
|
41
|
+
from pybabytracker import DIAPER_WET, BabyTrackerClient, SyncEngine
|
|
42
|
+
|
|
43
|
+
async with aiohttp.ClientSession() as session:
|
|
44
|
+
client = BabyTrackerClient(
|
|
45
|
+
"parent@example.com", "password", "STABLE-DEVICE-UUID", "Home Assistant", session=session
|
|
46
|
+
)
|
|
47
|
+
engine = SyncEngine(device_uuid="STABLE-DEVICE-UUID") # or SyncEngine.from_dict(saved_state)
|
|
48
|
+
await engine.sync(client) # fetches only devices that are ahead of what was seen
|
|
49
|
+
diaper = await engine.add_diaper(client, baby_id, DIAPER_WET) # posts as this device
|
|
50
|
+
await engine.delete_record(client, diaper.object_id) # only records this device created
|
|
51
|
+
saved_state = engine.to_dict() # JSON-safe, for the caller to persist
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- Pass your own `aiohttp.ClientSession` (the client never closes it), and make it dedicated to
|
|
55
|
+
this client: the service authenticates by cookie, so two accounts must not share a session (in
|
|
56
|
+
Home Assistant, use `async_create_clientsession`). Without one, the client makes its own and
|
|
57
|
+
closes it in `close()`.
|
|
58
|
+
- `AuthenticationError` means the credentials were rejected. `BabyTrackerConnectionError` means
|
|
59
|
+
the service was unreachable or answered with a server error. A 401 mid-session triggers one
|
|
60
|
+
sign-in and a retry before it becomes an `AuthenticationError`.
|
|
61
|
+
- Records are typed (`Diaper`, `Sleep`, `Nursing`, and so on). A record type the library
|
|
62
|
+
doesn't know stays raw as an `UnknownRecord` with a logged warning. Decoding then encoding any
|
|
63
|
+
payload returns the same JSON, including keys the library doesn't model.
|
|
64
|
+
|
|
65
|
+
## Writes
|
|
66
|
+
|
|
67
|
+
The engine posts as its own device (`device_uuid`, which must match the client's):
|
|
68
|
+
|
|
69
|
+
- A write needs a completed sync first (`NotSyncedError` otherwise), because the payload embeds
|
|
70
|
+
the baby record as known from sync (`UnknownBabyError` for a baby it hasn't seen).
|
|
71
|
+
- Numbers are at-most-once. The next number is one more than the highest of this device's
|
|
72
|
+
`LastSyncID` on the server, its own log already applied, and its last confirmed post. Before
|
|
73
|
+
choosing, the engine applies any of its own log it hasn't seen (another process using the same
|
|
74
|
+
device ID).
|
|
75
|
+
- When a post fails, the engine reads its own log at that number. If the same transaction is
|
|
76
|
+
there, the write succeeded. Otherwise the error is raised and the post stays pending; the next
|
|
77
|
+
write settles it first. After a definite HTTP error, a number nothing holds is reused. After
|
|
78
|
+
no answer at all (`NoResponseError`: a timeout or dropped connection), the post may still
|
|
79
|
+
land, so the number is reused only once `unknown_outcome_grace` seconds (default 90) have
|
|
80
|
+
passed with nothing under it; until then a write raises `WriteOutcomeUnknownError` without
|
|
81
|
+
posting. A 401 signs in again and resends the same number. Nothing is resent blindly.
|
|
82
|
+
- To retry an add safely, pass the same `object_id` again: if the earlier attempt landed after
|
|
83
|
+
all, its record is returned instead of a second copy being posted.
|
|
84
|
+
- If this device's own log is shorter than its `LastSyncID`, a write is refused
|
|
85
|
+
(`ProtocolError`) rather than skipping the missing entries.
|
|
86
|
+
- A confirmed write is applied locally at once, so the next sync doesn't fetch it back.
|
|
87
|
+
- Writes and syncs share a lock, so concurrent writes get consecutive numbers.
|
|
88
|
+
- `delete_record` only deletes records this device added (`NotOwnRecordError`), and raises
|
|
89
|
+
`RecordNotFoundError` if one is already deleted. Like the app, the delete sends the record as
|
|
90
|
+
last saved with only `newFlage` set to `"false"`; its timestamp is not bumped.
|
|
91
|
+
- Save `to_dict()` after every write, including one that raised: a pending post lives only in
|
|
92
|
+
that state. Use one device ID per running process; two processes writing as one device can
|
|
93
|
+
collide on a number while `LastSyncID` lags.
|
|
94
|
+
|
|
95
|
+
### Record types you can add
|
|
96
|
+
|
|
97
|
+
Every add shares the rules above (numbering, pending posts, `object_id` retries, local apply),
|
|
98
|
+
and bad input raises `ValueError` before any request. Payloads match the iPad capture key for
|
|
99
|
+
key.
|
|
100
|
+
|
|
101
|
+
| Method | Writes | Notes |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `add_diaper(client, baby_id, status, ...)` | `Diaper` | `DIAPER_WET`, `DIAPER_DIRTY`, `DIAPER_MIXED` |
|
|
104
|
+
| `add_formula(client, baby_id, volume=, ...)` | `Formula` | volume more than zero |
|
|
105
|
+
| `add_pumped(client, baby_id, volume=, ...)` | `Pumped` | a bottle of pumped milk |
|
|
106
|
+
| `add_nursing(client, baby_id, left_minutes=, right_minutes=, ...)` | `Nursing` | a finished session |
|
|
107
|
+
| `add_sleep(client, baby_id, start=, end=, ...)` | `Sleep` | a finished sleep |
|
|
108
|
+
| `add_pump(client, amount=, ...)` or `add_pump(client, left_volume=, right_volume=, ...)` | `Pump` | a pumping session; belongs to the account, so no `baby_id` |
|
|
109
|
+
|
|
110
|
+
- Volumes are `Volume(value, "oz")` or `Volume(value, "ml")`, written as a `VolumeMeasure` with
|
|
111
|
+
`englishMeasure` `"true"` (ounces) or `"false"` (milliliters), rounded to 2 decimals (an integer
|
|
112
|
+
when whole). The number is written as given, with no conversion, as the app does.
|
|
113
|
+
- `time` is when the activity started (or, for a bottle or diaper, when it happened), written in
|
|
114
|
+
UTC. `timezone_offset` (seconds east of UTC) defaults to the offset of the time given. The app itself writes the zone's *standard* offset
|
|
115
|
+
all year, ignoring daylight saving, so to match it pass
|
|
116
|
+
`timezone_offset=standard_offset_seconds(tz, time)`: New York is -18000 in July and January.
|
|
117
|
+
- Nursing: `time` is the session's start, as in the capture. Give `start_time`, or `end_time`
|
|
118
|
+
(start is end minus the total minutes), or neither (the session just ended). `finish_side` is
|
|
119
|
+
`SIDE_LEFT` or `SIDE_RIGHT`, defaulting to the last side used (right when both were). For a pump that default is a guess
|
|
120
|
+
(the one captured pump with both sides finished left), so pass `finish_side` yourself.
|
|
121
|
+
- Sleep: `time` is `start`. `duration` is whole minutes: the length rounded half up (the capture
|
|
122
|
+
stored about 30 seconds as 1), and at least 1, because a duration of 0 means a sleep in
|
|
123
|
+
progress. `end` must be after `start`, and a sleep can't be longer than 24 hours.
|
|
124
|
+
- Nursing and pump minutes are whole numbers, at most 240 per side (a guard against seconds
|
|
125
|
+
passed as minutes).
|
|
126
|
+
- Pump: a pump belongs to the account, not a baby, so it takes no `baby_id` and its payload has
|
|
127
|
+
no `baby` key, like the app's. Give the total as `amount`, or the side volumes (not both):
|
|
128
|
+
with only `amount` the payload has no per-side amounts, and with sides `amount` is their sum,
|
|
129
|
+
a side left out is written as 0 in the other's unit, and both sides must use one unit.
|
|
130
|
+
With no volume at all, minutes are required and the payload carries the durations and an
|
|
131
|
+
`amount` of 0 in `default_unit` (default `oz`), as the app writes a timed pump with no volume.
|
|
132
|
+
`sides` is written as 0, as captured. Babies are never derived from pumps, and a pump that
|
|
133
|
+
carries a `baby` still decodes (kept raw, not read). `engine.latest_pump()` is the newest pump
|
|
134
|
+
on the account; `latest_records(baby_id)` never includes pumps.
|
|
135
|
+
|
|
136
|
+
### Sleep timers
|
|
137
|
+
|
|
138
|
+
The app syncs a running sleep: an add with `duration` 0 at the start, then an update (opcode 1)
|
|
139
|
+
to the same record at the stop. Nursing timers stay on the phone until they stop, so a timed
|
|
140
|
+
nursing session is written once, finished, with `add_nursing`.
|
|
141
|
+
|
|
142
|
+
| Method | Writes | Notes |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `start_sleep(client, baby_id, start=None, ...)` | `Sleep` (add) | `duration` 0; `start` defaults to now, no more than a minute ahead or 24 hours back |
|
|
145
|
+
| `end_sleep(client, object_id, end=None)` | `Sleep` (update) | ends a running sleep, whichever device started it |
|
|
146
|
+
|
|
147
|
+
- `end_sleep` sends the record as last saved, changing only `duration` (rounded like
|
|
148
|
+
`add_sleep`), `newFlage` (`"false"`), and `timestamp` (the write time, or one second past the
|
|
149
|
+
stored one when that's ahead of this clock, so the end wins the merge). Key order and unknown
|
|
150
|
+
keys are kept.
|
|
151
|
+
- A sleep longer than 24 hours at the stop is **clamped to 1,440 minutes** with a warning, not
|
|
152
|
+
refused, so a forgotten timer can always be stopped. An `end` more than a minute in the future
|
|
153
|
+
is clamped to now, and an end at or up to a minute before the start (a clock running a little
|
|
154
|
+
fast) is written as 1 minute.
|
|
155
|
+
- Refused before any request: a record never seen or deleted (`RecordNotFoundError`), one that
|
|
156
|
+
isn't a sleep (`WrongRecordTypeError`), or a sleep another device finished
|
|
157
|
+
(`TimerNotRunningError`). A sleep this device already ended (its end is in this device's log,
|
|
158
|
+
even if a phone edited it since) is returned as is, with nothing posted, so retries are safe.
|
|
159
|
+
- The end carries the whole record as this device last saw it, so a phone edit that hasn't synced
|
|
160
|
+
yet (a note, say) is overwritten by it, as the app's own edits overwrite each other.
|
|
161
|
+
- A phone delete racing an end: the app's delete keeps the record's old timestamp, so an end
|
|
162
|
+
posted after it (with a newer timestamp) outlives it, and the sleep stays here as ended. How the
|
|
163
|
+
app shows that case is still to be checked on a test device.
|
|
164
|
+
- An end is an update, not an add: it doesn't make another device's sleep this device's own, so
|
|
165
|
+
`delete_record` still refuses it. Undo of a sleep this device started works running or ended.
|
|
166
|
+
|
|
167
|
+
The service lists a device only after its first post; on the test account, the dev device
|
|
168
|
+
appeared with `LastSyncID` 1 right after its first add, with no confirmation step.
|
|
169
|
+
|
|
170
|
+
## Merge rules
|
|
171
|
+
|
|
172
|
+
Records are keyed by `objectID`. Each stored record carries a version made of the record's
|
|
173
|
+
`timestamp`, the device, and the sync number. Within one device's log, later numbers always win.
|
|
174
|
+
Across devices the result doesn't depend on which device is fetched first:
|
|
175
|
+
|
|
176
|
+
- An add or update replaces a stored record if its (timestamp, device, number) is newer.
|
|
177
|
+
- A delete removes the record unless the record's timestamp is newer than the delete's, and it
|
|
178
|
+
leaves a tombstone, so a delete that arrives before the add it targets still wins.
|
|
179
|
+
- An add or update from another device brings a deleted record back only with a strictly newer
|
|
180
|
+
timestamp than the tombstone. Within one device, a later add always wins.
|
|
181
|
+
|
|
182
|
+
Tombstones are saved by `to_dict()`. State saved by an older version (1) loads with none.
|
|
183
|
+
A payload that can't be decoded at all is skipped with a warning, and the sync moves past it. A
|
|
184
|
+
fresh `SyncEngine` replays every log, so it recovers skipped payloads once a fix ships.
|
|
185
|
+
|
|
186
|
+
## Command line
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
export BT_EMAIL=... BT_PASSWORD=... # or put them in .env
|
|
190
|
+
python -m pybabytracker # latest record of each type, per baby
|
|
191
|
+
python -m pybabytracker --probe-history
|
|
192
|
+
|
|
193
|
+
# Writes go to a throwaway test account only (BT_TEST_EMAIL, BT_TEST_PASSWORD):
|
|
194
|
+
python -m pybabytracker --write-test-diaper [--status wet|dirty|mixed]
|
|
195
|
+
python -m pybabytracker --delete-test-diaper OBJECT_ID
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The tool signs in as a separate device named "Home Assistant (dev)". Its ID is made once and
|
|
199
|
+
kept in `.pybabytracker-device.json` (gitignored). `--probe-history` reads every device's log
|
|
200
|
+
from 0 and reports how many transactions come back versus its `LastSyncID`, the lowest and
|
|
201
|
+
highest numbers returned, and a verdict (full, paged, paged newest first, starts at K with older
|
|
202
|
+
numbers trimmed, duplicates, or gaps), then whether the probe device now appears in the device list. It only reads.
|
|
203
|
+
|
|
204
|
+
`--write-test-diaper` adds one diaper for the test account's first baby and reports whether the
|
|
205
|
+
dev device is in the device list before and after, with its `LastSyncID`, and whether the next
|
|
206
|
+
sync fetched its own log back. `--delete-test-diaper` deletes one the dev device wrote. Both
|
|
207
|
+
read only the `BT_TEST_*` credentials, and keep their device ID and engine state in
|
|
208
|
+
`.pybabytracker-test-device.json` and `.pybabytracker-test-state.json` (both gitignored).
|
|
209
|
+
|
|
210
|
+
## Develop
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
uv venv --python 3.12 && uv pip install -e ".[dev]"
|
|
214
|
+
pytest && mypy && ruff check .
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Release
|
|
218
|
+
|
|
219
|
+
Releases publish to PyPI from a `v<version>` tag such as `v0.2.0` through trusted publishing: there is no API token
|
|
220
|
+
anywhere. `.github/workflows/release.yml` runs on that tag and needs the tests to pass, then
|
|
221
|
+
builds the sdist and wheel, checks them with `twine check --strict`, installs the wheel in a clean
|
|
222
|
+
venv, and publishes from the GitHub environment named `pypi`.
|
|
223
|
+
|
|
224
|
+
To cut a release:
|
|
225
|
+
|
|
226
|
+
1. Set `__version__` in `src/pybabytracker/_version.py` (for example `0.2.0`, or `0.2.0rc1` for a
|
|
227
|
+
pre-release) and merge it to `main`.
|
|
228
|
+
2. Tag that commit with the same version and push the tag: `git tag v0.2.0 && git push origin v0.2.0`.
|
|
229
|
+
The workflow fails before building if the tag and `_version.py` disagree
|
|
230
|
+
(`python scripts/check_version.py v0.2.0` runs the same check locally).
|
|
231
|
+
|
|
232
|
+
One-time setup, done on PyPI and in GitHub before the first tag:
|
|
233
|
+
|
|
234
|
+
1. On PyPI, add a pending publisher at <https://pypi.org/manage/account/publishing/> for
|
|
235
|
+
the project name `pybabytracker`, with owner `wtadler`, repository `pybabytracker`, workflow
|
|
236
|
+
`release.yml`, and environment `pypi`.
|
|
237
|
+
2. In the repository settings, create an environment named `pypi`, and under deployment branches
|
|
238
|
+
and tags choose Selected branches and tags with the tag rule `v*`, so only a version tag can
|
|
239
|
+
reach the publish step (a branch could otherwise run the workflow with the publish
|
|
240
|
+
permission). Adding required reviewers there makes each publish wait for an approval.
|
|
241
|
+
|
|
242
|
+
The first successful publish turns the pending publisher into a normal one.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://github.com/wtadler/pybabytracker/raw/main/docs/icon.png" alt="Baby Tracker icon" width="128">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# pybabytracker
|
|
6
|
+
|
|
7
|
+
Async Python client for the sync service of
|
|
8
|
+
[Baby Tracker](https://apps.apple.com/us/app/baby-tracker-newborn-log/id779656557), the app by
|
|
9
|
+
[Nighp](https://nighp.com), built for a Home Assistant integration. It is unofficial and
|
|
10
|
+
identifies itself honestly (`pybabytracker/<version>`).
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import aiohttp
|
|
16
|
+
from pybabytracker import DIAPER_WET, BabyTrackerClient, SyncEngine
|
|
17
|
+
|
|
18
|
+
async with aiohttp.ClientSession() as session:
|
|
19
|
+
client = BabyTrackerClient(
|
|
20
|
+
"parent@example.com", "password", "STABLE-DEVICE-UUID", "Home Assistant", session=session
|
|
21
|
+
)
|
|
22
|
+
engine = SyncEngine(device_uuid="STABLE-DEVICE-UUID") # or SyncEngine.from_dict(saved_state)
|
|
23
|
+
await engine.sync(client) # fetches only devices that are ahead of what was seen
|
|
24
|
+
diaper = await engine.add_diaper(client, baby_id, DIAPER_WET) # posts as this device
|
|
25
|
+
await engine.delete_record(client, diaper.object_id) # only records this device created
|
|
26
|
+
saved_state = engine.to_dict() # JSON-safe, for the caller to persist
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- Pass your own `aiohttp.ClientSession` (the client never closes it), and make it dedicated to
|
|
30
|
+
this client: the service authenticates by cookie, so two accounts must not share a session (in
|
|
31
|
+
Home Assistant, use `async_create_clientsession`). Without one, the client makes its own and
|
|
32
|
+
closes it in `close()`.
|
|
33
|
+
- `AuthenticationError` means the credentials were rejected. `BabyTrackerConnectionError` means
|
|
34
|
+
the service was unreachable or answered with a server error. A 401 mid-session triggers one
|
|
35
|
+
sign-in and a retry before it becomes an `AuthenticationError`.
|
|
36
|
+
- Records are typed (`Diaper`, `Sleep`, `Nursing`, and so on). A record type the library
|
|
37
|
+
doesn't know stays raw as an `UnknownRecord` with a logged warning. Decoding then encoding any
|
|
38
|
+
payload returns the same JSON, including keys the library doesn't model.
|
|
39
|
+
|
|
40
|
+
## Writes
|
|
41
|
+
|
|
42
|
+
The engine posts as its own device (`device_uuid`, which must match the client's):
|
|
43
|
+
|
|
44
|
+
- A write needs a completed sync first (`NotSyncedError` otherwise), because the payload embeds
|
|
45
|
+
the baby record as known from sync (`UnknownBabyError` for a baby it hasn't seen).
|
|
46
|
+
- Numbers are at-most-once. The next number is one more than the highest of this device's
|
|
47
|
+
`LastSyncID` on the server, its own log already applied, and its last confirmed post. Before
|
|
48
|
+
choosing, the engine applies any of its own log it hasn't seen (another process using the same
|
|
49
|
+
device ID).
|
|
50
|
+
- When a post fails, the engine reads its own log at that number. If the same transaction is
|
|
51
|
+
there, the write succeeded. Otherwise the error is raised and the post stays pending; the next
|
|
52
|
+
write settles it first. After a definite HTTP error, a number nothing holds is reused. After
|
|
53
|
+
no answer at all (`NoResponseError`: a timeout or dropped connection), the post may still
|
|
54
|
+
land, so the number is reused only once `unknown_outcome_grace` seconds (default 90) have
|
|
55
|
+
passed with nothing under it; until then a write raises `WriteOutcomeUnknownError` without
|
|
56
|
+
posting. A 401 signs in again and resends the same number. Nothing is resent blindly.
|
|
57
|
+
- To retry an add safely, pass the same `object_id` again: if the earlier attempt landed after
|
|
58
|
+
all, its record is returned instead of a second copy being posted.
|
|
59
|
+
- If this device's own log is shorter than its `LastSyncID`, a write is refused
|
|
60
|
+
(`ProtocolError`) rather than skipping the missing entries.
|
|
61
|
+
- A confirmed write is applied locally at once, so the next sync doesn't fetch it back.
|
|
62
|
+
- Writes and syncs share a lock, so concurrent writes get consecutive numbers.
|
|
63
|
+
- `delete_record` only deletes records this device added (`NotOwnRecordError`), and raises
|
|
64
|
+
`RecordNotFoundError` if one is already deleted. Like the app, the delete sends the record as
|
|
65
|
+
last saved with only `newFlage` set to `"false"`; its timestamp is not bumped.
|
|
66
|
+
- Save `to_dict()` after every write, including one that raised: a pending post lives only in
|
|
67
|
+
that state. Use one device ID per running process; two processes writing as one device can
|
|
68
|
+
collide on a number while `LastSyncID` lags.
|
|
69
|
+
|
|
70
|
+
### Record types you can add
|
|
71
|
+
|
|
72
|
+
Every add shares the rules above (numbering, pending posts, `object_id` retries, local apply),
|
|
73
|
+
and bad input raises `ValueError` before any request. Payloads match the iPad capture key for
|
|
74
|
+
key.
|
|
75
|
+
|
|
76
|
+
| Method | Writes | Notes |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `add_diaper(client, baby_id, status, ...)` | `Diaper` | `DIAPER_WET`, `DIAPER_DIRTY`, `DIAPER_MIXED` |
|
|
79
|
+
| `add_formula(client, baby_id, volume=, ...)` | `Formula` | volume more than zero |
|
|
80
|
+
| `add_pumped(client, baby_id, volume=, ...)` | `Pumped` | a bottle of pumped milk |
|
|
81
|
+
| `add_nursing(client, baby_id, left_minutes=, right_minutes=, ...)` | `Nursing` | a finished session |
|
|
82
|
+
| `add_sleep(client, baby_id, start=, end=, ...)` | `Sleep` | a finished sleep |
|
|
83
|
+
| `add_pump(client, amount=, ...)` or `add_pump(client, left_volume=, right_volume=, ...)` | `Pump` | a pumping session; belongs to the account, so no `baby_id` |
|
|
84
|
+
|
|
85
|
+
- Volumes are `Volume(value, "oz")` or `Volume(value, "ml")`, written as a `VolumeMeasure` with
|
|
86
|
+
`englishMeasure` `"true"` (ounces) or `"false"` (milliliters), rounded to 2 decimals (an integer
|
|
87
|
+
when whole). The number is written as given, with no conversion, as the app does.
|
|
88
|
+
- `time` is when the activity started (or, for a bottle or diaper, when it happened), written in
|
|
89
|
+
UTC. `timezone_offset` (seconds east of UTC) defaults to the offset of the time given. The app itself writes the zone's *standard* offset
|
|
90
|
+
all year, ignoring daylight saving, so to match it pass
|
|
91
|
+
`timezone_offset=standard_offset_seconds(tz, time)`: New York is -18000 in July and January.
|
|
92
|
+
- Nursing: `time` is the session's start, as in the capture. Give `start_time`, or `end_time`
|
|
93
|
+
(start is end minus the total minutes), or neither (the session just ended). `finish_side` is
|
|
94
|
+
`SIDE_LEFT` or `SIDE_RIGHT`, defaulting to the last side used (right when both were). For a pump that default is a guess
|
|
95
|
+
(the one captured pump with both sides finished left), so pass `finish_side` yourself.
|
|
96
|
+
- Sleep: `time` is `start`. `duration` is whole minutes: the length rounded half up (the capture
|
|
97
|
+
stored about 30 seconds as 1), and at least 1, because a duration of 0 means a sleep in
|
|
98
|
+
progress. `end` must be after `start`, and a sleep can't be longer than 24 hours.
|
|
99
|
+
- Nursing and pump minutes are whole numbers, at most 240 per side (a guard against seconds
|
|
100
|
+
passed as minutes).
|
|
101
|
+
- Pump: a pump belongs to the account, not a baby, so it takes no `baby_id` and its payload has
|
|
102
|
+
no `baby` key, like the app's. Give the total as `amount`, or the side volumes (not both):
|
|
103
|
+
with only `amount` the payload has no per-side amounts, and with sides `amount` is their sum,
|
|
104
|
+
a side left out is written as 0 in the other's unit, and both sides must use one unit.
|
|
105
|
+
With no volume at all, minutes are required and the payload carries the durations and an
|
|
106
|
+
`amount` of 0 in `default_unit` (default `oz`), as the app writes a timed pump with no volume.
|
|
107
|
+
`sides` is written as 0, as captured. Babies are never derived from pumps, and a pump that
|
|
108
|
+
carries a `baby` still decodes (kept raw, not read). `engine.latest_pump()` is the newest pump
|
|
109
|
+
on the account; `latest_records(baby_id)` never includes pumps.
|
|
110
|
+
|
|
111
|
+
### Sleep timers
|
|
112
|
+
|
|
113
|
+
The app syncs a running sleep: an add with `duration` 0 at the start, then an update (opcode 1)
|
|
114
|
+
to the same record at the stop. Nursing timers stay on the phone until they stop, so a timed
|
|
115
|
+
nursing session is written once, finished, with `add_nursing`.
|
|
116
|
+
|
|
117
|
+
| Method | Writes | Notes |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `start_sleep(client, baby_id, start=None, ...)` | `Sleep` (add) | `duration` 0; `start` defaults to now, no more than a minute ahead or 24 hours back |
|
|
120
|
+
| `end_sleep(client, object_id, end=None)` | `Sleep` (update) | ends a running sleep, whichever device started it |
|
|
121
|
+
|
|
122
|
+
- `end_sleep` sends the record as last saved, changing only `duration` (rounded like
|
|
123
|
+
`add_sleep`), `newFlage` (`"false"`), and `timestamp` (the write time, or one second past the
|
|
124
|
+
stored one when that's ahead of this clock, so the end wins the merge). Key order and unknown
|
|
125
|
+
keys are kept.
|
|
126
|
+
- A sleep longer than 24 hours at the stop is **clamped to 1,440 minutes** with a warning, not
|
|
127
|
+
refused, so a forgotten timer can always be stopped. An `end` more than a minute in the future
|
|
128
|
+
is clamped to now, and an end at or up to a minute before the start (a clock running a little
|
|
129
|
+
fast) is written as 1 minute.
|
|
130
|
+
- Refused before any request: a record never seen or deleted (`RecordNotFoundError`), one that
|
|
131
|
+
isn't a sleep (`WrongRecordTypeError`), or a sleep another device finished
|
|
132
|
+
(`TimerNotRunningError`). A sleep this device already ended (its end is in this device's log,
|
|
133
|
+
even if a phone edited it since) is returned as is, with nothing posted, so retries are safe.
|
|
134
|
+
- The end carries the whole record as this device last saw it, so a phone edit that hasn't synced
|
|
135
|
+
yet (a note, say) is overwritten by it, as the app's own edits overwrite each other.
|
|
136
|
+
- A phone delete racing an end: the app's delete keeps the record's old timestamp, so an end
|
|
137
|
+
posted after it (with a newer timestamp) outlives it, and the sleep stays here as ended. How the
|
|
138
|
+
app shows that case is still to be checked on a test device.
|
|
139
|
+
- An end is an update, not an add: it doesn't make another device's sleep this device's own, so
|
|
140
|
+
`delete_record` still refuses it. Undo of a sleep this device started works running or ended.
|
|
141
|
+
|
|
142
|
+
The service lists a device only after its first post; on the test account, the dev device
|
|
143
|
+
appeared with `LastSyncID` 1 right after its first add, with no confirmation step.
|
|
144
|
+
|
|
145
|
+
## Merge rules
|
|
146
|
+
|
|
147
|
+
Records are keyed by `objectID`. Each stored record carries a version made of the record's
|
|
148
|
+
`timestamp`, the device, and the sync number. Within one device's log, later numbers always win.
|
|
149
|
+
Across devices the result doesn't depend on which device is fetched first:
|
|
150
|
+
|
|
151
|
+
- An add or update replaces a stored record if its (timestamp, device, number) is newer.
|
|
152
|
+
- A delete removes the record unless the record's timestamp is newer than the delete's, and it
|
|
153
|
+
leaves a tombstone, so a delete that arrives before the add it targets still wins.
|
|
154
|
+
- An add or update from another device brings a deleted record back only with a strictly newer
|
|
155
|
+
timestamp than the tombstone. Within one device, a later add always wins.
|
|
156
|
+
|
|
157
|
+
Tombstones are saved by `to_dict()`. State saved by an older version (1) loads with none.
|
|
158
|
+
A payload that can't be decoded at all is skipped with a warning, and the sync moves past it. A
|
|
159
|
+
fresh `SyncEngine` replays every log, so it recovers skipped payloads once a fix ships.
|
|
160
|
+
|
|
161
|
+
## Command line
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
export BT_EMAIL=... BT_PASSWORD=... # or put them in .env
|
|
165
|
+
python -m pybabytracker # latest record of each type, per baby
|
|
166
|
+
python -m pybabytracker --probe-history
|
|
167
|
+
|
|
168
|
+
# Writes go to a throwaway test account only (BT_TEST_EMAIL, BT_TEST_PASSWORD):
|
|
169
|
+
python -m pybabytracker --write-test-diaper [--status wet|dirty|mixed]
|
|
170
|
+
python -m pybabytracker --delete-test-diaper OBJECT_ID
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The tool signs in as a separate device named "Home Assistant (dev)". Its ID is made once and
|
|
174
|
+
kept in `.pybabytracker-device.json` (gitignored). `--probe-history` reads every device's log
|
|
175
|
+
from 0 and reports how many transactions come back versus its `LastSyncID`, the lowest and
|
|
176
|
+
highest numbers returned, and a verdict (full, paged, paged newest first, starts at K with older
|
|
177
|
+
numbers trimmed, duplicates, or gaps), then whether the probe device now appears in the device list. It only reads.
|
|
178
|
+
|
|
179
|
+
`--write-test-diaper` adds one diaper for the test account's first baby and reports whether the
|
|
180
|
+
dev device is in the device list before and after, with its `LastSyncID`, and whether the next
|
|
181
|
+
sync fetched its own log back. `--delete-test-diaper` deletes one the dev device wrote. Both
|
|
182
|
+
read only the `BT_TEST_*` credentials, and keep their device ID and engine state in
|
|
183
|
+
`.pybabytracker-test-device.json` and `.pybabytracker-test-state.json` (both gitignored).
|
|
184
|
+
|
|
185
|
+
## Develop
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
uv venv --python 3.12 && uv pip install -e ".[dev]"
|
|
189
|
+
pytest && mypy && ruff check .
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Release
|
|
193
|
+
|
|
194
|
+
Releases publish to PyPI from a `v<version>` tag such as `v0.2.0` through trusted publishing: there is no API token
|
|
195
|
+
anywhere. `.github/workflows/release.yml` runs on that tag and needs the tests to pass, then
|
|
196
|
+
builds the sdist and wheel, checks them with `twine check --strict`, installs the wheel in a clean
|
|
197
|
+
venv, and publishes from the GitHub environment named `pypi`.
|
|
198
|
+
|
|
199
|
+
To cut a release:
|
|
200
|
+
|
|
201
|
+
1. Set `__version__` in `src/pybabytracker/_version.py` (for example `0.2.0`, or `0.2.0rc1` for a
|
|
202
|
+
pre-release) and merge it to `main`.
|
|
203
|
+
2. Tag that commit with the same version and push the tag: `git tag v0.2.0 && git push origin v0.2.0`.
|
|
204
|
+
The workflow fails before building if the tag and `_version.py` disagree
|
|
205
|
+
(`python scripts/check_version.py v0.2.0` runs the same check locally).
|
|
206
|
+
|
|
207
|
+
One-time setup, done on PyPI and in GitHub before the first tag:
|
|
208
|
+
|
|
209
|
+
1. On PyPI, add a pending publisher at <https://pypi.org/manage/account/publishing/> for
|
|
210
|
+
the project name `pybabytracker`, with owner `wtadler`, repository `pybabytracker`, workflow
|
|
211
|
+
`release.yml`, and environment `pypi`.
|
|
212
|
+
2. In the repository settings, create an environment named `pypi`, and under deployment branches
|
|
213
|
+
and tags choose Selected branches and tags with the tag rule `v*`, so only a version tag can
|
|
214
|
+
reach the publish step (a branch could otherwise run the workflow with the publish
|
|
215
|
+
permission). Adding required reviewers there makes each publish wait for an approval.
|
|
216
|
+
|
|
217
|
+
The first successful publish turns the pending publisher into a normal one.
|
|
Binary file
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pybabytracker"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Async Python client for the Nighp Baby Tracker sync service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
authors = [{ name = "Will Adler" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["aiohttp>=3.10"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Source = "https://github.com/wtadler/pybabytracker"
|
|
27
|
+
Issues = "https://github.com/wtadler/pybabytracker/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8",
|
|
32
|
+
"pytest-asyncio>=0.24",
|
|
33
|
+
"mypy>=1.11",
|
|
34
|
+
"ruff>=0.6",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
pybabytracker = "pybabytracker.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "src/pybabytracker/_version.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/pybabytracker"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
pythonpath = ["tests"]
|
|
51
|
+
filterwarnings = ["error"]
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
strict = true
|
|
55
|
+
python_version = "3.12"
|
|
56
|
+
files = ["src", "tests"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py312"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|