FastWARC 1.0.8__tar.gz → 1.1.0__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.
- {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/PKG-INFO +1 -1
- {fastwarc-1.0.8 → fastwarc-1.1.0}/PKG-INFO +1 -1
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/Cargo.toml +3 -4
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/cli.py +138 -108
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/__init__.py +16 -4
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/lib.rs +1 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/warc.rs +13 -22
- {fastwarc-1.0.8 → fastwarc-1.1.0}/pyproject.toml +2 -1
- {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/SOURCES.txt +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/dependency_links.txt +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/entry_points.txt +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/requires.txt +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/top_level.txt +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/MANIFEST.in +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/README.md +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/__init__.py +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/__init__.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/build.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/__init__.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/__init__.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/_shims.py +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/stream_io.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/stream_io.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/stream_io.pyx +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/tools.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/tools.pyx +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/warc.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/warc.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/warc.pyx +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/py.typed +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/brotli.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/chunked.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/gzip.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/lz4.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/mod.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/zstd.rs +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/stream_io.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/warc.pyi +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_common/__init__.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_common/string_util.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/__init__.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/algorithm.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/atomic.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/boost_regex.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/cctype.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/cstdlib.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/cstring.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/dlfcn.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/errno.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/lexbor.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/lz4frame.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/lz4hc.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/pthread.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/re2.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/stdio.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/string_view.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/time.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/uchardet.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/unistd.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/utility.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/zlib.pxd +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/setup.cfg +0 -0
- {fastwarc-1.0.8 → fastwarc-1.1.0}/setup.py +0 -0
|
@@ -8,12 +8,11 @@ name = "fastwarc"
|
|
|
8
8
|
path = "src/lib.rs"
|
|
9
9
|
crate-type = [
|
|
10
10
|
"cdylib",
|
|
11
|
-
"rlib",
|
|
12
11
|
]
|
|
13
12
|
|
|
14
13
|
[dependencies]
|
|
15
|
-
fastwarc = "1.0
|
|
16
|
-
pyo3 = "0.
|
|
14
|
+
fastwarc = "1.1.0"
|
|
15
|
+
pyo3 = "0.30"
|
|
17
16
|
|
|
18
17
|
[dependencies.time]
|
|
19
18
|
version = "0.3"
|
|
@@ -24,6 +23,6 @@ features = [
|
|
|
24
23
|
]
|
|
25
24
|
|
|
26
25
|
[build-dependencies]
|
|
27
|
-
pyo3-build-config = "0.
|
|
26
|
+
pyo3-build-config = "0.30"
|
|
28
27
|
|
|
29
28
|
[workspace]
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
+
from contextlib import closing, nullcontext
|
|
15
16
|
import getpass
|
|
16
17
|
import importlib
|
|
17
18
|
from itertools import chain
|
|
@@ -24,24 +25,78 @@ import urllib.request
|
|
|
24
25
|
import click
|
|
25
26
|
from tqdm import tqdm
|
|
26
27
|
|
|
27
|
-
from fastwarc.stream_io import
|
|
28
|
+
from fastwarc.stream_io import GzipReader, GzipWriter, Lz4Reader, Lz4Writer, ZstdReader, ZstdWriter
|
|
28
29
|
from fastwarc.warc import ArchiveIterator, WarcRecordType
|
|
29
|
-
from fastwarc.legacy.tools import CompressionAlg, detect_compression_algorithm, wrap_warc_stream, \
|
|
30
|
-
recompress_warc_interactive, verify_digests
|
|
31
30
|
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
sys.exit(1)
|
|
37
|
-
|
|
38
|
-
raise value
|
|
39
|
-
|
|
32
|
+
COMPRESSION_ALGORITHMS = ['gzip', 'lz4', 'zstd', 'uncompressed', 'auto']
|
|
33
|
+
_READERS = {'gzip': GzipReader, 'lz4': Lz4Reader, 'zstd': ZstdReader}
|
|
34
|
+
_WRITERS = {'gzip': GzipWriter, 'lz4': Lz4Writer, 'zstd': ZstdWriter}
|
|
40
35
|
|
|
41
|
-
sys.excepthook = exception_handler
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
class _FastWarcGroup(click.Group):
|
|
38
|
+
def invoke(self, ctx):
|
|
39
|
+
try:
|
|
40
|
+
return super().invoke(ctx)
|
|
41
|
+
except (OSError, ValueError) as error:
|
|
42
|
+
raise click.ClickException(str(error)) from error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _compression_from_path(path):
|
|
46
|
+
name = os.fspath(path).lower()
|
|
47
|
+
for suffix, algorithm in (('.gz', 'gzip'), ('.lz4', 'lz4'), ('.zst', 'zstd'), ('.zstd', 'zstd')):
|
|
48
|
+
if name.endswith(suffix):
|
|
49
|
+
return algorithm
|
|
50
|
+
return 'uncompressed'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _warc_records(source, decompress_alg='auto', **options):
|
|
54
|
+
reader = _READERS[decompress_alg](source) if decompress_alg in _READERS else nullcontext(source)
|
|
55
|
+
with reader as source:
|
|
56
|
+
yield from ArchiveIterator(source, stream_detect=decompress_alg == 'auto', **options)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _recompress_records(infile, outfile, decompress_alg, compress_alg, compress_level):
|
|
60
|
+
if os.path.abspath(infile) == os.path.abspath(outfile) or (
|
|
61
|
+
os.path.exists(outfile) and os.path.samefile(infile, outfile)):
|
|
62
|
+
raise click.UsageError('Input and output must be different files.')
|
|
63
|
+
if compress_alg == 'auto':
|
|
64
|
+
compress_alg = _compression_from_path(outfile)
|
|
65
|
+
if compress_level is not None and compress_alg not in ('gzip', 'zstd'):
|
|
66
|
+
raise click.BadParameter('Compression levels are supported only for gzip and zstd.',
|
|
67
|
+
param_hint='--compress-level')
|
|
68
|
+
with closing(_warc_records(infile, decompress_alg, parse_http=False, inplace=True)) as records, \
|
|
69
|
+
open(outfile, 'wb') as output:
|
|
70
|
+
args = {} if compress_level is None else {'compression_level': compress_level}
|
|
71
|
+
writer = _WRITERS[compress_alg](output, **args) if compress_alg in _WRITERS else nullcontext(output)
|
|
72
|
+
with writer as writer:
|
|
73
|
+
for record in records:
|
|
74
|
+
before = output.tell()
|
|
75
|
+
record.write(writer)
|
|
76
|
+
if compress_alg in _WRITERS:
|
|
77
|
+
writer.finish() # One independently seekable compression member per record.
|
|
78
|
+
yield output.tell() - before
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _verify_digests(infile, verify_payloads, decompress_alg):
|
|
82
|
+
with closing(_warc_records(infile, decompress_alg, parse_http=False, inplace=True)) as records:
|
|
83
|
+
for record in records:
|
|
84
|
+
result = {
|
|
85
|
+
'record_id': record.record_id,
|
|
86
|
+
'block_digest_ok': record.verify_block_digest(not verify_payloads or not record.is_http)
|
|
87
|
+
if 'WARC-Block-Digest' in record.headers else None,
|
|
88
|
+
}
|
|
89
|
+
if verify_payloads:
|
|
90
|
+
if 'WARC-Payload-Digest' in record.headers:
|
|
91
|
+
if record.is_http:
|
|
92
|
+
record.parse_http()
|
|
93
|
+
result['payload_digest_ok'] = record.verify_payload_digest(True)
|
|
94
|
+
else:
|
|
95
|
+
result['payload_digest_ok'] = None
|
|
96
|
+
yield result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@click.group(cls=_FastWarcGroup, context_settings=dict(help_option_names=['-h', '--help']))
|
|
45
100
|
def main():
|
|
46
101
|
"""FastWARC Command Line Interface."""
|
|
47
102
|
return 0
|
|
@@ -51,7 +106,7 @@ def _human_readable_bytes(byte_num):
|
|
|
51
106
|
for unit in ['bytes', 'KiB', 'MiB', 'GiB', 'TiB']:
|
|
52
107
|
if byte_num <= 1024 or unit == 'TiB':
|
|
53
108
|
if int(byte_num) == byte_num:
|
|
54
|
-
return f'{byte_num
|
|
109
|
+
return f'{int(byte_num)} {unit}'
|
|
55
110
|
return f'{byte_num:.2f} {unit}'
|
|
56
111
|
byte_num /= 1024
|
|
57
112
|
|
|
@@ -59,47 +114,33 @@ def _human_readable_bytes(byte_num):
|
|
|
59
114
|
@main.command()
|
|
60
115
|
@click.argument('infile', type=click.Path(dir_okay=False, exists=True))
|
|
61
116
|
@click.argument('outfile', type=click.Path(dir_okay=False, exists=False))
|
|
62
|
-
@click.option('-c', '--compress-alg', type=click.Choice(
|
|
117
|
+
@click.option('-c', '--compress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
63
118
|
default='auto', show_default=True, help='Compression algorithm to use for output file')
|
|
64
|
-
@click.option('-d', '--decompress-alg', type=click.Choice(
|
|
119
|
+
@click.option('-d', '--decompress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
65
120
|
default='auto', show_default=True,
|
|
66
|
-
help='Decompression algorithm for decoding input file (auto
|
|
67
|
-
@click.option('-l', '--compress-level', type=int, default=None,
|
|
121
|
+
help='Decompression algorithm for decoding input file (auto detects input compression)')
|
|
122
|
+
@click.option('-l', '--compress-level', type=int, default=None,
|
|
123
|
+
help='Compression level for gzip or zstd (defaults: gzip 9, zstd 3)')
|
|
68
124
|
@click.option('-q', '--quiet', is_flag=True, help='Do not print progress information')
|
|
69
125
|
def recompress(infile, outfile, compress_alg, decompress_alg, compress_level, quiet):
|
|
70
126
|
"""
|
|
71
127
|
Recompress a WARC file.
|
|
72
128
|
|
|
73
129
|
This command allows you to recompress a WARC file if it is uncompressed or not compressed
|
|
74
|
-
properly at the record
|
|
130
|
+
properly at the record level, or to change between GZip, LZ4, and Zstandard.
|
|
75
131
|
"""
|
|
76
132
|
|
|
77
|
-
compress_alg = getattr(CompressionAlg, compress_alg)
|
|
78
|
-
decompress_alg = getattr(CompressionAlg, decompress_alg)
|
|
79
|
-
|
|
80
|
-
comp_args = {}
|
|
81
|
-
if compress_level is None:
|
|
82
|
-
if compress_alg == CompressionAlg.gzip:
|
|
83
|
-
comp_args['compression_level'] = 9
|
|
84
|
-
elif compress_alg == CompressionAlg.lz4:
|
|
85
|
-
comp_args['compression_level'] = 12
|
|
86
|
-
|
|
87
133
|
bytes_written = 0
|
|
88
134
|
num = 0
|
|
89
135
|
start = time.time()
|
|
90
136
|
try:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
with closing(_recompress_records(infile, outfile, decompress_alg, compress_alg, compress_level)) as records:
|
|
138
|
+
for b in tqdm(records, desc='Recompressing WARC file', unit=' record(s)', leave=False,
|
|
139
|
+
disable=quiet, mininterval=0.2):
|
|
140
|
+
num += 1
|
|
141
|
+
bytes_written += b
|
|
97
142
|
if not quiet:
|
|
98
143
|
click.echo('Recompression completed.')
|
|
99
|
-
except KeyboardInterrupt:
|
|
100
|
-
if not quiet:
|
|
101
|
-
click.echo('Recompression aborted.')
|
|
102
|
-
sys.exit(1)
|
|
103
144
|
finally:
|
|
104
145
|
if not quiet and num > 0:
|
|
105
146
|
click.echo(f' - Records recompressed: {num}')
|
|
@@ -109,7 +150,7 @@ def recompress(infile, outfile, compress_alg, decompress_alg, compress_level, qu
|
|
|
109
150
|
|
|
110
151
|
@main.command()
|
|
111
152
|
@click.argument('infile', type=click.Path(dir_okay=False, exists=True))
|
|
112
|
-
@click.option('-d', '--decompress-alg', type=click.Choice(
|
|
153
|
+
@click.option('-d', '--decompress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
113
154
|
default='auto', show_default=True, help='Decompression algorithm')
|
|
114
155
|
@click.option('-p', '--verify-payloads', is_flag=True, help='Also verify payload digests')
|
|
115
156
|
@click.option('-q', '--quiet', is_flag=True, help='Do not print progress information')
|
|
@@ -125,14 +166,12 @@ def check(infile, decompress_alg, verify_payloads, quiet, output):
|
|
|
125
166
|
The command will exit with a non-zero exit code if at least one record fails verification.
|
|
126
167
|
"""
|
|
127
168
|
|
|
128
|
-
decompress_alg = getattr(CompressionAlg, decompress_alg)
|
|
129
|
-
|
|
130
169
|
failed_digests = []
|
|
131
170
|
num_total = 0
|
|
132
171
|
num_no_digest = 0
|
|
133
172
|
try:
|
|
134
173
|
desc_tpl = 'Verifying digests ({} failed)'
|
|
135
|
-
pbar = tqdm(
|
|
174
|
+
pbar = tqdm(_verify_digests(infile, verify_payloads, decompress_alg),
|
|
136
175
|
desc=desc_tpl.format(0), unit=' record(s)', leave=False, disable=quiet, mininterval=0.2)
|
|
137
176
|
|
|
138
177
|
for v in pbar:
|
|
@@ -161,13 +200,6 @@ def check(infile, decompress_alg, verify_payloads, quiet, output):
|
|
|
161
200
|
if output:
|
|
162
201
|
output.write(f'{v["record_id"]}: {status}\n')
|
|
163
202
|
|
|
164
|
-
if output:
|
|
165
|
-
output.close()
|
|
166
|
-
|
|
167
|
-
except KeyboardInterrupt:
|
|
168
|
-
if not quiet:
|
|
169
|
-
click.echo('Verification aborted.')
|
|
170
|
-
sys.exit(1)
|
|
171
203
|
finally:
|
|
172
204
|
if not quiet and num_total > 0:
|
|
173
205
|
if not failed_digests:
|
|
@@ -179,13 +211,15 @@ def check(infile, decompress_alg, verify_payloads, quiet, output):
|
|
|
179
211
|
click.echo('===============')
|
|
180
212
|
for rec in failed_digests:
|
|
181
213
|
click.echo(rec)
|
|
182
|
-
|
|
214
|
+
|
|
215
|
+
if failed_digests:
|
|
216
|
+
raise click.exceptions.Exit(1)
|
|
183
217
|
|
|
184
218
|
|
|
185
219
|
@main.command()
|
|
186
220
|
@click.argument('infile', type=click.Path(dir_okay=False, exists=True))
|
|
187
|
-
@click.argument('offset', type=
|
|
188
|
-
@click.option('-o', '--output', type=click.File('wb'), default=
|
|
221
|
+
@click.argument('offset', type=click.IntRange(min=0))
|
|
222
|
+
@click.option('-o', '--output', type=click.File('wb'), default='-',
|
|
189
223
|
help='Output file, default is stdout')
|
|
190
224
|
@click.option('--payload', is_flag=True,
|
|
191
225
|
help='Output only record payload (transfer and/or content encoding are preserved')
|
|
@@ -202,15 +236,14 @@ def extract(infile, offset, output, payload, headers):
|
|
|
202
236
|
with open(infile, 'rb') as stream:
|
|
203
237
|
stream.seek(offset)
|
|
204
238
|
try:
|
|
205
|
-
|
|
239
|
+
iterator = ArchiveIterator(stream)
|
|
240
|
+
record = next(iterator)
|
|
206
241
|
if compl_record:
|
|
207
242
|
record.write(output)
|
|
208
243
|
elif headers:
|
|
209
|
-
record.headers.write(
|
|
210
|
-
output.write(b'\r\n')
|
|
244
|
+
record.headers.write(output)
|
|
211
245
|
if record.is_http:
|
|
212
|
-
record.http_headers.write(
|
|
213
|
-
output.write(b'\r\n')
|
|
246
|
+
record.http_headers.write(output)
|
|
214
247
|
elif payload:
|
|
215
248
|
buf = record.reader.read(4096)
|
|
216
249
|
while buf:
|
|
@@ -218,8 +251,8 @@ def extract(infile, offset, output, payload, headers):
|
|
|
218
251
|
buf = record.reader.read(4096)
|
|
219
252
|
except StopIteration:
|
|
220
253
|
return
|
|
221
|
-
except (
|
|
222
|
-
click.
|
|
254
|
+
except (OSError, ValueError) as e:
|
|
255
|
+
raise click.ClickException(f'Failed to extract WARC record at offset {offset}: {e}') from e
|
|
223
256
|
|
|
224
257
|
|
|
225
258
|
def _index_record(output, fields, preserve_multi_header, record, next_record_offset, file_name):
|
|
@@ -237,14 +270,11 @@ def _index_record(output, fields, preserve_multi_header, record, next_record_off
|
|
|
237
270
|
idx[f] = str(record.http_headers.status_code)
|
|
238
271
|
elif f.startswith('http:') and record.is_http:
|
|
239
272
|
if preserve_multi_header:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
idx[f] = l[0]
|
|
246
|
-
elif len(l) > 1:
|
|
247
|
-
idx[f] = l
|
|
273
|
+
values = record.http_headers.get_multiple(f[5:])
|
|
274
|
+
if len(values) == 1:
|
|
275
|
+
idx[f] = values[0]
|
|
276
|
+
elif values:
|
|
277
|
+
idx[f] = list(values)
|
|
248
278
|
else:
|
|
249
279
|
idx[f] = record.http_headers.get(f[5:])
|
|
250
280
|
elif f in record.headers:
|
|
@@ -255,7 +285,7 @@ def _index_record(output, fields, preserve_multi_header, record, next_record_off
|
|
|
255
285
|
|
|
256
286
|
@main.command()
|
|
257
287
|
@click.argument('infiles', type=click.Path(dir_okay=False, exists=True), nargs=-1)
|
|
258
|
-
@click.option('-o', '--output', type=click.File('w'), default=
|
|
288
|
+
@click.option('-o', '--output', type=click.File('w'), default='-',
|
|
259
289
|
help='Output file, default is stdout')
|
|
260
290
|
@click.option('-f', '--fields', type=str, metavar='FIELDS',
|
|
261
291
|
default='offset,warc-type,warc-target-uri', show_default=True,
|
|
@@ -272,15 +302,14 @@ def index(infiles, output, fields, preserve_multi_header):
|
|
|
272
302
|
"""
|
|
273
303
|
fields = fields.split(',')
|
|
274
304
|
for infile in infiles:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
for record in ArchiveIterator(stream):
|
|
278
|
-
if prev_record is not None:
|
|
279
|
-
_index_record(output, fields, preserve_multi_header, prev_record, record.stream_pos, infile)
|
|
280
|
-
prev_record = record
|
|
281
|
-
|
|
305
|
+
prev_record = None
|
|
306
|
+
for record in ArchiveIterator(infile):
|
|
282
307
|
if prev_record is not None:
|
|
283
|
-
_index_record(output, fields, preserve_multi_header, prev_record,
|
|
308
|
+
_index_record(output, fields, preserve_multi_header, prev_record, record.stream_pos, infile)
|
|
309
|
+
prev_record = record
|
|
310
|
+
|
|
311
|
+
if prev_record is not None:
|
|
312
|
+
_index_record(output, fields, preserve_multi_header, prev_record, os.path.getsize(infile), infile)
|
|
284
313
|
|
|
285
314
|
|
|
286
315
|
boto3 = None
|
|
@@ -325,12 +354,12 @@ def _get_raw_stream_from_url(input_url, use_python_stream=False):
|
|
|
325
354
|
|
|
326
355
|
else:
|
|
327
356
|
if input_url.startswith('file://'):
|
|
328
|
-
input_url = input_url[7:]
|
|
357
|
+
input_url = urllib.request.url2pathname(input_url[7:])
|
|
329
358
|
if not os.path.isfile(input_url):
|
|
330
359
|
click.echo(f"No such file or directory: '{input_url}", err=True)
|
|
331
360
|
sys.exit(1)
|
|
332
361
|
|
|
333
|
-
stream =
|
|
362
|
+
stream = open(input_url, 'rb') if use_python_stream else input_url
|
|
334
363
|
|
|
335
364
|
return stream
|
|
336
365
|
|
|
@@ -351,18 +380,18 @@ def _expand_s3_prefixes(input_urls, endpoint_url, aws_access_key, aws_secret_key
|
|
|
351
380
|
# noinspection PyPackageRequirements
|
|
352
381
|
@main.command()
|
|
353
382
|
@click.argument('input_url', nargs=-1)
|
|
354
|
-
@click.option('-d', '--decompress-alg', type=click.Choice(
|
|
383
|
+
@click.option('-d', '--decompress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
355
384
|
default='auto', show_default=True, help='Decompression algorithm')
|
|
356
385
|
@click.option('-e', '--endpoint-url', help='S3 endpoint URL', default='https://s3.amazonaws.com', show_default=True)
|
|
357
386
|
@click.option('-a', '--aws-access-key', help='AWS access key for s3:// URLs')
|
|
358
387
|
@click.option('-s', '--aws-secret-key', help='AWS secret key for s3:// URLs (leave empty to read from STDIN)')
|
|
359
388
|
@click.option('--is-prefix', is_flag=True, help='Treat input URL as prefix (only for S3)')
|
|
360
389
|
@click.option('-p', '--use-python-stream', is_flag=True,
|
|
361
|
-
help='Use
|
|
390
|
+
help='Use Python I/O instead of native file readers for local files')
|
|
362
391
|
@click.option('-H', '--parse-http', is_flag=True, help='Parse HTTP headers')
|
|
363
392
|
@click.option('-v', '--verify-digests', is_flag=True, help='Verify record block digests')
|
|
364
393
|
@click.option('-f', '--filter-type', type=click.Choice(['warcinfo', 'response', 'resource', 'request', 'metadata',
|
|
365
|
-
'revisit', '
|
|
394
|
+
'revisit', 'conversion', 'continuation', 'unknown', 'any_type']),
|
|
366
395
|
default=['any_type'], multiple=True, show_default=True, help='Filter for specific WARC record types')
|
|
367
396
|
@click.option('-w', '--bench-warcio', is_flag=True, help='Compare FastWARC performance with WARCIO')
|
|
368
397
|
def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secret_key, is_prefix, use_python_stream,
|
|
@@ -374,7 +403,7 @@ def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secre
|
|
|
374
403
|
performance on your own machine. The benchmarking results can be compared directly with WARCIO.
|
|
375
404
|
|
|
376
405
|
Supported WARC sources are local files, S3 and HTTP(s) URLs. Supported compression algorithms
|
|
377
|
-
are GZip, LZ4, or uncompressed.
|
|
406
|
+
are GZip, LZ4, Zstandard, or uncompressed.
|
|
378
407
|
|
|
379
408
|
The read benchmarking tool has additional options, such as reading WARCs directly from a remote S3 data source
|
|
380
409
|
using `Boto3 <https://boto3.amazonaws.com/v1/documentation/api/latest/index.html>`_.
|
|
@@ -393,15 +422,10 @@ def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secre
|
|
|
393
422
|
_init_s3(endpoint_url, aws_access_key, aws_secret_key)
|
|
394
423
|
break
|
|
395
424
|
|
|
396
|
-
if decompress_alg == 'auto':
|
|
397
|
-
decompress_alg = detect_compression_algorithm(input_url[0])
|
|
398
|
-
else:
|
|
399
|
-
decompress_alg = getattr(CompressionAlg, decompress_alg)
|
|
400
|
-
|
|
401
425
|
if bench_warcio:
|
|
402
|
-
if decompress_alg ==
|
|
403
|
-
|
|
404
|
-
|
|
426
|
+
algorithms = {_compression_from_path(url) for url in input_url} if decompress_alg == 'auto' else {decompress_alg}
|
|
427
|
+
if algorithms & {'lz4', 'zstd'}:
|
|
428
|
+
raise click.UsageError('WARCIO does not support LZ4 or Zstandard.')
|
|
405
429
|
|
|
406
430
|
try:
|
|
407
431
|
import warcio
|
|
@@ -419,7 +443,7 @@ def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secre
|
|
|
419
443
|
|
|
420
444
|
def _bench(urls, stream_loader, lib):
|
|
421
445
|
interrupted = False
|
|
422
|
-
start = time.
|
|
446
|
+
start = time.perf_counter()
|
|
423
447
|
num = 0
|
|
424
448
|
|
|
425
449
|
try:
|
|
@@ -429,20 +453,24 @@ def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secre
|
|
|
429
453
|
for _ in tqdm(chain(*(_load_lazy(u, stream_loader) for u in urls)),
|
|
430
454
|
desc=f'Benchmarking {lib}', unit=' records', leave=False, mininterval=0.5):
|
|
431
455
|
num += 1
|
|
432
|
-
except
|
|
433
|
-
click.
|
|
434
|
-
sys.exit(1)
|
|
456
|
+
except (OSError, ValueError) as e:
|
|
457
|
+
raise click.ClickException(f'Error reading input: {e}') from e
|
|
435
458
|
except KeyboardInterrupt:
|
|
436
459
|
click.echo(f'Benchmark interrupted.', err=True)
|
|
437
460
|
interrupted = True
|
|
438
461
|
|
|
439
|
-
|
|
462
|
+
# Short or empty inputs can finish within one clock tick.
|
|
463
|
+
elapsed = max(time.perf_counter() - start, time.get_clock_info('perf_counter').resolution)
|
|
464
|
+
return num, elapsed, interrupted
|
|
440
465
|
|
|
441
466
|
def _fastwarc_iterator(f):
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
467
|
+
source = _get_raw_stream_from_url(f, use_python_stream)
|
|
468
|
+
try:
|
|
469
|
+
yield from _warc_records(source, decompress_alg, record_types=rec_type_filter,
|
|
470
|
+
parse_http=parse_http, verify_digests=verify_digests, inplace=True)
|
|
471
|
+
finally:
|
|
472
|
+
if not isinstance(source, str):
|
|
473
|
+
source.close()
|
|
446
474
|
|
|
447
475
|
n, t_fastwarc, interrupted = _bench(input_url, _fastwarc_iterator, 'FastWARC')
|
|
448
476
|
click.echo(f'FastWARC: {n:,} records read in {t_fastwarc:.02f} seconds ({n / t_fastwarc:,.02f} records/s).')
|
|
@@ -452,17 +480,19 @@ def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secre
|
|
|
452
480
|
|
|
453
481
|
if bench_warcio:
|
|
454
482
|
def _warcio_iterator(f):
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
for rec in warcio.ArchiveIterator(s, not parse_http):
|
|
461
|
-
if rec_type_filter & getattr(WarcRecordType, rec.rec_type) != 0:
|
|
483
|
+
source = _get_raw_stream_from_url(f, True)
|
|
484
|
+
try:
|
|
485
|
+
for rec in warcio.ArchiveIterator(source, no_record_parse=not parse_http,
|
|
486
|
+
check_digests=verify_digests):
|
|
487
|
+
if rec_type_filter & getattr(WarcRecordType, rec.rec_type):
|
|
462
488
|
yield rec
|
|
489
|
+
finally:
|
|
490
|
+
source.close()
|
|
463
491
|
|
|
464
|
-
n, t_warcio,
|
|
492
|
+
n, t_warcio, interrupted = _bench(input_url, _warcio_iterator, 'WARCIO')
|
|
465
493
|
click.echo(f'WARCIO: {n:,} records read in {t_warcio:.02f} seconds ({n / t_warcio:,.02f} records/s).')
|
|
494
|
+
if interrupted:
|
|
495
|
+
raise click.exceptions.Exit(1)
|
|
466
496
|
click.echo(f'Time difference: {t_fastwarc - t_warcio:.02f} seconds, speedup: {t_warcio / t_fastwarc:,.02f}')
|
|
467
497
|
|
|
468
498
|
|
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
from .stream_io import FastWARCError, StreamError
|
|
17
|
-
from .warc import ArchiveIterator, WarcRecord, WarcRecordType
|
|
15
|
+
import importlib
|
|
18
16
|
|
|
19
|
-
#
|
|
17
|
+
# Load Cython exports only when requested. Importing the Rust-backed package's
|
|
18
|
+
# compatibility shims must not require the legacy extensions.
|
|
20
19
|
|
|
21
20
|
__all__ = [
|
|
22
21
|
"FileStream",
|
|
@@ -28,3 +27,16 @@ __all__ = [
|
|
|
28
27
|
"WarcRecord",
|
|
29
28
|
"WarcRecordType"
|
|
30
29
|
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name):
|
|
33
|
+
if name not in __all__:
|
|
34
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
35
|
+
module = '.warc' if name in ('ArchiveIterator', 'WarcRecord', 'WarcRecordType') else '.stream_io'
|
|
36
|
+
value = getattr(importlib.import_module(module, __name__), name)
|
|
37
|
+
globals()[name] = value
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __dir__():
|
|
42
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -459,15 +459,11 @@ impl HeaderMapPy {
|
|
|
459
459
|
///
|
|
460
460
|
pub fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
|
|
461
461
|
let dict = PyDict::new(py);
|
|
462
|
-
|
|
462
|
+
self.with_headers(|h| {
|
|
463
463
|
h.to_map()
|
|
464
464
|
.into_iter()
|
|
465
|
-
.
|
|
466
|
-
|
|
467
|
-
});
|
|
468
|
-
for (key, value) in items {
|
|
469
|
-
dict.set_item(key.as_str(), value.as_str())?;
|
|
470
|
-
}
|
|
465
|
+
.try_for_each(|(key, value)| dict.set_item(key.as_ref(), value.as_str()))
|
|
466
|
+
})?;
|
|
471
467
|
Ok(dict)
|
|
472
468
|
}
|
|
473
469
|
|
|
@@ -523,14 +519,10 @@ impl HeaderMapPy {
|
|
|
523
519
|
/// :param key: header key
|
|
524
520
|
pub fn get_multiple<'py>(&self, py: Python<'py>, key: HeaderKeyStrPy) -> PyResult<Bound<'py, PyTuple>> {
|
|
525
521
|
let key = key.into_string();
|
|
526
|
-
|
|
527
|
-
h.get_multiple(&key)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
.collect::<Vec<_>>()
|
|
531
|
-
});
|
|
532
|
-
let items = values.into_iter().map(|s| PyString::new(py, &s));
|
|
533
|
-
PyTuple::new(py, items)
|
|
522
|
+
self.with_headers(|h| {
|
|
523
|
+
let items = h.get_multiple(&key).into_iter().map(|s| PyString::new(py, &s));
|
|
524
|
+
PyTuple::new(py, items)
|
|
525
|
+
})
|
|
534
526
|
}
|
|
535
527
|
|
|
536
528
|
#[pyo3(signature = (key, default=None))]
|
|
@@ -548,14 +540,13 @@ impl HeaderMapPy {
|
|
|
548
540
|
|
|
549
541
|
pub fn get_bytes_multiple<'py>(&self, py: Python<'py>, key: HeaderKeyBytesPy) -> PyResult<Bound<'py, PyTuple>> {
|
|
550
542
|
let key = key.into_bytes();
|
|
551
|
-
|
|
552
|
-
h
|
|
543
|
+
self.with_headers(|h| {
|
|
544
|
+
let items = h
|
|
545
|
+
.get_bytes_multiple(&key)
|
|
553
546
|
.into_iter()
|
|
554
|
-
.map(|s| s.
|
|
555
|
-
|
|
556
|
-
})
|
|
557
|
-
let items = values.into_iter().map(|s| PyBytes::new(py, &s));
|
|
558
|
-
PyTuple::new(py, items)
|
|
547
|
+
.map(|s| PyBytes::new(py, s.as_ref()));
|
|
548
|
+
PyTuple::new(py, items)
|
|
549
|
+
})
|
|
559
550
|
}
|
|
560
551
|
|
|
561
552
|
pub fn contains_key(&self, key: HeaderKeyStrPy) -> bool {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
[build-system]
|
|
2
2
|
requires = [
|
|
3
3
|
"setuptools>=77.0",
|
|
4
|
+
"packaging>=24.2",
|
|
4
5
|
"setuptools-rust>=1.10",
|
|
5
6
|
"Cython>=0.29.33",
|
|
6
7
|
"tomli>=2.0.0; python_version < '3.11'",
|
|
@@ -9,7 +10,7 @@ build-backend = "setuptools.build_meta"
|
|
|
9
10
|
|
|
10
11
|
[project]
|
|
11
12
|
name = "FastWARC"
|
|
12
|
-
version = "1.0
|
|
13
|
+
version = "1.1.0"
|
|
13
14
|
description = "The world's fastest WARC parsing library written in Rust with bindings for Python."
|
|
14
15
|
authors = [
|
|
15
16
|
{ name = "Janek Bevendorff" }
|
|
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
|
|
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
|
|
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
|
|
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
|