storetle 0.2.0__tar.gz → 0.2.1__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.
- {storetle-0.2.0/storetle.egg-info → storetle-0.2.1}/PKG-INFO +19 -1
- {storetle-0.2.0 → storetle-0.2.1}/README.md +18 -0
- {storetle-0.2.0 → storetle-0.2.1}/pyproject.toml +1 -1
- {storetle-0.2.0 → storetle-0.2.1}/storetle/__init__.py +3 -2
- {storetle-0.2.0 → storetle-0.2.1}/storetle/cli.py +29 -12
- storetle-0.2.1/storetle/remote.py +173 -0
- {storetle-0.2.0 → storetle-0.2.1/storetle.egg-info}/PKG-INFO +19 -1
- {storetle-0.2.0 → storetle-0.2.1}/storetle.egg-info/SOURCES.txt +1 -0
- {storetle-0.2.0 → storetle-0.2.1}/LICENSE +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/setup.cfg +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/brotli_compat.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/cube_dict_v10.bin +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/decoder.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/encoder.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/folder.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/stream.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/vocab.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/warc.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle/zstd_compat.py +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle.egg-info/dependency_links.txt +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle.egg-info/entry_points.txt +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle.egg-info/requires.txt +0 -0
- {storetle-0.2.0 → storetle-0.2.1}/storetle.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storetle
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: HTML-aware compression for document corpora — solid-archive ratios with random access
|
|
5
5
|
Author-email: Davis Brief <davis@team8.co>
|
|
6
6
|
License: MIT
|
|
@@ -96,6 +96,24 @@ storetle to-warc archive.storetle out.warc.gz
|
|
|
96
96
|
storetle train my_corpus/ --output my.bin # domain-specific dictionary
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## Remote archives (v0.2.1)
|
|
100
|
+
|
|
101
|
+
`get`, `info`, and `unpack` accept URLs. Opening an archive costs a few KB
|
|
102
|
+
of Range requests; fetching a document downloads only its ~2MB chunk — no
|
|
103
|
+
server-side code, works against any Range-capable host (R2, S3, GitHub
|
|
104
|
+
Pages, nginx):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
storetle info https://adventurelands.github.io/storetle/sample.storetle
|
|
108
|
+
storetle get https://adventurelands.github.io/storetle/sample.storetle 4
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from storetle import RemoteReader
|
|
113
|
+
with RemoteReader('https://host/corpus.storetle') as r:
|
|
114
|
+
html = r[42] # one ~2MB range request
|
|
115
|
+
```
|
|
116
|
+
|
|
99
117
|
## Python API
|
|
100
118
|
|
|
101
119
|
```python
|
|
@@ -73,6 +73,24 @@ storetle to-warc archive.storetle out.warc.gz
|
|
|
73
73
|
storetle train my_corpus/ --output my.bin # domain-specific dictionary
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
## Remote archives (v0.2.1)
|
|
77
|
+
|
|
78
|
+
`get`, `info`, and `unpack` accept URLs. Opening an archive costs a few KB
|
|
79
|
+
of Range requests; fetching a document downloads only its ~2MB chunk — no
|
|
80
|
+
server-side code, works against any Range-capable host (R2, S3, GitHub
|
|
81
|
+
Pages, nginx):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
storetle info https://adventurelands.github.io/storetle/sample.storetle
|
|
85
|
+
storetle get https://adventurelands.github.io/storetle/sample.storetle 4
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from storetle import RemoteReader
|
|
90
|
+
with RemoteReader('https://host/corpus.storetle') as r:
|
|
91
|
+
html = r[42] # one ~2MB range request
|
|
92
|
+
```
|
|
93
|
+
|
|
76
94
|
## Python API
|
|
77
95
|
|
|
78
96
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "storetle"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "HTML-aware compression for document corpora — solid-archive ratios with random access"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
# benchmark — compare storetle vs gzip on your own data
|
|
9
9
|
|
|
10
10
|
from .stream import StreamWriter, StreamReader
|
|
11
|
+
from .remote import RemoteReader
|
|
11
12
|
from .folder import pack, unpack
|
|
12
13
|
|
|
13
|
-
__version__ = '0.2.
|
|
14
|
-
__all__ = ['StreamWriter', 'StreamReader', 'pack', 'unpack', 'benchmark']
|
|
14
|
+
__version__ = '0.2.1'
|
|
15
|
+
__all__ = ['StreamWriter', 'StreamReader', 'RemoteReader', 'pack', 'unpack', 'benchmark']
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def benchmark(folder, quiet=False):
|
|
@@ -54,16 +54,28 @@ def cmd_pack(args):
|
|
|
54
54
|
print(f'Output: {output}')
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def _is_url(s):
|
|
58
|
+
return s.startswith('http://') or s.startswith('https://')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _open_reader(src):
|
|
62
|
+
"""Open a local path with StreamReader or a URL with RemoteReader."""
|
|
63
|
+
if _is_url(src):
|
|
64
|
+
from .remote import RemoteReader
|
|
65
|
+
return RemoteReader(src)
|
|
66
|
+
from .stream import StreamReader
|
|
67
|
+
return StreamReader(src)
|
|
68
|
+
|
|
69
|
+
|
|
57
70
|
def cmd_unpack(args):
|
|
58
71
|
if len(args) < 2:
|
|
59
|
-
print('Usage: storetle unpack <file
|
|
72
|
+
print('Usage: storetle unpack <file-or-url> <output_folder>')
|
|
60
73
|
sys.exit(1)
|
|
61
|
-
from .stream import StreamReader
|
|
62
74
|
src = args[0]
|
|
63
75
|
dst = Path(args[1])
|
|
64
76
|
dst.mkdir(parents=True, exist_ok=True)
|
|
65
77
|
|
|
66
|
-
with
|
|
78
|
+
with _open_reader(src) as r:
|
|
67
79
|
print(f'Extracting {r.doc_count} documents to {dst}/')
|
|
68
80
|
for i, doc in enumerate(r):
|
|
69
81
|
out = dst / f'doc_{i:06d}.html'
|
|
@@ -75,15 +87,20 @@ def cmd_unpack(args):
|
|
|
75
87
|
|
|
76
88
|
def cmd_info(args):
|
|
77
89
|
if not args:
|
|
78
|
-
print('Usage: storetle info <file
|
|
90
|
+
print('Usage: storetle info <file-or-url>')
|
|
79
91
|
sys.exit(1)
|
|
80
|
-
from .stream import StreamReader
|
|
81
92
|
|
|
82
93
|
def fmt(n):
|
|
83
94
|
if n < 1048576: return f'{n/1024:.1f}KB'
|
|
84
95
|
return f'{n/1048576:.2f}MB'
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
if _is_url(args[0]):
|
|
98
|
+
from .remote import RemoteReader
|
|
99
|
+
with RemoteReader(args[0]) as r:
|
|
100
|
+
info = r.info()
|
|
101
|
+
else:
|
|
102
|
+
from .stream import StreamReader
|
|
103
|
+
info = StreamReader.info(args[0])
|
|
87
104
|
print(f'\n {args[0]}')
|
|
88
105
|
print(f' Documents: {info["docs"]:,}')
|
|
89
106
|
print(f' Chunks: {info["chunks"]:,}')
|
|
@@ -94,10 +111,9 @@ def cmd_info(args):
|
|
|
94
111
|
|
|
95
112
|
def cmd_get(args):
|
|
96
113
|
if len(args) < 2:
|
|
97
|
-
print('Usage: storetle get <file
|
|
114
|
+
print('Usage: storetle get <file-or-url> <index>')
|
|
98
115
|
sys.exit(1)
|
|
99
|
-
|
|
100
|
-
with StreamReader(args[0]) as r:
|
|
116
|
+
with _open_reader(args[0]) as r:
|
|
101
117
|
try:
|
|
102
118
|
idx = int(args[1])
|
|
103
119
|
doc = r[idx]
|
|
@@ -254,9 +270,10 @@ HELP = """storetle — HTML-aware compression for large document collections
|
|
|
254
270
|
Commands:
|
|
255
271
|
bench <folder> Benchmark your HTML data vs gzip WARC
|
|
256
272
|
pack <folder> <output> Compress a folder → .storetle file
|
|
257
|
-
unpack <file> <
|
|
258
|
-
info <file>
|
|
259
|
-
get <file> <index>
|
|
273
|
+
unpack <file-or-url> <out_folder> Extract a .storetle → HTML files
|
|
274
|
+
info <file-or-url> Show file statistics
|
|
275
|
+
get <file-or-url> <index> Extract one doc by index — over HTTP this
|
|
276
|
+
fetches only the containing ~2MB chunk
|
|
260
277
|
from-warc <input.warc[.gz]> <out> Convert WARC → .storetle
|
|
261
278
|
to-warc <input.storetle> <out> Convert .storetle → WARC (or .warc.gz)
|
|
262
279
|
warc-encode <input.warc[.gz]> <out> Encode HTML in-place → valid .warc.gz (smaller, standard format)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# remote.py — read .storetle archives over HTTP(S) with Range requests.
|
|
2
|
+
#
|
|
3
|
+
# Opens an archive with at most three small requests (footer+index tail,
|
|
4
|
+
# header, dictionary if embedded), then fetches exactly one chunk span
|
|
5
|
+
# (≤ ~2 MB compressed) per document access. Works against any server or
|
|
6
|
+
# object store that honors Range (S3, R2, GitHub Pages, nginx, ...).
|
|
7
|
+
#
|
|
8
|
+
# Stdlib only — urllib, struct.
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .stream import STREAM_MAGIC, STREAM_VERSION, _decompress, _decode_doc
|
|
15
|
+
|
|
16
|
+
_DEFAULT_DICT_PATH = Path(__file__).parent / 'cube_dict_v10.bin'
|
|
17
|
+
|
|
18
|
+
# One speculative tail fetch usually captures index + footer in a single
|
|
19
|
+
# round trip (index entries are 14 bytes; 64 KB covers ~4,600 chunks ≈
|
|
20
|
+
# 1.2M documents).
|
|
21
|
+
_TAIL_BYTES = 64 * 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RemoteReader:
|
|
25
|
+
"""Random-access reader for a .storetle file served over HTTP(S).
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
with RemoteReader('https://host/corpus.storetle') as r:
|
|
29
|
+
print(r.doc_count)
|
|
30
|
+
html = r[42]
|
|
31
|
+
for doc in r:
|
|
32
|
+
...
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, url, dictionary=None, timeout=30):
|
|
36
|
+
self._url = url
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
self._chunk_cache = (None, None) # (chunk_idx, [decoded raw docs])
|
|
39
|
+
self.bytes_fetched = 0
|
|
40
|
+
|
|
41
|
+
tail = self._fetch_suffix(_TAIL_BYTES)
|
|
42
|
+
if len(tail) < 16:
|
|
43
|
+
raise ValueError('File too small to be a .storetle archive')
|
|
44
|
+
chunk_count, index_offset = struct.unpack('>QQ', tail[-16:])
|
|
45
|
+
|
|
46
|
+
index_size = chunk_count * 14
|
|
47
|
+
if index_size + 16 <= len(tail):
|
|
48
|
+
index_raw = tail[-(index_size + 16):-16]
|
|
49
|
+
else:
|
|
50
|
+
index_raw = self._fetch(index_offset, index_offset + index_size - 1)
|
|
51
|
+
|
|
52
|
+
self._index = []
|
|
53
|
+
for i in range(chunk_count):
|
|
54
|
+
off, dc, orig = struct.unpack_from('>QHI', index_raw, i * 14)
|
|
55
|
+
self._index.append((off, dc, orig))
|
|
56
|
+
|
|
57
|
+
# chunk i occupies [offset_i, offset_{i+1}); the last ends at the index
|
|
58
|
+
self._chunk_ends = [self._index[i + 1][0] for i in range(chunk_count - 1)]
|
|
59
|
+
self._chunk_ends.append(index_offset)
|
|
60
|
+
|
|
61
|
+
# cumulative doc counts for index lookup
|
|
62
|
+
self._cum = [0]
|
|
63
|
+
for _, dc, _ in self._index:
|
|
64
|
+
self._cum.append(self._cum[-1] + dc)
|
|
65
|
+
self.doc_count = self._cum[-1]
|
|
66
|
+
|
|
67
|
+
head = self._fetch(0, 8)
|
|
68
|
+
if head[:4] != STREAM_MAGIC:
|
|
69
|
+
raise ValueError('Not a .storetle file (magic: %r)' % head[:4])
|
|
70
|
+
if head[4] != STREAM_VERSION:
|
|
71
|
+
raise ValueError('Unsupported version %d (reader is v%d)'
|
|
72
|
+
% (head[4], STREAM_VERSION))
|
|
73
|
+
dict_size = struct.unpack('>I', head[5:9])[0]
|
|
74
|
+
|
|
75
|
+
if dictionary is not None:
|
|
76
|
+
self._dict = dictionary
|
|
77
|
+
elif dict_size:
|
|
78
|
+
self._dict = self._fetch(9, 9 + dict_size - 1)
|
|
79
|
+
else:
|
|
80
|
+
self._dict = _DEFAULT_DICT_PATH.read_bytes() \
|
|
81
|
+
if _DEFAULT_DICT_PATH.exists() else b''
|
|
82
|
+
|
|
83
|
+
# -- HTTP plumbing ------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _fetch(self, start, end):
|
|
86
|
+
return self._range_request('bytes=%d-%d' % (start, end))
|
|
87
|
+
|
|
88
|
+
def _fetch_suffix(self, n):
|
|
89
|
+
return self._range_request('bytes=-%d' % n)
|
|
90
|
+
|
|
91
|
+
def _range_request(self, range_header):
|
|
92
|
+
req = urllib.request.Request(self._url, headers={
|
|
93
|
+
'Range': range_header,
|
|
94
|
+
'User-Agent': 'storetle-remote/0.2.1',
|
|
95
|
+
})
|
|
96
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
97
|
+
if resp.status not in (200, 206):
|
|
98
|
+
raise IOError('HTTP %d for %s' % (resp.status, self._url))
|
|
99
|
+
if resp.status == 200 and range_header != 'bytes=0-':
|
|
100
|
+
raise IOError(
|
|
101
|
+
'Server ignored Range request — remote access needs a '
|
|
102
|
+
'server that supports HTTP Range (got full response)')
|
|
103
|
+
data = resp.read()
|
|
104
|
+
self.bytes_fetched += len(data)
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
# -- document access ----------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _load_chunk(self, ci):
|
|
110
|
+
if self._chunk_cache[0] == ci:
|
|
111
|
+
return self._chunk_cache[1]
|
|
112
|
+
off, expect_dc, _ = self._index[ci]
|
|
113
|
+
raw = self._fetch(off, self._chunk_ends[ci] - 1)
|
|
114
|
+
dc, _orig, comp_size = struct.unpack_from('>HII', raw, 0)
|
|
115
|
+
if dc != expect_dc:
|
|
116
|
+
raise ValueError('Chunk %d header disagrees with index' % ci)
|
|
117
|
+
sizes = struct.unpack_from('>%dI' % dc, raw, 10)
|
|
118
|
+
blob = _decompress(raw[10 + dc * 4: 10 + dc * 4 + comp_size], self._dict)
|
|
119
|
+
docs, pos = [], 0
|
|
120
|
+
for s in sizes:
|
|
121
|
+
docs.append(blob[pos:pos + s])
|
|
122
|
+
pos += s
|
|
123
|
+
self._chunk_cache = (ci, docs)
|
|
124
|
+
return docs
|
|
125
|
+
|
|
126
|
+
def _locate(self, idx):
|
|
127
|
+
lo, hi = 0, len(self._index) - 1
|
|
128
|
+
while lo < hi:
|
|
129
|
+
mid = (lo + hi) // 2
|
|
130
|
+
if self._cum[mid + 1] <= idx:
|
|
131
|
+
lo = mid + 1
|
|
132
|
+
else:
|
|
133
|
+
hi = mid
|
|
134
|
+
return lo
|
|
135
|
+
|
|
136
|
+
def __len__(self):
|
|
137
|
+
return self.doc_count
|
|
138
|
+
|
|
139
|
+
def __getitem__(self, idx):
|
|
140
|
+
if isinstance(idx, slice):
|
|
141
|
+
return [self[i] for i in range(*idx.indices(self.doc_count))]
|
|
142
|
+
if idx < 0:
|
|
143
|
+
idx += self.doc_count
|
|
144
|
+
if not 0 <= idx < self.doc_count:
|
|
145
|
+
raise IndexError('doc %d out of range (%d docs)' % (idx, self.doc_count))
|
|
146
|
+
ci = self._locate(idx)
|
|
147
|
+
docs = self._load_chunk(ci)
|
|
148
|
+
return _decode_doc(docs[idx - self._cum[ci]])
|
|
149
|
+
|
|
150
|
+
def __iter__(self):
|
|
151
|
+
for ci in range(len(self._index)):
|
|
152
|
+
for raw in self._load_chunk(ci):
|
|
153
|
+
yield _decode_doc(raw)
|
|
154
|
+
|
|
155
|
+
def info(self):
|
|
156
|
+
comp = self._chunk_ends[-1] - self._index[0][0] if self._index else 0
|
|
157
|
+
return {
|
|
158
|
+
'docs': self.doc_count,
|
|
159
|
+
'chunks': len(self._index),
|
|
160
|
+
'original_bytes': sum(orig for _, _, orig in self._index),
|
|
161
|
+
'compressed_bytes': comp,
|
|
162
|
+
'ratio_pct': round(100 * (1 - comp / max(1, sum(o for _, _, o in self._index))), 2),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def close(self):
|
|
166
|
+
self._chunk_cache = (None, None)
|
|
167
|
+
|
|
168
|
+
def __enter__(self):
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def __exit__(self, *exc):
|
|
172
|
+
self.close()
|
|
173
|
+
return False
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: storetle
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: HTML-aware compression for document corpora — solid-archive ratios with random access
|
|
5
5
|
Author-email: Davis Brief <davis@team8.co>
|
|
6
6
|
License: MIT
|
|
@@ -96,6 +96,24 @@ storetle to-warc archive.storetle out.warc.gz
|
|
|
96
96
|
storetle train my_corpus/ --output my.bin # domain-specific dictionary
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
## Remote archives (v0.2.1)
|
|
100
|
+
|
|
101
|
+
`get`, `info`, and `unpack` accept URLs. Opening an archive costs a few KB
|
|
102
|
+
of Range requests; fetching a document downloads only its ~2MB chunk — no
|
|
103
|
+
server-side code, works against any Range-capable host (R2, S3, GitHub
|
|
104
|
+
Pages, nginx):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
storetle info https://adventurelands.github.io/storetle/sample.storetle
|
|
108
|
+
storetle get https://adventurelands.github.io/storetle/sample.storetle 4
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from storetle import RemoteReader
|
|
113
|
+
with RemoteReader('https://host/corpus.storetle') as r:
|
|
114
|
+
html = r[42] # one ~2MB range request
|
|
115
|
+
```
|
|
116
|
+
|
|
99
117
|
## Python API
|
|
100
118
|
|
|
101
119
|
```python
|
|
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
|