kelpgate-client 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.
kelpgate/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Python client for the Kelpgate malware-scanning API.
2
+
3
+ from kelpgate import KelpgateClient
4
+
5
+ with KelpgateClient.from_env() as kg:
6
+ result = kg.scan_file("upload.pdf")
7
+ if result.infected:
8
+ print("Rejected:", result.signature)
9
+
10
+ Use :class:`AsyncKelpgateClient` from asyncio code.
11
+ """
12
+
13
+ from ._async import AsyncKelpgateClient
14
+ from ._errors import (
15
+ AuthError,
16
+ ConfigError,
17
+ InfectedError,
18
+ KelpgateError,
19
+ NetworkError,
20
+ PayloadTooLargeError,
21
+ QuotaExceededError,
22
+ RateLimitedError,
23
+ RequestTimeout,
24
+ ScanError,
25
+ ServerError,
26
+ UnsupportedFileError,
27
+ )
28
+ from ._models import AcceptedScan, Health, PendingScan, ScanResult
29
+ from ._sync import KelpgateClient
30
+ from ._version import __version__
31
+
32
+
33
+ def assert_clean(result: ScanResult) -> ScanResult:
34
+ """Return ``result`` if it is clean. Raise :class:`InfectedError` if it is infected."""
35
+ if result.infected:
36
+ raise InfectedError(result)
37
+ return result
38
+
39
+
40
+ __all__ = [
41
+ "AcceptedScan",
42
+ "AsyncKelpgateClient",
43
+ "AuthError",
44
+ "ConfigError",
45
+ "Health",
46
+ "InfectedError",
47
+ "KelpgateClient",
48
+ "KelpgateError",
49
+ "NetworkError",
50
+ "PayloadTooLargeError",
51
+ "PendingScan",
52
+ "QuotaExceededError",
53
+ "RateLimitedError",
54
+ "RequestTimeout",
55
+ "ScanError",
56
+ "ScanResult",
57
+ "ServerError",
58
+ "UnsupportedFileError",
59
+ "__version__",
60
+ "assert_clean",
61
+ ]
kelpgate/_async.py ADDED
@@ -0,0 +1,256 @@
1
+ """The asyncio client. Same behavior as :class:`kelpgate.KelpgateClient`, with ``await``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from typing import Any, Dict, Mapping, Optional, Union
9
+ from urllib.parse import quote
10
+
11
+ import httpx
12
+
13
+ from ._common import (
14
+ REFRESH_AHEAD,
15
+ RETRY_STATUSES,
16
+ Config,
17
+ PreparedUpload,
18
+ Source,
19
+ accepted_outcome,
20
+ backoff_delay,
21
+ basic_auth_header,
22
+ build_config,
23
+ env_options,
24
+ health_outcome,
25
+ parse_json,
26
+ parse_token,
27
+ poll_outcome,
28
+ prepare_upload,
29
+ request_id_of,
30
+ scan_outcome,
31
+ )
32
+ from ._errors import KelpgateError, NetworkError, RequestTimeout
33
+ from ._models import AcceptedScan, Health, PendingScan, ScanResult
34
+
35
+
36
+ class AsyncKelpgateClient:
37
+ """Asyncio client for the Kelpgate malware-scanning API.
38
+
39
+ Arguments are the same as :class:`kelpgate.KelpgateClient`.
40
+
41
+ Use it as ``async with AsyncKelpgateClient(...) as kg:`` or call :meth:`aclose` when you are done.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ api_url: str,
47
+ *,
48
+ token_url: Optional[str] = None,
49
+ client_id: Optional[str] = None,
50
+ client_secret: Optional[str] = None,
51
+ bearer_token: Optional[str] = None,
52
+ scope: str = "scan",
53
+ timeout: float = 60.0,
54
+ max_retries: int = 2,
55
+ retry_delay: float = 0.5,
56
+ max_bytes: Optional[int] = None,
57
+ transport: Optional[httpx.AsyncBaseTransport] = None,
58
+ ) -> None:
59
+ opts: Dict[str, Any] = {}
60
+ if max_bytes is not None:
61
+ opts["max_bytes"] = max_bytes
62
+ self._cfg: Config = build_config(
63
+ api_url=api_url,
64
+ token_url=token_url,
65
+ client_id=client_id,
66
+ client_secret=client_secret,
67
+ bearer_token=bearer_token,
68
+ scope=scope,
69
+ timeout=timeout,
70
+ max_retries=max_retries,
71
+ retry_delay=retry_delay,
72
+ **opts,
73
+ )
74
+ self._http = httpx.AsyncClient(timeout=timeout, transport=transport, follow_redirects=False)
75
+ self._lock: Optional[asyncio.Lock] = None # created on first use, inside the running loop
76
+ self._token: Optional[str] = None
77
+ self._expires_at = 0.0
78
+
79
+ @classmethod
80
+ def from_env(cls, env: Optional[Mapping[str, str]] = None, **overrides: Any) -> AsyncKelpgateClient:
81
+ """Build a client from ``KELPGATE_*`` environment variables, like ``KelpgateClient.from_env``."""
82
+ opts = env_options(env)
83
+ opts.update(overrides)
84
+ return cls(**opts)
85
+
86
+ def __repr__(self) -> str:
87
+ return f"AsyncKelpgateClient(api_url={self._cfg.api_url!r})"
88
+
89
+ async def __aenter__(self) -> AsyncKelpgateClient:
90
+ return self
91
+
92
+ async def __aexit__(self, *exc: object) -> None:
93
+ await self.aclose()
94
+
95
+ async def aclose(self) -> None:
96
+ """Release the underlying connections."""
97
+ await self._http.aclose()
98
+
99
+ # ---- authentication
100
+
101
+ async def _access_token(self, force: bool = False) -> str:
102
+ if self._cfg.bearer_token:
103
+ return self._cfg.bearer_token
104
+ if not force and self._token and self._expires_at - time.monotonic() > REFRESH_AHEAD:
105
+ return self._token
106
+ if self._lock is None:
107
+ self._lock = asyncio.Lock()
108
+ async with self._lock: # one renewal at a time; concurrent callers wait, then reuse the result
109
+ now = time.monotonic()
110
+ if not force and self._token and self._expires_at - now > REFRESH_AHEAD:
111
+ return self._token
112
+ try:
113
+ token, ttl = await self._fetch_token()
114
+ except KelpgateError:
115
+ if not force and self._token and self._expires_at > now:
116
+ return self._token # renewing early failed, the old token is still good
117
+ raise
118
+ self._token, self._expires_at = token, time.monotonic() + ttl
119
+ return token
120
+
121
+ async def _fetch_token(self) -> tuple[str, float]:
122
+ cfg = self._cfg
123
+ assert cfg.token_url and cfg.client_id and cfg.client_secret
124
+ try:
125
+ res = await self._http.post(
126
+ cfg.token_url,
127
+ headers={
128
+ "Authorization": basic_auth_header(cfg.client_id, cfg.client_secret),
129
+ "User-Agent": cfg.user_agent,
130
+ },
131
+ data={"grant_type": "client_credentials", "scope": cfg.scope},
132
+ )
133
+ except httpx.TimeoutException as exc:
134
+ raise RequestTimeout(f"The token request timed out after {cfg.timeout:g} s") from exc
135
+ except httpx.HTTPError as exc:
136
+ raise NetworkError(f"The token request failed: {exc}") from exc
137
+ return parse_token(res.status_code, parse_json(res.content), request_id_of(res.headers))
138
+
139
+ # ---- HTTP
140
+
141
+ async def _request(
142
+ self,
143
+ method: str,
144
+ path: str,
145
+ *,
146
+ json_body: Any = None,
147
+ upload: Optional[PreparedUpload] = None,
148
+ form: Optional[Dict[str, str]] = None,
149
+ auth: bool = True,
150
+ ) -> tuple[int, Dict[str, Any], httpx.Headers]:
151
+ cfg = self._cfg
152
+ token_retried = False
153
+ attempt = 0
154
+ while True:
155
+ headers = {"User-Agent": cfg.user_agent, "Accept": "application/json"}
156
+ opened: Any = None
157
+ must_close = False
158
+ try:
159
+ if auth:
160
+ headers["Authorization"] = f"Bearer {await self._access_token()}"
161
+ kwargs: Dict[str, Any] = {}
162
+ if upload is not None:
163
+ opened, must_close = upload.open()
164
+ kwargs["files"] = {"file": (upload.filename, opened, "application/octet-stream")}
165
+ if form:
166
+ kwargs["data"] = form
167
+ elif json_body is not None:
168
+ kwargs["content"] = json.dumps(json_body).encode()
169
+ headers["Content-Type"] = "application/json"
170
+ res = await self._http.request(method, cfg.api_url + path, headers=headers, **kwargs)
171
+ except KelpgateError:
172
+ raise
173
+ except httpx.HTTPError as exc:
174
+ err: KelpgateError = (
175
+ RequestTimeout(f"The request timed out after {cfg.timeout:g} s")
176
+ if isinstance(exc, httpx.TimeoutException)
177
+ else NetworkError(f"The request failed: {exc}")
178
+ )
179
+ if attempt >= cfg.max_retries:
180
+ raise err from exc
181
+ await asyncio.sleep(backoff_delay(attempt, cfg.retry_delay))
182
+ attempt += 1
183
+ continue
184
+ finally:
185
+ if must_close and opened is not None:
186
+ opened.close()
187
+
188
+ data = parse_json(res.content)
189
+ if res.status_code == 401 and auth and not cfg.bearer_token and not token_retried:
190
+ token_retried = True # the token may have been revoked or rotated: renew once
191
+ self._token = None
192
+ continue
193
+ if res.status_code in RETRY_STATUSES and attempt < cfg.max_retries:
194
+ await asyncio.sleep(backoff_delay(attempt, cfg.retry_delay, res.headers.get("retry-after")))
195
+ attempt += 1
196
+ continue
197
+ return res.status_code, data, res.headers
198
+
199
+ # ---- scanning
200
+
201
+ async def scan_file(self, source: Source, *, filename: Optional[str] = None) -> ScanResult:
202
+ """Scan a file given as a path, ``bytes`` or a binary file object, and return the verdict."""
203
+ upload = prepare_upload(source, filename, self._cfg.max_bytes)
204
+ return scan_outcome(*await self._request("POST", "/api/v1/scan/file", upload=upload))
205
+
206
+ async def scan_bytes(
207
+ self, data: Union[bytes, bytearray, memoryview], *, filename: Optional[str] = None
208
+ ) -> ScanResult:
209
+ """Scan bytes you already have in memory."""
210
+ return await self.scan_file(data, filename=filename)
211
+
212
+ async def scan_url(self, url: str) -> ScanResult:
213
+ """Ask the service to fetch and scan a remote file."""
214
+ return scan_outcome(*await self._request("POST", "/api/v1/scan/url", json_body={"url": str(url)}))
215
+
216
+ async def submit_file(
217
+ self, source: Source, *, filename: Optional[str] = None, webhook_url: Optional[str] = None
218
+ ) -> AcceptedScan:
219
+ """Start an asynchronous scan. Collect the result with :meth:`get_result` or :meth:`wait`."""
220
+ upload = prepare_upload(source, filename, self._cfg.max_bytes)
221
+ form = {"webhook_url": webhook_url} if webhook_url else None
222
+ return accepted_outcome(
223
+ *await self._request("POST", "/api/v1/async-scan/file", upload=upload, form=form)
224
+ )
225
+
226
+ async def submit_url(self, url: str, *, webhook_url: Optional[str] = None) -> AcceptedScan:
227
+ """Start an asynchronous scan of a remote file."""
228
+ body: Dict[str, str] = {"url": str(url)}
229
+ if webhook_url:
230
+ body["webhook_url"] = webhook_url
231
+ return accepted_outcome(*await self._request("POST", "/api/v1/async-scan/url", json_body=body))
232
+
233
+ async def get_result(self, scan_id: str, *, kind: str = "file") -> Union[ScanResult, PendingScan]:
234
+ """Read an asynchronous result. Returns a :class:`PendingScan` until the verdict is ready."""
235
+ which = "url" if kind == "url" else "file"
236
+ status, data, headers = await self._request(
237
+ "GET", f"/api/v1/async-scan/{which}?scan_id={quote(scan_id, safe='')}"
238
+ )
239
+ return poll_outcome(scan_id, status, data, headers)
240
+
241
+ async def wait(
242
+ self, scan_id: str, *, kind: str = "file", interval: float = 1.0, timeout: float = 120.0
243
+ ) -> ScanResult:
244
+ """Poll until an asynchronous scan finishes. Raises :class:`RequestTimeout` if it takes too long."""
245
+ deadline = time.monotonic() + timeout
246
+ while True:
247
+ result = await self.get_result(scan_id, kind=kind)
248
+ if isinstance(result, ScanResult):
249
+ return result
250
+ if time.monotonic() + interval > deadline:
251
+ raise RequestTimeout(f"Scan {scan_id} was not finished after {timeout:g} s")
252
+ await asyncio.sleep(interval)
253
+
254
+ async def health(self) -> Health:
255
+ """Check that the service is up. Needs no credentials."""
256
+ return health_outcome(*await self._request("GET", "/api/v1/health", auth=False))
kelpgate/_common.py ADDED
@@ -0,0 +1,335 @@
1
+ """Logic shared by the synchronous and asynchronous clients: no I/O happens here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import random
9
+ import re
10
+ import urllib.parse
11
+ from dataclasses import dataclass
12
+ from io import BytesIO
13
+ from typing import Any, BinaryIO, Dict, Mapping, Optional, Tuple, Union
14
+
15
+ from ._errors import (
16
+ AuthError,
17
+ ConfigError,
18
+ KelpgateError,
19
+ PayloadTooLargeError,
20
+ QuotaExceededError,
21
+ RateLimitedError,
22
+ ScanError,
23
+ ServerError,
24
+ UnsupportedFileError,
25
+ )
26
+ from ._models import AcceptedScan, Health, PendingScan, ScanResult
27
+ from ._version import __version__
28
+
29
+ DEFAULT_MAX_BYTES = 128 * 1024 * 1024 # a little above the largest plan limit
30
+ REFRESH_AHEAD = 120.0 # renew a token this many seconds before it expires
31
+ RETRY_AFTER_CAP = 30.0
32
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
33
+ _LOOPBACK = frozenset({"localhost", "127.0.0.1", "::1"})
34
+ # RFC 6749 asks for form-urlencoding of the client id and secret in Basic auth.
35
+ _SAFE = "!~*'()"
36
+
37
+ Source = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview, BinaryIO]
38
+
39
+
40
+ def check_url(name: str, value: Optional[str]) -> str:
41
+ """Return ``value`` without a trailing slash, or raise ConfigError. Plain http is only for localhost."""
42
+ parts = urllib.parse.urlsplit(value or "")
43
+ if parts.scheme not in ("http", "https") or not parts.hostname:
44
+ raise ConfigError(f"{name} is not a valid URL")
45
+ if parts.scheme == "http" and parts.hostname not in _LOOPBACK:
46
+ raise ConfigError(f"{name} must use https (plain http is only allowed for localhost)")
47
+ return (value or "").rstrip("/")
48
+
49
+
50
+ @dataclass
51
+ class Config:
52
+ api_url: str
53
+ token_url: Optional[str]
54
+ client_id: Optional[str]
55
+ client_secret: Optional[str]
56
+ bearer_token: Optional[str]
57
+ scope: str
58
+ timeout: float
59
+ max_retries: int
60
+ retry_delay: float
61
+ max_bytes: int
62
+ user_agent: str
63
+
64
+ def __repr__(self) -> str: # never show the secret or token
65
+ return f"Config(api_url={self.api_url!r}, scope={self.scope!r})"
66
+
67
+
68
+ def build_config(
69
+ *,
70
+ api_url: Optional[str],
71
+ token_url: Optional[str] = None,
72
+ client_id: Optional[str] = None,
73
+ client_secret: Optional[str] = None,
74
+ bearer_token: Optional[str] = None,
75
+ scope: str = "scan",
76
+ timeout: float = 60.0,
77
+ max_retries: int = 2,
78
+ retry_delay: float = 0.5,
79
+ max_bytes: int = DEFAULT_MAX_BYTES,
80
+ ) -> Config:
81
+ checked_api = check_url("api_url", api_url)
82
+ checked_token: Optional[str] = None
83
+ if not bearer_token:
84
+ checked_token = check_url("token_url", token_url)
85
+ if not client_id or not client_secret:
86
+ raise ConfigError("client_id and client_secret are required (or pass bearer_token)")
87
+ return Config(
88
+ api_url=checked_api,
89
+ token_url=checked_token,
90
+ client_id=client_id,
91
+ client_secret=client_secret,
92
+ bearer_token=bearer_token or None,
93
+ scope=scope,
94
+ timeout=float(timeout),
95
+ max_retries=max(0, int(max_retries)),
96
+ retry_delay=float(retry_delay),
97
+ max_bytes=int(max_bytes),
98
+ user_agent=f"kelpgate-python/{__version__}",
99
+ )
100
+
101
+
102
+ def env_options(env: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
103
+ """Client options from ``KELPGATE_*`` environment variables, with the production endpoints as defaults."""
104
+ e = os.environ if env is None else env
105
+ return {
106
+ "api_url": e.get("KELPGATE_API_URL") or "https://api.kelpgate.cloud",
107
+ "token_url": e.get("KELPGATE_TOKEN_URL") or "https://auth.kelpgate.cloud/oauth2/token",
108
+ "client_id": e.get("KELPGATE_CLIENT_ID"),
109
+ "client_secret": e.get("KELPGATE_CLIENT_SECRET"),
110
+ "bearer_token": e.get("KELPGATE_TOKEN"),
111
+ }
112
+
113
+
114
+ def basic_auth_header(client_id: str, client_secret: str) -> str:
115
+ raw = f"{urllib.parse.quote(client_id, safe=_SAFE)}:{urllib.parse.quote(client_secret, safe=_SAFE)}"
116
+ return "Basic " + base64.b64encode(raw.encode()).decode()
117
+
118
+
119
+ def parse_json(content: bytes) -> Dict[str, Any]:
120
+ if not content:
121
+ return {}
122
+ try:
123
+ data = json.loads(content)
124
+ except ValueError:
125
+ return {"error": content[:200].decode("utf-8", "replace")}
126
+ return data if isinstance(data, dict) else {"value": data}
127
+
128
+
129
+ def parse_token(status: int, data: Dict[str, Any], request_id: Optional[str]) -> Tuple[str, float]:
130
+ """Return ``(access_token, lifetime_seconds)`` or raise AuthError."""
131
+ token = data.get("access_token")
132
+ if status != 200 or not isinstance(token, str) or not token:
133
+ why = data.get("error_description") or data.get("error") or f"HTTP {status}"
134
+ safe_body = {k: v for k, v in data.items() if k not in ("access_token", "refresh_token")}
135
+ raise AuthError(
136
+ f"Could not get an access token: {why}",
137
+ code="TOKEN_REQUEST_FAILED",
138
+ status=status,
139
+ request_id=request_id,
140
+ body=safe_body,
141
+ )
142
+ try:
143
+ ttl = float(data.get("expires_in") or 0)
144
+ except (TypeError, ValueError):
145
+ ttl = 0.0
146
+ return token, ttl if ttl > 0 else 300.0
147
+
148
+
149
+ def backoff_delay(attempt: int, base: float, retry_after: Optional[str] = None) -> float:
150
+ if retry_after is not None:
151
+ try:
152
+ seconds = float(retry_after)
153
+ except ValueError:
154
+ seconds = -1.0
155
+ if seconds >= 0:
156
+ return min(seconds, RETRY_AFTER_CAP)
157
+ return base * (2.0**attempt) + random.uniform(0, min(0.1, base)) # noqa: S311 - retry jitter, not security
158
+
159
+
160
+ def _basename(name: str) -> str:
161
+ return re.split(r"[\\/]", name)[-1] or "upload.bin"
162
+
163
+
164
+ class PreparedUpload:
165
+ """A file ready to send. :meth:`open` gives a fresh file object for each attempt, so retries work."""
166
+
167
+ def __init__(
168
+ self,
169
+ filename: str,
170
+ size: int,
171
+ *,
172
+ data: Optional[bytes] = None,
173
+ path: Optional[str] = None,
174
+ stream: Optional[BinaryIO] = None,
175
+ ) -> None:
176
+ self.filename = filename
177
+ self.size = size
178
+ self._data = data
179
+ self._path = path
180
+ self._stream = stream
181
+ self._start = stream.tell() if stream is not None else 0
182
+
183
+ def open(self) -> Tuple[Any, bool]:
184
+ """Return ``(content, must_close)``."""
185
+ if self._data is not None:
186
+ return self._data, False
187
+ if self._path is not None:
188
+ return open(self._path, "rb"), True
189
+ assert self._stream is not None
190
+ self._stream.seek(self._start)
191
+ return self._stream, False
192
+
193
+
194
+ def prepare_upload(source: Source, filename: Optional[str], max_bytes: int) -> PreparedUpload:
195
+ """Turn a path, bytes, or binary file object into something we can send, enforcing ``max_bytes``."""
196
+
197
+ def too_big(size: int) -> PayloadTooLargeError:
198
+ return PayloadTooLargeError(
199
+ f"The file is {size} bytes, over this client's limit of {max_bytes} bytes", status=413
200
+ )
201
+
202
+ upload: PreparedUpload
203
+ if isinstance(source, (str, os.PathLike)):
204
+ path = os.fspath(source)
205
+ try:
206
+ size = os.path.getsize(path)
207
+ with open(path, "rb"):
208
+ pass
209
+ except OSError as exc:
210
+ raise ScanError(f"Cannot read {path}: {exc.strerror or exc}", code="FILE_UNREADABLE") from exc
211
+ upload = PreparedUpload(filename or _basename(path), size, path=path)
212
+ elif isinstance(source, (bytes, bytearray, memoryview)):
213
+ data = bytes(source)
214
+ upload = PreparedUpload(filename or "upload.bin", len(data), data=data)
215
+ elif hasattr(source, "read"):
216
+ stream: BinaryIO = source
217
+ name = filename
218
+ if not name:
219
+ attr = getattr(stream, "name", None)
220
+ name = _basename(attr) if isinstance(attr, str) else "upload.bin"
221
+ seekable = False
222
+ try:
223
+ seekable = bool(stream.seekable())
224
+ except (AttributeError, ValueError):
225
+ seekable = False
226
+ if seekable:
227
+ start = stream.tell()
228
+ end = stream.seek(0, os.SEEK_END)
229
+ stream.seek(start)
230
+ if end - start > max_bytes:
231
+ raise too_big(end - start)
232
+ upload = PreparedUpload(name, end - start, stream=stream)
233
+ else: # a pipe or socket: buffer it, but never more than the limit
234
+ buf = BytesIO()
235
+ while True:
236
+ chunk = stream.read(1024 * 1024)
237
+ if not chunk:
238
+ break
239
+ buf.write(chunk)
240
+ if buf.tell() > max_bytes:
241
+ raise too_big(buf.tell())
242
+ data = buf.getvalue()
243
+ upload = PreparedUpload(name, len(data), data=data)
244
+ else:
245
+ raise ConfigError("source must be a file path, bytes, or a binary file object")
246
+ if upload.size > max_bytes:
247
+ raise too_big(upload.size)
248
+ return upload
249
+
250
+
251
+ def request_id_of(headers: Mapping[str, str]) -> Optional[str]:
252
+ return headers.get("x-request-id")
253
+
254
+
255
+ def to_error(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> KelpgateError:
256
+ """Map a failed response to the right exception."""
257
+ message = str(data.get("error") or data.get("message") or f"Kelpgate returned HTTP {status}")
258
+ kw: Dict[str, Any] = {"status": status, "request_id": request_id_of(headers), "body": data}
259
+ if status in (401, 403):
260
+ return AuthError(message, code="UNAUTHORIZED" if status == 401 else "FORBIDDEN", **kw)
261
+ if status == 402:
262
+ return QuotaExceededError(message, **kw)
263
+ if status == 413:
264
+ return PayloadTooLargeError(message, **kw)
265
+ if status == 415:
266
+ return UnsupportedFileError(message, **kw)
267
+ if status == 429:
268
+ try:
269
+ retry_after: Optional[float] = float(headers.get("retry-after", ""))
270
+ except ValueError:
271
+ retry_after = None
272
+ return RateLimitedError(message, retry_after=retry_after, **kw)
273
+ if status == 404:
274
+ return KelpgateError(message, code="NOT_FOUND", **kw)
275
+ if status in (400, 412):
276
+ return ScanError(message, **kw)
277
+ if status >= 500:
278
+ return ServerError(message, **kw)
279
+ return KelpgateError(message, **kw)
280
+
281
+
282
+ def to_result(data: Dict[str, Any], headers: Mapping[str, str]) -> ScanResult:
283
+ signature = str(data.get("av-signature") or "")
284
+ authors = data.get("rule_authors")
285
+ return ScanResult(
286
+ infected=data.get("av-status") == "INFECTED",
287
+ status=str(data.get("av-status")),
288
+ signature=signature,
289
+ sha256=data.get("sha256"),
290
+ rule_authors=tuple(str(a) for a in authors) if isinstance(authors, list) else (),
291
+ filename=data.get("filename"),
292
+ scan_id=data.get("scan_id"),
293
+ timestamp=data.get("av-timestamp"),
294
+ s3_path=data.get("s3_path"),
295
+ download_url=data.get("download_url"),
296
+ request_id=request_id_of(headers),
297
+ policy_rejected=signature.startswith("POLICY:"),
298
+ degraded="scan coverage reduced" in signature,
299
+ raw=data,
300
+ )
301
+
302
+
303
+ def scan_outcome(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> ScanResult:
304
+ """A scan answers 200 (clean) or 406 (infected). Both are verdicts. Anything else is an error."""
305
+ if status in (200, 406) and data.get("av-status"):
306
+ return to_result(data, headers)
307
+ raise to_error(status, data, headers)
308
+
309
+
310
+ def accepted_outcome(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> AcceptedScan:
311
+ if status == 202 and data.get("scan_id"):
312
+ return AcceptedScan(
313
+ scan_id=str(data["scan_id"]),
314
+ filename=data.get("filename"),
315
+ message=data.get("message"),
316
+ request_id=request_id_of(headers),
317
+ )
318
+ raise to_error(status, data, headers)
319
+
320
+
321
+ def poll_outcome(
322
+ scan_id: str, status: int, data: Dict[str, Any], headers: Mapping[str, str]
323
+ ) -> Union[ScanResult, PendingScan]:
324
+ if status == 202 or data.get("av-status") == "PROCESSING":
325
+ return PendingScan(scan_id=scan_id, request_id=request_id_of(headers))
326
+ return scan_outcome(status, data, headers)
327
+
328
+
329
+ def health_outcome(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> Health:
330
+ return Health(
331
+ ok=status == 200,
332
+ status=status,
333
+ signature_version=data.get("signature_version"),
334
+ request_id=request_id_of(headers),
335
+ )