functualize-state-sqlite 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- functualize_state_sqlite/__init__.py +13 -0
- functualize_state_sqlite/_backend.py +160 -0
- functualize_state_sqlite/_execution_store.py +379 -0
- functualize_state_sqlite/_migrations.py +157 -0
- functualize_state_sqlite/_plugin.py +205 -0
- functualize_state_sqlite/plugin.py +345 -0
- functualize_state_sqlite/py.typed +0 -0
- functualize_state_sqlite/sqlite_backend.py +721 -0
- functualize_state_sqlite/state_store.py +178 -0
- functualize_state_sqlite/tracker.py +318 -0
- functualize_state_sqlite-0.1.0.dist-info/METADATA +88 -0
- functualize_state_sqlite-0.1.0.dist-info/RECORD +14 -0
- functualize_state_sqlite-0.1.0.dist-info/WHEEL +4 -0
- functualize_state_sqlite-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""SQLite-backed state store implementing StateStoreProtocol.
|
|
2
|
+
|
|
3
|
+
Provides persistent key-value state storage per job namespace, backed by
|
|
4
|
+
the SQLiteBackend's state table. Values are JSON-serialized for storage;
|
|
5
|
+
non-serializable values are replaced with a type-indicating placeholder.
|
|
6
|
+
|
|
7
|
+
Each SQLiteStateStore instance is scoped to a specific scope_id and
|
|
8
|
+
job_namespace, providing namespace isolation per job.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from functualize_state_sqlite.sqlite_backend import SQLiteBackend
|
|
19
|
+
|
|
20
|
+
__all__ = ["SQLiteStateStore"]
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_NON_SERIALIZABLE_TEMPLATE = "<non-serializable: {type_name}>"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _serialize_value(value: Any) -> str:
|
|
28
|
+
"""Serialize a value to JSON string.
|
|
29
|
+
|
|
30
|
+
If the value cannot be serialized, returns a JSON-encoded placeholder
|
|
31
|
+
string containing the type name (Requirement 23.10).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
value: Any Python value to serialize.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A JSON string representation of the value.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return json.dumps(value)
|
|
41
|
+
except (TypeError, ValueError, OverflowError):
|
|
42
|
+
type_name = type(value).__name__
|
|
43
|
+
placeholder = _NON_SERIALIZABLE_TEMPLATE.format(type_name=type_name)
|
|
44
|
+
return json.dumps(placeholder)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _deserialize_value(value_json: str) -> Any:
|
|
48
|
+
"""Deserialize a JSON string back to a Python value.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
value_json: JSON string to deserialize.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The deserialized Python value.
|
|
55
|
+
"""
|
|
56
|
+
return json.loads(value_json)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SQLiteStateStore:
|
|
60
|
+
"""StateStoreProtocol implementation backed by SQLite.
|
|
61
|
+
|
|
62
|
+
Each instance is scoped to a specific (scope_id, job_namespace) pair,
|
|
63
|
+
providing namespace isolation between jobs. All values are persisted
|
|
64
|
+
immediately to the database on write.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
backend: The SQLiteBackend instance managing the database connection.
|
|
68
|
+
scope_id: The workflow scope identifier for state isolation.
|
|
69
|
+
job_namespace: The job namespace (typically the job name) for this store.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
backend: SQLiteBackend,
|
|
75
|
+
scope_id: str,
|
|
76
|
+
job_namespace: str,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._backend = backend
|
|
79
|
+
self._scope_id = scope_id
|
|
80
|
+
self._job_namespace = job_namespace
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def scope_id(self) -> str:
|
|
84
|
+
"""The workflow scope identifier."""
|
|
85
|
+
return self._scope_id
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def job_namespace(self) -> str:
|
|
89
|
+
"""The job namespace for this store instance."""
|
|
90
|
+
return self._job_namespace
|
|
91
|
+
|
|
92
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
93
|
+
"""Retrieve a value by key from the current job's namespace.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
key: The state key to look up.
|
|
97
|
+
default: Value to return if key not found.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The deserialized value, or default if not found.
|
|
101
|
+
"""
|
|
102
|
+
value_json = self._backend.get_state(self._scope_id, self._job_namespace, key)
|
|
103
|
+
if value_json is None:
|
|
104
|
+
return default
|
|
105
|
+
return _deserialize_value(value_json)
|
|
106
|
+
|
|
107
|
+
def set(self, key: str, value: Any) -> None:
|
|
108
|
+
"""Store a value under the given key in the current job's namespace.
|
|
109
|
+
|
|
110
|
+
The value is immediately persisted to the database as JSON.
|
|
111
|
+
Non-serializable values are stored as a placeholder string
|
|
112
|
+
containing the type name.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
key: The state key.
|
|
116
|
+
value: The value to store (will be JSON-serialized).
|
|
117
|
+
"""
|
|
118
|
+
value_json = _serialize_value(value)
|
|
119
|
+
self._backend.upsert_state(self._scope_id, self._job_namespace, key, value_json)
|
|
120
|
+
|
|
121
|
+
def delete(self, key: str) -> None:
|
|
122
|
+
"""Remove a key from the current job's namespace.
|
|
123
|
+
|
|
124
|
+
No-op if the key doesn't exist.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
key: The state key to delete.
|
|
128
|
+
"""
|
|
129
|
+
self._backend.delete_state(self._scope_id, self._job_namespace, key)
|
|
130
|
+
|
|
131
|
+
def keys(self) -> list[str]:
|
|
132
|
+
"""Return all stored key names in the current job's namespace.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of key strings.
|
|
136
|
+
"""
|
|
137
|
+
return self._backend.get_namespace_keys(self._scope_id, self._job_namespace)
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> dict[str, Any]:
|
|
140
|
+
"""Return a copy of all state as a plain dict for the current namespace.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Dict mapping keys to their deserialized values.
|
|
144
|
+
"""
|
|
145
|
+
raw = self._backend.get_namespace_state(self._scope_id, self._job_namespace)
|
|
146
|
+
return {key: _deserialize_value(value_json) for key, value_json in raw.items()}
|
|
147
|
+
|
|
148
|
+
def clear(self) -> None:
|
|
149
|
+
"""Remove all stored state in the current job's namespace."""
|
|
150
|
+
self._backend.clear_namespace_state(self._scope_id, self._job_namespace)
|
|
151
|
+
|
|
152
|
+
def get_job_state(self, job_name: str, key: str, default: Any = None) -> Any:
|
|
153
|
+
"""Read a value from another job's namespace.
|
|
154
|
+
|
|
155
|
+
Provides cross-job state access for coordination between jobs
|
|
156
|
+
within the same workflow scope.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
job_name: The job namespace to read from.
|
|
160
|
+
key: The state key within that namespace.
|
|
161
|
+
default: Value to return if key not found.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The deserialized value from the specified job's namespace,
|
|
165
|
+
or default if not found.
|
|
166
|
+
"""
|
|
167
|
+
value_json = self._backend.get_state(self._scope_id, job_name, key)
|
|
168
|
+
if value_json is None:
|
|
169
|
+
return default
|
|
170
|
+
return _deserialize_value(value_json)
|
|
171
|
+
|
|
172
|
+
def list_job_namespaces(self) -> list[str]:
|
|
173
|
+
"""Return all job namespaces that have stored state in this scope.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of job namespace strings.
|
|
177
|
+
"""
|
|
178
|
+
return self._backend.list_namespaces(self._scope_id)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Execution tracker for session management, execution recording, and AI context.
|
|
2
|
+
|
|
3
|
+
Provides high-level operations for tracking job executions within sessions,
|
|
4
|
+
including automatic session resume based on TTL, nested execution linkage,
|
|
5
|
+
and a plain-text AI context summary.
|
|
6
|
+
|
|
7
|
+
The tracker delegates all database operations to the SQLiteBackend and
|
|
8
|
+
follows the error resilience pattern (Requirement 23.11): database failures
|
|
9
|
+
are logged but never propagate to callers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from functualize_state_sqlite.sqlite_backend import SQLiteBackend
|
|
22
|
+
|
|
23
|
+
__all__ = ["ExecutionTracker"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Default session TTL: 30 minutes (in seconds)
|
|
28
|
+
_DEFAULT_SESSION_TTL_SECONDS = 30 * 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExecutionTracker:
|
|
32
|
+
"""Tracks job executions within sessions, providing history and AI context.
|
|
33
|
+
|
|
34
|
+
The tracker manages session lifecycle (auto-resume or create new) and
|
|
35
|
+
records execution start/end events. It supports nested executions via
|
|
36
|
+
parent_uid linkage and provides a plain-text summary for AI assistants.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
backend: An initialized SQLiteBackend instance.
|
|
40
|
+
session_ttl: Time in seconds before a session is considered expired.
|
|
41
|
+
Defaults to 1800 (30 minutes).
|
|
42
|
+
scope_id: The workflow scope identifier. Defaults to "default".
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
backend: SQLiteBackend,
|
|
48
|
+
*,
|
|
49
|
+
session_ttl: float = _DEFAULT_SESSION_TTL_SECONDS,
|
|
50
|
+
scope_id: str = "default",
|
|
51
|
+
) -> None:
|
|
52
|
+
self._backend = backend
|
|
53
|
+
self._session_ttl = session_ttl
|
|
54
|
+
self._scope_id = scope_id
|
|
55
|
+
self._session_id: str | None = None
|
|
56
|
+
self._execution_count: int = 0
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def session_id(self) -> str | None:
|
|
60
|
+
"""The current active session ID, or None if no session is active."""
|
|
61
|
+
return self._session_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def session_ttl(self) -> float:
|
|
65
|
+
"""The session TTL in seconds."""
|
|
66
|
+
return self._session_ttl
|
|
67
|
+
|
|
68
|
+
# ─── Session Management ───────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def ensure_session(self) -> str:
|
|
71
|
+
"""Ensure an active session exists, resuming or creating as needed.
|
|
72
|
+
|
|
73
|
+
If the latest session's updated_at is within the TTL, resume it.
|
|
74
|
+
Otherwise, create a new session.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The active session ID.
|
|
78
|
+
"""
|
|
79
|
+
if self._session_id is not None:
|
|
80
|
+
return self._session_id
|
|
81
|
+
|
|
82
|
+
# Try to resume the latest session
|
|
83
|
+
latest = self._backend.get_latest_session()
|
|
84
|
+
if latest is not None:
|
|
85
|
+
updated_at = latest.get("updated_at", 0.0)
|
|
86
|
+
elapsed = time.time() - updated_at
|
|
87
|
+
if elapsed < self._session_ttl:
|
|
88
|
+
self._session_id = latest["session_id"]
|
|
89
|
+
# Touch the session to keep it alive
|
|
90
|
+
self._backend.update_session(self._session_id)
|
|
91
|
+
logger.debug(
|
|
92
|
+
"Resumed session %s (idle %.1fs)",
|
|
93
|
+
self._session_id,
|
|
94
|
+
elapsed,
|
|
95
|
+
)
|
|
96
|
+
return self._session_id
|
|
97
|
+
|
|
98
|
+
# Create a new session
|
|
99
|
+
self._session_id = str(uuid.uuid4())
|
|
100
|
+
success = self._backend.insert_session(
|
|
101
|
+
self._session_id,
|
|
102
|
+
self._scope_id,
|
|
103
|
+
)
|
|
104
|
+
if success:
|
|
105
|
+
logger.debug("Created new session %s", self._session_id)
|
|
106
|
+
else:
|
|
107
|
+
logger.error("Failed to create session %s", self._session_id)
|
|
108
|
+
|
|
109
|
+
return self._session_id
|
|
110
|
+
|
|
111
|
+
# ─── Execution Recording ──────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def record_start(
|
|
114
|
+
self,
|
|
115
|
+
job_name: str,
|
|
116
|
+
*,
|
|
117
|
+
kwargs_json: str | None = None,
|
|
118
|
+
parent_uid: str | None = None,
|
|
119
|
+
depth: int = 0,
|
|
120
|
+
) -> str:
|
|
121
|
+
"""Record the start of a job execution.
|
|
122
|
+
|
|
123
|
+
Ensures a session is active, generates a unique execution UID,
|
|
124
|
+
and inserts the execution record.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
job_name: The name of the job being executed.
|
|
128
|
+
kwargs_json: JSON-serialized kwargs passed to the job.
|
|
129
|
+
parent_uid: The execution UID of the parent (for nested calls).
|
|
130
|
+
depth: The nesting depth (0 for top-level).
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
The generated execution UID.
|
|
134
|
+
"""
|
|
135
|
+
session_id = self.ensure_session()
|
|
136
|
+
execution_uid = str(uuid.uuid4())
|
|
137
|
+
|
|
138
|
+
success = self._backend.insert_execution(
|
|
139
|
+
execution_uid,
|
|
140
|
+
session_id,
|
|
141
|
+
job_name,
|
|
142
|
+
kwargs_json=kwargs_json,
|
|
143
|
+
parent_uid=parent_uid,
|
|
144
|
+
depth=depth,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if success:
|
|
148
|
+
self._execution_count += 1
|
|
149
|
+
# Keep session alive
|
|
150
|
+
self._backend.update_session(session_id)
|
|
151
|
+
logger.debug(
|
|
152
|
+
"Recorded execution start: %s (job=%s, parent=%s)",
|
|
153
|
+
execution_uid,
|
|
154
|
+
job_name,
|
|
155
|
+
parent_uid,
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
logger.error(
|
|
159
|
+
"Failed to record execution start for job %s",
|
|
160
|
+
job_name,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return execution_uid
|
|
164
|
+
|
|
165
|
+
def record_end(
|
|
166
|
+
self,
|
|
167
|
+
execution_uid: str,
|
|
168
|
+
*,
|
|
169
|
+
status: str,
|
|
170
|
+
duration_ms: float,
|
|
171
|
+
result_json: str | None = None,
|
|
172
|
+
error_message: str | None = None,
|
|
173
|
+
error_type: str | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Record the end of a job execution.
|
|
176
|
+
|
|
177
|
+
Updates the execution record with completion details.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
execution_uid: The execution UID returned by record_start().
|
|
181
|
+
status: The final status ("success" or "failure").
|
|
182
|
+
duration_ms: The execution duration in milliseconds.
|
|
183
|
+
result_json: JSON-serialized return value (if serializable).
|
|
184
|
+
error_message: Error message (for failed executions).
|
|
185
|
+
error_type: Error type name (for failed executions).
|
|
186
|
+
"""
|
|
187
|
+
success = self._backend.update_execution(
|
|
188
|
+
execution_uid,
|
|
189
|
+
status=status,
|
|
190
|
+
duration_ms=duration_ms,
|
|
191
|
+
result_json=result_json,
|
|
192
|
+
error_message=error_message,
|
|
193
|
+
error_type=error_type,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if success:
|
|
197
|
+
# Keep session alive
|
|
198
|
+
if self._session_id:
|
|
199
|
+
self._backend.update_session(self._session_id)
|
|
200
|
+
logger.debug(
|
|
201
|
+
"Recorded execution end: %s (status=%s, duration=%.1fms)",
|
|
202
|
+
execution_uid,
|
|
203
|
+
status,
|
|
204
|
+
duration_ms,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
logger.error(
|
|
208
|
+
"Failed to record execution end for %s",
|
|
209
|
+
execution_uid,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# ─── Query Methods ────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def get_execution_history(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
limit: int = 20,
|
|
218
|
+
) -> list[dict[str, Any]]:
|
|
219
|
+
"""Get recent executions for the current session.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
limit: Maximum number of executions to return.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
List of execution records as dicts, ordered by start time descending.
|
|
226
|
+
"""
|
|
227
|
+
if self._session_id is None:
|
|
228
|
+
return []
|
|
229
|
+
return self._backend.get_session_executions(
|
|
230
|
+
self._session_id,
|
|
231
|
+
limit=limit,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def get_session_execution_count(self) -> int:
|
|
235
|
+
"""Get the total number of executions in the current session.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The count of executions, or 0 if no session is active.
|
|
239
|
+
"""
|
|
240
|
+
if self._session_id is None:
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
row = self._backend.fetch_one(
|
|
244
|
+
"SELECT COUNT(*) as count FROM executions WHERE session_id = ?",
|
|
245
|
+
(self._session_id,),
|
|
246
|
+
)
|
|
247
|
+
if row is not None:
|
|
248
|
+
return int(row["count"])
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
# ─── AI Context ───────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def to_ai_context(self) -> str:
|
|
254
|
+
"""Generate a plain-text summary for AI assistant consumption.
|
|
255
|
+
|
|
256
|
+
Returns a formatted summary containing:
|
|
257
|
+
- Session ID
|
|
258
|
+
- Total execution count in the session
|
|
259
|
+
- List of recent 20 executions with job name, status, duration, timestamp
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
A plain-text string suitable for including in AI context.
|
|
263
|
+
"""
|
|
264
|
+
if self._session_id is None:
|
|
265
|
+
return "No active execution session."
|
|
266
|
+
|
|
267
|
+
total_count = self.get_session_execution_count()
|
|
268
|
+
recent = self._backend.get_session_executions(
|
|
269
|
+
self._session_id,
|
|
270
|
+
limit=20,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
lines: list[str] = [
|
|
274
|
+
"## Execution Context",
|
|
275
|
+
f"Session: {self._session_id}",
|
|
276
|
+
f"Total executions: {total_count}",
|
|
277
|
+
"",
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
if not recent:
|
|
281
|
+
lines.append("No executions recorded yet.")
|
|
282
|
+
else:
|
|
283
|
+
lines.append("Recent executions (most recent first):")
|
|
284
|
+
lines.append("")
|
|
285
|
+
for exec_record in recent:
|
|
286
|
+
job_name = exec_record.get("job_name", "unknown")
|
|
287
|
+
status = exec_record.get("status", "unknown")
|
|
288
|
+
duration = exec_record.get("duration_ms")
|
|
289
|
+
started_at = exec_record.get("started_at")
|
|
290
|
+
parent_uid = exec_record.get("parent_uid")
|
|
291
|
+
|
|
292
|
+
# Format timestamp
|
|
293
|
+
ts_str = ""
|
|
294
|
+
if started_at is not None:
|
|
295
|
+
dt = datetime.fromtimestamp(started_at, tz=UTC)
|
|
296
|
+
ts_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
297
|
+
|
|
298
|
+
# Format duration
|
|
299
|
+
dur_str = ""
|
|
300
|
+
if duration is not None:
|
|
301
|
+
if duration < 1000:
|
|
302
|
+
dur_str = f"{duration:.0f}ms"
|
|
303
|
+
else:
|
|
304
|
+
dur_str = f"{duration / 1000:.1f}s"
|
|
305
|
+
|
|
306
|
+
# Build the line
|
|
307
|
+
parts = [f" - {job_name}"]
|
|
308
|
+
parts.append(f"[{status}]")
|
|
309
|
+
if dur_str:
|
|
310
|
+
parts.append(f"({dur_str})")
|
|
311
|
+
if ts_str:
|
|
312
|
+
parts.append(f"at {ts_str}")
|
|
313
|
+
if parent_uid:
|
|
314
|
+
parts.append("(nested)")
|
|
315
|
+
|
|
316
|
+
lines.append(" ".join(parts))
|
|
317
|
+
|
|
318
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: functualize-state-sqlite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQLite-backed state persistence and execution tracking plugin for functualize
|
|
5
|
+
Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: functualize-state<1.0.0,>=0.1.0
|
|
15
|
+
Requires-Dist: functualize<1.0.0,>=0.1.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: hypothesis>=6.82.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=7.4.0; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# functualize-state-sqlite
|
|
23
|
+
|
|
24
|
+
> **Status: Published** — Independently installable from PyPI.
|
|
25
|
+
|
|
26
|
+
SQLite-backed state persistence and execution tracking plugin for functualize. Implements the `StateBackend` and `ExecutionStore` protocols from `functualize-state`, providing durable key-value storage and full execution history using a local SQLite database in WAL mode. Zero external dependencies beyond the Python standard library's `sqlite3` module.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install functualize-state-sqlite
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from functualize_state_sqlite import SQLiteStateBackend
|
|
38
|
+
|
|
39
|
+
# Use as a context manager for automatic cleanup
|
|
40
|
+
with SQLiteStateBackend(db_path="my_app.db") as backend:
|
|
41
|
+
backend.set("deploy_count", 42)
|
|
42
|
+
backend.set("last_env", "staging")
|
|
43
|
+
|
|
44
|
+
count = backend.get("deploy_count")
|
|
45
|
+
print(f"Deployments: {count}")
|
|
46
|
+
|
|
47
|
+
keys = backend.keys(prefix="deploy")
|
|
48
|
+
print(f"Keys: {keys}")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Persistent key-value state** — JSON-encoded values stored in SQLite with automatic schema initialization
|
|
54
|
+
- **WAL mode for concurrency** — concurrent read access without blocking writes, suitable for multi-process environments
|
|
55
|
+
- **Execution tracking** — full session and execution history with nested invocation support and phase recording
|
|
56
|
+
- **Namespace-scoped state** — `SQLiteStateStore` provides per-job namespace isolation within workflow scopes
|
|
57
|
+
- **Context manager support** — all backends support `with` statements for automatic connection cleanup
|
|
58
|
+
- **Zero external dependencies** — uses only Python's built-in `sqlite3` module
|
|
59
|
+
- **Automatic schema migrations** — database schema is created and migrated transparently on first access
|
|
60
|
+
|
|
61
|
+
## API Reference
|
|
62
|
+
|
|
63
|
+
Public classes exported by this plugin:
|
|
64
|
+
|
|
65
|
+
- `SQLiteStateBackend` — Implements the `StateBackend` protocol with `get()`, `set()`, `delete()`, and `keys()` methods for persistent key-value storage using a dedicated `kv_state` table.
|
|
66
|
+
- `SQLiteExecutionStore` — Implements the `ExecutionStore` protocol for recording and querying execution records and phase tracking. Methods include `insert_execution()`, `update_execution()`, `get_session_executions()`, `insert_phase()`, and `get_execution_phases()`.
|
|
67
|
+
- `SQLiteStatePlugin` — Plugin that registers `SQLiteStateBackend` and `SQLiteExecutionStore` with the DI registry at boot time. Hooks into `APP_READY` and `ON_SCOPE_CREATED` lifecycle events.
|
|
68
|
+
- `ExecutionStatePlugin` — Full lifecycle plugin that tracks job executions automatically. Hooks into `BEFORE_JOB`, `AFTER_SUCCESS`, `AFTER_FAILURE`, `INVOKE_START`, `INVOKE_END`, and `ON_SCOPE_CREATED` for comprehensive execution history.
|
|
69
|
+
|
|
70
|
+
Internal classes (available via direct import but not part of the public protocol surface):
|
|
71
|
+
|
|
72
|
+
- `SQLiteBackend` — Low-level connection manager with WAL mode, schema initialization, and query helpers for sessions, executions, steps, and namespaced state.
|
|
73
|
+
- `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()`, `clear()`, and cross-job access via `get_job_state()`.
|
|
74
|
+
- `ExecutionTracker` — High-level session management and execution recording with automatic session resume based on TTL, and AI context summary generation via `to_ai_context()`.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
Run plugin tests:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run pytest plugins/functualize-state-sqlite/tests/ -v
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Build the package:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv build --package functualize-state-sqlite
|
|
88
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
functualize_state_sqlite/__init__.py,sha256=JvtetjkGWcYOUvqgdEOy1Z8fQOvSKttdwXqq6kUtEvI,487
|
|
2
|
+
functualize_state_sqlite/_backend.py,sha256=U_AKJ-ZD-PyF1z-8o8aplSduXYS0VoUF32q_Z8jOW8Y,5031
|
|
3
|
+
functualize_state_sqlite/_execution_store.py,sha256=7ndSMD6bGAq_W1QpQo3NRsukl3NVGNHTe-bgUBXhY_Q,12360
|
|
4
|
+
functualize_state_sqlite/_migrations.py,sha256=dyZ8ghoMeDKdD_ea96mOCJGAXfWsVxM9KA628V_AsRk,4819
|
|
5
|
+
functualize_state_sqlite/_plugin.py,sha256=SDbbBNahw0tZ52jDWqdD5bCx1hdDHIB4gb2B1Tp0IJg,8272
|
|
6
|
+
functualize_state_sqlite/plugin.py,sha256=e2H5SE1fSI-2sNlKJc70hhA1nReVHTrtoMwj-8gr7sM,12960
|
|
7
|
+
functualize_state_sqlite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
functualize_state_sqlite/sqlite_backend.py,sha256=URKoE8U8H3uV3-uD4kwXn0K9kiev1bDYMV2NR6QehTc,22535
|
|
9
|
+
functualize_state_sqlite/state_store.py,sha256=NvtVkhKHOSwIgbsKG9PWXlqfU1SAb6NTGhVE2HanD5g,5804
|
|
10
|
+
functualize_state_sqlite/tracker.py,sha256=qntfLgfBO5PvEuN-CG34bUEFzWkMHQh4T3OHtNakGeQ,10712
|
|
11
|
+
functualize_state_sqlite-0.1.0.dist-info/METADATA,sha256=FvNa5SXkVTqpIaPQYvR0zMgMESKUqIJ1yuq77NyUIok,4330
|
|
12
|
+
functualize_state_sqlite-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
functualize_state_sqlite-0.1.0.dist-info/entry_points.txt,sha256=hSbeMehidZ8rCyB11aj_ZsE7s6-L9TcEhRHYzKO0Bm0,82
|
|
14
|
+
functualize_state_sqlite-0.1.0.dist-info/RECORD,,
|