agentdisco 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.
- agentdisco/__init__.py +49 -0
- agentdisco/client.py +219 -0
- agentdisco/exceptions.py +80 -0
- agentdisco/models.py +94 -0
- agentdisco/py.typed +0 -0
- agentdisco-0.1.0.dist-info/METADATA +160 -0
- agentdisco-0.1.0.dist-info/RECORD +9 -0
- agentdisco-0.1.0.dist-info/WHEEL +4 -0
- agentdisco-0.1.0.dist-info/licenses/LICENSE +21 -0
agentdisco/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Agent Disco Python client.
|
|
2
|
+
|
|
3
|
+
Grade any public URL for AI-agent discoverability. Wraps the REST API
|
|
4
|
+
at https://agentdisco.io/api/v1.
|
|
5
|
+
|
|
6
|
+
Basic usage:
|
|
7
|
+
|
|
8
|
+
>>> from agentdisco import AgentDisco
|
|
9
|
+
>>> client = AgentDisco() # anonymous (10 scans/day/IP)
|
|
10
|
+
>>> scan = client.submit_scan("https://example.com")
|
|
11
|
+
>>> scan.id # UUID
|
|
12
|
+
>>> client.get_scan(scan.id).status # poll: queued/running/completed
|
|
13
|
+
>>> client.get_website("example.com").grade # summary: A..F
|
|
14
|
+
|
|
15
|
+
With a key (100 or 500 scans/day depending on tier):
|
|
16
|
+
|
|
17
|
+
>>> key = AgentDisco().mint_key()
|
|
18
|
+
>>> print(key.token) # store this — shown once
|
|
19
|
+
>>> authed = AgentDisco(token=key.token)
|
|
20
|
+
>>> authed.submit_scan("https://example.com")
|
|
21
|
+
|
|
22
|
+
See https://agentdisco.io/api/docs for the full OpenAPI spec.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from agentdisco.client import AgentDisco
|
|
26
|
+
from agentdisco.exceptions import (
|
|
27
|
+
AgentDiscoError,
|
|
28
|
+
ApiError,
|
|
29
|
+
InvalidUrlError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
RateLimitedError,
|
|
32
|
+
UnauthorizedError,
|
|
33
|
+
)
|
|
34
|
+
from agentdisco.models import ApiKey, Scan, Website
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AgentDisco",
|
|
38
|
+
"AgentDiscoError",
|
|
39
|
+
"ApiError",
|
|
40
|
+
"ApiKey",
|
|
41
|
+
"InvalidUrlError",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"RateLimitedError",
|
|
44
|
+
"Scan",
|
|
45
|
+
"UnauthorizedError",
|
|
46
|
+
"Website",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
agentdisco/client.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The AgentDisco client.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over the REST API. Callers construct an `AgentDisco`
|
|
4
|
+
instance (with optional bearer token), then call methods that map
|
|
5
|
+
1:1 to endpoints and return dataclasses.
|
|
6
|
+
|
|
7
|
+
Scope is deliberately narrow — 4 endpoints today. More surface (report
|
|
8
|
+
diffs, check catalogue listing, scan history) can layer on without
|
|
9
|
+
breaking the existing shape.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from agentdisco.exceptions import (
|
|
19
|
+
AgentDiscoError,
|
|
20
|
+
ApiError,
|
|
21
|
+
InvalidUrlError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
RateLimitedError,
|
|
24
|
+
UnauthorizedError,
|
|
25
|
+
)
|
|
26
|
+
from agentdisco.models import ApiKey, Scan, Website
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE_URL = "https://agentdisco.io"
|
|
29
|
+
DEFAULT_TIMEOUT = 30.0
|
|
30
|
+
_USER_AGENT = "agentdisco-python/0.1.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgentDisco:
|
|
34
|
+
"""Synchronous Agent Disco client.
|
|
35
|
+
|
|
36
|
+
Construct once, reuse across calls — httpx's client is
|
|
37
|
+
connection-pooled, so per-call construction would reopen TLS on
|
|
38
|
+
every request.
|
|
39
|
+
|
|
40
|
+
An async variant (`AsyncAgentDisco`) can follow when a caller needs
|
|
41
|
+
one; today the synchronous API is enough for CI runners, CLIs,
|
|
42
|
+
notebooks.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
|
|
46
|
+
client = AgentDisco(token="ak_...")
|
|
47
|
+
scan = client.submit_scan("https://example.com")
|
|
48
|
+
while scan.status not in {"completed", "failed"}:
|
|
49
|
+
time.sleep(5)
|
|
50
|
+
scan = client.get_scan(scan.id)
|
|
51
|
+
print(scan.grade)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
token: str | None = None,
|
|
57
|
+
*,
|
|
58
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
59
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
60
|
+
transport: httpx.BaseTransport | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Construct a client.
|
|
63
|
+
|
|
64
|
+
`token` is an API key obtained via `mint_key()` or the web
|
|
65
|
+
flow at `/developers`. Absent → anonymous-tier rate limits.
|
|
66
|
+
|
|
67
|
+
`base_url` defaults to prod. Override for self-hosted
|
|
68
|
+
deployments or tests.
|
|
69
|
+
|
|
70
|
+
`transport` is an httpx transport escape-hatch used by the
|
|
71
|
+
SDK's own tests to pin a `MockTransport`. Real callers
|
|
72
|
+
shouldn't need it.
|
|
73
|
+
"""
|
|
74
|
+
self._token = token
|
|
75
|
+
headers = {
|
|
76
|
+
"User-Agent": _USER_AGENT,
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
}
|
|
79
|
+
if token is not None:
|
|
80
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
81
|
+
self._http = httpx.Client(
|
|
82
|
+
base_url=base_url.rstrip("/"),
|
|
83
|
+
timeout=timeout,
|
|
84
|
+
headers=headers,
|
|
85
|
+
transport=transport,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> AgentDisco:
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __exit__(self, *_exc_info: object) -> None:
|
|
92
|
+
self.close()
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
"""Close the underlying HTTP session. Safe to call twice."""
|
|
96
|
+
self._http.close()
|
|
97
|
+
|
|
98
|
+
# -------------------------------------------------------------
|
|
99
|
+
# Scans
|
|
100
|
+
# -------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def submit_scan(self, url: str) -> Scan:
|
|
103
|
+
"""Queue a scan for `url`. Returns a Scan with status=queued.
|
|
104
|
+
|
|
105
|
+
Raises `InvalidUrlError` if the URL fails server-side validation
|
|
106
|
+
(wrong scheme, private IP, malformed, etc.). Raises
|
|
107
|
+
`RateLimitedError` when the daily quota is used up — check
|
|
108
|
+
`.retry_after_seconds` for when to retry.
|
|
109
|
+
"""
|
|
110
|
+
response = self._http.post("/api/v1/scans", json={"url": url})
|
|
111
|
+
payload = self._parse(response)
|
|
112
|
+
return Scan.from_response(payload)
|
|
113
|
+
|
|
114
|
+
def get_scan(self, scan_id: str) -> Scan:
|
|
115
|
+
"""Fetch a scan by UUID. Raises `NotFoundError` on unknown id."""
|
|
116
|
+
response = self._http.get(f"/api/v1/scans/{scan_id}")
|
|
117
|
+
payload = self._parse(response)
|
|
118
|
+
return Scan.from_response(payload)
|
|
119
|
+
|
|
120
|
+
# -------------------------------------------------------------
|
|
121
|
+
# Websites
|
|
122
|
+
# -------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def get_website(self, host: str) -> Website:
|
|
125
|
+
"""Latest grade + scan count for a host that's been scanned.
|
|
126
|
+
|
|
127
|
+
Raises `NotFoundError` if the host has never been scanned (or
|
|
128
|
+
has been unlisted — the API returns 404 for both, deliberately).
|
|
129
|
+
"""
|
|
130
|
+
response = self._http.get(f"/api/v1/websites/{host}")
|
|
131
|
+
payload = self._parse(response)
|
|
132
|
+
return Website.from_response(payload)
|
|
133
|
+
|
|
134
|
+
# -------------------------------------------------------------
|
|
135
|
+
# API keys
|
|
136
|
+
# -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def mint_key(self) -> ApiKey:
|
|
139
|
+
"""Mint a new anonymous-tier API key.
|
|
140
|
+
|
|
141
|
+
The response contains the plaintext token exactly once — store
|
|
142
|
+
it immediately. The server keeps only a SHA-256 hash and
|
|
143
|
+
cannot reconstruct the plaintext if you lose it.
|
|
144
|
+
|
|
145
|
+
This method works without authentication (no token needed on
|
|
146
|
+
the calling client). To mint an authenticated-tier key (500
|
|
147
|
+
scans/day vs 100), sign in via the web flow at /developers.
|
|
148
|
+
|
|
149
|
+
Rate-limited at 5 keys/hour/IP; burst-hammering trips
|
|
150
|
+
`RateLimitedError`.
|
|
151
|
+
"""
|
|
152
|
+
response = self._http.post("/api/v1/keys")
|
|
153
|
+
payload = self._parse(response)
|
|
154
|
+
return ApiKey.from_response(payload)
|
|
155
|
+
|
|
156
|
+
# -------------------------------------------------------------
|
|
157
|
+
# Internal
|
|
158
|
+
# -------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _parse(self, response: httpx.Response) -> dict[str, Any]:
|
|
161
|
+
"""Extract JSON body; raise the right exception on 4xx/5xx.
|
|
162
|
+
|
|
163
|
+
Returns the raw dict — each caller wraps in its dataclass.
|
|
164
|
+
"""
|
|
165
|
+
if 200 <= response.status_code < 300:
|
|
166
|
+
try:
|
|
167
|
+
body = response.json()
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
raise AgentDiscoError(
|
|
170
|
+
f"server returned {response.status_code} with non-JSON body",
|
|
171
|
+
) from exc
|
|
172
|
+
if not isinstance(body, dict):
|
|
173
|
+
raise AgentDiscoError(
|
|
174
|
+
f"server returned {response.status_code} with non-object JSON",
|
|
175
|
+
)
|
|
176
|
+
return body
|
|
177
|
+
|
|
178
|
+
# Best-effort body parse so the error carries the server's
|
|
179
|
+
# error code + message; don't fail the wrap if the body isn't
|
|
180
|
+
# JSON (it usually is for /api/v1 but some 5xx paths return
|
|
181
|
+
# plain text).
|
|
182
|
+
payload: dict[str, Any] = {}
|
|
183
|
+
try:
|
|
184
|
+
parsed = response.json()
|
|
185
|
+
if isinstance(parsed, dict):
|
|
186
|
+
payload = parsed
|
|
187
|
+
except ValueError:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
message = str(payload.get("message") or payload.get("error") or response.text or "").strip()
|
|
191
|
+
if message == "":
|
|
192
|
+
message = f"HTTP {response.status_code} from Agent Disco API"
|
|
193
|
+
error_code = payload.get("error") if isinstance(payload.get("error"), str) else None
|
|
194
|
+
|
|
195
|
+
status = response.status_code
|
|
196
|
+
kwargs: dict[str, Any] = {
|
|
197
|
+
"status_code": status,
|
|
198
|
+
"error_code": error_code,
|
|
199
|
+
"payload": payload,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if status == 400 and error_code == "invalid_url":
|
|
203
|
+
raise InvalidUrlError(message, **kwargs)
|
|
204
|
+
if status == 401:
|
|
205
|
+
raise UnauthorizedError(message, **kwargs)
|
|
206
|
+
if status == 404:
|
|
207
|
+
raise NotFoundError(message, **kwargs)
|
|
208
|
+
if status == 429:
|
|
209
|
+
retry_after = response.headers.get("Retry-After")
|
|
210
|
+
retry_after_seconds = (
|
|
211
|
+
int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
212
|
+
)
|
|
213
|
+
raise RateLimitedError(
|
|
214
|
+
message,
|
|
215
|
+
retry_after_seconds=retry_after_seconds,
|
|
216
|
+
**kwargs,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
raise ApiError(message, **kwargs)
|
agentdisco/exceptions.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Exception hierarchy for the Agent Disco SDK.
|
|
2
|
+
|
|
3
|
+
All SDK-raised exceptions inherit `AgentDiscoError` so a caller can
|
|
4
|
+
do a single broad catch. More specific subtypes (`NotFoundError`,
|
|
5
|
+
`RateLimitedError`, etc.) let callers react differently to recoverable
|
|
6
|
+
vs unrecoverable failures.
|
|
7
|
+
|
|
8
|
+
Network-layer errors (connection timeout, DNS failure) leak through
|
|
9
|
+
as raw `httpx` exceptions — we don't wrap them because the failure
|
|
10
|
+
mode is platform, not API. Application-layer errors (4xx/5xx) are
|
|
11
|
+
wrapped into the `ApiError` branch.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentDiscoError(Exception):
|
|
20
|
+
"""Root of the SDK exception hierarchy."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(AgentDiscoError):
|
|
24
|
+
"""An HTTP response carried a 4xx or 5xx status.
|
|
25
|
+
|
|
26
|
+
`status_code` is the HTTP status; `error_code` is the server's
|
|
27
|
+
`error` field from the JSON body when present (e.g. `invalid_url`,
|
|
28
|
+
`not_found`); `payload` is the parsed JSON for anything the SDK
|
|
29
|
+
didn't model.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
message: str,
|
|
35
|
+
*,
|
|
36
|
+
status_code: int,
|
|
37
|
+
error_code: str | None = None,
|
|
38
|
+
payload: dict[str, Any] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.error_code = error_code
|
|
43
|
+
self.payload = payload or {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidUrlError(ApiError):
|
|
47
|
+
"""HTTP 400 with error=invalid_url — the URL failed server validation."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnauthorizedError(ApiError):
|
|
51
|
+
"""HTTP 401 — missing or invalid auth (ops endpoints only)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFoundError(ApiError):
|
|
55
|
+
"""HTTP 404 — unknown scan id or host."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitedError(ApiError):
|
|
59
|
+
"""HTTP 429 — quota exceeded.
|
|
60
|
+
|
|
61
|
+
`retry_after_seconds` is parsed from the `Retry-After` response
|
|
62
|
+
header when present; callers can sleep that long and retry.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
message: str,
|
|
68
|
+
*,
|
|
69
|
+
status_code: int,
|
|
70
|
+
retry_after_seconds: int | None = None,
|
|
71
|
+
error_code: str | None = None,
|
|
72
|
+
payload: dict[str, Any] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(
|
|
75
|
+
message,
|
|
76
|
+
status_code=status_code,
|
|
77
|
+
error_code=error_code,
|
|
78
|
+
payload=payload,
|
|
79
|
+
)
|
|
80
|
+
self.retry_after_seconds = retry_after_seconds
|
agentdisco/models.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Dataclass response models — what the REST endpoints return.
|
|
2
|
+
|
|
3
|
+
Only the load-bearing fields are modelled; the full JSON payload is
|
|
4
|
+
always available on `raw` for anything the SDK doesn't surface
|
|
5
|
+
directly. That gives us room to grow the API without breaking existing
|
|
6
|
+
callers who rely on specific fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Scan:
|
|
17
|
+
"""A scan (queued, running, or completed).
|
|
18
|
+
|
|
19
|
+
`grade` and `score` are `None` while the scan is still in flight;
|
|
20
|
+
poll `get_scan(id)` until `status == "completed"`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
status: str
|
|
25
|
+
result_url: str
|
|
26
|
+
grade: str | None
|
|
27
|
+
score: int | None
|
|
28
|
+
raw: dict[str, Any]
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_response(cls, payload: dict[str, Any]) -> Scan:
|
|
32
|
+
return cls(
|
|
33
|
+
id=str(payload["id"]),
|
|
34
|
+
status=str(payload["status"]),
|
|
35
|
+
result_url=str(payload.get("resultUrl") or payload.get("statusUrl") or ""),
|
|
36
|
+
grade=payload.get("grade"),
|
|
37
|
+
score=payload.get("score"),
|
|
38
|
+
raw=payload,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Website:
|
|
44
|
+
"""Summary view of a scanned host — latest grade + activity."""
|
|
45
|
+
|
|
46
|
+
host: str
|
|
47
|
+
latest_grade: str | None
|
|
48
|
+
latest_score: int | None
|
|
49
|
+
last_scanned_at: str | None
|
|
50
|
+
scan_count: int
|
|
51
|
+
raw: dict[str, Any]
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_response(cls, payload: dict[str, Any]) -> Website:
|
|
55
|
+
return cls(
|
|
56
|
+
host=str(payload["host"]),
|
|
57
|
+
latest_grade=payload.get("latestGrade"),
|
|
58
|
+
latest_score=payload.get("latestScore"),
|
|
59
|
+
last_scanned_at=payload.get("lastScannedAt"),
|
|
60
|
+
scan_count=int(payload.get("scanCount", 0)),
|
|
61
|
+
raw=payload,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ApiKey:
|
|
67
|
+
"""A freshly-minted API key.
|
|
68
|
+
|
|
69
|
+
`token` is the full plaintext — the server returns this exactly
|
|
70
|
+
ONCE (at mint time) and keeps only a SHA-256 hash. Store the
|
|
71
|
+
token immediately; losing it means minting a fresh one.
|
|
72
|
+
|
|
73
|
+
`token_prefix` is the first 10 chars (`ak_XXXXXXX`) and is safe to
|
|
74
|
+
display in logs or dashboards; unlike `token`, it can't be used
|
|
75
|
+
to authenticate.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
id: str
|
|
79
|
+
token: str
|
|
80
|
+
token_prefix: str
|
|
81
|
+
rate_limit_tier: str
|
|
82
|
+
created_at: str
|
|
83
|
+
raw: dict[str, Any]
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_response(cls, payload: dict[str, Any]) -> ApiKey:
|
|
87
|
+
return cls(
|
|
88
|
+
id=str(payload["id"]),
|
|
89
|
+
token=str(payload["token"]),
|
|
90
|
+
token_prefix=str(payload["tokenPrefix"]),
|
|
91
|
+
rate_limit_tier=str(payload["rateLimitTier"]),
|
|
92
|
+
created_at=str(payload["createdAt"]),
|
|
93
|
+
raw=payload,
|
|
94
|
+
)
|
agentdisco/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentdisco
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Agent Disco API — grade any public URL for AI-agent discoverability.
|
|
5
|
+
Project-URL: Homepage, https://agentdisco.io
|
|
6
|
+
Project-URL: Documentation, https://agentdisco.io/api/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/StarsolLtd/agent-disco
|
|
8
|
+
Author-email: Starsol Ltd <disty@agentdisco.io>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agentdisco,ai-agents,discoverability,llms-txt,openapi,scanner
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx<1.0,>=0.25
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# agentdisco — Python client for Agent Disco
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/agentdisco/)
|
|
33
|
+
[](https://pypi.org/project/agentdisco/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
[](https://github.com/agentdisco/agentdisco-python-sdk/actions/workflows/ci.yml)
|
|
36
|
+
|
|
37
|
+
Grade any public URL for AI-agent discoverability. Thin Python wrapper
|
|
38
|
+
over the REST API at <https://agentdisco.io/api/v1>.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install agentdisco
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Python 3.9+.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
### Submit a scan (anonymous, 10 scans/day/IP)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from agentdisco import AgentDisco
|
|
54
|
+
|
|
55
|
+
with AgentDisco() as client:
|
|
56
|
+
scan = client.submit_scan("https://example.com")
|
|
57
|
+
print(scan.id, scan.status)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Poll until complete
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import time
|
|
64
|
+
|
|
65
|
+
with AgentDisco() as client:
|
|
66
|
+
scan = client.submit_scan("https://example.com")
|
|
67
|
+
while scan.status not in {"completed", "failed"}:
|
|
68
|
+
time.sleep(5)
|
|
69
|
+
scan = client.get_scan(scan.id)
|
|
70
|
+
|
|
71
|
+
print(f"grade: {scan.grade} ({scan.score}/100)")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Mint a key (raises your quota to 100 scans/day)
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from agentdisco import AgentDisco
|
|
78
|
+
|
|
79
|
+
# Unauthenticated mint — no prior token needed, rate-limited at
|
|
80
|
+
# 5 keys/hour/IP. Token is shown ONCE; store it.
|
|
81
|
+
key = AgentDisco().mint_key()
|
|
82
|
+
print(key.token) # ak_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
83
|
+
|
|
84
|
+
authed = AgentDisco(token=key.token)
|
|
85
|
+
authed.submit_scan("https://your-site.example")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Get summary for a previously-scanned host
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
with AgentDisco() as client:
|
|
92
|
+
site = client.get_website("example.com")
|
|
93
|
+
print(site.latest_grade, site.latest_score, site.scan_count)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Higher rate limits
|
|
97
|
+
|
|
98
|
+
Authenticated-tier keys (500 scans/day/key) need a signed-in account.
|
|
99
|
+
Sign up at <https://agentdisco.io/register>, then mint via the web
|
|
100
|
+
form at <https://agentdisco.io/developers>.
|
|
101
|
+
|
|
102
|
+
| Tier | Rate limit | How to get |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| Anonymous (no key) | 10 scans / day / IP | default |
|
|
105
|
+
| Anonymous key | 100 scans / day / key | `mint_key()` above |
|
|
106
|
+
| Authenticated key | 500 scans / day / key | sign in, mint at `/developers` |
|
|
107
|
+
|
|
108
|
+
## Error handling
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from agentdisco import (
|
|
112
|
+
AgentDisco,
|
|
113
|
+
InvalidUrlError,
|
|
114
|
+
NotFoundError,
|
|
115
|
+
RateLimitedError,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
scan = AgentDisco().submit_scan("https://example.com")
|
|
120
|
+
except InvalidUrlError as e:
|
|
121
|
+
print(f"URL rejected: {e}")
|
|
122
|
+
except RateLimitedError as e:
|
|
123
|
+
print(f"quota exceeded; retry in {e.retry_after_seconds}s")
|
|
124
|
+
except NotFoundError as e:
|
|
125
|
+
print(f"not found: {e}")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
All SDK-raised exceptions inherit from `AgentDiscoError`, so a single
|
|
129
|
+
broad catch works too:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from agentdisco import AgentDiscoError
|
|
133
|
+
try:
|
|
134
|
+
...
|
|
135
|
+
except AgentDiscoError as e:
|
|
136
|
+
log.warning("agentdisco failure: %s", e)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Network-layer failures (connection timeout, DNS) leak through as raw
|
|
140
|
+
`httpx.HTTPError` — they're platform issues, not API errors.
|
|
141
|
+
|
|
142
|
+
## Custom base URL
|
|
143
|
+
|
|
144
|
+
For self-hosted deployments or local testing:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
AgentDisco(base_url="http://localhost:1977")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Links
|
|
151
|
+
|
|
152
|
+
- API docs: <https://agentdisco.io/api/docs>
|
|
153
|
+
- Check catalogue: <https://agentdisco.io/checks>
|
|
154
|
+
- Live scanner: <https://agentdisco.io>
|
|
155
|
+
|
|
156
|
+
## Licence
|
|
157
|
+
|
|
158
|
+
MIT. See [`LICENSE`](LICENSE). The scanner itself is operated by
|
|
159
|
+
**Starsol Ltd** (England, company 06002018); only this client library
|
|
160
|
+
is open-source. Issues + pull requests welcome.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
agentdisco/__init__.py,sha256=nQYF4JzFL4FW9A8NWs-1u_2-dTzw0Dh2puYKxgkThPw,1363
|
|
2
|
+
agentdisco/client.py,sha256=CZIGd4l1_nUyD3AS9Dp9Vvwy-pH7N8T5OMuvGCmsL_4,7612
|
|
3
|
+
agentdisco/exceptions.py,sha256=hff9Mr19SXkPPp7-ALjaFYt5y-6WY0EFUPt4wyVamVQ,2298
|
|
4
|
+
agentdisco/models.py,sha256=wzYkQDLI7EJvKUc88fOaOvpWep-oEkE4SGpzty1JMFE,2708
|
|
5
|
+
agentdisco/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
agentdisco-0.1.0.dist-info/METADATA,sha256=Bf0gkDEWr8ecw5Rnr36hQoP-87PbtGtjWxXmj23MUGg,4779
|
|
7
|
+
agentdisco-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
agentdisco-0.1.0.dist-info/licenses/LICENSE,sha256=eBT_2q1TL19ortBuaCXIzYivbs1CvQStzE5zBfITcIk,1068
|
|
9
|
+
agentdisco-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Starsol Ltd
|
|
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.
|