agentdisco 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ name: test (py${{ matrix.python-version }})
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ cache: pip
23
+ cache-dependency-path: pyproject.toml
24
+ - name: Install dev deps
25
+ run: pip install -e '.[dev]'
26
+ - name: Lint (ruff)
27
+ run: ruff check src tests
28
+ - name: Typecheck (mypy)
29
+ run: mypy src
30
+ - name: Test (pytest)
31
+ run: pytest -q
@@ -0,0 +1,18 @@
1
+ # Python build artefacts
2
+ /build/
3
+ /dist/
4
+ /*.egg-info/
5
+ /src/*.egg-info/
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+
10
+ # Tooling caches
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .pytest_cache/
14
+ .tox/
15
+
16
+ # Editor / virtualenv
17
+ .venv/
18
+ .env
@@ -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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/agentdisco.svg)](https://pypi.org/project/agentdisco/)
33
+ [![Python versions](https://img.shields.io/pypi/pyversions/agentdisco.svg)](https://pypi.org/project/agentdisco/)
34
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
35
+ [![CI](https://github.com/agentdisco/agentdisco-python-sdk/actions/workflows/ci.yml/badge.svg)](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,131 @@
1
+ # agentdisco — Python client for Agent Disco
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/agentdisco.svg)](https://pypi.org/project/agentdisco/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/agentdisco.svg)](https://pypi.org/project/agentdisco/)
5
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
+ [![CI](https://github.com/agentdisco/agentdisco-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/agentdisco/agentdisco-python-sdk/actions/workflows/ci.yml)
7
+
8
+ Grade any public URL for AI-agent discoverability. Thin Python wrapper
9
+ over the REST API at <https://agentdisco.io/api/v1>.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install agentdisco
15
+ ```
16
+
17
+ Requires Python 3.9+.
18
+
19
+ ## Quick start
20
+
21
+ ### Submit a scan (anonymous, 10 scans/day/IP)
22
+
23
+ ```python
24
+ from agentdisco import AgentDisco
25
+
26
+ with AgentDisco() as client:
27
+ scan = client.submit_scan("https://example.com")
28
+ print(scan.id, scan.status)
29
+ ```
30
+
31
+ ### Poll until complete
32
+
33
+ ```python
34
+ import time
35
+
36
+ with AgentDisco() as client:
37
+ scan = client.submit_scan("https://example.com")
38
+ while scan.status not in {"completed", "failed"}:
39
+ time.sleep(5)
40
+ scan = client.get_scan(scan.id)
41
+
42
+ print(f"grade: {scan.grade} ({scan.score}/100)")
43
+ ```
44
+
45
+ ### Mint a key (raises your quota to 100 scans/day)
46
+
47
+ ```python
48
+ from agentdisco import AgentDisco
49
+
50
+ # Unauthenticated mint — no prior token needed, rate-limited at
51
+ # 5 keys/hour/IP. Token is shown ONCE; store it.
52
+ key = AgentDisco().mint_key()
53
+ print(key.token) # ak_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
54
+
55
+ authed = AgentDisco(token=key.token)
56
+ authed.submit_scan("https://your-site.example")
57
+ ```
58
+
59
+ ### Get summary for a previously-scanned host
60
+
61
+ ```python
62
+ with AgentDisco() as client:
63
+ site = client.get_website("example.com")
64
+ print(site.latest_grade, site.latest_score, site.scan_count)
65
+ ```
66
+
67
+ ## Higher rate limits
68
+
69
+ Authenticated-tier keys (500 scans/day/key) need a signed-in account.
70
+ Sign up at <https://agentdisco.io/register>, then mint via the web
71
+ form at <https://agentdisco.io/developers>.
72
+
73
+ | Tier | Rate limit | How to get |
74
+ |---|---|---|
75
+ | Anonymous (no key) | 10 scans / day / IP | default |
76
+ | Anonymous key | 100 scans / day / key | `mint_key()` above |
77
+ | Authenticated key | 500 scans / day / key | sign in, mint at `/developers` |
78
+
79
+ ## Error handling
80
+
81
+ ```python
82
+ from agentdisco import (
83
+ AgentDisco,
84
+ InvalidUrlError,
85
+ NotFoundError,
86
+ RateLimitedError,
87
+ )
88
+
89
+ try:
90
+ scan = AgentDisco().submit_scan("https://example.com")
91
+ except InvalidUrlError as e:
92
+ print(f"URL rejected: {e}")
93
+ except RateLimitedError as e:
94
+ print(f"quota exceeded; retry in {e.retry_after_seconds}s")
95
+ except NotFoundError as e:
96
+ print(f"not found: {e}")
97
+ ```
98
+
99
+ All SDK-raised exceptions inherit from `AgentDiscoError`, so a single
100
+ broad catch works too:
101
+
102
+ ```python
103
+ from agentdisco import AgentDiscoError
104
+ try:
105
+ ...
106
+ except AgentDiscoError as e:
107
+ log.warning("agentdisco failure: %s", e)
108
+ ```
109
+
110
+ Network-layer failures (connection timeout, DNS) leak through as raw
111
+ `httpx.HTTPError` — they're platform issues, not API errors.
112
+
113
+ ## Custom base URL
114
+
115
+ For self-hosted deployments or local testing:
116
+
117
+ ```python
118
+ AgentDisco(base_url="http://localhost:1977")
119
+ ```
120
+
121
+ ## Links
122
+
123
+ - API docs: <https://agentdisco.io/api/docs>
124
+ - Check catalogue: <https://agentdisco.io/checks>
125
+ - Live scanner: <https://agentdisco.io>
126
+
127
+ ## Licence
128
+
129
+ MIT. See [`LICENSE`](LICENSE). The scanner itself is operated by
130
+ **Starsol Ltd** (England, company 06002018); only this client library
131
+ is open-source. Issues + pull requests welcome.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentdisco"
7
+ version = "0.1.0"
8
+ description = "Python client for the Agent Disco API — grade any public URL for AI-agent discoverability."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [
14
+ { name = "Starsol Ltd", email = "disty@agentdisco.io" },
15
+ ]
16
+ keywords = ["agentdisco", "ai-agents", "discoverability", "llms-txt", "openapi", "scanner"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Software Development :: Libraries",
27
+ ]
28
+ dependencies = [
29
+ "httpx>=0.25,<1.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.4",
35
+ "pytest-httpx>=0.30",
36
+ "mypy>=1.8",
37
+ "ruff>=0.4",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://agentdisco.io"
42
+ Documentation = "https://agentdisco.io/api/docs"
43
+ Repository = "https://github.com/StarsolLtd/agent-disco"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/agentdisco"]
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ python_files = ["test_*.py"]
51
+
52
+ [tool.ruff]
53
+ target-version = "py39"
54
+ line-length = 100
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
58
+ ignore = []
59
+
60
+ [tool.mypy]
61
+ python_version = "3.9"
62
+ strict = true
63
+ # SDK is small and user-facing — strict typing is worth the cost.
@@ -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"
@@ -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)
@@ -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
@@ -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
+ )
File without changes
File without changes
@@ -0,0 +1,296 @@
1
+ """Tests for the AgentDisco client.
2
+
3
+ Uses httpx's MockTransport to stub the HTTP layer. Real agentdisco.io
4
+ isn't touched — tests need to be hermetic (CI without network,
5
+ stable output, fast execution).
6
+
7
+ Pattern: each test builds a `MockTransport(handler)` where `handler`
8
+ is a callable that maps request → response. The client is
9
+ constructed with `transport=transport`, which pipes all HTTP through
10
+ the stub without any real I/O.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+
17
+ import httpx
18
+ import pytest
19
+
20
+ from agentdisco import (
21
+ AgentDisco,
22
+ AgentDiscoError,
23
+ ApiError,
24
+ InvalidUrlError,
25
+ NotFoundError,
26
+ RateLimitedError,
27
+ UnauthorizedError,
28
+ )
29
+
30
+
31
+ def make_transport(handler):
32
+ """Convenience wrapper: accept a `(request) -> httpx.Response` callable."""
33
+ return httpx.MockTransport(handler)
34
+
35
+
36
+ # ---------------------------------------------------------------
37
+ # Scan submit
38
+ # ---------------------------------------------------------------
39
+
40
+
41
+ def test_submit_scan_returns_scan_dataclass():
42
+ def handler(request):
43
+ assert request.method == "POST"
44
+ assert request.url.path == "/api/v1/scans"
45
+ body = json.loads(request.content)
46
+ assert body == {"url": "https://example.com"}
47
+ return httpx.Response(
48
+ 202,
49
+ json={
50
+ "id": "019d0000-0000-7000-8000-000000000001",
51
+ "status": "queued",
52
+ "statusUrl": "/api/v1/scans/019d0000-0000-7000-8000-000000000001",
53
+ "resultUrl": "/report/example.com",
54
+ "grade": None,
55
+ "score": None,
56
+ },
57
+ )
58
+
59
+ client = AgentDisco(transport=make_transport(handler))
60
+ scan = client.submit_scan("https://example.com")
61
+
62
+ assert scan.id == "019d0000-0000-7000-8000-000000000001"
63
+ assert scan.status == "queued"
64
+ assert scan.grade is None
65
+ assert scan.score is None
66
+ # The full payload stays reachable for fields the SDK doesn't surface.
67
+ assert scan.raw["statusUrl"].startswith("/api/v1/scans/")
68
+
69
+
70
+ def test_submit_scan_sends_bearer_token_when_configured():
71
+ received_headers = {}
72
+
73
+ def handler(request):
74
+ received_headers.update(dict(request.headers))
75
+ return httpx.Response(202, json={
76
+ "id": "019d0000-0000-7000-8000-000000000001",
77
+ "status": "queued",
78
+ "statusUrl": "/api/v1/scans/019d0000-0000-7000-8000-000000000001",
79
+ "resultUrl": "/report/example.com",
80
+ "grade": None,
81
+ "score": None,
82
+ })
83
+
84
+ client = AgentDisco(token="ak_test12345", transport=make_transport(handler))
85
+ client.submit_scan("https://example.com")
86
+
87
+ assert received_headers.get("authorization") == "Bearer ak_test12345"
88
+
89
+
90
+ def test_submit_scan_raises_invalid_url_on_400_with_error_code():
91
+ def handler(_request):
92
+ return httpx.Response(400, json={
93
+ "error": "invalid_url",
94
+ "message": "URL must use http or https scheme.",
95
+ })
96
+
97
+ client = AgentDisco(transport=make_transport(handler))
98
+
99
+ with pytest.raises(InvalidUrlError) as exc:
100
+ client.submit_scan("file:///etc/passwd")
101
+
102
+ assert exc.value.status_code == 400
103
+ assert exc.value.error_code == "invalid_url"
104
+ assert "URL must use" in str(exc.value)
105
+
106
+
107
+ def test_submit_scan_raises_rate_limited_on_429_with_retry_after():
108
+ def handler(_request):
109
+ return httpx.Response(
110
+ 429,
111
+ json={"error": "rate_limited", "message": "Anonymous scan quota exceeded."},
112
+ headers={"Retry-After": "3600"},
113
+ )
114
+
115
+ client = AgentDisco(transport=make_transport(handler))
116
+
117
+ with pytest.raises(RateLimitedError) as exc:
118
+ client.submit_scan("https://example.com")
119
+
120
+ assert exc.value.retry_after_seconds == 3600
121
+ assert exc.value.status_code == 429
122
+
123
+
124
+ # ---------------------------------------------------------------
125
+ # Scan polling
126
+ # ---------------------------------------------------------------
127
+
128
+
129
+ def test_get_scan_returns_completed_scan_with_grade():
130
+ def handler(request):
131
+ assert request.method == "GET"
132
+ assert request.url.path == "/api/v1/scans/abc"
133
+ return httpx.Response(200, json={
134
+ "id": "abc",
135
+ "status": "completed",
136
+ "statusUrl": "/api/v1/scans/abc",
137
+ "resultUrl": "/report/example.com",
138
+ "grade": "A",
139
+ "score": 92,
140
+ })
141
+
142
+ client = AgentDisco(transport=make_transport(handler))
143
+ scan = client.get_scan("abc")
144
+
145
+ assert scan.status == "completed"
146
+ assert scan.grade == "A"
147
+ assert scan.score == 92
148
+
149
+
150
+ def test_get_scan_raises_not_found_on_404():
151
+ def handler(_request):
152
+ return httpx.Response(404, json={"error": "not_found", "message": "unknown scan id"})
153
+
154
+ client = AgentDisco(transport=make_transport(handler))
155
+
156
+ with pytest.raises(NotFoundError):
157
+ client.get_scan("does-not-exist")
158
+
159
+
160
+ # ---------------------------------------------------------------
161
+ # Website summary
162
+ # ---------------------------------------------------------------
163
+
164
+
165
+ def test_get_website_returns_summary():
166
+ def handler(request):
167
+ assert request.url.path == "/api/v1/websites/example.com"
168
+ return httpx.Response(200, json={
169
+ "host": "example.com",
170
+ "latestGrade": "B",
171
+ "latestScore": 72,
172
+ "lastScannedAt": "2026-04-24T10:00:00+00:00",
173
+ "scanCount": 4,
174
+ })
175
+
176
+ client = AgentDisco(transport=make_transport(handler))
177
+ site = client.get_website("example.com")
178
+
179
+ assert site.host == "example.com"
180
+ assert site.latest_grade == "B"
181
+ assert site.latest_score == 72
182
+ assert site.scan_count == 4
183
+
184
+
185
+ def test_get_website_raises_not_found_for_unscanned_host():
186
+ def handler(_request):
187
+ return httpx.Response(404, json={"error": "not_found"})
188
+
189
+ client = AgentDisco(transport=make_transport(handler))
190
+
191
+ with pytest.raises(NotFoundError):
192
+ client.get_website("never-scanned.example")
193
+
194
+
195
+ # ---------------------------------------------------------------
196
+ # Mint key
197
+ # ---------------------------------------------------------------
198
+
199
+
200
+ def test_mint_key_returns_plaintext_once():
201
+ """Plaintext is server-side one-shot; SDK just surfaces it faithfully."""
202
+
203
+ def handler(request):
204
+ assert request.method == "POST"
205
+ assert request.url.path == "/api/v1/keys"
206
+ return httpx.Response(201, json={
207
+ "id": "019d0000-0000-7000-8000-000000000002",
208
+ "token": "ak_abcdefghijklmnopqrstuvwxyz01234567890ABCDEF",
209
+ "tokenPrefix": "ak_abcdef",
210
+ "rateLimitTier": "anonymous",
211
+ "createdAt": "2026-04-24T10:00:00+00:00",
212
+ })
213
+
214
+ client = AgentDisco(transport=make_transport(handler))
215
+ key = client.mint_key()
216
+
217
+ assert key.token.startswith("ak_")
218
+ assert len(key.token) == 46
219
+ assert key.token_prefix == "ak_abcdef"
220
+ assert key.rate_limit_tier == "anonymous"
221
+
222
+
223
+ def test_mint_key_rate_limited_after_five_per_hour():
224
+ def handler(_request):
225
+ return httpx.Response(429, json={"error": "rate_limited"}, headers={"Retry-After": "1800"})
226
+
227
+ client = AgentDisco(transport=make_transport(handler))
228
+
229
+ with pytest.raises(RateLimitedError) as exc:
230
+ client.mint_key()
231
+
232
+ assert exc.value.retry_after_seconds == 1800
233
+
234
+
235
+ # ---------------------------------------------------------------
236
+ # Error handling — generic paths
237
+ # ---------------------------------------------------------------
238
+
239
+
240
+ def test_500_raises_generic_api_error():
241
+ def handler(_request):
242
+ return httpx.Response(500, text="Internal Server Error")
243
+
244
+ client = AgentDisco(transport=make_transport(handler))
245
+
246
+ with pytest.raises(ApiError) as exc:
247
+ client.submit_scan("https://example.com")
248
+
249
+ # 500 is not one of the mapped specific subtypes; generic ApiError.
250
+ specific = (InvalidUrlError, NotFoundError, RateLimitedError, UnauthorizedError)
251
+ assert not isinstance(exc.value, specific)
252
+ assert exc.value.status_code == 500
253
+
254
+
255
+ def test_401_raises_unauthorized_error():
256
+ def handler(_request):
257
+ return httpx.Response(
258
+ 401,
259
+ json={"error": "unauthorized", "message": "HTTP Basic auth required."},
260
+ headers={"WWW-Authenticate": "Basic realm=\"ops\""},
261
+ )
262
+
263
+ client = AgentDisco(transport=make_transport(handler))
264
+
265
+ with pytest.raises(UnauthorizedError):
266
+ client.get_website("example.com")
267
+
268
+
269
+ def test_non_json_body_on_success_raises_agentdisco_error():
270
+ def handler(_request):
271
+ return httpx.Response(200, text="not json at all", headers={"Content-Type": "text/plain"})
272
+
273
+ client = AgentDisco(transport=make_transport(handler))
274
+
275
+ with pytest.raises(AgentDiscoError):
276
+ client.get_website("example.com")
277
+
278
+
279
+ def test_context_manager_closes_session():
280
+ handler_calls = 0
281
+
282
+ def handler(_request):
283
+ nonlocal handler_calls
284
+ handler_calls += 1
285
+ return httpx.Response(200, json={
286
+ "host": "x.com",
287
+ "latestGrade": "B",
288
+ "latestScore": 80,
289
+ "lastScannedAt": "2026-04-24T00:00:00+00:00",
290
+ "scanCount": 1,
291
+ })
292
+
293
+ with AgentDisco(transport=make_transport(handler)) as client:
294
+ client.get_website("x.com")
295
+
296
+ assert handler_calls == 1