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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentdisco
3
- Version: 0.2.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentdisco"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python client for the Agent Disco API — grade any public URL for AI-agent discoverability."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.2.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.exceptions import (
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.2.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
- Returns the raw dict each caller wraps in its dataclass.
232
+ Shared with the async client via {@link parse_json}.
215
233
  """
216
- if 200 <= response.status_code < 300:
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(401, json={"error": "invalid_token", "message": "Colony token exchange failed."})
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