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.
@@ -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,5 @@
1
+ """Still200 monitoring SDK."""
2
+
3
+ from still200_sdk._decorator import monitor
4
+
5
+ __all__ = ["monitor"]
@@ -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