s3view 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.
s3view/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """s3view - a fast, lightweight S3 browser that streams instead of downloading."""
2
+
3
+ __version__ = "0.1.0"
s3view/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from s3view.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
s3view/_deps.py ADDED
@@ -0,0 +1,41 @@
1
+ """The one hard dependency, checked with an explanation instead of a traceback.
2
+
3
+ Optional dependencies degrade quietly and report themselves in the startup
4
+ banner. botocore cannot degrade -- there is no browsing without it -- so its
5
+ absence gets a message. The usual cause is a shadowed interpreter: s3view run
6
+ from a checkout picks up whichever python3 is first on PATH, which is often a
7
+ project virtualenv rather than the environment that has botocore.
8
+ """
9
+
10
+ import sys
11
+
12
+ _MISSING = """\
13
+ s3view needs botocore, and the interpreter running it does not have it:
14
+
15
+ {exe}
16
+
17
+ Any one of these fixes it:
18
+
19
+ * Install s3view as a self-contained tool. This is the recommended route:
20
+ it brings its own dependencies and cannot be shadowed.
21
+
22
+ uv tool install 's3view[all]'
23
+
24
+ * Add botocore to the interpreter above.
25
+
26
+ {exe} -m pip install botocore
27
+
28
+ * If you run s3view from a checkout, point it at an interpreter that
29
+ already has botocore.
30
+
31
+ export S3VIEW_PYTHON=/path/to/python
32
+ """
33
+
34
+
35
+ def require_botocore():
36
+ """Exit with an explanation if botocore is not importable."""
37
+ try:
38
+ import botocore # noqa: F401
39
+ except ImportError:
40
+ sys.stderr.write(_MISSING.format(exe=sys.executable))
41
+ raise SystemExit(1)
s3view/asdfview.py ADDED
@@ -0,0 +1,331 @@
1
+ """Preview ASDF arrays that live in S3 without downloading them.
2
+
3
+ Same idea as `fitsview`, different container. An ASDF file is a YAML tree
4
+ describing the data, followed by binary blocks, and (usually) a block index at
5
+ the very end listing where each block starts. So two small ranged reads -- the
6
+ head for the tree, the tail for the index -- are enough to compute the exact
7
+ byte offset of any row of any array in the file. A 200 MB Roman WFI `_cal`
8
+ product then previews by reading a few MB of scattered rows.
9
+
10
+ Parsed directly rather than via the `asdf` package: the format's addressing is
11
+ simple, and the library would want to materialise whole arrays, which is the
12
+ one thing this program exists to avoid.
13
+ """
14
+
15
+ import io
16
+ import re
17
+ import struct
18
+
19
+ from s3view import fitsview
20
+
21
+ BLOCK_MAGIC = b"\xd3BLK"
22
+ BLOCK_INDEX_MAGIC = b"#ASDF BLOCK INDEX"
23
+ TREE_END = b"\n..."
24
+
25
+ HEAD_CHUNK = 256 * 1024
26
+ TAIL_CHUNK = 64 * 1024
27
+ MAX_TREE = 16 * 1024 * 1024
28
+ MAX_BLOCK_READ = 256 * 1024 * 1024 # ceiling when a block must be read whole
29
+
30
+ # ASDF datatype name -> numpy base type. Byte order comes from the tree.
31
+ DTYPES = {
32
+ "int8": "i1", "uint8": "u1", "bool8": "u1",
33
+ "int16": "i2", "uint16": "u2",
34
+ "int32": "i4", "uint32": "u4",
35
+ "int64": "i8", "uint64": "u8",
36
+ "float16": "f2", "float32": "f4", "float64": "f8",
37
+ "complex64": "c8", "complex128": "c16",
38
+ }
39
+
40
+
41
+ class AsdfError(Exception):
42
+ pass
43
+
44
+
45
+ def _yaml_loader():
46
+ """A SafeLoader that tolerates ASDF's custom tags instead of refusing them."""
47
+ import yaml
48
+
49
+ class Loader(yaml.SafeLoader):
50
+ pass
51
+
52
+ def unknown(loader, tag_suffix, node):
53
+ if isinstance(node, yaml.MappingNode):
54
+ return loader.construct_mapping(node, deep=True)
55
+ if isinstance(node, yaml.SequenceNode):
56
+ return loader.construct_sequence(node, deep=True)
57
+ return loader.construct_scalar(node)
58
+
59
+ Loader.add_multi_constructor("", unknown)
60
+ return Loader
61
+
62
+
63
+ def _walk(obj, path=""):
64
+ """Yield (path, node) for every ndarray-looking mapping in the tree."""
65
+ if isinstance(obj, dict):
66
+ if "source" in obj and "datatype" in obj and "shape" in obj:
67
+ yield path, obj
68
+ for k, v in obj.items():
69
+ yield from _walk(v, "%s.%s" % (path, k) if path else str(k))
70
+ elif isinstance(obj, list):
71
+ for i, v in enumerate(obj):
72
+ yield from _walk(v, "%s[%d]" % (path, i))
73
+
74
+
75
+ class RemoteASDF:
76
+ def __init__(self, s3, bucket, key, size=None):
77
+ self.s3 = s3
78
+ self.bucket = bucket
79
+ self.key = key
80
+ self.size = size if size is not None else s3.head(bucket, key)["size"]
81
+ self._tree = None
82
+ self._blocks = None
83
+ self._raw_tree = None
84
+
85
+ def _read(self, start, length):
86
+ start = max(0, start)
87
+ end = min(start + length, self.size) - 1
88
+ if end < start:
89
+ return b""
90
+ return self.s3.get_range(self.bucket, self.key, start, end)
91
+
92
+ # -- tree ------------------------------------------------------------
93
+ def tree(self):
94
+ if self._tree is not None:
95
+ return self._tree
96
+ import yaml
97
+
98
+ buf = b""
99
+ while len(buf) < MAX_TREE:
100
+ chunk = self._read(len(buf), HEAD_CHUNK)
101
+ if not chunk:
102
+ break
103
+ buf += chunk
104
+ cut = buf.find(TREE_END)
105
+ if cut >= 0:
106
+ break
107
+ if len(chunk) < HEAD_CHUNK:
108
+ break
109
+ if not buf.startswith(b"#ASDF"):
110
+ raise AsdfError("not an ASDF file (missing #ASDF signature)")
111
+ cut = buf.find(TREE_END)
112
+ if cut < 0:
113
+ raise AsdfError("could not find the end of the ASDF tree")
114
+ self._raw_tree = buf[:cut].decode("utf-8", errors="replace")
115
+ self._tree = yaml.load(io.BytesIO(buf[:cut]), Loader=_yaml_loader()) or {}
116
+ return self._tree
117
+
118
+ def tree_text(self):
119
+ self.tree()
120
+ return self._raw_tree or ""
121
+
122
+ # -- blocks ----------------------------------------------------------
123
+ def block_offsets(self):
124
+ """Start offset of every binary block, cheaply."""
125
+ if self._blocks is not None:
126
+ return self._blocks
127
+ offsets = self._block_index_from_tail()
128
+ if offsets is None:
129
+ offsets = self._walk_block_headers()
130
+ self._blocks = offsets
131
+ return offsets
132
+
133
+ def _block_index_from_tail(self):
134
+ import yaml
135
+
136
+ want = TAIL_CHUNK
137
+ while want <= 4 * 1024 * 1024:
138
+ tail = self._read(self.size - want, want)
139
+ pos = tail.find(BLOCK_INDEX_MAGIC)
140
+ if pos >= 0:
141
+ text = tail[pos:].decode("latin-1")
142
+ body = text.split("---", 1)
143
+ if len(body) < 2:
144
+ return None
145
+ try:
146
+ idx = yaml.safe_load(body[1].split("\n...")[0])
147
+ except Exception:
148
+ return None
149
+ if isinstance(idx, list) and all(isinstance(v, int) for v in idx):
150
+ return idx
151
+ return None
152
+ if want >= self.size:
153
+ return None
154
+ want *= 4
155
+ return None
156
+
157
+ def _walk_block_headers(self):
158
+ """Fallback: hop block to block using each header's allocated_size."""
159
+ first = None
160
+ buf = self._read(0, HEAD_CHUNK)
161
+ first = buf.find(BLOCK_MAGIC)
162
+ if first < 0:
163
+ raise AsdfError("no binary blocks found")
164
+ offsets = []
165
+ pos = first
166
+ while pos < self.size and len(offsets) < 4096:
167
+ hdr = self._read(pos, 64)
168
+ if not hdr.startswith(BLOCK_MAGIC):
169
+ break
170
+ offsets.append(pos)
171
+ hsize = struct.unpack(">H", hdr[4:6])[0]
172
+ _flags, _comp, allocated, _used, _dsize = struct.unpack(">I4sQQQ", hdr[6:38])
173
+ pos = pos + 6 + hsize + allocated
174
+ return offsets
175
+
176
+ def block_header(self, source):
177
+ offsets = self.block_offsets()
178
+ if source >= len(offsets):
179
+ raise AsdfError("block %d not present" % source)
180
+ start = offsets[source]
181
+ hdr = self._read(start, 64)
182
+ if not hdr.startswith(BLOCK_MAGIC):
183
+ raise AsdfError("block %d is not where the index says" % source)
184
+ hsize = struct.unpack(">H", hdr[4:6])[0]
185
+ flags, comp, allocated, used, dsize = struct.unpack(">I4sQQQ", hdr[6:38])
186
+ comp = comp.rstrip(b"\x00")
187
+ return {
188
+ "start": start,
189
+ "data_offset": start + 6 + hsize,
190
+ "compression": comp.decode("latin-1"),
191
+ "allocated": allocated,
192
+ "used": used,
193
+ "data_size": dsize,
194
+ }
195
+
196
+ # -- arrays ----------------------------------------------------------
197
+ def arrays(self):
198
+ """Every ndarray in the tree, largest first."""
199
+ out = []
200
+ for path, node in _walk(self.tree()):
201
+ shape = node.get("shape") or []
202
+ if not isinstance(node.get("source"), int):
203
+ continue # inline or external array; not a local block
204
+ dt = str(node.get("datatype"))
205
+ if dt not in DTYPES:
206
+ continue
207
+ n = 1
208
+ for d in shape:
209
+ n *= int(d)
210
+ out.append(
211
+ {
212
+ "path": path,
213
+ "source": node["source"],
214
+ "shape": [int(d) for d in shape],
215
+ "datatype": dt,
216
+ "byteorder": node.get("byteorder", "little"),
217
+ "offset": int(node.get("offset", 0) or 0),
218
+ "strides": node.get("strides"),
219
+ "elements": n,
220
+ }
221
+ )
222
+ out.sort(key=lambda a: -a["elements"])
223
+ return out
224
+
225
+ def image_array(self, sel=None):
226
+ arrays = self.arrays()
227
+ images = [a for a in arrays if len(a["shape"]) >= 2]
228
+ if not images:
229
+ raise AsdfError("no 2-D array in this file")
230
+ if sel in (None, "", "auto"):
231
+ return images[0] # largest: the science image in practice
232
+ for a in arrays:
233
+ if a["path"] == sel:
234
+ return a
235
+ raise AsdfError("no array at %r" % sel)
236
+
237
+ def read_preview_array(self, size=256, sel=None, plane=0):
238
+ """Decimated 2-D array via strided ranged reads."""
239
+ import numpy as np
240
+
241
+ arr_meta = self.image_array(sel)
242
+ blk = self.block_header(arr_meta["source"])
243
+ if blk["compression"]:
244
+ return self._read_compressed(arr_meta, blk, size, plane)
245
+
246
+ shape = arr_meta["shape"]
247
+ height, width = int(shape[-2]), int(shape[-1])
248
+ order = "<" if str(arr_meta["byteorder"]).startswith("little") else ">"
249
+ dtype = np.dtype(order + DTYPES[arr_meta["datatype"]])
250
+ itemsize = dtype.itemsize
251
+ row_bytes = width * itemsize
252
+
253
+ base = blk["data_offset"] + arr_meta["offset"]
254
+ if len(shape) > 2: # cube: skip to the requested plane
255
+ base += int(plane) * height * width * itemsize
256
+
257
+ rows = sorted(set(np.linspace(0, height - 1, min(size, height)).astype(int).tolist()))
258
+ wanted = [(base + r * row_bytes, base + (r + 1) * row_bytes - 1) for r in rows]
259
+ merged = fitsview._coalesce(wanted, min(fitsview.COALESCE_GAP, row_bytes))
260
+ blobs = self.s3.get_ranges(self.bucket, self.key, merged)
261
+ buf = {m[0]: b for m, b in zip(merged, blobs)}
262
+
263
+ out = np.empty((len(rows), width), dtype=dtype)
264
+ for i, (rstart, _rend) in enumerate(wanted):
265
+ mstart = max(m for m in buf if m <= rstart)
266
+ off = rstart - mstart
267
+ chunk = buf[mstart][off : off + row_bytes]
268
+ if len(chunk) < row_bytes:
269
+ chunk = chunk + b"\0" * (row_bytes - len(chunk))
270
+ out[i] = np.frombuffer(chunk, dtype=dtype)
271
+
272
+ arr = out.astype("f4")
273
+ arr = fitsview._bin_columns(arr, min(size, width))
274
+ return arr, arr_meta
275
+
276
+ def _read_compressed(self, arr_meta, blk, size, plane):
277
+ """Compressed blocks cannot be strided: decompress, then decimate."""
278
+ import numpy as np
279
+
280
+ if blk["used"] > MAX_BLOCK_READ:
281
+ raise AsdfError("compressed block is too large to preview (%d bytes)" % blk["used"])
282
+ raw = self._read(blk["data_offset"], blk["used"])
283
+ comp = blk["compression"]
284
+ if comp == "zlib":
285
+ import zlib
286
+
287
+ raw = zlib.decompress(raw)
288
+ elif comp == "bzp2":
289
+ import bz2
290
+
291
+ raw = bz2.decompress(raw)
292
+ else:
293
+ raise AsdfError("unsupported ASDF compression %r" % comp)
294
+
295
+ shape = arr_meta["shape"]
296
+ height, width = int(shape[-2]), int(shape[-1])
297
+ order = "<" if str(arr_meta["byteorder"]).startswith("little") else ">"
298
+ dtype = np.dtype(order + DTYPES[arr_meta["datatype"]])
299
+ flat = np.frombuffer(raw, dtype=dtype, offset=arr_meta["offset"])
300
+ planesz = height * width
301
+ if len(shape) > 2:
302
+ flat = flat[int(plane) * planesz : (int(plane) + 1) * planesz]
303
+ flat = flat[:planesz]
304
+ full = flat.reshape(height, width)
305
+ rows = np.linspace(0, height - 1, min(size, height)).astype(int)
306
+ arr = full[rows, :].astype("f4")
307
+ return fitsview._bin_columns(arr, min(size, width)), arr_meta
308
+
309
+
310
+ def preview_png(s3, bucket, key, size=256, sel=None, plane=0, stretch="zscale",
311
+ cmap="gray", etag="", filesize=None, use_cache=True):
312
+ """Cached ASDF preview PNG."""
313
+ import os
314
+
315
+ params = "asdf|%s|%s|%s|%s|%s" % (size, sel, plane, stretch, cmap)
316
+ path = fitsview.cache_path(bucket, key, etag, params)
317
+ if use_cache and os.path.exists(path):
318
+ with open(path, "rb") as fh:
319
+ return fh.read()
320
+ ra = RemoteASDF(s3, bucket, key, filesize)
321
+ arr, _meta = ra.read_preview_array(size=size, sel=sel, plane=plane)
322
+ png, _limits = fitsview.render_png(arr, stretch=stretch, cmap=cmap)
323
+ try:
324
+ os.makedirs(os.path.dirname(path), exist_ok=True)
325
+ tmp = path + ".tmp"
326
+ with open(tmp, "wb") as fh:
327
+ fh.write(png)
328
+ os.replace(tmp, path)
329
+ except OSError:
330
+ pass
331
+ return png
s3view/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Command line entry point."""
2
+
3
+ import argparse
4
+ import socket
5
+ import sys
6
+ import threading
7
+ import webbrowser
8
+
9
+ from s3view import __version__, config
10
+ from s3view._deps import require_botocore
11
+
12
+
13
+ def _port_free(port):
14
+ """Can the server bind this port? Mirrors the server's own socket options.
15
+
16
+ SO_REUSEADDR matters: the server sets allow_reuse_address, so a port left in
17
+ TIME_WAIT by a previous run is usable. A probe without it reports false
18
+ positives and would send us wandering off to a random port.
19
+ """
20
+ with socket.socket() as s:
21
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22
+ try:
23
+ s.bind(("127.0.0.1", port))
24
+ return True
25
+ except OSError:
26
+ return False
27
+
28
+
29
+ def _free_port():
30
+ with socket.socket() as s:
31
+ s.bind(("127.0.0.1", 0))
32
+ return s.getsockname()[1]
33
+
34
+
35
+ def main(argv=None):
36
+ p = argparse.ArgumentParser(
37
+ prog="s3view",
38
+ description="Fast, lightweight S3 browser: streams video, previews FITS, "
39
+ "never downloads what it does not need.",
40
+ )
41
+ p.add_argument("uri", nargs="?", help="s3://bucket/prefix/ to open (default: configured start)")
42
+ p.add_argument("-p", "--port", type=int, default=0, help="port (default: an unused one)")
43
+ p.add_argument("--profile", help="AWS profile")
44
+ p.add_argument("--region", help="AWS region")
45
+ p.add_argument("--endpoint-url", help="custom S3 endpoint (MinIO, R2, Ceph, ...)")
46
+ p.add_argument("--page-size", type=int, help="objects fetched per listing page")
47
+ p.add_argument("-n", "--no-open", action="store_true", help="do not open a browser")
48
+ p.add_argument("-v", "--verbose", action="store_true", help="log requests")
49
+ p.add_argument("--set-start", action="store_true", help="save URI as the default start location")
50
+ p.add_argument("--version", action="version", version="s3view " + __version__)
51
+ args = p.parse_args(argv)
52
+
53
+ # After parsing, so that --help and --version answer even in an
54
+ # environment that cannot run the server. Server is imported here rather
55
+ # than at module scope for the same reason: importing it pulls in botocore.
56
+ require_botocore()
57
+ from s3view.server import Server
58
+
59
+ cfg = config.load()
60
+ for key, val in (
61
+ ("profile", args.profile),
62
+ ("region", args.region),
63
+ ("endpoint_url", args.endpoint_url),
64
+ ("page_size", args.page_size),
65
+ ):
66
+ if val:
67
+ cfg[key] = val
68
+ if args.uri:
69
+ cfg["start"] = args.uri if args.uri.startswith("s3://") else "s3://" + args.uri
70
+ if args.set_start:
71
+ saved = config.load()
72
+ saved["start"] = cfg["start"]
73
+ config.save(saved)
74
+ print("default start location saved: %s" % cfg["start"])
75
+
76
+ if args.port:
77
+ if not _port_free(args.port):
78
+ print("port %d is already in use" % args.port, file=sys.stderr)
79
+ return 1
80
+ port = args.port
81
+ else:
82
+ port = _free_port()
83
+ try:
84
+ server = Server(("127.0.0.1", port), cfg, verbose=args.verbose)
85
+ except OSError as exc:
86
+ print("could not bind 127.0.0.1:%d: %s" % (port, exc), file=sys.stderr)
87
+ return 1
88
+
89
+ url = "http://127.0.0.1:%d/?t=%s" % (port, server.token)
90
+ caps = server.capabilities
91
+ # flush explicitly: stdout is block-buffered when redirected to a file or
92
+ # pipe, which would hide the URL (and its token) until the server exits.
93
+ where = cfg.get("start") or "(no default set - pick a bucket in the browser)"
94
+ print("s3view %s -> %s" % (__version__, where))
95
+ print(" %s" % url)
96
+ onoff = lambda k: "on" if caps.get(k) else "off" # noqa: E731
97
+ print(" thumbnails:%s fits:%s asdf:%s (ctrl-c to quit)"
98
+ % (onoff("images"), onoff("fits"), onoff("asdf")), flush=True)
99
+
100
+ if not args.no_open:
101
+ threading.Timer(0.3, lambda: webbrowser.open(url)).start()
102
+ try:
103
+ server.serve_forever()
104
+ except KeyboardInterrupt:
105
+ print("\nbye")
106
+ finally:
107
+ server.shutdown()
108
+ server.server_close()
109
+ return 0
s3view/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """User configuration: bookmarks, defaults, cache locations."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+
7
+ # No bucket is baked into the source. The start location and bookmarks live in
8
+ # the user's own config file, written on first run and editable from the UI.
9
+ DEFAULT_BOOKMARK = None
10
+
11
+ CONFIG_DIR = os.path.expanduser(os.environ.get("S3VIEW_CONFIG_DIR", "~/.config/s3view"))
12
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
13
+ CACHE_DIR = os.path.expanduser(os.environ.get("S3VIEW_CACHE_DIR", "~/.cache/s3view"))
14
+ THUMB_DIR = os.path.join(CACHE_DIR, "thumbs")
15
+
16
+ DEFAULTS = {
17
+ "start": DEFAULT_BOOKMARK,
18
+ "bookmarks": [],
19
+ "profile": None,
20
+ "region": None,
21
+ "endpoint_url": None,
22
+ "page_size": 1000,
23
+ "presign_expires": 3600,
24
+ "external_player": "IINA",
25
+ }
26
+
27
+ _lock = threading.Lock()
28
+
29
+
30
+ def _ensure_dirs():
31
+ os.makedirs(CONFIG_DIR, exist_ok=True)
32
+ os.makedirs(THUMB_DIR, exist_ok=True)
33
+
34
+
35
+ def load():
36
+ """Load config, creating it with defaults on first run."""
37
+ _ensure_dirs()
38
+ cfg = dict(DEFAULTS)
39
+ try:
40
+ with open(CONFIG_PATH) as fh:
41
+ cfg.update(json.load(fh))
42
+ except FileNotFoundError:
43
+ save(cfg)
44
+ except (OSError, ValueError):
45
+ pass # corrupt config: fall back to defaults rather than refusing to start
46
+ return cfg
47
+
48
+
49
+ def save(cfg):
50
+ _ensure_dirs()
51
+ with _lock:
52
+ tmp = CONFIG_PATH + ".tmp"
53
+ with open(tmp, "w") as fh:
54
+ json.dump(cfg, fh, indent=2)
55
+ os.replace(tmp, CONFIG_PATH)
56
+ return cfg
57
+
58
+
59
+ def parse_uri(uri):
60
+ """'s3://bucket/some/prefix/' -> ('bucket', 'some/prefix/')."""
61
+ if uri.startswith("s3://"):
62
+ uri = uri[5:]
63
+ uri = uri.lstrip("/")
64
+ bucket, _, prefix = uri.partition("/")
65
+ if prefix and not prefix.endswith("/"):
66
+ prefix += "/"
67
+ return bucket, prefix