Skip to content

Commit 826cdad

Browse files
authored
Merge pull request #20 from DougBurke/change-api
Handle the URL return type
2 parents 2bb9f0d + b4d70b5 commit 826cdad

File tree

3 files changed

+165
-50
lines changed

3 files changed

+165
-50
lines changed

CHANGELOG.md

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# Changes for ds9samp
22

3-
## Version 0.0.9 - 2025-01-30
3+
## Version 0.1.0 - 2025-02-06
44

5-
Minor linting changes. There is no change to functionality.
5+
Add the `get_raw` routine which does not try to read in the data
6+
stored in the URL response. Improved the handling of data from the
7+
DATA, PIXELTABLE, and REGION commands (when used with `get`) by
8+
returning a FITS HDUList or NumPy array instead of a string. Added the
9+
`get_image_info` method to return basic information about the current
10+
image.
611

7-
Let's try ruff for code formatting.
12+
Let's try ruff for linting and code formatting.
813

914
## Version 0.0.8 - 2025-01-30
1015

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "ds9samp"
7-
version = "0.0.9"
7+
version = "0.1.0"
88
authors = [
99
{ name = "Douglas Burke", email="[email protected]" }
1010
]

src/ds9samp/__init__.py

+156-46
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
ds9.set("zscale")
3232
ds9.set("cmap viridis")
3333
34-
The get method will return a value (as a string or None if there is
35-
no response).
34+
The get method will return a value (as a string, FITS HDUList, NumPy
35+
array, or None if there is no response).
3636
3737
Syntax errors are displayed as a screen message (to stdout) but they
3838
do not stop the connection. Lower-level errors - such as the DS9
@@ -155,9 +155,11 @@
155155
"""
156156

157157
from contextlib import contextmanager
158+
from dataclasses import dataclass
158159
from enum import Enum
159160
import importlib.metadata
160161
import os
162+
from pathlib import Path
161163
import sys
162164
import tempfile
163165
from typing import Protocol
@@ -202,6 +204,16 @@ class Cube(Enum):
202204
"Data is stored in Hue, Saturation, Value order."
203205

204206

207+
@dataclass(frozen=True)
208+
class ImgInfo:
209+
"""Metadata about the image."""
210+
211+
dtype: np.dtype
212+
"""The datatype."""
213+
shape: tuple[int, ...]
214+
"""The size of the image. It is expected to be 2D or 3D."""
215+
216+
205217
def add_color(txt):
206218
"""Allow ANSI color escape codes unless NO_COLOR env var is set
207219
or sys.stderr is not a TTY.
@@ -289,7 +301,16 @@ def warning(msg: str) -> None:
289301
print(f"{lhs} {msg}")
290302

291303

292-
def extract_url(url: str) -> str | None:
304+
def read_array(path: str | Path, img: ImgInfo) -> np.ndarray:
305+
"""Read in an array from a file."""
306+
307+
fp = np.memmap(path, mode="r", dtype=img.dtype, shape=img.shape)
308+
return fp[:]
309+
310+
311+
def extract_url(
312+
url: str, img: ImgInfo | None
313+
) -> str | np.ndarray | fits.HDUList | None:
293314
"""Read in the URL and return the values.
294315
295316
It relies on heuristics to determine the type of the data pointed
@@ -307,18 +328,38 @@ def extract_url(url: str) -> str | None:
307328
error(f"expected file url, not {url}")
308329
return None
309330

310-
if res.path.endswith(".dat"):
331+
# Look at all the DS9 samp commands at
332+
# https://ds9.si.edu/doc/ref/samp.html
333+
# that have an example of "string url = ds9.get(string cmd):
334+
#
335+
# command suffix encoding
336+
# ------- ------ --------
337+
# array .arr binary
338+
# data .dat.dat ascii
339+
# pixeltable .pix.txt ascii
340+
# region .reg.rgn ascii
341+
#
342+
# Not included on this page are
343+
#
344+
# command suffix encoding
345+
# ------- ------ --------
346+
# fits .fits FITS
347+
#
348+
#
349+
if any(res.path.endswith(f".{end}") for end in ["dat", "rgn", "txt"]):
311350
# What's the best encoding?
312351
with open(res.path, mode="rt", encoding="ascii") as fh:
313352
return fh.read()
314353

315354
if res.path.endswith(".fits"):
316-
# Should this extract the data (assuming the input is an
317-
# image)? Also, how do we convert to a string?
318-
#
319-
# return fits.open(res.path)
320-
error("Unable to convert FITS file to a string")
321-
return None
355+
return fits.open(res.path)
356+
357+
if res.path.endswith(".arr"):
358+
if img is None:
359+
error("Sent array but no image data")
360+
return None
361+
362+
return read_array(res.path, img)
322363

323364
error(f"Unable to determine contents of {url}")
324365
return None
@@ -348,15 +389,17 @@ def __str__(self) -> str:
348389

349390
return f"Connection to DS9 {version} (client {self.client})"
350391

351-
def _get(
392+
def get_raw(
352393
self, command: str, timeout: int | None = None
353-
) -> str | dict[str, str]:
394+
) -> dict[str, str] | None:
354395
"""Call ds9.get for the given command and arguments.
355396
356397
If the call fails then an error message is displayed (to
357398
stdout) and None is returned. This call will raise an error if
358399
there is a SAMP commmunication problem.
359400
401+
.. versionadded:: 0.1.0
402+
360403
Parameters
361404
----------
362405
command
@@ -369,7 +412,12 @@ def _get(
369412
-------
370413
retval
371414
The dictionary represents the 'samp.result' field of the
372-
query, and may be empty.
415+
query, and may be empty. It will be None if there was an
416+
error with the call.
417+
418+
See Also
419+
--------
420+
get
373421
374422
"""
375423

@@ -400,7 +448,9 @@ def _get(
400448

401449
return out["samp.result"]
402450

403-
def get(self, command: str, timeout: int | None = None) -> str | None:
451+
def get(
452+
self, command: str, timeout: int | None = None
453+
) -> str | np.ndarray | fits.HDUList | None:
404454
"""Call ds9.get for the given command and arguments.
405455
406456
If the call fails then an error message is displayed (to
@@ -426,14 +476,21 @@ def get(self, command: str, timeout: int | None = None) -> str | None:
426476
The return value, as a string, or None if there was no
427477
return value.
428478
479+
See Also
480+
--------
481+
get_raw, set
482+
429483
"""
430484

431485
# The result is assumed to be one of:
432486
# - the value field
433487
# - the url field
434488
# - otherwise we just return None
435489
#
436-
result = self._get(command=command, timeout=timeout)
490+
result = self.get_raw(command=command, timeout=timeout)
491+
if result is None:
492+
return None
493+
437494
value = result.get("value")
438495
if value is not None:
439496
return value
@@ -446,10 +503,78 @@ def get(self, command: str, timeout: int | None = None) -> str | None:
446503
if self.debug:
447504
debug(f"DS9 returned data in URL={url}")
448505

449-
return extract_url(url)
506+
# In order to interpret the data from an array call
507+
# we need extra information. This would be easier if
508+
# it was sent in-band (i.e. as part of the response).
509+
# Are there other cases it is needed?
510+
#
511+
if command.startswith("array"):
512+
img = self.get_image_info(timeout=timeout)
513+
if img is None:
514+
# Return an empty array
515+
return np.zeros(0)
516+
517+
else:
518+
img = None
519+
520+
return extract_url(url, img=img)
450521

451522
return None
452523

524+
def get_image_info(self, timeout: int | None = None) -> ImgInfo | None:
525+
"""Return information on the current image.
526+
527+
Parameters
528+
----------
529+
timeout: optional
530+
Over-ride the default timeout setting. Use 0 to remove
531+
any timeout.
532+
533+
Returns
534+
-------
535+
img
536+
A structure describing the current image, or None if
537+
there is none.
538+
539+
"""
540+
541+
# These values should convert, so do not try to improve the
542+
# error handling.
543+
#
544+
def convert(arg: str) -> int:
545+
result = self.get_raw(f"fits {arg}", timeout=timeout)
546+
if result is None:
547+
return 0
548+
549+
value = result.get("value")
550+
if value is None:
551+
return 0
552+
553+
return int(value)
554+
555+
bitpix = convert("bitpix")
556+
nx = convert("width")
557+
ny = convert("height")
558+
nz = convert("depth")
559+
560+
if nx == 0 or ny == 0:
561+
return None
562+
563+
dtype = bitpix_to_dtype(bitpix)
564+
if dtype is None:
565+
if self.debug:
566+
debug(f"Unsupported BITPIX: {bitpix}")
567+
568+
return None
569+
570+
shape: tuple[int, ...]
571+
if nz > 1:
572+
shape = (nz, ny, nx)
573+
else:
574+
shape = (ny, nx)
575+
576+
return ImgInfo(dtype, shape)
577+
453578
def set(self, command: str, timeout: int | None = None) -> None:
454579
"""Call ds9.set for the given command and arguments.
455580
@@ -466,6 +591,10 @@ def set(self, command: str, timeout: int | None = None) -> None:
466591
Over-ride the default timeout setting. Use 0 to remove
467592
any timeout.
468593
594+
See Also
595+
--------
596+
set
597+
469598
"""
470599

471600
# Use ecall_and_wait to
@@ -702,41 +831,17 @@ def retrieve_array(
702831
# Get the data information before creating the temporary file.
703832
# Do we have to worry about WCS messing around with the units?
704833
#
705-
# These values should convert, so do not try to improve the
706-
# error handling.
707-
#
708-
def convert(arg):
709-
val = self.get(f"fits {arg}")
710-
if val is None:
711-
return 0
712-
return int(val)
713-
714-
bitpix = convert("bitpix")
715-
nx = convert("width")
716-
ny = convert("height")
717-
nz = convert("depth")
718-
719-
if nx == 0 or ny == 0:
834+
img = self.get_image_info(timeout=timeout)
835+
if img is None:
836+
# We could return an empty array, but is it worth it?
720837
error("DS9 appears to contain no data")
721838
return None
722839

723-
dtype = bitpix_to_dtype(bitpix)
724-
if dtype is None:
725-
error(f"Unsupported BITPIX: {bitpix}")
726-
return None
727-
728-
shape: tuple[int, ...]
729-
if nz > 1:
730-
shape = (nz, ny, nx)
731-
else:
732-
shape = (ny, nx)
733-
734840
with tempfile.NamedTemporaryFile(prefix="ds9samp", suffix=".arr") as fh:
735841
cmd = f"export array {fh.name} native"
736842
self.set(cmd, timeout=timeout)
737843

738-
fp = np.memmap(fh.name, dtype=dtype, mode="r", shape=shape)
739-
out = fp[:]
844+
out = read_array(fh.name, img)
740845

741846
return out
742847

@@ -837,7 +942,10 @@ def retrieve_fits(
837942
# The result is assumed to be given by the url field.
838943
# Any other response is an error.
839944
#
840-
result = self._get(command="fits", timeout=timeout)
945+
result = self.get_raw(command="fits", timeout=timeout)
946+
if result is None:
947+
return None
948+
841949
url = result.get("url")
842950
if url is None:
843951
error("SAMP call returned unexpected data")
@@ -916,7 +1024,9 @@ def send_cat(
9161024
"data mut be a table, not image(s)"
9171025
) from None
9181026

919-
data.writeto(fh, overwrite=True, output_verify="fix+warn")
1027+
data.writeto(
1028+
fh, overwrite=True, output_verify="fix+warn", checksum=True
1029+
)
9201030

9211031
cmd = f"catalog import fits {fh.name}"
9221032
self.set(cmd, timeout=timeout)

0 commit comments

Comments
 (0)