checkanysite 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ """The official Python library for the CheckAnySite API: what is this website, with evidence.
2
+
3
+ from checkanysite import CheckAnySite
4
+
5
+ client = CheckAnySite() # reads CHECKANYSITE_API_KEY
6
+ site = client.sites.retrieve("stripe.com", checks=["ai", "tech"])
7
+
8
+ Docs: https://docs.checkanysite.com. The answers' types are in `checkanysite.models`.
9
+ """
10
+
11
+ from . import models
12
+ from ._client import AsyncCheckAnySite, CheckAnySite
13
+ from ._version import __version__
14
+ from .errors import (
15
+ APIConnectionError,
16
+ APIError,
17
+ APIStatusError,
18
+ APITimeoutError,
19
+ AuthenticationError,
20
+ BadRequestError,
21
+ ConflictError,
22
+ InternalServerError,
23
+ NotFoundError,
24
+ PermissionDeniedError,
25
+ RateLimitError,
26
+ ScanTimeoutError,
27
+ UnprocessableEntityError,
28
+ )
29
+
30
+ __all__ = [
31
+ "APIConnectionError",
32
+ "APIError",
33
+ "APIStatusError",
34
+ "APITimeoutError",
35
+ "AsyncCheckAnySite",
36
+ "AuthenticationError",
37
+ "BadRequestError",
38
+ "CheckAnySite",
39
+ "ConflictError",
40
+ "InternalServerError",
41
+ "NotFoundError",
42
+ "PermissionDeniedError",
43
+ "RateLimitError",
44
+ "ScanTimeoutError",
45
+ "UnprocessableEntityError",
46
+ "__version__",
47
+ "models",
48
+ ]
@@ -0,0 +1,268 @@
1
+ """Sending a request to the API: authentication, idempotency keys, retries and typed answers.
2
+
3
+ What: `SyncAPIClient` and `AsyncAPIClient`, the transport the clients in _client.py build on.
4
+ Each call is one `httpx.Request` (with one Idempotency-Key for a POST), sent until it succeeds,
5
+ fails in a way that won't pass (_retries.py), or runs out of retries; a success is parsed into
6
+ its model with the answer's headers as `.meta`, an error answer into the class for its status.
7
+ Why one request object for every try: a retried POST must carry the same Idempotency-Key, so the
8
+ API replays the first answer instead of doing (and charging for) the work twice.
9
+ Not here: the endpoints (resources/) or the retry rules (_retries.py).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import os
17
+ import time
18
+ import uuid
19
+ from collections.abc import Awaitable, Mapping
20
+ from typing import Callable, Optional, TypeVar, Union
21
+
22
+ import httpx
23
+
24
+ from ._constants import API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, USER_AGENT
25
+ from ._retries import is_retryable_status, retry_delay_seconds
26
+ from .errors import (
27
+ APIConnectionError,
28
+ APIError,
29
+ APIStatusError,
30
+ APITimeoutError,
31
+ error_from_response,
32
+ )
33
+ from .models import ResponseMeta, ResponseModel
34
+
35
+ ResponseT = TypeVar("ResponseT", bound=ResponseModel)
36
+
37
+ Timeout = Union[float, httpx.Timeout]
38
+ """Seconds for each of connecting, sending and reading, or an `httpx.Timeout` for each apart."""
39
+
40
+ RequestFailure = Union[APIStatusError, APIConnectionError]
41
+ """How one try can fail: an error answer, or no answer."""
42
+
43
+ QueryParams = Mapping[str, Union[str, int]]
44
+ JsonBody = Mapping[str, object]
45
+
46
+
47
+ class _BaseClient:
48
+ """What the sync and async clients share: their settings, how a request is built and read."""
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ api_key: Optional[str],
54
+ base_url: Optional[str],
55
+ timeout: Timeout,
56
+ max_retries: int,
57
+ ) -> None:
58
+ key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
59
+ if not key:
60
+ raise APIError(
61
+ f"No API key: pass api_key=... or set {API_KEY_ENV}. "
62
+ "Get one free at https://checkanysite.com/account/keys."
63
+ )
64
+ if max_retries < 0:
65
+ raise ValueError("max_retries must be 0 or more.")
66
+ self.api_key = key
67
+ self.base_url = (base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL).rstrip("/")
68
+ self.timeout = timeout
69
+ self.max_retries = max_retries
70
+
71
+ def _build_request(
72
+ self,
73
+ http: Union[httpx.Client, httpx.AsyncClient],
74
+ method: str,
75
+ path: str,
76
+ params: Optional[QueryParams],
77
+ body: Optional[JsonBody],
78
+ idempotency_key: Optional[str],
79
+ ) -> httpx.Request:
80
+ headers = {
81
+ "Authorization": f"Bearer {self.api_key}",
82
+ "Accept": "application/json",
83
+ "User-Agent": USER_AGENT,
84
+ }
85
+ if method == "POST":
86
+ # One key per call, reused by its retries, so a retried POST is never done twice.
87
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
88
+ return http.build_request(
89
+ method,
90
+ f"{self.base_url}{path}",
91
+ params=dict(params) if params else None,
92
+ json=dict(body) if body is not None else None,
93
+ headers=headers,
94
+ timeout=self.timeout,
95
+ )
96
+
97
+ def _retry_delay(self, retries_done: int, error: RequestFailure) -> Optional[float]:
98
+ """The wait before trying again after this error, or None to raise it now."""
99
+ if retries_done >= self.max_retries:
100
+ return None
101
+ if isinstance(error, APIStatusError):
102
+ if not is_retryable_status(error.status_code, error.code):
103
+ return None
104
+ return retry_delay_seconds(retries_done, error.retry_after_seconds)
105
+ # No answer at all (a connection error or a timeout): always worth another try.
106
+ return retry_delay_seconds(retries_done, None)
107
+
108
+
109
+ class _OwnedClient(httpx.Client):
110
+ """The `httpx.Client` a client makes for itself, closed when it is garbage collected.
111
+
112
+ Why: a client used without `with` or `close()`, as a script's one client often is, would keep
113
+ its pooled connections open until the interpreter exits, and Python warns about each one
114
+ (ResourceWarning: unclosed socket).
115
+ """
116
+
117
+ def __del__(self) -> None:
118
+ if self.is_closed:
119
+ return
120
+ # Nothing can be reported from a finalizer, so a failure to close is let pass.
121
+ with contextlib.suppress(Exception):
122
+ self.close()
123
+
124
+
125
+ class _OwnedAsyncClient(httpx.AsyncClient):
126
+ """The `httpx.AsyncClient` an async client makes for itself, closed when it is collected
127
+ while its event loop still runs (`async with` closes it for sure)."""
128
+
129
+ def __del__(self) -> None:
130
+ if self.is_closed:
131
+ return
132
+ # Without a running loop (RuntimeError) there is nothing to close it on.
133
+ with contextlib.suppress(Exception):
134
+ asyncio.get_running_loop().create_task(self.aclose())
135
+
136
+
137
+ def _connection_error(exception: httpx.RequestError) -> APIConnectionError:
138
+ error = (
139
+ APITimeoutError()
140
+ if isinstance(exception, httpx.TimeoutException)
141
+ else APIConnectionError(f"Could not reach the CheckAnySite API: {exception}")
142
+ )
143
+ error.__cause__ = exception
144
+ return error
145
+
146
+
147
+ def _parse(response: httpx.Response, cast_to: type[ResponseT]) -> ResponseT:
148
+ """The successful answer as its model, carrying the answer's headers as `.meta`."""
149
+ try:
150
+ data = response.json()
151
+ except ValueError as exception:
152
+ raise APIError(
153
+ f"The API answered {response.status_code} without a JSON body.",
154
+ status_code=response.status_code,
155
+ request_id=response.headers.get("x-request-id"),
156
+ ) from exception
157
+ model = cast_to.model_validate(data)
158
+ model._meta = ResponseMeta.from_headers(response.status_code, response.headers)
159
+ return model
160
+
161
+
162
+ class SyncAPIClient(_BaseClient):
163
+ """Sends requests with `httpx.Client`, waiting between retries with `time.sleep`."""
164
+
165
+ def __init__(
166
+ self,
167
+ *,
168
+ api_key: Optional[str],
169
+ base_url: Optional[str],
170
+ timeout: Timeout,
171
+ max_retries: int,
172
+ http_client: Optional[httpx.Client],
173
+ ) -> None:
174
+ super().__init__(
175
+ api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries
176
+ )
177
+ self._owns_http_client = http_client is None
178
+ self._http = http_client if http_client is not None else _OwnedClient()
179
+ # The clock and the wait, as attributes so tests can stand in for them.
180
+ self._sleep: Callable[[float], None] = time.sleep
181
+ self._clock: Callable[[], float] = time.monotonic
182
+
183
+ def _request(
184
+ self,
185
+ method: str,
186
+ path: str,
187
+ *,
188
+ cast_to: type[ResponseT],
189
+ params: Optional[QueryParams] = None,
190
+ body: Optional[JsonBody] = None,
191
+ idempotency_key: Optional[str] = None,
192
+ ) -> ResponseT:
193
+ request = self._build_request(self._http, method, path, params, body, idempotency_key)
194
+ retries_done = 0
195
+ while True:
196
+ error: RequestFailure
197
+ try:
198
+ response = self._http.send(request)
199
+ except httpx.RequestError as exception:
200
+ error = _connection_error(exception)
201
+ else:
202
+ if response.is_success:
203
+ return _parse(response, cast_to)
204
+ error = error_from_response(response)
205
+ delay = self._retry_delay(retries_done, error)
206
+ if delay is None:
207
+ raise error
208
+ self._sleep(delay)
209
+ retries_done += 1
210
+
211
+ def close(self) -> None:
212
+ """Close the connections, unless the `http_client` was passed in (its owner closes it)."""
213
+ if self._owns_http_client:
214
+ self._http.close()
215
+
216
+
217
+ class AsyncAPIClient(_BaseClient):
218
+ """Sends requests with `httpx.AsyncClient`, waiting between retries with `asyncio.sleep`."""
219
+
220
+ def __init__(
221
+ self,
222
+ *,
223
+ api_key: Optional[str],
224
+ base_url: Optional[str],
225
+ timeout: Timeout,
226
+ max_retries: int,
227
+ http_client: Optional[httpx.AsyncClient],
228
+ ) -> None:
229
+ super().__init__(
230
+ api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries
231
+ )
232
+ self._owns_http_client = http_client is None
233
+ self._http = http_client if http_client is not None else _OwnedAsyncClient()
234
+ self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
235
+ self._clock: Callable[[], float] = time.monotonic
236
+
237
+ async def _request(
238
+ self,
239
+ method: str,
240
+ path: str,
241
+ *,
242
+ cast_to: type[ResponseT],
243
+ params: Optional[QueryParams] = None,
244
+ body: Optional[JsonBody] = None,
245
+ idempotency_key: Optional[str] = None,
246
+ ) -> ResponseT:
247
+ request = self._build_request(self._http, method, path, params, body, idempotency_key)
248
+ retries_done = 0
249
+ while True:
250
+ error: RequestFailure
251
+ try:
252
+ response = await self._http.send(request)
253
+ except httpx.RequestError as exception:
254
+ error = _connection_error(exception)
255
+ else:
256
+ if response.is_success:
257
+ return _parse(response, cast_to)
258
+ error = error_from_response(response)
259
+ delay = self._retry_delay(retries_done, error)
260
+ if delay is None:
261
+ raise error
262
+ await self._sleep(delay)
263
+ retries_done += 1
264
+
265
+ async def close(self) -> None:
266
+ """Close the connections, unless the `http_client` was passed in (its owner closes it)."""
267
+ if self._owns_http_client:
268
+ await self._http.aclose()
@@ -0,0 +1,130 @@
1
+ """The two clients: `CheckAnySite` (sync) and `AsyncCheckAnySite` (async), one surface for both.
2
+
3
+ What: settings (key, base URL, timeout, retries) and the resources `sites`, `scans` and `usage`.
4
+ Not here: sending and retrying requests (_base_client.py) or the endpoints (resources/).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from types import TracebackType
10
+ from typing import Optional
11
+
12
+ import httpx
13
+
14
+ from ._base_client import AsyncAPIClient, SyncAPIClient, Timeout
15
+ from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS
16
+ from .resources import (
17
+ AsyncScansResource,
18
+ AsyncSitesResource,
19
+ AsyncUsageResource,
20
+ ScansResource,
21
+ SitesResource,
22
+ UsageResource,
23
+ )
24
+
25
+
26
+ class CheckAnySite(SyncAPIClient):
27
+ """The CheckAnySite API client.
28
+
29
+ Example:
30
+ >>> from checkanysite import CheckAnySite
31
+ >>> client = CheckAnySite() # reads CHECKANYSITE_API_KEY
32
+ >>> site = client.sites.retrieve("stripe.com", checks=["ai", "tech"])
33
+ >>> site.meta.credits_remaining
34
+
35
+ Args:
36
+ api_key: your key (`cas_live_…`); read from CHECKANYSITE_API_KEY when left out.
37
+ base_url: the API's address; CHECKANYSITE_BASE_URL, else https://api.checkanysite.com/v1.
38
+ timeout: seconds each request may take to connect, send and read (30 by default), or an
39
+ `httpx.Timeout`.
40
+ max_retries: retries after the first try for failures that may pass (2 by default; 0
41
+ turns retrying off).
42
+ http_client: an `httpx.Client` of your own (proxies, transports); you close it yourself.
43
+
44
+ Raises:
45
+ APIError: no API key was passed or found in the environment.
46
+ """
47
+
48
+ sites: SitesResource
49
+ """The Lookup API: stored results, at once, never a scan."""
50
+ scans: ScansResource
51
+ """The Scan API: live scans, answered asynchronously."""
52
+ usage: UsageResource
53
+ """This month's credits."""
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ api_key: Optional[str] = None,
59
+ base_url: Optional[str] = None,
60
+ timeout: Timeout = DEFAULT_TIMEOUT_SECONDS,
61
+ max_retries: int = DEFAULT_MAX_RETRIES,
62
+ http_client: Optional[httpx.Client] = None,
63
+ ) -> None:
64
+ super().__init__(
65
+ api_key=api_key,
66
+ base_url=base_url,
67
+ timeout=timeout,
68
+ max_retries=max_retries,
69
+ http_client=http_client,
70
+ )
71
+ self.sites = SitesResource(self)
72
+ self.scans = ScansResource(self)
73
+ self.usage = UsageResource(self)
74
+
75
+ def __enter__(self) -> CheckAnySite:
76
+ return self
77
+
78
+ def __exit__(
79
+ self,
80
+ exc_type: Optional[type[BaseException]],
81
+ exc: Optional[BaseException],
82
+ traceback: Optional[TracebackType],
83
+ ) -> None:
84
+ self.close()
85
+
86
+
87
+ class AsyncCheckAnySite(AsyncAPIClient):
88
+ """The CheckAnySite API client for asyncio, with the same methods as `CheckAnySite`.
89
+
90
+ Example:
91
+ >>> async with AsyncCheckAnySite() as client:
92
+ ... site = await client.sites.retrieve("stripe.com", checks=["ai"])
93
+
94
+ Args: as for `CheckAnySite`, with an `httpx.AsyncClient` as `http_client`.
95
+ """
96
+
97
+ sites: AsyncSitesResource
98
+ scans: AsyncScansResource
99
+ usage: AsyncUsageResource
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ api_key: Optional[str] = None,
105
+ base_url: Optional[str] = None,
106
+ timeout: Timeout = DEFAULT_TIMEOUT_SECONDS,
107
+ max_retries: int = DEFAULT_MAX_RETRIES,
108
+ http_client: Optional[httpx.AsyncClient] = None,
109
+ ) -> None:
110
+ super().__init__(
111
+ api_key=api_key,
112
+ base_url=base_url,
113
+ timeout=timeout,
114
+ max_retries=max_retries,
115
+ http_client=http_client,
116
+ )
117
+ self.sites = AsyncSitesResource(self)
118
+ self.scans = AsyncScansResource(self)
119
+ self.usage = AsyncUsageResource(self)
120
+
121
+ async def __aenter__(self) -> AsyncCheckAnySite:
122
+ return self
123
+
124
+ async def __aexit__(
125
+ self,
126
+ exc_type: Optional[type[BaseException]],
127
+ exc: Optional[BaseException],
128
+ traceback: Optional[TracebackType],
129
+ ) -> None:
130
+ await self.close()
@@ -0,0 +1,49 @@
1
+ """The library's defaults in one place: where the API is, how long to wait, how often to retry.
2
+
3
+ Each can be changed per client (`CheckAnySite(timeout=..., max_retries=...)`); the base URL and
4
+ the key can also come from the environment.
5
+ """
6
+
7
+ from ._version import __version__
8
+
9
+ DEFAULT_BASE_URL = "https://api.checkanysite.com/v1"
10
+ """The API v1 (https://checkanysite.com/api/v1 answers the same)."""
11
+
12
+ API_KEY_ENV = "CHECKANYSITE_API_KEY"
13
+ """The environment variable read when no `api_key` is passed."""
14
+
15
+ BASE_URL_ENV = "CHECKANYSITE_BASE_URL"
16
+ """The environment variable read when no `base_url` is passed."""
17
+
18
+ USER_AGENT = f"checkanysite-python/{__version__}"
19
+
20
+ DEFAULT_TIMEOUT_SECONDS = 30.0
21
+ """How long one request may wait for each of connecting, sending and reading."""
22
+
23
+ DEFAULT_MAX_RETRIES = 2
24
+ """Retries after the first try, for failures that may pass (see _retries.py)."""
25
+
26
+ INITIAL_RETRY_DELAY_SECONDS = 0.5
27
+ """The first retry's wait without a Retry-After header; it doubles with each retry."""
28
+
29
+ MAX_RETRY_DELAY_SECONDS = 8.0
30
+ """The longest wait between retries without a Retry-After header."""
31
+
32
+ RETRY_JITTER_FRACTION = 0.25
33
+ """Up to this share of a backoff wait is taken off at random, so clients don't retry in step."""
34
+
35
+ MAX_RETRY_AFTER_SECONDS = 60.0
36
+ """The longest Retry-After the library waits out by itself; a longer one is raised to the caller.
37
+
38
+ The API's rate limit window is 60 seconds; a longer wait (no capacity for new scans until later)
39
+ is the caller's to decide.
40
+ """
41
+
42
+ DEFAULT_WAIT_TIMEOUT_SECONDS = 120.0
43
+ """How long `scans.create_and_wait` waits for a scan to be complete before raising."""
44
+
45
+ DEFAULT_POLL_INTERVAL_SECONDS = 3.0
46
+ """The wait between polls of a scan when the API sends no Retry-After (it sends 3 seconds)."""
47
+
48
+ MIN_POLL_INTERVAL_SECONDS = 1.0
49
+ """Never poll a scan more than once a second, whatever Retry-After says (a polite client)."""
@@ -0,0 +1,60 @@
1
+ """When a failed request is tried again, and after how long (ADR 0102).
2
+
3
+ What: two pure functions the sync and async clients share. A request is retried when the
4
+ failure may pass by itself:
5
+ - no answer (a connection error or a timeout);
6
+ - 409 `idempotency_key_in_use`: the first request with this key is still running;
7
+ - 429 only for `rate_limited`, never `quota_exceeded` or `key_limit_reached` (credits spent) or
8
+ `scan_limit_reached` (the day's new scans started);
9
+ - 500, 502, 503 and 504.
10
+ Retrying a POST is safe because it carries the same Idempotency-Key on every try.
11
+ Not here: the loop itself, which sleeps in the sync or async way (_base_client.py).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import random
17
+ from typing import Callable, Optional
18
+
19
+ from ._constants import (
20
+ INITIAL_RETRY_DELAY_SECONDS,
21
+ MAX_RETRY_AFTER_SECONDS,
22
+ MAX_RETRY_DELAY_SECONDS,
23
+ RETRY_JITTER_FRACTION,
24
+ )
25
+
26
+ RETRYABLE_STATUSES = frozenset({500, 502, 503, 504})
27
+ """Statuses retried whatever their code."""
28
+
29
+ RETRYABLE_CODES = {409: frozenset({"idempotency_key_in_use"}), 429: frozenset({"rate_limited"})}
30
+ """Statuses retried only for these codes."""
31
+
32
+
33
+ def is_retryable_status(status_code: int, code: Optional[str]) -> bool:
34
+ """True when an error answer with this status and code may succeed if asked again."""
35
+ if status_code in RETRYABLE_STATUSES:
36
+ return True
37
+ return code is not None and code in RETRYABLE_CODES.get(status_code, frozenset())
38
+
39
+
40
+ def retry_delay_seconds(
41
+ retries_done: int,
42
+ retry_after_seconds: Optional[float],
43
+ random_fraction: Callable[[], float] = random.random,
44
+ ) -> Optional[float]:
45
+ """How long to wait before the next try, or None when the wait is the caller's to decide.
46
+
47
+ Args:
48
+ retries_done: how many retries came before this one (0 for the first).
49
+ retry_after_seconds: the answer's Retry-After, if it sent one.
50
+ random_fraction: a source of numbers in [0, 1), for the jitter (passed in by tests).
51
+
52
+ Returns:
53
+ The Retry-After when the API sent one it is reasonable to wait out (up to 60 seconds);
54
+ else an exponential backoff (0.5, 1, 2 … up to 8 seconds) with up to a quarter taken off
55
+ at random. None when the Retry-After is longer than the library waits by itself.
56
+ """
57
+ if retry_after_seconds is not None:
58
+ return retry_after_seconds if retry_after_seconds <= MAX_RETRY_AFTER_SECONDS else None
59
+ backoff: float = min(INITIAL_RETRY_DELAY_SECONDS * 2.0**retries_done, MAX_RETRY_DELAY_SECONDS)
60
+ return backoff * (1 - RETRY_JITTER_FRACTION * random_fraction())
@@ -0,0 +1,3 @@
1
+ """The library's version: sent in the User-Agent header and read by the build (pyproject.toml)."""
2
+
3
+ __version__ = "0.1.0"