everpod-ai 0.1.0__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.
- everpod_ai-0.1.0/LICENSE +21 -0
- everpod_ai-0.1.0/PKG-INFO +79 -0
- everpod_ai-0.1.0/README.md +63 -0
- everpod_ai-0.1.0/pyproject.toml +28 -0
- everpod_ai-0.1.0/setup.cfg +4 -0
- everpod_ai-0.1.0/src/everpod_ai/__init__.py +124 -0
- everpod_ai-0.1.0/src/everpod_ai/py.typed +1 -0
- everpod_ai-0.1.0/src/everpod_ai.egg-info/PKG-INFO +79 -0
- everpod_ai-0.1.0/src/everpod_ai.egg-info/SOURCES.txt +10 -0
- everpod_ai-0.1.0/src/everpod_ai.egg-info/dependency_links.txt +1 -0
- everpod_ai-0.1.0/src/everpod_ai.egg-info/top_level.txt +1 -0
- everpod_ai-0.1.0/tests/test_client.py +136 -0
everpod_ai-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Everpod
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: everpod-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Everpod API.
|
|
5
|
+
Author-email: Everpod <support@everpod.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://everpod.ai
|
|
8
|
+
Project-URL: Documentation, https://everpod.ai/docs/api
|
|
9
|
+
Project-URL: Repository, https://github.com/everpod-ai/sdk-python
|
|
10
|
+
Project-URL: Issues, https://github.com/everpod-ai/sdk-python/issues
|
|
11
|
+
Keywords: everpod,ai-agents,agent-hosting,openclaw,mcp,sdk
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Everpod SDK for Python
|
|
18
|
+
|
|
19
|
+
The official Python client for the [Everpod API](https://everpod.ai/docs/api).
|
|
20
|
+
|
|
21
|
+
Everpod runs an AI agent on a pod: a private, always-on cloud computer of its own. A key lets an agent or an app you trust see your pods and start a new one for you, which you then pay for on everpod.ai. It can't pay, change or cancel a plan, delete anything, or open your agent's control panel.
|
|
22
|
+
|
|
23
|
+
If you are connecting an agent such as Claude Code or Codex, you do not need this library: point it at Everpod's MCP server, as the [API reference](https://everpod.ai/docs/api) shows.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install everpod-ai
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python 3.9 or later. No dependencies.
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
To make a key, open [everpod.ai/account/keys](https://everpod.ai/account/keys) and sign in with your email address and the code we send you. If you have no account yet, signing in makes one. Give the key to the library from an environment variable.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
from everpod_ai import Everpod
|
|
41
|
+
|
|
42
|
+
everpod = Everpod(os.environ["EVERPOD_API_KEY"])
|
|
43
|
+
|
|
44
|
+
# Start a pod under the name you want for its agent. Nothing is charged:
|
|
45
|
+
# the pod stays unpaid until you open pay_url in a browser and pay there.
|
|
46
|
+
pod = everpod.start_pod("Otto")
|
|
47
|
+
print(pod["status"], pod["pay_url"])
|
|
48
|
+
|
|
49
|
+
# The pods on your account, oldest first.
|
|
50
|
+
pods = everpod.list_pods()
|
|
51
|
+
|
|
52
|
+
# One pod by its id: how to check whether it has been paid for, and whether it is ready.
|
|
53
|
+
same = everpod.get_pod(pod["id"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
While your account has an unpaid pod, starting another returns that same pod, renamed if the name differs.
|
|
57
|
+
|
|
58
|
+
A pod's fields and what each status means are in the [API reference](https://everpod.ai/docs/api).
|
|
59
|
+
|
|
60
|
+
## When a request is refused
|
|
61
|
+
|
|
62
|
+
A refusal raises an `EverpodError`. Its `message` is the API's own sentence, which says what happened and what to do next; `code` and `status` are the refusal's.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from everpod_ai import EverpodError
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
everpod.get_pod(pod_id)
|
|
69
|
+
except EverpodError as error:
|
|
70
|
+
print(error.code, error.status, error.message)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Questions
|
|
74
|
+
|
|
75
|
+
[support@everpod.ai](mailto:support@everpod.ai)
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Everpod SDK for Python
|
|
2
|
+
|
|
3
|
+
The official Python client for the [Everpod API](https://everpod.ai/docs/api).
|
|
4
|
+
|
|
5
|
+
Everpod runs an AI agent on a pod: a private, always-on cloud computer of its own. A key lets an agent or an app you trust see your pods and start a new one for you, which you then pay for on everpod.ai. It can't pay, change or cancel a plan, delete anything, or open your agent's control panel.
|
|
6
|
+
|
|
7
|
+
If you are connecting an agent such as Claude Code or Codex, you do not need this library: point it at Everpod's MCP server, as the [API reference](https://everpod.ai/docs/api) shows.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install everpod-ai
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.9 or later. No dependencies.
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
To make a key, open [everpod.ai/account/keys](https://everpod.ai/account/keys) and sign in with your email address and the code we send you. If you have no account yet, signing in makes one. Give the key to the library from an environment variable.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
from everpod_ai import Everpod
|
|
25
|
+
|
|
26
|
+
everpod = Everpod(os.environ["EVERPOD_API_KEY"])
|
|
27
|
+
|
|
28
|
+
# Start a pod under the name you want for its agent. Nothing is charged:
|
|
29
|
+
# the pod stays unpaid until you open pay_url in a browser and pay there.
|
|
30
|
+
pod = everpod.start_pod("Otto")
|
|
31
|
+
print(pod["status"], pod["pay_url"])
|
|
32
|
+
|
|
33
|
+
# The pods on your account, oldest first.
|
|
34
|
+
pods = everpod.list_pods()
|
|
35
|
+
|
|
36
|
+
# One pod by its id: how to check whether it has been paid for, and whether it is ready.
|
|
37
|
+
same = everpod.get_pod(pod["id"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
While your account has an unpaid pod, starting another returns that same pod, renamed if the name differs.
|
|
41
|
+
|
|
42
|
+
A pod's fields and what each status means are in the [API reference](https://everpod.ai/docs/api).
|
|
43
|
+
|
|
44
|
+
## When a request is refused
|
|
45
|
+
|
|
46
|
+
A refusal raises an `EverpodError`. Its `message` is the API's own sentence, which says what happened and what to do next; `code` and `status` are the refusal's.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from everpod_ai import EverpodError
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
everpod.get_pod(pod_id)
|
|
53
|
+
except EverpodError as error:
|
|
54
|
+
print(error.code, error.status, error.message)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Questions
|
|
58
|
+
|
|
59
|
+
[support@everpod.ai](mailto:support@everpod.ai)
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "everpod-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Everpod API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Everpod", email = "support@everpod.ai" },
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
dependencies = []
|
|
16
|
+
keywords = ["everpod", "ai-agents", "agent-hosting", "openclaw", "mcp", "sdk"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://everpod.ai"
|
|
20
|
+
Documentation = "https://everpod.ai/docs/api"
|
|
21
|
+
Repository = "https://github.com/everpod-ai/sdk-python"
|
|
22
|
+
Issues = "https://github.com/everpod-ai/sdk-python/issues"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
everpod_ai = ["py.typed"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The Everpod API's Python client: the operations a key can call
|
|
2
|
+
(https://everpod.ai/docs/api) and nothing else. Standard library only.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Callable, List, Optional, TypedDict
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = ["Everpod", "EverpodError", "Pod", "PodSubscription", "__version__"]
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://everpod.ai"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PodSubscription(TypedDict):
|
|
20
|
+
status: str
|
|
21
|
+
current_period_end: Optional[str] # when the paid period ends
|
|
22
|
+
cancel_at_period_end: bool # whether the subscription is set to end then
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Pod(TypedDict):
|
|
26
|
+
"""A pod. What each status means: https://everpod.ai/docs/api"""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
name: str # the name of the pod's agent
|
|
30
|
+
harness: str # the agent software the pod runs
|
|
31
|
+
status: str # awaiting_payment, building, setup_delayed, ready, needs_attention, stopped
|
|
32
|
+
created_at: str
|
|
33
|
+
url: Optional[str] # the pod's page on everpod.ai; None until the pod is paid for
|
|
34
|
+
pay_url: Optional[str] # where the pod's owner pays, while the status is awaiting_payment
|
|
35
|
+
plan: Optional[str] # None until the pod is paid for
|
|
36
|
+
subscription: Optional[PodSubscription] # None until the pod is paid for
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EverpodError(Exception):
|
|
40
|
+
"""What the API refused, or what stopped the request reaching it.
|
|
41
|
+
|
|
42
|
+
``message`` is the API's own sentence, written to be passed on to the
|
|
43
|
+
account's owner; ``code`` and ``status`` are the refusal's
|
|
44
|
+
(https://everpod.ai/docs/api).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, message: str, *, code: Optional[str] = None, status: Optional[int] = None):
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.message = message
|
|
50
|
+
self.code = code
|
|
51
|
+
self.status = status
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Everpod:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
api_key: str,
|
|
58
|
+
*,
|
|
59
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
opener: Optional[Callable[..., Any]] = None,
|
|
62
|
+
):
|
|
63
|
+
if not api_key:
|
|
64
|
+
raise EverpodError(
|
|
65
|
+
"An Everpod API key is needed: Everpod(api_key=...). The account's owner "
|
|
66
|
+
"makes one at https://everpod.ai/account/keys.",
|
|
67
|
+
code="missing_api_key",
|
|
68
|
+
)
|
|
69
|
+
self._api_key = api_key
|
|
70
|
+
self._base_url = base_url.rstrip("/")
|
|
71
|
+
self._timeout = timeout
|
|
72
|
+
self._open = opener or urllib.request.urlopen
|
|
73
|
+
|
|
74
|
+
def list_pods(self) -> List[Pod]:
|
|
75
|
+
"""The pods on the account, oldest first."""
|
|
76
|
+
return self._request("GET", "/pods")["pods"]
|
|
77
|
+
|
|
78
|
+
def get_pod(self, pod_id: str) -> Pod:
|
|
79
|
+
"""One pod by its id."""
|
|
80
|
+
return self._request("GET", "/pods/" + urllib.parse.quote(str(pod_id), safe=""))["pod"]
|
|
81
|
+
|
|
82
|
+
def start_pod(self, name: str) -> Pod:
|
|
83
|
+
"""Start a pod under the name its owner wants for their agent.
|
|
84
|
+
|
|
85
|
+
Nothing is charged: the pod stays unpaid until its owner pays at
|
|
86
|
+
``pay_url``.
|
|
87
|
+
"""
|
|
88
|
+
return self._request("POST", "/pods", {"name": name})["pod"]
|
|
89
|
+
|
|
90
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
91
|
+
headers = {
|
|
92
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
93
|
+
"Accept": "application/json",
|
|
94
|
+
"User-Agent": f"everpod-sdk-python/{__version__}",
|
|
95
|
+
}
|
|
96
|
+
data = None
|
|
97
|
+
if body is not None:
|
|
98
|
+
headers["Content-Type"] = "application/json"
|
|
99
|
+
data = json.dumps(body).encode("utf-8")
|
|
100
|
+
request = urllib.request.Request(
|
|
101
|
+
f"{self._base_url}/api/v1{path}", data=data, headers=headers, method=method
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
with self._open(request, timeout=self._timeout) as response:
|
|
105
|
+
return json.loads(response.read().decode("utf-8"))
|
|
106
|
+
except urllib.error.HTTPError as refused:
|
|
107
|
+
try:
|
|
108
|
+
error = json.loads(refused.read().decode("utf-8")).get("error")
|
|
109
|
+
except Exception:
|
|
110
|
+
error = None
|
|
111
|
+
# The API's refusals carry a code and a sentence; anything else that
|
|
112
|
+
# answers in its place is reported by its status alone.
|
|
113
|
+
if not isinstance(error, dict):
|
|
114
|
+
error = {}
|
|
115
|
+
raise EverpodError(
|
|
116
|
+
error.get("message") or f"Everpod answered {refused.code}.",
|
|
117
|
+
code=error.get("code") or "http_error",
|
|
118
|
+
status=refused.code,
|
|
119
|
+
) from None
|
|
120
|
+
except (urllib.error.URLError, OSError) as failed:
|
|
121
|
+
reason = getattr(failed, "reason", failed)
|
|
122
|
+
raise EverpodError(
|
|
123
|
+
f"The request to Everpod did not complete: {reason}", code="network_error"
|
|
124
|
+
) from failed
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: everpod-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Everpod API.
|
|
5
|
+
Author-email: Everpod <support@everpod.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://everpod.ai
|
|
8
|
+
Project-URL: Documentation, https://everpod.ai/docs/api
|
|
9
|
+
Project-URL: Repository, https://github.com/everpod-ai/sdk-python
|
|
10
|
+
Project-URL: Issues, https://github.com/everpod-ai/sdk-python/issues
|
|
11
|
+
Keywords: everpod,ai-agents,agent-hosting,openclaw,mcp,sdk
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Everpod SDK for Python
|
|
18
|
+
|
|
19
|
+
The official Python client for the [Everpod API](https://everpod.ai/docs/api).
|
|
20
|
+
|
|
21
|
+
Everpod runs an AI agent on a pod: a private, always-on cloud computer of its own. A key lets an agent or an app you trust see your pods and start a new one for you, which you then pay for on everpod.ai. It can't pay, change or cancel a plan, delete anything, or open your agent's control panel.
|
|
22
|
+
|
|
23
|
+
If you are connecting an agent such as Claude Code or Codex, you do not need this library: point it at Everpod's MCP server, as the [API reference](https://everpod.ai/docs/api) shows.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install everpod-ai
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Python 3.9 or later. No dependencies.
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
To make a key, open [everpod.ai/account/keys](https://everpod.ai/account/keys) and sign in with your email address and the code we send you. If you have no account yet, signing in makes one. Give the key to the library from an environment variable.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
|
|
40
|
+
from everpod_ai import Everpod
|
|
41
|
+
|
|
42
|
+
everpod = Everpod(os.environ["EVERPOD_API_KEY"])
|
|
43
|
+
|
|
44
|
+
# Start a pod under the name you want for its agent. Nothing is charged:
|
|
45
|
+
# the pod stays unpaid until you open pay_url in a browser and pay there.
|
|
46
|
+
pod = everpod.start_pod("Otto")
|
|
47
|
+
print(pod["status"], pod["pay_url"])
|
|
48
|
+
|
|
49
|
+
# The pods on your account, oldest first.
|
|
50
|
+
pods = everpod.list_pods()
|
|
51
|
+
|
|
52
|
+
# One pod by its id: how to check whether it has been paid for, and whether it is ready.
|
|
53
|
+
same = everpod.get_pod(pod["id"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
While your account has an unpaid pod, starting another returns that same pod, renamed if the name differs.
|
|
57
|
+
|
|
58
|
+
A pod's fields and what each status means are in the [API reference](https://everpod.ai/docs/api).
|
|
59
|
+
|
|
60
|
+
## When a request is refused
|
|
61
|
+
|
|
62
|
+
A refusal raises an `EverpodError`. Its `message` is the API's own sentence, which says what happened and what to do next; `code` and `status` are the refusal's.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from everpod_ai import EverpodError
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
everpod.get_pod(pod_id)
|
|
69
|
+
except EverpodError as error:
|
|
70
|
+
print(error.code, error.status, error.message)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Questions
|
|
74
|
+
|
|
75
|
+
[support@everpod.ai](mailto:support@everpod.ai)
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/everpod_ai/__init__.py
|
|
5
|
+
src/everpod_ai/py.typed
|
|
6
|
+
src/everpod_ai.egg-info/PKG-INFO
|
|
7
|
+
src/everpod_ai.egg-info/SOURCES.txt
|
|
8
|
+
src/everpod_ai.egg-info/dependency_links.txt
|
|
9
|
+
src/everpod_ai.egg-info/top_level.txt
|
|
10
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
everpod_ai
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Unit tests for the client: what it sends and how it reads what comes back,
|
|
2
|
+
against an opener that answers from a script. Nothing here reaches a network.
|
|
3
|
+
|
|
4
|
+
python tests/test_client.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import unittest
|
|
12
|
+
import urllib.error
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
15
|
+
|
|
16
|
+
from everpod_ai import Everpod, EverpodError, __version__ # noqa: E402
|
|
17
|
+
|
|
18
|
+
POD = {
|
|
19
|
+
"id": "5f0c1a52-8f1e-4d0b-9a57-3f6f2f7c1e9a",
|
|
20
|
+
"name": "Otto",
|
|
21
|
+
"harness": "openclaw",
|
|
22
|
+
"status": "awaiting_payment",
|
|
23
|
+
"created_at": "2026-10-03T09:12:44.512345+00:00",
|
|
24
|
+
"url": None,
|
|
25
|
+
"pay_url": "https://everpod.ai/create?pod=5f0c1a52-8f1e-4d0b-9a57-3f6f2f7c1e9a",
|
|
26
|
+
"plan": None,
|
|
27
|
+
"subscription": None,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Answer(io.BytesIO):
|
|
32
|
+
"""A response the client can use as a context manager."""
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, *exc):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Scripted:
|
|
42
|
+
"""An opener that records each request and answers ``status`` with ``body``."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, status, body):
|
|
45
|
+
self.status = status
|
|
46
|
+
self.raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
|
|
47
|
+
self.calls = []
|
|
48
|
+
|
|
49
|
+
def __call__(self, request, timeout=None):
|
|
50
|
+
self.calls.append((request, timeout))
|
|
51
|
+
if self.status >= 400:
|
|
52
|
+
raise urllib.error.HTTPError(
|
|
53
|
+
request.full_url, self.status, "refused", hdrs=None, fp=io.BytesIO(self.raw)
|
|
54
|
+
)
|
|
55
|
+
return Answer(self.raw)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def client(opener, **extra):
|
|
59
|
+
return Everpod("everpod_test", opener=opener, **extra)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ClientTests(unittest.TestCase):
|
|
63
|
+
def test_no_key_says_where_a_key_is_made(self):
|
|
64
|
+
with self.assertRaises(EverpodError) as raised:
|
|
65
|
+
Everpod("")
|
|
66
|
+
self.assertEqual(raised.exception.code, "missing_api_key")
|
|
67
|
+
self.assertIn("/account/keys", raised.exception.message)
|
|
68
|
+
|
|
69
|
+
def test_list_pods_asks_the_list_route_with_the_key(self):
|
|
70
|
+
opener = Scripted(200, {"pods": [POD]})
|
|
71
|
+
self.assertEqual(client(opener).list_pods(), [POD])
|
|
72
|
+
request, timeout = opener.calls[0]
|
|
73
|
+
self.assertEqual(request.full_url, "https://everpod.ai/api/v1/pods")
|
|
74
|
+
self.assertEqual(request.get_method(), "GET")
|
|
75
|
+
self.assertEqual(request.get_header("Authorization"), "Bearer everpod_test")
|
|
76
|
+
self.assertEqual(request.get_header("User-agent"), f"everpod-sdk-python/{__version__}")
|
|
77
|
+
self.assertIsNone(request.data)
|
|
78
|
+
self.assertEqual(timeout, 30.0)
|
|
79
|
+
|
|
80
|
+
def test_get_pod_escapes_its_id(self):
|
|
81
|
+
opener = Scripted(200, {"pod": POD})
|
|
82
|
+
self.assertEqual(client(opener).get_pod(POD["id"]), POD)
|
|
83
|
+
self.assertEqual(opener.calls[0][0].full_url, f"https://everpod.ai/api/v1/pods/{POD['id']}")
|
|
84
|
+
client(opener).get_pod("a/b")
|
|
85
|
+
self.assertEqual(opener.calls[1][0].full_url, "https://everpod.ai/api/v1/pods/a%2Fb")
|
|
86
|
+
|
|
87
|
+
def test_start_pod_posts_the_name_as_json(self):
|
|
88
|
+
opener = Scripted(201, {"pod": POD})
|
|
89
|
+
self.assertEqual(client(opener).start_pod("Otto"), POD)
|
|
90
|
+
request = opener.calls[0][0]
|
|
91
|
+
self.assertEqual(request.get_method(), "POST")
|
|
92
|
+
self.assertEqual(request.get_header("Content-type"), "application/json")
|
|
93
|
+
self.assertEqual(json.loads(request.data.decode("utf-8")), {"name": "Otto"})
|
|
94
|
+
|
|
95
|
+
def test_a_base_url_with_a_trailing_slash(self):
|
|
96
|
+
opener = Scripted(200, {"pods": []})
|
|
97
|
+
client(opener, base_url="http://localhost:3111/").list_pods()
|
|
98
|
+
self.assertEqual(opener.calls[0][0].full_url, "http://localhost:3111/api/v1/pods")
|
|
99
|
+
|
|
100
|
+
def test_a_refusal_carries_the_apis_code_status_and_sentence(self):
|
|
101
|
+
opener = Scripted(
|
|
102
|
+
404, {"error": {"code": "not_found", "message": "No pod with that id on this account."}}
|
|
103
|
+
)
|
|
104
|
+
with self.assertRaises(EverpodError) as raised:
|
|
105
|
+
client(opener).get_pod("nope")
|
|
106
|
+
self.assertEqual(raised.exception.code, "not_found")
|
|
107
|
+
self.assertEqual(raised.exception.status, 404)
|
|
108
|
+
self.assertEqual(raised.exception.message, "No pod with that id on this account.")
|
|
109
|
+
|
|
110
|
+
def test_an_answer_that_is_not_the_apis_json_still_says_its_status(self):
|
|
111
|
+
opener = Scripted(502, b"<html>bad gateway</html>")
|
|
112
|
+
with self.assertRaises(EverpodError) as raised:
|
|
113
|
+
client(opener).list_pods()
|
|
114
|
+
self.assertEqual(raised.exception.code, "http_error")
|
|
115
|
+
self.assertEqual(raised.exception.status, 502)
|
|
116
|
+
self.assertIn("502", raised.exception.message)
|
|
117
|
+
|
|
118
|
+
def test_a_json_answer_of_another_shape_still_says_its_status(self):
|
|
119
|
+
opener = Scripted(403, {"ok": False, "error": "forbidden"})
|
|
120
|
+
with self.assertRaises(EverpodError) as raised:
|
|
121
|
+
client(opener).list_pods()
|
|
122
|
+
self.assertEqual(raised.exception.code, "http_error")
|
|
123
|
+
self.assertEqual(raised.exception.status, 403)
|
|
124
|
+
|
|
125
|
+
def test_a_request_that_never_completes_is_a_network_error(self):
|
|
126
|
+
def failing(request, timeout=None):
|
|
127
|
+
raise urllib.error.URLError("no route to host")
|
|
128
|
+
|
|
129
|
+
with self.assertRaises(EverpodError) as raised:
|
|
130
|
+
client(failing).list_pods()
|
|
131
|
+
self.assertEqual(raised.exception.code, "network_error")
|
|
132
|
+
self.assertIsInstance(raised.exception.__cause__, urllib.error.URLError)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
unittest.main()
|