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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [functualize.state_providers]
2
+ sqlite = functualize_state_sqlite:SQLiteStatePlugin