gushwork-argus 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.
- gushwork_argus/__init__.py +78 -0
- gushwork_argus/_client.py +88 -0
- gushwork_argus/_http.py +273 -0
- gushwork_argus/_pagination.py +33 -0
- gushwork_argus/_upload.py +87 -0
- gushwork_argus/_version.py +1 -0
- gushwork_argus/errors.py +155 -0
- gushwork_argus/models.py +189 -0
- gushwork_argus/py.typed +0 -0
- gushwork_argus/resources/__init__.py +15 -0
- gushwork_argus/resources/images.py +174 -0
- gushwork_argus/resources/ingestions.py +89 -0
- gushwork_argus/resources/namespaces.py +108 -0
- gushwork_argus/resources/pages.py +95 -0
- gushwork_argus-0.1.0.dist-info/METADATA +198 -0
- gushwork_argus-0.1.0.dist-info/RECORD +18 -0
- gushwork_argus-0.1.0.dist-info/WHEEL +4 -0
- gushwork_argus-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Python client for Argus, Gushwork's image ingestion and search API."""
|
|
2
|
+
|
|
3
|
+
from ._client import Argus, AsyncArgus
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIResponseValidationError,
|
|
8
|
+
APIStatusError,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
ArgusError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BadRequestError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
IngestionTimeoutError,
|
|
15
|
+
InternalServerError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
UnprocessableEntityError,
|
|
20
|
+
UploadError,
|
|
21
|
+
)
|
|
22
|
+
from .models import (
|
|
23
|
+
CursorPage,
|
|
24
|
+
DoorMatches,
|
|
25
|
+
Image,
|
|
26
|
+
ImageAdded,
|
|
27
|
+
ImageFile,
|
|
28
|
+
ImageProgress,
|
|
29
|
+
ImageStatus,
|
|
30
|
+
IndexState,
|
|
31
|
+
Ingestion,
|
|
32
|
+
Namespace,
|
|
33
|
+
PageAdded,
|
|
34
|
+
PageType,
|
|
35
|
+
SearchFilters,
|
|
36
|
+
SearchMode,
|
|
37
|
+
SearchResponse,
|
|
38
|
+
SearchResult,
|
|
39
|
+
Usage,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"APIConnectionError",
|
|
44
|
+
"APIResponseValidationError",
|
|
45
|
+
"APIStatusError",
|
|
46
|
+
"APITimeoutError",
|
|
47
|
+
"Argus",
|
|
48
|
+
"ArgusError",
|
|
49
|
+
"AsyncArgus",
|
|
50
|
+
"AuthenticationError",
|
|
51
|
+
"BadRequestError",
|
|
52
|
+
"ConflictError",
|
|
53
|
+
"CursorPage",
|
|
54
|
+
"DoorMatches",
|
|
55
|
+
"Image",
|
|
56
|
+
"ImageAdded",
|
|
57
|
+
"ImageFile",
|
|
58
|
+
"ImageProgress",
|
|
59
|
+
"ImageStatus",
|
|
60
|
+
"IndexState",
|
|
61
|
+
"Ingestion",
|
|
62
|
+
"IngestionTimeoutError",
|
|
63
|
+
"InternalServerError",
|
|
64
|
+
"Namespace",
|
|
65
|
+
"NotFoundError",
|
|
66
|
+
"PageAdded",
|
|
67
|
+
"PageType",
|
|
68
|
+
"PermissionDeniedError",
|
|
69
|
+
"RateLimitError",
|
|
70
|
+
"SearchFilters",
|
|
71
|
+
"SearchMode",
|
|
72
|
+
"SearchResponse",
|
|
73
|
+
"SearchResult",
|
|
74
|
+
"UnprocessableEntityError",
|
|
75
|
+
"UploadError",
|
|
76
|
+
"Usage",
|
|
77
|
+
"__version__",
|
|
78
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The two entry points: Argus (blocking) and AsyncArgus (asyncio). Same methods on both."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._http import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, AsyncTransport, Settings, SyncTransport
|
|
8
|
+
from .resources import (
|
|
9
|
+
AsyncImages,
|
|
10
|
+
AsyncIngestions,
|
|
11
|
+
AsyncNamespaces,
|
|
12
|
+
AsyncPages,
|
|
13
|
+
Images,
|
|
14
|
+
Ingestions,
|
|
15
|
+
Namespaces,
|
|
16
|
+
Pages,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Argus:
|
|
21
|
+
"""A client for the Argus API.
|
|
22
|
+
|
|
23
|
+
argus = Argus(api_key="...", base_url="https://<argus host>")
|
|
24
|
+
namespace = argus.namespaces.create(slug="acme", name="Acme Dental")
|
|
25
|
+
|
|
26
|
+
`api_key` and `base_url` fall back to ARGUS_API_KEY and ARGUS_BASE_URL. `timeout` is
|
|
27
|
+
per request, in seconds. `max_retries` covers connection errors, timeouts, 429 and
|
|
28
|
+
502/503/504. Pass `http_client` only for proxies or custom transports. Reuse one client
|
|
29
|
+
per process; it holds a connection pool. Close it with `close()` or a `with` block.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
base_url: str | None = None,
|
|
36
|
+
*,
|
|
37
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
38
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
39
|
+
http_client: httpx.Client | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
settings = Settings.resolve(api_key, base_url, timeout, max_retries)
|
|
42
|
+
self._transport = SyncTransport(settings, http_client)
|
|
43
|
+
self.namespaces = Namespaces(self._transport)
|
|
44
|
+
self.pages = Pages(self._transport)
|
|
45
|
+
self.images = Images(self._transport)
|
|
46
|
+
self.ingestions = Ingestions(self._transport)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self._transport.close()
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> Argus:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
55
|
+
self.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AsyncArgus:
|
|
59
|
+
"""The asyncio client: the same methods as `Argus`, awaited.
|
|
60
|
+
|
|
61
|
+
async with AsyncArgus(api_key="...", base_url="https://<argus host>") as argus:
|
|
62
|
+
namespace = await argus.namespaces.get(namespace_id)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
api_key: str | None = None,
|
|
68
|
+
base_url: str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
71
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
72
|
+
http_client: httpx.AsyncClient | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
settings = Settings.resolve(api_key, base_url, timeout, max_retries)
|
|
75
|
+
self._transport = AsyncTransport(settings, http_client)
|
|
76
|
+
self.namespaces = AsyncNamespaces(self._transport)
|
|
77
|
+
self.pages = AsyncPages(self._transport)
|
|
78
|
+
self.images = AsyncImages(self._transport)
|
|
79
|
+
self.ingestions = AsyncIngestions(self._transport)
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
await self._transport.close()
|
|
83
|
+
|
|
84
|
+
async def __aenter__(self) -> AsyncArgus:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
88
|
+
await self.close()
|
gushwork_argus/_http.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Sending requests: the API key, retries with backoff, responses as models or exceptions.
|
|
2
|
+
|
|
3
|
+
SyncTransport and AsyncTransport are the only classes that do I/O. They mirror each other
|
|
4
|
+
line for line; a change to one is a change to both.
|
|
5
|
+
|
|
6
|
+
Argus writes are keyed by slug, content hash or image source, so sending one twice is
|
|
7
|
+
harmless: those requests are retried on connection errors, timeouts, 429 and 502/503/504.
|
|
8
|
+
A search is not a write but costs model calls each time it runs, so it is only retried
|
|
9
|
+
when it certainly never reached Argus (`resend=False`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, TypeVar
|
|
21
|
+
from urllib.parse import quote
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from pydantic import BaseModel, ValidationError
|
|
25
|
+
|
|
26
|
+
from ._upload import Payload, UploadTarget
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .errors import (
|
|
29
|
+
APIConnectionError,
|
|
30
|
+
APIResponseValidationError,
|
|
31
|
+
APITimeoutError,
|
|
32
|
+
UploadError,
|
|
33
|
+
status_error,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
DEFAULT_TIMEOUT = 30.0
|
|
37
|
+
DEFAULT_MAX_RETRIES = 2
|
|
38
|
+
|
|
39
|
+
_RESEND_STATUSES = frozenset({429, 502, 503, 504})
|
|
40
|
+
# Answers that mean the request was turned away before any work was done.
|
|
41
|
+
_NOT_PROCESSED_STATUSES = frozenset({429})
|
|
42
|
+
_NOT_SENT_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout)
|
|
43
|
+
_MAX_DELAY = 8.0
|
|
44
|
+
_log = logging.getLogger("gushwork_argus")
|
|
45
|
+
|
|
46
|
+
M = TypeVar("M", bound=BaseModel)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Settings:
|
|
51
|
+
# Kept out of repr so a logged or captured Settings never shows the key.
|
|
52
|
+
api_key: str = field(repr=False)
|
|
53
|
+
base_url: str
|
|
54
|
+
timeout: float
|
|
55
|
+
max_retries: int
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def resolve(
|
|
59
|
+
cls, api_key: str | None, base_url: str | None, timeout: float, max_retries: int
|
|
60
|
+
) -> Settings:
|
|
61
|
+
"""Arguments first, then ARGUS_API_KEY / ARGUS_BASE_URL from the environment."""
|
|
62
|
+
api_key = api_key or os.environ.get("ARGUS_API_KEY")
|
|
63
|
+
base_url = base_url or os.environ.get("ARGUS_BASE_URL")
|
|
64
|
+
if not api_key:
|
|
65
|
+
raise ValueError("api_key is required: pass api_key= or set ARGUS_API_KEY")
|
|
66
|
+
if not base_url:
|
|
67
|
+
raise ValueError("base_url is required: pass base_url= or set ARGUS_BASE_URL")
|
|
68
|
+
if max_retries < 0:
|
|
69
|
+
raise ValueError("max_retries can't be negative")
|
|
70
|
+
return cls(api_key, base_url.rstrip("/"), timeout, max_retries)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def headers(self) -> dict[str, str]:
|
|
74
|
+
return {
|
|
75
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
76
|
+
"User-Agent": f"gushwork-argus-python/{__version__}",
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def path(*segments: str) -> str:
|
|
82
|
+
"""`/v1/a/b` with each segment escaped, so an id can never change the route."""
|
|
83
|
+
return "/v1/" + "/".join(quote(segment, safe="") for segment in segments)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def without_none(values: dict[str, Any]) -> dict[str, Any]:
|
|
87
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def retry_delay(attempt: int, response: httpx.Response | None) -> float:
|
|
91
|
+
"""Exponential backoff with jitter; a numeric Retry-After from the server wins."""
|
|
92
|
+
retry_after = response.headers.get("Retry-After", "") if response is not None else ""
|
|
93
|
+
if retry_after.isdigit():
|
|
94
|
+
return min(float(retry_after), _MAX_DELAY)
|
|
95
|
+
return min(_MAX_DELAY, 0.5 * 2**attempt) * random.uniform(0.5, 1.0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _should_retry(outcome: httpx.Response | httpx.TransportError, resend: bool) -> bool:
|
|
99
|
+
if isinstance(outcome, httpx.TransportError):
|
|
100
|
+
return resend or isinstance(outcome, _NOT_SENT_ERRORS)
|
|
101
|
+
return outcome.status_code in (_RESEND_STATUSES if resend else _NOT_PROCESSED_STATUSES)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _connection_error(exc: httpx.TransportError) -> APIConnectionError:
|
|
105
|
+
message = str(exc) or type(exc).__name__
|
|
106
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
107
|
+
return APITimeoutError(message)
|
|
108
|
+
return APIConnectionError(message)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse(response: httpx.Response, cast_to: type[M]) -> M:
|
|
112
|
+
"""The response as `cast_to`, or the matching ArgusError."""
|
|
113
|
+
if response.is_error:
|
|
114
|
+
raise status_error(response)
|
|
115
|
+
try:
|
|
116
|
+
return cast_to.model_validate(response.json())
|
|
117
|
+
except (ValueError, ValidationError) as exc:
|
|
118
|
+
raise APIResponseValidationError(response, str(exc)) from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_upload(response: httpx.Response) -> None:
|
|
122
|
+
if response.is_error:
|
|
123
|
+
raise UploadError(response.status_code, response.text)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _log_retry(method: str, url: str, attempt: int, delay: float, reason: str) -> None:
|
|
127
|
+
_log.info("retrying %s %s in %.1fs (attempt %d): %s", method, url, delay, attempt + 1, reason)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SyncTransport:
|
|
131
|
+
def __init__(self, settings: Settings, http_client: httpx.Client | None = None) -> None:
|
|
132
|
+
self._settings = settings
|
|
133
|
+
self._client = http_client or httpx.Client()
|
|
134
|
+
self._owns_client = http_client is None
|
|
135
|
+
# S3 gets its own client and never sees the API key. Like any httpx client it
|
|
136
|
+
# honours HTTPS_PROXY / SSL_CERT_FILE from the environment.
|
|
137
|
+
self._s3 = httpx.Client()
|
|
138
|
+
|
|
139
|
+
def request(
|
|
140
|
+
self,
|
|
141
|
+
method: str,
|
|
142
|
+
route: str,
|
|
143
|
+
cast_to: type[M],
|
|
144
|
+
*,
|
|
145
|
+
json: Any = None,
|
|
146
|
+
params: dict[str, Any] | None = None,
|
|
147
|
+
resend: bool = True,
|
|
148
|
+
) -> M:
|
|
149
|
+
response = self._send(
|
|
150
|
+
self._client,
|
|
151
|
+
method,
|
|
152
|
+
self._settings.base_url + route,
|
|
153
|
+
resend,
|
|
154
|
+
headers=self._settings.headers,
|
|
155
|
+
json=json,
|
|
156
|
+
params=without_none(params or {}),
|
|
157
|
+
)
|
|
158
|
+
return _parse(response, cast_to)
|
|
159
|
+
|
|
160
|
+
def upload(self, namespace_id: str, payload: Payload) -> str:
|
|
161
|
+
"""Put the bytes in the Argus bucket through a presigned POST; returns the s3:// uri."""
|
|
162
|
+
target = self.request(
|
|
163
|
+
"POST", path("uploads"), UploadTarget, json={"namespace_id": namespace_id}
|
|
164
|
+
)
|
|
165
|
+
target.check(payload)
|
|
166
|
+
response = self._send(
|
|
167
|
+
self._s3,
|
|
168
|
+
"POST",
|
|
169
|
+
target.url,
|
|
170
|
+
True,
|
|
171
|
+
data=target.fields,
|
|
172
|
+
files={"file": (payload.filename, payload.data)},
|
|
173
|
+
)
|
|
174
|
+
_check_upload(response)
|
|
175
|
+
return target.uri
|
|
176
|
+
|
|
177
|
+
def close(self) -> None:
|
|
178
|
+
if self._owns_client:
|
|
179
|
+
self._client.close()
|
|
180
|
+
self._s3.close()
|
|
181
|
+
|
|
182
|
+
def _send(
|
|
183
|
+
self, client: httpx.Client, method: str, url: str, resend: bool, **kwargs: Any
|
|
184
|
+
) -> httpx.Response:
|
|
185
|
+
attempt = 0
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
response = client.request(method, url, timeout=self._settings.timeout, **kwargs)
|
|
189
|
+
except httpx.TransportError as exc:
|
|
190
|
+
if attempt >= self._settings.max_retries or not _should_retry(exc, resend):
|
|
191
|
+
raise _connection_error(exc) from exc
|
|
192
|
+
delay, reason = retry_delay(attempt, None), type(exc).__name__
|
|
193
|
+
else:
|
|
194
|
+
if attempt >= self._settings.max_retries or not _should_retry(response, resend):
|
|
195
|
+
return response
|
|
196
|
+
delay, reason = retry_delay(attempt, response), str(response.status_code)
|
|
197
|
+
_log_retry(method, url, attempt, delay, reason)
|
|
198
|
+
time.sleep(delay)
|
|
199
|
+
attempt += 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class AsyncTransport:
|
|
203
|
+
def __init__(self, settings: Settings, http_client: httpx.AsyncClient | None = None) -> None:
|
|
204
|
+
self._settings = settings
|
|
205
|
+
self._client = http_client or httpx.AsyncClient()
|
|
206
|
+
self._owns_client = http_client is None
|
|
207
|
+
# S3 gets its own client and never sees the API key. Like any httpx client it
|
|
208
|
+
# honours HTTPS_PROXY / SSL_CERT_FILE from the environment.
|
|
209
|
+
self._s3 = httpx.AsyncClient()
|
|
210
|
+
|
|
211
|
+
async def request(
|
|
212
|
+
self,
|
|
213
|
+
method: str,
|
|
214
|
+
route: str,
|
|
215
|
+
cast_to: type[M],
|
|
216
|
+
*,
|
|
217
|
+
json: Any = None,
|
|
218
|
+
params: dict[str, Any] | None = None,
|
|
219
|
+
resend: bool = True,
|
|
220
|
+
) -> M:
|
|
221
|
+
response = await self._send(
|
|
222
|
+
self._client,
|
|
223
|
+
method,
|
|
224
|
+
self._settings.base_url + route,
|
|
225
|
+
resend,
|
|
226
|
+
headers=self._settings.headers,
|
|
227
|
+
json=json,
|
|
228
|
+
params=without_none(params or {}),
|
|
229
|
+
)
|
|
230
|
+
return _parse(response, cast_to)
|
|
231
|
+
|
|
232
|
+
async def upload(self, namespace_id: str, payload: Payload) -> str:
|
|
233
|
+
"""Put the bytes in the Argus bucket through a presigned POST; returns the s3:// uri."""
|
|
234
|
+
target = await self.request(
|
|
235
|
+
"POST", path("uploads"), UploadTarget, json={"namespace_id": namespace_id}
|
|
236
|
+
)
|
|
237
|
+
target.check(payload)
|
|
238
|
+
response = await self._send(
|
|
239
|
+
self._s3,
|
|
240
|
+
"POST",
|
|
241
|
+
target.url,
|
|
242
|
+
True,
|
|
243
|
+
data=target.fields,
|
|
244
|
+
files={"file": (payload.filename, payload.data)},
|
|
245
|
+
)
|
|
246
|
+
_check_upload(response)
|
|
247
|
+
return target.uri
|
|
248
|
+
|
|
249
|
+
async def close(self) -> None:
|
|
250
|
+
if self._owns_client:
|
|
251
|
+
await self._client.aclose()
|
|
252
|
+
await self._s3.aclose()
|
|
253
|
+
|
|
254
|
+
async def _send(
|
|
255
|
+
self, client: httpx.AsyncClient, method: str, url: str, resend: bool, **kwargs: Any
|
|
256
|
+
) -> httpx.Response:
|
|
257
|
+
attempt = 0
|
|
258
|
+
while True:
|
|
259
|
+
try:
|
|
260
|
+
response = await client.request(
|
|
261
|
+
method, url, timeout=self._settings.timeout, **kwargs
|
|
262
|
+
)
|
|
263
|
+
except httpx.TransportError as exc:
|
|
264
|
+
if attempt >= self._settings.max_retries or not _should_retry(exc, resend):
|
|
265
|
+
raise _connection_error(exc) from exc
|
|
266
|
+
delay, reason = retry_delay(attempt, None), type(exc).__name__
|
|
267
|
+
else:
|
|
268
|
+
if attempt >= self._settings.max_retries or not _should_retry(response, resend):
|
|
269
|
+
return response
|
|
270
|
+
delay, reason = retry_delay(attempt, response), str(response.status_code)
|
|
271
|
+
_log_retry(method, url, attempt, delay, reason)
|
|
272
|
+
await asyncio.sleep(delay)
|
|
273
|
+
attempt += 1
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Walking every page of a list by following `next_cursor`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from .models import CursorPage
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def iterate(fetch: Callable[[str | None], CursorPage[T]]) -> Iterator[T]:
|
|
14
|
+
"""Every item across pages. `fetch(cursor)` returns one page; None asks for the first."""
|
|
15
|
+
cursor: str | None = None
|
|
16
|
+
while True:
|
|
17
|
+
page = fetch(cursor)
|
|
18
|
+
yield from page.items
|
|
19
|
+
if page.next_cursor is None:
|
|
20
|
+
return
|
|
21
|
+
cursor = page.next_cursor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def aiterate(fetch: Callable[[str | None], Awaitable[CursorPage[T]]]) -> AsyncIterator[T]:
|
|
25
|
+
"""Every item across pages. `fetch(cursor)` returns one page; None asks for the first."""
|
|
26
|
+
cursor: str | None = None
|
|
27
|
+
while True:
|
|
28
|
+
page = await fetch(cursor)
|
|
29
|
+
for item in page.items:
|
|
30
|
+
yield item
|
|
31
|
+
if page.next_cursor is None:
|
|
32
|
+
return
|
|
33
|
+
cursor = page.next_cursor
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Turning what a caller has (a path, text, bytes, an open file) into bytes to upload.
|
|
2
|
+
|
|
3
|
+
No I/O against Argus or S3 happens here; the transports do that.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import BinaryIO, Union
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from .models import PageType
|
|
17
|
+
|
|
18
|
+
PageContent = Union[str, bytes, "os.PathLike[str]"]
|
|
19
|
+
FileContent = Union[bytes, "os.PathLike[str]", BinaryIO]
|
|
20
|
+
|
|
21
|
+
_PAGE_TYPES: dict[str, PageType] = {
|
|
22
|
+
".md": "md",
|
|
23
|
+
".markdown": "md",
|
|
24
|
+
".html": "html",
|
|
25
|
+
".htm": "html",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Payload:
|
|
31
|
+
data: bytes
|
|
32
|
+
filename: str
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def content_hash(self) -> str:
|
|
36
|
+
"""How Argus recognises a page it has seen before, and checks it arrived intact."""
|
|
37
|
+
return "sha256:" + hashlib.sha256(self.data).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UploadTarget(BaseModel):
|
|
41
|
+
"""A presigned S3 POST from `POST /v1/uploads`: where to send the file, and its s3:// uri."""
|
|
42
|
+
|
|
43
|
+
url: str
|
|
44
|
+
fields: dict[str, str]
|
|
45
|
+
uri: str
|
|
46
|
+
max_bytes: int
|
|
47
|
+
|
|
48
|
+
def check(self, payload: Payload) -> None:
|
|
49
|
+
size = len(payload.data)
|
|
50
|
+
if size == 0:
|
|
51
|
+
raise ValueError(f"{payload.filename} is empty")
|
|
52
|
+
if size > self.max_bytes:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"{payload.filename} is {size} bytes; Argus accepts up to {self.max_bytes}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def page_payload(content: PageContent, page_type: PageType | None) -> tuple[Payload, PageType]:
|
|
59
|
+
"""A page as bytes plus its type: given, or read from a Path's extension."""
|
|
60
|
+
inferred: PageType | None = None
|
|
61
|
+
if isinstance(content, os.PathLike):
|
|
62
|
+
path = Path(content)
|
|
63
|
+
data, filename = path.read_bytes(), path.name
|
|
64
|
+
inferred = _PAGE_TYPES.get(path.suffix.lower())
|
|
65
|
+
elif isinstance(content, str):
|
|
66
|
+
data, filename = content.encode("utf-8"), "page"
|
|
67
|
+
else:
|
|
68
|
+
data, filename = content, "page"
|
|
69
|
+
|
|
70
|
+
resolved = page_type or inferred
|
|
71
|
+
if resolved not in ("html", "md"):
|
|
72
|
+
raise ValueError("pass type='html' or type='md' (only a .md/.html Path can be inferred)")
|
|
73
|
+
return Payload(data, filename), resolved
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def file_payload(file: FileContent) -> Payload:
|
|
77
|
+
"""An image file as bytes: from a Path, raw bytes, or an open binary file."""
|
|
78
|
+
if isinstance(file, os.PathLike):
|
|
79
|
+
path = Path(file)
|
|
80
|
+
return Payload(path.read_bytes(), path.name)
|
|
81
|
+
if isinstance(file, bytes):
|
|
82
|
+
return Payload(file, "image")
|
|
83
|
+
data = file.read()
|
|
84
|
+
# Typed as bytes, but a file opened in text mode hands back str.
|
|
85
|
+
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
86
|
+
raise TypeError("open the file in binary mode ('rb')")
|
|
87
|
+
return Payload(data, Path(str(getattr(file, "name", "image"))).name)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|