Mercurial > hg > cc > cirrus_work
changeset 383:def4c3375b02 plus
version of warc.py which uses igzip, therefore doesn't support the block position in the callback result
| author | Henry S. Thompson <ht@inf.ed.ac.uk> |
|---|---|
| date | Thu, 11 Jun 2026 21:26:09 +0100 |
| parents | ec914a136771 |
| children | 7245d684b0b5 |
| files | lib/python/cc/iwarc.py |
| diffstat | 1 files changed, 226 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/lib/python/cc/iwarc.py Thu Jun 11 21:26:09 2026 +0100 @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# cython: profile=False, language_level=3str +'''Stream a warc format file, unzipping if necessary, invoking a +callback on each record. Callback can be limited by WARC-Type, record +part''' + +import sys, io +from isal import igzip +import cython, typing, gzip + +INFO: int = 0 +RESP: int = 1 +REQ: int = 2 +META: int = 3 +REVISIT: int = 4 + +BUFSIZE: int = 16 * 1024 * 1024 +BUFMIN: int = 5.5 * 1024 * 1024 # was 3MiB, increased + # to 5.5MiB for CC-MAIN-2025-13 (Mar) and thereafter + +HDRMAX: int = 0 # will grow +RECORDMAX: int = 0 # will grow + +def warc(filename: str, + callback: typing.Callable[[bytes, typing.ByteString, int], typing.BinaryIO], + types: typing.List[int] = [RESP], whole: bool = False, parts: int = 7, + debug: bool = False): + '''parts is a bit-mask: + 1 for warc header; + 2 for req/resp HTTP header, warcinfo/metadata features; + 4 for req/resp body''' + # Not currently trying to depend on this, but I believe that + # warcinfo: record-headers+1bl+crawl-headers+2bl + # request: record-headers+1bl+HTTP-headers+3bl + # response: record-headers+1bl+HTTP-headers+[1bl or 2bl]+HTTP-body+1bl + # metadata: record-headers+1bl+metadata-headers+3bl + global BUFSIZE, HDRMAX, BUFMIN, RECORDMAX + _out: typing.BinaryIO + # should do some sanity checking wrt parts and types + stream: typing.BinaryIO + with gzip.open(filename, 'r') as fh: + try: + fh.read(1) + except gzip.BadGzipFile: + stream = open(filename, 'rb', 0) + else: + stream = igzip.IGzipFile(filename = filename) + buf: char[::1] = bytearray(BUFSIZE) + bufView: char[::1] = memoryview(buf) + fpos: int = 0 + bp: int = 0 + bl: int = stream.readinto(buf) + n: int = 0 + eob: int + eo2: int + done: bool = bl < BUFSIZE + while buf.startswith(b'\r\n', bp): + bp += 2 + while not (done and bp >= bl): + start_1: int = bp + if not buf.startswith(b'WARC/1.0\r\n', bp): + raise ValueError("Not a WARC file? In %s at %s of %s (%s): %s[%s]"%(filename, + bp, bl, fpos, + (buf[bp:min(bl, bp + 20)] if bp < bl else buf[bl-20:bl]).decode('latin-1'), + bl - bp)) + bp += 10 + n += 1 + wtype: int = -1 + length: int = 0 + tr: bytes = b'' # Was this record truncated? + while not buf.startswith(b'\r\n',bp): + # there should always be enough in the buffer to complete this loop, + # because of the buffer update logic at the end + eol = buf.index(b'\r\n', bp) + if buf.startswith(b"Content-Length: ", bp): + length = wl = int(bufView[bp+16:eol]) + if buf.startswith(b"WARC-Truncated: ", bp): + if bp + 16 == eol - 2: + tr = b"EMPTY" + else: + tr = bytes(bufView[bp + 16:eol - 2]) + elif buf.startswith(b'WARC-Type: ', bp): + if buf.startswith(b's', bp + 13): + wtype = RESP + elif buf.startswith(b'q', bp + 13): + wtype = REQ + elif buf.startswith(b'm', bp + 11): + wtype = META + elif buf.startswith(b'w', bp + 11): + wtype = INFO + elif buf.startswith(b'v', bp + 13): + wtype = REVISIT + else: + raise ValueError("Unknown WARC-Type: %s in %s at %s"%( + bytes(bufView[bp + 11:eol - 2]), filename, + fpos - (bl - bp))) + bp=eol+2 + # record header done + if (hl := (bp - start_1)) > HDRMAX: + HDRMAX = hl + #if done: + # if (bp+length)>bl: + # raise ValueError("Done but need more! %s + %s > %s in %s"%(bp, + # length,bl,filename)) + if (wtype in types): + # Output whole or part 1 as required + if whole: + _out = callback(wtype,bufView[start_1:bp+length], 7) + else: + if (parts & 1): + bp = eol + 2 + _out = callback(wtype, bufView[start_1:bp], 1) + if parts != 1: + while buf.startswith(b'\r\n', bp): + bp += 2 + start_2: int = bp + eob = bp + length + while buf.startswith(b'\r\n', eob - 2): + eob -= 2 + # Only output parts (2 = HTTP header, 4 = body) that are wanted + if parts & 2: + if wtype == RESP or wtype == REQ : + # request and response have http headers + eo2 = buf.index(b'\r\n\r\n', start_2) + _out = callback(wtype, bufView[start_2:eo2 + 2], 2) + else: + # rest of the part + _out = callback(wtype, bufView[start_2:eob], 2) + if parts & 4: + raise ValueError("Not implemented: body part (4): %s"%parts) + #bp += length + #if buf[bp] != 13: + # # Why does this sometimes happen, e.g. when doing + # python3 ~/lib/python/cc/test_warc.py 4 /beegfs/common_crawl/CC-MAIN-2019-35/1566027313501.0/orig/crawldiagnostics/CC-MAIN-20190817222907-20190818004907-00000.warc.gz + # at a point where bp+length is 11018, looking at >\n\r\n + # bp += 1 [doesn't work] + bp = buf.index(b'\r\n',bp+length) + # check if refill needed + rl: int + if (rl := (bp - start_1)) > RECORDMAX: + RECORDMAX = rl + keepLen: int + if (not done) and (keepLen := bl - bp) < BUFMIN: + # we need to shift and read more + buf[0:keepLen] = bufView[bp:bl] + with memoryview(buf)[keepLen:BUFSIZE] as xBuf: + nb = stream.readinto(xBuf) + bl = keepLen+nb + done = bl < BUFSIZE + bp = 0 + while buf.startswith(b'\r\n', bp): + bp+=2 + #print('end of loop',wtype,start_1,bp,eol,length,bl,file=sys.stderr) + print('%d records, max record: %d, max header: %d'%(n, RECORDMAX, HDRMAX), + file = sys.stderr) + +import zlib, gzip, struct +from isal import isal_zlib + +def decompOneBlock(data: bytes, bl: int, bp: int = 0) -> tuple[bytes, int]: + """Decompress one block of a gzip compressed stream in one shot. + Return the decompressed string and the stream repositioned + at the start of the next block. + """ + fp: io.BytesIO = io.BytesIO(data) + fp.seek(bp) + if gzip._read_gzip_header(fp) is None: + return (b"",0) + bp: int = fp.tell() + # Use a isal's zlib raw deflate compressor + do: isal_zlib.Decompress = isal_zlib.decompressobj(wbits = -zlib.MAX_WBITS) + # Read all the data except the header + decompressed: bytes = do.decompress(data[bp:]) + if not do.eof or (uu:=len(do.unused_data)) < 8: + raise EOFError("Compressed file ended before the end-of-stream " + "marker was reached") + bp = bl - uu + crc: int + length: int + crc, length = struct.unpack("<II", do.unused_data[:8]) + bp += 8 + if crc != zlib.crc32(decompressed): + raise gzip.BadGzipFile("CRC check failed") + if length != (len(decompressed) & 0xffffffff): + raise gzip.BadGzipFile("Incorrect length of data produced") + while bp < bl and data[bp] == 0: + bp += 1 + return (decompressed, bp) + +def dotest(): + f: io.BinaryIO = open(sys.argv[1], "rb") + buf: bytes = bytearray(BUFSIZE) + bufView: bytes = memoryview(buf) + xBuf: bytes + bl: int = f.readinto(buf) + offset: int = 0 + bp: int = 0 + done: bool = bl < BUFSIZE + unc: bytes + keepLen: int + print(0, file = sys.stderr, end = " ") + while True: + (unc, bp) = decompOneBlock(bufView, bl, bp) + if unc == b"": + break + print(unc[21:29],file = sys.stderr) + print(offset + bp, file = sys.stderr, end = " ") + if (not done) and (keepLen := bl - bp) < BUFMIN: + # we need to shift and read more + offset += bp + buf[0:keepLen]=bufView[bp:bl] + with memoryview(buf)[keepLen:BUFSIZE] as xBuf: + nb: int = f.readinto(xBuf) + bl = keepLen+nb + if (done := (bl < BUFSIZE)): + bufView = bufView[0:bl] + bp = 0 + print("EOF", file = sys.stderr) + f.close() + +if __name__ == "__main__": + dotest() + + + +
