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 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
+ ]