gushwork-argus 0.1.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.
- gushwork_argus-0.1.0/.gitignore +2 -0
- gushwork_argus-0.1.0/CHANGELOG.md +15 -0
- gushwork_argus-0.1.0/LICENSE +22 -0
- gushwork_argus-0.1.0/PKG-INFO +198 -0
- gushwork_argus-0.1.0/README.md +179 -0
- gushwork_argus-0.1.0/pyproject.toml +60 -0
- gushwork_argus-0.1.0/src/gushwork_argus/__init__.py +78 -0
- gushwork_argus-0.1.0/src/gushwork_argus/_client.py +88 -0
- gushwork_argus-0.1.0/src/gushwork_argus/_http.py +273 -0
- gushwork_argus-0.1.0/src/gushwork_argus/_pagination.py +33 -0
- gushwork_argus-0.1.0/src/gushwork_argus/_upload.py +87 -0
- gushwork_argus-0.1.0/src/gushwork_argus/_version.py +1 -0
- gushwork_argus-0.1.0/src/gushwork_argus/errors.py +155 -0
- gushwork_argus-0.1.0/src/gushwork_argus/models.py +189 -0
- gushwork_argus-0.1.0/src/gushwork_argus/py.typed +0 -0
- gushwork_argus-0.1.0/src/gushwork_argus/resources/__init__.py +15 -0
- gushwork_argus-0.1.0/src/gushwork_argus/resources/images.py +174 -0
- gushwork_argus-0.1.0/src/gushwork_argus/resources/ingestions.py +89 -0
- gushwork_argus-0.1.0/src/gushwork_argus/resources/namespaces.py +108 -0
- gushwork_argus-0.1.0/src/gushwork_argus/resources/pages.py +95 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (2026-10-16)
|
|
6
|
+
|
|
7
|
+
First release, on PyPI.
|
|
8
|
+
|
|
9
|
+
- `Argus` and `AsyncArgus` clients configured by an API key and a base URL
|
|
10
|
+
(or `ARGUS_API_KEY` / `ARGUS_BASE_URL`).
|
|
11
|
+
- Namespaces: `create`, `get`, `find_by_slug`, `list`, `list_all`.
|
|
12
|
+
- Pages: `add` an HTML or Markdown page through a presigned S3 upload.
|
|
13
|
+
- Images: `add` a file (presigned S3 upload) or a URL, `list`, `list_all`, `search`.
|
|
14
|
+
- Ingestions: `get`, and `wait` until complete.
|
|
15
|
+
- Typed errors, retries with backoff on connection errors, timeouts, 429 and 502/503/504.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gushwork
|
|
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.
|
|
22
|
+
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gushwork-argus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Argus: Gushwork's image ingestion and search API
|
|
5
|
+
Author: Gushwork
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx<1,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.6
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# gushwork-argus
|
|
21
|
+
|
|
22
|
+
Python client for **Argus**, Gushwork's image ingestion and search API. You give Argus
|
|
23
|
+
pages and images; it finds every image, describes it, and makes it searchable by meaning.
|
|
24
|
+
|
|
25
|
+
- One client, two settings: an API key and the API host.
|
|
26
|
+
- `Argus` (blocking) and `AsyncArgus` (asyncio) with the same methods.
|
|
27
|
+
- Typed responses (Pydantic v2), typed errors, retries with backoff.
|
|
28
|
+
- Python 3.10+. Depends only on `httpx` and `pydantic`.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install gushwork-argus
|
|
34
|
+
# or
|
|
35
|
+
uv add gushwork-argus
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pin a version in services (`gushwork-argus==0.1.0`); see [CHANGELOG.md](CHANGELOG.md).
|
|
39
|
+
|
|
40
|
+
## Configure
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from gushwork_argus import Argus
|
|
44
|
+
|
|
45
|
+
argus = Argus(api_key="...", base_url="https://<argus host>")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or set `ARGUS_API_KEY` and `ARGUS_BASE_URL` and call `Argus()`. The Argus team gives you
|
|
49
|
+
both: a key with the scopes your service needs, and the host for each environment.
|
|
50
|
+
|
|
51
|
+
Optional: `timeout=30.0` (seconds per request), `max_retries=2`.
|
|
52
|
+
Create one client per process and reuse it; it keeps a connection pool. Close it with
|
|
53
|
+
`argus.close()` or use `with Argus(...) as argus:`.
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
from gushwork_argus import Argus, ConflictError
|
|
60
|
+
|
|
61
|
+
argus = Argus()
|
|
62
|
+
|
|
63
|
+
# 1. Create a namespace (admin key). Safe to retry with the same fields.
|
|
64
|
+
namespace = argus.namespaces.create(
|
|
65
|
+
slug="acme-dental", name="Acme Dental", site_name="Acme Dental", domain="acme.com"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 2. Get it back, by id or slug
|
|
69
|
+
namespace = argus.namespaces.get(namespace.id)
|
|
70
|
+
namespace = argus.namespaces.find_by_slug("acme-dental") # None when there is none
|
|
71
|
+
|
|
72
|
+
# 3. Add a page or an image (ingest scope). Files go to S3 through a presigned upload.
|
|
73
|
+
page = argus.pages.add(namespace.id, Path("about.md"), page_url="https://acme.com/about")
|
|
74
|
+
page = argus.pages.add(namespace.id, html_text, type="html", page_url="https://acme.com/")
|
|
75
|
+
image = argus.images.add(namespace.id, file=Path("crown.jpg"), alt="Dentist fitting a crown")
|
|
76
|
+
image = argus.images.add(namespace.id, url="https://cdn.acme.com/crown.jpg") # no upload
|
|
77
|
+
|
|
78
|
+
# 7. Check an ingestion, or wait until it is complete
|
|
79
|
+
status = argus.ingestions.get(page.ingestion_id)
|
|
80
|
+
status = argus.ingestions.wait(page.ingestion_id, timeout=600)
|
|
81
|
+
print(status.status, status.images.searchable, "of", status.images.total, "searchable")
|
|
82
|
+
|
|
83
|
+
# 4. Search (retrieve scope)
|
|
84
|
+
response = argus.images.search(
|
|
85
|
+
namespace.id, "dentist fitting a crown", top_k=5, filters={"orientation": "landscape"}
|
|
86
|
+
)
|
|
87
|
+
for result in response.results:
|
|
88
|
+
print(result.rank, result.match, result.image.url, result.description)
|
|
89
|
+
|
|
90
|
+
# 5. List namespaces: one page, or all of them
|
|
91
|
+
page_of_namespaces = argus.namespaces.list(limit=50) # CursorPage: .items, .next_cursor
|
|
92
|
+
for ns in argus.namespaces.list_all():
|
|
93
|
+
print(ns.slug)
|
|
94
|
+
|
|
95
|
+
# 6. List images, newest first
|
|
96
|
+
for img in argus.images.list_all(namespace.id, status="RETRIEVAL_READY"):
|
|
97
|
+
print(img.media_id, img.description, img.file.url if img.file else None)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Async
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from gushwork_argus import AsyncArgus
|
|
104
|
+
|
|
105
|
+
async with AsyncArgus() as argus:
|
|
106
|
+
namespace = await argus.namespaces.get(namespace_id)
|
|
107
|
+
page = await argus.pages.add(namespace.id, Path("about.md"))
|
|
108
|
+
async for img in argus.images.list_all(namespace.id):
|
|
109
|
+
...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Pages and images, in detail
|
|
113
|
+
|
|
114
|
+
- `pages.add(namespace_id, content, *, type=None, page_url=None, page_title=None, markdown=None)`
|
|
115
|
+
`content` is a `Path` (type read from `.md`/`.html`), or the page itself as `str` or
|
|
116
|
+
`bytes` with `type="md"` or `type="html"`. **A `str` is always page text, never a file
|
|
117
|
+
name.** Send `page_url` whenever you have it: it resolves relative image links and tells
|
|
118
|
+
the describer where each image appears. For HTML, `markdown` (the same page converted to
|
|
119
|
+
markdown, if your scraper has it) improves captions. A page is identified by its bytes:
|
|
120
|
+
sending the same bytes again returns the first ingestion with `duplicate=True`, and any
|
|
121
|
+
new `page_url`, `page_title` or `markdown` is ignored.
|
|
122
|
+
- `images.add(namespace_id, *, file=None, url=None, alt=None, caption=None, page_url=None)`
|
|
123
|
+
Exactly one of `file` (a `Path`, `bytes` or a file opened `"rb"`) or `url`.
|
|
124
|
+
- An ingestion is `complete` when the page is parsed (or failed) and every image on it is
|
|
125
|
+
searchable or failed. Complete is not success: check `status`, `error` and
|
|
126
|
+
`images.failed`.
|
|
127
|
+
- Signed image links (`url`, `thumbnail_url`) expire after an hour. Store `media_id`, not
|
|
128
|
+
the link.
|
|
129
|
+
|
|
130
|
+
## Errors
|
|
131
|
+
|
|
132
|
+
Every runtime error is an `ArgusError`. Bad arguments raise `ValueError` before any
|
|
133
|
+
request is sent.
|
|
134
|
+
|
|
135
|
+
| Exception | When |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `APIConnectionError` / `APITimeoutError` | No answer from Argus (after retries) |
|
|
138
|
+
| `BadRequestError` (400) | Request refused, e.g. a search filter Argus can't apply |
|
|
139
|
+
| `AuthenticationError` (401) | API key missing, wrong or revoked |
|
|
140
|
+
| `PermissionDeniedError` (403) | Key lacks the scope, or the namespace is another tenant's |
|
|
141
|
+
| `NotFoundError` (404) | No such namespace or ingestion |
|
|
142
|
+
| `ConflictError` (409) | Slug taken with different fields (`.namespace_id`), or nothing searchable yet |
|
|
143
|
+
| `UnprocessableEntityError` (422) | A field has the wrong shape |
|
|
144
|
+
| `RateLimitError` (429) / `InternalServerError` (5xx) | After retries |
|
|
145
|
+
| `UploadError` | S3 refused the file |
|
|
146
|
+
| `APIResponseValidationError` | A 2xx body the SDK can't read (a proxy page, or a much newer server) |
|
|
147
|
+
| `IngestionTimeoutError` | `ingestions.wait()` ran out of time (`.ingestion` has the last status) |
|
|
148
|
+
|
|
149
|
+
Every `APIStatusError` has `status_code`, `detail` (the server's message, or a list of
|
|
150
|
+
field errors) and `body`.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
try:
|
|
154
|
+
argus.namespaces.create(slug="acme-dental", name="Acme")
|
|
155
|
+
except ConflictError as exc:
|
|
156
|
+
namespace = argus.namespaces.get(exc.namespace_id)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Retries
|
|
160
|
+
|
|
161
|
+
Writes are safe to repeat: namespaces are keyed by slug, pages by their content hash,
|
|
162
|
+
images by their source and context. So every call except search is retried on connection
|
|
163
|
+
errors, timeouts, 429, 502, 503 and 504, with exponential backoff and jitter
|
|
164
|
+
(`max_retries=2` by default; `0` turns retries off).
|
|
165
|
+
|
|
166
|
+
A search runs paid model calls each time, so it is retried only when it certainly never
|
|
167
|
+
reached Argus (a refused connection, or 429). A search that times out raises
|
|
168
|
+
`APITimeoutError` instead of running again; raise `timeout` if accurate searches on large
|
|
169
|
+
namespaces need longer. `ingestions.wait()` keeps polling through connection errors and
|
|
170
|
+
5xx until its own `timeout`.
|
|
171
|
+
|
|
172
|
+
Retries are logged at INFO on the `gushwork_argus` logger.
|
|
173
|
+
|
|
174
|
+
S3 uploads go through a separate HTTP client that never carries the API key. It does not
|
|
175
|
+
use a custom `http_client`; like any httpx client it reads `HTTPS_PROXY` and
|
|
176
|
+
`SSL_CERT_FILE` from the environment.
|
|
177
|
+
|
|
178
|
+
## API keys and scopes
|
|
179
|
+
|
|
180
|
+
| Scope | Can |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `admin` | create and edit namespaces, read anything |
|
|
183
|
+
| `ingest` | add pages and images, check ingestions |
|
|
184
|
+
| `retrieve` | search |
|
|
185
|
+
|
|
186
|
+
Any key can read namespaces and list images. A key bound to a namespace sees only that
|
|
187
|
+
namespace.
|
|
188
|
+
|
|
189
|
+
## Develop
|
|
190
|
+
|
|
191
|
+
See [CLAUDE.md](CLAUDE.md) for the layout, conventions and the release process.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
cd sdk/python
|
|
195
|
+
uv sync
|
|
196
|
+
uv run pytest
|
|
197
|
+
uv run ruff check . && uv run ruff format --check . && uv run pyright
|
|
198
|
+
```
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# gushwork-argus
|
|
2
|
+
|
|
3
|
+
Python client for **Argus**, Gushwork's image ingestion and search API. You give Argus
|
|
4
|
+
pages and images; it finds every image, describes it, and makes it searchable by meaning.
|
|
5
|
+
|
|
6
|
+
- One client, two settings: an API key and the API host.
|
|
7
|
+
- `Argus` (blocking) and `AsyncArgus` (asyncio) with the same methods.
|
|
8
|
+
- Typed responses (Pydantic v2), typed errors, retries with backoff.
|
|
9
|
+
- Python 3.10+. Depends only on `httpx` and `pydantic`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install gushwork-argus
|
|
15
|
+
# or
|
|
16
|
+
uv add gushwork-argus
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Pin a version in services (`gushwork-argus==0.1.0`); see [CHANGELOG.md](CHANGELOG.md).
|
|
20
|
+
|
|
21
|
+
## Configure
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from gushwork_argus import Argus
|
|
25
|
+
|
|
26
|
+
argus = Argus(api_key="...", base_url="https://<argus host>")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or set `ARGUS_API_KEY` and `ARGUS_BASE_URL` and call `Argus()`. The Argus team gives you
|
|
30
|
+
both: a key with the scopes your service needs, and the host for each environment.
|
|
31
|
+
|
|
32
|
+
Optional: `timeout=30.0` (seconds per request), `max_retries=2`.
|
|
33
|
+
Create one client per process and reuse it; it keeps a connection pool. Close it with
|
|
34
|
+
`argus.close()` or use `with Argus(...) as argus:`.
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from gushwork_argus import Argus, ConflictError
|
|
41
|
+
|
|
42
|
+
argus = Argus()
|
|
43
|
+
|
|
44
|
+
# 1. Create a namespace (admin key). Safe to retry with the same fields.
|
|
45
|
+
namespace = argus.namespaces.create(
|
|
46
|
+
slug="acme-dental", name="Acme Dental", site_name="Acme Dental", domain="acme.com"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# 2. Get it back, by id or slug
|
|
50
|
+
namespace = argus.namespaces.get(namespace.id)
|
|
51
|
+
namespace = argus.namespaces.find_by_slug("acme-dental") # None when there is none
|
|
52
|
+
|
|
53
|
+
# 3. Add a page or an image (ingest scope). Files go to S3 through a presigned upload.
|
|
54
|
+
page = argus.pages.add(namespace.id, Path("about.md"), page_url="https://acme.com/about")
|
|
55
|
+
page = argus.pages.add(namespace.id, html_text, type="html", page_url="https://acme.com/")
|
|
56
|
+
image = argus.images.add(namespace.id, file=Path("crown.jpg"), alt="Dentist fitting a crown")
|
|
57
|
+
image = argus.images.add(namespace.id, url="https://cdn.acme.com/crown.jpg") # no upload
|
|
58
|
+
|
|
59
|
+
# 7. Check an ingestion, or wait until it is complete
|
|
60
|
+
status = argus.ingestions.get(page.ingestion_id)
|
|
61
|
+
status = argus.ingestions.wait(page.ingestion_id, timeout=600)
|
|
62
|
+
print(status.status, status.images.searchable, "of", status.images.total, "searchable")
|
|
63
|
+
|
|
64
|
+
# 4. Search (retrieve scope)
|
|
65
|
+
response = argus.images.search(
|
|
66
|
+
namespace.id, "dentist fitting a crown", top_k=5, filters={"orientation": "landscape"}
|
|
67
|
+
)
|
|
68
|
+
for result in response.results:
|
|
69
|
+
print(result.rank, result.match, result.image.url, result.description)
|
|
70
|
+
|
|
71
|
+
# 5. List namespaces: one page, or all of them
|
|
72
|
+
page_of_namespaces = argus.namespaces.list(limit=50) # CursorPage: .items, .next_cursor
|
|
73
|
+
for ns in argus.namespaces.list_all():
|
|
74
|
+
print(ns.slug)
|
|
75
|
+
|
|
76
|
+
# 6. List images, newest first
|
|
77
|
+
for img in argus.images.list_all(namespace.id, status="RETRIEVAL_READY"):
|
|
78
|
+
print(img.media_id, img.description, img.file.url if img.file else None)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Async
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from gushwork_argus import AsyncArgus
|
|
85
|
+
|
|
86
|
+
async with AsyncArgus() as argus:
|
|
87
|
+
namespace = await argus.namespaces.get(namespace_id)
|
|
88
|
+
page = await argus.pages.add(namespace.id, Path("about.md"))
|
|
89
|
+
async for img in argus.images.list_all(namespace.id):
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Pages and images, in detail
|
|
94
|
+
|
|
95
|
+
- `pages.add(namespace_id, content, *, type=None, page_url=None, page_title=None, markdown=None)`
|
|
96
|
+
`content` is a `Path` (type read from `.md`/`.html`), or the page itself as `str` or
|
|
97
|
+
`bytes` with `type="md"` or `type="html"`. **A `str` is always page text, never a file
|
|
98
|
+
name.** Send `page_url` whenever you have it: it resolves relative image links and tells
|
|
99
|
+
the describer where each image appears. For HTML, `markdown` (the same page converted to
|
|
100
|
+
markdown, if your scraper has it) improves captions. A page is identified by its bytes:
|
|
101
|
+
sending the same bytes again returns the first ingestion with `duplicate=True`, and any
|
|
102
|
+
new `page_url`, `page_title` or `markdown` is ignored.
|
|
103
|
+
- `images.add(namespace_id, *, file=None, url=None, alt=None, caption=None, page_url=None)`
|
|
104
|
+
Exactly one of `file` (a `Path`, `bytes` or a file opened `"rb"`) or `url`.
|
|
105
|
+
- An ingestion is `complete` when the page is parsed (or failed) and every image on it is
|
|
106
|
+
searchable or failed. Complete is not success: check `status`, `error` and
|
|
107
|
+
`images.failed`.
|
|
108
|
+
- Signed image links (`url`, `thumbnail_url`) expire after an hour. Store `media_id`, not
|
|
109
|
+
the link.
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
Every runtime error is an `ArgusError`. Bad arguments raise `ValueError` before any
|
|
114
|
+
request is sent.
|
|
115
|
+
|
|
116
|
+
| Exception | When |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `APIConnectionError` / `APITimeoutError` | No answer from Argus (after retries) |
|
|
119
|
+
| `BadRequestError` (400) | Request refused, e.g. a search filter Argus can't apply |
|
|
120
|
+
| `AuthenticationError` (401) | API key missing, wrong or revoked |
|
|
121
|
+
| `PermissionDeniedError` (403) | Key lacks the scope, or the namespace is another tenant's |
|
|
122
|
+
| `NotFoundError` (404) | No such namespace or ingestion |
|
|
123
|
+
| `ConflictError` (409) | Slug taken with different fields (`.namespace_id`), or nothing searchable yet |
|
|
124
|
+
| `UnprocessableEntityError` (422) | A field has the wrong shape |
|
|
125
|
+
| `RateLimitError` (429) / `InternalServerError` (5xx) | After retries |
|
|
126
|
+
| `UploadError` | S3 refused the file |
|
|
127
|
+
| `APIResponseValidationError` | A 2xx body the SDK can't read (a proxy page, or a much newer server) |
|
|
128
|
+
| `IngestionTimeoutError` | `ingestions.wait()` ran out of time (`.ingestion` has the last status) |
|
|
129
|
+
|
|
130
|
+
Every `APIStatusError` has `status_code`, `detail` (the server's message, or a list of
|
|
131
|
+
field errors) and `body`.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
try:
|
|
135
|
+
argus.namespaces.create(slug="acme-dental", name="Acme")
|
|
136
|
+
except ConflictError as exc:
|
|
137
|
+
namespace = argus.namespaces.get(exc.namespace_id)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Retries
|
|
141
|
+
|
|
142
|
+
Writes are safe to repeat: namespaces are keyed by slug, pages by their content hash,
|
|
143
|
+
images by their source and context. So every call except search is retried on connection
|
|
144
|
+
errors, timeouts, 429, 502, 503 and 504, with exponential backoff and jitter
|
|
145
|
+
(`max_retries=2` by default; `0` turns retries off).
|
|
146
|
+
|
|
147
|
+
A search runs paid model calls each time, so it is retried only when it certainly never
|
|
148
|
+
reached Argus (a refused connection, or 429). A search that times out raises
|
|
149
|
+
`APITimeoutError` instead of running again; raise `timeout` if accurate searches on large
|
|
150
|
+
namespaces need longer. `ingestions.wait()` keeps polling through connection errors and
|
|
151
|
+
5xx until its own `timeout`.
|
|
152
|
+
|
|
153
|
+
Retries are logged at INFO on the `gushwork_argus` logger.
|
|
154
|
+
|
|
155
|
+
S3 uploads go through a separate HTTP client that never carries the API key. It does not
|
|
156
|
+
use a custom `http_client`; like any httpx client it reads `HTTPS_PROXY` and
|
|
157
|
+
`SSL_CERT_FILE` from the environment.
|
|
158
|
+
|
|
159
|
+
## API keys and scopes
|
|
160
|
+
|
|
161
|
+
| Scope | Can |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `admin` | create and edit namespaces, read anything |
|
|
164
|
+
| `ingest` | add pages and images, check ingestions |
|
|
165
|
+
| `retrieve` | search |
|
|
166
|
+
|
|
167
|
+
Any key can read namespaces and list images. A key bound to a namespace sees only that
|
|
168
|
+
namespace.
|
|
169
|
+
|
|
170
|
+
## Develop
|
|
171
|
+
|
|
172
|
+
See [CLAUDE.md](CLAUDE.md) for the layout, conventions and the release process.
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
cd sdk/python
|
|
176
|
+
uv sync
|
|
177
|
+
uv run pytest
|
|
178
|
+
uv run ruff check . && uv run ruff format --check . && uv run pyright
|
|
179
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gushwork-argus"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python client for Argus: Gushwork's image ingestion and search API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Gushwork" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27,<1",
|
|
15
|
+
"pydantic>=2.6,<3",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version]
|
|
28
|
+
path = "src/gushwork_argus/_version.py"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/gushwork_argus"]
|
|
32
|
+
|
|
33
|
+
# What PyPI gets as the source distribution: the package, not the tests or contributor notes.
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src/gushwork_argus", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"pyright>=1.1.390",
|
|
40
|
+
"pytest>=8.3",
|
|
41
|
+
"pytest-asyncio>=0.24",
|
|
42
|
+
"respx>=0.22",
|
|
43
|
+
"ruff>=0.8",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py310"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
56
|
+
|
|
57
|
+
[tool.pyright]
|
|
58
|
+
include = ["src"]
|
|
59
|
+
pythonVersion = "3.10"
|
|
60
|
+
typeCheckingMode = "strict"
|
|
@@ -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()
|