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.
Files changed (71) hide show
  1. cronwatch/__init__.py +115 -0
  2. cronwatch/_convert.py +98 -0
  3. cronwatch/_cron.py +524 -0
  4. cronwatch/_env.py +43 -0
  5. cronwatch/_js.py +280 -0
  6. cronwatch/_response.py +68 -0
  7. cronwatch/_scheduler_check.py +180 -0
  8. cronwatch/_zone.py +96 -0
  9. cronwatch/aio.py +223 -0
  10. cronwatch/alerts/__init__.py +147 -0
  11. cronwatch/alerts/_http.py +175 -0
  12. cronwatch/alerts/_shared.py +218 -0
  13. cronwatch/alerts/bugsnag.py +84 -0
  14. cronwatch/alerts/datadog.py +83 -0
  15. cronwatch/alerts/discord.py +67 -0
  16. cronwatch/alerts/email.py +93 -0
  17. cronwatch/alerts/honeybadger.py +83 -0
  18. cronwatch/alerts/mailgun.py +51 -0
  19. cronwatch/alerts/newrelic.py +72 -0
  20. cronwatch/alerts/postmark.py +57 -0
  21. cronwatch/alerts/resend.py +53 -0
  22. cronwatch/alerts/rollbar.py +68 -0
  23. cronwatch/alerts/sendgrid.py +54 -0
  24. cronwatch/alerts/sentry.py +117 -0
  25. cronwatch/alerts/ses.py +94 -0
  26. cronwatch/alerts/sigv4.py +96 -0
  27. cronwatch/alerts/slack.py +70 -0
  28. cronwatch/alerts/twilio.py +219 -0
  29. cronwatch/alerts/webhook.py +51 -0
  30. cronwatch/apscheduler.py +520 -0
  31. cronwatch/celery.py +765 -0
  32. cronwatch/client.py +1591 -0
  33. cronwatch/django/__init__.py +176 -0
  34. cronwatch/django/apps.py +34 -0
  35. cronwatch/django/management/__init__.py +0 -0
  36. cronwatch/django/management/commands/__init__.py +0 -0
  37. cronwatch/django/management/commands/cronwatch_check.py +27 -0
  38. cronwatch/django/urls.py +16 -0
  39. cronwatch/django/views.py +55 -0
  40. cronwatch/duration.py +70 -0
  41. cronwatch/evaluate.py +388 -0
  42. cronwatch/format.py +120 -0
  43. cronwatch/handler.py +461 -0
  44. cronwatch/job.py +163 -0
  45. cronwatch/output.py +214 -0
  46. cronwatch/py.typed +0 -0
  47. cronwatch/run_handle.py +214 -0
  48. cronwatch/schedule.py +221 -0
  49. cronwatch/serialize.py +68 -0
  50. cronwatch/sources/__init__.py +8 -0
  51. cronwatch/sources/pgcron.py +560 -0
  52. cronwatch/stats.py +22 -0
  53. cronwatch/stores/__init__.py +39 -0
  54. cronwatch/stores/_sql.py +211 -0
  55. cronwatch/stores/memory.py +166 -0
  56. cronwatch/stores/postgres.py +180 -0
  57. cronwatch/stores/sqlite.py +201 -0
  58. cronwatch/triage/__init__.py +3 -0
  59. cronwatch/triage/anthropic.py +170 -0
  60. cronwatch/types.py +492 -0
  61. cronwatch/web/__init__.py +891 -0
  62. cronwatch/web/_escape.py +71 -0
  63. cronwatch/web/_html.py +538 -0
  64. cronwatch/web/_icons.py +12 -0
  65. cronwatch/web/_origin.py +176 -0
  66. cronwatch/web/_pwa.py +147 -0
  67. cronwatch/web/_timeline.py +536 -0
  68. cronwatch_sdk-0.6.0.dist-info/METADATA +259 -0
  69. cronwatch_sdk-0.6.0.dist-info/RECORD +71 -0
  70. cronwatch_sdk-0.6.0.dist-info/WHEEL +4 -0
  71. 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"]