agskillmanager 1.0.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,318 @@
1
+ """PostgreSQL Skill source and registry.
2
+
3
+ Install with ``pip install 'agskillmanager[postgres]'``. Both classes take a
4
+ psycopg ``AsyncConnectionPool``; the caller opens it, and the class closes it in
5
+ ``aclose`` only when constructed with ``owns_pool=True``. Call `ensure_schema`
6
+ once to create the table and indexes. On Windows, run psycopg's async API on a
7
+ selector event loop: ``asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
14
+ from hashlib import sha256
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from skill_manager._optional import optional_import
18
+ from skill_manager.errors import SkillConflictError, SkillNotFoundError, SkillSourceError
19
+ from skill_manager.models import Skill, SkillLifecycleState
20
+ from skill_manager.registry import SkillRegistry, check_write, latest
21
+ from skill_manager.sources import SkillSource, validate_source_id
22
+ from skill_manager.validation import dump_skill_yaml
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import AsyncGenerator, Iterable, Mapping
26
+
27
+ from psycopg import AsyncConnection
28
+ from psycopg_pool import AsyncConnectionPool
29
+
30
+ _psycopg = optional_import("psycopg", "postgres")
31
+ _sql = optional_import("psycopg.sql", "postgres")
32
+ _Jsonb = optional_import("psycopg.types.json", "postgres").Jsonb
33
+ _TABLE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,62}")
34
+ _ACTIVE = SkillLifecycleState.ACTIVE.value
35
+
36
+
37
+ def _table(name: str) -> Any:
38
+ if not _TABLE.fullmatch(name):
39
+ raise ValueError("table must be a plain SQL identifier of at most 63 characters")
40
+ return _sql.Identifier(name)
41
+
42
+
43
+ @asynccontextmanager
44
+ async def _borrow(
45
+ pool: AsyncConnectionPool[Any], message: str
46
+ ) -> AsyncGenerator[AsyncConnection[Any]]:
47
+ """Borrow a pooled connection, translating database errors into Skill errors."""
48
+ try:
49
+ async with pool.connection() as connection:
50
+ yield connection
51
+ except _psycopg.errors.UniqueViolation as error:
52
+ raise SkillConflictError("a concurrent write created a second active revision") from error
53
+ except _psycopg.Error as error:
54
+ raise SkillSourceError(message) from error
55
+
56
+
57
+ class _PoolOwner:
58
+ def __init__(self, pool: AsyncConnectionPool[Any], *, owns_pool: bool) -> None:
59
+ self._pool = pool
60
+ self._owns_pool = owns_pool
61
+
62
+ async def aclose(self) -> None:
63
+ """Close the pool when this object owns it."""
64
+ if self._owns_pool:
65
+ await self._pool.close()
66
+
67
+
68
+ class PostgresSkillSource(_PoolOwner, SkillSource):
69
+ """Store Skill YAML documents in one PostgreSQL table, keyed by source and Skill ID.
70
+
71
+ Several sources can share a table under different ``source_id`` values.
72
+
73
+ Args:
74
+ pool: Open psycopg async connection pool.
75
+ source_id: Stable identifier for this source.
76
+ table: Table name; created by `ensure_schema`.
77
+ owns_pool: Close ``pool`` in `aclose`.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ pool: AsyncConnectionPool[Any],
83
+ *,
84
+ source_id: str = "postgres",
85
+ table: str = "skill_manager_documents",
86
+ owns_pool: bool = False,
87
+ ) -> None:
88
+ super().__init__(pool, owns_pool=owns_pool)
89
+ self._source_id = validate_source_id(source_id)
90
+ self._table = _table(table)
91
+
92
+ @property
93
+ def source_id(self) -> str:
94
+ """Return the configured source identifier."""
95
+ return self._source_id
96
+
97
+ async def ensure_schema(self) -> None:
98
+ """Create the document table if it does not exist."""
99
+ await self._execute(
100
+ "CREATE TABLE IF NOT EXISTS {table} ("
101
+ " source_id text NOT NULL,"
102
+ " skill_id text NOT NULL,"
103
+ " document text NOT NULL,"
104
+ " updated_at timestamptz NOT NULL DEFAULT now(),"
105
+ " PRIMARY KEY (source_id, skill_id))",
106
+ (),
107
+ "cannot create the Skill document table",
108
+ )
109
+
110
+ async def load(self) -> Mapping[str, str]:
111
+ """Return every document of this source, keyed by Skill ID."""
112
+ rows = await self._execute(
113
+ "SELECT skill_id, document FROM {table} WHERE source_id = %s ORDER BY skill_id",
114
+ (self._source_id,),
115
+ f"cannot load PostgreSQL Skill source {self._source_id!r}",
116
+ fetch=True,
117
+ )
118
+ return dict(rows)
119
+
120
+ async def save(self, skill: Skill) -> None:
121
+ """Insert or replace the document of ``skill``."""
122
+ await self._execute(
123
+ "INSERT INTO {table} (source_id, skill_id, document) VALUES (%s, %s, %s)"
124
+ " ON CONFLICT (source_id, skill_id)"
125
+ " DO UPDATE SET document = EXCLUDED.document, updated_at = now()",
126
+ (self._source_id, skill.skill_id, dump_skill_yaml(skill)),
127
+ f"cannot persist Skill {skill.skill_id!r}",
128
+ )
129
+
130
+ async def delete(self, skill_id: str) -> None:
131
+ """Delete the document of ``skill_id``, if present."""
132
+ await self._execute(
133
+ "DELETE FROM {table} WHERE source_id = %s AND skill_id = %s",
134
+ (self._source_id, skill_id),
135
+ f"cannot delete Skill {skill_id!r}",
136
+ )
137
+
138
+ async def _execute(
139
+ self, query: str, params: tuple[Any, ...], message: str, *, fetch: bool = False
140
+ ) -> list[Any]:
141
+ async with _borrow(self._pool, message) as connection:
142
+ cursor = await connection.execute(_sql.SQL(query).format(table=self._table), params)
143
+ return await cursor.fetchall() if fetch else []
144
+
145
+
146
+ class PostgresSkillRegistry(_PoolOwner, SkillRegistry):
147
+ """Shared Skill catalog in PostgreSQL, safe across processes.
148
+
149
+ A partial unique index guarantees one ``ACTIVE`` revision per name and
150
+ version, even under concurrent writers; a lost race raises
151
+ `SkillConflictError`.
152
+
153
+ Args:
154
+ pool: Open psycopg async connection pool.
155
+ table: Table name; created by `ensure_schema`.
156
+ owns_pool: Close ``pool`` in `aclose`.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ pool: AsyncConnectionPool[Any],
162
+ *,
163
+ table: str = "skill_manager_skills",
164
+ owns_pool: bool = False,
165
+ ) -> None:
166
+ super().__init__(pool, owns_pool=owns_pool)
167
+ self._table = _table(table)
168
+ self._active_index = _sql.Identifier(
169
+ f"sm_active_{sha256(table.encode('ascii')).hexdigest()[:32]}"
170
+ )
171
+
172
+ async def ensure_schema(self) -> None:
173
+ """Create the catalog table and its indexes if they do not exist."""
174
+ statements = (
175
+ "CREATE TABLE IF NOT EXISTS {table} ("
176
+ " skill_id text PRIMARY KEY,"
177
+ " name text NOT NULL,"
178
+ " version text NOT NULL,"
179
+ " revision integer NOT NULL,"
180
+ " state text NOT NULL,"
181
+ " document jsonb NOT NULL)",
182
+ "CREATE UNIQUE INDEX IF NOT EXISTS {active} ON {table} (name, version)"
183
+ " WHERE state = 'ACTIVE'",
184
+ )
185
+ async with self._connection() as connection:
186
+ for statement in statements:
187
+ await connection.execute(self._sql(statement))
188
+
189
+ async def put(self, skill: Skill) -> None:
190
+ """Insert or idempotently rewrite one revision."""
191
+ async with self._connection() as connection, connection.transaction():
192
+ await self._check(connection, skill, lock=True)
193
+ await connection.execute(
194
+ self._sql(
195
+ "INSERT INTO {table} (skill_id, name, version, revision, state, document)"
196
+ " VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (skill_id) DO UPDATE SET"
197
+ " revision = EXCLUDED.revision, state = EXCLUDED.state,"
198
+ " document = EXCLUDED.document"
199
+ ),
200
+ (
201
+ skill.skill_id,
202
+ skill.metadata.name,
203
+ skill.metadata.version,
204
+ skill.revision,
205
+ skill.lifecycle.state.value,
206
+ _Jsonb(skill.model_dump(mode="json")),
207
+ ),
208
+ )
209
+
210
+ async def check_put(self, skill: Skill) -> None:
211
+ """Validate a write without applying it."""
212
+ async with self._connection() as connection:
213
+ await self._check(connection, skill, lock=False)
214
+
215
+ async def get(self, skill_id: str) -> Skill:
216
+ """Return a Skill by ID."""
217
+ rows = await self._fetch("SELECT document FROM {table} WHERE skill_id = %s", (skill_id,))
218
+ if not rows:
219
+ raise SkillNotFoundError(f"Skill {skill_id!r} was not found")
220
+ return Skill.model_validate(rows[0][0])
221
+
222
+ async def by_name(self, name: str, version: str | None = None) -> Skill:
223
+ """Return the active Skill with ``name`` and optional exact ``version``."""
224
+ rows = await self._fetch(
225
+ "SELECT document FROM {table} WHERE name = %s AND state = %s"
226
+ " AND (%s::text IS NULL OR version = %s)",
227
+ (name, _ACTIVE, version, version),
228
+ )
229
+ if not rows:
230
+ raise SkillNotFoundError(f"Skill {name!r} version {version!r} was not found")
231
+ return latest(Skill.model_validate(row[0]) for row in rows)
232
+
233
+ async def all(
234
+ self, *, states: Iterable[SkillLifecycleState] | None = None
235
+ ) -> tuple[Skill, ...]:
236
+ """Return every Skill, optionally filtered by lifecycle state."""
237
+ if states is None:
238
+ rows = await self._fetch("SELECT document FROM {table} ORDER BY skill_id", ())
239
+ else:
240
+ rows = await self._fetch(
241
+ "SELECT document FROM {table} WHERE state = ANY(%s) ORDER BY skill_id",
242
+ ([state.value for state in states],),
243
+ )
244
+ return tuple(Skill.model_validate(row[0]) for row in rows)
245
+
246
+ async def remove(self, skill_id: str) -> Skill:
247
+ """Remove and return one revision."""
248
+ rows = await self._fetch(
249
+ "DELETE FROM {table} WHERE skill_id = %s RETURNING document", (skill_id,)
250
+ )
251
+ if not rows:
252
+ raise SkillNotFoundError(f"Skill {skill_id!r} was not found")
253
+ return Skill.model_validate(rows[0][0])
254
+
255
+ async def replace_lifecycle(self, skill: Skill) -> None:
256
+ """Overwrite an existing revision after a lifecycle transition."""
257
+ async with self._connection() as connection, connection.transaction():
258
+ cursor = await connection.execute(
259
+ self._sql("SELECT 1 FROM {table} WHERE skill_id = %s FOR UPDATE"),
260
+ (skill.skill_id,),
261
+ )
262
+ if await cursor.fetchone() is None:
263
+ raise SkillNotFoundError(f"Skill {skill.skill_id!r} was not found")
264
+ if skill.lifecycle.state is SkillLifecycleState.ACTIVE and await self._peers(
265
+ connection, skill
266
+ ):
267
+ raise SkillConflictError(
268
+ f"another active revision already exists for {skill.metadata.name!r}"
269
+ )
270
+ await connection.execute(
271
+ self._sql(
272
+ "UPDATE {table} SET revision = %s, state = %s, document = %s"
273
+ " WHERE skill_id = %s"
274
+ ),
275
+ (
276
+ skill.revision,
277
+ skill.lifecycle.state.value,
278
+ _Jsonb(skill.model_dump(mode="json")),
279
+ skill.skill_id,
280
+ ),
281
+ )
282
+
283
+ async def _check(self, connection: AsyncConnection[Any], skill: Skill, *, lock: bool) -> None:
284
+ cursor = await connection.execute(
285
+ self._sql(
286
+ "SELECT document FROM {table} WHERE skill_id = %s FOR UPDATE"
287
+ if lock
288
+ else "SELECT document FROM {table} WHERE skill_id = %s"
289
+ ),
290
+ (skill.skill_id,),
291
+ )
292
+ row = await cursor.fetchone()
293
+ existing = Skill.model_validate(row[0]) if row is not None else None
294
+ check_write(existing, skill, await self._peers(connection, skill))
295
+
296
+ async def _peers(self, connection: AsyncConnection[Any], skill: Skill) -> list[str]:
297
+ cursor = await connection.execute(
298
+ self._sql(
299
+ "SELECT skill_id FROM {table} WHERE name = %s AND version = %s"
300
+ " AND state = %s AND skill_id <> %s"
301
+ ),
302
+ (skill.metadata.name, skill.metadata.version, _ACTIVE, skill.skill_id),
303
+ )
304
+ return [row[0] for row in await cursor.fetchall()]
305
+
306
+ async def _fetch(self, query: str, params: tuple[Any, ...]) -> list[Any]:
307
+ async with self._connection() as connection:
308
+ cursor = await connection.execute(self._sql(query), params)
309
+ return await cursor.fetchall()
310
+
311
+ def _sql(self, query: str) -> Any:
312
+ return _sql.SQL(query).format(table=self._table, active=self._active_index)
313
+
314
+ def _connection(self) -> AbstractAsyncContextManager[AsyncConnection[Any]]:
315
+ return _borrow(self._pool, "PostgreSQL Skill registry operation failed")
316
+
317
+
318
+ __all__ = ["PostgresSkillRegistry", "PostgresSkillSource"]
skill_manager/py.typed ADDED
File without changes
@@ -0,0 +1,195 @@
1
+ """Skill catalog contract and its in-memory implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from abc import ABC, abstractmethod
7
+ from typing import TYPE_CHECKING
8
+
9
+ from packaging.version import Version
10
+
11
+ from skill_manager.errors import SkillConflictError, SkillNotFoundError
12
+ from skill_manager.models import Skill, SkillLifecycleState
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterable
16
+
17
+
18
+ class SkillRegistry(ABC):
19
+ """Validated Skill catalog used by `SkillManager`.
20
+
21
+ Implementations may keep the catalog in process (`InMemorySkillRegistry`) or
22
+ in a shared database (`skill_manager.postgres.PostgresSkillRegistry`). Every
23
+ implementation must keep these promises, which
24
+ `skill_manager.testing.SkillRegistryContract` verifies:
25
+
26
+ - A revision is immutable: writing a different Skill with the same ID and
27
+ revision raises `SkillConflictError`, and writing an older revision is a
28
+ stale update that raises `SkillConflictError`.
29
+ - At most one ``ACTIVE`` revision exists per name and version.
30
+ - `get` and `remove` raise `SkillNotFoundError` for unknown IDs.
31
+ """
32
+
33
+ @abstractmethod
34
+ async def put(self, skill: Skill) -> None:
35
+ """Insert a Skill revision, or idempotently rewrite an identical one."""
36
+
37
+ @abstractmethod
38
+ async def check_put(self, skill: Skill) -> None:
39
+ """Raise the error `put` would raise, without writing anything."""
40
+
41
+ @abstractmethod
42
+ async def get(self, skill_id: str) -> Skill:
43
+ """Return a Skill by immutable ID."""
44
+
45
+ @abstractmethod
46
+ async def by_name(self, name: str, version: str | None = None) -> Skill:
47
+ """Return the active Skill with ``name``.
48
+
49
+ Without ``version``, return the highest PEP 440 version.
50
+ """
51
+
52
+ @abstractmethod
53
+ async def all(
54
+ self, *, states: Iterable[SkillLifecycleState] | None = None
55
+ ) -> tuple[Skill, ...]:
56
+ """Return every Skill, optionally only those in ``states``."""
57
+
58
+ @abstractmethod
59
+ async def remove(self, skill_id: str) -> Skill:
60
+ """Remove and return one Skill revision."""
61
+
62
+ @abstractmethod
63
+ async def replace_lifecycle(self, skill: Skill) -> None:
64
+ """Overwrite an existing Skill after a lifecycle transition."""
65
+
66
+ async def aclose(self) -> None: # noqa: B027 - optional hook with a no-op default
67
+ """Release resources; the default does nothing."""
68
+
69
+
70
+ def check_write(existing: Skill | None, skill: Skill, active_peers: Iterable[str]) -> None:
71
+ """Apply the registry write rules shared by every implementation.
72
+
73
+ Args:
74
+ existing: The stored revision with the same ID, if any.
75
+ skill: The revision being written.
76
+ active_peers: IDs of other ``ACTIVE`` revisions with the same name and version.
77
+
78
+ Raises:
79
+ SkillConflictError: If the write is stale, overwrites an immutable
80
+ revision, or creates a second active revision.
81
+ """
82
+ if skill.lifecycle.state is SkillLifecycleState.ACTIVE and any(
83
+ peer != skill.skill_id for peer in active_peers
84
+ ):
85
+ raise SkillConflictError(
86
+ f"multiple active revisions for Skill {skill.metadata.name!r} "
87
+ f"version {skill.metadata.version!r}"
88
+ )
89
+ if existing is None:
90
+ return
91
+ if existing.revision > skill.revision:
92
+ raise SkillConflictError(f"stale update for Skill {skill.skill_id!r}")
93
+ if existing.revision == skill.revision and existing != skill:
94
+ raise SkillConflictError(
95
+ f"immutable Skill revision {skill.skill_id!r} cannot be overwritten"
96
+ )
97
+
98
+
99
+ def latest(skills: Iterable[Skill]) -> Skill:
100
+ """Return the Skill with the highest PEP 440 version, breaking ties by ID."""
101
+ return max(skills, key=lambda item: (Version(item.metadata.version), item.skill_id))
102
+
103
+
104
+ class InMemorySkillRegistry(SkillRegistry):
105
+ """Concurrency-safe, process-local Skill registry."""
106
+
107
+ def __init__(self) -> None:
108
+ self._by_id: dict[str, Skill] = {}
109
+ self._by_key: dict[tuple[str, str], set[str]] = {}
110
+ self._lock = asyncio.Lock()
111
+
112
+ def _active_peers(self, skill: Skill) -> set[str]:
113
+ key = (skill.metadata.name, skill.metadata.version)
114
+ return {
115
+ skill_id
116
+ for skill_id in self._by_key.get(key, ())
117
+ if self._by_id[skill_id].lifecycle.state is SkillLifecycleState.ACTIVE
118
+ }
119
+
120
+ async def put(self, skill: Skill) -> None:
121
+ """Insert or idempotently rewrite one revision."""
122
+ async with self._lock:
123
+ check_write(self._by_id.get(skill.skill_id), skill, self._active_peers(skill))
124
+ self._by_id[skill.skill_id] = skill
125
+ self._by_key.setdefault((skill.metadata.name, skill.metadata.version), set()).add(
126
+ skill.skill_id
127
+ )
128
+
129
+ async def check_put(self, skill: Skill) -> None:
130
+ """Validate a write without applying it."""
131
+ async with self._lock:
132
+ check_write(self._by_id.get(skill.skill_id), skill, self._active_peers(skill))
133
+
134
+ async def get(self, skill_id: str) -> Skill:
135
+ """Return a Skill by ID."""
136
+ async with self._lock:
137
+ try:
138
+ return self._by_id[skill_id]
139
+ except KeyError:
140
+ raise SkillNotFoundError(f"Skill {skill_id!r} was not found") from None
141
+
142
+ async def by_name(self, name: str, version: str | None = None) -> Skill:
143
+ """Return the active Skill with ``name`` and optional exact ``version``."""
144
+ async with self._lock:
145
+ matches = [
146
+ skill
147
+ for (skill_name, skill_version), ids in self._by_key.items()
148
+ if skill_name == name and version in {None, skill_version}
149
+ for skill_id in ids
150
+ if (skill := self._by_id[skill_id]).lifecycle.state is SkillLifecycleState.ACTIVE
151
+ ]
152
+ if not matches:
153
+ raise SkillNotFoundError(f"Skill {name!r} version {version!r} was not found")
154
+ return latest(matches)
155
+
156
+ async def all(
157
+ self, *, states: Iterable[SkillLifecycleState] | None = None
158
+ ) -> tuple[Skill, ...]:
159
+ """Return every Skill, optionally filtered by lifecycle state."""
160
+ async with self._lock:
161
+ values = tuple(self._by_id.values())
162
+ if states is None:
163
+ return values
164
+ wanted = frozenset(states)
165
+ return tuple(skill for skill in values if skill.lifecycle.state in wanted)
166
+
167
+ async def remove(self, skill_id: str) -> Skill:
168
+ """Remove and return one revision."""
169
+ async with self._lock:
170
+ try:
171
+ skill = self._by_id.pop(skill_id)
172
+ except KeyError:
173
+ raise SkillNotFoundError(f"Skill {skill_id!r} was not found") from None
174
+ key = (skill.metadata.name, skill.metadata.version)
175
+ ids = self._by_key[key]
176
+ ids.discard(skill_id)
177
+ if not ids:
178
+ del self._by_key[key]
179
+ return skill
180
+
181
+ async def replace_lifecycle(self, skill: Skill) -> None:
182
+ """Overwrite an existing revision after a lifecycle transition."""
183
+ async with self._lock:
184
+ if skill.skill_id not in self._by_id:
185
+ raise SkillNotFoundError(f"Skill {skill.skill_id!r} was not found")
186
+ if skill.lifecycle.state is SkillLifecycleState.ACTIVE and (
187
+ self._active_peers(skill) - {skill.skill_id}
188
+ ):
189
+ raise SkillConflictError(
190
+ f"another active revision already exists for {skill.metadata.name!r}"
191
+ )
192
+ self._by_id[skill.skill_id] = skill
193
+
194
+
195
+ __all__ = ["InMemorySkillRegistry", "SkillRegistry", "check_write", "latest"]