agentdisco 0.2.0__tar.gz → 0.3.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.
- {agentdisco-0.2.0 → agentdisco-0.3.0}/PKG-INFO +34 -1
- {agentdisco-0.2.0 → agentdisco-0.3.0}/README.md +33 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/pyproject.toml +1 -1
- {agentdisco-0.2.0 → agentdisco-0.3.0}/src/agentdisco/__init__.py +3 -1
- agentdisco-0.3.0/src/agentdisco/_response.py +78 -0
- agentdisco-0.3.0/src/agentdisco/async_client.py +140 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/src/agentdisco/client.py +30 -66
- agentdisco-0.3.0/tests/test_async_client.py +127 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/tests/test_client.py +52 -1
- {agentdisco-0.2.0 → agentdisco-0.3.0}/.github/workflows/ci.yml +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/.github/workflows/release.yml +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/.gitignore +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/LICENSE +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/src/agentdisco/exceptions.py +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/src/agentdisco/models.py +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/src/agentdisco/py.typed +0 -0
- {agentdisco-0.2.0 → agentdisco-0.3.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentdisco
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python client for the Agent Disco API — grade any public URL for AI-agent discoverability.
|
|
5
5
|
Project-URL: Homepage, https://agentdisco.io
|
|
6
6
|
Project-URL: Documentation, https://agentdisco.io/api/docs
|
|
@@ -93,6 +93,39 @@ with AgentDisco() as client:
|
|
|
93
93
|
print(site.latest_grade, site.latest_score, site.scan_count)
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
### Scan history + re-scan
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with AgentDisco() as client:
|
|
100
|
+
# Paginated history, most-recent first (per_page capped at 50).
|
|
101
|
+
for scan in client.get_scans("example.com", per_page=20):
|
|
102
|
+
print(scan.grade, scan.score)
|
|
103
|
+
|
|
104
|
+
# Queue a fresh scan of a known host (counts against your scan quota).
|
|
105
|
+
fresh = client.rescan("example.com")
|
|
106
|
+
print(fresh.id, fresh.status)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Async client
|
|
110
|
+
|
|
111
|
+
`AsyncAgentDisco` mirrors `AgentDisco` method-for-method over `asyncio`:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import asyncio
|
|
115
|
+
from agentdisco import AsyncAgentDisco
|
|
116
|
+
|
|
117
|
+
async def main():
|
|
118
|
+
async with AsyncAgentDisco(token="ak_...") as client:
|
|
119
|
+
scan = await client.submit_scan("https://example.com")
|
|
120
|
+
history = await client.get_scans("example.com")
|
|
121
|
+
print(scan.id, len(history))
|
|
122
|
+
|
|
123
|
+
asyncio.run(main())
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Same arguments, return types, and exceptions as the sync client —
|
|
127
|
+
including `await AsyncAgentDisco.from_colony_token(...)`.
|
|
128
|
+
|
|
96
129
|
### Sign in with the Colony (agents)
|
|
97
130
|
|
|
98
131
|
Autonomous agents on [The Colony](https://thecolony.cc) can authenticate
|
|
@@ -64,6 +64,39 @@ with AgentDisco() as client:
|
|
|
64
64
|
print(site.latest_grade, site.latest_score, site.scan_count)
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
### Scan history + re-scan
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
with AgentDisco() as client:
|
|
71
|
+
# Paginated history, most-recent first (per_page capped at 50).
|
|
72
|
+
for scan in client.get_scans("example.com", per_page=20):
|
|
73
|
+
print(scan.grade, scan.score)
|
|
74
|
+
|
|
75
|
+
# Queue a fresh scan of a known host (counts against your scan quota).
|
|
76
|
+
fresh = client.rescan("example.com")
|
|
77
|
+
print(fresh.id, fresh.status)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Async client
|
|
81
|
+
|
|
82
|
+
`AsyncAgentDisco` mirrors `AgentDisco` method-for-method over `asyncio`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import asyncio
|
|
86
|
+
from agentdisco import AsyncAgentDisco
|
|
87
|
+
|
|
88
|
+
async def main():
|
|
89
|
+
async with AsyncAgentDisco(token="ak_...") as client:
|
|
90
|
+
scan = await client.submit_scan("https://example.com")
|
|
91
|
+
history = await client.get_scans("example.com")
|
|
92
|
+
print(scan.id, len(history))
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Same arguments, return types, and exceptions as the sync client —
|
|
98
|
+
including `await AsyncAgentDisco.from_colony_token(...)`.
|
|
99
|
+
|
|
67
100
|
### Sign in with the Colony (agents)
|
|
68
101
|
|
|
69
102
|
Autonomous agents on [The Colony](https://thecolony.cc) can authenticate
|
|
@@ -27,6 +27,7 @@ Colony agents — sign in with your Colony token (no browser, RFC 8693):
|
|
|
27
27
|
See https://agentdisco.io/api/docs for the full OpenAPI spec.
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
|
+
from agentdisco.async_client import AsyncAgentDisco
|
|
30
31
|
from agentdisco.client import AgentDisco
|
|
31
32
|
from agentdisco.exceptions import (
|
|
32
33
|
AgentDiscoError,
|
|
@@ -43,6 +44,7 @@ __all__ = [
|
|
|
43
44
|
"AgentDiscoError",
|
|
44
45
|
"ApiError",
|
|
45
46
|
"ApiKey",
|
|
47
|
+
"AsyncAgentDisco",
|
|
46
48
|
"InvalidUrlError",
|
|
47
49
|
"NotFoundError",
|
|
48
50
|
"RateLimitedError",
|
|
@@ -51,4 +53,4 @@ __all__ = [
|
|
|
51
53
|
"Website",
|
|
52
54
|
]
|
|
53
55
|
|
|
54
|
-
__version__ = "0.
|
|
56
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared HTTP response handling for the sync + async clients.
|
|
2
|
+
|
|
3
|
+
`parse_json` extracts the JSON body on success and maps 4xx/5xx onto the
|
|
4
|
+
typed exception hierarchy. It's pure (no I/O, no awaiting) — httpx has
|
|
5
|
+
already read the body by the time a non-streaming response is returned, on
|
|
6
|
+
both the sync and async paths — so a single implementation serves both
|
|
7
|
+
clients.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from agentdisco.exceptions import (
|
|
17
|
+
AgentDiscoError,
|
|
18
|
+
ApiError,
|
|
19
|
+
InvalidUrlError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
RateLimitedError,
|
|
22
|
+
UnauthorizedError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_json(response: httpx.Response) -> dict[str, Any]:
|
|
27
|
+
"""Return the JSON object body, or raise the right typed exception."""
|
|
28
|
+
if 200 <= response.status_code < 300:
|
|
29
|
+
try:
|
|
30
|
+
body = response.json()
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise AgentDiscoError(
|
|
33
|
+
f"server returned {response.status_code} with non-JSON body",
|
|
34
|
+
) from exc
|
|
35
|
+
if not isinstance(body, dict):
|
|
36
|
+
raise AgentDiscoError(
|
|
37
|
+
f"server returned {response.status_code} with non-object JSON",
|
|
38
|
+
)
|
|
39
|
+
return body
|
|
40
|
+
|
|
41
|
+
payload: dict[str, Any] = {}
|
|
42
|
+
try:
|
|
43
|
+
parsed = response.json()
|
|
44
|
+
if isinstance(parsed, dict):
|
|
45
|
+
payload = parsed
|
|
46
|
+
except ValueError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
message = str(payload.get("message") or payload.get("error") or response.text or "").strip()
|
|
50
|
+
if message == "":
|
|
51
|
+
message = f"HTTP {response.status_code} from Agent Disco API"
|
|
52
|
+
error_code = payload.get("error") if isinstance(payload.get("error"), str) else None
|
|
53
|
+
|
|
54
|
+
status = response.status_code
|
|
55
|
+
kwargs: dict[str, Any] = {
|
|
56
|
+
"status_code": status,
|
|
57
|
+
"error_code": error_code,
|
|
58
|
+
"payload": payload,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if status == 400 and error_code == "invalid_url":
|
|
62
|
+
raise InvalidUrlError(message, **kwargs)
|
|
63
|
+
if status == 401:
|
|
64
|
+
raise UnauthorizedError(message, **kwargs)
|
|
65
|
+
if status == 404:
|
|
66
|
+
raise NotFoundError(message, **kwargs)
|
|
67
|
+
if status == 429:
|
|
68
|
+
retry_after = response.headers.get("Retry-After")
|
|
69
|
+
retry_after_seconds = (
|
|
70
|
+
int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
71
|
+
)
|
|
72
|
+
raise RateLimitedError(
|
|
73
|
+
message,
|
|
74
|
+
retry_after_seconds=retry_after_seconds,
|
|
75
|
+
**kwargs,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
raise ApiError(message, **kwargs)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The asynchronous Agent Disco client.
|
|
2
|
+
|
|
3
|
+
`AsyncAgentDisco` mirrors the synchronous `AgentDisco` method-for-method
|
|
4
|
+
over httpx's async client — same arguments, same return dataclasses, same
|
|
5
|
+
typed exceptions (shared via `agentdisco._response.parse_json`). Use it
|
|
6
|
+
from `asyncio` code or any concurrent runner.
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from agentdisco import AsyncAgentDisco
|
|
10
|
+
|
|
11
|
+
async def main():
|
|
12
|
+
async with AsyncAgentDisco(token="ak_...") as client:
|
|
13
|
+
scan = await client.submit_scan("https://example.com")
|
|
14
|
+
print(scan.id, scan.status)
|
|
15
|
+
|
|
16
|
+
asyncio.run(main())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from agentdisco._response import parse_json
|
|
24
|
+
from agentdisco.client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
|
|
25
|
+
from agentdisco.models import ApiKey, Scan, Website
|
|
26
|
+
|
|
27
|
+
_USER_AGENT = "agentdisco-python-async/0.3.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AsyncAgentDisco:
|
|
31
|
+
"""Asynchronous Agent Disco client. The async twin of `AgentDisco`.
|
|
32
|
+
|
|
33
|
+
Construct once and reuse — httpx's async client is connection-pooled.
|
|
34
|
+
Close it with `await client.aclose()`, or use it as an async context
|
|
35
|
+
manager (`async with AsyncAgentDisco() as client: ...`).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
token: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
43
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
44
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Construct a client. See `AgentDisco.__init__` for the arguments —
|
|
47
|
+
they're identical (`transport` here is an async transport, used by
|
|
48
|
+
the SDK's own tests to pin a `MockTransport`).
|
|
49
|
+
"""
|
|
50
|
+
self._token = token
|
|
51
|
+
headers = {
|
|
52
|
+
"User-Agent": _USER_AGENT,
|
|
53
|
+
"Accept": "application/json",
|
|
54
|
+
}
|
|
55
|
+
if token is not None:
|
|
56
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
57
|
+
self._http = httpx.AsyncClient(
|
|
58
|
+
base_url=base_url.rstrip("/"),
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
headers=headers,
|
|
61
|
+
transport=transport,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> AsyncAgentDisco:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
68
|
+
await self.aclose()
|
|
69
|
+
|
|
70
|
+
async def aclose(self) -> None:
|
|
71
|
+
"""Close the underlying async HTTP session. Safe to call twice."""
|
|
72
|
+
await self._http.aclose()
|
|
73
|
+
|
|
74
|
+
# -- Scans ----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
async def submit_scan(self, url: str) -> Scan:
|
|
77
|
+
"""Queue a scan for `url`. See `AgentDisco.submit_scan`."""
|
|
78
|
+
response = await self._http.post("/api/v1/scans", json={"url": url})
|
|
79
|
+
return Scan.from_response(parse_json(response))
|
|
80
|
+
|
|
81
|
+
async def get_scan(self, scan_id: str) -> Scan:
|
|
82
|
+
"""Fetch a scan by UUID. See `AgentDisco.get_scan`."""
|
|
83
|
+
response = await self._http.get(f"/api/v1/scans/{scan_id}")
|
|
84
|
+
return Scan.from_response(parse_json(response))
|
|
85
|
+
|
|
86
|
+
# -- Websites -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
async def get_website(self, host: str) -> Website:
|
|
89
|
+
"""Latest grade for a scanned host. See `AgentDisco.get_website`."""
|
|
90
|
+
response = await self._http.get(f"/api/v1/websites/{host}")
|
|
91
|
+
return Website.from_response(parse_json(response))
|
|
92
|
+
|
|
93
|
+
async def get_scans(self, host: str, *, page: int = 1, per_page: int = 10) -> list[Scan]:
|
|
94
|
+
"""A page of a host's completed-scan history. See `AgentDisco.get_scans`."""
|
|
95
|
+
response = await self._http.get(
|
|
96
|
+
f"/api/v1/websites/{host}/scans",
|
|
97
|
+
params={"page": page, "perPage": per_page},
|
|
98
|
+
)
|
|
99
|
+
payload = parse_json(response)
|
|
100
|
+
return [Scan.from_response(s) for s in payload.get("scans", [])]
|
|
101
|
+
|
|
102
|
+
async def rescan(self, host: str) -> Scan:
|
|
103
|
+
"""Queue a fresh scan of a known host. See `AgentDisco.rescan`."""
|
|
104
|
+
response = await self._http.post(f"/api/v1/websites/{host}/rescan")
|
|
105
|
+
return Scan.from_response(parse_json(response))
|
|
106
|
+
|
|
107
|
+
# -- API keys -------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async def mint_key(self) -> ApiKey:
|
|
110
|
+
"""Mint an anonymous-tier API key. See `AgentDisco.mint_key`."""
|
|
111
|
+
response = await self._http.post("/api/v1/keys")
|
|
112
|
+
return ApiKey.from_response(parse_json(response))
|
|
113
|
+
|
|
114
|
+
# -- Colony agent login (RFC 8693) ----------------------------------
|
|
115
|
+
|
|
116
|
+
async def exchange_colony_token(self, subject_token: str) -> ApiKey:
|
|
117
|
+
"""Exchange a Colony agent token for an API key. See
|
|
118
|
+
`AgentDisco.exchange_colony_token`.
|
|
119
|
+
"""
|
|
120
|
+
response = await self._http.post(
|
|
121
|
+
"/api/v1/auth/colony/agent",
|
|
122
|
+
json={"subject_token": subject_token},
|
|
123
|
+
)
|
|
124
|
+
return ApiKey.from_response(parse_json(response))
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
async def from_colony_token(
|
|
128
|
+
cls,
|
|
129
|
+
subject_token: str,
|
|
130
|
+
*,
|
|
131
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
132
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
133
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
134
|
+
) -> AsyncAgentDisco:
|
|
135
|
+
"""Build an authenticated client from a Colony agent token. See
|
|
136
|
+
`AgentDisco.from_colony_token`.
|
|
137
|
+
"""
|
|
138
|
+
async with cls(base_url=base_url, timeout=timeout, transport=transport) as anon:
|
|
139
|
+
key = await anon.exchange_colony_token(subject_token)
|
|
140
|
+
return cls(token=key.token, base_url=base_url, timeout=timeout, transport=transport)
|
|
@@ -15,19 +15,12 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
import httpx
|
|
17
17
|
|
|
18
|
-
from agentdisco.
|
|
19
|
-
AgentDiscoError,
|
|
20
|
-
ApiError,
|
|
21
|
-
InvalidUrlError,
|
|
22
|
-
NotFoundError,
|
|
23
|
-
RateLimitedError,
|
|
24
|
-
UnauthorizedError,
|
|
25
|
-
)
|
|
18
|
+
from agentdisco._response import parse_json
|
|
26
19
|
from agentdisco.models import ApiKey, Scan, Website
|
|
27
20
|
|
|
28
21
|
DEFAULT_BASE_URL = "https://agentdisco.io"
|
|
29
22
|
DEFAULT_TIMEOUT = 30.0
|
|
30
|
-
_USER_AGENT = "agentdisco-python/0.
|
|
23
|
+
_USER_AGENT = "agentdisco-python/0.3.0"
|
|
31
24
|
|
|
32
25
|
|
|
33
26
|
class AgentDisco:
|
|
@@ -131,6 +124,31 @@ class AgentDisco:
|
|
|
131
124
|
payload = self._parse(response)
|
|
132
125
|
return Website.from_response(payload)
|
|
133
126
|
|
|
127
|
+
def get_scans(self, host: str, *, page: int = 1, per_page: int = 10) -> list[Scan]:
|
|
128
|
+
"""A page of a host's completed-scan history, most-recent first.
|
|
129
|
+
|
|
130
|
+
`per_page` is capped server-side at 50. Track a grade trend by
|
|
131
|
+
paging through. Raises `NotFoundError` for an unknown/unlisted host.
|
|
132
|
+
"""
|
|
133
|
+
response = self._http.get(
|
|
134
|
+
f"/api/v1/websites/{host}/scans",
|
|
135
|
+
params={"page": page, "perPage": per_page},
|
|
136
|
+
)
|
|
137
|
+
payload = self._parse(response)
|
|
138
|
+
return [Scan.from_response(s) for s in payload.get("scans", [])]
|
|
139
|
+
|
|
140
|
+
def rescan(self, host: str) -> Scan:
|
|
141
|
+
"""Queue a fresh scan of an already-known host. Returns the queued
|
|
142
|
+
Scan (poll `get_scan(scan.id)` until completed).
|
|
143
|
+
|
|
144
|
+
Counts against your scan rate limit like `submit_scan` — present a
|
|
145
|
+
token for the higher per-key quota. Raises `NotFoundError` for an
|
|
146
|
+
unknown/unlisted host, `RateLimitedError` when the quota is spent.
|
|
147
|
+
"""
|
|
148
|
+
response = self._http.post(f"/api/v1/websites/{host}/rescan")
|
|
149
|
+
payload = self._parse(response)
|
|
150
|
+
return Scan.from_response(payload)
|
|
151
|
+
|
|
134
152
|
# -------------------------------------------------------------
|
|
135
153
|
# API keys
|
|
136
154
|
# -------------------------------------------------------------
|
|
@@ -209,62 +227,8 @@ class AgentDisco:
|
|
|
209
227
|
# -------------------------------------------------------------
|
|
210
228
|
|
|
211
229
|
def _parse(self, response: httpx.Response) -> dict[str, Any]:
|
|
212
|
-
"""Extract JSON body; raise the right exception on 4xx/5xx.
|
|
230
|
+
"""Extract the JSON body; raise the right exception on 4xx/5xx.
|
|
213
231
|
|
|
214
|
-
|
|
232
|
+
Shared with the async client via {@link parse_json}.
|
|
215
233
|
"""
|
|
216
|
-
|
|
217
|
-
try:
|
|
218
|
-
body = response.json()
|
|
219
|
-
except ValueError as exc:
|
|
220
|
-
raise AgentDiscoError(
|
|
221
|
-
f"server returned {response.status_code} with non-JSON body",
|
|
222
|
-
) from exc
|
|
223
|
-
if not isinstance(body, dict):
|
|
224
|
-
raise AgentDiscoError(
|
|
225
|
-
f"server returned {response.status_code} with non-object JSON",
|
|
226
|
-
)
|
|
227
|
-
return body
|
|
228
|
-
|
|
229
|
-
# Best-effort body parse so the error carries the server's
|
|
230
|
-
# error code + message; don't fail the wrap if the body isn't
|
|
231
|
-
# JSON (it usually is for /api/v1 but some 5xx paths return
|
|
232
|
-
# plain text).
|
|
233
|
-
payload: dict[str, Any] = {}
|
|
234
|
-
try:
|
|
235
|
-
parsed = response.json()
|
|
236
|
-
if isinstance(parsed, dict):
|
|
237
|
-
payload = parsed
|
|
238
|
-
except ValueError:
|
|
239
|
-
pass
|
|
240
|
-
|
|
241
|
-
message = str(payload.get("message") or payload.get("error") or response.text or "").strip()
|
|
242
|
-
if message == "":
|
|
243
|
-
message = f"HTTP {response.status_code} from Agent Disco API"
|
|
244
|
-
error_code = payload.get("error") if isinstance(payload.get("error"), str) else None
|
|
245
|
-
|
|
246
|
-
status = response.status_code
|
|
247
|
-
kwargs: dict[str, Any] = {
|
|
248
|
-
"status_code": status,
|
|
249
|
-
"error_code": error_code,
|
|
250
|
-
"payload": payload,
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if status == 400 and error_code == "invalid_url":
|
|
254
|
-
raise InvalidUrlError(message, **kwargs)
|
|
255
|
-
if status == 401:
|
|
256
|
-
raise UnauthorizedError(message, **kwargs)
|
|
257
|
-
if status == 404:
|
|
258
|
-
raise NotFoundError(message, **kwargs)
|
|
259
|
-
if status == 429:
|
|
260
|
-
retry_after = response.headers.get("Retry-After")
|
|
261
|
-
retry_after_seconds = (
|
|
262
|
-
int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
263
|
-
)
|
|
264
|
-
raise RateLimitedError(
|
|
265
|
-
message,
|
|
266
|
-
retry_after_seconds=retry_after_seconds,
|
|
267
|
-
**kwargs,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
raise ApiError(message, **kwargs)
|
|
234
|
+
return parse_json(response)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Async client tests.
|
|
2
|
+
|
|
3
|
+
Same MockTransport approach as the sync suite (the handler is a plain
|
|
4
|
+
sync callable; httpx's MockTransport adapts it for the async path), driven
|
|
5
|
+
through `asyncio.run` so no pytest-asyncio plugin is needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from agentdisco import AsyncAgentDisco, NotFoundError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_transport(handler):
|
|
20
|
+
return httpx.MockTransport(handler)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(coro):
|
|
24
|
+
return asyncio.run(coro)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_async_submit_scan_returns_scan():
|
|
28
|
+
def handler(request):
|
|
29
|
+
assert request.method == "POST"
|
|
30
|
+
assert request.url.path == "/api/v1/scans"
|
|
31
|
+
assert json.loads(request.content) == {"url": "https://example.com"}
|
|
32
|
+
return httpx.Response(202, json={
|
|
33
|
+
"id": "019d0000-0000-7000-8000-000000000001",
|
|
34
|
+
"status": "queued",
|
|
35
|
+
"statusUrl": "/api/v1/scans/019d0000-0000-7000-8000-000000000001",
|
|
36
|
+
"resultUrl": "/report/example.com",
|
|
37
|
+
"grade": None,
|
|
38
|
+
"score": None,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
async def go():
|
|
42
|
+
async with AsyncAgentDisco(transport=make_transport(handler)) as client:
|
|
43
|
+
return await client.submit_scan("https://example.com")
|
|
44
|
+
|
|
45
|
+
scan = run(go())
|
|
46
|
+
assert scan.status == "queued"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_async_get_scans_returns_history():
|
|
50
|
+
def handler(request):
|
|
51
|
+
assert request.url.path == "/api/v1/websites/example.com/scans"
|
|
52
|
+
return httpx.Response(200, json={
|
|
53
|
+
"host": "example.com",
|
|
54
|
+
"scans": [
|
|
55
|
+
{
|
|
56
|
+
"id": "1", "status": "completed", "grade": "A", "score": 90,
|
|
57
|
+
"completedAt": "2026-06-25T10:00:00+00:00", "statusUrl": "/api/v1/scans/1",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "2", "status": "completed", "grade": "B", "score": 70,
|
|
61
|
+
"completedAt": "2026-06-25T09:00:00+00:00", "statusUrl": "/api/v1/scans/2",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
"totalCount": 2, "page": 1, "perPage": 10,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
async def go():
|
|
68
|
+
async with AsyncAgentDisco(transport=make_transport(handler)) as client:
|
|
69
|
+
return await client.get_scans("example.com")
|
|
70
|
+
|
|
71
|
+
scans = run(go())
|
|
72
|
+
assert [s.grade for s in scans] == ["A", "B"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_async_rescan_returns_queued_scan():
|
|
76
|
+
def handler(request):
|
|
77
|
+
assert request.method == "POST"
|
|
78
|
+
assert request.url.path == "/api/v1/websites/example.com/rescan"
|
|
79
|
+
return httpx.Response(202, json={
|
|
80
|
+
"id": "r", "status": "queued", "statusUrl": "/api/v1/scans/r",
|
|
81
|
+
"resultUrl": "/report/example.com", "grade": None, "score": None,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
async def go():
|
|
85
|
+
async with AsyncAgentDisco(transport=make_transport(handler)) as client:
|
|
86
|
+
return await client.rescan("example.com")
|
|
87
|
+
|
|
88
|
+
assert run(go()).status == "queued"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_async_from_colony_token_returns_authed_client():
|
|
92
|
+
minted = "ak_asyncmintedfromcolonytokenabcdefghijklmno"
|
|
93
|
+
|
|
94
|
+
def handler(request):
|
|
95
|
+
if request.url.path == "/api/v1/auth/colony/agent":
|
|
96
|
+
return httpx.Response(201, json={
|
|
97
|
+
"id": "k", "token": minted, "tokenPrefix": "ak_async",
|
|
98
|
+
"rateLimitTier": "authenticated", "createdAt": "2026-06-25T10:00:00+00:00",
|
|
99
|
+
})
|
|
100
|
+
assert request.headers.get("Authorization") == f"Bearer {minted}"
|
|
101
|
+
return httpx.Response(202, json={
|
|
102
|
+
"id": "s", "status": "queued", "statusUrl": "/api/v1/scans/s",
|
|
103
|
+
"resultUrl": "/report/example.com", "grade": None, "score": None,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
async def go():
|
|
107
|
+
client = await AsyncAgentDisco.from_colony_token(
|
|
108
|
+
"colony-jwt", transport=make_transport(handler),
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
return await client.submit_scan("https://example.com")
|
|
112
|
+
finally:
|
|
113
|
+
await client.aclose()
|
|
114
|
+
|
|
115
|
+
assert run(go()).status == "queued"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_async_404_raises_not_found():
|
|
119
|
+
def handler(_request):
|
|
120
|
+
return httpx.Response(404, json={"error": "not_found", "message": "nope"})
|
|
121
|
+
|
|
122
|
+
async def go():
|
|
123
|
+
async with AsyncAgentDisco(transport=make_transport(handler)) as client:
|
|
124
|
+
await client.get_website("nobody.example")
|
|
125
|
+
|
|
126
|
+
with pytest.raises(NotFoundError):
|
|
127
|
+
run(go())
|
|
@@ -232,6 +232,55 @@ def test_mint_key_rate_limited_after_five_per_hour():
|
|
|
232
232
|
assert exc.value.retry_after_seconds == 1800
|
|
233
233
|
|
|
234
234
|
|
|
235
|
+
# ---------------------------------------------------------------
|
|
236
|
+
# Scan history + re-scan
|
|
237
|
+
# ---------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_get_scans_returns_history_most_recent_first():
|
|
241
|
+
def handler(request):
|
|
242
|
+
assert request.url.path == "/api/v1/websites/example.com/scans"
|
|
243
|
+
assert request.url.params.get("perPage") == "5"
|
|
244
|
+
return httpx.Response(200, json={
|
|
245
|
+
"host": "example.com",
|
|
246
|
+
"scans": [
|
|
247
|
+
{
|
|
248
|
+
"id": "1", "status": "completed", "grade": "A", "score": 90,
|
|
249
|
+
"completedAt": "2026-06-25T10:00:00+00:00", "statusUrl": "/api/v1/scans/1",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"id": "2", "status": "completed", "grade": "B", "score": 70,
|
|
253
|
+
"completedAt": "2026-06-25T09:00:00+00:00", "statusUrl": "/api/v1/scans/2",
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
"totalCount": 2, "page": 1, "perPage": 5,
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
scans = AgentDisco(transport=make_transport(handler)).get_scans("example.com", per_page=5)
|
|
260
|
+
|
|
261
|
+
assert [s.grade for s in scans] == ["A", "B"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_rescan_returns_queued_scan():
|
|
265
|
+
def handler(request):
|
|
266
|
+
assert request.method == "POST"
|
|
267
|
+
assert request.url.path == "/api/v1/websites/example.com/rescan"
|
|
268
|
+
return httpx.Response(202, json={
|
|
269
|
+
"id": "r", "status": "queued", "statusUrl": "/api/v1/scans/r",
|
|
270
|
+
"resultUrl": "/report/example.com", "grade": None, "score": None,
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
assert AgentDisco(transport=make_transport(handler)).rescan("example.com").status == "queued"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_get_scans_unknown_host_raises_not_found():
|
|
277
|
+
def handler(_request):
|
|
278
|
+
return httpx.Response(404, json={"error": "not_found", "message": "nope"})
|
|
279
|
+
|
|
280
|
+
with pytest.raises(NotFoundError):
|
|
281
|
+
AgentDisco(transport=make_transport(handler)).get_scans("nobody.example")
|
|
282
|
+
|
|
283
|
+
|
|
235
284
|
# ---------------------------------------------------------------
|
|
236
285
|
# Colony agent login (token exchange)
|
|
237
286
|
# ---------------------------------------------------------------
|
|
@@ -288,7 +337,9 @@ def test_from_colony_token_returns_client_authed_with_minted_key():
|
|
|
288
337
|
|
|
289
338
|
def test_exchange_colony_token_rejects_non_agent_with_401():
|
|
290
339
|
def handler(_request):
|
|
291
|
-
return httpx.Response(
|
|
340
|
+
return httpx.Response(
|
|
341
|
+
401, json={"error": "invalid_token", "message": "Colony token exchange failed."},
|
|
342
|
+
)
|
|
292
343
|
|
|
293
344
|
with pytest.raises(UnauthorizedError):
|
|
294
345
|
AgentDisco(transport=make_transport(handler)).exchange_colony_token("a-human-token")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|