kimi-agent-module-api 2.3.0__tar.gz → 2.4.0__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.
Files changed (26) hide show
  1. {kimi_agent_module_api-2.3.0/src/kimi_agent_module_api.egg-info → kimi_agent_module_api-2.4.0}/PKG-INFO +14 -1
  2. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/README.md +13 -0
  3. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/pyproject.toml +1 -1
  4. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/__init__.py +17 -0
  5. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/contracts.py +7 -0
  6. kimi_agent_module_api-2.4.0/src/kimi_agent_module_api/scheduled_results.py +105 -0
  7. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/testing.py +98 -0
  8. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0/src/kimi_agent_module_api.egg-info}/PKG-INFO +14 -1
  9. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/SOURCES.txt +2 -0
  10. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/tests/test_contracts.py +2 -2
  11. kimi_agent_module_api-2.4.0/tests/test_scheduled_results.py +99 -0
  12. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/LICENSE +0 -0
  13. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/setup.cfg +0 -0
  14. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/events.py +0 -0
  15. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/files.py +0 -0
  16. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/images.py +0 -0
  17. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/py.typed +0 -0
  18. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/settings.py +0 -0
  19. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/tools.py +0 -0
  20. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api/trust.py +0 -0
  21. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/dependency_links.txt +0 -0
  22. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/requires.txt +0 -0
  23. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/src/kimi_agent_module_api.egg-info/top_level.txt +0 -0
  24. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/tests/test_files.py +0 -0
  25. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/tests/test_public_api.py +0 -0
  26. {kimi_agent_module_api-2.3.0 → kimi_agent_module_api-2.4.0}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -30,6 +30,8 @@ implementation, or module loader. It exports:
30
30
  (storage, scheduler, events, Discord actions, interactions, HTTP, services,
31
31
  trust, proposals, health) plus the validators the host runs at preflight.
32
32
  - `kimi_agent_module_api.events`: the normalized `discord.*` event payloads.
33
+ - `kimi_agent_module_api.scheduled_results`: durable subscriptions to published
34
+ task output, typed results, and bounded attachment readers (SDK 2.4).
33
35
  - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
34
36
  `FileAccessError` for invocation-scoped attachment and workspace reads.
35
37
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
@@ -116,3 +118,14 @@ From this package directory, run its tests without installing the Kimi applicati
116
118
  ```console
117
119
  uv run --isolated --group test python -m pytest -q
118
120
  ```
121
+
122
+ SDK 2.4 adds `ScheduledResults`: named, guild-scoped subscriptions to task output
123
+ following confirmed Discord publication. Declare
124
+ `ModulePermissions(scheduled_results=("my_subscription",))`, require the host
125
+ capability `scheduled_results.v1`, and register through `ctx.scheduled_results`
126
+ during startup. Handlers receive a typed `ScheduledResult` and an invocation-scoped
127
+ `ScheduledResultFiles` reader. Returning acknowledges the result; exceptions retry
128
+ with the same notification ID. Use that ID for idempotent processing.
129
+ `FakeScheduledResults` supports standalone tests. See the
130
+ [published-result guide](https://github.com/Kimi-Discord-Agent/kimi-agent/blob/main/docs/module-scheduled-results.md)
131
+ for access checks, limits, retention, and deletion responsibilities.
@@ -13,6 +13,8 @@ implementation, or module loader. It exports:
13
13
  (storage, scheduler, events, Discord actions, interactions, HTTP, services,
14
14
  trust, proposals, health) plus the validators the host runs at preflight.
15
15
  - `kimi_agent_module_api.events`: the normalized `discord.*` event payloads.
16
+ - `kimi_agent_module_api.scheduled_results`: durable subscriptions to published
17
+ task output, typed results, and bounded attachment readers (SDK 2.4).
16
18
  - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
17
19
  `FileAccessError` for invocation-scoped attachment and workspace reads.
18
20
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
@@ -99,3 +101,14 @@ From this package directory, run its tests without installing the Kimi applicati
99
101
  ```console
100
102
  uv run --isolated --group test python -m pytest -q
101
103
  ```
104
+
105
+ SDK 2.4 adds `ScheduledResults`: named, guild-scoped subscriptions to task output
106
+ following confirmed Discord publication. Declare
107
+ `ModulePermissions(scheduled_results=("my_subscription",))`, require the host
108
+ capability `scheduled_results.v1`, and register through `ctx.scheduled_results`
109
+ during startup. Handlers receive a typed `ScheduledResult` and an invocation-scoped
110
+ `ScheduledResultFiles` reader. Returning acknowledges the result; exceptions retry
111
+ with the same notification ID. Use that ID for idempotent processing.
112
+ `FakeScheduledResults` supports standalone tests. See the
113
+ [published-result guide](https://github.com/Kimi-Discord-Agent/kimi-agent/blob/main/docs/module-scheduled-results.md)
114
+ for access checks, limits, retention, and deletion responsibilities.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kimi-agent-module-api"
3
- version = "2.3.0"
3
+ version = "2.4.0"
4
4
  description = "Stable contracts for community-built assistant modules"
5
5
  requires-python = ">=3.14"
6
6
  authors = [{ name = "Webhead" }]
@@ -46,6 +46,15 @@ from kimi_agent_module_api.tools import (
46
46
  TriggeringDiscordMessageSnapshot,
47
47
  )
48
48
  from kimi_agent_module_api.trust import TrustTier
49
+ from kimi_agent_module_api.scheduled_results import (
50
+ ScheduledResult,
51
+ ScheduledResultAccessError,
52
+ ScheduledResultAttachment,
53
+ ScheduledResultFiles,
54
+ ScheduledResultHandler,
55
+ ScheduledResultMessage,
56
+ ScheduledResults,
57
+ )
49
58
 
50
59
  MODULE_API_VERSION = 2
51
60
  MODULE_ENTRYPOINT_GROUP = "kimi_agent.modules"
@@ -147,6 +156,7 @@ class ModuleRuntimeContext:
147
156
  proposals: ProposalService | None = None
148
157
  raw_bot: Any = None
149
158
  raw_storage: Any = None
159
+ scheduled_results: ScheduledResults | None = None
150
160
 
151
161
 
152
162
  __all__ = [
@@ -183,6 +193,13 @@ __all__ = [
183
193
  "ProposalService",
184
194
  "ProposalState",
185
195
  "RoleSnapshot",
196
+ "ScheduledResult",
197
+ "ScheduledResultAccessError",
198
+ "ScheduledResultAttachment",
199
+ "ScheduledResultFiles",
200
+ "ScheduledResultHandler",
201
+ "ScheduledResultMessage",
202
+ "ScheduledResults",
186
203
  "ScopedModuleMigration",
187
204
  "ServiceDeclaration",
188
205
  "ServiceRequirement",
@@ -145,6 +145,8 @@ class ModulePermissions:
145
145
  raw_storage: bool = False
146
146
  # Read admitted attachments and caller-owned workspace files in tool handlers.
147
147
  tool_files: bool = False
148
+ # Names of durable, explicitly guild-scoped published-task subscriptions.
149
+ scheduled_results: tuple[str, ...] = ()
148
150
 
149
151
 
150
152
  @dataclass(frozen=True, slots=True)
@@ -439,6 +441,11 @@ def validate_permissions(module_name: str, permissions: ModulePermissions) -> No
439
441
  )
440
442
  for rule in permissions.http_hosts:
441
443
  validate_host_rule(rule)
444
+ names = permissions.scheduled_results
445
+ if len(set(names)) != len(names) or any(
446
+ not re.fullmatch(r"[a-z0-9_]{1,64}", name) for name in names
447
+ ):
448
+ raise ModuleContractError("scheduled-result subscription names must be unique and valid")
442
449
 
443
450
 
444
451
  def validate_services(
@@ -0,0 +1,105 @@
1
+ """Durable subscriptions to confirmed Discord publications, separate from module jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from typing import Literal, Protocol
8
+
9
+ from kimi_agent_module_api.contracts import MessageRef, ModuleContractError
10
+
11
+ MAX_RESULT_FILE_BYTES = 8 * 1024 * 1024
12
+ MAX_RESULT_TOTAL_FILE_BYTES = 16 * 1024 * 1024
13
+
14
+
15
+ class ScheduledResultAccessError(ModuleContractError):
16
+ """The subscription or its retained output is unavailable to this caller."""
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ScheduledResultAttachment:
21
+ id: str
22
+ filename: str
23
+ size_bytes: int
24
+ description: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ScheduledResultMessage:
29
+ message: MessageRef
30
+ content: str
31
+ attachments: tuple[ScheduledResultAttachment, ...] = ()
32
+ # The published Discord embed as JSON, capped at 32 KiB. No network fetches.
33
+ embed_json: str | None = None
34
+ embed_unavailable_reason: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ScheduledResult:
39
+ notification_id: str
40
+ subscription: str
41
+ task_id: str
42
+ run_id: str
43
+ revision: int
44
+ guild_id: int
45
+ owner_id: int
46
+ published_at: float
47
+ expires_at: float
48
+ messages: tuple[ScheduledResultMessage, ...]
49
+ outcome: Literal["completed"] = "completed"
50
+
51
+
52
+ class ScheduledResultFiles(Protocol):
53
+ """Read only this notification's attachments during its handler invocation.
54
+
55
+ The host rechecks access on every read. Reads are capped at 8 MiB each and
56
+ 16 MiB total per invocation, including repeated reads. Handles expire on
57
+ return, cancellation, deletion, or retention expiry; copy bytes deliberately.
58
+ """
59
+
60
+ async def read(
61
+ self, attachment_id: str, *, max_bytes: int = MAX_RESULT_FILE_BYTES
62
+ ) -> bytes: ...
63
+
64
+
65
+ type ScheduledResultHandler = Callable[[ScheduledResult, ScheduledResultFiles], Awaitable[None]]
66
+
67
+
68
+ class ScheduledResults(Protocol):
69
+ """A module-bound port. Declare each name in ``permissions.scheduled_results``.
70
+
71
+ Register on every start. A name is scoped to one guild per registration;
72
+ the same name may be registered in several guilds. Existing subscriptions
73
+ survive downtime; new registrations never replay historical publications.
74
+ Returning acknowledges the notification. Raising retries with the same ID.
75
+ Use that ID to make your own writes/remote requests idempotent.
76
+ """
77
+
78
+ async def subscribe(
79
+ self, name: str, *, guild_id: int, handler: ScheduledResultHandler
80
+ ) -> None: ...
81
+
82
+ async def unsubscribe(self, name: str, *, guild_id: int) -> None:
83
+ """Forget this subscription and its pending/acknowledged notifications."""
84
+ ...
85
+
86
+
87
+ def validate_result_subscription(name: str, guild_id: int) -> None:
88
+ # Keep validation here usable by the host and standalone test fakes.
89
+ if (
90
+ not isinstance(name, str)
91
+ or not 1 <= len(name) <= 64
92
+ or any(char not in "abcdefghijklmnopqrstuvwxyz0123456789_" for char in name)
93
+ ):
94
+ raise ModuleContractError("scheduled-result names use 1-64 lowercase letters, digits or _")
95
+ if isinstance(guild_id, bool) or not isinstance(guild_id, int) or not 0 < guild_id < 2**64:
96
+ raise ModuleContractError("scheduled-result subscriptions require a positive guild ID")
97
+
98
+
99
+ def validate_result_read_limit(max_bytes: int) -> None:
100
+ if (
101
+ isinstance(max_bytes, bool)
102
+ or not isinstance(max_bytes, int)
103
+ or not (0 < max_bytes <= MAX_RESULT_FILE_BYTES)
104
+ ):
105
+ raise ScheduledResultAccessError("attachment read limit must be between 1 byte and 8 MiB")
@@ -24,6 +24,15 @@ from pydantic_settings import BaseSettings
24
24
 
25
25
  from kimi_agent_module_api.trust import TrustTier
26
26
  from kimi_agent_module_api.files import FileAccessError, ToolAttachment, ToolFile
27
+ from kimi_agent_module_api.scheduled_results import (
28
+ MAX_RESULT_FILE_BYTES,
29
+ MAX_RESULT_TOTAL_FILE_BYTES,
30
+ ScheduledResult,
31
+ ScheduledResultAccessError,
32
+ ScheduledResultHandler,
33
+ validate_result_read_limit,
34
+ validate_result_subscription,
35
+ )
27
36
 
28
37
  from kimi_agent_module_api.contracts import (
29
38
  ALL_DISCORD_ACTIONS,
@@ -85,6 +94,93 @@ _MAX_FLOAT_LOG = math.log(sys.float_info.max)
85
94
  _T = TypeVar("_T")
86
95
 
87
96
 
97
+ class FakeScheduledResultFiles:
98
+ """An invocation-scoped reader; the same size/lifetime limits as the host."""
99
+
100
+ def __init__(
101
+ self, files: Mapping[str, bytes], *, available: Callable[[], bool] = lambda: True
102
+ ) -> None:
103
+ self.files = files
104
+ self.available = available
105
+ self.open = True
106
+ self.remaining = MAX_RESULT_TOTAL_FILE_BYTES
107
+
108
+ async def read(self, attachment_id: str, *, max_bytes: int = MAX_RESULT_FILE_BYTES) -> bytes:
109
+ validate_result_read_limit(max_bytes)
110
+ data = self.files.get(attachment_id)
111
+ if (
112
+ not self.open
113
+ or not self.available()
114
+ or self.remaining <= 0
115
+ or data is None
116
+ or len(data) > min(max_bytes, self.remaining)
117
+ ):
118
+ raise ScheduledResultAccessError("Result file unavailable or read limit exceeded")
119
+ self.remaining -= len(data)
120
+ return data
121
+
122
+
123
+ class FakeScheduledResults:
124
+ """Drive retries explicitly with ``deliver``; successful IDs are acknowledged.
125
+
126
+ A failed invocation propagates its exception and can be delivered again.
127
+ Supply the original result object to model a restart or duplicate attempt.
128
+ This fake has no background runner, Discord access, or persistence.
129
+ """
130
+
131
+ def __init__(
132
+ self,
133
+ declared: tuple[str, ...] = (),
134
+ *,
135
+ is_guild_active: Callable[[int], bool] = lambda _guild_id: True,
136
+ ) -> None:
137
+ self.declared, self.is_guild_active = declared, is_guild_active
138
+ self.handlers: dict[tuple[str, int], ScheduledResultHandler] = {}
139
+ self.acknowledged: set[str] = set()
140
+ self.attempts: list[str] = []
141
+
142
+ def _validate(self, name: str, guild_id: int) -> None:
143
+ validate_result_subscription(name, guild_id)
144
+ if name not in self.declared:
145
+ raise ScheduledResultAccessError("Undeclared scheduled-result subscription")
146
+
147
+ async def subscribe(self, name: str, *, guild_id: int, handler: ScheduledResultHandler) -> None:
148
+ self._validate(name, guild_id)
149
+ self.handlers[name, guild_id] = handler
150
+
151
+ async def unsubscribe(self, name: str, *, guild_id: int) -> None:
152
+ self._validate(name, guild_id)
153
+ self.handlers.pop((name, guild_id), None)
154
+
155
+ async def deliver(
156
+ self,
157
+ result: ScheduledResult,
158
+ *,
159
+ files: Mapping[str, bytes] | None = None,
160
+ preview: bool = False,
161
+ ) -> None:
162
+ if preview or result.notification_id in self.acknowledged:
163
+ return
164
+ handler = self.handlers.get((result.subscription, result.guild_id))
165
+ if handler is None or not self.is_guild_active(result.guild_id):
166
+ raise ScheduledResultAccessError("Subscriber unavailable in this guild")
167
+ visible = {a.id for m in result.messages for a in m.attachments}
168
+ reader = FakeScheduledResultFiles(
169
+ {k: v for k, v in (files or {}).items() if k in visible},
170
+ available=lambda: (
171
+ self.is_guild_active(result.guild_id)
172
+ and result.subscription in self.declared
173
+ and self.handlers.get((result.subscription, result.guild_id)) is handler
174
+ ),
175
+ )
176
+ self.attempts.append(result.notification_id)
177
+ try:
178
+ await handler(result, reader)
179
+ self.acknowledged.add(result.notification_id)
180
+ finally:
181
+ reader.open = False
182
+
183
+
88
184
  @dataclass(slots=True)
89
185
  class _Closable:
90
186
  _on_close: Callable[[], object]
@@ -1390,6 +1486,8 @@ __all__ = [
1390
1486
  "FakeInteractions",
1391
1487
  "FakeProposals",
1392
1488
  "FakeResponse",
1489
+ "FakeScheduledResultFiles",
1490
+ "FakeScheduledResults",
1393
1491
  "FakeScheduler",
1394
1492
  "FakeServiceRegistry",
1395
1493
  "FakeToolFiles",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kimi-agent-module-api
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Stable contracts for community-built assistant modules
5
5
  Author: Webhead
6
6
  License-Expression: MIT
@@ -30,6 +30,8 @@ implementation, or module loader. It exports:
30
30
  (storage, scheduler, events, Discord actions, interactions, HTTP, services,
31
31
  trust, proposals, health) plus the validators the host runs at preflight.
32
32
  - `kimi_agent_module_api.events`: the normalized `discord.*` event payloads.
33
+ - `kimi_agent_module_api.scheduled_results`: durable subscriptions to published
34
+ task output, typed results, and bounded attachment readers (SDK 2.4).
33
35
  - `kimi_agent_module_api.files`: `ToolFiles`, `ToolAttachment`, `ToolFile`, and
34
36
  `FileAccessError` for invocation-scoped attachment and workspace reads.
35
37
  - `kimi_agent_module_api.testing`: a fake for every port, `load_context()` for
@@ -116,3 +118,14 @@ From this package directory, run its tests without installing the Kimi applicati
116
118
  ```console
117
119
  uv run --isolated --group test python -m pytest -q
118
120
  ```
121
+
122
+ SDK 2.4 adds `ScheduledResults`: named, guild-scoped subscriptions to task output
123
+ following confirmed Discord publication. Declare
124
+ `ModulePermissions(scheduled_results=("my_subscription",))`, require the host
125
+ capability `scheduled_results.v1`, and register through `ctx.scheduled_results`
126
+ during startup. Handlers receive a typed `ScheduledResult` and an invocation-scoped
127
+ `ScheduledResultFiles` reader. Returning acknowledges the result; exceptions retry
128
+ with the same notification ID. Use that ID for idempotent processing.
129
+ `FakeScheduledResults` supports standalone tests. See the
130
+ [published-result guide](https://github.com/Kimi-Discord-Agent/kimi-agent/blob/main/docs/module-scheduled-results.md)
131
+ for access checks, limits, retention, and deletion responsibilities.
@@ -7,6 +7,7 @@ src/kimi_agent_module_api/events.py
7
7
  src/kimi_agent_module_api/files.py
8
8
  src/kimi_agent_module_api/images.py
9
9
  src/kimi_agent_module_api/py.typed
10
+ src/kimi_agent_module_api/scheduled_results.py
10
11
  src/kimi_agent_module_api/settings.py
11
12
  src/kimi_agent_module_api/testing.py
12
13
  src/kimi_agent_module_api/tools.py
@@ -19,4 +20,5 @@ src/kimi_agent_module_api.egg-info/top_level.txt
19
20
  tests/test_contracts.py
20
21
  tests/test_files.py
21
22
  tests/test_public_api.py
23
+ tests/test_scheduled_results.py
22
24
  tests/test_testing.py
@@ -84,8 +84,8 @@ def test_spec_and_runtime_context_keep_stable_defaults() -> None:
84
84
  assert {"events", "scheduler", "storage", "discord", "interactions", "services"} <= required
85
85
 
86
86
 
87
- def test_distribution_version_includes_triggering_message_snapshot_contract() -> None:
88
- assert version("kimi-agent-module-api") == "2.3.0"
87
+ def test_distribution_version_includes_scheduled_result_contract() -> None:
88
+ assert version("kimi-agent-module-api") == "2.4.0"
89
89
 
90
90
 
91
91
  def test_triggering_discord_message_snapshot_is_immutable() -> None:
@@ -0,0 +1,99 @@
1
+ """Exercise the public subscriber contract without importing a host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import FrozenInstanceError, replace
6
+
7
+ import pytest
8
+
9
+ from kimi_agent_module_api import (
10
+ ModulePermissions,
11
+ ScheduledResult,
12
+ ScheduledResultAccessError,
13
+ ScheduledResultAttachment,
14
+ ScheduledResultMessage,
15
+ )
16
+ from kimi_agent_module_api.contracts import MessageRef, ModuleContractError, validate_permissions
17
+ from kimi_agent_module_api.testing import FakeScheduledResults
18
+
19
+
20
+ def result():
21
+ return ScheduledResult(
22
+ notification_id="notification",
23
+ subscription="reports",
24
+ task_id="task",
25
+ run_id="run",
26
+ revision=3,
27
+ guild_id=100,
28
+ owner_id=10,
29
+ published_at=1000,
30
+ expires_at=2000,
31
+ messages=(
32
+ ScheduledResultMessage(
33
+ MessageRef(100, 300, 900),
34
+ "Published",
35
+ (ScheduledResultAttachment("file", "result.txt", 3),),
36
+ ),
37
+ ),
38
+ )
39
+
40
+
41
+ def test_result_is_immutable_and_permissions_must_be_named():
42
+ with pytest.raises(FrozenInstanceError):
43
+ result().owner_id = 99
44
+ validate_permissions("reports", ModulePermissions(scheduled_results=("reports",)))
45
+ for names in (("Upper Case",), ("reports", "reports"), ("",)):
46
+ with pytest.raises(ModuleContractError):
47
+ validate_permissions("reports", ModulePermissions(scheduled_results=names))
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_fake_retry_acknowledgement_preview_and_file_lifetime():
52
+ fake = FakeScheduledResults(("reports",))
53
+ attempts, readers = [], []
54
+
55
+ async def handler(notification, files):
56
+ attempts.append(notification.notification_id)
57
+ readers.append(files)
58
+ assert await files.read("file") == b"abc"
59
+ with pytest.raises(ScheduledResultAccessError):
60
+ await files.read("not published")
61
+ with pytest.raises(ScheduledResultAccessError):
62
+ await files.read("file", max_bytes=2)
63
+ if len(attempts) == 1:
64
+ raise RuntimeError("retry me")
65
+
66
+ await fake.subscribe("reports", guild_id=100, handler=handler)
67
+ await fake.deliver(result(), preview=True)
68
+ assert attempts == []
69
+ files = {"file": b"abc", "not published": b"private"}
70
+ with pytest.raises(RuntimeError, match="retry me"):
71
+ await fake.deliver(result(), files=files)
72
+ assert not fake.acknowledged
73
+ await fake.deliver(result(), files=files)
74
+ await fake.deliver(result(), files=files)
75
+ assert attempts == ["notification", "notification"]
76
+ assert fake.acknowledged == {"notification"}
77
+ for reader in readers:
78
+ with pytest.raises(ScheduledResultAccessError):
79
+ await reader.read("file")
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_fake_permission_and_guild_scope():
84
+ fake = FakeScheduledResults(("reports",), is_guild_active=lambda guild: guild == 100)
85
+
86
+ async def handler(_result, _files):
87
+ pass
88
+
89
+ with pytest.raises(ScheduledResultAccessError):
90
+ await fake.subscribe("other", guild_id=100, handler=handler)
91
+ with pytest.raises(ModuleContractError):
92
+ await fake.subscribe("reports", guild_id=0, handler=handler)
93
+ await fake.subscribe("reports", guild_id=200, handler=handler)
94
+ with pytest.raises(ScheduledResultAccessError):
95
+ await fake.deliver(replace(result(), guild_id=200))
96
+ await fake.subscribe("reports", guild_id=100, handler=handler)
97
+ await fake.unsubscribe("reports", guild_id=100)
98
+ with pytest.raises(ScheduledResultAccessError):
99
+ await fake.deliver(result())