steadycron 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.
- steadycron/__init__.py +61 -0
- steadycron/_config.py +70 -0
- steadycron/_exceptions.py +57 -0
- steadycron/_monitor.py +122 -0
- steadycron/_ping.py +68 -0
- steadycron/_resolve.py +97 -0
- steadycron/py.typed +0 -0
- steadycron-0.1.0.dist-info/METADATA +152 -0
- steadycron-0.1.0.dist-info/RECORD +11 -0
- steadycron-0.1.0.dist-info/WHEEL +4 -0
- steadycron-0.1.0.dist-info/licenses/LICENSE +21 -0
steadycron/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""SteadyCron code-monitoring SDK for Python.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import steadycron
|
|
6
|
+
|
|
7
|
+
steadycron.api_key = "sc_ro_..." # read-only key, or set STEADYCRON_API_KEY env var
|
|
8
|
+
|
|
9
|
+
@steadycron.job("nightly-db-backup")
|
|
10
|
+
def backup():
|
|
11
|
+
... # start on entry, success on return, fail (+re-raise) on exception
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
# ── Module-level configuration (set directly or via configure()) ──────────────
|
|
19
|
+
api_key: Optional[str] = None
|
|
20
|
+
api_url: str = "https://api.steadycron.com"
|
|
21
|
+
ping_url: str = "https://ping.steadycron.com"
|
|
22
|
+
environment: Optional[str] = None
|
|
23
|
+
capture_errors: bool = False
|
|
24
|
+
ping_timeout: float = 5.0
|
|
25
|
+
resolve_cache_ttl: float = 3600.0
|
|
26
|
+
monitors: dict[str, str] = {} # key → direct ping token (hardened path)
|
|
27
|
+
|
|
28
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
29
|
+
from ._config import configure
|
|
30
|
+
from ._exceptions import (
|
|
31
|
+
AmbiguousJobKeyError,
|
|
32
|
+
ConfigurationError,
|
|
33
|
+
InvalidMonitorKindError,
|
|
34
|
+
MonitorNotFoundError,
|
|
35
|
+
)
|
|
36
|
+
from ._monitor import Monitor, job, monitor
|
|
37
|
+
from ._resolve import clear_cache
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Config
|
|
41
|
+
"api_key",
|
|
42
|
+
"api_url",
|
|
43
|
+
"ping_url",
|
|
44
|
+
"environment",
|
|
45
|
+
"capture_errors",
|
|
46
|
+
"ping_timeout",
|
|
47
|
+
"resolve_cache_ttl",
|
|
48
|
+
"monitors",
|
|
49
|
+
"configure",
|
|
50
|
+
# Core API
|
|
51
|
+
"Monitor",
|
|
52
|
+
"job",
|
|
53
|
+
"monitor",
|
|
54
|
+
# Exceptions
|
|
55
|
+
"MonitorNotFoundError",
|
|
56
|
+
"AmbiguousJobKeyError",
|
|
57
|
+
"InvalidMonitorKindError",
|
|
58
|
+
"ConfigurationError",
|
|
59
|
+
# Utils
|
|
60
|
+
"clear_cache",
|
|
61
|
+
]
|
steadycron/_config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Module-level configuration and the configure() helper."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# ── Module-level settings ────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
api_key: Optional[str] = None
|
|
10
|
+
api_url: str = "https://api.steadycron.com"
|
|
11
|
+
ping_url: str = "https://ping.steadycron.com"
|
|
12
|
+
environment: Optional[str] = None
|
|
13
|
+
capture_errors: bool = False
|
|
14
|
+
ping_timeout: float = 5.0
|
|
15
|
+
resolve_cache_ttl: float = 3600.0
|
|
16
|
+
monitors: dict[str, str] = {} # key → direct ping token (hardened path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure(
|
|
20
|
+
*,
|
|
21
|
+
api_key: Optional[str] = None,
|
|
22
|
+
api_url: Optional[str] = None,
|
|
23
|
+
ping_url: Optional[str] = None,
|
|
24
|
+
environment: Optional[str] = None,
|
|
25
|
+
capture_errors: Optional[bool] = None,
|
|
26
|
+
ping_timeout: Optional[float] = None,
|
|
27
|
+
resolve_cache_ttl: Optional[float] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Set one or more configuration values at once."""
|
|
30
|
+
import steadycron as _mod
|
|
31
|
+
|
|
32
|
+
if api_key is not None:
|
|
33
|
+
_mod.api_key = api_key
|
|
34
|
+
if api_url is not None:
|
|
35
|
+
_mod.api_url = api_url
|
|
36
|
+
if ping_url is not None:
|
|
37
|
+
_mod.ping_url = ping_url
|
|
38
|
+
if environment is not None:
|
|
39
|
+
_mod.environment = environment
|
|
40
|
+
if capture_errors is not None:
|
|
41
|
+
_mod.capture_errors = capture_errors
|
|
42
|
+
if ping_timeout is not None:
|
|
43
|
+
_mod.ping_timeout = ping_timeout
|
|
44
|
+
if resolve_cache_ttl is not None:
|
|
45
|
+
_mod.resolve_cache_ttl = resolve_cache_ttl
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def effective_api_key() -> Optional[str]:
|
|
49
|
+
"""Return the configured API key, falling back to STEADYCRON_API_KEY env var."""
|
|
50
|
+
import steadycron as _mod
|
|
51
|
+
|
|
52
|
+
return _mod.api_key or os.environ.get("STEADYCRON_API_KEY")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def effective_api_url() -> str:
|
|
56
|
+
import steadycron as _mod
|
|
57
|
+
|
|
58
|
+
return _mod.api_url or os.environ.get("STEADYCRON_API_URL") or "https://api.steadycron.com"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def effective_ping_url() -> str:
|
|
62
|
+
import steadycron as _mod
|
|
63
|
+
|
|
64
|
+
return _mod.ping_url or os.environ.get("STEADYCRON_PING_URL") or "https://ping.steadycron.com"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def effective_environment() -> Optional[str]:
|
|
68
|
+
import steadycron as _mod
|
|
69
|
+
|
|
70
|
+
return _mod.environment or os.environ.get("STEADYCRON_ENVIRONMENT")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exceptions raised by the SteadyCron SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MonitorNotFoundError(Exception):
|
|
6
|
+
"""Raised when a job key is not found (resolve returned 404).
|
|
7
|
+
|
|
8
|
+
This is a developer/configuration error — verify the key matches
|
|
9
|
+
a job in the SteadyCron Dashboard.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, key: str) -> None:
|
|
13
|
+
self.key = key
|
|
14
|
+
super().__init__(
|
|
15
|
+
f"Job key '{key}' was not found. "
|
|
16
|
+
"Verify the key matches a job in the SteadyCron Dashboard."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AmbiguousJobKeyError(Exception):
|
|
21
|
+
"""Raised when a key matches more than one job in the account (resolve returned 409).
|
|
22
|
+
|
|
23
|
+
Keys must be unique within the account for code monitoring to work.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, key: str) -> None:
|
|
27
|
+
self.key = key
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"Job key '{key}' is ambiguous — it matches more than one job "
|
|
30
|
+
"in the account. Make the key unique in the SteadyCron Dashboard."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidMonitorKindError(Exception):
|
|
35
|
+
"""Raised when the resolved job has kind 'http' rather than 'heartbeat'.
|
|
36
|
+
|
|
37
|
+
HTTP jobs are executed server-side and cannot be pinged from user code.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, key: str, kind: str) -> None:
|
|
41
|
+
self.key = key
|
|
42
|
+
self.kind = kind
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"Job '{key}' has kind '{kind}'. "
|
|
45
|
+
"Only 'heartbeat' jobs can be pinged from code."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigurationError(Exception):
|
|
50
|
+
"""Raised when neither a direct token nor an API key is available for a key."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, key: str) -> None:
|
|
53
|
+
self.key = key
|
|
54
|
+
super().__init__(
|
|
55
|
+
f"No ping token or API key is configured for job '{key}'. "
|
|
56
|
+
"Either set steadycron.api_key or add an entry to steadycron.monitors."
|
|
57
|
+
)
|
steadycron/_monitor.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Monitor class, @job decorator, and monitor() context manager."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
import uuid
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Callable, Generator, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
from ._ping import send_ping
|
|
10
|
+
from ._resolve import resolve_token
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Monitor:
|
|
16
|
+
"""Represents a named SteadyCron monitor for manual ping control."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, key: str) -> None:
|
|
19
|
+
self.key = key
|
|
20
|
+
|
|
21
|
+
def ping(
|
|
22
|
+
self,
|
|
23
|
+
state: str = "success",
|
|
24
|
+
message: Optional[str] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Send a ping.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
state: One of ``"start"``, ``"success"`` (default), or ``"fail"``.
|
|
30
|
+
message: Optional error message; sent only when ``state="fail"``
|
|
31
|
+
(or always if explicitly provided).
|
|
32
|
+
"""
|
|
33
|
+
import steadycron as _mod
|
|
34
|
+
|
|
35
|
+
token = resolve_token(self.key)
|
|
36
|
+
suffix = state if state != "success" else None
|
|
37
|
+
ping_url = (_mod.ping_url or __import__("os").environ.get("STEADYCRON_PING_URL") or "https://ping.steadycron.com")
|
|
38
|
+
env = _mod.environment or __import__("os").environ.get("STEADYCRON_ENVIRONMENT")
|
|
39
|
+
send_ping(
|
|
40
|
+
token,
|
|
41
|
+
suffix,
|
|
42
|
+
env=env,
|
|
43
|
+
message=message if (state == "fail" or message is not None) else None,
|
|
44
|
+
timeout=_mod.ping_timeout,
|
|
45
|
+
ping_url_base=ping_url,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_tracked(key: str, fn: Callable[..., Any], args: Any, kwargs: Any) -> Any:
|
|
50
|
+
"""Core tracking logic shared by the decorator and context manager."""
|
|
51
|
+
import steadycron as _mod
|
|
52
|
+
|
|
53
|
+
token = resolve_token(key)
|
|
54
|
+
run_id = uuid.uuid4().hex
|
|
55
|
+
ping_url = (_mod.ping_url or __import__("os").environ.get("STEADYCRON_PING_URL") or "https://ping.steadycron.com")
|
|
56
|
+
env = _mod.environment or __import__("os").environ.get("STEADYCRON_ENVIRONMENT")
|
|
57
|
+
timeout = _mod.ping_timeout
|
|
58
|
+
|
|
59
|
+
send_ping(token, "start", env=env, run_id=run_id, timeout=timeout, ping_url_base=ping_url)
|
|
60
|
+
try:
|
|
61
|
+
result = fn(*args, **kwargs)
|
|
62
|
+
send_ping(token, None, env=env, run_id=run_id, timeout=timeout, ping_url_base=ping_url)
|
|
63
|
+
return result
|
|
64
|
+
except Exception:
|
|
65
|
+
msg: Optional[str] = None
|
|
66
|
+
if _mod.capture_errors:
|
|
67
|
+
import sys
|
|
68
|
+
exc = sys.exc_info()[1]
|
|
69
|
+
msg = str(exc) if exc is not None else None
|
|
70
|
+
send_ping(token, "fail", env=env, run_id=run_id, message=msg, timeout=timeout, ping_url_base=ping_url)
|
|
71
|
+
raise
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def job(key: str) -> Callable[[F], F]:
|
|
75
|
+
"""Decorator: wrap a function with start/success/fail pings.
|
|
76
|
+
|
|
77
|
+
Usage::
|
|
78
|
+
|
|
79
|
+
@steadycron.job("nightly-db-backup")
|
|
80
|
+
def backup():
|
|
81
|
+
...
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def decorator(fn: F) -> F:
|
|
85
|
+
@functools.wraps(fn)
|
|
86
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
87
|
+
return _run_tracked(key, fn, args, kwargs)
|
|
88
|
+
|
|
89
|
+
return wrapper # type: ignore[return-value]
|
|
90
|
+
|
|
91
|
+
return decorator
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextmanager
|
|
95
|
+
def monitor(key: str) -> Generator[Monitor, None, None]:
|
|
96
|
+
"""Context manager: wrap a block with start/success/fail pings.
|
|
97
|
+
|
|
98
|
+
Usage::
|
|
99
|
+
|
|
100
|
+
with steadycron.monitor("nightly-db-backup"):
|
|
101
|
+
run_backup()
|
|
102
|
+
"""
|
|
103
|
+
import steadycron as _mod
|
|
104
|
+
|
|
105
|
+
token = resolve_token(key)
|
|
106
|
+
run_id = uuid.uuid4().hex
|
|
107
|
+
ping_url = (_mod.ping_url or __import__("os").environ.get("STEADYCRON_PING_URL") or "https://ping.steadycron.com")
|
|
108
|
+
env = _mod.environment or __import__("os").environ.get("STEADYCRON_ENVIRONMENT")
|
|
109
|
+
timeout = _mod.ping_timeout
|
|
110
|
+
|
|
111
|
+
send_ping(token, "start", env=env, run_id=run_id, timeout=timeout, ping_url_base=ping_url)
|
|
112
|
+
try:
|
|
113
|
+
yield Monitor(key)
|
|
114
|
+
send_ping(token, None, env=env, run_id=run_id, timeout=timeout, ping_url_base=ping_url)
|
|
115
|
+
except Exception:
|
|
116
|
+
msg2: Optional[str] = None
|
|
117
|
+
if _mod.capture_errors:
|
|
118
|
+
import sys
|
|
119
|
+
exc2 = sys.exc_info()[1]
|
|
120
|
+
msg2 = str(exc2) if exc2 is not None else None
|
|
121
|
+
send_ping(token, "fail", env=env, run_id=run_id, message=msg2, timeout=timeout, ping_url_base=ping_url)
|
|
122
|
+
raise
|
steadycron/_ping.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Ping transport: fire-and-forget HTTP pings, never raise."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
_logger = logging.getLogger("steadycron")
|
|
12
|
+
|
|
13
|
+
_ERROR_MESSAGE_MAX_BYTES = 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def send_ping(
|
|
17
|
+
token: str,
|
|
18
|
+
suffix: Optional[str] = None,
|
|
19
|
+
*,
|
|
20
|
+
env: Optional[str] = None,
|
|
21
|
+
run_id: Optional[str] = None,
|
|
22
|
+
message: Optional[str] = None,
|
|
23
|
+
timeout: float = 5.0,
|
|
24
|
+
ping_url_base: str = "https://ping.steadycron.com",
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Send a single ping. All errors are swallowed — never raises."""
|
|
27
|
+
base = ping_url_base.rstrip("/")
|
|
28
|
+
path = f"/{token}/{suffix}" if suffix else f"/{token}"
|
|
29
|
+
url = base + path
|
|
30
|
+
|
|
31
|
+
qs_parts: dict[str, str] = {}
|
|
32
|
+
if env:
|
|
33
|
+
qs_parts["env"] = env
|
|
34
|
+
if run_id:
|
|
35
|
+
qs_parts["run_id"] = run_id
|
|
36
|
+
if qs_parts:
|
|
37
|
+
url += "?" + urlencode(qs_parts)
|
|
38
|
+
|
|
39
|
+
body: Optional[bytes] = None
|
|
40
|
+
if message:
|
|
41
|
+
truncated = _truncate_utf8(message, _ERROR_MESSAGE_MAX_BYTES)
|
|
42
|
+
body = truncated.encode("utf-8")
|
|
43
|
+
|
|
44
|
+
req = urllib.request.Request(url, data=body or b"", method="POST")
|
|
45
|
+
if body:
|
|
46
|
+
req.add_header("Content-Type", "text/plain; charset=utf-8")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
50
|
+
status = resp.status
|
|
51
|
+
if status >= 300:
|
|
52
|
+
_logger.debug("SteadyCron ping to %s returned HTTP %d.", url, status)
|
|
53
|
+
except TimeoutError:
|
|
54
|
+
_logger.warning("SteadyCron ping to %s timed out after %.1fs.", url, timeout)
|
|
55
|
+
except socket.timeout:
|
|
56
|
+
_logger.warning("SteadyCron ping to %s timed out after %.1fs.", url, timeout)
|
|
57
|
+
except urllib.error.URLError as exc:
|
|
58
|
+
_logger.warning("SteadyCron ping to %s failed: %s", url, exc.reason)
|
|
59
|
+
except Exception as exc: # noqa: BLE001
|
|
60
|
+
_logger.warning("SteadyCron ping to %s failed unexpectedly: %s", url, exc)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _truncate_utf8(text: str, max_bytes: int) -> str:
|
|
64
|
+
encoded = text.encode("utf-8")
|
|
65
|
+
if len(encoded) <= max_bytes:
|
|
66
|
+
return text
|
|
67
|
+
truncated = encoded[: max_bytes - 3].decode("utf-8", errors="ignore")
|
|
68
|
+
return truncated + "..."
|
steadycron/_resolve.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Token resolution: resolve a monitor key to its ping token via the API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
from ._exceptions import (
|
|
14
|
+
AmbiguousJobKeyError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
InvalidMonitorKindError,
|
|
17
|
+
MonitorNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_logger = logging.getLogger("steadycron")
|
|
21
|
+
|
|
22
|
+
# Cache: key → (token, expires_at_unix)
|
|
23
|
+
_cache: dict[str, tuple[str, float]] = {}
|
|
24
|
+
_cache_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cache_get(key: str) -> Optional[str]:
|
|
28
|
+
with _cache_lock:
|
|
29
|
+
entry = _cache.get(key)
|
|
30
|
+
if entry is not None and entry[1] > time.monotonic():
|
|
31
|
+
return entry[0]
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cache_set(key: str, token: str, ttl: float) -> None:
|
|
36
|
+
with _cache_lock:
|
|
37
|
+
_cache[key] = (token, time.monotonic() + ttl)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clear_cache() -> None:
|
|
41
|
+
"""Clear the entire resolution cache (useful in tests)."""
|
|
42
|
+
with _cache_lock:
|
|
43
|
+
_cache.clear()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_token(key: str) -> str:
|
|
47
|
+
"""Return the ping token for *key*, using cache or the resolve endpoint.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ConfigurationError: if neither a direct token nor an API key is available.
|
|
51
|
+
MonitorNotFoundError: if the key is not found (404).
|
|
52
|
+
AmbiguousJobKeyError: if the key matches multiple monitors (409).
|
|
53
|
+
InvalidMonitorKindError: if the resolved monitor is not a heartbeat.
|
|
54
|
+
"""
|
|
55
|
+
import steadycron as _mod
|
|
56
|
+
|
|
57
|
+
# 1. Direct token map (hardened path).
|
|
58
|
+
direct = _mod.monitors.get(key)
|
|
59
|
+
if direct:
|
|
60
|
+
return direct
|
|
61
|
+
|
|
62
|
+
# 2. Cache.
|
|
63
|
+
cached = _cache_get(key)
|
|
64
|
+
if cached is not None:
|
|
65
|
+
return cached
|
|
66
|
+
|
|
67
|
+
# 3. Resolve via API key.
|
|
68
|
+
api_key = _mod.api_key or __import__("os").environ.get("STEADYCRON_API_KEY")
|
|
69
|
+
if not api_key:
|
|
70
|
+
raise ConfigurationError(key)
|
|
71
|
+
|
|
72
|
+
api_url = (_mod.api_url or __import__("os").environ.get("STEADYCRON_API_URL") or "https://api.steadycron.com").rstrip("/")
|
|
73
|
+
params = urlencode({"key": key})
|
|
74
|
+
url = f"{api_url}/api/monitors/resolve?{params}"
|
|
75
|
+
|
|
76
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req) as resp:
|
|
79
|
+
body = json.loads(resp.read().decode())
|
|
80
|
+
except urllib.error.HTTPError as exc:
|
|
81
|
+
if exc.code == 404:
|
|
82
|
+
raise MonitorNotFoundError(key) from exc
|
|
83
|
+
if exc.code == 409:
|
|
84
|
+
raise AmbiguousJobKeyError(key) from exc
|
|
85
|
+
raise RuntimeError(f"Resolve returned HTTP {exc.code} for key '{key}'.") from exc
|
|
86
|
+
|
|
87
|
+
kind = body.get("kind", "")
|
|
88
|
+
if kind != "heartbeat":
|
|
89
|
+
raise InvalidMonitorKindError(key, kind)
|
|
90
|
+
|
|
91
|
+
ping_token: Optional[str] = body.get("ping_token")
|
|
92
|
+
if not ping_token:
|
|
93
|
+
raise RuntimeError(f"Resolve response missing ping_token for key '{key}'.")
|
|
94
|
+
|
|
95
|
+
ttl = _mod.resolve_cache_ttl
|
|
96
|
+
_cache_set(key, ping_token, ttl)
|
|
97
|
+
return ping_token
|
steadycron/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steadycron
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Code-monitoring SDK for SteadyCron — wrap your scheduled jobs with @steadycron.job to send start/success/fail pings automatically.
|
|
5
|
+
Project-URL: Homepage, https://steadycron.com
|
|
6
|
+
Project-URL: Repository, https://github.com/steadycron/steadycron-python
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/steadycron/steadycron-python/issues
|
|
8
|
+
Author: SteadyCron
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# SteadyCron Python SDK
|
|
26
|
+
|
|
27
|
+
`steadycron` is the official Python code-monitoring SDK for [SteadyCron](https://steadycron.com).
|
|
28
|
+
|
|
29
|
+
Wrap your scheduled jobs with `@steadycron.job` and SteadyCron will:
|
|
30
|
+
- Know when a job **started**, **succeeded**, or **failed**
|
|
31
|
+
- Alert you when a job **doesn't run** on time (missed heartbeat)
|
|
32
|
+
- Detect **stuck runs** that start but never finish
|
|
33
|
+
|
|
34
|
+
The monitor must already exist — create it in the Dashboard, via YAML manifest, or Terraform.
|
|
35
|
+
The SDK never creates monitors. For a fully code-driven workflow, declare the monitor in your
|
|
36
|
+
Terraform configuration or YAML manifest and reference it from your application by key:
|
|
37
|
+
the cron schedule, alert rules, and SDK instrumentation all live in the same repository.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install steadycron
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
No runtime dependencies — uses Python's standard library only.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import steadycron
|
|
55
|
+
|
|
56
|
+
steadycron.api_key = "sc_ro_..." # read-only key; or set STEADYCRON_API_KEY env var
|
|
57
|
+
|
|
58
|
+
@steadycron.job("nightly-db-backup")
|
|
59
|
+
def backup():
|
|
60
|
+
run_backup() # start on entry, success on return, fail (+re-raise) on exception
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Context manager
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
with steadycron.monitor("nightly-db-backup"):
|
|
67
|
+
run_backup()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Manual pings
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
m = steadycron.Monitor("nightly-db-backup")
|
|
74
|
+
m.ping() # bare success heartbeat
|
|
75
|
+
m.ping(state="start")
|
|
76
|
+
m.ping(state="fail", message="disk full")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Authentication
|
|
82
|
+
|
|
83
|
+
The SDK uses a **read-only** API key to resolve monitor keys to ping tokens at startup. Create one in:
|
|
84
|
+
|
|
85
|
+
**SteadyCron Dashboard → Settings → API keys → New key → Scope: Read-only**
|
|
86
|
+
|
|
87
|
+
Set it via:
|
|
88
|
+
- Environment variable: `STEADYCRON_API_KEY=sc_ro_...`
|
|
89
|
+
- Module attribute: `steadycron.api_key = "sc_ro_..."`
|
|
90
|
+
- `steadycron.configure(api_key="sc_ro_...")`
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Configuration
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
import steadycron
|
|
98
|
+
|
|
99
|
+
steadycron.configure(
|
|
100
|
+
api_key="sc_ro_...",
|
|
101
|
+
environment="production", # optional — sent on every ping
|
|
102
|
+
capture_errors=True, # include exception message on fail pings (default False)
|
|
103
|
+
ping_timeout=5.0, # seconds (default 5)
|
|
104
|
+
resolve_cache_ttl=3600.0, # seconds (default 3600 = 1 hour)
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Setting | Default | Env var fallback |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `api_key` | `None` | `STEADYCRON_API_KEY` |
|
|
111
|
+
| `api_url` | `https://api.steadycron.com` | `STEADYCRON_API_URL` |
|
|
112
|
+
| `ping_url` | `https://ping.steadycron.com` | `STEADYCRON_PING_URL` |
|
|
113
|
+
| `environment` | `None` | `STEADYCRON_ENVIRONMENT` |
|
|
114
|
+
| `capture_errors` | `False` | — |
|
|
115
|
+
| `ping_timeout` | `5.0` s | — |
|
|
116
|
+
| `resolve_cache_ttl` | `3600.0` s | — |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## How it works
|
|
121
|
+
|
|
122
|
+
1. On first use, the SDK calls `GET /api/monitors/resolve?key=<your-key>` with the read-only API key to retrieve the ping token. The token is cached for 1 hour (configurable).
|
|
123
|
+
2. All pings are fire-and-forget: a bounded `ping_timeout` is applied; any error is logged via `logging.getLogger("steadycron")` at WARNING and discarded. They never raise.
|
|
124
|
+
3. Resolution errors (404 unknown key, 409 ambiguous key, wrong kind) raise immediately — they indicate misconfiguration.
|
|
125
|
+
4. On exception, a `fail` ping is sent and the **original exception is re-raised unchanged**.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Direct / token mode (hardened path)
|
|
130
|
+
|
|
131
|
+
If you cannot use API key resolution (e.g. air-gapped environments), set the ping token directly:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
steadycron.monitors = {"nightly-db-backup": "hRkmWz8oZtlMFzvTAUdnRE"}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The token is visible via **Job detail → Code monitoring → Reveal ping token** in the Dashboard.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Reliability contract
|
|
142
|
+
|
|
143
|
+
- **Ping failures are never raised.** A transport error, timeout, or non-2xx response is logged and discarded.
|
|
144
|
+
- **Resolution errors always raise.** A 404 or 409 from the resolve endpoint raises on first use; fix the key or remove the decorator.
|
|
145
|
+
- **Original exceptions pass through unchanged.** The decorator and context manager do not wrap exceptions.
|
|
146
|
+
- **No runtime dependencies.** The SDK uses `urllib.request` from the standard library.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
steadycron/__init__.py,sha256=YOD0touQBH4TBWjHZFjSIE8e5j96ftR4KvqucYxK4rg,1682
|
|
2
|
+
steadycron/_config.py,sha256=szP8GE_rKQVYr4EfIES8G7SFDaV8U_P_ex9_QJNI2L4,2237
|
|
3
|
+
steadycron/_exceptions.py,sha256=DG4NBjNijcncslmnku90mVnG1EY2BB5pDvWxli0jqX0,1833
|
|
4
|
+
steadycron/_monitor.py,sha256=BP5AG8xLd90WTfcty2-33X-Gpsgv99gHzkGgCDkgkjo,4136
|
|
5
|
+
steadycron/_ping.py,sha256=cLPG8bS3zKWB5FAjkwSbY8E7aJ43wi4zpLgqV0r6VY4,2211
|
|
6
|
+
steadycron/_resolve.py,sha256=wBQ3pJ9ht2UuZDde5_bE_KXtEQXPNGOKt2fPD-nKRQE,2965
|
|
7
|
+
steadycron/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
steadycron-0.1.0.dist-info/METADATA,sha256=boODK6T9ZLqUXg90zY0UR985W3wVA44MdbiIeNemtQA,5081
|
|
9
|
+
steadycron-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
steadycron-0.1.0.dist-info/licenses/LICENSE,sha256=sAzhUjSXWkDJKX4EeGmAHvyyMa6A6fFbVhOYUsfu5yo,1067
|
|
11
|
+
steadycron-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SteadyCron
|
|
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.
|