mundane-sdk 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Bellamy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: mundane-sdk
3
+ Version: 0.0.2
4
+ Summary: Tiny durable-execution: one workflow run is one SQLite file.
5
+ Author: Paul Bellamy
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/paulbellamy/mundane
8
+ Project-URL: Repository, https://github.com/paulbellamy/mundane
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # mundane (Python)
15
+
16
+ See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
17
+
18
+ ```python
19
+ import mundane
20
+
21
+ def workflow(ctx):
22
+ user = ctx.step("fetch", lambda: {"name": "alice"})
23
+ ctx.sleep("cool-off", "100ms")
24
+ ctx.step("notify", lambda: f"hi {user['name']}")
25
+
26
+ mundane.run("task.db", workflow)
27
+ ```
28
+
29
+ An async variant is available: `await mundane.arun(path, async_workflow)`
30
+ with `await ctx.astep(...)` / `await ctx.asleep(...)`.
31
+
32
+ ## Implementation notes
33
+
34
+ - **Stdlib only.** No third-party dependencies — `sqlite3`, `fcntl`
35
+ (for `flock`), `json`, and `uuid` from the standard library.
@@ -0,0 +1,22 @@
1
+ # mundane (Python)
2
+
3
+ See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
4
+
5
+ ```python
6
+ import mundane
7
+
8
+ def workflow(ctx):
9
+ user = ctx.step("fetch", lambda: {"name": "alice"})
10
+ ctx.sleep("cool-off", "100ms")
11
+ ctx.step("notify", lambda: f"hi {user['name']}")
12
+
13
+ mundane.run("task.db", workflow)
14
+ ```
15
+
16
+ An async variant is available: `await mundane.arun(path, async_workflow)`
17
+ with `await ctx.astep(...)` / `await ctx.asleep(...)`.
18
+
19
+ ## Implementation notes
20
+
21
+ - **Stdlib only.** No third-party dependencies — `sqlite3`, `fcntl`
22
+ (for `flock`), `json`, and `uuid` from the standard library.
@@ -0,0 +1,26 @@
1
+ """mundane — tiny durable-execution library.
2
+
3
+ One workflow run is one SQLite file. Crash, re-invoke, resume.
4
+ """
5
+
6
+ from .core import (
7
+ DuplicateStepError,
8
+ LockedError,
9
+ SchemaError,
10
+ SerializationError,
11
+ StepFailedError,
12
+ arun,
13
+ run,
14
+ )
15
+
16
+ __all__ = [
17
+ "run",
18
+ "arun",
19
+ "LockedError",
20
+ "SerializationError",
21
+ "SchemaError",
22
+ "StepFailedError",
23
+ "DuplicateStepError",
24
+ ]
25
+
26
+ __version__ = "0.0.2"
@@ -0,0 +1,38 @@
1
+ """Parse human-friendly duration strings.
2
+
3
+ Accepts strings like "30s", "5m", "2h", "1d", "500ms", or ints/floats in
4
+ seconds (matching neither TS nor JS conventions strictly — the TS API
5
+ documents number-of-milliseconds, the Python API documents number-of-seconds).
6
+ For consistency with the TS interface, we accept both: strings and numbers
7
+ where numbers are treated as milliseconds when called from the public API
8
+ (see core.py).
9
+
10
+ This module only parses strings and returns milliseconds.
11
+ """
12
+
13
+ import re
14
+
15
+ _UNIT_MS = {
16
+ "ms": 1,
17
+ "s": 1000,
18
+ "m": 60 * 1000,
19
+ "h": 60 * 60 * 1000,
20
+ "d": 24 * 60 * 60 * 1000,
21
+ }
22
+
23
+ # Matches: "5m", "1h", "2.5s", "500ms". Allow integer or decimal magnitudes.
24
+ _RE = re.compile(r"^\s*(\d+(?:\.\d+)?)(ms|s|m|h|d)\s*$", re.IGNORECASE)
25
+
26
+
27
+ def parse_duration_ms(s: str) -> int:
28
+ """Parse a duration string into integer milliseconds."""
29
+ if not isinstance(s, str):
30
+ raise TypeError(f"duration must be a string, got {type(s).__name__}")
31
+ m = _RE.match(s)
32
+ if not m:
33
+ raise ValueError(
34
+ f"invalid duration {s!r}: expected e.g. '500ms', '30s', '5m', '2h', '1d'"
35
+ )
36
+ magnitude = float(m.group(1))
37
+ unit = m.group(2).lower()
38
+ return int(round(magnitude * _UNIT_MS[unit]))
@@ -0,0 +1,48 @@
1
+ """Exclusive file locking via flock(2).
2
+
3
+ Per spec section 3: the runtime acquires flock(LOCK_EX | LOCK_NB) on the
4
+ SQLite file's fd. On failure, fail-fast with exit code 75 / LockedError.
5
+
6
+ Note: flock(2) and POSIX record locks (used internally by SQLite) live in
7
+ separate lock-spaces on Linux, so an flock on the DB file does not interfere
8
+ with SQLite's own locking.
9
+ """
10
+
11
+ import contextlib
12
+ import fcntl
13
+ import os
14
+ from typing import Optional
15
+
16
+
17
+ class FileLock:
18
+ """An exclusive non-blocking flock held for the lifetime of the object."""
19
+
20
+ def __init__(self, path: str):
21
+ self.path = path
22
+ self._fd: Optional[int] = None
23
+
24
+ def acquire(self) -> None:
25
+ # Open for read+write, creating if missing. SQLite will also open it
26
+ # later; that's fine.
27
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644)
28
+ try:
29
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
30
+ except BlockingIOError:
31
+ os.close(fd)
32
+ raise
33
+ self._fd = fd
34
+
35
+ def release(self) -> None:
36
+ if self._fd is not None:
37
+ with contextlib.suppress(OSError):
38
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
39
+ with contextlib.suppress(OSError):
40
+ os.close(self._fd)
41
+ self._fd = None
42
+
43
+ def __enter__(self) -> "FileLock":
44
+ self.acquire()
45
+ return self
46
+
47
+ def __exit__(self, exc_type, exc, tb) -> None:
48
+ self.release()
@@ -0,0 +1,44 @@
1
+ """Schema definition shared across mundane runtimes.
2
+
3
+ The schema is pinned to v1. Opening a file whose meta.schema_version is
4
+ not '1' is a hard error.
5
+ """
6
+
7
+ import re
8
+
9
+ SCHEMA_VERSION = "1"
10
+
11
+ # Name regex per spec section 5: ^[A-Za-z0-9][A-Za-z0-9._-]*$
12
+ NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]*$")
13
+
14
+
15
+ BOOTSTRAP_SQL = """
16
+ PRAGMA journal_mode = DELETE;
17
+
18
+ CREATE TABLE IF NOT EXISTS mundane_meta (
19
+ key TEXT PRIMARY KEY,
20
+ value TEXT NOT NULL
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS mundane_steps (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ name TEXT NOT NULL,
26
+ kind TEXT NOT NULL,
27
+ encoding TEXT NOT NULL,
28
+ result BLOB,
29
+ status TEXT NOT NULL,
30
+ error TEXT,
31
+ started_at TEXT NOT NULL,
32
+ finished_at TEXT,
33
+ UNIQUE(name)
34
+ );
35
+
36
+ CREATE INDEX IF NOT EXISTS mundane_steps_status ON mundane_steps(status);
37
+ """
38
+
39
+
40
+ def validate_name(name: str) -> None:
41
+ if not isinstance(name, str) or not NAME_RE.match(name):
42
+ raise ValueError(
43
+ f"invalid step name {name!r}: must match {NAME_RE.pattern}"
44
+ )
@@ -0,0 +1,437 @@
1
+ """Core implementation: run, ctx.step, ctx.sleep."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sqlite3
8
+ import time
9
+ import urllib.parse
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Awaitable, Callable, Dict, Optional, Set, Union
14
+
15
+ from ._duration import parse_duration_ms
16
+ from ._lock import FileLock
17
+ from ._schema import BOOTSTRAP_SQL, SCHEMA_VERSION, validate_name
18
+
19
+
20
+ class LockedError(Exception):
21
+ """Raised when the SQLite file is locked by another live process."""
22
+
23
+
24
+ class SerializationError(Exception):
25
+ """Raised when a step's return value doesn't survive a JSON round-trip."""
26
+
27
+
28
+ class SchemaError(Exception):
29
+ """Raised when meta.schema_version doesn't equal 1."""
30
+
31
+
32
+ class DuplicateStepError(Exception):
33
+ """Raised when the same step name is used twice in one task body."""
34
+
35
+ def __init__(self, name: str):
36
+ super().__init__(f"duplicate step name: {name}")
37
+ self.name = name
38
+
39
+
40
+ class StepFailedError(Exception):
41
+ """Raised when a step function raises. Wraps the underlying error."""
42
+
43
+ def __init__(self, name: str, original: BaseException):
44
+ super().__init__(f"step {name!r} failed: {original!r}")
45
+ self.name = name
46
+ self.original = original
47
+
48
+
49
+ def _iso_now() -> str:
50
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
51
+
52
+
53
+ def _now_ms() -> int:
54
+ return int(time.time() * 1000)
55
+
56
+
57
+ def _duration_to_ms(duration: Union[str, int, float]) -> int:
58
+ if isinstance(duration, str):
59
+ return parse_duration_ms(duration)
60
+ if isinstance(duration, (int, float)):
61
+ return int(duration)
62
+ raise TypeError(f"duration must be str or number, got {type(duration).__name__}")
63
+
64
+
65
+ def _check_json_roundtrip(value: Any) -> str:
66
+ """Validate that value survives JSON.dumps -> JSON.loads -> deep equal.
67
+
68
+ Per spec section 7: catches Date, undefined, BigInt, Map, Set, functions,
69
+ and circular refs. Returns the JSON text on success.
70
+ """
71
+ try:
72
+ text = json.dumps(value, allow_nan=False)
73
+ except (TypeError, ValueError) as e:
74
+ raise SerializationError(str(e)) from None
75
+
76
+ decoded = json.loads(text)
77
+ if not _deep_equal(value, decoded):
78
+ raise SerializationError(
79
+ "value does not round-trip through JSON "
80
+ "(possibly contains tuples, sets, dates, or other non-JSON types)"
81
+ )
82
+ return text
83
+
84
+
85
+ def _deep_equal(a: Any, b: Any) -> bool:
86
+ """Strict structural equality across JSON-shaped values."""
87
+ if type(a) is not type(b):
88
+ # Allow int/float boundary if numerically equal? No — strict, since
89
+ # JSON.dumps preserves int vs float in Python (json emits 1 vs 1.0).
90
+ return False
91
+ if isinstance(a, dict):
92
+ if a.keys() != b.keys():
93
+ return False
94
+ return all(_deep_equal(a[k], b[k]) for k in a)
95
+ if isinstance(a, list):
96
+ if len(a) != len(b):
97
+ return False
98
+ return all(_deep_equal(x, y) for x, y in zip(a, b))
99
+ return a == b
100
+
101
+
102
+ @dataclass
103
+ class _StepRow:
104
+ id: int
105
+ name: str
106
+ kind: str
107
+ encoding: str
108
+ result: Optional[bytes]
109
+ status: str
110
+ error: Optional[str]
111
+
112
+
113
+ def _decode_result(row: _StepRow) -> Any:
114
+ if row.status != "done":
115
+ raise RuntimeError(
116
+ f"internal: step {row.name!r} not done (status={row.status})"
117
+ )
118
+ enc = row.encoding
119
+ raw = row.result
120
+ if raw is None:
121
+ return None
122
+ # v1.1: only json (structured + sleep wake-times) and bytes (raw payloads).
123
+ if enc == "json":
124
+ text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
125
+ return json.loads(text)
126
+ if enc == "bytes":
127
+ return raw if isinstance(raw, bytes) else raw.encode("utf-8")
128
+ raise RuntimeError(f"unknown encoding: {enc!r}")
129
+
130
+
131
+ class _Task:
132
+ """Per-invocation task state: open connection, cache, seen-name set."""
133
+
134
+ def __init__(self, conn: sqlite3.Connection):
135
+ self.conn = conn
136
+ # cache: name -> _StepRow
137
+ self.cache: Dict[str, _StepRow] = {}
138
+ # Per-run set of step names already used; duplicates raise.
139
+ self.seen: Set[str] = set()
140
+ self._load_cache()
141
+
142
+ def _load_cache(self) -> None:
143
+ cur = self.conn.execute(
144
+ "SELECT id, name, kind, encoding, result, status, error "
145
+ "FROM mundane_steps ORDER BY id"
146
+ )
147
+ for row in cur:
148
+ sr = _StepRow(
149
+ id=row[0],
150
+ name=row[1],
151
+ kind=row[2],
152
+ encoding=row[3],
153
+ result=row[4],
154
+ status=row[5],
155
+ error=row[6],
156
+ )
157
+ self.cache[sr.name] = sr
158
+
159
+ def _check_seen(self, name: str) -> None:
160
+ if name in self.seen:
161
+ raise DuplicateStepError(name)
162
+ self.seen.add(name)
163
+
164
+ def _ensure_pending_row(self, name: str, kind: str, encoding: str) -> _StepRow:
165
+ """Insert a pending row if not present; return the (possibly fresh) row.
166
+
167
+ A leftover pending/failed row (never 'done' on this path) is reset to
168
+ pending so the on-disk state reflects the retry, not a stale failure.
169
+ """
170
+ existing = self.cache.get(name)
171
+ if existing is not None:
172
+ self.conn.execute(
173
+ "UPDATE mundane_steps "
174
+ "SET kind=?, encoding=?, status='pending', result=NULL, error=NULL, finished_at=NULL "
175
+ "WHERE name=?",
176
+ (kind, encoding, name),
177
+ )
178
+ self.conn.commit()
179
+ existing.kind = kind
180
+ existing.encoding = encoding
181
+ existing.status = "pending"
182
+ existing.result = None
183
+ existing.error = None
184
+ return existing
185
+ now = _iso_now()
186
+ self.conn.execute(
187
+ "INSERT INTO mundane_steps "
188
+ "(name, kind, encoding, result, status, started_at) "
189
+ "VALUES (?, ?, ?, NULL, 'pending', ?)",
190
+ (name, kind, encoding, now),
191
+ )
192
+ self.conn.commit()
193
+ # Re-load row to get id
194
+ cur = self.conn.execute(
195
+ "SELECT id, name, kind, encoding, result, status, error "
196
+ "FROM mundane_steps WHERE name = ?",
197
+ (name,),
198
+ )
199
+ row = cur.fetchone()
200
+ sr = _StepRow(
201
+ id=row[0], name=row[1], kind=row[2], encoding=row[3],
202
+ result=row[4], status=row[5], error=row[6],
203
+ )
204
+ self.cache[name] = sr
205
+ return sr
206
+
207
+ def _commit_done(self, name: str, encoding: str, result: Any) -> None:
208
+ """Mark a step done with the given (already-encoded) result bytes/text."""
209
+ finished = _iso_now()
210
+ self.conn.execute(
211
+ "UPDATE mundane_steps "
212
+ "SET status = 'done', encoding = ?, result = ?, finished_at = ?, error = NULL "
213
+ "WHERE name = ?",
214
+ (encoding, result, finished, name),
215
+ )
216
+ self.conn.commit()
217
+ # Refresh cache
218
+ row = self.cache[name]
219
+ row.status = "done"
220
+ row.encoding = encoding
221
+ row.result = result if isinstance(result, (bytes, bytearray)) else (
222
+ result.encode("utf-8") if isinstance(result, str) else result
223
+ )
224
+
225
+ def _commit_failed(self, name: str, error: str) -> None:
226
+ finished = _iso_now()
227
+ self.conn.execute(
228
+ "UPDATE mundane_steps "
229
+ "SET status = 'failed', error = ?, finished_at = ? "
230
+ "WHERE name = ?",
231
+ (error, finished, name),
232
+ )
233
+ self.conn.commit()
234
+ row = self.cache.get(name)
235
+ if row is not None:
236
+ row.status = "failed"
237
+ row.error = error
238
+
239
+
240
+ class Context:
241
+ """The object passed to a workflow body. Provides step() and sleep()."""
242
+
243
+ def __init__(self, task: _Task):
244
+ self._task = task
245
+
246
+ def step(self, name: str, fn: Callable[[], Any]) -> Any:
247
+ validate_name(name)
248
+ self._task._check_seen(name)
249
+ resolved = name
250
+ row = self._task.cache.get(resolved)
251
+ if row is not None and row.status == "done":
252
+ return _decode_result(row)
253
+ # pending or absent: ensure row, run fn, commit
254
+ self._task._ensure_pending_row(resolved, "step", "json")
255
+ try:
256
+ value = fn()
257
+ except Exception as e:
258
+ self._task._commit_failed(resolved, repr(e))
259
+ raise StepFailedError(resolved, e) from e
260
+ text = _check_json_roundtrip(value)
261
+ self._task._commit_done(resolved, "json", text)
262
+ # Return the round-tripped value so first run and resume agree exactly.
263
+ return json.loads(text)
264
+
265
+ def sleep(self, name: str, duration: Union[str, int, float]) -> None:
266
+ validate_name(name)
267
+ self._task._check_seen(name)
268
+ resolved = name
269
+ row = self._task.cache.get(resolved)
270
+ if row is not None and row.status == "done":
271
+ # Resume: duration arg is ignored (SPEC §6); don't parse it.
272
+ wake_at = _decode_result(row)
273
+ self._sleep_remaining(int(wake_at))
274
+ return
275
+ # absent / pending: compute wake_at, write json-number row, sleep.
276
+ wake_at = _now_ms() + _duration_to_ms(duration)
277
+ self._task._ensure_pending_row(resolved, "sleep", "json")
278
+ self._task._commit_done(resolved, "json", str(wake_at))
279
+ self._sleep_remaining(wake_at)
280
+
281
+ def _sleep_remaining(self, wake_at_ms: int) -> None:
282
+ now = _now_ms()
283
+ remaining = wake_at_ms - now
284
+ if remaining > 0:
285
+ time.sleep(remaining / 1000.0)
286
+
287
+ # ---- async variants ----
288
+
289
+ async def astep(self, name: str, fn: Callable[[], Awaitable[Any]]) -> Any:
290
+ validate_name(name)
291
+ self._task._check_seen(name)
292
+ resolved = name
293
+ row = self._task.cache.get(resolved)
294
+ if row is not None and row.status == "done":
295
+ return _decode_result(row)
296
+ self._task._ensure_pending_row(resolved, "step", "json")
297
+ try:
298
+ value = await fn()
299
+ except Exception as e:
300
+ self._task._commit_failed(resolved, repr(e))
301
+ raise StepFailedError(resolved, e) from e
302
+ text = _check_json_roundtrip(value)
303
+ self._task._commit_done(resolved, "json", text)
304
+ # Return the round-tripped value so first run and resume agree exactly.
305
+ return json.loads(text)
306
+
307
+ async def asleep(self, name: str, duration: Union[str, int, float]) -> None:
308
+ validate_name(name)
309
+ self._task._check_seen(name)
310
+ resolved = name
311
+ row = self._task.cache.get(resolved)
312
+ if row is not None and row.status == "done":
313
+ # Resume: duration arg is ignored (SPEC §6); don't parse it.
314
+ wake_at = int(_decode_result(row))
315
+ else:
316
+ wake_at = _now_ms() + _duration_to_ms(duration)
317
+ self._task._ensure_pending_row(resolved, "sleep", "json")
318
+ self._task._commit_done(resolved, "json", str(wake_at))
319
+ remaining = wake_at - _now_ms()
320
+ if remaining > 0:
321
+ await asyncio.sleep(remaining / 1000.0)
322
+
323
+
324
+ def _open_task(path: str) -> tuple[FileLock, sqlite3.Connection, _Task]:
325
+ """Acquire lock, open DB, bootstrap schema, build cache. Caller owns cleanup."""
326
+ lock = FileLock(path)
327
+ try:
328
+ lock.acquire()
329
+ except BlockingIOError:
330
+ raise LockedError(
331
+ f"{path}: locked by another process"
332
+ ) from None
333
+
334
+ conn: Optional[sqlite3.Connection] = None
335
+ try:
336
+ # vfs=unix-none disables SQLite's own file locking. Mundane already
337
+ # holds an exclusive flock(2) on the file for the whole run, so
338
+ # SQLite's internal locking is redundant — and on macOS, where
339
+ # SQLite's POSIX locks share state with our flock on the same vnode,
340
+ # leaving it on would deadlock against ourselves ("database is
341
+ # locked"). The flock above is the sole writer-lock authority.
342
+ # quote() with safe="/" preserves path separators but escapes ? & %
343
+ # so paths with those characters don't break URI parsing.
344
+ encoded = urllib.parse.quote(path, safe="/")
345
+ conn = sqlite3.connect(
346
+ f"file:{encoded}?vfs=unix-none", uri=True, isolation_level=None
347
+ )
348
+ conn.execute("PRAGMA journal_mode = DELETE")
349
+
350
+ # Pre-check: if mundane_meta already exists with a non-1 schema_version,
351
+ # bail before running CREATE INDEX (which references columns we don't
352
+ # promise on other schema versions).
353
+ existing = conn.execute(
354
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='mundane_meta'"
355
+ ).fetchone()
356
+ if existing:
357
+ row = conn.execute(
358
+ "SELECT value FROM mundane_meta WHERE key='schema_version'"
359
+ ).fetchone()
360
+ if row and row[0] != SCHEMA_VERSION:
361
+ raise SchemaError(
362
+ f"{path}: schema_version is {row[0]!r}, expected {SCHEMA_VERSION!r}"
363
+ )
364
+
365
+ # Bootstrap inside an IMMEDIATE transaction (idempotent).
366
+ conn.execute("BEGIN IMMEDIATE")
367
+ try:
368
+ statements = [s.strip() for s in BOOTSTRAP_SQL.split(";") if s.strip()]
369
+ for stmt in statements:
370
+ conn.execute(stmt)
371
+ now_iso = _iso_now()
372
+ new_uuid = str(uuid.uuid4())
373
+ conn.execute(
374
+ "INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('schema_version', ?)",
375
+ (SCHEMA_VERSION,),
376
+ )
377
+ conn.execute(
378
+ "INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('task_id', ?)",
379
+ (new_uuid,),
380
+ )
381
+ conn.execute(
382
+ "INSERT OR IGNORE INTO mundane_meta (key, value) VALUES ('created_at', ?)",
383
+ (now_iso,),
384
+ )
385
+ conn.execute("COMMIT")
386
+ except Exception:
387
+ conn.execute("ROLLBACK")
388
+ raise
389
+
390
+ # Final schema-version check (covers the fresh-bootstrap path).
391
+ row = conn.execute(
392
+ "SELECT value FROM mundane_meta WHERE key = 'schema_version'"
393
+ ).fetchone()
394
+ if not row or row[0] != SCHEMA_VERSION:
395
+ raise SchemaError(
396
+ f"{path}: schema_version is {row[0] if row else None!r}, expected {SCHEMA_VERSION!r}"
397
+ )
398
+
399
+ task = _Task(conn)
400
+ return lock, conn, task
401
+ except Exception:
402
+ if conn is not None:
403
+ conn.close()
404
+ lock.release()
405
+ raise
406
+
407
+
408
+ def run(path: str, fn: Callable[[Context], Any]) -> Any:
409
+ """Run a workflow body against the SQLite file at `path`.
410
+
411
+ On cache miss, fn-passed step closures execute; on cache hit they return
412
+ the cached value without re-executing. Returns fn's return value.
413
+
414
+ Raises LockedError if another process holds the file lock.
415
+ """
416
+ lock, conn, task = _open_task(path)
417
+ try:
418
+ ctx = Context(task)
419
+ return fn(ctx)
420
+ finally:
421
+ try:
422
+ conn.close()
423
+ finally:
424
+ lock.release()
425
+
426
+
427
+ async def arun(path: str, fn: Callable[[Context], Awaitable[Any]]) -> Any:
428
+ """Async variant of run(). The body can use ctx.astep / ctx.asleep."""
429
+ lock, conn, task = _open_task(path)
430
+ try:
431
+ ctx = Context(task)
432
+ return await fn(ctx)
433
+ finally:
434
+ try:
435
+ conn.close()
436
+ finally:
437
+ lock.release()
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: mundane-sdk
3
+ Version: 0.0.2
4
+ Summary: Tiny durable-execution: one workflow run is one SQLite file.
5
+ Author: Paul Bellamy
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/paulbellamy/mundane
8
+ Project-URL: Repository, https://github.com/paulbellamy/mundane
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # mundane (Python)
15
+
16
+ See [`../SPEC.md`](../SPEC.md) for the cross-runtime contract.
17
+
18
+ ```python
19
+ import mundane
20
+
21
+ def workflow(ctx):
22
+ user = ctx.step("fetch", lambda: {"name": "alice"})
23
+ ctx.sleep("cool-off", "100ms")
24
+ ctx.step("notify", lambda: f"hi {user['name']}")
25
+
26
+ mundane.run("task.db", workflow)
27
+ ```
28
+
29
+ An async variant is available: `await mundane.arun(path, async_workflow)`
30
+ with `await ctx.astep(...)` / `await ctx.asleep(...)`.
31
+
32
+ ## Implementation notes
33
+
34
+ - **Stdlib only.** No third-party dependencies — `sqlite3`, `fcntl`
35
+ (for `flock`), `json`, and `uuid` from the standard library.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ mundane/__init__.py
5
+ mundane/_duration.py
6
+ mundane/_lock.py
7
+ mundane/_schema.py
8
+ mundane/core.py
9
+ mundane_sdk.egg-info/PKG-INFO
10
+ mundane_sdk.egg-info/SOURCES.txt
11
+ mundane_sdk.egg-info/dependency_links.txt
12
+ mundane_sdk.egg-info/top_level.txt
13
+ tests/test_basic.py
@@ -0,0 +1 @@
1
+ mundane
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mundane-sdk"
7
+ version = "0.0.2"
8
+ description = "Tiny durable-execution: one workflow run is one SQLite file."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Paul Bellamy" }]
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/paulbellamy/mundane"
18
+ Repository = "https://github.com/paulbellamy/mundane"
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["mundane*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,303 @@
1
+ """Basic tests for mundane (Python)."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import multiprocessing as mp
7
+ import os
8
+ import sqlite3
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import unittest
13
+
14
+ # Make the package importable without installation.
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ import mundane
18
+
19
+
20
+ def _hold_lock_child(path, barrier, done):
21
+ # Module-scope helper for test_second_process_gets_locked_error.
22
+ # Must be picklable for multiprocessing's spawn start method (default on
23
+ # macOS), so it can't be a local closure inside the test method.
24
+ def wf(ctx):
25
+ ctx.step("a", lambda: 1)
26
+ barrier.set()
27
+ done.wait(timeout=5)
28
+ return 0
29
+
30
+ mundane.run(path, wf)
31
+
32
+
33
+ def _names(path):
34
+ """Step names in id order, read straight from SQLite (no inspect API)."""
35
+ conn = sqlite3.connect(path)
36
+ try:
37
+ return [r[0] for r in conn.execute(
38
+ "SELECT name FROM mundane_steps ORDER BY id"
39
+ )]
40
+ finally:
41
+ conn.close()
42
+
43
+
44
+ class TempDB:
45
+ def __enter__(self):
46
+ self.fd, self.path = tempfile.mkstemp(suffix=".db")
47
+ os.close(self.fd)
48
+ os.remove(self.path) # mundane will recreate
49
+ return self.path
50
+
51
+ def __exit__(self, *_):
52
+ for ext in ("", "-wal", "-shm", ".lock"):
53
+ with contextlib.suppress(FileNotFoundError):
54
+ os.remove(self.path + ext)
55
+
56
+
57
+ class HappyPath(unittest.TestCase):
58
+ def test_three_steps_run_once(self):
59
+ calls = []
60
+
61
+ def wf(ctx):
62
+ a = ctx.step("a", lambda: (calls.append("a"), 1)[1])
63
+ b = ctx.step("b", lambda: (calls.append("b"), {"v": a + 1})[1])
64
+ return b
65
+
66
+ with TempDB() as path:
67
+ r = mundane.run(path, wf)
68
+ self.assertEqual(r, {"v": 2})
69
+ self.assertEqual(calls, ["a", "b"])
70
+
71
+ # Re-run: should return same value without re-calling
72
+ r2 = mundane.run(path, wf)
73
+ self.assertEqual(r2, {"v": 2})
74
+ self.assertEqual(calls, ["a", "b"]) # not re-called
75
+
76
+ def test_resume_after_crash(self):
77
+ """Simulate crash by raising after step 1; verify step 1 is cached."""
78
+ with TempDB() as path:
79
+ # First run: succeed step a, raise before step b commits
80
+ def wf1(ctx):
81
+ ctx.step("a", lambda: 42)
82
+ raise RuntimeError("simulated crash")
83
+
84
+ with self.assertRaises(RuntimeError):
85
+ mundane.run(path, wf1)
86
+
87
+ # Second run: step a returns 42 from cache; step b runs and finishes.
88
+ calls = []
89
+
90
+ def wf2(ctx):
91
+ v = ctx.step("a", lambda: (calls.append("a"), 999)[1])
92
+ w = ctx.step("b", lambda: (calls.append("b"), v + 1)[1])
93
+ return w
94
+
95
+ r = mundane.run(path, wf2)
96
+ self.assertEqual(r, 43)
97
+ self.assertEqual(calls, ["b"]) # only b was called; a came from cache
98
+
99
+
100
+ class Naming(unittest.TestCase):
101
+ def test_invalid_name_rejected(self):
102
+ with TempDB() as path:
103
+ def wf(ctx):
104
+ ctx.step("bad name with space", lambda: 1)
105
+ with self.assertRaises(ValueError):
106
+ mundane.run(path, wf)
107
+
108
+ def test_duplicate_name_raises(self):
109
+ with TempDB() as path:
110
+ def wf(ctx):
111
+ ctx.step("x", lambda: 1)
112
+ ctx.step("x", lambda: 2) # duplicate -> raises
113
+ with self.assertRaises(mundane.DuplicateStepError):
114
+ mundane.run(path, wf)
115
+ # First step still committed before the duplicate was raised.
116
+ self.assertEqual(_names(path), ["x"])
117
+
118
+
119
+ class Locking(unittest.TestCase):
120
+ def test_second_process_gets_locked_error(self):
121
+ with TempDB() as path:
122
+ barrier = mp.Event()
123
+ done = mp.Event()
124
+
125
+ proc = mp.Process(target=_hold_lock_child, args=(path, barrier, done))
126
+ proc.start()
127
+ try:
128
+ barrier.wait(timeout=5)
129
+ # Try to open while the other process holds the lock.
130
+ with self.assertRaises(mundane.LockedError):
131
+ mundane.run(path, lambda ctx: None)
132
+ finally:
133
+ done.set()
134
+ proc.join(timeout=5)
135
+
136
+
137
+ class Sleep(unittest.TestCase):
138
+ def test_sleep_persists_wake_at_and_resumes(self):
139
+ with TempDB() as path:
140
+ # First "run": write the sleep row but interrupt before sleeping
141
+ # by using a 0ms duration.
142
+ t0 = time.time()
143
+ mundane.run(path, lambda ctx: ctx.sleep("nap", "10ms"))
144
+ elapsed1 = time.time() - t0
145
+ self.assertLess(elapsed1, 0.2)
146
+
147
+ # Second run: nap row already done; should return immediately.
148
+ t0 = time.time()
149
+ mundane.run(path, lambda ctx: ctx.sleep("nap", "10s"))
150
+ elapsed2 = time.time() - t0
151
+ self.assertLess(elapsed2, 0.2)
152
+
153
+ def test_resume_ignores_invalid_duration(self):
154
+ with TempDB() as path:
155
+ mundane.run(path, lambda ctx: ctx.sleep("n", "1ms"))
156
+ # Resume ignores the duration arg, so an invalid string is a no-op.
157
+ mundane.run(path, lambda ctx: ctx.sleep("n", "not-a-duration"))
158
+
159
+ def test_sleep_remaining_on_resume(self):
160
+ """If we crash mid-sleep, next run should sleep only the remaining time."""
161
+ with TempDB() as path:
162
+ # First run: writes wake_at = now + 200ms then sleeps the full 200ms.
163
+ # We use 50ms so the test is quick but observable.
164
+ t0 = time.time()
165
+ mundane.run(path, lambda ctx: ctx.sleep("n", "50ms"))
166
+ self.assertGreaterEqual(time.time() - t0, 0.04)
167
+
168
+ def test_resume_sleeps_only_the_remainder(self):
169
+ """A wake_at still in the future makes resume sleep the remainder."""
170
+ with TempDB() as path:
171
+ # Establish the sleep row with a short duration.
172
+ mundane.run(path, lambda ctx: ctx.sleep("n", "10ms"))
173
+ # Rewrite wake_at ~300ms into the future to simulate a long nap whose
174
+ # process was restarted before it elapsed.
175
+ future = int(time.time() * 1000) + 300
176
+ conn = sqlite3.connect(path)
177
+ conn.execute("UPDATE mundane_steps SET result=? WHERE name='n'", (str(future),))
178
+ conn.commit()
179
+ conn.close()
180
+ # Resume must block for the remaining ~300ms (duration arg ignored).
181
+ t0 = time.time()
182
+ mundane.run(path, lambda ctx: ctx.sleep("n", "10ms"))
183
+ self.assertGreaterEqual(time.time() - t0, 0.2)
184
+
185
+
186
+ class FailedStep(unittest.TestCase):
187
+ def test_failed_step_reruns(self):
188
+ with TempDB() as path:
189
+ def boom():
190
+ raise RuntimeError("boom")
191
+
192
+ with self.assertRaises(mundane.StepFailedError):
193
+ mundane.run(path, lambda ctx: ctx.step("s", boom))
194
+
195
+ # A failed step is not cached; it must re-run.
196
+ calls = []
197
+ r = mundane.run(path, lambda ctx: ctx.step("s", lambda: (calls.append("s"), 7)[1]))
198
+ self.assertEqual(r, 7)
199
+ self.assertEqual(calls, ["s"])
200
+
201
+ def test_failed_row_reset_to_pending_during_rerun(self):
202
+ with TempDB() as path:
203
+ def boom():
204
+ raise RuntimeError("boom")
205
+
206
+ with self.assertRaises(mundane.StepFailedError):
207
+ mundane.run(path, lambda ctx: ctx.step("s", boom))
208
+
209
+ # While the re-run body executes, the row must read 'pending' with
210
+ # the stale error cleared (the reset committed before fn runs).
211
+ seen = {}
212
+
213
+ def observe():
214
+ # observe() runs inside mundane.run, while the writer holds
215
+ # an exclusive flock on the file. On macOS, opening with the
216
+ # default unix VFS would deadlock against that flock — open
217
+ # with unix-none (no SQLite-level lock) instead.
218
+ conn = sqlite3.connect(f"file:{path}?vfs=unix-none", uri=True)
219
+ seen["status"], seen["error"] = conn.execute(
220
+ "SELECT status, error FROM mundane_steps WHERE name='s'"
221
+ ).fetchone()
222
+ conn.close()
223
+ return 7
224
+
225
+ mundane.run(path, lambda ctx: ctx.step("s", observe))
226
+ self.assertEqual(seen["status"], "pending")
227
+ self.assertIsNone(seen["error"])
228
+
229
+
230
+ class Async(unittest.TestCase):
231
+ def test_arun_astep_asleep(self):
232
+ async def aval(calls, tag, v):
233
+ calls.append(tag)
234
+ return v
235
+
236
+ with TempDB() as path:
237
+ calls = []
238
+
239
+ async def wf(ctx):
240
+ a = await ctx.astep("a", lambda: aval(calls, "a", 1))
241
+ await ctx.asleep("nap", "10ms")
242
+ return await ctx.astep("b", lambda: aval(calls, "b", a + 1))
243
+
244
+ r = asyncio.run(mundane.arun(path, wf))
245
+ self.assertEqual(r, 2)
246
+ self.assertEqual(calls, ["a", "b"])
247
+
248
+ # Resume: both steps cache-hit, neither fn runs.
249
+ calls.clear()
250
+ r2 = asyncio.run(mundane.arun(path, wf))
251
+ self.assertEqual(r2, 2)
252
+ self.assertEqual(calls, [])
253
+
254
+
255
+ class Inspect(unittest.TestCase):
256
+ def test_steps_committed(self):
257
+ with TempDB() as path:
258
+ def wf(ctx):
259
+ ctx.step("a", lambda: {"x": 1})
260
+ ctx.step("b", lambda: "hello")
261
+
262
+ mundane.run(path, wf)
263
+ # Inspection lives in the CLI now; verify on-disk state directly.
264
+ conn = sqlite3.connect(path)
265
+ done = conn.execute(
266
+ "SELECT COUNT(*) FROM mundane_steps WHERE status='done'"
267
+ ).fetchone()[0]
268
+ self.assertEqual(done, 2)
269
+ a = conn.execute(
270
+ "SELECT result, encoding FROM mundane_steps WHERE name='a'"
271
+ ).fetchone()
272
+ self.assertEqual(a[1], "json")
273
+ self.assertEqual(json.loads(a[0]), {"x": 1})
274
+ conn.close()
275
+
276
+
277
+ class Serialization(unittest.TestCase):
278
+ def test_non_jsonable_raises(self):
279
+ with TempDB() as path:
280
+ def wf(ctx):
281
+ # Tuples become lists in JSON; that's a roundtrip mismatch.
282
+ ctx.step("a", lambda: (1, 2, 3))
283
+ with self.assertRaises(mundane.SerializationError):
284
+ mundane.run(path, wf)
285
+
286
+
287
+ class Schema(unittest.TestCase):
288
+ def test_wrong_schema_version_rejected(self):
289
+ with TempDB() as path:
290
+ # Create a file with wrong schema version manually.
291
+ conn = sqlite3.connect(path)
292
+ conn.execute("CREATE TABLE mundane_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
293
+ conn.execute("CREATE TABLE mundane_steps (id INTEGER PRIMARY KEY)")
294
+ conn.execute("INSERT INTO mundane_meta VALUES ('schema_version', '99')")
295
+ conn.commit()
296
+ conn.close()
297
+
298
+ with self.assertRaises(mundane.SchemaError):
299
+ mundane.run(path, lambda ctx: None)
300
+
301
+
302
+ if __name__ == "__main__":
303
+ unittest.main()