maritime 0.2.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.
@@ -0,0 +1,6 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ *.pyc
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: maritime
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for Maritime — provision and drive AI agents on Maritime's serverless infrastructure from your own backend.
5
+ Project-URL: Homepage, https://maritime.sh
6
+ Project-URL: Documentation, https://maritime.sh/docs/build
7
+ Project-URL: Repository, https://github.com/mariagorskikh/maritime-sdk
8
+ Author: Maritime
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,maritime,openclaw,sdk,serverless
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # maritime (Python SDK)
26
+
27
+ Official Python SDK for [Maritime](https://maritime.sh) — provision and drive AI agents on Maritime's serverless infrastructure, straight from your own backend.
28
+
29
+ Build an app where every one of **your** users gets **their own** agent: one call on sign-up spins up an isolated agent on Maritime's fleet; another sends it a message. You never touch a container.
30
+
31
+ ```bash
32
+ pip install maritime
33
+ ```
34
+
35
+ Python 3.9+. Zero dependencies (pure standard library).
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from maritime import Maritime
41
+
42
+ client = Maritime() # reads MARITIME_API_KEY
43
+
44
+ # When YOUR user signs up, give them their own agent. provision() is idempotent
45
+ # on external_id — safe to call on every login.
46
+ agent = client.agents.provision(
47
+ external_id=f"customer_{user.id}", # your id for this agent
48
+ name=f"assistant-{user.id}",
49
+ template="openclaw",
50
+ )
51
+
52
+ # Talk to it (sleeping agents auto-wake):
53
+ reply = client.agents.chat(agent["id"], "Summarize my unread email.")["response"]
54
+ print(reply)
55
+ ```
56
+
57
+ ## Authentication
58
+
59
+ Mint an API key (`mk_...`) from the dashboard (**Settings → API keys**) or the CLI (`maritime keys create`), then set `MARITIME_API_KEY`, or pass it explicitly:
60
+
61
+ ```python
62
+ client = Maritime(api_key="mk_...")
63
+ ```
64
+
65
+ Keys carry **scopes** — hand a narrower key to a subsystem that only needs part of the surface:
66
+
67
+ | Scope | Grants |
68
+ |-------|--------|
69
+ | `provision` | create agents |
70
+ | `deploy` | start/stop/restart/sleep/chat |
71
+ | `secrets` | read/write env vars |
72
+ | `manage` | everything, incl. delete + key management (wildcard) |
73
+
74
+ ```python
75
+ worker = client.keys.create("chat-worker", scopes=["deploy"])
76
+ # worker["raw_key"] is shown once — store it now.
77
+ ```
78
+
79
+ ## Agents
80
+
81
+ ```python
82
+ # Create (kicks off deploy). Prefer template — a bare framework yields a broken image.
83
+ agent = client.agents.create(
84
+ "support-bot",
85
+ template="openclaw",
86
+ external_id="customer_42",
87
+ instructions="You are a friendly support agent for Acme Inc.",
88
+ env=[{"key": "ACME_API_KEY", "value": "...", "secret": True}],
89
+ tier="smart", # "smart" | "extended" | "always_on"
90
+ idle_ttl_seconds=3600, # 0 = always-on
91
+ )
92
+
93
+ # Idempotent get-or-create by external_id (recommended for per-user provisioning)
94
+ agent = client.agents.provision(external_id="customer_42", name="support-bot")
95
+
96
+ client.agents.get(agent["id"])
97
+ client.agents.list(external_id="customer_42") # filter by your id
98
+ client.agents.list() # all of your agents
99
+
100
+ # Chat (synchronous; auto-wakes a sleeping agent)
101
+ result = client.agents.chat(agent["id"], "Hello")
102
+ print(result["response"]) # None + result["error"] if delivery failed
103
+
104
+ # Lifecycle
105
+ client.agents.start(agent["id"])
106
+ client.agents.sleep(agent["id"]) # cheapest resting state (serverless snapshot)
107
+ client.agents.restart(agent["id"])
108
+ client.agents.delete(agent["id"]) # tears down container + volume + network
109
+
110
+ # Env vars (secrets encrypted at rest; reach a running container after reload_env)
111
+ client.agents.set_env(agent["id"], "STRIPE_KEY", "sk_live_...", secret=True)
112
+ client.agents.list_env(agent["id"])
113
+ client.agents.reload_env(agent["id"])
114
+
115
+ # Logs
116
+ client.agents.logs(agent["id"], limit=100, level="error")
117
+ ```
118
+
119
+ ## Errors
120
+
121
+ Every failure is a subclass of `MaritimeError` — catch the base to catch them all, or narrow by type:
122
+
123
+ ```python
124
+ from maritime import (
125
+ MaritimeAuthError, # 401 / 403 — bad or under-scoped key
126
+ MaritimePaymentRequiredError, # 402 — wallet needs funding
127
+ MaritimeNotFoundError, # 404 — no such agent (or not yours)
128
+ MaritimeConflictError, # 409 — name already taken
129
+ MaritimeRateLimitError, # 429
130
+ MaritimeAPIError, # any other non-2xx (has .status, .detail)
131
+ MaritimeConnectionError, # never reached Maritime (network/timeout)
132
+ )
133
+
134
+ try:
135
+ client.agents.create("dupe", template="openclaw")
136
+ except MaritimeConflictError:
137
+ ... # an agent with that name already exists
138
+ except MaritimeAPIError as err:
139
+ print(err.status, err.detail, err.request_id)
140
+ ```
141
+
142
+ ## Configuration
143
+
144
+ ```python
145
+ Maritime(
146
+ api_key="mk_...", # or MARITIME_API_KEY
147
+ base_url="https://api.maritime.sh", # or MARITIME_API_URL
148
+ timeout=60.0, # per-request seconds
149
+ max_retries=2, # network + 5xx/429 (GET/DELETE and 429/503 only)
150
+ default_headers={"x-team": "acme"},
151
+ )
152
+ ```
153
+
154
+ Retries are safe by construction: `GET`/`DELETE` retry on any transient failure; `POST`/`PUT` retry only on network errors and `429`/`503` (never a `5xx` that might have applied a write).
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,134 @@
1
+ # maritime (Python SDK)
2
+
3
+ Official Python SDK for [Maritime](https://maritime.sh) — provision and drive AI agents on Maritime's serverless infrastructure, straight from your own backend.
4
+
5
+ Build an app where every one of **your** users gets **their own** agent: one call on sign-up spins up an isolated agent on Maritime's fleet; another sends it a message. You never touch a container.
6
+
7
+ ```bash
8
+ pip install maritime
9
+ ```
10
+
11
+ Python 3.9+. Zero dependencies (pure standard library).
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from maritime import Maritime
17
+
18
+ client = Maritime() # reads MARITIME_API_KEY
19
+
20
+ # When YOUR user signs up, give them their own agent. provision() is idempotent
21
+ # on external_id — safe to call on every login.
22
+ agent = client.agents.provision(
23
+ external_id=f"customer_{user.id}", # your id for this agent
24
+ name=f"assistant-{user.id}",
25
+ template="openclaw",
26
+ )
27
+
28
+ # Talk to it (sleeping agents auto-wake):
29
+ reply = client.agents.chat(agent["id"], "Summarize my unread email.")["response"]
30
+ print(reply)
31
+ ```
32
+
33
+ ## Authentication
34
+
35
+ Mint an API key (`mk_...`) from the dashboard (**Settings → API keys**) or the CLI (`maritime keys create`), then set `MARITIME_API_KEY`, or pass it explicitly:
36
+
37
+ ```python
38
+ client = Maritime(api_key="mk_...")
39
+ ```
40
+
41
+ Keys carry **scopes** — hand a narrower key to a subsystem that only needs part of the surface:
42
+
43
+ | Scope | Grants |
44
+ |-------|--------|
45
+ | `provision` | create agents |
46
+ | `deploy` | start/stop/restart/sleep/chat |
47
+ | `secrets` | read/write env vars |
48
+ | `manage` | everything, incl. delete + key management (wildcard) |
49
+
50
+ ```python
51
+ worker = client.keys.create("chat-worker", scopes=["deploy"])
52
+ # worker["raw_key"] is shown once — store it now.
53
+ ```
54
+
55
+ ## Agents
56
+
57
+ ```python
58
+ # Create (kicks off deploy). Prefer template — a bare framework yields a broken image.
59
+ agent = client.agents.create(
60
+ "support-bot",
61
+ template="openclaw",
62
+ external_id="customer_42",
63
+ instructions="You are a friendly support agent for Acme Inc.",
64
+ env=[{"key": "ACME_API_KEY", "value": "...", "secret": True}],
65
+ tier="smart", # "smart" | "extended" | "always_on"
66
+ idle_ttl_seconds=3600, # 0 = always-on
67
+ )
68
+
69
+ # Idempotent get-or-create by external_id (recommended for per-user provisioning)
70
+ agent = client.agents.provision(external_id="customer_42", name="support-bot")
71
+
72
+ client.agents.get(agent["id"])
73
+ client.agents.list(external_id="customer_42") # filter by your id
74
+ client.agents.list() # all of your agents
75
+
76
+ # Chat (synchronous; auto-wakes a sleeping agent)
77
+ result = client.agents.chat(agent["id"], "Hello")
78
+ print(result["response"]) # None + result["error"] if delivery failed
79
+
80
+ # Lifecycle
81
+ client.agents.start(agent["id"])
82
+ client.agents.sleep(agent["id"]) # cheapest resting state (serverless snapshot)
83
+ client.agents.restart(agent["id"])
84
+ client.agents.delete(agent["id"]) # tears down container + volume + network
85
+
86
+ # Env vars (secrets encrypted at rest; reach a running container after reload_env)
87
+ client.agents.set_env(agent["id"], "STRIPE_KEY", "sk_live_...", secret=True)
88
+ client.agents.list_env(agent["id"])
89
+ client.agents.reload_env(agent["id"])
90
+
91
+ # Logs
92
+ client.agents.logs(agent["id"], limit=100, level="error")
93
+ ```
94
+
95
+ ## Errors
96
+
97
+ Every failure is a subclass of `MaritimeError` — catch the base to catch them all, or narrow by type:
98
+
99
+ ```python
100
+ from maritime import (
101
+ MaritimeAuthError, # 401 / 403 — bad or under-scoped key
102
+ MaritimePaymentRequiredError, # 402 — wallet needs funding
103
+ MaritimeNotFoundError, # 404 — no such agent (or not yours)
104
+ MaritimeConflictError, # 409 — name already taken
105
+ MaritimeRateLimitError, # 429
106
+ MaritimeAPIError, # any other non-2xx (has .status, .detail)
107
+ MaritimeConnectionError, # never reached Maritime (network/timeout)
108
+ )
109
+
110
+ try:
111
+ client.agents.create("dupe", template="openclaw")
112
+ except MaritimeConflictError:
113
+ ... # an agent with that name already exists
114
+ except MaritimeAPIError as err:
115
+ print(err.status, err.detail, err.request_id)
116
+ ```
117
+
118
+ ## Configuration
119
+
120
+ ```python
121
+ Maritime(
122
+ api_key="mk_...", # or MARITIME_API_KEY
123
+ base_url="https://api.maritime.sh", # or MARITIME_API_URL
124
+ timeout=60.0, # per-request seconds
125
+ max_retries=2, # network + 5xx/429 (GET/DELETE and 429/503 only)
126
+ default_headers={"x-team": "acme"},
127
+ )
128
+ ```
129
+
130
+ Retries are safe by construction: `GET`/`DELETE` retry on any transient failure; `POST`/`PUT` retry only on network errors and `429`/`503` (never a `5xx` that might have applied a write).
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,81 @@
1
+ """Official Python SDK for Maritime.
2
+
3
+ Provision and drive AI agents on Maritime's serverless infrastructure from your
4
+ own backend.
5
+
6
+ from maritime import Maritime
7
+
8
+ client = Maritime() # reads MARITIME_API_KEY
9
+
10
+ # When YOUR user signs up, give them their own agent (idempotent):
11
+ agent = client.agents.provision(
12
+ external_id=f"customer_{user_id}",
13
+ name=f"assistant-{user_id}",
14
+ template="openclaw",
15
+ )
16
+ reply = client.agents.chat(agent["id"], "Hello!")["response"]
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Dict, Optional
22
+
23
+ from ._http import HttpClient
24
+ from .errors import (
25
+ MaritimeAPIError,
26
+ MaritimeAuthError,
27
+ MaritimeConflictError,
28
+ MaritimeConnectionError,
29
+ MaritimeError,
30
+ MaritimeNotFoundError,
31
+ MaritimePaymentRequiredError,
32
+ MaritimeRateLimitError,
33
+ )
34
+ from .resources import Agents, Keys, Webhooks
35
+
36
+ __version__ = "0.2.0"
37
+
38
+ __all__ = [
39
+ "Maritime",
40
+ "MaritimeError",
41
+ "MaritimeConnectionError",
42
+ "MaritimeAPIError",
43
+ "MaritimeAuthError",
44
+ "MaritimePaymentRequiredError",
45
+ "MaritimeNotFoundError",
46
+ "MaritimeConflictError",
47
+ "MaritimeRateLimitError",
48
+ ]
49
+
50
+
51
+ class Maritime:
52
+ """The Maritime client.
53
+
54
+ :param api_key: ``mk_...`` key. Defaults to the ``MARITIME_API_KEY`` env var.
55
+ :param base_url: API base. Defaults to ``MARITIME_API_URL`` or api.maritime.sh.
56
+ :param timeout: per-request timeout (seconds).
57
+ :param max_retries: retries on network errors and 5xx/429.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ api_key: Optional[str] = None,
63
+ base_url: Optional[str] = None,
64
+ timeout: float = 60.0,
65
+ max_retries: int = 2,
66
+ default_headers: Optional[Dict[str, str]] = None,
67
+ ) -> None:
68
+ self.http = HttpClient(
69
+ api_key=api_key,
70
+ base_url=base_url,
71
+ timeout=timeout,
72
+ max_retries=max_retries,
73
+ default_headers=default_headers,
74
+ )
75
+ self.agents = Agents(self.http)
76
+ self.keys = Keys(self.http)
77
+ self.webhooks = Webhooks(self.http)
78
+
79
+ def request(self, method: str, path: str, **kwargs: Any) -> Any:
80
+ """Escape hatch for endpoints not yet wrapped."""
81
+ return self.http.request(method, path, **kwargs)
@@ -0,0 +1,136 @@
1
+ """Zero-dependency HTTP transport (stdlib urllib) with typed errors + retry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any, Dict, Optional
12
+
13
+ from .errors import MaritimeConnectionError, MaritimeError, api_error_from_status
14
+
15
+ DEFAULT_BASE_URL = "https://api.maritime.sh"
16
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
17
+
18
+
19
+ class HttpClient:
20
+ """Thin transport: auth header, JSON encode/decode, typed errors, bounded
21
+ retry with backoff. No third-party dependencies."""
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: Optional[str] = None,
26
+ base_url: Optional[str] = None,
27
+ timeout: float = 60.0,
28
+ max_retries: int = 2,
29
+ default_headers: Optional[Dict[str, str]] = None,
30
+ ) -> None:
31
+ key = api_key or os.environ.get("MARITIME_API_KEY")
32
+ if not key:
33
+ raise MaritimeError(
34
+ "Missing Maritime API key. Pass api_key=... or set MARITIME_API_KEY."
35
+ )
36
+ self._api_key = key
37
+ self._base_url = (base_url or os.environ.get("MARITIME_API_URL") or DEFAULT_BASE_URL).rstrip("/")
38
+ self._timeout = timeout
39
+ self._max_retries = max_retries
40
+ self._default_headers = default_headers or {}
41
+
42
+ def _url(self, path: str, query: Optional[Dict[str, Any]]) -> str:
43
+ url = self._base_url + path
44
+ if query:
45
+ clean = {k: v for k, v in query.items() if v is not None}
46
+ if clean:
47
+ url += "?" + urllib.parse.urlencode(clean)
48
+ return url
49
+
50
+ def request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ query: Optional[Dict[str, Any]] = None,
55
+ body: Optional[Any] = None,
56
+ idempotent: Optional[bool] = None,
57
+ ) -> Any:
58
+ url = self._url(path, query)
59
+ data = None
60
+ headers = {
61
+ "Authorization": f"Bearer {self._api_key}",
62
+ "Accept": "application/json",
63
+ "User-Agent": "maritime-python-sdk",
64
+ }
65
+ headers.update(self._default_headers)
66
+ if body is not None:
67
+ data = json.dumps(body).encode("utf-8")
68
+ headers["Content-Type"] = "application/json"
69
+
70
+ method = method.upper()
71
+ retry_safe = idempotent if idempotent is not None else method in ("GET", "DELETE")
72
+
73
+ last_err: Optional[Exception] = None
74
+ for attempt in range(self._max_retries + 1):
75
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
78
+ return self._parse(resp.read(), resp.status)
79
+ except urllib.error.HTTPError as e:
80
+ status = e.code
81
+ detail = self._detail(e)
82
+ request_id = e.headers.get("x-request-id") if e.headers else None
83
+ retryable = (
84
+ attempt < self._max_retries
85
+ and status in _RETRYABLE_STATUS
86
+ and (retry_safe or status in (429, 503))
87
+ )
88
+ if retryable:
89
+ last_err = api_error_from_status(status, detail, request_id)
90
+ time.sleep(self._backoff(attempt, e))
91
+ continue
92
+ raise api_error_from_status(status, detail, request_id)
93
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
94
+ last_err = e
95
+ if attempt < self._max_retries:
96
+ time.sleep(self._backoff(attempt))
97
+ continue
98
+ reason = getattr(e, "reason", e)
99
+ raise MaritimeConnectionError(
100
+ f"Failed to reach Maritime at {self._base_url}: {reason}"
101
+ ) from e
102
+
103
+ if isinstance(last_err, MaritimeError):
104
+ raise last_err
105
+ raise MaritimeConnectionError(f"Request to {url} failed after retries")
106
+
107
+ @staticmethod
108
+ def _backoff(attempt: int, err: Optional[urllib.error.HTTPError] = None) -> float:
109
+ if err is not None and err.headers:
110
+ retry_after = err.headers.get("retry-after")
111
+ if retry_after:
112
+ try:
113
+ return min(float(retry_after), 20.0)
114
+ except ValueError:
115
+ pass
116
+ return min(0.5 * (2 ** attempt), 8.0)
117
+
118
+ @staticmethod
119
+ def _parse(raw: bytes, status: int) -> Any:
120
+ if status == 204 or not raw:
121
+ return None
122
+ try:
123
+ return json.loads(raw.decode("utf-8"))
124
+ except (ValueError, UnicodeDecodeError):
125
+ return raw.decode("utf-8", "replace")
126
+
127
+ @staticmethod
128
+ def _detail(err: urllib.error.HTTPError) -> str:
129
+ try:
130
+ payload = json.loads(err.read().decode("utf-8"))
131
+ if isinstance(payload, dict) and "detail" in payload:
132
+ d = payload["detail"]
133
+ return d if isinstance(d, str) else json.dumps(d)
134
+ except Exception: # noqa: BLE001
135
+ pass
136
+ return f"Request failed with status {err.code}"
@@ -0,0 +1,61 @@
1
+ """Exception hierarchy for the Maritime SDK.
2
+
3
+ Catch :class:`MaritimeError` to catch them all, or narrow by subclass.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+
11
+ class MaritimeError(Exception):
12
+ """Base class for every error the SDK raises."""
13
+
14
+
15
+ class MaritimeConnectionError(MaritimeError):
16
+ """The request never reached Maritime (DNS, connection, timeout)."""
17
+
18
+
19
+ class MaritimeAPIError(MaritimeError):
20
+ """Maritime returned a non-2xx response."""
21
+
22
+ def __init__(self, status: int, detail: str, request_id: Optional[str] = None) -> None:
23
+ super().__init__(f"Maritime API error {status}: {detail}")
24
+ self.status = status
25
+ self.detail = detail
26
+ self.request_id = request_id
27
+
28
+
29
+ class MaritimeAuthError(MaritimeAPIError):
30
+ """401 / 403 — bad or insufficiently-scoped API key."""
31
+
32
+
33
+ class MaritimePaymentRequiredError(MaritimeAPIError):
34
+ """402 — the account wallet needs funding before this action can proceed."""
35
+
36
+
37
+ class MaritimeNotFoundError(MaritimeAPIError):
38
+ """404 — the agent (or other resource) does not exist or is not yours."""
39
+
40
+
41
+ class MaritimeConflictError(MaritimeAPIError):
42
+ """409 — a uniqueness conflict (e.g. an agent name already exists)."""
43
+
44
+
45
+ class MaritimeRateLimitError(MaritimeAPIError):
46
+ """429 — rate limited."""
47
+
48
+
49
+ def api_error_from_status(status: int, detail: str, request_id: Optional[str] = None) -> MaritimeAPIError:
50
+ """Build the most specific error subclass for an HTTP status."""
51
+ if status in (401, 403):
52
+ return MaritimeAuthError(status, detail, request_id)
53
+ if status == 402:
54
+ return MaritimePaymentRequiredError(status, detail, request_id)
55
+ if status == 404:
56
+ return MaritimeNotFoundError(status, detail, request_id)
57
+ if status == 409:
58
+ return MaritimeConflictError(status, detail, request_id)
59
+ if status == 429:
60
+ return MaritimeRateLimitError(status, detail, request_id)
61
+ return MaritimeAPIError(status, detail, request_id)
@@ -0,0 +1,192 @@
1
+ """Resource namespaces: ``client.agents`` and ``client.keys``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ._http import HttpClient
8
+
9
+
10
+ class Agents:
11
+ """Operations on Maritime agents. Access via ``client.agents``."""
12
+
13
+ def __init__(self, http: HttpClient) -> None:
14
+ self._http = http
15
+
16
+ def create(
17
+ self,
18
+ name: str,
19
+ *,
20
+ template: Optional[str] = None,
21
+ external_id: Optional[str] = None,
22
+ description: Optional[str] = None,
23
+ instructions: Optional[str] = None,
24
+ tier: Optional[str] = None,
25
+ env: Optional[List[Dict[str, Any]]] = None,
26
+ mem_mb: Optional[int] = None,
27
+ vcpus: Optional[float] = None,
28
+ idle_ttl_seconds: Optional[int] = None,
29
+ disk_gb: Optional[float] = None,
30
+ github_repo: Optional[str] = None,
31
+ image_name: Optional[str] = None,
32
+ ) -> Dict[str, Any]:
33
+ """Create an agent and kick off its deploy.
34
+
35
+ ``env`` items look like ``{"key": ..., "value": ..., "secret": True}``.
36
+ """
37
+ body: Dict[str, Any] = {
38
+ "name": name,
39
+ "templateId": template,
40
+ "externalId": external_id,
41
+ "description": description,
42
+ "instructions": instructions,
43
+ "tier": tier,
44
+ "memMb": mem_mb,
45
+ "vcpus": vcpus,
46
+ "idleTtlSeconds": idle_ttl_seconds,
47
+ "diskGb": disk_gb,
48
+ "githubRepo": github_repo,
49
+ "imageName": image_name,
50
+ }
51
+ if env:
52
+ body["initialEnvVars"] = [
53
+ {"key": e["key"], "value": e["value"], "isSecret": e.get("secret", True)}
54
+ for e in env
55
+ ]
56
+ body = {k: v for k, v in body.items() if v is not None}
57
+ return self._http.request("POST", "/api/agents", body=body)
58
+
59
+ def provision(self, external_id: str, name: str, *, template: str = "openclaw", **kwargs: Any) -> Dict[str, Any]:
60
+ """Get-or-create an agent by ``external_id`` (idempotent — safe to call
61
+ on every sign-in). Returns the existing agent if one exists."""
62
+ existing = self.list(external_id=external_id)
63
+ if existing:
64
+ return existing[0]
65
+ try:
66
+ return self.create(name, template=template, external_id=external_id, **kwargs)
67
+ except Exception:
68
+ # Lost a race with a concurrent provisioner — re-read.
69
+ raced = self.list(external_id=external_id)
70
+ if raced:
71
+ return raced[0]
72
+ raise
73
+
74
+ def get(self, agent_id: str) -> Dict[str, Any]:
75
+ return self._http.request("GET", f"/api/agents/{_enc(agent_id)}")
76
+
77
+ def list(self, *, external_id: Optional[str] = None, name: Optional[str] = None) -> List[Dict[str, Any]]:
78
+ return self._http.request(
79
+ "GET", "/api/agents", query={"externalId": external_id, "name": name}
80
+ ) or []
81
+
82
+ def chat(self, agent_id: str, message: str, *, conversation_id: Optional[str] = None) -> Dict[str, Any]:
83
+ """Send a message and wait for the reply. Sleeping agents auto-wake.
84
+ Returns ``{"response": str | None, "error"?: str}``."""
85
+ return self._http.request(
86
+ "POST",
87
+ f"/api/agents/{_enc(agent_id)}/chat",
88
+ body={"message": message, "conversation_id": conversation_id},
89
+ )
90
+
91
+ def start(self, agent_id: str) -> Dict[str, Any]:
92
+ return self._lifecycle(agent_id, "start")
93
+
94
+ def stop(self, agent_id: str) -> Dict[str, Any]:
95
+ return self._lifecycle(agent_id, "stop")
96
+
97
+ def sleep(self, agent_id: str) -> Dict[str, Any]:
98
+ return self._lifecycle(agent_id, "sleep")
99
+
100
+ def restart(self, agent_id: str) -> Dict[str, Any]:
101
+ return self._lifecycle(agent_id, "restart")
102
+
103
+ def delete(self, agent_id: str) -> None:
104
+ self._http.request("DELETE", f"/api/agents/{_enc(agent_id)}")
105
+
106
+ def list_env(self, agent_id: str) -> List[Dict[str, Any]]:
107
+ return self._http.request("GET", f"/api/agents/{_enc(agent_id)}/env") or []
108
+
109
+ def set_env(self, agent_id: str, key: str, value: str, *, secret: bool = True) -> Dict[str, Any]:
110
+ """Set (upsert) an env var. Secrets are encrypted at rest; changes reach
111
+ a running container after :meth:`reload_env` or a restart."""
112
+ return self._http.request(
113
+ "POST",
114
+ f"/api/agents/{_enc(agent_id)}/env",
115
+ body={"key": key, "value": value, "isSecret": secret},
116
+ )
117
+
118
+ def delete_env(self, agent_id: str, key: str) -> None:
119
+ self._http.request("DELETE", f"/api/agents/{_enc(agent_id)}/env/{_enc(key)}")
120
+
121
+ def reload_env(self, agent_id: str) -> Dict[str, Any]:
122
+ return self._lifecycle(agent_id, "reload-env")
123
+
124
+ def logs(self, agent_id: str, *, limit: Optional[int] = None, level: Optional[str] = None) -> List[Dict[str, Any]]:
125
+ return self._http.request(
126
+ "GET", f"/api/agents/{_enc(agent_id)}/logs", query={"limit": limit, "level": level}
127
+ ) or []
128
+
129
+ def _lifecycle(self, agent_id: str, action: str) -> Dict[str, Any]:
130
+ return self._http.request(
131
+ "POST", f"/api/agents/{_enc(agent_id)}/{action}", idempotent=True
132
+ )
133
+
134
+
135
+ class Keys:
136
+ """Manage API keys programmatically. Access via ``client.keys``."""
137
+
138
+ def __init__(self, http: HttpClient) -> None:
139
+ self._http = http
140
+
141
+ def create(self, name: str, *, scopes: Optional[List[str]] = None, expires_in_days: Optional[int] = None) -> Dict[str, Any]:
142
+ """Mint a new key. The raw key is in ``["raw_key"]`` — shown once."""
143
+ return self._http.request(
144
+ "POST",
145
+ "/api/v1/keys",
146
+ body={
147
+ "name": name,
148
+ "scopes": scopes or ["provision", "deploy", "secrets", "manage"],
149
+ "expires_in_days": expires_in_days,
150
+ },
151
+ )
152
+
153
+ def list(self) -> List[Dict[str, Any]]:
154
+ return self._http.request("GET", "/api/v1/keys") or []
155
+
156
+ def revoke(self, key_id: str) -> None:
157
+ self._http.request("DELETE", f"/api/v1/keys/{_enc(key_id)}")
158
+
159
+
160
+ class Webhooks:
161
+ """Manage outbound webhook subscriptions. Access via ``client.webhooks``.
162
+
163
+ Subscribe a URL to receive signed agent lifecycle events (agent.deployed,
164
+ agent.error, agent.sleeping, agent.woken, agent.restarted, agent.stopped)
165
+ instead of polling. Each delivery carries ``X-Maritime-Signature:
166
+ sha256=<hmac>`` — verify it with the subscription's secret.
167
+ """
168
+
169
+ def __init__(self, http: HttpClient) -> None:
170
+ self._http = http
171
+
172
+ def create(self, url: str, *, events: Optional[List[str]] = None) -> Dict[str, Any]:
173
+ """Create a subscription. The signing ``secret`` is in the result — once."""
174
+ return self._http.request(
175
+ "POST", "/api/v1/webhooks", body={"url": url, "events": events or []}
176
+ )
177
+
178
+ def list(self) -> List[Dict[str, Any]]:
179
+ return self._http.request("GET", "/api/v1/webhooks") or []
180
+
181
+ def delete(self, subscription_id: str) -> None:
182
+ self._http.request("DELETE", f"/api/v1/webhooks/{_enc(subscription_id)}")
183
+
184
+ def test(self, subscription_id: str) -> Dict[str, Any]:
185
+ """Deliver a synthetic ``ping`` and return the result."""
186
+ return self._http.request("POST", f"/api/v1/webhooks/{_enc(subscription_id)}/test")
187
+
188
+
189
+ def _enc(value: str) -> str:
190
+ from urllib.parse import quote
191
+
192
+ return quote(str(value), safe="")
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "maritime"
7
+ version = "0.2.0"
8
+ description = "Official Python SDK for Maritime — provision and drive AI agents on Maritime's serverless infrastructure from your own backend."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Maritime" }]
13
+ keywords = ["maritime", "ai", "agents", "sdk", "openclaw", "serverless"]
14
+ dependencies = []
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://maritime.sh"
29
+ Documentation = "https://maritime.sh/docs/build"
30
+ Repository = "https://github.com/mariagorskikh/maritime-sdk"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=7"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["maritime"]
@@ -0,0 +1,187 @@
1
+ import io
2
+ import json
3
+ import urllib.error
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import pytest
7
+
8
+ from maritime import (
9
+ Maritime,
10
+ MaritimeAuthError,
11
+ MaritimeConflictError,
12
+ MaritimeError,
13
+ MaritimeNotFoundError,
14
+ MaritimePaymentRequiredError,
15
+ )
16
+
17
+
18
+ class _Resp:
19
+ """Minimal stand-in for the object returned by urlopen()."""
20
+
21
+ def __init__(self, status: int, body: Any):
22
+ self.status = status
23
+ self._body = b"" if body is None else json.dumps(body).encode()
24
+
25
+ def read(self):
26
+ return self._body
27
+
28
+ def __enter__(self):
29
+ return self
30
+
31
+ def __exit__(self, *a):
32
+ return False
33
+
34
+
35
+ class MockTransport:
36
+ """Patches urllib.request.urlopen to return queued responses + record calls."""
37
+
38
+ def __init__(self, responses: List[Dict[str, Any]]):
39
+ self.responses = responses
40
+ self.calls: List[Dict[str, Any]] = []
41
+ self._i = 0
42
+
43
+ def __call__(self, req, timeout=None):
44
+ self.calls.append({
45
+ "url": req.full_url,
46
+ "method": req.get_method(),
47
+ "body": json.loads(req.data) if req.data else None,
48
+ "headers": {k.lower(): v for k, v in req.headers.items()},
49
+ })
50
+ r = self.responses[min(self._i, len(self.responses) - 1)]
51
+ self._i += 1
52
+ status = r["status"]
53
+ if status >= 400:
54
+ raise urllib.error.HTTPError(
55
+ req.full_url, status, r.get("detail", "err"),
56
+ {"content-type": "application/json"},
57
+ io.BytesIO(json.dumps({"detail": r.get("detail", "err")}).encode()),
58
+ )
59
+ return _Resp(status, r.get("body"))
60
+
61
+
62
+ @pytest.fixture
63
+ def make_client(monkeypatch):
64
+ def _make(responses, **kw):
65
+ transport = MockTransport(responses)
66
+ monkeypatch.setattr("urllib.request.urlopen", transport)
67
+ client = Maritime(api_key="mk_test", base_url="https://api.example.test", max_retries=kw.get("max_retries", 2))
68
+ return client, transport
69
+ return _make
70
+
71
+
72
+ def test_missing_api_key_raises(monkeypatch):
73
+ monkeypatch.delenv("MARITIME_API_KEY", raising=False)
74
+ with pytest.raises(MaritimeError):
75
+ Maritime()
76
+
77
+
78
+ def test_bearer_and_json(make_client):
79
+ client, t = make_client([{"status": 200, "body": [{"id": "a1"}]}])
80
+ agents = client.agents.list()
81
+ assert agents == [{"id": "a1"}]
82
+ assert t.calls[0]["headers"]["authorization"] == "Bearer mk_test"
83
+ assert t.calls[0]["url"] == "https://api.example.test/api/agents"
84
+
85
+
86
+ def test_create_body_camelcase(make_client):
87
+ client, t = make_client([{"status": 201, "body": {"id": "a1"}}])
88
+ client.agents.create("x", template="openclaw", external_id="c1", env=[{"key": "F", "value": "b"}])
89
+ body = t.calls[0]["body"]
90
+ assert body["templateId"] == "openclaw"
91
+ assert body["externalId"] == "c1"
92
+ assert body["initialEnvVars"] == [{"key": "F", "value": "b", "isSecret": True}]
93
+
94
+
95
+ def test_chat_conversation_id(make_client):
96
+ client, t = make_client([{"status": 200, "body": {"response": "hi"}}])
97
+ r = client.agents.chat("a1", "hello", conversation_id="c1")
98
+ assert r["response"] == "hi"
99
+ assert t.calls[0]["body"] == {"message": "hello", "conversation_id": "c1"}
100
+
101
+
102
+ def test_list_external_id_query(make_client):
103
+ client, t = make_client([{"status": 200, "body": []}])
104
+ client.agents.list(external_id="cust_9")
105
+ assert "externalId=cust_9" in t.calls[0]["url"]
106
+
107
+
108
+ def test_delete_204(make_client):
109
+ client, t = make_client([{"status": 204}])
110
+ assert client.agents.delete("a1") is None
111
+
112
+
113
+ @pytest.mark.parametrize("status,klass", [
114
+ (401, MaritimeAuthError),
115
+ (403, MaritimeAuthError),
116
+ (402, MaritimePaymentRequiredError),
117
+ (404, MaritimeNotFoundError),
118
+ (409, MaritimeConflictError),
119
+ ])
120
+ def test_typed_errors(make_client, status, klass):
121
+ client, _ = make_client([{"status": status, "detail": "boom"}])
122
+ with pytest.raises(klass):
123
+ client.agents.get("a1")
124
+
125
+
126
+ def test_error_exposes_status_detail(make_client):
127
+ client, _ = make_client([{"status": 404, "detail": "no such agent"}])
128
+ with pytest.raises(MaritimeNotFoundError) as exc:
129
+ client.agents.get("a1")
130
+ assert exc.value.status == 404
131
+ assert exc.value.detail == "no such agent"
132
+
133
+
134
+ def test_retry_503_then_ok(make_client):
135
+ client, t = make_client([{"status": 503, "detail": "down"}, {"status": 200, "body": [{"id": "ok"}]}])
136
+ assert client.agents.list() == [{"id": "ok"}]
137
+ assert len(t.calls) == 2
138
+
139
+
140
+ def test_no_retry_post_500(make_client):
141
+ client, t = make_client([{"status": 500, "detail": "err"}, {"status": 201, "body": {"id": "nope"}}], max_retries=3)
142
+ with pytest.raises(MaritimeError) as exc:
143
+ client.agents.create("x", template="openclaw")
144
+ assert exc.value.status == 500
145
+ assert len(t.calls) == 1
146
+
147
+
148
+ def test_webhooks_create_list_delete_test(make_client):
149
+ client, t = make_client([
150
+ {"status": 201, "body": {"id": "wh1", "url": "https://x.test/h", "events": ["agent.error"], "secret": "whsec_x"}},
151
+ {"status": 200, "body": []},
152
+ {"status": 204},
153
+ {"status": 200, "body": {"delivered": True, "status_code": 200}},
154
+ ])
155
+ created = client.webhooks.create("https://x.test/h", events=["agent.error"])
156
+ assert created["secret"] == "whsec_x"
157
+ assert t.calls[0]["url"].endswith("/api/v1/webhooks")
158
+ assert t.calls[0]["body"] == {"url": "https://x.test/h", "events": ["agent.error"]}
159
+
160
+ client.webhooks.list()
161
+ assert t.calls[1]["method"] == "GET"
162
+
163
+ client.webhooks.delete("wh1")
164
+ assert t.calls[2]["method"] == "DELETE"
165
+ assert t.calls[2]["url"].endswith("/api/v1/webhooks/wh1")
166
+
167
+ r = client.webhooks.test("wh1")
168
+ assert r["delivered"] is True
169
+ assert t.calls[3]["url"].endswith("/api/v1/webhooks/wh1/test")
170
+
171
+
172
+ def test_provision_returns_existing(make_client):
173
+ client, t = make_client([{"status": 200, "body": [{"id": "existing", "externalId": "c1"}]}])
174
+ a = client.agents.provision(external_id="c1", name="x")
175
+ assert a["id"] == "existing"
176
+ assert len(t.calls) == 1 # only the list, no create
177
+
178
+
179
+ def test_provision_creates_when_absent(make_client):
180
+ client, t = make_client([
181
+ {"status": 200, "body": []},
182
+ {"status": 201, "body": {"id": "new", "externalId": "c2"}},
183
+ ])
184
+ a = client.agents.provision(external_id="c2", name="x")
185
+ assert a["id"] == "new"
186
+ assert len(t.calls) == 2
187
+ assert t.calls[1]["body"]["templateId"] == "openclaw"