aivisibilityskill 1.0.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.
- aivisibilityskill-1.0.0/.gitignore +24 -0
- aivisibilityskill-1.0.0/LICENSE +21 -0
- aivisibilityskill-1.0.0/PKG-INFO +131 -0
- aivisibilityskill-1.0.0/README.md +107 -0
- aivisibilityskill-1.0.0/pyproject.toml +41 -0
- aivisibilityskill-1.0.0/src/aivisibilityskill/__init__.py +13 -0
- aivisibilityskill-1.0.0/src/aivisibilityskill/_client.py +529 -0
- aivisibilityskill-1.0.0/src/aivisibilityskill/_version.py +1 -0
- aivisibilityskill-1.0.0/src/aivisibilityskill/_webhooks.py +96 -0
- aivisibilityskill-1.0.0/src/aivisibilityskill/py.typed +0 -0
- aivisibilityskill-1.0.0/tests/signing_vector.json +9 -0
- aivisibilityskill-1.0.0/tests/test_client.py +236 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
.turbo/
|
|
3
|
+
.wrangler/
|
|
4
|
+
dist/
|
|
5
|
+
coverage/
|
|
6
|
+
*.log
|
|
7
|
+
.DS_Store
|
|
8
|
+
|
|
9
|
+
# secrets — never commit
|
|
10
|
+
.dev.vars
|
|
11
|
+
.dev.vars.*
|
|
12
|
+
!.dev.vars.example
|
|
13
|
+
.env
|
|
14
|
+
.env.*
|
|
15
|
+
!.env.example
|
|
16
|
+
|
|
17
|
+
# review account passwords
|
|
18
|
+
.review-credentials*
|
|
19
|
+
|
|
20
|
+
# Python SDK
|
|
21
|
+
packages/sdk-python/.venv/
|
|
22
|
+
packages/sdk-python/dist/
|
|
23
|
+
**/__pycache__/
|
|
24
|
+
*.egg-info/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aivisibilityskill
|
|
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,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aivisibilityskill
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the aivisibilityskill API: see how ChatGPT, Gemini, AI Overviews, Perplexity, Claude and Grok answer about any brand.
|
|
5
|
+
Project-URL: Homepage, https://aivisibilityskill.com/sdk
|
|
6
|
+
Project-URL: Documentation, https://api.aivisibilityskill.com/docs
|
|
7
|
+
Author-email: aivisibilityskill <support@aivisibilityskill.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai overviews,ai visibility,chatgpt,generative engine optimization,geo,llm,sdk
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx<1,>=0.25
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# aivisibilityskill
|
|
26
|
+
|
|
27
|
+
The official Python client for the [aivisibilityskill](https://aivisibilityskill.com) API. Ask ChatGPT, Gemini, Google AI Overviews and AI Mode, Perplexity, Claude and Grok about any brand, in any country, and get back what they said, who they cited and what to fix.
|
|
28
|
+
|
|
29
|
+
- Safe retries: every check carries an idempotency key, so a retry is never charged twice.
|
|
30
|
+
- Webhook signature verification included.
|
|
31
|
+
- One dependency (httpx). Python 3.9+. Typed (`py.typed`).
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pip install aivisibilityskill
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import os
|
|
41
|
+
from aivisibilityskill import Aiv
|
|
42
|
+
|
|
43
|
+
aiv = Aiv(api_key=os.environ["AIV_KEY"]) # aiv_live_... or aiv_test_... (free sample data)
|
|
44
|
+
|
|
45
|
+
job = aiv.checks.create(
|
|
46
|
+
["best invoicing app for freelancers"],
|
|
47
|
+
engines=["chatgpt", "gemini", "ai_overview", "perplexity"],
|
|
48
|
+
locale="en-US",
|
|
49
|
+
)
|
|
50
|
+
done = aiv.jobs.wait(job["id"])
|
|
51
|
+
for answer in done["answers"]:
|
|
52
|
+
print(answer["engine"], [b["name"] for b in answer["brands"]])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Get a key at [app.aivisibilityskill.com](https://app.aivisibilityskill.com). Test keys (`aiv_test_...`) are free and return sample answers, so you can build without spending credits.
|
|
56
|
+
|
|
57
|
+
## Building for your own customers
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
aiv.checks.create(questions, customer="cus_8421", metadata={"workspace": "acme"})
|
|
61
|
+
|
|
62
|
+
key = aiv.keys.create("acme", customer="cus_8421", allowed_ips=["203.0.113.0/24"])
|
|
63
|
+
|
|
64
|
+
csv_text = aiv.usage.csv(from_="2026-11-01", to="2026-11-30", group_by="customer")
|
|
65
|
+
|
|
66
|
+
# Everything stored for a customer, deleted (runs in the background):
|
|
67
|
+
deletion = aiv.customers.delete_data("cus_8421")
|
|
68
|
+
aiv.data_deletions.wait(deletion["id"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Thousands of answers at once
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
batch = aiv.batches.create(
|
|
75
|
+
two_thousand_questions,
|
|
76
|
+
engines=["chatgpt", "ai_overviews"],
|
|
77
|
+
mode="batch", # cheapest
|
|
78
|
+
max_age_hours=24, # reuse answers from the last day at half price
|
|
79
|
+
)
|
|
80
|
+
done = aiv.batches.wait(batch["id"]) # or listen for the batch.completed webhook
|
|
81
|
+
for job_id in done["checks"]:
|
|
82
|
+
job = aiv.jobs.get(job_id)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Webhooks
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from aivisibilityskill import verify_webhook, WebhookVerificationError
|
|
89
|
+
|
|
90
|
+
# Flask: request.get_data(), Django: request.body, FastAPI: await request.body()
|
|
91
|
+
try:
|
|
92
|
+
event = verify_webhook(raw_body, request.headers, os.environ["AIV_WEBHOOK_SECRET"])
|
|
93
|
+
except WebhookVerificationError:
|
|
94
|
+
return "", 400
|
|
95
|
+
if event["type"] == "check.completed":
|
|
96
|
+
job = aiv.jobs.get(event["data"]["id"])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
During a secret rotation, pass both secrets: `verify_webhook(body, headers, [new_secret, old_secret])`. Deliveries older than 5 minutes are refused as replays.
|
|
100
|
+
|
|
101
|
+
## Lists
|
|
102
|
+
|
|
103
|
+
Lists are paged. `iterate()` follows the pages for you:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
for job in aiv.jobs.iterate(customer="cus_8421"): ...
|
|
107
|
+
for event in aiv.events.iterate(type="check.failed"): ...
|
|
108
|
+
for entry in aiv.audit_log.iterate(): ...
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
Every failure raises `AivError` with the HTTP `status`, a stable `code` (`insufficient_credits`, `budget_exceeded`, `rate_limited`, ...), a readable `detail` and the `request_id` to quote to support.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from aivisibilityskill import AivError
|
|
117
|
+
try:
|
|
118
|
+
aiv.checks.create(questions)
|
|
119
|
+
except AivError as err:
|
|
120
|
+
if err.code == "insufficient_credits": ...
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
GET requests and checks are retried on 429, 502, 503, 504 and network errors (twice by default, honouring `Retry-After`). Set `max_retries` and `timeout` in the constructor, or pass your own `httpx.Client`. Use it as a context manager (`with Aiv(...) as aiv:`) to close connections when done.
|
|
124
|
+
|
|
125
|
+
## Everything else
|
|
126
|
+
|
|
127
|
+
`aiv.projects` (tracking, schedules, visibility, competitors, sources, facts, history, site checks, research), `aiv.opportunities` and `aiv.fixes` (what to fix and proof it worked), `aiv.actions`, `aiv.webhooks`, `aiv.events`, `aiv.keys`, `aiv.account`, `aiv.locales`, `aiv.pricing()` and `aiv.estimate()`. Responses are the API's JSON as dicts. For anything not wrapped yet: `aiv.request("GET", "/v1/...")`.
|
|
128
|
+
|
|
129
|
+
API reference: [api.aivisibilityskill.com/docs](https://api.aivisibilityskill.com/docs). Support: support@aivisibilityskill.com.
|
|
130
|
+
|
|
131
|
+
MIT licensed.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# aivisibilityskill
|
|
2
|
+
|
|
3
|
+
The official Python client for the [aivisibilityskill](https://aivisibilityskill.com) API. Ask ChatGPT, Gemini, Google AI Overviews and AI Mode, Perplexity, Claude and Grok about any brand, in any country, and get back what they said, who they cited and what to fix.
|
|
4
|
+
|
|
5
|
+
- Safe retries: every check carries an idempotency key, so a retry is never charged twice.
|
|
6
|
+
- Webhook signature verification included.
|
|
7
|
+
- One dependency (httpx). Python 3.9+. Typed (`py.typed`).
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install aivisibilityskill
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from aivisibilityskill import Aiv
|
|
18
|
+
|
|
19
|
+
aiv = Aiv(api_key=os.environ["AIV_KEY"]) # aiv_live_... or aiv_test_... (free sample data)
|
|
20
|
+
|
|
21
|
+
job = aiv.checks.create(
|
|
22
|
+
["best invoicing app for freelancers"],
|
|
23
|
+
engines=["chatgpt", "gemini", "ai_overview", "perplexity"],
|
|
24
|
+
locale="en-US",
|
|
25
|
+
)
|
|
26
|
+
done = aiv.jobs.wait(job["id"])
|
|
27
|
+
for answer in done["answers"]:
|
|
28
|
+
print(answer["engine"], [b["name"] for b in answer["brands"]])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Get a key at [app.aivisibilityskill.com](https://app.aivisibilityskill.com). Test keys (`aiv_test_...`) are free and return sample answers, so you can build without spending credits.
|
|
32
|
+
|
|
33
|
+
## Building for your own customers
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
aiv.checks.create(questions, customer="cus_8421", metadata={"workspace": "acme"})
|
|
37
|
+
|
|
38
|
+
key = aiv.keys.create("acme", customer="cus_8421", allowed_ips=["203.0.113.0/24"])
|
|
39
|
+
|
|
40
|
+
csv_text = aiv.usage.csv(from_="2026-11-01", to="2026-11-30", group_by="customer")
|
|
41
|
+
|
|
42
|
+
# Everything stored for a customer, deleted (runs in the background):
|
|
43
|
+
deletion = aiv.customers.delete_data("cus_8421")
|
|
44
|
+
aiv.data_deletions.wait(deletion["id"])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Thousands of answers at once
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
batch = aiv.batches.create(
|
|
51
|
+
two_thousand_questions,
|
|
52
|
+
engines=["chatgpt", "ai_overviews"],
|
|
53
|
+
mode="batch", # cheapest
|
|
54
|
+
max_age_hours=24, # reuse answers from the last day at half price
|
|
55
|
+
)
|
|
56
|
+
done = aiv.batches.wait(batch["id"]) # or listen for the batch.completed webhook
|
|
57
|
+
for job_id in done["checks"]:
|
|
58
|
+
job = aiv.jobs.get(job_id)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Webhooks
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from aivisibilityskill import verify_webhook, WebhookVerificationError
|
|
65
|
+
|
|
66
|
+
# Flask: request.get_data(), Django: request.body, FastAPI: await request.body()
|
|
67
|
+
try:
|
|
68
|
+
event = verify_webhook(raw_body, request.headers, os.environ["AIV_WEBHOOK_SECRET"])
|
|
69
|
+
except WebhookVerificationError:
|
|
70
|
+
return "", 400
|
|
71
|
+
if event["type"] == "check.completed":
|
|
72
|
+
job = aiv.jobs.get(event["data"]["id"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
During a secret rotation, pass both secrets: `verify_webhook(body, headers, [new_secret, old_secret])`. Deliveries older than 5 minutes are refused as replays.
|
|
76
|
+
|
|
77
|
+
## Lists
|
|
78
|
+
|
|
79
|
+
Lists are paged. `iterate()` follows the pages for you:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
for job in aiv.jobs.iterate(customer="cus_8421"): ...
|
|
83
|
+
for event in aiv.events.iterate(type="check.failed"): ...
|
|
84
|
+
for entry in aiv.audit_log.iterate(): ...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Errors
|
|
88
|
+
|
|
89
|
+
Every failure raises `AivError` with the HTTP `status`, a stable `code` (`insufficient_credits`, `budget_exceeded`, `rate_limited`, ...), a readable `detail` and the `request_id` to quote to support.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from aivisibilityskill import AivError
|
|
93
|
+
try:
|
|
94
|
+
aiv.checks.create(questions)
|
|
95
|
+
except AivError as err:
|
|
96
|
+
if err.code == "insufficient_credits": ...
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
GET requests and checks are retried on 429, 502, 503, 504 and network errors (twice by default, honouring `Retry-After`). Set `max_retries` and `timeout` in the constructor, or pass your own `httpx.Client`. Use it as a context manager (`with Aiv(...) as aiv:`) to close connections when done.
|
|
100
|
+
|
|
101
|
+
## Everything else
|
|
102
|
+
|
|
103
|
+
`aiv.projects` (tracking, schedules, visibility, competitors, sources, facts, history, site checks, research), `aiv.opportunities` and `aiv.fixes` (what to fix and proof it worked), `aiv.actions`, `aiv.webhooks`, `aiv.events`, `aiv.keys`, `aiv.account`, `aiv.locales`, `aiv.pricing()` and `aiv.estimate()`. Responses are the API's JSON as dicts. For anything not wrapped yet: `aiv.request("GET", "/v1/...")`.
|
|
104
|
+
|
|
105
|
+
API reference: [api.aivisibilityskill.com/docs](https://api.aivisibilityskill.com/docs). Support: support@aivisibilityskill.com.
|
|
106
|
+
|
|
107
|
+
MIT licensed.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aivisibilityskill"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client for the aivisibilityskill API: see how ChatGPT, Gemini, AI Overviews, Perplexity, Claude and Grok answer about any brand."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "aivisibilityskill", email = "support@aivisibilityskill.com" }]
|
|
14
|
+
keywords = ["ai visibility", "geo", "generative engine optimization", "chatgpt", "ai overviews", "llm", "sdk"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://aivisibilityskill.com/sdk"
|
|
29
|
+
Documentation = "https://api.aivisibilityskill.com/docs"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/aivisibilityskill"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Official Python client for the aivisibilityskill API.
|
|
2
|
+
|
|
3
|
+
from aivisibilityskill import Aiv
|
|
4
|
+
aiv = Aiv(api_key="aiv_test_...")
|
|
5
|
+
job = aiv.checks.create(["best invoicing app for freelancers"])
|
|
6
|
+
done = aiv.jobs.wait(job["id"])
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._client import Aiv, AivError
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from ._webhooks import WebhookVerificationError, verify_webhook
|
|
12
|
+
|
|
13
|
+
__all__ = ["Aiv", "AivError", "WebhookVerificationError", "verify_webhook", "__version__"]
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""The aivisibilityskill API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Sequence
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from ._webhooks import verify_webhook
|
|
15
|
+
|
|
16
|
+
JSON = Dict[str, Any]
|
|
17
|
+
_RETRYABLE = {429, 502, 503, 504}
|
|
18
|
+
_KEY = re.compile(r"^aiv_(live|test)_")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _p(value: str) -> str:
|
|
22
|
+
return quote(str(value), safe="")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AivError(Exception):
|
|
26
|
+
"""An API error. ``code`` is stable (insufficient_credits, budget_exceeded, rate_limited, ...)."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
status: int,
|
|
31
|
+
code: str,
|
|
32
|
+
detail: Optional[str],
|
|
33
|
+
request_id: Optional[str],
|
|
34
|
+
body: Mapping[str, Any],
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(f"{status} {code}" + (f": {detail}" if detail else ""))
|
|
37
|
+
self.status = status
|
|
38
|
+
self.code = code
|
|
39
|
+
self.detail = detail
|
|
40
|
+
self.request_id = request_id
|
|
41
|
+
self.body = dict(body)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Resource:
|
|
45
|
+
def __init__(self, client: "Aiv") -> None:
|
|
46
|
+
self._c = client
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pages(fetch: Callable[[Optional[Any]], JSON], key: str) -> Iterator[JSON]:
|
|
50
|
+
before: Optional[Any] = None
|
|
51
|
+
while True:
|
|
52
|
+
page = fetch(before)
|
|
53
|
+
for item in page.get(key) or []:
|
|
54
|
+
yield item
|
|
55
|
+
before = page.get("next")
|
|
56
|
+
if before in (None, ""):
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _wait(get: Callable[[], JSON], done: Callable[[JSON], bool], timeout: float, interval: Callable[[JSON], float]) -> JSON:
|
|
61
|
+
deadline = time.monotonic() + timeout
|
|
62
|
+
while True:
|
|
63
|
+
item = get()
|
|
64
|
+
if done(item) or time.monotonic() >= deadline:
|
|
65
|
+
return item
|
|
66
|
+
time.sleep(max(0.0, min(interval(item), deadline - time.monotonic())))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _analytics(question_id: Optional[str], engines: Optional[Sequence[str]], days: Optional[int]) -> JSON:
|
|
70
|
+
return {
|
|
71
|
+
"question_id": question_id,
|
|
72
|
+
"engines": ",".join(engines) if engines else None,
|
|
73
|
+
"days": days,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Account(_Resource):
|
|
78
|
+
def get(self) -> JSON:
|
|
79
|
+
return self._c.request("GET", "/v1/account")
|
|
80
|
+
|
|
81
|
+
def update(self, **fields: Any) -> JSON:
|
|
82
|
+
"""For example ``update(balance_alert_below=10_000)``."""
|
|
83
|
+
return self._c.request("PATCH", "/v1/account", json=fields)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Locales(_Resource):
|
|
87
|
+
def list(self) -> JSON:
|
|
88
|
+
return self._c.request("GET", "/v1/locales")
|
|
89
|
+
|
|
90
|
+
def get(self, locale: str) -> JSON:
|
|
91
|
+
return self._c.request("GET", f"/v1/locales/{_p(locale)}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Checks(_Resource):
|
|
95
|
+
def create(
|
|
96
|
+
self,
|
|
97
|
+
questions: Sequence[str],
|
|
98
|
+
*,
|
|
99
|
+
idempotency_key: Optional[str] = None,
|
|
100
|
+
**fields: Any,
|
|
101
|
+
) -> JSON:
|
|
102
|
+
"""Starts a check: ``create(["best crm"], engines=["chatgpt"], locale="en-US", customer="cus_1")``.
|
|
103
|
+
|
|
104
|
+
A retry with the same idempotency key is never charged twice (one is made for you).
|
|
105
|
+
"""
|
|
106
|
+
return self._c.request(
|
|
107
|
+
"POST",
|
|
108
|
+
"/v1/checks",
|
|
109
|
+
json={"questions": list(questions), **fields},
|
|
110
|
+
idempotency_key=idempotency_key or str(uuid.uuid4()),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Batches(_Resource):
|
|
115
|
+
def create(self, questions: Sequence[str], *, idempotency_key: Optional[str] = None, **fields: Any) -> JSON:
|
|
116
|
+
"""Thousands of answers in one request, run as checks of up to 200 as your plan's
|
|
117
|
+
concurrency allows: ``create(questions, engines=["chatgpt"], mode="batch")``. Retrying
|
|
118
|
+
with the same idempotency key never starts a second batch."""
|
|
119
|
+
return self._c.request(
|
|
120
|
+
"POST",
|
|
121
|
+
"/v1/batches",
|
|
122
|
+
json={"questions": list(questions), **fields},
|
|
123
|
+
idempotency_key=idempotency_key or str(uuid.uuid4()),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def get(self, batch_id: str) -> JSON:
|
|
127
|
+
return self._c.request("GET", f"/v1/batches/{_p(batch_id)}")
|
|
128
|
+
|
|
129
|
+
def list(self, *, limit: Optional[int] = None, before: Optional[str] = None, customer: Optional[str] = None) -> JSON:
|
|
130
|
+
return self._c.request("GET", "/v1/batches", params={"limit": limit, "before": before, "customer": customer})
|
|
131
|
+
|
|
132
|
+
def iterate(self, *, customer: Optional[str] = None, limit: Optional[int] = None) -> Iterator[JSON]:
|
|
133
|
+
return _pages(lambda b: self.list(limit=limit, before=b, customer=customer), "batches")
|
|
134
|
+
|
|
135
|
+
def cancel(self, batch_id: str) -> JSON:
|
|
136
|
+
"""Stops starting new checks; running ones finish and nothing more is charged."""
|
|
137
|
+
return self._c.request("POST", f"/v1/batches/{_p(batch_id)}/cancel")
|
|
138
|
+
|
|
139
|
+
def wait(self, batch_id: str, *, timeout: float = 7200) -> JSON:
|
|
140
|
+
"""Polls until the batch completes or stops."""
|
|
141
|
+
return _wait(
|
|
142
|
+
lambda: self.get(batch_id),
|
|
143
|
+
lambda b: b.get("poll_after_seconds") is None,
|
|
144
|
+
timeout,
|
|
145
|
+
lambda b: float(b.get("poll_after_seconds") or 30),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Jobs(_Resource):
|
|
150
|
+
def get(self, job_id: str, *, text: bool = True) -> JSON:
|
|
151
|
+
return self._c.request("GET", f"/v1/jobs/{_p(job_id)}", params={"text": None if text else "false"})
|
|
152
|
+
|
|
153
|
+
def list(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
limit: Optional[int] = None,
|
|
157
|
+
before: Optional[str] = None,
|
|
158
|
+
project_id: Optional[str] = None,
|
|
159
|
+
customer: Optional[str] = None,
|
|
160
|
+
) -> JSON:
|
|
161
|
+
return self._c.request(
|
|
162
|
+
"GET",
|
|
163
|
+
"/v1/jobs",
|
|
164
|
+
params={"limit": limit, "before": before, "project_id": project_id, "customer": customer},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def iterate(self, *, project_id: Optional[str] = None, customer: Optional[str] = None, limit: Optional[int] = None) -> Iterator[JSON]:
|
|
168
|
+
"""Every job, newest first, across pages."""
|
|
169
|
+
return _pages(lambda b: self.list(limit=limit, before=b, project_id=project_id, customer=customer), "jobs")
|
|
170
|
+
|
|
171
|
+
def wait(self, job_id: str, *, timeout: float = 600) -> JSON:
|
|
172
|
+
"""Polls at the pace the API suggests until the job finishes (or ``timeout`` seconds)."""
|
|
173
|
+
return _wait(
|
|
174
|
+
lambda: self.get(job_id),
|
|
175
|
+
lambda j: bool(j.get("finished_at")) or j.get("poll_after_seconds") is None,
|
|
176
|
+
timeout,
|
|
177
|
+
lambda j: float(j.get("poll_after_seconds") or 1),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Answers(_Resource):
|
|
182
|
+
def get(self, answer_id: str) -> JSON:
|
|
183
|
+
return self._c.request("GET", f"/v1/answers/{_p(answer_id)}")
|
|
184
|
+
|
|
185
|
+
def receipt(self, answer_id: str) -> Any:
|
|
186
|
+
"""The engine's raw response, as stored when the answer was collected."""
|
|
187
|
+
return self._c.request("GET", f"/v1/answers/{_p(answer_id)}/receipt")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Projects(_Resource):
|
|
191
|
+
def create(self, *, idempotency_key: Optional[str] = None, **fields: Any) -> JSON:
|
|
192
|
+
"""``create(name="Acme", brand={"name": "Acme", "domains": ["acme.com"]}, questions=[...])``."""
|
|
193
|
+
return self._c.request("POST", "/v1/projects", json=fields, idempotency_key=idempotency_key or str(uuid.uuid4()))
|
|
194
|
+
|
|
195
|
+
def list(self, *, customer: Optional[str] = None) -> JSON:
|
|
196
|
+
return self._c.request("GET", "/v1/projects", params={"customer": customer})
|
|
197
|
+
|
|
198
|
+
def get(self, project_id: str) -> JSON:
|
|
199
|
+
return self._c.request("GET", f"/v1/projects/{_p(project_id)}")
|
|
200
|
+
|
|
201
|
+
def update(self, project_id: str, **fields: Any) -> JSON:
|
|
202
|
+
return self._c.request("PATCH", f"/v1/projects/{_p(project_id)}", json=fields)
|
|
203
|
+
|
|
204
|
+
def archive(self, project_id: str) -> None:
|
|
205
|
+
self._c.request("DELETE", f"/v1/projects/{_p(project_id)}")
|
|
206
|
+
|
|
207
|
+
def _analytics(self, kind: str, project_id: str, question_id: Optional[str], engines: Optional[Sequence[str]], days: Optional[int], **extra: Any) -> JSON:
|
|
208
|
+
return self._c.request(
|
|
209
|
+
"GET",
|
|
210
|
+
f"/v1/projects/{_p(project_id)}/{kind}",
|
|
211
|
+
params={**_analytics(question_id, engines, days), **extra},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def visibility(self, project_id: str, *, question_id: Optional[str] = None, engines: Optional[Sequence[str]] = None, days: Optional[int] = None) -> JSON:
|
|
215
|
+
return self._analytics("visibility", project_id, question_id, engines, days)
|
|
216
|
+
|
|
217
|
+
def competitors(self, project_id: str, *, question_id: Optional[str] = None, engines: Optional[Sequence[str]] = None, days: Optional[int] = None) -> JSON:
|
|
218
|
+
return self._analytics("competitors", project_id, question_id, engines, days)
|
|
219
|
+
|
|
220
|
+
def sources(self, project_id: str, *, question_id: Optional[str] = None, engines: Optional[Sequence[str]] = None, days: Optional[int] = None) -> JSON:
|
|
221
|
+
return self._analytics("sources", project_id, question_id, engines, days)
|
|
222
|
+
|
|
223
|
+
def facts(self, project_id: str, *, question_id: Optional[str] = None, engines: Optional[Sequence[str]] = None, days: Optional[int] = None) -> JSON:
|
|
224
|
+
return self._analytics("facts", project_id, question_id, engines, days)
|
|
225
|
+
|
|
226
|
+
def history(self, project_id: str, *, question_id: Optional[str] = None, engines: Optional[Sequence[str]] = None, days: Optional[int] = None, bucket: Optional[str] = None) -> JSON:
|
|
227
|
+
return self._analytics("history", project_id, question_id, engines, days, bucket=bucket)
|
|
228
|
+
|
|
229
|
+
def site_check(self, project_id: str, **fields: Any) -> JSON:
|
|
230
|
+
"""Paid action: robots.txt, llms.txt, sitemaps and key pages, as AI crawlers see them."""
|
|
231
|
+
return self._c.request("POST", f"/v1/projects/{_p(project_id)}/site-check", json=fields)
|
|
232
|
+
|
|
233
|
+
def research(self, project_id: str, **fields: Any) -> JSON:
|
|
234
|
+
"""Paid action: what ranks on Google for a question and what those pages say."""
|
|
235
|
+
return self._c.request("POST", f"/v1/projects/{_p(project_id)}/research", json=fields)
|
|
236
|
+
|
|
237
|
+
def find_opportunities(self, project_id: str, **fields: Any) -> JSON:
|
|
238
|
+
"""Paid action: ranks what to fix first from the project's answers and checks."""
|
|
239
|
+
return self._c.request("POST", f"/v1/projects/{_p(project_id)}/opportunities", json=fields)
|
|
240
|
+
|
|
241
|
+
def opportunities(self, project_id: str, *, status: Optional[str] = None) -> JSON:
|
|
242
|
+
return self._c.request("GET", f"/v1/projects/{_p(project_id)}/opportunities", params={"status": status})
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class Opportunities(_Resource):
|
|
246
|
+
def get(self, opportunity_id: str) -> JSON:
|
|
247
|
+
return self._c.request("GET", f"/v1/opportunities/{_p(opportunity_id)}")
|
|
248
|
+
|
|
249
|
+
def update(self, opportunity_id: str, **fields: Any) -> JSON:
|
|
250
|
+
return self._c.request("PATCH", f"/v1/opportunities/{_p(opportunity_id)}", json=fields)
|
|
251
|
+
|
|
252
|
+
def brief(self, opportunity_id: str, **fields: Any) -> JSON:
|
|
253
|
+
"""Paid action: a step-by-step fix brief for one opportunity."""
|
|
254
|
+
return self._c.request("POST", f"/v1/opportunities/{_p(opportunity_id)}/brief", json=fields)
|
|
255
|
+
|
|
256
|
+
def record_fix(self, opportunity_id: str, **fields: Any) -> JSON:
|
|
257
|
+
"""Records a fix you shipped; it's verified on the site and later in AI answers."""
|
|
258
|
+
return self._c.request("POST", f"/v1/opportunities/{_p(opportunity_id)}/fixes", json=fields)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Fixes(_Resource):
|
|
262
|
+
def get(self, fix_id: str) -> JSON:
|
|
263
|
+
return self._c.request("GET", f"/v1/fixes/{_p(fix_id)}")
|
|
264
|
+
|
|
265
|
+
def verify(self, fix_id: str, **fields: Any) -> JSON:
|
|
266
|
+
return self._c.request("POST", f"/v1/fixes/{_p(fix_id)}/verify", json=fields)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Actions(_Resource):
|
|
270
|
+
def get(self, job_id: str) -> JSON:
|
|
271
|
+
return self._c.request("GET", f"/v1/actions/{_p(job_id)}")
|
|
272
|
+
|
|
273
|
+
def wait(self, job_id: str, *, timeout: float = 600) -> JSON:
|
|
274
|
+
"""Polls a paid action (site check, research, brief...) until it's done."""
|
|
275
|
+
return _wait(
|
|
276
|
+
lambda: self.get(job_id),
|
|
277
|
+
lambda a: a.get("poll_after_seconds") is None,
|
|
278
|
+
timeout,
|
|
279
|
+
lambda a: float(a.get("poll_after_seconds") or 1),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class Keys(_Resource):
|
|
284
|
+
def list(self, *, limit: Optional[int] = None, before: Optional[str] = None, customer: Optional[str] = None) -> JSON:
|
|
285
|
+
return self._c.request("GET", "/v1/keys", params={"limit": limit, "before": before, "customer": customer})
|
|
286
|
+
|
|
287
|
+
def iterate(self, *, customer: Optional[str] = None, limit: Optional[int] = None) -> Iterator[JSON]:
|
|
288
|
+
return _pages(lambda b: self.list(limit=limit, before=b, customer=customer), "keys")
|
|
289
|
+
|
|
290
|
+
def create(self, name: str, **fields: Any) -> JSON:
|
|
291
|
+
"""The response's ``secret`` is shown once. ``customer=`` locks the key to one customer,
|
|
292
|
+
``allowed_ips=["203.0.113.0/24"]`` to your servers."""
|
|
293
|
+
return self._c.request("POST", "/v1/keys", json={"name": name, **fields})
|
|
294
|
+
|
|
295
|
+
def update(self, key_id: str, **fields: Any) -> JSON:
|
|
296
|
+
return self._c.request("PATCH", f"/v1/keys/{_p(key_id)}", json=fields)
|
|
297
|
+
|
|
298
|
+
def revoke(self, key_id: str) -> None:
|
|
299
|
+
self._c.request("DELETE", f"/v1/keys/{_p(key_id)}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class UsageReports(_Resource):
|
|
303
|
+
def _params(self, **q: Any) -> JSON:
|
|
304
|
+
return {k: v for k, v in q.items() if v is not None}
|
|
305
|
+
|
|
306
|
+
def get(self, *, from_: Optional[str] = None, to: Optional[str] = None, group_by: Optional[str] = None, customer: Optional[str] = None, project_id: Optional[str] = None) -> JSON:
|
|
307
|
+
return self._c.request(
|
|
308
|
+
"GET",
|
|
309
|
+
"/v1/usage",
|
|
310
|
+
params=self._params(**{"from": from_, "to": to, "group_by": group_by, "customer": customer, "project_id": project_id}),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def csv(self, *, from_: Optional[str] = None, to: Optional[str] = None, group_by: Optional[str] = None, customer: Optional[str] = None, project_id: Optional[str] = None) -> str:
|
|
314
|
+
"""The same report as CSV text, ready for a spreadsheet or an invoice."""
|
|
315
|
+
return self._c.request(
|
|
316
|
+
"GET",
|
|
317
|
+
"/v1/usage",
|
|
318
|
+
params=self._params(**{"from": from_, "to": to, "group_by": group_by, "customer": customer, "project_id": project_id, "format": "csv"}),
|
|
319
|
+
text=True,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class Deliveries(_Resource):
|
|
324
|
+
def list(self, webhook_id: str, *, status: Optional[str] = None, limit: Optional[int] = None, before: Optional[str] = None) -> JSON:
|
|
325
|
+
return self._c.request("GET", f"/v1/webhooks/{_p(webhook_id)}/deliveries", params={"status": status, "limit": limit, "before": before})
|
|
326
|
+
|
|
327
|
+
def get(self, webhook_id: str, delivery_id: str) -> JSON:
|
|
328
|
+
return self._c.request("GET", f"/v1/webhooks/{_p(webhook_id)}/deliveries/{_p(delivery_id)}")
|
|
329
|
+
|
|
330
|
+
def resend(self, webhook_id: str, delivery_id: str) -> JSON:
|
|
331
|
+
return self._c.request("POST", f"/v1/webhooks/{_p(webhook_id)}/deliveries/{_p(delivery_id)}/resend")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Webhooks(_Resource):
|
|
335
|
+
def __init__(self, client: "Aiv") -> None:
|
|
336
|
+
super().__init__(client)
|
|
337
|
+
self.deliveries = Deliveries(client)
|
|
338
|
+
|
|
339
|
+
def list(self) -> JSON:
|
|
340
|
+
return self._c.request("GET", "/v1/webhooks")
|
|
341
|
+
|
|
342
|
+
def create(self, url: str, **fields: Any) -> JSON:
|
|
343
|
+
"""The response's ``secret`` (whsec_...) is shown once: store it to verify deliveries."""
|
|
344
|
+
return self._c.request("POST", "/v1/webhooks", json={"url": url, **fields})
|
|
345
|
+
|
|
346
|
+
def get(self, webhook_id: str) -> JSON:
|
|
347
|
+
return self._c.request("GET", f"/v1/webhooks/{_p(webhook_id)}")
|
|
348
|
+
|
|
349
|
+
def update(self, webhook_id: str, **fields: Any) -> JSON:
|
|
350
|
+
return self._c.request("PATCH", f"/v1/webhooks/{_p(webhook_id)}", json=fields)
|
|
351
|
+
|
|
352
|
+
def delete(self, webhook_id: str) -> None:
|
|
353
|
+
self._c.request("DELETE", f"/v1/webhooks/{_p(webhook_id)}")
|
|
354
|
+
|
|
355
|
+
def rotate_secret(self, webhook_id: str, **fields: Any) -> JSON:
|
|
356
|
+
"""A new secret; the old one keeps working for 24 hours."""
|
|
357
|
+
return self._c.request("POST", f"/v1/webhooks/{_p(webhook_id)}/rotate-secret", json=fields)
|
|
358
|
+
|
|
359
|
+
def test(self, webhook_id: str, **fields: Any) -> JSON:
|
|
360
|
+
"""Sends a ``ping`` event to this endpoint only."""
|
|
361
|
+
return self._c.request("POST", f"/v1/webhooks/{_p(webhook_id)}/test", json=fields)
|
|
362
|
+
|
|
363
|
+
def recover(self, webhook_id: str, since: str) -> JSON:
|
|
364
|
+
"""Re-sends everything the endpoint missed since ``since`` (ISO date), and re-enables it."""
|
|
365
|
+
return self._c.request("POST", f"/v1/webhooks/{_p(webhook_id)}/recover", json={"since": since})
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class Events(_Resource):
|
|
369
|
+
def list(self, *, type: Optional[str] = None, customer: Optional[str] = None, limit: Optional[int] = None, before: Optional[str] = None, after: Optional[str] = None) -> JSON: # noqa: A002
|
|
370
|
+
return self._c.request("GET", "/v1/events", params={"type": type, "customer": customer, "limit": limit, "before": before, "after": after})
|
|
371
|
+
|
|
372
|
+
def get(self, event_id: str) -> JSON:
|
|
373
|
+
return self._c.request("GET", f"/v1/events/{_p(event_id)}")
|
|
374
|
+
|
|
375
|
+
def iterate(self, *, type: Optional[str] = None, customer: Optional[str] = None, limit: Optional[int] = None) -> Iterator[JSON]: # noqa: A002
|
|
376
|
+
"""Every event, newest first (the last 30 days)."""
|
|
377
|
+
return _pages(lambda b: self.list(type=type, customer=customer, limit=limit, before=b), "events")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class AuditLog(_Resource):
|
|
381
|
+
def list(self, *, limit: Optional[int] = None, before: Optional[int] = None, action: Optional[str] = None, target_id: Optional[str] = None) -> JSON:
|
|
382
|
+
return self._c.request("GET", "/v1/audit-log", params={"limit": limit, "before": before, "action": action, "target_id": target_id})
|
|
383
|
+
|
|
384
|
+
def iterate(self, *, action: Optional[str] = None, target_id: Optional[str] = None, limit: Optional[int] = None) -> Iterator[JSON]:
|
|
385
|
+
return _pages(lambda b: self.list(limit=limit, before=b, action=action, target_id=target_id), "entries")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class Customers(_Resource):
|
|
389
|
+
def delete_data(self, customer: str) -> JSON:
|
|
390
|
+
"""Deletes everything stored for one of your customers (irreversible). Runs in the
|
|
391
|
+
background: poll ``data_deletions.wait(id)``."""
|
|
392
|
+
return self._c.request("POST", f"/v1/customers/{_p(customer)}/delete-data", json={"confirm": True})
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class DataDeletions(_Resource):
|
|
396
|
+
def get(self, deletion_id: str) -> JSON:
|
|
397
|
+
return self._c.request("GET", f"/v1/data-deletions/{_p(deletion_id)}")
|
|
398
|
+
|
|
399
|
+
def wait(self, deletion_id: str, *, timeout: float = 1800, interval: float = 30) -> JSON:
|
|
400
|
+
return _wait(
|
|
401
|
+
lambda: self.get(deletion_id),
|
|
402
|
+
lambda d: d.get("status") in ("completed", "failed"),
|
|
403
|
+
timeout,
|
|
404
|
+
lambda _d: interval,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class Aiv:
|
|
409
|
+
"""aivisibilityskill API client.
|
|
410
|
+
|
|
411
|
+
from aivisibilityskill import Aiv
|
|
412
|
+
aiv = Aiv(api_key=os.environ["AIV_KEY"])
|
|
413
|
+
job = aiv.checks.create(["best invoicing app for freelancers"], engines=["chatgpt", "gemini"])
|
|
414
|
+
done = aiv.jobs.wait(job["id"])
|
|
415
|
+
|
|
416
|
+
GETs and checks are retried on 429/502/503/504 and network errors (``max_retries``, default
|
|
417
|
+
2), honouring Retry-After. Every check carries an idempotency key, so a retry never charges
|
|
418
|
+
twice. Errors raise AivError with the API's stable ``code``.
|
|
419
|
+
"""
|
|
420
|
+
|
|
421
|
+
def __init__(
|
|
422
|
+
self,
|
|
423
|
+
api_key: str,
|
|
424
|
+
*,
|
|
425
|
+
base_url: str = "https://api.aivisibilityskill.com",
|
|
426
|
+
max_retries: int = 2,
|
|
427
|
+
timeout: float = 120.0,
|
|
428
|
+
http_client: Optional[httpx.Client] = None,
|
|
429
|
+
) -> None:
|
|
430
|
+
if not isinstance(api_key, str) or not _KEY.match(api_key):
|
|
431
|
+
raise ValueError("api_key must start with aiv_live_ or aiv_test_")
|
|
432
|
+
self._key = api_key
|
|
433
|
+
self._base = base_url.rstrip("/")
|
|
434
|
+
self._retries = max(0, max_retries)
|
|
435
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
436
|
+
self._owns_http = http_client is None
|
|
437
|
+
self.account = Account(self)
|
|
438
|
+
self.locales = Locales(self)
|
|
439
|
+
self.checks = Checks(self)
|
|
440
|
+
self.jobs = Jobs(self)
|
|
441
|
+
self.batches = Batches(self)
|
|
442
|
+
self.answers = Answers(self)
|
|
443
|
+
self.projects = Projects(self)
|
|
444
|
+
self.opportunities = Opportunities(self)
|
|
445
|
+
self.fixes = Fixes(self)
|
|
446
|
+
self.actions = Actions(self)
|
|
447
|
+
self.keys = Keys(self)
|
|
448
|
+
self.usage = UsageReports(self)
|
|
449
|
+
self.webhooks = Webhooks(self)
|
|
450
|
+
self.events = Events(self)
|
|
451
|
+
self.audit_log = AuditLog(self)
|
|
452
|
+
self.customers = Customers(self)
|
|
453
|
+
self.data_deletions = DataDeletions(self)
|
|
454
|
+
|
|
455
|
+
verify_webhook = staticmethod(verify_webhook)
|
|
456
|
+
|
|
457
|
+
def __enter__(self) -> "Aiv":
|
|
458
|
+
return self
|
|
459
|
+
|
|
460
|
+
def __exit__(self, *exc: Any) -> None:
|
|
461
|
+
self.close()
|
|
462
|
+
|
|
463
|
+
def close(self) -> None:
|
|
464
|
+
if self._owns_http:
|
|
465
|
+
self._http.close()
|
|
466
|
+
|
|
467
|
+
def pricing(self) -> JSON:
|
|
468
|
+
return self.request("GET", "/v1/pricing")
|
|
469
|
+
|
|
470
|
+
def estimate(self, questions: Sequence[str], **fields: Any) -> JSON:
|
|
471
|
+
"""What a check would cost, and whether this key may run it. Free."""
|
|
472
|
+
return self.request("POST", "/v1/estimate", json={"questions": list(questions), **fields})
|
|
473
|
+
|
|
474
|
+
def request(
|
|
475
|
+
self,
|
|
476
|
+
method: str,
|
|
477
|
+
path: str,
|
|
478
|
+
*,
|
|
479
|
+
json: Optional[Any] = None,
|
|
480
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
481
|
+
idempotency_key: Optional[str] = None,
|
|
482
|
+
text: bool = False,
|
|
483
|
+
) -> Any:
|
|
484
|
+
"""Any endpoint, for ones this version doesn't wrap yet."""
|
|
485
|
+
query = {k: ("true" if v is True else "false" if v is False else v) for k, v in (params or {}).items() if v is not None}
|
|
486
|
+
headers = {
|
|
487
|
+
"Authorization": f"Bearer {self._key}",
|
|
488
|
+
"Accept": "text/csv" if text else "application/json",
|
|
489
|
+
"User-Agent": f"aivisibilityskill-python/{__version__}",
|
|
490
|
+
}
|
|
491
|
+
if idempotency_key:
|
|
492
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
493
|
+
retryable = method == "GET" or idempotency_key is not None
|
|
494
|
+
attempt = 0
|
|
495
|
+
while True:
|
|
496
|
+
try:
|
|
497
|
+
res = self._http.request(method, self._base + path, params=query, json=json, headers=headers)
|
|
498
|
+
except httpx.TransportError:
|
|
499
|
+
if retryable and attempt < self._retries:
|
|
500
|
+
time.sleep(0.5 * 2**attempt)
|
|
501
|
+
attempt += 1
|
|
502
|
+
continue
|
|
503
|
+
raise
|
|
504
|
+
if res.is_success:
|
|
505
|
+
if res.status_code == 204:
|
|
506
|
+
return None
|
|
507
|
+
return res.text if text else res.json()
|
|
508
|
+
if retryable and res.status_code in _RETRYABLE and attempt < self._retries:
|
|
509
|
+
after = res.headers.get("Retry-After", "")
|
|
510
|
+
delay = min(float(after), 60.0) if after.replace(".", "", 1).isdigit() and float(after) > 0 else float(2**attempt)
|
|
511
|
+
time.sleep(delay)
|
|
512
|
+
attempt += 1
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
body = res.json()
|
|
516
|
+
except ValueError:
|
|
517
|
+
body = {}
|
|
518
|
+
if not isinstance(body, dict):
|
|
519
|
+
body = {}
|
|
520
|
+
raise AivError(
|
|
521
|
+
res.status_code,
|
|
522
|
+
body.get("code") if isinstance(body.get("code"), str) else "http_error",
|
|
523
|
+
body.get("detail") if isinstance(body.get("detail"), str) else None,
|
|
524
|
+
body.get("request_id") if isinstance(body.get("request_id"), str) else res.headers.get("X-Request-Id"),
|
|
525
|
+
body,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
__all__ = ["Aiv", "AivError", "JSON"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Webhook signature verification (Standard Webhooks, https://www.standardwebhooks.com)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Iterable, Mapping, Optional, Sequence, Union
|
|
11
|
+
|
|
12
|
+
TOLERANCE_SECONDS = 300
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebhookVerificationError(Exception):
|
|
16
|
+
"""The delivery isn't from aivisibilityskill, was changed, or is a replay."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _header(headers: Any, name: str) -> Optional[str]:
|
|
20
|
+
getter = getattr(headers, "get", None)
|
|
21
|
+
if getter is not None and not isinstance(headers, Mapping):
|
|
22
|
+
value = getter(name)
|
|
23
|
+
return None if value is None else str(value)
|
|
24
|
+
for key, value in dict(headers).items():
|
|
25
|
+
if str(key).lower() == name:
|
|
26
|
+
if isinstance(value, (list, tuple)):
|
|
27
|
+
return str(value[0]) if value else None
|
|
28
|
+
return None if value is None else str(value)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _secret_bytes(secret: str) -> bytes:
|
|
33
|
+
raw = secret[len("whsec_") :] if secret.startswith("whsec_") else secret
|
|
34
|
+
try:
|
|
35
|
+
key = base64.b64decode(raw, validate=True)
|
|
36
|
+
except Exception as err: # noqa: BLE001
|
|
37
|
+
raise WebhookVerificationError("That webhook secret isn't valid (expected whsec_...).") from err
|
|
38
|
+
if len(key) < 16:
|
|
39
|
+
# An empty or tiny key would let anyone forge a signature.
|
|
40
|
+
raise WebhookVerificationError("That webhook secret isn't valid (expected whsec_...).")
|
|
41
|
+
return key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _signatures(header: str) -> Iterable[bytes]:
|
|
45
|
+
for part in header.split(" "):
|
|
46
|
+
version, _, value = part.partition(",")
|
|
47
|
+
if version != "v1" or not value:
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
yield base64.b64decode(value, validate=True)
|
|
51
|
+
except Exception: # noqa: BLE001
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verify_webhook(
|
|
56
|
+
body: Union[str, bytes],
|
|
57
|
+
headers: Any,
|
|
58
|
+
secret: Union[str, Sequence[str]],
|
|
59
|
+
*,
|
|
60
|
+
tolerance_seconds: int = TOLERANCE_SECONDS,
|
|
61
|
+
now: Optional[float] = None,
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""Checks a delivery's signature and returns the parsed event.
|
|
64
|
+
|
|
65
|
+
Pass the raw request body exactly as received (before JSON parsing) and the request headers
|
|
66
|
+
(a dict, or any object with ``.get``). During a secret rotation pass both secrets as a list.
|
|
67
|
+
Raises WebhookVerificationError for a bad signature, a missing header or a timestamp more
|
|
68
|
+
than 5 minutes off.
|
|
69
|
+
|
|
70
|
+
event = verify_webhook(request.body, request.headers, os.environ["AIV_WEBHOOK_SECRET"])
|
|
71
|
+
if event["type"] == "check.completed": ...
|
|
72
|
+
"""
|
|
73
|
+
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else body
|
|
74
|
+
msg_id = _header(headers, "webhook-id")
|
|
75
|
+
timestamp = _header(headers, "webhook-timestamp")
|
|
76
|
+
signature = _header(headers, "webhook-signature")
|
|
77
|
+
if not msg_id or not timestamp or not signature:
|
|
78
|
+
raise WebhookVerificationError(
|
|
79
|
+
"Missing webhook-id, webhook-timestamp or webhook-signature header."
|
|
80
|
+
)
|
|
81
|
+
if not timestamp.isdigit():
|
|
82
|
+
raise WebhookVerificationError("Bad webhook-timestamp.")
|
|
83
|
+
current = time.time() if now is None else now
|
|
84
|
+
if abs(current - int(timestamp)) > tolerance_seconds:
|
|
85
|
+
raise WebhookVerificationError("Timestamp too old or in the future (possible replay).")
|
|
86
|
+
signed = f"{msg_id}.{timestamp}.{text}".encode("utf-8")
|
|
87
|
+
given = list(_signatures(signature))
|
|
88
|
+
secrets = [secret] if isinstance(secret, str) else list(secret)
|
|
89
|
+
for s in secrets:
|
|
90
|
+
expected = hmac.new(_secret_bytes(s), signed, hashlib.sha256).digest()
|
|
91
|
+
if any(hmac.compare_digest(expected, g) for g in given):
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(text)
|
|
94
|
+
except ValueError as err:
|
|
95
|
+
raise WebhookVerificationError("The body isn't JSON.") from err
|
|
96
|
+
raise WebhookVerificationError("No signature matches this secret.")
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"secret": "whsec_+t4MGGyo+JRFitNHxDOuM9A7onK222XBs0KtHP5WvhQ=",
|
|
3
|
+
"other": "whsec_C0mtcEamIZA8tqUhP20/Q9giEDpCF2hlxrLUI8wBBUI=",
|
|
4
|
+
"body": "{\"id\":\"evt_vec\",\"type\":\"check.completed\",\"timestamp\":\"2030-01-01T00:00:00.000Z\",\"sandbox\":false,\"data\":{\"id\":\"job_vec\"}}",
|
|
5
|
+
"ts": 1893456000,
|
|
6
|
+
"id": "evt_vec",
|
|
7
|
+
"sig": "v1,0FbJHa49Y+z3wi2IR+xHrugWUvYRqnfH23A/B+475Zg=",
|
|
8
|
+
"both": "v1,+Jxq04BhLDxhKUPEWenAXlsdV+9KJ73UmzTBMJAHTXk= v1,0FbJHa49Y+z3wi2IR+xHrugWUvYRqnfH23A/B+475Zg="
|
|
9
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from aivisibilityskill import Aiv, AivError, WebhookVerificationError, verify_webhook
|
|
9
|
+
|
|
10
|
+
VECTOR = json.loads((Path(__file__).parent / "signing_vector.json").read_text())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def client(handler, **kw):
|
|
14
|
+
return Aiv(
|
|
15
|
+
api_key="aiv_test_abc",
|
|
16
|
+
base_url="https://api.test/",
|
|
17
|
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
|
18
|
+
**kw,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_rejects_bad_keys():
|
|
23
|
+
with pytest.raises(ValueError):
|
|
24
|
+
Aiv(api_key="sk_123")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_sends_auth_idempotency_and_json(monkeypatch):
|
|
28
|
+
seen = []
|
|
29
|
+
|
|
30
|
+
def handler(req: httpx.Request):
|
|
31
|
+
seen.append(req)
|
|
32
|
+
return httpx.Response(202, json={"id": "job_1", "status": "running"})
|
|
33
|
+
|
|
34
|
+
aiv = client(handler)
|
|
35
|
+
job = aiv.checks.create(["best crm"], engines=["chatgpt"], customer="cus_1")
|
|
36
|
+
assert job["id"] == "job_1"
|
|
37
|
+
req = seen[0]
|
|
38
|
+
assert req.url == "https://api.test/v1/checks"
|
|
39
|
+
assert req.headers["authorization"] == "Bearer aiv_test_abc"
|
|
40
|
+
assert req.headers["user-agent"].startswith("aivisibilityskill-python/")
|
|
41
|
+
assert len(req.headers["idempotency-key"]) == 36
|
|
42
|
+
assert json.loads(req.content) == {"questions": ["best crm"], "engines": ["chatgpt"], "customer": "cus_1"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_retries_safe_requests_with_the_same_idempotency_key(monkeypatch):
|
|
46
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
47
|
+
keys = []
|
|
48
|
+
|
|
49
|
+
def handler(req: httpx.Request):
|
|
50
|
+
keys.append(req.headers.get("idempotency-key"))
|
|
51
|
+
if len(keys) < 3:
|
|
52
|
+
return httpx.Response(503, json={"code": "vendor_unavailable"}, headers={"Retry-After": "1"})
|
|
53
|
+
return httpx.Response(202, json={"id": "job_2"})
|
|
54
|
+
|
|
55
|
+
aiv = client(handler)
|
|
56
|
+
assert aiv.checks.create(["q"])["id"] == "job_2"
|
|
57
|
+
assert len(keys) == 3 and len(set(keys)) == 1 # one charge, whatever the retries
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_never_retries_unkeyed_writes(monkeypatch):
|
|
61
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
62
|
+
calls = []
|
|
63
|
+
|
|
64
|
+
def handler(req):
|
|
65
|
+
calls.append(req)
|
|
66
|
+
return httpx.Response(503, json={"code": "vendor_unavailable", "detail": "down"})
|
|
67
|
+
|
|
68
|
+
with pytest.raises(AivError) as err:
|
|
69
|
+
client(handler).keys.create("k")
|
|
70
|
+
assert len(calls) == 1
|
|
71
|
+
assert err.value.status == 503 and err.value.code == "vendor_unavailable"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_retries_network_errors_on_gets(monkeypatch):
|
|
75
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
76
|
+
n = {"i": 0}
|
|
77
|
+
|
|
78
|
+
def handler(req):
|
|
79
|
+
n["i"] += 1
|
|
80
|
+
if n["i"] == 1:
|
|
81
|
+
raise httpx.ConnectError("boom", request=req)
|
|
82
|
+
return httpx.Response(200, json={"account": {"plan": "pro"}})
|
|
83
|
+
|
|
84
|
+
assert client(handler).account.get()["account"]["plan"] == "pro"
|
|
85
|
+
assert n["i"] == 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_errors_carry_code_detail_and_request_id():
|
|
89
|
+
def handler(req):
|
|
90
|
+
return httpx.Response(
|
|
91
|
+
402,
|
|
92
|
+
json={"code": "insufficient_credits", "detail": "Needs 50.", "request_id": "req_1", "credits": 50},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
with pytest.raises(AivError) as err:
|
|
96
|
+
client(handler).checks.create(["q"])
|
|
97
|
+
e = err.value
|
|
98
|
+
assert (e.status, e.code, e.detail, e.request_id) == (402, "insufficient_credits", "Needs 50.", "req_1")
|
|
99
|
+
assert e.body["credits"] == 50
|
|
100
|
+
assert "insufficient_credits" in str(e)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_non_json_errors():
|
|
104
|
+
with pytest.raises(AivError) as err:
|
|
105
|
+
client(lambda req: httpx.Response(500, text="oops", headers={"X-Request-Id": "r9"})).pricing()
|
|
106
|
+
assert err.value.code == "http_error" and err.value.request_id == "r9"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_pages_follow_next_and_build_query():
|
|
110
|
+
pages = {
|
|
111
|
+
None: {"keys": [{"id": "k1"}, {"id": "k2"}], "next": "k2"},
|
|
112
|
+
"k2": {"keys": [{"id": "k3"}], "next": None},
|
|
113
|
+
}
|
|
114
|
+
urls = []
|
|
115
|
+
|
|
116
|
+
def handler(req: httpx.Request):
|
|
117
|
+
urls.append(str(req.url))
|
|
118
|
+
return httpx.Response(200, json=pages[req.url.params.get("before")])
|
|
119
|
+
|
|
120
|
+
ids = [k["id"] for k in client(handler).keys.iterate(customer="c1", limit=2)]
|
|
121
|
+
assert ids == ["k1", "k2", "k3"]
|
|
122
|
+
assert "customer=c1" in urls[0] and "limit=2" in urls[0] and "before" not in urls[0]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_wait_polls_until_finished(monkeypatch):
|
|
126
|
+
sleeps = []
|
|
127
|
+
monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s))
|
|
128
|
+
states = iter(
|
|
129
|
+
[
|
|
130
|
+
{"id": "j", "finished_at": None, "poll_after_seconds": 3},
|
|
131
|
+
{"id": "j", "finished_at": "2030-01-01", "poll_after_seconds": None},
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
done = client(lambda req: httpx.Response(200, json=next(states))).jobs.wait("j")
|
|
135
|
+
assert done["finished_at"] and sleeps == [3.0]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_usage_csv_and_path_escaping():
|
|
139
|
+
seen = []
|
|
140
|
+
|
|
141
|
+
def handler(req):
|
|
142
|
+
seen.append(req)
|
|
143
|
+
if req.url.path == "/v1/usage":
|
|
144
|
+
return httpx.Response(200, text="day,credits\n2030-01-01,5\n", headers={"Content-Type": "text/csv"})
|
|
145
|
+
return httpx.Response(202, json={"id": "del_1", "status": "pending"})
|
|
146
|
+
|
|
147
|
+
aiv = client(handler)
|
|
148
|
+
assert aiv.usage.csv(group_by="day").startswith("day,credits")
|
|
149
|
+
assert seen[0].url.params["format"] == "csv" and seen[0].headers["accept"] == "text/csv"
|
|
150
|
+
aiv.customers.delete_data("cus/../x y")
|
|
151
|
+
assert seen[1].url.raw_path == b"/v1/customers/cus%2F..%2Fx%20y/delete-data"
|
|
152
|
+
assert json.loads(seen[1].content) == {"confirm": True}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_batches_create_with_an_idempotency_key_and_wait(monkeypatch):
|
|
156
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
157
|
+
seen = []
|
|
158
|
+
states = iter([{"id": "bat_1", "poll_after_seconds": 30}, {"id": "bat_1", "poll_after_seconds": None, "status": "completed"}])
|
|
159
|
+
|
|
160
|
+
def handler(req):
|
|
161
|
+
seen.append(req)
|
|
162
|
+
if req.method == "POST":
|
|
163
|
+
return httpx.Response(202, json={"id": "bat_1", "status": "running", "poll_after_seconds": 30})
|
|
164
|
+
return httpx.Response(200, json=next(states))
|
|
165
|
+
|
|
166
|
+
aiv = client(handler)
|
|
167
|
+
b = aiv.batches.create(["q1", "q2"], engines=["chatgpt"], mode="batch")
|
|
168
|
+
assert b["id"] == "bat_1"
|
|
169
|
+
assert json.loads(seen[0].content) == {"questions": ["q1", "q2"], "engines": ["chatgpt"], "mode": "batch"}
|
|
170
|
+
assert len(seen[0].headers["idempotency-key"]) == 36
|
|
171
|
+
assert aiv.batches.wait("bat_1")["status"] == "completed"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_204_returns_none():
|
|
175
|
+
assert client(lambda req: httpx.Response(204)).keys.revoke("key_1") is None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TestWebhooks:
|
|
179
|
+
headers = {
|
|
180
|
+
"webhook-id": VECTOR["id"],
|
|
181
|
+
"webhook-timestamp": str(VECTOR["ts"]),
|
|
182
|
+
"webhook-signature": VECTOR["sig"],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
def test_verifies_a_signature_made_by_the_engine(self):
|
|
186
|
+
event = verify_webhook(VECTOR["body"], self.headers, VECTOR["secret"], now=VECTOR["ts"])
|
|
187
|
+
assert event["type"] == "check.completed" and event["data"]["id"] == "job_vec"
|
|
188
|
+
# bytes body and case-insensitive header names
|
|
189
|
+
mixed = {k.title(): v for k, v in self.headers.items()}
|
|
190
|
+
assert verify_webhook(VECTOR["body"].encode(), mixed, VECTOR["secret"], now=VECTOR["ts"])
|
|
191
|
+
assert Aiv.verify_webhook is verify_webhook
|
|
192
|
+
|
|
193
|
+
def test_rotation_both_signatures(self):
|
|
194
|
+
h = {**self.headers, "webhook-signature": VECTOR["both"]}
|
|
195
|
+
assert verify_webhook(VECTOR["body"], h, [VECTOR["other"]], now=VECTOR["ts"])
|
|
196
|
+
assert verify_webhook(VECTOR["body"], h, [VECTOR["secret"]], now=VECTOR["ts"])
|
|
197
|
+
|
|
198
|
+
def test_refuses_tampering_wrong_secret_replays_and_missing_headers(self):
|
|
199
|
+
with pytest.raises(WebhookVerificationError):
|
|
200
|
+
verify_webhook(VECTOR["body"] + " ", self.headers, VECTOR["secret"], now=VECTOR["ts"])
|
|
201
|
+
with pytest.raises(WebhookVerificationError, match="No signature"):
|
|
202
|
+
verify_webhook(VECTOR["body"], self.headers, VECTOR["other"], now=VECTOR["ts"])
|
|
203
|
+
with pytest.raises(WebhookVerificationError, match="replay"):
|
|
204
|
+
verify_webhook(VECTOR["body"], self.headers, VECTOR["secret"], now=VECTOR["ts"] + 301)
|
|
205
|
+
with pytest.raises(WebhookVerificationError, match="Missing"):
|
|
206
|
+
verify_webhook(VECTOR["body"], {}, VECTOR["secret"])
|
|
207
|
+
with pytest.raises(WebhookVerificationError, match="secret"):
|
|
208
|
+
verify_webhook(VECTOR["body"], self.headers, "whsec_not base64!", now=VECTOR["ts"])
|
|
209
|
+
|
|
210
|
+
def test_an_empty_secret_never_verifies(self):
|
|
211
|
+
# A forger signs with an empty key; an unset env var must not accept it.
|
|
212
|
+
import base64, hashlib, hmac as _hmac
|
|
213
|
+
signed = f"{VECTOR['id']}.{VECTOR['ts']}.{VECTOR['body']}".encode()
|
|
214
|
+
forged = "v1," + base64.b64encode(_hmac.new(b"", signed, hashlib.sha256).digest()).decode()
|
|
215
|
+
h = {**self.headers, "webhook-signature": forged}
|
|
216
|
+
for empty in ("", "whsec_", "whsec_AAAA"):
|
|
217
|
+
with pytest.raises(WebhookVerificationError, match="secret"):
|
|
218
|
+
verify_webhook(VECTOR["body"], h, empty, now=VECTOR["ts"])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@pytest.mark.skipif(not os.environ.get("AIV_LIVE_TEST_KEY"), reason="set AIV_LIVE_TEST_KEY (a test key) to run")
|
|
222
|
+
def test_against_a_real_api_with_a_test_key():
|
|
223
|
+
base = os.environ.get("AIV_BASE", "https://api.aivisibilityskill.com")
|
|
224
|
+
with Aiv(api_key=os.environ["AIV_LIVE_TEST_KEY"], base_url=base) as aiv:
|
|
225
|
+
me = aiv.account.get()
|
|
226
|
+
assert me["account"]["mode"] == "test"
|
|
227
|
+
job = aiv.checks.create(["best invoicing app for freelancers"], engines=["chatgpt", "gemini"])
|
|
228
|
+
done = aiv.jobs.wait(job["id"], timeout=60)
|
|
229
|
+
assert done["status"] == "succeeded" and len(done["answers"]) == 2
|
|
230
|
+
assert all(a["brands"] for a in done["answers"])
|
|
231
|
+
assert len(aiv.locales.list()["countries"]) > 200
|
|
232
|
+
batch = aiv.batches.create(["best crm", "best vpn"], engines=["chatgpt"])
|
|
233
|
+
assert batch["status"] == "completed" and batch["answers"]["succeeded"] == 2
|
|
234
|
+
with pytest.raises(AivError) as err:
|
|
235
|
+
aiv.jobs.get("job_nope")
|
|
236
|
+
assert err.value.status == 404
|