runnotify 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runnotify-0.1.0/.gitignore +13 -0
- runnotify-0.1.0/CHANGELOG.md +56 -0
- runnotify-0.1.0/LICENSE +21 -0
- runnotify-0.1.0/PKG-INFO +226 -0
- runnotify-0.1.0/README.md +198 -0
- runnotify-0.1.0/pyproject.toml +109 -0
- runnotify-0.1.0/src/runnotify/__init__.py +75 -0
- runnotify-0.1.0/src/runnotify/_cli.py +169 -0
- runnotify-0.1.0/src/runnotify/_env.py +73 -0
- runnotify-0.1.0/src/runnotify/_http.py +151 -0
- runnotify-0.1.0/src/runnotify/_watchdog.py +112 -0
- runnotify-0.1.0/src/runnotify/channel.py +252 -0
- runnotify-0.1.0/src/runnotify/channels/__init__.py +18 -0
- runnotify-0.1.0/src/runnotify/channels/notion.py +234 -0
- runnotify-0.1.0/src/runnotify/channels/slack.py +148 -0
- runnotify-0.1.0/src/runnotify/config.py +284 -0
- runnotify-0.1.0/src/runnotify/event.py +139 -0
- runnotify-0.1.0/src/runnotify/notifier.py +487 -0
- runnotify-0.1.0/src/runnotify/py.typed +0 -0
- runnotify-0.1.0/tests/__init__.py +0 -0
- runnotify-0.1.0/tests/conftest.py +56 -0
- runnotify-0.1.0/tests/fakes.py +144 -0
- runnotify-0.1.0/tests/test_architecture.py +120 -0
- runnotify-0.1.0/tests/test_channels.py +262 -0
- runnotify-0.1.0/tests/test_cli.py +233 -0
- runnotify-0.1.0/tests/test_config.py +199 -0
- runnotify-0.1.0/tests/test_env.py +76 -0
- runnotify-0.1.0/tests/test_event.py +72 -0
- runnotify-0.1.0/tests/test_http.py +123 -0
- runnotify-0.1.0/tests/test_notifier.py +286 -0
- runnotify-0.1.0/tests/test_registry.py +163 -0
- runnotify-0.1.0/tests/test_watchdog.py +225 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-22
|
|
10
|
+
|
|
11
|
+
First release. Extracted from a single-file notifier used to report on
|
|
12
|
+
long-running measurement jobs, and rebuilt around a channel abstraction.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `Notifier` with `RUNNING`, `PROGRESS`, `COMPLETED`, `CANCELLED`, `HANG`,
|
|
17
|
+
`ERROR` and `OOM` statuses, progress throttling and hang detection.
|
|
18
|
+
- Context manager support: `RUNNING` on entry; `COMPLETED`, `CANCELLED` or
|
|
19
|
+
`ERROR` with a traceback tail on exit, never suppressing the exception.
|
|
20
|
+
- A `Channel` protocol with three ways to reach an implementation — construct
|
|
21
|
+
it, register it by name, or publish it from another distribution through the
|
|
22
|
+
`runnotify.channels` entry-point group.
|
|
23
|
+
- Built-in Slack and Notion channels, each the only module knowing its vendor.
|
|
24
|
+
- Per-channel `min_status` filtering over an explicit severity ordering, so one
|
|
25
|
+
channel can carry alerts and another the full log.
|
|
26
|
+
- Layered configuration: keyword arguments, environment variables, a TOML file
|
|
27
|
+
(`runnotify.toml` or `[tool.runnotify]`), then defaults. A `$VAR` reference to
|
|
28
|
+
an unset variable drops the key rather than emptying it.
|
|
29
|
+
- Bounded retries with exponential backoff, honouring `Retry-After`.
|
|
30
|
+
- An out-of-process OOM watchdog for the `SIGKILL` case `atexit` cannot observe.
|
|
31
|
+
- A `runnotify` command with distinct exit codes and `--dry-run`,
|
|
32
|
+
`--list-channels`, `--channel` and `--version`.
|
|
33
|
+
- Type annotations throughout, with a `py.typed` marker.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
Relative to the single-file predecessor this package replaces:
|
|
38
|
+
|
|
39
|
+
- Watchdog configuration is passed on stdin rather than interpolated into a
|
|
40
|
+
`python -c` argument, where the webhook URL and Notion API token were readable
|
|
41
|
+
through `ps` by any user on the machine.
|
|
42
|
+
- Terminality is read from the status itself rather than tracked by a flag that
|
|
43
|
+
only the named methods set, which previously let a generic send leave the exit
|
|
44
|
+
hook armed and emit a second, untrue `CANCELLED`.
|
|
45
|
+
- The first progress event is no longer throttled. The previous sentinel of
|
|
46
|
+
`0.0` was compared against `time.monotonic()`, which is uptime-based, so on a
|
|
47
|
+
recently booted host the first event was silently dropped.
|
|
48
|
+
- The Notion log keeps its most recent lines instead of its first ones.
|
|
49
|
+
- `OOM` has a Slack emoji; it was mapped for Notion only and rendered as the
|
|
50
|
+
fallback pin.
|
|
51
|
+
- Delivery failures are logged rather than printed, so a library no longer
|
|
52
|
+
writes to a caller's stdout.
|
|
53
|
+
- `progress` and `oom` are reachable from the command line.
|
|
54
|
+
|
|
55
|
+
[Unreleased]: https://github.com/Saiid2001/runnotify/compare/v0.1.0...HEAD
|
|
56
|
+
[0.1.0]: https://github.com/Saiid2001/runnotify/releases/tag/v0.1.0
|
runnotify-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saiid El Hajj Chehade
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
runnotify-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runnotify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Status reporting for long, unattended runs: Slack, Notion, or your own channel.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Saiid2001/runnotify
|
|
6
|
+
Project-URL: Source, https://github.com/Saiid2001/runnotify
|
|
7
|
+
Project-URL: Issues, https://github.com/Saiid2001/runnotify/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Saiid2001/runnotify/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Saiid El Hajj Chehade
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: monitoring,notifications,notion,oom,slack,watchdog
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
+
Classifier: Topic :: Communications :: Chat
|
|
24
|
+
Classifier: Topic :: System :: Monitoring
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# runnotify
|
|
30
|
+
|
|
31
|
+
Status reporting for long, unattended runs — to Slack, to Notion, or to a channel
|
|
32
|
+
you write yourself.
|
|
33
|
+
|
|
34
|
+
A job that is meant to be left alone fails in ways nobody is watching for. It
|
|
35
|
+
dies on item 40 of 900. It wedges on something that never returns. The kernel
|
|
36
|
+
kills it and leaves no traceback anywhere. Each of those ends with a short result
|
|
37
|
+
that looks like a result. `runnotify` tells you which one happened.
|
|
38
|
+
|
|
39
|
+
- **No dependencies.** Standard library only, on Python 3.11+.
|
|
40
|
+
- **Optional by construction.** With nothing configured, every call is a no-op
|
|
41
|
+
and your run is unchanged. A notifier must never be the reason a job fails.
|
|
42
|
+
- **Covers the endings you can't catch.** An exit hook reports processes that
|
|
43
|
+
end without saying so; a detached watchdog reports the ones killed with
|
|
44
|
+
`SIGKILL`, which no `atexit` hook will ever see.
|
|
45
|
+
- **Pluggable.** Slack and Notion ship here. A channel is one small class, and a
|
|
46
|
+
separate package can publish one that is discovered on install.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install runnotify
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Use
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from runnotify import Notifier
|
|
56
|
+
|
|
57
|
+
with Notifier("nightly-crawl") as run:
|
|
58
|
+
for i, item in enumerate(items):
|
|
59
|
+
process(item)
|
|
60
|
+
run.ping() # "still moving"
|
|
61
|
+
run.progress(f"{i}/{len(items)}", n=i)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The context manager reports `RUNNING` on entry and, on exit, `COMPLETED`,
|
|
65
|
+
`CANCELLED` (on `KeyboardInterrupt` / `SystemExit`) or `ERROR` with the tail of
|
|
66
|
+
the traceback. Exceptions are never suppressed.
|
|
67
|
+
|
|
68
|
+
Without the context manager, call the statuses yourself:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
run = Notifier("nightly-crawl")
|
|
72
|
+
run.start("2,400 pages queued")
|
|
73
|
+
...
|
|
74
|
+
run.completed("2,381 pages, 19 unreachable")
|
|
75
|
+
run.error("stage 2 failed: connection timeout")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If the process ends with nothing terminal sent, an exit hook reports `CANCELLED`
|
|
79
|
+
rather than leaving the run looking alive forever.
|
|
80
|
+
|
|
81
|
+
### Surviving the OOM killer
|
|
82
|
+
|
|
83
|
+
`atexit` runs for every ending Python can observe. `SIGKILL` is not one of them,
|
|
84
|
+
and `SIGKILL` is what the Linux OOM killer sends.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
run = Notifier("big-embedding-job")
|
|
88
|
+
run.start_oom_watchdog() # detached child, disarmed by any terminal status
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The watchdog polls whether your process still exists and, if it disappears
|
|
92
|
+
without a terminal status, reports `OOM` through your own channels — including
|
|
93
|
+
any you added. POSIX only; on Windows it declines to arm and says so, because
|
|
94
|
+
the liveness probe it uses would terminate the process it is watching.
|
|
95
|
+
|
|
96
|
+
### Detecting a hang
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
run = Notifier("crawl", hang_timeout=1800)
|
|
100
|
+
run.start_hang_watcher() # background thread
|
|
101
|
+
...
|
|
102
|
+
run.ping() # call wherever real progress happens
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Nothing pings for `hang_timeout` seconds and a single `HANG` goes out. `ping()`
|
|
106
|
+
re-arms it, so one stall produces one message.
|
|
107
|
+
|
|
108
|
+
## Configure
|
|
109
|
+
|
|
110
|
+
Four layers, each overriding the one below: **keyword arguments → environment
|
|
111
|
+
variables → TOML file → defaults.**
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
# runnotify.toml (or [tool.runnotify] in pyproject.toml)
|
|
115
|
+
hang_timeout = 1800
|
|
116
|
+
progress_interval = 300
|
|
117
|
+
|
|
118
|
+
[channels.slack]
|
|
119
|
+
webhook_url = "$NOTIFY_WEBHOOK_URL" # read from the environment
|
|
120
|
+
min_status = "completed" # terminal events only — this is the alert
|
|
121
|
+
|
|
122
|
+
[channels.notion]
|
|
123
|
+
token = "$NOTION_API_TOKEN"
|
|
124
|
+
database_id = "$NOTION_DATABASE_ID"
|
|
125
|
+
min_status = "progress" # everything — this is the log
|
|
126
|
+
|
|
127
|
+
[watchdog]
|
|
128
|
+
oom = true
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A value that is exactly `$VAR` or `${VAR}` is read from the environment, and if
|
|
132
|
+
that variable is unset the key is **dropped** rather than set to an empty string
|
|
133
|
+
— a missing secret reads as *not configured*, not as configured with nothing.
|
|
134
|
+
|
|
135
|
+
`min_status` is the knob that makes two channels worth having: severity runs
|
|
136
|
+
`progress < running < completed < cancelled < hang < error < oom`, so
|
|
137
|
+
`min_status = "completed"` selects exactly the terminal statuses.
|
|
138
|
+
|
|
139
|
+
Environment variables work without any file:
|
|
140
|
+
|
|
141
|
+
| Variable | Effect |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `NOTIFY_WEBHOOK_URL` | Slack incoming webhook |
|
|
144
|
+
| `NOTION_API_TOKEN`, `NOTION_DATABASE_ID` | Notion database |
|
|
145
|
+
| `NOTIFY_SOURCE` | Machine identifier (default: hostname) |
|
|
146
|
+
| `RUNNOTIFY_<CHANNEL>_<OPTION>` | Any option of any channel, e.g. `RUNNOTIFY_SLACK_MIN_STATUS` |
|
|
147
|
+
| `RUNNOTIFY_TOPIC`, `RUNNOTIFY_DRY_RUN`, `RUNNOTIFY_STRICT`, … | Top-level settings |
|
|
148
|
+
|
|
149
|
+
By default a channel that cannot be configured costs you that channel and
|
|
150
|
+
nothing else — the run starts, the other channels report, and the problem is
|
|
151
|
+
logged and available on `notifier.problems`. Pass `strict=True` when a missing
|
|
152
|
+
webhook should stop the job instead.
|
|
153
|
+
|
|
154
|
+
## From the shell
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
runnotify --topic nightly-crawl --status completed --message "2,381 pages"
|
|
158
|
+
runnotify --list-channels
|
|
159
|
+
runnotify --topic x --status error --dry-run -v # build it, send nothing
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
It reads the nearest `.env` the way a run would, and names the file it read when
|
|
163
|
+
nothing is configured — the common case is a webhook sitting in a `.env` that
|
|
164
|
+
the shell never loaded.
|
|
165
|
+
|
|
166
|
+
Exit codes: `0` delivered, `1` a channel failed, `2` nothing was configured.
|
|
167
|
+
|
|
168
|
+
## Writing a channel
|
|
169
|
+
|
|
170
|
+
A channel consumes events and delivers them. That is the whole contract:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from runnotify import BaseChannel, Event, Notifier, register
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ConsoleChannel(BaseChannel):
|
|
177
|
+
name = "console"
|
|
178
|
+
|
|
179
|
+
def deliver(self, event: Event) -> None:
|
|
180
|
+
print(f"[{event.status}] {event.topic}: {event.message}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# 1. pass it in directly
|
|
184
|
+
Notifier("my-run", channels=[ConsoleChannel()])
|
|
185
|
+
|
|
186
|
+
# 2. or register it, and select it by name in runnotify.toml
|
|
187
|
+
register("console", ConsoleChannel)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Raise on failure — the notifier isolates and records it, and a channel that
|
|
191
|
+
swallows its own errors reports success it did not achieve. Subclassing
|
|
192
|
+
`BaseChannel` gets you `min_status` filtering, a `close()` no-op and a `repr`
|
|
193
|
+
that redacts secrets; satisfying the `Channel` protocol directly also works.
|
|
194
|
+
|
|
195
|
+
To publish one from your own package, advertise the entry-point group:
|
|
196
|
+
|
|
197
|
+
```toml
|
|
198
|
+
[project.entry-points."runnotify.channels"]
|
|
199
|
+
discord = "runnotify_discord:DiscordChannel"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Installing that distribution makes `[channels.discord]` selectable. Nothing in
|
|
203
|
+
`runnotify` changes, and the new channel receives OOM reports from the watchdog
|
|
204
|
+
for free.
|
|
205
|
+
|
|
206
|
+
## Notion setup
|
|
207
|
+
|
|
208
|
+
Share a database with your integration and give it these properties. Rename any
|
|
209
|
+
of them via `[channels.notion.properties]`; set one to `""` to skip it.
|
|
210
|
+
|
|
211
|
+
| Property | Type | Role |
|
|
212
|
+
| --- | --- | --- |
|
|
213
|
+
| `Name` | Title | The topic |
|
|
214
|
+
| `Status` | Status | `Running` / `Completed` / `Error` / `Hung` / `Cancelled` / `OOM` |
|
|
215
|
+
| `Source` | Text | Machine |
|
|
216
|
+
| `Latest Message` | Text | Most recent message |
|
|
217
|
+
| `Log` | Text | Accumulated history |
|
|
218
|
+
| `Started At`, `Last Updated` | Date | |
|
|
219
|
+
| `Progress` | Number | |
|
|
220
|
+
|
|
221
|
+
A `PROGRESS` event deliberately leaves `Status` alone, so a progress ping does
|
|
222
|
+
not flicker the row out of `Running`.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# runnotify
|
|
2
|
+
|
|
3
|
+
Status reporting for long, unattended runs — to Slack, to Notion, or to a channel
|
|
4
|
+
you write yourself.
|
|
5
|
+
|
|
6
|
+
A job that is meant to be left alone fails in ways nobody is watching for. It
|
|
7
|
+
dies on item 40 of 900. It wedges on something that never returns. The kernel
|
|
8
|
+
kills it and leaves no traceback anywhere. Each of those ends with a short result
|
|
9
|
+
that looks like a result. `runnotify` tells you which one happened.
|
|
10
|
+
|
|
11
|
+
- **No dependencies.** Standard library only, on Python 3.11+.
|
|
12
|
+
- **Optional by construction.** With nothing configured, every call is a no-op
|
|
13
|
+
and your run is unchanged. A notifier must never be the reason a job fails.
|
|
14
|
+
- **Covers the endings you can't catch.** An exit hook reports processes that
|
|
15
|
+
end without saying so; a detached watchdog reports the ones killed with
|
|
16
|
+
`SIGKILL`, which no `atexit` hook will ever see.
|
|
17
|
+
- **Pluggable.** Slack and Notion ship here. A channel is one small class, and a
|
|
18
|
+
separate package can publish one that is discovered on install.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install runnotify
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from runnotify import Notifier
|
|
28
|
+
|
|
29
|
+
with Notifier("nightly-crawl") as run:
|
|
30
|
+
for i, item in enumerate(items):
|
|
31
|
+
process(item)
|
|
32
|
+
run.ping() # "still moving"
|
|
33
|
+
run.progress(f"{i}/{len(items)}", n=i)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The context manager reports `RUNNING` on entry and, on exit, `COMPLETED`,
|
|
37
|
+
`CANCELLED` (on `KeyboardInterrupt` / `SystemExit`) or `ERROR` with the tail of
|
|
38
|
+
the traceback. Exceptions are never suppressed.
|
|
39
|
+
|
|
40
|
+
Without the context manager, call the statuses yourself:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
run = Notifier("nightly-crawl")
|
|
44
|
+
run.start("2,400 pages queued")
|
|
45
|
+
...
|
|
46
|
+
run.completed("2,381 pages, 19 unreachable")
|
|
47
|
+
run.error("stage 2 failed: connection timeout")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If the process ends with nothing terminal sent, an exit hook reports `CANCELLED`
|
|
51
|
+
rather than leaving the run looking alive forever.
|
|
52
|
+
|
|
53
|
+
### Surviving the OOM killer
|
|
54
|
+
|
|
55
|
+
`atexit` runs for every ending Python can observe. `SIGKILL` is not one of them,
|
|
56
|
+
and `SIGKILL` is what the Linux OOM killer sends.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
run = Notifier("big-embedding-job")
|
|
60
|
+
run.start_oom_watchdog() # detached child, disarmed by any terminal status
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The watchdog polls whether your process still exists and, if it disappears
|
|
64
|
+
without a terminal status, reports `OOM` through your own channels — including
|
|
65
|
+
any you added. POSIX only; on Windows it declines to arm and says so, because
|
|
66
|
+
the liveness probe it uses would terminate the process it is watching.
|
|
67
|
+
|
|
68
|
+
### Detecting a hang
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
run = Notifier("crawl", hang_timeout=1800)
|
|
72
|
+
run.start_hang_watcher() # background thread
|
|
73
|
+
...
|
|
74
|
+
run.ping() # call wherever real progress happens
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Nothing pings for `hang_timeout` seconds and a single `HANG` goes out. `ping()`
|
|
78
|
+
re-arms it, so one stall produces one message.
|
|
79
|
+
|
|
80
|
+
## Configure
|
|
81
|
+
|
|
82
|
+
Four layers, each overriding the one below: **keyword arguments → environment
|
|
83
|
+
variables → TOML file → defaults.**
|
|
84
|
+
|
|
85
|
+
```toml
|
|
86
|
+
# runnotify.toml (or [tool.runnotify] in pyproject.toml)
|
|
87
|
+
hang_timeout = 1800
|
|
88
|
+
progress_interval = 300
|
|
89
|
+
|
|
90
|
+
[channels.slack]
|
|
91
|
+
webhook_url = "$NOTIFY_WEBHOOK_URL" # read from the environment
|
|
92
|
+
min_status = "completed" # terminal events only — this is the alert
|
|
93
|
+
|
|
94
|
+
[channels.notion]
|
|
95
|
+
token = "$NOTION_API_TOKEN"
|
|
96
|
+
database_id = "$NOTION_DATABASE_ID"
|
|
97
|
+
min_status = "progress" # everything — this is the log
|
|
98
|
+
|
|
99
|
+
[watchdog]
|
|
100
|
+
oom = true
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A value that is exactly `$VAR` or `${VAR}` is read from the environment, and if
|
|
104
|
+
that variable is unset the key is **dropped** rather than set to an empty string
|
|
105
|
+
— a missing secret reads as *not configured*, not as configured with nothing.
|
|
106
|
+
|
|
107
|
+
`min_status` is the knob that makes two channels worth having: severity runs
|
|
108
|
+
`progress < running < completed < cancelled < hang < error < oom`, so
|
|
109
|
+
`min_status = "completed"` selects exactly the terminal statuses.
|
|
110
|
+
|
|
111
|
+
Environment variables work without any file:
|
|
112
|
+
|
|
113
|
+
| Variable | Effect |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `NOTIFY_WEBHOOK_URL` | Slack incoming webhook |
|
|
116
|
+
| `NOTION_API_TOKEN`, `NOTION_DATABASE_ID` | Notion database |
|
|
117
|
+
| `NOTIFY_SOURCE` | Machine identifier (default: hostname) |
|
|
118
|
+
| `RUNNOTIFY_<CHANNEL>_<OPTION>` | Any option of any channel, e.g. `RUNNOTIFY_SLACK_MIN_STATUS` |
|
|
119
|
+
| `RUNNOTIFY_TOPIC`, `RUNNOTIFY_DRY_RUN`, `RUNNOTIFY_STRICT`, … | Top-level settings |
|
|
120
|
+
|
|
121
|
+
By default a channel that cannot be configured costs you that channel and
|
|
122
|
+
nothing else — the run starts, the other channels report, and the problem is
|
|
123
|
+
logged and available on `notifier.problems`. Pass `strict=True` when a missing
|
|
124
|
+
webhook should stop the job instead.
|
|
125
|
+
|
|
126
|
+
## From the shell
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
runnotify --topic nightly-crawl --status completed --message "2,381 pages"
|
|
130
|
+
runnotify --list-channels
|
|
131
|
+
runnotify --topic x --status error --dry-run -v # build it, send nothing
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
It reads the nearest `.env` the way a run would, and names the file it read when
|
|
135
|
+
nothing is configured — the common case is a webhook sitting in a `.env` that
|
|
136
|
+
the shell never loaded.
|
|
137
|
+
|
|
138
|
+
Exit codes: `0` delivered, `1` a channel failed, `2` nothing was configured.
|
|
139
|
+
|
|
140
|
+
## Writing a channel
|
|
141
|
+
|
|
142
|
+
A channel consumes events and delivers them. That is the whole contract:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from runnotify import BaseChannel, Event, Notifier, register
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ConsoleChannel(BaseChannel):
|
|
149
|
+
name = "console"
|
|
150
|
+
|
|
151
|
+
def deliver(self, event: Event) -> None:
|
|
152
|
+
print(f"[{event.status}] {event.topic}: {event.message}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# 1. pass it in directly
|
|
156
|
+
Notifier("my-run", channels=[ConsoleChannel()])
|
|
157
|
+
|
|
158
|
+
# 2. or register it, and select it by name in runnotify.toml
|
|
159
|
+
register("console", ConsoleChannel)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Raise on failure — the notifier isolates and records it, and a channel that
|
|
163
|
+
swallows its own errors reports success it did not achieve. Subclassing
|
|
164
|
+
`BaseChannel` gets you `min_status` filtering, a `close()` no-op and a `repr`
|
|
165
|
+
that redacts secrets; satisfying the `Channel` protocol directly also works.
|
|
166
|
+
|
|
167
|
+
To publish one from your own package, advertise the entry-point group:
|
|
168
|
+
|
|
169
|
+
```toml
|
|
170
|
+
[project.entry-points."runnotify.channels"]
|
|
171
|
+
discord = "runnotify_discord:DiscordChannel"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Installing that distribution makes `[channels.discord]` selectable. Nothing in
|
|
175
|
+
`runnotify` changes, and the new channel receives OOM reports from the watchdog
|
|
176
|
+
for free.
|
|
177
|
+
|
|
178
|
+
## Notion setup
|
|
179
|
+
|
|
180
|
+
Share a database with your integration and give it these properties. Rename any
|
|
181
|
+
of them via `[channels.notion.properties]`; set one to `""` to skip it.
|
|
182
|
+
|
|
183
|
+
| Property | Type | Role |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| `Name` | Title | The topic |
|
|
186
|
+
| `Status` | Status | `Running` / `Completed` / `Error` / `Hung` / `Cancelled` / `OOM` |
|
|
187
|
+
| `Source` | Text | Machine |
|
|
188
|
+
| `Latest Message` | Text | Most recent message |
|
|
189
|
+
| `Log` | Text | Accumulated history |
|
|
190
|
+
| `Started At`, `Last Updated` | Date | |
|
|
191
|
+
| `Progress` | Number | |
|
|
192
|
+
|
|
193
|
+
A `PROGRESS` event deliberately leaves `Status` alone, so a progress ping does
|
|
194
|
+
not flicker the row out of `Running`.
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "runnotify"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Status reporting for long, unattended runs: Slack, Notion, or your own channel."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
authors = [{ name = "Saiid El Hajj Chehade" }]
|
|
10
|
+
keywords = ["notifications", "slack", "notion", "monitoring", "watchdog", "oom"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
22
|
+
"Topic :: System :: Monitoring",
|
|
23
|
+
"Topic :: Communications :: Chat",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# No runtime dependencies, deliberately. This package is imported by every run in
|
|
28
|
+
# a study; a dependency here is a dependency there, and a resolver conflict here
|
|
29
|
+
# would be a resolver conflict in work that has nothing to do with notifications.
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Saiid2001/runnotify"
|
|
34
|
+
Source = "https://github.com/Saiid2001/runnotify"
|
|
35
|
+
Issues = "https://github.com/Saiid2001/runnotify/issues"
|
|
36
|
+
Changelog = "https://github.com/Saiid2001/runnotify/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
runnotify = "runnotify._cli:main"
|
|
40
|
+
|
|
41
|
+
# Channels published by other distributions are discovered through this group.
|
|
42
|
+
# The built-ins are registered directly in runnotify.channels; the group is here
|
|
43
|
+
# so `runnotify --list-channels` documents the extension point by example.
|
|
44
|
+
[project.entry-points."runnotify.channels"]
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=8",
|
|
49
|
+
"pytest-cov>=5",
|
|
50
|
+
"mypy>=1.11",
|
|
51
|
+
"ruff>=0.6",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["hatchling"]
|
|
56
|
+
build-backend = "hatchling.build"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.version]
|
|
59
|
+
path = "src/runnotify/__init__.py"
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.wheel]
|
|
62
|
+
packages = ["src/runnotify"]
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.sdist]
|
|
65
|
+
include = ["/src", "/tests", "/README.md", "/LICENSE", "/CHANGELOG.md"]
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
addopts = "-q --strict-markers --strict-config"
|
|
70
|
+
filterwarnings = ["error"]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
source = ["runnotify"]
|
|
74
|
+
branch = true
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
exclude_lines = [
|
|
78
|
+
"pragma: no cover",
|
|
79
|
+
"raise NotImplementedError",
|
|
80
|
+
"if __name__ == .__main__.:",
|
|
81
|
+
"if TYPE_CHECKING:",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff]
|
|
85
|
+
line-length = 100
|
|
86
|
+
target-version = "py311"
|
|
87
|
+
src = ["src", "tests"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint]
|
|
90
|
+
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "RUF"]
|
|
91
|
+
ignore = [
|
|
92
|
+
# The notifier catches broad exceptions on purpose: a channel failure must
|
|
93
|
+
# never propagate into the run being reported on.
|
|
94
|
+
"BLE001",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint.per-file-ignores]
|
|
98
|
+
"tests/*" = ["S101"]
|
|
99
|
+
|
|
100
|
+
[tool.mypy]
|
|
101
|
+
python_version = "3.11"
|
|
102
|
+
files = ["src/runnotify"]
|
|
103
|
+
strict = true
|
|
104
|
+
warn_unreachable = true
|
|
105
|
+
enable_error_code = ["ignore-without-code", "redundant-expr"]
|
|
106
|
+
|
|
107
|
+
[[tool.mypy.overrides]]
|
|
108
|
+
module = "tests.*"
|
|
109
|
+
strict = false
|