borgee-plugin-sdk 0.1.2__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.
@@ -0,0 +1,220 @@
1
+ """Durable monotonic cursor stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import fcntl
8
+ import json
9
+ import os
10
+ import stat
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from collections.abc import Callable, Mapping
15
+ from pathlib import Path
16
+ from typing import Protocol, TypeVar, runtime_checkable
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ @runtime_checkable
22
+ class CursorStore(Protocol):
23
+ async def read(self, key: str) -> int: ...
24
+ async def write(self, key: str, cursor: int) -> None: ...
25
+
26
+
27
+ class MemoryCursorStore:
28
+ """Process-local cursor storage with monotonic writes."""
29
+
30
+ def __init__(self) -> None:
31
+ self._values: dict[str, int] = {}
32
+ self._lock = asyncio.Lock()
33
+
34
+ async def read(self, key: str) -> int:
35
+ async with self._lock:
36
+ return self._values.get(key, 0)
37
+
38
+ async def write(self, key: str, cursor: int) -> None:
39
+ _validate_cursor(key, cursor)
40
+ async with self._lock:
41
+ self._values[key] = max(cursor, self._values.get(key, 0))
42
+
43
+
44
+ class _OperationCancelled(Exception):
45
+ pass
46
+
47
+
48
+ class FileCursorStore:
49
+ """POSIX cursor file protected by a separate advisory lock file."""
50
+
51
+ def __init__(self, path: str | os.PathLike[str], *, lock_timeout: float = 5.0) -> None:
52
+ if lock_timeout <= 0:
53
+ raise ValueError("lock_timeout must be positive")
54
+ self.path = Path(path)
55
+ self.lock_timeout = lock_timeout
56
+ self._lock_path = self.path.with_name(self.path.name + ".lock")
57
+
58
+ async def read(self, key: str) -> int:
59
+ if not key:
60
+ raise ValueError("cursor key must not be empty")
61
+ cancel = threading.Event()
62
+ return await self._run_worker(self._read_sync, key, cancel)
63
+
64
+ async def write(self, key: str, cursor: int) -> None:
65
+ _validate_cursor(key, cursor)
66
+ cancel = threading.Event()
67
+ await self._run_worker(self._write_sync, key, cursor, cancel)
68
+
69
+ async def _run_worker(self, function: Callable[..., T], *args: object) -> T:
70
+ cancel = args[-1]
71
+ assert isinstance(cancel, threading.Event)
72
+ worker: asyncio.Task[T] = asyncio.create_task(asyncio.to_thread(function, *args))
73
+ try:
74
+ return await asyncio.shield(worker)
75
+ except asyncio.CancelledError:
76
+ cancel.set()
77
+ with contextlib.suppress(_OperationCancelled):
78
+ await asyncio.shield(worker)
79
+ raise
80
+
81
+ def _read_sync(self, key: str, cancel: threading.Event) -> int:
82
+ self._ensure_secure_parent()
83
+ lock_fd = self._acquire_lock(fcntl.LOCK_SH, cancel)
84
+ try:
85
+ self._validate_existing_file(self.path)
86
+ values = self._read_values()
87
+ return values.get(key, 0)
88
+ finally:
89
+ self._release_lock(lock_fd)
90
+
91
+ def _write_sync(self, key: str, cursor: int, cancel: threading.Event) -> None:
92
+ self._ensure_secure_parent()
93
+ lock_fd = self._acquire_lock(fcntl.LOCK_EX, cancel)
94
+ temp_path: Path | None = None
95
+ try:
96
+ self._validate_existing_file(self.path)
97
+ values = self._read_values()
98
+ if cursor <= values.get(key, 0):
99
+ return
100
+ values[key] = cursor
101
+ encoded = (json.dumps(values, sort_keys=True, separators=(",", ":")) + "\n").encode()
102
+ temp_path = self.path.with_name(f".{self.path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
103
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
104
+ if hasattr(os, "O_NOFOLLOW"):
105
+ flags |= os.O_NOFOLLOW
106
+ fd = os.open(temp_path, flags, 0o600)
107
+ try:
108
+ os.fchmod(fd, 0o600)
109
+ view = memoryview(encoded)
110
+ while view:
111
+ written = os.write(fd, view)
112
+ view = view[written:]
113
+ os.fsync(fd)
114
+ finally:
115
+ os.close(fd)
116
+ if cancel.is_set():
117
+ raise _OperationCancelled
118
+
119
+ # Once rename starts, the worker must finish directory fsync. The
120
+ # async wrapper shields and awaits this old-or-new durable commit.
121
+ os.replace(temp_path, self.path)
122
+ temp_path = None
123
+ directory_fd = os.open(self.path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
124
+ try:
125
+ os.fsync(directory_fd)
126
+ finally:
127
+ os.close(directory_fd)
128
+ finally:
129
+ if temp_path is not None:
130
+ with contextlib.suppress(OSError):
131
+ temp_path.unlink()
132
+ self._release_lock(lock_fd)
133
+
134
+ def _ensure_secure_parent(self) -> None:
135
+ parent = self.path.parent
136
+ try:
137
+ info = parent.lstat()
138
+ except FileNotFoundError:
139
+ try:
140
+ parent.mkdir(mode=0o700, parents=True, exist_ok=False)
141
+ except FileExistsError:
142
+ # Another process may have created the same parent after our
143
+ # lstat. Re-read it below and apply the full ownership, mode,
144
+ # symlink, and directory checks before using it.
145
+ pass
146
+ info = parent.lstat()
147
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
148
+ raise OSError(f"cursor directory is not a real directory: {parent}")
149
+ if info.st_uid != os.geteuid():
150
+ raise PermissionError(f"cursor directory is not owned by the current user: {parent}")
151
+ if stat.S_IMODE(info.st_mode) & 0o077:
152
+ raise PermissionError(f"cursor directory permissions must be 0700: {parent}")
153
+
154
+ def _validate_existing_file(self, path: Path) -> None:
155
+ try:
156
+ info = path.lstat()
157
+ except FileNotFoundError:
158
+ return
159
+ if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
160
+ raise OSError(f"cursor path is not a regular file: {path}")
161
+ if info.st_uid != os.geteuid():
162
+ raise PermissionError(f"cursor file is not owned by the current user: {path}")
163
+ if stat.S_IMODE(info.st_mode) & 0o077:
164
+ raise PermissionError(f"cursor file permissions must not grant group or other access: {path}")
165
+
166
+ def _acquire_lock(self, operation: int, cancel: threading.Event) -> int:
167
+ self._validate_existing_file(self._lock_path)
168
+ flags = os.O_RDWR | os.O_CREAT
169
+ if hasattr(os, "O_NOFOLLOW"):
170
+ flags |= os.O_NOFOLLOW
171
+ fd = os.open(self._lock_path, flags, 0o600)
172
+ try:
173
+ os.fchmod(fd, 0o600)
174
+ deadline = time.monotonic() + self.lock_timeout
175
+ while True:
176
+ if cancel.is_set():
177
+ raise _OperationCancelled
178
+ try:
179
+ fcntl.flock(fd, operation | fcntl.LOCK_NB)
180
+ return fd
181
+ except BlockingIOError:
182
+ if time.monotonic() >= deadline:
183
+ raise TimeoutError(f"cursor lock timed out after {self.lock_timeout:.3f}s") from None
184
+ cancel.wait(min(0.01, max(0.0, deadline - time.monotonic())))
185
+ except BaseException:
186
+ os.close(fd)
187
+ raise
188
+
189
+ @staticmethod
190
+ def _release_lock(fd: int) -> None:
191
+ with contextlib.suppress(OSError):
192
+ fcntl.flock(fd, fcntl.LOCK_UN)
193
+ os.close(fd)
194
+
195
+ def _read_values(self) -> dict[str, int]:
196
+ try:
197
+ raw = self.path.read_text(encoding="utf-8")
198
+ except FileNotFoundError:
199
+ return {}
200
+ value = json.loads(raw)
201
+ if not isinstance(value, Mapping):
202
+ raise ValueError("cursor file must contain a JSON object")
203
+ result: dict[str, int] = {}
204
+ for key, cursor in value.items():
205
+ if (
206
+ not isinstance(key, str)
207
+ or not isinstance(cursor, int)
208
+ or isinstance(cursor, bool)
209
+ or cursor < 0
210
+ ):
211
+ raise ValueError("cursor file contains an invalid entry")
212
+ result[key] = cursor
213
+ return result
214
+
215
+
216
+ def _validate_cursor(key: str, cursor: int) -> None:
217
+ if not key:
218
+ raise ValueError("cursor key must not be empty")
219
+ if not isinstance(cursor, int) or isinstance(cursor, bool) or cursor < 0:
220
+ raise ValueError("cursor must be a non-negative integer")
@@ -0,0 +1,61 @@
1
+ """Public SDK exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class BorgeeError(Exception):
7
+ """Base SDK error with a stable machine-readable code."""
8
+
9
+ def __init__(self, message: str, code: str = "borgee.error") -> None:
10
+ super().__init__(message)
11
+ self.code = code
12
+
13
+
14
+ class PermissionDeniedError(BorgeeError):
15
+ def __init__(
16
+ self,
17
+ message: str = "permission denied",
18
+ *,
19
+ attempted_action: str = "",
20
+ required_capability: str = "",
21
+ current_scope: str = "",
22
+ ) -> None:
23
+ super().__init__(message, "bpp.permission_denied")
24
+ self.attempted_action = attempted_action
25
+ self.required_capability = required_capability
26
+ self.current_scope = current_scope
27
+
28
+
29
+ class NotOnlineError(BorgeeError):
30
+ def __init__(self, message: str = "plugin is not online") -> None:
31
+ super().__init__(message, "bpp.not_online")
32
+
33
+
34
+ class ProtocolError(BorgeeError):
35
+ def __init__(self, message: str, code: str = "bpp.protocol_error") -> None:
36
+ super().__init__(message, code)
37
+
38
+
39
+ class ProtocolCapacityError(ProtocolError):
40
+ def __init__(self, message: str = "protocol capacity exceeded") -> None:
41
+ super().__init__(message, "bpp.protocol_capacity")
42
+
43
+
44
+ class StaleDeliveryError(BorgeeError):
45
+ def __init__(self, message: str = "delivery belongs to a stale connection") -> None:
46
+ super().__init__(message, "bpp.stale_delivery")
47
+
48
+
49
+ class ResumeAckError(ProtocolError):
50
+ def __init__(self, message: str, code: str = "bpp.resume_ack_invalid") -> None:
51
+ super().__init__(message, code)
52
+
53
+
54
+ class PreResumeError(ProtocolError):
55
+ def __init__(self, message: str, code: str = "bpp.pre_resume_failed") -> None:
56
+ super().__init__(message, code)
57
+
58
+
59
+ class CursorStoreError(BorgeeError):
60
+ def __init__(self, message: str, code: str = "bpp.cursor_store_failed") -> None:
61
+ super().__init__(message, code)
@@ -0,0 +1,326 @@
1
+ """Public domain models for the Python plugin SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from typing import Any, Protocol, TypeAlias, runtime_checkable
9
+
10
+
11
+ class _Missing:
12
+ __slots__ = ()
13
+
14
+ def __repr__(self) -> str:
15
+ return "MISSING"
16
+
17
+
18
+ MISSING = _Missing()
19
+ MissingType: TypeAlias = _Missing
20
+
21
+
22
+ class ReplayMode(StrEnum):
23
+ FULL = "full"
24
+ SUMMARY = "summary"
25
+ LATEST_N = "latest_n"
26
+
27
+
28
+ class ConnectionStatus(StrEnum):
29
+ CONNECTING = "connecting"
30
+ RESUMING = "resuming"
31
+ ONLINE = "online"
32
+ RECONNECTING = "reconnecting"
33
+ ERROR = "error"
34
+ CLOSED = "closed"
35
+
36
+
37
+ class UserRole(StrEnum):
38
+ MEMBER = "member"
39
+ AGENT = "agent"
40
+
41
+
42
+ class InboundKind(StrEnum):
43
+ MESSAGE = "message"
44
+ EDITED = "edited"
45
+ DELETED = "deleted"
46
+ REACTION = "reaction"
47
+ MENTION = "mention"
48
+
49
+
50
+ class TaskOutcome(StrEnum):
51
+ COMPLETED = "completed"
52
+ FAILED = "failed"
53
+ CANCELLED = "cancelled"
54
+
55
+
56
+ class FaultReason(StrEnum):
57
+ API_KEY_INVALID = "api_key_invalid"
58
+ QUOTA_EXCEEDED = "quota_exceeded"
59
+ NETWORK_UNREACHABLE = "network_unreachable"
60
+ RUNTIME_CRASHED = "runtime_crashed"
61
+ RUNTIME_TIMEOUT = "runtime_timeout"
62
+ UNKNOWN = "unknown"
63
+
64
+
65
+ class ConfigApplyStatus(StrEnum):
66
+ APPLIED = "applied"
67
+ REJECTED = "rejected"
68
+ STALE = "stale"
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class ConnectionState:
73
+ status: ConnectionStatus
74
+ attempt: int = 0
75
+ reason: FaultReason | None = None
76
+ code: str | None = None
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class User:
81
+ id: str
82
+ display_name: str
83
+ role: str
84
+ permissions: tuple[str, ...]
85
+ avatar_url: str | None = None
86
+ email: str | None = None
87
+ created_at: int | None = None
88
+ last_seen_at: int | None = None
89
+ require_mention: bool | None = None
90
+ owner_id: str | None = None
91
+ deleted_at: int | None = None
92
+ disabled: bool | None = None
93
+
94
+ @property
95
+ def kind(self) -> str:
96
+ return "agent" if self.role == UserRole.AGENT.value else "user"
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class Message:
101
+ id: str
102
+ channel_id: str
103
+ author_id: str
104
+ body: str
105
+ created_at: int
106
+ content_type: str | None = None
107
+ reply_to_id: str | None = None
108
+ edited_at: int | None = None
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class SentMessage:
113
+ message_id: str
114
+ cursor: int
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class CreatedDM:
119
+ channel_id: str
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class ResponseFrame:
124
+ """Presence-sensitive plugin response envelope."""
125
+
126
+ id: str
127
+ data: Any | MissingType = field(default=MISSING)
128
+ error: str | MissingType = field(default=MISSING)
129
+
130
+ @property
131
+ def has_data(self) -> bool:
132
+ return self.data is not MISSING
133
+
134
+ @property
135
+ def has_error(self) -> bool:
136
+ return self.error is not MISSING
137
+
138
+ @classmethod
139
+ def from_wire(cls, value: Any) -> ResponseFrame:
140
+ if not isinstance(value, dict) or not isinstance(value.get("id"), str):
141
+ raise TypeError("response frame must contain a string id")
142
+ has_data = "data" in value
143
+ has_error = "error" in value
144
+ if has_data == has_error:
145
+ raise ValueError("response frame must contain exactly one of data or error")
146
+ if has_error and (not isinstance(value["error"], str) or not value["error"]):
147
+ raise TypeError("response error must be a non-empty string")
148
+ return cls(
149
+ id=value["id"],
150
+ data=value["data"] if has_data else MISSING,
151
+ error=value["error"] if has_error else MISSING,
152
+ )
153
+
154
+
155
+ @dataclass(frozen=True, slots=True)
156
+ class InboundReaction:
157
+ emoji: str
158
+ user_id: str
159
+ added: bool
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class InboundMessageEvent:
164
+ kind: InboundKind
165
+ cursor: int
166
+ channel_id: str
167
+ created_at: int
168
+ channel_type: str | None = None
169
+ message: Message | None = None
170
+ message_id: str | None = None
171
+ reaction: InboundReaction | None = None
172
+ author_name: str | None = None
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class ReplaySummaryEvent:
177
+ cursor: int
178
+ missed_count: int
179
+ since_cursor: int
180
+ through_cursor: int
181
+ summary: str
182
+
183
+
184
+ DeliveryEvent: TypeAlias = InboundMessageEvent | ReplaySummaryEvent
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class ConfigUpdate:
189
+ agent_id: str
190
+ schema_version: int
191
+ blob: Any
192
+ idempotency_key: str
193
+ cursor: int
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class ConfigApplyResult:
198
+ status: ConfigApplyStatus
199
+ reason: FaultReason | None = None
200
+
201
+
202
+ @dataclass(slots=True)
203
+ class ConfigApplyContext:
204
+ cancelled: asyncio.Event
205
+
206
+
207
+ @dataclass(frozen=True, slots=True)
208
+ class ServerRequest:
209
+ action: str
210
+ params: Any
211
+
212
+
213
+ @runtime_checkable
214
+ class Logger(Protocol):
215
+ def debug(self, message: str, *args: Any) -> None: ...
216
+ def info(self, message: str, *args: Any) -> None: ...
217
+ def warning(self, message: str, *args: Any) -> None: ...
218
+ def error(self, message: str, *args: Any) -> None: ...
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class Task:
223
+ id: str
224
+ channel_id: str
225
+ guild_id: str
226
+ seq: int
227
+ title: str
228
+ description: str
229
+ status: str
230
+ assignee_id: str | None
231
+ created_by: str
232
+ message_id: str | None
233
+ thread_id: str | None
234
+ created_at: int
235
+ updated_at: int
236
+ heartbeat_prompt: str
237
+
238
+
239
+ def task_from_wire(value: Any) -> Task:
240
+ """Deserialize a task wire dict into a Task dataclass."""
241
+ if not isinstance(value, dict):
242
+ raise TypeError("task result must be an object")
243
+ task_id = value.get("id")
244
+ if not isinstance(task_id, str) or not task_id:
245
+ raise TypeError("task id must be a non-empty string")
246
+ return Task(
247
+ id=task_id,
248
+ channel_id=value.get("channel_id", ""),
249
+ guild_id=value.get("guild_id", ""),
250
+ seq=value.get("seq", 0),
251
+ title=value.get("title", ""),
252
+ description=value.get("description", ""),
253
+ status=value.get("status", ""),
254
+ assignee_id=value.get("assignee_id"),
255
+ created_by=value.get("created_by", ""),
256
+ message_id=value.get("message_id"),
257
+ thread_id=value.get("thread_id"),
258
+ created_at=value.get("created_at", 0),
259
+ updated_at=value.get("updated_at", 0),
260
+ heartbeat_prompt=value.get("heartbeat_prompt", ""),
261
+ )
262
+
263
+
264
+ def user_from_wire(value: Any) -> User:
265
+ if not isinstance(value, dict):
266
+ raise TypeError("get_me result must be an object")
267
+ required = ("id", "display_name", "role", "permissions")
268
+ if any(key not in value for key in required):
269
+ raise TypeError("get_me result is missing a required user field")
270
+ user_id = _required_non_empty_string(value["id"], "id")
271
+ display_name = _required_non_empty_string(value["display_name"], "display_name")
272
+ role = _required_non_empty_string(value["role"], "role")
273
+ if role not in {item.value for item in UserRole}:
274
+ raise TypeError("user role must be member or agent")
275
+ permissions = value["permissions"]
276
+ if not isinstance(permissions, list):
277
+ raise TypeError("user permissions must be a string list")
278
+ if not all(isinstance(item, str) and bool(item.strip()) for item in permissions):
279
+ raise TypeError("user permissions must contain only non-empty strings")
280
+ return User(
281
+ id=user_id,
282
+ display_name=display_name,
283
+ role=role,
284
+ permissions=tuple(permissions),
285
+ avatar_url=_optional_string_field(value, "avatar_url"),
286
+ email=_optional_string_field(value, "email"),
287
+ created_at=_optional_integer_field(value, "created_at"),
288
+ last_seen_at=_optional_integer_field(value, "last_seen_at"),
289
+ require_mention=_optional_boolean_field(value, "require_mention"),
290
+ owner_id=_optional_string_field(value, "owner_id"),
291
+ deleted_at=_optional_integer_field(value, "deleted_at"),
292
+ disabled=_optional_boolean_field(value, "disabled"),
293
+ )
294
+
295
+
296
+ def _required_non_empty_string(value: Any, field_name: str) -> str:
297
+ if not isinstance(value, str) or not value.strip():
298
+ raise TypeError(f"user {field_name} must be a non-empty string")
299
+ return value
300
+
301
+
302
+ def _optional_string_field(value: dict[Any, Any], field_name: str) -> str | None:
303
+ field = value.get(field_name)
304
+ if field is None:
305
+ return None
306
+ if not isinstance(field, str):
307
+ raise TypeError(f"user {field_name} must be a string or null")
308
+ return field
309
+
310
+
311
+ def _optional_integer_field(value: dict[Any, Any], field_name: str) -> int | None:
312
+ field = value.get(field_name)
313
+ if field is None:
314
+ return None
315
+ if not isinstance(field, int) or isinstance(field, bool) or field < 0:
316
+ raise TypeError(f"user {field_name} must be a non-negative integer or null")
317
+ return field
318
+
319
+
320
+ def _optional_boolean_field(value: dict[Any, Any], field_name: str) -> bool | None:
321
+ field = value.get(field_name)
322
+ if field is None:
323
+ return None
324
+ if not isinstance(field, bool):
325
+ raise TypeError(f"user {field_name} must be a boolean or null")
326
+ return field
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,16 @@
1
+ """Testing helpers. Internal transports are intentionally absent from the root API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..client import BorgeePluginClient, BorgeePluginOptions
6
+ from .fake_transport import FakeDelivery, FakeTransport, RecordedAction
7
+
8
+
9
+ def create_test_client(
10
+ options: BorgeePluginOptions,
11
+ transport: FakeTransport,
12
+ ) -> BorgeePluginClient:
13
+ return BorgeePluginClient(options, transport) # type: ignore[arg-type]
14
+
15
+
16
+ __all__ = ["FakeDelivery", "FakeTransport", "RecordedAction", "create_test_client"]