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 CHANGED
@@ -1,11 +1,176 @@
1
- import requests
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
- import os
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 requests.RequestException as e:
49
- raise AuthenticationError(f"Login failed: {e}")
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 requests.RequestException as e:
83
- raise AuthenticationError(f"Failed to get user info: {e}")
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
- # Prepend base_url if needed
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
- # Apply verify_ssl unless overridden
115
- if 'verify' not in kwargs:
116
- kwargs['verify'] = self.verify_ssl
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
- return requests.request(method, url, headers=final_headers, **kwargs)
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
- # Prepend base_url if needed
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
- # Apply verify_ssl unless overridden
153
- if 'verify' not in kwargs:
154
- kwargs['verify'] = self.verify_ssl
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
- # Force streaming
157
- kwargs['stream'] = True
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 = requests.request(method, url, headers=final_headers, **kwargs)
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 resp
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.content
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.content)
224
- return resp.content
225
- return resp.content
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.content)
297
- return resp.content
298
- return resp.content
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.content)
344
- return resp.content
345
- return resp.content
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.content)
399
- return resp.content
400
- return resp.content
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.content)
455
- return resp.content
456
- return resp.content
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.content)
520
- return resp.content
521
- return resp.content
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.content)
570
- return resp.content
571
- return resp.content
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
- def create_stamp_by_object(self,
577
- collection_id: int, object_name: str,
578
- filter_name: str, ra: float, dec: float, size: float,
579
- size_unit: str = "arcmin", format: str = "fits",
580
- zmin: Optional[float] = None, zmax: Optional[float] = None,
581
- pattern: Optional[str] = None,
582
- output_path: Optional[str] = None,
583
- **kwargs) -> Union[bytes, str]:
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 = {"Accept": "image/png" if format == "png" else "application/fits"}
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, "filter_name": filter_name,
592
- "ra": ra, "dec": dec, "size": size,
593
- "size_unit": size_unit, "format": format
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
- resp = self.auth_manager.download(
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
- handle_response_errors(resp)
631
+ except Exception as e:
632
+ raise ResourceNotFoundError(f"Failed to create stamp by object: {e}")
612
633
 
613
- cd = resp.headers.get('Content-Disposition', '')
614
- ext = "fits" if format == "fits" else "png"
615
- filename = cd.split('filename=')[1].strip('"') if 'filename=' in cd else f"stamp.{ext}"
634
+ # If no output_path => return bytes
635
+ if not output_path:
636
+ return data
616
637
 
617
- if output_path and os.path.isdir(output_path):
618
- output_path = os.path.join(output_path, filename)
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
- except Exception as e:
626
- raise ResourceNotFoundError(f"Failed to create stamp by object: {e}")
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.content)
686
- return resp.content
687
- return resp.content
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.content)
771
+ f.write(resp.read())
735
772
  return output_path
736
- return resp.content
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.content)
791
- return resp.content
792
- return resp.content
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.content)
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.content)
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.json()
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.32
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.images.download_file("SPLUS-s17s23_F515_swpweight.fz", download_path=".")
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=4eZ5VjqyzF-gXNywjGdjjphd5im3wOFLf5tihKNfbdw,5320
3
- adss/client.py,sha256=oJ0O4ZUdmycprpKXBXEpPWiu4T4Jf79uPsXzOpuEG-w,29895
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=KeQUtTCcye3W07oHpBnwS7g3gG-RqwWMlaE7UgDWwsU,3557
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=jNKC-zjbVZ89PaRazeh7-30uvPz7-L1mX5w6JXeXl_E,31909
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=du4C_K8870ffyZkaLnMD08jMAWeVBygdk_bjgnEEMWM,17633
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.32.dist-info/licenses/LICENSE,sha256=yPw116pnd1J4TuMPnvm6I_irZUyC30EoBZ4BtWFAL7I,1557
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.32.dist-info/METADATA,sha256=8vG9FdaLPeQuzwzHCyK_RdY-QSk2usBkKzd_YmFC5wU,6548
19
- adss-1.32.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
- adss-1.32.dist-info/top_level.txt,sha256=NT2zObOOiTWXc0yowpEjT6BiiI1e7WXlXd0ZoK7T5hk,9
21
- adss-1.32.dist-info/RECORD,,
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