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 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)
@@ -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