fitsview 0.1.0__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.
fitsview/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError:
4
+ # Fallback when using the package in dev mode without installing
5
+ # in editable mode with pip. It is highly recommended to install
6
+ # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
7
+ import warnings
8
+ warnings.warn("Importing 'fitsview' outside a proper installation.")
9
+ __version__ = "dev"
10
+
11
+ from .handlers import setup_handlers
12
+
13
+
14
+ def _jupyter_labextension_paths():
15
+ return [{
16
+ "src": "labextension",
17
+ "dest": "fitsview"
18
+ }]
19
+
20
+
21
+ def _jupyter_server_extension_points():
22
+ return [{
23
+ "module": "fitsview"
24
+ }]
25
+
26
+
27
+ def _load_jupyter_server_extension(server_app):
28
+ """Register the API handlers."""
29
+ setup_handlers(server_app.web_app)
30
+ server_app.log.info("Registered fitsview server extension")
fitsview/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # This file is auto-generated by Hatchling. As such, do not:
2
+ # - modify
3
+ # - track in version control e.g. be sure to add to .gitignore
4
+ __version__ = VERSION = '0.1.0'
fitsview/handlers.py ADDED
@@ -0,0 +1,248 @@
1
+ """
2
+ API handlers for FITS file operations.
3
+ """
4
+
5
+ import json
6
+ import tornado
7
+ from enum import Enum
8
+ from jupyter_server.base.handlers import APIHandler, JupyterHandler
9
+ from jupyter_server.utils import url_path_join
10
+ from astropy.io import fits
11
+ import numpy as np
12
+
13
+
14
+ class ArrayType(str, Enum):
15
+ """
16
+ Enum mapping numpy dtypes to Rust-style type specifiers.
17
+
18
+ Uses dtype.kind and dtype.itemsize to unambiguously determine the type,
19
+ avoiding string comparison issues with numpy's various dtype representations.
20
+
21
+ Values use Rust-style type names for consistency across the wire protocol.
22
+ """
23
+ INT8 = "i8"
24
+ UINT8 = "u8"
25
+ INT16 = "i16"
26
+ UINT16 = "u16"
27
+ INT32 = "i32"
28
+ UINT32 = "u32"
29
+ INT64 = "i64"
30
+ UINT64 = "u64"
31
+ FLOAT32 = "f32"
32
+ FLOAT64 = "f64"
33
+
34
+
35
+ def numpy_dtype_to_array_type(dtype: np.dtype) -> ArrayType:
36
+ """
37
+ Map a numpy dtype to an ArrayType based on kind and itemsize.
38
+
39
+ dtype.kind values:
40
+ 'b' - boolean
41
+ 'i' - signed integer
42
+ 'u' - unsigned integer
43
+ 'f' - floating-point
44
+ 'c' - complex floating-point
45
+ 'S', 'a' - byte string
46
+ 'U' - unicode string
47
+ 'V' - void (raw data)
48
+ """
49
+ kind = dtype.kind
50
+ size = dtype.itemsize
51
+
52
+ if kind == 'i': # Signed integer
53
+ if size == 1:
54
+ return ArrayType.INT8
55
+ elif size == 2:
56
+ return ArrayType.INT16
57
+ elif size == 4:
58
+ return ArrayType.INT32
59
+ elif size == 8:
60
+ return ArrayType.INT64
61
+ elif kind == 'u': # Unsigned integer
62
+ if size == 1:
63
+ return ArrayType.UINT8
64
+ elif size == 2:
65
+ return ArrayType.UINT16
66
+ elif size == 4:
67
+ return ArrayType.UINT32
68
+ elif size == 8:
69
+ return ArrayType.UINT64
70
+ elif kind == 'f': # Floating-point
71
+ if size == 4:
72
+ return ArrayType.FLOAT32
73
+ elif size == 8:
74
+ return ArrayType.FLOAT64
75
+ elif kind == 'b': # Boolean - treat as uint8
76
+ return ArrayType.UINT8
77
+
78
+ # Default to float64 for unsupported types
79
+ return ArrayType.FLOAT64
80
+
81
+
82
+ class FITSMetadataHandler(APIHandler):
83
+ """Handler for retrieving FITS file metadata (headers, dimensions)."""
84
+
85
+ @tornado.web.authenticated
86
+ async def get(self):
87
+ path = self.get_argument('path')
88
+
89
+ # Validate path exists via Contents API
90
+ cm = self.contents_manager
91
+ try:
92
+ await cm.get(path, content=False)
93
+ except Exception as e:
94
+ self.set_status(404)
95
+ self.finish(json.dumps({'error': f'File not found: {path}'}))
96
+ return
97
+
98
+ # Get filesystem path for astropy
99
+ os_path = cm._get_os_path(path)
100
+
101
+ try:
102
+ with fits.open(os_path) as hdul:
103
+ hdus = []
104
+ for i, hdu in enumerate(hdul):
105
+ # Use repr() to get the raw 80-column card format with newlines
106
+ # str() returns an object description, repr() returns the actual content
107
+ header_str = repr(hdu.header)
108
+
109
+ hdu_info = {
110
+ 'index': i,
111
+ 'name': hdu.name,
112
+ 'type': hdu.__class__.__name__,
113
+ 'header': header_str, # Raw 80-column format string
114
+ }
115
+ if hdu.data is not None:
116
+ hdu_info['shape'] = list(hdu.data.shape)
117
+ # Use ArrayType enum for consistent type representation
118
+ hdu_info['arrayType'] = numpy_dtype_to_array_type(hdu.data.dtype).value
119
+ else:
120
+ hdu_info['shape'] = None
121
+ hdu_info['arrayType'] = None
122
+ hdus.append(hdu_info)
123
+
124
+ result = {
125
+ 'path': path,
126
+ 'hdus': hdus
127
+ }
128
+ self.finish(json.dumps(result))
129
+ except Exception as e:
130
+ self.set_status(500)
131
+ self.finish(json.dumps({'error': f'Error reading FITS file: {str(e)}'}))
132
+
133
+
134
+ class FITSSliceHandler(JupyterHandler):
135
+ """Handler for retrieving data slices from FITS files.
136
+
137
+ Uses JupyterHandler instead of APIHandler to support binary responses.
138
+
139
+ The slices parameter uses NumPy/Python conventions:
140
+ - Zero-indexed
141
+ - Axis order matches NumPy (e.g., for 3D data: z,y,x or depth,row,col)
142
+ - Half-open intervals [start, stop) with exclusive upper bound
143
+ - Format: "start:stop,start:stop,..." for each axis
144
+ """
145
+
146
+ @tornado.web.authenticated
147
+ async def get(self):
148
+ path = self.get_argument('path')
149
+ hdu = int(self.get_argument('hdu', 0))
150
+ slices_str = self.get_argument('slices') # e.g., "0:10,5:15" for 2D
151
+
152
+ # Parse slices parameter
153
+ try:
154
+ slice_tuples = []
155
+ for s in slices_str.split(','):
156
+ parts = s.strip().split(':')
157
+ if len(parts) != 2:
158
+ raise ValueError(f"Invalid slice format: '{s}'. Expected 'start:stop'.")
159
+ start, stop = int(parts[0]), int(parts[1])
160
+ if start < 0 or stop < 0:
161
+ raise ValueError(f"Negative indices not supported: '{s}'")
162
+ if start >= stop:
163
+ raise ValueError(f"Start must be less than stop: '{s}'")
164
+ slice_tuples.append((start, stop))
165
+ except ValueError as e:
166
+ self.set_status(400)
167
+ self.finish(json.dumps({'error': str(e)}))
168
+ return
169
+
170
+ # Validate path exists via Contents API
171
+ cm = self.contents_manager
172
+ try:
173
+ await cm.get(path, content=False)
174
+ except Exception as e:
175
+ self.set_status(404)
176
+ self.finish(json.dumps({'error': f'File not found: {path}'}))
177
+ return
178
+
179
+ # Get filesystem path for astropy
180
+ os_path = cm._get_os_path(path)
181
+
182
+ try:
183
+ with fits.open(os_path) as hdul:
184
+ if hdu >= len(hdul):
185
+ self.set_status(400)
186
+ self.finish(json.dumps({
187
+ 'error': f'HDU index {hdu} out of range (file has {len(hdul)} HDUs)'
188
+ }))
189
+ return
190
+
191
+ data = hdul[hdu].data
192
+ if data is None:
193
+ self.set_status(400)
194
+ self.finish(json.dumps({
195
+ 'error': f'HDU {hdu} has no data'
196
+ }))
197
+ return
198
+
199
+ # Validate number of slice dimensions matches data dimensions
200
+ if len(slice_tuples) != len(data.shape):
201
+ self.set_status(400)
202
+ self.finish(json.dumps({
203
+ 'error': f'Number of slice dimensions ({len(slice_tuples)}) does not match '
204
+ f'data dimensions ({len(data.shape)}). Data shape: {list(data.shape)}'
205
+ }))
206
+ return
207
+
208
+ # Validate slice bounds for each axis
209
+ for axis, ((start, stop), size) in enumerate(zip(slice_tuples, data.shape)):
210
+ if stop > size:
211
+ self.set_status(400)
212
+ self.finish(json.dumps({
213
+ 'error': f'Slice [{start}:{stop}] on axis {axis} out of bounds '
214
+ f'for dimension size {size}. Data shape: {list(data.shape)}'
215
+ }))
216
+ return
217
+
218
+ # Build the slice tuple and extract data
219
+ numpy_slices = tuple(slice(start, stop) for start, stop in slice_tuples)
220
+ slice_data = data[numpy_slices]
221
+
222
+ # Convert to little-endian for JavaScript TypedArray compatibility
223
+ le_dtype = slice_data.dtype.newbyteorder('<')
224
+ slice_bytes = slice_data.astype(le_dtype).tobytes()
225
+
226
+ # Use ArrayType enum for consistent type representation
227
+ array_type = numpy_dtype_to_array_type(slice_data.dtype)
228
+
229
+ self.set_header('Content-Type', 'application/octet-stream')
230
+ self.set_header('X-FITS-Shape', json.dumps(list(slice_data.shape)))
231
+ self.set_header('X-FITS-Type', array_type.value)
232
+ self.finish(slice_bytes)
233
+
234
+ except Exception as e:
235
+ self.set_status(500)
236
+ self.finish(json.dumps({'error': f'Error reading FITS data: {str(e)}'}))
237
+
238
+
239
+ def setup_handlers(web_app):
240
+ """Register FITS API handlers."""
241
+ host_pattern = '.*$'
242
+ base_url = web_app.settings['base_url']
243
+
244
+ handlers = [
245
+ (url_path_join(base_url, 'fitsview', 'metadata'), FITSMetadataHandler),
246
+ (url_path_join(base_url, 'fitsview', 'slice'), FITSSliceHandler),
247
+ ]
248
+ web_app.add_handlers(host_pattern, handlers)
@@ -0,0 +1 @@
1
+ # fitsview tests package
@@ -0,0 +1,51 @@
1
+ from astropy.io import fits
2
+ import math
3
+ import numpy as np
4
+ import pathlib
5
+ import sys
6
+
7
+
8
+ if __name__ == "__main__":
9
+ dest = pathlib.Path(sys.argv[1])
10
+ if not dest.is_dir():
11
+ raise RuntimeError(f"Make sure the directory {sys.argv[1]} exists for the destination")
12
+ rng = np.random.default_rng(seed=0)
13
+ # cases to cover:
14
+ # - simple 2D image
15
+ simple_2d = rng.random((16, 16))
16
+ fits.PrimaryHDU(simple_2d).writeto(dest / 'simple_2d.fits', overwrite=True)
17
+ # - simple 2D image (integers)
18
+ simple_2d_uints = (1000 * rng.random((16, 16))).astype(np.uint16)
19
+ fits.PrimaryHDU(simple_2d_uints).writeto(dest / 'simple_2d_uints.fits', overwrite=True)
20
+ # - simple 2D image > 5MB
21
+ min_bytes = 5.5e6
22
+ bytes_per_double = 4
23
+ npix_per_side = math.ceil(math.sqrt(min_bytes / bytes_per_double))
24
+ big_2d = rng.random((npix_per_side, npix_per_side), dtype=np.float64)
25
+ assert big_2d.nbytes >= min_bytes
26
+ fits.PrimaryHDU(big_2d).writeto(dest / 'big_2d.fits', overwrite=True)
27
+ # - 3D data cube
28
+ simple_3d = rng.random((5, 16, 16))
29
+ fits.PrimaryHDU(simple_3d).writeto(dest / 'simple_3d.fits', overwrite=True)
30
+ # - 3D data cube where 1 plane > 5 MB
31
+ big_3d = rng.random((5, npix_per_side, npix_per_side), dtype=np.float64)
32
+ assert big_3d[0].nbytes >= min_bytes
33
+ fits.PrimaryHDU(big_3d).writeto(dest / 'big_3d.fits', overwrite=True)
34
+ # - 4D hypercube
35
+ simple_4d = rng.random((4, 5, 16, 16))
36
+ fits.PrimaryHDU(simple_4d).writeto(dest / 'simple_4d.fits', overwrite=True)
37
+ # - file with no image extensions
38
+ table_hdu = fits.TableHDU(np.ones((10,), dtype=[('x', float), ('y', float)]), name='TABLE')
39
+ table_hdu.writeto(dest / 'no_image.fits', overwrite=True)
40
+ # - file with multiple extensions
41
+ fits.HDUList([
42
+ fits.PrimaryHDU(),
43
+ fits.ImageHDU(simple_4d, name='FOURDEE'),
44
+ table_hdu,
45
+ ]).writeto(dest / 'multi_ext.fits', overwrite=True)
46
+ # - file with multiple *image* extensions
47
+ fits.HDUList([
48
+ fits.PrimaryHDU(),
49
+ fits.ImageHDU(simple_4d, name='FOURDEE'),
50
+ fits.ImageHDU(simple_3d, name='THREEDEE')
51
+ ]).writeto(dest / 'multi_image_ext.fits', overwrite=True)
@@ -0,0 +1,298 @@
1
+ """Tests for FITS API handlers."""
2
+
3
+ import json
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from astropy.io import fits
8
+
9
+
10
+ @pytest.fixture
11
+ def fits_file(jp_root_dir):
12
+ """Create a test FITS file with multiple HDUs."""
13
+ # Primary HDU with float32 data
14
+ primary_data = np.arange(100, dtype=np.float32).reshape(10, 10)
15
+ primary_hdu = fits.PrimaryHDU(primary_data)
16
+ primary_hdu.header["OBJECT"] = "Test Object"
17
+ primary_hdu.header["TELESCOP"] = "Test Telescope"
18
+
19
+ # Image extension with int16 data
20
+ int16_data = np.arange(50, dtype=np.int16).reshape(5, 10)
21
+ image_hdu = fits.ImageHDU(int16_data, name="SCI")
22
+ image_hdu.header["EXTNAME"] = "SCI"
23
+
24
+ # Table extension
25
+ col1 = fits.Column(name="ID", format="J", array=np.array([1, 2, 3]))
26
+ col2 = fits.Column(name="NAME", format="10A", array=np.array(["a", "b", "c"]))
27
+ table_hdu = fits.BinTableHDU.from_columns([col1, col2], name="TABLE")
28
+
29
+ hdul = fits.HDUList([primary_hdu, image_hdu, table_hdu])
30
+
31
+ fits_path = jp_root_dir / "test_data.fits"
32
+ hdul.writeto(fits_path, overwrite=True)
33
+ hdul.close()
34
+
35
+ return "test_data.fits"
36
+
37
+
38
+ @pytest.fixture
39
+ def float64_fits_file(jp_root_dir):
40
+ """Create a test FITS file with float64 data."""
41
+ data = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float64)
42
+ hdu = fits.PrimaryHDU(data)
43
+ hdul = fits.HDUList([hdu])
44
+
45
+ fits_path = jp_root_dir / "float64_test.fits"
46
+ hdul.writeto(fits_path, overwrite=True)
47
+ hdul.close()
48
+
49
+ return "float64_test.fits"
50
+
51
+
52
+ @pytest.fixture
53
+ def cube_fits_file(jp_root_dir):
54
+ """Create a test FITS file with 3D data cube."""
55
+ # 3D cube: 4 planes of 5x6 data
56
+ data = np.arange(120, dtype=np.float32).reshape(4, 5, 6)
57
+ hdu = fits.PrimaryHDU(data)
58
+ hdul = fits.HDUList([hdu])
59
+
60
+ fits_path = jp_root_dir / "cube_test.fits"
61
+ hdul.writeto(fits_path, overwrite=True)
62
+ hdul.close()
63
+
64
+ return "cube_test.fits"
65
+
66
+
67
+ class TestMetadataHandler:
68
+ """Tests for the FITSMetadataHandler."""
69
+
70
+ async def test_get_metadata(self, jp_fetch, fits_file):
71
+ """Test retrieving metadata from a FITS file."""
72
+ response = await jp_fetch("fitsview", "metadata", params={"path": fits_file})
73
+
74
+ assert response.code == 200
75
+ data = json.loads(response.body)
76
+
77
+ assert data["path"] == fits_file
78
+
79
+ # Check primary HDU
80
+ hdus = data["hdus"]
81
+ assert len(hdus) == 3
82
+
83
+ primary = hdus[0]
84
+ assert primary["index"] == 0
85
+ assert primary["name"] == "PRIMARY"
86
+ assert primary["type"] == "PrimaryHDU"
87
+ assert primary["shape"] == [10, 10]
88
+ # arrayType is now a Rust-style type specifier
89
+ assert primary["arrayType"] == "f32"
90
+ # header is returned as raw 80-column FITS format string
91
+ assert "OBJECT" in primary["header"]
92
+ assert "Test Object" in primary["header"]
93
+ assert "TELESCOP" in primary["header"]
94
+ assert "Test Telescope" in primary["header"]
95
+
96
+ # Check image extension
97
+ sci = hdus[1]
98
+ assert sci["index"] == 1
99
+ assert sci["name"] == "SCI"
100
+ assert sci["type"] == "ImageHDU"
101
+ assert sci["shape"] == [5, 10]
102
+ assert sci["arrayType"] == "i16"
103
+
104
+ # Check table extension
105
+ table = hdus[2]
106
+ assert table["index"] == 2
107
+ assert table["name"] == "TABLE"
108
+ assert table["type"] == "BinTableHDU"
109
+
110
+ async def test_metadata_file_not_found(self, jp_fetch):
111
+ """Test that 404 is returned for non-existent files."""
112
+ response = await jp_fetch(
113
+ "fitsview",
114
+ "metadata",
115
+ params={"path": "nonexistent.fits"},
116
+ raise_error=False,
117
+ )
118
+
119
+ assert response.code == 404
120
+ data = json.loads(response.body)
121
+ assert "error" in data
122
+ assert "not found" in data["error"].lower()
123
+
124
+
125
+ class TestSliceHandler:
126
+ """Tests for the FITSSliceHandler."""
127
+
128
+ async def test_get_slice_float32(self, jp_fetch, fits_file):
129
+ """Test retrieving a slice of float32 data."""
130
+ # 2D data with shape [10, 10], slice rows 0:2, cols 0:3
131
+ response = await jp_fetch(
132
+ "fitsview",
133
+ "slice",
134
+ params={"path": fits_file, "hdu": "0", "slices": "0:2,0:3"},
135
+ )
136
+
137
+ assert response.code == 200
138
+ assert response.headers["Content-Type"] == "application/octet-stream"
139
+
140
+ # Check shape header
141
+ shape = json.loads(response.headers["X-FITS-Shape"])
142
+ assert shape == [2, 3]
143
+
144
+ # Check type header - should be f32 (Rust-style type specifier)
145
+ array_type = response.headers["X-FITS-Type"]
146
+ assert array_type == "f32"
147
+
148
+ # Verify data content (data is sent as little-endian)
149
+ data = np.frombuffer(response.body, dtype="<f4").reshape(2, 3)
150
+ expected = np.arange(100, dtype=np.float32).reshape(10, 10)[0:2, 0:3]
151
+ np.testing.assert_array_almost_equal(data, expected)
152
+
153
+ async def test_get_slice_int16(self, jp_fetch, fits_file):
154
+ """Test retrieving a slice of int16 data."""
155
+ # HDU 1 has shape [5, 10], slice rows 1:3, cols 2:6
156
+ response = await jp_fetch(
157
+ "fitsview",
158
+ "slice",
159
+ params={"path": fits_file, "hdu": "1", "slices": "1:3,2:6"},
160
+ )
161
+
162
+ assert response.code == 200
163
+
164
+ # Check type header - should be i16 (Rust-style type specifier)
165
+ array_type = response.headers["X-FITS-Type"]
166
+ assert array_type == "i16"
167
+
168
+ # Verify data content (data is sent as little-endian)
169
+ shape = json.loads(response.headers["X-FITS-Shape"])
170
+ assert shape == [2, 4]
171
+
172
+ data = np.frombuffer(response.body, dtype="<i2").reshape(2, 4)
173
+ expected = np.arange(50, dtype=np.int16).reshape(5, 10)[1:3, 2:6]
174
+ np.testing.assert_array_equal(data, expected)
175
+
176
+ async def test_get_slice_float64(self, jp_fetch, float64_fits_file):
177
+ """Test retrieving float64 data preserves dtype."""
178
+ # 2D data with shape [2, 2], slice entire array
179
+ response = await jp_fetch(
180
+ "fitsview",
181
+ "slice",
182
+ params={"path": float64_fits_file, "hdu": "0", "slices": "0:2,0:2"},
183
+ )
184
+
185
+ assert response.code == 200
186
+
187
+ # Check type header - should be f64 (Rust-style type specifier)
188
+ array_type = response.headers["X-FITS-Type"]
189
+ assert array_type == "f64"
190
+
191
+ data = np.frombuffer(response.body, dtype="<f8").reshape(2, 2)
192
+ expected = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float64)
193
+ np.testing.assert_array_almost_equal(data, expected)
194
+
195
+ async def test_get_slice_3d_cube(self, jp_fetch, cube_fits_file):
196
+ """Test retrieving a slice from a 3D data cube."""
197
+ # 3D data with shape [4, 5, 6], slice planes 1:3, rows 0:2, cols 2:5
198
+ response = await jp_fetch(
199
+ "fitsview",
200
+ "slice",
201
+ params={"path": cube_fits_file, "hdu": "0", "slices": "1:3,0:2,2:5"},
202
+ )
203
+
204
+ assert response.code == 200
205
+
206
+ # Check shape header
207
+ shape = json.loads(response.headers["X-FITS-Shape"])
208
+ assert shape == [2, 2, 3]
209
+
210
+ # Verify data content (using type header for reference, but hardcode dtype for parsing)
211
+ array_type = response.headers["X-FITS-Type"]
212
+ assert array_type == "f32"
213
+ data = np.frombuffer(response.body, dtype="<f4").reshape(2, 2, 3)
214
+ expected = np.arange(120, dtype=np.float32).reshape(4, 5, 6)[1:3, 0:2, 2:5]
215
+ np.testing.assert_array_almost_equal(data, expected)
216
+
217
+ async def test_slice_out_of_bounds(self, jp_fetch, fits_file):
218
+ """Test that out-of-bounds slices return 400."""
219
+ response = await jp_fetch(
220
+ "fitsview",
221
+ "slice",
222
+ params={"path": fits_file, "hdu": "0", "slices": "8:15,8:15"},
223
+ raise_error=False,
224
+ )
225
+
226
+ assert response.code == 400
227
+ data = json.loads(response.body)
228
+ assert "error" in data
229
+ assert "out of bounds" in data["error"].lower()
230
+
231
+ async def test_slice_dimension_mismatch(self, jp_fetch, fits_file):
232
+ """Test that wrong number of slice dimensions returns 400."""
233
+ # HDU 0 is 2D but we provide 3 slice dimensions
234
+ response = await jp_fetch(
235
+ "fitsview",
236
+ "slice",
237
+ params={"path": fits_file, "hdu": "0", "slices": "0:2,0:2,0:2"},
238
+ raise_error=False,
239
+ )
240
+
241
+ assert response.code == 400
242
+ data = json.loads(response.body)
243
+ assert "error" in data
244
+ assert "dimensions" in data["error"].lower()
245
+
246
+ async def test_slice_invalid_format(self, jp_fetch, fits_file):
247
+ """Test that invalid slice format returns 400."""
248
+ response = await jp_fetch(
249
+ "fitsview",
250
+ "slice",
251
+ params={"path": fits_file, "hdu": "0", "slices": "0:2:1,0:3"}, # step not supported
252
+ raise_error=False,
253
+ )
254
+
255
+ assert response.code == 400
256
+ data = json.loads(response.body)
257
+ assert "error" in data
258
+
259
+ async def test_slice_invalid_hdu(self, jp_fetch, fits_file):
260
+ """Test that invalid HDU index returns 400."""
261
+ response = await jp_fetch(
262
+ "fitsview",
263
+ "slice",
264
+ params={"path": fits_file, "hdu": "99", "slices": "0:1,0:1"},
265
+ raise_error=False,
266
+ )
267
+
268
+ assert response.code == 400
269
+ data = json.loads(response.body)
270
+ assert "error" in data
271
+ assert "out of range" in data["error"].lower()
272
+
273
+ async def test_slice_table_hdu_error(self, jp_fetch, fits_file):
274
+ """Test that requesting a slice from a table HDU returns an error."""
275
+ # HDU 2 is the table extension - tables have no standard shape
276
+ response = await jp_fetch(
277
+ "fitsview",
278
+ "slice",
279
+ params={"path": fits_file, "hdu": "2", "slices": "0:1,0:1"},
280
+ raise_error=False,
281
+ )
282
+
283
+ assert response.code == 400
284
+ data = json.loads(response.body)
285
+ assert "error" in data
286
+
287
+ async def test_slice_file_not_found(self, jp_fetch):
288
+ """Test that 404 is returned for non-existent files."""
289
+ response = await jp_fetch(
290
+ "fitsview",
291
+ "slice",
292
+ params={"path": "nonexistent.fits", "hdu": "0", "slices": "0:1,0:1"},
293
+ raise_error=False,
294
+ )
295
+
296
+ assert response.code == 404
297
+ data = json.loads(response.body)
298
+ assert "error" in data
@@ -0,0 +1,7 @@
1
+ {
2
+ "ServerApp": {
3
+ "jpserver_extensions": {
4
+ "fitsview": true
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "packageManager": "python",
3
+ "packageName": "fitsview",
4
+ "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package fitsview"
5
+ }