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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simplepush
3
- Version: 3.6.1
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); `critical=True` sends an iOS
117
- Critical Alert.
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); `critical=True` sends an iOS
67
- Critical Alert.
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.6.1"
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
- if critical:
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
- if critical:
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
- if critical:
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