forebot 0.2.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.
forebot/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Forebot SDK: report status to Forebot and cooperate with its controls.
2
+
3
+ Typical use::
4
+
5
+ import forebot
6
+
7
+ forebot.setup_logging()
8
+ bot = forebot.connect()
9
+ api_key = bot.require("api_key")
10
+
11
+ for _ in bot.loop(interval=60, idle_detail="waiting for work"):
12
+ for job in fetch_jobs():
13
+ with bot.working(f"processing {job.id}"):
14
+ handle(job)
15
+ if not bot.checkpoint(): # honours pause and shutdown
16
+ break
17
+ """
18
+
19
+ from .bot import IDLE, PAUSED, WORKING, Bot, ForebotError, connect
20
+ from .logs import JSONFormatter, setup_logging
21
+
22
+ __all__ = [
23
+ "Bot",
24
+ "ForebotError",
25
+ "IDLE",
26
+ "JSONFormatter",
27
+ "PAUSED",
28
+ "WORKING",
29
+ "connect",
30
+ "setup_logging",
31
+ ]
32
+
33
+ __version__ = "0.2.0"
forebot/bot.py ADDED
@@ -0,0 +1,341 @@
1
+ """The Forebot bot client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import logging
8
+ import os
9
+ import signal
10
+ import socket
11
+ import threading
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any, Dict, Iterator, Optional
16
+
17
+ IDLE = "idle"
18
+ WORKING = "working"
19
+ PAUSED = "paused"
20
+
21
+ _log = logging.getLogger("forebot")
22
+
23
+
24
+ class _UnixHTTPConnection(http.client.HTTPConnection):
25
+ """An HTTP connection over a Unix socket.
26
+
27
+ A runner gives each bot in a container its own socket, with a
28
+ ``unix://`` agent URL.
29
+ """
30
+
31
+ def __init__(self, path: str, timeout: float) -> None:
32
+ super().__init__("localhost", timeout=timeout)
33
+ self._path = path
34
+
35
+ def connect(self) -> None:
36
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
37
+ sock.settimeout(self.timeout)
38
+ try:
39
+ sock.connect(self._path)
40
+ except OSError:
41
+ sock.close()
42
+ raise
43
+ self.sock = sock
44
+
45
+
46
+ class Bot:
47
+ """Connection between a bot process and its Forebot runner.
48
+
49
+ When the process is not started by a Forebot runner (for example during
50
+ local development), the bot runs in *standalone* mode: configuration is
51
+ read from ``FOREBOT_CONFIG`` (a JSON string) or ``FOREBOT_CONFIG_FILE``
52
+ (a path to a JSON file), and status reports are no-ops.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ agent_url: Optional[str] = None,
58
+ token: Optional[str] = None,
59
+ heartbeat_interval: float = 5.0,
60
+ handle_signals: bool = True,
61
+ ) -> None:
62
+ self.agent_url = (agent_url or os.environ.get("FOREBOT_AGENT_URL", "")).rstrip("/")
63
+ self._token = token or os.environ.get("FOREBOT_AGENT_TOKEN", "")
64
+ self.managed = bool(self.agent_url and self._token)
65
+ self.id: Optional[int] = _int_or_none(os.environ.get("FOREBOT_BOT_ID"))
66
+ self.name: str = os.environ.get("FOREBOT_BOT_NAME", "")
67
+ self.revision: Optional[int] = None
68
+ self.heartbeat_interval = heartbeat_interval
69
+
70
+ self._lock = threading.Lock()
71
+ self._activity = IDLE
72
+ self._detail = ""
73
+ self._pause_requested = False
74
+ self._stop = threading.Event()
75
+ self._wake = threading.Event() # wakes the heartbeat thread early
76
+ self._unpaused = threading.Event()
77
+ self._unpaused.set()
78
+ self._cooperative = False
79
+ self._closed = False
80
+
81
+ self.config: Dict[str, Any] = self._load_config()
82
+
83
+ if handle_signals and threading.current_thread() is threading.main_thread():
84
+ self._install_signal_handlers()
85
+
86
+ self._thread: Optional[threading.Thread] = None
87
+ if self.managed:
88
+ self._send_heartbeat() # report immediately
89
+ self._thread = threading.Thread(target=self._heartbeat_loop, name="forebot-heartbeat", daemon=True)
90
+ self._thread.start()
91
+
92
+ # ------------------------------------------------------------------ config
93
+
94
+ def _load_config(self) -> Dict[str, Any]:
95
+ if self.managed:
96
+ try:
97
+ data = self._request("GET", "/v1/config")
98
+ except Exception as exc: # noqa: BLE001
99
+ raise ForebotError(f"could not load configuration from the runner: {exc}") from exc
100
+ self.id = data.get("bot_id", self.id)
101
+ self.name = data.get("bot_name", self.name)
102
+ self.revision = data.get("revision")
103
+ return dict(data.get("config") or {})
104
+ raw = os.environ.get("FOREBOT_CONFIG")
105
+ if raw:
106
+ return dict(json.loads(raw))
107
+ path = os.environ.get("FOREBOT_CONFIG_FILE")
108
+ if path:
109
+ with open(path, encoding="utf-8") as fh:
110
+ return dict(json.load(fh))
111
+ return {}
112
+
113
+ def get(self, key: str, default: Any = None) -> Any:
114
+ """Return a configuration value, or ``default`` if it is not set."""
115
+ return self.config.get(key, default)
116
+
117
+ def require(self, key: str) -> Any:
118
+ """Return a configuration value, raising ``KeyError`` with a clear message if it is missing."""
119
+ if key not in self.config:
120
+ raise KeyError(f"required configuration key {key!r} is not set in Forebot")
121
+ return self.config[key]
122
+
123
+ # ---------------------------------------------------------------- activity
124
+
125
+ def set_activity(self, state: str, detail: str = "") -> None:
126
+ """Report the bot's activity: ``idle``, ``working`` or ``paused``."""
127
+ if state not in (IDLE, WORKING, PAUSED):
128
+ raise ValueError(f"unknown activity {state!r}")
129
+ with self._lock:
130
+ changed = (state, detail) != (self._activity, self._detail)
131
+ self._activity, self._detail = state, detail
132
+ if changed:
133
+ self._wake.set()
134
+
135
+ def idle(self, detail: str = "") -> None:
136
+ """Report that the bot is waiting for work."""
137
+ self.set_activity(IDLE, detail)
138
+
139
+ def working(self, detail: str = "") -> "_Activity":
140
+ """Report that the bot is working.
141
+
142
+ Use it as a plain call, or as a context manager that reports idle
143
+ again when the block ends::
144
+
145
+ with bot.working("Triaging SENTRY-1234"):
146
+ ...
147
+ """
148
+ with self._lock:
149
+ prev = (self._activity, self._detail)
150
+ self.set_activity(WORKING, detail)
151
+ return _Activity(self, prev)
152
+
153
+ @property
154
+ def activity(self) -> str:
155
+ with self._lock:
156
+ return self._activity
157
+
158
+ # ------------------------------------------------------- pause / shutdown
159
+
160
+ @property
161
+ def pause_requested(self) -> bool:
162
+ """True when an operator asked the bot to pause."""
163
+ with self._lock:
164
+ return self._pause_requested
165
+
166
+ def should_stop(self) -> bool:
167
+ """True when the bot was asked to shut down (SIGTERM or SIGINT)."""
168
+ self._cooperative = True
169
+ return self._stop.is_set()
170
+
171
+ @property
172
+ def stopping(self) -> bool:
173
+ return self.should_stop()
174
+
175
+ def request_stop(self) -> None:
176
+ """Ask the bot's own loops to finish (same as receiving SIGTERM)."""
177
+ self._stop.set()
178
+ self._unpaused.set()
179
+ self._wake.set()
180
+
181
+ def checkpoint(self) -> bool:
182
+ """Call between units of work.
183
+
184
+ Blocks while the bot is paused, reporting ``paused`` to Forebot.
185
+ Returns ``False`` when the bot should stop, ``True`` otherwise.
186
+ """
187
+ self._cooperative = True
188
+ if self._stop.is_set():
189
+ return False
190
+ if not self.pause_requested:
191
+ return True
192
+ with self._lock:
193
+ before = (self._activity, self._detail)
194
+ self.set_activity(PAUSED, "paused by operator")
195
+ _log.info("paused by operator")
196
+ while self.pause_requested and not self._stop.is_set():
197
+ self._unpaused.wait(1.0)
198
+ if not self._stop.is_set():
199
+ _log.info("resumed")
200
+ self.set_activity(*before)
201
+ return not self._stop.is_set()
202
+
203
+ def sleep(self, seconds: float) -> bool:
204
+ """Sleep, waking early if the bot is asked to stop.
205
+
206
+ Returns ``False`` when the bot should stop.
207
+ """
208
+ self._cooperative = True
209
+ return not self._stop.wait(max(0.0, seconds))
210
+
211
+ def loop(self, interval: float, idle_detail: str = "") -> Iterator[int]:
212
+ """Run a polling loop until the bot is asked to stop.
213
+
214
+ Each iteration first honours pause requests. Between iterations the
215
+ bot reports idle and sleeps ``interval`` seconds::
216
+
217
+ for _ in bot.loop(interval=60, idle_detail="waiting for new alerts"):
218
+ check_for_alerts()
219
+ """
220
+ n = 0
221
+ while self.checkpoint():
222
+ yield n
223
+ n += 1
224
+ self.idle(idle_detail)
225
+ if not self.sleep(interval):
226
+ break
227
+
228
+ # --------------------------------------------------------------- internals
229
+
230
+ def _install_signal_handlers(self) -> None:
231
+ def handler(signum: int, frame: Any) -> None:
232
+ if self._stop.is_set() or not self._cooperative:
233
+ # Second signal, or the bot never checks should_stop():
234
+ # exit now so the bot does not wait for SIGKILL.
235
+ raise SystemExit(128 + signum)
236
+ _log.info("shutdown requested (signal %d)", signum)
237
+ self.request_stop()
238
+
239
+ for sig in (signal.SIGTERM, signal.SIGINT):
240
+ try:
241
+ signal.signal(sig, handler)
242
+ except (ValueError, OSError): # not supported on this platform/thread
243
+ pass
244
+
245
+ def _request(self, method: str, path: str, body: Optional[dict] = None) -> dict:
246
+ data = json.dumps(body).encode() if body is not None else None
247
+ if self.agent_url.startswith("unix://"):
248
+ return self._unix_request(method, path, data)
249
+ req = urllib.request.Request(self.agent_url + path, data=data, method=method)
250
+ req.add_header("Authorization", "Bearer " + self._token)
251
+ if data is not None:
252
+ req.add_header("Content-Type", "application/json")
253
+ with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 - local runner URL
254
+ return json.loads(resp.read().decode() or "{}")
255
+
256
+ def _unix_request(self, method: str, path: str, data: Optional[bytes]) -> dict:
257
+ headers = {"Authorization": "Bearer " + self._token}
258
+ if data is not None:
259
+ headers["Content-Type"] = "application/json"
260
+ conn = _UnixHTTPConnection(self.agent_url[len("unix://") :], timeout=5)
261
+ try:
262
+ conn.request(method, path, body=data, headers=headers)
263
+ resp = conn.getresponse()
264
+ raw = resp.read()
265
+ if resp.status >= 400:
266
+ raise urllib.error.HTTPError(self.agent_url + path, resp.status, resp.reason, resp.headers, None)
267
+ return json.loads(raw.decode() or "{}")
268
+ finally:
269
+ conn.close()
270
+
271
+ def _send_heartbeat(self) -> None:
272
+ with self._lock:
273
+ body = {"activity": self._activity, "detail": self._detail}
274
+ try:
275
+ reply = self._request("POST", "/v1/heartbeat", body)
276
+ except (urllib.error.URLError, OSError, ValueError) as exc:
277
+ _log.debug("heartbeat failed: %s", exc)
278
+ return
279
+ paused = bool(reply.get("paused"))
280
+ with self._lock:
281
+ changed = paused != self._pause_requested
282
+ self._pause_requested = paused
283
+ if changed:
284
+ if paused:
285
+ self._unpaused.clear()
286
+ _log.info("pause requested")
287
+ else:
288
+ self._unpaused.set()
289
+
290
+ def _heartbeat_loop(self) -> None:
291
+ while not self._closed:
292
+ self._wake.wait(self.heartbeat_interval)
293
+ self._wake.clear()
294
+ if self._closed:
295
+ break
296
+ self._send_heartbeat()
297
+
298
+ def close(self) -> None:
299
+ """Stop the heartbeat thread."""
300
+ self._closed = True
301
+ self._wake.set()
302
+
303
+
304
+ class _Activity:
305
+ def __init__(self, bot: Bot, prev: tuple) -> None:
306
+ self._bot = bot
307
+ self._prev = prev
308
+
309
+ def __enter__(self) -> "_Activity":
310
+ return self
311
+
312
+ def __exit__(self, *exc: Any) -> None:
313
+ state, detail = self._prev
314
+ if state == WORKING: # nested: go back to the outer task
315
+ self._bot.set_activity(WORKING, detail)
316
+ else:
317
+ self._bot.idle()
318
+
319
+
320
+ class ForebotError(RuntimeError):
321
+ """Raised when the bot cannot talk to its runner."""
322
+
323
+
324
+ def _int_or_none(v: Optional[str]) -> Optional[int]:
325
+ try:
326
+ return int(v) if v else None
327
+ except ValueError:
328
+ return None
329
+
330
+
331
+ _default: Optional[Bot] = None
332
+ _default_lock = threading.Lock()
333
+
334
+
335
+ def connect(**kwargs: Any) -> Bot:
336
+ """Return the process-wide :class:`Bot`, creating it on first call."""
337
+ global _default
338
+ with _default_lock:
339
+ if _default is None:
340
+ _default = Bot(**kwargs)
341
+ return _default
forebot/logs.py ADDED
@@ -0,0 +1,57 @@
1
+ """Structured logging that the Forebot runner understands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json
7
+ import logging
8
+ import os
9
+ import sys
10
+ from typing import Optional, TextIO, Union
11
+
12
+
13
+ class JSONFormatter(logging.Formatter):
14
+ """Formats records as one JSON object per line.
15
+
16
+ The runner reads ``level``, ``logger``, ``msg``, ``ts`` and ``exc_info``
17
+ so the web interface can filter by level and show tracebacks as one entry.
18
+ """
19
+
20
+ def format(self, record: logging.LogRecord) -> str:
21
+ out = {
22
+ "ts": _dt.datetime.fromtimestamp(record.created, tz=_dt.timezone.utc).isoformat(),
23
+ "level": record.levelname.lower(),
24
+ "logger": record.name,
25
+ "msg": record.getMessage(),
26
+ }
27
+ if record.exc_info:
28
+ out["exc_info"] = self.formatException(record.exc_info)
29
+ elif record.stack_info:
30
+ out["exc_info"] = self.formatStack(record.stack_info)
31
+ return json.dumps(out, ensure_ascii=False, default=str)
32
+
33
+
34
+ def setup_logging(
35
+ level: Union[int, str] = logging.INFO,
36
+ stream: Optional[TextIO] = None,
37
+ json_format: Optional[bool] = None,
38
+ ) -> logging.Logger:
39
+ """Configure the root logger for a bot.
40
+
41
+ Under a Forebot runner, logs are written as JSON lines to stdout. In
42
+ standalone mode they use a readable text format. Pass ``json_format`` to
43
+ force either one.
44
+ """
45
+ if json_format is None:
46
+ json_format = bool(os.environ.get("FOREBOT_AGENT_URL"))
47
+ handler = logging.StreamHandler(stream or sys.stdout)
48
+ if json_format:
49
+ handler.setFormatter(JSONFormatter())
50
+ else:
51
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s"))
52
+ root = logging.getLogger()
53
+ for h in list(root.handlers):
54
+ root.removeHandler(h)
55
+ root.addHandler(handler)
56
+ root.setLevel(level)
57
+ return root
forebot/py.typed ADDED
File without changes
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: forebot
3
+ Version: 0.2.0
4
+ Summary: Python SDK for bots managed by Forebot
5
+ Project-URL: Homepage, https://github.com/gridcell/forebot
6
+ Project-URL: Documentation, https://github.com/gridcell/forebot/blob/main/docs/sdk.md
7
+ Project-URL: Source, https://github.com/gridcell/forebot-sdk
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7; extra == 'test'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # forebot (Python SDK)
18
+
19
+ The Forebot SDK lets a Python bot report its activity to Forebot, read its configuration, and cooperate with pause and stop requests. It has no dependencies.
20
+
21
+ ```bash
22
+ pip install forebot
23
+ ```
24
+
25
+ ```python
26
+ import forebot
27
+
28
+ forebot.setup_logging()
29
+ bot = forebot.connect()
30
+
31
+ for _ in bot.loop(interval=60, idle_detail="waiting for work"):
32
+ with bot.working("checking the queue"):
33
+ ...
34
+ ```
35
+
36
+ See the [SDK guide](https://github.com/gridcell/forebot/blob/main/docs/sdk.md) in the Forebot repository for the full guide.
37
+
38
+ ## Development
39
+
40
+ ```bash
41
+ uv run --with pytest --with-editable . pytest -q
42
+ ```
43
+
44
+ ## Release
45
+
46
+ 1. Set `version` in `pyproject.toml` and commit.
47
+ 2. Tag the commit with the same version and push the tag:
48
+
49
+ ```bash
50
+ git tag v0.2.0
51
+ git push origin v0.2.0
52
+ ```
53
+
54
+ The `publish` workflow runs the tests, checks that the tag matches the version, builds the package, and uploads it to PyPI.
@@ -0,0 +1,7 @@
1
+ forebot/__init__.py,sha256=WHuHClwo9wymGl-qlfowGV0dSFERkq3m7UX_1A9NCpA,762
2
+ forebot/bot.py,sha256=-zI-HpmfTmabwTyVq7lBQD9dJJ5z7hcGLDZuH5Fk6kU,11989
3
+ forebot/logs.py,sha256=hXEXsvcE7_UCpeIwDD1wr1zZUCF2EbOjZ5x8YD4ZZDY,1909
4
+ forebot/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ forebot-0.2.0.dist-info/METADATA,sha256=ZwwjFDggwVubYSI9glE6r1sTUPIy4UHMwEHZW8Ib5oc,1538
6
+ forebot-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ forebot-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any