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/__init__.py +16 -0
- loopiter/_validation.py +335 -0
- loopiter/analysis.py +261 -0
- loopiter/client.py +637 -0
- loopiter/migrations/001-python-store.sql +25 -0
- loopiter/postgres.py +161 -0
- loopiter/py.typed +0 -0
- loopiter/store.py +143 -0
- loopiter/testing.py +95 -0
- loopiter-0.2.0a1.dist-info/METADATA +295 -0
- loopiter-0.2.0a1.dist-info/RECORD +13 -0
- loopiter-0.2.0a1.dist-info/WHEEL +4 -0
- loopiter-0.2.0a1.dist-info/licenses/LICENSE +21 -0
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)
|