Skip to content

Commit 55c1110

Browse files
authored
Merge pull request #148 from malmeloo/feat/better-serialization
Make more objects `Serializable`
2 parents 7a33b92 + 0014d03 commit 55c1110

File tree

12 files changed

+672
-157
lines changed

12 files changed

+672
-157
lines changed

examples/_login.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
# ruff: noqa: ASYNC230
1+
from __future__ import annotations
22

33
from findmy.reports import (
44
AppleAccount,
55
AsyncAppleAccount,
6-
BaseAnisetteProvider,
76
LoginState,
87
SmsSecondFactorMethod,
98
TrustedDeviceSecondFactorMethod,
109
)
11-
12-
ACCOUNT_STORE = "account.json"
10+
from findmy.reports.anisette import LocalAnisetteProvider, RemoteAnisetteProvider
1311

1412

1513
def _login_sync(account: AppleAccount) -> None:
@@ -66,27 +64,45 @@ async def _login_async(account: AsyncAppleAccount) -> None:
6664
await method.submit(code)
6765

6866

69-
def get_account_sync(anisette: BaseAnisetteProvider) -> AppleAccount:
67+
def get_account_sync(
68+
store_path: str,
69+
anisette_url: str | None,
70+
libs_path: str | None,
71+
) -> AppleAccount:
7072
"""Tries to restore a saved Apple account, or prompts the user for login otherwise. (sync)"""
71-
acc = AppleAccount(anisette=anisette)
72-
acc_store = "account.json"
7373
try:
74-
acc.from_json(acc_store)
74+
acc = AppleAccount.from_json(store_path, anisette_libs_path=libs_path)
7575
except FileNotFoundError:
76+
ani = (
77+
LocalAnisetteProvider(libs_path=libs_path)
78+
if anisette_url is None
79+
else RemoteAnisetteProvider(anisette_url)
80+
)
81+
acc = AppleAccount(ani)
7682
_login_sync(acc)
77-
acc.to_json(acc_store)
83+
84+
acc.to_json(store_path)
7885

7986
return acc
8087

8188

82-
async def get_account_async(anisette: BaseAnisetteProvider) -> AsyncAppleAccount:
89+
async def get_account_async(
90+
store_path: str,
91+
anisette_url: str | None,
92+
libs_path: str | None,
93+
) -> AsyncAppleAccount:
8394
"""Tries to restore a saved Apple account, or prompts the user for login otherwise. (async)"""
84-
acc = AsyncAppleAccount(anisette=anisette)
85-
acc_store = "account.json"
8695
try:
87-
acc.from_json(acc_store)
96+
acc = AsyncAppleAccount.from_json(store_path, anisette_libs_path=libs_path)
8897
except FileNotFoundError:
98+
ani = (
99+
LocalAnisetteProvider(libs_path=libs_path)
100+
if anisette_url is None
101+
else RemoteAnisetteProvider(anisette_url)
102+
)
103+
acc = AsyncAppleAccount(ani)
89104
await _login_async(acc)
90-
acc.to_json(acc_store)
105+
106+
acc.to_json(store_path)
91107

92108
return acc

examples/fetch_reports.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,47 @@
44
from _login import get_account_sync
55

66
from findmy import KeyPair
7-
from findmy.reports import RemoteAnisetteProvider
87

9-
# URL to (public or local) anisette server
10-
ANISETTE_SERVER = "http://localhost:6969"
8+
# Path where login session will be stored.
9+
# This is necessary to avoid generating a new session every time we log in.
10+
STORE_PATH = "account.json"
11+
12+
# URL to LOCAL anisette server. Set to None to use built-in Anisette generator instead (recommended)
13+
# IF YOU USE A PUBLIC SERVER, DO NOT COMPLAIN THAT YOU KEEP RUNNING INTO AUTHENTICATION ERRORS!
14+
# If you change this value, make sure to remove the account store file.
15+
ANISETTE_SERVER = None
16+
17+
# Path where Anisette libraries will be stored.
18+
# This is only relevant when using the built-in Anisette server.
19+
# It can be omitted (set to None) to avoid saving to disk,
20+
# but specifying a path is highly recommended to avoid downloading the bundle on every run.
21+
ANISETTE_LIBS_PATH = "ani_libs.bin"
1122

1223
logging.basicConfig(level=logging.INFO)
1324

1425

1526
def fetch_reports(priv_key: str) -> int:
16-
key = KeyPair.from_b64(priv_key)
17-
acc = get_account_sync(
18-
RemoteAnisetteProvider(ANISETTE_SERVER),
19-
)
27+
# Step 0: construct an account instance
28+
# We use a helper for this to simplify interactive authentication
29+
acc = get_account_sync(STORE_PATH, ANISETTE_SERVER, ANISETTE_LIBS_PATH)
2030

2131
print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})")
2232

23-
# It's that simple!
33+
# Step 1: construct a key object and get its location reports
34+
key = KeyPair.from_b64(priv_key)
2435
reports = acc.fetch_last_reports(key)
36+
37+
# Step 2: print the reports!
2538
for report in sorted(reports):
2639
print(report)
2740

28-
return 1
41+
# We can save the report to a file if we want
42+
report.to_json("last_report.json")
43+
44+
# Step 3: Make sure to save account state when you're done!
45+
acc.to_json(STORE_PATH)
46+
47+
return 0
2948

3049

3150
if __name__ == "__main__":

examples/fetch_reports_async.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,49 @@
55
from _login import get_account_async
66

77
from findmy import KeyPair
8-
from findmy.reports import RemoteAnisetteProvider
98

10-
# URL to (public or local) anisette server
11-
ANISETTE_SERVER = "http://localhost:6969"
9+
# Path where login session will be stored.
10+
# This is necessary to avoid generating a new session every time we log in.
11+
STORE_PATH = "account.json"
12+
13+
# URL to LOCAL anisette server. Set to None to use built-in Anisette generator instead (recommended)
14+
# IF YOU USE A PUBLIC SERVER, DO NOT COMPLAIN THAT YOU KEEP RUNNING INTO AUTHENTICATION ERRORS!
15+
# If you change this value, make sure to remove the account store file.
16+
ANISETTE_SERVER = None
17+
18+
# Path where Anisette libraries will be stored.
19+
# This is only relevant when using the built-in Anisette server.
20+
# It can be omitted (set to None) to avoid saving to disk,
21+
# but specifying a path is highly recommended to avoid downloading the bundle on every run.
22+
ANISETTE_LIBS_PATH = "ani_libs.bin"
1223

1324
logging.basicConfig(level=logging.INFO)
1425

1526

1627
async def fetch_reports(priv_key: str) -> int:
17-
key = KeyPair.from_b64(priv_key)
18-
acc = await get_account_async(
19-
RemoteAnisetteProvider(ANISETTE_SERVER),
20-
)
28+
# Step 0: construct an account instance
29+
# We use a helper for this to simplify interactive authentication
30+
acc = await get_account_async(STORE_PATH, ANISETTE_SERVER, ANISETTE_LIBS_PATH)
2131

2232
try:
2333
print(f"Logged in as: {acc.account_name} ({acc.first_name} {acc.last_name})")
2434

25-
# It's that simple!
35+
# Step 1: construct a key object and get its location reports
36+
key = KeyPair.from_b64(priv_key)
2637
reports = await acc.fetch_last_reports(key)
38+
39+
# Step 2: print the reports!
2740
for report in sorted(reports):
2841
print(report)
42+
43+
# We can save the report to a file if we want
44+
report.to_json("last_report.json")
2945
finally:
3046
await acc.close()
3147

48+
# Make sure to save account state when you're done!
49+
acc.to_json(STORE_PATH)
50+
3251
return 0
3352

3453

examples/real_airtag.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,21 @@
1111
from _login import get_account_sync
1212

1313
from findmy import FindMyAccessory
14-
from findmy.reports import RemoteAnisetteProvider
1514

16-
# URL to (public or local) anisette server
17-
ANISETTE_SERVER = "http://localhost:6969"
15+
# Path where login session will be stored.
16+
# This is necessary to avoid generating a new session every time we log in.
17+
STORE_PATH = "account.json"
18+
19+
# URL to LOCAL anisette server. Set to None to use built-in Anisette generator instead (recommended)
20+
# IF YOU USE A PUBLIC SERVER, DO NOT COMPLAIN THAT YOU KEEP RUNNING INTO AUTHENTICATION ERRORS!
21+
# If you change this value, make sure to remove the account store file.
22+
ANISETTE_SERVER = None
23+
24+
# Path where Anisette libraries will be stored.
25+
# This is only relevant when using the built-in Anisette server.
26+
# It can be omitted (set to None) to avoid saving to disk,
27+
# but specifying a path is highly recommended to avoid downloading the bundle on every run.
28+
ANISETTE_LIBS_PATH = "ani_libs.bin"
1829

1930
logging.basicConfig(level=logging.INFO)
2031

@@ -26,8 +37,7 @@ def main(plist_path: str) -> int:
2637

2738
# Step 1: log into an Apple account
2839
print("Logging into account")
29-
anisette = RemoteAnisetteProvider(ANISETTE_SERVER)
30-
acc = get_account_sync(anisette)
40+
acc = get_account_sync(STORE_PATH, ANISETTE_SERVER, ANISETTE_LIBS_PATH)
3141

3242
# step 2: fetch reports!
3343
print("Fetching reports")
@@ -39,6 +49,9 @@ def main(plist_path: str) -> int:
3949
for report in sorted(reports):
4050
print(f" - {report}")
4151

52+
# step 4: save current account state to disk
53+
acc.to_json(STORE_PATH)
54+
4255
return 0
4356

4457

findmy/accessory.py

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,40 @@
66

77
from __future__ import annotations
88

9-
import json
109
import logging
1110
import plistlib
1211
from abc import ABC, abstractmethod
1312
from datetime import datetime, timedelta, timezone
1413
from pathlib import Path
15-
from typing import IO, TYPE_CHECKING, overload
14+
from typing import IO, TYPE_CHECKING, Literal, TypedDict, overload
1615

1716
from typing_extensions import override
1817

18+
from findmy.util.abc import Serializable
19+
from findmy.util.files import read_data_json, save_and_return_json
20+
1921
from .keys import KeyGenerator, KeyPair, KeyType
2022
from .util import crypto
2123

2224
if TYPE_CHECKING:
23-
from collections.abc import Generator, Mapping
25+
from collections.abc import Generator
2426

2527
logger = logging.getLogger(__name__)
2628

2729

30+
class FindMyAccessoryMapping(TypedDict):
31+
"""JSON mapping representing state of a FindMyAccessory instance."""
32+
33+
type: Literal["accessory"]
34+
master_key: str
35+
skn: str
36+
sks: str
37+
paired_at: str
38+
name: str | None
39+
model: str | None
40+
identifier: str | None
41+
42+
2843
class RollingKeyPairSource(ABC):
2944
"""A class that generates rolling `KeyPair`s."""
3045

@@ -67,7 +82,7 @@ def keys_between(self, start: int | datetime, end: int | datetime) -> set[KeyPai
6782
return keys
6883

6984

70-
class FindMyAccessory(RollingKeyPairSource):
85+
class FindMyAccessory(RollingKeyPairSource, Serializable[FindMyAccessoryMapping]):
7186
"""A findable Find My-accessory using official key rollover."""
7287

7388
def __init__( # noqa: PLR0913
@@ -242,9 +257,10 @@ def from_plist(
242257
identifier=identifier,
243258
)
244259

245-
def to_json(self, path: str | Path | None = None) -> dict[str, str | int | None]:
246-
"""Convert the accessory to a JSON-serializable dictionary."""
247-
d = {
260+
@override
261+
def to_json(self, path: str | Path | None = None, /) -> FindMyAccessoryMapping:
262+
res: FindMyAccessoryMapping = {
263+
"type": "accessory",
248264
"master_key": self._primary_gen.master_key.hex(),
249265
"skn": self.skn.hex(),
250266
"sks": self.sks.hex(),
@@ -253,23 +269,32 @@ def to_json(self, path: str | Path | None = None) -> dict[str, str | int | None]
253269
"model": self.model,
254270
"identifier": self.identifier,
255271
}
256-
if path is not None:
257-
Path(path).write_text(json.dumps(d, indent=4))
258-
return d
272+
273+
return save_and_return_json(res, path)
259274

260275
@classmethod
261-
def from_json(cls, json_: str | Path | Mapping, /) -> FindMyAccessory:
262-
"""Create a FindMyAccessory from a JSON file."""
263-
data = json.loads(Path(json_).read_text()) if isinstance(json_, (str, Path)) else json_
264-
return cls(
265-
master_key=bytes.fromhex(data["master_key"]),
266-
skn=bytes.fromhex(data["skn"]),
267-
sks=bytes.fromhex(data["sks"]),
268-
paired_at=datetime.fromisoformat(data["paired_at"]),
269-
name=data["name"],
270-
model=data["model"],
271-
identifier=data["identifier"],
272-
)
276+
@override
277+
def from_json(
278+
cls,
279+
val: str | Path | FindMyAccessoryMapping,
280+
/,
281+
) -> FindMyAccessory:
282+
val = read_data_json(val)
283+
assert val["type"] == "accessory"
284+
285+
try:
286+
return cls(
287+
master_key=bytes.fromhex(val["master_key"]),
288+
skn=bytes.fromhex(val["skn"]),
289+
sks=bytes.fromhex(val["sks"]),
290+
paired_at=datetime.fromisoformat(val["paired_at"]),
291+
name=val["name"],
292+
model=val["model"],
293+
identifier=val["identifier"],
294+
)
295+
except KeyError as e:
296+
msg = f"Failed to restore account data: {e}"
297+
raise ValueError(msg) from None
273298

274299

275300
class AccessoryKeyGenerator(KeyGenerator[KeyPair]):

0 commit comments

Comments
 (0)