bazaar-compute-node 0.2.1rc15__py3-none-any.whl → 0.2.2rc2__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.
@@ -21,12 +21,10 @@ class AttachmentMaterializer:
21
21
  referenced_paths: Callable[[], Awaitable[set[str]]],
22
22
  *,
23
23
  max_file_bytes: int = 25 * 1024 * 1024,
24
- max_workspace_bytes: int = 1024 * 1024 * 1024,
25
24
  ) -> None:
26
25
  self._workspace = workspace
27
26
  self._referenced_paths = referenced_paths
28
27
  self._max_file_bytes = max_file_bytes
29
- self._max_workspace_bytes = max_workspace_bytes
30
28
  self._lock = asyncio.Lock()
31
29
 
32
30
  async def reconcile(self) -> None:
@@ -66,11 +64,9 @@ class AttachmentMaterializer:
66
64
  source: bytes | AsyncIterable[bytes],
67
65
  temporary: Path,
68
66
  destination: Path,
69
- root: Path,
70
67
  ) -> int:
71
68
  """Write an attachment aside, then move it into place once it is whole."""
72
69
 
73
- current_size = await asyncio.to_thread(self._stored_size, root)
74
70
  descriptor: int | None = None
75
71
  try:
76
72
  descriptor = await asyncio.to_thread(
@@ -80,7 +76,7 @@ class AttachmentMaterializer:
80
76
  0o600,
81
77
  )
82
78
  try:
83
- size = await self._write(descriptor, source, current_size)
79
+ size = await self._write(descriptor, source)
84
80
  await asyncio.to_thread(os.fsync, descriptor)
85
81
  stored = await asyncio.to_thread(os.fstat, descriptor)
86
82
  if stored.st_size != size:
@@ -107,12 +103,12 @@ class AttachmentMaterializer:
107
103
  self,
108
104
  descriptor: int,
109
105
  source: bytes | AsyncIterable[bytes],
110
- current_size: int,
111
106
  ) -> int:
112
107
  """Write the source through, refusing it the moment it goes over a limit."""
113
108
 
114
109
  if isinstance(source, bytes):
115
- self._check_room(len(source), current_size)
110
+ if len(source) > self._max_file_bytes:
111
+ raise ValueError("attachment exceeds the per-file size limit")
116
112
  await asyncio.to_thread(_write_all, descriptor, source)
117
113
  return len(source)
118
114
  size = 0
@@ -120,16 +116,11 @@ class AttachmentMaterializer:
120
116
  if not isinstance(chunk, bytes):
121
117
  raise TypeError("attachment stream must yield bytes")
122
118
  size += len(chunk)
123
- self._check_room(size, current_size)
119
+ if size > self._max_file_bytes:
120
+ raise ValueError("attachment exceeds the per-file size limit")
124
121
  await asyncio.to_thread(_write_all, descriptor, chunk)
125
122
  return size
126
123
 
127
- def _check_room(self, size: int, current_size: int) -> None:
128
- if size > self._max_file_bytes:
129
- raise ValueError("attachment exceeds the per-file size limit")
130
- if current_size + size > self._max_workspace_bytes:
131
- raise ValueError("attachment workspace quota exceeded")
132
-
133
124
  async def materialize(
134
125
  self,
135
126
  source: bytes | AsyncIterable[bytes],
@@ -153,7 +144,7 @@ class AttachmentMaterializer:
153
144
  await asyncio.to_thread(
154
145
  staging.mkdir, parents=True, exist_ok=True, mode=0o700
155
146
  )
156
- size = await self._stage(source, temporary, destination, root)
147
+ size = await self._stage(source, temporary, destination)
157
148
  return InboundAttachment(
158
149
  attachment_id=attachment_id,
159
150
  name=Path(name).name,
@@ -181,17 +172,6 @@ class AttachmentMaterializer:
181
172
  error=error,
182
173
  )
183
174
 
184
- @staticmethod
185
- def _stored_size(root: Path) -> int:
186
- if not root.exists():
187
- return 0
188
- return sum(
189
- path.stat().st_size
190
- for pattern in ("*/content", "*/content.*")
191
- for path in root.glob(pattern)
192
- if path.is_file() and not path.is_symlink()
193
- )
194
-
195
175
 
196
176
  def _write_all(descriptor: int, payload: bytes) -> None:
197
177
  view = memoryview(payload)
@@ -43,7 +43,7 @@ from ...core.timerwheel import TimerWheel
43
43
  from ...core.utils.clock import remaining
44
44
  from ...core.utils.markdown import split_markdown, utf8_bytes
45
45
  from ...i18n import ENGLISH, Translator, create_translator
46
- from .identity import conversation_thread_id, parse_provider_thread_id
46
+ from .identity import TopicAddress, conversation_thread_id, parse_provider_thread_id
47
47
  from .outbound import (
48
48
  AttachmentReader,
49
49
  PreparedAttachment,
@@ -52,6 +52,7 @@ from .outbound import (
52
52
  prepare_attachments,
53
53
  visible_message_body,
54
54
  )
55
+ from .topic import TopicConversations
55
56
 
56
57
  _STOP = object()
57
58
  _MAX_MEDIA_BYTES = 25 * 1024 * 1024
@@ -198,6 +199,13 @@ class WeComChannel(IChannel):
198
199
  self._logger.addHandler(logging.StreamHandler())
199
200
  self._logger.setLevel(logging.INFO)
200
201
  self._logger.propagate = False
202
+ self._topics = TopicConversations(
203
+ context,
204
+ bot_id=bot_id,
205
+ secret=secret,
206
+ emit=self._inbound.put,
207
+ logger=self._logger,
208
+ )
201
209
 
202
210
  @property
203
211
  def name(self) -> str:
@@ -241,6 +249,7 @@ class WeComChannel(IChannel):
241
249
  self._startup_finished.clear()
242
250
  self._startup_error = None
243
251
  self._state = "connecting"
252
+ self._topics.start()
244
253
  self._runner = asyncio.create_task(self._run(), name="bcn-wecom-channel")
245
254
  try:
246
255
  await asyncio.wait_for(self._startup_finished.wait(), timeout=timeout)
@@ -249,46 +258,57 @@ class WeComChannel(IChannel):
249
258
  self._runner.cancel()
250
259
  await asyncio.gather(self._runner, return_exceptions=True)
251
260
  self._runner = None
261
+ await self._topics.stop(timeout=timeout)
252
262
  self._state = "stopped"
253
263
  raise
254
264
  if self._startup_error is not None:
265
+ await self._topics.stop(timeout=timeout)
255
266
  raise self._startup_error
256
267
 
257
268
  async def stop(self, *, timeout: float) -> None:
258
269
  self._stopping.set()
259
- decided_at_ms = time_ns() // 1_000_000
260
- for pending in tuple(self._pending_approvals.values()):
261
- if not pending.future.done():
262
- pending.future.set_result(
263
- ApprovalResult(
264
- request_id=pending.request.approval.request_id,
265
- decision=ApprovalDecision.REJECTED,
266
- decided_at_ms=decided_at_ms,
267
- reason="channel_stopped",
268
- )
269
- )
270
- self._pending_approvals.clear()
271
- self._approval_task_ids_by_request.clear()
272
- await self._cancel_approval_card_updates()
270
+ deadline = asyncio.get_running_loop().time() + timeout
271
+ shutdown = asyncio.create_task(
272
+ self._topics.stop(timeout=timeout), name="bcn-wecom-topics-stop"
273
+ )
273
274
  try:
274
- await asyncio.wait_for(self._send_lock.acquire(), timeout=timeout)
275
- except TimeoutError:
276
- pass
277
- else:
278
- self._send_lock.release()
279
- connection = self._connection
280
- if connection is not None:
281
- await connection.close()
282
- runner = self._runner
283
- if runner is not None:
275
+ decided_at_ms = time_ns() // 1_000_000
276
+ for pending in tuple(self._pending_approvals.values()):
277
+ if not pending.future.done():
278
+ pending.future.set_result(
279
+ ApprovalResult(
280
+ request_id=pending.request.approval.request_id,
281
+ decision=ApprovalDecision.REJECTED,
282
+ decided_at_ms=decided_at_ms,
283
+ reason="channel_stopped",
284
+ )
285
+ )
286
+ self._pending_approvals.clear()
287
+ self._approval_task_ids_by_request.clear()
288
+ await self._cancel_approval_card_updates()
284
289
  try:
285
- await asyncio.wait_for(runner, timeout=timeout)
290
+ await asyncio.wait_for(
291
+ self._send_lock.acquire(), timeout=remaining(deadline)
292
+ )
286
293
  except TimeoutError:
287
- runner.cancel()
288
- await asyncio.gather(runner, return_exceptions=True)
289
- self._runner = None
290
- self._state = "stopped"
291
- await self._inbound.put(_STOP)
294
+ pass
295
+ else:
296
+ self._send_lock.release()
297
+ connection = self._connection
298
+ if connection is not None:
299
+ await connection.close()
300
+ runner = self._runner
301
+ if runner is not None:
302
+ try:
303
+ await asyncio.wait_for(runner, timeout=remaining(deadline))
304
+ except TimeoutError:
305
+ runner.cancel()
306
+ await asyncio.gather(runner, return_exceptions=True)
307
+ finally:
308
+ self._runner = None
309
+ await shutdown
310
+ self._state = "stopped"
311
+ await self._inbound.put(_STOP)
292
312
 
293
313
  async def receive(self) -> AsyncIterator[Message]:
294
314
  while True:
@@ -510,6 +530,24 @@ class WeComChannel(IChannel):
510
530
  async def send(
511
531
  self, request: ChannelSendRequest, *, timeout: float
512
532
  ) -> ProviderCallResult[ChannelDeliveryReceipt]:
533
+ try:
534
+ address = TopicAddress.parse(request.provider_thread_id)
535
+ if address is not None and address.bot_id != self._bot_id:
536
+ raise ValueError("WeCom topic belongs to another bot")
537
+ except ValueError as error:
538
+ return ProviderCallResult(
539
+ status=ProviderCallStatus.FAILED,
540
+ error_kind="invalid_topic_target",
541
+ error_message=str(error),
542
+ )
543
+ if address is not None:
544
+ if self._stopping.is_set():
545
+ return ProviderCallResult(
546
+ status=ProviderCallStatus.FAILED,
547
+ error_kind="channel_stopped",
548
+ error_message="WeCom channel is stopping",
549
+ )
550
+ return await self._topics.send(request, address, timeout=timeout)
513
551
  _, target_id = parse_provider_thread_id(request.provider_thread_id)
514
552
  try:
515
553
  batches = (
@@ -945,6 +983,13 @@ class WeComChannel(IChannel):
945
983
  async def request_approval(
946
984
  self, request: ChannelApprovalRequest, *, timeout: float
947
985
  ) -> ApprovalResult:
986
+ if TopicAddress.parse(request.provider_thread_id) is not None:
987
+ return ApprovalResult(
988
+ request_id=request.approval.request_id,
989
+ decision=ApprovalDecision.REJECTED,
990
+ decided_at_ms=time_ns() // 1_000_000,
991
+ reason="WeCom native topics do not support approval cards",
992
+ )
948
993
  request_id = request.approval.request_id
949
994
  if timeout <= 0:
950
995
  raise TimeoutError("WeCom approval card delivery timed out")
@@ -1375,7 +1420,7 @@ class WeComChannel(IChannel):
1375
1420
  return False
1376
1421
 
1377
1422
  async def _receive_event(self, frame: Mapping[str, object]) -> None:
1378
- """Take in a bot event frame, which is only ever an approval decision."""
1423
+ """Route native topic events and ordinary chat approval decisions."""
1379
1424
 
1380
1425
  body = frame.get("body")
1381
1426
  event = body.get("event") if isinstance(body, dict) else None
@@ -1387,6 +1432,8 @@ class WeComChannel(IChannel):
1387
1432
  "wecom.event.received",
1388
1433
  event_type=self._last_event_type,
1389
1434
  )
1435
+ if isinstance(body, dict) and self._topics.receive(body):
1436
+ return
1390
1437
  if (
1391
1438
  self._last_event_type == "template_card_event"
1392
1439
  and isinstance(event, dict)
@@ -1408,6 +1455,10 @@ class WeComChannel(IChannel):
1408
1455
  if not isinstance(body, dict):
1409
1456
  self._filter_message("invalid_body")
1410
1457
  return
1458
+ compatibility = body.get("compat_info")
1459
+ if isinstance(compatibility, dict) and compatibility.get("source") == "channel":
1460
+ self._filter_message("native_topic_projection")
1461
+ return
1411
1462
  route = self._route(body)
1412
1463
  if route is None:
1413
1464
  return
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
4
+ from urllib.parse import quote, unquote
5
+
3
6
  _PROVIDER_PREFIX = "wecom"
4
7
 
5
8
 
@@ -20,4 +23,49 @@ def parse_provider_thread_id(value: str) -> tuple[str, str]:
20
23
  return parts[1], parts[2]
21
24
 
22
25
 
23
- __all__ = ["conversation_thread_id", "parse_provider_thread_id"]
26
+ @dataclass(frozen=True, slots=True)
27
+ class TopicAddress:
28
+ bot_id: str
29
+ topic_id: str
30
+ post_id: str | None = None
31
+
32
+ @property
33
+ def provider_thread_id(self) -> str:
34
+ value = (
35
+ f"wecom:{quote(self.bot_id, safe='')}:topic:{quote(self.topic_id, safe='')}"
36
+ )
37
+ if self.post_id is not None:
38
+ value += f":post:{quote(self.post_id, safe='')}"
39
+ return value
40
+
41
+ @classmethod
42
+ def parse(cls, value: str) -> TopicAddress | None:
43
+ parts = value.split(":")
44
+ if len(parts) not in {4, 6} or parts[0] != "wecom" or parts[2] != "topic":
45
+ return None
46
+ if not parts[1] or not parts[3]:
47
+ raise ValueError("WeCom topic address requires a bot and channel")
48
+ if len(parts) == 6 and (parts[4] != "post" or not parts[5]):
49
+ raise ValueError("WeCom topic address requires a post id")
50
+ return cls(
51
+ unquote(parts[1]),
52
+ unquote(parts[3]),
53
+ unquote(parts[5]) if len(parts) == 6 else None,
54
+ )
55
+
56
+
57
+ def post_message_id(post_id: str) -> str:
58
+ return "post:" + quote(post_id, safe="")
59
+
60
+
61
+ def reply_message_id(post_id: str, reply_id: str) -> str:
62
+ return "reply:" + quote(post_id, safe="") + ":" + quote(reply_id, safe="")
63
+
64
+
65
+ __all__ = [
66
+ "TopicAddress",
67
+ "conversation_thread_id",
68
+ "parse_provider_thread_id",
69
+ "post_message_id",
70
+ "reply_message_id",
71
+ ]