loopiter 0.2.0a1__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.
loopiter/postgres.py ADDED
@@ -0,0 +1,161 @@
1
+ """Optional psycopg 3 adapter. Importing loopiter does not import a database driver."""
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from importlib.resources import files
6
+ from typing import TYPE_CHECKING
7
+
8
+ from . import _validation as v
9
+ from .store import page_options
10
+
11
+ if TYPE_CHECKING:
12
+ from psycopg_pool import AsyncConnectionPool
13
+
14
+
15
+ def migration_sql() -> str:
16
+ """Return versioned SQL for the application's migration process; execute explicitly."""
17
+ return files("loopiter").joinpath("migrations/001-python-store.sql").read_text(encoding="utf-8")
18
+
19
+
20
+ class PostgresTransaction:
21
+ def __init__(self, namespace, connection):
22
+ self.namespace, self.connection, self.active = namespace, connection, True
23
+ self.owner = asyncio.current_task()
24
+
25
+ def _check(self, kind):
26
+ if not self.active or asyncio.current_task() is not self.owner:
27
+ v.fail("transaction_closed", "Transaction closed or used from a different task.")
28
+ v.enum(kind, v.COLLECTIONS, "collection")
29
+
30
+ async def _query(self, sql, params):
31
+ # Use explicit row factory even when the application configures a different default.
32
+ from psycopg.rows import tuple_row
33
+
34
+ async with self.connection.cursor(row_factory=tuple_row) as cur:
35
+ await cur.execute(sql, params)
36
+ return await cur.fetchall()
37
+
38
+ async def get(self, collection, key):
39
+ self._check(collection)
40
+ v.nonempty(key, "id")
41
+ rows = await self._query(
42
+ "SELECT body FROM loopiter_python_records WHERE namespace=%s AND collection=%s AND id=%s",
43
+ (self.namespace, collection, key),
44
+ )
45
+ return rows[0][0] if rows else None
46
+
47
+ async def list(self, collection, *, limit=100, cursor=None, window=None):
48
+ self._check(collection)
49
+ page_options(limit, cursor, window)
50
+ window = window or {}
51
+ rows = await self._query(
52
+ "SELECT body FROM loopiter_python_records WHERE namespace=%s AND collection=%s "
53
+ 'AND (%s::text IS NULL OR id > %s COLLATE "C") '
54
+ "AND (%s::timestamptz IS NULL OR event_time >= %s::timestamptz) "
55
+ "AND (%s::timestamptz IS NULL OR event_time < %s::timestamptz) ORDER BY id LIMIT %s",
56
+ (
57
+ self.namespace,
58
+ collection,
59
+ cursor,
60
+ cursor,
61
+ window.get("from"),
62
+ window.get("from"),
63
+ window.get("to"),
64
+ window.get("to"),
65
+ limit + 1,
66
+ ),
67
+ )
68
+ result = {"items": [r[0] for r in rows[:limit]]}
69
+ if len(rows) > limit:
70
+ result["next_cursor"] = result["items"][-1]["id"]
71
+ return result
72
+
73
+ async def insert(self, collection, record):
74
+ from psycopg.types.json import Jsonb
75
+
76
+ self._check(collection)
77
+ v.stored(collection, record, self.namespace)
78
+ if record["revision"] != 1:
79
+ v.fail("conflict", "Insert requires revision 1.")
80
+ rows = await self._query(
81
+ "INSERT INTO loopiter_python_records(namespace,collection,id,revision,event_time,body) "
82
+ "VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING RETURNING id",
83
+ (
84
+ self.namespace,
85
+ collection,
86
+ record["id"],
87
+ 1,
88
+ record.get("observed_at", record.get("started_at", record["created_at"])),
89
+ Jsonb(record),
90
+ ),
91
+ )
92
+ if not rows:
93
+ v.fail("conflict", "Record already exists.")
94
+
95
+ async def replace(self, collection, record, expected_revision):
96
+ from psycopg.types.json import Jsonb
97
+
98
+ self._check(collection)
99
+ v.stored(collection, record, self.namespace)
100
+ v.integer(expected_revision, "expected_revision")
101
+ if collection in ("signals", "events"):
102
+ v.fail("immutable_record", "Collection is insert-only.")
103
+ if record["revision"] != expected_revision + 1:
104
+ v.fail("conflict", "Replacement revision must increment by one.")
105
+ rows = await self._query(
106
+ "UPDATE loopiter_python_records SET revision=%s, event_time=%s, body=%s "
107
+ "WHERE namespace=%s AND collection=%s AND id=%s AND revision=%s RETURNING id",
108
+ (
109
+ record["revision"],
110
+ record.get("observed_at", record.get("started_at", record["created_at"])),
111
+ Jsonb(record),
112
+ self.namespace,
113
+ collection,
114
+ record["id"],
115
+ expected_revision,
116
+ ),
117
+ )
118
+ if not rows:
119
+ v.fail("conflict", "Revision conflict.")
120
+
121
+
122
+ class PostgresStore:
123
+ """Developer-owned AsyncConnectionPool. Does not migrate or close the supplied pool.
124
+
125
+ Namespace advisory locking serializes ALL conforming writers. Use this adapter
126
+ for every write; do not mutate records directly. Separate namespaces can progress
127
+ concurrently. Configure pool timeouts/TLS/DB statement limits in your app.
128
+ """
129
+
130
+ version = 1
131
+
132
+ def __init__(self, pool: "AsyncConnectionPool"):
133
+ if not callable(getattr(pool, "connection", None)):
134
+ v.fail("invalid_input", "Expected a developer-owned async psycopg pool.")
135
+ self.pool = pool
136
+
137
+ @asynccontextmanager
138
+ async def transaction(self, namespace):
139
+ v.nonempty(namespace, "namespace")
140
+ async with self.pool.connection() as connection:
141
+ async with connection.transaction():
142
+ # READ COMMITTED refreshes snapshot after a contended lock is acquired.
143
+ await connection.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
144
+ await connection.execute(
145
+ "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
146
+ ("loopiter/python/v1/" + namespace,),
147
+ )
148
+ tx = PostgresTransaction(namespace, connection)
149
+ try:
150
+ yield tx
151
+ finally:
152
+ tx.active = False
153
+
154
+ async def delete_namespace(self, namespace):
155
+ async with self.transaction(namespace) as tx:
156
+ await tx.connection.execute(
157
+ "DELETE FROM loopiter_python_records WHERE namespace=%s", (namespace,)
158
+ )
159
+
160
+ async def close(self):
161
+ pass # Application owns the pool's lifecycle.
loopiter/py.typed ADDED
File without changes
loopiter/store.py ADDED
@@ -0,0 +1,143 @@
1
+ """Python store contract v1. Namespace-serializable, atomic, short transactions."""
2
+
3
+ import asyncio
4
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
5
+ from copy import deepcopy
6
+ from typing import Protocol
7
+
8
+ from ._validation import (
9
+ COLLECTIONS,
10
+ Record,
11
+ enum,
12
+ fail,
13
+ in_window,
14
+ integer,
15
+ nonempty,
16
+ stored,
17
+ window_valid,
18
+ )
19
+
20
+
21
+ class StoreTransaction(Protocol):
22
+ async def get(self, collection: str, key: str) -> Record | None: ...
23
+ async def list(
24
+ self,
25
+ collection: str,
26
+ *,
27
+ limit: int = 100,
28
+ cursor: str | None = None,
29
+ window: Record | None = None,
30
+ ) -> Record: ...
31
+ async def insert(self, collection: str, record: Record) -> None: ...
32
+ async def replace(self, collection: str, record: Record, expected_revision: int) -> None: ...
33
+
34
+
35
+ class FeedbackStore(Protocol):
36
+ version: int
37
+
38
+ def transaction(self, namespace: str) -> AbstractAsyncContextManager[StoreTransaction]: ...
39
+ async def delete_namespace(self, namespace: str) -> None: ...
40
+ async def close(self) -> None: ...
41
+
42
+
43
+ def page_options(limit: int, cursor: str | None, window: Record | None) -> None:
44
+ integer(limit, "page limit")
45
+ if limit > 1000:
46
+ fail("query_limit", "Page limit cannot exceed 1000.")
47
+ if cursor is not None:
48
+ nonempty(cursor, "cursor")
49
+ window_valid({} if window is None else window)
50
+
51
+
52
+ class MemoryTransaction:
53
+ def __init__(self, namespace: str, state: Record):
54
+ self.namespace, self.state, self.active = namespace, state, True
55
+ self.owner = asyncio.current_task()
56
+
57
+ def check(self, collection: str) -> None:
58
+ if not self.active or asyncio.current_task() is not self.owner:
59
+ fail("transaction_closed", "Transaction closed or used from a different task.")
60
+ enum(collection, COLLECTIONS, "collection")
61
+
62
+ async def get(self, collection: str, key: str) -> Record | None:
63
+ self.check(collection)
64
+ nonempty(key, "id")
65
+ return deepcopy(self.state.get(collection, {}).get(key))
66
+
67
+ async def list(
68
+ self,
69
+ collection: str,
70
+ *,
71
+ limit: int = 100,
72
+ cursor: str | None = None,
73
+ window: Record | None = None,
74
+ ) -> Record:
75
+ self.check(collection)
76
+ page_options(limit, cursor, window)
77
+ rows = sorted(
78
+ (
79
+ row
80
+ for row in self.state.get(collection, {}).values()
81
+ if (cursor is None or row["id"] > cursor)
82
+ and in_window(
83
+ row.get("observed_at", row.get("started_at", row["created_at"])), window or {}
84
+ )
85
+ ),
86
+ key=lambda row: row["id"],
87
+ )
88
+ page = {"items": deepcopy(rows[:limit])}
89
+ if len(rows) > limit:
90
+ page["next_cursor"] = rows[limit - 1]["id"]
91
+ return page
92
+
93
+ async def insert(self, collection: str, record: Record) -> None:
94
+ self.check(collection)
95
+ stored(collection, record, self.namespace)
96
+ bucket = self.state.setdefault(collection, {})
97
+ if record["id"] in bucket or record["revision"] != 1:
98
+ fail("conflict", "Insert requires a new ID and revision 1.")
99
+ bucket[record["id"]] = deepcopy(record)
100
+
101
+ async def replace(self, collection: str, record: Record, expected_revision: int) -> None:
102
+ self.check(collection)
103
+ stored(collection, record, self.namespace)
104
+ integer(expected_revision, "expected_revision")
105
+ if collection in ("signals", "events"):
106
+ fail("immutable_record", "Collection is insert-only.")
107
+ previous = self.state.get(collection, {}).get(record["id"])
108
+ if (
109
+ not previous
110
+ or previous["revision"] != expected_revision
111
+ or record["revision"] != expected_revision + 1
112
+ ):
113
+ fail("conflict", "Revision conflict.")
114
+ self.state[collection][record["id"]] = deepcopy(record)
115
+
116
+
117
+ class InMemoryStore:
118
+ """Development only. One asyncio event loop; no multi-process durability."""
119
+
120
+ version = 1
121
+
122
+ def __init__(self) -> None:
123
+ self._state: Record = {}
124
+ self._lock = asyncio.Lock()
125
+
126
+ @asynccontextmanager
127
+ async def transaction(self, namespace: str):
128
+ nonempty(namespace, "namespace")
129
+ async with self._lock:
130
+ tx = MemoryTransaction(namespace, deepcopy(self._state.get(namespace, {})))
131
+ try:
132
+ yield tx
133
+ self._state[namespace] = deepcopy(tx.state)
134
+ finally:
135
+ tx.active = False
136
+
137
+ async def delete_namespace(self, namespace: str) -> None:
138
+ nonempty(namespace, "namespace")
139
+ async with self._lock:
140
+ self._state.pop(namespace, None)
141
+
142
+ async def close(self) -> None:
143
+ pass
loopiter/testing.py ADDED
@@ -0,0 +1,95 @@
1
+ """Executable adapter conformance checks. Use only an explicitly disposable namespace."""
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+ from uuid import uuid4
6
+
7
+ from . import FeedbackLoop, LoopiterError
8
+ from .store import FeedbackStore
9
+
10
+
11
+ async def run_store_conformance(store: FeedbackStore, *, namespace: str | None = None) -> None:
12
+ """Asserts transaction, revision, idempotency, isolation and pagination guarantees.
13
+
14
+ Creates/deletes its own unique namespaces. Supplying a namespace is an explicit
15
+ request to clear that namespace; never pass a production namespace.
16
+ Does not close the developer-owned store/pool. Raises AssertionError on failure.
17
+ """
18
+ namespace = namespace or f"loopiter-conformance/{uuid4()}"
19
+ other = namespace + "/isolated"
20
+ loop = FeedbackLoop(store=store, namespace=namespace)
21
+ isolated = FeedbackLoop(store=store, namespace=other)
22
+ try:
23
+ first = await loop.record_execution(id="a", kind="prediction")
24
+ assert await loop.record_execution(id="a", kind="prediction") == first
25
+ assert await isolated.get_execution("a") is None
26
+ await isolated.record_execution(id="a", kind="tool")
27
+ try:
28
+ await loop.record_execution(id="a", kind="tool")
29
+ except LoopiterError as e:
30
+ assert e.code == "conflict"
31
+ else:
32
+ raise AssertionError("Conflicting insert overwrote an existing record")
33
+ results = await asyncio.gather(
34
+ *(
35
+ loop.complete_execution("a", expected_revision=1, output={"attempt": i})
36
+ for i in range(2)
37
+ ),
38
+ return_exceptions=True,
39
+ )
40
+ assert sum(isinstance(r, dict) for r in results) == 1
41
+ assert sum(isinstance(r, LoopiterError) and r.code == "conflict" for r in results) == 1
42
+ current = await loop.get_execution("a")
43
+ async with store.transaction(namespace) as tx:
44
+ leaked = tx
45
+ record = deepcopy(current)
46
+ record.update(id="b", revision=1)
47
+ await tx.insert("executions", record)
48
+ try:
49
+ await leaked.get("executions", "a")
50
+ except LoopiterError as e:
51
+ assert e.code == "transaction_closed"
52
+ else:
53
+ raise AssertionError("Completed transaction remained usable")
54
+ try:
55
+ async with store.transaction(namespace) as tx:
56
+ record = deepcopy(first)
57
+ record["id"] = "rolled-back"
58
+ await tx.insert("executions", record)
59
+ raise RuntimeError("injected rollback")
60
+ except RuntimeError:
61
+ pass
62
+ assert await loop.get_execution("rolled-back") is None
63
+ async with store.transaction(namespace) as tx:
64
+ returned = await tx.get("executions", "a")
65
+ returned["kind"] = "custom"
66
+ assert (await loop.get_execution("a"))["kind"] == "prediction"
67
+ page = await loop.list("executions", limit=1)
68
+ assert [r["id"] for r in page["items"]] == ["a"]
69
+ assert [
70
+ r["id"] for r in (await loop.list("executions", cursor=page["next_cursor"]))["items"]
71
+ ] == ["b"]
72
+ signal = await loop.record_signal(
73
+ id="signal", execution_id="a", kind="rating", name="correct", value=True, source="test"
74
+ )
75
+ try:
76
+ async with store.transaction(namespace) as tx:
77
+ signal["revision"] += 1
78
+ await tx.replace("signals", signal, 1)
79
+ except LoopiterError as e:
80
+ assert e.code == "immutable_record"
81
+ else:
82
+ raise AssertionError("Signal was mutable")
83
+ try:
84
+ async with store.transaction(other) as tx:
85
+ await tx.insert("executions", first)
86
+ except LoopiterError as e:
87
+ assert e.code == "namespace_mismatch"
88
+ else:
89
+ raise AssertionError("Cross-namespace write accepted")
90
+ await loop.delete_namespace(confirmation=namespace)
91
+ assert await loop.get_execution("a") is None
92
+ assert (await isolated.get_execution("a"))["kind"] == "tool"
93
+ finally:
94
+ await store.delete_namespace(namespace)
95
+ await store.delete_namespace(other)