runnotify 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.
- runnotify/__init__.py +75 -0
- runnotify/_cli.py +169 -0
- runnotify/_env.py +73 -0
- runnotify/_http.py +151 -0
- runnotify/_watchdog.py +112 -0
- runnotify/channel.py +252 -0
- runnotify/channels/__init__.py +18 -0
- runnotify/channels/notion.py +234 -0
- runnotify/channels/slack.py +148 -0
- runnotify/config.py +284 -0
- runnotify/event.py +139 -0
- runnotify/notifier.py +487 -0
- runnotify/py.typed +0 -0
- runnotify-0.1.0.dist-info/METADATA +226 -0
- runnotify-0.1.0.dist-info/RECORD +18 -0
- runnotify-0.1.0.dist-info/WHEEL +4 -0
- runnotify-0.1.0.dist-info/entry_points.txt +2 -0
- runnotify-0.1.0.dist-info/licenses/LICENSE +21 -0
runnotify/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Status reporting for long, unattended runs.
|
|
2
|
+
|
|
3
|
+
A run that is meant to be left alone fails in ways nobody is watching for: it
|
|
4
|
+
dies partway, it wedges on something that never returns, or the kernel kills it
|
|
5
|
+
and leaves no trace. Each ends with a short result that looks like a result.
|
|
6
|
+
This package reports those endings to wherever you read them.
|
|
7
|
+
|
|
8
|
+
from runnotify import Notifier
|
|
9
|
+
|
|
10
|
+
with Notifier("nightly-crawl") as run:
|
|
11
|
+
for i, item in enumerate(items):
|
|
12
|
+
process(item)
|
|
13
|
+
run.ping()
|
|
14
|
+
run.progress(f"{i}/{len(items)}", n=i)
|
|
15
|
+
|
|
16
|
+
The context manager reports RUNNING on entry and COMPLETED, CANCELLED or ERROR
|
|
17
|
+
on exit. Without it, call the status methods directly; an exit hook still reports
|
|
18
|
+
CANCELLED if the process ends with nothing terminal sent, and
|
|
19
|
+
:meth:`~runnotify.notifier.Notifier.start_oom_watchdog` covers the ``SIGKILL``
|
|
20
|
+
case the interpreter never sees.
|
|
21
|
+
|
|
22
|
+
Delivery is pluggable. Slack and Notion ship here; anything satisfying
|
|
23
|
+
:class:`~runnotify.channel.Channel` can be passed to the notifier, registered by
|
|
24
|
+
name for configuration, or published by another distribution through the
|
|
25
|
+
``runnotify.channels`` entry-point group.
|
|
26
|
+
|
|
27
|
+
Reporting is optional by construction: with nothing configured, every call is a
|
|
28
|
+
no-op and the run is unchanged.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
from .channel import (
|
|
38
|
+
BaseChannel,
|
|
39
|
+
Channel,
|
|
40
|
+
ChannelError,
|
|
41
|
+
available,
|
|
42
|
+
build,
|
|
43
|
+
build_all,
|
|
44
|
+
register,
|
|
45
|
+
unregister,
|
|
46
|
+
)
|
|
47
|
+
from .channels import NotionChannel, SlackChannel
|
|
48
|
+
from .config import Config, WatchdogConfig
|
|
49
|
+
from .event import Event, Status
|
|
50
|
+
from .notifier import DeliveryResult, Notifier
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"BaseChannel",
|
|
54
|
+
"Channel",
|
|
55
|
+
"ChannelError",
|
|
56
|
+
"Config",
|
|
57
|
+
"DeliveryResult",
|
|
58
|
+
"Event",
|
|
59
|
+
"Notifier",
|
|
60
|
+
"NotionChannel",
|
|
61
|
+
"SlackChannel",
|
|
62
|
+
"Status",
|
|
63
|
+
"WatchdogConfig",
|
|
64
|
+
"__version__",
|
|
65
|
+
"available",
|
|
66
|
+
"build",
|
|
67
|
+
"build_all",
|
|
68
|
+
"register",
|
|
69
|
+
"unregister",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# A library configures no handlers. Without this, a host application that never
|
|
73
|
+
# calls logging.basicConfig sees "No handlers could be found" noise from a
|
|
74
|
+
# package whose entire job is to stay out of the way.
|
|
75
|
+
logging.getLogger("runnotify").addHandler(logging.NullHandler())
|
runnotify/_cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The ``runnotify`` command: send one event from a shell.
|
|
2
|
+
|
|
3
|
+
Built for the two things a command line is good for here — wiring a notification
|
|
4
|
+
into a shell script or a job runner, and finding out why reporting is not
|
|
5
|
+
working. It loads a ``.env`` the way a run would, names the file it read, and
|
|
6
|
+
says which channel failed rather than reporting a single opaque boolean.
|
|
7
|
+
|
|
8
|
+
Exit codes: ``0`` delivered, ``1`` at least one channel failed, ``2`` nothing was
|
|
9
|
+
configured to deliver to, or the arguments were wrong.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from ._env import find_env_file, load_env
|
|
22
|
+
from .channel import available
|
|
23
|
+
from .config import Config
|
|
24
|
+
from .event import Status
|
|
25
|
+
from .notifier import Notifier
|
|
26
|
+
|
|
27
|
+
__all__ = ["build_parser", "main"]
|
|
28
|
+
|
|
29
|
+
EXIT_OK = 0
|
|
30
|
+
EXIT_DELIVERY_FAILED = 1
|
|
31
|
+
EXIT_NOT_CONFIGURED = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
# The channel list is read from the registry rather than written out, so
|
|
36
|
+
# installing a plugin documents it here without an edit.
|
|
37
|
+
installed = ", ".join(sorted(available())) or "none installed"
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="runnotify",
|
|
40
|
+
description=f"Report the status of a run. Channels available: {installed}.",
|
|
41
|
+
epilog="Configuration: command line > environment > runnotify.toml > defaults.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"runnotify {__version__}")
|
|
44
|
+
parser.add_argument("--topic", help="Identifies the run. Required unless configured.")
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--status",
|
|
47
|
+
type=str.lower,
|
|
48
|
+
choices=[s.value for s in Status],
|
|
49
|
+
help="What to report.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--message", default="", help="Message body.")
|
|
52
|
+
parser.add_argument("--progress", type=float, default=None, help="Numeric progress value.")
|
|
53
|
+
parser.add_argument("--source", default=None, help="Machine identifier (default: hostname).")
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--channel",
|
|
56
|
+
action="append",
|
|
57
|
+
dest="channels",
|
|
58
|
+
metavar="NAME",
|
|
59
|
+
help="Deliver only to this channel. Repeatable.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument("--config", default=None, help="TOML config file to use.")
|
|
62
|
+
parser.add_argument("--env-file", default=None, help="Read this .env instead of searching.")
|
|
63
|
+
parser.add_argument("--no-env", action="store_true", help="Do not read any .env file.")
|
|
64
|
+
parser.add_argument("--dry-run", action="store_true", help="Build the event, deliver nothing.")
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--list-channels", action="store_true", help="List selectable channels and exit."
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("-v", "--verbose", action="count", default=0, help="Repeatable.")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _configure_logging(verbosity: int) -> None:
|
|
73
|
+
level = logging.WARNING
|
|
74
|
+
if verbosity == 1:
|
|
75
|
+
level = logging.INFO
|
|
76
|
+
elif verbosity >= 2:
|
|
77
|
+
level = logging.DEBUG
|
|
78
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_dotenv(args: argparse.Namespace) -> str:
|
|
82
|
+
"""Read the nearest ``.env``, returning the path used for diagnostics."""
|
|
83
|
+
if args.no_env:
|
|
84
|
+
return ""
|
|
85
|
+
if args.env_file:
|
|
86
|
+
return str(args.env_file) if load_env(args.env_file) is not None else ""
|
|
87
|
+
found = find_env_file(Path.cwd())
|
|
88
|
+
if found:
|
|
89
|
+
load_env(found)
|
|
90
|
+
return str(found)
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: list[str] | None = None) -> int:
|
|
95
|
+
parser = build_parser()
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
_configure_logging(args.verbose)
|
|
98
|
+
|
|
99
|
+
if args.list_channels:
|
|
100
|
+
for name, factory in sorted(available().items()):
|
|
101
|
+
print(f"{name}\t{factory.__module__}.{factory.__qualname__}")
|
|
102
|
+
return EXIT_OK
|
|
103
|
+
|
|
104
|
+
if not args.status:
|
|
105
|
+
parser.error("--status is required (or use --list-channels)")
|
|
106
|
+
|
|
107
|
+
env_file = _load_dotenv(args)
|
|
108
|
+
|
|
109
|
+
config = Config.load(args.config, env=os.environ)
|
|
110
|
+
if args.channels:
|
|
111
|
+
wanted = {c.strip().lower() for c in args.channels}
|
|
112
|
+
unknown = wanted - set(config.channels)
|
|
113
|
+
if unknown:
|
|
114
|
+
print(
|
|
115
|
+
f"runnotify: no configuration for channel(s): {', '.join(sorted(unknown))}",
|
|
116
|
+
file=sys.stderr,
|
|
117
|
+
)
|
|
118
|
+
return EXIT_NOT_CONFIGURED
|
|
119
|
+
config.channels = {k: v for k, v in config.channels.items() if k in wanted}
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
notifier = Notifier(
|
|
123
|
+
topic=args.topic,
|
|
124
|
+
config=config,
|
|
125
|
+
source=args.source,
|
|
126
|
+
dry_run=args.dry_run,
|
|
127
|
+
# One event, then exit: the exit hook would otherwise append a
|
|
128
|
+
# second, untrue CANCELLED to every non-terminal status sent here.
|
|
129
|
+
cancel_on_exit=False,
|
|
130
|
+
)
|
|
131
|
+
except ValueError as exc:
|
|
132
|
+
parser.error(str(exc)) # raises SystemExit
|
|
133
|
+
|
|
134
|
+
if not notifier.channels:
|
|
135
|
+
print(_not_configured_message(config, env_file, notifier.problems), file=sys.stderr)
|
|
136
|
+
return EXIT_NOT_CONFIGURED
|
|
137
|
+
|
|
138
|
+
result = notifier.notify(args.status, args.message, progress=args.progress)
|
|
139
|
+
notifier.close()
|
|
140
|
+
|
|
141
|
+
if result:
|
|
142
|
+
print(f"sent {args.status} for {notifier.topic!r} via {', '.join(result.delivered)}")
|
|
143
|
+
return EXIT_OK
|
|
144
|
+
|
|
145
|
+
for name, failure in result.failed.items():
|
|
146
|
+
print(f"runnotify: {name} failed: {failure}", file=sys.stderr)
|
|
147
|
+
if not result.outcomes:
|
|
148
|
+
print(
|
|
149
|
+
f"runnotify: no channel accepted a {args.status} event "
|
|
150
|
+
"(check each channel's min_status)",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
return EXIT_NOT_CONFIGURED
|
|
154
|
+
return EXIT_DELIVERY_FAILED
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _not_configured_message(config: Config, env_file: str, problems: list[str]) -> str:
|
|
158
|
+
"""Say what was looked at, so the next step is obvious."""
|
|
159
|
+
lines = ["runnotify: no channels configured, nothing sent."]
|
|
160
|
+
lines += [f" {problem}" for problem in problems]
|
|
161
|
+
lines.append(f" config file: {config.source_file or 'none found'}")
|
|
162
|
+
lines.append(f" .env file: {env_file or 'none found'}")
|
|
163
|
+
lines.append(f" selectable: {', '.join(sorted(available())) or 'none'}")
|
|
164
|
+
lines.append(" add a [channels.<name>] section to runnotify.toml, or see --help")
|
|
165
|
+
return "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__": # pragma: no cover
|
|
169
|
+
raise SystemExit(main())
|
runnotify/_env.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Finding and reading a ``.env`` file, with no dependencies.
|
|
2
|
+
|
|
3
|
+
The CLI is the tool people reach for when a webhook is not working, and it is
|
|
4
|
+
invoked by hand rather than from a script that has already loaded its own
|
|
5
|
+
environment. Without this it reports a missing variable that is, as far as the
|
|
6
|
+
file is concerned, set.
|
|
7
|
+
|
|
8
|
+
Existing environment variables win: a value exported in the shell is a
|
|
9
|
+
deliberate override and must not be replaced by a file.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
__all__ = ["find_env_file", "load_env", "parse_env"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_env_file(start: Path | str | None = None, *, name: str = ".env") -> Path | None:
|
|
21
|
+
"""The nearest ``name`` at or above ``start``, or None."""
|
|
22
|
+
current = Path(start or Path.cwd()).resolve()
|
|
23
|
+
if current.is_file():
|
|
24
|
+
current = current.parent
|
|
25
|
+
for directory in (current, *current.parents):
|
|
26
|
+
candidate = directory / name
|
|
27
|
+
if candidate.is_file():
|
|
28
|
+
return candidate
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_env(text: str) -> dict[str, str]:
|
|
33
|
+
"""Parse ``KEY=VALUE`` lines.
|
|
34
|
+
|
|
35
|
+
Handles ``export`` prefixes, ``#`` comments, blank lines, and single or
|
|
36
|
+
double quoted values. Anything that is not a assignment is skipped rather
|
|
37
|
+
than raising: a malformed line in someone's ``.env`` must not stop a run from
|
|
38
|
+
reporting.
|
|
39
|
+
"""
|
|
40
|
+
values: dict[str, str] = {}
|
|
41
|
+
for raw in text.splitlines():
|
|
42
|
+
line = raw.strip()
|
|
43
|
+
if not line or line.startswith("#"):
|
|
44
|
+
continue
|
|
45
|
+
if line.startswith("export "):
|
|
46
|
+
line = line[len("export ") :].lstrip()
|
|
47
|
+
key, sep, value = line.partition("=")
|
|
48
|
+
if not sep:
|
|
49
|
+
continue
|
|
50
|
+
key = key.strip()
|
|
51
|
+
if not key:
|
|
52
|
+
continue
|
|
53
|
+
value = value.strip()
|
|
54
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
55
|
+
value = value[1:-1]
|
|
56
|
+
elif "#" in value:
|
|
57
|
+
value = value.split("#", 1)[0].strip()
|
|
58
|
+
values[key] = value
|
|
59
|
+
return values
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_env(path: Path | str, *, override: bool = False) -> dict[str, str]:
|
|
63
|
+
"""Read ``path`` into :data:`os.environ`. Returns what was applied."""
|
|
64
|
+
try:
|
|
65
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
66
|
+
except OSError:
|
|
67
|
+
return {}
|
|
68
|
+
applied: dict[str, str] = {}
|
|
69
|
+
for key, value in parse_env(text).items():
|
|
70
|
+
if override or key not in os.environ:
|
|
71
|
+
os.environ[key] = value
|
|
72
|
+
applied[key] = value
|
|
73
|
+
return applied
|
runnotify/_http.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""A small JSON-over-HTTP client with bounded retries, built on the stdlib.
|
|
2
|
+
|
|
3
|
+
Shared by the channels so that timeout, retry and backoff behave the same
|
|
4
|
+
everywhere and are configured the same way. There is no third-party HTTP
|
|
5
|
+
dependency: a notifier is imported by every run in a study, and a dependency
|
|
6
|
+
here would be a dependency there.
|
|
7
|
+
|
|
8
|
+
Transient failures are retried; a request that fails is retried at most
|
|
9
|
+
``retries`` times with exponential backoff, and ``Retry-After`` is honoured when
|
|
10
|
+
the server sends one. Statuses outside :data:`RETRY_STATUSES` are final — a 401
|
|
11
|
+
from a bad token will not be retried, because it will not succeed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import email.utils
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
__all__ = ["HttpError", "Response", "request"]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("runnotify")
|
|
30
|
+
|
|
31
|
+
#: Statuses worth trying again: rate limits, and the server-side 5xx family that
|
|
32
|
+
#: commonly reflects a momentary condition.
|
|
33
|
+
RETRY_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
34
|
+
|
|
35
|
+
#: Ceiling on any single backoff sleep, including a server's ``Retry-After``. A
|
|
36
|
+
#: notifier must not park a run for minutes because a backend asked it to.
|
|
37
|
+
MAX_BACKOFF_S = 30.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HttpError(RuntimeError):
|
|
41
|
+
"""A request that did not succeed, after any retries were exhausted."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, status: int | None = None, body: str = "") -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status = status
|
|
46
|
+
self.body = body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Response:
|
|
51
|
+
status: int
|
|
52
|
+
headers: Mapping[str, str]
|
|
53
|
+
body: bytes
|
|
54
|
+
|
|
55
|
+
def json(self) -> Any:
|
|
56
|
+
if not self.body:
|
|
57
|
+
return None
|
|
58
|
+
return json.loads(self.body.decode("utf-8"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _retry_after_seconds(headers: Mapping[str, str]) -> float | None:
|
|
62
|
+
"""``Retry-After`` as seconds, accepting both the numeric and date forms."""
|
|
63
|
+
raw = None
|
|
64
|
+
for key, value in headers.items():
|
|
65
|
+
if key.lower() == "retry-after":
|
|
66
|
+
raw = value
|
|
67
|
+
break
|
|
68
|
+
if not raw:
|
|
69
|
+
return None
|
|
70
|
+
raw = raw.strip()
|
|
71
|
+
try:
|
|
72
|
+
return max(0.0, float(raw))
|
|
73
|
+
except ValueError:
|
|
74
|
+
pass
|
|
75
|
+
# The header's other legal form is an HTTP date. Anything else is a server
|
|
76
|
+
# sending nonsense, and the caller's own backoff is the better answer.
|
|
77
|
+
try:
|
|
78
|
+
parsed = email.utils.parsedate_to_datetime(raw)
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
return max(0.0, parsed.timestamp() - time.time())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def request(
|
|
85
|
+
method: str,
|
|
86
|
+
url: str,
|
|
87
|
+
*,
|
|
88
|
+
headers: Mapping[str, str] | None = None,
|
|
89
|
+
json_body: Any = None,
|
|
90
|
+
timeout: float = 10.0,
|
|
91
|
+
retries: int = 2,
|
|
92
|
+
backoff: float = 0.5,
|
|
93
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
94
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
95
|
+
) -> Response:
|
|
96
|
+
"""Perform one JSON request, retrying transient failures.
|
|
97
|
+
|
|
98
|
+
``sleep`` and ``opener`` are injectable so tests can drive the retry path
|
|
99
|
+
without real time or real sockets.
|
|
100
|
+
|
|
101
|
+
Raises :class:`HttpError` when every attempt fails.
|
|
102
|
+
"""
|
|
103
|
+
all_headers = {"Content-Type": "application/json", **(headers or {})}
|
|
104
|
+
data = json.dumps(json_body).encode("utf-8") if json_body is not None else None
|
|
105
|
+
attempts = max(1, retries + 1)
|
|
106
|
+
last: HttpError | None = None
|
|
107
|
+
|
|
108
|
+
for attempt in range(attempts):
|
|
109
|
+
if attempt:
|
|
110
|
+
delay = min(backoff * (2 ** (attempt - 1)), MAX_BACKOFF_S)
|
|
111
|
+
if last is not None and last.status in (429, 503):
|
|
112
|
+
delay = min(getattr(last, "retry_after", None) or delay, MAX_BACKOFF_S)
|
|
113
|
+
logger.debug("retrying %s %s in %.1fs (attempt %d)", method, url, delay, attempt + 1)
|
|
114
|
+
sleep(delay)
|
|
115
|
+
|
|
116
|
+
req = urllib.request.Request(url, data=data, headers=dict(all_headers), method=method)
|
|
117
|
+
try:
|
|
118
|
+
with opener(req, timeout=timeout) as resp:
|
|
119
|
+
body = resp.read()
|
|
120
|
+
status = getattr(resp, "status", None) or resp.getcode()
|
|
121
|
+
return Response(status=int(status), headers=dict(resp.headers), body=body)
|
|
122
|
+
except urllib.error.HTTPError as exc:
|
|
123
|
+
body_text = _safe_body(exc)
|
|
124
|
+
last = HttpError(
|
|
125
|
+
f"{method} {url} failed with HTTP {exc.code}", status=exc.code, body=body_text
|
|
126
|
+
)
|
|
127
|
+
last.retry_after = _retry_after_seconds(dict(exc.headers or {})) # type: ignore[attr-defined]
|
|
128
|
+
if exc.code not in RETRY_STATUSES:
|
|
129
|
+
raise last from exc
|
|
130
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
131
|
+
last = HttpError(f"{method} {url} failed: {exc}")
|
|
132
|
+
|
|
133
|
+
assert last is not None # attempts >= 1, so a failure path always set this
|
|
134
|
+
raise last
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _safe_body(exc: urllib.error.HTTPError) -> str:
|
|
138
|
+
"""The error body, truncated, or an empty string if it cannot be read.
|
|
139
|
+
|
|
140
|
+
Closes the error. ``HTTPError`` is itself a file-like object holding the
|
|
141
|
+
response stream, and an unclosed one is a leaked connection that surfaces
|
|
142
|
+
only later, as a ``ResourceWarning`` raised from a garbage collector far
|
|
143
|
+
from the request that caused it.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
return exc.read().decode("utf-8", "replace")[:500]
|
|
147
|
+
except Exception: # pragma: no cover - the stream may already be closed
|
|
148
|
+
return ""
|
|
149
|
+
finally:
|
|
150
|
+
with contextlib.suppress(Exception):
|
|
151
|
+
exc.close()
|
runnotify/_watchdog.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Out-of-process watchdog: report a run that was killed without warning.
|
|
2
|
+
|
|
3
|
+
``atexit`` covers every ending the interpreter gets to observe. ``SIGKILL`` is
|
|
4
|
+
not one of them, and ``SIGKILL`` is what the Linux OOM killer sends — so a run
|
|
5
|
+
that exhausts memory leaves a short table, no traceback, and no notification.
|
|
6
|
+
|
|
7
|
+
This module runs as a detached child (``python -m runnotify._watchdog``). It
|
|
8
|
+
polls whether its parent still exists and, when the parent disappears without
|
|
9
|
+
having disarmed it, delivers an OOM event through that run's own channels. A
|
|
10
|
+
channel added by a plugin therefore reports OOM with no change here.
|
|
11
|
+
|
|
12
|
+
Configuration arrives as one JSON object on **stdin**, never on the command
|
|
13
|
+
line: it carries webhook URLs and API tokens, and a command line is world
|
|
14
|
+
readable through ``ps``.
|
|
15
|
+
|
|
16
|
+
POSIX only. The liveness probe is ``os.kill(pid, 0)``, which reports existence
|
|
17
|
+
without signalling on POSIX; on Windows the same call terminates the target.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .channel import build_all
|
|
31
|
+
from .event import Event, Status
|
|
32
|
+
|
|
33
|
+
__all__ = ["main", "run"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parent_alive(pid: int) -> bool:
|
|
37
|
+
"""Whether ``pid`` still names a live process.
|
|
38
|
+
|
|
39
|
+
``PermissionError`` counts as alive: the process exists, this one merely
|
|
40
|
+
cannot signal it.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
os.kill(pid, 0)
|
|
44
|
+
except ProcessLookupError:
|
|
45
|
+
return False
|
|
46
|
+
except PermissionError:
|
|
47
|
+
return True
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(
|
|
52
|
+
config: Mapping[str, Any],
|
|
53
|
+
*,
|
|
54
|
+
sleep: Any = time.sleep,
|
|
55
|
+
alive: Any = _parent_alive,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Watch until the parent exits. Returns the process exit code.
|
|
58
|
+
|
|
59
|
+
``sleep`` and ``alive`` are injectable so the loop can be tested without a
|
|
60
|
+
real process or real time.
|
|
61
|
+
"""
|
|
62
|
+
pid = int(config["pid"])
|
|
63
|
+
sentinel = str(config["sentinel"])
|
|
64
|
+
poll = float(config.get("poll_interval", 5.0))
|
|
65
|
+
topic = str(config["topic"])
|
|
66
|
+
source = str(config.get("source", ""))
|
|
67
|
+
sections = config.get("channels") or {}
|
|
68
|
+
|
|
69
|
+
while True:
|
|
70
|
+
sleep(poll)
|
|
71
|
+
if alive(pid):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# The parent is gone. A sentinel means it told us so.
|
|
75
|
+
if os.path.exists(sentinel):
|
|
76
|
+
with contextlib.suppress(OSError):
|
|
77
|
+
os.unlink(sentinel)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
channels, _ = build_all(sections)
|
|
81
|
+
event = Event(
|
|
82
|
+
topic=topic,
|
|
83
|
+
status=Status.OOM,
|
|
84
|
+
message=f"Process {pid} was killed before reporting a result — likely OOM",
|
|
85
|
+
source=source,
|
|
86
|
+
)
|
|
87
|
+
failures = 0
|
|
88
|
+
for channel in channels:
|
|
89
|
+
try:
|
|
90
|
+
channel.deliver(event)
|
|
91
|
+
except Exception:
|
|
92
|
+
failures += 1
|
|
93
|
+
return 1 if failures and failures == len(channels) else 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv: list[str] | None = None) -> int:
|
|
97
|
+
"""Entry point for ``python -m runnotify._watchdog``."""
|
|
98
|
+
del argv
|
|
99
|
+
try:
|
|
100
|
+
config = json.loads(sys.stdin.buffer.read().decode("utf-8"))
|
|
101
|
+
except (ValueError, OSError):
|
|
102
|
+
return 2
|
|
103
|
+
if not isinstance(config, dict) or "pid" not in config:
|
|
104
|
+
return 2
|
|
105
|
+
try:
|
|
106
|
+
return run(config)
|
|
107
|
+
except Exception:
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__": # pragma: no cover - exercised as a subprocess
|
|
112
|
+
raise SystemExit(main())
|