adss 1.32__py3-none-any.whl → 1.34__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.
- adss/auth.py +395 -27
- adss/client.py +18 -0
- adss/endpoints/images.py +94 -57
- adss/endpoints/queries.py +2 -2
- adss/utils.py +1 -1
- {adss-1.32.dist-info → adss-1.34.dist-info}/METADATA +110 -2
- {adss-1.32.dist-info → adss-1.34.dist-info}/RECORD +10 -10
- {adss-1.32.dist-info → adss-1.34.dist-info}/WHEEL +0 -0
- {adss-1.32.dist-info → adss-1.34.dist-info}/licenses/LICENSE +0 -0
- {adss-1.32.dist-info → adss-1.34.dist-info}/top_level.txt +0 -0
adss/auth.py
CHANGED
@@ -1,11 +1,176 @@
|
|
1
|
-
import
|
1
|
+
import os
|
2
|
+
import time
|
2
3
|
from typing import Dict, Optional, Tuple
|
3
4
|
|
5
|
+
import requests # kept for compatibility (exceptions/type expectations)
|
6
|
+
import httpx
|
7
|
+
|
4
8
|
from adss.exceptions import AuthenticationError
|
5
9
|
from adss.utils import handle_response_errors
|
6
10
|
from adss.models.user import User
|
7
11
|
|
8
|
-
|
12
|
+
|
13
|
+
# --- internal defaults (safe timeouts; env-overridable) ---
|
14
|
+
_CONNECT_TIMEOUT = float(os.getenv("ADSS_CONNECT_TIMEOUT", "5"))
|
15
|
+
_READ_TIMEOUT = float(os.getenv("ADSS_READ_TIMEOUT", "600"))
|
16
|
+
_DEFAULT_TIMEOUT = (_CONNECT_TIMEOUT, _READ_TIMEOUT)
|
17
|
+
|
18
|
+
_TOTAL_RETRIES = int(os.getenv("ADSS_RETRY_TOTAL", "3"))
|
19
|
+
_BACKOFF_FACTOR = float(os.getenv("ADSS_RETRY_BACKOFF", "0.5"))
|
20
|
+
_TRUST_ENV = os.getenv("ADSS_TRUST_ENV", "1").lower() not in ("0", "false", "no")
|
21
|
+
_FORCE_CLOSE_STREAMS = os.getenv("ADSS_FORCE_CLOSE_STREAMS", "0").lower() in ("1", "true", "yes")
|
22
|
+
|
23
|
+
def _read_all_bytes(resp: httpx.Response,
|
24
|
+
chunk_size: int = 1024 * 1024,
|
25
|
+
total_timeout: Optional[float] = None) -> bytes:
|
26
|
+
"""
|
27
|
+
Stream the response body to memory and return bytes.
|
28
|
+
- Respects httpx read timeout between chunks (via iter_bytes()).
|
29
|
+
- Optionally enforces an overall time budget (total_timeout).
|
30
|
+
- Validates Content-Length when present.
|
31
|
+
- Always closes the response.
|
32
|
+
"""
|
33
|
+
import io, time
|
34
|
+
from httpx import ReadTimeout, RemoteProtocolError, TransportError
|
35
|
+
|
36
|
+
# If httpx has already cached content, return it.
|
37
|
+
if hasattr(resp, "_content"):
|
38
|
+
return resp._content # type: ignore[attr-defined]
|
39
|
+
|
40
|
+
buf = io.BytesIO()
|
41
|
+
bytes_read = 0
|
42
|
+
start = time.monotonic()
|
43
|
+
|
44
|
+
expected = None
|
45
|
+
cl = resp.headers.get("Content-Length")
|
46
|
+
if cl:
|
47
|
+
try:
|
48
|
+
expected = int(cl)
|
49
|
+
except ValueError:
|
50
|
+
expected = None
|
51
|
+
|
52
|
+
try:
|
53
|
+
for chunk in resp.iter_bytes(chunk_size=chunk_size):
|
54
|
+
if not chunk:
|
55
|
+
break
|
56
|
+
buf.write(chunk)
|
57
|
+
bytes_read += len(chunk)
|
58
|
+
if total_timeout is not None and (time.monotonic() - start) > total_timeout:
|
59
|
+
raise ReadTimeout("overall read timeout exceeded")
|
60
|
+
except (ReadTimeout, RemoteProtocolError, TransportError):
|
61
|
+
# ensure socket cleanup before propagating
|
62
|
+
try:
|
63
|
+
resp.close()
|
64
|
+
finally:
|
65
|
+
raise
|
66
|
+
|
67
|
+
data = buf.getvalue()
|
68
|
+
if expected is not None and bytes_read != expected:
|
69
|
+
try:
|
70
|
+
resp.close()
|
71
|
+
finally:
|
72
|
+
raise RemoteProtocolError(
|
73
|
+
f"Incomplete body: got {bytes_read} bytes, expected {expected}"
|
74
|
+
)
|
75
|
+
|
76
|
+
# cache like httpx does; then close
|
77
|
+
resp._content = data # type: ignore[attr-defined]
|
78
|
+
resp.close()
|
79
|
+
return data
|
80
|
+
|
81
|
+
def _to_httpx_timeout(t):
|
82
|
+
"""Map (connect, read) tuple or scalar into httpx.Timeout."""
|
83
|
+
if isinstance(t, tuple) and len(t) == 2:
|
84
|
+
connect, read = t
|
85
|
+
return httpx.Timeout(connect=connect, read=read, write=read, pool=connect)
|
86
|
+
if isinstance(t, (int, float)):
|
87
|
+
return httpx.Timeout(t)
|
88
|
+
return httpx.Timeout(connect=_CONNECT_TIMEOUT, read=_READ_TIMEOUT, write=_READ_TIMEOUT, pool=_CONNECT_TIMEOUT)
|
89
|
+
|
90
|
+
|
91
|
+
def _attach_requests_compat(resp: httpx.Response):
|
92
|
+
"""
|
93
|
+
Give httpx.Response a requests-like surface and a SAFE .read():
|
94
|
+
- resp.iter_content(chunk_size) -> yields bytes
|
95
|
+
- resp.raw.read() -> returns remaining bytes
|
96
|
+
- resp.read() -> safe, streaming-based, idempotent
|
97
|
+
"""
|
98
|
+
import io, time
|
99
|
+
from httpx import ReadTimeout, RemoteProtocolError, TransportError
|
100
|
+
|
101
|
+
# requests-like streaming
|
102
|
+
if not hasattr(resp, "iter_content"):
|
103
|
+
def iter_content(chunk_size: int = 1024 * 1024):
|
104
|
+
return resp.iter_bytes(chunk_size=chunk_size)
|
105
|
+
setattr(resp, "iter_content", iter_content)
|
106
|
+
|
107
|
+
# requests-like raw.read()
|
108
|
+
if not hasattr(resp, "raw"):
|
109
|
+
class _RawAdapter:
|
110
|
+
def __init__(self, r: httpx.Response):
|
111
|
+
self._r = r
|
112
|
+
def read(self, amt: Optional[int] = None) -> bytes:
|
113
|
+
# Use the same safe read under the hood
|
114
|
+
return getattr(self._r, "read")( )
|
115
|
+
setattr(resp, "raw", _RawAdapter(resp))
|
116
|
+
|
117
|
+
# ---- SAFE .read(): stream to memory, cache, and close ----
|
118
|
+
# Only replace if httpx hasn't already cached content
|
119
|
+
def _safe_read(self, *, chunk_size: int = 1024 * 1024,
|
120
|
+
total_timeout: Optional[float] = None) -> bytes:
|
121
|
+
# If httpx already cached, return it (idempotent)
|
122
|
+
if hasattr(self, "_content"):
|
123
|
+
return self._content
|
124
|
+
|
125
|
+
buf = io.BytesIO()
|
126
|
+
bytes_read = 0
|
127
|
+
start = time.monotonic()
|
128
|
+
|
129
|
+
# If server provided length, we can validate
|
130
|
+
expected = None
|
131
|
+
cl = self.headers.get("Content-Length")
|
132
|
+
if cl:
|
133
|
+
try:
|
134
|
+
expected = int(cl)
|
135
|
+
except ValueError:
|
136
|
+
expected = None
|
137
|
+
|
138
|
+
try:
|
139
|
+
for chunk in self.iter_bytes(chunk_size=chunk_size):
|
140
|
+
if not chunk:
|
141
|
+
break
|
142
|
+
buf.write(chunk)
|
143
|
+
bytes_read += len(chunk)
|
144
|
+
if total_timeout is not None and (time.monotonic() - start) > total_timeout:
|
145
|
+
raise ReadTimeout("overall read timeout exceeded")
|
146
|
+
except (ReadTimeout, RemoteProtocolError, TransportError) as e:
|
147
|
+
# Ensure the socket is cleaned up
|
148
|
+
try:
|
149
|
+
self.close()
|
150
|
+
finally:
|
151
|
+
raise
|
152
|
+
|
153
|
+
data = buf.getvalue()
|
154
|
+
# Validate length if known
|
155
|
+
if expected is not None and bytes_read != expected:
|
156
|
+
try:
|
157
|
+
self.close()
|
158
|
+
finally:
|
159
|
+
raise RemoteProtocolError(
|
160
|
+
f"Incomplete body: got {bytes_read} bytes, expected {expected}"
|
161
|
+
)
|
162
|
+
|
163
|
+
# Cache like httpx normally does, then close the stream
|
164
|
+
self._content = data
|
165
|
+
self.close()
|
166
|
+
return data
|
167
|
+
|
168
|
+
# Bind as a method (so `resp.read()` calls _safe_read)
|
169
|
+
import types
|
170
|
+
resp.read = types.MethodType(_safe_read, resp) # type: ignore[attr-defined]
|
171
|
+
|
172
|
+
return resp
|
173
|
+
|
9
174
|
|
10
175
|
class Auth:
|
11
176
|
"""
|
@@ -18,6 +183,13 @@ class Auth:
|
|
18
183
|
self.current_user: Optional[User] = None
|
19
184
|
self.verify_ssl = verify_ssl
|
20
185
|
|
186
|
+
# Single keep-alive client; set verify at construction.
|
187
|
+
self._client = httpx.Client(
|
188
|
+
trust_env=_TRUST_ENV,
|
189
|
+
verify=self.verify_ssl,
|
190
|
+
limits=httpx.Limits(max_keepalive_connections=0, max_connections=10)
|
191
|
+
)
|
192
|
+
|
21
193
|
def login(self, username: str, password: str, **kwargs) -> Tuple[str, User]:
|
22
194
|
"""
|
23
195
|
Log in with username and password, obtaining an authentication token.
|
@@ -26,7 +198,6 @@ class Auth:
|
|
26
198
|
data = {"username": username, "password": password}
|
27
199
|
|
28
200
|
try:
|
29
|
-
# Use our own request() method here
|
30
201
|
response = self.request(
|
31
202
|
method="POST",
|
32
203
|
url=login_url,
|
@@ -41,12 +212,12 @@ class Auth:
|
|
41
212
|
if not self.token:
|
42
213
|
raise AuthenticationError("Login succeeded but no token returned")
|
43
214
|
|
44
|
-
# Now fetch user info (this will use auth_required=True internally)
|
45
215
|
self.current_user = self._get_current_user(**kwargs)
|
46
216
|
return self.token, self.current_user
|
47
217
|
|
48
|
-
except
|
49
|
-
|
218
|
+
except httpx.RequestError as e:
|
219
|
+
# preserve existing caller except-blocks that catch requests.RequestException
|
220
|
+
raise requests.RequestException(str(e)) # noqa: B904
|
50
221
|
|
51
222
|
def logout(self) -> None:
|
52
223
|
self.token = None
|
@@ -66,7 +237,6 @@ class Auth:
|
|
66
237
|
auth_headers = self._get_auth_headers()
|
67
238
|
|
68
239
|
try:
|
69
|
-
# Again, use request() so SSL and auth headers are applied consistently
|
70
240
|
response = self.request(
|
71
241
|
method="GET",
|
72
242
|
url=me_url,
|
@@ -79,8 +249,8 @@ class Auth:
|
|
79
249
|
user_data = response.json()
|
80
250
|
return User.from_dict(user_data)
|
81
251
|
|
82
|
-
except
|
83
|
-
raise
|
252
|
+
except httpx.RequestError as e:
|
253
|
+
raise requests.RequestException(str(e)) # noqa: B904
|
84
254
|
|
85
255
|
def _get_auth_headers(self) -> Dict[str, str]:
|
86
256
|
headers = {"Accept": "application/json"}
|
@@ -88,6 +258,99 @@ class Auth:
|
|
88
258
|
headers["Authorization"] = f"Bearer {self.token}"
|
89
259
|
return headers
|
90
260
|
|
261
|
+
# ---------------- core helpers ---------------- #
|
262
|
+
|
263
|
+
def _full_url(self, url: str) -> str:
|
264
|
+
return url if url.startswith(('http://', 'https://')) else f"{self.base_url}/{url.lstrip('/')}"
|
265
|
+
|
266
|
+
def _request_with_retries_nonstream(
|
267
|
+
self,
|
268
|
+
*,
|
269
|
+
method: str,
|
270
|
+
url: str,
|
271
|
+
headers: Dict[str, str],
|
272
|
+
params=None,
|
273
|
+
data=None,
|
274
|
+
json=None,
|
275
|
+
files=None,
|
276
|
+
timeout: httpx.Timeout,
|
277
|
+
follow_redirects: bool,
|
278
|
+
) -> httpx.Response:
|
279
|
+
last_exc = None
|
280
|
+
for attempt in range(_TOTAL_RETRIES + 1):
|
281
|
+
try:
|
282
|
+
return self._client.request(
|
283
|
+
method=method.upper(),
|
284
|
+
url=url,
|
285
|
+
headers=headers,
|
286
|
+
params=params,
|
287
|
+
data=data,
|
288
|
+
json=json,
|
289
|
+
files=files,
|
290
|
+
follow_redirects=follow_redirects,
|
291
|
+
timeout=timeout,
|
292
|
+
)
|
293
|
+
except httpx.RequestError as e:
|
294
|
+
last_exc = e
|
295
|
+
if attempt >= _TOTAL_RETRIES:
|
296
|
+
break
|
297
|
+
time.sleep(_BACKOFF_FACTOR * (2 ** attempt))
|
298
|
+
raise requests.RequestException(str(last_exc)) # noqa: B904
|
299
|
+
|
300
|
+
def _request_with_retries_stream(
|
301
|
+
self,
|
302
|
+
*,
|
303
|
+
method: str,
|
304
|
+
url: str,
|
305
|
+
headers: Dict[str, str],
|
306
|
+
params=None,
|
307
|
+
data=None,
|
308
|
+
json=None,
|
309
|
+
files=None,
|
310
|
+
timeout: httpx.Timeout,
|
311
|
+
follow_redirects: bool,
|
312
|
+
) -> httpx.Response:
|
313
|
+
"""
|
314
|
+
Use client.stream(...) but keep the stream open for the caller.
|
315
|
+
We manually __enter__ the context manager and override resp.close()
|
316
|
+
to ensure resources are cleaned when caller closes the response.
|
317
|
+
"""
|
318
|
+
last_exc = None
|
319
|
+
for attempt in range(_TOTAL_RETRIES + 1):
|
320
|
+
try:
|
321
|
+
cm = self._client.stream(
|
322
|
+
method=method.upper(),
|
323
|
+
url=url,
|
324
|
+
headers=headers,
|
325
|
+
params=params,
|
326
|
+
data=data,
|
327
|
+
json=json,
|
328
|
+
files=files,
|
329
|
+
follow_redirects=follow_redirects,
|
330
|
+
timeout=timeout,
|
331
|
+
)
|
332
|
+
resp = cm.__enter__() # don't exit: let caller iterate/close
|
333
|
+
# Make close() also exit the context manager safely
|
334
|
+
_orig_close = resp.close
|
335
|
+
def _close():
|
336
|
+
try:
|
337
|
+
_orig_close()
|
338
|
+
finally:
|
339
|
+
try:
|
340
|
+
cm.__exit__(None, None, None)
|
341
|
+
except Exception:
|
342
|
+
pass
|
343
|
+
resp.close = _close # type: ignore[attr-defined]
|
344
|
+
return resp
|
345
|
+
except httpx.RequestError as e:
|
346
|
+
last_exc = e
|
347
|
+
if attempt >= _TOTAL_RETRIES:
|
348
|
+
break
|
349
|
+
time.sleep(_BACKOFF_FACTOR * (2 ** attempt))
|
350
|
+
raise requests.RequestException(str(last_exc)) # noqa: B904
|
351
|
+
|
352
|
+
# ---------------- public API (unchanged signatures) ------------------- #
|
353
|
+
|
91
354
|
def request(
|
92
355
|
self,
|
93
356
|
method: str,
|
@@ -102,31 +365,64 @@ class Auth:
|
|
102
365
|
if auth_required and not self.is_authenticated():
|
103
366
|
raise AuthenticationError("Authentication required for this request")
|
104
367
|
|
105
|
-
|
106
|
-
if not url.startswith(('http://', 'https://')):
|
107
|
-
url = f"{self.base_url}/{url.lstrip('/')}"
|
368
|
+
url = self._full_url(url)
|
108
369
|
|
109
370
|
# Merge headers
|
110
371
|
final_headers = self._get_auth_headers()
|
111
372
|
if headers:
|
112
373
|
final_headers.update(headers)
|
113
374
|
|
114
|
-
#
|
115
|
-
|
116
|
-
|
375
|
+
# Map requests-style kwargs to httpx
|
376
|
+
timeout = _to_httpx_timeout(kwargs.pop('timeout', _DEFAULT_TIMEOUT))
|
377
|
+
follow_redirects = kwargs.pop('allow_redirects', True)
|
378
|
+
stream_flag = bool(kwargs.pop('stream', False))
|
379
|
+
|
380
|
+
# (verify is fixed per-client at __init__; ignore/strip any incoming 'verify' kw)
|
381
|
+
kwargs.pop('verify', None)
|
117
382
|
|
118
|
-
|
383
|
+
# Build payload pieces compatibly
|
384
|
+
params = kwargs.pop('params', None)
|
385
|
+
data = kwargs.pop('data', None)
|
386
|
+
json_ = kwargs.pop('json', None)
|
387
|
+
files = kwargs.pop('files', None)
|
388
|
+
|
389
|
+
if stream_flag:
|
390
|
+
resp = self._request_with_retries_stream(
|
391
|
+
method=method,
|
392
|
+
url=url,
|
393
|
+
headers=final_headers,
|
394
|
+
params=params,
|
395
|
+
data=data,
|
396
|
+
json=json_,
|
397
|
+
files=files,
|
398
|
+
timeout=timeout,
|
399
|
+
follow_redirects=follow_redirects,
|
400
|
+
)
|
401
|
+
else:
|
402
|
+
resp = self._request_with_retries_nonstream(
|
403
|
+
method=method,
|
404
|
+
url=url,
|
405
|
+
headers=final_headers,
|
406
|
+
params=params,
|
407
|
+
data=data,
|
408
|
+
json=json_,
|
409
|
+
files=files,
|
410
|
+
timeout=timeout,
|
411
|
+
follow_redirects=follow_redirects,
|
412
|
+
)
|
413
|
+
return _attach_requests_compat(resp)
|
119
414
|
|
120
415
|
def refresh_user_info(self, **kwargs) -> User:
|
121
416
|
self.current_user = self._get_current_user(**kwargs)
|
122
417
|
return self.current_user
|
123
|
-
|
418
|
+
|
124
419
|
def download(
|
125
420
|
self,
|
126
421
|
method: str,
|
127
422
|
url: str,
|
128
423
|
headers: Optional[Dict[str, str]] = None,
|
129
424
|
auth_required: bool = False,
|
425
|
+
timeout: Optional[float] = None,
|
130
426
|
**kwargs
|
131
427
|
) -> requests.Response:
|
132
428
|
"""
|
@@ -140,23 +436,95 @@ class Auth:
|
|
140
436
|
if auth_required and not self.is_authenticated():
|
141
437
|
raise AuthenticationError("Authentication required for this request")
|
142
438
|
|
143
|
-
|
144
|
-
if not url.startswith(('http://', 'https://')):
|
145
|
-
url = f"{self.base_url}/{url.lstrip('/')}"
|
439
|
+
url = self._full_url(url)
|
146
440
|
|
147
441
|
# Merge headers
|
148
442
|
final_headers = self._get_auth_headers()
|
149
443
|
if headers:
|
150
444
|
final_headers.update(headers)
|
445
|
+
if _FORCE_CLOSE_STREAMS:
|
446
|
+
final_headers.setdefault("Connection", "close")
|
151
447
|
|
152
|
-
|
153
|
-
|
154
|
-
|
448
|
+
if timeout is None:
|
449
|
+
timeout = _to_httpx_timeout(kwargs.pop('timeout', _DEFAULT_TIMEOUT))
|
450
|
+
else:
|
451
|
+
timeout = _to_httpx_timeout(timeout)
|
452
|
+
|
453
|
+
follow_redirects = kwargs.pop('allow_redirects', True)
|
454
|
+
kwargs.pop('verify', None) # verify is fixed on client
|
155
455
|
|
156
|
-
|
157
|
-
kwargs
|
456
|
+
params = kwargs.pop('params', None)
|
457
|
+
data = kwargs.pop('data', None)
|
458
|
+
json_ = kwargs.pop('json', None)
|
459
|
+
files = kwargs.pop('files', None)
|
158
460
|
|
159
|
-
resp =
|
461
|
+
resp = self._request_with_retries_stream(
|
462
|
+
method=method,
|
463
|
+
url=url,
|
464
|
+
headers=final_headers,
|
465
|
+
params=params,
|
466
|
+
data=data,
|
467
|
+
json=json_,
|
468
|
+
files=files,
|
469
|
+
timeout=timeout,
|
470
|
+
follow_redirects=follow_redirects,
|
471
|
+
)
|
160
472
|
handle_response_errors(resp) # fail fast on HTTP errors
|
473
|
+
return _attach_requests_compat(resp)
|
474
|
+
|
475
|
+
def download_bytes(
|
476
|
+
self,
|
477
|
+
method: str,
|
478
|
+
url: str,
|
479
|
+
headers: Optional[Dict[str, str]] = None,
|
480
|
+
auth_required: bool = False,
|
481
|
+
timeout: Optional[float | Tuple[float, float]] = None,
|
482
|
+
total_timeout: Optional[float] = None,
|
483
|
+
**kwargs
|
484
|
+
) -> bytes:
|
485
|
+
"""
|
486
|
+
Stream a large body and return bytes.
|
487
|
+
Safe replacement for patterns that do `resp = download(...); resp.read()`.
|
488
|
+
"""
|
489
|
+
if auth_required and not self.is_authenticated():
|
490
|
+
raise AuthenticationError("Authentication required for this request")
|
491
|
+
|
492
|
+
url = self._full_url(url)
|
493
|
+
|
494
|
+
final_headers = self._get_auth_headers()
|
495
|
+
if headers:
|
496
|
+
final_headers.update(headers)
|
497
|
+
# avoid gzip surprises on binaries; optionally force close via env
|
498
|
+
final_headers.setdefault("Accept-Encoding", "identity")
|
499
|
+
if _FORCE_CLOSE_STREAMS:
|
500
|
+
final_headers.setdefault("Connection", "close")
|
501
|
+
|
502
|
+
# timeouts
|
503
|
+
if timeout is None:
|
504
|
+
timeout = _to_httpx_timeout(_DEFAULT_TIMEOUT)
|
505
|
+
else:
|
506
|
+
timeout = _to_httpx_timeout(timeout)
|
507
|
+
follow_redirects = kwargs.pop('allow_redirects', True)
|
508
|
+
kwargs.pop('verify', None)
|
509
|
+
|
510
|
+
params = kwargs.pop('params', None)
|
511
|
+
data = kwargs.pop('data', None)
|
512
|
+
json_ = kwargs.pop('json', None)
|
513
|
+
files = kwargs.pop('files', None)
|
514
|
+
|
515
|
+
# open the stream
|
516
|
+
resp = self._request_with_retries_stream(
|
517
|
+
method=method,
|
518
|
+
url=url,
|
519
|
+
headers=final_headers,
|
520
|
+
params=params,
|
521
|
+
data=data,
|
522
|
+
json=json_,
|
523
|
+
files=files,
|
524
|
+
timeout=timeout,
|
525
|
+
follow_redirects=follow_redirects,
|
526
|
+
)
|
527
|
+
handle_response_errors(resp) # raise for HTTP errors
|
161
528
|
|
162
|
-
return
|
529
|
+
# read it all safely and return
|
530
|
+
return _read_all_bytes(resp, total_timeout=total_timeout)
|
adss/client.py
CHANGED
@@ -294,6 +294,24 @@ class ADSSClient:
|
|
294
294
|
"""
|
295
295
|
return self.metadata.get_database_metadata(**kwargs)
|
296
296
|
|
297
|
+
def pretty_print_db_metadata(self, dbmeta: Optional[DatabaseMetadata] = None) -> None:
|
298
|
+
"""
|
299
|
+
Pretty print the database metadata in a hierarchical format.
|
300
|
+
|
301
|
+
Args:
|
302
|
+
dbmeta: Optional DatabaseMetadata object. If not provided, fetches current metadata.
|
303
|
+
"""
|
304
|
+
if dbmeta is None:
|
305
|
+
dbmeta = self.get_database_metadata()
|
306
|
+
|
307
|
+
for schema in dbmeta.schemas:
|
308
|
+
print(f"Schema: {schema.name}")
|
309
|
+
for table in schema.tables:
|
310
|
+
print(f" Table: {table.name}")
|
311
|
+
for column in table.columns:
|
312
|
+
nullable = "NULL" if column.is_nullable else "NOT NULL"
|
313
|
+
print(f" Column: {column.name} ({column.data_type}, {nullable})")
|
314
|
+
|
297
315
|
def update_profile(self,
|
298
316
|
email: Optional[str] = None,
|
299
317
|
full_name: Optional[str] = None,
|
adss/endpoints/images.py
CHANGED
@@ -7,6 +7,7 @@ import os
|
|
7
7
|
from adss.exceptions import ResourceNotFoundError
|
8
8
|
from adss.utils import handle_response_errors
|
9
9
|
|
10
|
+
import re
|
10
11
|
|
11
12
|
class ImagesEndpoint:
|
12
13
|
"""
|
@@ -160,7 +161,7 @@ class ImagesEndpoint:
|
|
160
161
|
for chunk in resp.iter_content(8192):
|
161
162
|
f.write(chunk)
|
162
163
|
return output_path
|
163
|
-
return resp.
|
164
|
+
return resp.read()
|
164
165
|
except Exception as e:
|
165
166
|
raise ResourceNotFoundError(f"Failed to download image file {file_id}: {e}")
|
166
167
|
|
@@ -220,9 +221,9 @@ class LuptonImagesEndpoint:
|
|
220
221
|
output_path = os.path.join(output_path, filename)
|
221
222
|
if output_path:
|
222
223
|
with open(output_path, 'wb') as f:
|
223
|
-
f.write(resp.
|
224
|
-
return resp.
|
225
|
-
return resp.
|
224
|
+
f.write(resp.read())
|
225
|
+
return resp.read()
|
226
|
+
return resp.read()
|
226
227
|
|
227
228
|
except Exception as e:
|
228
229
|
raise ResourceNotFoundError(f"Failed to create RGB image: {e}")
|
@@ -293,9 +294,9 @@ class LuptonImagesEndpoint:
|
|
293
294
|
output_path = os.path.join(output_path, filename)
|
294
295
|
if output_path:
|
295
296
|
with open(output_path, 'wb') as f:
|
296
|
-
f.write(resp.
|
297
|
-
return resp.
|
298
|
-
return resp.
|
297
|
+
f.write(resp.read())
|
298
|
+
return resp.read()
|
299
|
+
return resp.read()
|
299
300
|
|
300
301
|
except Exception as e:
|
301
302
|
raise ResourceNotFoundError(f"Failed to create RGB image by filenames: {e}")
|
@@ -340,9 +341,9 @@ class LuptonImagesEndpoint:
|
|
340
341
|
output_path = os.path.join(output_path, filename)
|
341
342
|
if output_path:
|
342
343
|
with open(output_path, 'wb') as f:
|
343
|
-
f.write(resp.
|
344
|
-
return resp.
|
345
|
-
return resp.
|
344
|
+
f.write(resp.read())
|
345
|
+
return resp.read()
|
346
|
+
return resp.read()
|
346
347
|
|
347
348
|
except Exception as e:
|
348
349
|
raise ResourceNotFoundError(f"Failed to create RGB image by coordinates: {e}")
|
@@ -395,9 +396,9 @@ class LuptonImagesEndpoint:
|
|
395
396
|
output_path = os.path.join(output_path, filename)
|
396
397
|
if output_path:
|
397
398
|
with open(output_path, 'wb') as f:
|
398
|
-
f.write(resp.
|
399
|
-
return resp.
|
400
|
-
return resp.
|
399
|
+
f.write(resp.read())
|
400
|
+
return resp.read()
|
401
|
+
return resp.read()
|
401
402
|
|
402
403
|
except Exception as e:
|
403
404
|
raise ResourceNotFoundError(f"Failed to create RGB image by object: {e}")
|
@@ -451,9 +452,9 @@ class StampImagesEndpoint:
|
|
451
452
|
output_path = os.path.join(output_path, filename)
|
452
453
|
if output_path:
|
453
454
|
with open(output_path, 'wb') as f:
|
454
|
-
f.write(resp.
|
455
|
-
return resp.
|
456
|
-
return resp.
|
455
|
+
f.write(resp.read())
|
456
|
+
return resp.read()
|
457
|
+
return resp.read()
|
457
458
|
|
458
459
|
except Exception as e:
|
459
460
|
raise ResourceNotFoundError(f"Failed to create stamp from file {file_id}: {e}")
|
@@ -516,9 +517,9 @@ class StampImagesEndpoint:
|
|
516
517
|
output_path = os.path.join(output_path, filename)
|
517
518
|
if output_path:
|
518
519
|
with open(output_path, 'wb') as f:
|
519
|
-
f.write(resp.
|
520
|
-
return resp.
|
521
|
-
return resp.
|
520
|
+
f.write(resp.read())
|
521
|
+
return resp.read()
|
522
|
+
return resp.read()
|
522
523
|
|
523
524
|
except Exception as e:
|
524
525
|
raise ResourceNotFoundError(f"Failed to create stamp from file {filename}: {e}")
|
@@ -566,31 +567,49 @@ class StampImagesEndpoint:
|
|
566
567
|
output_path = os.path.join(output_path, filename)
|
567
568
|
if output_path:
|
568
569
|
with open(output_path, 'wb') as f:
|
569
|
-
f.write(resp.
|
570
|
-
return resp.
|
571
|
-
return resp.
|
570
|
+
f.write(resp.read())
|
571
|
+
return resp.read()
|
572
|
+
return resp.read()
|
572
573
|
|
573
574
|
except Exception as e:
|
574
575
|
raise ResourceNotFoundError(f"Failed to create stamp by coordinates: {e}")
|
575
576
|
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
577
|
+
# TODO: Apply the same pattern of this functions to all download functions in this file
|
578
|
+
def create_stamp_by_object(
|
579
|
+
self,
|
580
|
+
collection_id: int,
|
581
|
+
object_name: str,
|
582
|
+
filter_name: str,
|
583
|
+
ra: float,
|
584
|
+
dec: float,
|
585
|
+
size: float,
|
586
|
+
size_unit: str = "arcmin",
|
587
|
+
format: str = "fits",
|
588
|
+
zmin: Optional[float] = None,
|
589
|
+
zmax: Optional[float] = None,
|
590
|
+
pattern: Optional[str] = None,
|
591
|
+
output_path: Optional[str] = None,
|
592
|
+
**kwargs
|
593
|
+
) -> Union[bytes, str]:
|
584
594
|
url = f"{self.base_url}/adss/v1/images/collections/{collection_id}/stamp_by_object"
|
595
|
+
|
596
|
+
# Build headers (auth if available), prefer identity for big binaries
|
585
597
|
try:
|
586
598
|
headers = self.auth_manager._get_auth_headers()
|
587
|
-
except:
|
588
|
-
headers = {
|
599
|
+
except Exception:
|
600
|
+
headers = {}
|
601
|
+
headers.setdefault("Accept", "image/png" if format == "png" else "application/fits")
|
602
|
+
headers.setdefault("Accept-Encoding", "identity")
|
589
603
|
|
604
|
+
# Payload
|
590
605
|
payload: Dict[str, Any] = {
|
591
|
-
"object_name": object_name,
|
592
|
-
"
|
593
|
-
"
|
606
|
+
"object_name": object_name,
|
607
|
+
"filter_name": filter_name,
|
608
|
+
"ra": ra,
|
609
|
+
"dec": dec,
|
610
|
+
"size": size,
|
611
|
+
"size_unit": size_unit,
|
612
|
+
"format": format,
|
594
613
|
}
|
595
614
|
if zmin is not None:
|
596
615
|
payload["zmin"] = zmin
|
@@ -599,8 +618,9 @@ class StampImagesEndpoint:
|
|
599
618
|
if pattern:
|
600
619
|
payload["pattern"] = pattern
|
601
620
|
|
621
|
+
# Download bytes in one go (no Response object leaked to callers)
|
602
622
|
try:
|
603
|
-
|
623
|
+
data = self.auth_manager.download_bytes(
|
604
624
|
method="POST",
|
605
625
|
url=url,
|
606
626
|
headers=headers,
|
@@ -608,22 +628,39 @@ class StampImagesEndpoint:
|
|
608
628
|
auth_required=False,
|
609
629
|
**kwargs
|
610
630
|
)
|
611
|
-
|
631
|
+
except Exception as e:
|
632
|
+
raise ResourceNotFoundError(f"Failed to create stamp by object: {e}")
|
612
633
|
|
613
|
-
|
614
|
-
|
615
|
-
|
634
|
+
# If no output_path => return bytes
|
635
|
+
if not output_path:
|
636
|
+
return data
|
616
637
|
|
617
|
-
|
618
|
-
|
619
|
-
if output_path:
|
620
|
-
with open(output_path, 'wb') as f:
|
621
|
-
f.write(resp.content)
|
622
|
-
return resp.content
|
623
|
-
return resp.content
|
638
|
+
# If writing to disk, synthesize a stable filename
|
639
|
+
ext = "fits" if format == "fits" else "png"
|
624
640
|
|
625
|
-
|
626
|
-
|
641
|
+
# sanitize components for filesystem safety
|
642
|
+
def _safe(s: str) -> str:
|
643
|
+
s = s.strip()
|
644
|
+
s = re.sub(r"\s+", "_", s) # spaces -> underscores
|
645
|
+
s = re.sub(r"[^A-Za-z0-9._\-+]", "", s) # drop weird chars
|
646
|
+
return s or "unknown"
|
647
|
+
|
648
|
+
obj = _safe(object_name)
|
649
|
+
filt = _safe(filter_name)
|
650
|
+
size_str = f"{size:g}{size_unit}"
|
651
|
+
|
652
|
+
filename = f"stamp_{obj}_{filt}_{size_str}.{ext}"
|
653
|
+
|
654
|
+
# If output_path is a dir, append filename; otherwise treat as full path
|
655
|
+
final_path = output_path
|
656
|
+
if os.path.isdir(final_path):
|
657
|
+
final_path = os.path.join(final_path, filename)
|
658
|
+
|
659
|
+
os.makedirs(os.path.dirname(final_path) or ".", exist_ok=True)
|
660
|
+
with open(final_path, "wb") as f:
|
661
|
+
f.write(data)
|
662
|
+
|
663
|
+
return final_path
|
627
664
|
|
628
665
|
|
629
666
|
class TrilogyImagesEndpoint:
|
@@ -682,9 +719,9 @@ class TrilogyImagesEndpoint:
|
|
682
719
|
output_path = os.path.join(output_path, filename)
|
683
720
|
if output_path:
|
684
721
|
with open(output_path, 'wb') as f:
|
685
|
-
f.write(resp.
|
686
|
-
return resp.
|
687
|
-
return resp.
|
722
|
+
f.write(resp.read())
|
723
|
+
return resp.read()
|
724
|
+
return resp.read()
|
688
725
|
|
689
726
|
except Exception as e:
|
690
727
|
raise ResourceNotFoundError(f"Failed to create Trilogy RGB image: {e}")
|
@@ -731,9 +768,9 @@ class TrilogyImagesEndpoint:
|
|
731
768
|
output_path = os.path.join(output_path, filename)
|
732
769
|
if output_path:
|
733
770
|
with open(output_path, 'wb') as f:
|
734
|
-
f.write(resp.
|
771
|
+
f.write(resp.read())
|
735
772
|
return output_path
|
736
|
-
return resp.
|
773
|
+
return resp.read()
|
737
774
|
|
738
775
|
except Exception as e:
|
739
776
|
raise ResourceNotFoundError(f"Failed to create Trilogy RGB image by coordinates: {e}")
|
@@ -787,9 +824,9 @@ class TrilogyImagesEndpoint:
|
|
787
824
|
output_path = os.path.join(output_path, filename)
|
788
825
|
if output_path:
|
789
826
|
with open(output_path, 'wb') as f:
|
790
|
-
f.write(resp.
|
791
|
-
return resp.
|
792
|
-
return resp.
|
827
|
+
f.write(resp.read())
|
828
|
+
return resp.read()
|
829
|
+
return resp.read()
|
793
830
|
|
794
831
|
except Exception as e:
|
795
832
|
raise ResourceNotFoundError(f"Failed to create Trilogy RGB image by object: {e}")
|
adss/endpoints/queries.py
CHANGED
@@ -116,7 +116,7 @@ class QueriesEndpoint:
|
|
116
116
|
)
|
117
117
|
|
118
118
|
# Parse Parquet data
|
119
|
-
df = parquet_to_dataframe(response.
|
119
|
+
df = parquet_to_dataframe(response.read())
|
120
120
|
|
121
121
|
return QueryResult(
|
122
122
|
query=query_obj,
|
@@ -280,7 +280,7 @@ class QueriesEndpoint:
|
|
280
280
|
handle_response_errors(response)
|
281
281
|
|
282
282
|
# Parse Parquet data
|
283
|
-
df = parquet_to_dataframe(response.
|
283
|
+
df = parquet_to_dataframe(response.read())
|
284
284
|
|
285
285
|
# Extract metadata
|
286
286
|
expires_at = response.headers.get('X-Expires-At')
|
adss/utils.py
CHANGED
@@ -19,7 +19,7 @@ def handle_response_errors(response):
|
|
19
19
|
return response
|
20
20
|
|
21
21
|
try:
|
22
|
-
error_data = response.
|
22
|
+
error_data = response.read()
|
23
23
|
error_message = error_data.get('detail', str(error_data))
|
24
24
|
except Exception:
|
25
25
|
error_message = response.text or f"HTTP Error {response.status_code}"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: adss
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.34
|
4
4
|
Summary: Astronomical Data Smart System
|
5
5
|
Author-email: Gustavo Schwarz <gustavo.b.schwarz@gmail.com>
|
6
6
|
Project-URL: Homepage, https://github.com/schwarzam/adss
|
@@ -9,6 +9,7 @@ Requires-Python: >=3.8
|
|
9
9
|
Description-Content-Type: text/markdown
|
10
10
|
License-File: LICENSE
|
11
11
|
Requires-Dist: pyarrow
|
12
|
+
Requires-Dist: httpx
|
12
13
|
Requires-Dist: requests
|
13
14
|
Requires-Dist: astropy
|
14
15
|
Dynamic: license-file
|
@@ -213,5 +214,112 @@ cl.list_files(1) ## pass the collection ID
|
|
213
214
|
You can then download a file by its filename:
|
214
215
|
|
215
216
|
```python
|
216
|
-
cl.
|
217
|
+
file_bytes = cl.download_file(
|
218
|
+
file_id=28,
|
219
|
+
output_path=None
|
220
|
+
)
|
221
|
+
```
|
222
|
+
|
223
|
+
Then handle the bytes. Example:
|
224
|
+
|
225
|
+
```python
|
226
|
+
# if a fits you may open like
|
227
|
+
import io
|
228
|
+
from astropy.io import fits
|
229
|
+
|
230
|
+
hdul = fits.open(io.BytesIO(file_bytes))
|
231
|
+
|
232
|
+
# or a image
|
233
|
+
from PIL import Image
|
234
|
+
import matplotlib.pyplot as plt
|
235
|
+
|
236
|
+
image = Image.open(io.BytesIO(file_bytes))
|
237
|
+
plt.imshow(image)
|
238
|
+
```
|
239
|
+
|
240
|
+
### Image Tools
|
241
|
+
|
242
|
+
Now notice that (**if**) the image collection has some wcs parameters as `ra_center`, `dec_center`, `pixel_scale`. This allows us to do some image cutouts and colored images in real time. Example:
|
243
|
+
|
244
|
+
```python
|
245
|
+
cutout_bytes = cl.create_stamp_by_coordinates(
|
246
|
+
collection_id = 1,
|
247
|
+
ra = 0.1,
|
248
|
+
dec = 0.1,
|
249
|
+
size = 300,
|
250
|
+
filter = "R",
|
251
|
+
size_unit="pixels",
|
252
|
+
format = "fits",
|
253
|
+
pattern="swp."
|
254
|
+
)
|
255
|
+
|
256
|
+
hdul = fits.open(BytesIO(cutout_bytes))
|
257
|
+
```
|
258
|
+
|
259
|
+
or if the image collection has object_name info you may filter by it, forcing the cutout from that object:
|
260
|
+
|
261
|
+
```python
|
262
|
+
cutout_bytes = cl.stamp_images.create_stamp_by_object(
|
263
|
+
collection_id=1,
|
264
|
+
object_name="STRIPE82-0002",
|
265
|
+
size=300,
|
266
|
+
ra=0.1,
|
267
|
+
dec=0.1,
|
268
|
+
filter_name="R",
|
269
|
+
size_unit="pixels",
|
270
|
+
format="fits"
|
271
|
+
)
|
272
|
+
cutout = fits.open(BytesIO(cutout_bytes))
|
273
|
+
```
|
274
|
+
|
275
|
+
or just by file_id, this will force the cutout from that specific file:
|
276
|
+
|
277
|
+
```python
|
278
|
+
cl.stamp_images.create_stamp(
|
279
|
+
file_id=28,
|
280
|
+
size=300,
|
281
|
+
ra=0.1,
|
282
|
+
dec=0.1,
|
283
|
+
size_unit="pixels",
|
284
|
+
format="fits"
|
285
|
+
)
|
286
|
+
```
|
287
|
+
|
288
|
+
### Colored images
|
289
|
+
|
290
|
+
Colored images API is very similar to the cutouts. You just need to provide a list of filters and the output format (png or jpg). Example with lupton et al. (2004) algorithm:
|
291
|
+
|
292
|
+
```python
|
293
|
+
im_bytes = cl.create_rgb_image_by_coordinates(
|
294
|
+
collection_id=1,
|
295
|
+
ra=0.1,
|
296
|
+
dec=0.1,
|
297
|
+
size=300,
|
298
|
+
size_unit="pixels",
|
299
|
+
r_filter="I",
|
300
|
+
g_filter="R",
|
301
|
+
b_filter="G",
|
302
|
+
)
|
303
|
+
|
304
|
+
im = Image.open(BytesIO(im_bytes))
|
305
|
+
im.show()
|
306
|
+
```
|
307
|
+
|
308
|
+
Or trilogy algorithm:
|
309
|
+
|
310
|
+
```python
|
311
|
+
im_bytes = cl.trilogy_images.create_trilogy_rgb_by_coordinates(
|
312
|
+
collection_id=1,
|
313
|
+
ra=0.1,
|
314
|
+
dec=0.1,
|
315
|
+
size=300,
|
316
|
+
size_unit="pixels",
|
317
|
+
r_filters=["I", "R", "Z", "F861", "G"],
|
318
|
+
g_filters=["F660"],
|
319
|
+
b_filters=["U", "F378", "F395", "F410", "F430", "F515"],
|
320
|
+
satpercent=0.15,
|
321
|
+
)
|
322
|
+
|
323
|
+
im = Image.open(BytesIO(im_bytes))
|
324
|
+
im.show()
|
217
325
|
```
|
@@ -1,21 +1,21 @@
|
|
1
1
|
adss/__init__.py,sha256=3FpHFL3Pk5BvETwd70P2QqYvDq799Cu2AGxGxudGAAE,1020
|
2
|
-
adss/auth.py,sha256=
|
3
|
-
adss/client.py,sha256=
|
2
|
+
adss/auth.py,sha256=kxKX9OSxnD7gWLcjJfKfNC_16_4rENT2Lw18QtPYq6I,18181
|
3
|
+
adss/client.py,sha256=JpqcxSSGccxFxeY4VNLjstTcupTr8B5uOGzywzoEXYU,30670
|
4
4
|
adss/exceptions.py,sha256=YeN-xRHvlSmwyS8ni2jOEhhgZK9J1jsG11pOedy3Gfg,1482
|
5
|
-
adss/utils.py,sha256=
|
5
|
+
adss/utils.py,sha256=hBfE6FJD-R6OTWcIf4ChtHTS07EHFGM6Oh1OE_xOjOE,3557
|
6
6
|
adss/endpoints/__init__.py,sha256=Pr29901fT8ClCS2GasTjTiBNyn7DfVfxILpYDFsMvPA,488
|
7
7
|
adss/endpoints/admin.py,sha256=S6ZrkeA_Lh_LCpF1NHyfMKqjbIiylYXUSV65H_WKg1U,16391
|
8
|
-
adss/endpoints/images.py,sha256=
|
8
|
+
adss/endpoints/images.py,sha256=b9xE_n0F384tEK4vooT9cQmcfQEu2n2Ir_5AgaChkj0,32680
|
9
9
|
adss/endpoints/metadata.py,sha256=RPrRP6Uz6-uPMIcntMgfss9vAd5iN7JXjZbF8SW0EYg,8238
|
10
|
-
adss/endpoints/queries.py,sha256=
|
10
|
+
adss/endpoints/queries.py,sha256=qpJ0mdJK8DDhznkHX_DOEWkvbWKFyfemewcKyLFLUP4,17631
|
11
11
|
adss/endpoints/users.py,sha256=6Abkl3c3_YKdMYR_JWI-uL9HTHxcjlIOnE29GyN5_QE,10811
|
12
12
|
adss/models/__init__.py,sha256=ADWVaGy4dkpEMH3iS_6EnRSBlEgoM5Vy9zORQr-UG6w,404
|
13
13
|
adss/models/metadata.py,sha256=6fdH_0BenVRmeXkkKbsG2B68O-N2FXTTRgxsEhAHRoU,4058
|
14
14
|
adss/models/query.py,sha256=V1H9UAv9wORAr85aajeY7H1zaxyfNtKuEoBtBU66DbM,4820
|
15
15
|
adss/models/user.py,sha256=5qVT5qOktokmVLkGszPGCTZWv0wC-7aBMvJ8EeBOqdw,3493
|
16
|
-
adss-1.
|
16
|
+
adss-1.34.dist-info/licenses/LICENSE,sha256=yPw116pnd1J4TuMPnvm6I_irZUyC30EoBZ4BtWFAL7I,1557
|
17
17
|
dev/fetch_idr6.py,sha256=b6FrHPr-ZLaDup_wLOaQWP2fK254Sr3YNHbTxuUt088,12788
|
18
|
-
adss-1.
|
19
|
-
adss-1.
|
20
|
-
adss-1.
|
21
|
-
adss-1.
|
18
|
+
adss-1.34.dist-info/METADATA,sha256=rVawfEWIKgnlcWys4QpUE4offNyOAmpVz2lwJWxpd_4,8759
|
19
|
+
adss-1.34.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
20
|
+
adss-1.34.dist-info/top_level.txt,sha256=NT2zObOOiTWXc0yowpEjT6BiiI1e7WXlXd0ZoK7T5hk,9
|
21
|
+
adss-1.34.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|