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.
@@ -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