community-agent-framework-postgres 0.1.1__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,77 @@
1
+ """PostgreSQL storage for Microsoft Agent Framework.
2
+
3
+ Every store can be built on its own, or from one :class:`PostgresPersistence`
4
+ that shares a single connection pool. See the README for examples.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from ._checkpoint_storage import PostgresCheckpointStorage
11
+ from ._client import (
12
+ LeaseLost,
13
+ LeaseUnavailable,
14
+ PostgresClient,
15
+ PostgresSettings,
16
+ PostgresStorageError,
17
+ TableNames,
18
+ )
19
+ from ._entra import ENTRA_SCOPE, EntraCredential
20
+ from ._framework import Compatibility, compatibility
21
+ from ._history_provider import PostgresHistoryProvider
22
+ from ._leases import Lease, PostgresLeases
23
+ from ._migrations import MIGRATIONS, MigrationReport
24
+ from ._migrations import render as render_migrations
25
+ from ._persistence import PostgresPersistence
26
+ from ._retention import PurgeReport, RetentionMode, RetentionPolicy
27
+ from ._session_store import PostgresSessionStore
28
+
29
+ if TYPE_CHECKING:
30
+ from ._thread_snapshot_store import PostgresAGUIThreadSnapshotStore
31
+
32
+ try:
33
+ __version__ = version("community-agent-framework-postgres")
34
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
35
+ __version__ = "0.0.0"
36
+
37
+ _LAZY = {"PostgresAGUIThreadSnapshotStore"}
38
+
39
+
40
+ def __getattr__(name: str) -> Any:
41
+ if name in _LAZY:
42
+ from ._thread_snapshot_store import PostgresAGUIThreadSnapshotStore
43
+
44
+ return PostgresAGUIThreadSnapshotStore
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
46
+
47
+
48
+ def __dir__() -> list[str]:
49
+ return sorted({*globals(), *_LAZY})
50
+
51
+
52
+ __all__ = [
53
+ "ENTRA_SCOPE",
54
+ "MIGRATIONS",
55
+ "Compatibility",
56
+ "EntraCredential",
57
+ "Lease",
58
+ "LeaseLost",
59
+ "LeaseUnavailable",
60
+ "MigrationReport",
61
+ "PostgresAGUIThreadSnapshotStore",
62
+ "PostgresCheckpointStorage",
63
+ "PostgresClient",
64
+ "PostgresHistoryProvider",
65
+ "PostgresLeases",
66
+ "PostgresPersistence",
67
+ "PostgresSessionStore",
68
+ "PostgresSettings",
69
+ "PostgresStorageError",
70
+ "PurgeReport",
71
+ "RetentionMode",
72
+ "RetentionPolicy",
73
+ "TableNames",
74
+ "__version__",
75
+ "compatibility",
76
+ "render_migrations",
77
+ ]
@@ -0,0 +1,3 @@
1
+ from ._cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,218 @@
1
+ """Workflow checkpoints in PostgreSQL with the framework's own hybrid JSON + restricted-pickle encoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Mapping
8
+ from datetime import UTC, datetime
9
+ from typing import Any, ClassVar
10
+
11
+ from agent_framework import SecretString, WorkflowCheckpoint
12
+ from agent_framework.exceptions import WorkflowCheckpointException
13
+ from psycopg import sql
14
+
15
+ from ._client import ClientHandle, PostgresClient, optional_text
16
+ from ._entra import EntraCredential
17
+ from ._framework import decode_checkpoint_value, encode_checkpoint_value
18
+ from ._json import encode_jsonb
19
+ from ._retention import EXPIRES_AT, PurgeReport, RetentionPolicy, purge_rows
20
+ from ._store import BaseStore
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class PostgresCheckpointStorage(BaseStore):
26
+ """PostgreSQL-backed workflow ``CheckpointStorage``.
27
+
28
+ Checkpoints are encoded exactly as ``FileCheckpointStorage`` and ``CosmosCheckpointStorage``
29
+ encode them, so the same restricted deserializer and ``allowed_checkpoint_types`` apply, and
30
+ the same warning: the table is a trust boundary, because non-JSON values are pickled. Rows are
31
+ keyed by application, optional ``tenant_id`` and ``scope`` (a conversation or response id, the
32
+ way Foundry hosting scopes its stores) and the checkpoint id. "Latest" is the last row written
33
+ in a scope, whatever clock produced it. Saving an existing id replaces its content and keeps
34
+ its position, as the framework's own stores do.
35
+ """
36
+
37
+ PAYLOAD_FORMAT: ClassVar[str] = "agent-framework.python.checkpoint.v1"
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ application_id: str,
43
+ tenant_id: str | None = None,
44
+ scope: str | None = None,
45
+ allowed_checkpoint_types: list[str] | None = None,
46
+ connection_string: str | SecretString | None = None,
47
+ client: PostgresClient | ClientHandle | None = None,
48
+ credential: EntraCredential | None = None,
49
+ env_file_path: str | None = None,
50
+ env_file_encoding: str | None = None,
51
+ schema: str = "public",
52
+ table_prefix: str = "af_",
53
+ retention: RetentionPolicy | None = None,
54
+ pool_kwargs: Mapping[str, Any] | None = None,
55
+ ) -> None:
56
+ super().__init__(
57
+ application_id=application_id,
58
+ connection_string=connection_string,
59
+ client=client,
60
+ credential=credential,
61
+ env_file_path=env_file_path,
62
+ env_file_encoding=env_file_encoding,
63
+ schema=schema,
64
+ table_prefix=table_prefix,
65
+ retention=retention,
66
+ pool_kwargs=pool_kwargs,
67
+ )
68
+ self.tenant_id = optional_text(tenant_id, "tenant_id")
69
+ self.scope = optional_text(scope, "scope")
70
+ self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])
71
+ self._lock_key = json.dumps(
72
+ [
73
+ "agent-framework-postgres:checkpoints",
74
+ schema,
75
+ table_prefix,
76
+ self.application_id,
77
+ self.tenant_id,
78
+ self.scope,
79
+ ]
80
+ )
81
+
82
+ _SCOPE = sql.SQL(
83
+ "application_id = %(app)s AND tenant_id = %(tenant)s AND scope = %(scope)s AND payload_format = %(format)s"
84
+ )
85
+
86
+ def _params(self) -> dict[str, Any]:
87
+ return {
88
+ "app": self.application_id,
89
+ "tenant": self.tenant_id,
90
+ "scope": self.scope,
91
+ "format": self.PAYLOAD_FORMAT,
92
+ }
93
+
94
+ @staticmethod
95
+ def _parse_timestamp(checkpoint: WorkflowCheckpoint) -> datetime:
96
+ try:
97
+ parsed = datetime.fromisoformat(checkpoint.timestamp)
98
+ except (TypeError, ValueError) as exc:
99
+ raise WorkflowCheckpointException("A checkpoint has an unparsable timestamp.") from exc
100
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
101
+
102
+ def _decode(self, payload: Any) -> WorkflowCheckpoint:
103
+ try:
104
+ return WorkflowCheckpoint.from_dict(decode_checkpoint_value(payload, allowed_types=self._allowed_types))
105
+ except WorkflowCheckpointException:
106
+ raise
107
+ except Exception as exc:
108
+ raise WorkflowCheckpointException("A stored checkpoint cannot be restored.") from exc
109
+
110
+ async def save(self, checkpoint: WorkflowCheckpoint) -> str:
111
+ """Encode, verify it can be restored under this store's allowed types, then write it."""
112
+ when = self._parse_timestamp(checkpoint)
113
+ try:
114
+ encoded = encode_checkpoint_value(checkpoint.to_dict())
115
+ decode_checkpoint_value(encoded, allowed_types=self._allowed_types)
116
+ except WorkflowCheckpointException:
117
+ raise
118
+ except Exception as exc:
119
+ raise WorkflowCheckpointException(
120
+ "A checkpoint cannot be encoded or restored under this storage's allowed types."
121
+ ) from exc
122
+ payload = encode_jsonb(encoded, what="Checkpoint")
123
+ async with self._client.connection() as connection:
124
+ # Writers of one scope take turns, so sequence order is commit order.
125
+ await connection.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", (self._lock_key,))
126
+ await connection.execute(
127
+ sql.SQL(
128
+ "INSERT INTO {checkpoints} (application_id, tenant_id, scope, payload_format, workflow_name,"
129
+ " checkpoint_id, previous_checkpoint_id, checkpoint_timestamp, payload, expires_at)"
130
+ " VALUES (%(app)s, %(tenant)s, %(scope)s, %(format)s, %(workflow)s, %(id)s, %(previous)s,"
131
+ " %(when)s, %(payload)s, {expires})"
132
+ " ON CONFLICT (application_id, tenant_id, scope, checkpoint_id) DO UPDATE SET"
133
+ " workflow_name = EXCLUDED.workflow_name, previous_checkpoint_id = EXCLUDED.previous_checkpoint_id,"
134
+ " checkpoint_timestamp = EXCLUDED.checkpoint_timestamp, payload = EXCLUDED.payload,"
135
+ " expires_at = EXCLUDED.expires_at, payload_format = EXCLUDED.payload_format"
136
+ ).format(checkpoints=self._names.table("checkpoints"), expires=EXPIRES_AT),
137
+ self._params()
138
+ | {
139
+ "workflow": checkpoint.workflow_name,
140
+ "id": checkpoint.checkpoint_id,
141
+ "previous": checkpoint.previous_checkpoint_id,
142
+ "when": when,
143
+ "payload": payload,
144
+ "ttl": self._ttl(),
145
+ },
146
+ )
147
+ logger.debug("Saved a checkpoint.")
148
+ return checkpoint.checkpoint_id
149
+
150
+ async def load(self, checkpoint_id: str) -> WorkflowCheckpoint:
151
+ """The checkpoint with this id in this scope; ``WorkflowCheckpointException`` when absent or unreadable."""
152
+ async with self._client.connection(transactional=False) as connection:
153
+ cursor = await connection.execute(
154
+ sql.SQL("SELECT payload FROM {checkpoints} WHERE {scope} AND checkpoint_id = %(id)s").format(
155
+ checkpoints=self._names.table("checkpoints"), scope=self._SCOPE
156
+ ),
157
+ self._params() | {"id": checkpoint_id},
158
+ )
159
+ row = await cursor.fetchone()
160
+ if row is None:
161
+ raise WorkflowCheckpointException("No checkpoint found with that ID in this scope.")
162
+ return self._decode(row[0])
163
+
164
+ async def _rows(
165
+ self, workflow_name: str, *, column: sql.Identifier, newest_first: bool = False, limit: int | None = None
166
+ ) -> list[Any]:
167
+ order = sql.SQL("DESC") if newest_first else sql.SQL("ASC")
168
+ async with self._client.connection(transactional=False) as connection:
169
+ cursor = await connection.execute(
170
+ sql.SQL(
171
+ "SELECT {column} FROM {checkpoints} WHERE {scope} AND workflow_name = %(workflow)s"
172
+ " ORDER BY sequence {order} LIMIT %(limit)s"
173
+ ).format(column=column, checkpoints=self._names.table("checkpoints"), scope=self._SCOPE, order=order),
174
+ self._params() | {"workflow": workflow_name, "limit": limit},
175
+ )
176
+ return [row[0] for row in await cursor.fetchall()]
177
+
178
+ async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
179
+ """Checkpoints for the workflow in write order; rows that fail to decode are logged and skipped."""
180
+ found: list[WorkflowCheckpoint] = []
181
+ for payload in await self._rows(workflow_name, column=sql.Identifier("payload")):
182
+ try:
183
+ found.append(self._decode(payload))
184
+ except WorkflowCheckpointException:
185
+ logger.warning("Skipping a checkpoint row that failed to decode.")
186
+ return found
187
+
188
+ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[str]:
189
+ """Every checkpoint id for the workflow in write order, without decoding any row."""
190
+ return await self._rows(workflow_name, column=sql.Identifier("checkpoint_id"))
191
+
192
+ async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
193
+ """The last checkpoint written for the workflow in this scope, or ``None``; raises when it does not decode."""
194
+ rows = await self._rows(workflow_name, column=sql.Identifier("payload"), newest_first=True, limit=1)
195
+ return self._decode(rows[0]) if rows else None
196
+
197
+ async def delete(self, checkpoint_id: str) -> bool:
198
+ """Delete by id within this store's scope; ``True`` when a row existed."""
199
+ async with self._client.connection() as connection:
200
+ cursor = await connection.execute(
201
+ sql.SQL("DELETE FROM {checkpoints} WHERE {scope} AND checkpoint_id = %(id)s").format(
202
+ checkpoints=self._names.table("checkpoints"), scope=self._SCOPE
203
+ ),
204
+ self._params() | {"id": checkpoint_id},
205
+ )
206
+ return cursor.rowcount > 0
207
+
208
+ async def purge(self) -> PurgeReport:
209
+ """Delete expired checkpoints in this store's scope, whatever policy stamped them (no tombstone form)."""
210
+ count = await purge_rows(
211
+ self._client,
212
+ table=self._names.table("checkpoints"),
213
+ where=sql.SQL("application_id = %(app)s AND tenant_id = %(tenant)s AND scope = %(scope)s"),
214
+ params={"app": self.application_id, "tenant": self.tenant_id, "scope": self.scope},
215
+ mode="delete",
216
+ tombstone=None,
217
+ )
218
+ return PurgeReport({f"{self._names.prefix}checkpoints": count})
@@ -0,0 +1,97 @@
1
+ """``python -m agent_framework_community_postgres``: migrate, status, purge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from agent_framework.exceptions import SettingNotFoundError
11
+
12
+ from ._client import PostgresStorageError, TableNames
13
+ from ._migrations import MIGRATIONS, ensure_supported, render
14
+ from ._persistence import PostgresPersistence
15
+
16
+
17
+ def _add_common(parser: argparse.ArgumentParser, *, suppress: bool = False) -> None:
18
+ """Add the shared options; subcommand copies suppress defaults so both positions work."""
19
+
20
+ def d(value: str | None) -> str | None:
21
+ return argparse.SUPPRESS if suppress else value
22
+
23
+ parser.add_argument("--schema", default=d("public"))
24
+ parser.add_argument("--table-prefix", default=d("af_"))
25
+ parser.add_argument(
26
+ "--application-id", default=d(None), help="required by purge; migrate and status use 'cli' when omitted"
27
+ )
28
+
29
+
30
+ def _parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="python -m agent_framework_community_postgres",
33
+ description="Manage the PostgreSQL schema used by community-agent-framework-postgres.",
34
+ )
35
+ _add_common(parser)
36
+ shared = argparse.ArgumentParser(add_help=False)
37
+ _add_common(shared, suppress=True)
38
+ commands = parser.add_subparsers(dest="command", required=True)
39
+ migrate = commands.add_parser("migrate", parents=[shared], help="apply pending migrations")
40
+ migrate.add_argument("--print", action="store_true", dest="print_sql", help="print the SQL instead of running it")
41
+ migrate.add_argument(
42
+ "--from-version", type=int, default=None, help="with --print, print only the versions after this one"
43
+ )
44
+ commands.add_parser("status", parents=[shared], help="show the applied and pending versions")
45
+ purge = commands.add_parser(
46
+ "purge", parents=[shared], help="tombstone or delete the rows whose expires_at has passed"
47
+ )
48
+ purge.add_argument("--mode", choices=["tombstone", "delete"], default="tombstone")
49
+ return parser
50
+
51
+
52
+ async def _run(args: argparse.Namespace) -> int:
53
+ async with PostgresPersistence(
54
+ application_id=args.application_id or "cli",
55
+ schema=args.schema,
56
+ table_prefix=args.table_prefix,
57
+ ) as persistence:
58
+ if args.command == "migrate":
59
+ report = await persistence.migrate()
60
+ print(f"applied: {', '.join(map(str, report.applied)) or 'none'}; current version: {report.current}")
61
+ elif args.command == "status":
62
+ current = await persistence.current_version()
63
+ pending = range(current + 1, len(MIGRATIONS) + 1)
64
+ print(f"current version: {current}; pending: {', '.join(map(str, pending)) or 'none'}")
65
+ ensure_supported(current)
66
+ else:
67
+ report = await persistence.purge(mode=args.mode)
68
+ for table, count in sorted(report.counts.items()):
69
+ print(f"{table}: {count}")
70
+ print(f"total: {report.total}")
71
+ return 0
72
+
73
+
74
+ def main(argv: Sequence[str] | None = None) -> int:
75
+ """Entry point; returns the process exit code (2 for invalid options, 1 for other errors)."""
76
+ args = _parser().parse_args(argv)
77
+ if args.command == "purge" and args.application_id is None:
78
+ _parser().error("purge needs --application-id")
79
+ if args.command == "migrate" and args.from_version is not None and not args.print_sql:
80
+ _parser().error("--from-version needs --print")
81
+ loop_factory = asyncio.SelectorEventLoop if sys.platform == "win32" else None
82
+ try:
83
+ names = TableNames(schema=args.schema, prefix=args.table_prefix)
84
+ if args.command == "migrate" and args.print_sql:
85
+ print(render(names, range((args.from_version or 0) + 1, len(MIGRATIONS) + 1)), end="")
86
+ return 0
87
+ with asyncio.Runner(loop_factory=loop_factory) as runner:
88
+ return runner.run(_run(args))
89
+ except ValueError as exc:
90
+ # This package's option checks (TableNames, the connection string) raise ValueError with no values in it.
91
+ print(f"error: {exc}", file=sys.stderr)
92
+ return 2
93
+ except PostgresStorageError as exc:
94
+ print(f"error: {exc}", file=sys.stderr)
95
+ except SettingNotFoundError:
96
+ print("error: set POSTGRES_CONNECTION_STRING", file=sys.stderr)
97
+ return 1