cronwatch-sdk 0.6.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.
- cronwatch/__init__.py +115 -0
- cronwatch/_convert.py +98 -0
- cronwatch/_cron.py +524 -0
- cronwatch/_env.py +43 -0
- cronwatch/_js.py +280 -0
- cronwatch/_response.py +68 -0
- cronwatch/_scheduler_check.py +180 -0
- cronwatch/_zone.py +96 -0
- cronwatch/aio.py +223 -0
- cronwatch/alerts/__init__.py +147 -0
- cronwatch/alerts/_http.py +175 -0
- cronwatch/alerts/_shared.py +218 -0
- cronwatch/alerts/bugsnag.py +84 -0
- cronwatch/alerts/datadog.py +83 -0
- cronwatch/alerts/discord.py +67 -0
- cronwatch/alerts/email.py +93 -0
- cronwatch/alerts/honeybadger.py +83 -0
- cronwatch/alerts/mailgun.py +51 -0
- cronwatch/alerts/newrelic.py +72 -0
- cronwatch/alerts/postmark.py +57 -0
- cronwatch/alerts/resend.py +53 -0
- cronwatch/alerts/rollbar.py +68 -0
- cronwatch/alerts/sendgrid.py +54 -0
- cronwatch/alerts/sentry.py +117 -0
- cronwatch/alerts/ses.py +94 -0
- cronwatch/alerts/sigv4.py +96 -0
- cronwatch/alerts/slack.py +70 -0
- cronwatch/alerts/twilio.py +219 -0
- cronwatch/alerts/webhook.py +51 -0
- cronwatch/apscheduler.py +520 -0
- cronwatch/celery.py +765 -0
- cronwatch/client.py +1591 -0
- cronwatch/django/__init__.py +176 -0
- cronwatch/django/apps.py +34 -0
- cronwatch/django/management/__init__.py +0 -0
- cronwatch/django/management/commands/__init__.py +0 -0
- cronwatch/django/management/commands/cronwatch_check.py +27 -0
- cronwatch/django/urls.py +16 -0
- cronwatch/django/views.py +55 -0
- cronwatch/duration.py +70 -0
- cronwatch/evaluate.py +388 -0
- cronwatch/format.py +120 -0
- cronwatch/handler.py +461 -0
- cronwatch/job.py +163 -0
- cronwatch/output.py +214 -0
- cronwatch/py.typed +0 -0
- cronwatch/run_handle.py +214 -0
- cronwatch/schedule.py +221 -0
- cronwatch/serialize.py +68 -0
- cronwatch/sources/__init__.py +8 -0
- cronwatch/sources/pgcron.py +560 -0
- cronwatch/stats.py +22 -0
- cronwatch/stores/__init__.py +39 -0
- cronwatch/stores/_sql.py +211 -0
- cronwatch/stores/memory.py +166 -0
- cronwatch/stores/postgres.py +180 -0
- cronwatch/stores/sqlite.py +201 -0
- cronwatch/triage/__init__.py +3 -0
- cronwatch/triage/anthropic.py +170 -0
- cronwatch/types.py +492 -0
- cronwatch/web/__init__.py +891 -0
- cronwatch/web/_escape.py +71 -0
- cronwatch/web/_html.py +538 -0
- cronwatch/web/_icons.py +12 -0
- cronwatch/web/_origin.py +176 -0
- cronwatch/web/_pwa.py +147 -0
- cronwatch/web/_timeline.py +536 -0
- cronwatch_sdk-0.6.0.dist-info/METADATA +259 -0
- cronwatch_sdk-0.6.0.dist-info/RECORD +71 -0
- cronwatch_sdk-0.6.0.dist-info/WHEEL +4 -0
- cronwatch_sdk-0.6.0.dist-info/licenses/LICENSE +21 -0
cronwatch/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""CronWatch: cron and scheduled-job monitoring that lives inside your app.
|
|
2
|
+
|
|
3
|
+
import cronwatch
|
|
4
|
+
from cronwatch.stores import SqliteStore
|
|
5
|
+
|
|
6
|
+
cw = cronwatch.Cronwatch(store=SqliteStore("./data/cronwatch.db"))
|
|
7
|
+
nightly = cw.job("nightly-report", schedule="0 2 * * *", grace="15m")
|
|
8
|
+
|
|
9
|
+
with nightly.run() as ctx:
|
|
10
|
+
ctx.log("Report written")
|
|
11
|
+
ctx.metric("cost", 1.2)
|
|
12
|
+
|
|
13
|
+
cw.start() # checks for missed and stuck runs every minute, in a daemon thread
|
|
14
|
+
|
|
15
|
+
The Python port of @cronwatch/sdk: the same rules, the same alert text and
|
|
16
|
+
the same stored rows, so a Python, a Node and a Ruby process can share one
|
|
17
|
+
database.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import alerts, stores
|
|
26
|
+
from .alerts import ChannelContext, Console, Custom
|
|
27
|
+
from .client import ChannelTimeout, CheckInterrupted, Cronwatch, JobHandle, TriageContext
|
|
28
|
+
from .duration import format_duration, parse_duration
|
|
29
|
+
from .job import AbortError, AbortSignal, JobContext, current
|
|
30
|
+
from .output import redact_secrets
|
|
31
|
+
from .run_handle import RunHandle
|
|
32
|
+
from .schedule import parse_schedule
|
|
33
|
+
from .types import (
|
|
34
|
+
Alert,
|
|
35
|
+
AlertDraft,
|
|
36
|
+
AlertType,
|
|
37
|
+
CheckResult,
|
|
38
|
+
Condition,
|
|
39
|
+
JobDefinition,
|
|
40
|
+
JobHealth,
|
|
41
|
+
JobState,
|
|
42
|
+
JobStats,
|
|
43
|
+
JobSummary,
|
|
44
|
+
JobWithRuns,
|
|
45
|
+
Run,
|
|
46
|
+
RunStatus,
|
|
47
|
+
StoredJob,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__version__ = "0.6.0"
|
|
51
|
+
|
|
52
|
+
_client: Cronwatch | None = None
|
|
53
|
+
_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def configure(**options: Any) -> Cronwatch:
|
|
57
|
+
"""Make the process's client, taking the same options as Cronwatch(), and
|
|
58
|
+
return it. cronwatch.client() hands it out afterwards. Calling it again
|
|
59
|
+
replaces the client (stopping the old one's interval checks)."""
|
|
60
|
+
global _client
|
|
61
|
+
made = Cronwatch(**options)
|
|
62
|
+
with _lock:
|
|
63
|
+
previous, _client = _client, made
|
|
64
|
+
if previous is not None:
|
|
65
|
+
previous.stop()
|
|
66
|
+
return made
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def client() -> Cronwatch:
|
|
70
|
+
"""The client configure() made, or a default one (memory store, console alerts) when it was never called."""
|
|
71
|
+
global _client
|
|
72
|
+
with _lock:
|
|
73
|
+
if _client is None:
|
|
74
|
+
_client = Cronwatch()
|
|
75
|
+
return _client
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"AbortError",
|
|
80
|
+
"AbortSignal",
|
|
81
|
+
"Alert",
|
|
82
|
+
"AlertDraft",
|
|
83
|
+
"AlertType",
|
|
84
|
+
"ChannelContext",
|
|
85
|
+
"ChannelTimeout",
|
|
86
|
+
"CheckInterrupted",
|
|
87
|
+
"CheckResult",
|
|
88
|
+
"Condition",
|
|
89
|
+
"Console",
|
|
90
|
+
"Cronwatch",
|
|
91
|
+
"Custom",
|
|
92
|
+
"JobContext",
|
|
93
|
+
"JobDefinition",
|
|
94
|
+
"JobHandle",
|
|
95
|
+
"JobHealth",
|
|
96
|
+
"JobState",
|
|
97
|
+
"JobStats",
|
|
98
|
+
"JobSummary",
|
|
99
|
+
"JobWithRuns",
|
|
100
|
+
"Run",
|
|
101
|
+
"RunHandle",
|
|
102
|
+
"RunStatus",
|
|
103
|
+
"StoredJob",
|
|
104
|
+
"TriageContext",
|
|
105
|
+
"__version__",
|
|
106
|
+
"alerts",
|
|
107
|
+
"client",
|
|
108
|
+
"configure",
|
|
109
|
+
"current",
|
|
110
|
+
"format_duration",
|
|
111
|
+
"parse_duration",
|
|
112
|
+
"parse_schedule",
|
|
113
|
+
"redact_secrets",
|
|
114
|
+
"stores",
|
|
115
|
+
]
|
cronwatch/_convert.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What the scheduler integrations (cronwatch.celery, cronwatch.apscheduler)
|
|
2
|
+
share: cron fields written from the sets of values a scheduler matches, an
|
|
3
|
+
interval as CronWatch's "every" text, a zone's IANA name, and the job options
|
|
4
|
+
an integration takes. No framework is imported here."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from datetime import timedelta, tzinfo
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import _zone
|
|
14
|
+
from ._scheduler_check import ScheduleError
|
|
15
|
+
from .types import JobDefinition
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def field_text(values: Iterable[int], lo: int, hi: int) -> str:
|
|
19
|
+
"""A cron field for a set of values from lo to hi: "*" for all of them,
|
|
20
|
+
"*/n" for a step from lo (three values or more), else a list with runs of three or more as ranges."""
|
|
21
|
+
found = sorted(set(values))
|
|
22
|
+
if found == list(range(lo, hi + 1)):
|
|
23
|
+
return "*"
|
|
24
|
+
for step in range(2, hi - lo + 1):
|
|
25
|
+
if found == list(range(lo, hi + 1, step)) and len(found) > 2:
|
|
26
|
+
return f"*/{step}"
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
i = 0
|
|
29
|
+
while i < len(found):
|
|
30
|
+
j = i
|
|
31
|
+
while j + 1 < len(found) and found[j + 1] == found[j] + 1:
|
|
32
|
+
j += 1
|
|
33
|
+
if j - i >= 2:
|
|
34
|
+
parts.append(f"{found[i]}-{found[j]}")
|
|
35
|
+
else:
|
|
36
|
+
parts.extend(str(v) for v in found[i : j + 1])
|
|
37
|
+
i = j + 1
|
|
38
|
+
return ",".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def every_text(interval: timedelta | float) -> str:
|
|
42
|
+
""" "every 1h30m" for an interval (a timedelta, or seconds), exact to the millisecond."""
|
|
43
|
+
seconds = interval.total_seconds() if isinstance(interval, timedelta) else float(interval)
|
|
44
|
+
ms = round(seconds * 1000)
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
for unit, size in (("d", 86_400_000), ("h", 3_600_000), ("m", 60_000), ("s", 1000), ("ms", 1)):
|
|
47
|
+
if ms >= size:
|
|
48
|
+
n, ms = divmod(ms, size)
|
|
49
|
+
parts.append(f"{n}{unit}")
|
|
50
|
+
return "every " + ("".join(parts) or "0ms")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def zone_name(tz: Any) -> str | None:
|
|
54
|
+
"""The IANA name of a tzinfo (zoneinfo, pytz, dateutil, UTC), or None when it has none."""
|
|
55
|
+
if tz is None:
|
|
56
|
+
return None
|
|
57
|
+
if isinstance(tz, str):
|
|
58
|
+
return tz if _zone.is_valid(tz) else None
|
|
59
|
+
for attribute in ("key", "zone"):
|
|
60
|
+
name = getattr(tz, attribute, None)
|
|
61
|
+
if isinstance(name, str) and _zone.is_valid(name):
|
|
62
|
+
return name
|
|
63
|
+
if isinstance(tz, tzinfo):
|
|
64
|
+
from datetime import timezone
|
|
65
|
+
|
|
66
|
+
if tz is timezone.utc or tz == timezone.utc:
|
|
67
|
+
return "UTC"
|
|
68
|
+
text = str(tz)
|
|
69
|
+
return text if _zone.is_valid(text) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def local_zone_name() -> str | None:
|
|
73
|
+
"""The process's own zone by its IANA name: $TZ, or where /etc/localtime points."""
|
|
74
|
+
name = os.environ.get("TZ", "").lstrip(":")
|
|
75
|
+
if name and _zone.is_valid(name):
|
|
76
|
+
return name
|
|
77
|
+
try:
|
|
78
|
+
target = os.path.realpath("/etc/localtime")
|
|
79
|
+
except OSError:
|
|
80
|
+
return None
|
|
81
|
+
marker = "zoneinfo/"
|
|
82
|
+
if marker in target:
|
|
83
|
+
candidate = target.split(marker, 1)[1]
|
|
84
|
+
if _zone.is_valid(candidate):
|
|
85
|
+
return candidate
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_options(options: Mapping[str, Any], where: str, *, allow: Iterable[str] = ()) -> dict[str, Any]:
|
|
90
|
+
"""Job options given to an integration: Cronwatch.job()'s, and `allow`'s."""
|
|
91
|
+
known = set(JobDefinition.OPTIONS) | set(allow)
|
|
92
|
+
unknown = [key for key in options if key not in known]
|
|
93
|
+
if unknown:
|
|
94
|
+
raise TypeError(f"{where}: unknown option {', '.join(unknown)}")
|
|
95
|
+
return dict(options)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["ScheduleError", "check_options", "every_text", "field_text", "local_zone_name", "zone_name"]
|