FastWARC 1.1.0__cp315-cp315-win_amd64.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.
- fastwarc/__init__.py +56 -0
- fastwarc/__init__.pyi +50 -0
- fastwarc/_fastwarc.cp315-win_amd64.pyd +0 -0
- fastwarc/cli.py +500 -0
- fastwarc/legacy/__init__.py +42 -0
- fastwarc/legacy/__init__.pyi +19 -0
- fastwarc/legacy/_shims.py +213 -0
- fastwarc/legacy/stream_io.cp315-win_amd64.pyd +0 -0
- fastwarc/legacy/stream_io.pyi +105 -0
- fastwarc/legacy/tools.cp315-win_amd64.pyd +0 -0
- fastwarc/legacy/tools.pyi +48 -0
- fastwarc/legacy/warc.cp315-win_amd64.pyd +0 -0
- fastwarc/legacy/warc.pyi +118 -0
- fastwarc/py.typed +0 -0
- fastwarc/stream_io.pyi +189 -0
- fastwarc/warc.pyi +422 -0
- fastwarc-1.1.0.dist-info/DELVEWHEEL +2 -0
- fastwarc-1.1.0.dist-info/METADATA +111 -0
- fastwarc-1.1.0.dist-info/RECORD +25 -0
- fastwarc-1.1.0.dist-info/WHEEL +5 -0
- fastwarc-1.1.0.dist-info/entry_points.txt +2 -0
- fastwarc-1.1.0.dist-info/top_level.txt +1 -0
- fastwarc.libs/lz4-60e7a707b8438217cb40e52e87a30f3e.dll +0 -0
- fastwarc.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
- fastwarc.libs/zlib1-0d2dbacfff940ac2f6648025e67d72ee.dll +0 -0
fastwarc/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright 2026 Janek Bevendorff
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# start delvewheel patch
|
|
17
|
+
def _delvewheel_patch_1_13_2():
|
|
18
|
+
import os
|
|
19
|
+
if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'fastwarc.libs'))):
|
|
20
|
+
os.add_dll_directory(libs_dir)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_delvewheel_patch_1_13_2()
|
|
24
|
+
del _delvewheel_patch_1_13_2
|
|
25
|
+
# end delvewheel patch
|
|
26
|
+
|
|
27
|
+
import importlib
|
|
28
|
+
|
|
29
|
+
_native = importlib.import_module("._fastwarc", __name__)
|
|
30
|
+
_legacy_shims = importlib.import_module(".legacy._shims", __name__)
|
|
31
|
+
|
|
32
|
+
import fastwarc.stream_io as stream_io # noqa: F401
|
|
33
|
+
import fastwarc.warc as warc # noqa: F401
|
|
34
|
+
|
|
35
|
+
# Re-export module attributes
|
|
36
|
+
globals().update({attr: getattr(_native, attr) for attr in _native.__all__})
|
|
37
|
+
|
|
38
|
+
# Patch legacy shims onto stream_io
|
|
39
|
+
for name in _legacy_shims.__all__:
|
|
40
|
+
setattr(stream_io, name, getattr(_legacy_shims, name))
|
|
41
|
+
stream_io.__all__ += getattr(_legacy_shims, "__all__", ()) # type: ignore[attr-defined]
|
|
42
|
+
|
|
43
|
+
FileStream = _legacy_shims.FileStream
|
|
44
|
+
GZipStream = _legacy_shims.GZipStream
|
|
45
|
+
LZ4Stream = _legacy_shims.LZ4Stream
|
|
46
|
+
FastWARCError = _legacy_shims.FastWARCError
|
|
47
|
+
StreamError = _legacy_shims.StreamError
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
*_native.__all__,
|
|
51
|
+
'FileStream',
|
|
52
|
+
'GZipStream',
|
|
53
|
+
'LZ4Stream',
|
|
54
|
+
'FastWARCError',
|
|
55
|
+
'StreamError',
|
|
56
|
+
]
|
fastwarc/__init__.pyi
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2026 Janek Bevendorff
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from .warc import ArchiveIterator, WarcHeader, WarcRecord, WarcRecordType
|
|
16
|
+
from .stream_io import (
|
|
17
|
+
GzipReader,
|
|
18
|
+
GzipWriter,
|
|
19
|
+
ZstdReader,
|
|
20
|
+
ZstdWriter,
|
|
21
|
+
Lz4Reader,
|
|
22
|
+
Lz4Writer,
|
|
23
|
+
|
|
24
|
+
# Legacy names
|
|
25
|
+
FileStream,
|
|
26
|
+
GZipStream,
|
|
27
|
+
LZ4Stream,
|
|
28
|
+
FastWARCError,
|
|
29
|
+
StreamError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
'ArchiveIterator',
|
|
34
|
+
'WarcHeader',
|
|
35
|
+
'WarcRecord',
|
|
36
|
+
'WarcRecordType',
|
|
37
|
+
'GzipReader',
|
|
38
|
+
'GzipWriter',
|
|
39
|
+
'ZstdReader',
|
|
40
|
+
'ZstdWriter',
|
|
41
|
+
'Lz4Reader',
|
|
42
|
+
'Lz4Writer',
|
|
43
|
+
|
|
44
|
+
# Legacy names
|
|
45
|
+
'FileStream',
|
|
46
|
+
'GZipStream',
|
|
47
|
+
'LZ4Stream',
|
|
48
|
+
'FastWARCError',
|
|
49
|
+
'StreamError',
|
|
50
|
+
]
|
|
Binary file
|
fastwarc/cli.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# Copyright 2021 Janek Bevendorff
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from contextlib import closing, nullcontext
|
|
16
|
+
import getpass
|
|
17
|
+
import importlib
|
|
18
|
+
from itertools import chain
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
from tqdm import tqdm
|
|
27
|
+
|
|
28
|
+
from fastwarc.stream_io import GzipReader, GzipWriter, Lz4Reader, Lz4Writer, ZstdReader, ZstdWriter
|
|
29
|
+
from fastwarc.warc import ArchiveIterator, WarcRecordType
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
COMPRESSION_ALGORITHMS = ['gzip', 'lz4', 'zstd', 'uncompressed', 'auto']
|
|
33
|
+
_READERS = {'gzip': GzipReader, 'lz4': Lz4Reader, 'zstd': ZstdReader}
|
|
34
|
+
_WRITERS = {'gzip': GzipWriter, 'lz4': Lz4Writer, 'zstd': ZstdWriter}
|
|
35
|
+
|
|
36
|
+
|
|
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']))
|
|
100
|
+
def main():
|
|
101
|
+
"""FastWARC Command Line Interface."""
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _human_readable_bytes(byte_num):
|
|
106
|
+
for unit in ['bytes', 'KiB', 'MiB', 'GiB', 'TiB']:
|
|
107
|
+
if byte_num <= 1024 or unit == 'TiB':
|
|
108
|
+
if int(byte_num) == byte_num:
|
|
109
|
+
return f'{int(byte_num)} {unit}'
|
|
110
|
+
return f'{byte_num:.2f} {unit}'
|
|
111
|
+
byte_num /= 1024
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@main.command()
|
|
115
|
+
@click.argument('infile', type=click.Path(dir_okay=False, exists=True))
|
|
116
|
+
@click.argument('outfile', type=click.Path(dir_okay=False, exists=False))
|
|
117
|
+
@click.option('-c', '--compress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
118
|
+
default='auto', show_default=True, help='Compression algorithm to use for output file')
|
|
119
|
+
@click.option('-d', '--decompress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
120
|
+
default='auto', show_default=True,
|
|
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)')
|
|
124
|
+
@click.option('-q', '--quiet', is_flag=True, help='Do not print progress information')
|
|
125
|
+
def recompress(infile, outfile, compress_alg, decompress_alg, compress_level, quiet):
|
|
126
|
+
"""
|
|
127
|
+
Recompress a WARC file.
|
|
128
|
+
|
|
129
|
+
This command allows you to recompress a WARC file if it is uncompressed or not compressed
|
|
130
|
+
properly at the record level, or to change between GZip, LZ4, and Zstandard.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
bytes_written = 0
|
|
134
|
+
num = 0
|
|
135
|
+
start = time.time()
|
|
136
|
+
try:
|
|
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
|
|
142
|
+
if not quiet:
|
|
143
|
+
click.echo('Recompression completed.')
|
|
144
|
+
finally:
|
|
145
|
+
if not quiet and num > 0:
|
|
146
|
+
click.echo(f' - Records recompressed: {num}')
|
|
147
|
+
click.echo(f' - Bytes written: {_human_readable_bytes(bytes_written)}')
|
|
148
|
+
click.echo(f' - Completed in: {time.time() - start:.02f} seconds')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@main.command()
|
|
152
|
+
@click.argument('infile', type=click.Path(dir_okay=False, exists=True))
|
|
153
|
+
@click.option('-d', '--decompress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
154
|
+
default='auto', show_default=True, help='Decompression algorithm')
|
|
155
|
+
@click.option('-p', '--verify-payloads', is_flag=True, help='Also verify payload digests')
|
|
156
|
+
@click.option('-q', '--quiet', is_flag=True, help='Do not print progress information')
|
|
157
|
+
@click.option('-o', '--output', type=click.File('w'), help='Output file with verification details')
|
|
158
|
+
def check(infile, decompress_alg, verify_payloads, quiet, output):
|
|
159
|
+
"""
|
|
160
|
+
Verify WARC record consistency.
|
|
161
|
+
|
|
162
|
+
Check digests of all records in a WARC file and print a summary. You can verify all block and
|
|
163
|
+
payload digests in the given WARC file and print a summary of all corrupted and (optionally)
|
|
164
|
+
all intact records.
|
|
165
|
+
|
|
166
|
+
The command will exit with a non-zero exit code if at least one record fails verification.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
failed_digests = []
|
|
170
|
+
num_total = 0
|
|
171
|
+
num_no_digest = 0
|
|
172
|
+
try:
|
|
173
|
+
desc_tpl = 'Verifying digests ({} failed)'
|
|
174
|
+
pbar = tqdm(_verify_digests(infile, verify_payloads, decompress_alg),
|
|
175
|
+
desc=desc_tpl.format(0), unit=' record(s)', leave=False, disable=quiet, mininterval=0.2)
|
|
176
|
+
|
|
177
|
+
for v in pbar:
|
|
178
|
+
num_total += 1
|
|
179
|
+
|
|
180
|
+
status = 'OK'
|
|
181
|
+
if v['block_digest_ok'] is False:
|
|
182
|
+
status = 'FAIL'
|
|
183
|
+
elif v['block_digest_ok'] is None:
|
|
184
|
+
status = 'NO_DIGEST'
|
|
185
|
+
|
|
186
|
+
if 'payload_digest_ok' in v:
|
|
187
|
+
if v['payload_digest_ok'] is True:
|
|
188
|
+
status += ', PAYLOAD_OK'
|
|
189
|
+
elif v['payload_digest_ok'] is False:
|
|
190
|
+
status += ', PAYLOAD_FAIL'
|
|
191
|
+
elif v['payload_digest_ok'] is None:
|
|
192
|
+
status += ', PAYLOAD_NO_DIGEST'
|
|
193
|
+
|
|
194
|
+
if 'FAIL' in status:
|
|
195
|
+
failed_digests.append(v['record_id'])
|
|
196
|
+
pbar.set_description(desc_tpl.format(len(failed_digests)))
|
|
197
|
+
elif 'NO_DIGEST' in status and 'OK' not in status:
|
|
198
|
+
num_no_digest += 1
|
|
199
|
+
|
|
200
|
+
if output:
|
|
201
|
+
output.write(f'{v["record_id"]}: {status}\n')
|
|
202
|
+
|
|
203
|
+
finally:
|
|
204
|
+
if not quiet and num_total > 0:
|
|
205
|
+
if not failed_digests:
|
|
206
|
+
click.echo(f'{num_total - num_no_digest} records were verified successfully.')
|
|
207
|
+
if num_no_digest:
|
|
208
|
+
click.echo(f'{num_no_digest} records were skipped without digest.')
|
|
209
|
+
else:
|
|
210
|
+
click.echo('Failed records:')
|
|
211
|
+
click.echo('===============')
|
|
212
|
+
for rec in failed_digests:
|
|
213
|
+
click.echo(rec)
|
|
214
|
+
|
|
215
|
+
if failed_digests:
|
|
216
|
+
raise click.exceptions.Exit(1)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@main.command()
|
|
220
|
+
@click.argument('infile', type=click.Path(dir_okay=False, exists=True))
|
|
221
|
+
@click.argument('offset', type=click.IntRange(min=0))
|
|
222
|
+
@click.option('-o', '--output', type=click.File('wb'), default='-',
|
|
223
|
+
help='Output file, default is stdout')
|
|
224
|
+
@click.option('--payload', is_flag=True,
|
|
225
|
+
help='Output only record payload (transfer and/or content encoding are preserved')
|
|
226
|
+
@click.option('--headers', is_flag=True, help='Output only record (and HTTP) headers')
|
|
227
|
+
def extract(infile, offset, output, payload, headers):
|
|
228
|
+
"""
|
|
229
|
+
Extract WARC record by offset.
|
|
230
|
+
|
|
231
|
+
You can extract individual records at a given byte offset with either just
|
|
232
|
+
headers, payload, or both.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
compl_record = not (payload or headers)
|
|
236
|
+
with open(infile, 'rb') as stream:
|
|
237
|
+
stream.seek(offset)
|
|
238
|
+
try:
|
|
239
|
+
iterator = ArchiveIterator(stream)
|
|
240
|
+
record = next(iterator)
|
|
241
|
+
if compl_record:
|
|
242
|
+
record.write(output)
|
|
243
|
+
elif headers:
|
|
244
|
+
record.headers.write(output)
|
|
245
|
+
if record.is_http:
|
|
246
|
+
record.http_headers.write(output)
|
|
247
|
+
elif payload:
|
|
248
|
+
buf = record.reader.read(4096)
|
|
249
|
+
while buf:
|
|
250
|
+
output.write(buf)
|
|
251
|
+
buf = record.reader.read(4096)
|
|
252
|
+
except StopIteration:
|
|
253
|
+
return
|
|
254
|
+
except (OSError, ValueError) as e:
|
|
255
|
+
raise click.ClickException(f'Failed to extract WARC record at offset {offset}: {e}') from e
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _index_record(output, fields, preserve_multi_header, record, next_record_offset, file_name):
|
|
259
|
+
idx = dict()
|
|
260
|
+
for f in fields:
|
|
261
|
+
f = f.strip().lower()
|
|
262
|
+
|
|
263
|
+
if f == 'offset':
|
|
264
|
+
idx[f] = str(record.stream_pos)
|
|
265
|
+
elif f == 'length':
|
|
266
|
+
idx[f] = str(next_record_offset - record.stream_pos)
|
|
267
|
+
elif f == 'filename':
|
|
268
|
+
idx[f] = file_name
|
|
269
|
+
elif f == 'http:status' and record.is_http:
|
|
270
|
+
idx[f] = str(record.http_headers.status_code)
|
|
271
|
+
elif f.startswith('http:') and record.is_http:
|
|
272
|
+
if preserve_multi_header:
|
|
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)
|
|
278
|
+
else:
|
|
279
|
+
idx[f] = record.http_headers.get(f[5:])
|
|
280
|
+
elif f in record.headers:
|
|
281
|
+
idx[f] = record.headers.get(f)
|
|
282
|
+
|
|
283
|
+
output.write(json.dumps(idx) + '\n')
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@main.command()
|
|
287
|
+
@click.argument('infiles', type=click.Path(dir_okay=False, exists=True), nargs=-1)
|
|
288
|
+
@click.option('-o', '--output', type=click.File('w'), default='-',
|
|
289
|
+
help='Output file, default is stdout')
|
|
290
|
+
@click.option('-f', '--fields', type=str, metavar='FIELDS',
|
|
291
|
+
default='offset,warc-type,warc-target-uri', show_default=True,
|
|
292
|
+
help='Comma-separated list of indexed fields, eg. "offset", "length", "filename", '
|
|
293
|
+
'"http:status", "http:<http-header>", or "<warc-record-header>"')
|
|
294
|
+
@click.option('--preserve-multi-header', is_flag=True,
|
|
295
|
+
help='Preserve multiple values of HTTP headers as JSON list')
|
|
296
|
+
def index(infiles, output, fields, preserve_multi_header):
|
|
297
|
+
"""
|
|
298
|
+
Index WARC records as CDXJ.
|
|
299
|
+
|
|
300
|
+
WARC files can be indexed to the `CDXJ <https://github.com/webrecorder/cdxj-indexer>`_
|
|
301
|
+
format with a configurable set of fields.
|
|
302
|
+
"""
|
|
303
|
+
fields = fields.split(',')
|
|
304
|
+
for infile in infiles:
|
|
305
|
+
prev_record = None
|
|
306
|
+
for record in ArchiveIterator(infile):
|
|
307
|
+
if prev_record is not None:
|
|
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)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
boto3 = None
|
|
316
|
+
botocore = None
|
|
317
|
+
s3 = None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _init_s3(endpoint_url, aws_access_key, aws_secret_key=None):
|
|
321
|
+
global boto3, botocore, s3
|
|
322
|
+
|
|
323
|
+
if s3 is not None:
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
boto3 = importlib.import_module('boto3')
|
|
328
|
+
botocore = importlib.import_module('botocore')
|
|
329
|
+
except ModuleNotFoundError:
|
|
330
|
+
raise click.UsageError('Boto3 needs to be installed for S3 benchmarking.')
|
|
331
|
+
|
|
332
|
+
if not aws_access_key:
|
|
333
|
+
raise click.UsageError("s3:// URLs require '--aws-access-key' to be set.")
|
|
334
|
+
if not aws_secret_key:
|
|
335
|
+
aws_secret_key = getpass.getpass()
|
|
336
|
+
|
|
337
|
+
s3 = boto3.resource('s3', endpoint_url=endpoint_url, aws_access_key_id=aws_access_key,
|
|
338
|
+
aws_secret_access_key=aws_secret_key)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# noinspection PyProtectedMember, HttpUrlsUsage
|
|
342
|
+
def _get_raw_stream_from_url(input_url, use_python_stream=False):
|
|
343
|
+
if input_url.startswith('s3://'):
|
|
344
|
+
assert s3 is not None
|
|
345
|
+
try:
|
|
346
|
+
s3_bucket, s3_object = input_url[5:].split('/', 1)
|
|
347
|
+
stream = s3.Object(s3_bucket, s3_object).get()['Body']._raw_stream
|
|
348
|
+
except botocore.exceptions.ClientError as e:
|
|
349
|
+
click.echo(f'Error connecting to S3. Message: {e}.', err=True)
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
elif input_url.startswith('http://') or input_url.startswith('https://'):
|
|
353
|
+
stream = urllib.request.urlopen(input_url)
|
|
354
|
+
|
|
355
|
+
else:
|
|
356
|
+
if input_url.startswith('file://'):
|
|
357
|
+
input_url = urllib.request.url2pathname(input_url[7:])
|
|
358
|
+
if not os.path.isfile(input_url):
|
|
359
|
+
click.echo(f"No such file or directory: '{input_url}", err=True)
|
|
360
|
+
sys.exit(1)
|
|
361
|
+
|
|
362
|
+
stream = open(input_url, 'rb') if use_python_stream else input_url
|
|
363
|
+
|
|
364
|
+
return stream
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _expand_s3_prefixes(input_urls, endpoint_url, aws_access_key, aws_secret_key):
|
|
368
|
+
urls_expanded = []
|
|
369
|
+
for url in input_urls:
|
|
370
|
+
if url.startswith('s3://'):
|
|
371
|
+
_init_s3(endpoint_url, aws_access_key, aws_secret_key)
|
|
372
|
+
s3_bucket, s3_object_prefix = url[5:].split('/', 1)
|
|
373
|
+
urls_expanded.extend('/'.join(('s3:/', s3_bucket, o.key))
|
|
374
|
+
for o in s3.Bucket(s3_bucket).objects.filter(Prefix=s3_object_prefix))
|
|
375
|
+
else:
|
|
376
|
+
urls_expanded.append(url)
|
|
377
|
+
return urls_expanded
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# noinspection PyPackageRequirements
|
|
381
|
+
@main.command()
|
|
382
|
+
@click.argument('input_url', nargs=-1)
|
|
383
|
+
@click.option('-d', '--decompress-alg', type=click.Choice(COMPRESSION_ALGORITHMS),
|
|
384
|
+
default='auto', show_default=True, help='Decompression algorithm')
|
|
385
|
+
@click.option('-e', '--endpoint-url', help='S3 endpoint URL', default='https://s3.amazonaws.com', show_default=True)
|
|
386
|
+
@click.option('-a', '--aws-access-key', help='AWS access key for s3:// URLs')
|
|
387
|
+
@click.option('-s', '--aws-secret-key', help='AWS secret key for s3:// URLs (leave empty to read from STDIN)')
|
|
388
|
+
@click.option('--is-prefix', is_flag=True, help='Treat input URL as prefix (only for S3)')
|
|
389
|
+
@click.option('-p', '--use-python-stream', is_flag=True,
|
|
390
|
+
help='Use Python I/O instead of native file readers for local files')
|
|
391
|
+
@click.option('-H', '--parse-http', is_flag=True, help='Parse HTTP headers')
|
|
392
|
+
@click.option('-v', '--verify-digests', is_flag=True, help='Verify record block digests')
|
|
393
|
+
@click.option('-f', '--filter-type', type=click.Choice(['warcinfo', 'response', 'resource', 'request', 'metadata',
|
|
394
|
+
'revisit', 'conversion', 'continuation', 'unknown', 'any_type']),
|
|
395
|
+
default=['any_type'], multiple=True, show_default=True, help='Filter for specific WARC record types')
|
|
396
|
+
@click.option('-w', '--bench-warcio', is_flag=True, help='Compare FastWARC performance with WARCIO')
|
|
397
|
+
def benchmark(input_url, decompress_alg, endpoint_url, aws_access_key, aws_secret_key, is_prefix, use_python_stream,
|
|
398
|
+
verify_digests, parse_http, filter_type, bench_warcio):
|
|
399
|
+
"""
|
|
400
|
+
Benchmark FastWARC read performance.
|
|
401
|
+
|
|
402
|
+
The FastWARC CLI comes with a benchmarking tool for measuring WARC record decompression and parsing
|
|
403
|
+
performance on your own machine. The benchmarking results can be compared directly with WARCIO.
|
|
404
|
+
|
|
405
|
+
Supported WARC sources are local files, S3 and HTTP(s) URLs. Supported compression algorithms
|
|
406
|
+
are GZip, LZ4, Zstandard, or uncompressed.
|
|
407
|
+
|
|
408
|
+
The read benchmarking tool has additional options, such as reading WARCs directly from a remote S3 data source
|
|
409
|
+
using `Boto3 <https://boto3.amazonaws.com/v1/documentation/api/latest/index.html>`_.
|
|
410
|
+
|
|
411
|
+
See :ref:`FastWARC Benchmarks <fastwarc-benchmarks>` for more information and example benchmarking results.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
if not input_url:
|
|
415
|
+
raise click.UsageError('Need at least one input path.')
|
|
416
|
+
|
|
417
|
+
if is_prefix:
|
|
418
|
+
input_url = _expand_s3_prefixes(input_url, endpoint_url, aws_access_key, aws_secret_key)
|
|
419
|
+
else:
|
|
420
|
+
for u in input_url:
|
|
421
|
+
if u.startswith('s3://'):
|
|
422
|
+
_init_s3(endpoint_url, aws_access_key, aws_secret_key)
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
if bench_warcio:
|
|
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.')
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
import warcio
|
|
432
|
+
except ModuleNotFoundError:
|
|
433
|
+
click.echo("--bench-warcio requires 'warcio' to be installed.", err=True)
|
|
434
|
+
sys.exit(1)
|
|
435
|
+
|
|
436
|
+
click.echo(f'Benchmarking read performance from {len(input_url)} input path(s)...')
|
|
437
|
+
|
|
438
|
+
rec_type_filter = WarcRecordType.any_type
|
|
439
|
+
if 'any_type' not in filter_type:
|
|
440
|
+
rec_type_filter = WarcRecordType.no_type
|
|
441
|
+
for t in filter_type:
|
|
442
|
+
rec_type_filter |= getattr(WarcRecordType, t)
|
|
443
|
+
|
|
444
|
+
def _bench(urls, stream_loader, lib):
|
|
445
|
+
interrupted = False
|
|
446
|
+
start = time.perf_counter()
|
|
447
|
+
num = 0
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
def _load_lazy(u, l):
|
|
451
|
+
yield from l(u)
|
|
452
|
+
|
|
453
|
+
for _ in tqdm(chain(*(_load_lazy(u, stream_loader) for u in urls)),
|
|
454
|
+
desc=f'Benchmarking {lib}', unit=' records', leave=False, mininterval=0.5):
|
|
455
|
+
num += 1
|
|
456
|
+
except (OSError, ValueError) as e:
|
|
457
|
+
raise click.ClickException(f'Error reading input: {e}') from e
|
|
458
|
+
except KeyboardInterrupt:
|
|
459
|
+
click.echo(f'Benchmark interrupted.', err=True)
|
|
460
|
+
interrupted = True
|
|
461
|
+
|
|
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
|
|
465
|
+
|
|
466
|
+
def _fastwarc_iterator(f):
|
|
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()
|
|
474
|
+
|
|
475
|
+
n, t_fastwarc, interrupted = _bench(input_url, _fastwarc_iterator, 'FastWARC')
|
|
476
|
+
click.echo(f'FastWARC: {n:,} records read in {t_fastwarc:.02f} seconds ({n / t_fastwarc:,.02f} records/s).')
|
|
477
|
+
|
|
478
|
+
if interrupted:
|
|
479
|
+
sys.exit(1)
|
|
480
|
+
|
|
481
|
+
if bench_warcio:
|
|
482
|
+
def _warcio_iterator(f):
|
|
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):
|
|
488
|
+
yield rec
|
|
489
|
+
finally:
|
|
490
|
+
source.close()
|
|
491
|
+
|
|
492
|
+
n, t_warcio, interrupted = _bench(input_url, _warcio_iterator, 'WARCIO')
|
|
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)
|
|
496
|
+
click.echo(f'Time difference: {t_fastwarc - t_warcio:.02f} seconds, speedup: {t_warcio / t_fastwarc:,.02f}')
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == '__main__':
|
|
500
|
+
main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright 2021 Janek Bevendorff
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
|
|
17
|
+
# Load Cython exports only when requested. Importing the Rust-backed package's
|
|
18
|
+
# compatibility shims must not require the legacy extensions.
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FileStream",
|
|
22
|
+
"GZipStream",
|
|
23
|
+
"LZ4Stream",
|
|
24
|
+
"FastWARCError",
|
|
25
|
+
"StreamError",
|
|
26
|
+
"ArchiveIterator",
|
|
27
|
+
"WarcRecord",
|
|
28
|
+
"WarcRecordType"
|
|
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__))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .stream_io import (
|
|
2
|
+
FileStream,
|
|
3
|
+
GZipStream,
|
|
4
|
+
LZ4Stream,
|
|
5
|
+
FastWARCError,
|
|
6
|
+
StreamError,
|
|
7
|
+
)
|
|
8
|
+
from .warc import ArchiveIterator, WarcRecord, WarcRecordType
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"FileStream",
|
|
12
|
+
"GZipStream",
|
|
13
|
+
"LZ4Stream",
|
|
14
|
+
"FastWARCError",
|
|
15
|
+
"StreamError",
|
|
16
|
+
"ArchiveIterator",
|
|
17
|
+
"WarcRecord",
|
|
18
|
+
"WarcRecordType",
|
|
19
|
+
]
|