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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: storetle
3
- Version: 0.2.0
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.0"
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.0'
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.storetle> <output_folder>')
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 StreamReader(src) as r:
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.storetle>')
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
- info = StreamReader.info(args[0])
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.storetle> <index>')
114
+ print('Usage: storetle get <file-or-url> <index>')
98
115
  sys.exit(1)
99
- from .stream import StreamReader
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> <output_folder> Extract a .storetle → HTML files
258
- info <file> Show file statistics
259
- get <file> <index> Extract one document by index (0-based)
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.0
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
@@ -8,6 +8,7 @@ storetle/cube_dict_v10.bin
8
8
  storetle/decoder.py
9
9
  storetle/encoder.py
10
10
  storetle/folder.py
11
+ storetle/remote.py
11
12
  storetle/stream.py
12
13
  storetle/vocab.py
13
14
  storetle/warc.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes