pulselog 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.
- pulselog-0.1.0/PKG-INFO +222 -0
- pulselog-0.1.0/README.md +196 -0
- pulselog-0.1.0/pulselog/__init__.py +6 -0
- pulselog-0.1.0/pulselog/checkpoint.py +175 -0
- pulselog-0.1.0/pulselog/config.py +133 -0
- pulselog-0.1.0/pulselog/exceptions.py +9 -0
- pulselog-0.1.0/pulselog/handler.py +43 -0
- pulselog-0.1.0/pulselog/levels.py +35 -0
- pulselog-0.1.0/pulselog/logger.py +285 -0
- pulselog-0.1.0/pulselog/py.typed +0 -0
- pulselog-0.1.0/pulselog/queue.py +94 -0
- pulselog-0.1.0/pulselog/record.py +40 -0
- pulselog-0.1.0/pulselog/server/__init__.py +0 -0
- pulselog-0.1.0/pulselog/server/dashboard.html +696 -0
- pulselog-0.1.0/pulselog/server/websocket.py +262 -0
- pulselog-0.1.0/pulselog/worker.py +90 -0
- pulselog-0.1.0/pulselog.egg-info/PKG-INFO +222 -0
- pulselog-0.1.0/pulselog.egg-info/SOURCES.txt +29 -0
- pulselog-0.1.0/pulselog.egg-info/dependency_links.txt +1 -0
- pulselog-0.1.0/pulselog.egg-info/requires.txt +9 -0
- pulselog-0.1.0/pulselog.egg-info/top_level.txt +1 -0
- pulselog-0.1.0/pyproject.toml +60 -0
- pulselog-0.1.0/setup.cfg +4 -0
- pulselog-0.1.0/tests/test_checkpoint.py +93 -0
- pulselog-0.1.0/tests/test_config.py +125 -0
- pulselog-0.1.0/tests/test_handler.py +98 -0
- pulselog-0.1.0/tests/test_integration.py +186 -0
- pulselog-0.1.0/tests/test_logger.py +159 -0
- pulselog-0.1.0/tests/test_queue.py +91 -0
- pulselog-0.1.0/tests/test_server.py +136 -0
- pulselog-0.1.0/tests/test_worker.py +98 -0
pulselog-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulselog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Real-time browser dashboard for Python logging — zero config, non-blocking
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: logging,dashboard,websocket,real-time,monitoring
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: System :: Logging
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: websockets>=11.0
|
|
21
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# pulselog
|
|
28
|
+
|
|
29
|
+
A Python logging library that streams every `logger.info()` to a live real-time dashboard in your browser — with zero configuration.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install pulselog
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from pulselog import Logger
|
|
39
|
+
|
|
40
|
+
logger = Logger("my-app")
|
|
41
|
+
|
|
42
|
+
logger.info("training started", epoch=1)
|
|
43
|
+
logger.warning("learning rate too high", lr=0.1)
|
|
44
|
+
logger.save("epoch-1", {"acc": 0.91, "loss": 0.23}, status="DONE", progress=33)
|
|
45
|
+
logger.shutdown()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A browser tab opens automatically at `http://localhost:5678` showing all logs in real time.
|
|
49
|
+
|
|
50
|
+
## Why pulselog?
|
|
51
|
+
|
|
52
|
+
Standard logging solutions block the main thread on every log call — waiting for a file write, HTTP request, or DB insert. In tight loops (ML training, inference, data pipelines) this kills performance.
|
|
53
|
+
|
|
54
|
+
pulselog uses a **non-blocking in-memory queue + daemon background worker**. The main thread never waits. Log calls cost ~2µs. 1 million calls complete in under 2 seconds.
|
|
55
|
+
|
|
56
|
+
## Dashboard
|
|
57
|
+
|
|
58
|
+
The dashboard is a single self-contained HTML file served over WebSocket — no build step, no CDN, no framework.
|
|
59
|
+
|
|
60
|
+
**Logs tab:**
|
|
61
|
+
- Colour-coded by level (DEBUG=gray, INFO=blue, WARNING=amber, ERROR/CRITICAL=red)
|
|
62
|
+
- Level filter + full-text search
|
|
63
|
+
- Virtual list rendering — handles 100k+ logs with zero browser lag
|
|
64
|
+
- Auto-scroll with manual scroll override
|
|
65
|
+
- Export all logs as JSON
|
|
66
|
+
|
|
67
|
+
**Checkpoints tab:**
|
|
68
|
+
- Progress bars for each checkpoint
|
|
69
|
+
- Overall progress = average of all checkpoint progress values
|
|
70
|
+
- Expandable JSON data viewer
|
|
71
|
+
- Status icons: DONE ✅, IN_PROGRESS 🟡, FAILED 🔴, SKIPPED ⚫
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
### Initialisation
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
logger = Logger(
|
|
79
|
+
name, # shown in dashboard
|
|
80
|
+
host="localhost", # dashboard bind host
|
|
81
|
+
port=5678, # auto-increments if port is taken
|
|
82
|
+
auto_open=True, # open browser automatically
|
|
83
|
+
dashboard=True, # set False for production/CI
|
|
84
|
+
checkpoint_path=".pulselog/checkpoints.db",
|
|
85
|
+
level="DEBUG", # minimum capture level
|
|
86
|
+
worker_interval=0.05, # drain interval in seconds
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Logging
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
logger.debug(msg, **extra)
|
|
94
|
+
logger.info(msg, **extra)
|
|
95
|
+
logger.warning(msg, **extra)
|
|
96
|
+
logger.error(msg, **extra)
|
|
97
|
+
logger.critical(msg, **extra)
|
|
98
|
+
|
|
99
|
+
# Extra kwargs appear as metadata in the dashboard
|
|
100
|
+
logger.info("request handled", user_id=42, latency_ms=12)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Checkpoints
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
logger.save(
|
|
107
|
+
name, # checkpoint identifier
|
|
108
|
+
data, # any JSON-serialisable dict
|
|
109
|
+
status="DONE", # "DONE"|"IN_PROGRESS"|"FAILED"|"SKIPPED"
|
|
110
|
+
note="", # human-readable description
|
|
111
|
+
progress=None # 0–100, shown as progress bar
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
result = logger.load("epoch-5") # → dict | None (never raises)
|
|
115
|
+
names = logger.checkpoints() # → list[str]
|
|
116
|
+
logger.delete_checkpoint("epoch-3")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Utilities
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
logger.tag("phase-2") # group following logs under a label
|
|
123
|
+
logger.divider("epoch boundary") # insert visual divider in dashboard
|
|
124
|
+
logger.flush() # force-drain queue (call before exit)
|
|
125
|
+
logger.shutdown() # graceful teardown
|
|
126
|
+
stats = logger.stats() # operational metrics dict
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Stats
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
{
|
|
133
|
+
"records_logged": int,
|
|
134
|
+
"records_dropped": int, # due to queue overflow
|
|
135
|
+
"queue_size": int,
|
|
136
|
+
"checkpoints_saved": int,
|
|
137
|
+
"dashboard_clients": int,
|
|
138
|
+
"uptime_seconds": float,
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Configuration
|
|
143
|
+
|
|
144
|
+
Priority order (highest → lowest): `Logger()` kwargs > env vars > `pulselog.toml` > defaults
|
|
145
|
+
|
|
146
|
+
### Environment variables
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
PULSELOG_DASHBOARD=false
|
|
150
|
+
PULSELOG_HOST=0.0.0.0
|
|
151
|
+
PULSELOG_PORT=8080
|
|
152
|
+
PULSELOG_AUTO_OPEN=false
|
|
153
|
+
PULSELOG_CHECKPOINT_PATH=/data/checkpoints.db
|
|
154
|
+
PULSELOG_LEVEL=INFO
|
|
155
|
+
PULSELOG_WORKER_INTERVAL=0.01
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `pulselog.toml` (place in CWD)
|
|
159
|
+
|
|
160
|
+
```toml
|
|
161
|
+
[pulselog]
|
|
162
|
+
host = "0.0.0.0"
|
|
163
|
+
port = 8080
|
|
164
|
+
auto_open = false
|
|
165
|
+
level = "INFO"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## stdlib `logging` integration
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import logging
|
|
172
|
+
from pulselog.handler import PulseHandler
|
|
173
|
+
|
|
174
|
+
logging.getLogger().addHandler(PulseHandler("my-app"))
|
|
175
|
+
logging.info("this appears in the pulselog dashboard")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Production usage
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# In production: disable dashboard, keep checkpoints
|
|
182
|
+
logger = Logger("prod", dashboard=False, checkpoint_path="/data/checkpoints.db")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
With `dashboard=False`:
|
|
186
|
+
- No threads are started
|
|
187
|
+
- No port is bound
|
|
188
|
+
- No browser is opened
|
|
189
|
+
- Checkpoint reads/writes still work
|
|
190
|
+
- Log calls return in ~100ns (level check only)
|
|
191
|
+
|
|
192
|
+
## Performance
|
|
193
|
+
|
|
194
|
+
| Operation | Throughput |
|
|
195
|
+
|-----------|-----------|
|
|
196
|
+
| `logger.info()` call | ~2µs |
|
|
197
|
+
| 1 million log calls | <2s |
|
|
198
|
+
| Queue `put()` | O(1), <1µs |
|
|
199
|
+
| Dashboard at 100k logs | No lag (virtual list) |
|
|
200
|
+
|
|
201
|
+
## Design
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
logger.info() ← O(1), non-blocking
|
|
205
|
+
│
|
|
206
|
+
▼
|
|
207
|
+
LogQueue ← deque(maxlen=10_000), thread-safe
|
|
208
|
+
│
|
|
209
|
+
▼ every 50ms
|
|
210
|
+
BackgroundWorker ← daemon thread
|
|
211
|
+
│
|
|
212
|
+
├─▶ DashboardServer.broadcast()
|
|
213
|
+
│ └─▶ WebSocket clients (all connected browsers)
|
|
214
|
+
│
|
|
215
|
+
└─▶ (additional handlers)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The queue uses `collections.deque(maxlen=N)` — when full, the oldest record is silently dropped (never blocks). Dropped records are counted in `logger.stats()`.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
pulselog-0.1.0/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# pulselog
|
|
2
|
+
|
|
3
|
+
A Python logging library that streams every `logger.info()` to a live real-time dashboard in your browser — with zero configuration.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install pulselog
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pulselog import Logger
|
|
13
|
+
|
|
14
|
+
logger = Logger("my-app")
|
|
15
|
+
|
|
16
|
+
logger.info("training started", epoch=1)
|
|
17
|
+
logger.warning("learning rate too high", lr=0.1)
|
|
18
|
+
logger.save("epoch-1", {"acc": 0.91, "loss": 0.23}, status="DONE", progress=33)
|
|
19
|
+
logger.shutdown()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A browser tab opens automatically at `http://localhost:5678` showing all logs in real time.
|
|
23
|
+
|
|
24
|
+
## Why pulselog?
|
|
25
|
+
|
|
26
|
+
Standard logging solutions block the main thread on every log call — waiting for a file write, HTTP request, or DB insert. In tight loops (ML training, inference, data pipelines) this kills performance.
|
|
27
|
+
|
|
28
|
+
pulselog uses a **non-blocking in-memory queue + daemon background worker**. The main thread never waits. Log calls cost ~2µs. 1 million calls complete in under 2 seconds.
|
|
29
|
+
|
|
30
|
+
## Dashboard
|
|
31
|
+
|
|
32
|
+
The dashboard is a single self-contained HTML file served over WebSocket — no build step, no CDN, no framework.
|
|
33
|
+
|
|
34
|
+
**Logs tab:**
|
|
35
|
+
- Colour-coded by level (DEBUG=gray, INFO=blue, WARNING=amber, ERROR/CRITICAL=red)
|
|
36
|
+
- Level filter + full-text search
|
|
37
|
+
- Virtual list rendering — handles 100k+ logs with zero browser lag
|
|
38
|
+
- Auto-scroll with manual scroll override
|
|
39
|
+
- Export all logs as JSON
|
|
40
|
+
|
|
41
|
+
**Checkpoints tab:**
|
|
42
|
+
- Progress bars for each checkpoint
|
|
43
|
+
- Overall progress = average of all checkpoint progress values
|
|
44
|
+
- Expandable JSON data viewer
|
|
45
|
+
- Status icons: DONE ✅, IN_PROGRESS 🟡, FAILED 🔴, SKIPPED ⚫
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### Initialisation
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
logger = Logger(
|
|
53
|
+
name, # shown in dashboard
|
|
54
|
+
host="localhost", # dashboard bind host
|
|
55
|
+
port=5678, # auto-increments if port is taken
|
|
56
|
+
auto_open=True, # open browser automatically
|
|
57
|
+
dashboard=True, # set False for production/CI
|
|
58
|
+
checkpoint_path=".pulselog/checkpoints.db",
|
|
59
|
+
level="DEBUG", # minimum capture level
|
|
60
|
+
worker_interval=0.05, # drain interval in seconds
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Logging
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
logger.debug(msg, **extra)
|
|
68
|
+
logger.info(msg, **extra)
|
|
69
|
+
logger.warning(msg, **extra)
|
|
70
|
+
logger.error(msg, **extra)
|
|
71
|
+
logger.critical(msg, **extra)
|
|
72
|
+
|
|
73
|
+
# Extra kwargs appear as metadata in the dashboard
|
|
74
|
+
logger.info("request handled", user_id=42, latency_ms=12)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Checkpoints
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
logger.save(
|
|
81
|
+
name, # checkpoint identifier
|
|
82
|
+
data, # any JSON-serialisable dict
|
|
83
|
+
status="DONE", # "DONE"|"IN_PROGRESS"|"FAILED"|"SKIPPED"
|
|
84
|
+
note="", # human-readable description
|
|
85
|
+
progress=None # 0–100, shown as progress bar
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
result = logger.load("epoch-5") # → dict | None (never raises)
|
|
89
|
+
names = logger.checkpoints() # → list[str]
|
|
90
|
+
logger.delete_checkpoint("epoch-3")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Utilities
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
logger.tag("phase-2") # group following logs under a label
|
|
97
|
+
logger.divider("epoch boundary") # insert visual divider in dashboard
|
|
98
|
+
logger.flush() # force-drain queue (call before exit)
|
|
99
|
+
logger.shutdown() # graceful teardown
|
|
100
|
+
stats = logger.stats() # operational metrics dict
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Stats
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
{
|
|
107
|
+
"records_logged": int,
|
|
108
|
+
"records_dropped": int, # due to queue overflow
|
|
109
|
+
"queue_size": int,
|
|
110
|
+
"checkpoints_saved": int,
|
|
111
|
+
"dashboard_clients": int,
|
|
112
|
+
"uptime_seconds": float,
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
Priority order (highest → lowest): `Logger()` kwargs > env vars > `pulselog.toml` > defaults
|
|
119
|
+
|
|
120
|
+
### Environment variables
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
PULSELOG_DASHBOARD=false
|
|
124
|
+
PULSELOG_HOST=0.0.0.0
|
|
125
|
+
PULSELOG_PORT=8080
|
|
126
|
+
PULSELOG_AUTO_OPEN=false
|
|
127
|
+
PULSELOG_CHECKPOINT_PATH=/data/checkpoints.db
|
|
128
|
+
PULSELOG_LEVEL=INFO
|
|
129
|
+
PULSELOG_WORKER_INTERVAL=0.01
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `pulselog.toml` (place in CWD)
|
|
133
|
+
|
|
134
|
+
```toml
|
|
135
|
+
[pulselog]
|
|
136
|
+
host = "0.0.0.0"
|
|
137
|
+
port = 8080
|
|
138
|
+
auto_open = false
|
|
139
|
+
level = "INFO"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## stdlib `logging` integration
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import logging
|
|
146
|
+
from pulselog.handler import PulseHandler
|
|
147
|
+
|
|
148
|
+
logging.getLogger().addHandler(PulseHandler("my-app"))
|
|
149
|
+
logging.info("this appears in the pulselog dashboard")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Production usage
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# In production: disable dashboard, keep checkpoints
|
|
156
|
+
logger = Logger("prod", dashboard=False, checkpoint_path="/data/checkpoints.db")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
With `dashboard=False`:
|
|
160
|
+
- No threads are started
|
|
161
|
+
- No port is bound
|
|
162
|
+
- No browser is opened
|
|
163
|
+
- Checkpoint reads/writes still work
|
|
164
|
+
- Log calls return in ~100ns (level check only)
|
|
165
|
+
|
|
166
|
+
## Performance
|
|
167
|
+
|
|
168
|
+
| Operation | Throughput |
|
|
169
|
+
|-----------|-----------|
|
|
170
|
+
| `logger.info()` call | ~2µs |
|
|
171
|
+
| 1 million log calls | <2s |
|
|
172
|
+
| Queue `put()` | O(1), <1µs |
|
|
173
|
+
| Dashboard at 100k logs | No lag (virtual list) |
|
|
174
|
+
|
|
175
|
+
## Design
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
logger.info() ← O(1), non-blocking
|
|
179
|
+
│
|
|
180
|
+
▼
|
|
181
|
+
LogQueue ← deque(maxlen=10_000), thread-safe
|
|
182
|
+
│
|
|
183
|
+
▼ every 50ms
|
|
184
|
+
BackgroundWorker ← daemon thread
|
|
185
|
+
│
|
|
186
|
+
├─▶ DashboardServer.broadcast()
|
|
187
|
+
│ └─▶ WebSocket clients (all connected browsers)
|
|
188
|
+
│
|
|
189
|
+
└─▶ (additional handlers)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The queue uses `collections.deque(maxlen=N)` — when full, the oldest record is silently dropped (never blocks). Dropped records are counted in `logger.stats()`.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Any
|
|
9
|
+
|
|
10
|
+
from pulselog.exceptions import InvalidStatusError
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Constants
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
VALID_STATUSES = {"DONE", "IN_PROGRESS", "FAILED", "SKIPPED"}
|
|
16
|
+
|
|
17
|
+
_SCHEMA = """
|
|
18
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
19
|
+
name TEXT PRIMARY KEY,
|
|
20
|
+
data TEXT NOT NULL,
|
|
21
|
+
status TEXT NOT NULL,
|
|
22
|
+
note TEXT DEFAULT '',
|
|
23
|
+
progress REAL,
|
|
24
|
+
saved_at REAL NOT NULL
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CheckpointStore:
|
|
30
|
+
"""Thread-safe SQLite-backed key/value store for training checkpoints.
|
|
31
|
+
|
|
32
|
+
Multiple threads may call save/load/delete concurrently; a threading.Lock
|
|
33
|
+
serialises all SQLite operations so we never hit "database is locked".
|
|
34
|
+
|
|
35
|
+
WAL mode is enabled so concurrent reads do not block writes.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, db_path: str = ":memory:") -> None:
|
|
39
|
+
"""Open (or create) the checkpoint database.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
db_path: Filesystem path to the SQLite file, or ":memory:".
|
|
43
|
+
"""
|
|
44
|
+
if db_path != ":memory:":
|
|
45
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# check_same_thread=False is safe because we guard every access with _lock.
|
|
48
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False) # guarded by self._lock
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
53
|
+
self._conn.execute(_SCHEMA)
|
|
54
|
+
self._conn.commit()
|
|
55
|
+
|
|
56
|
+
self._saved_count = 0 # guarded by self._lock
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Public API
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def save(
|
|
63
|
+
self,
|
|
64
|
+
name: str,
|
|
65
|
+
data: Dict[str, Any],
|
|
66
|
+
status: str = "DONE",
|
|
67
|
+
note: str = "",
|
|
68
|
+
progress: Optional[float] = None,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Persist a checkpoint, overwriting any previous value for *name*.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
name: Unique checkpoint identifier.
|
|
74
|
+
data: JSON-serialisable payload dict.
|
|
75
|
+
status: One of DONE | IN_PROGRESS | FAILED | SKIPPED.
|
|
76
|
+
note: Human-readable description.
|
|
77
|
+
progress: Optional 0–100 progress value.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The full record dict including a ``_meta`` key.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
InvalidStatusError: If *status* is not one of the allowed values.
|
|
84
|
+
"""
|
|
85
|
+
upper_status = status.upper()
|
|
86
|
+
if upper_status not in VALID_STATUSES:
|
|
87
|
+
raise InvalidStatusError(
|
|
88
|
+
f"Invalid checkpoint status {status!r}. "
|
|
89
|
+
f"Allowed values: {', '.join(sorted(VALID_STATUSES))}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
saved_at = time.time()
|
|
93
|
+
data_json = json.dumps(data)
|
|
94
|
+
|
|
95
|
+
with self._lock:
|
|
96
|
+
self._conn.execute(
|
|
97
|
+
"""
|
|
98
|
+
INSERT OR REPLACE INTO checkpoints
|
|
99
|
+
(name, data, status, note, progress, saved_at)
|
|
100
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
101
|
+
""",
|
|
102
|
+
(name, data_json, upper_status, note, progress, saved_at),
|
|
103
|
+
)
|
|
104
|
+
self._conn.commit()
|
|
105
|
+
self._saved_count += 1
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
**data,
|
|
109
|
+
"_meta": {
|
|
110
|
+
"name": name,
|
|
111
|
+
"status": upper_status,
|
|
112
|
+
"note": note,
|
|
113
|
+
"progress": progress,
|
|
114
|
+
"saved_at": saved_at,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def load(self, name: str) -> Optional[Dict[str, Any]]:
|
|
119
|
+
"""Load a checkpoint by name.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
name: Checkpoint identifier.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict with original data keys plus ``_meta``, or None if not found.
|
|
126
|
+
"""
|
|
127
|
+
with self._lock:
|
|
128
|
+
cur = self._conn.execute(
|
|
129
|
+
"SELECT data, status, note, progress, saved_at FROM checkpoints WHERE name = ?",
|
|
130
|
+
(name,),
|
|
131
|
+
)
|
|
132
|
+
row = cur.fetchone()
|
|
133
|
+
|
|
134
|
+
if row is None:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
data_json, status, note, progress, saved_at = row
|
|
138
|
+
data = json.loads(data_json)
|
|
139
|
+
return {
|
|
140
|
+
**data,
|
|
141
|
+
"_meta": {
|
|
142
|
+
"name": name,
|
|
143
|
+
"status": status,
|
|
144
|
+
"note": note,
|
|
145
|
+
"progress": progress,
|
|
146
|
+
"saved_at": saved_at,
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def checkpoints(self) -> List[str]:
|
|
151
|
+
"""Return all checkpoint names ordered by most-recently saved first.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of name strings.
|
|
155
|
+
"""
|
|
156
|
+
with self._lock:
|
|
157
|
+
cur = self._conn.execute(
|
|
158
|
+
"SELECT name FROM checkpoints ORDER BY saved_at DESC"
|
|
159
|
+
)
|
|
160
|
+
return [row[0] for row in cur.fetchall()]
|
|
161
|
+
|
|
162
|
+
def delete_checkpoint(self, name: str) -> None:
|
|
163
|
+
"""Delete a checkpoint by name. Silent if the name doesn't exist.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
name: Checkpoint identifier to remove.
|
|
167
|
+
"""
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._conn.execute("DELETE FROM checkpoints WHERE name = ?", (name,))
|
|
170
|
+
self._conn.commit()
|
|
171
|
+
|
|
172
|
+
def saved_count(self) -> int:
|
|
173
|
+
"""Total number of save() calls since this store was created."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
return self._saved_count
|