simplepush 3.6.1__tar.gz → 3.7.1__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.
- {simplepush-3.6.1 → simplepush-3.7.1}/PKG-INFO +4 -3
- {simplepush-3.6.1 → simplepush-3.7.1}/README.md +3 -2
- {simplepush-3.6.1 → simplepush-3.7.1}/pyproject.toml +1 -1
- {simplepush-3.6.1 → simplepush-3.7.1}/src/simplepush/api.py +45 -22
- {simplepush-3.6.1 → simplepush-3.7.1}/src/simplepush/client.py +10 -4
- {simplepush-3.6.1 → simplepush-3.7.1}/.gitignore +0 -0
- {simplepush-3.6.1 → simplepush-3.7.1}/LICENSE +0 -0
- {simplepush-3.6.1 → simplepush-3.7.1}/src/simplepush/__init__.py +0 -0
- {simplepush-3.6.1 → simplepush-3.7.1}/src/simplepush/_extras.py +0 -0
- {simplepush-3.6.1 → simplepush-3.7.1}/src/simplepush/crypto.py +0 -0
- {simplepush-3.6.1 → simplepush-3.7.1}/src/simplepush/decrypt.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simplepush
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.7.1
|
|
4
4
|
Summary: Python client for Simplepush (tasks, notifications, events, end-to-end encryption).
|
|
5
5
|
Project-URL: Homepage, https://simplepu.sh
|
|
6
6
|
Project-URL: Documentation, https://simplepu.sh/guide/python-sdk
|
|
@@ -113,8 +113,9 @@ form at once (by default each filled input arrives as an intermediate
|
|
|
113
113
|
set); `reply=ReplyMode.STICKY` (or `"one-shot"` / `"one-time-per-user"`) shows
|
|
114
114
|
recipients an in-thread reply composer (collect via `replies()`);
|
|
115
115
|
`content_format=ContentFormat.MARKDOWN` renders a task's `content` as
|
|
116
|
-
Markdown (notifications are always plain); `
|
|
117
|
-
|
|
116
|
+
Markdown (notifications are always plain); `priority=` sets how loudly the
|
|
117
|
+
push interrupts, 1 (minimal) to 5 (critical; sounds even on a muted phone),
|
|
118
|
+
default 3, with `critical_volume=` (0 to 1) for level 5 on iOS.
|
|
118
119
|
|
|
119
120
|
A task can have **subtasks** appended to its chain. A subtask inherits the
|
|
120
121
|
parent's recipients and encryption (no target, no password); its `inputs()` /
|
|
@@ -63,8 +63,9 @@ form at once (by default each filled input arrives as an intermediate
|
|
|
63
63
|
set); `reply=ReplyMode.STICKY` (or `"one-shot"` / `"one-time-per-user"`) shows
|
|
64
64
|
recipients an in-thread reply composer (collect via `replies()`);
|
|
65
65
|
`content_format=ContentFormat.MARKDOWN` renders a task's `content` as
|
|
66
|
-
Markdown (notifications are always plain); `
|
|
67
|
-
|
|
66
|
+
Markdown (notifications are always plain); `priority=` sets how loudly the
|
|
67
|
+
push interrupts, 1 (minimal) to 5 (critical; sounds even on a muted phone),
|
|
68
|
+
default 3, with `critical_volume=` (0 to 1) for level 5 on iOS.
|
|
68
69
|
|
|
69
70
|
A task can have **subtasks** appended to its chain. A subtask inherits the
|
|
70
71
|
parent's recipients and encryption (no target, no password); its `inputs()` /
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simplepush"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.7.1"
|
|
8
8
|
description = "Python client for Simplepush (tasks, notifications, events, end-to-end encryption)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -56,12 +56,33 @@ from .client import (
|
|
|
56
56
|
Event, GroupCancelResult, InputEvent, Notification, NotificationGroup,
|
|
57
57
|
NotificationGroupRecipient, RawEvents, Reply, Subtask, Submissions, Task,
|
|
58
58
|
TaskGroup, TaskGroupRecipient,
|
|
59
|
-
_DownloadTransport, _FileBinder, _Hub,
|
|
59
|
+
_HTTP_TIMEOUT, _DownloadTransport, _FileBinder, _Hub,
|
|
60
60
|
)
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
# --- Wire serialization helpers ---
|
|
64
64
|
|
|
65
|
+
def _priority_wire(priority, critical, critical_volume) -> dict:
|
|
66
|
+
"""The `priority` / `criticalVolume` fields of a create request. `priority`
|
|
67
|
+
is 1 (minimal) to 5 (critical), absent = 3; the deprecated `critical` flag
|
|
68
|
+
means 5 when no priority is given. `critical_volume` (0 < v <= 1) is the
|
|
69
|
+
iOS critical alert volume and is only valid with level 5."""
|
|
70
|
+
level = priority if priority is not None else (5 if critical else None)
|
|
71
|
+
if level is not None and (not isinstance(level, int) or isinstance(level, bool) or not 1 <= level <= 5):
|
|
72
|
+
raise ValueError("priority must be an integer between 1 and 5")
|
|
73
|
+
if critical_volume is not None:
|
|
74
|
+
if level != 5:
|
|
75
|
+
raise ValueError("critical_volume applies to priority 5 only")
|
|
76
|
+
if not 0 < critical_volume <= 1:
|
|
77
|
+
raise ValueError("critical_volume must be greater than 0 and at most 1")
|
|
78
|
+
out: dict = {}
|
|
79
|
+
if level is not None:
|
|
80
|
+
out["priority"] = level
|
|
81
|
+
if critical_volume is not None:
|
|
82
|
+
out["criticalVolume"] = critical_volume
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
65
86
|
def _expires_at_wire(value) -> str:
|
|
66
87
|
"""Serialize a task deadline to wire ISO-8601. A naive datetime is
|
|
67
88
|
rejected rather than guessed at — the server compares against UTC."""
|
|
@@ -547,7 +568,7 @@ class _BaseClient:
|
|
|
547
568
|
method="GET",
|
|
548
569
|
)
|
|
549
570
|
try:
|
|
550
|
-
with urllib.request.urlopen(req) as resp:
|
|
571
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
|
|
551
572
|
info = json.loads(resp.read().decode("utf-8"))
|
|
552
573
|
except urllib.error.HTTPError as e:
|
|
553
574
|
raise ApiError(e.code, e.read().decode("utf-8")) from e
|
|
@@ -595,6 +616,8 @@ class _BaseClient:
|
|
|
595
616
|
password: str | None = None,
|
|
596
617
|
tag: str | None = None,
|
|
597
618
|
critical: bool = False,
|
|
619
|
+
priority: int | None = None,
|
|
620
|
+
critical_volume: float | None = None,
|
|
598
621
|
reply: ReplyMode | Literal["one-shot", "sticky", "one-time-per-user"] | None = None,
|
|
599
622
|
content_format: ContentFormat | Literal["plain", "markdown"] | None = None,
|
|
600
623
|
shared: bool = False,
|
|
@@ -667,7 +690,7 @@ class _BaseClient:
|
|
|
667
690
|
links=links, files=files,
|
|
668
691
|
auto_commit=auto_commit,
|
|
669
692
|
password=password if password is not None else self._send_password(topic),
|
|
670
|
-
tag=tag, critical=critical, reply=reply,
|
|
693
|
+
tag=tag, critical=critical, priority=priority, critical_volume=critical_volume, reply=reply,
|
|
671
694
|
content_format=content_format, shared=shared,
|
|
672
695
|
expires_at=expires_at,
|
|
673
696
|
)
|
|
@@ -687,6 +710,8 @@ class _BaseClient:
|
|
|
687
710
|
password: str | None = None,
|
|
688
711
|
tag: str | None = None,
|
|
689
712
|
critical: bool = False,
|
|
713
|
+
priority: int | None = None,
|
|
714
|
+
critical_volume: float | None = None,
|
|
690
715
|
shared: bool = False,
|
|
691
716
|
) -> "Notification | NotificationGroup":
|
|
692
717
|
"""Send a notification and return a handle to its event stream.
|
|
@@ -757,13 +782,13 @@ class _BaseClient:
|
|
|
757
782
|
title=title, content=content, input=input,
|
|
758
783
|
image=image, audio=audio, link=link,
|
|
759
784
|
password=password if password is not None else self._send_password(topic),
|
|
760
|
-
tag=tag, critical=critical, shared=shared,
|
|
785
|
+
tag=tag, critical=critical, priority=priority, critical_volume=critical_volume, shared=shared,
|
|
761
786
|
)
|
|
762
787
|
|
|
763
788
|
def _create_task(self, *, topic=None, member=None, broadcast=False,
|
|
764
789
|
title=None, content=None, inputs=None, links=None,
|
|
765
790
|
files=None,
|
|
766
|
-
auto_commit=False, password=None, tag=None, critical=False,
|
|
791
|
+
auto_commit=False, password=None, tag=None, critical=False, priority=None, critical_volume=None,
|
|
767
792
|
reply=None, content_format=None, shared=False,
|
|
768
793
|
expires_at=None) -> "Task | TaskGroup":
|
|
769
794
|
if not content and not inputs:
|
|
@@ -893,8 +918,7 @@ class _BaseClient:
|
|
|
893
918
|
body["title"] = title
|
|
894
919
|
if content is not None:
|
|
895
920
|
body["content"] = content
|
|
896
|
-
|
|
897
|
-
body["critical"] = critical
|
|
921
|
+
body.update(_priority_wire(priority, critical, critical_volume))
|
|
898
922
|
if reply is not None:
|
|
899
923
|
body["reply"] = reply.value if isinstance(reply, ReplyMode) else reply
|
|
900
924
|
if content_format is not None:
|
|
@@ -971,7 +995,7 @@ class _BaseClient:
|
|
|
971
995
|
|
|
972
996
|
def _create_notification(self, *, topic=None, member=None, broadcast=False,
|
|
973
997
|
title=None, content=None, input=None, image=None, audio=None,
|
|
974
|
-
link=None, password=None, tag=None, critical=False,
|
|
998
|
+
link=None, password=None, tag=None, critical=False, priority=None, critical_volume=None,
|
|
975
999
|
shared=False) -> "Notification | NotificationGroup":
|
|
976
1000
|
if not content and input is None:
|
|
977
1001
|
raise ValueError("Either content or an input must be provided")
|
|
@@ -1098,8 +1122,7 @@ class _BaseClient:
|
|
|
1098
1122
|
payload["choiceInput"] = {"options": choice_options}
|
|
1099
1123
|
elif action_defs is not None:
|
|
1100
1124
|
payload["actionInput"] = {"actions": action_defs}
|
|
1101
|
-
|
|
1102
|
-
payload["critical"] = critical
|
|
1125
|
+
payload.update(_priority_wire(priority, critical, critical_volume))
|
|
1103
1126
|
if encryption_dict is not None:
|
|
1104
1127
|
payload["encryption"] = encryption_dict
|
|
1105
1128
|
if shared:
|
|
@@ -1227,7 +1250,7 @@ class _BaseClient:
|
|
|
1227
1250
|
|
|
1228
1251
|
def _build_subtask_data(self, send_key, *, title=None, content=None, inputs=None,
|
|
1229
1252
|
links=None, files=None,
|
|
1230
|
-
auto_commit=False, critical=False,
|
|
1253
|
+
auto_commit=False, critical=False, priority=None, critical_volume=None,
|
|
1231
1254
|
reply=None, content_format=None) -> "tuple[dict, list]":
|
|
1232
1255
|
"""Build the (encrypted) `data` dict of a subtask append plus the
|
|
1233
1256
|
prepared local attachments awaiting upload — shared by the single-task
|
|
@@ -1320,8 +1343,7 @@ class _BaseClient:
|
|
|
1320
1343
|
data["title"] = title
|
|
1321
1344
|
if content is not None:
|
|
1322
1345
|
data["content"] = content
|
|
1323
|
-
|
|
1324
|
-
data["critical"] = critical
|
|
1346
|
+
data.update(_priority_wire(priority, critical, critical_volume))
|
|
1325
1347
|
if reply is not None:
|
|
1326
1348
|
data["reply"] = reply.value if isinstance(reply, ReplyMode) else reply
|
|
1327
1349
|
if content_format is not None:
|
|
@@ -1332,14 +1354,14 @@ class _BaseClient:
|
|
|
1332
1354
|
|
|
1333
1355
|
def _append_subtask(self, task, *, title=None, content=None, inputs=None,
|
|
1334
1356
|
links=None, files=None,
|
|
1335
|
-
auto_commit=False, critical=False,
|
|
1357
|
+
auto_commit=False, critical=False, priority=None, critical_volume=None,
|
|
1336
1358
|
reply=None, content_format=None) -> Subtask:
|
|
1337
1359
|
if not task.append_token:
|
|
1338
1360
|
raise RuntimeError("this task has no append token; cannot append a subtask")
|
|
1339
1361
|
# Inherit the parent's key (subtask encryption must match the chain's).
|
|
1340
1362
|
data, prepared = self._build_subtask_data(
|
|
1341
1363
|
task._send_key, title=title, content=content, inputs=inputs,
|
|
1342
|
-
links=links, files=files, auto_commit=auto_commit, critical=critical,
|
|
1364
|
+
links=links, files=files, auto_commit=auto_commit, critical=critical, priority=priority, critical_volume=critical_volume,
|
|
1343
1365
|
reply=reply, content_format=content_format,
|
|
1344
1366
|
)
|
|
1345
1367
|
|
|
@@ -1369,7 +1391,7 @@ class _BaseClient:
|
|
|
1369
1391
|
|
|
1370
1392
|
def _append_subtasks_to_group(self, group, *, instances=None, title=None,
|
|
1371
1393
|
content=None, inputs=None, links=None, files=None,
|
|
1372
|
-
auto_commit=False, critical=False,
|
|
1394
|
+
auto_commit=False, critical=False, priority=None, critical_volume=None,
|
|
1373
1395
|
reply=None, content_format=None) -> "list[Subtask]":
|
|
1374
1396
|
"""Append one subtask per member instance to a task group's chains,
|
|
1375
1397
|
atomically, via the group append token. `instances` (task ids) restricts
|
|
@@ -1381,7 +1403,7 @@ class _BaseClient:
|
|
|
1381
1403
|
raise RuntimeError("this task group has no append token; cannot append a subtask")
|
|
1382
1404
|
data, prepared = self._build_subtask_data(
|
|
1383
1405
|
group._send_key, title=title, content=content, inputs=inputs,
|
|
1384
|
-
links=links, files=files, auto_commit=auto_commit, critical=critical,
|
|
1406
|
+
links=links, files=files, auto_commit=auto_commit, critical=critical, priority=priority, critical_volume=critical_volume,
|
|
1385
1407
|
reply=reply, content_format=content_format,
|
|
1386
1408
|
)
|
|
1387
1409
|
|
|
@@ -1502,7 +1524,7 @@ class _BaseClient:
|
|
|
1502
1524
|
attempt += 1
|
|
1503
1525
|
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
|
1504
1526
|
try:
|
|
1505
|
-
with urllib.request.urlopen(req) as resp:
|
|
1527
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
|
|
1506
1528
|
resp_body = resp.read().decode("utf-8")
|
|
1507
1529
|
if resp_body:
|
|
1508
1530
|
return json.loads(resp_body)
|
|
@@ -1513,8 +1535,9 @@ class _BaseClient:
|
|
|
1513
1535
|
if not (retry and retryable) or attempt >= self._RETRY_MAX_ATTEMPTS:
|
|
1514
1536
|
raise ApiError(e.code, payload) from e
|
|
1515
1537
|
time.sleep(self._retry_delay(attempt, e.headers.get("Retry-After")))
|
|
1516
|
-
except urllib.error.URLError as e:
|
|
1517
|
-
# Network-level failure (connection refused/reset, DNS
|
|
1538
|
+
except (urllib.error.URLError, TimeoutError) as e:
|
|
1539
|
+
# Network-level failure (connection refused/reset, DNS, or no
|
|
1540
|
+
# response within the timeout).
|
|
1518
1541
|
if not retry or attempt >= self._RETRY_MAX_ATTEMPTS:
|
|
1519
1542
|
raise
|
|
1520
1543
|
time.sleep(self._retry_delay(attempt, None))
|
|
@@ -1526,7 +1549,7 @@ class _BaseClient:
|
|
|
1526
1549
|
url = f"{self._base}{path}"
|
|
1527
1550
|
req = urllib.request.Request(url, data=b"", headers=dict(headers or {}), method="POST")
|
|
1528
1551
|
try:
|
|
1529
|
-
with urllib.request.urlopen(req) as resp:
|
|
1552
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
|
|
1530
1553
|
resp_body = resp.read().decode("utf-8")
|
|
1531
1554
|
return json.loads(resp_body) if resp_body else None
|
|
1532
1555
|
except urllib.error.HTTPError as e:
|
|
@@ -1539,7 +1562,7 @@ class _BaseClient:
|
|
|
1539
1562
|
req = urllib.request.Request(url, data=data, method="PUT")
|
|
1540
1563
|
req.add_header("Content-Type", "application/octet-stream")
|
|
1541
1564
|
try:
|
|
1542
|
-
with urllib.request.urlopen(req) as resp:
|
|
1565
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
|
|
1543
1566
|
resp.read()
|
|
1544
1567
|
except urllib.error.HTTPError as e:
|
|
1545
1568
|
raise ApiError(e.code, e.read().decode("utf-8")) from e
|
|
@@ -49,6 +49,10 @@ from urllib.parse import quote
|
|
|
49
49
|
import websockets
|
|
50
50
|
import websockets.exceptions
|
|
51
51
|
|
|
52
|
+
# Seconds an HTTP request waits for the connection or the next data before it
|
|
53
|
+
# fails. It bounds each wait, not a whole upload or download.
|
|
54
|
+
_HTTP_TIMEOUT = 30
|
|
55
|
+
|
|
52
56
|
if TYPE_CHECKING:
|
|
53
57
|
# Type-only: api.py imports from this module at runtime, so a real import
|
|
54
58
|
# here would be circular. The enum-or-Literal unions below give static
|
|
@@ -276,7 +280,7 @@ class _DownloadTransport:
|
|
|
276
280
|
url = f"{self._base}/{scope}/{quote(str(scope_id))}/{kind}/{quote(str(file_id))}/download-url"
|
|
277
281
|
req = urllib.request.Request(url, data=b"", headers=self._headers, method="POST")
|
|
278
282
|
try:
|
|
279
|
-
with urllib.request.urlopen(req) as resp:
|
|
283
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
|
|
280
284
|
body = resp.read().decode("utf-8")
|
|
281
285
|
except urllib.error.HTTPError as e:
|
|
282
286
|
detail = e.read().decode("utf-8", "replace")
|
|
@@ -286,7 +290,7 @@ class _DownloadTransport:
|
|
|
286
290
|
|
|
287
291
|
def get(self, url: str) -> bytes:
|
|
288
292
|
try:
|
|
289
|
-
with urllib.request.urlopen(url) as resp:
|
|
293
|
+
with urllib.request.urlopen(url, timeout=_HTTP_TIMEOUT) as resp:
|
|
290
294
|
return resp.read()
|
|
291
295
|
except urllib.error.HTTPError as e:
|
|
292
296
|
raise DownloadError(f"file fetch failed: HTTP {e.code}", status_code=e.code) from e
|
|
@@ -1472,6 +1476,7 @@ class Task:
|
|
|
1472
1476
|
inputs=None, links: list[str] | None = None,
|
|
1473
1477
|
files: "list[str | os.PathLike] | None" = None,
|
|
1474
1478
|
auto_commit: bool = False, critical: bool = False,
|
|
1479
|
+
priority: int | None = None, critical_volume: float | None = None,
|
|
1475
1480
|
reply: "ReplyMode | Literal['one-shot', 'sticky', 'one-time-per-user'] | None" = None,
|
|
1476
1481
|
content_format: "ContentFormat | Literal['plain', 'markdown'] | None" = None) -> "Subtask":
|
|
1477
1482
|
"""Append a subtask to this task's chain and return a `Subtask` handle.
|
|
@@ -1486,7 +1491,7 @@ class Task:
|
|
|
1486
1491
|
return self._client._append_subtask(
|
|
1487
1492
|
self, title=title, content=content, inputs=inputs,
|
|
1488
1493
|
links=links, files=files,
|
|
1489
|
-
auto_commit=auto_commit, critical=critical, reply=reply,
|
|
1494
|
+
auto_commit=auto_commit, critical=critical, priority=priority, critical_volume=critical_volume, reply=reply,
|
|
1490
1495
|
content_format=content_format,
|
|
1491
1496
|
)
|
|
1492
1497
|
|
|
@@ -1644,6 +1649,7 @@ class TaskGroup:
|
|
|
1644
1649
|
files: "list[str | os.PathLike] | None" = None,
|
|
1645
1650
|
instances: "list[str | Task] | None" = None,
|
|
1646
1651
|
auto_commit: bool = False, critical: bool = False,
|
|
1652
|
+
priority: int | None = None, critical_volume: float | None = None,
|
|
1647
1653
|
reply: "ReplyMode | Literal['one-shot', 'sticky', 'one-time-per-user'] | None" = None,
|
|
1648
1654
|
content_format: "ContentFormat | Literal['plain', 'markdown'] | None" = None) -> "list[Subtask]":
|
|
1649
1655
|
"""Append a subtask to every member instance's chain atomically — or
|
|
@@ -1661,7 +1667,7 @@ class TaskGroup:
|
|
|
1661
1667
|
ids = [t.task_id if isinstance(t, Task) else t for t in instances]
|
|
1662
1668
|
return self._client._append_subtasks_to_group(
|
|
1663
1669
|
self, instances=ids, title=title, content=content, inputs=inputs,
|
|
1664
|
-
links=links, files=files, auto_commit=auto_commit, critical=critical,
|
|
1670
|
+
links=links, files=files, auto_commit=auto_commit, critical=critical, priority=priority, critical_volume=critical_volume,
|
|
1665
1671
|
reply=reply, content_format=content_format,
|
|
1666
1672
|
)
|
|
1667
1673
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|