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.
- agentdisco-0.1.0/.github/workflows/ci.yml +31 -0
- agentdisco-0.1.0/.gitignore +18 -0
- agentdisco-0.1.0/LICENSE +21 -0
- agentdisco-0.1.0/PKG-INFO +160 -0
- agentdisco-0.1.0/README.md +131 -0
- agentdisco-0.1.0/pyproject.toml +63 -0
- agentdisco-0.1.0/src/agentdisco/__init__.py +49 -0
- agentdisco-0.1.0/src/agentdisco/client.py +219 -0
- agentdisco-0.1.0/src/agentdisco/exceptions.py +80 -0
- agentdisco-0.1.0/src/agentdisco/models.py +94 -0
- agentdisco-0.1.0/src/agentdisco/py.typed +0 -0
- agentdisco-0.1.0/tests/__init__.py +0 -0
- agentdisco-0.1.0/tests/test_client.py +296 -0
|
@@ -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
|
agentdisco-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](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,131 @@
|
|
|
1
|
+
# agentdisco — Python client for Agent Disco
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/agentdisco/)
|
|
4
|
+
[](https://pypi.org/project/agentdisco/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](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
|