monsta 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.
monsta-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,296 @@
1
+ Metadata-Version: 2.3
2
+ Name: monsta
3
+ Version: 0.1.0
4
+ Summary: Simple monitoring REST API for Python applications
5
+ Author: Stefan Schönberger
6
+ Author-email: Stefan Schönberger <stefan@sniner.dev>
7
+ Requires-Dist: fastapi>=0.133.1
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: uvicorn>=0.41.0
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Monsta - Status Reporting REST API for Python Applications
14
+
15
+ **Monsta** (short for "Monitoring State") is a lightweight library for Python applications that provides a REST API endpoint for exposing application state and metrics. It's designed for seamless integration with FastAPI.
16
+
17
+ ## Features
18
+
19
+ - **Simple Integration**: Add monitoring to your application with just a few lines of code
20
+ - **Async Support**: Native async/await support for FastAPI
21
+ - **Thread-Safe**: Built-in thread safety for concurrent access
22
+ - **Flexible State Management**: Support for both direct state values and callback functions
23
+ - **Built-in Metrics**: Automatic uptime tracking (and possibly other internal metrics)
24
+ - **Customizable**: Configure endpoint paths, ports, and update intervals
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install monsta
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ ### Basic Usage
35
+
36
+ ```python
37
+ from monsta import StatusReporter
38
+
39
+ # Create status reporter
40
+ mon = StatusReporter()
41
+
42
+ # Set application state
43
+ mon.publish({"status": "running", "version": "1.0.0"})
44
+
45
+ # Start status reporting server (blocking)
46
+ mon.start(blocking=True)
47
+ ```
48
+
49
+ ### FastAPI Integration
50
+
51
+ ```python
52
+ from fastapi import FastAPI
53
+ from monsta import StatusReporter
54
+
55
+ app = FastAPI()
56
+
57
+ # Create and integrate status reporter
58
+ mon = StatusReporter(endpoint="/api/v1/monitoring")
59
+ app.include_router(mon.router)
60
+
61
+ # Update state during application lifecycle
62
+ mon.publish({"status": "running", "requests": 0})
63
+
64
+ # Start FastAPI app
65
+ # Status reporting will be available at /api/v1/monitoring
66
+ ```
67
+
68
+ ### Async FastAPI Integration
69
+
70
+ ```python
71
+ from contextlib import asynccontextmanager
72
+ from fastapi import FastAPI
73
+ from monsta import AsyncStatusReporter
74
+
75
+ @asynccontextmanager
76
+ async def lifespan(app: FastAPI):
77
+ # Initialize status reporting
78
+ app.state.mon = AsyncStatusReporter(endpoint="/api/v1/state")
79
+ app.include_router(app.state.mon.router)
80
+
81
+ # Start async status reporting
82
+ await app.state.mon.start(state={"status": "starting"})
83
+
84
+ yield
85
+
86
+ # Clean up
87
+ await app.state.mon.stop()
88
+
89
+ app = FastAPI(lifespan=lifespan)
90
+
91
+ @app.get("/")
92
+ async def root():
93
+ # Update status asynchronously
94
+ await app.state.mon.publish({"status": "running", "requests": 1})
95
+ return {"message": "Hello World"}
96
+ ```
97
+
98
+ ## API Reference
99
+
100
+ ### StatusReporter
101
+
102
+ The main synchronous status reporter class.
103
+
104
+ #### `StatusReporter(endpoint: Optional[str] = None)`
105
+
106
+ - `endpoint`: Custom endpoint path for the status API (default: `/mon/v1/state`)
107
+
108
+ #### `publish(state: StateSource) -> Self`
109
+
110
+ Set the application state.
111
+
112
+ - `state`: Either a callable that returns state data, or a mapping/dictionary containing the state data directly
113
+
114
+ Returns `self` for method chaining.
115
+
116
+ **Examples:**
117
+ ```python
118
+ # Direct state setting
119
+ reporter.publish({"status": "running", "count": 42})
120
+
121
+ # Using a callback function
122
+ def get_current_state():
123
+ return {"status": "running", "count": get_count()}
124
+
125
+ reporter.publish(get_current_state)
126
+ ```
127
+
128
+ #### `start(*, state: Optional[StateSource] = None, host: Optional[str] = None, port: Optional[int] = None, log_level: Optional[Union[int, str]] = None, blocking: bool = False) -> None`
129
+
130
+ Start the status reporter.
131
+
132
+ - `state`: Initial state or callable to get initial state
133
+ - `host`: Host address to bind to (default: `"0.0.0.0"`)
134
+ - `port`: Port number to listen on (default: `4242`)
135
+ - `log_level`: Logging level for uvicorn
136
+ - `blocking`: If True, blocks until server stops (default: `False`)
137
+
138
+ #### `stop() -> None`
139
+
140
+ Stop the status reporter and clean up resources.
141
+
142
+ #### `reset() -> None`
143
+
144
+ Reset the status reporter to its initial state.
145
+
146
+ ### AsyncStatusReporter
147
+
148
+ Async version of StatusReporter for use with FastAPI and other async frameworks.
149
+
150
+ #### `AsyncStatusReporter(endpoint: Optional[str] = None)`
151
+
152
+ - `endpoint`: Custom endpoint path for the status API
153
+
154
+ #### `async publish(state: AsyncStateType) -> None`
155
+
156
+ Set the application state asynchronously.
157
+
158
+ - `state`: Either a callable that returns state data (can be async), or a mapping/dictionary containing the state data directly
159
+
160
+ **Examples:**
161
+ ```python
162
+ # Direct state setting
163
+ await reporter.publish({"status": "running", "count": 42})
164
+
165
+ # Using an async callback function
166
+ async def get_current_state():
167
+ return {"status": "running", "count": await get_count()}
168
+ await reporter.publish(get_current_state)
169
+
170
+ # Using a sync callback function
171
+ def get_current_state():
172
+ return {"status": "running", "count": get_count()}
173
+ await reporter.publish(get_current_state)
174
+ ```
175
+
176
+ #### `async start(*, state: Optional[AsyncStateType] = None, host: Optional[str] = None, port: Optional[int] = None, update_interval: int = 5) -> None`
177
+
178
+ Start the async status reporter.
179
+
180
+ - `state`: Initial state or callable to get initial state
181
+ - `host`: Host address to bind to (default: `"0.0.0.0"`)
182
+ - `port`: Port number to listen on (default: `4242`)
183
+ - `update_interval`: Interval in seconds for automatic state updates (default: `5`)
184
+
185
+ #### `async stop() -> None`
186
+
187
+ Stop the async status reporter.
188
+
189
+ #### `reset() -> None`
190
+
191
+ Reset the status reporter to its initial state.
192
+
193
+ ## Singleton Functions
194
+
195
+ For simple use cases, you can use the singleton functions:
196
+
197
+ ```python
198
+ import monsta
199
+
200
+ # Start monitoring with singleton
201
+ monsta.start(state={"status": "running"}, blocking=False)
202
+
203
+ # Update state
204
+ monsta.publish({"status": "running", "requests": 42})
205
+
206
+ # Stop monitoring
207
+ monsta.stop()
208
+ ```
209
+
210
+ ## Configuration
211
+
212
+ ### Environment Variables
213
+
214
+ Monsta respects standard uvicorn environment variables for configuration.
215
+
216
+ ### Customization
217
+
218
+ You can customize the monitoring behavior:
219
+
220
+ ```python
221
+ # Custom endpoint
222
+ mon = MonitoringAgent(endpoint="/custom/monitoring/path")
223
+
224
+ # Custom host and port
225
+ mon.start(host="127.0.0.1", port=8080)
226
+
227
+ # Custom update interval (async only)
228
+ await async_mon.start(update_interval=10) # Update every 10 seconds
229
+ ```
230
+
231
+ ## Monitoring Endpoint
232
+
233
+ The monitoring endpoint returns a JSON response with the following structure:
234
+
235
+ ```json
236
+ {
237
+ "internal": {
238
+ "uptime": 12345
239
+ },
240
+ "state": {
241
+ "status": "running",
242
+ "requests": 42,
243
+ "custom_metrics": {}
244
+ }
245
+ }
246
+ ```
247
+
248
+ - `internal.uptime`: Automatic uptime tracking in seconds
249
+ - `state`: Your application-specific state data
250
+
251
+ ## Examples
252
+
253
+ See the `examples/` directory for complete working examples:
254
+
255
+ - `embedded.py`: Basic FastAPI integration
256
+ - `embedded_async.py`: Async FastAPI integration
257
+ - `singleton.py`: Singleton usage example
258
+ - `standalone.py`: Standalone monitoring server
259
+
260
+ ## Development
261
+
262
+ ```bash
263
+ # Install development dependencies using uv
264
+ uv sync --dev
265
+
266
+ # Run tests
267
+ uv run pytest
268
+ ```
269
+
270
+ ## Project Structure
271
+
272
+ ```
273
+ src/monsta/
274
+ ├── __init__.py # Main package exports
275
+ ├── mon.py # Synchronous StatusReporter implementation
276
+ ├── aiomon.py # Async StatusReporter implementation
277
+ └── py.typed # Type annotations
278
+
279
+ examples/
280
+ ├── embedded.py # Basic FastAPI integration example
281
+ ├── embedded_async.py # Async FastAPI integration example
282
+ ├── singleton.py # Singleton usage example
283
+ └── standalone.py # Standalone server example
284
+
285
+ tests/
286
+ ├── test_sync.py # Synchronous StatusReporter tests
287
+ └── test_async.py # AsyncStatusReporter tests
288
+ ```
289
+
290
+ ## License
291
+
292
+ [BSD 3-Clause License](./LICENSE)
293
+
294
+ ## Support
295
+
296
+ For issues, questions, or contributions, please open an issue on the GitHub repository.
monsta-0.1.0/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # Monsta - Status Reporting REST API for Python Applications
2
+
3
+ **Monsta** (short for "Monitoring State") is a lightweight library for Python applications that provides a REST API endpoint for exposing application state and metrics. It's designed for seamless integration with FastAPI.
4
+
5
+ ## Features
6
+
7
+ - **Simple Integration**: Add monitoring to your application with just a few lines of code
8
+ - **Async Support**: Native async/await support for FastAPI
9
+ - **Thread-Safe**: Built-in thread safety for concurrent access
10
+ - **Flexible State Management**: Support for both direct state values and callback functions
11
+ - **Built-in Metrics**: Automatic uptime tracking (and possibly other internal metrics)
12
+ - **Customizable**: Configure endpoint paths, ports, and update intervals
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install monsta
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ### Basic Usage
23
+
24
+ ```python
25
+ from monsta import StatusReporter
26
+
27
+ # Create status reporter
28
+ mon = StatusReporter()
29
+
30
+ # Set application state
31
+ mon.publish({"status": "running", "version": "1.0.0"})
32
+
33
+ # Start status reporting server (blocking)
34
+ mon.start(blocking=True)
35
+ ```
36
+
37
+ ### FastAPI Integration
38
+
39
+ ```python
40
+ from fastapi import FastAPI
41
+ from monsta import StatusReporter
42
+
43
+ app = FastAPI()
44
+
45
+ # Create and integrate status reporter
46
+ mon = StatusReporter(endpoint="/api/v1/monitoring")
47
+ app.include_router(mon.router)
48
+
49
+ # Update state during application lifecycle
50
+ mon.publish({"status": "running", "requests": 0})
51
+
52
+ # Start FastAPI app
53
+ # Status reporting will be available at /api/v1/monitoring
54
+ ```
55
+
56
+ ### Async FastAPI Integration
57
+
58
+ ```python
59
+ from contextlib import asynccontextmanager
60
+ from fastapi import FastAPI
61
+ from monsta import AsyncStatusReporter
62
+
63
+ @asynccontextmanager
64
+ async def lifespan(app: FastAPI):
65
+ # Initialize status reporting
66
+ app.state.mon = AsyncStatusReporter(endpoint="/api/v1/state")
67
+ app.include_router(app.state.mon.router)
68
+
69
+ # Start async status reporting
70
+ await app.state.mon.start(state={"status": "starting"})
71
+
72
+ yield
73
+
74
+ # Clean up
75
+ await app.state.mon.stop()
76
+
77
+ app = FastAPI(lifespan=lifespan)
78
+
79
+ @app.get("/")
80
+ async def root():
81
+ # Update status asynchronously
82
+ await app.state.mon.publish({"status": "running", "requests": 1})
83
+ return {"message": "Hello World"}
84
+ ```
85
+
86
+ ## API Reference
87
+
88
+ ### StatusReporter
89
+
90
+ The main synchronous status reporter class.
91
+
92
+ #### `StatusReporter(endpoint: Optional[str] = None)`
93
+
94
+ - `endpoint`: Custom endpoint path for the status API (default: `/mon/v1/state`)
95
+
96
+ #### `publish(state: StateSource) -> Self`
97
+
98
+ Set the application state.
99
+
100
+ - `state`: Either a callable that returns state data, or a mapping/dictionary containing the state data directly
101
+
102
+ Returns `self` for method chaining.
103
+
104
+ **Examples:**
105
+ ```python
106
+ # Direct state setting
107
+ reporter.publish({"status": "running", "count": 42})
108
+
109
+ # Using a callback function
110
+ def get_current_state():
111
+ return {"status": "running", "count": get_count()}
112
+
113
+ reporter.publish(get_current_state)
114
+ ```
115
+
116
+ #### `start(*, state: Optional[StateSource] = None, host: Optional[str] = None, port: Optional[int] = None, log_level: Optional[Union[int, str]] = None, blocking: bool = False) -> None`
117
+
118
+ Start the status reporter.
119
+
120
+ - `state`: Initial state or callable to get initial state
121
+ - `host`: Host address to bind to (default: `"0.0.0.0"`)
122
+ - `port`: Port number to listen on (default: `4242`)
123
+ - `log_level`: Logging level for uvicorn
124
+ - `blocking`: If True, blocks until server stops (default: `False`)
125
+
126
+ #### `stop() -> None`
127
+
128
+ Stop the status reporter and clean up resources.
129
+
130
+ #### `reset() -> None`
131
+
132
+ Reset the status reporter to its initial state.
133
+
134
+ ### AsyncStatusReporter
135
+
136
+ Async version of StatusReporter for use with FastAPI and other async frameworks.
137
+
138
+ #### `AsyncStatusReporter(endpoint: Optional[str] = None)`
139
+
140
+ - `endpoint`: Custom endpoint path for the status API
141
+
142
+ #### `async publish(state: AsyncStateType) -> None`
143
+
144
+ Set the application state asynchronously.
145
+
146
+ - `state`: Either a callable that returns state data (can be async), or a mapping/dictionary containing the state data directly
147
+
148
+ **Examples:**
149
+ ```python
150
+ # Direct state setting
151
+ await reporter.publish({"status": "running", "count": 42})
152
+
153
+ # Using an async callback function
154
+ async def get_current_state():
155
+ return {"status": "running", "count": await get_count()}
156
+ await reporter.publish(get_current_state)
157
+
158
+ # Using a sync callback function
159
+ def get_current_state():
160
+ return {"status": "running", "count": get_count()}
161
+ await reporter.publish(get_current_state)
162
+ ```
163
+
164
+ #### `async start(*, state: Optional[AsyncStateType] = None, host: Optional[str] = None, port: Optional[int] = None, update_interval: int = 5) -> None`
165
+
166
+ Start the async status reporter.
167
+
168
+ - `state`: Initial state or callable to get initial state
169
+ - `host`: Host address to bind to (default: `"0.0.0.0"`)
170
+ - `port`: Port number to listen on (default: `4242`)
171
+ - `update_interval`: Interval in seconds for automatic state updates (default: `5`)
172
+
173
+ #### `async stop() -> None`
174
+
175
+ Stop the async status reporter.
176
+
177
+ #### `reset() -> None`
178
+
179
+ Reset the status reporter to its initial state.
180
+
181
+ ## Singleton Functions
182
+
183
+ For simple use cases, you can use the singleton functions:
184
+
185
+ ```python
186
+ import monsta
187
+
188
+ # Start monitoring with singleton
189
+ monsta.start(state={"status": "running"}, blocking=False)
190
+
191
+ # Update state
192
+ monsta.publish({"status": "running", "requests": 42})
193
+
194
+ # Stop monitoring
195
+ monsta.stop()
196
+ ```
197
+
198
+ ## Configuration
199
+
200
+ ### Environment Variables
201
+
202
+ Monsta respects standard uvicorn environment variables for configuration.
203
+
204
+ ### Customization
205
+
206
+ You can customize the monitoring behavior:
207
+
208
+ ```python
209
+ # Custom endpoint
210
+ mon = MonitoringAgent(endpoint="/custom/monitoring/path")
211
+
212
+ # Custom host and port
213
+ mon.start(host="127.0.0.1", port=8080)
214
+
215
+ # Custom update interval (async only)
216
+ await async_mon.start(update_interval=10) # Update every 10 seconds
217
+ ```
218
+
219
+ ## Monitoring Endpoint
220
+
221
+ The monitoring endpoint returns a JSON response with the following structure:
222
+
223
+ ```json
224
+ {
225
+ "internal": {
226
+ "uptime": 12345
227
+ },
228
+ "state": {
229
+ "status": "running",
230
+ "requests": 42,
231
+ "custom_metrics": {}
232
+ }
233
+ }
234
+ ```
235
+
236
+ - `internal.uptime`: Automatic uptime tracking in seconds
237
+ - `state`: Your application-specific state data
238
+
239
+ ## Examples
240
+
241
+ See the `examples/` directory for complete working examples:
242
+
243
+ - `embedded.py`: Basic FastAPI integration
244
+ - `embedded_async.py`: Async FastAPI integration
245
+ - `singleton.py`: Singleton usage example
246
+ - `standalone.py`: Standalone monitoring server
247
+
248
+ ## Development
249
+
250
+ ```bash
251
+ # Install development dependencies using uv
252
+ uv sync --dev
253
+
254
+ # Run tests
255
+ uv run pytest
256
+ ```
257
+
258
+ ## Project Structure
259
+
260
+ ```
261
+ src/monsta/
262
+ ├── __init__.py # Main package exports
263
+ ├── mon.py # Synchronous StatusReporter implementation
264
+ ├── aiomon.py # Async StatusReporter implementation
265
+ └── py.typed # Type annotations
266
+
267
+ examples/
268
+ ├── embedded.py # Basic FastAPI integration example
269
+ ├── embedded_async.py # Async FastAPI integration example
270
+ ├── singleton.py # Singleton usage example
271
+ └── standalone.py # Standalone server example
272
+
273
+ tests/
274
+ ├── test_sync.py # Synchronous StatusReporter tests
275
+ └── test_async.py # AsyncStatusReporter tests
276
+ ```
277
+
278
+ ## License
279
+
280
+ [BSD 3-Clause License](./LICENSE)
281
+
282
+ ## Support
283
+
284
+ For issues, questions, or contributions, please open an issue on the GitHub repository.
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "monsta"
3
+ version = "0.1.0"
4
+ description = "Simple monitoring REST API for Python applications"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Stefan Schönberger", email = "stefan@sniner.dev" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "fastapi>=0.133.1",
12
+ "pydantic>=2.12.5",
13
+ "uvicorn>=0.41.0",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.10.6,<0.11.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "basedpyright>=1.38.2",
23
+ "pytest>=9.0.2",
24
+ "ruff>=0.15.3",
25
+ ]
26
+
27
+ [tool.pytest.ini_options]
28
+ addopts = "-ra -q"
29
+ testpaths = [
30
+ "tests",
31
+ ]
32
+
33
+ [tool.pyright]
34
+ typeCheckingMode = "standard"
35
+ useLibraryCodeForTypes = true
36
+ venvPath = "."
37
+ venv = ".venv"
38
+
39
+ [tool.ruff]
40
+ line-length = 96
41
+ ignore = ["E402"]
42
+
43
+ [tool.ruff.lint]
44
+ per-file-ignores = { "__init__.py" = ["F401"] }
@@ -0,0 +1,10 @@
1
+ from .aiomon import (
2
+ AsyncStatusReporter,
3
+ )
4
+ from .mon import (
5
+ StatusReporter,
6
+ publish,
7
+ reset,
8
+ start,
9
+ stop,
10
+ )
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ from typing import Any, Callable, Mapping, Optional, Union
7
+
8
+ from .mon import (
9
+ DEFAULT_HOST,
10
+ DEFAULT_PORT,
11
+ UPDATE_HOLDOFF,
12
+ StateCallback,
13
+ StatusReporter,
14
+ _now,
15
+ )
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+ AsyncStateCallback = Callable[[], Any]
20
+ AsyncStateType = Union[AsyncStateCallback, StateCallback, Mapping]
21
+
22
+
23
+ class AsyncStatusReporter:
24
+ """Async version of StatusReporter for use with FastAPI and other async frameworks.
25
+
26
+ This class provides the same status reporting functionality as StatusReporter but with
27
+ native async/await support, making it ideal for FastAPI applications.
28
+ """
29
+
30
+ def __init__(self, *, endpoint: Optional[str] = None):
31
+ """Initialize an async status reporter.
32
+
33
+ Args:
34
+ endpoint: Custom endpoint path for the status API
35
+ """
36
+ # Create a synchronous StatusReporter for state management
37
+ self._sync_agent = StatusReporter(endpoint=endpoint)
38
+
39
+ # Async-specific components
40
+ self._async_state_callback: Optional[AsyncStateCallback] = None
41
+ self._async_update_task: Optional[asyncio.Task] = None
42
+
43
+ # Use the sync agent's router
44
+ self.router = self._sync_agent.router
45
+
46
+ def reset(self) -> None:
47
+ """Reset the monitoring agent to its initial state."""
48
+ self._sync_agent.reset()
49
+
50
+ async def publish(self, state: AsyncStateType) -> None:
51
+ """Set the application state asynchronously.
52
+
53
+ Args:
54
+ state: Either a callable that returns state data (can be async),
55
+ or a mapping/dictionary containing the state data directly
56
+
57
+ Examples:
58
+ # Direct state setting
59
+ await agent.publish({"status": "running", "count": 42})
60
+
61
+ # Using an async callback function
62
+ async def get_current_state():
63
+ return {"status": "running", "count": await get_count()}
64
+ await agent.publish(get_current_state)
65
+
66
+ # Using a sync callback function
67
+ def get_current_state():
68
+ return {"status": "running", "count": get_count()}
69
+ await agent.publish(get_current_state)
70
+ """
71
+ if inspect.iscoroutinefunction(state):
72
+ self._async_state_callback = state
73
+ self._sync_agent._state_callback = None
74
+ _state = await state()
75
+ _logger.debug("Async state callback registered and executed")
76
+ self._sync_agent._set_state(_state)
77
+ else:
78
+ self._async_state_callback = None
79
+ self._sync_agent.publish(state)
80
+
81
+ async def _update_state_async(self) -> None:
82
+ """Update the monitoring state if sufficient time has passed.
83
+
84
+ This method implements rate-limiting to prevent excessive state updates.
85
+ Only updates the state if UPDATE_HOLDOFF seconds have passed since
86
+ the last update.
87
+ """
88
+
89
+ if self._async_state_callback:
90
+ now = _now()
91
+ if self._sync_agent._update_deferred(now):
92
+ return
93
+ try:
94
+ self._sync_agent._update_internal_state(now)
95
+ _state = await self._async_state_callback()
96
+ self._sync_agent._set_state(_state)
97
+ except ValueError as ve:
98
+ _logger.error("Invalid state value during async update: %s", ve)
99
+ except RuntimeError as re:
100
+ _logger.error("Runtime error during async state update: %s", re)
101
+ except Exception as exc:
102
+ _logger.error("Unexpected error during async state update: %s", exc)
103
+ else:
104
+ self._sync_agent._update_state()
105
+
106
+ async def start(
107
+ self,
108
+ *,
109
+ state: Optional[AsyncStateType] = None,
110
+ host: Optional[str] = None,
111
+ port: Optional[int] = None,
112
+ update_interval: int = UPDATE_HOLDOFF,
113
+ ) -> None:
114
+ """Start the async monitoring agent.
115
+
116
+ Args:
117
+ app_state: Initial state or callable to get initial state
118
+ host: Host address to bind to
119
+ port: Port number to listen on
120
+ update_interval: Interval in seconds for automatic state updates
121
+ """
122
+ try:
123
+ if self._sync_agent._worker_alive():
124
+ raise RuntimeError("Monitoring agent already running")
125
+
126
+ self.reset()
127
+ await self.publish(state or {})
128
+
129
+ actual_host = host or DEFAULT_HOST
130
+ actual_port = port or DEFAULT_PORT
131
+
132
+ # Start the async update task
133
+ self._async_update_task = asyncio.create_task(
134
+ self._periodic_update_async(update_interval)
135
+ )
136
+ _logger.info(
137
+ "Async monitoring agent started successfully on %s:%d", actual_host, actual_port
138
+ )
139
+
140
+ except Exception as exc:
141
+ _logger.error("Failed to start async monitoring agent: %s", exc)
142
+ raise
143
+
144
+ async def _periodic_update_async(self, interval: int) -> None:
145
+ """Background task for periodic state updates in async mode."""
146
+ try:
147
+ while True:
148
+ await asyncio.sleep(interval)
149
+ await self._update_state_async()
150
+ except asyncio.CancelledError:
151
+ _logger.info("Async update task cancelled")
152
+ except Exception as exc:
153
+ _logger.error("Async update task failed: %s", exc)
154
+
155
+ async def stop(self) -> None:
156
+ """Stop the async monitoring agent."""
157
+ try:
158
+ # Cancel async update task if running
159
+ if self._async_update_task:
160
+ self._async_update_task.cancel()
161
+ try:
162
+ await self._async_update_task
163
+ except asyncio.CancelledError:
164
+ pass
165
+ self._async_update_task = None
166
+ _logger.info("Async update task cancelled successfully")
167
+
168
+ # Stop the sync components
169
+ if self._sync_agent._uvicorn_server:
170
+ self._sync_agent._uvicorn_server.should_exit = True
171
+ _logger.info("Signaled uvicorn server to exit")
172
+
173
+ if self._sync_agent._worker_thread:
174
+ self._sync_agent._worker_thread.join(timeout=5)
175
+ if self._sync_agent._worker_thread.is_alive():
176
+ _logger.warning("Monitoring thread did not terminate within timeout")
177
+ self._sync_agent._worker_thread = None
178
+ _logger.info("Monitoring thread joined successfully")
179
+
180
+ self._sync_agent._uvicorn_server = None
181
+ _logger.info("Async monitoring agent stopped successfully")
182
+ except RuntimeError as re:
183
+ _logger.error("Runtime error during async agent shutdown: %s", re)
184
+ except Exception as exc:
185
+ _logger.error("Unexpected error during async agent shutdown: %s", exc)
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ from typing import Any, Callable, Mapping, Optional, Self, Union
7
+
8
+ import uvicorn
9
+ from fastapi import APIRouter, FastAPI
10
+ from pydantic import BaseModel, Field
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+ UPDATE_HOLDOFF = 5 # seconds: rate limit for state updates
15
+ DEFAULT_PORT = 4242
16
+ DEFAULT_HOST = "0.0.0.0"
17
+
18
+ DEFAULT_ENDPOINT = "/mon/v1/state"
19
+
20
+ StateValue = Mapping[str, Any]
21
+ StateCallback = Callable[[], StateValue]
22
+ StateSource = Union[StateCallback, StateValue]
23
+
24
+
25
+ class InternalState(BaseModel):
26
+ uptime: int = 0
27
+
28
+
29
+ class MonitoringState(BaseModel):
30
+ # "internal" holds library-generated stats (like uptime)
31
+ internal: InternalState = Field(default_factory=InternalState)
32
+ # "state" holds the application state
33
+ state: StateValue = {}
34
+
35
+
36
+ def _now() -> float:
37
+ return time.monotonic()
38
+
39
+
40
+ class StatusReporter:
41
+ def __init__(self, *, endpoint: Optional[str] = None):
42
+ self._state: MonitoringState = MonitoringState()
43
+ self._state_lock: threading.RLock = threading.RLock()
44
+ self._state_callback: Optional[StateCallback] = None
45
+ self._update_time: float = 0.0
46
+ self._startup_time: float = _now()
47
+
48
+ self._worker_thread: Optional[threading.Thread] = None
49
+ self._uvicorn_server: Optional[uvicorn.Server] = None
50
+
51
+ self.router = APIRouter()
52
+ self.router.add_api_route(
53
+ path=endpoint or DEFAULT_ENDPOINT,
54
+ endpoint=self._api_endpoint,
55
+ methods=["GET"],
56
+ response_model=MonitoringState,
57
+ )
58
+
59
+ def reset(self) -> None:
60
+ """Reset the monitoring agent to its initial state.
61
+
62
+ Clears all state variables and resets timers, but does not stop
63
+ any running threads. Use stop() method to stop the monitoring agent.
64
+
65
+ Note: This method does NOT kill the monitoring thread.
66
+ Use stop() or restart() for thread management.
67
+ """
68
+ with self._state_lock:
69
+ self._state = MonitoringState()
70
+ self._update_time = 0.0
71
+ self._startup_time = _now()
72
+ _logger.debug("State reset")
73
+
74
+ def _set_state(self, state: StateValue) -> None:
75
+ """Internal method to update the state with thread safety.
76
+
77
+ Args:
78
+ state: The state data to set, as a mapping/dictionary
79
+ """
80
+ with self._state_lock:
81
+ self._state.state = state
82
+ _logger.debug("State updated: %r", state)
83
+
84
+ def publish(self, state: StateSource) -> Self:
85
+ """Set the application state.
86
+
87
+ Args:
88
+ state: Either a callable that returns state data, or a mapping/dictionary
89
+ containing the state data directly
90
+
91
+ Returns:
92
+ Self: Returns self for method chaining
93
+
94
+ Examples:
95
+ # Direct state setting
96
+ agent.publish({"status": "running", "count": 42})
97
+
98
+ # Using a callback function
99
+ def get_current_state():
100
+ return {"status": "running", "count": get_count()}
101
+ agent.publish(get_current_state)
102
+ """
103
+ _state: Mapping[str, Any] = {}
104
+ if callable(state):
105
+ self._state_callback = state
106
+ _state = state()
107
+ _logger.debug("State callback registered and executed")
108
+ else:
109
+ self._state_callback = None
110
+ if isinstance(state, Mapping):
111
+ _state = dict(state)
112
+ else:
113
+ _logger.warning("Unsupported state value: %r", state)
114
+ self._set_state(_state)
115
+ return self
116
+
117
+ def _uptime(self, now: Optional[float] = None) -> int:
118
+ return int((now or _now()) - self._startup_time)
119
+
120
+ def _update_internal_state(self, now: Optional[float] = None) -> None:
121
+ """Update the internal state with global monitoring information.
122
+
123
+ Updates system-level monitoring data like uptime.
124
+ """
125
+ try:
126
+ self._state.internal.uptime = self._uptime(now)
127
+ except Exception as exc:
128
+ _logger.error("Failed to update internal state: %s", exc)
129
+ raise
130
+
131
+ def _update_deferred(self, now: float) -> bool:
132
+ if now - self._update_time < UPDATE_HOLDOFF:
133
+ return True
134
+ self._update_time = now
135
+ return False
136
+
137
+ def _update_state(self) -> None:
138
+ """Update the monitoring state if sufficient time has passed.
139
+
140
+ This method implements rate-limiting to prevent excessive state updates.
141
+ Only updates the state if UPDATE_HOLDOFF seconds have passed since
142
+ the last update.
143
+ """
144
+ now = _now()
145
+ if self._update_deferred(now):
146
+ return
147
+ try:
148
+ self._update_internal_state(now)
149
+ if self._state_callback:
150
+ self._set_state(self._state_callback())
151
+ except ValueError as ve:
152
+ _logger.error("Invalid state value during update: %s", ve)
153
+ except RuntimeError as re:
154
+ _logger.error("Runtime error during state update: %s", re)
155
+ except Exception as exc:
156
+ _logger.error("Unexpected error during state update: %s", exc)
157
+
158
+ def _api_endpoint(self) -> MonitoringState:
159
+ """Get the current monitoring status.
160
+
161
+ Returns the complete monitoring state including both internal
162
+ monitoring data and application state.
163
+
164
+ Returns:
165
+ Dict[str, Any]: Dictionary containing 'internal' and 'state' keys
166
+
167
+ Note:
168
+ This method is called by the FastAPI endpoint and implements
169
+ error handling to ensure a response is always returned.
170
+ """
171
+ try:
172
+ with self._state_lock:
173
+ self._update_state()
174
+ return self._state
175
+ except Exception as exc:
176
+ _logger.error("Failed to provide monitoring status: %s", exc)
177
+ # Return minimal state even on error
178
+ return MonitoringState(internal=InternalState(uptime=self._uptime()))
179
+
180
+ def _worker(
181
+ self,
182
+ host: str,
183
+ port: int,
184
+ log_level: Optional[Union[int, str]] = None,
185
+ ) -> None:
186
+ """Main monitoring worker thread function.
187
+
188
+ Starts the FastAPI application with uvicorn server and handles
189
+ the monitoring endpoint.
190
+
191
+ Args:
192
+ host: Host address to bind to
193
+ port: Port number to listen on
194
+ log_level: Logging level for uvicorn
195
+ """
196
+ try:
197
+ app = FastAPI()
198
+ app.include_router(self.router)
199
+
200
+ config = uvicorn.Config(
201
+ app=app,
202
+ host=host,
203
+ port=port,
204
+ log_level=log_level,
205
+ )
206
+ self._uvicorn_server = uvicorn.Server(config)
207
+
208
+ _logger.info("Starting monitoring worker on %s:%d", host, port)
209
+
210
+ # Update state once on startup
211
+ self._update_state()
212
+
213
+ self._uvicorn_server.run()
214
+ except Exception as exc:
215
+ _logger.error("Monitoring worker failed to start: %s", exc)
216
+ raise
217
+
218
+ def _worker_alive(self) -> bool:
219
+ return bool(self._worker_thread and self._worker_thread.is_alive())
220
+
221
+ def start(
222
+ self,
223
+ *,
224
+ state: Optional[StateSource] = None,
225
+ host: Optional[str] = None,
226
+ port: Optional[int] = None,
227
+ log_level: Optional[Union[int, str]] = None,
228
+ blocking: bool = False,
229
+ ) -> None:
230
+ try:
231
+ if self._worker_alive():
232
+ raise RuntimeError("Monitoring worker already running")
233
+
234
+ self.reset()
235
+ self.publish(state or {})
236
+
237
+ actual_host = host or DEFAULT_HOST
238
+ actual_port = port or DEFAULT_PORT
239
+
240
+ self._worker_thread = threading.Thread(
241
+ target=self._worker,
242
+ args=(actual_host, actual_port, log_level),
243
+ daemon=True,
244
+ )
245
+ self._worker_thread.start()
246
+ _logger.debug("Monitoring worker started successfully")
247
+
248
+ if blocking:
249
+ try:
250
+ self._worker_thread.join()
251
+ except KeyboardInterrupt:
252
+ _logger.debug("Received keyboard interrupt, stopping monitoring worker")
253
+ self.stop()
254
+ raise
255
+ except Exception as exc:
256
+ _logger.error("Failed to start monitoring worker: %s", exc)
257
+ raise
258
+
259
+ def stop(self) -> None:
260
+ try:
261
+ if self._uvicorn_server:
262
+ self._uvicorn_server.should_exit = True
263
+ _logger.debug("Signaled uvicorn server to exit")
264
+
265
+ if self._worker_thread:
266
+ self._worker_thread.join(timeout=5)
267
+ if self._worker_thread.is_alive():
268
+ _logger.warning("Monitoring worker did not terminate within timeout")
269
+ self._worker_thread = None
270
+ _logger.debug("Monitoring worker joined successfully")
271
+
272
+ self._uvicorn_server = None
273
+ _logger.debug("Monitoring worker stopped successfully")
274
+ except RuntimeError as re:
275
+ _logger.error("Runtime error during worker shutdown: %s", re)
276
+ except Exception as exc:
277
+ _logger.error("Unexpected error during worker shutdown: %s", exc)
278
+
279
+
280
+ # Singleton instance
281
+ _instance: Optional[StatusReporter] = None
282
+
283
+
284
+ def _get_instance() -> StatusReporter:
285
+ global _instance
286
+ if _instance is None:
287
+ _instance = StatusReporter()
288
+ return _instance
289
+
290
+
291
+ def publish(state: StateSource) -> None:
292
+ _get_instance().publish(state)
293
+
294
+
295
+ def start(
296
+ *,
297
+ state: Optional[StateSource] = None,
298
+ host: Optional[str] = None,
299
+ port: Optional[int] = None,
300
+ log_level: Optional[Union[int, str]] = None,
301
+ blocking: bool = False,
302
+ ) -> None:
303
+ _get_instance().start(
304
+ state=state,
305
+ port=port,
306
+ blocking=blocking,
307
+ host=host,
308
+ log_level=log_level,
309
+ )
310
+
311
+
312
+ def stop() -> None:
313
+ _get_instance().stop()
314
+
315
+
316
+ def reset() -> None:
317
+ """Reset the global agent state. For testing purposes only."""
318
+ _get_instance().reset()
File without changes