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.
Files changed (63) hide show
  1. {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/PKG-INFO +1 -1
  2. {fastwarc-1.0.8 → fastwarc-1.1.0}/PKG-INFO +1 -1
  3. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/Cargo.toml +3 -4
  4. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/cli.py +138 -108
  5. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/__init__.py +16 -4
  6. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/lib.rs +1 -0
  7. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/warc.rs +13 -22
  8. {fastwarc-1.0.8 → fastwarc-1.1.0}/pyproject.toml +2 -1
  9. {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/SOURCES.txt +0 -0
  10. {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/dependency_links.txt +0 -0
  11. {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/entry_points.txt +0 -0
  12. {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/requires.txt +0 -0
  13. {fastwarc-1.0.8 → fastwarc-1.1.0}/FastWARC.egg-info/top_level.txt +0 -0
  14. {fastwarc-1.0.8 → fastwarc-1.1.0}/MANIFEST.in +0 -0
  15. {fastwarc-1.0.8 → fastwarc-1.1.0}/README.md +0 -0
  16. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/__init__.py +0 -0
  17. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/__init__.pyi +0 -0
  18. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/build.rs +0 -0
  19. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/__init__.pxd +0 -0
  20. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/__init__.pyi +0 -0
  21. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/_shims.py +0 -0
  22. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/stream_io.pxd +0 -0
  23. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/stream_io.pyi +0 -0
  24. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/stream_io.pyx +0 -0
  25. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/tools.pyi +0 -0
  26. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/tools.pyx +0 -0
  27. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/warc.pxd +0 -0
  28. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/warc.pyi +0 -0
  29. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/legacy/warc.pyx +0 -0
  30. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/py.typed +0 -0
  31. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/brotli.rs +0 -0
  32. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/chunked.rs +0 -0
  33. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/gzip.rs +0 -0
  34. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/lz4.rs +0 -0
  35. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/mod.rs +0 -0
  36. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/src/stream_io/zstd.rs +0 -0
  37. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/stream_io.pyi +0 -0
  38. {fastwarc-1.0.8 → fastwarc-1.1.0}/fastwarc/warc.pyi +0 -0
  39. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_common/__init__.pxd +0 -0
  40. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_common/string_util.pxd +0 -0
  41. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/__init__.pxd +0 -0
  42. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/algorithm.pxd +0 -0
  43. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/atomic.pxd +0 -0
  44. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/boost_regex.pxd +0 -0
  45. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/cctype.pxd +0 -0
  46. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/cstdlib.pxd +0 -0
  47. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/cstring.pxd +0 -0
  48. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/dlfcn.pxd +0 -0
  49. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/errno.pxd +0 -0
  50. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/lexbor.pxd +0 -0
  51. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/lz4frame.pxd +0 -0
  52. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/lz4hc.pxd +0 -0
  53. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/pthread.pxd +0 -0
  54. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/re2.pxd +0 -0
  55. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/stdio.pxd +0 -0
  56. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/string_view.pxd +0 -0
  57. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/time.pxd +0 -0
  58. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/uchardet.pxd +0 -0
  59. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/unistd.pxd +0 -0
  60. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/utility.pxd +0 -0
  61. {fastwarc-1.0.8 → fastwarc-1.1.0}/resiliparse_inc/zlib.pxd +0 -0
  62. {fastwarc-1.0.8 → fastwarc-1.1.0}/setup.cfg +0 -0
  63. {fastwarc-1.0.8 → fastwarc-1.1.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastWARC
3
- Version: 1.0.8
3
+ Version: 1.1.0
4
4
  Summary: The world's fastest WARC parsing library written in Rust with bindings for Python.
5
5
  Author: Janek Bevendorff
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastWARC
3
- Version: 1.0.8
3
+ Version: 1.1.0
4
4
  Summary: The world's fastest WARC parsing library written in Rust with bindings for Python.
5
5
  Author: Janek Bevendorff
6
6
  License-Expression: Apache-2.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.8"
16
- pyo3 = "0.28"
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.28"
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 FileStream, StreamError, FastWARCError, PythonIOStreamAdapter
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
- def exception_handler(exctype, value, _):
34
- if exctype == FastWARCError:
35
- click.echo(str(value), err=True)
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
- @click.group(context_settings=dict(help_option_names=['-h', '--help']))
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:d} {unit}'
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(['gzip', 'lz4', 'uncompressed', 'auto']),
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(['gzip', 'lz4', 'uncompressed', 'auto']),
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 tries to detect based on file extension)')
67
- @click.option('-l', '--compress-level', type=int, default=None, help='Compression level (defaults to max)')
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-level if or you want to recompress a GZip WARC as LZ4 or vice versa.
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
- for _, b in tqdm(recompress_warc_interactive(infile, outfile, decompress_alg, compress_alg, **comp_args),
92
- desc='Recompressing WARC file', unit=' record(s)', leave=False,
93
- disable=quiet, mininterval=0.2):
94
- num += 1
95
- bytes_written += b
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(['gzip', 'lz4', 'uncompressed', 'auto']),
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(verify_digests(infile, verify_payloads, decompress_alg),
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
- sys.exit(1)
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=int)
188
- @click.option('-o', '--output', type=click.File('wb'), default=sys.stdout.buffer,
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
- record = next(ArchiveIterator(stream))
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(PythonIOStreamAdapter(output))
210
- output.write(b'\r\n')
244
+ record.headers.write(output)
211
245
  if record.is_http:
212
- record.http_headers.write(PythonIOStreamAdapter(output))
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 (FastWARCError, OSError, ValueError) as e:
222
- click.echo(f'Failed to extract WARC record at offset {offset}: {str(e)}', err=True)
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
- l = []
241
- for k, v in record.http_headers:
242
- if k == f[5:]:
243
- l.append(v)
244
- if len(l) == 1:
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=sys.stdout,
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
- with open(infile, 'rb') as stream:
276
- prev_record = None
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, stream.tell(), infile)
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 = FileStream(input_url, 'rb') if not use_python_stream else open(input_url, 'rb')
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(['gzip', 'lz4', 'uncompressed', 'auto']),
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 slower Python I/O instead of native FileStream for local files')
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', 'conversation', 'continuation', 'any_type']),
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 == CompressionAlg.lz4:
403
- click.echo('WARCIO does not support LZ4.', err=True)
404
- sys.exit(1)
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.monotonic()
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 StreamError as e:
433
- click.echo(f'Error reading input: {e}', err=True)
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
- return num, time.monotonic() - start, interrupted
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
- # s = _get_raw_stream_from_url(f, use_python_stream)
443
- # s = wrap_warc_stream(s, 'rb', decompress_alg)
444
- s = '/home/roce3528/code-in-progress/code-research/web-search/chatnoir/chatnoir-resiliparse/tmpfs/CC-MAIN-20231005012006-20231005042006-00899.warc'
445
- return ArchiveIterator(s, rec_type_filter, parse_http=parse_http, verify_digests=verify_digests)
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
- s = _get_raw_stream_from_url(f, True)
456
- if rec_type_filter == WarcRecordType.any_type:
457
- yield from warcio.ArchiveIterator(s, no_record_parse=not parse_http, check_digests=verify_digests)
458
- else:
459
- # WARCIO does not support record filtering out of the box
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, _ = _bench(input_url, _warcio_iterator, '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
- from .stream_io import FileStream, GZipStream, LZ4Stream
16
- from .stream_io import FastWARCError, StreamError
17
- from .warc import ArchiveIterator, WarcRecord, WarcRecordType
15
+ import importlib
18
16
 
19
- # Exposing symbols for legacy compatibility, please prefer explicit imports from submodules
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__))
@@ -27,6 +27,7 @@ pub mod _fastwarc {
27
27
  let py = m.py();
28
28
 
29
29
  // Load submodules.
30
+ // Don't add with add_submodule(), otherwise they end up in __all__.
30
31
  let stream_io = pyo3::wrap_pymodule!(_stream_io)(py);
31
32
  let stream_io = stream_io.bind(py);
32
33
 
@@ -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
- let items = self.with_headers(|h| {
462
+ self.with_headers(|h| {
463
463
  h.to_map()
464
464
  .into_iter()
465
- .map(|(k, v)| (k.as_ref().to_string(), v))
466
- .collect::<Vec<_>>()
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
- let values = self.with_headers(|h| {
527
- h.get_multiple(&key)
528
- .into_iter()
529
- .map(|s| s.into_owned())
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
- let values = self.with_headers(|h| {
552
- h.get_bytes_multiple(&key)
543
+ self.with_headers(|h| {
544
+ let items = h
545
+ .get_bytes_multiple(&key)
553
546
  .into_iter()
554
- .map(|s| s.into_owned())
555
- .collect::<Vec<_>>()
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.8"
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