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 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())