baark 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.
baark/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ # Copyright (c) 2026 doabell.
2
+ """baark: typed Python bindings for Bark notifications at https://bark.day.app.
3
+
4
+ Use ``Baark`` or ``AsyncBaark`` for pooled connections, or ``send``/``async_send``
5
+ for one-off notifications. Encryption uses PyCryptodome.
6
+ """
7
+
8
+ from baark.client import AsyncBaark, Baark, async_send, send
9
+ from baark.crypto import EncryptedPayload, Encryption, EncryptionMode, KeyEncoding
10
+ from baark.errors import (
11
+ APIError,
12
+ APNSReason,
13
+ BaarkError,
14
+ BatchError,
15
+ EncryptionError,
16
+ ProtocolError,
17
+ TransportError,
18
+ ValidationError,
19
+ )
20
+ from baark.models import (
21
+ Action,
22
+ Delivery,
23
+ Endpoint,
24
+ JSONValue,
25
+ Level,
26
+ Message,
27
+ MessageOptions,
28
+ Registration,
29
+ Response,
30
+ ServerInfo,
31
+ Sound,
32
+ )
33
+
34
+ __all__ = [
35
+ "APIError",
36
+ "APNSReason",
37
+ "Action",
38
+ "AsyncBaark",
39
+ "Baark",
40
+ "BaarkError",
41
+ "BatchError",
42
+ "Delivery",
43
+ "EncryptedPayload",
44
+ "Encryption",
45
+ "EncryptionError",
46
+ "EncryptionMode",
47
+ "Endpoint",
48
+ "JSONValue",
49
+ "KeyEncoding",
50
+ "Level",
51
+ "Message",
52
+ "MessageOptions",
53
+ "ProtocolError",
54
+ "Registration",
55
+ "Response",
56
+ "ServerInfo",
57
+ "Sound",
58
+ "TransportError",
59
+ "ValidationError",
60
+ "async_send",
61
+ "send",
62
+ ]
baark/_protocol.py ADDED
@@ -0,0 +1,531 @@
1
+ # Copyright (c) 2026 doabell.
2
+ """Shared request construction and strict response parsing."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from collections import Counter
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass, field
10
+ from http import HTTPStatus
11
+ from typing import TYPE_CHECKING, cast
12
+ from urllib.parse import quote, unquote, urlencode
13
+
14
+ import httpx2
15
+
16
+ from baark.crypto import Encryption
17
+ from baark.errors import APIError, ProtocolError, invalid
18
+ from baark.models import (
19
+ Delivery,
20
+ Endpoint,
21
+ EndpointValue,
22
+ JSONValue,
23
+ Message,
24
+ MessageOptions,
25
+ Registration,
26
+ Response,
27
+ ServerInfo,
28
+ normalize_options,
29
+ )
30
+
31
+ DEFAULT_SERVER = "https://api.day.app"
32
+ _MAX_DEVICE_TOKEN_LENGTH = 160
33
+ _MESSAGE_QUERY_FIELDS = frozenset(
34
+ name.lower()
35
+ for name in (
36
+ *MessageOptions.__annotations__,
37
+ "body",
38
+ "autoCopy",
39
+ "isArchive",
40
+ "device_key",
41
+ "device_keys",
42
+ "device_token",
43
+ )
44
+ )
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import Sequence
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Config:
52
+ """HTTP-independent settings shared by sync and async clients."""
53
+
54
+ server_url: str
55
+ device_key: str | None = field(repr=False)
56
+ encryption: Encryption | None
57
+ defaults: MessageOptions = field(repr=False)
58
+ endpoint: Endpoint
59
+
60
+ def url(self, path: str) -> str:
61
+ """Append an endpoint while preserving reverse-proxy path prefixes.
62
+
63
+ Args:
64
+ path: Relative endpoint path.
65
+
66
+ Returns:
67
+ An absolute URL.
68
+ """
69
+ return f"{self.server_url}/{path.lstrip('/')}"
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class RequestSpec:
74
+ """A complete request that either HTTP client can send."""
75
+
76
+ method: str
77
+ url: str = field(repr=False)
78
+ content: bytes | None = field(default=None, repr=False)
79
+ content_type: str | None = None
80
+ expected_keys: tuple[str, ...] | None = field(default=None, repr=False)
81
+ query_fields: frozenset[str] = field(default=_MESSAGE_QUERY_FIELDS, repr=False)
82
+
83
+
84
+ def configure(
85
+ device_key: str | None,
86
+ *,
87
+ server_url: str | None,
88
+ encryption: Encryption | None,
89
+ defaults: MessageOptions,
90
+ endpoint: Endpoint | EndpointValue,
91
+ ) -> Config:
92
+ """Validate configuration and optionally split a copied device URL.
93
+
94
+ Args:
95
+ device_key: A key or a URL ending in the key.
96
+ server_url: Optional server URL, including a reverse-proxy prefix.
97
+ encryption: Optional payload encryption.
98
+ defaults: Default message options.
99
+ endpoint: Default request format.
100
+
101
+ Returns:
102
+ Validated client configuration.
103
+ """
104
+ if device_key is not None and not isinstance(device_key, str):
105
+ invalid("device_key must be a string or a device URL.")
106
+ if device_key is not None and "://" in device_key:
107
+ if server_url is not None:
108
+ invalid("Pass either a device URL or a separate key and server_url.")
109
+ device_url = _server_url(device_key)
110
+ server_url, separator, encoded_key = device_url.rpartition("/")
111
+ if not separator or not encoded_key or server_url.endswith(":/"):
112
+ invalid("A device URL must end in a device key.")
113
+ device_key = unquote(encoded_key)
114
+ if device_key is not None:
115
+ validate_key(device_key)
116
+ if encryption is not None and not isinstance(encryption, Encryption):
117
+ invalid("encryption must be an Encryption instance.")
118
+ selected_endpoint = parse_endpoint(endpoint)
119
+ normalized = normalize_options(defaults)
120
+ Message(**normalized)
121
+ return Config(
122
+ server_url=_server_url(
123
+ server_url if server_url is not None else DEFAULT_SERVER
124
+ ),
125
+ device_key=device_key,
126
+ encryption=encryption,
127
+ defaults=normalized,
128
+ endpoint=selected_endpoint,
129
+ )
130
+
131
+
132
+ def _server_url(value: str) -> str:
133
+ if not isinstance(value, str):
134
+ invalid("server_url must be an absolute HTTP or HTTPS URL.")
135
+ try:
136
+ url = httpx2.URL(value)
137
+ except httpx2.InvalidURL:
138
+ invalid("server_url is not a valid URL.")
139
+ if url.scheme not in {"http", "https"} or not url.host:
140
+ invalid("server_url must be an absolute HTTP or HTTPS URL.")
141
+ if url.username or url.password or url.query or url.fragment:
142
+ invalid("Server URLs cannot contain credentials, query strings, or fragments.")
143
+ return str(url).rstrip("/")
144
+
145
+
146
+ def validate_key(key: str) -> str:
147
+ """Validate a key without including it in diagnostic messages.
148
+
149
+ Args:
150
+ key: Device key.
151
+
152
+ Returns:
153
+ The validated key.
154
+ """
155
+ if (
156
+ not isinstance(key, str)
157
+ or not key
158
+ or not key.isprintable()
159
+ or any(character.isspace() for character in key)
160
+ or any(character in key for character in "/\\?#")
161
+ or key in {".", ".."}
162
+ ):
163
+ invalid("A device key must be a non-empty URL segment without whitespace.")
164
+ return key
165
+
166
+
167
+ def parse_endpoint(value: Endpoint | EndpointValue) -> Endpoint:
168
+ """Resolve a public endpoint option.
169
+
170
+ Args:
171
+ value: An endpoint enum or its string value.
172
+
173
+ Returns:
174
+ The endpoint enum.
175
+ """
176
+ try:
177
+ return Endpoint(value)
178
+ except ValueError:
179
+ invalid("endpoint must be push, device, form, or get.")
180
+
181
+
182
+ def prepare_push( # noqa: PLR0913 - Mirrors the public send options.
183
+ config: Config,
184
+ body: str | Message,
185
+ *,
186
+ options: MessageOptions,
187
+ device_key: str | None,
188
+ device_keys: Sequence[str] | None,
189
+ endpoint: Endpoint | EndpointValue | None,
190
+ ) -> RequestSpec:
191
+ """Build a push request without performing network I/O.
192
+
193
+ Args:
194
+ config: Client configuration.
195
+ body: Text or a reusable message.
196
+ options: Per-call overrides; explicit None removes defaults.
197
+ device_key: Optional single recipient override.
198
+ device_keys: Optional server-side batch recipients.
199
+ endpoint: Optional request format override.
200
+
201
+ Returns:
202
+ A request specification ready for either client.
203
+ """
204
+ payload = _message(config, body, options).to_payload()
205
+ query_fields = _query_field_names(payload)
206
+ if config.encryption is not None:
207
+ if "ciphertext" in payload or "iv" in payload:
208
+ invalid(
209
+ "Pre-encrypted ciphertext cannot be combined with client encryption."
210
+ )
211
+ encrypted: dict[str, JSONValue] = dict(
212
+ config.encryption.encrypt(_json(payload)).to_payload()
213
+ )
214
+ # APNs collapse IDs and background deletion are processed before decryption.
215
+ for name in ("id", "delete"):
216
+ if name in payload:
217
+ encrypted[name] = payload[name]
218
+ payload = encrypted
219
+
220
+ selected = config.endpoint if endpoint is None else parse_endpoint(endpoint)
221
+ keys = _recipients(config, device_key, device_keys)
222
+ expected = keys if device_keys is not None else None
223
+ if selected != Endpoint.PUSH and device_keys is not None:
224
+ invalid("device_keys batches require the push JSON endpoint.")
225
+ if selected == Endpoint.PUSH:
226
+ if expected is not None:
227
+ payload["device_keys"] = list(keys)
228
+ else:
229
+ payload["device_key"] = keys[0]
230
+ return json_request(
231
+ config, "push", payload, expected_keys=expected, query_fields=query_fields
232
+ )
233
+ path = quote(keys[0], safe="")
234
+ if selected == Endpoint.DEVICE:
235
+ return json_request(config, path, payload, query_fields=query_fields)
236
+ encoded = urlencode(_query_fields(payload))
237
+ if selected == Endpoint.GET:
238
+ return RequestSpec(
239
+ "GET",
240
+ f"{config.url(path)}?{encoded}",
241
+ query_fields=query_fields,
242
+ )
243
+ return RequestSpec(
244
+ "POST",
245
+ config.url(path),
246
+ encoded.encode("utf-8"),
247
+ "application/x-www-form-urlencoded; charset=utf-8",
248
+ query_fields=query_fields,
249
+ )
250
+
251
+
252
+ def _message(config: Config, body: str | Message, options: MessageOptions) -> Message:
253
+ merged = normalize_options(config.defaults)
254
+ if isinstance(body, Message):
255
+ merged.update(body.options())
256
+ body = body.body
257
+ merged.update(normalize_options(options))
258
+ return Message(body, **merged)
259
+
260
+
261
+ def _recipients(
262
+ config: Config, device_key: str | None, device_keys: Sequence[str] | None
263
+ ) -> tuple[str, ...]:
264
+ if device_keys is not None:
265
+ if device_key is not None:
266
+ invalid("Specify device_key or device_keys, not both.")
267
+ if isinstance(device_keys, (str, bytes)) or not device_keys:
268
+ invalid("device_keys must be a non-empty sequence of device keys.")
269
+ return tuple(validate_key(key) for key in device_keys)
270
+ key = config.device_key if device_key is None else device_key
271
+ if key is None:
272
+ invalid("Provide a device key to send a notification.")
273
+ return (validate_key(key),)
274
+
275
+
276
+ def _query_fields(payload: dict[str, JSONValue]) -> dict[str, str]:
277
+ result: dict[str, str] = {}
278
+ for name, value in payload.items():
279
+ if isinstance(value, (dict, list)):
280
+ invalid("Structured extension fields require a JSON endpoint.")
281
+ result[name] = str(value) if value is not None else ""
282
+ return result
283
+
284
+
285
+ def _query_field_names(payload: dict[str, JSONValue]) -> frozenset[str]:
286
+ names = set(payload)
287
+ for value in payload.values():
288
+ if isinstance(value, dict):
289
+ names.update(value)
290
+ return _MESSAGE_QUERY_FIELDS | frozenset(name.lower() for name in names)
291
+
292
+
293
+ def _json(payload: dict[str, JSONValue]) -> str:
294
+ return json.dumps(
295
+ payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
296
+ )
297
+
298
+
299
+ def json_request(
300
+ config: Config,
301
+ path: str,
302
+ payload: dict[str, JSONValue],
303
+ *,
304
+ expected_keys: tuple[str, ...] | None = None,
305
+ query_fields: frozenset[str] = frozenset(),
306
+ ) -> RequestSpec:
307
+ """Build a JSON POST request.
308
+
309
+ Args:
310
+ config: Client configuration.
311
+ path: Endpoint path relative to the server prefix.
312
+ payload: JSON object to send.
313
+ expected_keys: Recipients expected in a batch response.
314
+ query_fields: Message fields to protect even when hidden by encryption.
315
+
316
+ Returns:
317
+ Encoded request specification.
318
+ """
319
+ return RequestSpec(
320
+ "POST",
321
+ config.url(path),
322
+ _json(payload).encode("utf-8"),
323
+ "application/json; charset=utf-8",
324
+ expected_keys,
325
+ _query_field_names(payload) | query_fields,
326
+ )
327
+
328
+
329
+ def registration_request(
330
+ config: Config, device_token: str, device_key: str | None
331
+ ) -> RequestSpec:
332
+ """Build a registration request without implicitly reusing the client key.
333
+
334
+ Args:
335
+ config: Client configuration.
336
+ device_token: APNs device token obtained from the app.
337
+ device_key: Explicit existing or custom key; None asks for a new key.
338
+
339
+ Returns:
340
+ A JSON POST to the registration endpoint.
341
+ """
342
+ if (
343
+ not isinstance(device_token, str)
344
+ or not device_token
345
+ or len(device_token) > _MAX_DEVICE_TOKEN_LENGTH
346
+ ):
347
+ invalid("device_token must contain between 1 and 160 characters.")
348
+ payload: dict[str, JSONValue] = {"device_token": device_token}
349
+ if device_key is not None:
350
+ payload["device_key"] = validate_key(device_key)
351
+ return json_request(config, "register", payload)
352
+
353
+
354
+ def check_request(config: Config, device_key: str | None) -> RequestSpec:
355
+ """Build a registration check for a supplied or configured key.
356
+
357
+ Args:
358
+ config: Client configuration.
359
+ device_key: Optional key override.
360
+
361
+ Returns:
362
+ A GET request to the registration-check endpoint.
363
+ """
364
+ key = _recipients(config, device_key, None)[0]
365
+ return RequestSpec("GET", config.url(f"register/{quote(key, safe='')}"))
366
+
367
+
368
+ def read_json(response: httpx2.Response) -> dict[str, JSONValue]:
369
+ """Decode an object response and detect HTTP errors.
370
+
371
+ Args:
372
+ response: HTTP response.
373
+
374
+ Returns:
375
+ A decoded JSON object.
376
+
377
+ Raises:
378
+ APIError: For an HTTP error.
379
+ ProtocolError: For a malformed successful response.
380
+ """
381
+ raw: object = None
382
+ with suppress(ValueError):
383
+ raw = response.json()
384
+ if not response.is_success:
385
+ code = raw.get("code") if isinstance(raw, dict) else None
386
+ message = raw.get("message") if isinstance(raw, dict) else None
387
+ raise APIError(
388
+ status_code=response.status_code,
389
+ code=code if type(code) is int else None,
390
+ server_message=message if isinstance(message, str) else None,
391
+ )
392
+ if not isinstance(raw, dict):
393
+ message = "Expected a Bark JSON object; received an invalid response."
394
+ raise ProtocolError(message)
395
+ return cast("dict[str, JSONValue]", raw)
396
+
397
+
398
+ def parse_response(
399
+ response: httpx2.Response, *, expected_keys: tuple[str, ...] | None = None
400
+ ) -> Response:
401
+ """Validate a Bark envelope and every expected batch outcome.
402
+
403
+ Args:
404
+ response: HTTP response.
405
+ expected_keys: Recipients of the corresponding batch, if any.
406
+
407
+ Returns:
408
+ A typed response.
409
+
410
+ Raises:
411
+ APIError: For HTTP or Bark application errors.
412
+ ProtocolError: For malformed or incomplete responses.
413
+ BatchError: When any batch recipient failed.
414
+ """
415
+ raw = read_json(response)
416
+ code = raw.get("code")
417
+ message = raw.get("message")
418
+ timestamp = raw.get("timestamp")
419
+ if (
420
+ type(code) is not int
421
+ or not isinstance(message, str)
422
+ or (timestamp is not None and type(timestamp) is not int)
423
+ ):
424
+ detail = "Bark response has invalid or missing code, message, or timestamp."
425
+ raise ProtocolError(detail)
426
+ if code != HTTPStatus.OK:
427
+ raise APIError(
428
+ status_code=response.status_code, code=code, server_message=message
429
+ )
430
+ deliveries = ()
431
+ if expected_keys is not None:
432
+ deliveries = _deliveries(raw.get("data"), expected_keys)
433
+ result = Response(
434
+ code, message, timestamp, raw.get("data"), deliveries, response.status_code
435
+ )
436
+ result.raise_for_status()
437
+ return result
438
+
439
+
440
+ def _deliveries(
441
+ data: JSONValue, expected_keys: tuple[str, ...]
442
+ ) -> tuple[Delivery, ...]:
443
+ if not isinstance(data, list):
444
+ message = "Bark batch response is missing per-device results."
445
+ raise ProtocolError(message)
446
+ result: list[Delivery] = []
447
+ for item in data:
448
+ if not isinstance(item, dict):
449
+ message = "Bark batch result must be an object."
450
+ raise ProtocolError(message)
451
+ key, code, detail = (
452
+ item.get("device_key"),
453
+ item.get("code"),
454
+ item.get("message", ""),
455
+ )
456
+ if (
457
+ not isinstance(key, str)
458
+ or type(code) is not int
459
+ or not isinstance(detail, str)
460
+ ):
461
+ message = "Bark batch result has invalid or missing fields."
462
+ raise ProtocolError(message)
463
+ result.append(Delivery(key, code, detail))
464
+ if Counter(item.device_key for item in result) != Counter(expected_keys):
465
+ message = "Bark batch results do not match the requested recipients."
466
+ raise ProtocolError(message)
467
+ return tuple(result)
468
+
469
+
470
+ def parse_info(response: httpx2.Response) -> ServerInfo:
471
+ """Read the unwrapped server-info object.
472
+
473
+ Args:
474
+ response: HTTP response from /info.
475
+
476
+ Returns:
477
+ Validated server information.
478
+ """
479
+ raw = read_json(response)
480
+ names = ("version", "build", "arch", "commit")
481
+ if (
482
+ any(not isinstance(raw.get(name), str) for name in names)
483
+ or type(raw.get("devices")) is not int
484
+ ):
485
+ message = "Bark server-info response has invalid or missing fields."
486
+ raise ProtocolError(message)
487
+ return ServerInfo(
488
+ version=cast("str", raw["version"]),
489
+ build=cast("str", raw["build"]),
490
+ arch=cast("str", raw["arch"]),
491
+ commit=cast("str", raw["commit"]),
492
+ devices=cast("int", raw["devices"]),
493
+ )
494
+
495
+
496
+ def parse_registration(response: httpx2.Response) -> Registration:
497
+ """Validate the registration envelope and extract device credentials.
498
+
499
+ Args:
500
+ response: HTTP response from /register.
501
+
502
+ Returns:
503
+ Registered device credentials.
504
+ """
505
+ data = parse_response(response).data
506
+ if not isinstance(data, dict):
507
+ message = "Bark registration response is missing device credentials."
508
+ raise ProtocolError(message)
509
+ key = data.get("device_key", data.get("key"))
510
+ token = data.get("device_token")
511
+ if not isinstance(key, str) or not key or not isinstance(token, str) or not token:
512
+ message = "Bark registration response has invalid device credentials."
513
+ raise ProtocolError(message)
514
+ return Registration(key, token)
515
+
516
+
517
+ def parse_health(response: httpx2.Response) -> str:
518
+ """Validate the plain-text response used by the root and health endpoints.
519
+
520
+ Args:
521
+ response: HTTP response.
522
+
523
+ Returns:
524
+ The string ``ok``.
525
+ """
526
+ if not response.is_success:
527
+ raise APIError(status_code=response.status_code)
528
+ if response.text.strip() != "ok":
529
+ message = "Expected 'ok' from the Bark health endpoint."
530
+ raise ProtocolError(message)
531
+ return "ok"