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.
@@ -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()
@@ -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"