deploypod 0.1.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.
- deploypod/__init__.py +31 -0
- deploypod/_http.py +73 -0
- deploypod/auth.py +90 -0
- deploypod/client.py +122 -0
- deploypod/env.py +49 -0
- deploypod/exceptions.py +39 -0
- deploypod/py.typed +0 -0
- deploypod/resources/__init__.py +0 -0
- deploypod/resources/agents.py +430 -0
- deploypod/resources/async_tasks.py +84 -0
- deploypod/resources/connections.py +57 -0
- deploypod/resources/conversations.py +41 -0
- deploypod/resources/custom_tools.py +126 -0
- deploypod/resources/groups.py +71 -0
- deploypod/resources/jobs.py +28 -0
- deploypod/resources/knowledge_bases.py +47 -0
- deploypod/resources/knowledge_sources.py +141 -0
- deploypod/resources/mcp_servers.py +117 -0
- deploypod/resources/organization.py +150 -0
- deploypod/resources/providers.py +64 -0
- deploypod/resources/skills.py +75 -0
- deploypod/resources/users.py +56 -0
- deploypod/types.py +390 -0
- deploypod-0.1.0.dist-info/METADATA +104 -0
- deploypod-0.1.0.dist-info/RECORD +28 -0
- deploypod-0.1.0.dist-info/WHEEL +5 -0
- deploypod-0.1.0.dist-info/licenses/LICENSE +21 -0
- deploypod-0.1.0.dist-info/top_level.txt +1 -0
deploypod/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from deploypod.auth import AppCredentials
|
|
4
|
+
from deploypod.client import DeployPodClient, DeploypodClient
|
|
5
|
+
from deploypod.env import get_client_from_env
|
|
6
|
+
from deploypod.exceptions import (
|
|
7
|
+
DeploypodError,
|
|
8
|
+
DeployPodError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
ConflictError,
|
|
13
|
+
LicenseError,
|
|
14
|
+
ServerError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"AppCredentials",
|
|
20
|
+
"DeploypodClient",
|
|
21
|
+
"DeployPodClient",
|
|
22
|
+
"get_client_from_env",
|
|
23
|
+
"DeploypodError",
|
|
24
|
+
"DeployPodError",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"ValidationError",
|
|
28
|
+
"ConflictError",
|
|
29
|
+
"LicenseError",
|
|
30
|
+
"ServerError",
|
|
31
|
+
]
|
deploypod/_http.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared HTTP utilities — imported by both client.py and resource modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from deploypod.exceptions import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
DeploypodError,
|
|
13
|
+
LicenseError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
ServerError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_STATUS_MAP: dict[int, type[DeploypodError]] = {
|
|
21
|
+
401: AuthenticationError,
|
|
22
|
+
403: AuthenticationError,
|
|
23
|
+
404: NotFoundError,
|
|
24
|
+
409: ConflictError,
|
|
25
|
+
422: ValidationError,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def raise_for_status(resp: httpx.Response) -> None:
|
|
30
|
+
"""Raise a typed DeploypodError for non-2xx responses."""
|
|
31
|
+
if resp.is_success:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
body = resp.json()
|
|
36
|
+
except Exception:
|
|
37
|
+
body = {"detail": resp.text}
|
|
38
|
+
|
|
39
|
+
detail = body.get("detail") or body.get("message") or resp.text
|
|
40
|
+
|
|
41
|
+
# License-specific 403
|
|
42
|
+
if resp.status_code == 403 and "license" in str(detail).lower():
|
|
43
|
+
raise LicenseError(resp.status_code, detail, body)
|
|
44
|
+
|
|
45
|
+
exc_cls = _STATUS_MAP.get(resp.status_code)
|
|
46
|
+
if exc_cls:
|
|
47
|
+
raise exc_cls(resp.status_code, detail, body)
|
|
48
|
+
|
|
49
|
+
if resp.status_code >= 500:
|
|
50
|
+
raise ServerError(resp.status_code, detail, body)
|
|
51
|
+
|
|
52
|
+
raise DeploypodError(resp.status_code, detail, body)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def json_body(resp: httpx.Response) -> Any:
|
|
56
|
+
"""The response's JSON, or {} when it carries none.
|
|
57
|
+
|
|
58
|
+
Several endpoints answer a successful delete or assign with 204 and no
|
|
59
|
+
body; parsing that as JSON raised JSONDecodeError after the server had
|
|
60
|
+
already done the work.
|
|
61
|
+
"""
|
|
62
|
+
if resp.status_code == 204 or not resp.content:
|
|
63
|
+
return {}
|
|
64
|
+
return resp.json()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def message_result(resp: httpx.Response) -> "MessageResult":
|
|
68
|
+
"""A {message: ...} response as MessageResult; empty for a 204."""
|
|
69
|
+
from deploypod.types import MessageResult
|
|
70
|
+
|
|
71
|
+
body = json_body(resp)
|
|
72
|
+
message = body.get("message", "") if isinstance(body, dict) else ""
|
|
73
|
+
return MessageResult(message=message)
|
deploypod/auth.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Signing in with an application's own credentials (machine to machine).
|
|
2
|
+
|
|
3
|
+
An administrator gives an application an ``app_id`` and an ``app_secret``.
|
|
4
|
+
The application trades them for an access token at Deploypod's identity
|
|
5
|
+
provider; the token lasts an hour. ``AppCredentials`` does the trade, keeps
|
|
6
|
+
the token, and fetches a new one shortly before it expires or when the API
|
|
7
|
+
answers 401.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Generator, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from deploypod.exceptions import AuthenticationError
|
|
19
|
+
|
|
20
|
+
TOKEN_URL = "https://auth.deploypod.ai/oidc/token"
|
|
21
|
+
API_RESOURCE = "https://api.deploypod.io"
|
|
22
|
+
|
|
23
|
+
# A token is replaced this many seconds before it expires, so a request
|
|
24
|
+
# never leaves with one that runs out on the way.
|
|
25
|
+
_EARLY_REFRESH_S = 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AppCredentials(httpx.Auth):
|
|
29
|
+
"""httpx auth that sends a bearer token obtained with client credentials."""
|
|
30
|
+
|
|
31
|
+
# The request may be sent twice (see auth_flow), so httpx must keep its
|
|
32
|
+
# body, file uploads included, instead of streaming it once.
|
|
33
|
+
requires_request_body = True
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
app_id: str,
|
|
38
|
+
app_secret: str,
|
|
39
|
+
*,
|
|
40
|
+
token_url: str = TOKEN_URL,
|
|
41
|
+
resource: str = API_RESOURCE,
|
|
42
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
43
|
+
headers: Optional[dict[str, str]] = None,
|
|
44
|
+
):
|
|
45
|
+
self._app_id = app_id
|
|
46
|
+
self._app_secret = app_secret
|
|
47
|
+
self._token_url = token_url
|
|
48
|
+
self._resource = resource
|
|
49
|
+
self._transport = transport
|
|
50
|
+
self._headers = headers or {}
|
|
51
|
+
self._token: Optional[str] = None
|
|
52
|
+
self._expires_at = 0.0
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
def _fetch(self) -> None:
|
|
56
|
+
with httpx.Client(transport=self._transport, headers=self._headers, timeout=30.0) as c:
|
|
57
|
+
resp = c.post(
|
|
58
|
+
self._token_url,
|
|
59
|
+
auth=(self._app_id, self._app_secret),
|
|
60
|
+
data={
|
|
61
|
+
"grant_type": "client_credentials",
|
|
62
|
+
"resource": self._resource,
|
|
63
|
+
"scope": "all",
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
if not resp.is_success:
|
|
67
|
+
raise AuthenticationError(
|
|
68
|
+
resp.status_code,
|
|
69
|
+
f"could not get an access token with app_id {self._app_id!r}: {resp.text}",
|
|
70
|
+
None,
|
|
71
|
+
)
|
|
72
|
+
body = resp.json()
|
|
73
|
+
self._token = body["access_token"]
|
|
74
|
+
self._expires_at = time.monotonic() + float(body.get("expires_in", 3600))
|
|
75
|
+
|
|
76
|
+
def token(self, *, renew: bool = False) -> str:
|
|
77
|
+
"""The current access token, fetched or renewed when needed."""
|
|
78
|
+
with self._lock:
|
|
79
|
+
if renew or self._token is None or time.monotonic() >= self._expires_at - _EARLY_REFRESH_S:
|
|
80
|
+
self._fetch()
|
|
81
|
+
assert self._token is not None
|
|
82
|
+
return self._token
|
|
83
|
+
|
|
84
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
85
|
+
request.headers["Authorization"] = f"Bearer {self.token()}"
|
|
86
|
+
response = yield request
|
|
87
|
+
if response.status_code == 401:
|
|
88
|
+
# Revoked or rotated early: one fresh token, one retry.
|
|
89
|
+
request.headers["Authorization"] = f"Bearer {self.token(renew=True)}"
|
|
90
|
+
yield request
|
deploypod/client.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from deploypod import __version__
|
|
8
|
+
from deploypod.auth import AppCredentials
|
|
9
|
+
from deploypod.resources.agents import AgentsResource
|
|
10
|
+
from deploypod.resources.knowledge_bases import KnowledgeBasesResource
|
|
11
|
+
from deploypod.resources.knowledge_sources import KnowledgeSourcesResource
|
|
12
|
+
from deploypod.resources.skills import SkillsResource
|
|
13
|
+
from deploypod.resources.custom_tools import CustomToolsResource
|
|
14
|
+
from deploypod.resources.mcp_servers import MCPServersResource
|
|
15
|
+
from deploypod.resources.providers import ProvidersResource
|
|
16
|
+
from deploypod.resources.connections import ConnectionsResource
|
|
17
|
+
from deploypod.resources.conversations import ConversationsResource
|
|
18
|
+
from deploypod.resources.users import UsersResource
|
|
19
|
+
from deploypod.resources.groups import GroupsResource
|
|
20
|
+
from deploypod.resources.organization import OrganizationResource
|
|
21
|
+
from deploypod.resources.async_tasks import AsyncTasksResource
|
|
22
|
+
from deploypod.resources.jobs import JobsResource
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://deploypod.ai/api"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DeploypodClient:
|
|
29
|
+
"""Synchronous Python client for the Deploypod API.
|
|
30
|
+
|
|
31
|
+
Sign in with your application's credentials (an administrator creates
|
|
32
|
+
them; the client gets and renews the access token itself)::
|
|
33
|
+
|
|
34
|
+
from deploypod import DeploypodClient
|
|
35
|
+
|
|
36
|
+
client = DeploypodClient(org_id="42", app_id="...", app_secret="...")
|
|
37
|
+
for agent in client.agents.list():
|
|
38
|
+
print(agent.uuid, agent.name)
|
|
39
|
+
|
|
40
|
+
or with an access token you already have::
|
|
41
|
+
|
|
42
|
+
client = DeploypodClient(org_id="42", token="<access token>")
|
|
43
|
+
|
|
44
|
+
``base_url`` defaults to Deploypod's cloud; pass your own for an
|
|
45
|
+
on-premises installation.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
org_id: str | None = None,
|
|
52
|
+
token: str | None = None,
|
|
53
|
+
*,
|
|
54
|
+
app_id: str | None = None,
|
|
55
|
+
app_secret: str | None = None,
|
|
56
|
+
token_url: str | None = None,
|
|
57
|
+
timeout: float = 30.0,
|
|
58
|
+
extra_headers: dict[str, str] | None = None,
|
|
59
|
+
transport: httpx.BaseTransport | None = None,
|
|
60
|
+
):
|
|
61
|
+
if org_id is None or str(org_id) == "":
|
|
62
|
+
raise ValueError("org_id is required: your organization's number in Deploypod")
|
|
63
|
+
if token and (app_id or app_secret):
|
|
64
|
+
raise ValueError("pass either token, or app_id and app_secret, not both")
|
|
65
|
+
if not token and not (app_id and app_secret):
|
|
66
|
+
raise ValueError("pass token, or app_id and app_secret")
|
|
67
|
+
|
|
68
|
+
# Cloudflare in front of deploypod.ai refuses requests that carry
|
|
69
|
+
# no User-Agent; name the SDK so they always have one.
|
|
70
|
+
headers: dict[str, str] = {
|
|
71
|
+
"org_id": str(org_id),
|
|
72
|
+
"User-Agent": f"deploypod-python/{__version__}",
|
|
73
|
+
}
|
|
74
|
+
auth: httpx.Auth | None = None
|
|
75
|
+
if token:
|
|
76
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
77
|
+
else:
|
|
78
|
+
kw = {"token_url": token_url} if token_url else {}
|
|
79
|
+
auth = AppCredentials(
|
|
80
|
+
app_id, app_secret, transport=transport,
|
|
81
|
+
headers={"User-Agent": headers["User-Agent"]}, **kw,
|
|
82
|
+
)
|
|
83
|
+
if extra_headers:
|
|
84
|
+
headers.update(extra_headers)
|
|
85
|
+
|
|
86
|
+
self._http = httpx.Client(
|
|
87
|
+
base_url=base_url,
|
|
88
|
+
headers=headers,
|
|
89
|
+
auth=auth,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
transport=transport,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Resource namespaces
|
|
95
|
+
self.agents = AgentsResource(self._http)
|
|
96
|
+
self.knowledge_bases = KnowledgeBasesResource(self._http)
|
|
97
|
+
self.knowledge_sources = KnowledgeSourcesResource(self._http)
|
|
98
|
+
self.skills = SkillsResource(self._http)
|
|
99
|
+
self.custom_tools = CustomToolsResource(self._http)
|
|
100
|
+
self.mcp_servers = MCPServersResource(self._http)
|
|
101
|
+
self.providers = ProvidersResource(self._http)
|
|
102
|
+
self.connections = ConnectionsResource(self._http)
|
|
103
|
+
self.conversations = ConversationsResource(self._http)
|
|
104
|
+
self.users = UsersResource(self._http)
|
|
105
|
+
self.groups = GroupsResource(self._http)
|
|
106
|
+
self.organization = OrganizationResource(self._http)
|
|
107
|
+
self.async_tasks = AsyncTasksResource(self._http)
|
|
108
|
+
self.jobs = JobsResource(self._http)
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self._http.close()
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> DeploypodClient:
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *args: Any) -> None:
|
|
117
|
+
self.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# The name before the package went public. Kept so existing code runs
|
|
121
|
+
# unchanged; new code uses DeploypodClient.
|
|
122
|
+
DeployPodClient = DeploypodClient
|
deploypod/env.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""A client built from environment variables, for scripts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from deploypod.client import DEFAULT_BASE_URL, DeploypodClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_client_from_env(
|
|
13
|
+
deploypod_repo: Optional[Path] = None,
|
|
14
|
+
) -> DeploypodClient:
|
|
15
|
+
"""Build a DeploypodClient from environment variables.
|
|
16
|
+
|
|
17
|
+
DEPLOYPOD_ORG_ID — your organization's number. Required.
|
|
18
|
+
DEPLOYPOD_APP_ID,
|
|
19
|
+
DEPLOYPOD_APP_SECRET — your application's credentials; the client gets
|
|
20
|
+
and renews the access token itself.
|
|
21
|
+
DEPLOYPOD_TOKEN — or an access token you already have. If neither
|
|
22
|
+
is set and ``deploypod_repo`` is given, the token
|
|
23
|
+
comes from ``<deploypod_repo>/scripts/get_token.sh``
|
|
24
|
+
(Deploypod's own development scripts).
|
|
25
|
+
DEPLOYPOD_BASE_URL — API address, default https://deploypod.ai/api.
|
|
26
|
+
|
|
27
|
+
Raises RuntimeError if the organization or the credentials are missing.
|
|
28
|
+
"""
|
|
29
|
+
org_id = os.environ.get("DEPLOYPOD_ORG_ID")
|
|
30
|
+
if not org_id:
|
|
31
|
+
raise RuntimeError("DEPLOYPOD_ORG_ID is not set: export your organization's number.")
|
|
32
|
+
base_url = os.environ.get("DEPLOYPOD_BASE_URL", DEFAULT_BASE_URL)
|
|
33
|
+
|
|
34
|
+
app_id = os.environ.get("DEPLOYPOD_APP_ID")
|
|
35
|
+
app_secret = os.environ.get("DEPLOYPOD_APP_SECRET")
|
|
36
|
+
if app_id and app_secret:
|
|
37
|
+
return DeploypodClient(base_url, org_id, app_id=app_id, app_secret=app_secret)
|
|
38
|
+
|
|
39
|
+
token = os.environ.get("DEPLOYPOD_TOKEN")
|
|
40
|
+
if not token and deploypod_repo is not None:
|
|
41
|
+
script = Path(deploypod_repo) / "scripts" / "get_token.sh"
|
|
42
|
+
if script.exists():
|
|
43
|
+
token = subprocess.check_output([str(script)], text=True).strip()
|
|
44
|
+
if not token:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"No credentials: export DEPLOYPOD_APP_ID and DEPLOYPOD_APP_SECRET, "
|
|
47
|
+
"or DEPLOYPOD_TOKEN."
|
|
48
|
+
)
|
|
49
|
+
return DeploypodClient(base_url, org_id, token)
|
deploypod/exceptions.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DeploypodError(Exception):
|
|
5
|
+
"""Base exception for all Deploypod SDK errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, status_code: int, detail: str, response_body: dict | None = None):
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self.detail = detail
|
|
10
|
+
self.response_body = response_body
|
|
11
|
+
super().__init__(f"[{status_code}] {detail}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# The name before the package went public, kept for existing code.
|
|
15
|
+
DeployPodError = DeploypodError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthenticationError(DeploypodError):
|
|
19
|
+
"""401/403 — invalid or missing credentials."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotFoundError(DeploypodError):
|
|
23
|
+
"""404 — resource does not exist."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValidationError(DeploypodError):
|
|
27
|
+
"""422 — request body failed validation."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConflictError(DeploypodError):
|
|
31
|
+
"""409 — resource conflict (e.g. duplicate slug)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LicenseError(DeploypodError):
|
|
35
|
+
"""403 — license limit exceeded or feature not available."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ServerError(DeploypodError):
|
|
39
|
+
"""5xx — unexpected server error."""
|
deploypod/py.typed
ADDED
|
File without changes
|
|
File without changes
|