adss 1.31__tar.gz → 1.33__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adss
3
- Version: 1.31
3
+ Version: 1.33
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
@@ -178,3 +179,147 @@ cl.get_image_collections()
178
179
  ]
179
180
  ```
180
181
 
182
+ And then to list the files in a collection:
183
+
184
+ ```python
185
+ cl.list_files(1) ## pass the collection ID
186
+ ```
187
+
188
+ ```
189
+ [
190
+ {
191
+ 'filename': 'SPLUS-s17s23_F515_swpweight.fz',
192
+ 'full_path': '/dados/splus/SPLUS-s17s23 SPLUS-s17s23_F515_swpweight.fz',
193
+ 'file_type': 'fz',
194
+ 'ra_center': 316.45153076969416,
195
+ 'dec_center': -21.580560694390957,
196
+ 'width': 11000,
197
+ 'height': 11000,
198
+ 'pixel_scale': 0.55000000000008,
199
+ 'hdus': 2,
200
+ 'data_hdu': 1,
201
+ 'object_name': 'SPLUS-s17s23',
202
+ 'filter': 'F515',
203
+ 'instrument': 'T80Cam',
204
+ 'telescope': 'T80',
205
+ 'date_obs': None,
206
+ 'file_size': 51353280,
207
+ 'id': 28,
208
+ 'collection_id': 1,
209
+ 'created_at': '2025-04-22T15:35:05.487208',
210
+ 'updated_at': '2025-05-08T19:53:09.541437'},
211
+ },...]
212
+ ```
213
+
214
+ You can then download a file by its filename:
215
+
216
+ ```python
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()
325
+ ```
@@ -163,3 +163,147 @@ cl.get_image_collections()
163
163
  ]
164
164
  ```
165
165
 
166
+ And then to list the files in a collection:
167
+
168
+ ```python
169
+ cl.list_files(1) ## pass the collection ID
170
+ ```
171
+
172
+ ```
173
+ [
174
+ {
175
+ 'filename': 'SPLUS-s17s23_F515_swpweight.fz',
176
+ 'full_path': '/dados/splus/SPLUS-s17s23 SPLUS-s17s23_F515_swpweight.fz',
177
+ 'file_type': 'fz',
178
+ 'ra_center': 316.45153076969416,
179
+ 'dec_center': -21.580560694390957,
180
+ 'width': 11000,
181
+ 'height': 11000,
182
+ 'pixel_scale': 0.55000000000008,
183
+ 'hdus': 2,
184
+ 'data_hdu': 1,
185
+ 'object_name': 'SPLUS-s17s23',
186
+ 'filter': 'F515',
187
+ 'instrument': 'T80Cam',
188
+ 'telescope': 'T80',
189
+ 'date_obs': None,
190
+ 'file_size': 51353280,
191
+ 'id': 28,
192
+ 'collection_id': 1,
193
+ 'created_at': '2025-04-22T15:35:05.487208',
194
+ 'updated_at': '2025-05-08T19:53:09.541437'},
195
+ },...]
196
+ ```
197
+
198
+ You can then download a file by its filename:
199
+
200
+ ```python
201
+ file_bytes = cl.download_file(
202
+ file_id=28,
203
+ output_path=None
204
+ )
205
+ ```
206
+
207
+ Then handle the bytes. Example:
208
+
209
+ ```python
210
+ # if a fits you may open like
211
+ import io
212
+ from astropy.io import fits
213
+
214
+ hdul = fits.open(io.BytesIO(file_bytes))
215
+
216
+ # or a image
217
+ from PIL import Image
218
+ import matplotlib.pyplot as plt
219
+
220
+ image = Image.open(io.BytesIO(file_bytes))
221
+ plt.imshow(image)
222
+ ```
223
+
224
+ ### Image Tools
225
+
226
+ 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:
227
+
228
+ ```python
229
+ cutout_bytes = cl.create_stamp_by_coordinates(
230
+ collection_id = 1,
231
+ ra = 0.1,
232
+ dec = 0.1,
233
+ size = 300,
234
+ filter = "R",
235
+ size_unit="pixels",
236
+ format = "fits",
237
+ pattern="swp."
238
+ )
239
+
240
+ hdul = fits.open(BytesIO(cutout_bytes))
241
+ ```
242
+
243
+ or if the image collection has object_name info you may filter by it, forcing the cutout from that object:
244
+
245
+ ```python
246
+ cutout_bytes = cl.stamp_images.create_stamp_by_object(
247
+ collection_id=1,
248
+ object_name="STRIPE82-0002",
249
+ size=300,
250
+ ra=0.1,
251
+ dec=0.1,
252
+ filter_name="R",
253
+ size_unit="pixels",
254
+ format="fits"
255
+ )
256
+ cutout = fits.open(BytesIO(cutout_bytes))
257
+ ```
258
+
259
+ or just by file_id, this will force the cutout from that specific file:
260
+
261
+ ```python
262
+ cl.stamp_images.create_stamp(
263
+ file_id=28,
264
+ size=300,
265
+ ra=0.1,
266
+ dec=0.1,
267
+ size_unit="pixels",
268
+ format="fits"
269
+ )
270
+ ```
271
+
272
+ ### Colored images
273
+
274
+ 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:
275
+
276
+ ```python
277
+ im_bytes = cl.create_rgb_image_by_coordinates(
278
+ collection_id=1,
279
+ ra=0.1,
280
+ dec=0.1,
281
+ size=300,
282
+ size_unit="pixels",
283
+ r_filter="I",
284
+ g_filter="R",
285
+ b_filter="G",
286
+ )
287
+
288
+ im = Image.open(BytesIO(im_bytes))
289
+ im.show()
290
+ ```
291
+
292
+ Or trilogy algorithm:
293
+
294
+ ```python
295
+ im_bytes = cl.trilogy_images.create_trilogy_rgb_by_coordinates(
296
+ collection_id=1,
297
+ ra=0.1,
298
+ dec=0.1,
299
+ size=300,
300
+ size_unit="pixels",
301
+ r_filters=["I", "R", "Z", "F861", "G"],
302
+ g_filters=["F660"],
303
+ b_filters=["U", "F378", "F395", "F410", "F430", "F515"],
304
+ satpercent=0.15,
305
+ )
306
+
307
+ im = Image.open(BytesIO(im_bytes))
308
+ im.show()
309
+ ```
adss-1.33/adss/auth.py ADDED
@@ -0,0 +1,346 @@
1
+ import os
2
+ import time
3
+ from typing import Dict, Optional, Tuple
4
+
5
+ import requests # kept for compatibility (exceptions/type expectations)
6
+ import httpx
7
+
8
+ from adss.exceptions import AuthenticationError
9
+ from adss.utils import handle_response_errors
10
+ from adss.models.user import User
11
+
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
+
24
+ def _to_httpx_timeout(t):
25
+ """Map (connect, read) tuple or scalar into httpx.Timeout."""
26
+ if isinstance(t, tuple) and len(t) == 2:
27
+ connect, read = t
28
+ return httpx.Timeout(connect=connect, read=read, write=read, pool=connect)
29
+ if isinstance(t, (int, float)):
30
+ return httpx.Timeout(t)
31
+ return httpx.Timeout(connect=_CONNECT_TIMEOUT, read=_READ_TIMEOUT, write=_READ_TIMEOUT, pool=_CONNECT_TIMEOUT)
32
+
33
+
34
+ def _attach_requests_compat(resp: httpx.Response) -> httpx.Response:
35
+ """
36
+ Attach requests-like helpers so existing code doesn't break:
37
+ - iter_content(chunk_size) -> yields bytes
38
+ - raw.read([n]) -> bytes (returns remaining body)
39
+ """
40
+ if not hasattr(resp, "iter_content"):
41
+ def iter_content(chunk_size: int = 1024 * 1024):
42
+ return resp.iter_bytes(chunk_size=chunk_size)
43
+ setattr(resp, "iter_content", iter_content)
44
+
45
+ if not hasattr(resp, "raw"):
46
+ class _RawAdapter:
47
+ def __init__(self, r: httpx.Response):
48
+ self._r = r
49
+ def read(self, amt: Optional[int] = None) -> bytes:
50
+ # httpx sync API doesn't expose partial read; return remaining.
51
+ return self._r.read()
52
+ setattr(resp, "raw", _RawAdapter(resp))
53
+
54
+ return resp
55
+
56
+
57
+ class Auth:
58
+ """
59
+ Handles authentication, token management, and HTTP requests for the TAP client.
60
+ """
61
+
62
+ def __init__(self, base_url: str, verify_ssl: bool = True):
63
+ self.base_url = base_url.rstrip('/')
64
+ self.token: Optional[str] = None
65
+ self.current_user: Optional[User] = None
66
+ self.verify_ssl = verify_ssl
67
+
68
+ # Single keep-alive client; set verify at construction.
69
+ self._client = httpx.Client(trust_env=_TRUST_ENV, verify=self.verify_ssl)
70
+
71
+ def login(self, username: str, password: str, **kwargs) -> Tuple[str, User]:
72
+ """
73
+ Log in with username and password, obtaining an authentication token.
74
+ """
75
+ login_url = f"{self.base_url}/adss/v1/auth/login"
76
+ data = {"username": username, "password": password}
77
+
78
+ try:
79
+ response = self.request(
80
+ method="POST",
81
+ url=login_url,
82
+ auth_required=False,
83
+ data=data,
84
+ **kwargs
85
+ )
86
+ handle_response_errors(response)
87
+
88
+ token_data = response.json()
89
+ self.token = token_data.get("access_token")
90
+ if not self.token:
91
+ raise AuthenticationError("Login succeeded but no token returned")
92
+
93
+ self.current_user = self._get_current_user(**kwargs)
94
+ return self.token, self.current_user
95
+
96
+ except httpx.RequestError as e:
97
+ # preserve existing caller except-blocks that catch requests.RequestException
98
+ raise requests.RequestException(str(e)) # noqa: B904
99
+
100
+ def logout(self) -> None:
101
+ self.token = None
102
+ self.current_user = None
103
+
104
+ def is_authenticated(self) -> bool:
105
+ return self.token is not None
106
+
107
+ def _get_current_user(self, **kwargs) -> User:
108
+ """
109
+ Fetch the current user's information using the stored token.
110
+ """
111
+ if not self.token:
112
+ raise AuthenticationError("Not authenticated")
113
+
114
+ me_url = f"{self.base_url}/adss/v1/users/me"
115
+ auth_headers = self._get_auth_headers()
116
+
117
+ try:
118
+ response = self.request(
119
+ method="GET",
120
+ url=me_url,
121
+ headers=auth_headers,
122
+ auth_required=True,
123
+ **kwargs
124
+ )
125
+ handle_response_errors(response)
126
+
127
+ user_data = response.json()
128
+ return User.from_dict(user_data)
129
+
130
+ except httpx.RequestError as e:
131
+ raise requests.RequestException(str(e)) # noqa: B904
132
+
133
+ def _get_auth_headers(self) -> Dict[str, str]:
134
+ headers = {"Accept": "application/json"}
135
+ if self.token:
136
+ headers["Authorization"] = f"Bearer {self.token}"
137
+ return headers
138
+
139
+ # ---------------- core helpers ---------------- #
140
+
141
+ def _full_url(self, url: str) -> str:
142
+ return url if url.startswith(('http://', 'https://')) else f"{self.base_url}/{url.lstrip('/')}"
143
+
144
+ def _request_with_retries_nonstream(
145
+ self,
146
+ *,
147
+ method: str,
148
+ url: str,
149
+ headers: Dict[str, str],
150
+ params=None,
151
+ data=None,
152
+ json=None,
153
+ files=None,
154
+ timeout: httpx.Timeout,
155
+ follow_redirects: bool,
156
+ ) -> httpx.Response:
157
+ last_exc = None
158
+ for attempt in range(_TOTAL_RETRIES + 1):
159
+ try:
160
+ return self._client.request(
161
+ method=method.upper(),
162
+ url=url,
163
+ headers=headers,
164
+ params=params,
165
+ data=data,
166
+ json=json,
167
+ files=files,
168
+ follow_redirects=follow_redirects,
169
+ timeout=timeout,
170
+ )
171
+ except httpx.RequestError as e:
172
+ last_exc = e
173
+ if attempt >= _TOTAL_RETRIES:
174
+ break
175
+ time.sleep(_BACKOFF_FACTOR * (2 ** attempt))
176
+ raise requests.RequestException(str(last_exc)) # noqa: B904
177
+
178
+ def _request_with_retries_stream(
179
+ self,
180
+ *,
181
+ method: str,
182
+ url: str,
183
+ headers: Dict[str, str],
184
+ params=None,
185
+ data=None,
186
+ json=None,
187
+ files=None,
188
+ timeout: httpx.Timeout,
189
+ follow_redirects: bool,
190
+ ) -> httpx.Response:
191
+ """
192
+ Use client.stream(...) but keep the stream open for the caller.
193
+ We manually __enter__ the context manager and override resp.close()
194
+ to ensure resources are cleaned when caller closes the response.
195
+ """
196
+ last_exc = None
197
+ for attempt in range(_TOTAL_RETRIES + 1):
198
+ try:
199
+ cm = self._client.stream(
200
+ method=method.upper(),
201
+ url=url,
202
+ headers=headers,
203
+ params=params,
204
+ data=data,
205
+ json=json,
206
+ files=files,
207
+ follow_redirects=follow_redirects,
208
+ timeout=timeout,
209
+ )
210
+ resp = cm.__enter__() # don't exit: let caller iterate/close
211
+ # Make close() also exit the context manager safely
212
+ _orig_close = resp.close
213
+ def _close():
214
+ try:
215
+ _orig_close()
216
+ finally:
217
+ try:
218
+ cm.__exit__(None, None, None)
219
+ except Exception:
220
+ pass
221
+ resp.close = _close # type: ignore[attr-defined]
222
+ return resp
223
+ except httpx.RequestError as e:
224
+ last_exc = e
225
+ if attempt >= _TOTAL_RETRIES:
226
+ break
227
+ time.sleep(_BACKOFF_FACTOR * (2 ** attempt))
228
+ raise requests.RequestException(str(last_exc)) # noqa: B904
229
+
230
+ # ---------------- public API (unchanged signatures) ------------------- #
231
+
232
+ def request(
233
+ self,
234
+ method: str,
235
+ url: str,
236
+ headers: Optional[Dict[str, str]] = None,
237
+ auth_required: bool = False,
238
+ **kwargs
239
+ ) -> requests.Response:
240
+ """
241
+ Make an HTTP request with automatic base_url prefix, SSL config, and auth headers.
242
+ """
243
+ if auth_required and not self.is_authenticated():
244
+ raise AuthenticationError("Authentication required for this request")
245
+
246
+ url = self._full_url(url)
247
+
248
+ # Merge headers
249
+ final_headers = self._get_auth_headers()
250
+ if headers:
251
+ final_headers.update(headers)
252
+
253
+ # Map requests-style kwargs to httpx
254
+ timeout = _to_httpx_timeout(kwargs.pop('timeout', _DEFAULT_TIMEOUT))
255
+ follow_redirects = kwargs.pop('allow_redirects', True)
256
+ stream_flag = bool(kwargs.pop('stream', False))
257
+
258
+ # (verify is fixed per-client at __init__; ignore/strip any incoming 'verify' kw)
259
+ kwargs.pop('verify', None)
260
+
261
+ # Build payload pieces compatibly
262
+ params = kwargs.pop('params', None)
263
+ data = kwargs.pop('data', None)
264
+ json_ = kwargs.pop('json', None)
265
+ files = kwargs.pop('files', None)
266
+
267
+ if stream_flag:
268
+ resp = self._request_with_retries_stream(
269
+ method=method,
270
+ url=url,
271
+ headers=final_headers,
272
+ params=params,
273
+ data=data,
274
+ json=json_,
275
+ files=files,
276
+ timeout=timeout,
277
+ follow_redirects=follow_redirects,
278
+ )
279
+ else:
280
+ resp = self._request_with_retries_nonstream(
281
+ method=method,
282
+ url=url,
283
+ headers=final_headers,
284
+ params=params,
285
+ data=data,
286
+ json=json_,
287
+ files=files,
288
+ timeout=timeout,
289
+ follow_redirects=follow_redirects,
290
+ )
291
+ return _attach_requests_compat(resp)
292
+
293
+ def refresh_user_info(self, **kwargs) -> User:
294
+ self.current_user = self._get_current_user(**kwargs)
295
+ return self.current_user
296
+
297
+ def download(
298
+ self,
299
+ method: str,
300
+ url: str,
301
+ headers: Optional[Dict[str, str]] = None,
302
+ auth_required: bool = False,
303
+ **kwargs
304
+ ) -> requests.Response:
305
+ """
306
+ Like request(), but always streams the body.
307
+ Caller can iterate over response.iter_content() or
308
+ call response.raw.read() for large files.
309
+
310
+ Signature is identical to request(), so you can just
311
+ swap `request` -> `download` in call sites.
312
+ """
313
+ if auth_required and not self.is_authenticated():
314
+ raise AuthenticationError("Authentication required for this request")
315
+
316
+ url = self._full_url(url)
317
+
318
+ # Merge headers
319
+ final_headers = self._get_auth_headers()
320
+ if headers:
321
+ final_headers.update(headers)
322
+ if _FORCE_CLOSE_STREAMS:
323
+ final_headers.setdefault("Connection", "close")
324
+
325
+ timeout = _to_httpx_timeout(kwargs.pop('timeout', _DEFAULT_TIMEOUT))
326
+ follow_redirects = kwargs.pop('allow_redirects', True)
327
+ kwargs.pop('verify', None) # verify is fixed on client
328
+
329
+ params = kwargs.pop('params', None)
330
+ data = kwargs.pop('data', None)
331
+ json_ = kwargs.pop('json', None)
332
+ files = kwargs.pop('files', None)
333
+
334
+ resp = self._request_with_retries_stream(
335
+ method=method,
336
+ url=url,
337
+ headers=final_headers,
338
+ params=params,
339
+ data=data,
340
+ json=json_,
341
+ files=files,
342
+ timeout=timeout,
343
+ follow_redirects=follow_redirects,
344
+ )
345
+ handle_response_errors(resp) # fail fast on HTTP errors
346
+ return _attach_requests_compat(resp)
@@ -516,7 +516,7 @@ class ADSSClient:
516
516
  """
517
517
  return self.images.cone_search(collection_id, ra, dec, radius, filter_name, limit, **kwargs)
518
518
 
519
- def download_image(self, file_id: int, output_path: Optional[str] = None, **kwargs) -> Union[bytes, str]:
519
+ def download_file(self, file_id: int, output_path: Optional[str] = None, **kwargs) -> Union[bytes, str]:
520
520
  """
521
521
  Download an image file.
522
522
 
@@ -160,7 +160,7 @@ class ImagesEndpoint:
160
160
  for chunk in resp.iter_content(8192):
161
161
  f.write(chunk)
162
162
  return output_path
163
- return resp.content
163
+ return resp.read()
164
164
  except Exception as e:
165
165
  raise ResourceNotFoundError(f"Failed to download image file {file_id}: {e}")
166
166
 
@@ -220,9 +220,9 @@ class LuptonImagesEndpoint:
220
220
  output_path = os.path.join(output_path, filename)
221
221
  if output_path:
222
222
  with open(output_path, 'wb') as f:
223
- f.write(resp.content)
224
- return resp.content
225
- return resp.content
223
+ f.write(resp.read())
224
+ return resp.read()
225
+ return resp.read()
226
226
 
227
227
  except Exception as e:
228
228
  raise ResourceNotFoundError(f"Failed to create RGB image: {e}")
@@ -293,9 +293,9 @@ class LuptonImagesEndpoint:
293
293
  output_path = os.path.join(output_path, filename)
294
294
  if output_path:
295
295
  with open(output_path, 'wb') as f:
296
- f.write(resp.content)
297
- return resp.content
298
- return resp.content
296
+ f.write(resp.read())
297
+ return resp.read()
298
+ return resp.read()
299
299
 
300
300
  except Exception as e:
301
301
  raise ResourceNotFoundError(f"Failed to create RGB image by filenames: {e}")
@@ -340,9 +340,9 @@ class LuptonImagesEndpoint:
340
340
  output_path = os.path.join(output_path, filename)
341
341
  if output_path:
342
342
  with open(output_path, 'wb') as f:
343
- f.write(resp.content)
344
- return resp.content
345
- return resp.content
343
+ f.write(resp.read())
344
+ return resp.read()
345
+ return resp.read()
346
346
 
347
347
  except Exception as e:
348
348
  raise ResourceNotFoundError(f"Failed to create RGB image by coordinates: {e}")
@@ -395,9 +395,9 @@ class LuptonImagesEndpoint:
395
395
  output_path = os.path.join(output_path, filename)
396
396
  if output_path:
397
397
  with open(output_path, 'wb') as f:
398
- f.write(resp.content)
399
- return resp.content
400
- return resp.content
398
+ f.write(resp.read())
399
+ return resp.read()
400
+ return resp.read()
401
401
 
402
402
  except Exception as e:
403
403
  raise ResourceNotFoundError(f"Failed to create RGB image by object: {e}")
@@ -451,9 +451,9 @@ class StampImagesEndpoint:
451
451
  output_path = os.path.join(output_path, filename)
452
452
  if output_path:
453
453
  with open(output_path, 'wb') as f:
454
- f.write(resp.content)
455
- return resp.content
456
- return resp.content
454
+ f.write(resp.read())
455
+ return resp.read()
456
+ return resp.read()
457
457
 
458
458
  except Exception as e:
459
459
  raise ResourceNotFoundError(f"Failed to create stamp from file {file_id}: {e}")
@@ -516,9 +516,9 @@ class StampImagesEndpoint:
516
516
  output_path = os.path.join(output_path, filename)
517
517
  if output_path:
518
518
  with open(output_path, 'wb') as f:
519
- f.write(resp.content)
520
- return resp.content
521
- return resp.content
519
+ f.write(resp.read())
520
+ return resp.read()
521
+ return resp.read()
522
522
 
523
523
  except Exception as e:
524
524
  raise ResourceNotFoundError(f"Failed to create stamp from file {filename}: {e}")
@@ -566,9 +566,9 @@ class StampImagesEndpoint:
566
566
  output_path = os.path.join(output_path, filename)
567
567
  if output_path:
568
568
  with open(output_path, 'wb') as f:
569
- f.write(resp.content)
570
- return resp.content
571
- return resp.content
569
+ f.write(resp.read())
570
+ return resp.read()
571
+ return resp.read()
572
572
 
573
573
  except Exception as e:
574
574
  raise ResourceNotFoundError(f"Failed to create stamp by coordinates: {e}")
@@ -618,9 +618,9 @@ class StampImagesEndpoint:
618
618
  output_path = os.path.join(output_path, filename)
619
619
  if output_path:
620
620
  with open(output_path, 'wb') as f:
621
- f.write(resp.content)
622
- return resp.content
623
- return resp.content
621
+ f.write(resp.read())
622
+ return resp.read()
623
+ return resp.read()
624
624
 
625
625
  except Exception as e:
626
626
  raise ResourceNotFoundError(f"Failed to create stamp by object: {e}")
@@ -682,9 +682,9 @@ class TrilogyImagesEndpoint:
682
682
  output_path = os.path.join(output_path, filename)
683
683
  if output_path:
684
684
  with open(output_path, 'wb') as f:
685
- f.write(resp.content)
686
- return resp.content
687
- return resp.content
685
+ f.write(resp.read())
686
+ return resp.read()
687
+ return resp.read()
688
688
 
689
689
  except Exception as e:
690
690
  raise ResourceNotFoundError(f"Failed to create Trilogy RGB image: {e}")
@@ -731,9 +731,9 @@ class TrilogyImagesEndpoint:
731
731
  output_path = os.path.join(output_path, filename)
732
732
  if output_path:
733
733
  with open(output_path, 'wb') as f:
734
- f.write(resp.content)
734
+ f.write(resp.read())
735
735
  return output_path
736
- return resp.content
736
+ return resp.read()
737
737
 
738
738
  except Exception as e:
739
739
  raise ResourceNotFoundError(f"Failed to create Trilogy RGB image by coordinates: {e}")
@@ -787,9 +787,9 @@ class TrilogyImagesEndpoint:
787
787
  output_path = os.path.join(output_path, filename)
788
788
  if output_path:
789
789
  with open(output_path, 'wb') as f:
790
- f.write(resp.content)
791
- return resp.content
792
- return resp.content
790
+ f.write(resp.read())
791
+ return resp.read()
792
+ return resp.read()
793
793
 
794
794
  except Exception as e:
795
795
  raise ResourceNotFoundError(f"Failed to create Trilogy RGB image by object: {e}")
@@ -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')
@@ -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.31
3
+ Version: 1.33
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
@@ -178,3 +179,147 @@ cl.get_image_collections()
178
179
  ]
179
180
  ```
180
181
 
182
+ And then to list the files in a collection:
183
+
184
+ ```python
185
+ cl.list_files(1) ## pass the collection ID
186
+ ```
187
+
188
+ ```
189
+ [
190
+ {
191
+ 'filename': 'SPLUS-s17s23_F515_swpweight.fz',
192
+ 'full_path': '/dados/splus/SPLUS-s17s23 SPLUS-s17s23_F515_swpweight.fz',
193
+ 'file_type': 'fz',
194
+ 'ra_center': 316.45153076969416,
195
+ 'dec_center': -21.580560694390957,
196
+ 'width': 11000,
197
+ 'height': 11000,
198
+ 'pixel_scale': 0.55000000000008,
199
+ 'hdus': 2,
200
+ 'data_hdu': 1,
201
+ 'object_name': 'SPLUS-s17s23',
202
+ 'filter': 'F515',
203
+ 'instrument': 'T80Cam',
204
+ 'telescope': 'T80',
205
+ 'date_obs': None,
206
+ 'file_size': 51353280,
207
+ 'id': 28,
208
+ 'collection_id': 1,
209
+ 'created_at': '2025-04-22T15:35:05.487208',
210
+ 'updated_at': '2025-05-08T19:53:09.541437'},
211
+ },...]
212
+ ```
213
+
214
+ You can then download a file by its filename:
215
+
216
+ ```python
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()
325
+ ```
@@ -1,3 +1,4 @@
1
1
  pyarrow
2
+ httpx
2
3
  requests
3
4
  astropy
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adss"
7
- version = "1.31"
7
+ version = "1.33"
8
8
  description = "Astronomical Data Smart System"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Gustavo Schwarz", email = "gustavo.b.schwarz@gmail.com" }]
11
11
  requires-python = ">=3.8"
12
12
  dependencies = [
13
13
  "pyarrow",
14
+ "httpx",
14
15
  "requests",
15
16
  "astropy"
16
17
  ]
adss-1.31/adss/auth.py DELETED
@@ -1,162 +0,0 @@
1
- import requests
2
- from typing import Dict, Optional, Tuple
3
-
4
- from adss.exceptions import AuthenticationError
5
- from adss.utils import handle_response_errors
6
- from adss.models.user import User
7
-
8
- import os
9
-
10
- class Auth:
11
- """
12
- Handles authentication, token management, and HTTP requests for the TAP client.
13
- """
14
-
15
- def __init__(self, base_url: str, verify_ssl: bool = True):
16
- self.base_url = base_url.rstrip('/')
17
- self.token: Optional[str] = None
18
- self.current_user: Optional[User] = None
19
- self.verify_ssl = verify_ssl
20
-
21
- def login(self, username: str, password: str, **kwargs) -> Tuple[str, User]:
22
- """
23
- Log in with username and password, obtaining an authentication token.
24
- """
25
- login_url = f"{self.base_url}/adss/v1/auth/login"
26
- data = {"username": username, "password": password}
27
-
28
- try:
29
- # Use our own request() method here
30
- response = self.request(
31
- method="POST",
32
- url=login_url,
33
- auth_required=False,
34
- data=data,
35
- **kwargs
36
- )
37
- handle_response_errors(response)
38
-
39
- token_data = response.json()
40
- self.token = token_data.get("access_token")
41
- if not self.token:
42
- raise AuthenticationError("Login succeeded but no token returned")
43
-
44
- # Now fetch user info (this will use auth_required=True internally)
45
- self.current_user = self._get_current_user(**kwargs)
46
- return self.token, self.current_user
47
-
48
- except requests.RequestException as e:
49
- raise AuthenticationError(f"Login failed: {e}")
50
-
51
- def logout(self) -> None:
52
- self.token = None
53
- self.current_user = None
54
-
55
- def is_authenticated(self) -> bool:
56
- return self.token is not None
57
-
58
- def _get_current_user(self, **kwargs) -> User:
59
- """
60
- Fetch the current user's information using the stored token.
61
- """
62
- if not self.token:
63
- raise AuthenticationError("Not authenticated")
64
-
65
- me_url = f"{self.base_url}/adss/v1/users/me"
66
- auth_headers = self._get_auth_headers()
67
-
68
- try:
69
- # Again, use request() so SSL and auth headers are applied consistently
70
- response = self.request(
71
- method="GET",
72
- url=me_url,
73
- headers=auth_headers,
74
- auth_required=True,
75
- **kwargs
76
- )
77
- handle_response_errors(response)
78
-
79
- user_data = response.json()
80
- return User.from_dict(user_data)
81
-
82
- except requests.RequestException as e:
83
- raise AuthenticationError(f"Failed to get user info: {e}")
84
-
85
- def _get_auth_headers(self) -> Dict[str, str]:
86
- headers = {"Accept": "application/json"}
87
- if self.token:
88
- headers["Authorization"] = f"Bearer {self.token}"
89
- return headers
90
-
91
- def request(
92
- self,
93
- method: str,
94
- url: str,
95
- headers: Optional[Dict[str, str]] = None,
96
- auth_required: bool = False,
97
- **kwargs
98
- ) -> requests.Response:
99
- """
100
- Make an HTTP request with automatic base_url prefix, SSL config, and auth headers.
101
- """
102
- if auth_required and not self.is_authenticated():
103
- raise AuthenticationError("Authentication required for this request")
104
-
105
- # Prepend base_url if needed
106
- if not url.startswith(('http://', 'https://')):
107
- url = f"{self.base_url}/{url.lstrip('/')}"
108
-
109
- # Merge headers
110
- final_headers = self._get_auth_headers()
111
- if headers:
112
- final_headers.update(headers)
113
-
114
- # Apply verify_ssl unless overridden
115
- if 'verify' not in kwargs:
116
- kwargs['verify'] = self.verify_ssl
117
-
118
- return requests.request(method, url, headers=final_headers, **kwargs)
119
-
120
- def refresh_user_info(self, **kwargs) -> User:
121
- self.current_user = self._get_current_user(**kwargs)
122
- return self.current_user
123
-
124
- def download(
125
- self,
126
- method: str,
127
- url: str,
128
- headers: Optional[Dict[str, str]] = None,
129
- auth_required: bool = False,
130
- **kwargs
131
- ) -> requests.Response:
132
- """
133
- Like request(), but always streams the body.
134
- Caller can iterate over response.iter_content() or
135
- call response.raw.read() for large files.
136
-
137
- Signature is identical to request(), so you can just
138
- swap `request` -> `download` in call sites.
139
- """
140
- if auth_required and not self.is_authenticated():
141
- raise AuthenticationError("Authentication required for this request")
142
-
143
- # Prepend base_url if needed
144
- if not url.startswith(('http://', 'https://')):
145
- url = f"{self.base_url}/{url.lstrip('/')}"
146
-
147
- # Merge headers
148
- final_headers = self._get_auth_headers()
149
- if headers:
150
- final_headers.update(headers)
151
-
152
- # Apply verify_ssl unless overridden
153
- if 'verify' not in kwargs:
154
- kwargs['verify'] = self.verify_ssl
155
-
156
- # Force streaming
157
- kwargs['stream'] = True
158
-
159
- resp = requests.request(method, url, headers=final_headers, **kwargs)
160
- handle_response_errors(resp) # fail fast on HTTP errors
161
-
162
- return resp
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes