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 +3 -0
- s3view/__main__.py +4 -0
- s3view/_deps.py +41 -0
- s3view/asdfview.py +331 -0
- s3view/cli.py +109 -0
- s3view/config.py +67 -0
- s3view/fitsview.py +337 -0
- s3view/s3client.py +327 -0
- s3view/server.py +469 -0
- s3view/static/app.js +969 -0
- s3view/static/index.html +69 -0
- s3view/static/style.css +221 -0
- s3view/thumbs.py +118 -0
- s3view-0.1.0.dist-info/METADATA +300 -0
- s3view-0.1.0.dist-info/RECORD +19 -0
- s3view-0.1.0.dist-info/WHEEL +5 -0
- s3view-0.1.0.dist-info/entry_points.txt +2 -0
- s3view-0.1.0.dist-info/licenses/LICENSE +21 -0
- s3view-0.1.0.dist-info/top_level.txt +1 -0
s3view/__init__.py
ADDED
s3view/__main__.py
ADDED
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
|