still200-sdk 0.1.0__tar.gz
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.
- still200_sdk-0.1.0/PKG-INFO +161 -0
- still200_sdk-0.1.0/README.md +151 -0
- still200_sdk-0.1.0/pyproject.toml +21 -0
- still200_sdk-0.1.0/pyproject.toml.orig +22 -0
- still200_sdk-0.1.0/src/still200_sdk/__init__.py +5 -0
- still200_sdk-0.1.0/src/still200_sdk/_client.py +139 -0
- still200_sdk-0.1.0/src/still200_sdk/_config.py +43 -0
- still200_sdk-0.1.0/src/still200_sdk/_decorator.py +107 -0
- still200_sdk-0.1.0/src/still200_sdk/py.typed +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: still200-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Still200 Monitoring SDK
|
|
5
|
+
Author: Dennis Wainaina
|
|
6
|
+
Author-email: Dennis Wainaina <dennis@byteslab.io>
|
|
7
|
+
Requires-Dist: httpx2>=2.16.0
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Still200 Python SDK
|
|
12
|
+
|
|
13
|
+
Monitor Python scheduled jobs with [Still200](https://still200.com) using a
|
|
14
|
+
decorator, without writing repetitive monitoring boilerplate.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from still200_sdk import monitor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@monitor()
|
|
21
|
+
async def generate_report() -> None:
|
|
22
|
+
...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
When the function runs, the SDK reports when the job starts and whether it
|
|
26
|
+
finishes successfully or fails.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Using uv:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add still200-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or using pip:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install still200-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The SDK requires Python 3.12 or later.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
Still200 generates a unique ping token when you create a scheduled-job monitor.
|
|
47
|
+
Set that token in the job's environment:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export STILL200_PING_TOKEN="your-ping-token"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Keep this token secret. Do not commit it to source control or include it
|
|
54
|
+
directly in your script.
|
|
55
|
+
|
|
56
|
+
If `STILL200_PING_TOKEN` is missing, the decorated function raises a
|
|
57
|
+
configuration error before the job starts. This prevents a job from silently
|
|
58
|
+
running without monitoring.
|
|
59
|
+
|
|
60
|
+
The SDK supports both asynchronous and synchronous functions.
|
|
61
|
+
|
|
62
|
+
## Async jobs
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
|
|
67
|
+
from still200_sdk import monitor
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@monitor()
|
|
71
|
+
async def generate_report() -> None:
|
|
72
|
+
# Run the scheduled task.
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
asyncio.run(generate_report())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Synchronous jobs
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from still200_sdk import monitor
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@monitor()
|
|
87
|
+
def clean_expired_sessions() -> None:
|
|
88
|
+
# Run the scheduled task.
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
clean_expired_sessions()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The decorator preserves the function's arguments, return value, and original
|
|
97
|
+
exception behavior.
|
|
98
|
+
|
|
99
|
+
## Multiple scheduled jobs
|
|
100
|
+
|
|
101
|
+
For separate scripts or processes, give each process its own value for
|
|
102
|
+
`STILL200_PING_TOKEN`:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
STILL200_PING_TOKEN="report-token" python report_job.py
|
|
106
|
+
STILL200_PING_TOKEN="cleanup-token" python cleanup_job.py
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If multiple monitored jobs run in the same process, load separate secrets and
|
|
110
|
+
pass them explicitly:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import os
|
|
114
|
+
|
|
115
|
+
from still200_sdk import monitor
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@monitor(ping_token=os.environ["REPORT_JOB_PING_TOKEN"])
|
|
119
|
+
def generate_report() -> None:
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@monitor(ping_token=os.environ["CLEANUP_JOB_PING_TOKEN"])
|
|
124
|
+
def clean_expired_sessions() -> None:
|
|
125
|
+
...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
An explicit `ping_token` takes precedence over `STILL200_PING_TOKEN`.
|
|
129
|
+
|
|
130
|
+
## Runtime behavior
|
|
131
|
+
|
|
132
|
+
For each invocation, the SDK sends:
|
|
133
|
+
|
|
134
|
+
1. A start ping before running the function.
|
|
135
|
+
2. A finish ping if the function returns successfully.
|
|
136
|
+
3. A failure ping if the function raises an exception.
|
|
137
|
+
|
|
138
|
+
Failure pings include the exception message, limited to 2,000 characters. The
|
|
139
|
+
SDK does not automatically send function arguments because they may contain
|
|
140
|
+
sensitive data.
|
|
141
|
+
|
|
142
|
+
Still200 reporting errors are logged but do not change the job's behavior:
|
|
143
|
+
|
|
144
|
+
- If a start ping fails, the job still runs.
|
|
145
|
+
- If a finish ping fails, the job's result is still returned.
|
|
146
|
+
- If a failure ping fails, the job's original exception is still raised.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
Clone this repo and install the project and development dependencies:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uv sync --dev
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Run the tests and lint checks:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
uv run pytest
|
|
160
|
+
uv run ruff check src tests
|
|
161
|
+
```
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Still200 Python SDK
|
|
2
|
+
|
|
3
|
+
Monitor Python scheduled jobs with [Still200](https://still200.com) using a
|
|
4
|
+
decorator, without writing repetitive monitoring boilerplate.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from still200_sdk import monitor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@monitor()
|
|
11
|
+
async def generate_report() -> None:
|
|
12
|
+
...
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
When the function runs, the SDK reports when the job starts and whether it
|
|
16
|
+
finishes successfully or fails.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Using uv:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv add still200-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or using pip:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install still200-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The SDK requires Python 3.12 or later.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
Still200 generates a unique ping token when you create a scheduled-job monitor.
|
|
37
|
+
Set that token in the job's environment:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
export STILL200_PING_TOKEN="your-ping-token"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Keep this token secret. Do not commit it to source control or include it
|
|
44
|
+
directly in your script.
|
|
45
|
+
|
|
46
|
+
If `STILL200_PING_TOKEN` is missing, the decorated function raises a
|
|
47
|
+
configuration error before the job starts. This prevents a job from silently
|
|
48
|
+
running without monitoring.
|
|
49
|
+
|
|
50
|
+
The SDK supports both asynchronous and synchronous functions.
|
|
51
|
+
|
|
52
|
+
## Async jobs
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import asyncio
|
|
56
|
+
|
|
57
|
+
from still200_sdk import monitor
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@monitor()
|
|
61
|
+
async def generate_report() -> None:
|
|
62
|
+
# Run the scheduled task.
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
asyncio.run(generate_report())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Synchronous jobs
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from still200_sdk import monitor
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@monitor()
|
|
77
|
+
def clean_expired_sessions() -> None:
|
|
78
|
+
# Run the scheduled task.
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
clean_expired_sessions()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The decorator preserves the function's arguments, return value, and original
|
|
87
|
+
exception behavior.
|
|
88
|
+
|
|
89
|
+
## Multiple scheduled jobs
|
|
90
|
+
|
|
91
|
+
For separate scripts or processes, give each process its own value for
|
|
92
|
+
`STILL200_PING_TOKEN`:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
STILL200_PING_TOKEN="report-token" python report_job.py
|
|
96
|
+
STILL200_PING_TOKEN="cleanup-token" python cleanup_job.py
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If multiple monitored jobs run in the same process, load separate secrets and
|
|
100
|
+
pass them explicitly:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import os
|
|
104
|
+
|
|
105
|
+
from still200_sdk import monitor
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@monitor(ping_token=os.environ["REPORT_JOB_PING_TOKEN"])
|
|
109
|
+
def generate_report() -> None:
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@monitor(ping_token=os.environ["CLEANUP_JOB_PING_TOKEN"])
|
|
114
|
+
def clean_expired_sessions() -> None:
|
|
115
|
+
...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
An explicit `ping_token` takes precedence over `STILL200_PING_TOKEN`.
|
|
119
|
+
|
|
120
|
+
## Runtime behavior
|
|
121
|
+
|
|
122
|
+
For each invocation, the SDK sends:
|
|
123
|
+
|
|
124
|
+
1. A start ping before running the function.
|
|
125
|
+
2. A finish ping if the function returns successfully.
|
|
126
|
+
3. A failure ping if the function raises an exception.
|
|
127
|
+
|
|
128
|
+
Failure pings include the exception message, limited to 2,000 characters. The
|
|
129
|
+
SDK does not automatically send function arguments because they may contain
|
|
130
|
+
sensitive data.
|
|
131
|
+
|
|
132
|
+
Still200 reporting errors are logged but do not change the job's behavior:
|
|
133
|
+
|
|
134
|
+
- If a start ping fails, the job still runs.
|
|
135
|
+
- If a finish ping fails, the job's result is still returned.
|
|
136
|
+
- If a failure ping fails, the job's original exception is still raised.
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
Clone this repo and install the project and development dependencies:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv sync --dev
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run the tests and lint checks:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
uv run pytest
|
|
150
|
+
uv run ruff check src tests
|
|
151
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "still200-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Still200 Monitoring SDK"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["httpx2>=2.16.0"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Dennis Wainaina"
|
|
11
|
+
email = "dennis@byteslab.io"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
15
|
+
build-backend = "uv_build"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=9.2.0",
|
|
20
|
+
"ruff>=0.17.4",
|
|
21
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "still200-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Still200 Monitoring SDK"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Dennis Wainaina", email = "dennis@byteslab.io" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx2>=2.16.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.12.0,<0.13.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=9.2.0",
|
|
21
|
+
"ruff>=0.17.4",
|
|
22
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""HTTP client for the Still200 API."""
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Any, Self
|
|
5
|
+
|
|
6
|
+
import httpx2
|
|
7
|
+
|
|
8
|
+
from still200_sdk._config import Config
|
|
9
|
+
|
|
10
|
+
PING_TOKEN_HEADER = "STILL200_PING_TOKEN"
|
|
11
|
+
PING_PATH = "/jobs/ping"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Client:
|
|
15
|
+
"""Synchronous client for scheduled-job pings."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
config: Config,
|
|
20
|
+
*,
|
|
21
|
+
transport: httpx2.BaseTransport | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._client = httpx2.Client(
|
|
24
|
+
base_url=config.base_url,
|
|
25
|
+
headers={PING_TOKEN_HEADER: config.ping_token},
|
|
26
|
+
timeout=config.timeout,
|
|
27
|
+
transport=transport,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def start(self) -> None:
|
|
31
|
+
"""Report that the scheduled job has started."""
|
|
32
|
+
self._ping("start")
|
|
33
|
+
|
|
34
|
+
def finish(self) -> None:
|
|
35
|
+
"""Report that the scheduled job completed successfully."""
|
|
36
|
+
self._ping("finish")
|
|
37
|
+
|
|
38
|
+
def fail(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
error_message: str | None = None,
|
|
42
|
+
payload: dict[str, Any] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Report that the scheduled job failed."""
|
|
45
|
+
data = _failure_data(error_message=error_message, payload=payload)
|
|
46
|
+
self._ping("fail", json=data)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
"""Close the underlying HTTP client."""
|
|
50
|
+
self._client.close()
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> Self:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(
|
|
56
|
+
self,
|
|
57
|
+
exc_type: type[BaseException] | None,
|
|
58
|
+
exc_value: BaseException | None,
|
|
59
|
+
traceback: TracebackType | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.close()
|
|
62
|
+
|
|
63
|
+
def _ping(self, ping_type: str, *, json: dict[str, Any] | None = None) -> None:
|
|
64
|
+
response = self._client.post(f"{PING_PATH}/{ping_type}", json=json)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncClient:
|
|
69
|
+
"""Asynchronous client for scheduled-job pings."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
config: Config,
|
|
74
|
+
*,
|
|
75
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._client = httpx2.AsyncClient(
|
|
78
|
+
base_url=config.base_url,
|
|
79
|
+
headers={PING_TOKEN_HEADER: config.ping_token},
|
|
80
|
+
timeout=config.timeout,
|
|
81
|
+
transport=transport,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def start(self) -> None:
|
|
85
|
+
"""Report that the scheduled job has started."""
|
|
86
|
+
await self._ping("start")
|
|
87
|
+
|
|
88
|
+
async def finish(self) -> None:
|
|
89
|
+
"""Report that the scheduled job completed successfully."""
|
|
90
|
+
await self._ping("finish")
|
|
91
|
+
|
|
92
|
+
async def fail(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
error_message: str | None = None,
|
|
96
|
+
payload: dict[str, Any] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Report that the scheduled job failed."""
|
|
99
|
+
data = _failure_data(error_message=error_message, payload=payload)
|
|
100
|
+
await self._ping("fail", json=data)
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
"""Close the underlying HTTP client."""
|
|
104
|
+
await self._client.aclose()
|
|
105
|
+
|
|
106
|
+
async def __aenter__(self) -> Self:
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __aexit__(
|
|
110
|
+
self,
|
|
111
|
+
exc_type: type[BaseException] | None,
|
|
112
|
+
exc_value: BaseException | None,
|
|
113
|
+
traceback: TracebackType | None,
|
|
114
|
+
) -> None:
|
|
115
|
+
await self.close()
|
|
116
|
+
|
|
117
|
+
async def _ping(
|
|
118
|
+
self,
|
|
119
|
+
ping_type: str,
|
|
120
|
+
*,
|
|
121
|
+
json: dict[str, Any] | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
response = await self._client.post(f"{PING_PATH}/{ping_type}", json=json)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _failure_data(
|
|
128
|
+
*,
|
|
129
|
+
error_message: str | None,
|
|
130
|
+
payload: dict[str, Any] | None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
data: dict[str, Any] = {}
|
|
133
|
+
|
|
134
|
+
if error_message is not None:
|
|
135
|
+
data["error_message"] = error_message
|
|
136
|
+
if payload is not None:
|
|
137
|
+
data["payload"] = payload
|
|
138
|
+
|
|
139
|
+
return data
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""SDK configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.still200.com"
|
|
9
|
+
DEFAULT_TIMEOUT = 5.0
|
|
10
|
+
PING_TOKEN_ENV_VAR = "STILL200_PING_TOKEN"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class Config:
|
|
15
|
+
"""Internal configuration for Still200 API requests."""
|
|
16
|
+
|
|
17
|
+
ping_token: str = field(repr=False)
|
|
18
|
+
base_url: str = DEFAULT_BASE_URL
|
|
19
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
ping_token = self.ping_token.strip()
|
|
23
|
+
base_url = self.base_url.rstrip("/")
|
|
24
|
+
|
|
25
|
+
if not ping_token:
|
|
26
|
+
raise ValueError("ping_token must not be empty")
|
|
27
|
+
if not base_url:
|
|
28
|
+
raise ValueError("base_url must not be empty")
|
|
29
|
+
if self.timeout <= 0:
|
|
30
|
+
raise ValueError("timeout must be greater than zero")
|
|
31
|
+
|
|
32
|
+
object.__setattr__(self, "ping_token", ping_token)
|
|
33
|
+
object.__setattr__(self, "base_url", base_url)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_env(cls) -> Config:
|
|
37
|
+
"""Load configuration from the process environment."""
|
|
38
|
+
ping_token = os.getenv(PING_TOKEN_ENV_VAR)
|
|
39
|
+
|
|
40
|
+
if ping_token is None:
|
|
41
|
+
raise ValueError(f"{PING_TOKEN_ENV_VAR} is not set")
|
|
42
|
+
|
|
43
|
+
return cls(ping_token=ping_token)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Scheduled-job monitoring decorator."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from still200_sdk._client import AsyncClient, Client
|
|
10
|
+
from still200_sdk._config import Config
|
|
11
|
+
|
|
12
|
+
MAX_ERROR_MESSAGE_LENGTH = 2_000
|
|
13
|
+
|
|
14
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def monitor(*, ping_token: str | None = None) -> Callable[[F], F]:
|
|
20
|
+
"""Monitor a synchronous or asynchronous scheduled job."""
|
|
21
|
+
|
|
22
|
+
def decorator(func: F) -> F:
|
|
23
|
+
if inspect.iscoroutinefunction(func):
|
|
24
|
+
|
|
25
|
+
@wraps(func)
|
|
26
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
27
|
+
config = _resolve_config(ping_token)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
client = AsyncClient(config)
|
|
31
|
+
except Exception:
|
|
32
|
+
logger.warning("Failed to initialize Still200 monitoring", exc_info=True)
|
|
33
|
+
return await func(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
await _report_safely_async(client.start)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
result = await func(*args, **kwargs)
|
|
40
|
+
except Exception as error:
|
|
41
|
+
error_message = _error_message(error)
|
|
42
|
+
await _report_safely_async(
|
|
43
|
+
lambda: client.fail(error_message=error_message)
|
|
44
|
+
)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
await _report_safely_async(client.finish)
|
|
48
|
+
return result
|
|
49
|
+
finally:
|
|
50
|
+
await _report_safely_async(client.close)
|
|
51
|
+
|
|
52
|
+
return cast(F, async_wrapper)
|
|
53
|
+
|
|
54
|
+
@wraps(func)
|
|
55
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
56
|
+
config = _resolve_config(ping_token)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
client = Client(config)
|
|
60
|
+
except Exception:
|
|
61
|
+
logger.warning("Failed to initialize Still200 monitoring", exc_info=True)
|
|
62
|
+
return func(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
_report_safely(client.start)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
result = func(*args, **kwargs)
|
|
69
|
+
except Exception as error:
|
|
70
|
+
error_message = _error_message(error)
|
|
71
|
+
_report_safely(lambda: client.fail(error_message=error_message))
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
_report_safely(client.finish)
|
|
75
|
+
return result
|
|
76
|
+
finally:
|
|
77
|
+
_report_safely(client.close)
|
|
78
|
+
|
|
79
|
+
return cast(F, sync_wrapper)
|
|
80
|
+
|
|
81
|
+
return decorator
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _resolve_config(ping_token: str | None) -> Config:
|
|
85
|
+
if ping_token is None:
|
|
86
|
+
return Config.from_env()
|
|
87
|
+
|
|
88
|
+
return Config(ping_token=ping_token)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _error_message(error: Exception) -> str:
|
|
92
|
+
message = str(error) or type(error).__name__
|
|
93
|
+
return message[:MAX_ERROR_MESSAGE_LENGTH]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _report_safely(operation: Callable[[], None]) -> None:
|
|
97
|
+
try:
|
|
98
|
+
operation()
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.warning("Failed to report scheduled job status to Still200", exc_info=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _report_safely_async(operation: Callable[[], Awaitable[None]]) -> None:
|
|
104
|
+
try:
|
|
105
|
+
await operation()
|
|
106
|
+
except Exception:
|
|
107
|
+
logger.warning("Failed to report scheduled job status to Still200", exc_info=True)
|
|
File without changes
|