persona-sdk-python 0.2.25__tar.gz → 0.2.26__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.
Files changed (41) hide show
  1. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/.github/workflows/ci.yml +4 -3
  2. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/.github/workflows/deploy.yml +5 -4
  3. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/PKG-INFO +83 -2
  4. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/README.md +81 -0
  5. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/__init__.py +24 -0
  6. persona_sdk_python-0.2.26/persona_sdk/api/model_aliases.py +131 -0
  7. persona_sdk_python-0.2.26/persona_sdk/api/notifications.py +50 -0
  8. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/client.py +10 -5
  9. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/pyproject.toml +13 -2
  10. persona_sdk_python-0.2.26/tests/__init__.py +0 -0
  11. persona_sdk_python-0.2.26/tests/conftest.py +53 -0
  12. persona_sdk_python-0.2.26/tests/test_model_aliases.py +236 -0
  13. persona_sdk_python-0.2.26/tests/test_notifications.py +68 -0
  14. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/uv.lock +175 -5
  15. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/.gitignore +0 -0
  16. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/DEPLOY.md +0 -0
  17. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/MIGRATION_GITHUB.md +0 -0
  18. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/_serde.py +0 -0
  19. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/__init__.py +0 -0
  20. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/agents.py +0 -0
  21. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/channels.py +0 -0
  22. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/credentials.py +0 -0
  23. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/features.py +0 -0
  24. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/human_tasks.py +0 -0
  25. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/knowledge_bases.py +0 -0
  26. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/markup_rules.py +0 -0
  27. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/missions.py +0 -0
  28. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/projects.py +0 -0
  29. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/providers.py +0 -0
  30. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/service_prices.py +0 -0
  31. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/sessions.py +0 -0
  32. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/triggers.py +0 -0
  33. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/api/workflows.py +0 -0
  34. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/auth/__init__.py +0 -0
  35. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/auth/api_key_auth.py +0 -0
  36. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/auth/base.py +0 -0
  37. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/auth/bearer_token_auth.py +0 -0
  38. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/runners/__init__.py +0 -0
  39. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/runners/agent_runner.py +0 -0
  40. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/runners/resource_retriever.py +0 -0
  41. {persona_sdk_python-0.2.25 → persona_sdk_python-0.2.26}/persona_sdk/runners/tool_invoker.py +0 -0
@@ -1,7 +1,7 @@
1
1
  name: CI
2
2
 
3
- # Smoke su PR: il repo non ha suite di test (non ne aveva in Bitbucket).
4
- # Lo smoke verifica che uv.lock risolva (--frozen) e che il package importi.
3
+ # Smoke + test su PR: verifica che uv.lock risolva (--frozen), che il package importi
4
+ # e che i test passino (httpx MockTransport: niente rete, niente server).
5
5
  # Il publish avviene solo al push su main (deploy.yml).
6
6
 
7
7
  on:
@@ -23,7 +23,8 @@ jobs:
23
23
  python-version: '3.12'
24
24
  - name: Installa uv
25
25
  run: pip install uv
26
- - name: Smoke import
26
+ - name: Smoke import + test
27
27
  run: |
28
28
  uv sync --frozen
29
29
  uv run python -c "import persona_sdk; print('smoke ok:', persona_sdk.__name__)"
30
+ uv run pytest -q
@@ -64,7 +64,7 @@ jobs:
64
64
  echo "Versione finale: ${VERSION}"
65
65
 
66
66
  test:
67
- name: Test (smoke import)
67
+ name: Test (smoke import + pytest)
68
68
  runs-on: ubuntu-latest
69
69
  if: github.event_name == 'push' || inputs.mode == 'test-and-publish'
70
70
  steps:
@@ -74,12 +74,13 @@ jobs:
74
74
  python-version: '3.12'
75
75
  - name: Installa uv
76
76
  run: pip install uv
77
- - name: Smoke import
78
- # il repo non ha suite di test (non ne aveva in Bitbucket): lo smoke
79
- # verifica che uv.lock risolva e che il package importi
77
+ - name: Smoke import + test
78
+ # lo smoke verifica che uv.lock risolva e che il package importi;
79
+ # pytest esegue i test (httpx MockTransport: niente rete, niente server)
80
80
  run: |
81
81
  uv sync --frozen
82
82
  uv run python -c "import persona_sdk; print('smoke ok:', persona_sdk.__name__)"
83
+ uv run pytest -q
83
84
 
84
85
  build-package:
85
86
  name: Build & check sdist/wheel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: persona-sdk-python
3
- Version: 0.2.25
3
+ Version: 0.2.26
4
4
  Summary: Async Python SDK for the Persona AI platform
5
5
  Project-URL: Homepage, https://persona.applica.guru
6
6
  Project-URL: Repository, https://bitbucket.org/applicaguru/persona-sdk-python
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: httpx>=0.27.0
22
- Requires-Dist: persona-models>=0.2.16
22
+ Requires-Dist: persona-models>=0.2.20
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  <div align="center">
@@ -140,6 +140,8 @@ The `PersonaSdk` instance exposes one client per resource:
140
140
  | `sdk.features` | Feature templates and MCP tools |
141
141
  | `sdk.credentials` | OAuth credentials and authorization |
142
142
  | `sdk.service_prices` | Service pricing |
143
+ | `sdk.model_aliases` | Model aliases (`alias:<name>` -> concrete model), impact, health check, suggestions |
144
+ | `sdk.notifications` | In-platform notifications (badge, read state) |
143
145
 
144
146
  Each API method is fully async. Always close the SDK when done to release the
145
147
  underlying HTTP connection pool:
@@ -223,6 +225,85 @@ page = await sdk.agents.list(keyword="bot", page=1, size=20)
223
225
  # page.size -> 20
224
226
  ```
225
227
 
228
+ ## Model aliases
229
+
230
+ A model alias is a named indirection: agents set their model to `alias:<name>` and
231
+ the alias holds the concrete `provider/model`, ordered fallbacks and optional
232
+ defaults, so changing one alias moves every agent that uses it. Writing needs a
233
+ super admin, a project admin or the project API key.
234
+
235
+ ```python
236
+ from persona_sdk import ModelAlias, ModelAliasDefaults, alias_ref
237
+
238
+ # Create: only name, target, projectId, description, fallbacks and defaults are
239
+ # sent (health, history and timestamps are managed by the server). A plain dict
240
+ # with the same keys works too.
241
+ alias = await sdk.model_aliases.create(
242
+ ModelAlias(
243
+ name="smart-default",
244
+ target="openai/gpt-5",
245
+ fallbacks=["anthropic/claude-sonnet-4"],
246
+ defaults=ModelAliasDefaults(max_output_tokens=4000),
247
+ )
248
+ )
249
+
250
+ # Point an agent at it
251
+ agent.model.model_name = alias_ref("smart-default") # "alias:smart-default"
252
+
253
+ page = await sdk.model_aliases.list(keyword="smart", page=1, size=20)
254
+ alias = await sdk.model_aliases.get(alias.id)
255
+
256
+ # Move every agent to another model (name and scope are immutable)
257
+ alias.target = "grok/grok-4"
258
+ alias = await sdk.model_aliases.update(alias.id, alias)
259
+
260
+ impact = await sdk.model_aliases.impact(alias.id) # AliasImpact: total, usages
261
+ alias = await sdk.model_aliases.check(alias.id) # runs the health check now
262
+ print(alias.health.status, alias.health.issues)
263
+ ```
264
+
265
+ ### Suggestions
266
+
267
+ Replacement and fallback candidates for the alias' target (or for another model,
268
+ e.g. a fallback that went missing). A proposal only: nothing is applied.
269
+
270
+ ```python
271
+ sugg = await sdk.model_aliases.suggestions(alias.id, model="grok/grok-4", limit=5)
272
+ for s in sugg.replacements:
273
+ print(s.model, s.context_window, s.warnings)
274
+ ```
275
+
276
+ ### Deleting an alias that is in use
277
+
278
+ Deleting an alias still referenced by agents is refused with a `409`. The SDK
279
+ raises the usual `httpx.HTTPStatusError`; `ModelAliasesApi.in_use` reads the
280
+ blocking agents from it, or returns `None` for any other error:
281
+
282
+ ```python
283
+ import httpx
284
+ from persona_sdk import ModelAliasesApi
285
+
286
+ try:
287
+ await sdk.model_aliases.remove(alias.id)
288
+ except httpx.HTTPStatusError as e:
289
+ impact = ModelAliasesApi.in_use(e)
290
+ if impact is None:
291
+ raise
292
+ print(f"used by {impact.total} agent(s):", [u.agent_id for u in impact.usages])
293
+ ```
294
+
295
+ ## Notifications
296
+
297
+ In-platform notifications (e.g. an alias whose model is being retired). A project
298
+ sees its own; platform-wide ones are for super admins.
299
+
300
+ ```python
301
+ page = await sdk.notifications.list(unread_only=True, page=1, size=20)
302
+ badge = await sdk.notifications.unread_count() # int
303
+ n = await sdk.notifications.mark_read(page.items[0].id) # -> Notification
304
+ await sdk.notifications.mark_all_read(project_id="p1") # returns None
305
+ ```
306
+
226
307
  ## Workflows
227
308
 
228
309
  If your workflows service runs on a different host, pass `workflows_url`:
@@ -116,6 +116,8 @@ The `PersonaSdk` instance exposes one client per resource:
116
116
  | `sdk.features` | Feature templates and MCP tools |
117
117
  | `sdk.credentials` | OAuth credentials and authorization |
118
118
  | `sdk.service_prices` | Service pricing |
119
+ | `sdk.model_aliases` | Model aliases (`alias:<name>` -> concrete model), impact, health check, suggestions |
120
+ | `sdk.notifications` | In-platform notifications (badge, read state) |
119
121
 
120
122
  Each API method is fully async. Always close the SDK when done to release the
121
123
  underlying HTTP connection pool:
@@ -199,6 +201,85 @@ page = await sdk.agents.list(keyword="bot", page=1, size=20)
199
201
  # page.size -> 20
200
202
  ```
201
203
 
204
+ ## Model aliases
205
+
206
+ A model alias is a named indirection: agents set their model to `alias:<name>` and
207
+ the alias holds the concrete `provider/model`, ordered fallbacks and optional
208
+ defaults, so changing one alias moves every agent that uses it. Writing needs a
209
+ super admin, a project admin or the project API key.
210
+
211
+ ```python
212
+ from persona_sdk import ModelAlias, ModelAliasDefaults, alias_ref
213
+
214
+ # Create: only name, target, projectId, description, fallbacks and defaults are
215
+ # sent (health, history and timestamps are managed by the server). A plain dict
216
+ # with the same keys works too.
217
+ alias = await sdk.model_aliases.create(
218
+ ModelAlias(
219
+ name="smart-default",
220
+ target="openai/gpt-5",
221
+ fallbacks=["anthropic/claude-sonnet-4"],
222
+ defaults=ModelAliasDefaults(max_output_tokens=4000),
223
+ )
224
+ )
225
+
226
+ # Point an agent at it
227
+ agent.model.model_name = alias_ref("smart-default") # "alias:smart-default"
228
+
229
+ page = await sdk.model_aliases.list(keyword="smart", page=1, size=20)
230
+ alias = await sdk.model_aliases.get(alias.id)
231
+
232
+ # Move every agent to another model (name and scope are immutable)
233
+ alias.target = "grok/grok-4"
234
+ alias = await sdk.model_aliases.update(alias.id, alias)
235
+
236
+ impact = await sdk.model_aliases.impact(alias.id) # AliasImpact: total, usages
237
+ alias = await sdk.model_aliases.check(alias.id) # runs the health check now
238
+ print(alias.health.status, alias.health.issues)
239
+ ```
240
+
241
+ ### Suggestions
242
+
243
+ Replacement and fallback candidates for the alias' target (or for another model,
244
+ e.g. a fallback that went missing). A proposal only: nothing is applied.
245
+
246
+ ```python
247
+ sugg = await sdk.model_aliases.suggestions(alias.id, model="grok/grok-4", limit=5)
248
+ for s in sugg.replacements:
249
+ print(s.model, s.context_window, s.warnings)
250
+ ```
251
+
252
+ ### Deleting an alias that is in use
253
+
254
+ Deleting an alias still referenced by agents is refused with a `409`. The SDK
255
+ raises the usual `httpx.HTTPStatusError`; `ModelAliasesApi.in_use` reads the
256
+ blocking agents from it, or returns `None` for any other error:
257
+
258
+ ```python
259
+ import httpx
260
+ from persona_sdk import ModelAliasesApi
261
+
262
+ try:
263
+ await sdk.model_aliases.remove(alias.id)
264
+ except httpx.HTTPStatusError as e:
265
+ impact = ModelAliasesApi.in_use(e)
266
+ if impact is None:
267
+ raise
268
+ print(f"used by {impact.total} agent(s):", [u.agent_id for u in impact.usages])
269
+ ```
270
+
271
+ ## Notifications
272
+
273
+ In-platform notifications (e.g. an alias whose model is being retired). A project
274
+ sees its own; platform-wide ones are for super admins.
275
+
276
+ ```python
277
+ page = await sdk.notifications.list(unread_only=True, page=1, size=20)
278
+ badge = await sdk.notifications.unread_count() # int
279
+ n = await sdk.notifications.mark_read(page.items[0].id) # -> Notification
280
+ await sdk.notifications.mark_all_read(project_id="p1") # returns None
281
+ ```
282
+
202
283
  ## Workflows
203
284
 
204
285
  If your workflows service runs on a different host, pass `workflows_url`:
@@ -6,6 +6,8 @@ from persona_sdk.api.features import FeaturesApi
6
6
  from persona_sdk.api.knowledge_bases import KnowledgeBasesApi
7
7
  from persona_sdk.api.markup_rules import MarkupRulesApi
8
8
  from persona_sdk.api.missions import MissionsApi
9
+ from persona_sdk.api.model_aliases import ModelAliasesApi
10
+ from persona_sdk.api.notifications import NotificationsApi
9
11
  from persona_sdk.api.projects import ProjectsApi
10
12
  from persona_sdk.api.providers import ProvidersApi
11
13
  from persona_sdk.api.service_prices import ServicePricesApi
@@ -36,6 +38,16 @@ from persona_models.knowledge_bases import (
36
38
  from persona_models.markup import MarkupRule
37
39
  from persona_models.mcp import GenerationRequest
38
40
  from persona_models.missions import CreateMissionRequest, Mission
41
+ from persona_models.model_aliases import (
42
+ AliasImpact,
43
+ AliasSuggestions,
44
+ ModelAlias,
45
+ ModelAliasDefaults,
46
+ alias_name_from_ref,
47
+ alias_ref,
48
+ is_alias_ref,
49
+ )
50
+ from persona_models.notifications import Notification
39
51
  from persona_models.projects import Project
40
52
  from persona_models.providers import Provider, ProviderModel
41
53
  from persona_models.service_prices import ServicePrice, UsageCost
@@ -55,6 +67,8 @@ __all__ = [
55
67
  "KnowledgeBasesApi",
56
68
  "MarkupRulesApi",
57
69
  "MissionsApi",
70
+ "ModelAliasesApi",
71
+ "NotificationsApi",
58
72
  "ProjectsApi",
59
73
  "ProvidersApi",
60
74
  "ServicePricesApi",
@@ -77,6 +91,14 @@ __all__ = [
77
91
  "GenerationResponse",
78
92
  "CreateMissionRequest",
79
93
  "Mission",
94
+ "ModelAlias",
95
+ "ModelAliasDefaults",
96
+ "AliasImpact",
97
+ "AliasSuggestions",
98
+ "Notification",
99
+ "alias_ref",
100
+ "is_alias_ref",
101
+ "alias_name_from_ref",
80
102
  "Project",
81
103
  "Provider",
82
104
  "ProviderModel",
@@ -152,6 +174,8 @@ class PersonaSdk:
152
174
  self.service_prices = ServicePricesApi(self.client)
153
175
  self.providers = ProvidersApi(self.client)
154
176
  self.markup_rules = MarkupRulesApi(self.client)
177
+ self.model_aliases = ModelAliasesApi(self.client)
178
+ self.notifications = NotificationsApi(self.client)
155
179
 
156
180
  async def close(self):
157
181
  await self.client.close()
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from persona_models.common import Paginated
7
+ from persona_models.model_aliases import AliasImpact, AliasSuggestions, ModelAlias
8
+
9
+ from persona_sdk.client import PersonaClient
10
+
11
+ # The only fields a client controls. Health, history and timestamps are managed by
12
+ # the server and persona-core rejects ``createdAt: null`` / ``updatedAt: null``.
13
+ _INPUT_FIELDS = {
14
+ "name": "name",
15
+ "target": "target",
16
+ "project_id": "projectId",
17
+ "projectId": "projectId",
18
+ "description": "description",
19
+ "fallbacks": "fallbacks",
20
+ "defaults": "defaults",
21
+ }
22
+
23
+
24
+ def _alias_payload(alias: ModelAlias | dict[str, Any]) -> dict[str, Any]:
25
+ """Build the write body: user-controlled fields only, camelCase, no ``None``."""
26
+ if isinstance(alias, ModelAlias):
27
+ data = alias.model_dump(by_alias=True, exclude_none=True)
28
+ return {k: data[k] for k in set(_INPUT_FIELDS.values()) if k in data}
29
+ payload: dict[str, Any] = {}
30
+ for key, value in alias.items():
31
+ field = _INPUT_FIELDS.get(key)
32
+ if field is None or value is None:
33
+ continue
34
+ if field == "defaults" and hasattr(value, "model_dump"):
35
+ value = value.model_dump(by_alias=True, exclude_none=True)
36
+ elif field == "defaults":
37
+ value = {k: v for k, v in value.items() if v is not None}
38
+ payload[field] = value
39
+ return payload
40
+
41
+
42
+ class ModelAliasesApi:
43
+ """Model aliases: a named indirection ``alias:<name>`` to a concrete
44
+ ``provider/model``, with ordered fallbacks and optional defaults. Writing needs
45
+ a super admin, a project admin or the project API key."""
46
+
47
+ def __init__(self, client: PersonaClient):
48
+ self._client = client
49
+
50
+ async def list(
51
+ self,
52
+ project_id: str | None = None,
53
+ keyword: str | None = None,
54
+ page: int = 1,
55
+ size: int = 100,
56
+ ) -> Paginated[ModelAlias]:
57
+ """Aliases visible to the caller (their project's and the global ones)."""
58
+ params: dict[str, Any] = {"page": page, "size": size}
59
+ if project_id:
60
+ params["projectId"] = project_id
61
+ if keyword:
62
+ params["keyword"] = keyword
63
+ data = await self._client.get("/model-aliases", params=params)
64
+ return Paginated[ModelAlias].model_validate(data)
65
+
66
+ async def get(self, alias_id: str) -> ModelAlias:
67
+ data = await self._client.get(f"/model-aliases/{alias_id}")
68
+ return ModelAlias.model_validate(data)
69
+
70
+ async def create(self, alias: ModelAlias | dict[str, Any]) -> ModelAlias:
71
+ """Create an alias. Only name, target, projectId, description, fallbacks and
72
+ defaults are sent; server-managed fields are never sent."""
73
+ data = await self._client.post("/model-aliases", json=_alias_payload(alias))
74
+ return ModelAlias.model_validate(data)
75
+
76
+ async def update(
77
+ self, alias_id: str, alias: ModelAlias | dict[str, Any]
78
+ ) -> ModelAlias:
79
+ """Change target, fallbacks, defaults and description (``name`` and scope
80
+ are immutable)."""
81
+ data = await self._client.put(
82
+ f"/model-aliases/{alias_id}", json=_alias_payload(alias)
83
+ )
84
+ return ModelAlias.model_validate(data)
85
+
86
+ async def remove(self, alias_id: str) -> None:
87
+ """Delete an alias. Refused with a 409 while agents still reference it: use
88
+ ``ModelAliasesApi.in_use`` on the raised error to read which ones."""
89
+ await self._client.delete(f"/model-aliases/{alias_id}")
90
+
91
+ async def impact(self, alias_id: str) -> AliasImpact:
92
+ """Agents that reference the alias, and in which model slot."""
93
+ data = await self._client.get(f"/model-aliases/{alias_id}/impact")
94
+ return AliasImpact.model_validate(data)
95
+
96
+ async def check(self, alias_id: str) -> ModelAlias:
97
+ """Run the health check now; stores the result in ``health``."""
98
+ data = await self._client.post(f"/model-aliases/{alias_id}/check")
99
+ return ModelAlias.model_validate(data)
100
+
101
+ async def suggestions(
102
+ self, alias_id: str, model: str | None = None, limit: int | None = None
103
+ ) -> AliasSuggestions:
104
+ """Replacement and fallback candidates for the alias' target, or for
105
+ ``model``. A proposal only: nothing is applied."""
106
+ params: dict[str, Any] = {}
107
+ if model:
108
+ params["model"] = model
109
+ if limit is not None:
110
+ params["limit"] = limit
111
+ data = await self._client.get(
112
+ f"/model-aliases/{alias_id}/suggestions", params=params or None
113
+ )
114
+ return AliasSuggestions.model_validate(data)
115
+
116
+ @staticmethod
117
+ def in_use(error: BaseException) -> AliasImpact | None:
118
+ """The agents blocking a delete, when ``error`` is the 409 raised by
119
+ :meth:`remove`; ``None`` for any other error."""
120
+ if (
121
+ not isinstance(error, httpx.HTTPStatusError)
122
+ or error.response.status_code != 409
123
+ ):
124
+ return None
125
+ try:
126
+ impact = error.response.json()["detail"]["impact"]
127
+ if not isinstance(impact.get("total"), int):
128
+ return None
129
+ return AliasImpact.model_validate(impact)
130
+ except Exception:
131
+ return None
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from persona_models.common import Paginated
6
+ from persona_models.notifications import Notification
7
+
8
+ from persona_sdk.client import PersonaClient
9
+
10
+
11
+ class NotificationsApi:
12
+ """In-platform notifications. A project sees its own; platform-wide ones (no
13
+ project) are for super admins. Read state is shared by everyone who can see a
14
+ notification."""
15
+
16
+ def __init__(self, client: PersonaClient):
17
+ self._client = client
18
+
19
+ async def list(
20
+ self,
21
+ unread_only: bool = False,
22
+ include_resolved: bool = False,
23
+ project_id: str | None = None,
24
+ page: int = 1,
25
+ size: int = 20,
26
+ ) -> Paginated[Notification]:
27
+ params: dict[str, Any] = {"page": page, "size": size}
28
+ if unread_only:
29
+ params["unreadOnly"] = True
30
+ if include_resolved:
31
+ params["includeResolved"] = True
32
+ if project_id:
33
+ params["projectId"] = project_id
34
+ data = await self._client.get("/notifications", params=params)
35
+ return Paginated[Notification].model_validate(data)
36
+
37
+ async def unread_count(self, project_id: str | None = None) -> int:
38
+ """Open, unread notifications: the value of the badge."""
39
+ params = {"projectId": project_id} if project_id else None
40
+ data = await self._client.get("/notifications/unread-count", params=params)
41
+ return data["count"]
42
+
43
+ async def mark_read(self, notification_id: str) -> Notification:
44
+ data = await self._client.post(f"/notifications/{notification_id}/read")
45
+ return Notification.model_validate(data)
46
+
47
+ async def mark_all_read(self, project_id: str | None = None) -> None:
48
+ """Mark as read everything the caller can see."""
49
+ params = {"projectId": project_id} if project_id else None
50
+ await self._client.post("/notifications/read-all", params=params)
@@ -8,6 +8,11 @@ from persona_sdk.auth import (
8
8
  )
9
9
 
10
10
 
11
+ def _body(response: httpx.Response) -> Any:
12
+ """Decoded JSON body, or ``None`` for an empty one (e.g. ``204 No Content``)."""
13
+ return response.json() if response.content else None
14
+
15
+
11
16
  class PersonaClient:
12
17
  """
13
18
  HTTP client for the Persona API.
@@ -56,7 +61,7 @@ class PersonaClient:
56
61
  async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
57
62
  response = await self._client.get(path, params=params)
58
63
  response.raise_for_status()
59
- return response.json()
64
+ return _body(response)
60
65
 
61
66
  async def post(
62
67
  self,
@@ -66,22 +71,22 @@ class PersonaClient:
66
71
  ) -> Any:
67
72
  response = await self._client.post(path, json=json, params=params)
68
73
  response.raise_for_status()
69
- return response.json()
74
+ return _body(response)
70
75
 
71
76
  async def put(self, path: str, json: dict[str, Any] | None = None) -> Any:
72
77
  response = await self._client.put(path, json=json)
73
78
  response.raise_for_status()
74
- return response.json()
79
+ return _body(response)
75
80
 
76
81
  async def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
77
82
  response = await self._client.patch(path, json=json)
78
83
  response.raise_for_status()
79
- return response.json()
84
+ return _body(response)
80
85
 
81
86
  async def delete(self, path: str) -> Any:
82
87
  response = await self._client.delete(path)
83
88
  response.raise_for_status()
84
- return response.json()
89
+ return _body(response)
85
90
 
86
91
  async def close(self):
87
92
  await self._client.aclose()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "persona-sdk-python"
3
- version = "0.2.25"
3
+ version = "0.2.26"
4
4
  description = "Async Python SDK for the Persona AI platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Framework :: AsyncIO",
23
23
  ]
24
24
  dependencies = [
25
- "persona-models>=0.2.16",
25
+ "persona-models>=0.2.20",
26
26
  "httpx>=0.27.0",
27
27
  ]
28
28
 
@@ -36,3 +36,14 @@ build-backend = "hatchling.build"
36
36
 
37
37
  [tool.hatch.build.targets.wheel]
38
38
  packages = ["persona_sdk"]
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=8",
43
+ "pytest-asyncio>=0.23",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ asyncio_default_fixture_loop_scope = "function"
49
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,53 @@
1
+ import json
2
+ from typing import Any, Callable
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ from persona_sdk import PersonaSdk
8
+
9
+
10
+ class Recorder:
11
+ """Records requests made through the SDK and answers with a canned response."""
12
+
13
+ def __init__(self):
14
+ self.calls: list[httpx.Request] = []
15
+ self.response: Callable[[httpx.Request], httpx.Response] = lambda r: httpx.Response(200, json={})
16
+
17
+ def reply(self, status: int = 200, body: Any = None):
18
+ def handler(request: httpx.Request) -> httpx.Response:
19
+ if body is None:
20
+ return httpx.Response(status)
21
+ return httpx.Response(status, json=body)
22
+
23
+ self.response = handler
24
+
25
+ @property
26
+ def last(self) -> httpx.Request:
27
+ return self.calls[-1]
28
+
29
+ def body(self) -> Any:
30
+ return json.loads(self.last.content)
31
+
32
+ def query(self) -> dict[str, list[str]]:
33
+ return {k: self.last.url.params.get_list(k) for k in self.last.url.params.keys()}
34
+
35
+
36
+ @pytest.fixture
37
+ async def mock():
38
+ rec = Recorder()
39
+
40
+ def handler(request: httpx.Request) -> httpx.Response:
41
+ rec.calls.append(request)
42
+ return rec.response(request)
43
+
44
+ sdk = PersonaSdk("http://persona.test", "prs_test")
45
+ await sdk.client._client.aclose()
46
+ sdk.client._client = httpx.AsyncClient(
47
+ base_url="http://persona.test",
48
+ headers=sdk.client.auth_provider.headers(),
49
+ transport=httpx.MockTransport(handler),
50
+ )
51
+ rec.sdk = sdk
52
+ yield rec
53
+ await sdk.close()