server4agent 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.
@@ -0,0 +1,115 @@
1
+ """Transport pieces shared by the sync and async clients.
2
+
3
+ Only the actual send/await differs between them; URL/header prep, the retry
4
+ policy, JSON decoding, error parsing, and debug logging all live here so the
5
+ two clients stay in lockstep.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import random
13
+ from typing import Any, Mapping, Optional
14
+ from urllib.parse import quote
15
+
16
+ from ._version import __version__
17
+ from .errors import error_from_status
18
+
19
+ DEFAULT_BASE_URL = "https://api.server4agent.com"
20
+ DEFAULT_TIMEOUT = 60.0
21
+ DEFAULT_MAX_RETRIES = 2
22
+
23
+ USER_AGENT = f"server4agent-python/{__version__}"
24
+
25
+ # Statuses worth retrying at all.
26
+ RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504})
27
+ # Methods safe to retry on any retryable status (no risk of duplicate writes).
28
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"})
29
+ # For non-idempotent methods (POST/PATCH) we only retry when the server clearly
30
+ # did not process the request, so a retry can never create a duplicate.
31
+ NON_IDEMPOTENT_RETRY_STATUS = frozenset({408, 429, 503})
32
+
33
+ _BACKOFF_BASE = 0.5
34
+ _BACKOFF_MAX = 8.0
35
+
36
+ logger = logging.getLogger("server4agent")
37
+ # Opt-in one-line request logging via SERVER4AGENT_LOG=debug.
38
+ if os.environ.get("SERVER4AGENT_LOG", "").lower() == "debug" and not logger.handlers:
39
+ _h = logging.StreamHandler()
40
+ _h.setFormatter(logging.Formatter("[server4agent] %(message)s"))
41
+ logger.addHandler(_h)
42
+ logger.setLevel(logging.DEBUG)
43
+
44
+
45
+ def default_headers(api_key: str) -> dict:
46
+ return {
47
+ "Authorization": f"Bearer {api_key}",
48
+ "User-Agent": USER_AGENT,
49
+ "Accept": "application/json",
50
+ }
51
+
52
+
53
+ def quote_segment(value: Any) -> str:
54
+ """Percent-encode a value for safe use as a single URL path segment, so an id
55
+ containing "/", "..", "?", or "#" can't redirect the request elsewhere."""
56
+ return quote(str(value), safe="")
57
+
58
+
59
+ def clean_params(query: Optional[Mapping[str, Any]]) -> Optional[dict]:
60
+ """Drop None values so we never send `?path=None`."""
61
+ if not query:
62
+ return None
63
+ out = {k: v for k, v in query.items() if v is not None}
64
+ return out or None
65
+
66
+
67
+ def decode_json(raw: bytes) -> Any:
68
+ """Parse a JSON body, tolerating empty or non-JSON responses (proxy HTML, etc.)."""
69
+ if not raw:
70
+ return None
71
+ try:
72
+ return json.loads(raw)
73
+ except (json.JSONDecodeError, ValueError):
74
+ return None
75
+
76
+
77
+ def should_retry_status(method: str, status: int, attempt: int, max_retries: int) -> bool:
78
+ if attempt >= max_retries or status not in RETRYABLE_STATUS:
79
+ return False
80
+ if method.upper() in IDEMPOTENT_METHODS:
81
+ return True
82
+ return status in NON_IDEMPOTENT_RETRY_STATUS
83
+
84
+
85
+ def can_retry_exception(method: str, attempt: int, max_retries: int) -> bool:
86
+ # A network/timeout error may have been processed server-side, so only retry
87
+ # methods where a duplicate is harmless.
88
+ return attempt < max_retries and method.upper() in IDEMPOTENT_METHODS
89
+
90
+
91
+ def retry_delay(attempt: int, retry_after: Optional[str]) -> float:
92
+ """Seconds to wait before the next attempt.
93
+
94
+ Honors a numeric ``Retry-After`` header when present; otherwise uses
95
+ exponential backoff with full jitter to avoid thundering herds.
96
+ """
97
+ if retry_after:
98
+ try:
99
+ return min(float(retry_after), _BACKOFF_MAX)
100
+ except ValueError:
101
+ pass # HTTP-date form — fall through to backoff
102
+ ceiling = min(_BACKOFF_MAX, _BACKOFF_BASE * (2 ** attempt))
103
+ return random.uniform(0, ceiling)
104
+
105
+
106
+ def request_id_from(headers: Mapping[str, str]) -> Optional[str]:
107
+ return headers.get("x-request-id") or headers.get("X-Request-Id")
108
+
109
+
110
+ def raise_for_status(status: int, body: Any, headers: Mapping[str, str]) -> None:
111
+ """Turn a non-2xx response into the right typed error."""
112
+ parsed = body if isinstance(body, dict) else {}
113
+ code = parsed.get("error", "unknown_error")
114
+ message = parsed.get("message") or f"Request failed with status {status}"
115
+ raise error_from_status(status, code, message, request_id_from(headers))
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
server4agent/errors.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class Server4AgentError(Exception):
7
+ """Base class for every error raised by this SDK."""
8
+
9
+
10
+ class APIConnectionError(Server4AgentError):
11
+ """The request never reached the API (DNS, TCP, TLS, or a dropped connection)."""
12
+
13
+ def __init__(self, message: str = "Could not reach the Server4Agent API.") -> None:
14
+ super().__init__(message)
15
+ self.message = message
16
+
17
+
18
+ class APITimeoutError(APIConnectionError):
19
+ """The request timed out before the API responded."""
20
+
21
+ def __init__(self, message: str = "Request to the Server4Agent API timed out.") -> None:
22
+ super().__init__(message)
23
+
24
+
25
+ class APIStatusError(Server4AgentError):
26
+ """The API returned a 4xx/5xx response.
27
+
28
+ Prefer catching a specific subclass (``NotFoundError``, ``RateLimitError``,
29
+ ...). ``code`` is the machine-readable string from the response body and
30
+ ``request_id`` (when present) is worth quoting to support.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ status: int,
36
+ code: str,
37
+ message: str,
38
+ request_id: Optional[str] = None,
39
+ ) -> None:
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.code = code
43
+ self.message = message
44
+ self.request_id = request_id
45
+
46
+ def __repr__(self) -> str:
47
+ return (
48
+ f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, "
49
+ f"message={self.message!r}, request_id={self.request_id!r})"
50
+ )
51
+
52
+
53
+ class BadRequestError(APIStatusError):
54
+ """400 — the request was malformed."""
55
+
56
+
57
+ class AuthenticationError(APIStatusError):
58
+ """401 — the API key is missing, invalid, or revoked."""
59
+
60
+
61
+ class PermissionDeniedError(APIStatusError):
62
+ """403 — the key is valid but not scoped to this resource."""
63
+
64
+
65
+ class NotFoundError(APIStatusError):
66
+ """404 — no such resource."""
67
+
68
+
69
+ class ConflictError(APIStatusError):
70
+ """409 — the request conflicts with the current state (e.g. a cap was hit)."""
71
+
72
+
73
+ class UnprocessableEntityError(APIStatusError):
74
+ """422 — the request was well-formed but semantically invalid."""
75
+
76
+
77
+ class RateLimitError(APIStatusError):
78
+ """429 — too many requests. The SDK retries these automatically; you only see it once retries are exhausted."""
79
+
80
+
81
+ class InternalServerError(APIStatusError):
82
+ """5xx — something went wrong on the API side."""
83
+
84
+
85
+ _STATUS_TO_ERROR = {
86
+ 400: BadRequestError,
87
+ 401: AuthenticationError,
88
+ 403: PermissionDeniedError,
89
+ 404: NotFoundError,
90
+ 409: ConflictError,
91
+ 422: UnprocessableEntityError,
92
+ 429: RateLimitError,
93
+ }
94
+
95
+
96
+ def error_from_status(
97
+ status: int, code: str, message: str, request_id: Optional[str] = None
98
+ ) -> APIStatusError:
99
+ """Map an HTTP status onto the most specific ``APIStatusError`` subclass."""
100
+ cls = _STATUS_TO_ERROR.get(status)
101
+ if cls is None:
102
+ cls = InternalServerError if status >= 500 else APIStatusError
103
+ return cls(status, code, message, request_id)
server4agent/py.typed ADDED
File without changes
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import time
6
+ from typing import Optional
7
+
8
+ # Server4Agent signs webhook deliveries as
9
+ # HMAC-SHA256(secret, f"{unix_ts}.{raw_body}"), sent as the
10
+ # Server4Agent-Signature header: "t=<unix_ts>,v1=<hex digest>".
11
+
12
+
13
+ def _sign(secret: str, body: str, timestamp: int) -> str:
14
+ digest = hmac.new(
15
+ secret.encode("utf-8"),
16
+ f"{timestamp}.{body}".encode("utf-8"),
17
+ hashlib.sha256,
18
+ ).hexdigest()
19
+ return f"t={timestamp},v1={digest}"
20
+
21
+
22
+ def verify_webhook_signature(
23
+ secret: str,
24
+ body: str,
25
+ header: Optional[str],
26
+ tolerance_sec: int = 300,
27
+ ) -> bool:
28
+ """Verify a webhook delivery.
29
+
30
+ `body` must be the raw request body exactly as received — decoding and
31
+ re-serializing JSON changes the bytes and breaks the signature. `header`
32
+ is the `Server4Agent-Signature` header value. Deliveries older than
33
+ `tolerance_sec` (default 300s) are rejected to limit replay windows.
34
+ """
35
+ if not header:
36
+ return False
37
+
38
+ parts = {}
39
+ for piece in header.split(","):
40
+ if "=" not in piece:
41
+ continue
42
+ k, v = piece.split("=", 1)
43
+ parts[k] = v
44
+
45
+ raw_ts = parts.get("t")
46
+ v1 = parts.get("v1")
47
+ if not raw_ts or not v1:
48
+ return False
49
+ try:
50
+ timestamp = int(raw_ts)
51
+ except ValueError:
52
+ return False
53
+
54
+ if abs(time.time() - timestamp) > tolerance_sec:
55
+ return False
56
+
57
+ expected = _sign(secret, body, timestamp)
58
+ expected_v1 = expected.split("v1=")[1]
59
+ return hmac.compare_digest(expected_v1, v1)
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: server4agent
3
+ Version: 0.1.0
4
+ Summary: Python client for the Server4Agent REST API — sync + async, typed, with retries.
5
+ Project-URL: Homepage, https://www.server4agent.com
6
+ Project-URL: Documentation, https://www.server4agent.com/docs/sdks
7
+ Project-URL: Repository, https://github.com/Server4Agent/server4agent-python
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,mcp,sdk,server4agent
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 :: Only
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: httpx<1,>=0.24
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # server4agent
24
+
25
+ Python client for the [Server4Agent](https://www.server4agent.com) REST API —
26
+ sync **and** async, fully typed, with automatic retries.
27
+
28
+ This SDK is for the code *around* your agent — your backend, a script, a
29
+ notebook, a webhook receiver. If your agent itself does tool-calling, point it
30
+ at the [MCP server](https://www.server4agent.com/docs/mcp) directly; it doesn't
31
+ need this package.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install server4agent
37
+ ```
38
+
39
+ Requires Python 3.9+. Depends only on [`httpx`](https://www.python-httpx.org/).
40
+
41
+ ## Quickstart
42
+
43
+ ```python
44
+ from server4agent import Server4Agent
45
+
46
+ client = Server4Agent() # reads SERVER4AGENT_API_KEY from the environment
47
+
48
+ # create() returns a handle you can act on directly
49
+ server = client.servers.create(tier="small")
50
+
51
+ # kick off a build and block until it's live
52
+ build = server.builds.start("a FastAPI todo API with a web UI").wait()
53
+ print(build.url)
54
+
55
+ # run a command right on the server
56
+ print(server.exec("ls -la"))
57
+ ```
58
+
59
+ `api_key` can also be passed explicitly: `Server4Agent(api_key="sk_live_...")`.
60
+ Use it as a context manager to close the connection pool:
61
+
62
+ ```python
63
+ with Server4Agent() as client:
64
+ ...
65
+ ```
66
+
67
+ ## Async
68
+
69
+ The async client mirrors the sync one method-for-method:
70
+
71
+ ```python
72
+ import asyncio
73
+ from server4agent import AsyncServer4Agent
74
+
75
+ async def main():
76
+ async with AsyncServer4Agent() as client:
77
+ server = await client.servers.create(tier="small")
78
+ task = await server.tasks.create("Scrape today's HN front page to JSON.")
79
+ await task.wait()
80
+ print(task.status, task.result)
81
+
82
+ asyncio.run(main())
83
+ ```
84
+
85
+ ## Handles
86
+
87
+ `create()` and `get()` return rich handles — data records you can also act on:
88
+
89
+ ```python
90
+ server = client.servers.get("srv_abc")
91
+ server.tasks.create("...") # sub-resources are scoped to the server
92
+ server.files.write("app.py", "...")
93
+ server.deploy()
94
+ server.refresh() # re-fetch in place
95
+
96
+ project = client.projects.get("prj_xyz")
97
+ project.update(visibility="public")
98
+ ```
99
+
100
+ `task.wait()` / `build.wait()` poll until a terminal state; both accept
101
+ `poll_interval` and `timeout` (seconds).
102
+
103
+ ## Errors
104
+
105
+ Every failure is a subclass of `Server4AgentError`, so you can catch broadly or
106
+ narrowly:
107
+
108
+ ```python
109
+ from server4agent import NotFoundError, RateLimitError, APIStatusError
110
+
111
+ try:
112
+ client.servers.get("srv_missing")
113
+ except NotFoundError:
114
+ ... # 404
115
+ except RateLimitError as e:
116
+ ... # 429 (already retried; quote e.request_id to support)
117
+ except APIStatusError as e:
118
+ print(e.status, e.code, e.message, e.request_id)
119
+ ```
120
+
121
+ Retryable failures (429, 5xx, network blips) are retried automatically with
122
+ exponential backoff. Tune it per client:
123
+
124
+ ```python
125
+ Server4Agent(timeout=30.0, max_retries=4)
126
+ ```
127
+
128
+ ## Verifying webhooks
129
+
130
+ ```python
131
+ import os
132
+ from server4agent import verify_webhook_signature
133
+
134
+ # request.data must be the raw body — parse it as JSON only after verifying.
135
+ ok = verify_webhook_signature(
136
+ secret=os.environ["SERVER4AGENT_WEBHOOK_SECRET"],
137
+ body=request.data.decode("utf-8"),
138
+ header=request.headers.get("Server4Agent-Signature"),
139
+ )
140
+ if not ok:
141
+ return "invalid signature", 401
142
+ ```
143
+
144
+ ## API surface
145
+
146
+ `servers`, `projects`, `templates`, `tasks`, `builds`, `files`, `keys`,
147
+ `webhooks` — see the [REST API docs](https://www.server4agent.com/docs/api) for
148
+ the endpoints each method wraps.
149
+
150
+ Server-side only: this SDK holds your `sk_live_` key. Never embed it in a
151
+ notebook or script you share.
@@ -0,0 +1,17 @@
1
+ server4agent/__init__.py,sha256=_D-aXt-Krlyj1yotPeEl2ca3J0w_2ccAR9Cu2GZR4sY,1632
2
+ server4agent/_models.py,sha256=QedyzzguyqYur_gVim7TYTm_VtBAQIUeXee0S9kp7m8,5520
3
+ server4agent/_transport.py,sha256=6UhDhdd74Vpo39Ju2oQJDodshwe8DrySciL01DMdQuI,4088
4
+ server4agent/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
5
+ server4agent/errors.py,sha256=gvyF5DyfE76vCMgOZNmVftDaKZUS9RhahhHgolR9MQU,3007
6
+ server4agent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ server4agent/webhooks.py,sha256=NqSESJ2AeQ2R_4fqwCruA6u93qPNSTmPgHJYLLq-H1g,1589
8
+ server4agent/_async/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ server4agent/_async/client.py,sha256=9nvwd8aCPgi4OK6HISD3FJkKi9WQAiNGwNbi2aZXdIE,20145
10
+ server4agent/_async/transport.py,sha256=8giEr6CA27-4r-AATLuVVdebCwYECx_xKnx2L-o6ZL0,2638
11
+ server4agent/_sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ server4agent/_sync/client.py,sha256=1kKRdLiYuRwCOHFOVFBF7684873DhPppU7tBA9SRt1w,19838
13
+ server4agent/_sync/transport.py,sha256=Wbxer1dXRMomeN3l814ZUBdmD9ZWr-zDEKJ7yRVjUpw,2562
14
+ server4agent-0.1.0.dist-info/METADATA,sha256=OGDblXPEsytSRUzgoSqtNs6_7EZkKux0nlAHDJAapSE,4365
15
+ server4agent-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ server4agent-0.1.0.dist-info/licenses/LICENSE,sha256=mn_K4ahqVsYCCH2u2DsU7nUWuhYa2Qza07_TnTqYPSE,1069
17
+ server4agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Server4Agent
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.