beepgrid 0.1.0__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.
beepgrid/__init__.py ADDED
@@ -0,0 +1,356 @@
1
+ """Beep Grid's dependency-free synchronous application and worker client."""
2
+ from __future__ import annotations
3
+ import json
4
+ import http.client
5
+ import time
6
+ import uuid
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Any, Callable
11
+
12
+
13
+ class NotifError(Exception):
14
+ def __init__(self, code: str, message: str, status: int):
15
+ super().__init__(f"{code}: {message}")
16
+ self.code, self.message, self.status = code, message, status
17
+
18
+
19
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
20
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
21
+ return None
22
+
23
+
24
+ def _open(request, *, timeout):
25
+ # A bearer credential must never follow a redirect to another origin.
26
+ return urllib.request.build_opener(_NoRedirect()).open(request, timeout=timeout)
27
+
28
+
29
+ class Client:
30
+ def __init__(self, base_url: str, token: str, timeout: float = 20):
31
+ parsed = urllib.parse.urlsplit(base_url)
32
+ if parsed.scheme not in ("http", "https") or not parsed.netloc or parsed.username:
33
+ raise ValueError("Invalid Beep Grid server URL")
34
+ self.base_url, self._token, self.timeout = base_url.rstrip("/"), token, timeout
35
+
36
+ def credentials(self) -> list[dict[str, Any]]:
37
+ """List permitted metadata without retrieving secrets."""
38
+ return self.call("GET", "/api/v1/credentials")
39
+
40
+ def create_credential(self, principal_id: str, *, kind: str = "api", name: str = "", **options) -> dict[str, Any]:
41
+ return self.call("POST", "/api/v1/credentials", dict(principal_id=principal_id, kind=kind, name=name, **options))
42
+
43
+ def edit_credential(self, credential_id: str, *, version: int, **changes) -> dict[str, Any]:
44
+ return self.call("POST", "/api/v1/credentials/" + urllib.parse.quote(credential_id, safe="") + "/edit", dict(version=version, **changes))
45
+
46
+ def rotate_credential(self, credential_id: str, *, version: int, overlap_seconds: int = 3600) -> dict[str, Any]:
47
+ """Returns a secret once; does not automatically retry an uncertain write."""
48
+ return self.call("POST", "/api/v1/credentials/" + urllib.parse.quote(credential_id, safe="") + "/rotate", dict(version=version, overlap_seconds=overlap_seconds))
49
+
50
+ def revoke_credential(self, credential_id: str, *, revoke_workers: bool = False) -> dict[str, Any]:
51
+ return self.call("POST", "/api/v1/credentials/" + urllib.parse.quote(credential_id, safe="") + "/revoke", dict(revoke_workers=revoke_workers))
52
+
53
+ def renew_credential(self) -> dict[str, Any]:
54
+ return self.call("POST", "/api/v1/credentials/self/renew", {})
55
+
56
+ def delivery_preferences(self, person_id: str) -> dict[str, Any]:
57
+ """Read your channel defaults and ordered Agent/Agent-role overrides."""
58
+ return self.call("GET", "/api/v1/people/" + urllib.parse.quote(person_id, safe="") + "/delivery-preferences")
59
+
60
+ def save_delivery_preferences(self, person_id: str, preferences: dict[str, Any]) -> dict[str, Any]:
61
+ """Save the observed version. This never grants communication access."""
62
+ return self.call("POST", "/api/v1/people/" + urllib.parse.quote(person_id, safe="") + "/delivery-preferences", preferences)
63
+
64
+ def call(self, method: str, path: str, data: Any = None, *, key: str | None = None, minimal: bool = False, timeout: float | None = None) -> Any:
65
+ if not path.startswith("/api/v1/"):
66
+ raise ValueError("Invalid API path")
67
+ raw = json.dumps(data).encode() if data is not None else None
68
+ headers = {"Content-Type": "application/json", "Authorization": "Bearer " + self._token,
69
+ "User-Agent": "BeepgridPython/0.1.0"}
70
+ if minimal:
71
+ headers["Prefer"] = "return=minimal"
72
+ if key:
73
+ headers["Idempotency-Key"] = key
74
+ for attempt in range(3):
75
+ req = urllib.request.Request(self.base_url + path, raw, headers, method=method)
76
+ try:
77
+ with _open(req, timeout=self.timeout if timeout is None else timeout) as response:
78
+ return json.load(response)
79
+ except urllib.error.HTTPError as error:
80
+ try:
81
+ with error:
82
+ detail = json.load(error).get("error", {})
83
+ except (ValueError, AttributeError):
84
+ detail = {}
85
+ raise NotifError(detail.get("code", "HTTP_ERROR"), detail.get("message", "Request failed"), error.code) from error
86
+ except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead):
87
+ if attempt == 2 or (method != "GET" and not key):
88
+ raise
89
+ time.sleep(.2 * (attempt + 1))
90
+ raise RuntimeError("Request attempts exhausted")
91
+
92
+ def events(self, **options: Any):
93
+ """Yield canonical events. Save sequence only after applying each event."""
94
+ from .events import read_events
95
+ return read_events(self, **options)
96
+
97
+ def start_chat(self, *, targets: list[dict[str, str]], body: str, title: str = "", approval: bool = False,
98
+ choices: list[str] | None = None, mentions: list[dict[str, Any]] | None = None,
99
+ attachment_ids: list[str] | None = None,
100
+ key: str | None = None) -> dict[str, Any]:
101
+ """Start shared history. Mention offsets are code points; all members receive it."""
102
+ data: dict[str, Any] = {"targets": targets, "body": body}
103
+ if title: data["title"] = title
104
+ if approval: data["approval"] = True
105
+ if choices is not None: data["choices"] = choices
106
+ if mentions is not None: data["mentions"] = mentions
107
+ if attachment_ids is not None: data["attachment_ids"] = attachment_ids
108
+ return self.call("POST", "/api/v1/conversations", data, key=key or str(uuid.uuid4()))
109
+
110
+ def change_chat_membership(self, conversation_id: str, *, operation: str, version: int, title: str = "",
111
+ members: list[str] | None = None, share_history: bool = False,
112
+ next_manager: str = "", key: str | None = None) -> dict[str, Any]:
113
+ """Change shared disclosure, never IAM. Earlier history is shared only by explicit opt-in."""
114
+ data: dict[str, Any] = {"operation": operation, "version": version}
115
+ if title: data["title"] = title
116
+ if members is not None: data["members"] = members
117
+ if share_history: data["share_history"] = True
118
+ if next_manager: data["next_manager"] = next_manager
119
+ return self.call("POST", "/api/v1/conversations/" + urllib.parse.quote(conversation_id, safe="") + "/membership", data, key=key or str(uuid.uuid4()))
120
+
121
+ def attachments(self, conversation_id: str) -> list[dict[str, Any]]:
122
+ return self.call("GET", "/api/v1/conversations/" + urllib.parse.quote(conversation_id, safe="") + "/attachments")
123
+
124
+ def upload_attachment(self, conversation_id: str, name: str, media: str, data: bytes) -> dict[str, Any]:
125
+ """No automatic retry: list attachments after an unknown upload outcome."""
126
+ return self._upload_attachment("/api/v1/conversations/" + urllib.parse.quote(conversation_id, safe="") + "/attachments", name, media, data)
127
+
128
+ def stage_attachment(self, name: str, media: str, data: bytes) -> dict[str, Any]:
129
+ """Private draft for attachment_ids. No automatic retry; unused drafts expire after 24 hours."""
130
+ return self._upload_attachment("/api/v1/attachments", name, media, data)
131
+
132
+ def _upload_attachment(self, path: str, name: str, media: str, data: bytes) -> dict[str, Any]:
133
+ if not data or len(data) > 5 * 1024 * 1024 or any(ch in name + media for ch in "\r\n"):
134
+ raise ValueError("Invalid attachment or size exceeds 5 MiB")
135
+ boundary = "beepgrid-" + uuid.uuid4().hex
136
+ escaped = name.replace("\\", "\\\\").replace('"', '\\"')
137
+ header = ('--' + boundary + '\r\nContent-Disposition: form-data; name="file"; filename="' + escaped + '"\r\nContent-Type: ' + media + '\r\n\r\n').encode()
138
+ raw = header + data + ("\r\n--" + boundary + "--\r\n").encode()
139
+ return json.loads(self._file_request("POST", path, raw, "multipart/form-data; boundary=" + boundary))
140
+
141
+ def download_attachment(self, attachment_id: str) -> bytes:
142
+ return self._file_request("GET", "/api/v1/attachments/" + urllib.parse.quote(attachment_id, safe=""))
143
+
144
+ def _file_request(self, method: str, path: str, data=None, content_type="") -> bytes:
145
+ headers = {"Authorization": "Bearer " + self._token, "User-Agent": "BeepgridPython/0.1.0"}
146
+ if content_type: headers["Content-Type"] = content_type
147
+ req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
148
+ try:
149
+ with _open(req, timeout=self.timeout) as response:
150
+ raw = response.read(5 * 1024 * 1024 + 1)
151
+ if len(raw) > 5 * 1024 * 1024: raise ValueError("Attachment response exceeds 5 MiB")
152
+ return raw
153
+ except urllib.error.HTTPError as error:
154
+ with error:
155
+ try: detail = json.loads(error.read(65536)).get("error", {})
156
+ except (ValueError, AttributeError): detail = {}
157
+ raise NotifError(detail.get("code", "HTTP_ERROR"), detail.get("message", "Attachment request rejected"), error.code) from error
158
+
159
+ def directory(self, kind: str, search: str = "") -> dict[str, Any]:
160
+ if kind not in ("agent", "person", "service"): raise ValueError("Invalid identity kind")
161
+ return self.query("people" if kind == "person" else kind + "s", search=search)
162
+
163
+ def conversation(self, conversation_id: str) -> dict[str, Any]:
164
+ return self.call("GET", "/api/v1/conversations/" + urllib.parse.quote(conversation_id, safe=""))
165
+
166
+ def provider_catalog(self) -> list[dict[str, Any]]:
167
+ return self.call("GET", "/api/v1/integrations/catalog")
168
+
169
+ def connection_setup(self, connection_id: str) -> dict[str, Any]:
170
+ return self.call("GET", "/api/v1/connections/" + urllib.parse.quote(connection_id, safe="") + "/setup")
171
+
172
+ def replace_request(self, request_id: str, version: int, body: str, *, key: str | None = None, mentions: list[dict[str, Any]] | None = None) -> dict[str, Any]:
173
+ """Replace an own pending request. Retain key when retrying an unknown result."""
174
+ payload = {"version": version, "body": body}
175
+ if mentions is not None:
176
+ payload["mentions"] = mentions
177
+ return self.call("POST", "/api/v1/requests/" + urllib.parse.quote(request_id, safe="") + "/replace", payload, key=key or str(uuid.uuid4()))
178
+
179
+ def cancel(self, request_id: str, version: int) -> dict[str, Any]:
180
+ return self.call("POST", "/api/v1/requests/" + urllib.parse.quote(request_id, safe="") + "/cancel", {"version": version})
181
+
182
+ def query(self, collection: str, **options: Any) -> dict[str, Any]:
183
+ return self.call("POST", "/api/v1/query", {"collection": collection, **options})
184
+
185
+ def capabilities(self, **options: Any) -> dict[str, Any]:
186
+ return self.call("POST", "/api/v1/capabilities", options)
187
+
188
+ def preview_contact_permissions(self, subject: dict[str, str], *, rules: list[dict[str, str]] | None = None,
189
+ version: int = 0, targets: list[dict[str, str]] | None = None) -> dict[str, Any]:
190
+ data: dict[str, Any] = {"subject": subject, "version": version, "targets": targets or []}
191
+ if rules is not None:
192
+ data["rules"] = rules
193
+ return self.call("POST", "/api/v1/contact-permissions/preview", data)
194
+
195
+ def save_contact_permissions(self, subject: dict[str, str], rules: list[dict[str, str]], *, version: int) -> dict[str, Any]:
196
+ return self.call("POST", "/api/v1/contact-permissions", {"subject": subject, "rules": rules, "version": version})
197
+
198
+ def access(self, subject_id: str = "") -> dict[str, Any]:
199
+ return self.call("GET", "/api/v1/access?subject_id=" + urllib.parse.quote(subject_id, safe=""))
200
+
201
+ def dispatch_preview(self, targets: list[dict[str, str]], operation: str, **options: Any) -> dict[str, Any]:
202
+ return self.call("POST", "/api/v1/dispatch-preview", {"targets": targets, "operation": operation, **options})
203
+
204
+ def send_batch(self, targets: list[dict[str, str]], body: str, *, key: str | None = None, **options: Any) -> list[dict[str, Any]]:
205
+ return self.call("POST", "/api/v1/message-batches", {"targets": targets, "body": body, **options}, key=key or str(uuid.uuid4()))
206
+
207
+ def ask_batch(self, targets: list[dict[str, str]], request: dict[str, Any], *, key: str | None = None, **options: Any) -> list[dict[str, Any]]:
208
+ return self.call("POST", "/api/v1/request-batches", {"targets": targets, "request": request, **options}, key=key or str(uuid.uuid4()))
209
+
210
+ def request_trace(self, request_id: str, cursor: str = "") -> dict[str, Any]:
211
+ return self.call("GET", "/api/v1/requests/" + urllib.parse.quote(request_id, safe="") + "/trace?delivery_cursor=" + urllib.parse.quote(cursor, safe=""))
212
+
213
+ def workspace(self) -> dict[str, Any]:
214
+ return self.call("GET", "/api/v1/workspace")
215
+
216
+ def account(self) -> dict[str, Any]:
217
+ return self.call("GET", "/api/v1/workspace/account")
218
+
219
+ def connect(self, agent: str, name: str, capabilities: list[str] | None = None,
220
+ metadata: dict[str, Any] | None = None, *, renewable: bool = False,
221
+ expires_at: str | None = None, connection_name: str = "", never_expires: bool = False) -> tuple[Client, dict[str, Any]]:
222
+ data = {"agent": agent, "name": name, "capabilities": capabilities or [], "metadata": metadata or {}, "renewable": renewable, "connection_name": connection_name, "never_expires": never_expires}
223
+ if expires_at is not None:
224
+ data["expires_at"] = expires_at
225
+ session = self.call("POST", "/api/v1/sessions", data)
226
+ return Client(self.base_url, session["token"], self.timeout), session
227
+
228
+ def send(self, *, body: str, target: dict[str, str] | None = None, conversation_id: str = "",
229
+ title: str = "", key: str | None = None, minimal: bool = False, preview_token: str = "", mentions: list[dict[str, Any]] | None = None, reuse_direct: bool = False, attachment_ids: list[str] | None = None) -> dict[str, Any]:
230
+ data: dict[str, Any] = {"body": body, "conversation_id": conversation_id, "title": title}
231
+ if mentions:
232
+ data["mentions"] = mentions
233
+ if reuse_direct:
234
+ data["reuse_direct"] = True
235
+ if attachment_ids is not None:
236
+ data["attachment_ids"] = attachment_ids
237
+ if target:
238
+ data["target"] = target
239
+ if preview_token:
240
+ data["preview_token"] = preview_token
241
+ return self.call("POST", "/api/v1/messages", data, key=key or str(uuid.uuid4()), minimal=minimal)
242
+
243
+ def notify(self, **options: Any) -> dict[str, Any]:
244
+ """Durable acceptance only. send with the same input/key recovers the full receipt."""
245
+ return self.send(**{**options, "minimal": True})
246
+
247
+ def ask(self, *, target: dict[str, str] | None = None, title: str = "", key: str | None = None, **options: Any) -> dict[str, Any]:
248
+ """Use target for a new dispatch or conversation_id for an existing chat."""
249
+ data = {"title": title, **options}
250
+ if target is not None:
251
+ data["target"] = target
252
+ return self.call("POST", "/api/v1/requests", data, key=key or str(uuid.uuid4()))
253
+
254
+ def set_request_blocking(self, request_id: str, blocking: bool) -> dict[str, Any]:
255
+ return self.call("POST", "/api/v1/requests/" + urllib.parse.quote(request_id, safe="") + "/blocking", {"blocking": blocking})
256
+
257
+ def coordinate(self, prompt: str, *, key: str | None = None, **options: Any) -> dict[str, Any]:
258
+ return self.call("POST", "/api/v1/fleet/coordinate", {"prompt": prompt, **options}, key=key or str(uuid.uuid4()), timeout=max(40, self.timeout))
259
+
260
+ def get_request(self, request_id: str) -> dict[str, Any]:
261
+ return self.call("GET", "/api/v1/requests/" + urllib.parse.quote(request_id, safe=""))
262
+
263
+ def respond(self, request_id: str, outcome: str, *, version: int, answer: Any = None) -> dict[str, Any]:
264
+ return self.call("POST", "/api/v1/requests/" + urllib.parse.quote(request_id, safe="") + "/responses",
265
+ {"outcome": outcome, "version": version, "answer": answer})
266
+
267
+ def assistance(self) -> dict[str, Any]:
268
+ return self.call("GET", "/api/v1/assistance/settings")
269
+
270
+ def update_assistance(self, *, overseer: bool | None = None, version: int | None = None) -> dict[str, Any]:
271
+ settings = {key: value for key, value in {"overseer": overseer, "version": version}.items() if value is not None}
272
+ return self.call("POST", "/api/v1/assistance/settings", settings)
273
+
274
+ def plan_overseer(self, prompt: str, target: dict[str, str] | None = None, *, previous_plan_id: str | None = None, key: str | None = None) -> dict[str, Any]:
275
+ data: dict[str, Any] = {"prompt": prompt}
276
+ if target is not None:
277
+ data["target"] = target
278
+ if previous_plan_id is not None:
279
+ data["previous_plan_id"] = previous_plan_id
280
+ options = {"key": key} if key is not None else {}
281
+ return self.call("POST", "/api/v1/fleet/plan", data, timeout=max(40, self.timeout), **options)
282
+
283
+ def overseer_plans(self) -> list[dict[str, Any]]:
284
+ return self.call("GET", "/api/v1/fleet/plans")
285
+
286
+ def overseer_plan(self, plan_id: str) -> dict[str, Any]:
287
+ return self.call("GET", "/api/v1/fleet/plans/" + urllib.parse.quote(plan_id, safe=""))
288
+
289
+ def confirm_overseer(self, plan_id: str) -> dict[str, Any]:
290
+ return self.call("POST", "/api/v1/fleet/command", {"plan_id": plan_id})
291
+
292
+ def summarize_overseer(self, plan_id: str) -> dict[str, Any]:
293
+ return self.call("POST", "/api/v1/fleet/plans/" + urllib.parse.quote(plan_id, safe="") + "/summary",
294
+ {}, timeout=max(40, self.timeout))
295
+
296
+ def redeem(self, request_id: str, action: dict[str, Any]) -> dict[str, Any]:
297
+ """Recheck authorization; the business tool must enforce operation/version fences."""
298
+ return self.call("POST", "/api/v1/requests/" + urllib.parse.quote(request_id, safe="") + "/redeem",
299
+ action, key=str(uuid.uuid4()))
300
+
301
+ def report_execution(self, execution_id: str, state: str, result: Any = None) -> dict[str, Any]:
302
+ """Report observed state without equating delivery or approval with execution."""
303
+ return self.call("POST", "/api/v1/executions/" + urllib.parse.quote(execution_id, safe=""),
304
+ {"state": state, "result": result}, key=str(uuid.uuid4()))
305
+
306
+ def wait(self, request_id: str, timeout: float | None = None, interval: float = 1) -> dict[str, Any]:
307
+ deadline = None if timeout is None else time.monotonic() + timeout
308
+ while True:
309
+ if deadline is not None and time.monotonic() >= deadline:
310
+ raise TimeoutError("Stopped waiting; the durable request remains active")
311
+ request = self.get_request(request_id)
312
+ if request["state"] != "open":
313
+ return request
314
+ remaining = interval if deadline is None else min(interval, max(0, deadline - time.monotonic()))
315
+ time.sleep(remaining)
316
+
317
+ def inbox(self) -> list[dict[str, Any]]:
318
+ return self.call("GET", "/api/v1/worker/inbox")
319
+
320
+ def ack(self, delivery: dict[str, Any]) -> None:
321
+ self.call("POST", "/api/v1/worker/inbox/" + urllib.parse.quote(delivery["id"], safe="") + "/ack",
322
+ {"generation": delivery["generation"]})
323
+
324
+ def heartbeat(self, **progress: Any) -> None:
325
+ self.call("POST", "/api/v1/worker/heartbeat", progress)
326
+
327
+ def run(self, handler: Callable[[dict[str, Any]], None], stop: Callable[[], bool] = lambda: False) -> None:
328
+ while not stop():
329
+ try:
330
+ deliveries = self.call("GET", "/api/v1/worker/inbox?wait_ms=15000")
331
+ except NotifError as error:
332
+ if error.status < 500 and error.status != 429:
333
+ raise
334
+ time.sleep(2)
335
+ continue
336
+ except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead):
337
+ time.sleep(2)
338
+ continue
339
+ for delivery in deliveries:
340
+ handler(delivery)
341
+ self.ack(delivery)
342
+ if not deliveries:
343
+ time.sleep(.1)
344
+
345
+
346
+ __all__ = ["Client", "NotifError"]
347
+
348
+
349
+ def mention_context(message: dict[str, Any], recipient: str) -> str:
350
+ """An addressing hint. Other room participants may still contribute."""
351
+ mentions = message.get("mentions", [])
352
+ if not mentions:
353
+ return ""
354
+ if any(m.get("principal_id") == recipient for m in mentions):
355
+ return "You are explicitly addressed. Other participants may contribute relevant context."
356
+ return "Other participants are explicitly addressed. You may contribute relevant context."
beepgrid/events.py ADDED
@@ -0,0 +1,72 @@
1
+ """Bounded full-event SSE consumption with explicit application checkpoints."""
2
+ import http.client
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+
9
+ _MAX_FRAME = 8 * 1024 * 1024
10
+
11
+
12
+ def read_frames(response):
13
+ data, event_id, kind, size = [], "", "", 0
14
+ while True:
15
+ line = response.readline(_MAX_FRAME + 1)
16
+ if not line:
17
+ return # Discard incomplete frames after connection loss.
18
+ size += len(line)
19
+ if size > _MAX_FRAME:
20
+ raise ValueError("Event frame exceeds limit")
21
+ line = line.decode("utf-8").rstrip("\r\n")
22
+ if not line:
23
+ if event_id and data:
24
+ if not event_id.isdecimal():
25
+ raise ValueError("Invalid event ID")
26
+ sequence = int(event_id)
27
+ if kind == "cursor":
28
+ yield sequence, None
29
+ else:
30
+ event = json.loads("\n".join(data))
31
+ if not isinstance(event, dict) or event.get("sequence") != sequence or not isinstance(event.get("kind"), str):
32
+ raise ValueError("Event identity mismatch")
33
+ yield sequence, event
34
+ data, event_id, kind, size = [], "", "", 0
35
+ elif not line.startswith(":"):
36
+ name, _, value = line.partition(":")
37
+ value = value.removeprefix(" ")
38
+ if name == "data": data.append(value)
39
+ elif name == "id": event_id = value
40
+ elif name == "event": kind = value
41
+
42
+
43
+ def read_events(client, *, after=0, summary=False, conversation_id="", stop=lambda: False):
44
+ from . import NotifError, _open
45
+ if isinstance(after, bool) or not isinstance(after, int) or after < 0:
46
+ raise ValueError("Invalid event cursor")
47
+ cursor = after
48
+ while not stop():
49
+ query = {"stream": "1", "after": str(cursor)}
50
+ if summary: query["summary"] = "1"
51
+ if conversation_id: query["conversation_id"] = conversation_id
52
+ request = urllib.request.Request(client.base_url + "/api/v1/events?" + urllib.parse.urlencode(query),
53
+ headers={"Authorization": "Bearer " + client._token, "Last-Event-ID": str(cursor), "User-Agent": "BeepgridPython/0.1.0"})
54
+ try:
55
+ with _open(request, timeout=max(35, client.timeout)) as response:
56
+ if not response.headers.get("Content-Type", "").startswith("text/event-stream"):
57
+ raise NotifError("INVALID_EVENT_STREAM", "Expected an event stream", 502)
58
+ for sequence, event in read_frames(response):
59
+ if stop(): return
60
+ if sequence > cursor:
61
+ if event is not None:
62
+ yield event
63
+ cursor = sequence
64
+ except urllib.error.HTTPError as error:
65
+ error.close()
66
+ if error.code < 500 and error.code != 429:
67
+ raise NotifError("EVENT_STREAM_REJECTED", "Event stream rejected", error.code) from error
68
+ except (ValueError, UnicodeError) as error:
69
+ raise NotifError("INVALID_EVENT_STREAM", "Invalid event frame", 502) from error
70
+ except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead):
71
+ pass
72
+ if not stop(): time.sleep(.1)
beepgrid/runtime.py ADDED
@@ -0,0 +1,359 @@
1
+ """Durable, independent input for existing async Agent loops. No model dependency.
2
+
3
+ Use one connection exclusively. Tool-only MCP should use --live-input so it
4
+ cannot compete for this inbox. Local custody is not model/business completion.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import asyncio
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import sqlite3
13
+ import time
14
+ import uuid
15
+ import contextvars
16
+ import urllib.error
17
+ from typing import Any, Callable, Awaitable
18
+
19
+
20
+ class LiveInbox:
21
+ def __init__(
22
+ self,
23
+ client,
24
+ path: str,
25
+ identity: str,
26
+ *,
27
+ mode: str = "steer",
28
+ max_pending: int = 1000,
29
+ ):
30
+ if (
31
+ mode not in ("steer", "queue")
32
+ or not identity
33
+ or path == ":memory:"
34
+ or max_pending < 1
35
+ ):
36
+ raise ValueError(
37
+ "Use steer or queue, an exact connection identity and a persistent path"
38
+ )
39
+ self.client, self.mode, self.max_pending = client, mode, max_pending
40
+ Path(path).parent.mkdir(mode=0o700, parents=True, exist_ok=True)
41
+ self.db = sqlite3.connect(path)
42
+ os.chmod(path, 0o600)
43
+ self.db.execute("PRAGMA journal_mode=WAL")
44
+ self.db.execute("PRAGMA synchronous=FULL")
45
+ self.db.executescript("""
46
+ CREATE TABLE IF NOT EXISTS identity(id INTEGER PRIMARY KEY CHECK(id=1),value TEXT NOT NULL);
47
+ CREATE TABLE IF NOT EXISTS lease(id INTEGER PRIMARY KEY CHECK(id=1),owner TEXT NOT NULL,expires INTEGER NOT NULL);
48
+ CREATE TABLE IF NOT EXISTS inputs(id TEXT PRIMARY KEY,payload TEXT NOT NULL,state TEXT NOT NULL,created INTEGER NOT NULL);
49
+ """)
50
+ self.owner = str(uuid.uuid4())
51
+ try:
52
+ with self.db:
53
+ self.db.execute("BEGIN IMMEDIATE")
54
+ row = self.db.execute(
55
+ "SELECT value FROM identity WHERE id=1"
56
+ ).fetchone()
57
+ if row and row[0] != identity:
58
+ raise ValueError("Inbox belongs to another connection")
59
+ self.db.execute(
60
+ "INSERT OR IGNORE INTO identity VALUES(1,?)", (identity,)
61
+ )
62
+ row = self.db.execute("SELECT expires FROM lease WHERE id=1").fetchone()
63
+ if row and row[0] > self.now():
64
+ raise ValueError("This inbox already has a receiver")
65
+ self.db.execute(
66
+ "INSERT OR REPLACE INTO lease VALUES(1,?,?)",
67
+ (self.owner, self.now() + 30000),
68
+ )
69
+ except BaseException:
70
+ self.db.close()
71
+ raise
72
+ self.changed = asyncio.Event()
73
+ self.tasks: set[asyncio.Task] = set()
74
+ self.receiving = None
75
+ self.renewing = None
76
+ self.ready = False
77
+
78
+ @staticmethod
79
+ def now():
80
+ return int(time.time() * 1000)
81
+
82
+ def guard(self):
83
+ row = self.db.execute("SELECT owner,expires FROM lease WHERE id=1").fetchone()
84
+ if not row or row[0] != self.owner or row[1] <= self.now():
85
+ raise RuntimeError("Receiver lease lost; restart before receiving")
86
+
87
+ def pending(self, conversation: str | None = None):
88
+ self.guard()
89
+ rows = self.db.execute(
90
+ "SELECT payload FROM inputs WHERE state!='accepted' ORDER BY created,rowid"
91
+ ).fetchall()
92
+ values = [json.loads(row[0]) for row in rows]
93
+ return [
94
+ v
95
+ for v in values
96
+ if conversation is None or v["conversationId"] == conversation
97
+ ]
98
+
99
+ def put(self, delivery):
100
+ self.guard()
101
+ payload = delivery["payload"]
102
+ conversation = (
103
+ payload.get("conversation_id")
104
+ or payload.get("message", {}).get("conversation_id")
105
+ or payload.get("request", {}).get("conversation_id")
106
+ )
107
+ if not conversation or not delivery.get("id"):
108
+ raise ValueError("Missing delivery identity")
109
+ with self.db:
110
+ if self.db.execute(
111
+ "SELECT 1 FROM inputs WHERE id=?", (delivery["id"],)
112
+ ).fetchone():
113
+ return
114
+ if (
115
+ self.db.execute(
116
+ "SELECT count(*) FROM inputs WHERE state!='accepted'"
117
+ ).fetchone()[0]
118
+ >= self.max_pending
119
+ ):
120
+ raise RuntimeError("Local inbox full; receiving paused")
121
+ value = dict(
122
+ id=delivery["id"],
123
+ conversationId=conversation,
124
+ kind=payload["type"],
125
+ delivery=delivery,
126
+ )
127
+ self.db.execute(
128
+ "INSERT INTO inputs VALUES(?,?,'pending',?)",
129
+ (delivery["id"], json.dumps(value), self.now()),
130
+ )
131
+ self.db.execute(
132
+ "DELETE FROM inputs WHERE state='accepted' AND created<?",
133
+ (self.now() - 31 * 86400000,),
134
+ )
135
+ self.changed.set()
136
+
137
+ def checkpoint(self, ids):
138
+ """Only after host state containing these IDs is durably checkpointed."""
139
+ self.guard()
140
+ with self.db:
141
+ self.db.executemany(
142
+ "UPDATE inputs SET state='accepted',payload=json_remove(payload,'$.delivery.payload.message','$.delivery.payload.request') WHERE id=?",
143
+ [(i,) for i in ids],
144
+ )
145
+
146
+ async def receive(self):
147
+ while True:
148
+ self.guard()
149
+ with self.db:
150
+ self.db.execute(
151
+ "UPDATE lease SET expires=? WHERE owner=?",
152
+ (self.now() + 30000, self.owner),
153
+ )
154
+ try:
155
+ jobs = await asyncio.to_thread(
156
+ self.client.call, "GET", "/api/v1/worker/inbox?wait_ms=15000"
157
+ )
158
+ self.ready = True
159
+ for job in jobs:
160
+ self.put(job)
161
+ await asyncio.to_thread(self.client.ack, job)
162
+ if not jobs:
163
+ await asyncio.sleep(0.05)
164
+ except asyncio.CancelledError:
165
+ raise
166
+ except (urllib.error.URLError, TimeoutError, ConnectionError):
167
+ await asyncio.sleep(0.5)
168
+ except Exception as error:
169
+ status = getattr(error, "status", None)
170
+ if status in (409, 429) or status and status >= 500:
171
+ await asyncio.sleep(0.5)
172
+ elif str(error).startswith("Local inbox full"):
173
+ await asyncio.sleep(0.5)
174
+ else:
175
+ raise
176
+
177
+ async def renew(self):
178
+ while True:
179
+ await asyncio.sleep(5)
180
+ self.guard()
181
+ with self.db:
182
+ self.db.execute(
183
+ "UPDATE lease SET expires=? WHERE owner=?",
184
+ (self.now() + 30000, self.owner),
185
+ )
186
+
187
+ def start(self):
188
+ if self.receiving:
189
+ raise RuntimeError("Already started")
190
+ self.receiving = asyncio.create_task(self.receive())
191
+ self.renewing = asyncio.create_task(self.renew())
192
+ return self
193
+
194
+ async def close(self):
195
+ if self.receiving:
196
+ self.receiving.cancel()
197
+ await asyncio.gather(self.receiving, return_exceptions=True)
198
+ if self.renewing:
199
+ self.renewing.cancel()
200
+ await asyncio.gather(self.renewing, return_exceptions=True)
201
+ for task in self.tasks:
202
+ task.cancel()
203
+ await asyncio.gather(*self.tasks, return_exceptions=True)
204
+ with self.db:
205
+ self.db.execute("DELETE FROM lease WHERE owner=?", (self.owner,))
206
+ self.db.close()
207
+
208
+
209
+ def input_text(value):
210
+ """Keep external input, sender, mentions, attachments and correlation as data."""
211
+ return json.dumps(
212
+ {
213
+ "beepgrid": {
214
+ **value["delivery"]["payload"],
215
+ "delivery_id": value["id"],
216
+ "conversation_id": value["conversationId"],
217
+ }
218
+ }
219
+ )
220
+
221
+
222
+ class LangGraphBridge:
223
+ """LangChain Agent middleware and LangGraph checkpoint-compatible receiving.
224
+
225
+ Configure the graph with a durable checkpointer and before_model(). Existing
226
+ tools/model/session state remain owned by the application. serve() owns one
227
+ invocation per conversation, checks the final checkpoint and catches the
228
+ input-arrived-at-completion race. It never updates a running graph externally.
229
+ """
230
+
231
+ def __init__(self, inbox: LiveInbox, *, thread_id: Callable[[str], str] | None = None):
232
+ self.inbox = inbox
233
+ self.thread_id = thread_id or (lambda conversation: conversation)
234
+ self.active: dict[str, asyncio.Task] = {}
235
+ self.initial: dict[str, set[str]] = {}
236
+ self.conversation = contextvars.ContextVar("beepgrid_conversation")
237
+
238
+ def before_model(self):
239
+ from langchain.agents.middleware import before_model
240
+ from langchain_core.messages import HumanMessage
241
+
242
+ @before_model
243
+ async def receive_input(state, runtime):
244
+ conversation = self.conversation.get()
245
+ existing = {getattr(m, "id", None) for m in state.get("messages", [])}
246
+ inputs = self.inbox.pending(conversation)
247
+ if self.inbox.mode == "queue":
248
+ inputs = [
249
+ i
250
+ for i in inputs
251
+ if i["id"] in self.initial.get(conversation, set())
252
+ or i["kind"] == "request.resolved"
253
+ ]
254
+ messages = [
255
+ HumanMessage(content=input_text(i), id="beepgrid/" + i["id"])
256
+ for i in inputs
257
+ if "beepgrid/" + i["id"] not in existing
258
+ ]
259
+ return {"messages": messages} if messages else None
260
+
261
+ return receive_input
262
+
263
+ async def serve(
264
+ self, graph, *, on_result: Callable[[str, Any, str], Awaitable[None]]
265
+ ):
266
+ if not graph.checkpointer:
267
+ raise ValueError("Configure the existing graph with a durable checkpointer")
268
+ if not self.inbox.receiving:
269
+ self.inbox.start()
270
+
271
+ async def publish(conversation, values, persisted):
272
+ ids = sorted(
273
+ i["id"]
274
+ for i in self.inbox.pending(conversation)
275
+ if "beepgrid/" + i["id"] in persisted
276
+ )
277
+ if ids:
278
+ import hashlib
279
+
280
+ key = (
281
+ "beepgrid-result/"
282
+ + hashlib.sha256(json.dumps(ids).encode()).hexdigest()
283
+ )
284
+ await on_result(conversation, values, key)
285
+ self.inbox.checkpoint(ids)
286
+
287
+ async def run(conversation):
288
+ self.conversation.set(conversation)
289
+ config = {"configurable": {"thread_id": self.thread_id(conversation)}}
290
+ try:
291
+ while self.inbox.pending(conversation):
292
+ snapshot = await graph.aget_state(config)
293
+ if snapshot.next:
294
+ raise RuntimeError(
295
+ "Graph is paused; resume its native interrupt/approval instead of starting another run"
296
+ )
297
+ existing = {
298
+ getattr(m, "id", None)
299
+ for m in snapshot.values.get("messages", [])
300
+ }
301
+ await publish(conversation, snapshot.values, existing)
302
+ values = self.inbox.pending(conversation)
303
+ if not values:
304
+ break
305
+ self.initial[conversation] = {i["id"] for i in values}
306
+ # Queue includes only the batch at invocation start. Steer also
307
+ # receives subsequent arrivals through the local middleware.
308
+ from langchain_core.messages import HumanMessage
309
+
310
+ result = await graph.ainvoke(
311
+ {
312
+ "messages": [
313
+ HumanMessage(
314
+ content=input_text(i), id="beepgrid/" + i["id"]
315
+ )
316
+ for i in values
317
+ ]
318
+ },
319
+ config,
320
+ )
321
+ snapshot = await graph.aget_state(config)
322
+ if snapshot.next:
323
+ raise RuntimeError(
324
+ "Graph paused; leave custody pending until native resume"
325
+ )
326
+ persisted = {
327
+ getattr(m, "id", None)
328
+ for m in snapshot.values.get("messages", [])
329
+ }
330
+ if not any("beepgrid/" + i["id"] in persisted for i in values):
331
+ raise RuntimeError("Host checkpoint did not retain input IDs")
332
+ await publish(conversation, result, persisted)
333
+ finally:
334
+ self.active.pop(conversation, None)
335
+ self.initial.pop(conversation, None)
336
+ self.inbox.changed.set()
337
+
338
+ while True:
339
+ if self.inbox.receiving.done():
340
+ await self.inbox.receiving
341
+ if self.inbox.renewing.done():
342
+ await self.inbox.renewing
343
+ if self.inbox.ready:
344
+ for conversation in dict.fromkeys(
345
+ i["conversationId"] for i in self.inbox.pending()
346
+ ):
347
+ if conversation not in self.active and len(self.active) < 8:
348
+ task = asyncio.create_task(run(conversation))
349
+ self.active[conversation] = task
350
+ self.inbox.tasks.add(task)
351
+ self.inbox.changed.clear()
352
+ completed = [t for t in self.inbox.tasks if t.done()]
353
+ for task in completed:
354
+ self.inbox.tasks.remove(task)
355
+ await task # Do not silently retry failed business jobs.
356
+ try:
357
+ await asyncio.wait_for(self.inbox.changed.wait(), 0.25)
358
+ except asyncio.TimeoutError:
359
+ pass
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: beepgrid
3
+ Version: 0.1.0
4
+ Summary: Reliable communication between AI agents, people and services
5
+ Author-email: Beepgrid <sasi@beepgrid.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://beepgrid.com/
8
+ Project-URL: Documentation, https://beepgrid.com/docs/sdks/python/
9
+ Project-URL: Repository, https://github.com/beepgrid/beepgrid
10
+ Project-URL: Issues, https://github.com/beepgrid/beepgrid/issues
11
+ Keywords: beepgrid,agents,communication
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Beepgrid for Python
18
+
19
+ For an Agent that is already running in a framework or custom loop, see
20
+ [live input](https://beepgrid.com/docs/live-input/). `beepgrid.runtime.LiveInbox` receives
21
+ independently of ongoing work, and `LangGraphBridge` adds Steer/Queue behavior
22
+ to an existing LangChain Agent with its own durable checkpointer. Framework
23
+ packages remain optional. Keep the existing model, tools and runtime authority.
24
+
25
+ [Website](https://beepgrid.com) · [Documentation](https://beepgrid.com/docs/sdks/python/) · [Source](https://github.com/beepgrid/beepgrid/tree/main/sdk/python)
26
+
27
+ ```sh
28
+ pip install beepgrid
29
+ # or: uv add beepgrid
30
+ ```
31
+
32
+ Python 3.10 or later. No runtime dependencies.
33
+
34
+ ```python
35
+ import os
36
+ from beepgrid import Client
37
+ client = Client(os.environ["NOTIF_URL"], os.environ["NOTIF_TOKEN"])
38
+ ```
39
+
40
+ Use an individually issued credential from your secret manager or a protected file. The legacy `notif` import remains available.
41
+
42
+ Start with a dashboard-issued worker credential. Save it in a protected file, then run from the source checkout:
43
+
44
+ ```sh
45
+ export NOTIF_URL="https://your-workspace.example"
46
+ export NOTIF_TOKEN_FILE="/absolute/path/beepgrid-worker-token"
47
+ chmod 600 "$NOTIF_TOKEN_FILE"
48
+ unset NOTIF_AGENT_ID
49
+ python3 examples/python-worker/main.py
50
+ ```
51
+
52
+ The complete starter validates its agent identity, receives ordinary messages and replies in the original conversation. Replace its deterministic handler with your existing agent. For explicit launcher enrollment, see [runtime examples](https://github.com/beepgrid/beepgrid/tree/main/examples).
53
+
54
+ `Client(url, token)` constructs a synchronous client. `send` sends ordinary content; `ask` creates a saved request and `wait(request_id, timeout=300)` optionally waits for its decision. `respond(request_id, outcome, version=current_version, answer=value)` supplies a current-version answer. Use a stable `key` for writes you may retry. A local `TimeoutError` does not cancel the saved request. `run` acknowledges after the handler returns, so persist long work first.
55
+
56
+ `Notify` (Go) / `notify` (TypeScript and Python) requests `Prefer: return=minimal` and returns only `conversation_id` and `message_count`. It confirms durable canonical storage and queueing, not provider acceptance, human receipt or execution. `Send` / `send` keeps its full response by default. Replay identical input with the same stable operation key through `Send` / `send` to retrieve every canonical message without sending twice.
57
+
58
+ `directory("agent", search="Reviewer")` returns a bounded authorized page. Use `query` and `next_cursor` to continue larger results. `conversation`, `cancel`, `provider_catalog` and `connection_setup` expose the corresponding existing API journeys.
59
+
60
+ `upload_attachment(conversation_id, name, media, bytes)`, `attachments` and `download_attachment` use current history access, with a 5 MiB bound. Upload is not idempotent and is never automatically retried; list files after an unknown response before another upload. Files inherit conversation access and are untrusted content.
61
+
62
+ `for event in client.events(after=saved_cursor): ...` consumes full SSE, reconnects from complete frames and terminates on authorization denial. Apply the event before persisting its `sequence`. Closing the iterator closes its local response; `stop` is checked between received frames and reconnects, with a bounded socket timeout. Clear private caches when access changes. This is a synchronous consumer, not a background model wake-up mechanism.
63
+
64
+ Run `examples/file-exchange/main.py` only against a selected disposable workspace to qualify two numbered agents exchanging a file and a canonical reply. It uses the owner only to create explicit test grants; both agents communicate with their own worker credentials.
65
+
66
+ `start_chat` starts explicit shared history with recipients and content plus optional title, choices, approval and `mentions`. Mention offsets count Unicode code points. An emoji counts as one code point; UTF-16 code units are not used. All members receive the original communication. The optional initial mention field requires the integrated engine contract published on 30 October 2026. `stage_attachment` creates a private 24-hour draft. Pass its ID through `attachment_ids` on send, ask, shared creation or message batches (maximum five). `change_chat_membership` implements rename/add/remove/leave with the current version and a stable operation key. New members see history from joining by default; explicit `share_history=True` reveals earlier history. Membership does not grant contact or decision rights. Removal revokes current history/file access. These helpers require the committed schema 32 engine contract.
67
+
68
+ For the maintained runtime starter, a dashboard-created worker token already identifies one connection. Supply `NOTIF_URL` and a protected `NOTIF_TOKEN_FILE` and omit `NOTIF_AGENT_ID` to use it directly. Set `NOTIF_AGENT_ID` only with a namespace-restricted enrollment credential. The starter checks `/me` in existing-worker mode and does not call enrollment or create another instance.
@@ -0,0 +1,11 @@
1
+ beepgrid/__init__.py,sha256=7Edd7N-y1AkfuMC8qEO30tclGfbtpc3SiPtCjjNjkeE,21110
2
+ beepgrid/events.py,sha256=fwOXUs4RfpKBr8wFPBsEdldHrgOnQBIMOkDeGrD-SEo,3332
3
+ beepgrid/runtime.py,sha256=58gV_uoRdp-IypzW0N7JDZTxNeTHSv3jUGHOoy4ipo4,14182
4
+ beepgrid-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
5
+ notif/__init__.py,sha256=QL9ZHnJTKTDygxiqdxBdwLHL7VVzmoNlFdkyLV_cHCc,92
6
+ notif/events.py,sha256=N4sTArG8FVRVqgNpFlbnUGnhmqA8_842bdtPDLVIFpc,78
7
+ notif/runtime.py,sha256=d5SJh_oEnzobJ2HVYpHKim0DCCIHKCNFo_AqpxHtpVA,80
8
+ beepgrid-0.1.0.dist-info/METADATA,sha256=Ycq_BU8mKrlvY8wFKokBl70837dZHlK9paGgqSAisXY,5662
9
+ beepgrid-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ beepgrid-0.1.0.dist-info/top_level.txt,sha256=c8YlLI1zLnQ9ZL6KW1rji-lK0VrW0ZGOOtC9aZuBQM4,15
11
+ beepgrid-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,2 @@
1
+ beepgrid
2
+ notif
notif/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Compatibility import. New applications should import beepgrid."""
2
+ from beepgrid import *
notif/events.py ADDED
@@ -0,0 +1,2 @@
1
+ """Compatibility import for beepgrid.events."""
2
+ from beepgrid.events import *
notif/runtime.py ADDED
@@ -0,0 +1,2 @@
1
+ """Compatibility import for beepgrid.runtime."""
2
+ from beepgrid.runtime import *