From baa02add6901d5e20e285f7b4c1368fc22d55064 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 19 Feb 2016 17:25:54 -0800 Subject: [PATCH 001/112] add indexloader and tests, including file, redis, remote cdx, memento, and live sources --- indexloader.py | 159 ++++++++++++++++++++++++++++++++++++++++++++ test_indexloader.py | 134 +++++++++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 indexloader.py create mode 100644 test_indexloader.py diff --git a/indexloader.py b/indexloader.py new file mode 100644 index 00000000..7e1b4341 --- /dev/null +++ b/indexloader.py @@ -0,0 +1,159 @@ +import redis + +from pywb.utils.binsearch import iter_range +from pywb.utils.timeutils import timestamp_to_http_date, http_date_to_timestamp +from pywb.utils.timeutils import timestamp_to_sec, timestamp_now +from pywb.utils.canonicalize import calc_search_range + +from pywb.cdx.cdxobject import CDXObject +from pywb.cdx.cdxops import cdx_sort_closest, cdx_limit + +import requests + +from utils import MementoUtils + + +#============================================================================= +class BaseIndexSource(object): + def __init__(self, index_template=''): + self.index_template = index_template + + def get_index(self, params): + return self.index_template.format(params.get('coll')) + + +#============================================================================= +class FileIndexSource(BaseIndexSource): + def load_index(self, params): + filename = self.get_index(params) + + with open(filename, 'rb') as fh: + gen = iter_range(fh, params['start_key'], params['end_key']) + for line in gen: + yield CDXObject(line) + + +#============================================================================= +class RemoteIndexSource(BaseIndexSource): + def __init__(self, cdx_url, replay_url): + self.index_template = cdx_url + self.replay_url = replay_url + + def load_index(self, params): + url = self.get_index(params) + url += '?url=' + params['url'] + r = requests.get(url) + lines = r.content.strip().split(b'\n') + for line in lines: + cdx = CDXObject(line) + cdx['load_url'] = self.replay_url.format(timestamp=cdx['timestamp'], url=cdx['url']) + yield cdx + + +#============================================================================= +class LiveIndexSource(BaseIndexSource): + def load_index(self, params): + cdx = CDXObject() + cdx['urlkey'] = params.get('start_key').decode('utf-8') + cdx['timestamp'] = timestamp_now() + cdx['url'] = params['url'] + cdx['load_url'] = params['url'] + def live(): + yield cdx + + return live() + + +#============================================================================= +class RedisIndexSource(BaseIndexSource): + def __init__(self, redis_url): + parts = redis_url.split('/') + key_prefix = '' + if len(parts) > 4: + key_prefix = parts[4] + redis_url = 'redis://' + parts[2] + '/' + parts[3] + + self.redis_url = redis_url + self.index_template = key_prefix + self.redis = redis.StrictRedis.from_url(redis_url) + + def load_index(self, params): + z_key = self.get_index(params) + index_list = self.redis.zrangebylex(z_key, + b'[' + params['start_key'], + b'(' + params['end_key']) + + for line in index_list: + yield CDXObject(line) + + +#============================================================================= +class MementoIndexSource(BaseIndexSource): + def __init__(self, timegate_url, timemap_url, replay_url): + self.timegate_url = timegate_url + self.timemap_url = timemap_url + self.replay_url = replay_url + + def make_iter(self, links, def_name): + original, link_iter = MementoUtils.links_to_json(links, def_name) + + for cdx in link_iter(): + cdx['load_url'] = self.replay_url.format(timestamp=cdx['timestamp'], url=original) + yield cdx + + def load_timegate(self, params, closest): + url = self.timegate_url.format(coll=params.get('coll')) + params['url'] + accept_dt = timestamp_to_http_date(closest) + res = requests.head(url, headers={'Accept-Datetime': accept_dt}) + return self.make_iter(res.headers.get('Link'), 'timegate') + + def load_timemap(self, params): + url = self.timemap_url + params['url'] + r = requests.get(url) + return self.make_iter(r.text, 'timemap') + + def load_index(self, params): + closest = params.get('closest') + if not closest: + return self.load_timemap(params) + else: + return self.load_timegate(params, closest) + + @staticmethod + def from_timegate_url(timegate_url, type_='link'): + return MementoIndexSource(timegate_url, + timegate_url + 'timemap/' + type_ + '/', + timegate_url + '{timestamp}id_/{url}') + + + +def query_index(source, params): + url = params.get('url', '') + + if not params.get('matchType'): + if url.startswith('*.'): + params['url'] = url[2:] + params['matchType'] = 'domain' + elif url.endswith('*'): + params['url'] = url[:-1] + params['matchType'] = 'prefix' + else: + params['matchType'] = 'exact' + + start, end = calc_search_range(url=params['url'], + match_type=params['matchType']) + + params['start_key'] = start.encode('utf-8') + params['end_key'] = end.encode('utf-8') + + res = source.load_index(params) + + limit = int(params.get('limit', 10)) + closest = params.get('closest') + if closest: + res = cdx_sort_closest(closest, res, limit) + elif limit: + res = cdx_limit(res, limit) + + + return res diff --git a/test_indexloader.py b/test_indexloader.py new file mode 100644 index 00000000..9abb6541 --- /dev/null +++ b/test_indexloader.py @@ -0,0 +1,134 @@ +from indexloader import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource +from indexloader import LiveIndexSource +from indexloader import query_index + +from pywb.utils.timeutils import timestamp_now + +import redis +import pytest + +def key_ts_res(cdxlist, extra='filename'): + return '\n'.join([cdx['urlkey'] + ' ' + cdx['timestamp'] + ' ' + cdx[extra] for cdx in cdxlist]) + +def setup_module(): + r = redis.StrictRedis(db=2) + r.delete('test:rediscdx') + with open('sample.cdxj', 'rb') as fh: + for line in fh: + r.zadd('test:rediscdx', 0, line.rstrip()) + + +def teardown_module(): + r = redis.StrictRedis(db=2) + r.delete('test:rediscdx') + + +local_sources = [ + FileIndexSource('sample.cdxj'), + RedisIndexSource('redis://localhost:6379/2/test:rediscdx') +] + + +remote_sources = [ + RemoteIndexSource('http://webenact.rhizome.org/all-cdx', + 'http://webenact.rhizome.org/all/{timestamp}id_/{url}'), + + MementoIndexSource('http://webenact.rhizome.org/all/', + 'http://webenact.rhizome.org/all/timemap/*/', + 'http://webenact.rhizome.org/all/{timestamp}id_/{url}') +] + + + +# Url Match -- Local Loaders +# ============================================================================ +@pytest.mark.parametrize("source1", local_sources, ids=["file", "redis"]) +def test_local_cdxj_loader(source1): + url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' + res = query_index(source1, dict(url=url, + limit=3)) + + expected = """\ +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200912 iana.warc.gz +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz""" + + assert(key_ts_res(res) == expected) + + +# Closest -- Local Loaders +# ============================================================================ +@pytest.mark.parametrize("source1", local_sources, ids=["file", "redis"]) +def test_local_closest_loader(source1): + url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' + res = query_index(source1, dict(url=url, + closest='20140126200930', + limit=3)) + + expected = """\ +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200912 iana.warc.gz +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz""" + + assert(key_ts_res(res) == expected) + + +# Prefix -- Local Loaders +# ============================================================================ +@pytest.mark.parametrize("source1", local_sources, ids=["file", "redis"]) +def test_file_prefix_loader(source1): + res = query_index(source1, dict(url='http://iana.org/domains/root/*')) + + expected = """\ +org,iana)/domains/root/db 20140126200927 iana.warc.gz +org,iana)/domains/root/db 20140126200928 iana.warc.gz +org,iana)/domains/root/servers 20140126201227 iana.warc.gz""" + + assert(key_ts_res(res) == expected) + + +# Url Match -- Remote Loaders +# ============================================================================ +@pytest.mark.parametrize("source2", remote_sources, ids=["remote_cdx", "memento"]) +def test_remote_loader(source2): + url = 'http://instagram.com/amaliaulman' + res = query_index(source2, dict(url=url)) + + expected = """\ +com,instagram)/amaliaulman 20141014150552 http://webenact.rhizome.org/all/20141014150552id_/http://instagram.com/amaliaulman +com,instagram)/amaliaulman 20141014155217 http://webenact.rhizome.org/all/20141014155217id_/http://instagram.com/amaliaulman +com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman +com,instagram)/amaliaulman 20141014171636 http://webenact.rhizome.org/all/20141014171636id_/http://instagram.com/amaliaulman""" + + assert(key_ts_res(res, 'load_url') == expected) + + +# Url Match -- Remote Loaders +# ============================================================================ +@pytest.mark.parametrize("source2", remote_sources, ids=["remote_cdx", "memento"]) +def test_remote_closest_loader(source2): + url = 'http://instagram.com/amaliaulman' + res = query_index(source2, dict(url=url, closest='20141014162332', limit=1)) + + expected = """\ +com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" + + assert(key_ts_res(res, 'load_url') == expected) + + +# Live Index -- No Load! +# ============================================================================ +def test_live(): + url = 'http://example.com/' + source = LiveIndexSource() + res = query_index(source, dict(url=url)) + + expected = 'com,example)/ {0} http://example.com/'.format(timestamp_now()) + + assert(key_ts_res(res, 'load_url') == expected) + + + + + + From 37198767ed3b0c1c4314f8b1a35e334c65381eac Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 19 Feb 2016 17:27:19 -0800 Subject: [PATCH 002/112] add utils, responseloader and liverec --- liverec.py | 245 ++++++++++++++++++++++++++++++++++++++++++++++ responseloader.py | 118 ++++++++++++++++++++++ utils.py | 122 +++++++++++++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 liverec.py create mode 100644 responseloader.py create mode 100644 utils.py diff --git a/liverec.py b/liverec.py new file mode 100644 index 00000000..eb375e3f --- /dev/null +++ b/liverec.py @@ -0,0 +1,245 @@ +from io import BytesIO + +try: + import httplib +except ImportError: + import http.client as httplib + + +orig_connection = httplib.HTTPConnection + +from contextlib import contextmanager + +import ssl +from array import array + +from time import sleep + + +BUFF_SIZE = 8192 + + +# ============================================================================ +class RecordingStream(object): + def __init__(self, fp, recorder): + self.fp = fp + self.recorder = recorder + self.incomplete = False + + if hasattr(self.fp, 'unread'): + self.unread = self.fp.unread + + if hasattr(self.fp, 'tell'): + self.tell = self.fp.tell + + def read(self, *args, **kwargs): + buff = self.fp.read(*args, **kwargs) + self.recorder.write_response_buff(buff) + return buff + + def readinto(self, buff): + res = self.fp.readinto(buff) + self.recorder.write_response_buff(buff) + return res + + def readline(self, maxlen=None): + line = self.fp.readline(maxlen) + self.recorder.write_response_header_line(line) + return line + + def flush(self): + self.fp.flush() + + def close(self): + try: + self.recorder.finish_response(self.incomplete) + except Exception as e: + import traceback + traceback.print_exc() + + res = self.fp.close() + return res + + +# ============================================================================ +class RecordingHTTPResponse(httplib.HTTPResponse): + def __init__(self, recorder, *args, **kwargs): + httplib.HTTPResponse.__init__(self, *args, **kwargs) + self.fp = RecordingStream(self.fp, recorder) + + def mark_incomplete(self): + self.fp.incomplete = True + + +# ============================================================================ +class RecordingHTTPConnection(httplib.HTTPConnection): + global_recorder_maker = None + + def __init__(self, *args, **kwargs): + orig_connection.__init__(self, *args, **kwargs) + if not self.global_recorder_maker: + self.recorder = None + else: + self.recorder = self.global_recorder_maker() + + def make_recording_response(*args, **kwargs): + return RecordingHTTPResponse(self.recorder, *args, **kwargs) + + self.response_class = make_recording_response + + def send(self, data): + if not self.recorder: + orig_connection.send(self, data) + return + + if hasattr(data,'read') and not isinstance(data, array): + url = None + while True: + buff = data.read(self.BUFF_SIZE) + if not buff: + break + + orig_connection.send(self, buff) + self.recorder.write_request(url, buff) + else: + orig_connection.send(self, data) + self.recorder.write_request(self, data) + + + def get_url(self, data): + try: + buff = BytesIO(data) + line = buff.readline() + + path = line.split(' ', 2)[1] + host = self.host + port = self.port + scheme = 'https' if isinstance(self.sock, ssl.SSLSocket) else 'http' + + url = scheme + '://' + host + if (scheme == 'https' and port != '443') and (scheme == 'http' and port != '80'): + url += ':' + port + + url += path + except Exception as e: + raise + + return url + + + def request(self, *args, **kwargs): + #if self.recorder: + # self.recorder.start_request(self) + + res = orig_connection.request(self, *args, **kwargs) + + if self.recorder: + self.recorder.finish_request(self.sock) + + return res + + +# ============================================================================ +class BaseRecorder(object): + def write_request(self, conn, buff): + #url = conn.get_url() + pass + + def write_response_header_line(self, line): + pass + + def write_response_buff(self, buff): + pass + + def finish_request(self, socket): + pass + + def finish_response(self, incomplete=False): + pass + +#================================================================= +class ReadFullyStream(object): + def __init__(self, stream): + self.stream = stream + + def read(self, *args, **kwargs): + try: + return self.stream.read(*args, **kwargs) + except: + self.mark_incomplete() + raise + + def readline(self, *args, **kwargs): + try: + return self.stream.readline(*args, **kwargs) + except: + self.mark_incomplete() + raise + + def mark_incomplete(self): + if (hasattr(self.stream, '_fp') and + hasattr(self.stream._fp, 'mark_incomplete')): + self.stream._fp.mark_incomplete() + + def close(self): + try: + while True: + buff = self.stream.read(BUFF_SIZE) + sleep(0) + if not buff: + break + + except Exception as e: + import traceback + traceback.print_exc() + self.mark_incomplete() + finally: + self.stream.close() + + +# ============================================================================ +httplib.HTTPConnection = RecordingHTTPConnection +# ============================================================================ + +class DefaultRecorderMaker(object): + def __call__(self): + return BaseRecorder() + + +class FixedRecorder(object): + def __init__(self, recorder): + self.recorder = recorder + + def __call__(self): + return self.recorder + +@contextmanager +def record_requests(url, recorder_maker): + RecordingHTTPConnection.global_recorder_maker = recorder_maker + yield + RecordingHTTPConnection.global_recorder_maker = None + +@contextmanager +def orig_requests(): + httplib.HTTPConnection = orig_connection + yield + httplib.HTTPConnection = RecordingHTTPConnection + + +import requests as patched_requests + +def request(url, method='GET', recorder=None, recorder_maker=None, session=patched_requests, **kwargs): + if kwargs.get('skip_recording'): + recorder_maker = None + elif recorder: + recorder_maker = FixedRecorder(recorder) + elif not recorder_maker: + recorder_maker = DefaultRecorderMaker() + + with record_requests(url, recorder_maker): + kwargs['allow_redirects'] = False + r = session.request(method=method, + url=url, + **kwargs) + + return r diff --git a/responseloader.py b/responseloader.py new file mode 100644 index 00000000..880f1a9d --- /dev/null +++ b/responseloader.py @@ -0,0 +1,118 @@ +from liverec import BaseRecorder +from liverec import request as remote_request + +from pywb.warc.recordloader import ArcWarcRecordLoader, ArchiveLoadFailed +from pywb.utils.timeutils import timestamp_to_datetime + +from io import BytesIO +from bottle import response + +import uuid + + +#============================================================================= +def incr_reader(stream, header=None, size=8192): + if header: + yield header + + while True: + data = stream.read(size) + if data: + yield data + else: + break + + +#============================================================================= +class WARCPathPrefixLoader(object): + def __init__(self, prefix): + self.prefix = prefix + self.record_loader = ArcWarcRecordLoader() + + def __call__(self, cdx): + filename = cdx.get('filename') + offset = cdx.get('offset') + length = cdx.get('length', -1) + + if filename is None or offset is None: + raise Exception + + record = self.record_loader.load(self.prefix + filename, + offset, + length, + no_record_parse=True) + + for n, v in record.rec_headers.headers: + response.headers[n] = v + + return incr_reader(record.stream) + + +#============================================================================= +class HeaderRecorder(BaseRecorder): + def __init__(self, skip_list=None): + self.buff = BytesIO() + self.skip_list = skip_list + self.skipped = [] + + def write_response_header_line(self, line): + if self.accept_header(line): + self.buff.write(line) + + def get_header(self): + return self.buff.getvalue() + + def accept_header(self, line): + if self.skip_list and line.lower().startswith(self.skip_list): + self.skipped.append(line) + return False + + return True + + +#============================================================================= +class LiveWebLoader(object): + SKIP_HEADERS = (b'link', + b'memento-datetime', + b'content-location', + b'x-archive', + b'set-cookie') + + def __call__(self, cdx): + load_url = cdx.get('load_url') + if not load_url: + raise Exception + + recorder = HeaderRecorder(self.SKIP_HEADERS) + + upstream_res = remote_request(load_url, recorder=recorder, stream=True, + headers={'Accept-Encoding': 'identity'}) + + response.headers['Content-Type'] = 'application/http; msgtype=response' + + response.headers['WARC-Type'] = 'response' + response.headers['WARC-Record-ID'] = self._make_warc_id() + response.headers['WARC-Target-URI'] = cdx['url'] + response.headers['WARC-Date'] = self._make_date(cdx['timestamp']) + + # Try to set content-length, if it is available and valid + try: + content_len = int(upstream_res.headers.get('content-length', 0)) + if content_len > 0: + content_len += len(recorder.get_header()) + response.headers['Content-Length'] = content_len + except: + pass + + return incr_reader(upstream_res.raw, header=recorder.get_header()) + + @staticmethod + def _make_date(ts): + return timestamp_to_datetime(ts).strftime('%Y-%m-%dT%H:%M:%SZ') + + @staticmethod + def _make_warc_id(id_=None): + if not id_: + id_ = uuid.uuid1() + return ''.format(id_) + diff --git a/utils.py b/utils.py new file mode 100644 index 00000000..a5299825 --- /dev/null +++ b/utils.py @@ -0,0 +1,122 @@ +import re, json +from pywb.utils.canonicalize import canonicalize +from pywb.utils.timeutils import timestamp_to_sec, http_date_to_timestamp +from pywb.cdx.cdxobject import CDXObject + + +LINK_SPLIT = re.compile(',\s*(?=[<])') +LINK_SEG_SPLIT = re.compile(';\s*') +LINK_URL = re.compile('<(.*)>') +LINK_PROP = re.compile('([\w]+)="([^"]+)') + + +#================================================================= +class MementoUtils(object): + @staticmethod + def parse_links(link_header, def_name='timemap'): + links = LINK_SPLIT.split(link_header) + results = {} + mementos = [] + + for link in links: + props = LINK_SEG_SPLIT.split(link) + m = LINK_URL.match(props[0]) + if not m: + raise Exception('Invalid Link Url: ' + props[0]) + + result = dict(url=m.group(1)) + key = '' + is_mem = False + + for prop in props[1:]: + m = LINK_PROP.match(prop) + if not m: + raise Exception('Invalid prop ' + prop) + + name = m.group(1) + value = m.group(2) + + if name == 'rel': + if 'memento' in value: + is_mem = True + result[name] = value + elif value == 'self': + key = def_name + else: + key = value + else: + result[name] = value + + if key: + results[key] = result + elif is_mem: + mementos.append(result) + + results['mementos'] = mementos + return results + + @staticmethod + def links_to_json(link_header, def_name='timemap', sort=False): + results = MementoUtils.parse_links(link_header, def_name) + + #meta = MementoUtils.meta_field('timegate', results) + #if meta: + # yield meta + + #meta = MementoUtils.meta_field('timemap', results) + #if meta: + # yield meta + + #meta = MementoUtils.meta_field('original', results) + #if meta: + # yield meta + + original = results['original']['url'] + key = canonicalize(original) + + mementos = results['mementos'] + if sort: + mementos = sorted(mementos) + + def link_iter(): + for val in mementos: + dt = val.get('datetime') + if not dt: + continue + + ts = http_date_to_timestamp(dt) + line = CDXObject() + line['urlkey'] = key + line['timestamp'] = ts + line['url'] = original + line['mem_rel'] = val.get('rel', '') + line['memento_url'] = val['url'] + yield line + + return original, link_iter + + @staticmethod + def meta_field(name, results): + v = results.get(name) + if v: + c = CDXObject() + c['key'] = '@' + name + c['url'] = v['url'] + return c + + + + +#================================================================= +def cdx_sort_closest(closest, cdx_json): + closest_sec = timestamp_to_sec(closest) + + def get_key(cdx): + sec = timestamp_to_sec(cdx['timestamp']) + return abs(closest_sec - sec) + + cdx_sorted = sorted(cdx_json, key=get_key) + return cdx_sorted + + + From 1a0b2fba174a1a3d9bde884ef0a8cf88e4235de1 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 22 Feb 2016 13:30:12 -0800 Subject: [PATCH 003/112] add aggregate index source and tests! --- aggindexsource.py | 141 ++++++++++++++++++++ indexloader.py => indexsource.py | 142 +++++++++++++-------- liverec.py | 1 + responseloader.py | 65 ++++++---- test_aggindexsource.py | 62 +++++++++ test_indexloader.py => test_indexsource.py | 75 +++++++---- utils.py | 72 +---------- 7 files changed, 388 insertions(+), 170 deletions(-) create mode 100644 aggindexsource.py rename indexloader.py => indexsource.py (52%) create mode 100644 test_aggindexsource.py rename test_indexloader.py => test_indexsource.py (61%) diff --git a/aggindexsource.py b/aggindexsource.py new file mode 100644 index 00000000..12af280a --- /dev/null +++ b/aggindexsource.py @@ -0,0 +1,141 @@ +from gevent.pool import Pool +import gevent +import json +import time + +from heapq import merge +from collections import deque + +from indexsource import BaseIndexSource +from pywb.utils.wbexception import NotFoundException + + +#============================================================================= +class BaseAggIndexSource(BaseIndexSource): + def __init__(self, sources): + self.sources = sources + + def do_query(self, name, source, params): + try: + cdx_iter = source.load_index(params) + except NotFoundException as nf: + print('Not found in ' + name) + cdx_iter = iter([]) + + def add_name(cdx_iter): + for cdx in cdx_iter: + cdx['source_name'] = name + yield cdx + + return add_name(cdx_iter) + + def load_index(self, params): + iter_list = self._load_all(params) + + cdx_iter = merge(*(iter_list)) + + return cdx_iter + + +#============================================================================= +class TimingOutMixin(object): + def __init__(self, *args, **kwargs): + super(TimingOutMixin, self).__init__(*args, **kwargs) + self.t_count = kwargs.get('t_count', 3) + self.t_dura = kwargs.get('t_duration', 20) + self.timeouts = {} + + def is_timed_out(self, name): + timeout_deq = self.timeouts.get(name) + if not timeout_deq: + return False + + the_time = time.time() + for t in list(timeout_deq): + if (the_time - t) > self.t_dura: + timeout_deq.popleft() + + if len(timeout_deq) >= self.t_count: + print('Skipping {0}, {1} timeouts in {2} seconds'. + format(name, self.t_count, self.t_dura)) + return True + + return False + + def get_valid_sources(self, sources): + for name in sources.keys(): + if not self.is_timed_out(name): + yield name + + def track_source_error(self, name): + the_time = time.time() + if name not in self.timeouts: + self.timeouts[name] = deque() + + self.timeouts[name].append(the_time) + print(name + ' timed out!') + + +#============================================================================= +class GeventAggIndexSource(BaseAggIndexSource): + def __init__(self, sources, timeout=5.0, size=None): + super(GeventAggIndexSource, self).__init__(sources) + self.pool = Pool(size=size) + self.timeout = timeout + + def get_valid_sources(self, sources): + return sources.keys() + + def track_source_error(self, name): + pass + + def _load_all(self, params): + def do_spawn(n): + return self.pool.spawn(self.do_query, n, self.sources[n], params) + + jobs = [do_spawn(src) for src in self.get_valid_sources(self.sources)] + + gevent.joinall(jobs, timeout=self.timeout) + + res = [] + for name, job in zip(self.sources.keys(), jobs): + if job.value: + res.append(job.value) + else: + self.track_source_error(name) + + return res + + +#============================================================================= +class AggIndexSource(TimingOutMixin, GeventAggIndexSource): + pass + + +#============================================================================= +class SimpleAggIndexSource(BaseAggIndexSource): + def _load_all(self, params): + return list(map(lambda n: self.do_query(n, self.sources[n], params), + self.sources)) + + +#============================================================================= +class ResourceLoadAgg(object): + def __init__(self, load_index, load_resource): + self.load_index = load_index + self.load_resource = load_resource + + def __call__(self, params): + cdx_iter = self.load_index(params) + for cdx in cdx_iter: + for loader in self.load_resource: + try: + resp = loader(cdx) + if resp: + return resp + except Exception: + pass + + raise Exception('Not Found') + + diff --git a/indexloader.py b/indexsource.py similarity index 52% rename from indexloader.py rename to indexsource.py index 7e1b4341..4d6971a9 100644 --- a/indexloader.py +++ b/indexsource.py @@ -3,10 +3,12 @@ import redis from pywb.utils.binsearch import iter_range from pywb.utils.timeutils import timestamp_to_http_date, http_date_to_timestamp from pywb.utils.timeutils import timestamp_to_sec, timestamp_now -from pywb.utils.canonicalize import calc_search_range +from pywb.utils.canonicalize import canonicalize, calc_search_range +from pywb.utils.wbexception import NotFoundException from pywb.cdx.cdxobject import CDXObject -from pywb.cdx.cdxops import cdx_sort_closest, cdx_limit +from pywb.cdx.query import CDXQuery +from pywb.cdx.cdxops import process_cdx import requests @@ -21,6 +23,17 @@ class BaseIndexSource(object): def get_index(self, params): return self.index_template.format(params.get('coll')) + def __call__(self, params): + query = CDXQuery(**params) + + try: + cdx_iter = self.load_index(query.params) + except NotFoundException as nf: + cdx_iter = iter([]) + + cdx_iter = process_cdx(cdx_iter, query) + return cdx_iter + #============================================================================= class FileIndexSource(BaseIndexSource): @@ -28,7 +41,7 @@ class FileIndexSource(BaseIndexSource): filename = self.get_index(params) with open(filename, 'rb') as fh: - gen = iter_range(fh, params['start_key'], params['end_key']) + gen = iter_range(fh, params['key'], params['end_key']) for line in gen: yield CDXObject(line) @@ -43,21 +56,28 @@ class RemoteIndexSource(BaseIndexSource): url = self.get_index(params) url += '?url=' + params['url'] r = requests.get(url) + if r.status_code >= 400: + raise NotFoundException(url) + lines = r.content.strip().split(b'\n') - for line in lines: - cdx = CDXObject(line) - cdx['load_url'] = self.replay_url.format(timestamp=cdx['timestamp'], url=cdx['url']) - yield cdx + def do_load(lines): + for line in lines: + cdx = CDXObject(line) + cdx['load_url'] = self.replay_url.format(timestamp=cdx['timestamp'], url=cdx['url']) + yield cdx + + return do_load(lines) #============================================================================= class LiveIndexSource(BaseIndexSource): def load_index(self, params): cdx = CDXObject() - cdx['urlkey'] = params.get('start_key').decode('utf-8') + cdx['urlkey'] = params.get('key').decode('utf-8') cdx['timestamp'] = timestamp_now() cdx['url'] = params['url'] cdx['load_url'] = params['url'] + cdx['is_live'] = True def live(): yield cdx @@ -80,7 +100,7 @@ class RedisIndexSource(BaseIndexSource): def load_index(self, params): z_key = self.get_index(params) index_list = self.redis.zrangebylex(z_key, - b'[' + params['start_key'], + b'[' + params['key'], b'(' + params['end_key']) for line in index_list: @@ -94,66 +114,84 @@ class MementoIndexSource(BaseIndexSource): self.timemap_url = timemap_url self.replay_url = replay_url - def make_iter(self, links, def_name): - original, link_iter = MementoUtils.links_to_json(links, def_name) + def links_to_cdxobject(self, link_header, def_name, sort=False): + results = MementoUtils.parse_links(link_header, def_name) - for cdx in link_iter(): - cdx['load_url'] = self.replay_url.format(timestamp=cdx['timestamp'], url=original) + #meta = MementoUtils.meta_field('timegate', results) + #if meta: + # yield meta + + #meta = MementoUtils.meta_field('timemap', results) + #if meta: + # yield meta + + #meta = MementoUtils.meta_field('original', results) + #if meta: + # yield meta + + original = results['original']['url'] + key = canonicalize(original) + + mementos = results['mementos'] + if sort: + mementos = sorted(mementos) + + for val in mementos: + dt = val.get('datetime') + if not dt: + continue + + ts = http_date_to_timestamp(dt) + cdx = CDXObject() + cdx['urlkey'] = key + cdx['timestamp'] = ts + cdx['url'] = original + cdx['mem_rel'] = val.get('rel', '') + cdx['memento_url'] = val['url'] + + load_url = self.replay_url.format(timestamp=cdx['timestamp'], + url=original) + + cdx['load_url'] = load_url yield cdx - def load_timegate(self, params, closest): + def get_timegate_links(self, params, closest): url = self.timegate_url.format(coll=params.get('coll')) + params['url'] accept_dt = timestamp_to_http_date(closest) res = requests.head(url, headers={'Accept-Datetime': accept_dt}) - return self.make_iter(res.headers.get('Link'), 'timegate') + if res.status_code >= 400: + raise NotFoundException(url) - def load_timemap(self, params): + return res.headers.get('Link') + + def get_timemap_links(self, params): url = self.timemap_url + params['url'] - r = requests.get(url) - return self.make_iter(r.text, 'timemap') + res = requests.get(url) + if res.status_code >= 400: + raise NotFoundException(url) + + return res.text def load_index(self, params): closest = params.get('closest') + if not closest: - return self.load_timemap(params) + links = self.get_timemap_links(params) + def_name = 'timemap' else: - return self.load_timegate(params, closest) + links = self.get_timegate_links(params, closest) + def_name = 'timegate' + + #if not links: + # return iter([]) + + return self.links_to_cdxobject(links, def_name) @staticmethod - def from_timegate_url(timegate_url, type_='link'): + def from_timegate_url(timegate_url, path='link'): return MementoIndexSource(timegate_url, - timegate_url + 'timemap/' + type_ + '/', + timegate_url + 'timemap/' + path + '/', timegate_url + '{timestamp}id_/{url}') -def query_index(source, params): - url = params.get('url', '') - - if not params.get('matchType'): - if url.startswith('*.'): - params['url'] = url[2:] - params['matchType'] = 'domain' - elif url.endswith('*'): - params['url'] = url[:-1] - params['matchType'] = 'prefix' - else: - params['matchType'] = 'exact' - - start, end = calc_search_range(url=params['url'], - match_type=params['matchType']) - - params['start_key'] = start.encode('utf-8') - params['end_key'] = end.encode('utf-8') - - res = source.load_index(params) - - limit = int(params.get('limit', 10)) - closest = params.get('closest') - if closest: - res = cdx_sort_closest(closest, res, limit) - elif limit: - res = cdx_limit(res, limit) - - - return res diff --git a/liverec.py b/liverec.py index eb375e3f..c17d39d0 100644 --- a/liverec.py +++ b/liverec.py @@ -157,6 +157,7 @@ class BaseRecorder(object): def finish_response(self, incomplete=False): pass + #================================================================= class ReadFullyStream(object): def __init__(self, stream): diff --git a/responseloader.py b/responseloader.py index 880f1a9d..baf9d7bc 100644 --- a/responseloader.py +++ b/responseloader.py @@ -2,7 +2,8 @@ from liverec import BaseRecorder from liverec import request as remote_request from pywb.warc.recordloader import ArcWarcRecordLoader, ArchiveLoadFailed -from pywb.utils.timeutils import timestamp_to_datetime +from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date +from pywb.warc.resolvingloader import ResolvingLoader from io import BytesIO from bottle import response @@ -25,22 +26,26 @@ def incr_reader(stream, header=None, size=8192): #============================================================================= class WARCPathPrefixLoader(object): - def __init__(self, prefix): + def __init__(self, prefix, cdx_loader): self.prefix = prefix - self.record_loader = ArcWarcRecordLoader() + + def add_prefix(filename, cdx): + return [self.prefix + filename] + + self.resolve_loader = ResolvingLoader([add_prefix], no_record_parse=True) + self.cdx_loader = cdx_loader def __call__(self, cdx): - filename = cdx.get('filename') - offset = cdx.get('offset') - length = cdx.get('length', -1) + if not cdx.get('filename') or cdx.get('offset') is None: + return None - if filename is None or offset is None: - raise Exception + failed_files = [] + headers, payload = self.resolve_loader.load_headers_and_payload(cdx, failed_files, self.cdx_loader) - record = self.record_loader.load(self.prefix + filename, - offset, - length, - no_record_parse=True) + if headers != payload: + headers.stream.close() + + record = payload for n, v in record.rec_headers.headers: response.headers[n] = v @@ -75,40 +80,50 @@ class LiveWebLoader(object): SKIP_HEADERS = (b'link', b'memento-datetime', b'content-location', - b'x-archive', - b'set-cookie') + b'x-archive') def __call__(self, cdx): load_url = cdx.get('load_url') if not load_url: - raise Exception + return None recorder = HeaderRecorder(self.SKIP_HEADERS) - upstream_res = remote_request(load_url, recorder=recorder, stream=True, - headers={'Accept-Encoding': 'identity'}) + req_headers = {} + + dt = timestamp_to_datetime(cdx['timestamp']) + + if not cdx.get('is_live'): + req_headers['Accept-Datetime'] = datetime_to_http_date(dt) + + upstream_res = remote_request(load_url, + recorder=recorder, + stream=True, + headers=req_headers) + + resp_headers = recorder.get_header() response.headers['Content-Type'] = 'application/http; msgtype=response' - response.headers['WARC-Type'] = 'response' - response.headers['WARC-Record-ID'] = self._make_warc_id() + #response.headers['WARC-Type'] = 'response' + #response.headers['WARC-Record-ID'] = self._make_warc_id() response.headers['WARC-Target-URI'] = cdx['url'] - response.headers['WARC-Date'] = self._make_date(cdx['timestamp']) + response.headers['WARC-Date'] = self._make_date(dt) # Try to set content-length, if it is available and valid try: content_len = int(upstream_res.headers.get('content-length', 0)) if content_len > 0: - content_len += len(recorder.get_header()) + content_len += len(resp_headers) response.headers['Content-Length'] = content_len except: - pass + raise - return incr_reader(upstream_res.raw, header=recorder.get_header()) + return incr_reader(upstream_res.raw, header=resp_headers) @staticmethod - def _make_date(ts): - return timestamp_to_datetime(ts).strftime('%Y-%m-%dT%H:%M:%SZ') + def _make_date(dt): + return dt.strftime('%Y-%m-%dT%H:%M:%SZ') @staticmethod def _make_warc_id(id_=None): diff --git a/test_aggindexsource.py b/test_aggindexsource.py new file mode 100644 index 00000000..d0866c0a --- /dev/null +++ b/test_aggindexsource.py @@ -0,0 +1,62 @@ +from gevent import monkey; monkey.patch_all() +from aggindexsource import AggIndexSource + +from indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource +import json + + +sources = { + 'local': FileIndexSource('sample.cdxj'), + 'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), + 'ait': MementoIndexSource.from_timegate_url('http://wayback.archive-it.org/all/'), + 'bl': MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/'), + 'rhiz': MementoIndexSource.from_timegate_url('http://webenact.rhizome.org/vvork/', path='*') +} + +source = AggIndexSource(sources, timeout=5.0) + +def select_json(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source_name']): + return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) + + +def test_agg_index_1(): + url = 'http://iana.org/' + res = source(dict(url=url, closest='20140126000000', limit=5)) + + + exp = [{"timestamp": "20140126093743", "load_url": "http://web.archive.org/web/20140126093743id_/http://iana.org/", "source_name": "ia"}, + {"timestamp": "20140126200624", "filename": "iana.warc.gz", "source_name": "local"}, + {"timestamp": "20140123034755", "load_url": "http://web.archive.org/web/20140123034755id_/http://iana.org/", "source_name": "ia"}, + {"timestamp": "20140129175203", "load_url": "http://web.archive.org/web/20140129175203id_/http://iana.org/", "source_name": "ia"}, + {"timestamp": "20140107040552", "load_url": "http://wayback.archive-it.org/all/20140107040552id_/http://iana.org/", "source_name": "ait"} + ] + + assert(select_json(res) == exp) + + +def test_agg_index_2(): + url = 'http://example.com/' + res = source(dict(url=url, closest='20100512', limit=6)) + + exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source_name": "bl"}, + {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source_name": "bl"}, + {"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source_name": "ia"}, + {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source_name": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source_name": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source_name": "ia"}] + + assert(select_json(res) == exp) + + +def test_agg_index_3(): + url = 'http://vvork.com/' + res = source(dict(url=url, closest='20141001', limit=5)) + + exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source_name": "rhiz"}, + {"timestamp": "20141018133107", "load_url": "http://web.archive.org/web/20141018133107id_/http://vvork.com/", "source_name": "ia"}, + {"timestamp": "20141020161243", "load_url": "http://web.archive.org/web/20141020161243id_/http://vvork.com/", "source_name": "ia"}, + {"timestamp": "20140806161228", "load_url": "http://web.archive.org/web/20140806161228id_/http://vvork.com/", "source_name": "ia"}, + {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source_name": "ait"}] + + assert(select_json(res) == exp) + diff --git a/test_indexloader.py b/test_indexsource.py similarity index 61% rename from test_indexloader.py rename to test_indexsource.py index 9abb6541..349c609e 100644 --- a/test_indexloader.py +++ b/test_indexsource.py @@ -1,6 +1,5 @@ -from indexloader import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource -from indexloader import LiveIndexSource -from indexloader import query_index +from indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource +from indexsource import LiveIndexSource from pywb.utils.timeutils import timestamp_now @@ -42,11 +41,10 @@ remote_sources = [ # Url Match -- Local Loaders # ============================================================================ -@pytest.mark.parametrize("source1", local_sources, ids=["file", "redis"]) -def test_local_cdxj_loader(source1): +@pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) +def test_local_cdxj_loader(source): url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' - res = query_index(source1, dict(url=url, - limit=3)) + res = source(dict(url=url, limit=3)) expected = """\ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz @@ -58,12 +56,12 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz""" # Closest -- Local Loaders # ============================================================================ -@pytest.mark.parametrize("source1", local_sources, ids=["file", "redis"]) -def test_local_closest_loader(source1): +@pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) +def test_local_closest_loader(source): url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' - res = query_index(source1, dict(url=url, - closest='20140126200930', - limit=3)) + res = source(dict(url=url, + closest='20140126200930', + limit=3)) expected = """\ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz @@ -75,9 +73,9 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz""" # Prefix -- Local Loaders # ============================================================================ -@pytest.mark.parametrize("source1", local_sources, ids=["file", "redis"]) -def test_file_prefix_loader(source1): - res = query_index(source1, dict(url='http://iana.org/domains/root/*')) +@pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) +def test_file_prefix_loader(source): + res = source(dict(url='http://iana.org/domains/root/*')) expected = """\ org,iana)/domains/root/db 20140126200927 iana.warc.gz @@ -89,10 +87,10 @@ org,iana)/domains/root/servers 20140126201227 iana.warc.gz""" # Url Match -- Remote Loaders # ============================================================================ -@pytest.mark.parametrize("source2", remote_sources, ids=["remote_cdx", "memento"]) -def test_remote_loader(source2): +@pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) +def test_remote_loader(source): url = 'http://instagram.com/amaliaulman' - res = query_index(source2, dict(url=url)) + res = source(dict(url=url)) expected = """\ com,instagram)/amaliaulman 20141014150552 http://webenact.rhizome.org/all/20141014150552id_/http://instagram.com/amaliaulman @@ -105,10 +103,10 @@ com,instagram)/amaliaulman 20141014171636 http://webenact.rhizome.org/all/201410 # Url Match -- Remote Loaders # ============================================================================ -@pytest.mark.parametrize("source2", remote_sources, ids=["remote_cdx", "memento"]) -def test_remote_closest_loader(source2): +@pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) +def test_remote_closest_loader(source): url = 'http://instagram.com/amaliaulman' - res = query_index(source2, dict(url=url, closest='20141014162332', limit=1)) + res = source(dict(url=url, closest='20141014162332', limit=1)) expected = """\ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" @@ -116,12 +114,24 @@ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/201410 assert(key_ts_res(res, 'load_url') == expected) +# Url Match -- Memento +# ============================================================================ +@pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) +def test_remote_closest_loader(source): + url = 'http://instagram.com/amaliaulman' + res = source(dict(url=url, closest='20141014162332', limit=1)) + + expected = """\ +com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" + + assert(key_ts_res(res, 'load_url') == expected) + # Live Index -- No Load! # ============================================================================ def test_live(): url = 'http://example.com/' source = LiveIndexSource() - res = query_index(source, dict(url=url)) + res = source(dict(url=url)) expected = 'com,example)/ {0} http://example.com/'.format(timestamp_now()) @@ -130,5 +140,26 @@ def test_live(): +# Errors -- Not Found All +# ============================================================================ +@pytest.mark.parametrize("source", local_sources + remote_sources, ids=["file", "redis", "remote_cdx", "memento"]) +def test_all_not_found(source): + url = 'http://x-not-found-x.notfound/' + res = source(dict(url=url, limit=3)) + + expected = '' + assert(key_ts_res(res) == expected) + + + +# ============================================================================ +def test_another_remote_not_found(): + source = MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/') + url = 'http://x-not-found-x.notfound/' + res = source(dict(url=url, limit=3)) + + + expected = '' + assert(key_ts_res(res) == expected) diff --git a/utils.py b/utils.py index a5299825..6f9df22d 100644 --- a/utils.py +++ b/utils.py @@ -1,8 +1,4 @@ -import re, json -from pywb.utils.canonicalize import canonicalize -from pywb.utils.timeutils import timestamp_to_sec, http_date_to_timestamp -from pywb.cdx.cdxobject import CDXObject - +import re LINK_SPLIT = re.compile(',\s*(?=[<])') LINK_SEG_SPLIT = re.compile(';\s*') @@ -54,69 +50,3 @@ class MementoUtils(object): results['mementos'] = mementos return results - - @staticmethod - def links_to_json(link_header, def_name='timemap', sort=False): - results = MementoUtils.parse_links(link_header, def_name) - - #meta = MementoUtils.meta_field('timegate', results) - #if meta: - # yield meta - - #meta = MementoUtils.meta_field('timemap', results) - #if meta: - # yield meta - - #meta = MementoUtils.meta_field('original', results) - #if meta: - # yield meta - - original = results['original']['url'] - key = canonicalize(original) - - mementos = results['mementos'] - if sort: - mementos = sorted(mementos) - - def link_iter(): - for val in mementos: - dt = val.get('datetime') - if not dt: - continue - - ts = http_date_to_timestamp(dt) - line = CDXObject() - line['urlkey'] = key - line['timestamp'] = ts - line['url'] = original - line['mem_rel'] = val.get('rel', '') - line['memento_url'] = val['url'] - yield line - - return original, link_iter - - @staticmethod - def meta_field(name, results): - v = results.get(name) - if v: - c = CDXObject() - c['key'] = '@' + name - c['url'] = v['url'] - return c - - - - -#================================================================= -def cdx_sort_closest(closest, cdx_json): - closest_sec = timestamp_to_sec(closest) - - def get_key(cdx): - sec = timestamp_to_sec(cdx['timestamp']) - return abs(closest_sec - sec) - - cdx_sorted = sorted(cdx_json, key=get_key) - return cdx_sorted - - - From 398e8f1a7749572d0cea523741e808195bb6cbfe Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 24 Feb 2016 14:22:29 -0800 Subject: [PATCH 004/112] inputrequest: add input request handling (direct wsgi headers) or as a prepared post request add timemap link output rename source_name -> source --- aggindexsource.py | 80 ++++++++++++-------- indexsource.py | 43 ++++++++--- inputrequest.py | 136 +++++++++++++++++++++++++++++++++ liverec.py | 2 +- responseloader.py | 168 +++++++++++++++++++++++++++++++++++++---- test_aggindexsource.py | 45 ++++++----- utils.py | 43 +++++++++++ 7 files changed, 441 insertions(+), 76 deletions(-) create mode 100644 inputrequest.py diff --git a/aggindexsource.py b/aggindexsource.py index 12af280a..546032f5 100644 --- a/aggindexsource.py +++ b/aggindexsource.py @@ -2,29 +2,27 @@ from gevent.pool import Pool import gevent import json import time +import os from heapq import merge from collections import deque -from indexsource import BaseIndexSource +from indexsource import BaseIndexSource, FileIndexSource from pywb.utils.wbexception import NotFoundException #============================================================================= class BaseAggIndexSource(BaseIndexSource): - def __init__(self, sources): - self.sources = sources - def do_query(self, name, source, params): try: - cdx_iter = source.load_index(params) + cdx_iter = source.load_index(dict(params)) except NotFoundException as nf: print('Not found in ' + name) cdx_iter = iter([]) def add_name(cdx_iter): for cdx in cdx_iter: - cdx['source_name'] = name + cdx['source'] = name yield cdx return add_name(cdx_iter) @@ -36,6 +34,9 @@ class BaseAggIndexSource(BaseIndexSource): return cdx_iter + def _load_all(self): + raise NotImplemented() + #============================================================================= class TimingOutMixin(object): @@ -63,7 +64,7 @@ class TimingOutMixin(object): return False def get_valid_sources(self, sources): - for name in sources.keys(): + for name in sources: if not self.is_timed_out(name): yield name @@ -79,10 +80,19 @@ class TimingOutMixin(object): #============================================================================= class GeventAggIndexSource(BaseAggIndexSource): def __init__(self, sources, timeout=5.0, size=None): - super(GeventAggIndexSource, self).__init__(sources) + self.sources = sources self.pool = Pool(size=size) self.timeout = timeout + def get_sources(self, params): + srcs_list = params.get('sources') + if not srcs_list: + return self.sources + + sel_sources = tuple(srcs_list.split(',')) + + return [src for src in self.sources if src in sel_sources] + def get_valid_sources(self, sources): return sources.keys() @@ -90,15 +100,18 @@ class GeventAggIndexSource(BaseAggIndexSource): pass def _load_all(self, params): + params['_timeout'] = self.timeout + def do_spawn(n): return self.pool.spawn(self.do_query, n, self.sources[n], params) - jobs = [do_spawn(src) for src in self.get_valid_sources(self.sources)] + sources = self.get_sources(params) + jobs = [do_spawn(src) for src in self.get_valid_sources(sources)] gevent.joinall(jobs, timeout=self.timeout) res = [] - for name, job in zip(self.sources.keys(), jobs): + for name, job in zip(sources, jobs): if job.value: res.append(job.value) else: @@ -113,29 +126,30 @@ class AggIndexSource(TimingOutMixin, GeventAggIndexSource): #============================================================================= -class SimpleAggIndexSource(BaseAggIndexSource): +class DirAggIndexSource(BaseAggIndexSource): + CDX_EXT = ('.cdx', '.cdxj') + + def __init__(self, base_dir): + self.index_template = base_dir + + def _init_files(self, the_dir): + sources = {} + for name in os.listdir(the_dir): + filename = os.path.join(the_dir, name) + + if filename.endswith(self.CDX_EXT): + print('Adding ' + filename) + sources[name] = FileIndexSource(filename) + + return sources + def _load_all(self, params): - return list(map(lambda n: self.do_query(n, self.sources[n], params), - self.sources)) - - -#============================================================================= -class ResourceLoadAgg(object): - def __init__(self, load_index, load_resource): - self.load_index = load_index - self.load_resource = load_resource - - def __call__(self, params): - cdx_iter = self.load_index(params) - for cdx in cdx_iter: - for loader in self.load_resource: - try: - resp = loader(cdx) - if resp: - return resp - except Exception: - pass - - raise Exception('Not Found') + the_dir = self.get_index(params) + try: + sources = self._init_files(the_dir) + except Exception: + raise NotFoundException(the_dir) + return list([self.do_query(src, sources[src], params) + for src in sources.keys()]) diff --git a/indexsource.py b/indexsource.py index 4d6971a9..e332e6bf 100644 --- a/indexsource.py +++ b/indexsource.py @@ -21,10 +21,14 @@ class BaseIndexSource(object): self.index_template = index_template def get_index(self, params): - return self.index_template.format(params.get('coll')) + res = self.index_template.format(**params) + return res + + def load_index(self, params): + raise NotImplemented() def __call__(self, params): - query = CDXQuery(**params) + query = CDXQuery(params) try: cdx_iter = self.load_index(query.params) @@ -34,10 +38,20 @@ class BaseIndexSource(object): cdx_iter = process_cdx(cdx_iter, query) return cdx_iter + def _include_post_query(self, params): + input_req = params.get('_input_req') + if input_req: + orig_url = params['url'] + params['url'] = input_req.include_post_query(params['url']) + return (params['url'] != orig_url) + #============================================================================= class FileIndexSource(BaseIndexSource): def load_index(self, params): + if self._include_post_query(params): + params = CDXQuery(params).params + filename = self.get_index(params) with open(filename, 'rb') as fh: @@ -45,6 +59,8 @@ class FileIndexSource(BaseIndexSource): for line in gen: yield CDXObject(line) + #return do_load(filename) + #============================================================================= class RemoteIndexSource(BaseIndexSource): @@ -53,11 +69,14 @@ class RemoteIndexSource(BaseIndexSource): self.replay_url = replay_url def load_index(self, params): - url = self.get_index(params) - url += '?url=' + params['url'] - r = requests.get(url) + if self._include_post_query(params): + params = CDXQuery(**params).params + + api_url = self.get_index(params) + api_url += '?url=' + params['url'] + r = requests.get(api_url, timeout=params.get('_timeout')) if r.status_code >= 400: - raise NotFoundException(url) + raise NotFoundException(api_url) lines = r.content.strip().split(b'\n') def do_load(lines): @@ -103,8 +122,11 @@ class RedisIndexSource(BaseIndexSource): b'[' + params['key'], b'(' + params['end_key']) - for line in index_list: - yield CDXObject(line) + def do_load(index_list): + for line in index_list: + yield CDXObject(line) + + return do_load(index_list) #============================================================================= @@ -166,7 +188,7 @@ class MementoIndexSource(BaseIndexSource): def get_timemap_links(self, params): url = self.timemap_url + params['url'] - res = requests.get(url) + res = requests.get(url, timeout=params.get('_timeout')) if res.status_code >= 400: raise NotFoundException(url) @@ -182,9 +204,6 @@ class MementoIndexSource(BaseIndexSource): links = self.get_timegate_links(params, closest) def_name = 'timegate' - #if not links: - # return iter([]) - return self.links_to_cdxobject(links, def_name) @staticmethod diff --git a/inputrequest.py b/inputrequest.py new file mode 100644 index 00000000..4e8964e3 --- /dev/null +++ b/inputrequest.py @@ -0,0 +1,136 @@ +from pywb.utils.loaders import extract_client_cookie +from pywb.utils.loaders import extract_post_query, append_post_query +from pywb.utils.loaders import LimitReader +from pywb.utils.statusandheaders import StatusAndHeadersParser + +from six.moves.urllib.parse import urlsplit +from six import StringIO +import six + + +#============================================================================= +class WSGIInputRequest(object): + def __init__(self, env): + self.env = env + + def get_req_method(self): + return self.env['REQUEST_METHOD'].upper() + + def get_req_headers(self, url): + headers = {} + + splits = urlsplit(url) + + for name, value in six.iteritems(self.env): + if name == 'HTTP_HOST': + name = 'Host' + value = splits.netloc + + elif name == 'HTTP_ORIGIN': + name = 'Origin' + value = (splits.scheme + '://' + splits.netloc) + + elif name == 'HTTP_X_CSRFTOKEN': + name = 'X-CSRFToken' + cookie_val = extract_client_cookie(env, 'csrftoken') + if cookie_val: + value = cookie_val + + elif name == 'HTTP_X_FORWARDED_PROTO': + name = 'X-Forwarded-Proto' + value = splits.scheme + + elif name.startswith('HTTP_'): + name = name[5:].title().replace('_', '-') + + elif name in ('CONTENT_LENGTH', 'CONTENT_TYPE'): + name = name.title().replace('_', '-') + + else: + value = None + + if value: + headers[name] = value + + return headers + + def get_req_body(self): + input_ = self.env.get('wsgi.input') + if not input_: + return None + + len_ = self._get_content_length() + enc = self._get_header('Transfer-Encoding') + + if len_: + data = LimitReader(input_, int(len_)) + elif enc: + data = input_ + else: + data = None + + return data + #buf = data.read().decode('utf-8') + #print(buf) + #return StringIO(buf) + + def _get_content_type(self): + return self.env.get('CONTENT_TYPE') + + def _get_content_length(self): + return self.env.get('CONTENT_LENGTH') + + def _get_header(self, name): + return self.env.get('HTTP_' + name.upper().replace('-', '_')) + + def include_post_query(self, url): + if self.get_req_method() != 'POST': + return url + + mime = self._get_content_type() + mime = mime.split(';')[0] if mime else '' + length = self._get_content_length() + stream = self.env['wsgi.input'] + + buffered_stream = StringIO() + + post_query = extract_post_query('POST', mime, length, stream, + buffered_stream=buffered_stream) + + if post_query: + self.env['wsgi.input'] = buffered_stream + url = append_post_query(url, post_query) + + return url + + +#============================================================================= +class POSTInputRequest(WSGIInputRequest): + def __init__(self, env): + self.env = env + + parser = StatusAndHeadersParser([], verify=False) + + self.status_headers = parser.parse(self.env['wsgi.input']) + + def get_req_method(self): + return self.status_headers.protocol + + def get_req_headers(self, url): + headers = {} + for n, v in self.status_headers.headers: + headers[n] = v + + return headers + + def _get_content_type(self): + return self.status_headers.get_header('Content-Type') + + def _get_content_length(self): + return self.status_headers.get_header('Content-Length') + + def _get_header(self, name): + return self.status_headers.get_header(name) + + + diff --git a/liverec.py b/liverec.py index c17d39d0..5d8bacf0 100644 --- a/liverec.py +++ b/liverec.py @@ -95,7 +95,7 @@ class RecordingHTTPConnection(httplib.HTTPConnection): if hasattr(data,'read') and not isinstance(data, array): url = None while True: - buff = data.read(self.BUFF_SIZE) + buff = data.read(BUFF_SIZE) if not buff: break diff --git a/responseloader.py b/responseloader.py index baf9d7bc..17533d40 100644 --- a/responseloader.py +++ b/responseloader.py @@ -9,6 +9,7 @@ from io import BytesIO from bottle import response import uuid +from utils import MementoUtils #============================================================================= @@ -23,24 +24,46 @@ def incr_reader(stream, header=None, size=8192): else: break + try: + stream.close() + except: + pass + #============================================================================= -class WARCPathPrefixLoader(object): - def __init__(self, prefix, cdx_loader): - self.prefix = prefix +class WARCPathLoader(object): + def __init__(self, paths, cdx_source): + self.paths = paths + if isinstance(paths, str): + self.paths = [paths] - def add_prefix(filename, cdx): - return [self.prefix + filename] + self.path_checks = list(self.warc_paths()) - self.resolve_loader = ResolvingLoader([add_prefix], no_record_parse=True) - self.cdx_loader = cdx_loader + self.resolve_loader = ResolvingLoader(self.path_checks, + no_record_parse=True) + self.cdx_source = cdx_source - def __call__(self, cdx): + def warc_paths(self): + for path in self.paths: + def check(filename, cdx): + try: + full_path = path.format(**cdx) + return full_path + except KeyError: + return None + + yield check + + + def __call__(self, cdx, params): if not cdx.get('filename') or cdx.get('offset') is None: return None failed_files = [] - headers, payload = self.resolve_loader.load_headers_and_payload(cdx, failed_files, self.cdx_loader) + headers, payload = (self.resolve_loader. + load_headers_and_payload(cdx, + failed_files, + self.cdx_source)) if headers != payload: headers.stream.close() @@ -50,6 +73,8 @@ class WARCPathPrefixLoader(object): for n, v in record.rec_headers.headers: response.headers[n] = v + response.headers['WARC-Coll'] = cdx.get('source') + return incr_reader(record.stream) @@ -82,24 +107,33 @@ class LiveWebLoader(object): b'content-location', b'x-archive') - def __call__(self, cdx): + def __call__(self, cdx, params): load_url = cdx.get('load_url') if not load_url: return None recorder = HeaderRecorder(self.SKIP_HEADERS) - req_headers = {} + input_req = params['_input_req'] + + req_headers = input_req.get_req_headers(cdx['url']) dt = timestamp_to_datetime(cdx['timestamp']) if not cdx.get('is_live'): req_headers['Accept-Datetime'] = datetime_to_http_date(dt) - upstream_res = remote_request(load_url, + method = input_req.get_req_method() + data = input_req.get_req_body() + + upstream_res = remote_request(url=load_url, + method=method, recorder=recorder, stream=True, - headers=req_headers) + allow_redirects=False, + headers=req_headers, + data=data, + timeout=params.get('_timeout')) resp_headers = recorder.get_header() @@ -109,6 +143,7 @@ class LiveWebLoader(object): #response.headers['WARC-Record-ID'] = self._make_warc_id() response.headers['WARC-Target-URI'] = cdx['url'] response.headers['WARC-Date'] = self._make_date(dt) + response.headers['WARC-Coll'] = cdx.get('source', '') # Try to set content-length, if it is available and valid try: @@ -131,3 +166,110 @@ class LiveWebLoader(object): id_ = uuid.uuid1() return ''.format(id_) + +#============================================================================= +def to_cdxj(cdx_iter, fields): + response.headers['Content-Type'] = 'text/x-cdxj' + return [cdx.to_cdxj(fields) for cdx in cdx_iter] + +def to_json(cdx_iter, fields): + response.headers['Content-Type'] = 'application/x-ndjson' + return [cdx.to_json(fields) for cdx in cdx_iter] + +def to_text(cdx_iter, fields): + response.headers['Content-Type'] = 'text/plain' + return [cdx.to_text(fields) for cdx in cdx_iter] + +def to_link(cdx_iter, fields): + response.headers['Content-Type'] = 'application/link' + return MementoUtils.make_timemap(cdx_iter) + + +#============================================================================= +class IndexLoader(object): + OUTPUTS = { + 'cdxj': to_cdxj, + 'json': to_json, + 'text': to_text, + 'link': to_link, + } + + DEF_OUTPUT = 'cdxj' + + def __init__(self, index_source): + self.index_source = index_source + + def __call__(self, params): + cdx_iter = self.index_source(params) + + output = params.get('output', self.DEF_OUTPUT) + fields = params.get('fields') + + handler = self.OUTPUTS.get(output) + if not handler: + handler = self.OUTPUTS[self.DEF_OUTPUT] + + res = handler(cdx_iter, fields) + return res + + +#============================================================================= +class ResourceLoader(IndexLoader): + def __init__(self, index_source, resource_loaders): + super(ResourceLoader, self).__init__(index_source) + self.resource_loaders = resource_loaders + + def __call__(self, params): + output = params.get('output') + if output != 'resource': + return super(ResourceLoader, self).__call__(params) + + cdx_iter = self.index_source(params) + + any_found = False + + for cdx in cdx_iter: + any_found = True + cdx['coll'] = params.get('coll', '') + + for loader in self.resource_loaders: + try: + resp = loader(cdx, params) + if resp: + return resp + except ArchiveLoadFailed as e: + print(e) + pass + + if any_found: + raise ArchiveLoadFailed('Resource Found, could not be Loaded') + else: + raise ArchiveLoadFailed('No Resource Found') + + +#============================================================================= +class DefaultResourceLoader(ResourceLoader): + def __init__(self, index_source, warc_paths=''): + loaders = [WARCPathLoader(warc_paths, index_source), + LiveWebLoader() + ] + super(DefaultResourceLoader, self).__init__(index_source, loaders) + + +#============================================================================= +class LoaderSeq(object): + def __init__(self, loaders): + self.loaders = loaders + + def __call__(self, params): + for loader in self.loaders: + try: + res = loader(params) + if res: + return res + except ArchiveLoadFailed: + pass + + raise ArchiveLoadFailed('No Resource Found') + + diff --git a/test_aggindexsource.py b/test_aggindexsource.py index d0866c0a..db93dd26 100644 --- a/test_aggindexsource.py +++ b/test_aggindexsource.py @@ -15,7 +15,7 @@ sources = { source = AggIndexSource(sources, timeout=5.0) -def select_json(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source_name']): +def select_json(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) @@ -24,11 +24,11 @@ def test_agg_index_1(): res = source(dict(url=url, closest='20140126000000', limit=5)) - exp = [{"timestamp": "20140126093743", "load_url": "http://web.archive.org/web/20140126093743id_/http://iana.org/", "source_name": "ia"}, - {"timestamp": "20140126200624", "filename": "iana.warc.gz", "source_name": "local"}, - {"timestamp": "20140123034755", "load_url": "http://web.archive.org/web/20140123034755id_/http://iana.org/", "source_name": "ia"}, - {"timestamp": "20140129175203", "load_url": "http://web.archive.org/web/20140129175203id_/http://iana.org/", "source_name": "ia"}, - {"timestamp": "20140107040552", "load_url": "http://wayback.archive-it.org/all/20140107040552id_/http://iana.org/", "source_name": "ait"} + exp = [{"timestamp": "20140126093743", "load_url": "http://web.archive.org/web/20140126093743id_/http://iana.org/", "source": "ia"}, + {"timestamp": "20140126200624", "filename": "iana.warc.gz", "source": "local"}, + {"timestamp": "20140123034755", "load_url": "http://web.archive.org/web/20140123034755id_/http://iana.org/", "source": "ia"}, + {"timestamp": "20140129175203", "load_url": "http://web.archive.org/web/20140129175203id_/http://iana.org/", "source": "ia"}, + {"timestamp": "20140107040552", "load_url": "http://wayback.archive-it.org/all/20140107040552id_/http://iana.org/", "source": "ait"} ] assert(select_json(res) == exp) @@ -38,12 +38,12 @@ def test_agg_index_2(): url = 'http://example.com/' res = source(dict(url=url, closest='20100512', limit=6)) - exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source_name": "bl"}, - {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source_name": "bl"}, - {"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source_name": "ia"}, - {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source_name": "ait"}, - {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source_name": "ait"}, - {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source_name": "ia"}] + exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source": "bl"}, + {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source": "bl"}, + {"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source": "ia"}, + {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}] assert(select_json(res) == exp) @@ -52,11 +52,22 @@ def test_agg_index_3(): url = 'http://vvork.com/' res = source(dict(url=url, closest='20141001', limit=5)) - exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source_name": "rhiz"}, - {"timestamp": "20141018133107", "load_url": "http://web.archive.org/web/20141018133107id_/http://vvork.com/", "source_name": "ia"}, - {"timestamp": "20141020161243", "load_url": "http://web.archive.org/web/20141020161243id_/http://vvork.com/", "source_name": "ia"}, - {"timestamp": "20140806161228", "load_url": "http://web.archive.org/web/20140806161228id_/http://vvork.com/", "source_name": "ia"}, - {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source_name": "ait"}] + exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, + {"timestamp": "20141018133107", "load_url": "http://web.archive.org/web/20141018133107id_/http://vvork.com/", "source": "ia"}, + {"timestamp": "20141020161243", "load_url": "http://web.archive.org/web/20141020161243id_/http://vvork.com/", "source": "ia"}, + {"timestamp": "20140806161228", "load_url": "http://web.archive.org/web/20140806161228id_/http://vvork.com/", "source": "ia"}, + {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] assert(select_json(res) == exp) + +def test_agg_index_4(): + url = 'http://vvork.com/' + res = source(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + + exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, + {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] + + assert(select_json(res) == exp) + + diff --git a/utils.py b/utils.py index 6f9df22d..6d36162c 100644 --- a/utils.py +++ b/utils.py @@ -1,4 +1,8 @@ import re +import six + +from pywb.utils.timeutils import timestamp_to_http_date + LINK_SPLIT = re.compile(',\s*(?=[<])') LINK_SEG_SPLIT = re.compile(';\s*') @@ -50,3 +54,42 @@ class MementoUtils(object): results['mementos'] = mementos return results + + @staticmethod + def make_timemap_memento_link(cdx, datetime=None, rel='memento', end=',\n'): + + url = cdx.get('load_url') + if not url: + url = 'filename://' + cdx.get('filename') + + memento = '<{0}>; rel="{1}"; datetime="{2}"; src="{3}"' + end + + if not datetime: + datetime = timestamp_to_http_date(cdx['timestamp']) + + return memento.format(url, rel, datetime, cdx.get('source', '')) + + + @staticmethod + def make_timemap(cdx_iter): + # get first memento as it'll be used for 'from' field + try: + first_cdx = six.next(cdx_iter) + from_date = timestamp_to_http_date(first_cdx['timestamp']) + except StopIteration: + first_cdx = None + + # first memento link + yield MementoUtils.make_timemap_memento_link(first_cdx, datetime=from_date) + + prev_cdx = None + + for cdx in cdx_iter: + if prev_cdx: + yield MementoUtils.make_timemap_memento_link(prev_cdx) + + prev_cdx = cdx + + # last memento link, if any + if prev_cdx: + yield MementoUtils.make_timemap_memento_link(prev_cdx, end='') From c88c5f4cca0551c0182092ed6b6a835c9703d6eb Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 26 Feb 2016 18:25:10 -0800 Subject: [PATCH 005/112] add new package setup! add tests and testdata, splitting mem and dir agg tests --- .gitignore | 42 +++ README.rst | 0 aggindexsource.py | 155 ----------- rezag/__init__.py | 0 rezag/aggindexsource.py | 259 ++++++++++++++++++ rezag/handlers.py | 120 ++++++++ indexsource.py => rezag/indexsource.py | 87 +++--- inputrequest.py => rezag/inputrequest.py | 3 +- liverec.py => rezag/liverec.py | 0 responseloader.py => rezag/responseloader.py | 118 +------- utils.py => rezag/utils.py | 1 - setup.py | 59 ++++ test/test_dir_agg.py | 103 +++++++ .../test_indexsource.py | 47 ++-- test/test_memento_agg.py | 145 ++++++++++ test_aggindexsource.py | 73 ----- testdata/example.cdxj | 1 + testdata/iana.cdxj | 171 ++++++++++++ 18 files changed, 970 insertions(+), 414 deletions(-) create mode 100644 .gitignore create mode 100644 README.rst delete mode 100644 aggindexsource.py create mode 100644 rezag/__init__.py create mode 100644 rezag/aggindexsource.py create mode 100644 rezag/handlers.py rename indexsource.py => rezag/indexsource.py (76%) rename inputrequest.py => rezag/inputrequest.py (99%) rename liverec.py => rezag/liverec.py (100%) rename responseloader.py => rezag/responseloader.py (58%) rename utils.py => rezag/utils.py (99%) create mode 100755 setup.py create mode 100644 test/test_dir_agg.py rename test_indexsource.py => test/test_indexsource.py (82%) create mode 100644 test/test_memento_agg.py delete mode 100644 test_aggindexsource.py create mode 100644 testdata/example.cdxj create mode 100644 testdata/iana.cdxj diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..af231b1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +.eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +.vagrant + +# Node +node_modules/ diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..e69de29b diff --git a/aggindexsource.py b/aggindexsource.py deleted file mode 100644 index 546032f5..00000000 --- a/aggindexsource.py +++ /dev/null @@ -1,155 +0,0 @@ -from gevent.pool import Pool -import gevent -import json -import time -import os - -from heapq import merge -from collections import deque - -from indexsource import BaseIndexSource, FileIndexSource -from pywb.utils.wbexception import NotFoundException - - -#============================================================================= -class BaseAggIndexSource(BaseIndexSource): - def do_query(self, name, source, params): - try: - cdx_iter = source.load_index(dict(params)) - except NotFoundException as nf: - print('Not found in ' + name) - cdx_iter = iter([]) - - def add_name(cdx_iter): - for cdx in cdx_iter: - cdx['source'] = name - yield cdx - - return add_name(cdx_iter) - - def load_index(self, params): - iter_list = self._load_all(params) - - cdx_iter = merge(*(iter_list)) - - return cdx_iter - - def _load_all(self): - raise NotImplemented() - - -#============================================================================= -class TimingOutMixin(object): - def __init__(self, *args, **kwargs): - super(TimingOutMixin, self).__init__(*args, **kwargs) - self.t_count = kwargs.get('t_count', 3) - self.t_dura = kwargs.get('t_duration', 20) - self.timeouts = {} - - def is_timed_out(self, name): - timeout_deq = self.timeouts.get(name) - if not timeout_deq: - return False - - the_time = time.time() - for t in list(timeout_deq): - if (the_time - t) > self.t_dura: - timeout_deq.popleft() - - if len(timeout_deq) >= self.t_count: - print('Skipping {0}, {1} timeouts in {2} seconds'. - format(name, self.t_count, self.t_dura)) - return True - - return False - - def get_valid_sources(self, sources): - for name in sources: - if not self.is_timed_out(name): - yield name - - def track_source_error(self, name): - the_time = time.time() - if name not in self.timeouts: - self.timeouts[name] = deque() - - self.timeouts[name].append(the_time) - print(name + ' timed out!') - - -#============================================================================= -class GeventAggIndexSource(BaseAggIndexSource): - def __init__(self, sources, timeout=5.0, size=None): - self.sources = sources - self.pool = Pool(size=size) - self.timeout = timeout - - def get_sources(self, params): - srcs_list = params.get('sources') - if not srcs_list: - return self.sources - - sel_sources = tuple(srcs_list.split(',')) - - return [src for src in self.sources if src in sel_sources] - - def get_valid_sources(self, sources): - return sources.keys() - - def track_source_error(self, name): - pass - - def _load_all(self, params): - params['_timeout'] = self.timeout - - def do_spawn(n): - return self.pool.spawn(self.do_query, n, self.sources[n], params) - - sources = self.get_sources(params) - jobs = [do_spawn(src) for src in self.get_valid_sources(sources)] - - gevent.joinall(jobs, timeout=self.timeout) - - res = [] - for name, job in zip(sources, jobs): - if job.value: - res.append(job.value) - else: - self.track_source_error(name) - - return res - - -#============================================================================= -class AggIndexSource(TimingOutMixin, GeventAggIndexSource): - pass - - -#============================================================================= -class DirAggIndexSource(BaseAggIndexSource): - CDX_EXT = ('.cdx', '.cdxj') - - def __init__(self, base_dir): - self.index_template = base_dir - - def _init_files(self, the_dir): - sources = {} - for name in os.listdir(the_dir): - filename = os.path.join(the_dir, name) - - if filename.endswith(self.CDX_EXT): - print('Adding ' + filename) - sources[name] = FileIndexSource(filename) - - return sources - - def _load_all(self, params): - the_dir = self.get_index(params) - - try: - sources = self._init_files(the_dir) - except Exception: - raise NotFoundException(the_dir) - - return list([self.do_query(src, sources[src], params) - for src in sources.keys()]) diff --git a/rezag/__init__.py b/rezag/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rezag/aggindexsource.py b/rezag/aggindexsource.py new file mode 100644 index 00000000..76e32525 --- /dev/null +++ b/rezag/aggindexsource.py @@ -0,0 +1,259 @@ +from gevent.pool import Pool +import gevent +import json +import time +import os + +from pywb.cdx.cdxops import process_cdx +from pywb.cdx.query import CDXQuery + +from heapq import merge +from collections import deque + +from rezag.indexsource import FileIndexSource +from pywb.utils.wbexception import NotFoundException +import six +import glob + + +#============================================================================= +class BaseAggregator(object): + def __call__(self, params): + query = CDXQuery(params) + self._set_src_params(params) + + try: + cdx_iter = self.load_index(query.params) + except NotFoundException as nf: + cdx_iter = iter([]) + + cdx_iter = process_cdx(cdx_iter, query) + return cdx_iter + + def _set_src_params(self, params): + src_params = {} + for param, value in six.iteritems(params): + if not param.startswith('param.'): + continue + + parts = param.split('.', 3)[1:] + + if len(parts) == 2: + src = parts[0] + name = parts[1] + else: + src = '' + name = parts[0] + + if not src in src_params: + src_params[src] = {} + + src_params[src][name] = value + + params['_all_src_params'] = src_params + + def load_child_source(self, name, source, all_params): + try: + _src_params = all_params['_all_src_params'].get(name) + + #params = dict(url=all_params['url'], + # key=all_params['key'], + # end_key=all_params['end_key'], + # closest=all_params.get('closest'), + # _input_req=all_params.get('_input_req'), + # _timeout=all_params.get('_timeout'), + # _all_src_params=all_params.get('_all_src_params'), + # _src_params=_src_params) + + params = all_params + params['_src_params'] = _src_params + cdx_iter = source.load_index(params) + except NotFoundException as nf: + print('Not found in ' + name) + cdx_iter = iter([]) + + def add_name(cdx_iter): + for cdx in cdx_iter: + if 'source' in cdx: + cdx['source'] = name + '.' + cdx['source'] + else: + cdx['source'] = name + yield cdx + + return add_name(cdx_iter) + + def load_index(self, params): + iter_list = list(self._load_all(params)) + + #optimization: if only a single entry (or empty) just load directly + if len(iter_list) <= 1: + cdx_iter = iter_list[0] if iter_list else iter([]) + else: + cdx_iter = merge(*(iter_list)) + + return cdx_iter + + def _load_all(self, params): #pragma: no cover + raise NotImplemented() + + def get_sources(self, params): #pragma: no cover + raise NotImplemented() + + +#============================================================================= +class BaseSourceListAggregator(BaseAggregator): + def __init__(self, sources, **kwargs): + self.sources = sources + + def get_all_sources(self, params): + return self.sources + + def get_sources(self, params): + sources = self.get_all_sources(params) + srcs_list = params.get('sources') + if not srcs_list: + return sources.items() + + sel_sources = tuple(srcs_list.split(',')) + + return [(name, sources[name]) for name in sources.keys() if name in sel_sources] + + +#============================================================================= +class SeqAggMixin(object): + def __init__(self, *args, **kwargs): + super(SeqAggMixin, self).__init__(*args, **kwargs) + + + def _load_all(self, params): + sources = list(self.get_sources(params)) + return list([self.load_child_source(name, source, params) + for name, source in sources]) + + +#============================================================================= +class SimpleAggregator(SeqAggMixin, BaseSourceListAggregator): + pass + + +#============================================================================= +class TimeoutMixin(object): + def __init__(self, *args, **kwargs): + super(TimeoutMixin, self).__init__(*args, **kwargs) + self.t_count = kwargs.get('t_count', 3) + self.t_dura = kwargs.get('t_duration', 20) + self.timeouts = {} + + def is_timed_out(self, name): + timeout_deq = self.timeouts.get(name) + if not timeout_deq: + return False + + the_time = time.time() + for t in list(timeout_deq): + if (the_time - t) > self.t_dura: + timeout_deq.popleft() + + if len(timeout_deq) >= self.t_count: + print('Skipping {0}, {1} timeouts in {2} seconds'. + format(name, self.t_count, self.t_dura)) + return True + + return False + + def get_sources(self, params): + sources = super(TimeoutMixin, self).get_sources(params) + for name, source in sources: + if not self.is_timed_out(name): + yield name, source + + def track_source_error(self, name): + the_time = time.time() + if name not in self.timeouts: + self.timeouts[name] = deque() + + self.timeouts[name].append(the_time) + print(name + ' timed out!') + + +#============================================================================= +class GeventAggMixin(object): + def __init__(self, *args, **kwargs): + super(GeventAggMixin, self).__init__(*args, **kwargs) + self.pool = Pool(size=kwargs.get('size')) + self.timeout = kwargs.get('timeout', 5.0) + + def track_source_error(self, name): + pass + + def _load_all(self, params): + params['_timeout'] = self.timeout + + sources = list(self.get_sources(params)) + + def do_spawn(name, source): + return self.pool.spawn(self.load_child_source, name, source, params) + + jobs = [do_spawn(name, source) for name, source in sources] + + gevent.joinall(jobs, timeout=self.timeout) + + res = [] + for name, job in zip(sources, jobs): + if job.value: + res.append(job.value) + else: + self.track_source_error(name) + + return res + + +#============================================================================= +class GeventTimeoutAggregator(TimeoutMixin, GeventAggMixin, BaseSourceListAggregator): + pass + + +#============================================================================= +class BaseDirectoryIndexAggregator(BaseAggregator): + CDX_EXT = ('.cdx', '.cdxj') + + def __init__(self, base_prefix, base_dir): + self.base_prefix = base_prefix + self.base_dir = base_dir + + def get_sources(self, params): + # see if specific params (when part of another agg) + src_params = params.get('_src_params') + if not src_params: + # try default param. settings + src_params = params.get('_all_src_params', {}).get('') + + if src_params: + the_dir = self.base_dir.format(**src_params) + else: + the_dir = self.base_dir + + the_dir = os.path.join(self.base_prefix, the_dir) + + try: + sources = list(self._load_files(the_dir)) + except Exception: + raise NotFoundException(the_dir) + + return sources + + def _load_files(self, glob_dir): + for the_dir in glob.iglob(glob_dir): + print(the_dir) + for name in os.listdir(the_dir): + filename = os.path.join(the_dir, name) + + if filename.endswith(self.CDX_EXT): + print('Adding ' + filename) + rel_path = os.path.relpath(the_dir, self.base_prefix) + yield rel_path, FileIndexSource(filename) + +class DirectoryIndexAggregator(SeqAggMixin, BaseDirectoryIndexAggregator): + pass + + diff --git a/rezag/handlers.py b/rezag/handlers.py new file mode 100644 index 00000000..30e3ce98 --- /dev/null +++ b/rezag/handlers.py @@ -0,0 +1,120 @@ +from rezag.utils import MementoUtils +from pywb.warc.recordloader import ArchiveLoadFailed +from rezag.responseloader import WARCPathHandler, LiveWebHandler +from bottle import response + + +#============================================================================= +def to_cdxj(cdx_iter, fields): + response.headers['Content-Type'] = 'text/x-cdxj' + return [cdx.to_cdxj(fields) for cdx in cdx_iter] + +def to_json(cdx_iter, fields): + response.headers['Content-Type'] = 'application/x-ndjson' + return [cdx.to_json(fields) for cdx in cdx_iter] + +def to_text(cdx_iter, fields): + response.headers['Content-Type'] = 'text/plain' + return [cdx.to_text(fields) for cdx in cdx_iter] + +def to_link(cdx_iter, fields): + response.headers['Content-Type'] = 'application/link' + return MementoUtils.make_timemap(cdx_iter) + + +#============================================================================= +class IndexHandler(object): + OUTPUTS = { + 'cdxj': to_cdxj, + 'json': to_json, + 'text': to_text, + 'link': to_link, + } + + DEF_OUTPUT = 'cdxj' + + def __init__(self, index_source, opts=None): + self.index_source = index_source + self.opts = opts or {} + + def __call__(self, params): + if params.get('mode') == 'sources': + srcs = self.index_source.get_sources(params) + result = [(name, str(value)) for name, value in srcs] + result = {'sources': dict(result)} + return result + + input_req = params.get('_input_req') + if input_req: + params['url'] = input_req.include_post_query() + + cdx_iter = self.index_source(params) + + output = params.get('output', self.DEF_OUTPUT) + fields = params.get('fields') + + handler = self.OUTPUTS.get(output) + if not handler: + handler = self.OUTPUTS[self.DEF_OUTPUT] + + res = handler(cdx_iter, fields) + return res + + +#============================================================================= +class ResourceHandler(IndexHandler): + def __init__(self, index_source, resource_loaders): + super(ResourceHandler, self).__init__(index_source) + self.resource_loaders = resource_loaders + + def __call__(self, params): + if params.get('mode', 'resource') != 'resource': + return super(ResourceHandler, self).__call__(params) + + cdx_iter = self.index_source(params) + + any_found = False + + for cdx in cdx_iter: + any_found = True + cdx['coll'] = params.get('coll', '') + + for loader in self.resource_loaders: + try: + resp = loader(cdx, params) + if resp: + return resp + except ArchiveLoadFailed as e: + print(e) + pass + + if any_found: + raise ArchiveLoadFailed('Resource Found, could not be Loaded') + else: + raise ArchiveLoadFailed('No Resource Found') + + +#============================================================================= +class DefaultResourceHandler(ResourceHandler): + def __init__(self, index_source, warc_paths=''): + loaders = [WARCPathHandler(warc_paths, index_source), + LiveWebHandler() + ] + super(DefaultResourceHandler, self).__init__(index_source, loaders) + + +#============================================================================= +class HandlerSeq(object): + def __init__(self, loaders): + self.loaders = loaders + + def __call__(self, params): + for loader in self.loaders: + try: + res = loader(params) + if res: + return res + except ArchiveLoadFailed: + pass + + raise ArchiveLoadFailed('No Resource Found') diff --git a/indexsource.py b/rezag/indexsource.py similarity index 76% rename from indexsource.py rename to rezag/indexsource.py index e332e6bf..200d136a 100644 --- a/indexsource.py +++ b/rezag/indexsource.py @@ -8,71 +8,52 @@ from pywb.utils.wbexception import NotFoundException from pywb.cdx.cdxobject import CDXObject from pywb.cdx.query import CDXQuery -from pywb.cdx.cdxops import process_cdx import requests -from utils import MementoUtils +from rezag.utils import MementoUtils #============================================================================= class BaseIndexSource(object): - def __init__(self, index_template=''): - self.index_template = index_template - - def get_index(self, params): - res = self.index_template.format(**params) - return res - - def load_index(self, params): + def load_index(self, params): #pragma: no cover raise NotImplemented() - def __call__(self, params): - query = CDXQuery(params) - - try: - cdx_iter = self.load_index(query.params) - except NotFoundException as nf: - cdx_iter = iter([]) - - cdx_iter = process_cdx(cdx_iter, query) - return cdx_iter - - def _include_post_query(self, params): - input_req = params.get('_input_req') - if input_req: - orig_url = params['url'] - params['url'] = input_req.include_post_query(params['url']) - return (params['url'] != orig_url) + @staticmethod + def res_template(template, params): + src_params = params.get('_src_params') + if src_params: + res = template.format(**src_params) + else: + res = template + return res #============================================================================= class FileIndexSource(BaseIndexSource): - def load_index(self, params): - if self._include_post_query(params): - params = CDXQuery(params).params + def __init__(self, filename): + self.filename_template = filename - filename = self.get_index(params) + def load_index(self, params): + filename = self.res_template(self.filename_template, params) with open(filename, 'rb') as fh: gen = iter_range(fh, params['key'], params['end_key']) for line in gen: yield CDXObject(line) - #return do_load(filename) + def __str__(self): + return 'file' #============================================================================= class RemoteIndexSource(BaseIndexSource): - def __init__(self, cdx_url, replay_url): - self.index_template = cdx_url + def __init__(self, api_url, replay_url): + self.api_url_template = api_url self.replay_url = replay_url def load_index(self, params): - if self._include_post_query(params): - params = CDXQuery(**params).params - - api_url = self.get_index(params) + api_url = self.res_template(self.api_url_template, params) api_url += '?url=' + params['url'] r = requests.get(api_url, timeout=params.get('_timeout')) if r.status_code >= 400: @@ -87,6 +68,9 @@ class RemoteIndexSource(BaseIndexSource): return do_load(lines) + def __str__(self): + return 'remote' + #============================================================================= class LiveIndexSource(BaseIndexSource): @@ -102,6 +86,9 @@ class LiveIndexSource(BaseIndexSource): return live() + def __str__(self): + return 'live' + #============================================================================= class RedisIndexSource(BaseIndexSource): @@ -113,11 +100,11 @@ class RedisIndexSource(BaseIndexSource): redis_url = 'redis://' + parts[2] + '/' + parts[3] self.redis_url = redis_url - self.index_template = key_prefix + self.redis_key_template = key_prefix self.redis = redis.StrictRedis.from_url(redis_url) def load_index(self, params): - z_key = self.get_index(params) + z_key = self.res_template(self.redis_key_template, params) index_list = self.redis.zrangebylex(z_key, b'[' + params['key'], b'(' + params['end_key']) @@ -128,6 +115,9 @@ class RedisIndexSource(BaseIndexSource): return do_load(index_list) + def __str__(self): + return 'redis' + #============================================================================= class MementoIndexSource(BaseIndexSource): @@ -136,7 +126,7 @@ class MementoIndexSource(BaseIndexSource): self.timemap_url = timemap_url self.replay_url = replay_url - def links_to_cdxobject(self, link_header, def_name, sort=False): + def links_to_cdxobject(self, link_header, def_name): results = MementoUtils.parse_links(link_header, def_name) #meta = MementoUtils.meta_field('timegate', results) @@ -155,14 +145,9 @@ class MementoIndexSource(BaseIndexSource): key = canonicalize(original) mementos = results['mementos'] - if sort: - mementos = sorted(mementos) for val in mementos: - dt = val.get('datetime') - if not dt: - continue - + dt = val['datetime'] ts = http_date_to_timestamp(dt) cdx = CDXObject() cdx['urlkey'] = key @@ -178,7 +163,8 @@ class MementoIndexSource(BaseIndexSource): yield cdx def get_timegate_links(self, params, closest): - url = self.timegate_url.format(coll=params.get('coll')) + params['url'] + url = self.res_template(self.timegate_url, params) + url += params['url'] accept_dt = timestamp_to_http_date(closest) res = requests.head(url, headers={'Accept-Datetime': accept_dt}) if res.status_code >= 400: @@ -187,7 +173,8 @@ class MementoIndexSource(BaseIndexSource): return res.headers.get('Link') def get_timemap_links(self, params): - url = self.timemap_url + params['url'] + url = self.res_template(self.timemap_url, params) + url += params['url'] res = requests.get(url, timeout=params.get('_timeout')) if res.status_code >= 400: raise NotFoundException(url) @@ -212,5 +199,7 @@ class MementoIndexSource(BaseIndexSource): timegate_url + 'timemap/' + path + '/', timegate_url + '{timestamp}id_/{url}') + def __str__(self): + return 'memento' diff --git a/inputrequest.py b/rezag/inputrequest.py similarity index 99% rename from inputrequest.py rename to rezag/inputrequest.py index 4e8964e3..221ede0f 100644 --- a/inputrequest.py +++ b/rezag/inputrequest.py @@ -4,8 +4,7 @@ from pywb.utils.loaders import LimitReader from pywb.utils.statusandheaders import StatusAndHeadersParser from six.moves.urllib.parse import urlsplit -from six import StringIO -import six +from six import StringIO, iteritems #============================================================================= diff --git a/liverec.py b/rezag/liverec.py similarity index 100% rename from liverec.py rename to rezag/liverec.py diff --git a/responseloader.py b/rezag/responseloader.py similarity index 58% rename from responseloader.py rename to rezag/responseloader.py index 17533d40..f4c4fa04 100644 --- a/responseloader.py +++ b/rezag/responseloader.py @@ -1,7 +1,6 @@ -from liverec import BaseRecorder -from liverec import request as remote_request +from rezag.liverec import BaseRecorder +from rezag.liverec import request as remote_request -from pywb.warc.recordloader import ArcWarcRecordLoader, ArchiveLoadFailed from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date from pywb.warc.resolvingloader import ResolvingLoader @@ -9,7 +8,6 @@ from io import BytesIO from bottle import response import uuid -from utils import MementoUtils #============================================================================= @@ -31,7 +29,7 @@ def incr_reader(stream, header=None, size=8192): #============================================================================= -class WARCPathLoader(object): +class WARCPathHandler(object): def __init__(self, paths, cdx_source): self.paths = paths if isinstance(paths, str): @@ -101,7 +99,7 @@ class HeaderRecorder(BaseRecorder): #============================================================================= -class LiveWebLoader(object): +class LiveWebHandler(object): SKIP_HEADERS = (b'link', b'memento-datetime', b'content-location', @@ -165,111 +163,3 @@ class LiveWebLoader(object): if not id_: id_ = uuid.uuid1() return ''.format(id_) - - -#============================================================================= -def to_cdxj(cdx_iter, fields): - response.headers['Content-Type'] = 'text/x-cdxj' - return [cdx.to_cdxj(fields) for cdx in cdx_iter] - -def to_json(cdx_iter, fields): - response.headers['Content-Type'] = 'application/x-ndjson' - return [cdx.to_json(fields) for cdx in cdx_iter] - -def to_text(cdx_iter, fields): - response.headers['Content-Type'] = 'text/plain' - return [cdx.to_text(fields) for cdx in cdx_iter] - -def to_link(cdx_iter, fields): - response.headers['Content-Type'] = 'application/link' - return MementoUtils.make_timemap(cdx_iter) - - -#============================================================================= -class IndexLoader(object): - OUTPUTS = { - 'cdxj': to_cdxj, - 'json': to_json, - 'text': to_text, - 'link': to_link, - } - - DEF_OUTPUT = 'cdxj' - - def __init__(self, index_source): - self.index_source = index_source - - def __call__(self, params): - cdx_iter = self.index_source(params) - - output = params.get('output', self.DEF_OUTPUT) - fields = params.get('fields') - - handler = self.OUTPUTS.get(output) - if not handler: - handler = self.OUTPUTS[self.DEF_OUTPUT] - - res = handler(cdx_iter, fields) - return res - - -#============================================================================= -class ResourceLoader(IndexLoader): - def __init__(self, index_source, resource_loaders): - super(ResourceLoader, self).__init__(index_source) - self.resource_loaders = resource_loaders - - def __call__(self, params): - output = params.get('output') - if output != 'resource': - return super(ResourceLoader, self).__call__(params) - - cdx_iter = self.index_source(params) - - any_found = False - - for cdx in cdx_iter: - any_found = True - cdx['coll'] = params.get('coll', '') - - for loader in self.resource_loaders: - try: - resp = loader(cdx, params) - if resp: - return resp - except ArchiveLoadFailed as e: - print(e) - pass - - if any_found: - raise ArchiveLoadFailed('Resource Found, could not be Loaded') - else: - raise ArchiveLoadFailed('No Resource Found') - - -#============================================================================= -class DefaultResourceLoader(ResourceLoader): - def __init__(self, index_source, warc_paths=''): - loaders = [WARCPathLoader(warc_paths, index_source), - LiveWebLoader() - ] - super(DefaultResourceLoader, self).__init__(index_source, loaders) - - -#============================================================================= -class LoaderSeq(object): - def __init__(self, loaders): - self.loaders = loaders - - def __call__(self, params): - for loader in self.loaders: - try: - res = loader(params) - if res: - return res - except ArchiveLoadFailed: - pass - - raise ArchiveLoadFailed('No Resource Found') - - diff --git a/utils.py b/rezag/utils.py similarity index 99% rename from utils.py rename to rezag/utils.py index 6d36162c..2e5ae1c6 100644 --- a/utils.py +++ b/rezag/utils.py @@ -57,7 +57,6 @@ class MementoUtils(object): @staticmethod def make_timemap_memento_link(cdx, datetime=None, rel='memento', end=',\n'): - url = cdx.get('load_url') if not url: url = 'filename://' + cdx.get('filename') diff --git a/setup.py b/setup.py new file mode 100755 index 00000000..e3ce8061 --- /dev/null +++ b/setup.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# vim: set sw=4 et: + +from setuptools import setup, find_packages +from setuptools.command.test import test as TestCommand + +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + # should work with setuptools <18, 18 18.5 + self.test_suite = ' ' + + def run_tests(self): + import pytest + import sys + import os + cmdline = ' --cov rezag -v test/' + errcode = pytest.main(cmdline) + sys.exit(errcode) + +setup( + name='rezag', + version='1.0', + author='Ilya Kreymer', + author_email='ikreymer@gmail.com', + license='MIT', + packages=find_packages(), + url='https://github.com/webrecorder/rezag', + description='Resource Aggregator', + long_description=open('README.rst').read(), + provides=[ + 'rezag', + ], + install_requires=[ + 'pywb', + ], + zip_safe=True, + entry_points=""" + [console_scripts] + """, + cmdclass={'test': PyTest}, + test_suite='', + tests_require=[ + 'pytest', + 'pytest-cov', + ], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Utilities', + ] +) diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py new file mode 100644 index 00000000..02cd5839 --- /dev/null +++ b/test/test_dir_agg.py @@ -0,0 +1,103 @@ +import tempfile +import os +import shutil +import json + +from rezag.aggindexsource import DirectoryIndexAggregator, SimpleAggregator + + +#============================================================================= +root_dir = None +orig_cwd = None +dir_agg = None + +def setup_module(): + global root_dir + root_dir = tempfile.mkdtemp() + + coll_A = to_path(root_dir + '/colls/A/indexes') + coll_B = to_path(root_dir + '/colls/B/indexes') + + os.makedirs(coll_A) + os.makedirs(coll_B) + + dir_prefix = to_path(root_dir) + dir_path ='colls/{coll}/indexes' + + shutil.copy(to_path('testdata/example.cdxj'), coll_A) + shutil.copy(to_path('testdata/iana.cdxj'), coll_B) + + global dir_agg + dir_agg = DirectoryIndexAggregator(dir_prefix, dir_path) + + global orig_cwd + orig_cwd = os.getcwd() + os.chdir(root_dir) + + # use actually set dir + root_dir = os.getcwd() + +def teardown_module(): + global orig_cwd + os.chdir(orig_cwd) + + global root_dir + shutil.rmtree(root_dir) + + +def to_path(path): + if os.path.sep != '/': + path = path.replace('/', os.path.sep) + + return path + + +def to_json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): + return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) + + +def test_agg_no_coll_set(): + res = dir_agg(dict(url='example.com/')) + assert(to_json_list(res) == []) + + +def test_agg_collA_found(): + res = dir_agg({'url': 'example.com/', 'param.coll': 'A'}) + + exp = [{'source': 'colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] + + assert(to_json_list(res) == exp) + +def test_agg_collB(): + res = dir_agg({'url': 'example.com/', 'param.coll': 'B'}) + + exp = [] + + assert(to_json_list(res) == exp) + +def test_agg_collB_found(): + res = dir_agg({'url': 'iana.org/', 'param.coll': 'B'}) + + exp = [{'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + + assert(to_json_list(res) == exp) + + +def test_agg_all_found(): + res = dir_agg({'url': 'iana.org/', 'param.coll': '*'}) + + exp = [{'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + + assert(to_json_list(res) == exp) + + +def test_extra_agg_all(): + agg_dir_agg = SimpleAggregator({'dir': dir_agg}) + res = agg_dir_agg({'url': 'iana.org/', 'param.coll': '*'}) + + exp = [{'source': 'dir.colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + + assert(to_json_list(res) == exp) + + + diff --git a/test_indexsource.py b/test/test_indexsource.py similarity index 82% rename from test_indexsource.py rename to test/test_indexsource.py index 349c609e..b4f81bf1 100644 --- a/test_indexsource.py +++ b/test/test_indexsource.py @@ -1,29 +1,32 @@ -from indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource -from indexsource import LiveIndexSource +from rezag.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource +from rezag.indexsource import LiveIndexSource + +from rezag.aggindexsource import SimpleAggregator from pywb.utils.timeutils import timestamp_now -import redis import pytest +import redis +import fakeredis + +redis.StrictRedis = fakeredis.FakeStrictRedis +redis.Redis = fakeredis.FakeRedis + def key_ts_res(cdxlist, extra='filename'): return '\n'.join([cdx['urlkey'] + ' ' + cdx['timestamp'] + ' ' + cdx[extra] for cdx in cdxlist]) def setup_module(): - r = redis.StrictRedis(db=2) + global r + r = fakeredis.FakeStrictRedis(db=2) r.delete('test:rediscdx') - with open('sample.cdxj', 'rb') as fh: + with open('testdata/iana.cdxj', 'rb') as fh: for line in fh: r.zadd('test:rediscdx', 0, line.rstrip()) -def teardown_module(): - r = redis.StrictRedis(db=2) - r.delete('test:rediscdx') - - local_sources = [ - FileIndexSource('sample.cdxj'), + FileIndexSource('testdata/iana.cdxj'), RedisIndexSource('redis://localhost:6379/2/test:rediscdx') ] @@ -38,13 +41,17 @@ remote_sources = [ ] +def query_single_source(source, params): + return SimpleAggregator({'source': source})(params) + + # Url Match -- Local Loaders # ============================================================================ @pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) def test_local_cdxj_loader(source): url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' - res = source(dict(url=url, limit=3)) + res = query_single_source(source, dict(url=url, limit=3)) expected = """\ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz @@ -59,7 +66,7 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz""" @pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) def test_local_closest_loader(source): url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' - res = source(dict(url=url, + res = query_single_source(source, dict(url=url, closest='20140126200930', limit=3)) @@ -75,7 +82,7 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz""" # ============================================================================ @pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) def test_file_prefix_loader(source): - res = source(dict(url='http://iana.org/domains/root/*')) + res = query_single_source(source, dict(url='http://iana.org/domains/root/*')) expected = """\ org,iana)/domains/root/db 20140126200927 iana.warc.gz @@ -90,7 +97,7 @@ org,iana)/domains/root/servers 20140126201227 iana.warc.gz""" @pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) def test_remote_loader(source): url = 'http://instagram.com/amaliaulman' - res = source(dict(url=url)) + res = query_single_source(source, dict(url=url)) expected = """\ com,instagram)/amaliaulman 20141014150552 http://webenact.rhizome.org/all/20141014150552id_/http://instagram.com/amaliaulman @@ -106,7 +113,7 @@ com,instagram)/amaliaulman 20141014171636 http://webenact.rhizome.org/all/201410 @pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) def test_remote_closest_loader(source): url = 'http://instagram.com/amaliaulman' - res = source(dict(url=url, closest='20141014162332', limit=1)) + res = query_single_source(source, dict(url=url, closest='20141014162332', limit=1)) expected = """\ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" @@ -119,7 +126,7 @@ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/201410 @pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) def test_remote_closest_loader(source): url = 'http://instagram.com/amaliaulman' - res = source(dict(url=url, closest='20141014162332', limit=1)) + res = query_single_source(source, dict(url=url, closest='20141014162332', limit=1)) expected = """\ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" @@ -131,7 +138,7 @@ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/201410 def test_live(): url = 'http://example.com/' source = LiveIndexSource() - res = source(dict(url=url)) + res = query_single_source(source, dict(url=url)) expected = 'com,example)/ {0} http://example.com/'.format(timestamp_now()) @@ -145,7 +152,7 @@ def test_live(): @pytest.mark.parametrize("source", local_sources + remote_sources, ids=["file", "redis", "remote_cdx", "memento"]) def test_all_not_found(source): url = 'http://x-not-found-x.notfound/' - res = source(dict(url=url, limit=3)) + res = query_single_source(source, dict(url=url, limit=3)) expected = '' assert(key_ts_res(res) == expected) @@ -156,7 +163,7 @@ def test_all_not_found(source): def test_another_remote_not_found(): source = MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/') url = 'http://x-not-found-x.notfound/' - res = source(dict(url=url, limit=3)) + res = query_single_source(source, dict(url=url, limit=3)) expected = '' diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py new file mode 100644 index 00000000..aff7359f --- /dev/null +++ b/test/test_memento_agg.py @@ -0,0 +1,145 @@ +from gevent import monkey; monkey.patch_all() +from rezag.aggindexsource import SimpleAggregator, GeventTimeoutAggregator + +from rezag.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource +import json +import pytest + +from rezag.handlers import IndexHandler + + +sources = { + 'local': FileIndexSource('testdata/iana.cdxj'), + 'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), + 'ait': MementoIndexSource.from_timegate_url('http://wayback.archive-it.org/all/'), + 'bl': MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/'), + 'rhiz': MementoIndexSource.from_timegate_url('http://webenact.rhizome.org/vvork/', path='*') +} + +#@pytest.mark.parametrize("agg", aggs, ids=["simple", "gevent_timeout"]) +def pytest_generate_tests(metafunc): + metafunc.parametrize("agg", aggs, ids=["simple", "gevent_timeout"]) + + +aggs = [SimpleAggregator(sources), + GeventTimeoutAggregator(sources, timeout=5.0) + ] + + +def json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): + return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) + + +def test_mem_agg_index_1(agg): + url = 'http://iana.org/' + res = agg(dict(url=url, closest='20140126000000', limit=5)) + + + exp = [{"timestamp": "20140126093743", "load_url": "http://web.archive.org/web/20140126093743id_/http://iana.org/", "source": "ia"}, + {"timestamp": "20140126200624", "filename": "iana.warc.gz", "source": "local"}, + {"timestamp": "20140123034755", "load_url": "http://web.archive.org/web/20140123034755id_/http://iana.org/", "source": "ia"}, + {"timestamp": "20140129175203", "load_url": "http://web.archive.org/web/20140129175203id_/http://iana.org/", "source": "ia"}, + {"timestamp": "20140107040552", "load_url": "http://wayback.archive-it.org/all/20140107040552id_/http://iana.org/", "source": "ait"} + ] + + assert(json_list(res) == exp) + + +def test_mem_agg_index_2(agg): + url = 'http://example.com/' + res = agg(dict(url=url, closest='20100512', limit=6)) + + exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source": "bl"}, + {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source": "bl"}, + {"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source": "ia"}, + {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}] + + assert(json_list(res) == exp) + + +def test_mem_agg_index_3(agg): + url = 'http://vvork.com/' + res = agg(dict(url=url, closest='20141001', limit=5)) + + exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, + {"timestamp": "20141018133107", "load_url": "http://web.archive.org/web/20141018133107id_/http://vvork.com/", "source": "ia"}, + {"timestamp": "20141020161243", "load_url": "http://web.archive.org/web/20141020161243id_/http://vvork.com/", "source": "ia"}, + {"timestamp": "20140806161228", "load_url": "http://web.archive.org/web/20140806161228id_/http://vvork.com/", "source": "ia"}, + {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] + + assert(json_list(res) == exp) + + +def test_mem_agg_index_4(agg): + url = 'http://vvork.com/' + res = agg(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + + exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, + {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] + + assert(json_list(res) == exp) + + +def test_handler_output_cdxj(agg): + loader = IndexHandler(agg) + url = 'http://vvork.com/' + res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + + exp = """\ +com,vvork)/ 20141006184357 {"url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} +com,vvork)/ 20131004231540 {"url": "http://vvork.com/", "mem_rel": "last memento", "memento_url": "http://wayback.archive-it.org/all/20131004231540/http://vvork.com/", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"} +""" + + assert(''.join(res) == exp) + + +def test_handler_output_json(agg): + loader = IndexHandler(agg) + url = 'http://vvork.com/' + res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) + + exp = """\ +{"urlkey": "com,vvork)/", "timestamp": "20141006184357", "url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} +{"urlkey": "com,vvork)/", "timestamp": "20131004231540", "url": "http://vvork.com/", "mem_rel": "last memento", "memento_url": "http://wayback.archive-it.org/all/20131004231540/http://vvork.com/", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"} +""" + + assert(''.join(res) == exp) + + +def test_handler_output_link(agg): + loader = IndexHandler(agg) + url = 'http://vvork.com/' + res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) + + exp = """\ +; rel="memento"; datetime="Mon, 06 Oct 2014 18:43:57 GMT"; src="rhiz", +; rel="memento"; datetime="Fri, 04 Oct 2013 23:15:40 GMT"; src="ait"\ +""" + assert(''.join(res) == exp) + + +def test_handler_output_text(agg): + loader = IndexHandler(agg) + url = 'http://vvork.com/' + res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) + + exp = """\ +com,vvork)/ 20141006184357 http://www.vvork.com/ memento http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/ http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/ rhiz +com,vvork)/ 20131004231540 http://vvork.com/ last memento http://wayback.archive-it.org/all/20131004231540/http://vvork.com/ http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/ ait +""" + assert(''.join(res) == exp) + + +def test_handler_list_sources(agg): + loader = IndexHandler(agg) + res = loader(dict(mode='sources')) + + assert(res == {'sources': {'bl': 'memento', + 'ait': 'memento', + 'ia': 'memento', + 'rhiz': 'memento', + 'local': 'file'}}) + + diff --git a/test_aggindexsource.py b/test_aggindexsource.py deleted file mode 100644 index db93dd26..00000000 --- a/test_aggindexsource.py +++ /dev/null @@ -1,73 +0,0 @@ -from gevent import monkey; monkey.patch_all() -from aggindexsource import AggIndexSource - -from indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource -import json - - -sources = { - 'local': FileIndexSource('sample.cdxj'), - 'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), - 'ait': MementoIndexSource.from_timegate_url('http://wayback.archive-it.org/all/'), - 'bl': MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/'), - 'rhiz': MementoIndexSource.from_timegate_url('http://webenact.rhizome.org/vvork/', path='*') -} - -source = AggIndexSource(sources, timeout=5.0) - -def select_json(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): - return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) - - -def test_agg_index_1(): - url = 'http://iana.org/' - res = source(dict(url=url, closest='20140126000000', limit=5)) - - - exp = [{"timestamp": "20140126093743", "load_url": "http://web.archive.org/web/20140126093743id_/http://iana.org/", "source": "ia"}, - {"timestamp": "20140126200624", "filename": "iana.warc.gz", "source": "local"}, - {"timestamp": "20140123034755", "load_url": "http://web.archive.org/web/20140123034755id_/http://iana.org/", "source": "ia"}, - {"timestamp": "20140129175203", "load_url": "http://web.archive.org/web/20140129175203id_/http://iana.org/", "source": "ia"}, - {"timestamp": "20140107040552", "load_url": "http://wayback.archive-it.org/all/20140107040552id_/http://iana.org/", "source": "ait"} - ] - - assert(select_json(res) == exp) - - -def test_agg_index_2(): - url = 'http://example.com/' - res = source(dict(url=url, closest='20100512', limit=6)) - - exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source": "bl"}, - {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source": "bl"}, - {"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source": "ia"}, - {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source": "ait"}, - {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source": "ait"}, - {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}] - - assert(select_json(res) == exp) - - -def test_agg_index_3(): - url = 'http://vvork.com/' - res = source(dict(url=url, closest='20141001', limit=5)) - - exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, - {"timestamp": "20141018133107", "load_url": "http://web.archive.org/web/20141018133107id_/http://vvork.com/", "source": "ia"}, - {"timestamp": "20141020161243", "load_url": "http://web.archive.org/web/20141020161243id_/http://vvork.com/", "source": "ia"}, - {"timestamp": "20140806161228", "load_url": "http://web.archive.org/web/20140806161228id_/http://vvork.com/", "source": "ia"}, - {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] - - assert(select_json(res) == exp) - - -def test_agg_index_4(): - url = 'http://vvork.com/' - res = source(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) - - exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, - {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] - - assert(select_json(res) == exp) - - diff --git a/testdata/example.cdxj b/testdata/example.cdxj new file mode 100644 index 00000000..72f092f5 --- /dev/null +++ b/testdata/example.cdxj @@ -0,0 +1 @@ +com,example)/ 20160225042329 {"url": "http://example.com/", "mime": "text/html", "status": "200", "digest": "37cf167c2672a4a64af901d9484e75eee0e2c98a", "length": "1286", "offset": "363", "filename": "example.warc.gz"} diff --git a/testdata/iana.cdxj b/testdata/iana.cdxj new file mode 100644 index 00000000..aadc54c0 --- /dev/null +++ b/testdata/iana.cdxj @@ -0,0 +1,171 @@ +org,iana)/ 20140126200624 {"url": "http://www.iana.org/", "mime": "text/html", "status": "200", "digest": "OSSAPWJ23L56IYVRW3GFEAR4MCJMGPTB", "length": "2258", "offset": "334", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 {"url": "http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf", "mime": "application/octet-stream", "status": "200", "digest": "LNMEDYOENSOEI5VPADCKL3CB6N3GWXPR", "length": "34054", "offset": "620049", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200912 {"url": "http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf", "mime": "warc/revisit", "digest": "LNMEDYOENSOEI5VPADCKL3CB6N3GWXPR", "length": "546", "offset": "667073", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 {"url": "http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf", "mime": "warc/revisit", "digest": "LNMEDYOENSOEI5VPADCKL3CB6N3GWXPR", "length": "534", "offset": "697255", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126201055 {"url": "http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf", "mime": "warc/revisit", "digest": "LNMEDYOENSOEI5VPADCKL3CB6N3GWXPR", "length": "547", "offset": "714833", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126201249 {"url": "http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf", "mime": "warc/revisit", "digest": "LNMEDYOENSOEI5VPADCKL3CB6N3GWXPR", "length": "551", "offset": "768625", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200625 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "application/octet-stream", "status": "200", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "117166", "offset": "198285", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200654 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "548", "offset": "482544", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200706 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "552", "offset": "495230", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200718 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "536", "offset": "566542", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200738 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "552", "offset": "578743", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200805 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "535", "offset": "593400", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200816 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "554", "offset": "608401", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200826 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "550", "offset": "654593", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200912 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "553", "offset": "670224", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126200930 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "551", "offset": "699343", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126201055 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "552", "offset": "712719", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126201128 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "554", "offset": "731718", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126201228 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "551", "offset": "745730", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126201240 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "551", "offset": "757988", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126201249 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "552", "offset": "771773", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140126201308 {"url": "https://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "551", "offset": "783712", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200626 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "application/octet-stream", "status": "200", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "114499", "offset": "83293", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200654 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "550", "offset": "446529", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200706 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "553", "offset": "493141", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200718 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "554", "offset": "567576", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200738 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "555", "offset": "580835", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200805 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "551", "offset": "595503", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200816 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "554", "offset": "609468", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200826 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "551", "offset": "655640", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200912 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "551", "offset": "669172", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126200930 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "553", "offset": "698287", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126201055 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "553", "offset": "711664", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126201128 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "553", "offset": "730663", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126201228 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "537", "offset": "743642", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126201240 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "552", "offset": "755896", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126201249 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "553", "offset": "769676", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140126201308 {"url": "https://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "551", "offset": "784758", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200654 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "application/octet-stream", "status": "200", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "116641", "offset": "329393", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200706 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "538", "offset": "494192", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200718 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "538", "offset": "565504", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200738 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "539", "offset": "579795", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200805 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "555", "offset": "592333", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200816 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "556", "offset": "607332", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200826 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "556", "offset": "656690", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200912 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "554", "offset": "668113", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126200930 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "556", "offset": "700397", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126201055 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "555", "offset": "713774", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126201128 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "556", "offset": "732779", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126201228 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "538", "offset": "744686", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126201240 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "537", "offset": "756949", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126201249 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "539", "offset": "770730", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-semibold.ttf 20140126201308 {"url": "https://www.iana.org/_css/2013.1/fonts/OpenSans-Semibold.ttf", "mime": "warc/revisit", "digest": "6HXHVHDNCPXC2ZBKQBWATZZXE5PGCN4S", "length": "554", "offset": "782657", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200625 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "text/css", "status": "200", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "4662", "offset": "50482", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200653 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "534", "offset": "326315", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200706 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "534", "offset": "487982", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200716 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "535", "offset": "561375", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200737 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "536", "offset": "574583", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200804 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "538", "offset": "588168", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200816 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "537", "offset": "602081", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200825 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "535", "offset": "613943", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200912 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "536", "offset": "662904", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126200929 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "537", "offset": "693076", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126201054 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "526", "offset": "707519", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126201127 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "525", "offset": "726489", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126201227 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "527", "offset": "738432", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126201239 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "526", "offset": "750710", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126201248 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "535", "offset": "763424", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/print.css 20140126201307 {"url": "https://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "539", "offset": "777477", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200625 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "text/css", "status": "200", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "8754", "offset": "41238", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200653 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "533", "offset": "328367", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200706 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "539", "offset": "489005", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200716 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "542", "offset": "563417", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200737 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "528", "offset": "572623", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200804 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "527", "offset": "589212", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200816 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "528", "offset": "603125", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200825 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "527", "offset": "614971", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200912 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "531", "offset": "661876", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126200929 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "538", "offset": "691096", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126201054 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "543", "offset": "706476", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126201127 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "543", "offset": "725445", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126201227 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "543", "offset": "739461", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126201239 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "541", "offset": "751731", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126201248 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "541", "offset": "764454", "filename": "iana.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140126201307 {"url": "https://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "537", "offset": "779533", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200654 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "image/svg+xml", "status": "200", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "9739", "offset": "447577", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200706 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "553", "offset": "491049", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200718 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "551", "offset": "564454", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200737 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "550", "offset": "576643", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200805 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "552", "offset": "591269", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200816 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "552", "offset": "605204", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200826 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "552", "offset": "617954", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200912 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "553", "offset": "664967", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126200929 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "550", "offset": "695150", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126201054 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "548", "offset": "709577", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126201128 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "552", "offset": "728551", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126201228 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "548", "offset": "741538", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126201239 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "549", "offset": "753801", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126201249 {"url": "http://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "551", "offset": "766525", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-header.svg 20140126201307 {"url": "https://www.iana.org/_img/2013.1/iana-logo-header.svg", "mime": "warc/revisit", "digest": "N6T6ZRHLEHKP2675D7JVKDYKVKYKWQ6X", "length": "552", "offset": "780562", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/iana-logo-homepage.png 20140126200625 {"url": "http://www.iana.org/_img/2013.1/iana-logo-homepage.png", "mime": "image/png", "status": "200", "digest": "GCW2GM3SIMHEIQYZX25MLSRYVWUCZ7OK", "length": "27163", "offset": "55631", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200625 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "image/svg+xml", "status": "200", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "2809", "offset": "4009", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200654 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "546", "offset": "457816", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200706 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "545", "offset": "492101", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200719 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "548", "offset": "568628", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200738 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "548", "offset": "577695", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200805 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "547", "offset": "594444", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200816 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "548", "offset": "606272", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200826 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "545", "offset": "619007", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200912 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "547", "offset": "666025", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126200930 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "547", "offset": "696207", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126201055 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "529", "offset": "710633", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126201128 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "549", "offset": "729609", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126201228 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "544", "offset": "742593", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126201240 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "546", "offset": "754853", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126201249 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "544", "offset": "767580", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140126201308 {"url": "https://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "546", "offset": "781613", "filename": "iana.warc.gz"} +org,iana)/_img/2013.1/rir-map.svg 20140126200654 {"url": "http://www.iana.org/_img/2013.1/rir-map.svg", "mime": "image/svg+xml", "status": "200", "digest": "C4LTM7ATRZYZL3W2UCEEX6A26L6PIT4K", "length": "23189", "offset": "458860", "filename": "iana.warc.gz"} +org,iana)/_img/bookmark_icon.ico 20140126200631 {"url": "http://www.iana.org/_img/bookmark_icon.ico", "mime": "application/octet-stream", "status": "200", "digest": "PG3PAWWE72JQ37CXJSPCJNNF7QI3SNX7", "length": "4968", "offset": "315944", "filename": "iana.warc.gz"} +org,iana)/_img/bookmark_icon.ico 20140126201310 {"url": "https://www.iana.org/_img/bookmark_icon.ico", "mime": "warc/revisit", "digest": "PG3PAWWE72JQ37CXJSPCJNNF7QI3SNX7", "length": "548", "offset": "785806", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200625 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "458", "offset": "3074", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200653 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "456", "offset": "325380", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200706 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "458", "offset": "487044", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200716 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "457", "offset": "560436", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200737 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "457", "offset": "573645", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200804 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "460", "offset": "587215", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200816 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "459", "offset": "601126", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200825 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "458", "offset": "615991", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200912 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "456", "offset": "660937", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126200929 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "458", "offset": "692132", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126201054 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "456", "offset": "705534", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126201127 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "457", "offset": "724500", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126201227 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "458", "offset": "737484", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126201239 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "457", "offset": "749770", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126201248 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "458", "offset": "762480", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140126201307 {"url": "https://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "453", "offset": "776543", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200625 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "application/x-javascript", "status": "200", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "33449", "offset": "7311", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200653 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "542", "offset": "327341", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200706 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "529", "offset": "490037", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200716 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "529", "offset": "562402", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200737 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "543", "offset": "575613", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200804 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "530", "offset": "590244", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200816 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "544", "offset": "604162", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200825 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "543", "offset": "616929", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200912 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "544", "offset": "663936", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126200929 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "546", "offset": "694112", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126201054 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "544", "offset": "708544", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126201127 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "545", "offset": "727515", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126201227 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "543", "offset": "740505", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126201239 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "545", "offset": "752769", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126201248 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "544", "offset": "765491", "filename": "iana.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140126201307 {"url": "https://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "543", "offset": "778507", "filename": "iana.warc.gz"} +org,iana)/about 20140126200706 {"url": "http://www.iana.org/about", "mime": "text/html", "status": "200", "digest": "6G77LZKFAVKH4PCWWKMW6TRJPSHWUBI3", "length": "2962", "offset": "483588", "filename": "iana.warc.gz"} +org,iana)/about/performance/ietf-draft-status 20140126200815 {"url": "http://www.iana.org/about/performance/ietf-draft-status", "mime": "text/html", "status": "302", "digest": "Y7CTA2QZUSCDTJCSECZNSPIBLJDO7PJJ", "length": "584", "offset": "596566", "filename": "iana.warc.gz"} +org,iana)/about/performance/ietf-statistics 20140126200804 {"url": "http://www.iana.org/about/performance/ietf-statistics", "mime": "text/html", "status": "302", "digest": "HNYDN7XRX46RQTT2OFIWXKEYMZQAJWHD", "length": "582", "offset": "581890", "filename": "iana.warc.gz"} +org,iana)/dnssec 20140126201306 {"url": "http://www.iana.org/dnssec", "mime": "text/html", "status": "302", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "442", "offset": "772827", "filename": "iana.warc.gz"} +org,iana)/dnssec 20140126201307 {"url": "https://www.iana.org/dnssec", "mime": "text/html", "status": "200", "digest": "PHLRSX73EV3WSZRFXMWDO6BRKTVUSASI", "length": "2278", "offset": "773766", "filename": "iana.warc.gz"} +org,iana)/domains 20140126200825 {"url": "http://www.iana.org/domains", "mime": "text/html", "status": "200", "digest": "7UPSCLNWNZP33LGW6OJGSF2Y4CDG4ES7", "length": "2912", "offset": "610534", "filename": "iana.warc.gz"} +org,iana)/domains/arpa 20140126201248 {"url": "http://www.iana.org/domains/arpa", "mime": "text/html", "status": "200", "digest": "QOFZZRN6JIKAL2JRL6ZC2VVG42SPKGHT", "length": "2939", "offset": "759039", "filename": "iana.warc.gz"} +org,iana)/domains/idn-tables 20140126201127 {"url": "http://www.iana.org/domains/idn-tables", "mime": "text/html", "status": "200", "digest": "HNCUFTJMOQOGAEY6T56KVC3T7TVLKGEW", "length": "8118", "offset": "715878", "filename": "iana.warc.gz"} +org,iana)/domains/int 20140126201239 {"url": "http://www.iana.org/domains/int", "mime": "text/html", "status": "200", "digest": "X32BBNNORV4SPEHTQF5KI5NFHSKTZK6Q", "length": "2482", "offset": "746788", "filename": "iana.warc.gz"} +org,iana)/domains/reserved 20140126201054 {"url": "http://www.iana.org/domains/reserved", "mime": "text/html", "status": "200", "digest": "R5AAEQX5XY5X5DG66B23ODN5DUBWRA27", "length": "3573", "offset": "701457", "filename": "iana.warc.gz"} +org,iana)/domains/root 20140126200912 {"url": "http://www.iana.org/domains/root", "mime": "text/html", "status": "200", "digest": "YWA2R6UVWCYNHBZJKBTPYPZ5CJWKGGUX", "length": "2691", "offset": "657746", "filename": "iana.warc.gz"} +org,iana)/domains/root/db 20140126200927 {"url": "http://www.iana.org/domains/root/db/", "mime": "text/html", "status": "302", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "446", "offset": "671278", "filename": "iana.warc.gz"} +org,iana)/domains/root/db 20140126200928 {"url": "http://www.iana.org/domains/root/db", "mime": "text/html", "status": "200", "digest": "DHXA725IW5VJJFRTWBQT6BEZKRE7H57S", "length": "18365", "offset": "672225", "filename": "iana.warc.gz"} +org,iana)/domains/root/servers 20140126201227 {"url": "http://www.iana.org/domains/root/servers", "mime": "text/html", "status": "200", "digest": "AFW34N3S4NK2RJ6QWMVPB5E2AIUETAHU", "length": "3137", "offset": "733840", "filename": "iana.warc.gz"} +org,iana)/numbers 20140126200651 {"url": "http://www.iana.org/numbers", "mime": "text/html", "status": "200", "digest": "HWT5UZKURYLW5QNWVZCWFCANGEMU7XWK", "length": "3498", "offset": "321385", "filename": "iana.warc.gz"} +org,iana)/performance/ietf-draft-status 20140126200815 {"url": "http://www.iana.org/performance/ietf-draft-status", "mime": "text/html", "status": "200", "digest": "T5IQTX6DWV5KABGH454CYEDWKRI5Y23E", "length": "2940", "offset": "597667", "filename": "iana.warc.gz"} +org,iana)/performance/ietf-statistics 20140126200804 {"url": "http://www.iana.org/performance/ietf-statistics", "mime": "text/html", "status": "200", "digest": "XOFML5WNBQMTSULLIIPLSP6U5MX33HN6", "length": "3712", "offset": "582987", "filename": "iana.warc.gz"} +org,iana)/protocols 20140126200715 {"url": "http://www.iana.org/protocols", "mime": "text/html", "status": "200", "digest": "IRUJZEUAXOUUG224ZMI4VWTUPJX6XJTT", "length": "63663", "offset": "496277", "filename": "iana.warc.gz"} +org,iana)/time-zones 20140126200737 {"url": "http://www.iana.org/time-zones", "mime": "text/html", "status": "200", "digest": "4Z27MYWOSXY2XDRAJRW7WRMT56LXDD4R", "length": "2449", "offset": "569675", "filename": "iana.warc.gz"} From 68090d00c1a7583c9ff14baad53495e5035c3241 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 28 Feb 2016 14:33:08 -0800 Subject: [PATCH 006/112] add routing setup via app.py add full test suite for handlers and responseloaders, as well as timeouts --- rezag/aggindexsource.py | 105 ++++++++++++------ rezag/app.py | 31 ++++++ rezag/handlers.py | 9 +- rezag/indexsource.py | 9 +- rezag/inputrequest.py | 31 +++--- rezag/responseloader.py | 26 ++++- test/__init__.py | 0 test/test_dir_agg.py | 94 ++++++++++++---- test/test_handlers.py | 216 +++++++++++++++++++++++++++++++++++++ test/test_indexsource.py | 19 +++- test/test_memento_agg.py | 27 ++--- test/test_timeouts.py | 105 ++++++++++++++++++ test/testutils.py | 16 +++ testdata/dupes.cdxj | 12 +++ testdata/dupes.warc.gz | Bin 0 -> 12905 bytes testdata/example.warc.gz | Bin 0 -> 2272 bytes testdata/iana.warc.gz | Bin 0 -> 786828 bytes testdata/post-test.cdxj | 3 + testdata/post-test.warc.gz | Bin 0 -> 3593 bytes 19 files changed, 607 insertions(+), 96 deletions(-) create mode 100644 rezag/app.py create mode 100644 test/__init__.py create mode 100644 test/test_handlers.py create mode 100644 test/test_timeouts.py create mode 100644 test/testutils.py create mode 100644 testdata/dupes.cdxj create mode 100644 testdata/dupes.warc.gz create mode 100644 testdata/example.warc.gz create mode 100644 testdata/iana.warc.gz create mode 100644 testdata/post-test.cdxj create mode 100644 testdata/post-test.warc.gz diff --git a/rezag/aggindexsource.py b/rezag/aggindexsource.py index 76e32525..435d0152 100644 --- a/rezag/aggindexsource.py +++ b/rezag/aggindexsource.py @@ -1,9 +1,13 @@ from gevent.pool import Pool import gevent + +from concurrent import futures + import json import time import os +from pywb.utils.timeutils import timestamp_now from pywb.cdx.cdxops import process_cdx from pywb.cdx.query import CDXQuery @@ -19,6 +23,9 @@ import glob #============================================================================= class BaseAggregator(object): def __call__(self, params): + if params.get('closest') == 'now': + params['closest'] = timestamp_now() + query = CDXQuery(params) self._set_src_params(params) @@ -55,32 +62,21 @@ class BaseAggregator(object): def load_child_source(self, name, source, all_params): try: _src_params = all_params['_all_src_params'].get(name) + all_params['_src_params'] = _src_params - #params = dict(url=all_params['url'], - # key=all_params['key'], - # end_key=all_params['end_key'], - # closest=all_params.get('closest'), - # _input_req=all_params.get('_input_req'), - # _timeout=all_params.get('_timeout'), - # _all_src_params=all_params.get('_all_src_params'), - # _src_params=_src_params) - - params = all_params - params['_src_params'] = _src_params - cdx_iter = source.load_index(params) + cdx_iter = source.load_index(all_params) except NotFoundException as nf: print('Not found in ' + name) cdx_iter = iter([]) - def add_name(cdx_iter): - for cdx in cdx_iter: - if 'source' in cdx: - cdx['source'] = name + '.' + cdx['source'] - else: - cdx['source'] = name - yield cdx + def add_name(cdx): + if cdx.get('source'): + cdx['source'] = name + ':' + cdx['source'] + else: + cdx['source'] = name + return cdx - return add_name(cdx_iter) + return [add_name(cdx) for cdx in cdx_iter] def load_index(self, params): iter_list = list(self._load_all(params)) @@ -93,6 +89,9 @@ class BaseAggregator(object): return cdx_iter + def _on_source_error(self, name): + pass + def _load_all(self, params): #pragma: no cover raise NotImplemented() @@ -167,7 +166,7 @@ class TimeoutMixin(object): if not self.is_timed_out(name): yield name, source - def track_source_error(self, name): + def _on_source_error(self, name): the_time = time.time() if name not in self.timeouts: self.timeouts[name] = deque() @@ -177,15 +176,12 @@ class TimeoutMixin(object): #============================================================================= -class GeventAggMixin(object): +class GeventMixin(object): def __init__(self, *args, **kwargs): - super(GeventAggMixin, self).__init__(*args, **kwargs) + super(GeventMixin, self).__init__(*args, **kwargs) self.pool = Pool(size=kwargs.get('size')) self.timeout = kwargs.get('timeout', 5.0) - def track_source_error(self, name): - pass - def _load_all(self, params): params['_timeout'] = self.timeout @@ -198,18 +194,58 @@ class GeventAggMixin(object): gevent.joinall(jobs, timeout=self.timeout) - res = [] - for name, job in zip(sources, jobs): - if job.value: - res.append(job.value) + results = [] + for (name, source), job in zip(sources, jobs): + if job.value is not None: + results.append(job.value) else: - self.track_source_error(name) + self._on_source_error(name) - return res + return results #============================================================================= -class GeventTimeoutAggregator(TimeoutMixin, GeventAggMixin, BaseSourceListAggregator): +class GeventTimeoutAggregator(TimeoutMixin, GeventMixin, BaseSourceListAggregator): + pass + + +#============================================================================= +class ConcurrentMixin(object): + def __init__(self, *args, **kwargs): + super(ConcurrentMixin, self).__init__(*args, **kwargs) + if kwargs.get('use_processes'): + self.pool_class = futures.ThreadPoolExecutor + else: + self.pool_class = futures.ProcessPoolExecutor + self.timeout = kwargs.get('timeout', 5.0) + self.size = kwargs.get('size') + + def _load_all(self, params): + params['_timeout'] = self.timeout + + sources = list(self.get_sources(params)) + + with self.pool_class(max_workers=self.size) as executor: + def do_spawn(name, source): + return executor.submit(self.load_child_source, + name, source, params), name + + jobs = dict([do_spawn(name, source) for name, source in sources]) + + res_done, res_not_done = futures.wait(jobs.keys(), timeout=self.timeout) + + results = [] + for job in res_done: + results.append(job.result()) + + for job in res_not_done: + self._on_source_error(jobs[job]) + + return results + + +#============================================================================= +class ThreadedTimeoutAggregator(TimeoutMixin, ConcurrentMixin, BaseSourceListAggregator): pass @@ -244,13 +280,14 @@ class BaseDirectoryIndexAggregator(BaseAggregator): def _load_files(self, glob_dir): for the_dir in glob.iglob(glob_dir): - print(the_dir) for name in os.listdir(the_dir): filename = os.path.join(the_dir, name) if filename.endswith(self.CDX_EXT): print('Adding ' + filename) rel_path = os.path.relpath(the_dir, self.base_prefix) + if rel_path == '.': + rel_path = '' yield rel_path, FileIndexSource(filename) class DirectoryIndexAggregator(SeqAggMixin, BaseDirectoryIndexAggregator): diff --git a/rezag/app.py b/rezag/app.py new file mode 100644 index 00000000..c25b4ac7 --- /dev/null +++ b/rezag/app.py @@ -0,0 +1,31 @@ +from rezag.inputrequest import WSGIInputRequest, POSTInputRequest +from bottle import route, request, response, default_app + + +def add_route(path, handler): + def debug(func): + def do_d(): + try: + return func() + except Exception: + import traceback + traceback.print_exc() + + return do_d + + def direct_input_request(): + params = dict(request.query) + params['_input_req'] = WSGIInputRequest(request.environ) + return handler(params) + + def post_fullrequest(): + params = dict(request.query) + params['_input_req'] = POSTInputRequest(request.environ) + return handler(params) + + route(path + '/postreq', method=['POST'], callback=debug(post_fullrequest)) + route(path, method=['ANY'], callback=debug(direct_input_request)) + + +application = default_app() + diff --git a/rezag/handlers.py b/rezag/handlers.py index 30e3ce98..1a6e3495 100644 --- a/rezag/handlers.py +++ b/rezag/handlers.py @@ -1,6 +1,6 @@ +from rezag.responseloader import WARCPathHandler, LiveWebHandler from rezag.utils import MementoUtils from pywb.warc.recordloader import ArchiveLoadFailed -from rezag.responseloader import WARCPathHandler, LiveWebHandler from bottle import response @@ -46,7 +46,7 @@ class IndexHandler(object): input_req = params.get('_input_req') if input_req: - params['url'] = input_req.include_post_query() + params['alt_url'] = input_req.include_post_query(params.get('url')) cdx_iter = self.index_source(params) @@ -71,13 +71,16 @@ class ResourceHandler(IndexHandler): if params.get('mode', 'resource') != 'resource': return super(ResourceHandler, self).__call__(params) + input_req = params.get('_input_req') + if input_req: + params['alt_url'] = input_req.include_post_query(params.get('url')) + cdx_iter = self.index_source(params) any_found = False for cdx in cdx_iter: any_found = True - cdx['coll'] = params.get('coll', '') for loader in self.resource_loaders: try: diff --git a/rezag/indexsource.py b/rezag/indexsource.py index 200d136a..a597e0c4 100644 --- a/rezag/indexsource.py +++ b/rezag/indexsource.py @@ -9,7 +9,7 @@ from pywb.utils.wbexception import NotFoundException from pywb.cdx.cdxobject import CDXObject from pywb.cdx.query import CDXQuery -import requests +from rezag.liverec import patched_requests as requests from rezag.utils import MementoUtils @@ -37,7 +37,12 @@ class FileIndexSource(BaseIndexSource): def load_index(self, params): filename = self.res_template(self.filename_template, params) - with open(filename, 'rb') as fh: + try: + fh = open(filename, 'rb') + except IOError: + raise NotFoundException(filename) + + with fh: gen = iter_range(fh, params['key'], params['end_key']) for line in gen: yield CDXObject(line) diff --git a/rezag/inputrequest.py b/rezag/inputrequest.py index 221ede0f..17b6ef6b 100644 --- a/rezag/inputrequest.py +++ b/rezag/inputrequest.py @@ -5,6 +5,7 @@ from pywb.utils.statusandheaders import StatusAndHeadersParser from six.moves.urllib.parse import urlsplit from six import StringIO, iteritems +from io import BytesIO #============================================================================= @@ -15,19 +16,19 @@ class WSGIInputRequest(object): def get_req_method(self): return self.env['REQUEST_METHOD'].upper() - def get_req_headers(self, url): + def get_req_headers(self): headers = {} - splits = urlsplit(url) - - for name, value in six.iteritems(self.env): + for name, value in iteritems(self.env): if name == 'HTTP_HOST': - name = 'Host' - value = splits.netloc + #name = 'Host' + #value = splits.netloc + # will be set automatically + continue - elif name == 'HTTP_ORIGIN': - name = 'Origin' - value = (splits.scheme + '://' + splits.netloc) + #elif name == 'HTTP_ORIGIN': + # name = 'Origin' + # value = (splits.scheme + '://' + splits.netloc) elif name == 'HTTP_X_CSRFTOKEN': name = 'X-CSRFToken' @@ -35,9 +36,9 @@ class WSGIInputRequest(object): if cookie_val: value = cookie_val - elif name == 'HTTP_X_FORWARDED_PROTO': - name = 'X-Forwarded-Proto' - value = splits.scheme + #elif name == 'HTTP_X_FORWARDED_PROTO': + # name = 'X-Forwarded-Proto' + # value = splits.scheme elif name.startswith('HTTP_'): name = name[5:].title().replace('_', '-') @@ -83,7 +84,7 @@ class WSGIInputRequest(object): return self.env.get('HTTP_' + name.upper().replace('-', '_')) def include_post_query(self, url): - if self.get_req_method() != 'POST': + if not url or self.get_req_method() != 'POST': return url mime = self._get_content_type() @@ -91,7 +92,7 @@ class WSGIInputRequest(object): length = self._get_content_length() stream = self.env['wsgi.input'] - buffered_stream = StringIO() + buffered_stream = BytesIO() post_query = extract_post_query('POST', mime, length, stream, buffered_stream=buffered_stream) @@ -115,7 +116,7 @@ class POSTInputRequest(WSGIInputRequest): def get_req_method(self): return self.status_headers.protocol - def get_req_headers(self, url): + def get_req_headers(self): headers = {} for n, v in self.status_headers.headers: headers[n] = v diff --git a/rezag/responseloader.py b/rezag/responseloader.py index f4c4fa04..52bf4760 100644 --- a/rezag/responseloader.py +++ b/rezag/responseloader.py @@ -45,7 +45,11 @@ class WARCPathHandler(object): for path in self.paths: def check(filename, cdx): try: - full_path = path.format(**cdx) + if hasattr(cdx, '_src_params') and cdx._src_params: + full_path = path.format(**cdx._src_params) + else: + full_path = path + full_path += filename return full_path except KeyError: return None @@ -57,15 +61,13 @@ class WARCPathHandler(object): if not cdx.get('filename') or cdx.get('offset') is None: return None + cdx._src_params = params.get('_src_params') failed_files = [] headers, payload = (self.resolve_loader. load_headers_and_payload(cdx, failed_files, self.cdx_source)) - if headers != payload: - headers.stream.close() - record = payload for n, v in record.rec_headers.headers: @@ -73,6 +75,13 @@ class WARCPathHandler(object): response.headers['WARC-Coll'] = cdx.get('source') + if headers != payload: + response.headers['WARC-Target-URI'] = headers.rec_headers.get_header('WARC-Target-URI') + response.headers['WARC-Date'] = headers.rec_headers.get_header('WARC-Date') + response.headers['WARC-Refers-To-Target-URI'] = payload.rec_headers.get_header('WARC-Target-URI') + response.headers['WARC-Refers-To-Date'] = payload.rec_headers.get_header('WARC-Date') + headers.stream.close() + return incr_reader(record.stream) @@ -114,13 +123,20 @@ class LiveWebHandler(object): input_req = params['_input_req'] - req_headers = input_req.get_req_headers(cdx['url']) + req_headers = input_req.get_req_headers() dt = timestamp_to_datetime(cdx['timestamp']) if not cdx.get('is_live'): req_headers['Accept-Datetime'] = datetime_to_http_date(dt) + # if different url, ensure origin is not set + # may need to add other headers + if load_url != cdx['url']: + if 'Origin' in req_headers: + splits = urlsplit(load_url) + req_headers['Origin'] = splits.scheme + '://' + splits.netloc + method = input_req.get_req_method() data = input_req.get_req_body() diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py index 02cd5839..3a9c916f 100644 --- a/test/test_dir_agg.py +++ b/test/test_dir_agg.py @@ -3,13 +3,16 @@ import os import shutil import json +from .testutils import to_path + from rezag.aggindexsource import DirectoryIndexAggregator, SimpleAggregator +from rezag.indexsource import MementoIndexSource #============================================================================= root_dir = None orig_cwd = None -dir_agg = None +dir_loader = None def setup_module(): global root_dir @@ -17,18 +20,21 @@ def setup_module(): coll_A = to_path(root_dir + '/colls/A/indexes') coll_B = to_path(root_dir + '/colls/B/indexes') + coll_C = to_path(root_dir + '/colls/C/indexes') os.makedirs(coll_A) os.makedirs(coll_B) + os.makedirs(coll_C) dir_prefix = to_path(root_dir) dir_path ='colls/{coll}/indexes' shutil.copy(to_path('testdata/example.cdxj'), coll_A) shutil.copy(to_path('testdata/iana.cdxj'), coll_B) + shutil.copy(to_path('testdata/dupes.cdxj'), coll_C) - global dir_agg - dir_agg = DirectoryIndexAggregator(dir_prefix, dir_path) + global dir_loader + dir_loader = DirectoryIndexAggregator(dir_prefix, dir_path) global orig_cwd orig_cwd = os.getcwd() @@ -45,57 +51,103 @@ def teardown_module(): shutil.rmtree(root_dir) -def to_path(path): - if os.path.sep != '/': - path = path.replace('/', os.path.sep) - - return path - - def to_json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) def test_agg_no_coll_set(): - res = dir_agg(dict(url='example.com/')) + res = dir_loader(dict(url='example.com/')) assert(to_json_list(res) == []) def test_agg_collA_found(): - res = dir_agg({'url': 'example.com/', 'param.coll': 'A'}) + res = dir_loader({'url': 'example.com/', 'param.coll': 'A'}) exp = [{'source': 'colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] assert(to_json_list(res) == exp) def test_agg_collB(): - res = dir_agg({'url': 'example.com/', 'param.coll': 'B'}) + res = dir_loader({'url': 'example.com/', 'param.coll': 'B'}) exp = [] assert(to_json_list(res) == exp) def test_agg_collB_found(): - res = dir_agg({'url': 'iana.org/', 'param.coll': 'B'}) + res = dir_loader({'url': 'iana.org/', 'param.coll': 'B'}) exp = [{'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] assert(to_json_list(res) == exp) -def test_agg_all_found(): - res = dir_agg({'url': 'iana.org/', 'param.coll': '*'}) +def test_extra_agg_collB(): + agg_source = SimpleAggregator({'dir': dir_loader}) + res = agg_source({'url': 'iana.org/', 'param.coll': 'B'}) - exp = [{'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + exp = [{'source': 'dir:colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] assert(to_json_list(res) == exp) -def test_extra_agg_all(): - agg_dir_agg = SimpleAggregator({'dir': dir_agg}) - res = agg_dir_agg({'url': 'iana.org/', 'param.coll': '*'}) +def test_agg_all_found_1(): + res = dir_loader({'url': 'iana.org/', 'param.coll': '*'}) - exp = [{'source': 'dir.colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + exp = [ + {'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}, + {'source': 'colls/C/indexes', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/C/indexes', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, + ] + + assert(to_json_list(res) == exp) + + +def test_agg_all_found_2(): + res = dir_loader({'url': 'example.com/', 'param.coll': '*'}) + + exp = [ + {'source': 'colls/C/indexes', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/C/indexes', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + ] + + assert(to_json_list(res) == exp) + + + +def test_agg_dir_and_memento(): + sources = {'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), + 'local': dir_loader} + agg_source = SimpleAggregator(sources) + + res = agg_source({'url': 'example.com/', 'param.coll': '*', 'closest': '20100512', 'limit': 6}) + + exp = [ + {'source': 'ia', 'timestamp': '20100513052358', 'load_url': 'http://web.archive.org/web/20100513052358id_/http://example.com/'}, + {'source': 'ia', 'timestamp': '20100514231857', 'load_url': 'http://web.archive.org/web/20100514231857id_/http://example.com/'}, + {'source': 'ia', 'timestamp': '20100506013442', 'load_url': 'http://web.archive.org/web/20100506013442id_/http://example.com/'}, + {'source': 'local:colls/C/indexes', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'local:colls/C/indexes', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'local:colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + ] + + assert(to_json_list(res) == exp) + + +def test_agg_no_dir_1(): + res = dir_loader({'url': 'example.com/', 'param.coll': 'X'}) + + exp = [] + + assert(to_json_list(res) == exp) + + +def test_agg_no_dir_2(): + loader = DirectoryIndexAggregator(root_dir, 'no_such') + res = loader({'url': 'example.com/', 'param.coll': 'X'}) + + exp = [] assert(to_json_list(res) == exp) diff --git a/test/test_handlers.py b/test/test_handlers.py new file mode 100644 index 00000000..1e2d2822 --- /dev/null +++ b/test/test_handlers.py @@ -0,0 +1,216 @@ +from gevent import monkey; monkey.patch_all(thread=False) + +from collections import OrderedDict + +from rezag.handlers import DefaultResourceHandler, HandlerSeq + +from rezag.indexsource import MementoIndexSource, FileIndexSource, LiveIndexSource +from rezag.aggindexsource import GeventTimeoutAggregator, SimpleAggregator +from rezag.aggindexsource import DirectoryIndexAggregator + +from rezag.app import add_route, application + +import webtest +import bottle + +from .testutils import to_path + +import json + +sources = { + 'local': DirectoryIndexAggregator(to_path('testdata/'), ''), + 'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), + 'rhiz': MementoIndexSource.from_timegate_url('http://webenact.rhizome.org/vvork/', path='*'), + 'live': LiveIndexSource(), +} + +testapp = None + +def setup_module(self): + live_source = SimpleAggregator({'live': LiveIndexSource()}) + live_handler = DefaultResourceHandler(live_source) + add_route('/live', live_handler) + + source1 = GeventTimeoutAggregator(sources) + handler1 = DefaultResourceHandler(source1, to_path('testdata/')) + add_route('/many', handler1) + + source2 = SimpleAggregator({'post': FileIndexSource(to_path('testdata/post-test.cdxj'))}) + handler2 = DefaultResourceHandler(source2, to_path('testdata/')) + add_route('/posttest', handler2) + + source3 = SimpleAggregator({'example': FileIndexSource(to_path('testdata/example.cdxj'))}) + handler3 = DefaultResourceHandler(source3, to_path('testdata/')) + + + add_route('/fallback', HandlerSeq([handler3, + handler2, + live_handler])) + + + bottle.debug = True + global testapp + testapp = webtest.TestApp(application) + + +def to_json_list(text): + return list([json.loads(cdx) for cdx in text.rstrip().split('\n')]) + + +class TestResAgg(object): + def setup(self): + self.testapp = testapp + + def test_live_index(self): + resp = self.testapp.get('/live?url=http://httpbin.org/get&mode=index&output=json') + resp.charset = 'utf-8' + + res = to_json_list(resp.text) + res[0]['timestamp'] = '2016' + assert(res == [{'url': 'http://httpbin.org/get', 'urlkey': 'org,httpbin)/get', 'is_live': True, + 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) + + def test_live_resource(self): + resp = self.testapp.get('/live?url=http://httpbin.org/get?foo=bar&mode=resource') + + assert resp.headers['WARC-Coll'] == 'live' + assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' + assert 'WARC-Date' in resp.headers + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + + def test_live_post_resource(self): + resp = self.testapp.post('/live?url=http://httpbin.org/post&mode=resource', + OrderedDict([('foo', 'bar')])) + + assert resp.headers['WARC-Coll'] == 'live' + assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' + assert 'WARC-Date' in resp.headers + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + def test_agg_select_mem_1(self): + resp = self.testapp.get('/many?url=http://vvork.com/&closest=20141001') + + assert resp.headers['WARC-Coll'] == 'rhiz' + assert resp.headers['WARC-Target-URI'] == 'http://www.vvork.com/' + assert resp.headers['WARC-Date'] == '2014-10-06T18:43:57Z' + assert b'HTTP/1.1 200 OK' in resp.body + + + def test_agg_select_mem_2(self): + resp = self.testapp.get('/many?url=http://vvork.com/&closest=20151231') + + assert resp.headers['WARC-Coll'] == 'ia' + assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' + assert resp.headers['WARC-Date'] == '2016-01-10T13:48:55Z' + assert b'HTTP/1.1 200 OK' in resp.body + + + def test_agg_select_live(self): + resp = self.testapp.get('/many?url=http://vvork.com/&closest=2016') + + assert resp.headers['WARC-Coll'] == 'live' + assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' + assert resp.headers['WARC-Date'] != '' + + def test_agg_select_local(self): + resp = self.testapp.get('/many?url=http://iana.org/&closest=20140126200624') + + assert resp.headers['WARC-Coll'] == 'local' + assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' + assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + + + def test_agg_select_local_postreq(self): + req_data = """\ +GET / HTTP/1.1 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 +User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36 +Host: iana.org +""" + + resp = self.testapp.post('/many/postreq?url=http://iana.org/&closest=20140126200624', req_data) + + assert resp.headers['WARC-Coll'] == 'local' + assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' + assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + + + def test_agg_live_postreq(self): + req_data = """\ +GET /get?foo=bar HTTP/1.1 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 +User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36 +Host: httpbin.org +""" + + resp = self.testapp.post('/many/postreq?url=http://httpbin.org/get?foo=bar&closest=now', req_data) + + assert resp.headers['WARC-Coll'] == 'live' + assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' + assert 'WARC-Date' in resp.headers + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + def test_agg_post_resolve_postreq(self): + req_data = """\ +POST /post HTTP/1.1 +content-length: 16 +accept-encoding: gzip, deflate +accept: */* +host: httpbin.org +content-type: application/x-www-form-urlencoded + +foo=bar&test=abc""" + + resp = self.testapp.post('/posttest/postreq?url=http://httpbin.org/post', req_data) + + assert resp.headers['WARC-Coll'] == 'post' + assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + assert b'"test": "abc"' in resp.body + assert b'"url": "http://httpbin.org/post"' in resp.body + + def test_agg_post_resolve_fallback(self): + req_data = OrderedDict([('foo', 'bar'), ('test', 'abc')]) + + resp = self.testapp.post('/fallback?url=http://httpbin.org/post', req_data) + + assert resp.headers['WARC-Coll'] == 'post' + assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + assert b'"test": "abc"' in resp.body + assert b'"url": "http://httpbin.org/post"' in resp.body + + def test_agg_seq_fallback_1(self): + resp = self.testapp.get('/fallback?url=http://www.iana.org/') + + assert resp.headers['WARC-Coll'] == 'live' + assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' + assert b'HTTP/1.1 200 OK' in resp.body + + def test_agg_seq_fallback_2(self): + resp = self.testapp.get('/fallback?url=http://www.example.com/') + + assert resp.headers['WARC-Coll'] == 'example' + assert resp.headers['WARC-Date'] == '2016-02-25T04:23:29Z' + assert resp.headers['WARC-Target-URI'] == 'http://example.com/' + assert b'HTTP/1.1 200 OK' in resp.body + + def test_agg_local_revisit(self): + resp = self.testapp.get('/many?url=http://www.example.com/&closest=20140127171251&sources=local') + + assert resp.headers['WARC-Coll'] == 'local' + assert resp.headers['WARC-Target-URI'] == 'http://example.com' + assert resp.headers['WARC-Date'] == '2014-01-27T17:12:51Z' + assert resp.headers['WARC-Refers-To-Target-URI'] == 'http://example.com' + assert resp.headers['WARC-Refers-To-Date'] == '2014-01-27T17:12:00Z' + assert b'HTTP/1.1 200 OK' in resp.body + assert b'' in resp.body diff --git a/test/test_indexsource.py b/test/test_indexsource.py index b4f81bf1..643bd3e0 100644 --- a/test/test_indexsource.py +++ b/test/test_indexsource.py @@ -5,6 +5,9 @@ from rezag.aggindexsource import SimpleAggregator from pywb.utils.timeutils import timestamp_now +from .testutils import key_ts_res + + import pytest import redis @@ -13,9 +16,6 @@ import fakeredis redis.StrictRedis = fakeredis.FakeStrictRedis redis.Redis = fakeredis.FakeRedis -def key_ts_res(cdxlist, extra='filename'): - return '\n'.join([cdx['urlkey'] + ' ' + cdx['timestamp'] + ' ' + cdx[extra] for cdx in cdxlist]) - def setup_module(): global r r = fakeredis.FakeStrictRedis(db=2) @@ -170,3 +170,16 @@ def test_another_remote_not_found(): assert(key_ts_res(res) == expected) +# ============================================================================ +def test_file_not_found(): + source = FileIndexSource('testdata/not-found-x') + url = 'http://x-not-found-x.notfound/' + res = query_single_source(source, dict(url=url, limit=3)) + + + expected = '' + assert(key_ts_res(res) == expected) + + + + diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index aff7359f..be49fe9c 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -1,7 +1,11 @@ -from gevent import monkey; monkey.patch_all() +from gevent import monkey; monkey.patch_all(thread=False) + from rezag.aggindexsource import SimpleAggregator, GeventTimeoutAggregator +from rezag.aggindexsource import ThreadedTimeoutAggregator from rezag.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource +from .testutils import json_list, to_path + import json import pytest @@ -9,26 +13,23 @@ from rezag.handlers import IndexHandler sources = { - 'local': FileIndexSource('testdata/iana.cdxj'), + 'local': FileIndexSource(to_path('testdata/iana.cdxj')), 'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), 'ait': MementoIndexSource.from_timegate_url('http://wayback.archive-it.org/all/'), 'bl': MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/'), 'rhiz': MementoIndexSource.from_timegate_url('http://webenact.rhizome.org/vvork/', path='*') } + +aggs = {'simple': SimpleAggregator(sources), + 'gevent': GeventTimeoutAggregator(sources, timeout=5.0), + 'threaded': ThreadedTimeoutAggregator(sources, timeout=5.0), + 'processes': ThreadedTimeoutAggregator(sources, timeout=5.0, use_processes=True), + } + #@pytest.mark.parametrize("agg", aggs, ids=["simple", "gevent_timeout"]) def pytest_generate_tests(metafunc): - metafunc.parametrize("agg", aggs, ids=["simple", "gevent_timeout"]) - - -aggs = [SimpleAggregator(sources), - GeventTimeoutAggregator(sources, timeout=5.0) - ] - - -def json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): - return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) - + metafunc.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_1(agg): url = 'http://iana.org/' diff --git a/test/test_timeouts.py b/test/test_timeouts.py new file mode 100644 index 00000000..6a96d464 --- /dev/null +++ b/test/test_timeouts.py @@ -0,0 +1,105 @@ +from gevent import monkey; monkey.patch_all(thread=False) +import time +from rezag.indexsource import FileIndexSource + +from rezag.aggindexsource import SimpleAggregator, TimeoutMixin +from rezag.aggindexsource import GeventTimeoutAggregator, GeventTimeoutAggregator + +from .testutils import json_list + + +class TimeoutFileSource(FileIndexSource): + def __init__(self, filename, timeout): + super(TimeoutFileSource, self).__init__(filename) + self.timeout = timeout + self.calls = 0 + + def load_index(self, params): + self.calls += 1 + print('Sleeping') + time.sleep(self.timeout) + return super(TimeoutFileSource, self).load_index(params) + +TimeoutAggregator = GeventTimeoutAggregator + + + +def setup_module(): + global sources + sources = {'slow': TimeoutFileSource('testdata/example.cdxj', 0.2), + 'slower': TimeoutFileSource('testdata/dupes.cdxj', 0.5) + } + + + +def test_timeout_long_all_pass(): + agg = TimeoutAggregator(sources, timeout=1.0) + + res = agg(dict(url='http://example.com/')) + + exp = [{'source': 'slower', 'timestamp': '20140127171200'}, + {'source': 'slower', 'timestamp': '20140127171251'}, + {'source': 'slow', 'timestamp': '20160225042329'}] + + assert(json_list(res, fields=['source', 'timestamp']) == exp) + + +def test_timeout_slower_skipped_1(): + agg = GeventTimeoutAggregator(sources, timeout=0.49) + + res = agg(dict(url='http://example.com/')) + + exp = [{'source': 'slow', 'timestamp': '20160225042329'}] + + assert(json_list(res, fields=['source', 'timestamp']) == exp) + + + +def test_timeout_slower_skipped_2(): + agg = GeventTimeoutAggregator(sources, timeout=0.19) + + res = agg(dict(url='http://example.com/')) + + exp = [] + + assert(json_list(res, fields=['source', 'timestamp']) == exp) + + + +def test_timeout_skipping(): + assert(sources['slow'].calls == 3) + assert(sources['slower'].calls == 3) + + agg = GeventTimeoutAggregator(sources, timeout=0.49, + t_count=2, t_duration=2.0) + + exp = [{'source': 'slow', 'timestamp': '20160225042329'}] + + res = agg(dict(url='http://example.com/')) + assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(sources['slow'].calls == 4) + assert(sources['slower'].calls == 4) + + res = agg(dict(url='http://example.com/')) + assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(sources['slow'].calls == 5) + assert(sources['slower'].calls == 5) + + res = agg(dict(url='http://example.com/')) + assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(sources['slow'].calls == 6) + assert(sources['slower'].calls == 5) + + res = agg(dict(url='http://example.com/')) + assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(sources['slow'].calls == 7) + assert(sources['slower'].calls == 5) + + time.sleep(2.01) + + res = agg(dict(url='http://example.com/')) + assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(sources['slow'].calls == 8) + assert(sources['slower'].calls == 6) + + diff --git a/test/testutils.py b/test/testutils.py new file mode 100644 index 00000000..b9f8ab98 --- /dev/null +++ b/test/testutils.py @@ -0,0 +1,16 @@ +import json +import os + +def json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): + return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) + +def key_ts_res(cdxlist, extra='filename'): + return '\n'.join([cdx['urlkey'] + ' ' + cdx['timestamp'] + ' ' + cdx[extra] for cdx in cdxlist]) + +def to_path(path): + if os.path.sep != '/': + path = path.replace('/', os.path.sep) + + return path + + diff --git a/testdata/dupes.cdxj b/testdata/dupes.cdxj new file mode 100644 index 00000000..6d42a7b1 --- /dev/null +++ b/testdata/dupes.cdxj @@ -0,0 +1,12 @@ +com,example)/ 20140127171200 {"url": "http://example.com", "mime": "text/html", "status": "200", "digest": "B2LTWWPUOYAH7UIPQ7ZUPQ4VMBSVC36A", "length": "1046", "offset": "334", "filename": "dupes.warc.gz"} +com,example)/ 20140127171251 {"url": "http://example.com", "mime": "warc/revisit", "digest": "B2LTWWPUOYAH7UIPQ7ZUPQ4VMBSVC36A", "length": "553", "offset": "11875", "filename": "dupes.warc.gz"} +org,iana)/ 20140127171238 {"url": "http://iana.org", "mime": "unk", "status": "302", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "343", "offset": "1858", "filename": "dupes.warc.gz"} +org,iana)/ 20140127171238 {"url": "http://www.iana.org/", "mime": "warc/revisit", "digest": "OSSAPWJ23L56IYVRW3GFEAR4MCJMGPTB", "length": "536", "offset": "2678", "filename": "dupes.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-bold.ttf 20140127171240 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Bold.ttf", "mime": "warc/revisit", "digest": "YFUR5ALIWJMWV6FAAFRLVRQNXZQF5HRW", "length": "556", "offset": "10826", "filename": "dupes.warc.gz"} +org,iana)/_css/2013.1/fonts/opensans-regular.ttf 20140127171240 {"url": "http://www.iana.org/_css/2013.1/fonts/OpenSans-Regular.ttf", "mime": "warc/revisit", "digest": "GVSO2C2TMPPVZ4TXYFXAY27NYWTIEIL7", "length": "540", "offset": "9793", "filename": "dupes.warc.gz"} +org,iana)/_css/2013.1/print.css 20140127171239 {"url": "http://www.iana.org/_css/2013.1/print.css", "mime": "warc/revisit", "digest": "VNBXHMUNWJQC5OWWGZ3X7GM5C7X6ZAB4", "length": "537", "offset": "6684", "filename": "dupes.warc.gz"} +org,iana)/_css/2013.1/screen.css 20140127171239 {"url": "http://www.iana.org/_css/2013.1/screen.css", "mime": "warc/revisit", "digest": "BUAEPXZNN44AIX3NLXON4QDV6OY2H5QD", "length": "541", "offset": "4630", "filename": "dupes.warc.gz"} +org,iana)/_img/2013.1/iana-logo-homepage.png 20140127171240 {"url": "http://www.iana.org/_img/2013.1/iana-logo-homepage.png", "mime": "warc/revisit", "digest": "GCW2GM3SIMHEIQYZX25MLSRYVWUCZ7OK", "length": "549", "offset": "8750", "filename": "dupes.warc.gz"} +org,iana)/_img/2013.1/icann-logo.svg 20140127171239 {"url": "http://www.iana.org/_img/2013.1/icann-logo.svg", "mime": "warc/revisit", "digest": "HGRZHOH73EFQQWBYWBSOIV2UU5JDTSGJ", "length": "549", "offset": "7709", "filename": "dupes.warc.gz"} +org,iana)/_js/2013.1/iana.js 20140127171239 {"url": "http://www.iana.org/_js/2013.1/iana.js", "mime": "application/x-javascript", "status": "200", "digest": "3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ", "length": "457", "offset": "3696", "filename": "dupes.warc.gz"} +org,iana)/_js/2013.1/jquery.js 20140127171239 {"url": "http://www.iana.org/_js/2013.1/jquery.js", "mime": "warc/revisit", "digest": "AAW2RS7JB7HTF666XNZDQYJFA6PDQBPO", "length": "547", "offset": "5658", "filename": "dupes.warc.gz"} diff --git a/testdata/dupes.warc.gz b/testdata/dupes.warc.gz new file mode 100644 index 0000000000000000000000000000000000000000..48e6b6fd6cf67e3825541966f6ac2ad1f7269dee GIT binary patch literal 12905 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 literal 0 HcmV?d00001 diff --git a/testdata/example.warc.gz b/testdata/example.warc.gz new file mode 100644 index 0000000000000000000000000000000000000000..143b947d121e61b479cf9cae596b3853a9a60633 GIT binary patch literal 2272 zcmb`I`9Bkk1IJ13mB}8VF(i@kY%^vP#gc21YjZ|(tKqG9zOV21Klpw>KYxDuyxy<(2SO1MX-n+EA32QhCSDKm^z*;tt%-oa zbzumE4h)IVMxfD1Jx$oZqp70_gTvu^a6R;OZ!|(H;-5YrE|U`=pae?&C9rW^Ya*p1 zbjT~mR!ua~n4kol@A;&6KjUFCyxAaDN7gUAWNv-;u#?O2K?`&&1yHTSX2yZ~do0BF z1v%l+=Di-I;tyLt)05F<6`6pGKI@`J(9hMq1{(0}2gk8{zb>Pdn ztf2h@%$|g4W_!Wub6PgVBlKrY&?+_I1R=Rwg_rSHm)Ep2@w(m?{$*hGDov_TiqXL} z0tcpoDm71%@65h^YyJA;--K40^~0$xbQB6a&<_M!xeX7W*;F6p>U2!VxOy1^y$a5Q z-pjO~%c9tE?}km)g1?1MA5sn`*w z$lKUUV=a7qe20eud{_YdRm1V!X&#}%_2<; zXY$Dd(Rrsz$A=p4{*@O+`xQl->mGC&Kt>|U@w4i`4>?aKf|ZRw&fGDFxi((>swq|x zEJ-enG&wo9g*lq8WCo_E$T*v2AT1<;SSM-eXF!jHexp|6`4&aoOOY-yjl?Oy14%U_ z7x;tIX6YVO19|aFr*itiluYb0eR&EgH!r|eIE(wiWj}%a%kT15Qt`cPy}M_!gXME^ z-*5=?N(cWyD!?to@xoeU!EuGI<4jAb=T8aHF|S$QzZ%#@x?aXqtJfMaNU0)v-@=d4 zVhUJS?IT!ujK6MK%=`r3gMMQ{Q`-za=tu1&>2F7R1@bzSQU+6QK5he86a*^%nVmee zZlYH*D|*?IPYec9Bf?!h)a3;k$h*MgqrHm>&Sm&Ams+1QAlFv_EKas}NY1s%CFspf zFi|5sG6(Z*1$gY>LEu?3NB|;sGsrO61_RhkBY$gXypsfgb@9Hoh>$adRqN~4Qg zl_)0UBp=DsOvUiy`T2mO49{tc%+KMV`7?C|dMl|0gd$8u(Qm&XJ+++rn_k~PGgMojC`zD!i@ z6PGKwA7;NuQ2=@C$oRj}X)uO2txn)@yLoe1YAGD@@j;}z) zb<{ILaFg#+BRTs7{n=Ng7}lyrUF>w~f#n2oN$klv((};%fk97HV_I}_!G=+MwVqac zXinZrw95%0>+%D}#`Mwxwx#h`z5a+FBG%+0A{ff1h zKR>m03#_$;o-QX4m(;D+HryntdXKkob^StIv-Z|`I^t2mx5lfc79i2WC#rOFYs{dI zg|<)8ih=r}UOoqvsUxlv)w0U;xHvmDIzI6k{LKK0bd4?1y_;Q9i%t|4)6CQtB6gU?})Q-i!JMs0gDfL|e|fCnnTq)tcbTzjc4<%Y+EE z#ni*By(?Y{UA8f=x(Iv?)b53Q5t8Yr?andDyRP*7EgFyQH42e zuacpvQqzv4Y={{0&aenFiM`WqwXlHV`sOAP(2mztg{DvB`LyC!M@VAyM7 zS14cud+eaoBLn0xaRwvtMs`zsRAdHj3)HX^$@~-ZAOq9ancd24=pmnCrrvma*d4A#_O6AbcZTEp5`+$D98#-XBzbn{k>%2>;U*#I@X_s6KHew>eu=87 RH3|9e>Fz|Z3l0B4?{AQ;Bs>5B literal 0 HcmV?d00001 diff --git a/testdata/iana.warc.gz b/testdata/iana.warc.gz new file mode 100644 index 0000000000000000000000000000000000000000..3a88a71ac33ef0677c8db07e09b2b5612e66422f GIT binary patch literal 786828 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�gu9*RX$jHXkP*AX0x%xg|i{;<+A*8{{O?o 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Ô|CxI#am*#H;$zx7qTPljY{~(=(M(LRmx?I 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 z zG)YaF`0Z}}V(dJ2^A~07eE$A(Uw?lF_riBwx&$};1XoR(uIrY zd7=jJMCqyubBZ0w{3+R%@p+AXs>A{}U*_KYSL8uq8!W(F)EY5Im`7-46Zu=E$#c-b zNtIzvWz=fw%qFxJU*PzYSjX4GQtY?|k^<-w#W&5mHIjk|x=c;PCTU9HQMmO_DbNyS z;8rv`#%NFsAH_SMFcuNPs4&hkK=tkTUgaD?OJZb2Bd&gzhG&}KVkBZZ&=P~Tv<8ZaB)KzFLL!64HvC zhMNu{+}^PBJ*}aVsCMMlyGCcF2=a4-pXLrKw7{sL)G=0P%Zt%1@oSr$9y@_ zE;yb=bGbrVC`p1=19-CX8beQQ*~Qk9ASVS3_F={ALjknQc?V2>xB>h({`(^Pz9{k< ziKkIupT@oqT_W;ZOW=KS(UkSw(u8f!Bls8Sqni?x;osphaEW}|8-gP)3`BAZr423_S42d6Z zFPcjFbRO3#V{;yTbqLz=zYfQSD82?GtHD5P&>48yq(cT8e;NEVp$4U=g4*HnY|zfa z%|%l-kd6y0l)+D>BkLbVn+gr)yyuLw4rn6Ik>sibo%$J zf8yUD|BH(bGye;nsVRiFEM?eu9YdLrmo!?U$VELUB$|er7X_jS2Eye4Dmx}Nwuv5{cAnb?sciW*7MRE1>;nvlt2wb}Ke!Duv8g=>sZGD=2)tT1aV z_D*S;YW-{4L`SrdC2C6@0TeU8qT9sq&wOg~q*+ft_Sn&YAtfLTdY%)o@1C zKs(C`=BKBHH_2)sgbYb2km-?$)Y>QPbI_>5L^xK}8{;+!M&n;MW>U+$`PEc4xmsnD zzhuYNHeA#$7Y-3E)B)Te6XX*v-3^QhwRbyRN$G*)+~RhvI*EE<(OaBJf!ubTG!^9; zZQA6m=mNWRaigIo__hg}Oi6B(WYyUC)L^h#N7fBXkcbRrO13_hDl%xf$t6B^Va606 zdNUy?s0C)CSO=gV-uj7f%TJ*-R9Vq=US$X#TfchQ23WabBlm3mv0W$^xk44>NdX*+(7W(db7!cyZCfB`|x@;vdfb^bI`t)4%8~Z9Vtm3yWTYfdw!A@WW51 z;K7S0o4hCI&MdSRj$!xVIwlvB_uTL7y})clwO}|mnB`|n3h9u@5$39q1aeI5jhWGG zH)RTq&kjmx)FjX#JcB1Rpb$?(P3p!CzI6sS*0P2(=sSkQ$}uS0T8;>_|>-*h* z9e1L(0yX-bc@P0Dy{ci%gWX<88mT4>G#bHVvh<=RF(FB3>=o9D@ivQ_u!USI6o?|N zT2w$Ycm$TboOp5Rb9LMLez_-Q`sBLjmQFiZnraW(;NDP?UNTv&lHZY* zoa{l8*6i?zIn6W{lcgrCH<>&sY3^R(w6qjYQc8BOaEd5^-eJKVA4+tq@sH;L0mWC< zVo~thD6Es+8O>_sYxJ2D4)yNb{YiXj>8h%!zyID5kHp{LpW2^+9XLm1Vf(eOzwlb$ z70GLx!62M9xZkl;>+!s9J!RgcP?#`E2$=IKrU!UE|)DM1M~`KNH$Ziu+8061Hbj`MP1euJzR27 zAWW+oCL+r_spKeV>_ztdf%Jm+^4Q!N=X|7M!x^_~ex=`D%s; z8xu~`kVj^55Yp<8?LoP4+oqQnZQ4+`=xyv<_VUYjRzzM{_t)(k9~{!Mf6u?ZH|DM? zg}2;Tw)&$_R=)n>&J`z49Df-0+_q-pw%jKJ$BzYO=1_^Lrr{#mL3W^roLSvNnJF&B zXPDh?J+C*q_^bdzb}7T`HM#XBqtOl|x7RL3FJ*%CPLBQNH-d~sS0MwiVPJxyR8(A| z=2S5Y7Q0Qc3KbV~(|X(`y{LU_)f?}1eRO2anho<-;rmaFY1?}2=q1C4?RtOj)(>52 zb*U+Ef8B;PFW~tfVEhI8@4B9Y@XX?UG!rw?@uaVq$B&00n1n<58RitHo}8mVOYe<= zWOn8>7xP*meKSqKoyOA1+icBD8kEzl8I-Ki1C}LXrn!f2g5ixB?))(b-Xcrzc9}*g zuseSU@}iB{U)i@$Wo4h*9<#VIeq6)Lk!&(SFZ{lc zJ5&G3x(|1}@y3U{SBx4xderC?Y1P1SvDAq03s+F z%{DRFEs<$58#RDvKu(KEOLZ2)-e`7I^3zmaXuKR}VRI1qS)LP{joSZ9{0L55^ck0P zn5+7$$Uk>{%ZGno%!|9Y@41>8Ga{S0w28PscO>#7=ZxgT&xDC#^x!8PgB@S|9xW>Tf-Oj%&cPt*%Dgz6&i0{vDVamj9) zl9M5M=aJGQ)oiMV0nMfwLR%y6(rl{aoqj8kx8=stHKg=#mG(hOo9m!aXm5$qZqZn} zA1Pg>mL8|lUP5VeWvNYQD|?l%VtJ)~)E-SF9Ycy$G*}Vgd$Pmft_(XIvR2ENhqaQN zRvwmb3dn8Z-U5|G??p<|8{nf$8u$urG`AoNn6aY%+@wh}7A{nnOp+KDz8)~+4=RTVyuFXCB|gP$Gx9nQvE>hS4xqsKs(lJk<{JJ~Hbr0nxu^jdYxI+eN7)i)y z)o33XijL64ROB0hhF|y-;s-zjT88WphyqbgMWmH9M3Uq+BGiEVd?n+2EF_H%Gud{2 zNO*z`2DzEb@Rcnvcmn>q#l-#gcH}wXMJmsXhqHwO-UV!AXXk=YC^HA7>VTk=U4Fu4 z$-e?`%(B~&l%AYyDKkzpzGg&5peQYS3#s_(YU1GS&&ZCkQVRC$gk+~j@eAk??^L;~ zP_Sv{`;%v^nYZDcr{8((@ptCFQ9q;2kXCtvJ9ew7x!5DAiRPmQ&MG%TkA=PD>S1>5K!4+pDDUG4GAN%g+Voq`l_7=1-)cA42=diM9ddZl@l`|@*4J>-1&9J&I)kRP{$Vt z2C`c247EmN)Gd-(XVmM+p5bL1XCPAyfrS@%p*GBG_2dN8YBi+C+I($q?N}|ZRaQ9R z2|)$(lfF`>Mo~FXHWBoE)Fad8VzQvm!TDya8j4}<1Gs|IJ-zxq$l(pc;SRVN|Jd!w zmXq(ouaXE4K#(gCV%210hCN7AGUfsoNkW zPmU?FmAR7c5Tg9Dax;g!Pa@^;=>E~p~R`JKt?_vO$Kzhg+Lx7CIKmG~&WBEV!>5qxI zeeSqtnEtr@5B~Ve?^f3M@49cp3W~iz@4FT=Z-cauNrxoKWF+E4+})&NM54WmZc`eV zQ8Kc;YsHca?%p#?JPd z4Q&_={y4W#^hIfC(Jg3*3{AUs=R=j1UAk0KgAaap^&7Cqnm2jy;)3~0 zURLuPHQg3&g6$*l=l`Ux&&SH^Jx=Hdz zZ@PjB1}jzL_B+VFEut0?Us3B4QIfc0Q+MLW;IliX;yq&P%on)pn>SD6xpT1OJ^O|=s7)9v&cMPE!aX$%IYEL=71#nR_ej|K|( zCP~F`*2oR=zkxpLu#h1m=A z)W*z}a#UlY7RcgIipT1sX<0-o3S{cNKsL*qEITwPEy)pU&!FHBp2flPc?)zp-{{bc`ENhJd>gFpR9jKmWk_GNpKvQqe#!pK3$0o&esb;` zN6Kn?b?eiksL#DrS|KyFg<09tLoyUZKs~865=iF|o0hl}*ha5I9B?`8kGpV_@Mb#4 zFHMQa6y%kR_ckVNW{~r%ER-Qw(sOu?sRhcir)pd#M6Z@x<>#bZ1YYk4X~}w@5+v2s zb5P;c%zM_y@FZ9;>+QLnJX3zx?}5eFK_J8x1#M@OGWn{6+NSX zAharI-yzgVV=@^L)fAoXIjYaVhep9s6rj3DZ>k*CdmtPLbdEq)PV<7o4jnsn?p8iInX0*lO&qU1RazcKyM@{M}>5?8ZlE4I&;p_WC+#;)q^&z1^5Ga4o632d$<) zADSmYIWioYyDwa}Y~i9;>K52;$0HGSwg@Inv?yJ;?f8BG~Nr5-r^Nn|6t|~?m_znfYcL|zriC}h%N1ribUL@hN#@L4 zp}43;^W5f@;q=^GA=_%K2m>p$BCA!%&d#q0XG0;Sq7i;Q(S1JAI@@S=q4%#28m(h# zU7~2-QLlQ&KGH2k*;$!vk*3>%WUr|w0l%Hcedf)f=eSdx;ht5o=`c&2Fr%s)!I?t$%!GU~MZZ1Dz z_d_c-gf?v2wDovZr`b!i6UE{V>0|0Ylmrr}_h5H9^>(M=aa$c6M=xlVagU%RMz(-O zZ+nLmCH4-yHk?@moqBk9z_XLO5AN8pjXx=;P5&AFGB1-9Bnnp4_lG;OU7Pi-mi@A0 zMCce_AS_@fKW<#mvAw@eigo=1&9jUdW=C>zl1uUkf+S<3IZ2n3?a(JBaeAXm*2`Uk zdL=EMi|-n=Mx_DK*86Dd+oDn19@*1KTecKZSAaa^r$5OZ!;%Q^PSi(btEGj3L^b1ioY%EcKz9l+ z3Vf}A1cl{3=jlvqQqkvwC;ivV=a4ny;63(YZk!odY=pUeR;B-sF@L;ypT}ZabTv#D5;1uZFi2Fpn8BZnLCV zE@v{Y<=WoS*>j@(6Zeo$9-ZCkmb9F-vXXY)#D$-YaL#^v)~yr0mG0a`hus>Vn2}SV z&Q5fbBD0e$t{cow=AD+GFhBxtjZ}z>)|KI4`gk6liGLA3rUDCf;XJQehGyp26nUsW@0O_hmujQ_H!mF^E6971B+b3w zYTNo(C3WHCUf0(iJ%l!pzK4(U$D$K8AMiXh)BVc(c^iHvm&gS^gZGnP)NEGL6!9AP zYUKf+%gpJlc6$=%(doE!zsoFGaR8?>W-cK<;xuAA0)9`42ty)O?j>{81$TlQE2UEqBdb;CbtxEu1csGI(n-VG=53)KS@ zprwV^*_^D+*{E%$P-~Og_UeAwk}&M$=@dnOAs5J(m(c*!OWORAebHR^V=G76L+h@S zlPI-b*ihe(ypH+_5I~d$o2qDpdczh@r$zmmi0;BBpQYs+_kOnfm(dfRT6Cc|RXuU|%hQ(j z?7wF(e=yXK?eOEXrrQ@k=vn&G2uuC88#n4(iAP90o&D&z&euL;_FaFGe)2VZGVS_2 z_Q~~-c=(1QUFCGB`nrwH)ax&cACQIea_VW~H?cyeou3!N=|G)7t3*BX1z{qxa1CDM z=+SLlyVm91<}Q>DKAAOmwYf#JZk7eMk+hr;CNqfr#w?05@F z52Mu+$UcU^m{I9aWMCPA1_lZ}5;9u_hl3q3v5QHG9I!H5O(tYP301*VgcO8~0zw80 zXK-j|{3ArDO}SO-Ws{TS3>iv}Bj07Tm`r)`)mLBL zHCG&YgFAZQ+waAZTYvj)YyF5RvuBLa^cMF^S@f-nV|$wIB4<(rk4G=+u}w^fHye?g zosH2OIc#Jbn~jRz)3B#GildF~c5SR`_<%@wOR%~J+gFG#B8(27KSSg{|Ga>PJXd=^ zLHr-WazfZi5n=h3Lx;A6wrKmAcTl@8llF3{T%SOmAjipeuhZ!gO?FqZl;)EiUfO(u z*Xwc%E_)(Z6-*QrLsd{|bPT=m{)3`M8M8{%lvxeO(lgj1wzO#HnRtF_XdJ%q<*?1e zHy$TD$%_g(d+8EN%0!b<^Y8cc+bxnqpYA(xEhA~)E$B{EsU1nrK;`_1PX@u>UOL(x z5o_q|nAX6By;v|vk}SxICt32PdPRfSHRv)JWSt_r%yyR5j!THz;Qx0ukkZnl&yw7< z-Ctb!3;ESX&Yvf7NIbLd`Ob?b%zxN~j*}F_Qx84<;segm@9GBfpDuLXSoX-H@4oz0 z#g2|D*SNG=+5)+BdjxU>(SWU-QK#3-CaY*hM!nNv)ftdvl6nMFO~_=@83d!Cu24f9 zEq0%HL}8x*Y2*bzoIK59U?Xn)bJn5S|GCU}M<%|ielhA)-N__p@k>IJ*k5VOwMwD- z(E64M@Pc0$xlVN^44$`uuZg$92B3%1j=ky z7w3@lic|FJc)?+pbry_in;7k0DUs0~O7v}~rs+&&JLim6t4R=a4uK_Q@<7~^w%V@n ztf28$8TL+5Kdn$*2v-$WP1V8JDd@04TT=RT=Al_ve@6!LG_}yJmsopV4LyX~)-=Pq zJeD0S{1>u>^`{Nn59w!RrA*gOH>`yW<8fRtP~_me?8vDB+stMQEFKyycBjkBoE^PW zUm3KSx(2E9h-B%%1d<_SEWB_a-)*HjBSJ~Dsgn+ zOCfB-a$)*e=1ZsxedQ>W`%+RS77TxMB$+NAAonca*#7a~>vG0+_h0h$tjVoIf1q}u zg=5g5C!V^^f=(=4NK)LdeL~uzHS;HgM)S+=d6*0mMx$C5xu!PGj_4)Qx{V5y7{nx( z%bl!GkrYcJt*qYSrm*4kir#L)B!eQRh`dn{McGaLI=z%4%PEyXS!q-+wmYe*s%bTI zl$59yOXhE?n8RnCS@O^X@~P+~yLybS%AIw1N?Nay!~?#z-ThP0>Wd@`^Bm@CK0Z7&mVaj0QreX@s7^a$DIcbN$wRH%vs(KxM{A8NJgc>z z)fy`8r?pOwhrvMMpw@mtcIk{3fj600VtTDhJ^zv`w+@$H`|c8}^+G4}3hZ*TwckKG&3o!Gb*ZF+kCm)pm0{3xOP z!L>ae>(O^mNvHhyR(Yd}UfurAEAQ_i`^d3jS!GcHLP0+jFUb6Ru%)4`$Cy_w<3J0YATvzVy zK)dF)Qey{wxi>erU7)bsk}4Q&xLc*OnYoZoF)M@3ii)zzJKQ310fVWM>k#NrM*X%9 z)-38;WTl1OH#Rfkjj7?LBoWcgp|yt2qVBjUE!7%U-H)A-Ufe)^p|z^k zs6tsFKdB&paOMLKADBCdT)EnJM1JL<+iRYkm+I(S+GW@Dm)4<|QQnG|=1*1Ab38Py zV(O}1^DFPTQ|eD!q@pbOv61hW=`$W2x$1R9c)Z+73%x(x3>D#%CS$je&-STyZ#*;J0a8Rr=I@#BIFm^)N#9Z;e;5UebOd6>g6)L%myrHXe=bJ(Y zO%Knld&t7)DGOpFC1h`C=CjL|JCQlfOYv)XK)b#qYq~`gF&0MKo1v0sbg2HSyJ*P)7Ju0y`y!DMz4{0OCUvRT! zs1xNHrriiwOyo)MP8ES>PUMC-av}@Z3yLNpf4no|?mJYq8&7pdd zJ%@bNvwLy*`Yt)txSuBXqX*F>G!e}vGwDy~$ggxh>_HA>C6|YX7S+zW?KOVN^#t;Y z?LE`3(OUKp{QW`uXp^M8F%s$Rdy(P0l zY?hap9H)z;eJknGGqcUvRY8ZheSQ2_J16J`pB8QxIPYLX0 zv$HJYu)mF$Gsu2&DQVh$@#HM|3KgI@bNALI-4*0QEwA@lx6WBnbXWSTue|a~+TCL^ z*R7>J`ze``Gig#LdI+^g?NDu2hZZe5WYIp}bA+7F&iD7pMy@0OM$Vk-J~`x~y61MO zcv;*;YnlV|`s6fPQ!lM)YPhC(xycD$owqBisVgmmR<$B%b(ocgs@{BPDqK}3&&K&I zwkg+Ms#eu)m#iN3vczW1QprnX@$%Vk?33=b9lUt)u%S~)VJG9E%k`rVDs5U9x3wME zZ)xAUZ9B_;KJPp^o-yX`bd>S)FDNywC@ZTtoqT(e>};EznVs1VwM9i8^7HcYJE-G{ z?wEcg*PQFb-NsD}bS!Jry>l}|s!KE|uK3IrZ8~@DZM?m2rJw?f$Bu$=)c0r=2d?S!gROU@m<@tVxHXc})fLAUL3g@=8kG zS|m?!`GH)OZQE%D?5Zq_Z87tXn)B=vxAbYd5!t;SeEv`5Bwvs-lV0!9MQZAbq@FQBO`3OH<2l^O#)IG|6T0o39gSE+-1ab;R=T3K1vuNloU z$ob%Gdv9Coc!$jvpD?JGIv&cyb$Qf+I&gisdjjpfezQ=~u~o64wym^bK7R|nO?nC>E zdZ$%ReE6I1Zd`fko_l!FI^xMG19OY=^vh?=)tdI%8ExD6a(uV6?$pDHvXGbl@t18V zAhlaRsmbfO=R;JNY}-&qj?wNqLyq+CFYkH5pX)Demo@GFjQk98ao{X_FGp!StVHD( z^rG+OSZ2LCKi7h`>0N;`B|AH-xxq(m*A<`DvQ=r@4mi-Uq)nR+0fV`+L+e)Uyn(DN zyO&G$CWF=HrQ6e+uv!Qa##_HHEZmn@7~`Wi1fc)IWSa^<3oe~M%dh<{mOjnvU*2o} zGG?Ir$k{0ydiLKqh62|<%jOf_DQyYj%--hnHQwvI8Bwtmbb!1Q&K^ap5S#x^ilQR$Yt(de?+G9s>E zU-Tw3d(?(tM@Fdxe?4@V%bQddKN@sG$r)9S&=}=x(J>br}##twf&q+%VTClBa zib1JtQIy=QSw_*g0k;V?;m>Nu_8T!`(AX{=N`)%+SrYxMs&|{p%3FFDjqTlgY>{BF zdII!A4?FV)NhOp@9pjrXqL2c@rfq?u{cqiu=P6XLK95|%XH6Z`Zv2PwHo{!U55ANT z{sDeMF+chK@88}0l{i6a*QHmX)Wf)I@FZPt?FVV(U4y2`=y$1IIsLIp8pVFRUJ&!6 z*z#|`z07_rqUC-H=cYa=0%K>9*28aI@jyC>; z#5HS{-QuIs1K@+vHG@=JsnyMr4$`+!ffHt#)H5vR+H=DLZAIB+ak>+ded$@bEs9FY ztQ|XK=M@yr`Ou22?u~RbPY(D~3sO;=(m8pB?&Q?c($r*kVV-OfO%cC*)pj+iXcMK6(u4!or$4qawesmJY>vPw!l zbf#YF_UN)QGsRA}iu7T{wnYaV-BO0NvM=I4=(^{YV|CXDbo#LOu{!Z$=W`v0i?iS8 z{CMe5hyRXi7doHlI!v6qzVphQp^lURE!Df$(%<>+LE>X&u(Gr=`D5kFnh&-2x*k{l zeUIT{n-AKSKhcW*ES@g^tz@`ZxPwKE$vAnrZaUkK!{Xps@xD}((QL#zi(4>wMTFuE zI4(X_%E|VK7A{LkMFt@?ke;ODggCp&4|F--{#_W)PM$PAH0dddYL4&*(Kz5h)XtPP z${C!UOQl{{2{ssv`lMu!TaipAomFJa7M-$)2RRX-(bnj(}uWY%ZJzN$FgX zn#O-$*i=o68tv_4JP@>|MS`Ezl%=H2XsBe7ETe@;VHs+FS$JmNk4oqeImz3<-f29f z>pyKojp1AC*VKPFV$1zg7*IcwrCa0+Mt@wtR;*s|+*>ub4;=c>7dh=b-Aauqht6m{ zdQM>L;wGMsl#D{&qFAa}(k#&ioIj$;CYT(|gZwWC_|*i2E}tv191e@jwn+Z>jVM62 zuS98d+Wj5*iHvySiA$?r`PbTQPf4adKYaVux8IB9d)8mKO_@D)9B`?yT!^rKAJ5$p zC~%mNs299mtBKB<#l!@iowIif(vHycZ2eo&Bcq_#TY1jvtqfXS(Tn$gt*=8%jMb1n z(vP$ianp+Ps1MnQjK7|_e2T~`gxcDf1POmgIp@!>Ar8piIKO@$EQxEG>X*Vv;zVw6 zpiq!xhs!OCPA_N0*vN@Srt&$`MLgQd(F&cS7G6{;^)D zfAEvGi$}caWk>K&5RW{5*b}$RW9RVsa5h@%OJ;Gy0V0Vd}S+D34%$^FiK%QI?yFjikklLb-4|!BaOHB{y_jtldC)msk zs>Du~sWEJ=Li~}h?zQN2=ePOI22pfQY+3`n&lq9aCC=R&S%R#`ygY3J$p%i#S0O4Emad|T zrK`Y_9h)_}CAA-7%%$w6{1nujSCEmHo_InS8=5ba?g=gBC+y*C_i>C^mlJ9s>mz-d zk>!8pM4f>?O)_9Z*C3CvN#`=53UvYLFP~XmD25axeKU+kQF6pp1s#c^9hfq-dN!G?P$Mp2 z=0!E2{Ub73MC|~Kc1EFBhoP5*iJ{uDbM~O@7w7%?a?OKXW-eUw=70FUrqC8fn^afUDV1t3AwEU{jfr*r@}5lWa-o>1(IwHcgjw492Z6WfI$yMP@=4 zM9^ChjWX6r$ZY4#7N>(wh-96`=Ae2@CdI`|cDqsF9E!!}GNc*hu0f;gf33HgWIjq~ z`U8q&n(uV#$n@w`^JpE*Q_jbQ&XXB17=_c#vjich*}6}ElE=87_)PQ$+dC`NqM5KB zR7vt!CVKk$0qygo2)oS!7`bizf%&lM(04fT@g%zXtg0uqr9_;PKRh#Op=|~>8%Fp4QWhIH)mys zkh@Cuq&7#Xt;oelpOVOVvbJv4rAxEwP#0PDM(0emrlHz6*j6CH z<+o+2j8s<_9uB9*zp`+|`Sbh+RJn$}Hs@&@C47j6PK~ruf!0cHu16pjaSo@!WM%n^ zI7w%5@@}2iqrebAP01?oZiZdD9L&wqes_E23|DMPSqJH+|YqE z%+}J;cJnLe@Yx!4@#*C5eNy)6~_sSyieX$`MnT{)b7Xj(pjfa(JteF&>1oH?O{l?rg_ggW4ayf) zZJ)fDyscH6Z!4a@9v4+@m1W{I_)hexyk4(EMx%-PYV@fHvpSmuv!XY-fWAff+o#&X z9#puxd=Ij!f#zXLeT`OG@SyYqXkAmeo`GD49M8oiCCiq$WHHUhyXn}@Qx}TolT)yj zRy;92*`7ju_LNAan{uL59h!8WG ziN!*a$ z6~V+rufyZ=y1E9vifE_^ic!8)tc9q?>1-frjaAVGIdtT0ckg}Vp?hkk-21@tbvelw zN%47MO{+F9oSohAEi$k57j55r>#ePr^jG;Uh#xN?_3HZR>(WJOAD7FOaN`1PTbVNQ zxRx17cB-j2Luy^xDy?~{V9K^+H}4VTvaQ*?LCDUwxWK|J0apUB?50Ms?6UnapEo)# zR~9p`4m;3JUKi%YW~->_JkY+g4fOt0HR7GgH#(c-DJhXYo;jg?_g;Z^qyP0}%@g~` z*DFys)TZ^k{PunOlc^{6XC_qK@yR>yeD+iC?%n%Vwe2;WU%Q5!8sv5Fj2oWz2I_GF zJ%Ao|+jb;{4v-tVkVzM7~rzyT!I#sZA-^$COn}nvN=#*4;C0*_qi$ zyj*iftIE#vXFdFAE9=96J-30nT&std3=0<%85cf!A)>ptwQqzmx3plM8+|G zM$X8!3M3$rlSD~}MYBop8(}op3(LSRXY4Cv zCa|x-69k#paWWlp%qA%f8Pu^x$9Ih|G2HY78e zyB#mHJjyWtaEeuceFXuJ$t;O_#8xoO6#Mj?;G*+YK7msm^~g+7TTNjIR}|hJ!LPVL z`jCBD1vo!ve?3p!=$zO$bnvd7A57r$wOu$L09$EgAd?d;0&}BztHR3$qaxXDI-`lP z>X}S}h2soJ5eyogBgKlFcfG4Y2}rP{G0^ym>2ziY8h}I+hl?4bTd8e6Ah`6LBFMzS6|B$rc4U{mzUpoP=B>{#159@E@WPFeW* zQBAQD#gm^dkdr8ZRFk7wKJ3doQvW9T1hsjSj`*+Nvuxh|`~a$lNaq&4V63kQWU;x0 z*63D!f*JiVS`DEmaI6?4yp_Xhx7tbF) zR^N#n7y8n-GnI~CBege_G5%1iVEF+(T=zhxh$Oe$V=*JMM~L&f=rqP+FxVMKr{p#Z zjf^6S4Y28{XHy)g)E|syK~*iIF%>G)Y6kekZus5X zc8kufn9?j1t1Pa@O5LPW120jGavC?$!J{W**@2;+M)|1I;ti>P zw6=r!JE$#FpM{_D`|p?Un^QM=`Q^*YC)c5iTeh*bn|2SXChwR__>I@P^A)X~aE^)T zJcPERm%B4is5l&UlhNbx>SWpO72@N(UI(ujQ;fVrFq##kfSqoc=W%6_=N(pAam3I( zmDxP1KN6M8GVHwtR2)mYE}Vn_!4n`59D-X2?hrzN5CQ~ux8OQ3xI=(o!978OySoLq z5M=OSAV^?#eTtsjlvMx=UL}uc6#z z5^#3Lrg%|Ir8gtsJoDvnV-IWeMfVxM{^z}A(e2n!UBF^Cz68KoZ%V{zSr>g*&t`eY zHMD!#X^yQ$Qn}HMBHfSTse3K&i1^w^#n1gX`1I}YkeWQ z?}q6t?#@~htC%dnTq{*j&exuW)e>Afi{VRTQt^YrRqN^2i1{b$uQtvxENo!y=jWQtha zm=lE--H+a=MCc?CDS`3}?!A0tTLwd0P){*F!#2(GNcWO*{gA)f-&A6|G_K_{iI}rz4seP+FUIS7_%S<`aby4uvV$1 zo;O2irQ9maZ?+#b%ntHaL=KQfS@u-fI*jG;{>dyx&~w-KuXeTFG}`MKFG9(A5|xl|D0OaLV77Q?=H&WC~nIdb>l>lJD1Jh(@PFO`}Ey`__E{tG?3Lox=1e! zx#=1Y>p-!R;gb}tjmj1E3QZaQkcEB1NbyPe`crsA#tZ(g#S?o@DX46Y0pAI$s|mfs zGvNhGRQHFUw1Xe^ zQc*+-optg@MX7qKE69AiUCNzv-jI{Y_F+&HtXy6zoYBFCQeI1Px;e~wf+4MTE|mhJ z+b;N~>OGY=se3wc`@R;wImSPj*zg=^*AVzHOMbDf0#SP}<$&3w(y<3m7ATM4G^(0) zGY$YLKX7qxb*-1IY|hSKvNEr1NrUwb@V{;?6_W?_5{`vM>>@q(yDABG-ItZB%B~8k zEyne88ByD53RlVa8{A99?Gc^QE^hcK&;3=TLGRKS`)I6Wz~WdXrt#?CQ1!pZe)*A3 z&LvFJQ?*_wP}sLl_ylgBrDnZ? z8GS-M?l#+zifBXiC&65pEsdSqg7}$O%k3Iv64HzZrKb6#{=>NWo30p^_1JCAX5O_*^PV{vhX$R8f&`+L37lva~n) z9N7|*uxaq7o{}vc{=}rNV3Fq&ABdZe8=8Qttls>Lf{VElP_87Cox8k217UC?tVy-|P7B#ndd2r?n9oLTAF@1~1#|oE^RoIV&h>$$} zRu?K353crZe=WFQV8si$&y*}TA@K%w#ao`}F?VP01Dyh=V@p_5O_l%XE!7h$e?;_` zBI>4Dq&?IUG1(1~2VRAaLL=ap0|GvDsZ0H58m+#DG^l%7XO|4LP9*Q2Od;In)%K_Y zPohVZb`C&7Qq>=%ne*MRvV5+oL92zCa^(h|GuRwZ}L41*s*RX{rQ1gDjRO%5I2z>{xrk&aJIh1_#^~ zxZc*(Nux}h8OqMt<4m4fOTtdK@w_kh@1s`sreydv;B)uOPf zgyI$(kA6pd$Xyk=&HGRM$%X)-6L0QhJrKk}HM_;A**N&!>#W2E^G;p5zJ|?TY6z^FZ}JN3mye zmh;c&E5+6o-+H%5Zm$Nsq>{wZq&gHY^1cY2>toN>0vBX~XrLR{v#T^0WTp*;$KT1M zWR*HT+Kh{OyRlyNct+`y%8>YPr_?gGL7*y0e!8;`mzw zJL+KBe8P;Ym)iG7eS_?M!%Zaes@mA|ZY(7~c!m@Z=IT#&eh> zzF+W$bxmbN=#VzALr~}0N2^-Ge5(wle9o&Y?(QptOp>TwVxPKc4$n^pFFq}=`#`Lx zd@7wCWKgUsPF!eb`t3>ywW?!7l>IzOZNArN4-ynb@`EwOR9;W5^4T4aScIB1 zaG1I1=$qUP(q!JOlAJKxl+U{b5~&%$P)nk#jfdX5qnsr-oPY1Fp@sd6z!FIz6hNbk zkAfi?r)F7{18%Xjg}vsw)f8x+W`Q6TaX6NgJAx?7GQ$0^MS`K?7bsGvOKrJEn21B$ z)pMz_RpA5|x9!+>CzGBZ<$))Dj*~6-ji&ZeA;vS3B&eP+M^Ea{FP7 zQ(aQr+`?f6gFxs!niv|xnhl5^>4oa0-^U&}CZ+rYaO-nz-4?Qo?o=cyu{wO3JC3Yi zX^iT74YtLKnj!x-Q9maMz1A{&1&=w=L$YsLl>qz*d)ThTusrDFWeMkwhsl*^x0|Wtp zQDcB!lV$6ito^R{Fj2@$ggjkfDdDG_VMNL^fH65w@?%nyQa1shRHA#*grV^St-tMg zb_`jyzes9elmdpzM1)Qrq5ywu&^h+$CKt&I9|p!Z+~0M0eaEcJFRdTuMDCNI*X zGrlWN6*U9;F5Pvs#>n0iS`^vJ_Tegl#WGMO+DgGfpvnfNbXIfN?c$7lP;$3vXhnv~ z;9|cc-gj-0_p;Sj_mS}8Wj9D>^k51EcHclhqTFvs^w=hh?U#eaHx@GL3upP)096D6 zN<}E{PuxEA*gs8S6AUnP#5xB!BNx0b|}N6 z5q;_&ZtNr-a-3AX*>F2eY*WMQY|J*|Rh}X>p#ka=lXo z594tNv_C+*ji(Y*)ZM%HDAf}0Bk%w+s@ROX&22a@w|CxlPMlJEjAC9aRiiGK+v<=h zqEVNBUbPxCyBl>l;NDnRND_{3GlCw}?%YGn>@DN`(fO)$7Zvl_O@ zGke%eaQ{-Gm@2)+a=PY|2}7HzRu7tV8F318Sx|$})Xa9cGp2d#?3N4tP@8J-8hsJ= z=iDzw(WIA^y@JhXtGZWet1nlj=lG;OYoK{%jC|!qa~?jRGh{U*mDqWA6|u&_&_@Z+ z_KIaB7c@=yGj8?^GbEz*`aL)@3xLf@a`ZMiY#WDfbaxedUu-L9`&d6K+Bi$`X#^XB zafog?61Og7oI$%&de5SAB!BfRWKqjC6u32-nkqczdo=|yG4)ZF=<%apr9So0kr%c+ zJYF;jdEJ&ZyDNim^?m$WM1hk2WUrC?dVs8R;c}Eww1?_0Mj`njxlO1wxSGMqLoqw~ zL&=070YT}c*TbBSI~5hJ{YZnO$O0@c>d4hfS(H6ep;I;zmrbTKK_LduTO*|0TsGCs z;t&<%fNJf;fkwY|(K{&$#OviRha@KA#`ze(?aY(Act3<9J&i4Ll|jHRy$^}`e%xvjS~zmPzoCsLdv?EsLS+XS zD#?{Ht0gu7{WO>Inv%n*@>ZSfC$`n^HZV=U)v77bkk`$(?+-W+lfXVArL#WNR`d4FgJ0ULsZ1(8^)tY zq>h-2b?#pn1N$A76))-X;WnV|6h|Se>I=?PI|b3uqawS>*6(yVvAA~9#ors51$|5w zyHt4C86}dRb@!D+4hK+$8U^LxIb}@9zIqQA-{y_x`RQ`cb@~HN@j8Cw3N}S=Nq=yv zn!Uo%Qse4kG-V1(v+mpGwazA4Eo#~P4Z>Dq6FCN9vpMPws?EXCbrY~8vIcR^YjC+nR-!M2x3~bdMK>fou zdtS=oU6sTK=HUTMizuPDQ(q4A`nfw}RHcHV(4%7?b}>{LzZ{vaM$hnc-EU*pd7j@J z)ztOgg4p2)ZvOS(b=S8w^Ga5bg~IgF#F(voTHU5@E5MOqM0|FCT}YVhQjwu@94(;R z=9!tndl!Y7=EJ-$D^DT0GxhY8MpHNkYob94kNasTFILvGZIHbH05?)PBtxg4b51eK z=kii~i752Fo^V69m1N&^PDtjG&s7cUwY=`)M;v3l(DA-KcPrxC*(!~d61ML|Tt+tP z4|YBaa$9fVy~F(kiL9~BeL7bhkCOX6NMU8kM6`vhSGB7AQC8a0Dg{jucag80?hCs5 zRzRa!`5RIW@h;#~_4aC)CTl%;L#Jl;C7xyAGRpENRNVYinpGB^hpks?g6&1D$Lhx} zln2~tp&#p~+}}MyKfDibj2w~tN&}#L9A8|i$3@E!dZqV4CbIMc38&!29=?WDPXNaY z<4)cWq$X<_m6gkD+T=+~G1-ZZQr%lUfG)!FRD{wNr`xhj3fo|UY=iRkD{#fwln4X=B8`K zI(;LpjVEWeNNmtz%`Z_Bl%!o7PJB_C4wYWhe7rUsHCb$uLqtVs=QtTrcXTVa6{nJw z;_He>X%Dwjx$|%@WapjM{)~kAy<;|?j_JqC$l3fI-b|XrPEWN$@3tiCejZ<%Dpw`i zT{RQM>6zMywYfRe=2gG*;mLxR%q0S_fW-HNrOq7 zS=%r2v74JlhxOV9@fRsKZ=jTlPCVZEaYvqXuu*a0Qvv}ob06xlB~$29PC^_3bLbCl z(xqXWol?X{S?`eUG_AtL>N5uKk7mmw!_!MmCpAt^CWFj@{ME|MjJgVGAx@g*A!oOi zz321WQuxb?nUWt_T27BaO0FC>t_QRiI7`Fk5TejpzReSJt8z>kvSWmcN9ezqn z>Q)<(cP=&;%JlX}Y;%<^jEX{w0qh#>CTa2VZ(;_+g@|Q~3Z^br~AkZzK7=}vjz!YQ_{{2UTwBKdgB9 zpJ+}0bVzXNp6nDCOSjoqFgQ0c_Mr-zB(EBlDm&k|fMG*6K`RWyy|JL3jLPwXc62zd zh|~hZxw{T9OWXP+I%G`aTOw7Tal5YX&L!3s^61PKf_z&#Rn>lu+uTiBmP_;$cPaW( zn_aPR&p}sH#xU8xj`H&{QF$UHnng0CGLx!osgqr8sQay=P9xmhkT zM{!7YoTM#n*6=>0a+;`F-G#U6xeAgmKE^bnrBC?FP)Q=W&V#Rx84ZhUZC*(E{d==` zHpRH3RRdigY&l=ec?ZY%htaFKdg`|IKm4KAMtgSh~Q2TuQRKWb_V6iMl!>(R{N> z)#q9*1}C9^(ldqWa%t@DAPGi5}x+2q&o z=)U((kyIs9-vMWe+HrKF#1)umWXM%nY(r-=;|4MJVEO6(@nl}6&sI9-138iPHIy-s zXzMFWRn+m;6FQexgQP^|BC76b&~~wwC_sHwvbD!94O=&0?D`{V@OtHMEVJ$YraI5_ zBI90*jhe1_Jr6JLm>Q>VxTBz$XYJ{fUbMV?kJm^)v$Ve{Zs*bXH>t#e*~iy|$f&nS z*`XslJt2DY4Q=Tkdqtz0N)O&yfO?yE(>$);`Qs`0){aDHXbcKsw0%iWbQ_U3G%Ty-8Xmyq%+RfNN#{3H?0AIGK-vZ*V06KJqoqeCO7Ir1GuSLJ>?>mC(XJnE}X9T0ZHta zOgh_o9*+;^D0f5(3+`t+d#uFYZq|0)fkQncGY*!_Jxli={+Q|T)N{k5XEXppf8=L* z@LgbM49^y@^Z3#(^`wGxW?1#I<-Rb#^8cok@^O;oL)D^ns0=PzcR#CDxCG1je(sv8 z`@JXDvy6<3kT*r4DX+3+}%id^@K*u2rCC>Y#ULrX!D@38TN) z@pb77E6NYd{<4PAl!H)-q-9c=u0-cD zTBf@q>vET?l;*k|E@1D##w@gkB!|_G*)mhfq0Uq}e|qtJ9WTwsS9id|k${m|2GJ0A zmHK-WP<>sQ>u4aL){FBj*sJ#-mlmCcAuA4x%BSwC z^#op7c(3ne5Pw8YT0!hFhgEJMsjXNs2dl5QkcS&yR?XqKE4zhQpoi63Sn@Pe<`5Rp zWmF)uUSRcF=Sa3oQe&3gpf90r)9?x>lrBOEU98H1NNkbS{dyv_F-brfP%h`#jYb*PVNUa}&<@14<|4a2ag z;Y=PuVL2GEHFHU#oH#$9@dW#`g|YP*_2?<0Ungj`lX!NKaOB>nvZF>RpQ#Bl{tv!! z2A8LO%N(I)1}96qb3#1dFMrgRlXI@A1hpfg@1JZEWi7Qr!UFTpy=Ge+;4Os&b$TSq zDvy9?g{6~3BBmJvw>`Q#7x7$zY4yo6^NQyg@ zvc)th?cp=MM^Eo{viIPz%s@hML=i3?(2?D}dL$dlS$ZZe8R~th~W{o!B*_^B`zafbT&FyIzB38|qxt57TYU{vHd_42BV~&Mc zDq+%xZ^8vk0YeW#+?#`hN3DTf;1pFLs|JvlWw>Dkcmy4fpHDGn){;~ovnL>T*iRo< zt#ciWTD|ApzA|jn1-@E-pE9A5H1-_ylIq)76DzJ`|AUs}15kcU)qC>x^Oe==L=iro z(}fPQ4osGf7n%|PQdXwYhrX}18FoH`>sNNMYuJYE$Fo1Gc~rLyL!sou^z-!1p+lDH zA6c1u2cEQ2*St*}UHh26a5t&=sW^Ai_%>l>I%wKE5bK98YO}H8A%E@=^S758YC(Aq zhrTHn@Rh^@7wqUxY0gr+JBh^PkarAVGe~Do`JxoNu`N$)sks0Qx5%-f9krQk@BJN> z=1*pOgDWdJWMJ{rcqV&dKJ1KmC7rJvqe;oJ`r5NB0&Qn6ni&~stEo%h-oHLVDGKL@ z=+8HRZUwhWORzEwFMaTj8)UjgJ`&EY`Q(n0RlSRAXX7+L;vPHiveancw6>RPN{$P~ zZBzDl;g)N?etjeuRAQ6GD>w`0O-{dGp4_TlucIMhfX52rt?D3Jr`dXBizNj~a0O%q zLaSaKNgd0;rQ*Ac(_DiBsg8gdB73u1&hEvdPj{P<7NfprBRo)~fXi``M*RiP$qxWW zTU_-Y840x!1WcSJoJTZur@r)Lf7h9jG#e$Mf8L>%t{&!y!G7E+ekth%ByvZOy5G4P_S1R9MJ2 zq2Yjxte~WUGi$JnnpMKJeV;SI``Hp^6{%P|P8p)>c*kK8&(t>tud8}JARXOFtUvVG zlUrwxMcjDfVO%2KIQ_nUx>yGfW452QVY-!}pON;1BrXZQGsM^jE?^%}#OOS0LqG8T zx`PscQ0UDaoK}k*B6r>irR?x-6sb98RcE~d*6*>WS&u?x?_KL z1WsebFiK?JagUUx3!Dt(2~?f2ry+ZZ{*w45o);N97e+3gsnE83JO&q@*Z{iLtt$Hd z$1y$Ba?7LBCB$#YVluS6A86hwx`n~#*Mas%{1mPb(=8EoTM%Wp<*L_+#UdabCu_3P zUi4npFsdNl_d79I5@MKcVrXtp{I~lg#4rv7W2~Qr9>Yp-9B^9Siv{Ah`Hg+z_)61o ziB|S$ZTy6K%>M%QyA*KxVZ$M5%`?%LU36oWuFHFdCotr!s8`I74m`8=A-vGBzOG}_ zQ&-G>E&eBOw6LL@ke;yMd(T0Iv5`T5)yU*z&Ep@vY021AG?B?K&UDh7DO|^VU z1vzJoNrJ?&Qu|scKdnHZVuPd`_&RxKq76e87aSP937A4gEp{V!{vbSE~pZi1coNo0?P>1>jEfId?500g_einr$Nc3Hlx-T%j zqvUz=T_?jL|EEFz^^-yRN!EN9v+jE2 zn(BH0+ud1AQ|8j7uG-JYJO{I*Fig;O*A;^W$;^KyhI2p?GZvtW<|Np^@hRvbwZW}M z{O=KOMkyOMxMQ*cFYbIlq4vFXM%$m2{DLS(J;pUgA;!k#%^QY(U3D=lv?{@VB5Ev& zx;q0NuOvz(p0~cyV9F1H=LGcnr{B$!53$ehs8@lUi^Uv%N+({o}lO6C#Gyl(5evK?U0m9p$SXj32pr6 z?MR=C(DpHpI=J8-!UyEh?pf-cc=ANZX}TV1XeJX*FLOC(06r4c0M+(iKD+>QRH?z@ zV4oZmW@(MSR58vP;DFWPyl_y)1m--&k07ugI7qR3qifSF2f6{t>L!TFGW7+YT%eM> zU6ia6NPCStS~I7awqqOk`~Em}hl9^1!QH+=Lzh;7S-0m)!)<9J`LidT*4+)WVDl?7 zAmhcO3xO6ayHI(UK2Y2y1n)W2tTGA7bAf%q=5@!1ayP($CyVHm@WNFn>%c|E%U;c$ z)bjRW1uCTr-gdlxyD>&%wmmyNjvEz5voQNV-&?FRCxao4aLC5T&rfs`~EGu9QNVJ4+bi zXyUkg0rN%#EyPQBX^=qJ*2&#Wja7HvYdAYs+f$Hfzn*5XOku1=Z0#_Q&|coTe_KTi zWm>o`r4_YK=o{L+5jr$T@2TTDUGv@iJD)Fd!C40c4w3?lYm6T&xsJawrIb3PDN-17 zbQyFSpucv?}o6QndvI zL=UI3v;Pl}**qv$uTsO%BlW6T(8XgebppG2PqhNGeh0}~fhoMnS|)kc-)eKOdlTG8 z(^b9N)0#u1)>)xj|xsWppABc zAY?H1N1EE9d4OLaNeicxex&W&o?>;mAFqRGNie`6kefePFt?f6FpxF^rU3)pp3sZC zgP{OQOOXi5=XvKk8zsfv=B-tQNp)as+%{*}}a{m^=Dn-!AfpB1dw zG1jdIi!c)V@dWFpPdvw|oh(cgnwr!4d99qUy`?IbVO6M7x~O4&GBxL2>uT_`d7b51 zF-=)gi*8-0!VW0PQ~2X1JF~xT`n$}z@mg1xQVKn58PK1aKULr+u**=9+tqoN@^I&r zWu_}uqFv`0JF__CC7DeqMFoLtr>tgx%I#7lk#&2|oV_e4laj+z=TRP%0@mh0r>EBe z4g_`LbzMupbW!Ys_&WhGbD&%}pv9mM@jIASyH2WW{DJ#7F8@eAD(HSjfwl!~k2=}@ zSHU-@_*<{q+K+c+dTD%3e74tlQ)*D&ln1eVZ)9@Bh3ydrlTNy?JqF*pw zyx2;LEZz-p6RyfY%DNIDC~24a+Q9tcx6&ZErym<@&sBeIkk-*X|JDjTc=v6Ads&W` z)$8JSLm~G>u%kOR(giW2yG$x>^(|un@qMT=1M(px#jIV9Kq7_Yfm~;OF7+@ZmuEN< z6OqpCyV=i!kb#oGqi3t38nfk{SsjP4J1zG(I)`9@taD}r4m=H7KO=5(m5#)M8z;N3 z8+6onx^+3I%_Y0L3G~%knlSX08McQa_2Gya(77u8#o~RGlx@RPfMJdSI-j}S5h!h0 z2*}-ndMUQBDa;>wZ{Xr~3yvLk`023MT|NO7FLN=g5_*|Ll*EK!#f0n04m)DBY~!_T z*|5}hq}}HD6Bnu4Mjkqt%yvhKkX~->9`!=oaeV=99wu-kbbE%%&^N|gbXZt8*rOc?@q}&2LTxJIDDl@S+v=881z_t&h3ZM#s%(Ze_*Ea>&4`0$COrX;6 zzF9Yk<{4gZlj$d}X5i%3P;U-0xv~SYYP^b_H7G7T5UN<|nA~FoX9;*oZhih+g2~)5t_ZmFRqMoQKG=H{|8k zgkL*)c_38~sN1RmZRwtU!1nS8Eb;ovvLTpkjEe?n1*kPN1Feh7Am#iW+M4L%>7$To zRSgur4&h}P3em2XLzjDw2q*L9b_Lpc2+ryMISO`OLa=$LG9dvcgzNl$^J=B>v#RHbpo?IXE;8ri|A|Rx{8!8i+u^V+% zUbvDc^Gy^VdQx3(;_HW$fFD2Id&w(oTy6yJXp3!4^d@KyX^m>l^yY3(^u}tAYQ1gu z>+zk=WYJGPlbLm^x{5(^y3!(6Z4j*1zGge+CCG%@cI6%{zQn4N$~_D54mKWAzrGx(UE-yO3^WQgL|cfpUG3hILzd*(ua=8ltqI_)Ho|Ud@;z zP100svHR+)#Ru$+!Ew@~H;jhprb?-_P2f%iifh-C22hG<1u?wSN8lDZguB;+2)ENn z;0|6lElg10Kj^)ge(Jqb59LOgD-FyQD-DdYxaX;=@@K`3$0~e>GtJrueQ+-CW`+_k zO93(TL&_18iq|6y`BS0`k_*8LRO}4PG{9%u>6AJxi#c=020?q5XB+Mh3>SQp+6vnM}w*I3s-TOH|^u&WW0{X{GC^AW4zo299d zFKMw4w>`_QPK7T&l?3_~wMLMPWo@L;{IiVm2sRus3#t_FxTHpSB)jFNQdEK}>!}|{;ZH>oFYp3-Qqq<^zWCmZlaWK zBEfE=*)AfDEA}#kj_!Va!?e94FL*_$t>>Xvg!Ks#XqLS!Q%Bdku3^{4v1fKgySk^= zJfe7*2$aNL#-^jIQrD1b<47^Pf_Z>`-YM3y#jk?sE-u5+gzLa|n3r}>ccQ?%Q42e} z{fob>+O{NoaZbwZ+|bwIuN(3`JV~!^+Xu=;g*LO+BQEF5ecI2Fw8cK$Q(Ms`O$>&! z_MP?lz|ir%r{@&X?cJMhkC3=IV51}9_db6@UdaocYbduc7z%lGTyL5`zQ=Q(AqL1- zc-Tl*I2^g}wuIVZhQNnxJJz&5^|Z%~75tvK>vO8U$y$E#MUZSFVPEG_b2SenE< zXQ4{TZkDOuCSHeckezHTG16&By3s7hrb3Bwh6%+c9>F^}L270YS(PC@tB_-puT(d| zG;bNd!!zhgx+WOOl`h>VpX2peiD7~%)H1%6XHbXKj4jeBL0W5&5q5^*<#Q)1t7Ahi z3cPmu6D&r7cTN{ueEIjlBM7&{%Re#Ou6B#TV~F|4szs*11m6fR6&1rl9eBbA4`y!d z`O!Gr;mgSz7s|5n7DlH|%E)i!FdQqzofpV@Ptq-=e$j~oTI1WxuawHR@P^eh>`(tn zD&OWU^iEY3m493zdzUY)gW|7@cn-y0rGL-pJmx6a>Fh0Q{*34%bN0q z4Kwgc#ieG;#`EPNIL+7=qi^rI(S1>ld-X{+@@uZpTjd8lg{Y~jx8)D67*c%W(%WV0 zz;t8w_-FEu+hxD=hg~qlg6R&@UiifI|2cW3f9}fR-JI06C#L!-j~Isxi|K!Ts8V{MdGEu@JP#2mSFXS z=f$r+spWkwQhuw(TvJ4-eVkuA*sWu>TzdWAw3b^jTa()50*kX<0(bpjxXxO4DHlXt z(BY=s>+R1cZrryNtrME-u5l`B`VYldO{;4e6nmAceFm}gStqR44(@8N0ZO@=)EjlL z-!xUboK9^$7yb0R8rcHvx}C`~=V1#W8+;=5kVc%bznfzBJodcvTh#r9s0~+<`qf5? zu^pgYpR;i0{NGG@!f&3t{1Vk|xe;ZI0=V1ZjGieBTkzk&75TfVDCpE4#&1!-I5TDn z?=OUHD2TYNG!l$u0C)dxYPJ|m@LSa2jVB^*kVdRAJ>YJaGjV1!Y~k;wsx;aee~o%Z zCM0)a4CDwoS#jmCJ6Uny!2Kh@;l}nHe+oWuZG^rx*m~#ssd9PkUM;+Jx(zgU@_W*E zg7k{ki}aEinwSO{8fgM}0k%)KruU~xSBrJ}H0q4Z#iBmCAMz`ZvS1BSj#b_6wlrpxZSOD zJyA1kI<)?8qxy8jm(KU6qw9TIj#o>m?2*9jzl*wlgt2UU%N_+t)-iu;2@S^QSO2$B zU*@To(DwP>)l0PCtk~YS4+N6^U6gmz{gWTh7xJ+jvz~nU&rKlL8^IzcX&XL=3-+0a z5$AUIUH-9ug%mN&L?1r%bEkYdhO+M7A%d5@ar^LJA_Ldmzlz|cZb%%8yv;-(IsE8O z$ust^kgtzk(s|I~pFAZH#Sbf@QR1a53VAUVvl;vH=ZtHXGVDe4i)@+4*?-L&2Bh@UrS8Tnsnwtu(z^+EZMO8t$_OTTG^ zp_{l?i)#Q(B6xCW?=FMr#36G@rHM< zpG%-PU(@nU_ToRmddeD}^j5qEeMV4eJSJzBAk&9V8#Rcqh<9M>exLBWc zolw^2yb1AJ$-gAH!euqS%Ys&9H6XGe@XeyJBnw)Q)tHwB&B|)b$bzN^Ziei?m2+Ka z(w7c7<7N6b&#=8$ryP*>pey`Kxxe2~zPYveHE`1Y71yOf@72(0R@W8bWj;(i_=@>L z5#oz^rSkVvH#Zf(M!mwkH0ZoyJ@rMs+Hml}-Pow`#+~1&@W3tpBV=vB+;RUDOg1VU zamoG(aM}VU)cOC5fLop41n^%4)_`ht{{JFy<_Z!d=}Px(ciu&gHl7G?EbXPA+l{t@ z1=W`R$o|3QKThQ1r>kI$7T(=hKx8=Yx!sg<;`2*FL5!O*F6G~{e{lKJ#0js~v~^Vp zNWIl$s$S*dnKo5h-4-8wr?@T`$Y>Kp6^e)0=9s-QnbJ^iWuWquXW$znKJ?=JKMDIu zO?O6{tJ2-f1UZ_;{(qM}Ou*8^trqBc`=jlE$|rvjE(bMW0Rx&z@BbS3 z+LjFUHrf0&IL}k^v5Yr3-{wFbW2&`3pDKTI%&Lm~uQLt4-fsJd;w6E88h{H!rC7!i z>L7OURM-|RH-ADe%4|FaZy`=&a-3;s*W z)AuGGQwKh(X7(*PLc_2-Y)XFgm=!N>|Bht#Jf0WB5l0cn%JRz!Z8iiJEq%z}Vt4;} zb@+oT>&btAaDs2udz1R0`4EZS7RPhzT%Ir!(_6m*$i&0{RZ!WRQsCSd-AL!gu~=sF zqG-Df4(QBh^=j(+FM{i0K+L^3Q9>OZ8=zFS6lyf|oB(Gq2PCUTOEW-mKhbJ|AMSYylyF z9Xb$S7$+-U7n>D8qfq6Qx;DXW=a$uRLSpDCLs@7=VnSgP_s$m$X?hROI zzt+@Sr@-#!AGd3KU7R-D+?5eT==`+esLX+prbh?K8*&_FZ*K2j8ddX)X5)3b;J}m4 zLO+PY^KkbDnskC)y4<%V*WOY&LBbf!c87(MP^nYa)9s%;?6D5<-0+oKIMS)E{Z2h$ zLZ=cy(UK7BeaXs{ZeyiaT^r zmsnux{`HtGYRjc}>>mREHtHlqm?p(qud^J0+Ri7!N!=6OaU-jGdtPWQ$(y>u2;qil z3H&CLT4oZ#jPATc9>};Xd+H6u%nae^OoFh&LSZtnKA74m=>>)e9!Ez648H~2*GkI1 zE34DsE_SVLxZo+}so1*|Y;!Jw*h*bilg6K7g`$KoqUE0YoZe0jeb92dEch1JnNE1`z+RK@UQ$#o+%jD3%O$ z4PX25&;qbIp|FoqJe}L%kw?EODnqg-I0)=k>LrU(zWzKhUcUQ}Ef_YOkZhR(mIEVT z4P;(I-E%b+M#MoG;G)tk(cbO$KDYR~DPR)L*2jf>Gq}QrjL-nj3^0nP5fwBP#o@l# z>V?IK?>*e^2f#;^{$qm9gp|R3hPR_S31D;=Vj`%D-Q4OLL8~yf^rvyM8!e_K_y{vN zA>tui(BkrItR*~ph8s#%Z82M3We_OtzX3r7KF|+Hf^3T&tH8;i{Im9zzP*=%2x4f@ z)5Dvp-srg`y=#O-0!3GmD$MIM51}U&;2|F>gmzX@P&=71^>t3q6irzd_l!!*xWUi* zHKJ35L(bj;`D419-1%j`oOff_rM<7$&0`YlzV_60G8HrgqIrgEosz+9A?aTc87r+u zB{vcG@W7vmbSN@AGgopTVlyZ8$qr2#yA4c+@=t<2$7}HF{LkKc&^I#@_hTE|lss^HqtDwqecn6b)VT znvp;Awg$|`vaLVcBL>X7es1H*4?oi9-@0ETCD{zMZ(KiTRf9H?U-vX#ACD zPe6|IkFrke-#mVD`zNq2YRw$3_~-8H-nPtQ(3dmO|D=pxYx7rKmiUvh7hg>r@QgHX zDBGC&rh60DMBE$=cQU>3lWW0{N!ayqOFfy$YY0SABUEh2ztwpC{^{(`g3eTR`H3P+ zZC?4}&z2A5Bi?Gin0TB@G+y)WvlI_|_Ww}-m$pQehM*VrZcudhSSKTdc$w-<^=l1T z*Oj^gC%Q(y8XO1W%bGv)%L^S2_=(G^9kBKUZvLRF()1PK8}05Xc#!#$vIW=YzCK9R8->svpYn^F5;#}%p#yeKK-Z(7+O5M;t{Fg}5!xGN1|7|#4Z(I@qrEPc| z>e^>+k2trwm+_DND`W}pnD*iSK5&l-9hUs>!ovL}(l}$>5H>SOp!Mr;ci-0A*LNR} zcFk-ZK#qaUQDh5%h2^r<5ZH7w;oh}&zU>$7JdnzUSN$J)dFVg%@|=U!4!>PlJC%Th zl!2%MXo?+iFa(TH1ZV6~<-IS9Bg{ch>HnVzr{ujZpe#fA{=Ogq#dc<2@V6x}5h^ypoY`E4p<| zl(F61_=DYJ2l*+Xz(2-y#1r zBn@HlU_vOssj9?#?XEjaTi-^{78+~c#?YTIu7xIo{v?h+i18x-^e3U^ z&)sIzSPPA3LU-S?!l}Roxfi|>SFrti z`ftOcY9x(kDv}nCjp&1F!Hc1wWyNK8;BEb&q-C?yCPgIS^^JUxeP>o@R<}V%&(jT9 zZtTy4qN!?T5lgxrC)a-?mUTsbzVWel`Tom2SUVm+B2(P2zSGvf_&spw9Y62r=l4J? zMbZHq<(``b^V$rme};y*bckXxR{?%|86F+d9>N%zds^Nd`%}QbJo0J4=yl+3!ST7L z-TX^Y|IoZcn?HKzhtI~>?x}L7b;U_5w2<{aQ2&iu;*ZjJqN(D{Ej7jCqI{1jl~(P~`s^y@iG>%A#|T#y6-4ee~39UmW@d60Ov>bB}9>T`&fC&__^Vz$kakDZ1*w9Hz)s`SOQIO0l@ zB&UZcx7Q+%xq3Ysja%8qJ9?6nR8_tbj-%d@Jdw(7JO z)r;q?06L|1#Z$JmO{MDf|Cyu?&`IiAHS7>MzfM}uF4Fk8N86R+`bJMjC1aK-Q#W>H z-Q~*8nSO5lRn17k+ceg3K8Ayvq$-0$B9E}q}D8rOkeZ6{IYI(jn_364vZd#o@FR(r|GC<=Oel{VM(yGHA}FBa!qc2e>Xp^7nE| zzv9z#k-So){Uyx%J=?$o_SgR^!?v`G*G;wlnY2}=^TYBXvu~%4cT_4A@xIn6)E{GJ zww+3X>1K2p-ZR8)oW<1BD7QoR8h%V*y3_Z29IP5l58epkIW#tq9vrb^I`#e-CLNA= zg&L)%_Cc}x*PneXA=y5zh)C;`D;)&n#v3ls2H7ZAhW}y@SpoP# zZ~7VY4D(u^EZU6XVM^2QqZnL)IsW~98LxO}qAc{u_QU~Vhf6669y8QB6Uk1jND?Td zaNPEsy6!op$V&;7Ij;NWI^HJ`i#w3Ru!3}-X8mgH=x6xMtNe54I#OWoc$?~?6V=QE zG<^zld30dPC~_zX7Be)`=485zT}RJD@dJA@A3q=u2SXdwe3FU%@|BA}Pn29aef~h7 z%vikX65g^Ja$D^W)YhvNhmy|+)(TeX2YV>YPK{<{R2JO|3hcMlU%HT$QPPR--yIh? z*Ke-xl@;6X=`lUpoIx);m0;+DPEI3_G=&P1y)0MM&M{7)7#mC~u6>Ir1 zOO5l;lbbrqkCUW#zmA>WQZTgrwS7Zie6NqtfXdkl<4dLgZgMy=aR}0D77%o5Xviw4 zbXARy8^U|+?;85f{!qn#IR2oztJ5ZCo|RWTpYeH7@i%HNxKs43cecUa zqL*)GESw*NOO!gJuhPVw%}oWUikmJH$F9t~aG6mHmk@n)X72(%c{<7HwLIeteU_8~ zg%M;J{q@dJs#o&T6fWP;y`=f&BC+Q?DS(&;NP0;O>i>_N9CE9IQYS6;V$PK8B8&E6 zZO%nH-{6BAYg4=CsTAPG`?gF}93lT1;fd)-|LWAG`@U2?m1UM-LNji{GHw<>g(B2D zQwqUyjrq0Ab;xK04~mbuiJ?H1rVfRTGmfIm5VNIhRvKYLRe zo;p>WV(vBm3+$~|)tuT}s#-L$H)r+vm+uoy_YJ%jTc^c}cbs$NfvD;OuGKf-hjiw$ zrhJQ7J(*V5Gkj{U@Q+iT_^BFZe&tSEe4;N%*CoPnL0#V$001kafevfPS z@PVGm$SbohySSS*aX!+i_i)j$j*Prlge$ryfmqQD3PC*G7tc;5!5HJidG^f7+uZ$H*N7!fNNtXc*W)5nw_ z!?}F+g>_)`=8XE7tuO`~&4K#5utT5xR^6vh zwHM#wg9v?h&pB|E8HHHSI)#v#Rhmrp3clC%{LT7(j#Iv`D21rEjAWuGH7kj-^!Wfp zP-d>jV_R}+VvVJ;Ueo85!VzMyM>TeUk#5)o651}8xI}yo+~nJ%XL|LCkW95JaBrg3 z@7?~&I#4s?hQ+m~g|<|sJSVq!WFh%P+il1B-Qbgf64h+XwArOuqHjcVE46$IQH%1Y zBlO9$easq$=_1pWDNKu7T3wYQ#+~n-=@})`rA&``D4I33H7d0{3Cwi zG4(o9O}M|5lGl-{#ckv__0x%{7m`ZC`fZ5$ODUOWMls_ZuFL*+8B*pFyy8^d<5B!s z1{>&GFU4dKC5dUkXV!r3ufyhiwYqbHzv84+V4_ZX-aaW^0VY4gkZxMGslJ@QGrNA@ zgu1cb@f$3)@4Z~$VTS+V&;C1vXKZ9|EJ^*4BR0~E5YA}ll;m^pmQ1rg{hQoFVAGGV zUA>6L>J82@zICW@Cgw%PZidAobB>RRJ;hXzN;Q_cVJvl|D0V)*@cqb@!)U)9?Ghg~ zTdO~}peQ|ofm{{0h8h{1P&HcGR2B}Yx7X#RJTbW0*^l1Q=QbsV+X0g>kFu`NlRA?B zsg~b{AV1!v+cr@-BGyg@p73G(=(8{~)17HZlkH`;FpQ?KCGvThriDGPFlacJzJo^c z6PvaxFF!?&XMOZKufxc9xr1VuFZWnd22FX zBFao-s_?N=K1(oul1ce3Iu_`1vna zAMD^b`mOh6hp$!i|E3zn;2%}>I{_|=FrfW_3{)H5F9uY7-}xU_?>DUo?0ep}zqg04 zW_c!VL=4${PZo|B{q-T)dgehm$JR67?LUg@DU1ktf{i;37#w<7IYOR1kl79N_gnQR zOWuha>y1^un|^Wx+N@Y5+wV!kKW_OXk7+fim}qaKykqP-PlMhjK=%>K455R=20p$r zS>?^o+rdYn4pK5%n$5;N;DRd-BGO_;&Bkc3hJSi~GIJ^E+A0(xYSa| z6ajlpUDYB3|NPW>k91A6wy;#DQd)Eo(HgOGY;kq&JSq%z1>cd<` zaR6&w@cFCH52U|y;?0p-%J>ADwt3SDzoVZqH+bXhmsQ9T6ckcGZwVYC?Uj}bImPPXGe_@dtrTlrk&?dv5^+4A3%&e!EuD427=iMcz zINuoDi%>nsV&z}{#9goqe_-LW73zmw&|8J73C zKj>gJV0R#3eKPQmbFCi5HJueZvks&qtDfnMwa4~PA440;2m$gA9=U`YKtF%ty)?X7WzqaJ~rEeiu*E;_}j=!ag z!RgvbolQUvohT_FfBaLH+AbxgJF?xwVKiY)0Y8GX@o)0k{zlBmaW06AAs%5< zh9-+T=z1Io#4J`AQyE1L4mMHKtzi9i4WY%x(9R>6o)tDrd?Mv zi!J81?Ys&}d*CK4a_M0AKDqK;iftRb?>$fwo_oZ7LlakS@Iwx8?K+pK_Cm+W3S9Xm zT`Yrqb3{#-!2+ktJ*A_0zHc2dH!G@l>Rx0idx}SsPOudHgFAylkDXC^qh|Fhx1gA# zGyHc3rIp@H+OIJjVtLQ-uP}EEg+6|r233sC*{nv#$1$V_j`UNc{@t%iFjvq4cj?Lo3 z1q0yO$Z7+U?j2ji2YxMw)^6yF))^4=klcHA>&J;0>VP0Z67T!Xi|*YL>j_9Kicdm= z4;&`vjfm~xBbCcJ?SC>Y0Irlv+TTEm))@wRWOe#E#t(FLS`Mw=K#8Uf3t}Yk&_gia z*jZLEsY^|6_&|NHOw*$J>zD$=|0o6W&PRJf1I!1I{{4*fq5z|fKJ;?O zi2Q*A^W{|$`CP;1oCy?qh>Kd2?=@4L4os4<5#8uNB_A>bdek!wY?5kwro~}~VoU)> z$PAl@&t~6ndz>~^2YA{SC9^`p46BQIbQK^RMo~0r~=YuoLprU#AYJx zgeRv`=}!mQlNz)yKZo;GDqzd>q-}un-xY_9q?}H0fL@+?bJzGkO@`PWL%n?J-2G9_ zYdp1NzSVB|yvj;p3jBXUD~nf0;FKX15v50(6{L|@U>`}lX^^a`k&Kh_ryiOFa#V_3 zgdI&h9(7ZOM`w&W4H<+F?WP4}wb`283%$RWw(q>T=TS4WG2ldKl_y(`tx&5Q0U6?` z%B1BtHn;~P^~6n;SZXoV`%GZe&bzf#=epesI!|S?+8!OhXMB%mp)AM8?nu_1EB{WY z+F69Q-#eM7JRXocRen0q@kqYc`JR%(NxOR#&+cHPd(D|glXiV9<4K2}cX?lpGjzap zD8u;&0eDbgLxw*H$(f~SXf-a=w~X*FQsD!JsOd~hpn`A3Em=Lrp=QJvssP5&XA|R) z8!;^;E-k21vN;p~?5~is=Jsiw-Jd^wPJgYPmD~QjUDwMA~7#eDm2&Q|xJ*6zl4Kg8}rU-_Bekv(7*>9C4x9Bd`k?8TISNScKz zpr={0&}-rR{2&j#nwr(T~v$uh3u z=bz6fS-tjIC4hKYr(eRNS>y-8>G?OJp+3e)`+uLRx_jv6WtHHQ-8U7YRD+$~-6TBS z^L=wNJLp_buxVgM&^a!80FvQBtstUw7*M=LEW(-2^-4N$Dc2PH*pGKA8o2)OV93+A z!`9D)>El$XTBt(FnQ$l$R3;0TmI>p+?Z@tSWdjMUF2ku-BZkvJL8PiTSk%}Ik&h74 zH=}mRhzMMZ$yl<$cj7hiMfjdZ-&l+YLJ}_K6o!eB_Y-l77^x1Ba0(YDDy3gNMckV3 zW^%RUlV4UhfrfYN$`%@3i1? zoMUZTNHXNGD^H*9@!WkZQ>1+K$ivl*t==$1MjbSE`R%QPsRp~R{L3~~;ZfW(Us_U`(NtZfBHytp;~ds+KxISV2xEu-51?iVtNn+t#3e( z&Ls0I1ji66>-~puELy4K`CR%7%^ZWQcm6N_3;!36fgMvd2El2>%LLYB9p-&Z_yjRe z{02HlzH5&NqcgE&12#!l9ZhUZC5~)NL%IObsLq)(j>yZ{^y}|o zc&Q`3xw>miOg22ZPUV%PPt-vAQZg}~$X&)hbF~87L+|z}2Gbmvv3`C$Vejr1RLYUB;A?VgGD7ebbYXwu4+{9=6q5LWEzwP z`>43cFkCCmSwR*}{&=!BJHv+LfzKlCZeT;~k+}x+LLI?xAz8%QH+mE0lP(D|c}XY{ z<`ZgG1M?`o6OR;PzN9+GBh2ZRq>gx_T?}LDF`^ANzs@<5$*BZ|IbEo67Gp+J_cx+; zGQ3H%u?Vq?K_uNrY$X-ceILRQ+Zczy71UdJi)+lE>7E6rC6EWD1Y;I-ULsQKjUtgz z2w-67Y!pKDm1>A(jR>b2Pw&RZ^`DH@c*>Myt~rUQhgi-eC7Zsasna*o4PaYmfLrpr zb+*i?h*2ayUw*?*`gu@U{FTL^8vF=sd(* zDY%VNQNU1Rlop!;PD|V|XlIY(+W?!(Lw5oSOr30dHl|^rI6Y*wEPUhZ8iD5i1m8Yh z>Zv69FXwjWE1%UG?Xqd2RA0B?Nkusw@55WOABUG45yz2r9W_i&X7@ekjYsWmDM7C~ zZ^VS=wBBG{yeJQHmg2#4bo0h88wcaAD4wyG5w5%HIzLQR2RkUM8}V3 z!4u_Yg%RuEg2N-!7;JLm&8AW*PC6JIPgQ8h#cNZAOtoW~QB>zR#7X)oJYpxqlhhH5 zI8D!QG*Vz>Qwfc-!puh%%R8ycL>pXwErJ~waS=dGBbLfJzaaelFgd=L--yGAkGbK6 zSyk`Rzma##v`0!4Mgp>72-#0J{XSiNyh}<8m(6WzrgO2*QaI`Uf2sc-3E(pk&?OB{ zFPTU}UX_iKjg%GGX&2C}7ImM6F&F^zYn*|T5qZtY=u{qYCv_ivFRUpbI>Uc!W|VIY z7{cKcbxNqdOb0*Y$LGN7hI*w`t+3rCUDwu{l8^1ELGT&z7;(Bu=f=(6+?eeUz^n}b zNnZ4`9s2GJV_tMFVSU6(WP?db7{WWA4kP#3j9et`z(k%+n5euu zrpoXcl-oiys>Ot%I1pS6Rk|USMijs);ACr!qI+|iomQCaOlK;y7Wi6P&)8Ynu>z9; ztz&`)EtDufkI6hAl)nRgayi**=Egh2lW$D_OaAwO{0}}1YLu2%!@0}Jm4ovCCsb#O z&zqD6iG(VDrmaGTJB`kpT)l zGdPxR+Bo$6nxzgE!(7KqwP2P~oU|KM5j-0 z8psuWZNKwUDX7<{Du3vxbA?uQU=DP;d$vD!u*AN|tJJOst=u1M422;bYre2g!yXJN z*{?P>otD3tJWaBF&NdqX_Rbz!wyL=EJkaap^y$a_xh5s{F&9hMclk^|A9``BV&Pe! zvf1>)tdqivyo`zd=Kz2tm>ELqiQ)hf^TLpYdGos`k0Rq{A9GW)fb~T>@VcbXeQZoS z`qGJHe>afiBqQ^gNz553rWl?%?NI44DnVFOdFepm94d?PnGX3 zY4o4au1VG}+=RJymv+V=lg2@9CR%LoW(LrU8DNsp2KsSoV+SYw5cLRkH`SEbsm{QA z)Tx;at~26Kb)`3UTy;*M@)DDA$iwsTNN$ukNp?M)zaCjnB&gGMH(}y&Pz{(bEbm~A zVR$KY#9wqh+z}=y6A^>+2Tcn%?^zk9Iz)x3)%(_-LQ6#W*6{kNKC&ybQ5lp(<_Hqm zno;Xyube9?;{}bh*fesvIMat%{>G3tUA1dNN6;A!1f>nY8)wEl$#@|h9<^F8V!8ho z>?3*6GGLFyfgI}Ld_==nLV4FPi5=D(sRvAFxC?^dW0hoON-D`npG~diO{@-SF)vI& zXU@HWip&V3Y@p{*M^nN)8>l;;hS7kNXEld~?q1Hv$LQ8D?PE0+*hoKrm)ZRFr9uKa zgl$t~PZXpk%b0poUIE*RCfSF-jh#K5U}45hl_^ur^h}(qrm7W%X^d2O3ci`A!<n$QarPDrP-@EtwcGGNTT%(8ua0nQE_g z(<_J&l3dMLXroT#G1^VmoB!2lN?!Om_Tn`9i}kJ5)e&d~IYvmlG)Z9owCQe4&LL?-K}b9mWcp|hNOz>oyJ6U5%KJ%9;+2vc zdD z{`usL!sfqEI|jX&ThLRI2Z%AQl|}dLeUdEv`E&^OWKx+1!`+SEp6>4tyYE$_bF7l( z?vf>+23yXC#FyDuS`W$(@0t3Y zH5;un`qBxl%M$QfV2OWAjP7W?)SRm~nI*$ibsN~-5yQ60^6W?PO8>V(=3$&^HeToj zn;0-{VEah5iazg1o1l`ny#RPtUmioPxbURl(O=dpMAPQIV!8OnQ~YdYEe zIAHQa`ND;vkYXQ0yHc54`{eGwOIMWoQ2xHh|6jg8 zE*V8nH$xb12271irntFG{o8M92O@#syWgvH=;csgQQ$zk)umLUOKaNw-oI@)8dZN2 zfoexY)%U=D?;hUoiOAdo?K^-hlt${|`9ZM};Sc`K^W$Qn%U8AM0NF5RbYMxpO>|b> zrn26F#P8p@ra@NMZ>yR5J&UHws#|B_nwVcTewEFlu+xEL4x}!jWPCOWyf4}JylQrz zO=ag`i#b_Nzg={-alFO6NLIfcKDDo>;FbUjJLyfVD8N3-t>1omiWLGRDw1L~Mg>+4 ztGExf>Pwbfr8euoY4{emuZVOG#&rVt;T4EBkif8s5U=Zm)ZJR_FnG(BnJ~MK2f~In zW^M1rQ1s{4CFJB-TpY5vh_bktYti36w;H>8mECKO{KUzUeO{y|w($zn=D~Zb*%h%| zjg+epvFYhYRgM#0Vu;~JWYF_`M|$vA)pA@_Y3Qj_Ac+7_spY+ZbyXE(u(njpk2YdB zYRU2L_x93rsw_RMVA?>Gc?}7DF*E)i7>vVi(bZVxZP7LDzhlM~s`Tk+gO6O!erOHI zpf1>I&T42k$(97HNofP`EJf=w2Z;Kh6;)t~J{>X?KU-f4^EwO@O48fQuU3H;ZB~F$ zUO+ZGMRpR)HGv21i$k*I9cfAQmK%sI9dFE!7|f<~khZ@BF6ona&Is7lSd;8Y2t1pS zSbhqP*JF6Hvm<^4Sm;!d{M;c%o>`xTP=;>|)GK@|DkJJE6ck??2W=Q=sKQSZ!KRsh z85tzBe?^0Hv@;mpgOxE|tj*UaucJSckHNZ9ft!P6bCx7B9z>808r?}!ez%}xFjkSj zbmfG>4jJI_Q>V!cpAEFLw0e4@;i#gVDf=6t%EY>3#5iBG3N&A~jQ_PahC7_&I#hYZX?V3pk5mrWF$11v+yK$kPc_qx@HTbIVCg1! zITe5F-TL1hdI{b`-#LO&lURc920h<{r8UTgQLf5ca5cx@HQ7{kv0=#dF!Ra~hy)g= zRb)+?UvtAO7dzlR4W~M5SL#SuxR9FwtypG}MLdj2guK5OiieZd?Ws$ZGLnP$i8=|< z9BrFBLk6^>|)ThEPY{=7iAV{X>Mz;L^juo8>vHHzQw& zv3|fb<2C>zR~65~&N|hY`SA|&l-4!_?_4#2aa8lD)hH8LV^q}_&pThU=-|B@L-Il4 zeNdZ`55859D0F2G+^E zP>cyp9A7*OSNlx)1@JI6G-Ye^^q4p_@$=&4gsRg<6uXr{uYIMd(5c2nr%ST{jFeQ- zfh4U?ZYfnqi*2qg;t{^E=2JehNK7-10;Yya!I6biBV-zt4$7-eM$V29Xw)ST{1^3U zFM&a^Yz67{5zrLUQYZ}0iX~9qkV6OIvZXAQw6sFSBs0ImY$jy{VZ`_YKyP;l??#f%mj-)Cz?~Xs5DcA$_a7EQk^`yflsZDC7>JNa)SZp!2qr_( zX&8SlB4Qo$JJcA;)-c)Ve*{5rz;*D4!-+U{s!z4@EExY7&JHAODS`LuMFAiOS#PSP# zz{%+u9FB=OcQ_`Yi2rT;?}&tl^iAG1!XbT=LwYY-ZwFHskaOt^m;BN!xQ@&BA6tA0 z?k)|p9PYMw^-QH!4kg&`NDa5yA>gFjA@&!j0OU7I0b%~ zX834@r8m%VEW?7A>4F1G#uQAuK3F=1-e#8_t1^8vV+n`e?vxoTHhqJ)ghQ+nrL5&m zb%!mTf&g{Q*!0bNONPIdwDcXQ6d*wG(p$@!4<4z;&M?%W8Xd#{0Zp4K`BK#LsKY59 z1XWqSoo#V#dA>1}(bXk}nH3}kP@^X+$18xBj(bz3k#I5KrN!w>h9nBkzFKR0^+r=^ z39@o3woE?NksRm03u~>zos}MJX26%P4-94DIsgqNt?;w?>dncMj4F`2XD-9oh678C z!8^~g`VT2v-?SnZx)?LxNYMvog?TiU&vbIq@HFZ5!?T@S>3z5sW*CAOc-Vl(jJCx9 zD@2>(wlr5X+BX{Fz41;YH{ed{AW6X?SPU365@&(p?1He;Mjo9vE9g(24cbUe*Gw+! zXI;wj)l_&4FaTr({ZOzf5b0E*R*jLvdilK^q#l zf3_!$GTBaB_`DKA{x{G9#7AtZ`)Z;%6L>ijHhz<`_v^&As&5g`)}N*Vm+k6G9uH>B zsM4m@ydCwtNeByiC^bc19v|=b=>{0mc!L{t+1h|Rzt$7N&Zu#$nzq*<%U)j+*uWUM zfe4J!1-~T)6ZXW4xwg@{sBhO9No>^Gj7}v85O1*sGr|lIKV=$i>yW)p5NIKDMcisA z6ZW}`evDVa`;bn9lObtkHkHz#nHJ%^EoAz)03V}mrLxxt1X?Dz&MmMPF0&XLBc{@GgE!Df$I)xA=x>Q(TYa&E#>PlMF}W8-6kFJQHgLQCtV@Y+hXfL<|elR z6}Az(i6ioZw$u&)C0Ivmo|gFC#>sRr(*yEBLiI{h1`>$*B>|Z5EzLKo zq`JrsW2*`E=$f)E@jE@KFL_(SD8uiZQd57wt@rdL@gU45ar?o(2Qhx;;%3eAj(F3C z8ZePCPIJ>HOP4br=`JI;>0Oo5#4>$pFw z=at`RTjb<1U7{mt*YI|5^4&tYbMVlDb5K-ID22~wk#j%vP$KGK6XpqF|M-$w2>ezi zYJ@(k!_-GJlgOw^`m7$a0?o`LqX_g_T_zOG%p{{`>9hJw8k$*6Hf=kSboZ8&hOGYC zU4abEast0K+T1L9J&jzE(;JxIUG9xuJ%0J%E?v1> z>DP~#16Da?IuLyjq46;0{`*5_z47|4Su0mF7KPng6y|M(mtS)02e7($A9VbS)~>0c z43>Ab-ou`|YdXcU*fkXZF37#Esk2udtA!0NM(CTdI+D%&=JbYSUE*i0;-E!=b~&tA z2Y;~ke{FN#)TX`5`Hr-swX;oEkG20zR#VFc(X|I}?Z03%W?*w(-=CUu+R!@*M z+W(up_V1b+v@Y`h?(VR%BkK6cmj8`l*&-5k{9ghNx44|X%1B$!r#USjn3)X3EZ6Y| z{Tt=fsh+Y)t%ZQZ3a630ALj8W*TbHY_mg(*0lA;cyTOi=WqG2& zGC6stMglHYy3XPSvS+xgDC~C2Y$_}3jErhJ5}famlr>>4(E@G6a0!rlJ(oMRW|Qn) z9QzvbkldOE;Botc$+aJDB3h51XdcyD6DWSS_FIZiT8C_YgqGj`Rnj#9R{Os;K6|Je zt)XGql&x=`n)r~y?P1t0vSp22Kh&o)KF=%uhF#eob14V^H$u|E&x+^%CGh01 z+)kd|mOo!RcvR%%qwAZaZY%z0sl66*WRZUj6#gW~EAqb)N_UTHM-4u^J|J>A?V!)8 z7H^(6W^(^L`ADn6*eftT6IOU(Kl;&-PJH0Vp8mw5FK-oAxdqn`w=A|8E}gSXDrIqd ztBmgrKRWJf`K(m2(|&&RmhsZ?BL(E1kh!<4jh&W#@x|AQk`62j?F=iB6@UI3E19Cr zDlIX>@_Z*L_RTnUon`#ip<>Re&zrGFgO@FR-dy1N^vDnyzj#2UpZw-RbIWR^uGqAI zZL!h04~*-^wqI5$V^%UtlX6y;xsS%dQ7ap5PYV~J-FlldF zl|1x)#|*6sJ+Huk?x~5FeO=V7{mJ0tnOevSK;SWV0eSo%~f*=l1++7k^21 zFPoK&Z24RBF6Q%Zm3%z{x;ZRAO&gY9;bWFQo%h>=Q`~Sr?g}2LnyOt?Wo82QUW_J3`zDlc^r3 zE933`_|c{b`uo;@#@&aw52gtb*@>BcPn^al?&JY5VgyXJ5Ke9wy1+)!y{!JqQkL?B zV=7Wjhahe+lP*lUPeMAgb;JP+UzW|KRR^G#fGK07@gQ6MhGvc7;k z9T2XsiM8(BUhirb+Z72s+>>{_CBxosaro+N3R9EOPSh&S_t! zcI1a4aBa}gaf1js#AtWL+)s&|O#+LfemIRlT41Do zjTXppl4mx69-5RN-`0SDbI`X{Dx|;J4mu+5;&vEbxcxJPO)VC)Ik^r@)usz201V3{ z16P$H$S9xvyk+gP+Ri1LhEl9lda}6xldxd%Avfc;*!A#rCX}*U#|9-RavNX=V4E_D z<98w3Z!e?EwmKoQUi*0rdWjJqVh;wLq-9)zD@0+A3>U_%C&uY*sm7LpkV7RaYM^3V@Ph#2DVLl8mM)qhi4>N}dtcL&3!r-)0Pz5U-VKbO4rk;3) za^-!OV*m}?6UjfzQ{~Z6PlQa9*_=0{HlcvQ*hY)VL5-oO*wo?`uvP|}gXTFZd533P z5Xs1By+o3GF9$N*nKT{_8B2%8(vGZOjOTI@l8J22VBREK=-~OgXpXv+=fkt_GFVf3 z#he9`V_BaxK6Ba?ONa1QqO`a**k&a|<8w_=mjd1z&sny$nroP^r;(NbEtnuvJ4TF> z>katHV4HI_t$ypxb!AqJk~<7QB&OjI3s2`mdGIp`k=^?7iQ^SU!4$I44V;z7QfAipH}r#-ax{$WjC6(k_G{`*9VA7-~USwFXGoE(fSCuOgsggyUc;G z7g2!CN(@MqUEPgE@G*L@eti5r?|Zi%&#N5;;9-Fl)giJ&tIRI)sTS2XGEob1CNf;> zUA4*c9>v+MV|It!ye?egOU$)=r1h@QWOp|Zy48Lp6L{Wx3+nUe-md$ezXvCWca3?U z{J3i;3(j9U+4NgKgMA;43v1uM@Qho*Y+vVLO#rSw1)S=vh1>VJzP#pspV$qsyFofXi&5BC}{U3j(%-1V#|0xK%;yZx&ZLWR+TN2u`k;QdtEUp9`~9nS;czHNL* z3DqG!r{s)XJhG(sOngj<_?et3uR9^Rb9vTzCHsm}+`B)v+3mE;+2eKRED-wiUdhZ- z71(K?ixgkge#KGuntEItrO6P-=C(*3*3sXpE&z-JZ_sKFb0kU-=4;tZLb&0$AsC_`X{gppJV$D$7>YdPWpA%Aq+5C;n10)}v!*Y9F~bMEI|_4y3%B|$ z!G#Y>Oz3XBg9%%~vz@uxj6DW2lv|uEqy$hM6x^J}Y~buk!1$`I2@*8D!Myruy$MIt z(n&m-lf9|nV@RB%MZx6mK*PqotlkBYip;Y;r>6dCk_M-c;WluTG76)GnXQoh)B!(X zL^}|$av&fy8NMj9GG(~j2c0jHje|C_KO@TH+5{T+Cvwuq%lBbT8-WR@L1?uYJH`oE zCoq*i*PFYnJ&U;!wGkPnk2Pn_pP{>wJRw`E*_v^p5K)tWP{nG`ec07Grbbe;3ao}& z6YZMsRz6^ww2_X-ThgW`OL7c8&4hgy$sUY=))*yvG_gA5quLXLu(NfTNR$9m5Wblq zM0cak1kA+uB(tK|(P>0wqiCjVJu?}{Y$jq>U%@hhl3=z|eVY)Pj5N|;PEBH}%tyaf zz)T)tHj|hmR3?~SibZiSRj9tT2wsLg5fj1O@|RO7%R%|VOW!JB3R9Wfbmj&I@K%6a zs&6AgnPE@D#4%6POYtbtznm&z3{4*XUI9X!Dci)1Au(O)r8wY?7~WLhdW0lnfQX4^ zYX9Zb>#v9HYXwXpI${G;ww7s1M6od?|24p1UPK!h#3^~x0?^N|1ipT?k%x1`q0TGi z${9~1g^s9&Kj|!f=vgCv_WqMwSH4`yD%ZQ@BPg;#%&#_-t&zjQL&U@3x)8V?EE(V+ zZYk0%kpZp^a6p|3%D95xOGYm^_0XD4RWr#}UC;fI%ZAoLHr88fP#fv-{}Z^Yk%a!9 zBP)VwMMUy2PSs*IG2Ds!BT*9ccC0TaLX(>I*Hia^%D&aX+($%mGCFGzTdDRnnDvY; z#8O~lI9&zn%ZA|n>nY8q4@=+bK=Ly>YY}=>3oe8*)xH+P!#GVWjX;UgbFsc0e>ru& zNk{K{r;S+WO^j90CYFYy_~_nPUv`8fb)W{r$@r_OrUdYn@10I#eYp_&)PY*yl{g%9 zQKC)Fe?nn>?!Wb`#>U)CT!o-86gyoSJG&0SMb)pturc_Fe>vrz@ROua=41F9dU_w3JNh7f%j5#ZckIOOn4`Lr zI4`9KV^>pz?aVLeKK#4N>7B*yKY4NfIhfpV!TLZBkDc}V7ZImLi(fpj=Ezn0eQNUX zh4!o6$NwZ8d4HcH*Y;1s#S3p9j76mI+Nu5nB|3%6PUb@JgR$_xuar<%4ywRWCzQ>a z?#Ic>juhOaJRbu+YZiW0&9ihZHuOx<7Lrthzfg}{o@phUA!R!zCL0l%okH6zi=^F@ zXh!Y zr0^mQ+Yf6#;`k7E;gFS}bS!r$Y{n+!li+^Ry`G190omr#FHXfweEJhA$f`MJf_g$) z_QlAd09baCKo{wu-hY5Rl|1q3vED+qx#5dHqf#xUjb4nH2GFyUWV=X@_25>`$rJwp z(tO?EdhC{UrR&N#YE;(wu=(+Z+5c6PR+v3|*?$x`YE?G+9Ps0fuooPRz;4-4x~=Rl zqc#p6#BNz%Dp(d(qq4zA&W|_T{;#4`ZeDyEY@mAB!1yAeK!{}3u&co^WXGv_(36b? z>%FRt=}yVTR?Zf!f@1R8lQ$N=*!M-X>?ikbY&22SK3E_Wmw4jR)wA*LDQX`m_+v^_1b9>Prbc%~?L!3;aqTBAMW6j+YK!9Qp9(nQR8Cw9 zKg%m(|Dbn$;~!J5N0^*H-*pCuq_=fszxotY_RCk~nyP~gny_AFQYz=fpzdZ-nNaL z8(pp_*)W{DlXzMsy!0aXTk=SkLH9PaNqXd&m|O*al-bXGvE)~vSQuzdaE@beN%-UT z(f9%zS&^RYnUN4xyk5gXtmJ$0-UgM}RC%Xx$WDA$?EZt3yBelr_sb7QK$P*`vHF5R z+lP}NT=N?B4+EXounNRpFktdFe_l07gOuWJ+yqFpyiqH~X0Y)9}Eg>GGf zk9KTh%FlV+Fp8lEkE(62zDni6=fvjA%lrfrCT&{`;U#%D9EzRYRvix!CmA;Q&g%*b zpWSj6^oX%b^T0ep|6UEPZG%J-Y{T68QTZFm2SJ?BL}RIZn|sF(7rP{ieu&QArYG1c zn4^w9-e4Q+lObBWWa3willF|j!sG*%Tb5A?pEU%)4?+ACYeOUB*~tA*x%(qv*Q)Q( zR50&$eeo)roCubiyVj!c|JZxWptz!FT`&nD1a}GUF2NlV+&d5?xVsZvLkO;odj}8h zZoz`PYa@*XZyIZ$dvf2rx$oVnnW^{pRZZ18U#VRS_#PKS_KJ8zhYRNXov(N$9j9QT7IZA*Xx5u-~r*P8pOL}5S9bX^@ zkJgSndzaGx$(R)P$7=uo|#adQR$COzAR*S9IZQR$1-x-2B3Wd2XL z>qw?&`B>uM2zWPZ$(@+9t(gNunnw@sjNG^jDZ_=ah5E|>hba>u?LE{-50k@k<3-Tw zajvX_jdI#dkKL)4UIV8UsbzXz#Kb0nz5^-n0rE~VMse*o=C#dHhqU>zZ8+|dyKi8< zSnim+`%!wdCeglvyYWXMga^G;S2lx&c&_qY*zRcl=W<_!qJp;l9$l7kD9w z_q4mRA{~+VJ`7Ud+tBz|W2F*5I?_OgfzqK*$`C-o;ry3Ing{+7WYm*BWSTC7`XM%o zhpy-QJ^SuS6dE2_P9my%O9mkXl^p?pL~ug~M!>R&c&V@FHvSVb6N(F)PJ}-ETWBsV z(VbcOi62b91u{Z4M~2O6>!Uhco#+YiSxyd#MX6`MSL@1A6n;PYz$shxJ5vY#uK!s* zu@P_cQ{Thre|WQmhb(u(#wUhVvmv-`%1C5`uT>OZ;dyY*c5qJHqdV7maG>eGOP~$R zd>#zXW`gUU2TL5D54U>U&0otv2!Q~ZU`i-k)Uc&GKD2yfa-@u|l@^q+5z|H1j@?ex z{=MsUJBlA0)B~b1_%2E(su9mPidt^-S%}xD+O1c8;~BP*7|0EM0evH9H1vxK6{crDZELvlI9_-&k=Ev{x-pXtQE5bSIEGjR`IEr^r!~*wrZE!kia$_Er zS)_Pw?>~_?k&Li?6|E}Ahe(C?Drgkr2ab-Er?M?ZF*$DBzbSN7^2<>hjOvBYdsZsz$FlU($)yeRa`_yhtNy@4 zWp$^-k?RuX2Rk4x@L+sgUP_G|xJ1s4T~s77n_$P@!9J(ELZT6lX-Fu^kusYwrM;8T zPtaMY#*>?;mmSjP`Z^$wI(`6|rYv{(g?iI610$GS(CJr+?I;T9Cjs zmJzc$y!Z6;(cgIJuaz@Qdgb`U&%cN`yt_eLlWSmSv*BO5WZY^@1J1y6HxGOB!tN06 z9bZFpA7AU8WF>RHIs8Q}AR{P5z+{D7xwOCQ?60~)bBkT2lwq}qWaY=XMHn0V!n`W1bji1Y70V>pN>|U3>YzUt;BH&uF3_^G!V?e?& zjX*20NlT?rkHyrG^=vytm9ew<5_Bv9ntWuuUm^o{e)71M82wiW$cmVY+q5}R?>pX3 zW0cUE(qsr&;g{#!ed&K3Z2K$Gvq5(Qm6*7zIS4YS2T&v`Z-K{<2;n^<6>cdNZ3qUa z(%9e|uK+JikTY)!EOA*n`-ZgWIopCrlO?s^y~Ug+Xi4><1J0e!QYUXF8KD7u!McVnmh`E*fRjiiCYYvSG0Jk%(3oNTVV|!k)2L4m^?9Ul zJw#pJ4*~_NQNTikxzVaSS$=mFW)IE0B5bRL}TKZ_C{tQWPJH* zw8P0Vd_Xn&Vc@70hG;$!uzXOh6dyB7qpCP_B`S}#_=kuR8ymxZ@plnrrmT+|v^o$u zzLZ=!8=%83y~{40iI%KQifD2A0zOSSOE6!?>ygpo&C2K7JlO5& zW0m&qA@Q$#0u&wUhAPW1$qFe-E?=Q1kcYAPq&G0OXo@-^gE(Q=Hkdx(jK zT58RBTKy@qr_DmxgZl(+%7sSH@wLRHL zzJ_@@$#K*R;_rd7s?q|a7*)3tZ_nD0abao&QdzOI=WTpEXY>Jfj!Hs=A*~P-51Li^ zbCzC)UKZRWv{6}zc|(qb%x#i%lH#Zdlx$Uzngx)=GWr=}-vE-3MJVMnO#=@{F(L9$ z7AS#VR+kJy&4j5IxI9V$dGUbhmqN+t3Tz#vgW~vQP%>Wtdz0@*!6V>NG>9w|7Ycyl zKV$~VNHHmn*h0QSQ6G{6<$IWs^x!1dQ6ESE6y*V^lKM&IU7GzH!CAFz`9^!3$r3*S z5Ke*Y@q4k{grBIMj8yW9^Sm%tvGjx@#d*skU$IbXRm@{Kv1n>VmU%XquYp{d7Jr@s zm#0|soP|S?cf}0B-=8H-aq06Ie~Z&R-$;h)&JrH0`4i;HSC+?ilt=p8W9O+K&KMXd z1~uiwkB!KGvdq5`OMG)2NM49}B-0U) zlh2qVJ1ce1{wb+lqFgacbXGEc4D~EE$`xt~W2(lvq=8IP>*btRp{P zQFtaE7U=)7JIO>&cy*W7M?wW z+4y62r)??t%;dqm{n5Hpw&X8L?`FU=;JJX~AWzb65F!DQFZF?@7LW(Z1`QUsOp%#a>~j#OdRaExdEptK$Z(=yi?6c`Uo z-9M>E9$XrEBn~t7&+Jh|s({NvibBe0W^j+CkED+kVU}?8=Tzzf(CqXvDNGek{G5%5 z4VLXK0nc6>UM?9n2@?_P|31xj`%9gYab5;JSCy>80)3~48 zI-{QJ)WaLKGue2k#%8(Ban2lLH$zJiYvCKUvE(#lY@qOY>*bLqM&yX>zB$y*?}rg>Vyfb3(LOI!6Z*w3H?S!%PQoOgmWG3L zjBsBW>JGK?iwW#9Vg#g3LIqao0U6aE^-d{g4fGi!IPKcpDdYPXt898>{18G2`-7Zc zX<)L1YE8OvgW>InNxMQm*EG;^Uu>0Sc|6#!hB66|P66@r_>|8R2|r@;KwF5Z!k5gpo6k$PG5deQv*U%9PIiw%nuuD;ZP>;nAG^*nL%q>i#%{ z3%U;_hNeMvpo>sJXfxCcdIQCV>iXpb76qns8TaV-$oJ^s)8(rEe@7tXL2 z^4d1wJlU$v%bu}>M(>s&-=f3Mf-8YHLpn^ZsCw4Q(bWehpH~1OKrniw6p%*(C_h^e5Krp)3k?j^20I=g~^Xk@_d7-F(aJCu*SA zkOmZ?5vvyF9S z(D|Z}S_!Ek7mcC}8gnZp|AaS8TZy1I$~|A1 z0pbJo-m+R%3sCqpCRKVlxfh>~C3X9v0@b=Dkhs(#X{zR&B~>#IB4OVwJkC{W1tk{` zh|lXr5w{I^yc;W*r+M}qFPE2=q%shn(KDT@J-evo;dQN;7a)a}ext*G&-g0rfb{(V z=@FQ}vQs!c=5|mw`;~~QI-TWmQoW=98r1>YteQ=0_?HU4S+6@5JA8H%C7FX)KEB z@@!LgF+zc?#b}&hf3C@Y%xL^awd#nZfDf##UbGpT&e5qGV_~D4Zxil=GpiUozh!)n^`0a|PJ`RgU2E=WYKk9M)Ednhu4|IAs#_!GOSu4a# zl#wJW@D#T<|Cxw4QIIMY3i<~BTP3uaF2LnbQ9Ykw-^9`CG85SG1}XvGX7~b6LEWs_ zZ89(nD{1wO0Y}xmMmQ5=P0h{?H}>}(XI0@ZNh0FvMxCk zlkBL>gLnMOEu;H;G+ya)~RJc77=b~lKbRc;V4$LEMvMFJt8iH=Yqdo5%-S>0aUJi0dv%gH#$aU0c7;e{P@FevH5{`FMU` zOeGfrOzdWGL_^P&B4^!wQ!k)|-B?GXHA?Kcyy6pyc)}D3V{>W+Y{F3WCt1D;=~mzL zlGwlg?|R$-fK1cDP1Uvj1+ePj?VS9q8ZV~5N9yE>n$_zfs!ekt>5UvVn?a59I6h%y zKm&r>18;G{N<{49LahTX3aUYGvwy`N6y%V{Gx=TaDyz)@-(`^%C^7wc=(sqPjj~5 zUltfuku)|O$Z=AB0)K4in>`wMW7YxF8`3%-x0i&+uWTaMi!_H^c-&#O)+%Bg%C;zm zs@IQ=d>fB2p0pFyV-w=ziL5F1$3~9;F;&O3J4dz>>*teH7^{ju5tF2fvVUqajFsv5 zT}EnYBg~g+|Cm$KL)jma3>!)KPZPjYG#CIbxfKgi+GGDWmsQ;{wNy}Lp1j09ey-`y zi;TG@1$%=cfoe^$-=f`$e*TG281??KPd!ti zw5Y-w6mBYYB))&gW7Z2}4&wQ2*cK*TX+?o;YHu(%ey40LZ&A-G%*K@=%?#~)FnMMQ zR}Oi#cws?by|Hrn0n^XeQpq2kes!15UzxKq56pf%9kqG|OJO%9b```~($}xZ^rz20 zpV->6dgVBtM&OQqrIxF;9}{M12KOm`cfT^eL?hif!1fFjrS21t|F@2+ki0#;viBCx zk9sQ*=DLkLT5cz@MB?l^eYt&I$dYAu;;e4%*!{#W2XcRt-^SlwjQEvL9N&7)^01(i zUkp2J_`;3EQ)brP)=^GJ*w?VE!h-}FB(81;ebWe3t9DyK_|F5Sf2{T743ohfe4`hz z!s@p6qh8z#@(7B5BJXHn(48=7F=Cc%bRG;Zra8X3lSIL!{D73H`8W9o`@JAPW-?R0 zDDJI0GM%wCuxMSEuZ(JuYr>gLk7-eB+ApletNz@SR#;6^jmg+0C}`*Cuu*ukZK+=# zTGKtaFCO7fhRt!3GjOpYeK+-xUOPft!F=oZx?!0=PL z#fA}2HmciSAT{* zAs4Z8mfg6#A)P^)9f!OjmqCWzX5rcL^?y8$<417#Of4RqSf*FzNbl`4Mu90E?EjI> zlL52A{wsLq$t<(|7iiTw(#FVBDfrCh$sVpzA~K~?pvMMd4>u{9sU-4Gld07@(!uC< zaL--b6{!plZl0+nfiKGR8XW0iK06eca>4#@WKhP85BmQ*xJ#yY{OD0V>U8j!?Ktz@ zG=o^+xbUL2W`-F~^l=?*lYDiH6>S@$-1x8bEyh{)OXtLG+Q1_1dxdhvi9UvdZTzp7 zQKG(sloA#=)VyeknPLA)zr~@}M5~>-!wC9cfE`eYpiHmA(em48@&Z!^nB$M&_?cRK z`2Qezo;sQojwphsE>cemb>s}`4I=GS zO!46{GkQO3eJh6!f4JvC|7F+n`hVPEaOG!dRMzDc)1uY1Sm+hK#AT3ZH*~1oe4p#_ z{}HwS5>w2e))}Aw8GPhxX2l+B1@rY{ycWZjqW#*2BsMIlU3hU@Gyf?){nLT^r|fhY z<>xynAz?xN40@#Q>thh{eT|m5y^Rm2F1YybU{Y{F4F6w%{~nlb@&Y{un8Oc~_?b%l zyU)8{`^;(7)Nqp%hY-*4 z@yEuIH{kWZ>o<`^eVY8yo^8@wo4Tofr4j@zJt0V|*??A_+mqVEfRK#T zd0=oxeo1fla*BDF>DrzisF`_>^fU8bhE{u6FkM<6!a8--*AK+DwUQIp3d`DE44nFX z;%i`-kr#Z!a-zSrO=@?nI0GB-&_|Y)y1@|~V_Z{sKFx?A3wCQ|QE3D#_E4+u^*wm@ z9_O8@g}84jTF~8>WRGI4awGbBH}2X=aj^j;1-9tbpwhS<_?R2o=N-g)`z2cnBakR<4t3}@zk{c^RGY3qP@3VbVlz` z;qCe7*Vh;Ld13xUmo(9!>fuY?$1kANc#inPZIQW^oV$0Uk8uaKkD5=XRKwG!mhWSm zjgrm>y_vMEit$_FOK-EoeQ>}HH$*JOb-q3iY~ zm6si4&XS!;caWl-TSdPtfX^~uB6I{ z4R%l1ULWSap?Mtq7&giP7{STowHi8|H(hfZ!bEmJauU}rB+aLndUj&Zf}GO26S-H% zU>9OIUp4%0a%u;BOpy-TM^ck=-VY-y6kF=ogcG8hzeu1T4z^>p`} z)LcFqJp=YK0})qRHL2+$W9;MX6ldzl61(#5LA>TCK;g$gy51QBkOz(dTjRhI7`di`N9z27oKRXAX_07dIi_%%=C`{*dB9 zi`34NHw$>EH~_7HM!=^3)6(b(b2OD>!zxN;tu9toPqWdxprR#m{9SXE7{2m_5%s z>!mlYF1=6igDwtm`vXui=um3O?~Dh#$m9BAUVOkD|BO zu~Qw}zvqO;0b= z&;Hw5mkxNkJ78AUc6mC$M`>KzhU)7Q$^#m}AKKHpbRp%elc6O8f0$S(YL(~*=*4R&6>=Jy02dW?*(=-A1{J&?{O z1m6wpdAUj@i9W?B+frug!*+iVBilau!RLE5$OFv4-Hn@iiQ>*DKPDq{dhd_o0MlJT z=&P`B>AP*r6q1dW{ngGXL+@op+=CGB6(oGgRlIde5c(rvSsI??7Kd^xSo_RFWhuAD z+i=Rf^@)D95*wUp?z1-Dn(o4-Z;SNlG0)`)0}vx-|}9)>zBFDY7eRk zQbRsWVnEx%F9|2@b*iirKosbA#~md|xY?}9H1AkMnw~y4c-{x?;hOhmdaKc0^QoHi zT7435*4wllm_7^biQ`39r8qz5P-+7qX z|H^{2SDf9@@pn-xM5J&8*L8XJ zcNHailLUEb<#huP3CQ@U6A`|Pd)ZztslPL9vXh5fyvNU_Ak*Qp=L|iC@a~B=nb^(x zx>8~>48XZ@Kj<0*prOz?Fq$UaZGl-Ca8ny6|Cz(xUUzL=B{fv_vuV?vX}04zlE;=- zjkeVc_PaUjbWtn(E5nl}U%R$wpNYLEYZi^NwjFrI<$6FT^{=zQt#-bUvga0*dBX!Eb~V^#RdtdY&}-&KStky*F5KEHmF*$#Ql`)>yb-``7ZfTC@O9U ziS)qJd;O=phMuc5%&D_4o?9!`0b^1Rzdhy$?}S6zZ@bqKs||$@u=Hjy&xEm29$2h* zfBHO{h9V<`M)Y@d7rZjVBeAFEQ^K0~DgI}d@eWa|&1I8h(TG*jm9ksY?9@1^1MpI9 zvtlDa^{)69eGd_2YI0&~1lhDV97`Fvop0gV@~?O%dXx#U^AwV<6sK=VuoLeMkX*`V zJ2%}2=4=JnZ*+)rciA0VPS>9M`%P)i185ALKzwkK9hrJL#oe-wae=CNMRHfNX}iaf zrmGpxKZp$Ga4%yGu%NFI^{xibUVb;A<#dn7Yw_GTW z&)W7_92+iBah##n*ZkOd_)envtavd6M!eX?($^(Cv29=^#i>!Y4>djiT(>MdA*XPkEa+;7j+5Q^RsgB#-TuSb zI6OgWe`f8@dwJ{B=yI#?@4hSvcJrMz(dA>NYdEsG_fdIx?72w62SllsE9||oX1X!_ zgmjSJ(7RGk9_s_JYjpCTRq{N+e#265gzX-JrCi#YQvlp)bMT8HK1?-CLRJ41_izwC z?R$?@X?(yD(xI&`-4wG!?88Kp-n(J_h>&ko3B*hOb1BUv@S9qB`{-M3^Q6fA9jjuu zk0K<_{8FUpX{?Gn+$K|0qMkic-alC^o;q!+O|zaU_L29#o>{gh=|}Gho=9=xdg_Xu zEW7t^5Bi;NYBdZm0&m80Y=ih(gnZ_Hn3>f+ravEEe7STFOgL_d(@Y~Y0Axy7T%+ho z^-5QsPCz;vNrQW#S@B*M(|_yJoAZN~{~q3LKCiW!NkQw5%EFfF53+vxYclc^W4de#`N1^F6fKKfkWqTn-QSt`r6uY zXsN*~0Wq%Q==CKYZV=*r+!1z;h;REY8R2D-3NkosLe7bKi9e-L(Aiv2L1a1vkv`m> zS{h?eb&l* z)~1I^gZW`|r=))pjkn)@-SYzl1?+hF1f`jJ*RfyVOj~np*)Ko2sTU%CfK$C;ScZ3& zme!y5I_rDZ$ETw1dwLrJmVb>q)JoRct}w75Y(~=`aW?Lt6d|28{@41_xevc@Ri)x+ zvYM`<_xBO;@$G$B`%L2torhE%Zi{oqG&b)Uks79){u|Rxx7QOt-UgKi3f!VhTBuyc$p~y6`3VTC#Fo zoBR0ZyJ1bwezEOe7H=1+Ip}slZx)H31}E!7+1#*L8&`{#%&BAk19Is$cxVRAgv;1dq z#&v5~jqyLWJ&r0yQa#rfruj{-(>~G(+zbOCL=B(jd%q%apLxRdX`@D`lQL5Sj9tCB z%c{VmedFk^ffUog{;S0~n#Ae+<*bLu0v=amg9cxCIiVC)(1GIP!B+iJrz65yEn$9@zXq4M2R@fOm;^7Xvp`qvD2fPM|&oaSQx zay?)js+W*MPeUdKYcehxQ}+qxKkEiBbv1T2-i~hR93Gm&?vy(*sUIIRp9wXBzV;E~ zp4mxS-{m*=wl}VX0InS!!IEbaVTZ=Y!N~6eor;@2$0_S73PFue*CmFyIZ_T^bS2JL zxg7hk^|qQkhIj7M6czIC7AtJP1BOyk$zBPJ`-hOjC+RY)19D|clovFyZ(o2N_O)Ih zsv3uGrj^=+7Q@m+6S61<(P8Zdv zNZutA`lgz%(5CWjMxxdNZ|CrHD2I#FY|?99=9f73_yh{9s=F68{LX1FtuHCHom;Tv zKf(;3lm|L%fZr)r*+U%FgcTlpgoHdFSjr{|!j@d4&lH#VNQh_b!uB%f_xjv%1b%-{ z<|NW5N7Z?lGH^2v>#H2XmL0a4xWkPMV`<+tsdPksgmt?rMtA+L<+Qm7Is3rX{`ZRD zw!tYU-d4@4qUFFqNvAm^2hGpu&5KM%-t9IOwkZ`83xja_2{!ab0?wopqY$oD4B^iJOks~aRPCQ1kpXly z{Eh*!51JANhhow%$YzX-)?dB&pN6~o^QOz%d!nAiRG&+fg+UzVmlujMGu~BaF)F%i zTWWtq(U*P^uD)4l1uKW@Rx}x_x7QPZF7B*EdvX}NhIj3vSq!?GXrsgOu9zvoo1rd7 z%C?`CrCjsU)~ng}Kg6~DA}h1zR?x<(Os%MzKBAqj09qK>eh-m+rKa;?Ce{6;JMCml zs&YEr;Acv8a*E$?{IOeb9F^gmAt8RBiq^a>n=nf2N5y{J68hbG{RWs)-nZ0KCrEs~ zX|UH;E-QUQ!+7O5w7>tnE0k9}G#MC-WQ>U%j*Yys%trbeku+YfdIj^<7-5GZx^k4~ zfFq2n7k83HReuF*G=l9f2%Bauu8L*+q}rYPxKnR30o|Ld%a{dX-?`G}|L9H*Pna?4 zQhsX)eZgKKQ8bWdFk7YKB{ud&%&3p8vE7lqF;tkBL?+g|?`&IVnwpJZX>S1XMb||1A5Qx$K6hwAuLb1$#M6+(Lk2ssC`ky_9TQ$qX@$RU+6?_zTYQK^90K^R_~Q@XyDDE)HZ*lP0{P2u2`EC}U)&XV|KP1l_F zc;CRld)#2B3Ua6S%t>BasT%5w+LZ)_Y60bOqHi&H7cxuFYC-cQhQep&uD5yT=hr@4 zY8m_>|3NRI+aHY?I9kO!?%%d@EmV`Lxi2SmVx+JQN(MfWvc2Y2_w94Xd;O}B?5)Pn z7Zb7cR(|*jeqz+r*>wh%NC2(7$n+b8FaQZ5_5nc1)%c)Eq zi90fTqQ0rk5M~p@Q4K=6iTE~u>UZ?ommcZ4JNqn?2rn5reXgbYmG-IoMjJbRCZBh| zd{x0tzp~+Z-1s&6INJ+me3Xf?%sqE*kBRXQZ_ru#M9{|f#FkC@U?9R-iShF@`r{l) zROl}b`3Cy{x4iPynJl8b$HNQKF1!kIIcwCg{*y)2U!!|WSAS|6&p+D6L@%PGDw&Ap7)1RPUefU?u1Hi zmOFl?lmVuvNm!O`Ge{%LmX`rY+uNJJqs>ppw^Lfuq7Ua!P{^$B-RY=@SCD{VUj9l^ zZ*k-?4QQ(qiRez0a%UUve3%A>c-~#m1R-X@((v7ofB)*X05$oXwZqd=&zE))8I)Ai z!Pk?**NrK$DG&RLDFZyyJ;5HVIhS7pqIia{$5}G=j{iLc>d@nlD3&~kO{pAO9oieG z@*lFsHeKEK5a8t;z7#2|5A=dAW>R;CSB|zp7qyOV&Z`4(X@cn~>t%TOA5Ro7w*2e5 z{Cm2@vN!$w8${CLx1{xr6KPDP!~z0a0zCyD=NDJs^PArureG$mOvgz!Jd8xFqR~hP z-Yp%n=z&LFNn`itPp+P0!=KJ~N4bNhd!+mtBvK`x?pjSjy#XgXi=#t-ub>VcNV^u1 zAn&W`{URS%HXa&@PBI(rn~Vtk!(Tlx*P^c5T_>ieiB;0ohH-yl84~@@Yq@;QysGn4 zh+V{_)be9{UF`m=-1Llm)9;O)^n3<0$KBE+2DUO;X&V8LAFS@pTAWV0gDy|uS6^@t zmyd=Q&%>L&)Y4CnEEm&O0gV8u$GvCVL|e4IogGrd{pIEPKxZQwDC8RxC3O!nBW$(r z&0+5q5x&vji3!ePzDBxlW-O+8Jj1mbB*;JyuZL5a^q~4D zn-;G`&SN{KhSsj2OYH^hxhQq%9cCKt-fjdmQ{s0QMbj?@yneZu>5FsUAW^ zGe?bShTFZo>ZB%Rucj3Z512YX3s2KYEaTq!j^Ck0*80aaUU%G3^51Q7P1qf*MzLkD z8!3qU=yMnOcw`NVjH>gFNryEw}6H|>*xkz4sdRQ3w1@xVz{4xd8i=sLvysU%XnXW;63dLQ2^WY zS6`%xZiMvfNNLE=<>~#QF=;M} zhParH1Y6j}fh8kUrB~^T}36{j{6u@JPBJ0b^_b(o|ol@TpZ4*qFt^we9O}1Z<^M z_%~%)oMpQR-4nN``^DGA(e)=t7@#q4wpgbNZGX9F27~3m5|1SA6n)w z8v*E0r6g%S9{H|GMHO8tkIpdu^iRf(Z4QsSO?s0-u;O|}Lh&0*kM$k>Uf^UoX-{c* zU#+oo10z;~Z-vggq&o}s8VMd+957}nWbO;w9SdgR>k*STAS$*!6{fpfEhjtezGm*A z?O63($b5G3_s+ksoJYO)+|G@>@%XB=j1M=x~NDxUIN z3@K>kbwLg92cYEVSQJd5&J21q&T_1bco7VX~GT2W@Tp12LHuIrM}_k`KRp6X_o z5=F7a_bGUNzt)&4^1?n6zx%OM1#czD2*Rzp1^B)o-1}gz9Zfl_TS7H z@4k=FMbTIWzK{Nz>a%EW*wG6LqO|&4*t-;+Tr^^WrS*j$=l>)hmA37sXMZ2+^nO7+O8m!9d(!}&rjO3F=N#eO__<%3 zN=5rH(}{v#c>1!ru)gG*DF{2F_x@z|j^F1KK~+k*#-c`k%Bx^1zAYW&FojEkK;mZ+ zy=_V&q0hBLUV@6XvDb`2OY(aL=%@qoA_X}9|BCq>G`fijGuF5s!h0DXh-HEHpo!>a zdJ*u03GG#bsjxyo~CGL!Xk1KDVq1q+S@7F2sIha?{ zo%>!hO`Br{b0qy-cp%41?Ki7J`CeUpI_s`qUMJ8KkAigG{oq05vHGfy3g;pPEn_g# ze{1vo&wF>c?MfB_y(#4a%I=VNfiKS8YmemCLzQKdg=Ww|#oN^A{y!L;f*8e0@Mem8 zXMTIjE&dk zcU*rPYru?Px^>EFOyQpquY6*#(+@TA3xO*$O0Qy4w6jE1X1Mb-GG>&D zQES;xquABti0E<$;-a^$B(4Q8H0Pi5q*&O6hZy<5Mkj`Kr=l*pbjrYZk4+QsuQb zl1HSALaE16eP4WWCO%i_q&X*tG)K1UI@~Fl3T_r63|3Rnl_YfJQak8Ah?(ngU=uwP z?xm?1=4`9)NBTMI7V7va@fynx?Q+hl(uSG8F!NUM=*p6Jh&*hhZRT^WD58Yzubp>v% z>=eII%EI(q_>0bYto4dl!?37+-pig_fN+NmZZ8$%68=(;iufCfkmVu{pDj8!xvMAJ zDu-NIRJ(@WD^)Lj49=i2j9oJlvX+;G5eu|Z*p22NlHTH|S7ZCK-_p-{5!sFF*^DLl zXuR}vCjVwghmv*Gu&5I`kwe7J*VP~}5-xKRIE&Y&IOa)J!Df*=^0{BUgS@^Ob5YT& z=o3p>;_EAcFBon|FQw4PiHN*TnBH!jsb8jukEPnZhDKrRT6`b-A(fRI6(OheH-MDu z{6Z=YUQMl|oaygN=$P*^|Lr{=d}dwc6|Ba{dMPULK;CHAsvcs365D0nce~m-lGoMj z<9fDf7;v|=^-OZQX;q)yz20ILa7Kkv_wfU?%S=&LmB3I?73;OYI-S*PdVrdC_-~@H zdDS&x#tK?(4BL+$MWgczjqM45CC-;lZ-4M4ZsuoJy`r)EBuG>EQKwP3MdLv$KHBU< z!MBN*<=i$*%!Dnp6xU8dy6USqUHg3pr6^F`b-8iaM#Xa`joy~^Wf^rZ9X|9S<`_5GQYx1ev;h(M zPSNi#Lu(6&E2EVah?0Jn1MkWor~@3sUUSfVhA|cwuJXqdpWOYr=@#wkjtegvPIzM;?Q;ZH#4S*v%4br z>#q|F0?u+~f=rsU^sjvC`Qtr_*3CStZ{q@Qlw5E~hT@mlqC}>RBI9 z$9XFVCY$Gz*>UO}UXa@#SuxCNGpEjUb>0{y`M;>$&LEDKYb;rAZ$}i==E8X56a3k* z?$F6qKp)NP3DuO!@eM!wz$?y0KflGeBdIbuwLssaI)g8Xo@%Wg$Z(iVb7W=1ZCr|v<5q_2rD0yX`jqQE+o4-S0(a+B$FXHplrEAJHZcG`|xrYA}&qRl@NOYTpB~FM&&f2VCpFJ-E>BZAk+%5Nj)vO}GtzPY zoGR;)itB#y>*xCL0d;vsO>_4#yWN{5^t7K~MR2A(Iz{J>&;kMy{U=2M9yjIWGCK1y zV8Tv6l8tJc3i*MefVaMfe||Og?C)&H!SB(or9W_p;j7f*4ozzp`NiMdY1TPskq z<6)HD@*=LAs8&BRA=wVUeCi{w-0X@5A^4wsmRH#Wy5Nh5$?&sMv-0^s)*C~Ml&{bH z*v_#@I$O<(Kd=khOM(Q|;y9eLeuj4~o*1Co$LxKT%VzU%VzRc*rRvloiD1;lkBKmC zCM9Z<*rNcmSkHg=6`LLrjrkX6^jPkUY@VO<%4(r@TROUpTEZ)pq^S;s(~nE*F2h?j zYnK^o2lWijDc?((GSz0839`o~a^umtcGx=8j59nc0y3%(wT}E`%qCP_@rV9~|EA=& zQnKr0`25KDKHaa}FrOt)$TB>i+GP7R7Qt8U9qFVL1*ISFPAmgd2e7g6+PwKkmBlC#^MO*BMsNT79JA3kve(2yB zeM)x>I|acc6Jk)oBn3NljM z+UNA}8R$inO=uBoyQxQbf zg~jD))kn0dTl3J-t@R1ckS}GS`|Hp9f{UX`<#5-Ed+KR`i3WnzTZ=f4Mpdo)MQ^?B zlH?O$!t>Ls!7tWlR&>7IM(x7!>oL06nOjXw*BF5>*X~TAOM!&ko-<9Zal)A6Sjm3- zrV|Sxg#}>^=|*GM0VA8fh8!|ikn}%H8XS&()QCI^0s-`%)?pc-5t%)Wv1%yI$D_0c z^Jobb(P8Vn(E2LwcJDx7i+PnX*DF%-Rx1oBk#ls6QE2+|u@LQ%X^f&Iln=DN+L+{i z%frQHoE|3o#go@8EseRNMCPSa&Hj=E<9KKWL+V7E#eEfPC}K6!d`T&c#@LP$2nlP}AY%=;H$}3i3Wg9ut zqCj{)F7%L1XMlxKLdMRk4GpL`c@sw~1rPl~nJYSS=p)&D_--nrt~L=X8YhGCJ!ugW zG*P{D_=2H$7_+X2iB*hyYEh%&teYiJSY$ED*k;IbjFH=L1*gF{GoXu6@QG`!Zs}z_ zMuVW|P1Sr*19>?{V{0^GEjA_H>V75*nfnRdwRk)MAJLw6HIsM)$)!$pTc}h?o{H7_ zVVoty$RUa{QP2={VB+4?1(8;N7?G^HCb;`9ym->2qaQqlS-XFdaqdE zh#DI3RFNp+#=pstEb+3T{T|K^kc?U2<#!-+WPPd_lxGcp(Nb`9CrhLZ!ZAbcqB?%i zD;+vYn<6)pC4=YP_bUVzWwm)1Mu!W%j^B_?=an*>F%Cw@)7V-JE$5BUdawT14%fhLXlXnny}__D`!&oqxny@iXG4DOe|O&yQ|84nH+~#M|qMwu6|3YQ$v1Ia|x?T zy~*i6RJ|I(TR7g-O8oXGzVRc9mA%U>HpOvof-1P!C&eqwTwgOPPevz8?-Z+(_IGC) zC8!;Cv2s-29+#9T`uj7T`BrYVVy%+Fo!qK%dKooZ6@7dB$|xLTRX6vlif#_Ip9MO z#U0D7{xM^l!L6vpza&kxSKqCq#qhz&YNiDODL$T@y77(<@~9!h|+C_?_xPuEX70F zTT7>FS02!buDzh#FX9{NTBjrhh=owF==}t5>>?{SK^)LV`cynsEfIvNlF=uG8)qRbINNVK{anfzRGqm7#jsW!u~5TmS)hv6wEnfqE) z(Z=>3KeLXm@3tIVx0= z#y``htWD)ArwyymHLk_sI;RyLi+snQU5DeIubo-_D#ABd_M#n|0noT!ZN8jmj{&hI$msRP^e5)WtF9?pv*J+eqO z_r5#8k8liu4BHGiqaYTYCR$6xDJl^%TZmMSE+T}`Ve7+Xdf^|q(QDgfVOH?hvhjXq zsnD%bqcY3#VSWLL)x&5kRC2ULQixUt9Ac<=j7C-MzC4tjI*ZQlfxwl!_Wr^~LY}rR zJDCX0IXhD5NZx(y!rTxgDnZ1SMOc5U6+N{`d;;-Gy$HOa8Y~Za0j9)VSk@tH1NRg9 zmljON!WA6QOpICE;+)QXb%Pj0qU8wO4orVdqWSzzh&^kwygpzU2vr|Fq+2)%HRb#M z?88Qy5ErLvotpyQfH9-rJRo5)?@>0Gcyelbgmjd~G+42F1s~uw#L`T$oWGG_9cI-n zYObc%c&xN=|3TC{GK?%<${(+in&I;l2q16g;|O>Es!HQ>se?1Rs5UdJP~#yTu8=_z zl`-6JLhcz4>YC0}hG(T2*9r$3P#`R;#Xze;o(jvSH%Y$r`Q`LpD}k<)M4{NM$RP`I z0YJ=&rz@an|BSrDCi+{yMJG@ugPIDdLc&vKcpenJYbD~OC_7Pw7X-`$i;zl=s;IN) zWNc$JP)pcA_~co(+&_YI*%VbolkW#wpQYFj9H8Htc+5(pm{Q`YVZ==415Woo*p z>@SYk`F3LwUSMLsWh`7L^ae~MNbrC&Beg?eTB^I>!0bRL(In9*W_akLNO?mc5@x#0 zdGOKTk_L?Xv+vWit#KT^+ksT7L(6nh)}ZhjpamDJ&4z}=VuN+IP)LD~61r$ca|f{n zL~)PK{XX1*2|v^!K3Q1CVm9hQt)VH`JYs( z6kdibzyoYI@g6mUnCt&2Trhm46nC1osNqh-j`a61q)XlNo^)pfco94z2%R-epw zX85MD5-kGJ>bqQ*k%8;6kYwX>#j{Ra!N#q=NFt!cGZm~QRQxphGQl+PV{*L7;6Qbp zq9+dO63bByyQ$)D>gpY(Lh7VG_B1wcFCAQCHicpnXq}+@@@s~nZ3Cbr{SZ z(q73n5`SoDpq{hQIb}&$!2Dh#h>Y4Nqq?gR5cL!itpSbNoDfLd01>r=K`nl>Qcqo9K3N1%f4p;JlmBczh@R#a3{dC;)K=mA12rd3g~6B2ccjp8u9 zgQ%tWf-z8hZ8K2JFi13Au$X%V4(G}``O8&pI1vhrcDstx8+?P(M?3_q`MDn-CjNDJ zL(z?mpNg@IwD{$zJk3j%J0?OERi_iMmZ2SF7hhKBFRZol%*^0F1#b$sL$-H6NS?d1Z`c~&H`yggEg;xlmFo3N zi|tgu0`U?8<7cUevOw-MHcG*nd#g(B&+96xP9VFEIZ%sYZb`qai3UXMKd)uxqmB5XJI522L;yv z@dvjIw;VU9)CBJ*!PerbQ3|kBsHlaG(lL#lgY!HU1($h<4bf(A*Nwo%YbMBlD?aM+6w zdx!$fLboiuGmgJLCaq7*Q6=;12Lh&d2}l_=(9Ysm;tH`xoSRAu*B2CzVP?I059AG> z8NVY95f8XQ`n4LJhGGl-3r$JT)lvjbCYg96a*wYz7ufq#>fhU(PIGN1L=h0GJcPMR z(Am0kpt#oT2A1ttY{0{Fo1}|Uff-vzz8Uh&yT(wOVDA!_`UJC}V%(s>WUuNa{&+Y< z+$uwIW+ANmkB8#vRVi>W3azqn1sV9*^6!Ix;vVpvXKQkU267lLjolTIip714@;;ho785aM>v^bzUN&i?n704dNTYtSrCR%cC4`Q?`MSGL? zY=v-iwKN~^tI_~21wKu9-&V{XIzRVFXibSJgTae9A#;wIEad2{#<)azeOMjL<`S;ja0ap80I4Jd$*cB8zdVL#W&*5M| zC!S5-GD>3z3ib@FS+9QXz@gQP?%PZK>)RjkU8wqZ@c$+Mg(m6$FoIEsei*^rFNwt2 ztFQX*G8G0OB8du6XN?#xm)k|?)wshYx*IoR8e?u;Q==Tw!ef(o$k0vdkrDsKt|+D(Lke zA8fn0Ui>VEJcTg}MP(>t8bo&&+L+mz3x%hru(BI9V3NvD9WTic%^OW>v)0!8syp`N zCJRknX>#8O54u(kS50$%CZVK2MI0qDs!?WAB`U-<7X8xQli2X!;qJ@WI?=pc0DUMY z4YGZYo*i*)SdrzX+u-fSI)5qb+ZTl*>kX}TA&r9QcHMV2GhjK(h1DLN3CC>7I0?;M zqN&XKyi_pBXfnmIRs%`Wx#W<(p|s2ocZ{~Gb8P~Af7i)8E{)$2S{v{K1{J7w-hvMh zS~1TQE#xD&w+E*wqDvi8<_7D1=RtY-&+gk5GFchfP=icxZqAz{OY69;Yrssvb#_U{vDWM5fiI?HJ@f-J?Ac z8z|_<@K|;qYX0Ux)CXWdoy|#e|8BvzLxpg*nazK9Co@}>aM8uoS)X3;TZGpO>qGE$ zh!59tCUGpxKzKV`}SPTewcP&|G-Vcc#{;J9egh3xpIC zEOC}9LK1}zDoTvlXe{tzj+noz3<$d)fs752SZ*{$e8qx)!}wg0^Tg@t94k@7jL&l` zr0V`#r113jR3XVM`O{XbL~U+D&VE(-`QEq!szrnR$^9}X?=oiBY&NsbHg-uCMukjNht$Qpkq zWls%9?+3nDh*4@>PQ`ERsDRXfPL@_ZL-39=M6JdIdi?jxLDNzwoVu<+66@e(2_cM{ z`m*pa{{~prjTb z(G1Zz^DpyQJ>&(-05D+khuK0p{wseh3+_TDK9ibBm)s4b=DSy_wD-(%IwAIcL=h%w&BM$Tm zXOzCp5*AbvilvBLC{NN?oF4nZ<&D&wo*ZeUo&pe^!OCYq&3jb{nY!gtfcydUzqO^?6jO zvxK@VV(O8&2$Ht?oW}P@<#v` z@L#seFISoQ^1+HWmIx5z%ShF$Wf|B*S_m;h^*Ov;&A&l@s zGD$&TG!mGxJ#Eu6mG~<0$9ZRd0}3MhJ3|1LPFoyG5BjD%cHbSj*}s+E)ViNzmK}oz zlir12qnXG`s+7eXe+l~?O#(f0YZys6V))KWQQ*-S7?>slftu)+M>y%XXa$dtSLfu5XRJuodt zmFKtuziJwhqSFUEm`h*7&ETf78J_7OuVhyecn@NxE9+Un(J>L1)&!nxMMYE2i z`(v!@YlHLqe~DSeT8~a(|Iz4)I>)g7EI9Cg9ZRO~@qIiRs$`#Nz^Li=bbcMu)R;j3 z_macw^|j?bl7=y})A{kVbev|t?epvp_S^?O>XX$JT{X8FJFeBaT0I)9YX``<=?tNNp#H@uze`=x&C6MWoX_jul# zy_W8L+(k39+zlNAzV{IPIs9KgmhSKEd%lnE{e54rcYNpyC64!g^i6&}AMf|q)B5_p z#`@ns#UtqTy5Bxq?Dxt$SHaQf^=Pce=>L4YSJPd(&-k}2?ut}ynbR1)(F?E~I==W&;DAiZ!{lD*qR`j!dU0(+T zYh9lZFzf@?UC`Gt7=4ji8hYNB)PE*s<9O#=vAe6)<8~|S5Tl0fphJD9Gj=)c`|BeM z12BR4RrCGw{7*jD=kfDiy~h80|6T7Zx*>XkK<{%Sy2k%5#Q7zjzmqF_$m{8w$G`aA z^N4v&?0bcHs@v;T>&S6KDVeDBCYhZ7U91}w`u%d^n5l=UpBjCByVJ`tP&4&U+>R0Y zPlZ!bAWr{B{^DTsptu#Kr&9S)13+$9t=!kcDT2LUPh;u#eGTAa>AQ}`-2ZuhDf$}F z^NL>f{h5spe5RO?42J;tn<(4=^XR;E z9?U9t7%Mlo>*K9d-v54L=i|I4*Z;XD{e-mY?z_}HDDekLFIf^H0cxry;-5%w4=aCU zfd;G3pU%G2qW*6#pI7|3e6JT5@jhJd0~0knI$sZ#T4?P)w|7|+?DNGHyGs)o{x4bf zw{vS=WTMjt?lNQdJ9zpuJ>Gt|_B%e`mk2dQ$2EE%dlyA952w*9Li6-~Zx7s>orF#? z@80>KE=H6ao>K41F2-Y*ojknnpA-KSsotKJqUXwa0ru8-TT<4ZZ*3~~Tlef~PkrBQ zzOSaEx2G8o`|6CjEp8N-oh1%2$BtWn9(lriiv17M{%jD9Ht-zr;)h5yJEWvlOY~(9 zFpgdqe|L{W?bG5vV({_5%1DJaN|$%Cdp0yRp6pCKcBc9)W**p5spd|&MJRRZ-I^vK z?I~0G>8u91!2o3v=9w0^Wv(NA^5&T&{`u!Q{=WI+h1?QKF;l!8{aAZeirM)fnET9& zgoN>7%8QYL=Ywj`t3TFWxeoB%fqzrS*3*Fh*Vhn}ZJWos>^pn?)`5FJR8U4rbrDL^ z!9T&EB>AZc(OcrO>63EgZBC3xY0Ue?brAX-SFIIaWO$7P0GIbD^>v+eSp3ZJIH33} z&eJN@IqFBe&H}3D>0g%G=0f{olAfDlrKzpdE=xj;1$9Hj8os@ zqo0u;SWAkp#7Edhl}>H;uA|W3+l{r~={`?-?y(y3cnKxXM-Q!)N{MyDBkC{>_pM3q zw|R**&RTgWM~R#Wafw$VQ9bW6lxWie+}?KalH_z`e(_`Wf9fa65FkQ^4;}Jej`DD< zb8awEh}w|Pe%VMTr5N)UU(g&FnY9g4>O=mpJ-^uIWK8< zoTWNIK`UdjShXHBy2%;Q-r&ImY1wqqbeF{JSCKu1p8%map=$igww=dZ>DbF_U~iPI zP@>_;OFq0jspq82VObjr3>iwS_Faghn?|98Vf(DN!eS4GXg1wI{-|8jbca zvf|q(ofzk0`B;!#Y2fR43xmbRi_&M(DpjheCa7man-ECV?h27sN33dwlOrT?lI=~9 zg3M{(l`z>${6|u%tAh7JO5J45v?J-6mK#kn&~_uPYhpvohF7Yt%NG8`00NOcM5of{V>Qv#JMeUH!I0>oJDYY#pypJ5ldGxHZq zOd8Q~ZkYphwNH``Zfg>W1hIGVU9!eOVFA8I+-zo=rNby(Aldv>bh0*zDw<0^%r!)6 zk|sB{Yjj?rA_&?A&R$meNn&;ls<2HoE677MndYjN(Sc5^`)_N>RJJ zMfH8D+zSWlcUNbZJ2rw2Mlo6v7G%FE?@T<@{~jfb7(ceN5=p0%6w?kEnWQ$D6-7H4 zg$FCIRgfXK)e7Jz?=#4Hu7v%ubfq**7AhK12OTkHoeJ?5{rX`}UVJ??LrG*Q+I-u8 zUk(Xlh3KXvU-K3TmSq~Nmk~@7vJ}tB_J_0ovfYfXFxfRR&KilOg%L~(Kf z`V$t)24lhQT3_Q)7Xayvyr^OJg+e8xWwdluiMerVBWv@rmyjlYZo!4{^wsAeeJZii zpU-jlj(xoU$I*tmJ<6r~PPfnF(W5*-t^A-?bZ_esb|wf;aa0XFlSUNyPHlSJPfr?5 zMT$EaZEio{>7EeAcjDN3?%Dtg>*1gwDm4daBz?M~j6yR%ge~bKT~S`UrGbjSSvDU} zV7t?mGHn@d%f?#_ub-iF#(_4HlJGC9qyj9F2?ysz+?gJ9V)&7Nd>3JTPEb{$fi@(M(q z3BK+Tn5u~s%|NL#+6H0uix{>X7t(xbpUuS`)115`l2qJABx@;a_AQZ$tN!xya11U? zUwYED@sG`s8LnbUoNk7qloy#la*$F?!hk|MI2HAx~^l{(OQ$C3Klc@D%ch#Y;H1b)Tkd+fc2e-a zu$C7~=`L=-u4nQ^@zT>r`>SkBK!j3$_&hz&=L`xn`0^ zW=7X3MaqK*($*1-y(qv^{4VIh;W}0T@ll_0cxO?4n;dOUG8f^oK@_Pg>d`ngz8_aW zXDP;fWJ-z2Ev_8{kTrt2^r_QyIqMSbxXmB)+_Lka+hBOw1kM0|w29M2>OO=jM@lmf z0nY0*98@ZWETq*3jt6yt$Hx)Kg1~U>?po9dg@8rhfBiXRu4qwro^IaYM?D zI`$sXfe7RLhQwf2`fKW(7h3l-rUmHU1(NZoBm?|qJn5sE&J#d)96J&d{=@@ zr5wj{cZ0=jW{pL?>_2WPx%o&w&pXzRc{Bnxs>RRD1ql_I8Dpe{?psv}Gm!p8KX^j} zoo$IfC5Mrdgr@AIHve!BV9WrDtn0(|>EhiSSb^Q#rkJxaQgq@uCe}LB4e5D^Li+L? z43!Jw^-z@6FGthio!!4nQZ>-8iOOYXT`VU8syy|AKU5HTF(t@B-I#h29Op&`@tyc~ z-Zr$e0g?-{m$GaZ=O-sABl)0HzSA61`m#2G#>Do~4@c?k+TyxCe+t6uq#j``4=R)c zyRMxDqdMyK)-rcm#giA)LvqTyxF@sacGjg#c6>b=5LvAnB(sl}L!fS}WZj>nGP#I! z?~M;62Y?H>6}dwl7faL|B{$bfZL(rg&f_~HJ&>)6047+-gKPL$f9bHtDdSS-Gi)&> zuctWaGr+@kgHixVFzGkP$O-nREVX*9u9I~jIB2IqKG`HJ_|_t2%*l%~r6*4;yXS)O9H%<$7GYr#w2DVa*<06^7oT z!!E%hoFtXq^aQRTWmB z*~ejqu^V3*kEZ0@@SXDdZoN+DfLNzzu4nDGe0=Ht!crj`8pKHwVieiwv?KT`L3$&& zDvv5mK!KoK>=TCk&3Lk+9DctyfaJ=GO73G1lWTkF58WT#2EDGwUtu~jL5E3M(K$vv z94|$i3y%Y529%w1$nES?52A+RL_087(L0~F+lFiDj@uqyn;vI={IPEBVwP$!+bGhQ z<@6NtN$p_m76{js^qUCnGtW<6kz-E2J9avmWnXWY32l(N)2HS;SEZe_o*G@R5enhlPX~3^1pltHk6$y;d^{_6SF9g`_!2g>lk!_6lChU=fL?APKi2X z^~h1oP&lum?+}zzy|672h6}zQ=jxC;;;>VbUePVX$1zOL!OU*tdS-_g=w$&nj}are zxM8S5SXtHjyMdGV&|K!3Y8&E2+3Oj`pVx+KhYBCbqZ|YIA16T=kVDtYpDPT-6`Hu%6S1-c}Oc zlRKR4;c;6vP%ui@GfS0E8H-%HhmDh+WK?M%^qEmmkOhhKy8SI41 z?`aJ4l}}*dWjUqQp>qXch|0640fVj*DF^RJTUXzJ-;BxO)y0OSE>x}vO5@=fIC39l zsRM%$5J)cD?0PS&Q~LI5J35ah^{w`YCqY?7Tr}`41ntR}yIv*vvKjD^VVwi2cbdkB zR||u*M}SyjPQqq3YwyOpjSYCF=sHLD$XA<_zX~0CSg71$!=jg?F7n8kM0PLZasb-i zXup`Hi_}VG;Cge;?!jBvy9P zeRQleB@YRVMOs`<*l<^iGAt%t!@f5zTsb6AG$EAK4^7{V zejV6i=s@Ot*fqKpZ8%Je7FZ+7^dhzQM)Y?TOmUa4_! z&27Q7Wm{R7M7MzvOfs~sI3vHpnn>E=y3#63I=zhN@SI|DYl3CdvGtkP*PR&-j!ZME zM5KpWWnl})>u$Pas*-Em{6&XD=hmLKe{{mpOHpc`))$n$+Bv(ryJ|Zz@o<EF~@Ow;b6GD#F%tLw6)H7dWH#LCr?;XCz?77Gdjt zliG62uGz*yYG3y)KXcU3n(hulmRr(M0&Kx%dcED>`T=K~&^7t;Cshj-ez%h&j!?<< zkSaWRSXdp_T88!wE4@SJ;Ip@01D%PF0+_`7Rl4MO(^1y=!jtzxk}|bYfRKfsT{5~W za;{@PxD7^FHrrBp--SaA3VP~INmvL2QL<&)p{gM|TnL^qaf z4E88*F6KXnxcC0#%jbCUao$L_s+0z6hw*Kx?k-Hy+F`vtz@Pe=>@LuR+T*;rs0YgP z=za_4%e!N}uw0gJGCiEag91*#dqhtbwjlUqjs5gb7CyoF9$k=wUv&xITv`IVSwBZh&Na}o5m*#1|huIr-4EMISsj;h7+&e&oI6mFuo}) z-z)Y9%d$z5pKBPlEzFi~K=8dbyO&lX!27>cQ(J=O>hFZ}{nH<=6kiss?;PoTVW-r-gV-xk%4rJDCO%fnd4wKpih ztZ?5>81Sa#t-qVx(a8ncoi$rpDBtf50`Q?XXxo@A)th?<0p$9)`Ty^ok;B~F^W_O} zUszy1$c!0A8RiFg}^YlzhAoryR20_Sd|D z^2)m}r6GK9uFpTgTq~^pjL&hg5=;O7Qrlfve9--gjqS$r&%8UKc0^kT#RSr zHy0-R1*>$9r>>u&tk0LdrB^*&BR_9v`PC*UfCuryPZ;2p*a8A50Xlkz^5y#a!9JVx z-`5Yc{KzjX)kp6zzGtdkXfKQGdvsDoGf+l4$| zeg%>*hYa$bMLrjo`pBm{`fpAYtQ&u{=fYz`W_p=w?`LP^Q#K=Z;K9lwP!lTZ=8(tK zo)3)jnoIZ9?ZudR-6%zo{hhGgAuQoMjdZY~v=&d1%jj+ARfih7lz=H~<90l()ij7& zIcV29HQ}7<(`Q2SeOErgYp2AJq*&JS zXflJUf?qrHYmi<~fG%a%qcH_9ObZ*;P4rL8IHO+1GbBJdqL)F!aUu4s10)OYM{J0A&Abxdu$+cH5}Qd zkd-Jhm(tjkaX^F8@^#hA6|qAZtQRY1eN3LZ)#j1X;4oM#4bcwW!U@2AYkvRCd za}c3JsNqAC3-m*&*$t0%q|Aj3=ha7sr9U-5=|saeLZN6e_l6{1RBCMizn1_7CzL8QP4%DE{hzPcGA~bnYwO$p2w%xb(~kaTs5{3Y_7O7wuvJ#!J+*HUX>t5 z>=%fWx!^k6F@?Kc&w;kuU<`!Hi@*e~v;YEDjg*<24ua&0X&=`DKDtpX@^{x2-I3#Z z&)4?NO>6WkH+e3Dm z`s)o%dONCe^ZHkEyt0ymcBLBLz5x&x<1b)?wI<~{)0C~1G6Oq9Eq?_u#D~*Xs`*$J zV<&QE;9UNos4KgI*_GsSuY^dUN^<2#0hCZT`I0kb^0O`0-$`Ym9*9jw@%hm(A2LA$AdQH{pZ@n6G~M*p#!YSNN#NZhT@L z6Sx!!oOyt_H?X{A5g= z%N5MoYsTom!6Ms@b8%#C3ImS`1PY+*yI^xtkuWWp1TZ9l`ANb!?I;rz&M%dGP5S&%JpM-q?r{~ z<2E~B9Xa$vztn0R56HA38pmeFwRz9_?Bp7ffZks0BhR<`tur1c>?$=+TkL9se$tH2 zMX&5{>f5XHjIoykkG0LM78i%+gCuaa-p05PbUNqt6mpHNn%GZ4X55-hrY~tp>(-NI zL|oY;3NsN+okKnC#p}|D`bgne!ioF}^0%|OL8yJXmAmG?F4T6CtddEFDc&boV3qx~#az9wp`lr`d%NFLQwZ9v-N-eCEoEy(HJmGvm18Rpn3PeJ^isYk_)?eViANxTJa03r@F$|T zpo}swdl>-;1mlD^d47idP0Vnt!Fn{86uatmVJ5~`uv)YAt*z-T@pw;ww<^UjSc`Rq z2j%imiQZ~88F=)GI!fy$)CpWu?OqZ7rb;F2rqK|>97N~23D`>#7;iyd{@->aZEXW- zhTnPawm7{4o#J%>#vq-D=PKPePRiE(Y6^dXLxr{m%8>+lvd_Sh?6O?4&-{{2Z2aUe z0p&P1`}7BE9;%!Bi*V#27E7xvwYsio%;K9YQ?6hzR2j3JZx9Fu$_XBaW^ zZ3u7@jeZZD-LyV!Mu>YlpJ2i(vB{h4g= zGXam#2eZ&@;T7=yCrdR8Tj(R~cbZ=37EMh{TF_)L>j@xgU4xf=)Q<$AQ;yUcbq=wB z23_h6J7?xaR}>F+yq4U!zy3xeHGPge`mSokV1EQOTRUWK_0}_bT>4?Gp z-INB$fL>HUI6Ja?5yu6RvNfCPUPri7qN-sPi0%l1xY7qaSzn@n(ihZ)m(V)u6M(b5 zk17qMu~$lqc#dXJDP?shO#3zf0bJSX3fU@*2@~Oc0Cc2#E>{4a8U=&^ZVaV|_0~js zdGseMsDR62g5Xf7r$f;>eWCR`rm)-0Et#c3(15L#960KZ!XOZv`rB+y_*T9Wdbnu5 zshXgv%eAFimpL=6Ya=iRg8q!KC&xyu1d47K`dnsbIMsen;IeF!(t^r`D`)VF_1IM1 zkm#D^fS<008p&YZ(#op^cRQ#iU%zf@--Rce1@W4w$kzcK6)>G570u?y!yyA90@i>x=PydD% zL>%}NDb;^d?+UAzWz)$OTVuu3Gc{PNc}cPe6Bu%P&4iXR6( z3>zQEx2(TdH^z6czvK-lexlA=-u@*i1SxZn1U%%Yr$s)S)KyEVG$9a7m~M`fs-yMb z@o$>e%8`k<>qNNRss&yR z>G}@JH;_eoc#jVJSBLH>iqkDI$7UA-;eDrsYDhZaURGcZU204pA_8B6+?8%GMIt{p zQX@gV=L1DP0qH|wh+oQVJo1~O5Wmz3EeGn26RtK~d^ViK|_j z&fcR%D#Z@N>`WtB5j0NX+jSC*B;wH%Xz&$7Ko{}rauqnMDS?F;apiH~tmm%w0E;wJXXSG;>XeqG%R4`O9OJ5l zuT@66G){-7Hc_OVfnKZzpqxIYT&tDFbQvV#CV`eG&0tNhB>!V`dICD+oD^3{MLL*Z zA`m%~n?4bR7a%)*5IRHE9=WAH5zWev!_|4YEo|n$EiFzzI(QB`<{0^yip_EmMK4j} zAs9rW<^o>N{E00(5u$S5hY&OIi{(3wRtOHtigI$ME17v44+%w-N(r9Y{<1ED>0|W6 zlE-ul5dTF(O79B6MQe1>t-;e)Jk4DWi(2YW|4GC4_IKu9Hp-a|#cvgI(>bQWKiC{M zn|JNi!1vD;O?NXbxzDm=0{`Ymx2jhi5A+!ux#Tlc)V5))nS0 z2W?2YK!ZB5dtvL2&|iH1>ng+>1J|ltm5y2cAG-ztx||vNOV?P%UkuxGwX{$XrMHXxQ)hT}k5X ztBD0x(HO5uwzg6SWlJZ?0gSeLI(}&uCt!9{RGe|AWT_|)ra(*+V2rYc zsrSf+ReDm4jz}f<{wayU_-B|>6tZpC`$ErIK0fm7YGbZkYFLk{UQG<#)lSr{YMIMQ z{m>2DX1M7MNp(avl_zQ7wxo^;9YB*@+2rlhgK|ZJ$n!i*vk(uzi@*^&0Evfoz{H)d!hA z+CGoA&!g@0X!|_cK99D~qwVu(`#jn{%cJe{X!|S=VEdeIhq=My8&d^}YeLdJ>F*h0 zQw2cQtqP!Q`=KI3h(awkyi4&mq4#&%(2wj^AuhKZHpD(-GrjZLgA)Vkx<4OX!?6t- zpND62MIT}^-e(8iKW`OjEp~i?BQ=0RJ#6_rt@RAKx%L4eMVHpy*4vz*yvLT_sT(QB zxt+jlzcV{G zAG+qA&9#2W^1YYdJo)VX{W>vDZ272}5EieNxgD5xfX!Jp2&PM50tY6$ z=xy^A(-V|A4FRuc*))91^HXTWb9xQSdyz&*Jm-NS!83hWdG!?Ueb)fD-!IVuS#z?=P!SGN1=v_u4*K&X$R|N z)|^d^u*1sydH!^qQiJrx#uyc+YO_2K$ z0K@5SfU>&?w(%CWn-oYWWK9PHp#n>n%z9r4xNv;>WQ#|CL&Y{ z#IxQjKsgy~pM*KAI59ylVW{idsk>$>Hu0+>7PGoCg%;B#@n;fxwwon~#8Q$QGkS22 z&a6)|bk3+r1ri%y%+IfTeI$IBhK095((%k|kHR)TKl$u(P$I=|VA3vTnv#N-HrT}# z44Y{#W|Q_*8Zs|~Ne4%CQB_^#fnVO)TJImJHY#7&3J&YYb~QgAT@xYFnP{v|G-_9? zDP42j@|o&KI9DGlU$kZwJv(7yAEc?>6ZiAWBp#Ogk>fcrJ|y>Z=fCm4hvt4=L9;Ln*d^PIYeTf}CI{9P`hby%IE)gPOO)J!{{xp8`DF zgxa%Sm@y?yrg3ChuXE|tL~VBDa$dT^%3jdh-8A;GrXHqPldX}1E_Ob@cBP;5!0-W8 z5`7k_08TSaMNghDY+f}2BHR1v`N?O`PLTRRcI|!52E7d6BagH3K977v{zu-yW}BFL zNSq-s6g!-llr4LGzry<9rc<$--zN@gUWkpHvzJu+Dc)JmB%BbzUe ze2q81?o0nX^iNzPlMiTml`0ua;%g9iVewb zwTpoERWJT(>)vTQS;S5*TFIu#y+3ME-Ep5}ft~c~Cdrjcv-aKB#f|CC&V)EFx?@{e zo=vW*to^xL5P761BFheDOB}jqkOk=(>=x1d{OqJ-A1Gh`GmhwV6=&&a#GRob*rh5O zA;sIOlWKQ%Rd%s!sa2FUnmm3ljrOv~mK=uoa;{*Wa(4hPv&WQUI;z>Xm?0(R^z^k| z6~owSe?IYYpZYcz_t~2YY2ullSTmgooM@W1`rG#G1T7!L6+aE1+Q@bD75K) z%Sn?_S3yLprf8p4K^@Eq)>=e8D0)?5%jFF5*jY=4{S1kFx>ixD-ra$#?GP2+#M%$g zP1b@cO4M$)t)co-vKM^v8IlR(YS*e)b>KqK#57{IpbFE9CZ$;E@^Ka2BJb8_!e~WpsFWi)a!t0*1=qrcMNB3THmDtKq4aT=0ECo=(<JwnpfSFS#(dV zDpf>R(NRJ0LB(S}!{tv+fhHAa9OV!_6am=+A%jf=gsZpl4l-7ZCJlqZ?M<}+UPaF? zpw9t8XG!?7)-znWvnvqOS%DXzxX3LzV2m%UJtdm}KUNY~F zuek09wQf;8w7En;Q9n%SVw8Z2+i8y%{ zlWj0*M`hG2lXg%rG1D; zdFrhcQX6aD`QvK2;=MJd_GWefmJ)pVNq)^z(eDhENZ$OdjP)#`_y|wDO~^ZAP1{**f8w*f)FRY#u#tw~X!* z{$GmKp3?caS~~QTgyYO?&!b;G`L?>D=z?mV+#lF)d}jJ|?v--?RMFd?k4`M!E=R=K zGcr^h_Z;ThFh4kYlJ`8at4E7@Zbq8^CG&~*W49q3ok4O}FXydk*@rn96;Sy&>n^&a5%__DmyeAjLtR^92r8UJqf7k$F$IG|~3F zbZk8RUI7bEPGgbVeG6PqMU&Q8^Q_MLu6JH&-hHByD}#$wXzl3yhO=qtC|Bf0P6}lu zxdRhkGmBOYE9N<_RCmLoCX~0OtzREck-`Od!pO!Y4n{0}xJ(!C6?3Zk?G=}OjVfi~ zt`MIQCH>GM$=7Oi?#7h%{GDXiuLS6eko3k3^qNim$VH)-j`Xid3S3ysOs`qu_G+^f z7w?cQKFw+$88{RC3{dDv4wQB!C_Q#oHao5L5`TR$xx~i1CTl z^6y(}ejWa{RXgLgFHRr4zNC-Gu0k0A5x@&cOoZ800d~APF0%w$oPgMIqQzoOkXsfB?%t+T7 zC6l%*PxywtHUKwY^uBR$vyL_z8kE$lkT+Lz_qV=MIbkMInyZ8^A4ra-N1cdM7g>Fo zk12AT_>v{Tm#I5OY{Y`zL*cV-H);^U)?HrtA)UMAJeZp=+HFXK{%tV~WnrS>%L%KD z@0{=3IJuz5fVfQ;V~|Sw{URq{_5N{|$I2jjY)Blrt-m&XK@G_2+FT#>6=E2*HQ_2T z6^9cs=YyM2>PO~g{Lck|j zG~lGAlL~sV%VnLetC`t1H5$8kg%?iA>^=)|KiA7=$*M^!j{n%kQew9ZwdCTCXJ6LK zI_{?FdNBW9E?pYDwct$pJUcZ&Z@i-I{!CZnzizw^w(wA<>)ZibKM!TP4kf;0?7>V| zs&#I>9v|3r<>AJDq0xhxu715&Z})*r*TEDf<8>_h`!ii>DP3c{9`D(7?KgUj5uIF$ zcfY3VH0_`PhxuUh(~yt~&H%6$eWFO{U57v6;rmKZ;xbf;*>iwFo<_hW;s}5rSXw&sH zD8TJi-`WmaIZqkaomLU3^$hTe9p4qvue-om?^6kDJC6#jFk@GwgBCEIH)xIn1hAjy z4RI5I5xNtC3}(Z7+yL7YF0l!$;fD(E32gxdfPNb_++MRNxVnM6k9x%Ll* z(|ML9AHlOCUgEe5U>gjgvaZiF|IYYZw3C#GQIje#HF_um0baYpNfChaD*{^hmw>DA zu-HVUKty~>=XKo`t~lPDaA!LakdY)qenAAN46w->*ApSQ*&-?&)~q$;kQ6S6rW05g z%>?5l30jb4uLzWucl2N**A#eNqrT7VU=h+|2lkaCiZy8$g~REHQ_ecVsE0tC5Fdt! z6fwKLZ|VB;H1A-sBAQRo5H|=YnF!%YI0zvcSF2Isg!4M!tWgFbyjgFJ(;z-6FW>Ko z)&*y6LE+dT8H2%yDioB|jpxg;e4^A|g-GK@#+y)k;h6=TtSL=lJ-!x@Wm~~)Y_@^~ z9Tvt}wR6apn^bVN9JP5_73i+W;nd0*;MBldWxcNGzO3Ip>-&8x3L`ujHEfyhBj#8W zrpOf3pfS$i`*jDb<`nw3ZBo2Ik`Z1T0urO}0368(L9bJYIBvC?>;?jGp9kuf5eQQR zxMMb?`A|^bCf#Z(3UaMyUfT*v$yyw0aqoQr7b^Wdnn${6j<@bYA_$2DLZw6%gdz$O zf^G$q#%oza+YARyRfzUmRWR6VHJ!LB`DHy-*MAXQa7oDo z$?{P+f{6tLlFJA(6ouRg4*Uq`YjExhZzBWHom_<#>ZM1@{Q(QHLoGq7g8)lGJ{Cfz zluSX|lf|7>@xVmN3?^7K3JPKs%x%~8lSFTc$~rPNCGa$dMGwM3yIvkS^#Z?Nbu|mm z)$u1BE=*b=1L5FRSbze@SQRcSKUf3bv%D}@(2Kx3Iwsmi|7!kwG zI=}{nB+y}zR)Ga}(Fy<;8~_v^n=9*&P~?&x8q^9)BhST!8NgqIrks#F1PG}}38>{0 z7KpOKs4(J#(E;HN3P%uphA(6v?TfJ5l)VYzVLhuf4GI^GKbs8bu<>xDW&{Tk3f({v z9Tq8z^%aq^P}|QSeb$)AiV>({!VAu#=g}WuK{7t97g=ykg|rP%)@Q42Dh@u$?zKG* zIKebf0w}n}j0i^XHehIJj!$3kj!zA}-lyV;`J&`~&V*N$SyD|Z3TyucxT(y=G?M>$ z1t6?8J`nK&sf>W_St=vETV<}SWE~)4z$r7+UMJYg3syG;HyLl|zXC@kzY2Fl9N#VB~Ham#D6`qfDVcs;A4GrrQhk|C(>Q-&;hB}yvf++!W%v}M9yYR^nhrAIu zsRGoC*XZKqcI`o7C_2UhB@zkU8Wj>89|zo87y?b;)F|LuyCpe_>7Y6j4z&ffBxGS7 zc2Lu%dOb{E0Y!2QHGkC;W4CNT=sY!T0i+<71TIBF6$SI)mci!djtluYCRWQ123x?#xY9XzDPnV+Ut|J&S0K6&k}W5!-Z2RllhzX`Ol##){KY z0?Q!uFuud1+qZ8*aH!KE3^uTdwzA5-gkdv?LKxRu;#U1J5i?$)Gb0g{=tu%?Fk3c= z^zE>V1P%KPrbx4|tS4Yzm6j64;|4~nmEo8@>Q0%om)rHekLwKKpjH56byE{%uLEs> zqiT(~s*xs2P#s^E$Yv(XKBy7SD>V@mbQ8v@-`KlT{{Z1vf;hl=Y*}%pA zp;lbk(yJ3gWYYMJtNT_b#OljDKh~x*zII|h7V*rty*}IJ+SV6nh zw6Odu0(Lhe+$75mj>2z6Z=FG{iee27D?fDfbtvW3O{Ja2EyfSVCWO^6=>^BgYM%(C zzsQ`#6(cs}_Bj^vf*rDAQ8z<&DWU(;H3?t-{R@W!^K`68`I8mi0?Vtcam7N25RM|B zdtdq>E79H6=?g(uRJG8HMmw3pg-kBkDrsx87E`IimhNN@l``lufGf&ZE#OVhS;Cgz z;AaId2)C4}-9oc*iGsUAOt0|1P$8}p8-&5t_Q53uOk-ts`whMpRS!jfF636~27;F? z6KnbU6OGp>Pu!W>aiij{D)TNv+!vW?%>6AD)E$Oe)3;)0;jNM*w!U|RZr66?$w;x6 z*L`Tm!d&+~o|x0>eXzbce7gytP3t`ta9vAZ6b{;gGILhI73M+)lh>7-H-ei`$c{UN zXdUCa#N8Ku{~Egau!VP+s6|a9q&RdQl6AM>u*3Nk6@COdIevRV1>p3r8_uH?R3obA;W{XYfGzGd;+EvMz_2sYLz>jqv})3 zSg&J}El}=nseD@vW7V>~&Ra5krFD7_&*8*N`uE1Y+_7Q`6elp&I{2Zldt-;T z?vdB-wq5ovVe@Mk=ADs1uPKAO_2OhD-EkxE_33`DX%TzfeN0h}p*V1qc5ZNV*ql2y zsHl!}zF*-UPU`Ka7F*rL;V27SHT{}Tj(xqGr_4N!J8nBsc7-I}ySbI*W!sblHlt;& z$u@!ps6r}XxW(-Nx-=7oK2l@=46CX*Rei$1-B6-sRU$Qv+X)G>Zm37&SEzNgdH?!{@Bh+&K+=lL+HH9X*@v>P$EycbvN+@!pXb44;ZunNL1X( zhwk%We7C7T)tIuAxPzqe+eraBeS|=2;ub>?775o-t}fOZmT2i}3hwaSVpmhJ@wn3W zo5YN=w{-m?8$~O6;f-x>^oC@9ih^rC0k_DFkf3X|gfbekrJJ$cqeQ&kmi0j%V;NvA z*1ipbE!4f5v~Id*fLGXLY__9d%16U~0e4uipVBlmS;uJT!@haat|7ybv=jE*S{U3W z#cpe*5{j`&2Wtm}W+!F0gEm#*Ah%Vfv9A@;TU^54dkaIn4ZI@Ar0c9r1J-m7uv^pRa{#FFacaAW+qfnig(ZPsLT_%{FW^f&w<&4!?#vgS+jw zSzWfhW$HH^d{B5yY1@z$rgg1!0tH1tN32~19S{h1g9xK~X_%`&5=(NOewp=)P-!Lv z5sh9u(opVz_T2hQL`2+n8{Nj;G)KDPCGu8t<&-81a4lfl`nDlF#hv~EKcH>PsC z#>sfP5s2OJ>`Xi(i)ZZ;l}yN>+YSvfvYHKRVz6#{roLZMHY9KVV~ud)o*Z zE3_10x@S>SV8KVUZGPEEyDqk55;|mx*1qZkMNh!1;Rj> zq26{Xy|9s$x0I0$b_ZBXG4T~p6-?*bxl%Y?*;HBhV#NB=WK(;eTVb+o-EAAS0Sqzp zsqP@VnxQ;;7=r#FI4s;`g~tdC)@BcxUcpl`XAdFJVE@_nD+8dIh6hv}>cx9E^sYMvk>WIC8*v+jVTRmR=F`XG^NE^-0~U8d zaHkr6&vp3qsyi_3TlyUOGHjZeeu#Zd`+IvXRkD&7Jy)_=K*-Qb>r78hZI~i#gz6GO z97zF)_)>B>1zXCdX=M?2*1y{s28=-480ew<+X;|v(`h&Yplzcayp}E>b+0=B+-|#R zr%sCKT2Wk~xMbIH5Ud0KZ$z}JE@t-0N+F2IFc~O7RmNZ>-EwmzGQP_laS^Q~SFT}C z1saT5--#;TmCjf+XSD09W|^5G1zQXuA?QmPhHA4V6&YrQm^*N00oS$<5J8l+>x}*u z;3WXFwuG*`MQ&G_?iMv} zKvlI&Pn-H0Oj~Pb9Te3an^%#WSDVE*MQ&apK6>4>DMa%jemhJp+y1u`JvdG2y~g$) zhdh4J?Q}#&LQBFOF?$asLTI*y0^tTv>^f**5q!=OV1yQ#H0l>%0Cvqz6HTCEw@NM8 z9V$k!s3BVI*dhtE+J)>9tCwj!N3>*4EdLn$u`G+jN(CE=O>&)z^QEbVXf@7f~H_tuu(C$4x%Aq>k5Q9vk*ZOQm@*aAPC_sT2_}K;#0N?u+X9> z@?6`1;Vq;%fk~k?)ed3qYDfFqp!p;aMkBNZJxSS`^#Z{w&Bm)gfWf6cOHc&THpYus zpgM5pAb06I$SH#;&LcFgxLcks4Jahh>;xDNj*ra9RY#?Ra4_!XYtLN;5)g;zu*FH; z9hQ!(G-@@48HC+{9Fi^Cmd6@Z>#Hj*k{wbIei>Gc0KWoHR_-34!n~!uTgE(XLj+kZ zH@B%^^0&TO0Fk#>+jc;3GQ!0|H^8uWmb(4r+KIV}GMT(vde~5@p?4Ugc7sZ)1xL#r zQBbqF=C+Conh@?($J<>`WmruxKoXsj z^s6F>Nj?UbWW;5{yAOfxO&_dAS7v_~xX+$&iGe^x6WMyDf%luX?3(DZvp-wE%mi$qd#NZW$lCw7W7;5%v}w ziYvFHCQxa}92+ZF+XTYI%<%pc1wrN5Ig`a1;1A!GOm@*UgGOd#(4Wvgj>7Ci@O#l)4TIgX6 z>WWu;EhbjB)#}W+7v^SV(A~kHCYA_IfN)kCyA5#Jrd}0V4w^%o%qyrDJUi=}K-i6; zaofN+nTRvpaq)&ld)m>k7p68=xN87#O%t;UIAKttGqZsB|Jn`QpdN$Z(kmcfPPrn^ zBl$aO$o)%7L4Z+695D_nKx+o^J;~a|{T3C`f?9eLwpY1})n76I0G+O~KoH-6qhB^8 zy~YIK1}wQCpFjj4psGEIB!@R=w}$lx;SR#yP`pwvfa?LsglgpAzUoy7rZr%eaO-@< zGTL=gLjtsmx#}~H7>SHeDPT<Hbp+QMs_v)uyMJ_F-n2V!U!o-F?1hy=;1`OD~LD*ddM0CXqe| z?FK?U5w%=92geO366nL&+fzuw9|2_QN;sP@vi3vp0Pln8npMoK&uV4 zCk6LimGxXzvkMI6-4Jnw;)q&m-G7R6G9sUGRttI$vSt^3tD-V60L-1Zx}CSQ{(*mTlS7;%D?&RC!d`r@Yes?t#c6DyDC?E;MLxG8x1o( z=+$$p-UnJUbM$B#-mVZd83=z5T86h{2TQ_$>=hX)Uq8b&=UcL@AxIe{KkH46`(}5M z>oo|gR-%tFT4w<0eRrBWXXRxBEZ%TquCPU98fcoyp(IUoDp_7}2YiTtr(P+YyoW}zs=hE31m%m`{|E0@)|3^iUc6kD z7S}$}ngT+<2$r%!?+nr8W0m)NXZ&rOjU(7~bDCmfBY9~O480b)aMZ%hu8>&2Uh1Vw z@weFh-7s@Ivzlo}FW!r;3FereVc$s!De&4}y#`&gH4tMvFX@P=36ZsW)jF@sO`2+~ zX*!8D;k{ns1Ye>$OfWgR3iUaK#+Cw;327aPmy7WJ=&ZDgRSYF_)nL+94`+K07CyH~ z1{3w-QoY?#^OEQiEj9S;vj>_`L$9RPL|F)uK<{)RAp#JuH93V|)E7A;nmVTFO^`u> zRTl~qkp$kbu1g_C#~;73^}*X^ctv&7TxOcTOw*mEQoe)|pbEX1&0zWPL1T%U@Nla7 z)7h`o^{7VEhHNHeg@xHN^wz7UH>Yfx0#BZm5OJlpGAd;}$}Os0r?p+->Wz4*^p811 zrW;^n(>0~tSDq5Vct86R`it5??glLY)~y^)dUn%V5!t_Uz$INR@Tga*lUhh^g}0+t3Te$a|i3@sL9&Czqg8M`V$~D}PPS$fvsO z!dxPEYH-!NbVR8Y-dUoq@KqP8aW&1t6`0wEN#JnF4vDppP&NNcpIhY>Y1@uY-O{}a z9Sq5qgE{DVHx5SE!i5$@5gUl6iya7KO z1Me8!t`?2ryCgzMr&KmR|K>falg1$NG*ry{jk*)URC2gcdAeF~!ClFBr#)E*Z=pV6 zr_c^#6>?Du78Au}$C_`@L@^Q;>1mNu-k|5wFEtGZcoC{i%&igVxdnl1>J0-&qc^Y; zJ&)meQp%%-cRLL)Xc!^R%pq(DSCVGpAI8TiN~z_Qomn|?XuWKIE;UclkK+|Mj$xYK zY0qw=mdbG9fF+VBVz;h2Rs4|7nYFEkt|{s`t0v|Ro94Y$nIO;B7SuU(f%)T9SYi$^MPSl!S$2^?QJR>8 zPM`Hg?_lLM`#$7##O$l9ZoJ%U$jL?G4W6+zZBNbwr43#;xsqE@KhcQzXp{|_!^KRPZp=Wqg z3a^?#a8)j|$K0i>7$aSxX6+)apSlkHoiaJ9ULSQWX8k=O(WOpZ<@(T(P(*9zWj+pW zQ-sLdv?I9$6`V4(jKo!s-y>Dn!ZQ0Z(TG}Dlsl0oQ0}UT4B|pj*guZScZ0GOnq@)7 zBbAS^afWm#m1-6*IfRS?tFW{sneI$YA_4ZU?#qdf+@v!{AupcpU2O*c67=0N8eGe) z@79meclGabOMQ1ce6+s1y{+$FEjS>aK0)D4L(u&9R(PW+yhf=vk+r8|3z*SsZ9y$@ zlPw4ze0vnhk0SX|BtMGe_lo3(<(<1o2DsNQlTR7REzI?R-B6zYiY?w`u3s)@T`xCn zb|9%Dl0)B>xz7IG!dll1LP9pXQpuw&d@EZRS%7fJziIY1O$vk{gtn`<`4pwJg^?$! zR6WtM|0P+%bH8p7x|+@qXXp^DMgJzf<~juB&(tV*NzW?OuX$&$0{F}&4hX>iEaOyS zsMxU(iF~hgHaVzM6+^vNSGNK;R-;E5v>LM${}N2u{|s9I&A3ne~v?}mhW6ICN#F>UWz94v5SH5)cKpgOct|0`Lt{3(X4atdKMNK1i4rvQs4t=rxU8VZ znDnU44u`|&4-TQ6cJD?71gDX7sO{R|EEc$_7NpM?n#Q>@sC$E%s?g=VX&Z0eEkkRk z8pdfXD;KKX)4KJwraqdWB97A87N&|1S#cMO%MAKS3h%DcRj>D3$>SwaeT4|rr2dAW z%h5=M7r&~gtlY*?fwt=cSH+zyf$Botk`)i|8ZE9`BTsNE25RGPb-;BQKlfTqS#nIK12 znk%i_dt^(2Btunh>DnG5nGm&(^&H_)EIH{7wGC89AW4fzma>1T{VsZF46)M+W{siV znt^>(e|jn%cs~{pJi3vGnfvh3%)T=-+r{^zk$p!-Hp%p+m2K(0vsxSwETjb$#EfbxZ<(+~@soWF9}*KCiNhqQ|HkQeOk)$#2|xbHYb%WID*Pv_CSPN2c@0 zbRL<``&Ap^6AyNfN!CEjUliS+LlJ~>U?A?U*H zX!3B5!}=E!TcXdO8d~!D)5A&hq;=KoTu4Z#xl)j#dDoS6ibaN#=uvIv>_w? zNkGYI&u$R4dMjMfv}ZA-hjbggUT*Cwn!+<~@D!Op=@XqrxA7Dir@O&f^rhl=)$kr% zA=A-~i&ER+6#IV?s>;db6y_^Wfo~x{Uo|3P44zs z>3zf|+%C*M(0lP;D9(NgPKYFYDW^yp0bPW)6l8D$ZYDeBBa0jf3|h9VOFF22+7uG>k$jck>_F{YcMrmu~auKwh-E-`5R z)7XAwC7B3BnM^Q=lM=^dQevK^9Fvqn5EBv?VR~lGxvs(B)$)#^<1ps?hr_{>Tyd|} zwf`)e+P6=MywKND`6v*_Ko}Ccwg=al2?3p9NH)9%{{TfdzVt7rFIRz=o>jcy zsvfM)5GcHQ8iP7tSoNUsthU)P^w55nyB+rvFF+Sx!*GfgQVp-@HKorq9-*1t2dtsE zm&Q+`i5F3HaetdXEXHUjw}5UyY}*lK2pd4O;YTcTlgiPKp4{UhX zZKU8%Y89=W>pq|-+dBX2lL6alWnsX%=Fc0=Ydn)JSjUiEbIN|s?~3tsxu`BKtNUy| zoz9EpeR1>S;qhiZJ};`HaBf;~_^wzm)Pt}n+VA%x$rT@I(?-99wUL*QjA+z&dN#W1 zK;3d>@fW?RN6t0FrivRORiob-Hipx5MFB7wr-0LDlHq*CS2$~85uaux&R1|W?Aqu? zZs3)P0@r!1V0Q3AX73>)GNO4jBo)cp`{hmA;BGLXelg)S!Ul}RrT&jP-*)~b-+1iv z4TJNty!H*F{sBQ(-^1Xx*I^i%c3iA|c1vy@zSOmBB-9MejXXglMHgK4I}=1GqZ39` z^zE_?gL`hC{j3nsIs8=#Kd0J%RYnWv1zTr;KZQYQycAFTb(A5o-m2eFhrvO|?N*(j zI6LB}=-Ac+{sZ<5l(fF9lSz{MG?Q#N(fBkBrfu&lstf#^wj(2eeU z`@a5|_LlYEAB}f03VA80N-lmQUL#rl2LEkFcgK~BjrE%u~ctE(o2wLk=#5VMzS^V_k5w{K3UfY^n9%}o&XNcd!G>i?VkKh8frx_?H`fSU!c0$0lEgYYX)e#HO(Hv*!{79@`MD*5G`dPP4#Q)sgh_d zUvjB-uJr`y-oXugZvh1uF#leJ?)N#E&i7_b=V;&ej_LW{5BZXsINAaL03VA80P1Sx zQUL$8S?g}wI2Qh00eJ_Gb}*X&k(H#m)pZPHnqD$(v)RNmS?3dr)V%QM3-q{3Y8mC zEnbL3=`=b$>9Ss_^KMznwEK)d8$K0j5{c2)L9~1Dlt;t8{k`bm>CR!A?(L7#QPQsn zoNx=OJ6qek(bjgfvp?9`>TVr$xAuQt3tp()irhx;L_W5YE<4yg+*_=Ad3aG|GT~OL zJf2uvJZDogws>{4BI!AwXNsrMi5v@KyUa}Zc6a~v!NI$q-@ZEj>Ft}{^A}fFZ{J_- z4=#QqO=wwPKS! z@oaavEkv}nw=1IE(ZPOnIOM}wS}|gj~)@g?`Yh{|KJ;gC{*JZ6^As$7jdv^QV8aV{#U=6TwbFP3OY0 zV`Jnv7b!a{r$eC)J1*@+X=&%Z*q;tSXDTdj>=TxD6di^v?$>W9Fe33wDQ8DvEhtG9 zvc!Kn3h%JKVssS79};6?ik!}N>|b@T5;}<bM6?wQ2k zvS62F+?b;<=QBBWP+9XWGygz7>Mx{7)s)K|+Mf6aBHN3x<*v=S&zZ1aCC`JK3$3h5 zREF*6_2YvF9JGdgpR|<2%e#AVr9z`|{oq~W91$#xPHrOQ;jwy&H2k3Dcmg&71=wV} z4`k3PEdk`?iYXRxrsxkIhZ}62j%_e$7-uQmqwN$~%+!dTzBoQRtLvy7W`YGHrIF_- zq%wzlz|+8`w3kroTFjZq1RXb6;1(;QSOzd%<~f}U%v32m$>tq)HIbQF!8u5yMXqj0 zWa2p#bcnMXrLz=}A{chz!!<+#5U`O}Q+0n<3LGbL@gLhf^)T7C9TvopU<9xagB_ zE(?*MDkem8)4f81WD4DZFpey_+EPHXGMB&wut=*{K8n2!=f;X@Wor&CO9QoJ?o?PP zYb?%jof@|O`O|-z!2bN{zZ?E&s^tvMZ7bf0O2U2q^qZk_CshdbBL3mlEOMe zjdlgb8EaJ*l>iEq$uXqT7=b2CmngEpWwOhh;&vX#bt1Df6c?XwLmm5)I0dkvBXY`} zoL#aD1dwwt>%dsfHXW`DM6oC49Rv|iPtI7ON$x~YTp3mBL^ut7e%=zNLW;PC3U*N) zS`ldx4qhxDC#+B^a|v|rL^!^zoCc9#{h6fpg!WI#P8VW~u)(P&=4i!Ove`p2Hhzp3 z2vy~|%fN-CHQkoye`|$N9YqvC-o|Miu;fbAw2x)(+8cHw?W7e>z^D}=3FKHEHk1V_ zZ{VSp|o1u*g&Oxj_p3P(V!3; zgAUX=*_+ub(wMB>5F>PR*Aa*PrF?&(G`bm|$%GFJ;G}?b?rc>L3NdIwXWbFX=Ll3e z8bh4PRUSc-5u~gZJzRFbZpzUI<{?hY}>ZF z!;Wp+wr#WGq+=T=z4ku$J8R#EdZ>qbnl=A9#*Z;HX2Thl_K7wI;HcJt>L)U4%Mvor z?f}vfaD2(=6jYcBXs$0^ls(!-y5L*NF^m;?%S7tuv^M}rW%#x%wvP4quDTg7nx_Tf z`#h1~vp@Z*gR89jT7b7SgtyyNc-GdqH>qoajvS?xgLK9EUm_O^YgttoG%9bnI~fg; z;317T$|l0%aD*WWf(3$uw}d@zm8xpyfv z?j#1}67r}(lNgu_2bUtdOR>^YjyxJ9NTnAB2fOMRl6?=GjVe^J9nrq&!I!ci0V0bm zg@umT>4}in)~h*jTsEOZ9f}rc%f(@n8ZuK^HX+!5X~LIeE{Y3_(9&vJjT%02_?@st!XWVk2x&EHmNUmG-e#f{QZuDyZNK?7(BAL-N9sW+$=v_lKv)obl#`8a(z-5Y2ml0^t=WC(QgIj|lc{21Y9?5@dib!NC(Vt~vm?JvreCCz2|F#>s z50i;3sWy9R^+J7NROwd3*&xqR@LC~Mu-rm&tX)EAgBXTg;A;^)2vQYw_wpYQykwLN~hSfh5X`o`D%Vyx~sZ?#^PYY1{C4sa|u6+@dws?E^e9fXrsi3Wmzwuq7 zkHmCkMi^c zrwy1hpB|m!0b9YRiwHY?W?Ue)5=Gej(R7d@O^vgHI^ycktg2 zYI67WnrtO5T1kGf8jE{xv%jY1R0b|Sk5vx&4F7?y1aR`=c%KWVjoRNl?Y=#gdlY%> zSHRxRpo)7KlAa9EeVe?8=3OH1kN@B@tI$UBFr!8A>mDZph|F%LEyWknnzqkedpEuQ zz-g4bXpeu@^NLF^nSFe^Is4dCbVeZK6IsmXW_T0qbe)kCd*b&v`rvNm&meXCZF`TW z$5@i6jP#Dj88Q#T^ZLfM1F18C^GWel<>UBd(1+g>unjM(baO+opk4INHNDjD#(Qyv zG1U3WPM z6vy|>`K6@oNfV&|RWy0G<9}GR25cSc z!}9&r(qTpuCbc#7JA z_)^9xHmokaEf!ZA)Aah~W7R<VfTjZpp`{{b1v7n?gQS6k&8UJ|J=j(5AHD-G*(j zB2~h`$T&i}o;A*(^h-`pG?989znbaoY*#x$++-687E6dJePU4OP$CcrBb*hs{97q_ z@%`}~<~ExldG&!wF~@PMs|N+=eCf*@(4xyU_o?-^W**_0-Ge>IwMUq7Jae8>n{?RC zazLF$3r0Jr&fC>HQ`&eFe~z$}->msh@+C|)g$>EQL4(~;RGp2*w5cz39WPAVWzkK4>ZLygULoZ|9A@~I*)^smNc_mO2LP-mt6yRG+JGKm%^0pF} z1>9dfK}wXURlBgFc*8zlJCEhTfzM-Y&2~t5xEBe3hZI%GfF$pOs+hp=U_;+|*hQ^e zx)2ePSkS9R*C$}4ROiDU<;q;QNdPNV?J^qaF~Eh+Jy%64L_-bIW-$><5KFwRox@V^ z^>DmF7CGa5QjdO+PoIvoZCQ|IR>{;jRDA^dvKQW}>+#XP(8{+-X5z;K2YJ1&aR}bQC|Zay#bGVH$42WMBcGCEx7Cp3dt9 zQF0_a6BA}@2K@k}mg7seI3(?r5Y@j|He&@BBG9Y`Ancp%WTBfwM4~n)HV^KKG5ZvL z3~jz4#4&L>|2Vkt6VRh7!ON1^yM{{0#&wT1yDjo^3i{!lIX1i4ce_BAC2i$Ak1e4z z#hk|I3nz*CdRTagT(~)g z^ztur5=WfDp*JNyTu>(CPl-%j%5@IDIwfhTdA7@e`fz&WYGQ=q_Q2^o?J@1tkugzh zfQksvYD1|4G${~)OoQA#V5SGn6!k7+puK#@RD}z&F2i)3N+%w!-Nuq5K}?w??^Vn~ z*=o=jFM0B$wePK*si#?#|CPY zs1E52fg8aKz0cAl6l}}bYol8P&9l)k$^QsgF9M=e5{&=u`>4r0 zA21m4>=N=YS;n|=?iyem4z}jJ({Du3CK*QvjZi!ML8<+nq`>mtFXKCh3+z zOvVM|6qiH6-N6ss^37W=u@>(yAhT0~sCoDSw)a1=5N?R+xx=D0WJO zCY?>v>7Rc&g)TB$V=pIj!9Wh3CL&LJ&+nxkr%s0_@DnfJzcEEsFEst@d<&J=2<*9%^*+Mfl!_p|Hndy`aQ$~#U z8PPJ;8O&i=5=zdKoVmfvT1LHC;+3yV&rxNZaXT8BpamNxNs>UQ5;RGMfdmS=un1FS z9Vw(mX!irm(85X~a37KgCb%R%A-B9jopYul30yKbpMyqv<2m+r%`+s#!`@Ask(n`}zA_-0}V>7hyXw3bzHz@D;99)4Xq8?u0`h%kcur$S2dar;=g zqH9VW((xS|XLLE$NjUFx0Ln}NA^>ptn3WUf8CCY4En@|cg5*7GmXg?872?-Y7!0mR zD@PghMyzB!LL5X2Q%odh)S5+;Df0&l0tH(Q&z5IVmL+3*3#4ncsN|}0eS_?7M&!G( zKYZMJ%4@)=Gh6LxO4h+Q(UU1}nPas3kX{{5^7BmjP1uCrHl>p_Z5>`?Bd~oi>!Qnj z818Q%y}I^tE7BM+Uxb>tO$H1|YyqbHhM?lgE$q+O`(>0%k#Sn~C_d3Q!`iD10Fc%@ z)pbRO?D$3c6{LM_03`FYAoH(mzQJJ7IU0C%p|KPAopDWUFSiY42TYfTx^~DQ+YdYF z5crBAR(c9+)lLFU$0P`K?0g~jE-n{WPoIwNJnyX05zR8N`K#@c!OHsp!REhw>3?2p zoBzJnF$tgB!%90VQ*IMnP_v~f|5rdcB>$lh#wC~g#g-=6Y%Z|BW_ew3@-2^Fdkg&# z@UiD9Q4gc7Sgi#`5B0k^)T4-EB!dXE+% zf;g1p*%RNMt^x;J3EgPSJ|&6hfMt*6ea9I z-VV~_{A3zWYro+und2giC}|WHT^@MBY-~q>mb3mH;V0O@LNX)#* z*-s#h$46=p7`t7z2f2*fL=xFVFd;9Z-NJ((C$Dl1s13N)0>ZqG?VS1dh)=nHArxF3 zEN{3b=6%?G8K`D>v(!jbkS;JUhZ6`OT^J60;%GIZ?0mDX*ygCiS0sApd5eJ_lwf@P zf3E+i?K&f3C(I206^KxTaYG%<_K;xwk^`=|p zNge%IHMYB#I}xVl5PIamQy2kc_R z+44@=q@as6@%~Xy08I`b%M>6@R*fORX7svjl_ggqtonH3u5^^2H;i2w4B2M&9Qb++ z(`NGr=d@kvJVnj?E2@s{oKP4=z+nXF%eeQ1JmC+o?-EdSVwDzo*>h_n(i{Y{BT^ta z;vXiMzF%_+b($}Eq;h_$f4$eF;JC{g;kSuDb{b6M(6%bQ!6;%5iz*kvb$eM`rp7_) zgFrsEghh-+Zp}I53aIU3A5DVV1>Ia`-aQ+!BL_`h{pPlo-fqLG%zkwnVrnNIY#jZ3 z0S~n84(s)i-?V4B2-bdukqvQqDymee%-Jp3=)=kbOKWWRJ>tycG;56kd+}5b?agOo z@KuvK#_d{|oBng?!-)IlFdJZL5!{q@Y(*pP*tyu9106(i`C7>*Oe+cq<}(*4hQ+^5 zS|PDCc?FH*iH1pIVoM;E)WD3y=?+2G2?_EuZ;SonUv-U%6OIH9+8Xu2Lz8CAi>}=F z?Zk`5t?zjq9`yUwoUqw+BUs5&!qbChj(q2A-etN{fyD?-5m2&0+)m3j6#g~kP{&Au zl1A@%!<%>4wx*_=g@i^VRX@?RFts@~<>#T-{68=txa>-&Fz|G_%N@0G-V_zrWbqFS z+L6<@ zHE;OA4Q{%y%1fi2M{w_85Am*)=(f`Y0$koQIbGoK{L!>Mkraxu?P+WUa}sBhV1{T< zo(DhY*E(25PKy<-WFE^;j<%NcgU4H7&OML8A8d~{E7k# z-&S3mUurj%zb*F))CCaWZMVH(N_zaC-CpXI(;?ek}yWtw7`qNW`<);%Y_BYeP$Ej`c`wnqFK(2DJJdN!}4ayx>D`$Y|GKU*1L~_=fq*Y!i+K zBni$zRip(V?^4I+Y~Bk_XT1a-JS&yM8e4JqS4A!}t_zKzM;d+SGjUtz&#HQwp_Tcg zqtX85_40&Cas-U}*LwS0E5@hsx6u5ouW^OG-EIX6eHPB=zFKZos@vQPI-e_nd>p5? zlseEl5~{4-oTx&``=~%Drzr4cKX*7VI877P-JwWyGx}F^Q*EhR128PXiW+wXF1JUz zno;Q;%+KIwTk0CXHPqQ!@Kln;7a3AjbGS2DKS|Ee_N$@3UxcvrULSLfo4SUUybH{{ z7hjvtvR83RX(?7%bGhX)yRRkG#VI(l*eu}edpu}7rtsx4HFLwx4qH4_-}&t}uKcQ+ zsblqvmfJ-^j}f;}6tKF|raXlZf9~~1b5^QZd6Qf^zDlgqFKFnlGr7b zFyDVZB7q};(bY=mspFCARjl@KE8}R&&pCeGFIi!ESIJsp_(_^;V)U=uZxg5Yx9%VO zQcSs@Vu>?WO84IWq^KnRA`TChDwFdPY;uY(qH}P-m3sVhR99E~i+z3hC@XvV-jOFe zn1!&b*_lQg*Y!6E$TY9>gEJdMq3kcaD#yD*cAKq7+0kj`I(_uxpXUqJ@;c|EL3O+Hle->)6^k#mv%2&vxp@y!9Vg3w-u2L z96G}xJ11A#fk6x4GDxg}c%}%RU|Hu__dBe4i|gSbVnutrGgYMJn$%~q3M^Lq*B@Zm zUn9{zLyCy0ni$O_L!=b+=)!9m0DWiS$3hqG#ZLN?Bm;ATDH^??=Cuf#2YMZ4Z2Y{2T{bdP&E{=&s%V+tIn$k8QmYa?d}f{xTtYro!S?{VwfB zd~nh`1#H*-@iqJ0(Y@1f}_>@A1ZO{N3hOpLsG>>sx`_f77Ywo zAa`U2Ouz)t(ie$1f(NawQSEdL+rX7FqVeQJ+Mge?tX(aU+UF@_9&yrSMxSdjg-YC` z%p2#DjCa6al*ISrgiVnM6BZsa8smVQCpHc=2`r51ax-lJIX`AS9;&Rd%;+S0v^HLD zEK88X!mG$Q<%@FxaCpC1=Uzr$?D#XsPglmGWtM<@Qb=^iX?x2@+&TC<`1mqqhFB!Y z)RnjY**EZH_er7mEqWXtEVSvF)w`7z1V=;yZQrylC`ckPO5bkQWCyN7jB;lrwXnh# zRwtyX$@g8y9ilCuORSpYZbZf$mWCJ5Kn5vGEM(CeBa=O?fgPrTxh2D0Ow&*|r(3sKY z^uEW70-vF+y5OlrSTc7G0rN@znM%~|zjkA6^H=VrC>rs3} z2A-f%qq7-aGhX2}n)l|Dv=NR;q!ukPyBz)9hYh`08G{n?l9Bf7=UqD?siBTnba_pt zSAV?PmH8l9z5ReOnyA#Bmv)6^%T^ zlI;GdqNOe)l<93KSk!t%R^G?evWHpjx#Tv3O>e@ff5D3O@1IE z@Ba7=dMk;#BSgxQY8)$3_cW2_q!ZLzet;)DY%uYN5Eew?G0t0P^{H`T3GgMm`Syu9 z5LJe@1yn8I_1?wTsNtkDrdHCeEv=7?0SrVg37bS($U);CPzpJ2FC>|QrZ1^|bSNGD z!40!{_?)k!dv);x=3tgMbWH4io2&lo_!vpu{$E?w!1xrk?o|_h#S~(L`#nqOLq^Ss z;GTxfrtz_$PqJ+P`vA%Dt`(xptWZ-wjpyVsbCkHbDLbeY)KR(^Yh)WWoK+wmd zoO>>~7ie=?a|Jq3U z9+s;tblq<`e|qH3rIIkWQ`V(DdTP(K^Wfr-Irb0kMV4!Ew;&akyKpY|@ncW!YeSwU zhxJ+I>TlB0`A(I9>{wAlsI*%!;W#lIuFtwJ$);qaOMP7lkz$XK!;}j}jykM?gd*lB zR(Hh6(L5x*cOAu&sMo*1;nmGkvLmZ()!W%buIUBv7E5osxjHS6w=t5X@yhc0P}0)f z{Z(arie zTNTJ89KjED(eAsV@SZ+<0TM(b*eV+ni1Vrw;G_*Gn}qk@%GAT7;X)#$vV;_gX6_BU zG#RBo{MC_T)NWGdJL{#EZ)E*ryMQC+&iBhhc@;#A1;iD*b0m@xQASD~=Dis7I=gM; zB$s#lKDs;IO8jh@jU1rYVS_9bH-xh(VG*4ld128ry@zKk`UkPuT>7}*OClgNaV z<+2Ima;$8*CWwd=W;13%bvDrrt_C(?I?#mJ@bxf(FJaOdfZFJ5727$a4@|Ah%Y|6{Ur_@MY7kEKfuq^l*e z`Qslmu9Y^6wRD%5%TE5UIrGj;w;(YsQ)5~7&r86f;JV-|_r+`9TG&{-+Kaxn#i8PX zYs{BaNq)^{F@Q#j?q})Yly}Uyj*Jh);2DjZE(Tt%XciVm9!LRg%7uj@!y^0h<*~F_ z3_7{SRhh4*3h%r6GE~8xwy8NTNmstP0NhX-Hd#CWFe63Oa?$5^`ElU#jH2E)SiG6F+IYVvjKFxq6r$R? zMuloVdzGt+@-ELl{A!%5KY?cFhr9R9qA_~Gtdn%`RlSIdznUe)=G+D ztJ&Tp1A8-v5$#yyw$G$Gnz=_ErL*ec7g&s9cpo1fN4vhM4?mJ&4O*o77LqJ)q;T}b zLR0N^&9$(hkq-MK=>UhHiLZwNwcH}Md_{i-crLC#xf9)7LdnX<1pcKV;Y(D7s zj?`=gb_Vgz7dLNW^Mvs=?oPod_`(BNUZ&p2x5dU}*vb{H!KWc9FM$|z+kGw=ME#yO zZV0tMz2JW3Zb|p~j(0OIzCgCpea*}NQ!A|7rZZx8iSOdQ0lCx!*k8dbCEzEq>f{Bp z%G!||`TlHNAY8XdXwJ5PPb{-gM`2Ie`Hbc-pEbmt0lw}`I)Is^l9Az?dW7!L?xqx5gbFK0@Gyj#aLRON0adlS)fHsYQG${aou|Oi(igfsoqL!)tcI-*I->4qs?*#Y?)n0s zJ#<{#y+{zgPwFom#BS&nef6vC>i=jw2oCWX?|Q7HJhYuIdG*g(?YyM(qDjkD93C8E za_885V#O)H&w?9FUe>;W$oL!@wQ?Pm$UC%LPfgO9x zBo)@hZiy=;lBqI^b&}bQPqC#!jo3*m-fr{y{{DoczZSN{?+0|~J>j4GFI z#9NP1ReckKW`}bMN>mKU!y%?tj`e-0uHOFse2yv~>^(U^fQl%RLlzM%N&`rRG-H+{ zFrl79nrSaP29CaGrHBZ|aO@j4x#);v(jo2h1y{WqmP+W9qN-_nJf(ymgx{RNGHWBK za`PAfdtP=#SPv_m>oa~{ZB+2Ix$l9*szZ>dPDCLqiW%DsNkL*hGPEvXuXbDL>MpYp zNGCV9e9%P>zTfdLkE{y^-Jz!b(Rt(e0K^PtE0470e^q& zm-QOHju|zXD>+5+$Dh}*DM3{b6*YYo=SCi3Ru3LC-9xT&eAY}gwULaCrfQdY|7l(^ zxh}BY;0Y^Mp7kxM& zUKYCgmjioL8bVNQAFB5%AR7G;DH0YhTdtkNO7W1GZ-qF0T0r6M}zY~-i)~<%I50+N6s{N`JinvcI@MP5{76r&Nk|8 zDC>{9yq?7U$cBst3%RZ=prJVk1u@FuV8f)uNXR&txXN8~;|7THYr5k`K(H2=A3sl7 ztWL9P<`^;H*IUeQbI@Y>gqek!}Fh?9oQ8o=Ze2Tr-S!Yhe z^6~WSoxxuwc_8-r1vw-v(jO%+$o+lRLZ9WDdI(DOLuGV1{0TzY#cFhmR%UK9fvw5lVy=nTMnO3W9A%t&`yt!=*|*C_1ms zo-5R1KdVU>3Ec00Jn*bb3H{3=xH4#WoDqqG<9i*SQ8fYO%7*-95lY#qFJFtsz~V`W z%8?GS4pPXdw!e>wrI6$PKpUqHADjMS5jOtIBIFnRl#rXtAO-l?l_i3id{qfH?3~zh zXKq&%aDdBoG5d9MG~0^JnAxiGN@<;s(LZG(b`x4(EJ7N*(P$m*AR1%12AXP$xJIb- z6frSYtb^2+28^v`jeBFu=&ZvJ{Lrv|BJ-Br05+E=!Zv#6a##>-sj$@$y~ms+Z5MZ_ z_M!zFv>!C`n@bx%#U$U8-I*ePNCGqd!qO+72Q?#Oa3Cf@ib6&$wfw0|h&eB%8w z6?M3*D6o5>LKN?HB-PvuxRed{@0^_VaR2z|b*ehaT9z|ZaBu7YV)gAH4Pe$^4Cswt zlT6L$eqU>#L-@-n*d;{Ib|}6xdrrlL1;fn@tb$lk+Tq{~^|N4-AD_9ZZA>yKtXl)? z1g)F^r`bks3350$qtB3%3ufezC2z2Fmt|pp#W4!^4NsNi{V(t~dLGdUd;OoAi8>ZK z+x=57aKH3KMgA`$aRP$Fdi&pKptSy##`rH9Y!6M<-oqHjKXBiCM<)}97ljxQBzyvSW1c$iPsdhI%-2W9&MWXd@=5E)zrXruX-LAyT#5PMXxX#sp2%@ zH*+&#%haP~z!-n^X3Lx!d-n18Cy$YonLM8F@J}A2L%)Q=K(S%{Tr7@;Z>}iH7s`sn zjtFbE~{l`Fj z#=ssLE=W6DTP%*dYF>}a7EOxTO2n#1_|M2=4s5osJjT(1@K+uKGIuNNs_kzcBk?1K z55V}@2FMsp1=wL`s4q6!VcsJ^w!PqkH+LfR&ot~Ap1ZLhE$xI7N79FqWL4O4@bl?6bkOB9fK!!;N8pHn+$msn~AcOzrpFqYZ$OH7>K!#{+ zq-4^;vwhw*DQr>YutX^aHK%ars~lxjNuNzur4xSzE95Z=G>(ZXR8B zq|%!a!Tr3n*?_{B_8N%|2m|(f#nuX3DC$$k;Cx}kOhgdYI#9)Pz)$qo7zG+e#-t|$ zsw%6C4l!;hJ04yJ$N1tGBWEyC!m$`IMX zl~&Zs?XAvz120~O+7_O(GpeNEhdNMC9xJfokt9Kfzf+Uo#-RKHlchqp&bTqAtt*q- z`T=8rUE|)_G4}ri*3Q&kWujFi)JN~_y4ocfi=sywcU^kT1;&t=9 zPg^QdE99f)OEB6|ygeW#09C8PbEP~QPskNQ^)4$w}{`Q6Pr6n&(G$NMye zC=KR|SHW4|eTTpLhqlHZ@UhdSP{{;vByo!6+g($K&^A|yW8{6gm>FxUJKo^fS0ms9 zD5yBpN)bAl*YcBEG;@+HX(BiJd&uLZ-Y9Q7DAs=>8IBZ+cyq^#FSJYC&EO7ij=+& zqm~j;wX;NkJrF-QJ^;2xbw|ba10XcYkg1d)v~GRu)W1u{mr@XukdW+MiPtZ4DFZM* zA8rX|l)!JP-UYNzWbxMX^XkRnhViJ?&k^>QQ?2|`CMR~n!kMV8M0M0pR4S=WDz#Od zHR-5_-b*CH8p_mZ7M-pGn=bmb8uK*O!prhhAvCXrYaawF-%joLn{n5Ro2s>yz0UZ( z2^Xab6)QvDy+vM}EmK1;(xZJIwqNJ37aDH5ZL7xgJ~6%je0-SQPXi!8p7&hbWPA>!FHb;vW4t-Iy?lLr z?M|9Lwj74dl$WpZ`fGWv-w4{QQXWP$L_*od9;pkZYL$wlJXFf5@4eo2OQTtYtyCg3 z-?P4b_j`Sw*Pj79QxzzZxZwLdKjL%AE_n@LDQ>$4>OL;NnA#a*JYG=eYTAL7_3D6( zlJ9=@A02oJ!9-{~Rs3vDdaL2oI-xd+CP51Cf-x63_U2D}M_+>A%hPds$@niOAApwM*aseC__Qn?n;J zwnd8e#T(3-bZcW`0;%Zs4fYo$Aw1m5(#Z`XnzGUPVL;!ZM0EokVn- z{QS`JoAa;>aIXqrcyeP*^7}Ls+kAfY^9s4un|@M08U^%`TfX4|-Bs7UTdxTpJ$D-A z`xAD@&Q6%Vc{oaaAl%%3K0EDK=S;3%9_d|X?qtlvc$L7MXZzx%$q(RuEq>PlJA>h- zGdsJg%Jy9((`2OVk&ed7jeync9-Hth3q?3 z<2?OZsedYem;xpKyqP+PBe8sIk1A}Im(E7Ga0uspUTt){h{ z*B$fCL6B#cioXE=R+b~Dk*6aR%O#zZShVz&EZ-Y}grfPn({AL?PXU8Ngi>*5F9}d> z8T6hYXGoOnV@gEXllr4-0f`p2dr6=rZlRs&5|}=BiHlOOGs%w}_s>yWr6-`sfgn-f z0;sM@dwo1M8!?L;>lXxT$X+Y%2h1*M9o#*ENBq%{y*`30Z5MEi;!8i?^emJQ5{4@X zPWmns-}T^bgpbdlTN&>#fA0WbU{K_2H{-8$Ir)s}QbNelnapm+DlCH*6O|L|ra-|z|DXKu%hDDr9k`cdg7JJmLoB)9qs045EkWCoiJ2@Xw?9Z!_0 zgo9zmB=B9#88h31?<(;EUieK2mbgA!5>XFAPF0MaR)zyE5Ua;_7Xu9unG$Q2)KGai zXM}^J(KlM6>=T$uvip58xRfe*Dz2RX=7>NYV1qWLResqd_)^iePL<)N2I!>B(5vY3 z(%|UU_IXs^d7D3JR}Z-@Uv%?h$oTAfxj5>3K3a6EhB;a;zuZEdvitf4*iQS)h0e~K z?foXb#O|Wory|Ga@JTyw%1rs?1I*F;q8miWZSP6D9`uo(8sJ@%;BCB6IDR|DVbS z@}lX_PX;_2rr2rCsbE?2XUwE;O8I-16_PnX4-FuOGj+pNiYw zwIr+Bc33d5q11H`SdNs0v@E^SU7YSI<-^eY%HbL3dguI}JYj>r+kt&iJi1k2g$r>BC87_1bcCL;XAjY>Rfaa-x zKgl-v2TDQvp+*ww{m@6eatX0uK}Okv_=E@G6QXP@&2$;bIQGA8k%w*TsssB10bK!$ z+UAgQ=(Nb&2EOhKZTVRV^d1_RKG&u$80nUQzneP!HN+U|h!Bm?{y%DF9{MuID>vh#P!^5}@%JFiGZ@IOR zGAOf0wvK1=Ii-Rhx?i49kQj{1h+3#vQG!`@0^&Oj22 zt$ss?S{wmE$xNpV6}}l|8p)JhODM&&L22#*2UCG1V4J0wY=2l*o?CQCKV^yw!KOqZP>>qO4Tk;s)wgL>&##U0ehbw#^Hld6k_ca`+| zEzGNPt0MY?g?XVFA&aWAd`fAyGYoypXqI{62?X-|pSf$5Gnfg!Oa5c?l;St9RXe}ok66R$6jvouF_yeK6rPShcMOtF;lt`}jb^`-`hq0MO&uYeN0|%j$g={} zEN8BROZWJJNb=`F-+=MqJw*uQWN=VTq0@2myg*Zi>?gf^;h(#=%b#XH-`|(+Qf-f> zH`G$8-iJO&B;jN;m}M`>f*;QoUoQ^YQ=YXS8Qa9ow-c+PxyrlWj`Nkvpu@`5GQdJhk_%qBBQk z@wf{f55d@Ur9CVo@m-b%CN{eA4ri>;v_u=ri+P&`SXfmlqsi zUmQn8DyFaHy7~O)(LTO9Rtwilm;I*9P8(JUs7!RqQ;lt|(MjUhF%YbL8a;*uv-S6!6~O(ODH zWoq*JOfJ5)zfWJlxTD|tQuYKkEpO=OO(apQ1QirC!P z>WRSQ$euE4nc0=K3G+bus)Ywr-6|S8>Vbuyh5L{z*#5Z}GhLkS72i=nFClgN6JD!Z z1`2`LqNiZ-Ylmy#S<;^^@Z0c}c?^@|{A=Y3wdn>PTGY>jKM)(^IEMO`pwa4yZ2VT% zdd!3r%NsRim1$PXD0V9`N0%wt%UGZ6bdNNjQBad_t2bGu)EFpJdy%IWE;%X#Ro}6z zN_2dSZ_ z305gnp&6I?(@yyY74&%zE7%Mj!HZep^sZ)vebnJaV0WFa%=qP}tJ!+P>#29Fz7O2_ zaH-(`d0Pd}tt;baNuq5Z|M{!=bmS^W{x0C=f*{qzy{EzU=JE;!{unr1Z@ zO)fg`b;dhtBa#sWOH|IaIWbzlR*09j9r2t*T``u^jJ`e|mFP%$G(E0-s+}LTBXO6v zuvV|Pr{g5v8Yd@@4A)lJ%x;yGhl_~AsA;5PXo2al&u`xfvehuY%DTgiIpj`P$}NVz zQ(Dz{!0MDW{brpZu-Lue^HMRLzu}ikavM`Trts+EKUnvKJ%X6FI&rIZ{kFq3E%E*H zNmA@(oc__&3M>X}i9V@AgeUta%~)aE%7*?aPzO3<#qj_ZLpuB3pgHD1)m~f93YlKu zn5OK&n&Bzo*$$NewYXqlX2Z3by&=<_U{gRZ^web8YK>t1?SFE7RSoGRB@6U%Eq`Zk$o-00wu zoOlCRF9U9d3#|v0npz?|AIKulZ>3fl3|zeJc#p^`h}cVCyMv#Z*Nf0S6St%LP$mYs zFxX;xaSvRBPphH897PkQu^Qk_tEzrwkBm2iyYIS&qtW32)&H!J3)o|Bq&=8*%{s?W zvU{!^$*X+>Zbf+^X-Qv`sbs@u{i8ioVnf`-sAI`Ak)au8dPpqO@dGXnydJiaJCP-p z=W3$vHE++P%Y7+IN??GSYY_NC35-)VgR9H~=k@)ld}lDJqRNWTz#=tsg?7%I@y@~$ zV~Sg^y;*$SIo#%P%4Qx1OFto&*$HjabNj*P-0SPg=YcbZoK-V?4FY=kx;zma ziXrH{^ekrqZ3pyorxl$qU_R?qqg8gT3~oy=armR+9bLeI$P7dBf{s9!PpEdelp`i`90F z>Vs&Ct??;N42VR^@8z`h20~WJskm%;;k*rf$NiC|ftTo?64-=O9N%eY{TmqFUCzJ7 zL=6P|8f{bcKv%mlNvCoPeeCv{doRP0Q0NvsFz-pRx33~+1 zSE$v4*A`Z23T8{^n{1NaH-~U$n`RwejXw-^?_$~?!Mqi9Y}%0w-{J|Ry+pIF3)Jik z44u@s2YqJ*%B%NihDj2VXb*DrQh^~hcCvQ?%kJIF=4j}Hk&+@40pf&eQJxI$^dg{R zXZoUoMf8a}rDdSbl~e}yyHjeD1Ugj$1r35uR=1=XOzYSeSDao(q&IHpu7thb8z1yH z{lVF3nCgC5GMaUb0>t!Eea#|iUfiT$B?A9vf>ll769 za?MDMXStjrpT>#@afA1*1vZ|cAj-CKa+;I6z8agbPph*lQ6Z=P(HDkNOtdt=b*+;k zRGBVo6J7Xf0kv}Q&oUJAidDODzRKjpZk*uYwX*ATa(`Ak_t6cpBFdoaav=g6rwKNx z=4j9UYZJu!yY2kTN;v!kIl(UCVssAys!s&XEBKwm+bRTTi539SiwIbyLz5vVU(J zW)9|EX*Or7cFc==FuIMn1hTJ28CeId3vr)$R2m}i%2*mB%;p>^VVj*`VvBw03_=ig z?af0JeBC7Z`RC)O11~?I&oNX)88`)lAz7$Pp3?4{)m91ddfLOtFP#xpy1BuJHzt3L zUc>~p<({>D=qv|{w(JGR1c)20c>0!Bvnhch!BJM9JoF{0?AA7${rz@>Z3?t$Xo!{q z8hf#A_#_e^L(SVj+zS`x4KPRo8OL6HNeiyZaUQr8F49hJs3e06ui=nQe)ZmDAZ(xL zjcq4xQ5P)=Tge(09;+7A4zQ-Zn-{#8bm zDGj_GP6JlJ48HnZsEiJlQ%zXhgEkQK50ZqaW;V0)0E{2udu$Gojw#wLzu>_LhP@T5 zuJsMyaS#pmHM=2Xp@T#exU*W#MUVcFU85enl&yDjPrjf|WOzouvGd&b(U?x{=p~vE z{~rK_KzhHO5nw zz9)QtCH{VPV$htzUp?v84oxzgg>Ll;E_$A0%@$4Fx4j%q-L@OI-o~#bFU5OtHwEKE z@+8gORVr15rvkPPN^+(`N(+!m91ivd(JHZ1a9o_lLCV3xwYfmJn^KShj%6D2V3U5* zy6g5&bK09bQ!hrb__d^1yjLkz3g=C3&P_%twJz3tY;Bym{s;E;WQp5$ma8ot3Cc)< z_RUqle|2~JSGRXXPP%*#Rs2@0oTe^yQ!N)ox?XH$L!ymK3zQQiXxv9yfik9muFPzw zEl#Dcx;0Bk8m%US#IUtl(uS=RYD$L|8f`5TULf{rnIpANzqKTPER&|>H&Wzo$&6Ll z9LAc)X)0D#gRI-t^jWv}o>xE$;_9e$d*tr^->?@WeVl3U;2*|4?;=1ZN3m3tS!S^H zdQpT$`9|U;;t4PqngJF;kaZ@n3YL-F;&OoKu-TRl$;71=4^0%cd(q(p^AAB!WLoVX z;$@ikH`EbOkjFbx{8ZA+omYPCvH@^@tet4L8PH&m>ml!ac4uhNuC zNh@ca>KNZySE`jtX>ZaUr=vIePsXL5lz2R}O5STz!(`@8vBHSc9{%tdTdp@-X5NTm zSH^8RH7H1DAVM)-ESuGo7=_drOS1&6!H$rq_ z8KvkBz+9|t&)0z_+&06?{j|DQH>czi)rXlICa2~^dEhUOT}YOSbg_*Bj3gXL63wn& zm6ix-Wl-s2u=VH-#|E)P$w$*hM;sxWKyoqSNfUqT<6SS~bBBA7ah^GuT2y&rD%p}Q zc^t?3fJ`t7(q|WGwsW;&S%lhPYoiI=$f{0MNz^7>?gFxM3a`ysp(j>L2hl)n@)3QN zY&&1?Dx{POGa*1O69NED7wU zzJF4^o~Fq{D5_=l`b;;0)e3tv-vq8*pEreA`!#p}7zA?zf z!p$RbW`uoV;_Uejd0H!?g55?s zEFx{{8Llb3m(0;`r{o&`1Qb8~lSrMs6uNx1+9=B6<&6!DWo*m@wx%wq7;Mq|`+sJR z{|o+T-pXL74vQ>t{0Hh1zOC7Yy1%7#8|yKP>#t%KbXuCZ29FRVC$4{Fk^^9p&GdZK z)i686)WOuc@YCdxj6KXxy$AJh2%ijF5si{vGhfFggNu$Ri7{5Kix^{U9g_FeL^mxZ z1T20jJ@R9nN9Yo;nYtXSH0}x6;IwFb6uO(jbvM`<#*~_*rpf%7?+ECoELkp`e}W;i zh{j;frU9@7y0tKeLn}3aSWc&%e>t5Ly!J+sT8D{X=6rR~&vYYPw6Z1~60o)JxTN5q zR{_(e`rkL7SQzNyvGe}GJ`wtDXGtTGX@?*8iIft{Iu|nYL~r35vp7m=bxh-#UzW*? zv6Y&7eCx4%m%#6l`}vm75*ck-)r=88otJSae-3htL;2>%;|1c^QdGrz1HEBkz$4S5 zZ$2$tIz+YQh=$?ej%)sx^C`=eNLspO$n;!IYYeyjhe<*Y)g18N=6pf7l_qX{K{aIV zL8w|dBk~8#Vhi&Vh@5X-$Kn^N!f50D8JPgiP;%tCr zm0=mg%ce*H^|p8qqXn242^{C7PLn*B`H=}DiysG-JCYr-+yKjJ0OnPv(;@Yu_Fh#t zNRSD5Rs&=+&+&FZ$d&_OC79A#_PG3{#Z-NP>CRT5!w0hwfXTKHtTsuohdAPz8rMpI zYp}DBDKo_SgEW&V%kbM6<~(x*DB7Lzn|{Rpn))mj|f&vY)bR37n6g=amE=RchO z5y(`tUW)_S#~QXU<&i;C8Ap$>GFVG-SO@x;#Z#v{b30~D=m^NGIkJgcNP*x?BIK?sda!a3iM-xd(G%FI#I~Y52 zXwa=Adu{+I%W{LYomr&$5yVs=qMIf4XD((HSUm9Lxtfl^rUJH6ILGz;qocb+!VUIE zJAK*>XOV*`!)2Z0p~nVbsvOJ<-Lmd6`sR2Rml{ex~NS>kZsG+W2{ zogT$9N0BR=s9<`50)Ip`HL4YXD!q3c|8>I}f}7l7A5Sfuv8zuXOoQ}ircXr()922U z`i}jh@g$yCU5^^s(wBW)@z_`6SCX#LKIWE|c)W=!G#7&k&BYB8Q^5;x0F+bPU7+@W zx(@klKAAS%5uHJin3GFtsJQwGexnUi0p4N{>9@by1!4}vi?j2# z_^Y$%pYb976prtwO~e`7pR=qw{b$oR9`LkC)W(p}WkH~3yOm>f+0ck9C=1tuokn=A zh+K=y#vY88?@Ur;%2w5~O!T0pkG|hl>B{wBGo&ZQd@bzy)W#^4!2e=?fH-bCC;GlS z<8>ENDZ1l2+> z^Z9NXkK`*g(Dum=jS^~CrE*bjMasnt^oDe4*wdh880JuWf!Shj6uHHFOxbE(wKc^P zsUXM&)tOWixkSl{C{K5q6je1qH4B~fpitFbpehoy@^WHFNRvaVzJL_7Qu~CdD{m9g z(o#fA-$AsrWa^i4nEG0P>h9`V>FSy)ikLAYdf;R>>r7MF z01tTYofxKC;e26{-N__WVHT>5TxW@@VVT0}rn4lhbePvL>%8~~t~22>!xorBn0J-J zc~r&N2{)CLQZZkdUOBSs>tI(EP3*=aO_TKYEv*wJUT@9MY&lJ_Mrg-Lw{MdUQ8Ep2 zv>m^al}@95INkcZV~2X#()7|;(_qEfU+B8k84gWKK06NF>BFw|Id#L6 z-4o1Edm8cS@g36fd>#1Ad%)mi?jpyY$0X3YrT8W~K)=jKcGB++y4fE7dM@Tvh7a)c z9gEkJPvX7EDfp`t91k0wsN5*>StmRLtyRUTKDILEoUQQzwdk0Cx{no(Ts)~iB|*KZ z9>rRhLHqkX^pYv&+9DFSBNs z$}($qsl4);AhUL9M@exvV*rySzT&ws$gSgt+z7mIdn%QfOv!8qM79@5Y=;CEKBTEi z2UJyAI-sg9Cmh@<2hSXUchee}u}o(A*mgW0mhK~LYr+P4=*^(Xy^>!iXqyNKb2OqZ znp2ryx6rgaP-dt#WD3_*EnySdc#fTz*nwG2R>*m zl5YznaGB2!21++eVD`xrc3iSze4Rp-HsI; zUAUGmldu8dt36d_tfo-m9Q5NRX@xh)7g1~|Sw+!$P*HRT?t!_0HPN|mFkdtTP&cA8 z#k!FZNEz%Oa)HZZsTCbpBGs4I2MW?AQo)(%rY)kLxCB1|Ns-^=x4jsBVwX~Q$2wV^ zm5YA+BkA=>e|GODjb*IX8)6rPt>sFP8tjzRO?FDM-V{8>#QPMJqUOl$$-#uRtNp2U z)kZMGlW4wwPGO;{6{YJQv@+XZh6boROCQZ#ayc7LX7BZNIDEIxBU8)dXsmmR0acSMEyBEOpNau>{Mw$@_d50oc z@pI>&xS9q>3U`K2{ZtXgYnXIFtO_)-m45qqeb?`__fS7!)gu7ZYb-9CX6KU4l-@SC zB}A#hLAskJSJ`$rWr^;Qi8Dlvb4c#zdxmM1W7=!owL5zYX+#Oi89tP8BU}(-7a?>( zmk@(O3MOAeO0|kW23zYMcD=?~&5D#PuE*p=t&)4w_jaP%>^ov*f!GsVp@iW+o#do` zglq+5W#-tlxm_Lwi5v+v<+b-B01*rh8 zF5%C9en(h(BGV=g^t!|_U1K)!fKGV&g106!&nGqoF8cYN65X;KgB{V}ivS_h*4;UN z@%={oYPP)aU(w{BU=AKE>HCI`QQ5y5civUCTx#U;a(HpMD0j>oZ|w8mCI>G)S)AS5 zjtzjx?0FBPDegZOX8w}?hzMA67j0Ml{-=vBF5K+ofPpqL?J;$yu4((cH`yjelgl}_ zHdAyA=}nlo49yNwH3I1%fRwvH6404IxTJCk+C8ixxLF)BM11*f`~6PqH1w4U12eLw zGBp%{U$Oc7m-WevB(6E3ibd_Q|KLS-`Fy>^b9|F3pQvR`bo9_e-@Hzs~^O6uQT^BK%K*e`=#q zR;G4%Tb;f1euOWL9w+Z$MOmAwOYTTBSTLiPOyrD>VC!rdt3{dE3R^3lX_VzN&^j#F zr*%Z?E&b+0W9HHlIk|;aDbI6VjMBm?x~xBBr)75qOTmB%8+DpmQ>tVUY#qvG zE<*@Q3lM(Ny@%AP#@;%ZS{%%7v)8;iP^R2i=CJUJz;$3qZgdlql6}6q$xpaEnv)n2 zM6cH zE}Wx}Ac%_p0)Yn)raM%Cw3t&7UdW@TPnv^n|CHefD{j2O>q$aHh@wkP{G1@)GXG)t;}&#Lfl%q?bukW!Tc|mBOje%LTUCk zpey|tI(~qB1n(ty+6(ahF~bnk8w&dF@W2q;WG^?j^@(YaPY%lfV2{l4%++PYxy&^E z2{TRO0qIA_@j<)Lr;)oK@9qZqC8EYEMpI?@5KrE*v{!CkEZ_8JLkgQ0(c*6H=``0_0fDyhp2%GC0Uf3$BS(lMb=f2CRrihQZ%j#1# zItPpE^IWW{)F9fYLa1TakimR@Ji2CsPT0bDRwCsP^F zA-SfLyz>xjfGVo@U#{9m&dWrRs=jSSYJHH8i~gn)7Z&Zi?%?X~_OI)I{PmA7o!&nQ z2P_$0MY;N8>ytA}``OWld-W7Mf>v*=Z39#Z<^L8(rOf0Jod zWm$0xTd#)?VTcBZH^)_U*LX1TaBSwn^zF)i(v0+{P--fTT2W5mTKcoG6$ygAnzn(q zzkiwVT*CbNxnb(+Lv!Xy1BmYXu6aN6F{xy)*}nSHC(WQM&az_35l^My(ps@3W{-?jslXkwWw6IWy+sd(!UrPQ#sKUV#=~JOtWX7WwQg20X7f zI&}-{LkKL4XT-R~alzlp2B4jZU=kNny$un3$0$}=NqVXSLj(E!TKX)@vF#kB6 z^8ZTN$d4WH_z*w0;W0$9T9Wax)zV?CA}4GjMx`{<%c8bRK10h~uGId8R zJR60VjkWZb7Iku6}}Filq|Z zE%uOp`>RtR<}kcCJMW0UI*a}pAL38p_c6oUN z0~fmRx@Qa`?YdNKL2)(UKAA~3m?c$iFiTknV1f^yZ;1=7 zRB|dKFt=pp*bpcz3lODZmx5S`=G|eicvX!ElC{j#o6$scZ&jfWPbZV zX0H9gr2gmwd&K*Ik0;ynsH(7-VrzrR(Z3}66mC4})_ze@ZXPsF&nGQciau$JG>!E{ zs2c0XdD{Ur|LW>UukeY!jZgXA|M0At$?x|Id<0@D=F_ zvT!9y742hUe)YhT*gRBK9;rYpH`Vteb#0T9PY$)I>CuF~N}G@mb3@zI&n5jR6)z7` zEG~9P+AVY=`L3F`O>)z0k!HKy>-78N_vco_3TH`I7fh6Fv~qndqLO z+xZ@nq=vBygTHNdLyLWy)RY;axbh%m?@_E2&Ib>av@X8E|Iq8q{DF8+p1pLF35@Z) zLza=w10@2+=dm3Q9uJ&!Zu+0|+u=;S$C$PlzYdnrSTEBQVN?X|U1`{{qRH4pY;BO= zR?)O#7PZ zt&u!r{@$W5bobyWwA0M^Ae5Q&^l$RtsF6?qhW7YoyTd(2n7%pHjbtg~$~751iLK3S zJwmoWnK}U+$RC#k)nhcgSfF<8${_EV6a7&S#rObyL8AKs0cs4ul3Wri7SW#(!^ zFq#TsZKg4>XAWRQW+G}=nBN2y9~OJTAdJl5g!naN%r&$QAiV#_V3`7)W&9?QJ91o7 z5ItXa{qI4k^zn%!peq1HWz1D9eNJ5g;5GJux<604IjdG{fdg_aBWL!5Qy*&#M5Xz; z`S<@RHGUz@!C#--7iYb_8d&aVn+xtKhFvt^m9ZR@;mMQ5L)$v_#-Sa%?MQO*(5q>xivzUk$MB z;+az1-jEbuEx>n!Q>E90Z)E}BBdS@T8r5_#*#T(j19#^6bf_@L$PMZ7kX2>B4c@%f1M}3wI>Im)-tn(lVzH&^I%a z+PNy_Qc2FhQu?TxEqbJH23jcs+J$aftdv6vE$3~aR*s-{*F6IEL%_5MV0cVR4b0fk z)_t9H^||HfNn@4{4nOmeGACXBW0!PAjjFL5Qm|cp?mTHJ;ZloLS@f}(-g>_mmO#bR zAL$||F7!pPT(~2_>^DDm$Rth7Qn@DMF0qx-NeFp1{RguB=+yPqGquQp8w{@dr1`Z= zz94i7-B0RrFAS~2I76CZy2-G(#nQeIt;IWRp|A~i&NK9-thrdOH3E)n1RR&LhvRl`rp8>Yg%NA8p8|nv=Xt_@(Sk#8bz3~@)Zc;k;SE@=`Ym^(+n%1an z`U?(6UTwDF1(nBkY{d(Fc=dE1fNyo*i@A101-ZmoGMCQS#E@+>zuEWKN6d^fySGFh zy5lmrDRW`%G42%^31exYxxh(rc5#sF=4QeT81g9CvT~IKQP-c{`^l4YzP_LipG&-e zD;KUMgyCGM3x9swEf6oJ29_H;;0gf~I~;acl)Pg~btPTt9w@;3J*GZbi7WhX{VgZdsWF7Lzf>6VaPyD~*}w4Z%6}=xpdHF00lN z!r5irB$FZ@2LNR@y6lbaK7!H`R_^!{3wN{}dt{DhJO?!FV6c8sM9=-Rg^F%YJH&5B z+yJp~EqGv|7Y|2qROo7HNxZ+<=6`0CBrO(Su4@$;ys2eTJe8R%SXSgI^Ulx{ZwRxY z^@_rToo*Ke&eZWt-*M+curqhtY$x+{s<^>t6mz(&OX@W*ZZJ3eZg=HC6Z!qfOjX!Q zP2daD_0V)Rp@cV?$!o7_vt2}Mvt1O<&M)$>&34gZi4F1*4kJVNeE#2XN8#W$wje%y zcmHo{_*jfVA7cDsN8C>){u@={8HTN_1M;?H$$g2{h*?`#A;oR6yTEBrOy4g7W6n0K z@Jiu1y_amY$y}#H>M{@C3lkQY{@nBFgq%S4ML#zMVMK=`FRCFdcEl>bvayh+Z96_^ zFHF?E(NTjv@}A1dNd9yMzb&!Cvg@l49KQdTJ6(u!qJd)$0~(0=lWNsI1Tx5t25DKK z)^9E0`h|IqNg=pVmvkLhWVXr2);uzlDSU$20v4|1A`|UH_x4o-Os13<%QA=(TAM^y zG~j<9NL+?uq*kVp6-A8LYVc37;w(c4C;~Ewa4phFa{Q?#jNK2eaW4*VCl^h5Z`%(A0fgjLW( zX#+Y3{!xB9hlL)H(e-YjV_`RfryIENv0s9P=aGHnRwQloYPF`x00L-btZi>?lHc8Z zZ1zZVICRmdbfHg7Yo1F%t&!v0>#m}Y0$=+VJ;A*B<0&*Re>)qBshW7dQ_XH=V$vtxo?mV?w%E$d3Ob z9rCD;qXi;4hRMI($1YqDrx@jtOib5RVG+XChH>HSe~N5gTx3u;NxV!A-g|L@@7+Qu zr9J^M`$->~XrwkL@5t{C#SRPZCoH^|0o8p9Z0J1Oal#KouRKcG57KiIWkY&Zytj72 z|Mvz-Prn(II}VsbG=YdKa@9^~f~m+g6j}#wv04Ju1Jz+;D@#0lVpl23IV_(S$ycoH zq;OjXgyoEZK$aqmc-QJuG1bd0A^Z%9?KN_ zj{L}=!3VDx^g;iD<7)iLO?DO4N~M%p0)W4d{a?fjSL`Wx*rjgI#Yq@we)^McS}Z=) zi_WR-76M$xKH|7uvQPSa8Ck9{um-5vtSTB`Pu}24G7$!RwvYtY{Np;Z0FwhFz#t1b z0q&;Mm5R2-ci`mYuG{HhMUB%P&kLb-Wu)u7?~?knzF_Awtv+Zn`#ur*ZQ2gk=V!E{&n{>y~QpY z{Uuf-WLri#VR%nx z4<|t)GZ>b^qJCR{v~PmoqCW4%eY=|~?Sobn;TqUFh~#fcQ5-Hsv3*1_5sLRPv}beZ zKj64Wro}8rco2d%{EzY`DjMu&GWd!`Ar!IoD&d3G5q(sBTm&P)^;tI=u&}Ga^;hpK zxP{?Zp6outxv;djx;?XW*909r%7~!~cNu-+2;UkjGf1$girl?wd!Ps_@b75`dS$xJ zeyuEbhasE;nOr92<{w^KR!Ul}TKhnPkf#BiOdS{4#hMe)@3?Ki#J|ujURgM;si9V_ zzjQiY{Tp5&yxVDpPCu9M-{aD_8l?x=M(eKeFfW{cl1-9kM^;sfGFL3LUL{sbB~MMb z{*QEn{&m$(Q;P7om;DaDuwlCHcf)AhEbv{3?!cW{NfWtVu(gTY*Gi4dN;H;>Jmx!) z^nP+lzgIHZ@ol_ehjBi4gIi&#O1Pa~5|U#W`edpEpXb&uWn3#Zo=OUO>#dH_`N5yQ z9dQQQW2lF2Yp%U~rdF(p1R~hl4g4Vy(wS>bBREq8>7`FSQ0ciF?PV{&+y`SfmiZul z8K_3Fx_>}17*S|vPfm8NRu|;LR%&wHQj~EH89Vsjt4^nr>wHO$l*W6S1 zNR-1z9@t>(U}vE-bAAUVzQ%I-Yo?^n$5{mc4-ngV==y&O^^QCO--QCLb(ksTBhlD|BdGs5>s zq--=Z)<{HUGv9dx-7bC=#>9txsC;mrMKWL=$&RE|wIRz*tLal+?ZBybY7R$=mWjWimEIVP z`QqXZx}V)xDX^S6rpo_};M(yjnz3PPGJPt+B!{oXE&`Kf*o7wSg<&N;;n5nxB!aCC z6W%DrZ(5M|K`ZRQ%Ita@o>87+2rf??q5qmgL@>5uc}6`y}?;WExEq7&uA;^1~| zfO8MS0LNk@EjoR0Y>=zee|FqQr1;?WG&oF_X|6+S2}VhECc0@!(UG+qs&7Y=A1_=7 zJP_+eCiurpw={)W50beO*URF_8(X_{V%!Q+C61IiQrL2;Ziv-O_kfbxkTEXMdi8il zmMUIK#BpCSq??EyyanP8M2AhB5qD^xy406ZKkd6K^sk*;jy}v-rVJf)V&`v;UrDM) z`(VphIKOOmvf`4DV%?%bC?Uy_7STeOhp)8lYH)c4j5|4A}B> zEjuW;3A{{NVRwp?#Hv=2iL0Qsb(hf`V6t@|#SogBbc;iD+Z>>siFdJcv7}{+YQ-Qx zWbdo|t^<-UtLCu7Il)O`DV^jHRkf-r`KY?#j0*#9)93unUpQ$TG?A0wD4w27Jt(J* zxR`nhb7{3GbCh7~EwE9eaDMB60H1G?Dt{?IB|nu=l}a%iN4bis)YXRigA&(krd?mp z%};G;idrqzOAANp(k<`HlC$(R{qaimvQjQK(D83;zX!fo4JwbX{eyDdoTlJzjN8PR zhg1avUZa#;z^9D(h^rk={Md2irarQG94S&|Zf&;kl$qs*T2`~3{`gW&sjB7DHm5&) zuN+MOOdWx@FzIne5v|B6UD#7Gcqw4;(k2YfytT66@8ynL2PKr%1^OK6vKMrz7S8)K z3^F`CbH^rjx;gqX&~HB9e~x06XE1lpDQTNy3bM9ojU5*R?If^qXGt&LpDIIhtmxJ_ zb|GmM>0%~W%<8jpV2M0LN=4}*Qc7=Jz!wA&o51)#isk$rjm7AsGp6p=yhPTZbzYWP zBwuts#;>D9j;pVX&glPxk$5eh3pWLowW=ylm-4?FMyVd;$LKk8 zcmuWY_xuvgj&Y;Aj|6t4m_PGDpX3@rzaCLQj6drx{0u>i3wfO?OnylWwmzMD`tw*} zpAWtUD)ILMuvh-k-kS&40(}a9NL^|h%s7a!cf+2vXnQubERGm|zoOPxV7%!J zW3(Mxw}$V?jR}-xB~dB$bKN)KC|MN+-w3daT*sheRauomD=~ECdNC*(S=_)k3fKxH zmic53(MO97hsaD|1fDnOaOTq7na!L|VMM-b8eWjdO=7p=UJS>pZudsH=?r>Tt-jLh zU@hq0Ka+#1$`pUtT9F1&3(^2;<@ED;@L+mm;tXdNB@ens?kT7Npd?|H5v<8`C;ZXg zlfeTGDe`Pu7O@>-IcOPyzY;ENz~C5K zzdzlbMCL)fD^9|}7o~aK?A@f94yAfcx&%WjYe@ndfCz1J7ao>wk7t;LL82Dzh+%f! zyuJL=yzC^$ORZL9Xhk)BF1{6b{XV1a82GtwJUG;T2C*w+zuG!T8gdQK=Z@k;w=5^* zyznxH;7#-T`Yr{(RVoeXf?i3V(hgsMwYb80PnU<%0dAmvKj>Ccb7(5yW)ak8OCbA_ zV!0~G1gp@>n&0lGL5gK$vPf)a)1^9Tn=T7bA}4M5LeH7#o}t^b)7+7qx+ZG=0h*EP z7^2>k<=O`p-y3I)A_sy zlh_=wthfPg!DSRKbA)HB$-$~uWfdA|WzBZ+Ihr?4u$6Y7G|RLfxr!M>ZPm>vhWwdp zc;<#ZlN`|{Hvfrk!xogk8bA1hI(17P&zdS}ldGm6iEo#*T9HPy##m1j`~km_LSfV@ z(jypKsnPnwaUXeYW()+7pn0&XYIpjr-c@&Sb$5&J!0hz!BF8Yon}+yM*b??)UP;y~oTn)Z zT&a|09Uf?9L^d|3VvYs@A%R}D?!xwidhX2hJgU#Wy(6u;JM)0Z_l}dVP+JM2yi`Cu zxH4=epzRkgo!jLRTn;Y&yx;^7fsJ<@K}`dyGnqOzJ_L9M?IV;apd7iBD)%#QuK4C8 z2%5Wq-m{O;O9s$;oc^id<>jzESqAF}Vj2)b_m4;@z!A!nQ2M4}PI=85*I@-JyU|Y2 zHLc@h&j4KRit-ZQzUV(foCado!2y~K)}VcaG6j?_S>k=iReu9$|1+bW zFGK(!()q`iPJf{E2hG8kei~mzz_S`4o7K_90V3;R=ERvr6@84(=1&aC!g*hGlO}cx zfYVjFtX50%XdPO4A-!YtMviMx#e;hpp=9~o$)DGQ<*i;3qFUV*jCgMzxz3z_K)WbO|R8B!q{%EC)mf6yo7vo*Kr zJM#O?GV#2Uf~xsQe?jLvI?;tM3P#krCi&t{bqifU^Q}GnBWR|xd&@M;Vc}f1jxa5O z$sLY7HoC%j&^!XQ1SpA8UJ(5#T77%KZ6XbCnqBgl&YvAO<2>W$M4u{;;?40Zxg16N zP-$5hOrmUu^}5Vo!)B z(`|D)ID@!X9wq6Bg&{MD9`x*x0 z@}jG`sE6i6@!Tg~-*w?CpirbQoK$NMHj$&a^mfG7!P3&FW0$#4$`U-e8L;qEtSncg z8AwCfff=CD2L=OR5s*)2QdgK#F$jPxZV&*uy(EHb0uxIdm^|^jrUmICv@ktHnv9{W zxM9d7WJpIW0M4|@|AP0!>ns2JCe;qhOWzLrTz%$0U|RVR5yjo&`6zxKI>wASZl1_8 zIy^ke*8`VMzzt-;>2?!?tx4cW`Jdg9SL5VOUF0+%A=Ca$?9{E@RyAcl{z}t>|C-(- zp??|=m;(zGhvuKTWuG$jZ_#Wp@k>ejSSQv6fDw@Vw=7)lyRecV6Z(l$cE4p1SthIqima><2wraVJ>s}F zjwOjB2CF1b+4H8sli0~poGvRAI=g4T^$6p-FqW6Vc!Y0l`05M%ej0+Z+-Lxd@PNo=?B%?dEvys65Rz| zXXl{q8+2j?4tB#Fx}C$65$}aCrn9kO`1%v>a&4Pp6RU7`Jan5hy$5cXy=rw%Gxlnx z_Psecxi~WV=!DpTwPi0D`k12a*dZK+F3=SbmS8;K#Xuq(^<#U`yjYKR}uEa%fgZ>7iLe@ zT*cW_b&;^K9+^;xXA+mbolG4moHuzdiB$yS+LE+xrtmte)@mZ`3tQ<`r!9-O^?-cQ zkEgZKI^10IB;lT^E3y@G0V*iXw{m`YsFk5dN?s`Ya3PJ7R{$ zTE2+Vh;PnIU#J9rJ{4H&VlU5qaJlPCX<3Jv)sS;;+oXs2bY0RMKH&=Cao4j^@&iLv zuBh0`Y!x_|*;tW?Qgi%p52J3Ip5XwdbE&PQF`btKzuodCe!BsCPeb@> z$_~iKU@jGpFeehXu)F+63VT6S6Wda39fEWC1{hTf=L2_!)oN{nIZj6zPOY3WH{&@fLOsxlXGMU_?dlaueIwO90=Idt+Lu?E#Ki%#P1=PCDNLFZ<7( zQ_}Q(*SrUwJL_dP>)x>MHOx; zO!%ziz{Ih~WHQ4%O4b}}f?T}D{hbh9cma~1zL*gtH zM{%^fnsED16^$jyG)MEeLrI3>Xm5UMY4N9eL?PT$KseE;N?mRIK)R&%k8!u*ACp4F zf+pGRT?E2)=Q_sg(;q`pW@IV;DjX;^W;1@$^7Eg3q)%GxNNu4*^3*~%kN?4;E!Sw1 z9pE+^vIGXSvW^29P}d=!&8OVch^EI{41XBBaKti>r50l8*dud16F$9C zDGBZMDWb;TI=02{W81p8JffO>_DfJ>M(QJmi8wP@jV?iSKI?PgT;$KwfLX0eUqfiU zJuuZ^nlHvdi+O(4KC)Zk-3|fV@VK#Bs@D?w!o78r%Q2J} zHofufU73_?%!R*B#|Oz{{axZ`U8TCD6ZX6oO@Eh-nr7q zgzWI9YEkZa-z?pO1wXyD@r}t!j~X+VLcix>Sp;Pw3s`d;`|LKr$#17hNmeaaYtqTJ znt4Kxy+ib+FsjGUMD;W9h7G;lk6fAv(}u(pbfgjJO@OUM4L`$G{Oy2=Ci33hr`Bd> zkb01|r=ESPf3}y^r?Pt zS|%?LnI%oJ5D<__#|p0Tt7xep*~hd9EEROq88q9?L6h9*rgiUpBOTj!=jn=JRLa$| z%xYClpB58(BE^&JEbEIx z9KRC)-4oqu@@iK~MVTR>ls*n@&5Bo|4h7~$bIqXzS9>R)qS7g^0GL2$zv4amw5%AB z?=0zxP(6MlAbMPkCW*SK6lK5~v@)1WesnI02ppJ8blY?31*EGGJt#nxYyjQD)VaRu z4@l?FLFcyJY3Dh2EO;?b=r^RpWqq;P6T5@d#&IgaIGq&P5i43n=2d}KYKmX9F8aT) zs8kkecKT=1oL z-TX56ByUFyn@qXy0%qp7j5qc(*TRx_iq5{Nn8RgVGSeUH5L91#u!(j|jgs6kHPWXB zizNQtTN1M}gXVvwYacqOnBF8G%`PBl^rOl|el+xYUH3u359>Z2NQ(7;eWC5+%0XDQ0^(8B1O-_Gk zr9T+9G3AWs>#pySXa7Ou*?Y2w?|JJNf)z1QcUkmk&Nw?S8_3Y1p8fa#DK&oa`Jl+N z`Qt(1h3f#v>GzfxT12dEH##B{z9>t+h&o&!nPdN$BMVV=H>Ebp(IKyn8LWYK12hA2 zOKO(dt>aInl{aWg{Wa*oWhl^4&rm!ii`S@2QxvwcHs`kWCv!+X&fFpO{d|w-)&xt9 zg2{~V;<4rJ91+h4?49- z|C!R9C(8ziH=>C5=eDmWJqS4BodoY_w$z>Ad=o*H!MsADI7D!(n8tKAi8ywra7tc| zb8_P)=7?5>vCojH;nSHP@m1PF+!Pr(mgPLdQgc6#O_lVYC@p^CWa zqY+^-qM5HaBjqFh$OS2S)c0;sduV~1Po(;#gbuLP1Yp<|(X%WC&d z7vsabWN>xUA%i<|ef70Ny3JnmrZedD)=vMI6U!$1B_;k#?)u>m=pWWE07VfEQz+-~ zh{`n9QWJOM5)d%7tYKV6)_y9V5?Ly^Kh+JIF`iFlkWH+f9~~;PQn800`tHpD?bVO4 zT{ozbJl%{3;sqwp&3 z`SzgCJdq3sJ|2!Xbh-0T4aNa8zv+{n&U`qBq)Kk|Inj!$mh&o;BY{+T8hRwdI%HS^ z{u0QAhduu#(TTgB^Je}c6luc-?_oMr-HPONU3p7#)5-^!2W#-V`}{8H&Rp15QnFC% zJYo24tt6e_p@o0Cq{dynAtFh!b&!71f%uoIOqGfMDu{o3Zs=G*kfWG+^{%I?qVrEk zex|FsL#eXlrXq{)E*WqVcV$p(g}XAemeX6SXo4rOwE?}iRb(?ekhKMn`?GssA4OI> zjzhm8@LL8o7cDB7Mq>5(Vw)VHP&LU#zE)Su*O#{zdmvJrUiNW3gDhRJNV1lu}vV4;L zZJ8@ry(7EHC)S-JMN_R<7Ab78l^Ro`0~buVLqBN#z`RkAmw}qM{-1)oWm%HcV5Vc@ zm(P5kD0_q$E<~WnpcOJ7v?}$WVtajqitUkwY`ewU*Fx2uS_)5tp!n7k{M_!fydGSC zO_o=&QW44lwhm(9{~y3&=(W z>h0V%9$d$EK!=f`wCm0cc6>^Q%FxwEzG6~;#6le>3jdxV&wK;8+V~*({^J=0E^k5o zN(;Oyf!E_jZ6-;R;qDjq+Y(=Lvsupkt5DI;oOioUpr&d4wD9^I|bh_><>b| zRAU4<8{od=Go5DxI&~GbvJTwr!6lmcei3xvCsRez@@{Iprinv*hYab+v?)94^XNp! zh0w#ow%FmYpZm+tan}|2Bah|f=6@t={E3H8ynO8fYmzYd?G}BamLy-G*Ir|9W>oa_ ziGr0A*CQf-Izu<2gAj*Ut%VbzN71Dd{?R?)z~e%cH}Z7}{6O~}$qoFHF=cLM7c-kn zfg=qvrokitSs4kCgKW+}q(<8O&if>!_K_$rMG{-qJH1R-_2in_>jfA@e!tiK>#}$E zrAr>^oIE9u7%A0ck|k`V#;Z;7_h5j#^e<;wjh0t`-?~W<^QU1RHOv4+q~GL;*}v`Q zXs`mY8>4CLIv{DR7mX$#Eys8aMl3a|gpLoYJQTpzrmB!D0y6IB&I!oFOzfHxFPW(brEz-qYM_?qWj?v4tAa`Uf`?^{msZB1ZX?s5WXPY4e zFQ_L|M+Q{9fMrqlk%l*&i~Pjia6(71Nx0Av&@5xd@v&F7kZ$;_A;+ESZx-ouui= z{Ko$q_y9m#F~e~v)Z&{JR?tTr(y}qdN6Vign2st{tz}gfpQ#?oelG!bf${8n(Dmg* zN901}X13{n7qu_=z%CG574h34Z3oK|=4|U+AH(m#m(-n1)uiW=s=YGgJnCcecnMY? zwq955#Q3NIK3SCi+1*_9Kr1u+8HOo^g8!u)>j#fTB!5atRxPR0y9rtcjh=9KS8Nsl z8=w`Bi#nj@&aY}&E%BPO44A7t@N>3dk*y2a$sq@ zyuRylW>2Omu8TBU|AD8gy+n zuF=)VS^Ws>`mz?CkQT*h58XD7vUE)dVIRi(Pw63 zMf%1>7**n3(gJayEpENFv-%P4WpK-+JyRE}XDjzQ=vRevk()>9MtaF|J7&@V|6-{u zP6(m3OISm2(^+(7f-nnpaqbEj_>5(0iN}PK>{~j1N_eWR>W`(Vd~sJ~l>7eRlT4CQ zMfzbt>!8v#APSg?_v75B%yW$;>>0TF2&D^mXuC6f>Ss?)-|>`t_-y(T;Kt#6dked`|dNRw;lnf(9kz4>q3O0qxt^PIn;;rno5 z17RYOS~GYC6iH5W;zc7VJ~Q{>A)u+2#2bp#kd$N37w><6RlRW0Vw;OLOPM)Ivni3S zPgPe}SJ%>YegnI9naek9*&8m^gMIskju3xVEax{2BYOU|qEC=*#T_JG%YtGw9iQ7g-~vr6S!cO#mpYmL$P+O&Tu+&0N}Wto*OO)H3?18n8( z^tUa=Hg~8oot!hIxkCMxWV<wM={hwcPVkBu5P0_`mvIJ|K<3R^$3OUMK7TGK;R~ zkW?O1I|kSH1U{6^L{XD@FR+!*GPT*{21%e>aff+BAw@|eo{*DS##k<#08Adzm%s{L zC`l`HA-(Bh&63a4NBpI`!w;l6aZL|1>CZNu1}zEN*jX$E`0|SL()O_m-jbRys!JA! z!PdLMck0Kpbu4y8c{z3?WM!tTDzK_MmNR}PomQ$tl%gsRQJ|GR$f%%b^?G@s4sp6j zB6v_mDHZT(T@gO?bmM}A`PzSkd zp|w&E(mp(xzQ_Z`1k&Gl6rs;VCV+u{f2 zn4l(=m`f9XMcEduC~H7M>qH17L}e8rk_z3Gy4-Q&T3v~2B`HtW0-jFq!}6|ht*ykh zn!l_#@$?e*^;Os>+*ZE@`%*o~>R8V!V(8GZX9m-IX4Bw3>9%TGxgv9WKx;Mp6;+c~ zI%w@B{C}{mI3aJcu*MCn!su#H3{n(Ve**|GsUJMW9@3|uf1VL@7+!q3{8N1EQ}kA^RZeM`W#}j&ko7=iLt&%8S6VR*7wab)_3YY zP}lqqIa zVeWWbNlKbn#j%ye%2+Gbi_u(0BE}R(Wy3$7>{nP8br{eeEX@&%+04N-32#@GVnwTI zFWV;HmX`^hQ&LK5y;QGMRWx_zY2)l;)*JY_U)*O91Rp@&+x9cLF}f4x$Gp*HHXW0x zg2|-tO`ZibFE%SGMYXy+E-R`vo=R&&=I{Q*F^!2y`uZ>3=J5yLsr}#orPcq=`oHT_ zeW>pthm-_|usXb5{n>K#G<81nlSU^tNysLd*H6dW*vcrLA7jhqKEawJ=1S&H9Tyay zMt?vW*iKV)wislsEFMVK$`nY6?DYbxG`Z{FJx(j`uq9EleRD#MU*24#0ZfwuRI69S zejZ!7L5T8h%_bA%1?lPZVeW2N;mC}1F@kj4Hr(e~=pS;zIz84tCd0x+^eh8#1mr-= zlOISUJF7Jr&kL=6Kk&}Zq@@UcgJu^v!Kb#DSd^9vvd%fSvgki*{c}76(5eJLDZM}n zVJg{-sbe=vr`SkS;}|Y`E8R>QOLc4d5{5nez(M1e$DZS!eC_v@Mz;g&7S54shH4E)% zO?d-RediZ_iJ$|#&o$GCGk8L0eW?LpYxVI{wNNh0f&t~+UAQ94&K4~Q&!#=*m7?OS zGoGSioDkj)st%I#?~Pt}W0v^1f}pT1hckBd*~B!$pJNW+{6WD!Yww?3Hlw$~(srHJ z+nHxYbyCNhjT9y#3Y7=bQwlGZC|&;a!R1s3KDu>$y1515*@nlLGzGS;HCMEnrrY#Y>q` zC)UXl=&@zr#7AJ93NJ;BcN5wzH0d{5&?%hMlo^^BHnQosj7uXaH-#L#ourx-w^7Ey7kiY_f62&m`pfD zAuyx@UcBOe^3ehdPipvR9+^xvJ*Jb%h!yR4oaS-umqNS_yjeqBE17#vad^4yBKCHpIY_6<;4K`fRpv z#b?LaC25&1#e(S17+heg;T)|fQZ2Gqj8O>dP zLS5p8GH?R$R&ec_51hp$Q$$qml#64oWZS}T;Yz9&(LPqLFD}Wq zFDKtDCO>ZL9Q0a$#svAl9Wt2PHnpOs=j6ZtJ$L-SU#o*IZ{eWAJw5b;_nLpm~TlNV&%To+m5NDU1y{&7 zrv>9ph*d#BU&SPet2Z6{ti!`n(Agg~()H+k==?U_G?DnLVa#x#okw2TET`Ei;-~kY z*to5wI&?A2c24MRlUIHzn$XQW>D4#iY0Afjf0br@Foj}gGyIOEmShb-q zq}tD))ZlDe69BKLZgClq0ZB@ zh&@@)tZF?K5;-Y*J82R*4a#xP?O0(p|uNJS%_u2 zQwTalA%J>Tm`~#Tx;eOzWqg-nU-P7?;VTpNn#fVx>Rw6TRrgr+)#hOBh!dU1m1Ty9 zN;%W^tD@x)np-J}Oj&P>#7*XkpLukc(~srRN*E}dVoTWNlAcg`KCipK$X~keIriVf zGz-D)d(*}cBGPk=?d93xCE{SG((t@FOeRc!dhIkW@Mv#jT6xbMhmDFoICc>f=MKBr zG3@f_!f_=^9TTJ{$WqR4%^LrwGqK4RS07sR`2$?pCR|b5)@`^lr{oI0>_GdPnj`!$ zh%D)zaXmMl$@Q~sOtwdH)A(hXtk^jU&#CEo3jt+=4F;4gtcRG(Dex^yYGsQ!ghraEO6?`pwi#S9#f!nZ@=gK(dm%frI=_F4qnuDoJt<*|d zxm2zwC9Pg7lwc7k77N;1vlnwGb@8;q<5r6KjQ9>=8A?S#osXCjW=ta=bGd+A7;m29 z@`ueiWz*KPx3Zwewb}(Pl)CPFVMM;@qiD>yLKoDMG`DVkY27DY!DYz;ZW-fufGoO) zc^fcmGAv+&E@<)i0bm^V0Zg%j(m!EZ<*jf}PCKf-B$KsdM`W}yXP6;*ZYK6F)YPi1 zsG#Nz;I6SUEE8E-3P5uxIhpkbA~031fD!k7@4a*`9y2E>`Q}D-4Gc4(TTpom(ljrcc|DmP@3+ZOd9;Bl!EVWDeSLv}_K-Tb-NeNe zvpfFB9FiG4*?dYGagv*$jxavT<1FI%LppK1yv5r48CDQ9yfQ85AWbZovKtC+H1?wSMw4Ysa#*X=j&r5C4n)t)cxpwbA6@_TFpj5$(C4P?>p&6vrCtG+%kvT#}pCrr-gVQ7UE16Qu`OVflrTl_RNS;^k#!L(P^Aprz@EC zfll;C({X`K?2$QEMwULt!?2q!`66zib1K}Li!3tedz{c~tX7w)KcRIPOWGTti+8e< z@S89|DN8=Jk}8wHV=HqG=X}!IvcjpQe1Zzqk|@HOIFWaXBfpk8HGS8FSvJ#$yzR_4 zyR;_fo)YICp%L+9;FzS3c*s3W*zr!(D9L$KLH+=E58yT0_O zC2c}^@-kMFcIBfNE+sF=I+-Ml4hWM|$-H^V3;C7nGI2Dva?kaKzT~%#k420o`t$?& zb8f*CkcEuNFACRi(XhtUMF(HH*&?ASur4lQog(bvfC0`jXB-0*#b-sRnyuSD`5(v5 z-BF*vgv%pk2o(DdzGUqUL1lCH2(r4hw3b*)+Yl(i^`HRxjw)4PRkq5Fj25 zi8PvnG-i=XO)JQ4rIy|-5}A!?n%G`8%>;lAhc0X@S#?Y95g$!S${^&Q;JeO*F7a!j z%vLmL4Wt>i3FW=1r}B2v{i4r`IgEH2Bek%q9v1XU>SmlD)8}D4{o`*NEf9ATEf9Be z_UZCQd~1UR;%;ZVNtsh_!?G=%QyKEKC&oN;~w&S zUnX5(WZ2xA^CEZ@< zuGbm-iv(#o(d){QN~L%U={lBaBs_7i7UgjVwBEg3Pz9j!h(4?^xYOllBXc^74nc>e zPGkkA=0wLjdIXEVP*{3?ZA!hE;%m`S8egjz{I#olWdbPCI|KM&Kk0>Kr@Epi*=$gQ z!#_2{;a@aK-A3m?V!T9l-w`Sj09I75I3X27%6xQjywe$aFx>ZW0|;$$ue&CCo^nTk z5qI9t#Bjv`#`J<@e_>?z4BxdBmdyd0<|9=eF(MtdZ{7^kaw`k7m}v%cPo^fkX}3C! z=Ad^?I^iS8Jw5_At?UttRFL)gQg~`qROx~St;5(^q@W7sXyGT}Sf>Q9-f#?lbVA_9 zd;)2S>bRb5f|!k?+s{K}+XGqYg5HU3?8}llr42sEa#+dV6(gGlE)r4OGvZEn!+phN)I72~mZu z_m8Re8m4pCT*6e_37`J3!)ycO91q^{-~&{A0%P1-gbZI0zUn^xtv`pVMc_|<^R?N{ zS?-UGS+H4-UrD+~`*@_8=VN-=FmGzh9GgD5(P!kE`hL=bFs)LNvjSRKg}S>S)W5ha z_Q~0FBpHK=MahUFasZ6Ob6UQ1FADPNPwoh+LUiJ~lY=N(OmJlhx>;bHuG=sFgj1FSFq3*h4y_HXdk`Z?JW69$|xXe$)Auv&=Ck_@AVaIboFP zd;<*_4Jja6K$Of(8`R+|l)%z9%TK#_-7w5-9hT6N5;AG^scKzTpQ@%mJlIw65vN&? zdd}Q6=<;fD4}ut_vkD~aHM@`M#v+lXt}%O5W5)Nz9zpCeTu*iP?D8vb!0<<~0{k+K7ISBmTHmnqg;3N_y%PHFNn4Sxf%4JL8R@v>^v(eJC}>9tkVjuHC`~4 zqWlcB@;2o)CoZTHs7HUrgL7}g=DfFJs$nia?no(jCFH#DR4Wt;sL&RkM(`(@{o{o7 z8mxI8bC#MQ`9anR)itQXPOUqCu$Z6@8MuIM-i4=X4&0l=PMUem46Z<~3KQwxqgoCs zk)vzPn2J(+$HlXm)=eHg(lWi7<1r_19#|68P`PQ{Zf^TKwDCVd*C8e$m^wqya9X!Y zWJK7>;sp3HdoqhtRVH-AR!$0{yHv?w;MmV-`PsPTZW%G#@f8PsYZg(nBXm@_O?2M? z+#Xc)$>%13z*Pg#3JmelTL+3x4nlz2MDkXf*|QaHg;~0m)yb$km0hq-!j*2LEj2ze zVaH6alxVd|Ngf|oN_h+KudY7Pr{uf!Y$r^kxEU;A1&Lq@S0=K!ZoPLjbC>~r%Hta_ zMd5Sw_^{vGTso?$=f+a>EL=;iC*F(aiRo>arFvCpGuX=H`ooo4ed7kER3H_lOWp~d zRq$vTyvOzcqZ}qYix$ARuz$>{8=LWif542F=fxYzEjac6JyU{{?#P9tR-_B3I}RyT zwMVTcO?=o&A22J?_&sqv$`W9CWax;Z6JyinIqZ3wq(^)Bk+-4|wth4?Bg~6I7lJd= z#oq?QTRn=+q+`p>gUEc1hq;GaS3Hu%>njHoARm~p(YxB*vZK4Bc-T+uItcrT^`b<* zqICGcLUGxEGaRLr>lL}FhSnij?^9u?E@Q`HAMTSUl-wWWvP=Ne!rGM~e-kB;wD2$x zGQ*tRC-8kSx-Iqut`!UYOq1ZlWjU{PUw)4)UBzmz@2=CCjvR9`J0SF(ebmAh+!S3uJ`F3Yr^StrqE`uOy4ilki|U z#xKRU9_6vTB*z!LK?c|NY0}S^N@C}Wt<1S+bEqT1lFh1GJ~b)S(u({gvB24!3sd~* zP?#RZFVG&)vjp`-($js*WTzpUT)yANOc;Knc=X?x6IYSe7M9sKh#o(?Y~I}te)ik{ z_<8%|CSFn}O-MA;kTFlZtB-tz#Ut>+!Sr(PiN>osY zT}-EW9~RKq$Z~lS-LFt3I+r6eBTk8RR*X3QnavnSk2(1@>pEvD6uhTs3iFZga5uEf!g7pWD!H<5LWej4b4dm&Vhk4s1j{U%A52wiM^XT?V?`Nac>ir!2tDEbF@kF$@6V34_7?nlM z@#NtRG`kyoYxjQMwmV(xQs_57(m_t=P5zZNkI`!2v~` zA5bjkVyu({OJ;f5lDS`~VU^|99ArGG51?nBxB54@m|sMSz?aguZ`}JGY$ZugqIo8aj&CQ~fDnnwf*+KG^)R2RwDO4`= zKXk}5d0gwypy12hU<~?YUSk`9S02$AKEgCIJ&L1U@r={*vRXd|h6Tx3AzgT=`6Skm z5WW=2)Q?3hV)o)7gQ4$Y2B{D?xu}%N2^E`qu`2Vs6?58K?xJmFWdQT(cySuSxK1a zyejQw&`J;GLSdO8p? z%lnl5E9#vZS*&fqwl$tqUiVSZNwylx#^@qU@J87tto#B!yC)`+8l5S={* zgk94;GHMFB*;@=Y=zQBjI0L1=!9xR@ia2*q>A%61*&u~rXKFPk9c}~T= zg)?-z6%(|BRa04ck7P-W>t&hL7+P5obN3f94XrfGno3Le_2tQ0E}T@9l^|AEg1Efk z)=&7YoVG z%878Ugpr^bFUUUr@XQC?>6Qp?w$OgUj^ww0$Y05y+US-M_25|UlWECpGdR+h+s=+X zVyVd43~Af~m9g|UPqdyocXg#&4k}J&m)u)g>)s6GiaScNu+CXRX*y}%1%Q83XYLp#h&@8@~_K+pi56mUZT5@ISujF6e5U#tol)M?z z2M(PAnvf%EhQ&+uN^E<<2bQc-sf&q}Yo&^S8(K#SH_CvOFr%P2!I@kj6hkzB%!XJy z_9O^ihjQpm%~?h9EidqU=04BNb)ljRuKR)0GdmLvi{m7UMk~tGXJ{RU;+zb^$BHs> zB96(tl26PV?~0(G}A+eI+&;l-VnT9NzHFcP2y@sKkZG0)?1xNV)|(kt7$A;6GAxbItLXx{EWY z6MTXU#3xQv+A@{6QXL~pGIjbyhfH)ZhXQ?R=9i~);%k{ToJyfqmu`%;^!auUNI@64 zD&G7-REJy*acHVLA(~v1tYcXW9w{K?IE?>ftxE&%1aW+%F6x?6r3BdrTXBK$Bf8Iq z_tI?B>!o_&Bz6=$yAo8X$ge~-{U-8w!Nq0T)cs|4?T8yEUQGq(5~Buqp1AbwW~PZT z11(^Nm1LDE0LDD%OQiL!*@e}g^@E}{OvR+N!0Wb_f1*m9==#Rk0iX*epee19grosn0+;;o2R5I-kPX=E;_(#Tv;kKv@b9LF+(qTFYt zib7IrT3`>&?+|3ap_JhZxJeZy&iQ$V68gA&3rg_}sw;WzYU;J4EFjh(z2kVh-*7o@b6E(ylvu<+w^cvyw97Dt(!w{H8=iDY4I zN*Ekb2t+_3g9tcqcx&(rxFRZaCuhWUr(5M)gL&sdtI>dFJf=rJi#RFNf_HwZ@4Na4 zbIW?vq6R1dXO{!sL;9kJTrp?f(_z4iIL>E!V4e~)K9-+rjW&=<#i|x$KrXfpEw(uU zq_*Nx-D5{pt1#{Hn}-f!2xa0J0CF?8d{gn^m%*-a$YPV(VYTB}zf7N;-}D9-`I6A} z@jsb^o{MAY>?@^n3LQhMivGf0O7S$dHbh^%&V?iUA$)QtDFMps-#L`UHF< z5BK>Oe24s=n8t)HEaFjY7=%1f%dyAaM-sdu0u1c#Ej&%duZ7*jom-LUmf$sB>^;xo z(H}QD_hN*LQf-hY$&x1ztdx~ z(*+i4;x3Oj`*~E4&oKYl^fz=ENyC62>6zR>mM7cDHz<1ehSj}6T{sNTJX<|B8NQj$ zw4iCyFbl0iTK5uz=u&n(6Qm_x2WO9cE|QM8Khur0mYd^&Hxi?=4%x^??tOrokC+k0 zt=6-xPhpeLv`k#)8kQwZHC^#M&^;QhW}|x%27cz8gjgRM`YfJjdZ9;3q{V)A;K4>m z+O7+WbMnt)3r7W*765h)dlb5OE_p(3Epo*G zx%9cejtVd}04&X+*Y2Upb6HaQ6x4G|H>bGz^qU)8dpsjfuGn=ztXMDS?S3;0XV(kD zu7<5cFh-^Vsw{xIy&H6{I?cwQb9Wn{7Fn@*g@yTpk$g4E5y&lxh%`W|0?4~=I|Pu+ z3Ehfhy!7P9xqgH6G%9EttaK@^naUY$Cn_k5M=tj{V$wi=Qrjr`i%ReXUoxt=j8OchNojo8W~?ZtK-7LsF(5W}#6=Y_BG3FeK! z3=**QjVIqcA-oBMOR!OEH=Cq6cOR2wy{f1c>8}i}LsG9&@L>TH-8@punb8wBZVqR} zgm2MSuB#_}v*KG0N-{-jubh~9BSHg|?@lWK7fr6HmT_T6PhjV}jujZ#J;zG8iWN$l zbhs^L@W0>7RSdJ17!x|B44Xb{CPSwj@KHGfAMGbPi?R`P&F}R2w7hQ);%Wrpfvejd z^T9e{y$P&YmAcON_PZ+!pWCj9EO z0JbsdNW^@`L1Bf1noW|2BPN!V*Bc@UKq|wrHH+ zbg%n4hX~FrPl2JZbN-mG%bFW9F`4t*_I#J>Q)Q~p@H1zZt)P$G@V+1fA$^LHl2#S7 z0b6TkOi4!EW9u-I7p25~Y&j3QC36*iWmhS3zX49=0I~e?=WHA?kxndjA!SOW3%wYk zg{`=_Mr&xWAB}kVi>yxn_^8%PMZ#>=rjfd6VL=lz30vtMAPP^#f>L<8YP}q6xiqAG zU{cHRJY`5N{TY6b?Yr!E4XN_)Idz}UF0bMCrxxV}B6SZW8wu$`fw-y1>hiI1OfUW4;Agii6L7I=0UMKn|nlk3q(f??e0 zGPQH%vMP{Lv?|4V@$-@^SvnW8VqI0#a`DY^?3)+Z4(Mcy)QR`WEb}_3o=lYhs(@xm zuCRmLHEx(jkurw1q*epXBEuef+nPl})S^tigkF@zPbj{CA`h02gNKf%IbyaC;vqqr zi!JDLxWf#5f){(D%5=1!#ls&j6Jcwhf*yuPEh+2Q{+A`G`Csh}2z%RlZ*$P9N)E_BqEaElnl^#b9J{63xSYA_K zEx{8GN3N%$Se#60XmgrU8zc}cznH4;`hI5wsc1DVHtjC}gCRCw+5w=6Dgk;zl=Pj`8O=otyRr&ydW*0EM9}N50k#44LEx zx!8r^-)I+Z{2J^!jm1utsfDqXS}5ZKX7dM2lG5FRtAIWqnvUXAi_RvFO%?ixn>bgf z(XZT=IKKEHM7I6Njq<~^r=wM=Ut%lg6L7|ZYf2CeoGBUL`MrCw(IpaRfMC=}o8ybN zzEq$0{y^QuqI@7%F&oB!dI0ft5MFV5+hyWU3WU{Y@kTxK|9 z7zePf6B*GfcV%9fXkTOt4d1hL9~dq60hndFPbT^!C7ib@@?vBqm;N~6jsRE3U65#d zK9dol<-2p{?T6LBMp|VDdIpp!Wzh#>)duOw%0+DU++zbct1se=w*3EF!i%E|bA^M)0dnMA#k?)FmRMNM`)r zDUhRi4e;h>HQ0+YJDW7GebGG-{ef*Q83lG^T>FC^bP@8VL#z0il{m*TC>pE8)!*P} z*#Hzg#U9e9pSNek9EKO4E^o!RK1ILAhxoT}d{H7NK1;*U%OQ!x_~gn+`0Iv>89sfv zN3hRC_a=&Njn9@x#B*Ubtg+*o{$!eR$6Z%tQe0@|z2p8916aw8Zjb3^&vD)k=-f5K zdAJJIAH7Z_A9*XR8Tc-6%{V1ImbH%U5;|Rid51}--ENbjhTms$5yoBiTp{Q1r1=_K-8!xLYQ-qEItVgOen%_l*U#7vF-$KU)xpT2V8kT0ImdV!p z5b^WdyWY*lYSh>u-l8hY!nNQG);nRze?1V5Fd&%r=duK4 z%$!>)W{Vrx-BQ7+dIkGd(tq}RIwi0wGZ|X~qF(hMOZleaut(lwiTgA^>MmPU<}6D+Ki|1%&CH?vN6s#G!KM*=d;H~z zf~VCWcv{_l@N^EOxuw57o-=(ECDHlq?V!6kOPKA)ifb=iODe^C8M$CoB;3dnmc1fj zii&zwWDvntYLc-O8R!->Hf3NFt-~Xl6|E{zsDxxV2qxoR9i(PGU+=?8x9tA0h zYiTsKtX%V+~Zp9i93^ubkLMU3pyC1!w51Rny8x0 zW`wQW&yUS1xt<&FC3CZzk+%U>6o7U0AYb^qKr0J_!}<#gHqI(EPBn&)rsqVJq<#Zf z{B}NlKuuO5;`7m+Sk5Op_XNG!pFZg ziuF7FNPeG%eaHE2|9XRZi)Y1`mH4%w%M*80E=JDT8b1c#eztE1KRYdQ{!fJktraiu zw2d_%lc!-T*K2Z*39UmqvrK|vDS~0E-5>mHv|7CY!O{uAR}rj45NzMJegLaLRKE_; z2~q-BIRUsHz=%(qZpHGDV+BlBX*7*mD6{-wD@$&eiuQ62u{S-YQc{ZQo@BLr8&z!G zAN>~2GctYtZDVlt7eUD{{b-VB(g$*A0b$Z-;Mmr-FacHzd7C3(rBC7f9?_CH*JVO3 z4pfHJ>hBRGigHh317;W@8wpv+`!Y(yWmm=bh$27fk@(4J$W`{;tS3dKSWxp=QQ$GK zrqt{8Lt0c||2frdOzJe!O>6E_h4DFNo{)FQ6~A*@^LIcV`5OYN$nD-q4cqNHz*owD zIDM81HWNJpYiX9C<~`j?Jec+nGfYPCVv_$0@6U~a4|`SQJmooam-&$e2gnh?IDhro zM^cBJV6GMmu%qp)RC^xqwyJ;|+5zuoV5?rwjgb&hb8$jJh-Y(ml^CZzt|k6yDK zH^XTsE7GeNmZKOB8ofc>5LZ4yxE?}p&hpk`q7R6|VaM4D$#g<7sW??85itg{*Kr=SA&8ZYLKIy%K;%^rKiCv3pdX|j&`m2c+3L4+Cxmiz@=p5=8 zFfe=#X=3u6xgYVQyKBO4=;ZTXWNT@{vkg}vDB z3+W+n5Ehm^#e+8+DSGTJ{LE_oouEY0@-0{gR}MIl!?_H;Q$_IIvV%ya94P4J3<^54 z7d2BAB;Fv&uTA?GxpoXJe8GI4nG$<`ecitx3e&(pT~mAPPd@SF6|uO>!S#L4%Lj6S zWqDYbcHv4cDA7Jtg;xInEM*%TD*PaBJh(_0X27L+(}?ANQwGk=v7GTU>4ByLY?wcBr@rFyXD?ny z2^{vaoPhDMby{hvvX|;bnH9bsmQ77#J6FS%F=aVOMJo#Pdr;p!ZDC1nfzLLcqFReD z`1`xS-_7)YgAhlVsq}r+M;gONm{+F9O3S01>^Cli0N6nE5#vsW4MK#IE%ikCINFb# zoND+l}uA>{OmehrXZ|Jg9XV~bR^Q~Di+192~Q>(H@o0{H`b_igq z>%c?_g8Rn^ifS?xk3ybs6w5E5c#D;(!_0pXJj)3;+|^n|X8NriUgGez>+GY-m)b{l zb4N))!ACWAOtRP$QH)~!nEeJv7tP#2&L12YJ`5b~0FPQSI=;oQR;tUa&e+NnTg3dR z7HGa7ySXhAu`Y-xmWXIEjPqXSLMU{JHvQFrK-EkH($s1tHxl8)YWT=1JhC08w#xLx zYE7w>%i2K_zF1gGdEJVp6aQi+^rcwM0eio^r3O;SK@fUhL4l>XzaWvF$Mtt+HsWy_#C+q;u?^e8qr#^3*7(k9uAs53t@O(;HXQ=iPFD zHGoen-MHZJEh$sMQ|;_eJf1U;7Z<72bsap8G}9(5 zyxVNYYTl$caxT<_;Kx=ryS%YC9vY9DFd1k|o;d?oH!a{pA#?L5w6^GrV3OPB+w6Dq9oY*=9=bW?{)q{Bw*;WK+09X3dw4T#=b(CwG6r9jnqkK7HNtED5_1*L?o6u{Pn2b)hJddIYdObK2k0B_Y6GO5hUXXjXWZ#&17 zw5cxAChNwrtObQowO#RcW*wCk$@F-&9N2EzlrOQMq;@WdtQHbvOy|+{T^0u}G#-py zy6rc*jSCW}^teEaPld?l$_A!ld$3Cl@}@8K1Z=R-&zFS`VMAUuVt%CFxattCoNT_9 zOG1Cc*5Pe@F}lhg%d)AgFn!+PX#5q5$6mpT7q07;Fb87yF4x`6NA`Amomg|1+A`CrUNLQC9aV^i4p6x+X5;5ptgo7ci9Z@3ksTD~+ zwen7l{aF|T{UTbruRrJ>mZbFz*W~d5=dIR-ba)ar=e)h_hC`UH_yV3uer0FWS$*La z%F4G6J(JaE2TJ7MHkzNR8_iGEJ^OUo72n!mekwtazCNAtAj)pPakIJ15i<`Rqa!QG zVuwsrv(Sx9#YRRvYJuwQxT~^BB;=@4ReEhgEAN=UKj+jP!^YtsbJO?fudN0Btv#{| za~O1+?XY}PzT`FNjQu6ZXjqOHl<-CApp39U;n`*L`mW!W<(Nm}&@J6I zaCf(2DqY_-TFRHkb>p_#x&3<7a|&hPN{IU1#!&+Ui>M2`uockUv=KGMJeCgDDz6zmg9#Y{HI9E*mD7_>Ep>MWsX3xQ)fCw$9F&lz3G@a!!1s2Tl{ zx@=D<1?1g*yN6rA7%HP9Q^?qw`inkCO$KaD{oejp2D?Eaup1m4pI|d6a*Bh21%%g! z1!S7-u=-Jx-HVopJ-Rs+#mbArlhmSo@Gt~LI2j75ifN|V%YYrH%yk_@@y#g> zIjMKoR9OA5V>T_hvjC7@_oz91v8VVM;Xe=d zj4(ta`Efre?cbak`9df9qX`SI^oH12I`rA(!5oSN=#jRR>tPS`*?8OVlDLi)<=n+q zR<8{hk?vBR3{96=WS5HIbX{|*yU(A=7Z;vHnz}n>@Y{yui=`V| z^z#U6uy?wT6`sr4&^@1g#qY$OV)CRuyjGdwi}>KM=)#J=ND29cJIED_LrkM1YFa^L z0>oC{-aCEY9Ai+Iu6x^~D0FQLQ76N(4Y9pmlPn zikc>T^s$v~0ZUizVRH;uK%gEgc|lb!Rs~epdRI`X@WXDY|D|)+Cs*h?>EQ@{7jO!_ z|8Bor@ft?Uvo&F2=|WvdU~J{?Kgox$yOzgt`0fL~MVPZ7mC1Rh`_FtZES6<#4YrN~ zLycqD7&N=9my9&huUeDRubSRB{D4ptjJS}1BxQ#9V!bZbS!^8xd{`C)@%y6N&+xnM zO`D6#T$r(15v63Xbrfh;1e&fn9AgkcPDpCyVomCQ#WjgOv|8|)S}|iV=@u*)Z^yK{ zfay)6d4ez*gtY|-`!Fb*6^U?ZvLubi45MJgW{%5sy&{z!XdM@tDo3+*eI2UgK_pHR z3@a63vBXvq3{xb=czSwLH25nAr=}7&e4b_CvqrBzYl$>F;v%8Hv>RPk z_>+46ryliYxE)QiN>pV=25jY{vh2rn6PigoSK(C)gx68*HB_+zbh>%&*cR3e_V6vnUqSYeZ7FtOu-uEpnd7u*sw*8JPkPTgQcFDFS9r6Fs$_ za`23NQD2v#9!@A{qFJf*Ex ztpUcFizWpsy%s36v^SSaX*P3cESJ(kM@W=dx3EDvZRQX2NZCS;ab?duyC-ui#WcpsgXxn; zMw5#~L@Cv^s?5l%rC)}2kmoQprbUVApz&4kF*z?*-UEc0ribgVP{f%YzNuMZ#Pf?8 zE3%6@Gv=U*!5E-|@rz_jT&)(AA+80~Nt1F~D}Eq3<}lP$rJ{Wf)+R1;(L!vx+fG6K zHbSsvQxYQi8k!#=DjFk%Qmg|-ax7j@DJk_bJi4wbs(L`Aaj6GeJ|!|ydaNhK{y9G) z0fBNQ4yOEM^&2)qm5hQ@f3PU&KTmmnBJ!O+Gf}FWHYgeA-TwFHMZR3&{W92Jf&+AG zjAI>{7IHwCZ}CU36V}T^+e?pHx(}Ks4hf|^`$jSjDaeB4*h=r|yON3<)fW~p!x;2A z1`eNHBE5dF5bIP)^K*h^duMVBNPs@+3E7TpanCPzip8!92)uDJZ!YKRFC(6E_1B7Q z-8pgYilS!lWI*XrVec)}U#e)f@X^EI`q#0IOHwu;LUEd0VD1OTyAigpgS}et93H$o z6IQTGyc>q+*VxK|tOH~-3_$F(`(KGZL#_cOiiVZRECR3F=!D7}WmTXY z_#!3WEaZeUJ|G7Pm2o1?T}m20toRReED(;heP z%3!qyLyP4Js70Wv94F=)^f>i2|xgGT_B#-aM*fwanEdOilMSbXIC!4f_PCrxxM zQW$5%Yi(jB6xhK#V(WKW1=nI$fo(E;pDCc^))5*hR)5|HMSgo;Ajy#{N0HkDtoB=o zfy9sdP93Zq3n5Ek5;D>2gxqw#@Nn0e%dMT)x;>t=J+wYGEt7ggA$MQ;ch~JfJ7?P- z8-Zep7lx5QYi@9Io~0dnBBNiSERBAJa^8`99akqeM$55vYxsfOn8rk>7U}<{)K3=2 zmyP_aHdmYDXL@uaCA?|y_<*|0U1u+jCW*I4S!Qm;R^DMQuahJMNy zWa9g?&!0ZcsQnS2_tA03c=4%A?Z#|o36t-~8Sv%yi0eEsza-BG3Dm!p$Kk^3 z_>jT}g7IM|oI;#5G$1eH1f9o>5ifu;g1I)*O$+vTSl^1R%G`y9*%+?)trJd&lQ%aE z>Upbh>hoL2j1w>2DTTI#C{ncZsL=^VCgK!Ago34C9MBvyN+P0H)*%!KiKyP>wZq;S zIWcDdCaJ|=7TacSpbsmK1UVDUgryG1$Rywq(mzM&j}B}V}WH3olin^(%5(~ zVz5@=6@)ioyqmFC)C#{GQY{w`3$83=z zTsuH5v#Go8Gnxw)imKo}8S>CziYNwh-8MEDlZC^NM!v+ZgSeMiFFMjS?z?Fm^ow;_ z`x08K(=b-5vZgM!Ht!w%0plVUBVa>we8Yl&$-n5>+;AGbRFtT4N+g$ZZ=poF7UX)a zWvE`ZgsqH2ZLm00-P%7`VF8el6DMf%g;ourO;b-VEwsrq02P%=8Qv1B(XfPX$~;Zw zvRc5$Yh`%+ibZLM$$;cC)vywe-czwv~M}IlSoo*0~8V zWe4ZU(&=dRs?esfl>wq{3AN-PsT`y~9n;Pho&N5)Y03pX)Av1&QhM26DORLj2(9-3 z&jKG68A1v_-AI$5@GT-Mr}?h8tg*QniWuM-v)k6^cRvN*1< zEVAxhZ;*v|%Vm*h1Y4OBjcmoj@>Z01PR$rd=~H>{7^6ItrVZIyqV5LE9g7DO5yMJ@ zJ%_F;S&_zrs;tUH!Pr_YV5)_ZaE`-PJ`4V;9r_k&`}9nj)b+UxqonItrtuuO6Xs6s zr{}=8ap^jwb+lL5&|V#9A|FS!AblLwjNYp|Bb`U*L+3ZHN4FrnuqLO0sj|C&wY8ib z)_zkhCNbmJl4|i@7B2xa)G zK|USpo)BM$I2EOdUWWa1Si*>*G}18_aO8X;D^*jlXMkcS$am1$W*zLGcxtX?Zy0 zJ$1vAj`QaIMVe#4Lai(-tk*Km@9pGrNQYu+0Y$csNE_IBvw6a9J$7jC4MpP$8)Ayw z4w{4xTWkCsQri<#6ySO)z#paXRK2>Y#+-1{2{QJe8h_r^XADK)Czf~&*^bsh_~FsC zZ>aGRU$Hm5G~y+K?4GAeF_v1Ti(~2#1$RmQ$=YoG}g zz>J+C$4uJId5Bx`!}5XBZghdBqD%)%^MX!3<)Z>T?UhB^B{ z_zKl$;-BS8YiywT)LOGCfWuz%N~N{f2X}Kik%hKADI?33C&P<2;;O{nqYU9afEasE z=(V)|o?M95e}Xx()*clU4w1)(RH6#jy=ZQIfg-J{uX`D!i1Y0 zd*tdcrn$jpFi+(4&yVFo9p>=&&ObNjxc|$1ib>x1wLCV|bt!B^Hzk(Ve-1LlMf(Zt z$}sI7&o!Q0_|{4#S^K$^-hp~+UY2i04I^C0lXeko?KEVV>2!p=Ns`0`i4ZOZ#YIqD{SAJp4Jg7>>>+*n`HwSV4#SI2 zm;Vso`V{>dAL8G_@%=4}fcuBjC%64hpRgRJNS|lw#!_va-}bM&7vvxPZo9cD5%+Ho zh{OMu>8+l44w*Ll7xX2n`}}BAjv$%ovM!HxuysI_Zxu2=+)SP@}u9GgLh zEV-AEbF$;Oap7rnI3$VfQST0oASV?EBuyf!cZ5k~^??zZOi#v*Te$diMelL1LcTer zSOtXR1=DE&9mIFUI-MqZ`W>P3Tr8K0XP5u_i2oAQMw>Y1lbZLe7OM;x{FgL_Qj5e7(^`uQDNSg z7LDX@h@z<#Pq+oR73;85g@NflCMyGZ=3!W6G#UwAYNEVYIe`~2ohr&%7_wGmY}VF4 zkT1Gt8cM*JJcw86&0&V0m@vfgOfF?vGOlQWE43zEsaK?ug*jEoCvVrgFVQs7R!oz7-I^zyCu*%C<_WgeB$ip4OZx?dikT}K ztBE2N`?|10K#4gHvV*|#5Bltx{-QXmIC-HXP10>@w$$j-6CKW zK9DQi6Au+pFJ{Zr@WA0M)?UN6_<^u0Goa98Ij-PV!Yq@4@v2x|H}%y__B5{(vF9th+o}pHf3MM1{>QPY(nwy;6mYEv2-lAh z;aBWmRap0dcc(M|{oxv-o7}gE>K0+HgMHtn`ZOM+(Hr!;7oUnB3!942aD8tU4FX!Y zmV}P?vV&w885MPe`wt8=WPoAS^~8 z48no84`R6M%>4tc6ah_^Oh_82JD-`JQ6GA#mGH0{UEna#_>Z5aiw zg3jAKU5^Z!CM}Q~u-OBd+rfnKc@oYX;(s#E%Z8sTqr2c;q#bsUYtu`%JXXrGa0Ipv zK&6Ob@ies*&zR7u?wwu!^X8iL`2}e|857+e(;;~@b<#^1$FvDhU^N-8Dax}|&Sk1X zm}VlXDs(p%s_yy=l4qshnJpw7y1E&Vh(_4$UwwfAK3k4HOp|J@9Hd$+XUI~w3%Q*b zyxJ0YK;091Z$uAG5xvGuAAQYcXuxAD9xr5)GS9jy(Zf~-cgClu&m`hlp0*UGiqGlK z*)BC@hEjY&j}C!+8NB*R>?wmTJjtLYrZ`41^t1PL!5vD{J++Ka{W5nw|5K0pDC%U8 zjHp(5{-xJn_l9@M(wbmt%pjIKBfn3~*k+;G4YH&P2S%UA&U6O}J@Bd(Swt6F>+BF^ zS^f!HnZ2IlD7&%*Agl(IYbI`%ylM3kyrsV3x2T1JqN>`a--15am;)~DTM|!zSgOKD zSYUL|rY`!&jBVIq{e_;Ot~IxZ7kf4-@C+XXHoBnchA+!zu`Ck_GlQ|xc0uOgEaGHM z7RV(qKOow(3AF{@`v$Jcv6Wid6P@>hw_{$%7HCQpASuk?b;sAA$159rL8@pkf!B$G+AsmNc3VL+tZg@2x`+A zYzUb2krF%*=d{uG4C|`Ennz3Wz_p~XtymIIOkC!`_FL^fF?~8sAs_OBc6Fg)nc#aX zMxhi!0Ay`{agU4&vM4v4Fo)X_sl^o-XB@Y|-S_rwEWRi0-l7zMxc8OV)9@TD0GQ3a zN15sEi{%)b-t&5!w#sRskCRT9oC6#iv`~6x zoSyM(NziyNQ`lfsL=>t@BZP|uSs5N$>9>4^7a}9yxMyThsH=BC8KOg@F`JCDXCh*D zz9M3F39;RixF$%nW%-P%~pRAa}g{vTqbfk~z*YUx;z)z{>OhcqaBM;A{(`?+{ZqL#wPV}VNw=YV* zcWl!)gKVS1OOx3^ZyVh~5BY_=Gxex}h2TX{tS1vk=SVJGL4Ayg2wSqei@Zh>F};a} zPBP{sT>?1n>2L$&I(V|gbT4+kqa@jayuW~9`P-c0d_Noh0V}Gq(n-< z^=<|iMJ9xeh|^DmbwJ}p>u>??5;c_$0>Vu ziDP6jhs-_x94@bV4O|%)e^IhiB25w;TYJAmCV+DJ2F@ugg!dV4eTG|~CBQ@>S98mH zR)pS1IY%&8cywkcU~$ZZ{%M7>Sah*<2vP6ZpjFpH`@0e**6X)rr}r3ijud8|> z4RIY3E{KOL05ho*>xWlO)azn3m(>PXs;7CllZl*Z1Fv6UHB9*o~ zc_W1<)H^`QGyIt{n%nHfiZ}-7HwRtVj`L2A3$x{+aN#*w0+XHyB|A9SvWt6W2zr>0 zMl8nj>PyZte)NF$dLcFv!c;T$X`N?PMyPcoe1!`AT zA|_4U!t@>8X1+4ju?=@-u=BaFDUZN|4rKgWkpU-CjO^U~X;a@ECNq=5rY49;($X(= zu>=Vl&)x+%u%8-jN=6Sl5T?7^CIKpuTh>F)*BmA%v@BO-Vn%EohEnx5#MEWP7DxH+ zp_*f0iNd*g;Yt7&>x**P54<5mh*QO5<%HK7Hpc+LEpy$2X%7`YC_53gPGR^BKTr4-zd7rfUKg+eEq!o zggu`=2wd0PlE?Fw{^+>ygFzxRVjsjpn2%UXlz%>%WGHJgmkG4;mJIufrN^=#uEbI# zMm#z!H#g$}lh6^yqf;Hh_Qbpot`BeSZ$|OQ&M{yYa$wGLX2zc%n=A``oT_KZhPnNT z5=rkcqqc#Y&Iz4aNi#fTqT_5Xio9;hZz6a@Y)Y=`m9D$9nI(i7O1N-@jmV8eA5x7{8&h1+hY`&i*QVMp=O-1!pMoIO?)Q@)1n&zHLdS(k5c zL)9BboMdK}f@8@NKbBlbZN?Jy(xEbR^^vcj+m%uQ39ih?N}+=Lb#$YrK55~7vhq`h zC#~DUJNNodaBIUJl9uTi&LeeUf6!}R-!<~i6ihLtk@!bWZRYL-fUu(npc8wq^nwbK z+a(TWTS3&BIzxZp2i>Er9N>Dw#y@!&YWON_XET^oA*P|QRFb3|-T;(ok_KvOwNlNi zU^hOjrc{b03{Q$#QS;26ccZ%o=~CbHm2dO$oZ6;M&S$PU)!pZd52QJ_ENYWKsckb? z^mC8GJQ(85&Dy(H@XAp4b>$Z-yb+FJ?hoIw^WgZU@KoS`ib~Z0)`LUI2w#@fGLcf2rY1So%Cg!yWHTibgF!qjngQU;z?rDz5MHfQ7Tm z_;oTgYDMW(4y}B=BXgK42JK3_e^Bg;XPFpY!EJ%X*nSVK}lRsz8AC-8zDABec%UK<}+ z1{&U#B0XYV6A{4J%97j*q(y!<9?Y1cy>`Oz#%Gnd`RUF1yxwevD%P~zesK*(S*zyr zi)%Gra4hFvTvrvfl$#w-EukH+n9DC#%j$|0iEqi-mclC7V%tsDdm^}GvIS)FfG-}b2iAp~I z0Lv6cuxdt%)cd)K8db|p)TpJxi4FII4U0u-+Ys(gg6F)bq5sX92KVTPsb`lN((>=` ztSN8M1>o@6`w(#vIgR>^=#`?)7WK7;1`TaicHJR6Jb+VqzSz5 zDuecBZB0I1;@ntQlzC$}aIp+5C@G9h<&r9^twHM$@@8g(s~CaH3(JY0Fna+rmP~`? zXzn@4Wx@i5yBdDJk%I3uwNRIdj|=s~u|=gvQG?NnJUZcoy-SNvI{-m zJ2zqC>}&c+6PAQLWJE-ko*=BYdmUuxNmFp)@PP;`QpjNS2qa-uiWIIH2Mi9h6km78 z)L&3c5S#(Kah&>y6eK877%sYuoW`?5AyWGMUKmwI3`sGbc#pQ2;6%^lu8(HzD zm+uM=m^d@&oZG(eP+{ahyZi>-P3(?3UXW4Zqc|XUF(CW_#3pBj(+1^@SyIt_IizV% zFpmy4PmlB8ZNG8iyhISLNVqEy4G)~|q1edeuTk$>d@0%cY zr-tHNk1ydmxV}%UcI1KKPIB(&f+t&uUH~+yhro`RGK(HJ=!Q9vr7p)377k$}fN5A< zA9c4uvrGQjyC7dI$N06FY9~(u`^HRpa+L>sk35xK?vwewhf`1SW*%bKy6M9R#Q^)= z{ve-RK>DL^I4zhcr%}y(6(UW3b7CkAZ z4=Pzxbo7<vN_Rho1)JI{3DJu zb<;v(_o(i{Wtey{Bg@OitLT@E{#h1}bo?GcCP*Yc<#uL1qSng%;%B#Wa&mX*J z&!;Crw;DH)bF8#tfSlO#c&GLLkk@<6+}pMd_j%@@miyP?LbRkEST0hmI2_x%j;Kqp zdfZPmoBu=`0iO@hb(#lzJ`XBqmuQ1%KN&#QF+FVnUqq{Rc6rlip2Wp#IE86K(-dUS z38HOgph^J}@KV>K^P!^*s5><=d<#}22Sseqag*4vdKBzc2k64OI;YwAifyAL;raz= zg$jR|Z7*Sdvtp3nEQ9GjZlB#Z$kf%4W!PgL18Ukj)VvuE`2^c)*ea`GgZ0awdnpdr zR)dx*w;AS{&8aR#J#9+)eW6nwA1@e%8w=MXvXcRL$P|K`n1#paumXj>92?7>~k zD`QNS%BrRcVqpuv`hhH+eU}QVIQxdy+Rs#i&#ZY)Nt4gP*4oc#!DrI@)Zzo&Ejudq zsUE84=$V>X|Vqu-GH{YO$Yj7aJY<(=h|Ha{BnKq9*<1 zGdM`^z)!x9Rz*+*n12S=$e2tr!sYhEcGMzbYV$Gc=Flg zOI@W>7PAvu2kI=$LQ-7P)Qt%x{b$>lxQ=c9XEBp!zuDhhDo@YwW}z^TTu2f|xS44*#)1wB(0KFvr=l$_U&u|&UXAYYG8 z9Z=Hc$Jl|`|KHx1F1L*=>Hcm<%sbTRO!So^42uMJyGIkWw$`yE>xh!wJ%1b^S(0!B z0z3eyrElDb`x^K4?vqSrZCD6O#Kz4!i&ZR;K;g@*tgNhDfcV9_a(mWT8ycF&9}@d0 z2^#5SGW7J~h2f=f+-}#zq{LP+9*N7R-#6fV?9dq{AM_ovk7r)at!s?$%mMk_JCGa9 zB1!q+TFyWlgde5#z2VFYlDso6BcW%GJIyq1Rz#Erw$@Hxo}b7f*1`~H(Z!S2r=zh2 z8~5oBPRgy>!+oDH-Yfa*wB+n4au)7HeCV3@ma;?$2siB=kjz7`}oujE-(eYgRT3cmV_~K3qYnfILni@=4{S3iU;_ z|E@hG*)6#pxq0j4*t;zu)pX>InS%~<-oc>GP;df%Dn%AXsDTd+B;w3Vaa{t3KF7$j zR-mjkWJ$$Z_PCQ^>ATv$*u6fuV8NQ?%9#xAK9NJq1j?UM!+lS>mSv&`;hACIoA!H* zTDrfVkT1?Zo%iI+WMtkuP{rVj@6r6w zx9EHJCS!IafpxNpF&H3iFs63s3?_KfQq|w|L{0x&ZbS=fd?aWU23sm zX%9@rmYR9=Vs3prA!FHSH1W&<%NsP{&OEbImc_rjVU&n7Z&OlXNXOJ33iOeWbM1&4 zjdo2gL+zsTzO5LB03o%}1#~G}n#1Fh-qq!w0&*VDtR!vms`bBEkaTptUgy5R#Q zwnH5IeAT6s$7tUe8-3G4Z~Jq4>-Z+0RncOYAwT>~hkT7rO}GqUE7>u= z*>1_4m_tmb zho0f7^}d#x@2w4Al29yCK+iV^9*YBnO{O?ZWk<$I_Jef1*lr6g8e5yHMPnA5SvY#0 zJJ1ROv!KI(HQ(SN)3Oxl%oCaPCZgJiXPuK=0>rkTBAPHa;y z!y#vwlZzc${I+icZw*LndsbgT(RDSL~Ge1NI*UawF7?DfI7~GX>-ZIB=VKA-InQ=p) zEaQgm2q~FJZ_45$&`M^Ic^IuICJL2uH7t?k;A1qzF&q0xCRF!)l`A)rSy~n5)&BIX z9Z|!_B;?|+7TVgM*hAb7!sg)0rKwjI8DWL34P04D)Sgn(vKq@k-W%M}w4n*Ldb=s- zc6(~0wklkUiuy5iED_YBWv$lItk-22J+#)zVv=E|gIpJRu3wzu;q54!?@O8PulTQB z7!!+pdHxGiKZWbE?BBUCcv$*=cPxzKxJ9SXfrFysd1uvRZ#A}3hfUL{6snI|H2PXc z?q;!-nGh_`kavg0+whF98zdaie0dr`Owg&nUa1@|tA=wEa6Vx+RsUH-g(8&iF&D=5 zdP_JLVyl3)Z7~Nd84#?-5zK01`DqYqa@`ex65G6N5oHy*9Am3Aak{$tX%k&yn6DKR zi7F3d`n7(ZolWwtWwFX?`Iy2C%z8k#$soE+Q4qCXr+UIrhWmL~FzYZmrp(4P@uU?3 zHVNrOWJNlbU~9$>yjGQ&W}sC7#A+=sHQylsM@NkFc%&}c;1WA!ncaj-0&meD*z(e@ zL$byywhCK^4i*vYm_hZ&th%o0usnnjjWe66xUEdZIkO2Y#-N^d$-xaws4`2+#B6Z` zYi5VK72QYH6gtunl5z>D$t0H8+Uz|yI?jR0!eZz&H3PHDEM3X0q=g|GZ{j*0wKaEq zr@8c25YN-(=?F{5fSu8m?f-Ij4t!4_p4}@?1a+&2F}Il^TU8( z>?jdAWkg$*x5b4{q*c*Q4tBfau_eSYd?cqvpQc{Sm-)fyt-p$0&SSxzqpY06Tuq@)R;6%2){790638wG9YNEK!iOEV~4DxUB zVAZLq^=B=7rsly*`IvFAlxtb8fNr?)zyTSA4|t?~&w(%a>?C$}VE3%wtdFTIrN&)l zi0{Jxd*u4^B&$@Zb($S@q%|Y*VqJ}oDG`O~$$*@=W7r?0^>b~hz^hhv6UADn3h<)e z4cEp@C%nq;_9J}MutuJjE-}lrw4dkQd|CDo?eb2yY(Ozsggc->=2)_qv0%A;CV)wdL{(t zzX$N$?;s@0J^jtZgH6GMSzWx6nQ&!w*-RMe0;97GSxE|=q*t+&XO5bst(kap z{;wbqPpi+A$1U;f%wnlgQ)#JcOA5HjjfcYn@FW)8spKcbfjNRm6w=#nHv$8YRcbdV zq4*Z+%eFYJ05W;RlSvnhn^>uK#|c_K+zDELp8Zhx4B%%ohj;Ds|-U$@aHJ zA63AV(5U@jx(@5oOrMTs_A3if;#Ibd;Sw{rL{9KhI*L|R#?NYy@iRR|@l1$BPM}y8 zj0b!8fwu~#u#BuQ|3cSn+nIVE%cy2P?Sx|$eJKKHY|<}<5kmFHVjWS^2i3;{nuk`& zG{n|*nWY6<*@^uSpT0nW*XX}f?i5eY18f;E3j51~e&)>n5}(Yi)x(HD;zQGete0 zM<*tBia9d?tOIX-YJ50nHxHIA&4$-OeT2cDIPKTBaunW`8tuJnal$h{lzBnV3*VzX zz3ur$$kOeNlAMn$(Ojz4f>8n^M~N+IPx%`By5g{G3_ zZK?o-Q#Ks!syy(y()X%h@B*O=8ZoUqs zH*!IlQ6D?4Vc6NO9D&D5I3LTrKR%0H-RY?UlkA|V0S&AONBn%wcc*l3+Lhh)j<%n~ zifwlLIc7Ma4ozGLH^t1?GdzQ*qapi?2Wb!j+?1dj&eRZQYT@A6kzKJ>*`8yRTZzR4 zwM_xXqybA-NVZI-Y;#Vh{9G44!HiyAw-8e@5jM>!_Wf7#3eV{hU4jT)Q=N9q{9Lyv z*794}HMGiG*b!$cti@;*i`5bvuv+5+>^&U)5hE7XinBRSZms$#w(nR2&n-1%Kp^OgXzP;M9@py%~WU`DcOi-t{XH#@G$!RNr zx!=U`emuJ9J?z0mL>Ijt?J$yE!>K2d z&LQc8|D;FXi*J%X5&40fsLeSx3gyuREMSWH98J;yhu2~Gob}KyX`U2@%Z13gC77`R ztg*H^7nFCdL`g+c#tPDmk=Dg>U$`1uuC^J>ot6+tMPQI_3Qs0%LGHXA4bNPh)U+8- zsb9xxJgrw#Q7EvWDH?#C6A6EG`dc-z-dm+d!~+lsOy#tB04n6|3g?KHLe|Up!>q_1abx_$`r`l`q+r{$LTJK#HTe5pj^;v zGCHbqPQfiso|XfSisA`g@9XgT4YfE_l&-A*0fpzUU)ZFH zbPpo2%I%v)IB+?X_xZ%nMX!s2m4v=v>70Vg3U))M_v)-dP*vcgB*Ze3 zRR!`96uJ9Kal=iozHF%eFTBrZ;57SZ8KV*Mx=S#ppUDkFOZm=7ORQx4dmpvCknDwSPgaBK34Mk;ml+i^-yR7 zR?-ZaX5jD|yKX_M*(qTpjA3ZaAl9G0iyK=oqWHfcnl=);uYs%H3Nv5`)ZE{?%*Po+ zdjs>ZdW>2fLtV@QxDo(cvASHT#p;oA9W86U)0vlYP#Oo2Xoo;U`o+aTV$j@QJAX~YGenxF<@ok?wg)0P)zGXburdVOWAdwF zuT{V2ex$Rv1|||^wocqhGF5u2W-A#`doV7n$zJ1Go0d4vyU-?*KF(rcZT8fXpWx0& z^*61;M_OAjd7ulPTCE#X+UsPk@ihNcpEduJc<27ZOWzJbzlQbo{O8(?aWXWg`AGZk zBd4G+Ni8|LSh)|NXIN?2tGYftYw(4I(f3*DWV42fv1GM|GkzpM9X3N zk$k~($#|nqmFq8^s;S6UtA+h*jK;_SO8e zx7CGacbkTqlYs`qZIoo26LWo4QApKW5y+~aVz`A6en4=I4rs#bJoUoNzi&6Lhj4}h zUQny@(hIT$U3B66Y_I!2=Q_(&aq55JHZJzn+4?#M15Hv|-e^jO5e~D-hQ&NkK6#>G z7d2u#xhe%h4K2F98pgz`w!;plG~cB|Cf)}0Vgpw(!z&oW=W{zq*fi&nhTgt8Lx3Hb zV|@1{DkK(6xm0mJ;ZYmKPik}AE+C5a*a%y^sOD~E_8F7Sz?KaEJeGaI&+}h9rlamg z@(vi}p5XsXjS2Xmg6jPn+1>V6zXn5$I)EMDl^N6cl&Y*Vm%Pk7v>%4c+q7j4qRfZ^RCGNZz?5R#v z*gGrb+NC%chu(&Z^HpTS?#MOI1rwQGzgD-*U)xc0d`u#<&;9nv9(&H6y#AwkB*+nx zYGRDs5ie&pd`(AOTU5-EeFKm##vIg=Y7;>_W$VgK5KzTx{$EBym=3R_vT z=>xm~r~C|4DEwU0N!BQF5EbM#l9t(u`7 z%fc^rdzMk@{oDN)n<-BVwom5X;eR0Z;F8#yKKw*!xw@VHny1h%gSsSS>%^Z|jwjvx zZ1P@;Zp|3x8*e|F(VJ(pS5YLEu$)v&LsVZLJIg#A!qxn|07=_ov{M6m7;?L7SFl^L z;hrH62ZztGGbjg^OEdVi&gMOh8x9Y8rJ4y>QUS+w$Un&YD~YZGKOyVexp6a^zwx*$ zaDp_;SGPlRufSTPgP$fA*?#$XuFnj3-Ply!6Ysh) z{ycVSK(wN|P+sN!I}yD-Sg{$a^Hm{+yT<@`#9yu?y5@d7I9VT%9D~4k_GjvO2GW6? zxPCjSA!6ai1;98zDK%-K0$onJ5vpXNv473<8rG0$uwn6o+K<7i`|C;Xq<`h&vi8=n zxPQ81jmIEv{PnbV(tV;D$v74FfoFq7qy0STzI4=grz`vgG)GUIoK^b{HKVRKgT@JIe_UdoXIa_O~o-kK>%$ipZlh}pUS$D>o zS_WvUpULsQwl|<#N3<2u)9ZyXm{zfaR96#DIApdW{wO>Lka=t91k-OfB`BMW$1ZAOiuJh0@tKjiRV))jBCHaC38#8ZPP z>qTt)2=R7~ir#M!+g{j%!Ux$OG=a2>Q7p>hFqrMzQ%QktV}8vusRtwZDO<&2#tvbo z=5zx& zp=u+6?EgXNWjELGc+G8>LBrd@djwTXkht%gV1W=av1NfEJz>no~`&0ZF38 zf(Psw+tho^mMMzPVK>5k{fVkErOL4Zqe~bsas)1B(a43fiYr^hD9vQnmuc%kt~0y~ zl!?T^N~9tZShcxF1L}S!Oc2nnQ4r!Xyq0wv53xR#z2LU1W0PZxG{Up&8B`=Xy1317 z1r8(z#YjiLm_%YZ|IN|5X20j7!3Fj6;}@Q}Xp2UzzNg?ScB6rCML3i_Ae1h|mMEk_ zFw6`u@5c*@BH^@_>+0M~D0E!6Jx{2$0uobgvyKknYS2jdX1M!MTQ41{#52)Zo-cW# z!n}c&GP;VD%Ostk8cDym7@Zs>>CDp;&(?)-y#J`$P)krUOlU)RB}dewYlQp&8~fC_ zWaHRCNp&}Vl&P+%yKx(GulNUjHsRj~?35PyV^|544#Hz*)*cqAluh*NkYz174HLba zk?8Tc)JJNT&QG}*qHSUsGErnZhQ*8J$+ir$3Snfr#mSLoKO`KD_Evx<2&@*4WMln9 zu44c1Jd?Eh(usOS8!dnXk0Lz0`k8Nx$%%aFu#0o~upqViW_pyQ{t8D2y9SztVO!M^ zs4I&ZGidr$2lK_r;^qou7F0cPu=SYe*Tw~7D_WtBqp&Cfe=Cd2T`pv3*(dlrkw+Xo z-3?HU2{9(#OX&rlYAjTx<;2%H7 zD=D%`%I-5~k}W~2a!`SThCGcu$V7b^uk?t^J2ma0WdZ8pI@#lFsK*;=-B(buA!YYF zHhb%=n~Lfw1Wp(4xOdibuecECyxdeQ0&C4Wi#Ca{{NHPClSI}^*S}{=JeDiXus%H=mzCYFk101P1!&{rHSs0dpUi;SVj#V9 z-d>~(h@zK{vbp-Sb)|W6H*plTNt7G3XD?O=aG3D%S{5%5zZd)7eoK%!5 z1(j?#%sXqA!)W0|sjKd~*dkt>&+FAf7NDjQnBX+nYA5q-gU%tkt-$cO@2ei;6GaT) zWNq%^qRt~tm0!cpX>;1zVE9I&a3@Ld#D}{w{dw>Aspk*CgL1R&p4g99zbq>{lba&B zhLdzo2d!WehV}Uq23EsfW+t#;s8Dxv$rSfA9h9QQsrd~wjg6W9>z;IbH1eaugZb2S zsq`FT^CHCH5(gp+<=|l@{`C!gpI;zczauXp2yWVBiS=%Kr6$?J#?TGct8Qvfjqipvc`zn(Gh+}t%v%5m&I_fyNTjqA8$o^ zy{ja$rfQ34v6^rGdTJ)V#^0oK{^_b-QsSnonYUyCK|eGu)c{@2bI&$<75o#uJwm9> zEqlTWT9vXzh1Qk7R8lap>oKdCOD@P^h@NZSnYl$+bmnqVqp!ppd^6~#3mnAV+99(s zdcnGXHKEL<%q>)=rO7QUy7feJiY$H(&yCo|5@J#F+*pF#)4=rok+~?N{XLJ0E+Y$; zb|=v+f?Cu6Hw%An|8T9~^(BiF=IgKBZ6Tk?j1pMg5KhV*YE|iy#r{Pc@G2~D(;pHD zqrz-u1ir3Wo1wPi$0*pw<&~OZd0IjZyr?<*sNBrE3l%ITokFq9Nm!6zOF@rXxN-;A z4?{jR>04{a43~6edN(DMoiHyR>Eh|xDujzT&|MA+T!_doW_C>Y+A2?Cp|lPr+}2eS1X2Ct+==sOYVw}NB({^Ha^9-i8)@8?#qn^WSA$5 z=t8}NIz-844fb26z61c1GpqnE7S{W?Aa+#p({f*4H~Acr6st!tL{$oZqC`j~$NYhR z2|v(=r4kw2qKd@((wDL+f7zuqfBAm#VzcQ`M+iLF{$Bw#^BP9s5{ytxkr2kyyjjTgdBvX&O=BP|J;rpXkX;$@;inF7p&7aCC0 z8-`FnfR1|kIVSqD?7*m7V%YA6^5ea@;ynWZ9`$wj5)?6uOcvg=Yi1sM;dCe{&u$jX zqV@41JA(Tg!AB2I7tab3D^8TIX-qfsbyI|uB@OMebJ$&9xQpHS`%6v4o}bZsr=&sr zex$Cf8}^2Zq21&?tXD@dVh~AoyiJ8P2!{`-Ik3jB(uJ>=^dSv&L<@Xrk5{T?Os%{@ zRk4~@ba03_^J63Wd>U3*S2mFAcPkzK;-GPOy1Pzj@S0K&H1DzL4R;wRqBVNJet1eE zFFi<#la=RRY`39s@48$Yt&2o|l)!DazFx(KDDV*Faw__Z^*K(3yrtY`V<0|NQ=Zo{nNz&8n6>5i5hah&U{7n<#u z`=8BQb!Tv{L<0b)o~DHi%Z{t$V)IZh`6)aCX*TduI(g-ReB^ngX*YM90LBul?u0s- zC4AcC#OV720zvi<(9uBx50BV#_OFi(tk~TxzefDW!ci$WGI&+6>pQWU)iz!dAvOn0 zpQTYXHfd1jJjW_n$Z~K2GE8f#ux9pgPVT+Z`FKL?4~!x?`6!y%c($6+JVz@MMe3?H zc~lrrb7pl~T|ZsBmbHRvv)~O*F%`TUnd5xR{4ks?D3wyP@Xq>|uQ|ly%HBxkhRB*bLVs70?WS zN|UXeipjoqP0>V1G!f!8PqkR{se10UnPQeQdWoJspM$dU8=EnB?SY8C9 z9}V2s2K5hkN4(ykTb1VN%{4CP1=<&PvZ5h?wWn#>Ul4ja_~2E@O%JfMZARnjyBO3UpEO~D1LVEK&@4bw-SaOqP|1n5!*^ygl)Mf9f=OXsAf|9DB}DgLR;|2kUQZ`7 z7IsZoy9t&IHAS;ztBXrZca0)KL9)EX1HJI?cbGB^WDy!mv)J~klQ!=mjYs|NEG<2R>zZ>?^;>UB5mdp)iZWsPpwk<(C~3wZWqSlFkAZI97b zYuw%zT??@8^_sLfLTCOq<*L-rO(4}bH8-X2lH&@{O9HUfrs(FoO?Q|agQPa^MWsA} zM+0!j1}@jbvpwOo1OCi?!~nJe+cgp|Z;HGHfKtoaVW9~&ej63@mi0f4XLjbe>64O}gbMa{&>3&hg@x!^OjV2;p2kyy}v;_nQ?9nRJDUl^^TsbFCsgDg)AEv6SJerY~PwUuT7hu*ub))!Z%pJvO*E-$srCgQM z2QdW2v~?Q$WRTqd)CWC*asFhrkhTxZj#)++QH!@@vU1qE>te7Lf2*I+e#06+qDAQt z9rB!pn#3wPhS|GIx$hJVVvShX<4-fN6G zINeF2j0Kt<;jmc8c$q&-`CH6*zfy5t<;2O`VPEUG<^(iP#$N z$=J&6GVZ1d+|L_`!&B*j)x1jH)hlP7T*tOa9h>H#bCD>L(e97ZMt#j>nh{;s zo4UFb1*8oVixMrv7NWSiBh%3(dJuEGS)t_gW(aeag0!0I0Yxi)w0<2zxw>6B*)i>9 z#myB%M3dDo{AMpyJokRr%)oD08SjZ(%wz|~(#>ha87Xdz zztq~~7Q)N>cc(7;3|d;wR`um)Tr))~NGAEkRFFW4=e`{MdP@q+Zx!Dw))YprxS|v4 z0+x));)7FJztH;5FFv&60cb{%!v3@9*LwNGkaB_(Z zCT&%+vi&ldcpoL=Gy%6QEWXP+bWZ^RxHaKh!^;hBIle`7?d7d)@S<*LIE5{HKaIPg zwCv!e0Rx-8-cD2R_o7RlQTp{0uLw2w<0Jiajl`GKQGD>n=YY$%{^AyGqHm;#n`$?B z35cnjXJ#MzJ6?@=UNH4UWPNkaE7 zaKxscL=wAWkl))yr31!TbIagn9ZuY zCEiHaq4hhzfOgPDQLsJy{`{MJnihiQJsHBaI@~A??}Vgn4iX7#TLeb z@wSg8&yO`X(n@+l02_l6(i{n58Tc{ALhkl)oZ|vM zda(k1wiWs;fFHm;({s!A()(eW)n+Is`eOh*0yxNTSlmi-O7d->kW7+F6X(-`YDWtd zQ-Hq&W-m2-7`etVEYPzAF*b_s!04-bKIN+cT-hHG#0Z`g@C z1`{*1*Xy7>x6mwZ_fky{1?_MZ%{Er4PG{a%__i%0MVxn+A5N7st3TanG8X)77uJM2 zRP|F@aGjP==7D>*gjbc z2H*=5x0t7~TZvyRG5K0Zw-HcH+`9QcT035!o}L0?j)V3#FG4L#F1YEgerySBD(Plau{4(~vI=+@4EHdm&)K3&>v_OK3rY<7Gu<8uYUSY~q*0IeQ8 zUd@aeuo}9AKOQ^uKa9Q3?2vSAj)E9*FZS+ML$NG-{}%RF<;Y+9Vqrbrxg7C_4S;XrXwXeDol7SkrG=Hmz z>&iRQEFPc_#y=X`%fsl|Nidx&V{(V+D4eoiOIx47wqM z#EXl@W{P}kPPKw11%M0RrNS(<%3b3gC|?J65wY6_YyIB}>J4Ylpm{^9@yS;98lz4v z5sne}wcLR2rgu<%xv#FErZ6yxe4gkb3=3P;OH%nE?&Bdk+-GROB)FyPE-X}op0`IE zGS9Ocy>{s3?nj)VS(1%QFpz)Q`|e-%_EFOQvNx))_%8nw^!$KpcDy@AL_qs&YBecV zTDOdx<2SL12wd=kt!aQj5*OfRe5P*Ntvf2M=y|yaeD;!7H)PHU0SwFHREpgmFhx`& z6Htm0z(){`9u`C;q9VX%Vi>*G6UPX>yC>mDpp2M6Ac9J9pid8i0>GmpPBs=|gi{uS zz=Fl@e(>6p(gvdQ2hyD{c3}_sW#QpzE_tO$S5lqZR8{d8Y{>#P7Sg_pS5QJTB?BJ=TXbk}OuYapoCd_SAv!m0^OvpajObOcD%J{@^eDRhXqZ=N_~ zQGvtf+0pn}EH6>tYU1UMu)V*J{sbX=ggf-ry!X8np1 z$6LEz*fWnd#32BcBfIF6(qqH2m=rnwqeePT%Xc%Cy!uoH62a`9=n$+}|6mR@#c%)w zU_=QvS&Tr_slbZCy{U8Gf}SWc5!6fp>@GvEpZyD?e_uRe|Z8>M6e* ze0^CyNP6uzdlgbfIXK1bR#=nxK@thL+6LXDH}Tl+<5;?zw;?_tcM9S@KDc)1A=1~cs#!`c zmW`+A+_@*ZSTYNt2| z9mGbY)bU~5SvisgtsX2knv{ z1+q!|e=L0eFIO#A8n<0z_|XXm=xXx3n03uZ02j zld>p!m_?;1aHv06F{2JO@1X3PDiWIab(5Vv5gr=?mKn2LKg;(FgN*HytV>zTy&euv z=n_Zi3GC~*?c5ma%oMfRlT`@mqfGgIH*LOATZ%39CYs}r>U@{LM(NSP zv-jsW%NIxfj%P+me6xn}7K>b6xg<$8`K_U&Yr)n=G>(@WXIcs9pMeG)8;Ff8jtXm3 zPvxs@0x&cF&@l@`2n@ibsb8wA$@in-$e4q0hyt3(;4$Mg#r+{Vz(#mg4k=9zogPDx zxMsS0*w|$#ouJbmTLb7nqT&7sPYlOyD92#r!N!_1C26{UO$gG|0&M_#pK0}Rbf4>n zRDBR`$6o3lAK`Sn={+O;RhPVO6IFzJVilwpM{>=va%Vzm*sy}#+CE|$GPb`?JjH?? zdS{Rr+p-V4AF0l{7pWj)ingNrxRi5N`+5m5{+1o8WuiymD?=)dH$6j!T%LRI*Gyu; z%z^94AR?~sO;Y~E-YER6r@^i#nRNHtqq*&J2;oiq76G7?D0OO>-g02d=d$~GTyfFq zZV4FcKyo>vH;iXxwE_P(!T zr!c|^7O=6FNM4>zOBU%}&a-=&o162TO9&!%ud5ozr}}xRq)=7zBKIrQ z3R3{11z|=?hvXs9L&=`MO_|9B6aY!S&O@CggDX#$tb!tq!`IjMO{>=Z+}Wm2l9$aE z`2zfF?hT2(mA;IQ9W9o6rB89lq%uMfj&R~0#T15Y^v<5&u%aA>g6Zf62f9axU#^cX z5XIwHYxT3#nk3v96AQWOwAaNVvjJ6QjTJj(U`MxEIl$Ynjann#qdjh zVCe}qLN|@JXF6o@bQMdBVakW^23O5W=|X$0$tneitl63nMO!DgFqQa3b-OjyiC?lh z+Z1tOx|{9R0z@Ln>qDpP=c^zN0PQBXgHI1e-Z{FWig$O<9yeF_4^%N`Nnued@0zVZ zR?Ih3-+o2;K3=;&TOCRiJ8bO_-BsMvR9CvEResij1nVyBJi4&D5~{3!I8lX=_fbKT z7(j4U_P!0bjX0>(1(G5eIv|>dnYEYiL+O>Fr7uQ;cLfo@ZYsGC6xXqf?2XQ1o9M1= zyDI47tNt-j3%Iafe2c49kN-o4>43fpku&3xI%yqN)+GRZCpT|AhF-!guA)$5%i*5E z;<1s^l%(LyWIIJLn!rE9pA%;}eE2SW=aBhM{hsGe>smhA^)UkLE6zBMuHd^JT$>i58!CtZW4t-F~6gL z6z@9RI2m-ng%sr{7auG+(e`-p1wHH8j5Pjl=l|cR|GY=?Pt=e285t~9A?GC+>y%W2 z;NXBO^>}hrUtbr&{)(UDHT}L)IcjvO9M?KEmTT|33@VPD3w=CU-V9Ri9AQ_@@xpW* z`(0Ss5nW6{OfwKmp+`|Igd+)NP%&9BTM!exvO5=B=l_rTwLFB`B-vm4N|;r10;aY( zotreBP!q50uHCTf)|z5aOB8edu$i%o$@|#+c7C>#J4*~&v=h5$tfUDKrio=p77Z`@ zS#;Qiq07UiXMX<7cb0VYwPk+kC%&ABhBm8O=`$gcC<+$VUlLbSNwv>jD;%tWDqe6%A|BnKv}5YN1=nYlVDjc|lMmU4 zcrJjaa9g6e0(vP!0*1SPrp@oex7TTAA*hU&=lJSo2kh&EFzL0@lIBDH^{?i5j&^1K zuZzIGf03LYa%LGb**C5t^^_k*3Xw83u4)5!n;Cs^$@E5mE9W$Svh?$tSHRk#+Ej^I zbgk|od@cME#5H9)IRw8KBDX*9c>*A`B7}+d7p3U^FKk*9&S>(%5MG?-xvQcRE1JB` zD_T7GE5r?|Z{gN|gjRO{kK6yhkzd+`Leq%A9|t-{d)O73)WPZhV*ju6mrLmtDKN|~ zv`(7Te`5bFPy3(95kkZubAp{+AecCm>loG2(S;ye$v_gLI6;fp?YUY?C#px{FPMJD zWH9E;VGf*Xl9FhI75nr9Q&Rp%@S(ov0v59FOoL2xmc#4#q&rghYV30XZXQV8j;5Ni z6|b8CvBq;T$sLuVq9&(v=)1MWDL)@3gEcYjV+Ztq`TcMO)EP&;mEcp_@XWo@GgB5L zTP6*?`P_l`(8ZAa$s2T+AT74K8fPWLu+xEeQQw=`)r1u-`FJ_+7W1S)Kq}nAr|f4vsm4EPsPhEyn_6@CnyMzLL zQ+xnpz~X}!9-r$pv`CS9e^uXt>UV#OY&jJqsrVo|-H*|k-zzT6^hl!|lby7{rL~{% zxxt)IdXxn&Pg>GjrDsmY98Y_N#0!1jYJs8MBcO;cJkII(OIsS=+6QBQ`L5 zmxdM+v5+EWmSwzB>uo~Ie#5`F@_<%YoLOwd_s^piU0bQMaIVM}%4;(4R+65@|IYe= zr}I#&v0lxuE|MrtiU4Ge0}OWo5ZI8I#!(KAv=4(&kNo@3*}69+&<_{bNrC^j@C^kI zW;Z;DXr>%r$D9h{FT5}mV(9Y4HECORr_PM=Yey)C4jZ@~jeNZO{XZ$dh>rO}(9qnF zqw%qxk6lM>(sI%9ZsnysurQ?`6t8}tGD6J4(hby#;!;a^m*Kt-LU(;K>U9v)I z2D6~K?XR~Gk^e#3I z5jZN3Sg8Zg5`N~fO;t<|p<3o+c<2v7rWnO5*X{dcf`P;SgycjeOhWC(g^F$*E@0dX zWRf$^7?i0KxxS->r?Wik_4I^mzN7w)O?ElTi?hL!GTOx66r;AuPB7X|%gg8E3S?`t z+3{zdp;xVf<6O$CnWNFc<$J~WCWE0 zol0fqFkj%hqUYLI+Magu^s{OIwZ~6Stsbtiqe`X95)QDV8piGYxm>;{&>_!1*Sjd2^l;%F zcX@Gi)R{d|N&+jirQQB5&5oAAWV<+QD%a8J$LjB^jV~;C7HMpaqm2W) zRd;`0#B#=n^OPiG44&SW_p-KB18~JC5s7ik~#J&iZv?!^};0!Uf(3^UEZO3x@9=7|g>;PQ)5 zE%bUan)XYeqA+f`Lu30fiNgAc;&rYCvNCzbb!FF;6A6C~lh`^}T|+&=$^g&Ll(R_L zg({jp#+%^90BE4w4AQ*``=}UbH4M7LKk;W6=qiXKE;qa>XZ*|y*6O{T$D=m!lnGli3Q_2A}F_C6ao?D>ys&Y4c5lmL+HluoE_W z`j_Aoxd|XYZUly#8O9$RN7S?>{Hb_IEF$%*Y+dF8d_SJ)Mew#mv|=#$=t^3&s0^{? z+Ui)y*?TX;-?-}{$eIH_9Q_X&)@;9ex7~5cZdc&*1Bi0iok1do)VlKU^?|J!xJTbL z3SCWb6t;Zj0@N}qbY;8iaF4j(^Dz2XC<=Iy#iMHb52WF6HJ7PN-%hNdW|y(IqB&nW z8!|6lD-M65Yl;aN4c)*ul$2cy%z`cb8cY98VhZWa3mP5rJ)g1w9wli4HQ&ofiurkSC3&IsZtAn92t0D?8?hohjB28HjJUXR+x~hl`+1#BLbs*5~1E zXSqO8#`$8q0QnRC{YQKMUt%Fnget@1m+m)p@#wqWoLlsPuTzuK0DjtC3kuRk{OMps zEC-b?cY6otMux2uxa31~%h0J1UM2eLq*|>u#2D0Q1dEyJ>9y$K78V`oWWJapa#D^{ znaeo8&eIt}?gHG=s*^D<9Y2r-WF*4K|AIkG;=d9|(MPbm?>a+*6p5aiC0Ssh$^(M9 zEVZVs$7K0s2{amHXpv)QXVRsTS?IdX;!}g`oSpsW)~w_-`Q0rp6VXObqb{EHL{A08?LC~Q^!-1n6vJhgdtAV$keKBB%+T^j`Ih3=~tb= zSQGO?Oie4As1VN)J2wDoPMRx`x|4`5Ndq^!3%{x9WN2CI-q5~#%n^-p^Ynzt@=s$w zbA7a%{DM-kN8*6N>!u033B6+2ii0J@cD!fxbkc@8+FGUf6P7+eQilOVCJ7T4$T6r% z1e>i^08dVd(6IY$-z!bu@$L`t%+G1N4XPC@U)X&{hFFtIudA+%WT0lO+~~lN;LhX={QTWVHdMx?jVFVT+vK{Z z0o7=2gq`warKLcDJuQ!8o-05nRXI9AW>zX`r+wxAuQD z*dCJ0Npg$lQh@#q|E<_`T_*Onv`OXfh-;*05RP37^NRar6Bg&ZWMDYpIz1AT9u`TVlmeE4Wck9+=K~SE2U&)WnqD-rnAlH zkp6cN^FC-`vO*}#Ab?LEh8(i=z!*0N`wIs=;9WOoR~06gVP33-m+VroHPLd5n~!Ocfl&{wf%UKpLt?99F`ij&!N!sK@~;J&d(DR$NU1?4;P<1f79Cq3Vns z0txX*v`PKHkQYKbg(Ps+34|>4h23!sp%-`Bp5yqo82Pj9rsilS*kh-TZnJ<4*!Vx3 z--%-{pa@cnBZ${qq6BH_cia_&+RA{1OU&_LC z)ba2V6h>criGv75?6+7=Byo@A!(hMLt_(HwRjd;g7j&14jW&C4&jmpo%SM8c(D{YHILpmY6LQ6Ho^uDn?o<=FG!(s}?N+T?Dnn({A#GywzevD(kp#l%Z^>(yG3_I9;MU{V(pgbA4 zf}0asYKPJJqXJ*#d_J)6YyqomwBG}jhFxK|&vBX9+9Ri8#S!x7=7Mqo) zfPCr#6R)6L8_qhv)7CmYdd^@1IC|gNs+nETyaL`1(Y8d)vWDgJeP{W-&cBufHW9T- z#Wcbt*F>hQ@YU#S1bh&ijGLQT4zJ$)fOeR_X zZxSf4*{%^{cDL-iZG%ch!_<9kdN0gPZPG0ol3v}Ef%tRla|%b9LAj*y^IYR zR#puG?2PC_VXnQr{gt_RN+j0t=%a=j5#&+I zlq_Qj(%Fr1Xi+={x;2H;0i7zZb8S9Uv(=#Q{sw{Hqly;`vs1jCSCDviHT@}wEb5AT zUd;WGi}wu4>FPS|C$AX5ijR|Z)E^jAe{KHp1Fjfbg9>FKbhjHEOWUEI zy?XY$Qx*Tv*>wcRiVeXu>GSZ^;uLNJyQ#w`{V!J5=a|Geue z|A!4u3Mm^*EYW>iz3kYV&9bzuRZTy&t=1UZFR9Lyj{F?I4g}!bYL)Rf9AbsgjIwKt z(eygV80(CEtNoE|9v=z8Hp6YzD+`HUgRTyb7s@E~@am)BGze}({%Oe$2@D0-Gg7EAOO5JhaPG|=T@19vZYsDHMU9noqT$z2jI$#zwe6vKS-G1( zo#5X)5ZM;7EpV*%JoI3IP!Nf*G+kRce+9l(?BZM7zR{6VoWCvJde0*|VXy!5BbQcD z6?*%Lw_OSM@GGa6!m-yM5S=X<_1*ul;qgDB;=lF(xa806$Atko8M;kTOKEL`s#PJa z=584>hrlKS@z{XtOE;zbl!`9dgrDPlM;+O>v{UA3pm;yFw0mHVUez2;4%A%6@Gi~j z8JY)@ki_-b)NUumQaUA4Aq&QlnG?j)2Y>bf68}HK&M`W&t$X*eZQHh;j?uA`j@dCg zwr$(CZ5th19d+#FR-beJ_rB-7W8C{`*BDhFc8yv!_p|2wJtSZ!6&& zX9t9XGR~C86?q$L7jKu|^|VzqCvO>ITU5OXQ(k}fev>QSXk7ran>g1u-uA$%<=PZb zQlsWlHJ8PA!x!ph^d(H9JUMH>R2zDHgtH;#`kgDRx|)y>6H^cq^Y(Sj@#&5&Gy3Lv z>dsi_rxwo}TZY|Y!f&FV7cQXWjaXRPkUfj-2)!a~xxk@wIGn{C0XEu%z!?1)(&|S8 znZYbZGBo5Aq7=*(C|Gk!boM$yVyQ!9vMD!gC6HD$OQW2?TJ0Eg(j7@PI4(lSvA}Wx z`~D!R{i#Co0qX^iH%qVUM>H0^nHirgj|lfO*28>w{TyDlna7B+lrZ36n?8?SKNzYbmYF=tE8 zVt^H`6eoZhy(t05sS%Gl|Hagl01|}3&v4<#xgNW$Po*J9Pi+{D7ghay0ee& zj_voEisu_Tc@h)|TAzU>8ALoyHyi`It2%N?qm`#$nFU`9mOSy6(qUoXO%ivD%N%7V zMD}uK|xw> zLlnDqDC_4?e&8Y5III&Tj=@q*WfqD#xt=94VOJxl)7dVCL2V!e-A|vZyp~~Xi*{l- zhN&IE#GytYxDQe4fo*90pRxdL^#=oM(mZom=fL=|o_fMpurXW7h8B}bCfAPRs%T1B z-W>k40u<88e02^Licla!YR7Y zHiW*`!LiRJXLXtm3pdezWU!;cDJG5jZ}ecgg-clEVq{EveB3Dk3m}s?KFZxp>7hHS z71l@N9_A#KQtX7Y0??mv5r}kawi*#S$9vePccWibu+C!V84f~L7T$-oG0Bj0{8V4w z3lf&EEnsC?qj$-kCKxjjpu&3^Ga~ieq(vJEh5O!zYu^mm zZg4$Br=0uWBCZcp-#*rTM$uv^uYi7l7%uqIaNM0ILwwg`l3OX~6?YAB^KSDmc3K_D zWR#GnhOc*#H183g7DZAUgKF0&H5*&iO_tW>_8cMQClvcWCVG?}V-^To%`K;cJ|7sh zmW-ev-TDO7mlTOtqz8MIV;!#WW8p5w*b;S_Gs;Ty97%RUH7=J2ooU>Wh zPkKw5V8%}wH0%4;gDW{`>hRKJ&NSeBWnqMQY%p3sb6Jfq!CJ2`1WKU~QZ!SP#m=&a zrZ=--oO`eVp~y5OD8SVD$10+|#tKeEce|)JnCd)9wDHrD@gQFQs{WI{=lk94C>n7@8191KM!ww9RYPv%~pYkSplN*53jtF zXU&6T57ATmS&x;s#QQ-j1UT*IP!ZT$|2q1fkSnd0C7OjsV=ooNa9>B_gQ@ffoF+%z zGX8ZQ=F@?GKMF)ADeG}FuT6&!lpa#mOij|pq7UL^I!$zNNFH|$pI3~4Ar&Wk($v$| zwP21Y{+KYNlEkN|0*gQ4bLgVkNuW>q;>n(K*_or-FPE#v)ejxZnV-F>18XpJS-DBy zguj>0s}xmL2F^1!;=uw7;NW~yE?sAejb&H@!P_Pj?cHdbU;{1zP1!sLgKVNh(HC|A z1JSMg=?}{hjb@4(8JhJ0(QW#iIBG4be&-tjj>et)-CgJawr`jC+a#uMjyBBE)5;HD zRVw{0+`RF+sfceAMEgk3qb3lMA-GO*00rdoD=)Z!;9&muCn@G|G|Mstz z?2W|`z3_vjtBLEU&O*3*-=46+>vDQmB#Rd8P4#JoCuG%ScVM#VWw3JNz-G9$#WLL` zA5j8x1#S{|ktou3pi`cu7vU^DwkU+4h)Tu>U$*OXfs@n?fUM?;{(N$8`bB!3qTOhA zMD?c2?T*;Bc=^mz6{ZzdDe z9)Qa{WMuH``oX3$NS*ELXatw3(t5t?tXG;$6mlQ|xSj&%QM3uj=M@L#7Fj>pNC)n2 z1Re9(48T&U)Q?JaNHm^O&tHggexKnsWbZjU&ec@5JG#QgopC{tjuUm^;#@(myb}(b z)B>GxT5q(uI=uyPTH*jIq~MZl+;6ylR!N6=pS@BG%+vBSWlI|3`op}YU94`a$p&>G zbpt%Lp`pPu6JxNgE`MyUY3pYNoqJ}Vmr~v-(NbV9Ebmc|AFB0(XBcMo^pbjQ7+ID@ z%ZB&0;Y)nxK3W@xGP}U~ru)6WQWJl&?JInMX8Nx+v2l5H%(ANN^V^Q7Dmjk0ry z4m*PGv!6FE&@FaNNl`6hLj4TXt3%=1{B~(KWTTf)s88!SJ$6N^@TDQq`zGtQdOGqs z?y=*M{bSb$mT$({adzLS3M=Wm`0iMV0 z9qja2?tI!V!Xp(Xk5f>B_G(HgfgK>GEv6j=8hFIfh62XNJ$7>978U9v{ z6XX#oOMLbw3H-<}gy(!_N+!!Izu~HR+J2kZG5Cszw8Uc%D?Fo$d~8xcT(m~@EdX=` zB_t)Q_8exyH3}9uNrCD8)%H{M$a;it_rTw05?cXMVwV-6ZAhRWXbA1x!hKTT1nT^J zawP>;(gGaRT$21L)*4bVv98|*9V(5+VpKi*W3+E*_37rO)?Dq81H9{ot%c#@x0yzF zske_p_bVO;dBNN-eTWt%V-GvYkENflAHB~qO>#8IDqKJ&k)isSroS7~f5CQU<^*ViEBvleaD%S+s3?HWgMgqwG76 zYJCr68g}%BGMHK_{kO-!rME%P7;FSf&FaAf>#nP88xgwx)!t7ZNODEL9?Wy*zp?!) zh+_{1M?;){H(XX`V5v(o0S$~KjZ4*)}nW=wL6z$qlZ?S(v- zkF_reJfuP3g{jPrbc!2G@yWiPO#603Jc?eiSQ1*Q5J#_~4ji+LTTsHT?=6@=8N&Ds zes8KKe6SOf=jo3YX{}g{ufoeNXEx-kufHpU*{9^~OoISi=h_kIi?!vM zx7WNr4mrFNAM8WBa^;|rVn7{Q8a?UV#O;Yq-H)`Xd~>!{IManGPYnKO^|3bzH$uT^ z#^JQ;zP;na!2IOEmyc_=b65(^Z>MXYV^P;xLBcst!B#-4t_hdd=@tyeMb$27q8R94 zK#=xeT;;*g@~~<1t_aF#WS$wTYU=nJ0Wp&uQd*U7&-yp$dL5p$rgkkD_*LYq!P36HB zk7wBNY*&n@#uUzKu@l?`%DNM!X4c;jHL1z{jjFKoRO6XjmAvtEvL0KdE>zWlw0%^T z`1sR^^7&-Hn?s_VOV+k@S1=P?urD4l@oEU

L#|6KwAoX>v{{`ovEAKVr^6sk;$ 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

1+kj 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 Re*NcMv23Ip-21K7{{a{yq$mIY literal 0 HcmV?d00001 From 008e5284b1379049343584c68e46b1e44ac49a37 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 29 Feb 2016 12:34:06 -0800 Subject: [PATCH 007/112] seperate iter_sources from list_sources api all errors returned as json block with error msg tests for not found, invalid errors --- rezag/aggindexsource.py | 31 +++++++++----- rezag/app.py | 53 ++++++++++++++++-------- rezag/handlers.py | 89 +++++++++++++++++++++++----------------- rezag/indexsource.py | 19 +++++---- rezag/inputrequest.py | 31 ++------------ rezag/responseloader.py | 26 +++++++----- rezag/utils.py | 3 +- setup.py | 5 ++- test/test_dir_agg.py | 34 ++++++++++++++- test/test_handlers.py | 56 ++++++++++++++++++++++--- test/test_indexsource.py | 28 +++++++++++-- test/test_memento_agg.py | 74 ++++++++++++++++++++++++--------- 12 files changed, 304 insertions(+), 145 deletions(-) diff --git a/rezag/aggindexsource.py b/rezag/aggindexsource.py index 435d0152..292622c9 100644 --- a/rezag/aggindexsource.py +++ b/rezag/aggindexsource.py @@ -63,7 +63,6 @@ class BaseAggregator(object): try: _src_params = all_params['_all_src_params'].get(name) all_params['_src_params'] = _src_params - cdx_iter = source.load_index(all_params) except NotFoundException as nf: print('Not found in ' + name) @@ -89,15 +88,21 @@ class BaseAggregator(object): return cdx_iter - def _on_source_error(self, name): + def _on_source_error(self, name): #pragma: no cover pass def _load_all(self, params): #pragma: no cover raise NotImplemented() - def get_sources(self, params): #pragma: no cover + def _iter_sources(self, params): #pragma: no cover raise NotImplemented() + def get_source_list(self, params): + srcs = self._iter_sources(params) + result = [(name, str(value)) for name, value in srcs] + result = {'sources': dict(result)} + return result + #============================================================================= class BaseSourceListAggregator(BaseAggregator): @@ -107,7 +112,7 @@ class BaseSourceListAggregator(BaseAggregator): def get_all_sources(self, params): return self.sources - def get_sources(self, params): + def _iter_sources(self, params): sources = self.get_all_sources(params) srcs_list = params.get('sources') if not srcs_list: @@ -125,7 +130,7 @@ class SeqAggMixin(object): def _load_all(self, params): - sources = list(self.get_sources(params)) + sources = list(self._iter_sources(params)) return list([self.load_child_source(name, source, params) for name, source in sources]) @@ -160,8 +165,8 @@ class TimeoutMixin(object): return False - def get_sources(self, params): - sources = super(TimeoutMixin, self).get_sources(params) + def _iter_sources(self, params): + sources = super(TimeoutMixin, self)._iter_sources(params) for name, source in sources: if not self.is_timed_out(name): yield name, source @@ -185,7 +190,7 @@ class GeventMixin(object): def _load_all(self, params): params['_timeout'] = self.timeout - sources = list(self.get_sources(params)) + sources = list(self._iter_sources(params)) def do_spawn(name, source): return self.pool.spawn(self.load_child_source, name, source, params) @@ -223,7 +228,7 @@ class ConcurrentMixin(object): def _load_all(self, params): params['_timeout'] = self.timeout - sources = list(self.get_sources(params)) + sources = list(self._iter_sources(params)) with self.pool_class(max_workers=self.size) as executor: def do_spawn(name, source): @@ -257,7 +262,8 @@ class BaseDirectoryIndexAggregator(BaseAggregator): self.base_prefix = base_prefix self.base_dir = base_dir - def get_sources(self, params): + def _iter_sources(self, params): + self._set_src_params(params) # see if specific params (when part of another agg) src_params = params.get('_src_params') if not src_params: @@ -270,7 +276,6 @@ class BaseDirectoryIndexAggregator(BaseAggregator): the_dir = self.base_dir the_dir = os.path.join(self.base_prefix, the_dir) - try: sources = list(self._load_files(the_dir)) except Exception: @@ -290,6 +295,10 @@ class BaseDirectoryIndexAggregator(BaseAggregator): rel_path = '' yield rel_path, FileIndexSource(filename) + def __str__(self): + return 'file_dir' + + class DirectoryIndexAggregator(SeqAggMixin, BaseDirectoryIndexAggregator): pass diff --git a/rezag/app.py b/rezag/app.py index c25b4ac7..90275d21 100644 --- a/rezag/app.py +++ b/rezag/app.py @@ -1,31 +1,50 @@ -from rezag.inputrequest import WSGIInputRequest, POSTInputRequest -from bottle import route, request, response, default_app +from rezag.inputrequest import DirectWSGIInputRequest, POSTInputRequest +from bottle import route, request, response, default_app, abort + +from pywb.utils.wbexception import WbException + +import traceback +import json + +def err_handler(exc): + response.status = exc.status_code + response.content_type = 'application/json' + return json.dumps({'message': exc.body}) + +def wrap_error(func): + def do_d(*args, **kwargs): + try: + return func(*args, **kwargs) + except WbException as exc: + if application.debug: + traceback.print_exc() + abort(exc.status(), exc.msg) + except Exception as e: + if application.debug: + traceback.print_exc() + abort(500, 'Internal Error: ' + str(e)) + + return do_d def add_route(path, handler): - def debug(func): - def do_d(): - try: - return func() - except Exception: - import traceback - traceback.print_exc() - - return do_d - - def direct_input_request(): + @wrap_error + def direct_input_request(mode=''): params = dict(request.query) - params['_input_req'] = WSGIInputRequest(request.environ) + params['_input_req'] = DirectWSGIInputRequest(request.environ) return handler(params) - def post_fullrequest(): + @wrap_error + def post_fullrequest(mode=''): params = dict(request.query) params['_input_req'] = POSTInputRequest(request.environ) return handler(params) - route(path + '/postreq', method=['POST'], callback=debug(post_fullrequest)) - route(path, method=['ANY'], callback=debug(direct_input_request)) + route(path + '/postreq', method=['POST'], callback=post_fullrequest) + route(path, method=['ANY'], callback=direct_input_request) application = default_app() +application.default_error_handler = err_handler + diff --git a/rezag/handlers.py b/rezag/handlers.py index 1a6e3495..ff19c725 100644 --- a/rezag/handlers.py +++ b/rezag/handlers.py @@ -1,12 +1,13 @@ -from rezag.responseloader import WARCPathHandler, LiveWebHandler +from rezag.responseloader import WARCPathLoader, LiveWebLoader from rezag.utils import MementoUtils -from pywb.warc.recordloader import ArchiveLoadFailed +from pywb.utils.wbexception import BadRequestException, WbException +from pywb.utils.wbexception import NotFoundException from bottle import response #============================================================================= def to_cdxj(cdx_iter, fields): - response.headers['Content-Type'] = 'text/x-cdxj' + response.headers['Content-Type'] = 'application/x-cdxj' return [cdx.to_cdxj(fields) for cdx in cdx_iter] def to_json(cdx_iter, fields): @@ -37,26 +38,36 @@ class IndexHandler(object): self.index_source = index_source self.opts = opts or {} - def __call__(self, params): - if params.get('mode') == 'sources': - srcs = self.index_source.get_sources(params) - result = [(name, str(value)) for name, value in srcs] - result = {'sources': dict(result)} - return result + def get_supported_modes(self): + return dict(modes=['list_modes', 'list_sources', 'index']) + + def _load_index_source(self, params): + url = params.get('url') + if not url: + raise BadRequestException('The "url" param is required') input_req = params.get('_input_req') if input_req: - params['alt_url'] = input_req.include_post_query(params.get('url')) + params['alt_url'] = input_req.include_post_query(url) - cdx_iter = self.index_source(params) + return self.index_source(params) + + def __call__(self, params): + mode = params.get('mode', 'index') + if mode == 'list_sources': + return self.index_source.get_source_list(params) + + if mode == 'list_modes' or mode != 'index': + return self.get_supported_modes() output = params.get('output', self.DEF_OUTPUT) fields = params.get('fields') handler = self.OUTPUTS.get(output) if not handler: - handler = self.OUTPUTS[self.DEF_OUTPUT] + raise BadRequestException('output={0} not supported'.format(output)) + cdx_iter = self._load_index_source(params) res = handler(cdx_iter, fields) return res @@ -67,57 +78,59 @@ class ResourceHandler(IndexHandler): super(ResourceHandler, self).__init__(index_source) self.resource_loaders = resource_loaders + def get_supported_modes(self): + res = super(ResourceHandler, self).get_supported_modes() + res['modes'].append('resource') + return res + def __call__(self, params): if params.get('mode', 'resource') != 'resource': return super(ResourceHandler, self).__call__(params) - input_req = params.get('_input_req') - if input_req: - params['alt_url'] = input_req.include_post_query(params.get('url')) - - cdx_iter = self.index_source(params) - - any_found = False + cdx_iter = self._load_index_source(params) + last_exc = None for cdx in cdx_iter: - any_found = True - for loader in self.resource_loaders: try: resp = loader(cdx, params) - if resp: + if resp is not None: return resp - except ArchiveLoadFailed as e: - print(e) - pass + except WbException as e: + last_exc = e - if any_found: - raise ArchiveLoadFailed('Resource Found, could not be Loaded') + if last_exc: + raise last_exc + #raise ArchiveLoadFailed('Resource Found, could not be Loaded') else: - raise ArchiveLoadFailed('No Resource Found') + raise NotFoundException('No Resource Found') #============================================================================= class DefaultResourceHandler(ResourceHandler): def __init__(self, index_source, warc_paths=''): - loaders = [WARCPathHandler(warc_paths, index_source), - LiveWebHandler() + loaders = [WARCPathLoader(warc_paths, index_source), + LiveWebLoader() ] super(DefaultResourceHandler, self).__init__(index_source, loaders) #============================================================================= class HandlerSeq(object): - def __init__(self, loaders): - self.loaders = loaders + def __init__(self, handlers): + self.handlers = handlers def __call__(self, params): - for loader in self.loaders: + last_exc = None + for handler in self.handlers: try: - res = loader(params) - if res: + res = handler(params) + if res is not None: return res - except ArchiveLoadFailed: - pass + except WbException as e: + last_exc = e - raise ArchiveLoadFailed('No Resource Found') + if last_exc: + raise last_exc + else: + raise NotFoundException('No Resource Found') diff --git a/rezag/indexsource.py b/rezag/indexsource.py index a597e0c4..ed4a26a6 100644 --- a/rezag/indexsource.py +++ b/rezag/indexsource.py @@ -14,6 +14,9 @@ from rezag.liverec import patched_requests as requests from rezag.utils import MementoUtils +WAYBACK_ORIG_SUFFIX = '{timestamp}id_/{url}' + + #============================================================================= class BaseIndexSource(object): def load_index(self, params): #pragma: no cover @@ -22,10 +25,10 @@ class BaseIndexSource(object): @staticmethod def res_template(template, params): src_params = params.get('_src_params') - if src_params: - res = template.format(**src_params) + if not src_params: + res = template.format(url=params['url']) else: - res = template + res = template.format(url=params['url'], **src_params) return res @@ -59,7 +62,7 @@ class RemoteIndexSource(BaseIndexSource): def load_index(self, params): api_url = self.res_template(self.api_url_template, params) - api_url += '?url=' + params['url'] + print('API URL', api_url) r = requests.get(api_url, timeout=params.get('_timeout')) if r.status_code >= 400: raise NotFoundException(api_url) @@ -169,7 +172,6 @@ class MementoIndexSource(BaseIndexSource): def get_timegate_links(self, params, closest): url = self.res_template(self.timegate_url, params) - url += params['url'] accept_dt = timestamp_to_http_date(closest) res = requests.head(url, headers={'Accept-Datetime': accept_dt}) if res.status_code >= 400: @@ -179,7 +181,6 @@ class MementoIndexSource(BaseIndexSource): def get_timemap_links(self, params): url = self.res_template(self.timemap_url, params) - url += params['url'] res = requests.get(url, timeout=params.get('_timeout')) if res.status_code >= 400: raise NotFoundException(url) @@ -200,9 +201,9 @@ class MementoIndexSource(BaseIndexSource): @staticmethod def from_timegate_url(timegate_url, path='link'): - return MementoIndexSource(timegate_url, - timegate_url + 'timemap/' + path + '/', - timegate_url + '{timestamp}id_/{url}') + return MementoIndexSource(timegate_url + '{url}', + timegate_url + 'timemap/' + path + '/{url}', + timegate_url + WAYBACK_ORIG_SUFFIX) def __str__(self): return 'memento' diff --git a/rezag/inputrequest.py b/rezag/inputrequest.py index 17b6ef6b..332716a2 100644 --- a/rezag/inputrequest.py +++ b/rezag/inputrequest.py @@ -1,4 +1,3 @@ -from pywb.utils.loaders import extract_client_cookie from pywb.utils.loaders import extract_post_query, append_post_query from pywb.utils.loaders import LimitReader from pywb.utils.statusandheaders import StatusAndHeadersParser @@ -9,7 +8,7 @@ from io import BytesIO #============================================================================= -class WSGIInputRequest(object): +class DirectWSGIInputRequest(object): def __init__(self, env): self.env = env @@ -20,26 +19,10 @@ class WSGIInputRequest(object): headers = {} for name, value in iteritems(self.env): + # will be set by requests to match actual host if name == 'HTTP_HOST': - #name = 'Host' - #value = splits.netloc - # will be set automatically continue - #elif name == 'HTTP_ORIGIN': - # name = 'Origin' - # value = (splits.scheme + '://' + splits.netloc) - - elif name == 'HTTP_X_CSRFTOKEN': - name = 'X-CSRFToken' - cookie_val = extract_client_cookie(env, 'csrftoken') - if cookie_val: - value = cookie_val - - #elif name == 'HTTP_X_FORWARDED_PROTO': - # name = 'X-Forwarded-Proto' - # value = splits.scheme - elif name.startswith('HTTP_'): name = name[5:].title().replace('_', '-') @@ -55,10 +38,7 @@ class WSGIInputRequest(object): return headers def get_req_body(self): - input_ = self.env.get('wsgi.input') - if not input_: - return None - + input_ = self.env['wsgi.input'] len_ = self._get_content_length() enc = self._get_header('Transfer-Encoding') @@ -70,9 +50,6 @@ class WSGIInputRequest(object): data = None return data - #buf = data.read().decode('utf-8') - #print(buf) - #return StringIO(buf) def _get_content_type(self): return self.env.get('CONTENT_TYPE') @@ -105,7 +82,7 @@ class WSGIInputRequest(object): #============================================================================= -class POSTInputRequest(WSGIInputRequest): +class POSTInputRequest(DirectWSGIInputRequest): def __init__(self, env): self.env = env diff --git a/rezag/responseloader.py b/rezag/responseloader.py index 52bf4760..ee835c80 100644 --- a/rezag/responseloader.py +++ b/rezag/responseloader.py @@ -2,6 +2,7 @@ from rezag.liverec import BaseRecorder from rezag.liverec import request as remote_request from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date +from pywb.utils.wbexception import LiveResourceException from pywb.warc.resolvingloader import ResolvingLoader from io import BytesIO @@ -29,7 +30,7 @@ def incr_reader(stream, header=None, size=8192): #============================================================================= -class WARCPathHandler(object): +class WARCPathLoader(object): def __init__(self, paths, cdx_source): self.paths = paths if isinstance(paths, str): @@ -108,7 +109,7 @@ class HeaderRecorder(BaseRecorder): #============================================================================= -class LiveWebHandler(object): +class LiveWebLoader(object): SKIP_HEADERS = (b'link', b'memento-datetime', b'content-location', @@ -140,14 +141,17 @@ class LiveWebHandler(object): method = input_req.get_req_method() data = input_req.get_req_body() - upstream_res = remote_request(url=load_url, - method=method, - recorder=recorder, - stream=True, - allow_redirects=False, - headers=req_headers, - data=data, - timeout=params.get('_timeout')) + try: + upstream_res = remote_request(url=load_url, + method=method, + recorder=recorder, + stream=True, + allow_redirects=False, + headers=req_headers, + data=data, + timeout=params.get('_timeout')) + except Exception: + raise LiveResourceException(load_url) resp_headers = recorder.get_header() @@ -175,7 +179,7 @@ class LiveWebHandler(object): return dt.strftime('%Y-%m-%dT%H:%M:%SZ') @staticmethod - def _make_warc_id(id_=None): + def _make_warc_id(id_=None): #pragma: no cover if not id_: id_ = uuid.uuid1() return ''.format(id_) diff --git a/rezag/utils.py b/rezag/utils.py index 2e5ae1c6..b10eeef8 100644 --- a/rezag/utils.py +++ b/rezag/utils.py @@ -77,6 +77,7 @@ class MementoUtils(object): from_date = timestamp_to_http_date(first_cdx['timestamp']) except StopIteration: first_cdx = None + return # first memento link yield MementoUtils.make_timemap_memento_link(first_cdx, datetime=from_date) @@ -91,4 +92,4 @@ class MementoUtils(object): # last memento link, if any if prev_cdx: - yield MementoUtils.make_timemap_memento_link(prev_cdx, end='') + yield MementoUtils.make_timemap_memento_link(prev_cdx, end='\n') diff --git a/setup.py b/setup.py index e3ce8061..cdb646ce 100755 --- a/setup.py +++ b/setup.py @@ -32,8 +32,11 @@ setup( 'rezag', ], install_requires=[ - 'pywb', + 'pywb==1.0b', ], + dependency_links=[ + 'git+https://github.com/ikreymer/pywb.git@py3#egg=pywb-1.0b-py3', + ], zip_safe=True, entry_points=""" [console_scripts] diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py index 3a9c916f..42f6387f 100644 --- a/test/test_dir_agg.py +++ b/test/test_dir_agg.py @@ -33,6 +33,9 @@ def setup_module(): shutil.copy(to_path('testdata/iana.cdxj'), coll_B) shutil.copy(to_path('testdata/dupes.cdxj'), coll_C) + with open(to_path(root_dir) + 'somefile', 'w') as fh: + fh.write('foo') + global dir_loader dir_loader = DirectoryIndexAggregator(dir_prefix, dir_path) @@ -121,7 +124,7 @@ def test_agg_dir_and_memento(): 'local': dir_loader} agg_source = SimpleAggregator(sources) - res = agg_source({'url': 'example.com/', 'param.coll': '*', 'closest': '20100512', 'limit': 6}) + res = agg_source({'url': 'example.com/', 'param.local.coll': '*', 'closest': '20100512', 'limit': 6}) exp = [ {'source': 'ia', 'timestamp': '20100513052358', 'load_url': 'http://web.archive.org/web/20100513052358id_/http://example.com/'}, @@ -144,7 +147,7 @@ def test_agg_no_dir_1(): def test_agg_no_dir_2(): - loader = DirectoryIndexAggregator(root_dir, 'no_such') + loader = DirectoryIndexAggregator(root_dir, '') res = loader({'url': 'example.com/', 'param.coll': 'X'}) exp = [] @@ -152,4 +155,31 @@ def test_agg_no_dir_2(): assert(to_json_list(res) == exp) +def test_agg_dir_sources_1(): + res = dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) + exp = {'sources': {'colls/A/indexes': 'file', + 'colls/B/indexes': 'file', + 'colls/C/indexes': 'file'} + } + + assert(res == exp) + + +def test_agg_dir_sources_2(): + res = dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '[A,C]'}) + exp = {'sources': {'colls/A/indexes': 'file', + 'colls/C/indexes': 'file'} + } + + assert(res == exp) + + +def test_agg_dir_sources_single_dir(): + loader = DirectoryIndexAggregator('testdata/', '') + res = loader.get_source_list({'url': 'example.com/'}) + + exp = {'sources': {}} + + assert(res == exp) + diff --git a/test/test_handlers.py b/test/test_handlers.py index 1e2d2822..f5ac05a2 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -42,13 +42,17 @@ def setup_module(self): source3 = SimpleAggregator({'example': FileIndexSource(to_path('testdata/example.cdxj'))}) handler3 = DefaultResourceHandler(source3, to_path('testdata/')) - add_route('/fallback', HandlerSeq([handler3, handler2, live_handler])) + add_route('/seq', HandlerSeq([handler3, + handler2])) - bottle.debug = True + add_route('/empty', HandlerSeq([])) + add_route('/invalid', HandlerSeq(['foo'])) + + application.debug = True global testapp testapp = webtest.TestApp(application) @@ -61,8 +65,23 @@ class TestResAgg(object): def setup(self): self.testapp = testapp + def test_list_handlers(self): + resp = self.testapp.get('/many?mode=list_modes') + assert resp.json == {'modes': ['list_modes', 'list_sources', 'index', 'resource']} + + resp = self.testapp.get('/many?mode=other') + assert resp.json == {'modes': ['list_modes', 'list_sources', 'index', 'resource']} + + # defaults to resource, must specify url + resp = self.testapp.get('/many', status=400) + assert resp.json == {'message': 'The "url" param is required'} + + def test_list_sources(self): + resp = self.testapp.get('/many?mode=list_sources') + assert resp.json == {'sources': {'local': 'file_dir', 'ia': 'memento', 'rhiz': 'memento', 'live': 'live'}} + def test_live_index(self): - resp = self.testapp.get('/live?url=http://httpbin.org/get&mode=index&output=json') + resp = self.testapp.get('/live?mode=index&url=http://httpbin.org/get&output=json') resp.charset = 'utf-8' res = to_json_list(resp.text) @@ -71,7 +90,8 @@ class TestResAgg(object): 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) def test_live_resource(self): - resp = self.testapp.get('/live?url=http://httpbin.org/get?foo=bar&mode=resource') + headers = {'foo': 'bar'} + resp = self.testapp.get('/live?url=http://httpbin.org/get?foo=bar', headers=headers) assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' @@ -82,7 +102,7 @@ class TestResAgg(object): def test_live_post_resource(self): - resp = self.testapp.post('/live?url=http://httpbin.org/post&mode=resource', + resp = self.testapp.post('/live?url=http://httpbin.org/post', OrderedDict([('foo', 'bar')])) assert resp.headers['WARC-Coll'] == 'live' @@ -204,6 +224,11 @@ foo=bar&test=abc""" assert resp.headers['WARC-Target-URI'] == 'http://example.com/' assert b'HTTP/1.1 200 OK' in resp.body + def test_error_fallback_live_not_found(self): + resp = self.testapp.get('/fallback?url=http://invalid.url-not-found', status=400) + + assert resp.json == {'message': 'http://invalid.url-not-found'} + def test_agg_local_revisit(self): resp = self.testapp.get('/many?url=http://www.example.com/&closest=20140127171251&sources=local') @@ -214,3 +239,24 @@ foo=bar&test=abc""" assert resp.headers['WARC-Refers-To-Date'] == '2014-01-27T17:12:00Z' assert b'HTTP/1.1 200 OK' in resp.body assert b'' in resp.body + + def test_error_invalid_index_output(self): + resp = self.testapp.get('/live?mode=index&url=http://httpbin.org/get&output=foobar', status=400) + + assert resp.json == {'message': 'output=foobar not supported'} + + def test_error_local_not_found(self): + resp = self.testapp.get('/many?url=http://not-found.error/&sources=local', status=404) + + assert resp.json == {'message': 'No Resource Found'} + + def test_error_empty(self): + resp = self.testapp.get('/empty?url=http://example.com/', status=404) + + assert resp.json == {'message': 'No Resource Found'} + + def test_error_invalid(self): + resp = self.testapp.get('/invalid?url=http://example.com/', status=500) + + assert resp.json['message'].startswith('Internal Error') + diff --git a/test/test_indexsource.py b/test/test_indexsource.py index 643bd3e0..c935a5fd 100644 --- a/test/test_indexsource.py +++ b/test/test_indexsource.py @@ -32,16 +32,20 @@ local_sources = [ remote_sources = [ - RemoteIndexSource('http://webenact.rhizome.org/all-cdx', + RemoteIndexSource('http://webenact.rhizome.org/all-cdx?url={url}', 'http://webenact.rhizome.org/all/{timestamp}id_/{url}'), - MementoIndexSource('http://webenact.rhizome.org/all/', - 'http://webenact.rhizome.org/all/timemap/*/', + MementoIndexSource('http://webenact.rhizome.org/all/{url}', + 'http://webenact.rhizome.org/all/timemap/*/{url}', 'http://webenact.rhizome.org/all/{timestamp}id_/{url}') ] +ait_source = RemoteIndexSource('http://wayback.archive-it.org/cdx?url={url}', + 'http://wayback.archive-it.org/all/{timestamp}id_/{url}') + def query_single_source(source, params): + string = str(source) return SimpleAggregator({'source': source})(params) @@ -182,4 +186,22 @@ def test_file_not_found(): +def test_ait_filters(): + ait_source = RemoteIndexSource('http://wayback.archive-it.org/cdx/search/cdx?url={url}&filter=filename:ARCHIVEIT-({colls})-.*', + 'http://wayback.archive-it.org/all/{timestamp}id_/{url}') + + cdxlist = query_single_source(ait_source, {'url': 'http://iana.org/', 'param.source.colls': '5610|933'}) + filenames = [cdx['filename'] for cdx in cdxlist] + + prefix = ('ARCHIVEIT-5610-', 'ARCHIVEIT-933-') + + assert(all([x.startswith(prefix) for x in filenames])) + + + cdxlist = query_single_source(ait_source, {'url': 'http://iana.org/', 'param.source.colls': '1883|366|905'}) + filenames = [cdx['filename'] for cdx in cdxlist] + + prefix = ('ARCHIVEIT-1883-', 'ARCHIVEIT-366-', 'ARCHIVEIT-905-') + + assert(all([x.startswith(prefix) for x in filenames])) diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index be49fe9c..59040670 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -27,10 +27,11 @@ aggs = {'simple': SimpleAggregator(sources), 'processes': ThreadedTimeoutAggregator(sources, timeout=5.0, use_processes=True), } -#@pytest.mark.parametrize("agg", aggs, ids=["simple", "gevent_timeout"]) -def pytest_generate_tests(metafunc): - metafunc.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) +#def pytest_generate_tests(metafunc): +# metafunc.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) + +@pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_1(agg): url = 'http://iana.org/' res = agg(dict(url=url, closest='20140126000000', limit=5)) @@ -46,6 +47,7 @@ def test_mem_agg_index_1(agg): assert(json_list(res) == exp) +@pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_2(agg): url = 'http://example.com/' res = agg(dict(url=url, closest='20100512', limit=6)) @@ -60,6 +62,7 @@ def test_mem_agg_index_2(agg): assert(json_list(res) == exp) +@pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_3(agg): url = 'http://vvork.com/' res = agg(dict(url=url, closest='20141001', limit=5)) @@ -73,6 +76,7 @@ def test_mem_agg_index_3(agg): assert(json_list(res) == exp) +@pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_4(agg): url = 'http://vvork.com/' res = agg(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) @@ -83,10 +87,11 @@ def test_mem_agg_index_4(agg): assert(json_list(res) == exp) -def test_handler_output_cdxj(agg): - loader = IndexHandler(agg) +def test_handler_output_cdxj(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) url = 'http://vvork.com/' - res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) exp = """\ com,vvork)/ 20141006184357 {"url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} @@ -96,10 +101,11 @@ com,vvork)/ 20131004231540 {"url": "http://vvork.com/", "mem_rel": "last memento assert(''.join(res) == exp) -def test_handler_output_json(agg): - loader = IndexHandler(agg) +def test_handler_output_json(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) url = 'http://vvork.com/' - res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) + res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) exp = """\ {"urlkey": "com,vvork)/", "timestamp": "20141006184357", "url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} @@ -109,22 +115,50 @@ def test_handler_output_json(agg): assert(''.join(res) == exp) -def test_handler_output_link(agg): - loader = IndexHandler(agg) +def test_handler_output_link(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) url = 'http://vvork.com/' - res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) + res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) exp = """\ ; rel="memento"; datetime="Mon, 06 Oct 2014 18:43:57 GMT"; src="rhiz", -; rel="memento"; datetime="Fri, 04 Oct 2013 23:15:40 GMT"; src="ait"\ +; rel="memento"; datetime="Fri, 04 Oct 2013 23:15:40 GMT"; src="ait" """ assert(''.join(res) == exp) -def test_handler_output_text(agg): - loader = IndexHandler(agg) +def test_handler_output_link_2(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) + url = 'http://iana.org/' + res = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) + + exp = """\ +; rel="memento"; datetime="Sun, 26 Jan 2014 09:37:43 GMT"; src="ia", +; rel="memento"; datetime="Sun, 26 Jan 2014 20:06:24 GMT"; src="local", +; rel="memento"; datetime="Thu, 23 Jan 2014 03:47:55 GMT"; src="ia", +; rel="memento"; datetime="Wed, 29 Jan 2014 17:52:03 GMT"; src="ia", +; rel="memento"; datetime="Tue, 07 Jan 2014 04:05:52 GMT"; src="ait" +""" + assert(''.join(res) == exp) + + +def test_handler_output_link_3(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) + url = 'http://foo.bar.non-existent' + res = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) + + exp = '' + + assert(''.join(res) == exp) + +def test_handler_output_text(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) url = 'http://vvork.com/' - res = loader(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) + res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) exp = """\ com,vvork)/ 20141006184357 http://www.vvork.com/ memento http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/ http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/ rhiz @@ -133,9 +167,10 @@ com,vvork)/ 20131004231540 http://vvork.com/ last memento http://wayback.archive assert(''.join(res) == exp) -def test_handler_list_sources(agg): - loader = IndexHandler(agg) - res = loader(dict(mode='sources')) +def test_handler_list_sources(): + agg = GeventTimeoutAggregator(sources, timeout=5.0) + handler = IndexHandler(agg) + res = handler(dict(mode='list_sources')) assert(res == {'sources': {'bl': 'memento', 'ait': 'memento', @@ -143,4 +178,3 @@ def test_handler_list_sources(agg): 'rhiz': 'memento', 'local': 'file'}}) - From 1f3763d02ce585e64bfca423c5ee2197f4cbc8d3 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 1 Mar 2016 14:46:05 -0800 Subject: [PATCH 008/112] misc fixes: add route listing, more not found tests, timemap use file:// with ranges --- rezag/aggindexsource.py | 29 ++++++++++++++------------ rezag/app.py | 33 ++++++++++++++++++++++------- rezag/handlers.py | 6 +++++- rezag/indexsource.py | 12 ++++++----- rezag/responseloader.py | 45 +++++++++++++++++++++++++++------------- rezag/utils.py | 2 +- test/test_dir_agg.py | 8 +++---- test/test_handlers.py | 4 ++-- test/test_indexsource.py | 4 +--- test/test_memento_agg.py | 17 ++++++++++++++- 10 files changed, 108 insertions(+), 52 deletions(-) diff --git a/rezag/aggindexsource.py b/rezag/aggindexsource.py index 292622c9..738c781d 100644 --- a/rezag/aggindexsource.py +++ b/rezag/aggindexsource.py @@ -59,11 +59,14 @@ class BaseAggregator(object): params['_all_src_params'] = src_params - def load_child_source(self, name, source, all_params): + def load_child_source_list(self, name, source, params): + return list(self.load_child_source(name, source, params)) + + def load_child_source(self, name, source, params): try: - _src_params = all_params['_all_src_params'].get(name) - all_params['_src_params'] = _src_params - cdx_iter = source.load_index(all_params) + _src_params = params['_all_src_params'].get(name) + params['_src_params'] = _src_params + cdx_iter = source.load_index(params) except NotFoundException as nf: print('Not found in ' + name) cdx_iter = iter([]) @@ -75,10 +78,10 @@ class BaseAggregator(object): cdx['source'] = name return cdx - return [add_name(cdx) for cdx in cdx_iter] + return (add_name(cdx) for cdx in cdx_iter) def load_index(self, params): - iter_list = list(self._load_all(params)) + iter_list = self._load_all(params) #optimization: if only a single entry (or empty) just load directly if len(iter_list) <= 1: @@ -130,9 +133,9 @@ class SeqAggMixin(object): def _load_all(self, params): - sources = list(self._iter_sources(params)) - return list([self.load_child_source(name, source, params) - for name, source in sources]) + sources = self._iter_sources(params) + return [self.load_child_source(name, source, params) + for name, source in sources] #============================================================================= @@ -232,7 +235,7 @@ class ConcurrentMixin(object): with self.pool_class(max_workers=self.size) as executor: def do_spawn(name, source): - return executor.submit(self.load_child_source, + return executor.submit(self.load_child_source_list, name, source, params), name jobs = dict([do_spawn(name, source) for name, source in sources]) @@ -255,10 +258,10 @@ class ThreadedTimeoutAggregator(TimeoutMixin, ConcurrentMixin, BaseSourceListAgg #============================================================================= -class BaseDirectoryIndexAggregator(BaseAggregator): +class BaseDirectoryIndexSource(BaseAggregator): CDX_EXT = ('.cdx', '.cdxj') - def __init__(self, base_prefix, base_dir): + def __init__(self, base_prefix, base_dir=''): self.base_prefix = base_prefix self.base_dir = base_dir @@ -299,7 +302,7 @@ class BaseDirectoryIndexAggregator(BaseAggregator): return 'file_dir' -class DirectoryIndexAggregator(SeqAggMixin, BaseDirectoryIndexAggregator): +class DirectoryIndexSource(SeqAggMixin, BaseDirectoryIndexSource): pass diff --git a/rezag/app.py b/rezag/app.py index 90275d21..bb4b4892 100644 --- a/rezag/app.py +++ b/rezag/app.py @@ -1,5 +1,6 @@ from rezag.inputrequest import DirectWSGIInputRequest, POSTInputRequest from bottle import route, request, response, default_app, abort +import bottle from pywb.utils.wbexception import WbException @@ -11,37 +12,53 @@ def err_handler(exc): response.content_type = 'application/json' return json.dumps({'message': exc.body}) + def wrap_error(func): - def do_d(*args, **kwargs): + def wrap_func(*args, **kwargs): try: return func(*args, **kwargs) except WbException as exc: - if application.debug: + if bottle.debug: traceback.print_exc() abort(exc.status(), exc.msg) except Exception as e: - if application.debug: + if bottle.debug: traceback.print_exc() abort(500, 'Internal Error: ' + str(e)) - return do_d + return wrap_func +route_dict = {} + def add_route(path, handler): + @route(path, 'ANY') @wrap_error - def direct_input_request(mode=''): + def direct_input_request(): params = dict(request.query) params['_input_req'] = DirectWSGIInputRequest(request.environ) return handler(params) + @route(path + '/postreq', 'POST') @wrap_error - def post_fullrequest(mode=''): + def post_fullrequest(): params = dict(request.query) params['_input_req'] = POSTInputRequest(request.environ) return handler(params) - route(path + '/postreq', method=['POST'], callback=post_fullrequest) - route(path, method=['ANY'], callback=direct_input_request) + global route_dict + handler_dict = {'handler': handler.get_supported_modes()} + route_dict[path] = handler_dict + route_dict[path + '/postreq'] = handler_dict + +@route('/') +def list_routes(): + return route_dict + + + + + application = default_app() diff --git a/rezag/handlers.py b/rezag/handlers.py index ff19c725..a2a7fcd7 100644 --- a/rezag/handlers.py +++ b/rezag/handlers.py @@ -7,7 +7,7 @@ from bottle import response #============================================================================= def to_cdxj(cdx_iter, fields): - response.headers['Content-Type'] = 'application/x-cdxj' + response.headers['Content-Type'] = 'text/x-cdxj' return [cdx.to_cdxj(fields) for cdx in cdx_iter] def to_json(cdx_iter, fields): @@ -120,6 +120,10 @@ class HandlerSeq(object): def __init__(self, handlers): self.handlers = handlers + def get_supported_modes(self): + return [] + # return zip([self.handlers.get_supported_modes()] + def __call__(self, params): last_exc = None for handler in self.handlers: diff --git a/rezag/indexsource.py b/rezag/indexsource.py index ed4a26a6..06822150 100644 --- a/rezag/indexsource.py +++ b/rezag/indexsource.py @@ -45,10 +45,13 @@ class FileIndexSource(BaseIndexSource): except IOError: raise NotFoundException(filename) - with fh: - gen = iter_range(fh, params['key'], params['end_key']) - for line in gen: - yield CDXObject(line) + def do_load(fh): + with fh: + gen = iter_range(fh, params['key'], params['end_key']) + for line in gen: + yield CDXObject(line) + + return do_load(fh) def __str__(self): return 'file' @@ -62,7 +65,6 @@ class RemoteIndexSource(BaseIndexSource): def load_index(self, params): api_url = self.res_template(self.api_url_template, params) - print('API URL', api_url) r = requests.get(api_url, timeout=params.get('_timeout')) if r.status_code >= 400: raise NotFoundException(api_url) diff --git a/rezag/responseloader.py b/rezag/responseloader.py index ee835c80..ed4cc6aa 100644 --- a/rezag/responseloader.py +++ b/rezag/responseloader.py @@ -12,21 +12,37 @@ import uuid #============================================================================= -def incr_reader(stream, header=None, size=8192): - if header: - yield header +class StreamIter(object): + def __init__(self, stream, header=None, size=8192): + self.stream = stream + self.header = header + self.size = size - while True: - data = stream.read(size) + def __iter__(self): + return self + + def __next__(self): + if self.header: + header = self.header + self.header = None + return header + + data = self.stream.read(self.size) if data: - yield data - else: - break + return data - try: - stream.close() - except: - pass + self.close() + raise StopIteration + + def close(self): + if not self.stream: + return + + try: + self.stream.close() + self.stream = None + except Exception: + pass #============================================================================= @@ -83,7 +99,8 @@ class WARCPathLoader(object): response.headers['WARC-Refers-To-Date'] = payload.rec_headers.get_header('WARC-Date') headers.stream.close() - return incr_reader(record.stream) + res = StreamIter(record.stream) + return res #============================================================================= @@ -172,7 +189,7 @@ class LiveWebLoader(object): except: raise - return incr_reader(upstream_res.raw, header=resp_headers) + return StreamIter(upstream_res.raw, header=resp_headers) @staticmethod def _make_date(dt): diff --git a/rezag/utils.py b/rezag/utils.py index b10eeef8..ab44aa4e 100644 --- a/rezag/utils.py +++ b/rezag/utils.py @@ -59,7 +59,7 @@ class MementoUtils(object): def make_timemap_memento_link(cdx, datetime=None, rel='memento', end=',\n'): url = cdx.get('load_url') if not url: - url = 'filename://' + cdx.get('filename') + url = 'file://{0}:{1}:{2}'.format(cdx.get('filename'), cdx.get('offset'), cdx.get('length')) memento = '<{0}>; rel="{1}"; datetime="{2}"; src="{3}"' + end diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py index 42f6387f..6ec1c6a4 100644 --- a/test/test_dir_agg.py +++ b/test/test_dir_agg.py @@ -5,7 +5,7 @@ import json from .testutils import to_path -from rezag.aggindexsource import DirectoryIndexAggregator, SimpleAggregator +from rezag.aggindexsource import DirectoryIndexSource, SimpleAggregator from rezag.indexsource import MementoIndexSource @@ -37,7 +37,7 @@ def setup_module(): fh.write('foo') global dir_loader - dir_loader = DirectoryIndexAggregator(dir_prefix, dir_path) + dir_loader = DirectoryIndexSource(dir_prefix, dir_path) global orig_cwd orig_cwd = os.getcwd() @@ -147,7 +147,7 @@ def test_agg_no_dir_1(): def test_agg_no_dir_2(): - loader = DirectoryIndexAggregator(root_dir, '') + loader = DirectoryIndexSource(root_dir, '') res = loader({'url': 'example.com/', 'param.coll': 'X'}) exp = [] @@ -175,7 +175,7 @@ def test_agg_dir_sources_2(): def test_agg_dir_sources_single_dir(): - loader = DirectoryIndexAggregator('testdata/', '') + loader = DirectoryIndexSource('testdata/', '') res = loader.get_source_list({'url': 'example.com/'}) exp = {'sources': {}} diff --git a/test/test_handlers.py b/test/test_handlers.py index f5ac05a2..55d63d62 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -6,7 +6,7 @@ from rezag.handlers import DefaultResourceHandler, HandlerSeq from rezag.indexsource import MementoIndexSource, FileIndexSource, LiveIndexSource from rezag.aggindexsource import GeventTimeoutAggregator, SimpleAggregator -from rezag.aggindexsource import DirectoryIndexAggregator +from rezag.aggindexsource import DirectoryIndexSource from rezag.app import add_route, application @@ -18,7 +18,7 @@ from .testutils import to_path import json sources = { - 'local': DirectoryIndexAggregator(to_path('testdata/'), ''), + 'local': DirectoryIndexSource(to_path('testdata/'), ''), 'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), 'rhiz': MementoIndexSource.from_timegate_url('http://webenact.rhizome.org/vvork/', path='*'), 'live': LiveIndexSource(), diff --git a/test/test_indexsource.py b/test/test_indexsource.py index c935a5fd..5853f02c 100644 --- a/test/test_indexsource.py +++ b/test/test_indexsource.py @@ -162,7 +162,6 @@ def test_all_not_found(source): assert(key_ts_res(res) == expected) - # ============================================================================ def test_another_remote_not_found(): source = MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/') @@ -180,12 +179,11 @@ def test_file_not_found(): url = 'http://x-not-found-x.notfound/' res = query_single_source(source, dict(url=url, limit=3)) - expected = '' assert(key_ts_res(res) == expected) - +# ============================================================================ def test_ait_filters(): ait_source = RemoteIndexSource('http://wayback.archive-it.org/cdx/search/cdx?url={url}&filter=filename:ARCHIVEIT-({colls})-.*', 'http://wayback.archive-it.org/all/{timestamp}id_/{url}') diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index 59040670..9a1b9209 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -27,6 +27,13 @@ aggs = {'simple': SimpleAggregator(sources), 'processes': ThreadedTimeoutAggregator(sources, timeout=5.0, use_processes=True), } +nf = {'notfound': FileIndexSource(to_path('testdata/not-found-x'))} +agg_nf = {'simple': SimpleAggregator(nf), + 'gevent': GeventTimeoutAggregator(nf, timeout=5.0), + 'threaded': ThreadedTimeoutAggregator(nf, timeout=5.0), + 'processes': ThreadedTimeoutAggregator(nf, timeout=5.0, use_processes=True), + } + #def pytest_generate_tests(metafunc): # metafunc.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) @@ -87,6 +94,14 @@ def test_mem_agg_index_4(agg): assert(json_list(res) == exp) +@pytest.mark.parametrize("agg", list(agg_nf.values()), ids=list(agg_nf.keys())) +def test_mem_agg_not_found(agg): + url = 'http://vvork.com/' + res = agg(dict(url=url, closest='20141001', limit=2)) + + assert(json_list(res) == []) + + def test_handler_output_cdxj(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) @@ -136,7 +151,7 @@ def test_handler_output_link_2(): exp = """\ ; rel="memento"; datetime="Sun, 26 Jan 2014 09:37:43 GMT"; src="ia", -; rel="memento"; datetime="Sun, 26 Jan 2014 20:06:24 GMT"; src="local", +; rel="memento"; datetime="Sun, 26 Jan 2014 20:06:24 GMT"; src="local", ; rel="memento"; datetime="Thu, 23 Jan 2014 03:47:55 GMT"; src="ia", ; rel="memento"; datetime="Wed, 29 Jan 2014 17:52:03 GMT"; src="ia", ; rel="memento"; datetime="Tue, 07 Jan 2014 04:05:52 GMT"; src="ait" From 65e969a492b59413e5f2a064dd856396843967e8 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 2 Mar 2016 18:13:13 -0800 Subject: [PATCH 009/112] errors and timeouts reported back to the user via ResErrors header add new /index, /resource access point system --- rezag/aggindexsource.py | 39 ++++++++----- rezag/app.py | 60 +++++++++++++------- rezag/handlers.py | 61 ++++++++++++--------- rezag/responseloader.py | 13 ++++- rezag/utils.py | 11 +++- test/test_dir_agg.py | 31 +++++++---- test/test_handlers.py | 115 +++++++++++++++++++++++++++++---------- test/test_indexsource.py | 41 +++++++++----- test/test_memento_agg.py | 76 +++++++++++++++++++++----- test/test_timeouts.py | 29 +++++++--- 10 files changed, 333 insertions(+), 143 deletions(-) diff --git a/rezag/aggindexsource.py b/rezag/aggindexsource.py index 738c781d..02885f58 100644 --- a/rezag/aggindexsource.py +++ b/rezag/aggindexsource.py @@ -13,9 +13,10 @@ from pywb.cdx.query import CDXQuery from heapq import merge from collections import deque +from itertools import chain from rezag.indexsource import FileIndexSource -from pywb.utils.wbexception import NotFoundException +from pywb.utils.wbexception import NotFoundException, WbException import six import glob @@ -29,13 +30,10 @@ class BaseAggregator(object): query = CDXQuery(params) self._set_src_params(params) - try: - cdx_iter = self.load_index(query.params) - except NotFoundException as nf: - cdx_iter = iter([]) + cdx_iter, errs = self.load_index(query.params) cdx_iter = process_cdx(cdx_iter, query) - return cdx_iter + return cdx_iter, dict(errs) def _set_src_params(self, params): src_params = {} @@ -60,16 +58,23 @@ class BaseAggregator(object): params['_all_src_params'] = src_params def load_child_source_list(self, name, source, params): - return list(self.load_child_source(name, source, params)) + res = self.load_child_source(name, source, params) + return list(res[0]), res[1] def load_child_source(self, name, source, params): try: _src_params = params['_all_src_params'].get(name) params['_src_params'] = _src_params - cdx_iter = source.load_index(params) - except NotFoundException as nf: - print('Not found in ' + name) + res = source.load_index(params) + if isinstance(res, tuple): + cdx_iter, err_list = res + else: + cdx_iter = res + err_list = [] + except WbException as wbe: + #print('Not found in ' + name) cdx_iter = iter([]) + err_list = [(name, repr(wbe))] def add_name(cdx): if cdx.get('source'): @@ -78,10 +83,13 @@ class BaseAggregator(object): cdx['source'] = name return cdx - return (add_name(cdx) for cdx in cdx_iter) + return (add_name(cdx) for cdx in cdx_iter), err_list def load_index(self, params): - iter_list = self._load_all(params) + res_list = self._load_all(params) + + iter_list = [res[0] for res in res_list] + err_list = chain(*[res[1] for res in res_list]) #optimization: if only a single entry (or empty) just load directly if len(iter_list) <= 1: @@ -89,7 +97,7 @@ class BaseAggregator(object): else: cdx_iter = merge(*(iter_list)) - return cdx_iter + return cdx_iter, err_list def _on_source_error(self, name): #pragma: no cover pass @@ -207,6 +215,7 @@ class GeventMixin(object): if job.value is not None: results.append(job.value) else: + results.append((iter([]), [(name, 'timeout')])) self._on_source_error(name) return results @@ -247,7 +256,9 @@ class ConcurrentMixin(object): results.append(job.result()) for job in res_not_done: - self._on_source_error(jobs[job]) + name = jobs[job] + results.append((iter([]), [(name, 'timeout')])) + self._on_source_error(name) return results diff --git a/rezag/app.py b/rezag/app.py index bb4b4892..23f20bce 100644 --- a/rezag/app.py +++ b/rezag/app.py @@ -2,25 +2,49 @@ from rezag.inputrequest import DirectWSGIInputRequest, POSTInputRequest from bottle import route, request, response, default_app, abort import bottle -from pywb.utils.wbexception import WbException - import traceback import json +JSON_CT = 'application/json; charset=utf-8' + def err_handler(exc): response.status = exc.status_code - response.content_type = 'application/json' - return json.dumps({'message': exc.body}) + response.content_type = JSON_CT + err_msg = json.dumps({'message': exc.body}) + response.headers['ResErrors'] = err_msg + return err_msg def wrap_error(func): def wrap_func(*args, **kwargs): try: - return func(*args, **kwargs) - except WbException as exc: - if bottle.debug: - traceback.print_exc() - abort(exc.status(), exc.msg) + res, errs = func(*args, **kwargs) + + if res: + if errs: + response.headers['ResErrors'] = json.dumps(errs) + return res + + last_exc = errs.pop('last_exc', None) + if last_exc: + if bottle.debug: + traceback.print_exc() + + response.status = last_exc.status() + message = last_exc.msg + else: + response.status = 404 + message = 'No Resource Found' + + response.content_type = JSON_CT + res = {'message': message} + if errs: + res['errors'] = errs + + err_msg = json.dumps(res) + response.headers['ResErrors'] = err_msg + return err_msg + except Exception as e: if bottle.debug: traceback.print_exc() @@ -32,35 +56,33 @@ def wrap_error(func): route_dict = {} def add_route(path, handler): - @route(path, 'ANY') + @route([path, path + '/'], 'ANY') @wrap_error - def direct_input_request(): + def direct_input_request(mode=''): params = dict(request.query) + params['mode'] = mode params['_input_req'] = DirectWSGIInputRequest(request.environ) return handler(params) - @route(path + '/postreq', 'POST') + @route([path + '/postreq', path + '//postreq'], 'POST') @wrap_error - def post_fullrequest(): + def post_fullrequest(mode=''): params = dict(request.query) + params['mode'] = mode params['_input_req'] = POSTInputRequest(request.environ) return handler(params) global route_dict - handler_dict = {'handler': handler.get_supported_modes()} + handler_dict = handler.get_supported_modes() route_dict[path] = handler_dict route_dict[path + '/postreq'] = handler_dict + @route('/') def list_routes(): return route_dict - - - - - application = default_app() application.default_error_handler = err_handler diff --git a/rezag/handlers.py b/rezag/handlers.py index a2a7fcd7..fdc6cc65 100644 --- a/rezag/handlers.py +++ b/rezag/handlers.py @@ -39,12 +39,13 @@ class IndexHandler(object): self.opts = opts or {} def get_supported_modes(self): - return dict(modes=['list_modes', 'list_sources', 'index']) + return dict(modes=['list_sources', 'index']) def _load_index_source(self, params): url = params.get('url') if not url: - raise BadRequestException('The "url" param is required') + errs = dict(last_exc=BadRequestException('The "url" param is required')) + return None, errs input_req = params.get('_input_req') if input_req: @@ -55,21 +56,25 @@ class IndexHandler(object): def __call__(self, params): mode = params.get('mode', 'index') if mode == 'list_sources': - return self.index_source.get_source_list(params) + return self.index_source.get_source_list(params), {} - if mode == 'list_modes' or mode != 'index': - return self.get_supported_modes() + if mode != 'index': + return self.get_supported_modes(), {} output = params.get('output', self.DEF_OUTPUT) fields = params.get('fields') handler = self.OUTPUTS.get(output) if not handler: - raise BadRequestException('output={0} not supported'.format(output)) + errs = dict(last_exc=BadRequestException('output={0} not supported'.format(output))) + return None, errs + + cdx_iter, errs = self._load_index_source(params) + if not cdx_iter: + return None, errs - cdx_iter = self._load_index_source(params) res = handler(cdx_iter, fields) - return res + return res, errs #============================================================================= @@ -87,7 +92,10 @@ class ResourceHandler(IndexHandler): if params.get('mode', 'resource') != 'resource': return super(ResourceHandler, self).__call__(params) - cdx_iter = self._load_index_source(params) + cdx_iter, errs = self._load_index_source(params) + if not cdx_iter: + return None, errs + last_exc = None for cdx in cdx_iter: @@ -95,15 +103,15 @@ class ResourceHandler(IndexHandler): try: resp = loader(cdx, params) if resp is not None: - return resp + return resp, errs except WbException as e: last_exc = e + errs[str(loader)] = repr(e) if last_exc: - raise last_exc - #raise ArchiveLoadFailed('Resource Found, could not be Loaded') - else: - raise NotFoundException('No Resource Found') + errs['last_exc'] = last_exc + + return None, errs #============================================================================= @@ -121,20 +129,19 @@ class HandlerSeq(object): self.handlers = handlers def get_supported_modes(self): - return [] - # return zip([self.handlers.get_supported_modes()] + if self.handlers: + return self.handlers[0].get_supported_modes() + else: + return {} def __call__(self, params): - last_exc = None + all_errs = {} for handler in self.handlers: - try: - res = handler(params) - if res is not None: - return res - except WbException as e: - last_exc = e + res, errs = handler(params) + all_errs.update(errs) + if res is not None: + return res, all_errs + + return None, all_errs + - if last_exc: - raise last_exc - else: - raise NotFoundException('No Resource Found') diff --git a/rezag/responseloader.py b/rezag/responseloader.py index ed4cc6aa..c3c6dd5c 100644 --- a/rezag/responseloader.py +++ b/rezag/responseloader.py @@ -58,6 +58,10 @@ class WARCPathLoader(object): no_record_parse=True) self.cdx_source = cdx_source + def cdx_index_source(self, *args, **kwargs): + cdx_iter, errs = self.cdx_source(*args, **kwargs) + return cdx_iter + def warc_paths(self): for path in self.paths: def check(filename, cdx): @@ -83,7 +87,7 @@ class WARCPathLoader(object): headers, payload = (self.resolve_loader. load_headers_and_payload(cdx, failed_files, - self.cdx_source)) + self.cdx_index_source)) record = payload @@ -102,6 +106,9 @@ class WARCPathLoader(object): res = StreamIter(record.stream) return res + def __str__(self): + return 'WARCPathLoader' + #============================================================================= class HeaderRecorder(BaseRecorder): @@ -200,3 +207,7 @@ class LiveWebLoader(object): if not id_: id_ = uuid.uuid1() return ''.format(id_) + + def __str__(self): + return 'LiveWebLoader' + diff --git a/rezag/utils.py b/rezag/utils.py index ab44aa4e..94c67975 100644 --- a/rezag/utils.py +++ b/rezag/utils.py @@ -2,7 +2,7 @@ import re import six from pywb.utils.timeutils import timestamp_to_http_date - +from pywb.utils.wbexception import BadRequestException LINK_SPLIT = re.compile(',\s*(?=[<])') LINK_SEG_SPLIT = re.compile(';\s*') @@ -10,6 +10,11 @@ LINK_URL = re.compile('<(.*)>') LINK_PROP = re.compile('([\w]+)="([^"]+)') +#================================================================= +class MementoException(BadRequestException): + pass + + #================================================================= class MementoUtils(object): @staticmethod @@ -22,7 +27,7 @@ class MementoUtils(object): props = LINK_SEG_SPLIT.split(link) m = LINK_URL.match(props[0]) if not m: - raise Exception('Invalid Link Url: ' + props[0]) + raise MementoException('Invalid Link Url: ' + props[0]) result = dict(url=m.group(1)) key = '' @@ -31,7 +36,7 @@ class MementoUtils(object): for prop in props[1:]: m = LINK_PROP.match(prop) if not m: - raise Exception('Invalid prop ' + prop) + raise MementoException('Invalid prop ' + prop) name = m.group(1) value = m.group(2) diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py index 6ec1c6a4..7fe75920 100644 --- a/test/test_dir_agg.py +++ b/test/test_dir_agg.py @@ -59,43 +59,47 @@ def to_json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source'] def test_agg_no_coll_set(): - res = dir_loader(dict(url='example.com/')) + res, errs = dir_loader(dict(url='example.com/')) assert(to_json_list(res) == []) - + assert(errs == {}) def test_agg_collA_found(): - res = dir_loader({'url': 'example.com/', 'param.coll': 'A'}) + res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'A'}) exp = [{'source': 'colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_collB(): - res = dir_loader({'url': 'example.com/', 'param.coll': 'B'}) + res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'B'}) exp = [] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_collB_found(): - res = dir_loader({'url': 'iana.org/', 'param.coll': 'B'}) + res, errs = dir_loader({'url': 'iana.org/', 'param.coll': 'B'}) exp = [{'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] assert(to_json_list(res) == exp) + assert(errs == {}) def test_extra_agg_collB(): agg_source = SimpleAggregator({'dir': dir_loader}) - res = agg_source({'url': 'iana.org/', 'param.coll': 'B'}) + res, errs = agg_source({'url': 'iana.org/', 'param.coll': 'B'}) exp = [{'source': 'dir:colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_all_found_1(): - res = dir_loader({'url': 'iana.org/', 'param.coll': '*'}) + res, errs = dir_loader({'url': 'iana.org/', 'param.coll': '*'}) exp = [ {'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}, @@ -104,10 +108,11 @@ def test_agg_all_found_1(): ] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_all_found_2(): - res = dir_loader({'url': 'example.com/', 'param.coll': '*'}) + res, errs = dir_loader({'url': 'example.com/', 'param.coll': '*'}) exp = [ {'source': 'colls/C/indexes', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, @@ -116,6 +121,7 @@ def test_agg_all_found_2(): ] assert(to_json_list(res) == exp) + assert(errs == {}) @@ -124,7 +130,7 @@ def test_agg_dir_and_memento(): 'local': dir_loader} agg_source = SimpleAggregator(sources) - res = agg_source({'url': 'example.com/', 'param.local.coll': '*', 'closest': '20100512', 'limit': 6}) + res, errs = agg_source({'url': 'example.com/', 'param.local.coll': '*', 'closest': '20100512', 'limit': 6}) exp = [ {'source': 'ia', 'timestamp': '20100513052358', 'load_url': 'http://web.archive.org/web/20100513052358id_/http://example.com/'}, @@ -136,23 +142,26 @@ def test_agg_dir_and_memento(): ] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_no_dir_1(): - res = dir_loader({'url': 'example.com/', 'param.coll': 'X'}) + res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'X'}) exp = [] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_no_dir_2(): loader = DirectoryIndexSource(root_dir, '') - res = loader({'url': 'example.com/', 'param.coll': 'X'}) + res, errs = loader({'url': 'example.com/', 'param.coll': 'X'}) exp = [] assert(to_json_list(res) == exp) + assert(errs == {}) def test_agg_dir_sources_1(): diff --git a/test/test_handlers.py b/test/test_handlers.py index 55d63d62..3e911224 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -50,7 +50,7 @@ def setup_module(self): handler2])) add_route('/empty', HandlerSeq([])) - add_route('/invalid', HandlerSeq(['foo'])) + add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) application.debug = True global testapp @@ -65,23 +65,49 @@ class TestResAgg(object): def setup(self): self.testapp = testapp + def test_list_routes(self): + resp = self.testapp.get('/') + res = resp.json + assert set(res.keys()) == set(['/empty', '/empty/postreq', + '/fallback', '/fallback/postreq', + '/live', '/live/postreq', + '/many', '/many/postreq', + '/posttest', '/posttest/postreq', + '/seq', '/seq/postreq', + '/invalid', '/invalid/postreq']) + + assert res['/fallback'] == {'modes': ['list_sources', 'index', 'resource']} + def test_list_handlers(self): - resp = self.testapp.get('/many?mode=list_modes') - assert resp.json == {'modes': ['list_modes', 'list_sources', 'index', 'resource']} + resp = self.testapp.get('/many') + assert resp.json == {'modes': ['list_sources', 'index', 'resource']} + assert 'ResErrors' not in resp.headers - resp = self.testapp.get('/many?mode=other') - assert resp.json == {'modes': ['list_modes', 'list_sources', 'index', 'resource']} + resp = self.testapp.get('/many/other') + assert resp.json == {'modes': ['list_sources', 'index', 'resource']} + assert 'ResErrors' not in resp.headers - # defaults to resource, must specify url - resp = self.testapp.get('/many', status=400) + def test_list_errors(self): + # must specify url for index or resource + resp = self.testapp.get('/many/index', status=400) assert resp.json == {'message': 'The "url" param is required'} + assert resp.text == resp.headers['ResErrors'] + + resp = self.testapp.get('/many/index', status=400) + assert resp.json == {'message': 'The "url" param is required'} + assert resp.text == resp.headers['ResErrors'] + + resp = self.testapp.get('/many/resource', status=400) + assert resp.json == {'message': 'The "url" param is required'} + assert resp.text == resp.headers['ResErrors'] def test_list_sources(self): - resp = self.testapp.get('/many?mode=list_sources') + resp = self.testapp.get('/many/list_sources') assert resp.json == {'sources': {'local': 'file_dir', 'ia': 'memento', 'rhiz': 'memento', 'live': 'live'}} + assert 'ResErrors' not in resp.headers def test_live_index(self): - resp = self.testapp.get('/live?mode=index&url=http://httpbin.org/get&output=json') + resp = self.testapp.get('/live/index?url=http://httpbin.org/get&output=json') resp.charset = 'utf-8' res = to_json_list(resp.text) @@ -91,7 +117,7 @@ class TestResAgg(object): def test_live_resource(self): headers = {'foo': 'bar'} - resp = self.testapp.get('/live?url=http://httpbin.org/get?foo=bar', headers=headers) + resp = self.testapp.get('/live/resource?url=http://httpbin.org/get?foo=bar', headers=headers) assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' @@ -100,9 +126,10 @@ class TestResAgg(object): assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body + assert 'ResErrors' not in resp.headers def test_live_post_resource(self): - resp = self.testapp.post('/live?url=http://httpbin.org/post', + resp = self.testapp.post('/live/resource?url=http://httpbin.org/post', OrderedDict([('foo', 'bar')])) assert resp.headers['WARC-Coll'] == 'live' @@ -112,38 +139,45 @@ class TestResAgg(object): assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body + assert 'ResErrors' not in resp.headers + def test_agg_select_mem_1(self): - resp = self.testapp.get('/many?url=http://vvork.com/&closest=20141001') + resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=20141001') assert resp.headers['WARC-Coll'] == 'rhiz' assert resp.headers['WARC-Target-URI'] == 'http://www.vvork.com/' assert resp.headers['WARC-Date'] == '2014-10-06T18:43:57Z' assert b'HTTP/1.1 200 OK' in resp.body + assert 'ResErrors' not in resp.headers def test_agg_select_mem_2(self): - resp = self.testapp.get('/many?url=http://vvork.com/&closest=20151231') + resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=20151231') assert resp.headers['WARC-Coll'] == 'ia' assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' assert resp.headers['WARC-Date'] == '2016-01-10T13:48:55Z' assert b'HTTP/1.1 200 OK' in resp.body + assert 'ResErrors' not in resp.headers def test_agg_select_live(self): - resp = self.testapp.get('/many?url=http://vvork.com/&closest=2016') + resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=2016') assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' assert resp.headers['WARC-Date'] != '' + assert 'ResErrors' not in resp.headers + def test_agg_select_local(self): - resp = self.testapp.get('/many?url=http://iana.org/&closest=20140126200624') + resp = self.testapp.get('/many/resource?url=http://iana.org/&closest=20140126200624') assert resp.headers['WARC-Coll'] == 'local' assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + assert json.loads(resp.headers['ResErrors']) == {"rhiz": "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"} def test_agg_select_local_postreq(self): req_data = """\ @@ -153,12 +187,13 @@ User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 ( Host: iana.org """ - resp = self.testapp.post('/many/postreq?url=http://iana.org/&closest=20140126200624', req_data) + resp = self.testapp.post('/many/resource/postreq?url=http://iana.org/&closest=20140126200624', req_data) assert resp.headers['WARC-Coll'] == 'local' assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + assert json.loads(resp.headers['ResErrors']) == {"rhiz": "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"} def test_agg_live_postreq(self): req_data = """\ @@ -168,7 +203,7 @@ User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 ( Host: httpbin.org """ - resp = self.testapp.post('/many/postreq?url=http://httpbin.org/get?foo=bar&closest=now', req_data) + resp = self.testapp.post('/many/resource/postreq?url=http://httpbin.org/get?foo=bar&closest=now', req_data) assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' @@ -177,6 +212,8 @@ Host: httpbin.org assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body + assert json.loads(resp.headers['ResErrors']) == {"rhiz": "NotFoundException('http://webenact.rhizome.org/vvork/http://httpbin.org/get?foo=bar',)"} + def test_agg_post_resolve_postreq(self): req_data = """\ POST /post HTTP/1.1 @@ -188,7 +225,7 @@ content-type: application/x-www-form-urlencoded foo=bar&test=abc""" - resp = self.testapp.post('/posttest/postreq?url=http://httpbin.org/post', req_data) + resp = self.testapp.post('/posttest/resource/postreq?url=http://httpbin.org/post', req_data) assert resp.headers['WARC-Coll'] == 'post' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' @@ -197,10 +234,12 @@ foo=bar&test=abc""" assert b'"test": "abc"' in resp.body assert b'"url": "http://httpbin.org/post"' in resp.body + assert 'ResErrors' not in resp.headers + def test_agg_post_resolve_fallback(self): req_data = OrderedDict([('foo', 'bar'), ('test', 'abc')]) - resp = self.testapp.post('/fallback?url=http://httpbin.org/post', req_data) + resp = self.testapp.post('/fallback/resource?url=http://httpbin.org/post', req_data) assert resp.headers['WARC-Coll'] == 'post' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' @@ -209,28 +248,37 @@ foo=bar&test=abc""" assert b'"test": "abc"' in resp.body assert b'"url": "http://httpbin.org/post"' in resp.body + assert 'ResErrors' not in resp.headers + def test_agg_seq_fallback_1(self): - resp = self.testapp.get('/fallback?url=http://www.iana.org/') + resp = self.testapp.get('/fallback/resource?url=http://www.iana.org/') assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' assert b'HTTP/1.1 200 OK' in resp.body + assert 'ResErrors' not in resp.headers + def test_agg_seq_fallback_2(self): - resp = self.testapp.get('/fallback?url=http://www.example.com/') + resp = self.testapp.get('/fallback/resource?url=http://www.example.com/') assert resp.headers['WARC-Coll'] == 'example' assert resp.headers['WARC-Date'] == '2016-02-25T04:23:29Z' assert resp.headers['WARC-Target-URI'] == 'http://example.com/' assert b'HTTP/1.1 200 OK' in resp.body - def test_error_fallback_live_not_found(self): - resp = self.testapp.get('/fallback?url=http://invalid.url-not-found', status=400) + assert 'ResErrors' not in resp.headers - assert resp.json == {'message': 'http://invalid.url-not-found'} + def test_error_fallback_live_not_found(self): + resp = self.testapp.get('/fallback/resource?url=http://invalid.url-not-found', status=400) + + assert resp.json == {'message': 'http://invalid.url-not-found', + 'errors': {'LiveWebLoader': "LiveResourceException('http://invalid.url-not-found',)"}} + + assert resp.text == resp.headers['ResErrors'] def test_agg_local_revisit(self): - resp = self.testapp.get('/many?url=http://www.example.com/&closest=20140127171251&sources=local') + resp = self.testapp.get('/many/resource?url=http://www.example.com/&closest=20140127171251&sources=local') assert resp.headers['WARC-Coll'] == 'local' assert resp.headers['WARC-Target-URI'] == 'http://example.com' @@ -240,23 +288,30 @@ foo=bar&test=abc""" assert b'HTTP/1.1 200 OK' in resp.body assert b'' in resp.body + assert 'ResErrors' not in resp.headers + def test_error_invalid_index_output(self): - resp = self.testapp.get('/live?mode=index&url=http://httpbin.org/get&output=foobar', status=400) + resp = self.testapp.get('/live/index?url=http://httpbin.org/get&output=foobar', status=400) assert resp.json == {'message': 'output=foobar not supported'} + assert resp.text == resp.headers['ResErrors'] def test_error_local_not_found(self): - resp = self.testapp.get('/many?url=http://not-found.error/&sources=local', status=404) + resp = self.testapp.get('/many/resource?url=http://not-found.error/&sources=local', status=404) assert resp.json == {'message': 'No Resource Found'} + assert resp.text == resp.headers['ResErrors'] def test_error_empty(self): - resp = self.testapp.get('/empty?url=http://example.com/', status=404) + resp = self.testapp.get('/empty/resource?url=http://example.com/', status=404) assert resp.json == {'message': 'No Resource Found'} + assert resp.text == resp.headers['ResErrors'] def test_error_invalid(self): - resp = self.testapp.get('/invalid?url=http://example.com/', status=500) + resp = self.testapp.get('/invalid/resource?url=http://example.com/', status=500) + + assert resp.json == {'message': "Internal Error: 'list' object is not callable"} + assert resp.text == resp.headers['ResErrors'] - assert resp.json['message'].startswith('Internal Error') diff --git a/test/test_indexsource.py b/test/test_indexsource.py index 5853f02c..b69206cd 100644 --- a/test/test_indexsource.py +++ b/test/test_indexsource.py @@ -55,7 +55,7 @@ def query_single_source(source, params): @pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) def test_local_cdxj_loader(source): url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' - res = query_single_source(source, dict(url=url, limit=3)) + res, errs = query_single_source(source, dict(url=url, limit=3)) expected = """\ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz @@ -63,6 +63,7 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200912 iana.warc.gz org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz""" assert(key_ts_res(res) == expected) + assert(errs == {}) # Closest -- Local Loaders @@ -70,7 +71,7 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200930 iana.warc.gz""" @pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) def test_local_closest_loader(source): url = 'http://www.iana.org/_css/2013.1/fonts/Inconsolata.otf' - res = query_single_source(source, dict(url=url, + res, errs = query_single_source(source, dict(url=url, closest='20140126200930', limit=3)) @@ -80,13 +81,14 @@ org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200912 iana.warc.gz org,iana)/_css/2013.1/fonts/inconsolata.otf 20140126200826 iana.warc.gz""" assert(key_ts_res(res) == expected) + assert(errs == {}) # Prefix -- Local Loaders # ============================================================================ @pytest.mark.parametrize("source", local_sources, ids=["file", "redis"]) def test_file_prefix_loader(source): - res = query_single_source(source, dict(url='http://iana.org/domains/root/*')) + res, errs = query_single_source(source, dict(url='http://iana.org/domains/root/*')) expected = """\ org,iana)/domains/root/db 20140126200927 iana.warc.gz @@ -94,6 +96,7 @@ org,iana)/domains/root/db 20140126200928 iana.warc.gz org,iana)/domains/root/servers 20140126201227 iana.warc.gz""" assert(key_ts_res(res) == expected) + assert(errs == {}) # Url Match -- Remote Loaders @@ -101,7 +104,7 @@ org,iana)/domains/root/servers 20140126201227 iana.warc.gz""" @pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) def test_remote_loader(source): url = 'http://instagram.com/amaliaulman' - res = query_single_source(source, dict(url=url)) + res, errs = query_single_source(source, dict(url=url)) expected = """\ com,instagram)/amaliaulman 20141014150552 http://webenact.rhizome.org/all/20141014150552id_/http://instagram.com/amaliaulman @@ -110,6 +113,7 @@ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/201410 com,instagram)/amaliaulman 20141014171636 http://webenact.rhizome.org/all/20141014171636id_/http://instagram.com/amaliaulman""" assert(key_ts_res(res, 'load_url') == expected) + assert(errs == {}) # Url Match -- Remote Loaders @@ -117,12 +121,13 @@ com,instagram)/amaliaulman 20141014171636 http://webenact.rhizome.org/all/201410 @pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) def test_remote_closest_loader(source): url = 'http://instagram.com/amaliaulman' - res = query_single_source(source, dict(url=url, closest='20141014162332', limit=1)) + res, errs = query_single_source(source, dict(url=url, closest='20141014162332', limit=1)) expected = """\ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" assert(key_ts_res(res, 'load_url') == expected) + assert(errs == {}) # Url Match -- Memento @@ -130,25 +135,26 @@ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/201410 @pytest.mark.parametrize("source", remote_sources, ids=["remote_cdx", "memento"]) def test_remote_closest_loader(source): url = 'http://instagram.com/amaliaulman' - res = query_single_source(source, dict(url=url, closest='20141014162332', limit=1)) + res, errs = query_single_source(source, dict(url=url, closest='20141014162332', limit=1)) expected = """\ com,instagram)/amaliaulman 20141014162333 http://webenact.rhizome.org/all/20141014162333id_/http://instagram.com/amaliaulman""" assert(key_ts_res(res, 'load_url') == expected) + assert(errs == {}) + # Live Index -- No Load! # ============================================================================ def test_live(): url = 'http://example.com/' source = LiveIndexSource() - res = query_single_source(source, dict(url=url)) + res, errs = query_single_source(source, dict(url=url)) expected = 'com,example)/ {0} http://example.com/'.format(timestamp_now()) assert(key_ts_res(res, 'load_url') == expected) - - + assert(errs == {}) # Errors -- Not Found All @@ -156,31 +162,36 @@ def test_live(): @pytest.mark.parametrize("source", local_sources + remote_sources, ids=["file", "redis", "remote_cdx", "memento"]) def test_all_not_found(source): url = 'http://x-not-found-x.notfound/' - res = query_single_source(source, dict(url=url, limit=3)) + res, errs = query_single_source(source, dict(url=url, limit=3)) expected = '' assert(key_ts_res(res) == expected) + if source == remote_sources[0]: + assert('http://x-not-found-x.notfound/' in errs['source']) + else: + assert(errs == {}) # ============================================================================ def test_another_remote_not_found(): source = MementoIndexSource.from_timegate_url('http://www.webarchive.org.uk/wayback/archive/') url = 'http://x-not-found-x.notfound/' - res = query_single_source(source, dict(url=url, limit=3)) + res, errs = query_single_source(source, dict(url=url, limit=3)) expected = '' assert(key_ts_res(res) == expected) - + assert(errs['source'] == "NotFoundException('http://www.webarchive.org.uk/wayback/archive/timemap/link/http://x-not-found-x.notfound/',)") # ============================================================================ def test_file_not_found(): source = FileIndexSource('testdata/not-found-x') url = 'http://x-not-found-x.notfound/' - res = query_single_source(source, dict(url=url, limit=3)) + res, errs = query_single_source(source, dict(url=url, limit=3)) expected = '' assert(key_ts_res(res) == expected) + assert(errs['source'] == "NotFoundException('testdata/not-found-x',)"), errs # ============================================================================ @@ -188,7 +199,7 @@ def test_ait_filters(): ait_source = RemoteIndexSource('http://wayback.archive-it.org/cdx/search/cdx?url={url}&filter=filename:ARCHIVEIT-({colls})-.*', 'http://wayback.archive-it.org/all/{timestamp}id_/{url}') - cdxlist = query_single_source(ait_source, {'url': 'http://iana.org/', 'param.source.colls': '5610|933'}) + cdxlist, errs = query_single_source(ait_source, {'url': 'http://iana.org/', 'param.source.colls': '5610|933'}) filenames = [cdx['filename'] for cdx in cdxlist] prefix = ('ARCHIVEIT-5610-', 'ARCHIVEIT-933-') @@ -196,7 +207,7 @@ def test_ait_filters(): assert(all([x.startswith(prefix) for x in filenames])) - cdxlist = query_single_source(ait_source, {'url': 'http://iana.org/', 'param.source.colls': '1883|366|905'}) + cdxlist, errs = query_single_source(ait_source, {'url': 'http://iana.org/', 'param.source.colls': '1883|366|905'}) filenames = [cdx['filename'] for cdx in cdxlist] prefix = ('ARCHIVEIT-1883-', 'ARCHIVEIT-366-', 'ARCHIVEIT-905-') diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index 9a1b9209..0e5ded7c 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -1,13 +1,14 @@ from gevent import monkey; monkey.patch_all(thread=False) from rezag.aggindexsource import SimpleAggregator, GeventTimeoutAggregator -from rezag.aggindexsource import ThreadedTimeoutAggregator +from rezag.aggindexsource import ThreadedTimeoutAggregator, BaseAggregator from rezag.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource from .testutils import json_list, to_path import json import pytest +import time from rezag.handlers import IndexHandler @@ -27,6 +28,10 @@ aggs = {'simple': SimpleAggregator(sources), 'processes': ThreadedTimeoutAggregator(sources, timeout=5.0, use_processes=True), } +agg_tm = {'gevent': GeventTimeoutAggregator(sources, timeout=0.0), + 'threaded': ThreadedTimeoutAggregator(sources, timeout=0.0), + 'processes': ThreadedTimeoutAggregator(sources, timeout=0.0, use_processes=True)} + nf = {'notfound': FileIndexSource(to_path('testdata/not-found-x'))} agg_nf = {'simple': SimpleAggregator(nf), 'gevent': GeventTimeoutAggregator(nf, timeout=5.0), @@ -41,7 +46,7 @@ agg_nf = {'simple': SimpleAggregator(nf), @pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_1(agg): url = 'http://iana.org/' - res = agg(dict(url=url, closest='20140126000000', limit=5)) + res, errs = agg(dict(url=url, closest='20140126000000', limit=5)) exp = [{"timestamp": "20140126093743", "load_url": "http://web.archive.org/web/20140126093743id_/http://iana.org/", "source": "ia"}, @@ -52,12 +57,13 @@ def test_mem_agg_index_1(agg): ] assert(json_list(res) == exp) - + assert(errs == {'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://iana.org/',)", + 'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"}) @pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_2(agg): url = 'http://example.com/' - res = agg(dict(url=url, closest='20100512', limit=6)) + res, errs = agg(dict(url=url, closest='20100512', limit=6)) exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source": "bl"}, {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source": "bl"}, @@ -67,12 +73,13 @@ def test_mem_agg_index_2(agg): {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}] assert(json_list(res) == exp) + assert(errs == {'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://example.com/',)"}) @pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_3(agg): url = 'http://vvork.com/' - res = agg(dict(url=url, closest='20141001', limit=5)) + res, errs = agg(dict(url=url, closest='20141001', limit=5)) exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, {"timestamp": "20141018133107", "load_url": "http://web.archive.org/web/20141018133107id_/http://vvork.com/", "source": "ia"}, @@ -81,32 +88,53 @@ def test_mem_agg_index_3(agg): {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] assert(json_list(res) == exp) + assert(errs == {}) @pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_4(agg): url = 'http://vvork.com/' - res = agg(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + res, errs = agg(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] assert(json_list(res) == exp) + assert(errs == {}) @pytest.mark.parametrize("agg", list(agg_nf.values()), ids=list(agg_nf.keys())) def test_mem_agg_not_found(agg): url = 'http://vvork.com/' - res = agg(dict(url=url, closest='20141001', limit=2)) + res, errs = agg(dict(url=url, closest='20141001', limit=2)) assert(json_list(res) == []) + assert(errs == {'notfound': "NotFoundException('testdata/not-found-x',)"}) + + +@pytest.mark.parametrize("agg", list(agg_tm.values()), ids=list(agg_tm.keys())) +def test_mem_agg_timeout(agg): + url = 'http://vvork.com/' + + orig_source = BaseAggregator.load_child_source + def load_child_source(self, name, source, params): + time.sleep(0.1) + return orig_source(name, source, params) + + BaseAggregator.load_child_source = load_child_source + res, errs = agg(dict(url=url, closest='20141001', limit=2)) + BaseAggregator.load_child_source = orig_source + + assert(json_list(res) == []) + assert(errs == {'local': 'timeout', + 'ait': 'timeout', 'bl': 'timeout', 'ia': 'timeout', 'rhiz': 'timeout'}) def test_handler_output_cdxj(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) exp = """\ com,vvork)/ 20141006184357 {"url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} @@ -114,13 +142,14 @@ com,vvork)/ 20131004231540 {"url": "http://vvork.com/", "mem_rel": "last memento """ assert(''.join(res) == exp) + assert(errs == {}) def test_handler_output_json(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) + res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) exp = """\ {"urlkey": "com,vvork)/", "timestamp": "20141006184357", "url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} @@ -128,26 +157,27 @@ def test_handler_output_json(): """ assert(''.join(res) == exp) - + assert(errs == {}) def test_handler_output_link(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) + res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) exp = """\ ; rel="memento"; datetime="Mon, 06 Oct 2014 18:43:57 GMT"; src="rhiz", ; rel="memento"; datetime="Fri, 04 Oct 2013 23:15:40 GMT"; src="ait" """ assert(''.join(res) == exp) + assert(errs == {}) def test_handler_output_link_2(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://iana.org/' - res = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) + res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) exp = """\ ; rel="memento"; datetime="Sun, 26 Jan 2014 09:37:43 GMT"; src="ia", @@ -158,38 +188,54 @@ def test_handler_output_link_2(): """ assert(''.join(res) == exp) + exp_errs = {'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://iana.org/',)", + 'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"} + + assert(errs == exp_errs) + + def test_handler_output_link_3(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://foo.bar.non-existent' - res = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) + res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) exp = '' assert(''.join(res) == exp) + exp_errs = {'ait': "NotFoundException('http://wayback.archive-it.org/all/http://foo.bar.non-existent',)", + 'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://foo.bar.non-existent',)", + 'ia': "NotFoundException('http://web.archive.org/web/http://foo.bar.non-existent',)", + 'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://foo.bar.non-existent',)"} + + assert(errs == exp_errs) + def test_handler_output_text(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) + res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) exp = """\ com,vvork)/ 20141006184357 http://www.vvork.com/ memento http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/ http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/ rhiz com,vvork)/ 20131004231540 http://vvork.com/ last memento http://wayback.archive-it.org/all/20131004231540/http://vvork.com/ http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/ ait """ assert(''.join(res) == exp) + assert(errs == {}) def test_handler_list_sources(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) - res = handler(dict(mode='list_sources')) + res, errs = handler(dict(mode='list_sources')) assert(res == {'sources': {'bl': 'memento', 'ait': 'memento', 'ia': 'memento', 'rhiz': 'memento', 'local': 'file'}}) + assert(errs == {}) + diff --git a/test/test_timeouts.py b/test/test_timeouts.py index 6a96d464..263d21f3 100644 --- a/test/test_timeouts.py +++ b/test/test_timeouts.py @@ -35,7 +35,7 @@ def setup_module(): def test_timeout_long_all_pass(): agg = TimeoutAggregator(sources, timeout=1.0) - res = agg(dict(url='http://example.com/')) + res, errs = agg(dict(url='http://example.com/')) exp = [{'source': 'slower', 'timestamp': '20140127171200'}, {'source': 'slower', 'timestamp': '20140127171251'}, @@ -43,27 +43,31 @@ def test_timeout_long_all_pass(): assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(errs == {}) + def test_timeout_slower_skipped_1(): agg = GeventTimeoutAggregator(sources, timeout=0.49) - res = agg(dict(url='http://example.com/')) + res, errs = agg(dict(url='http://example.com/')) exp = [{'source': 'slow', 'timestamp': '20160225042329'}] assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(errs == {'slower': 'timeout'}) def test_timeout_slower_skipped_2(): agg = GeventTimeoutAggregator(sources, timeout=0.19) - res = agg(dict(url='http://example.com/')) + res, errs = agg(dict(url='http://example.com/')) exp = [] assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(errs == {'slower': 'timeout', 'slow': 'timeout'}) def test_timeout_skipping(): @@ -75,31 +79,40 @@ def test_timeout_skipping(): exp = [{'source': 'slow', 'timestamp': '20160225042329'}] - res = agg(dict(url='http://example.com/')) + res, errs = agg(dict(url='http://example.com/')) assert(json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 4) assert(sources['slower'].calls == 4) - res = agg(dict(url='http://example.com/')) + assert(errs == {'slower': 'timeout'}) + + res, errs = agg(dict(url='http://example.com/')) assert(json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 5) assert(sources['slower'].calls == 5) - res = agg(dict(url='http://example.com/')) + assert(errs == {'slower': 'timeout'}) + + res, errs = agg(dict(url='http://example.com/')) assert(json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 6) assert(sources['slower'].calls == 5) - res = agg(dict(url='http://example.com/')) + assert(errs == {}) + + res, errs = agg(dict(url='http://example.com/')) assert(json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 7) assert(sources['slower'].calls == 5) + assert(errs == {}) + time.sleep(2.01) - res = agg(dict(url='http://example.com/')) + res, errs = agg(dict(url='http://example.com/')) assert(json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 8) assert(sources['slower'].calls == 6) + assert(errs == {'slower': 'timeout'}) From 98830147b559d904028b49967e556ec81bca5f44 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 3 Mar 2016 11:04:28 -0800 Subject: [PATCH 010/112] add memento headers to all response loaders, use BaseLoader base class, update tests for memento headers --- rezag/responseloader.py | 43 +++++++++++++++++++++++++----------- rezag/utils.py | 4 ++++ test/test_handlers.py | 48 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/rezag/responseloader.py b/rezag/responseloader.py index c3c6dd5c..774d7c34 100644 --- a/rezag/responseloader.py +++ b/rezag/responseloader.py @@ -1,7 +1,10 @@ from rezag.liverec import BaseRecorder from rezag.liverec import request as remote_request +from rezag.utils import MementoUtils + from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date +from pywb.utils.timeutils import iso_date_to_datetime from pywb.utils.wbexception import LiveResourceException from pywb.warc.resolvingloader import ResolvingLoader @@ -46,7 +49,28 @@ class StreamIter(object): #============================================================================= -class WARCPathLoader(object): +class BaseLoader(object): + def __call__(self, cdx, params): + res = self._load_resource(cdx, params) + if not res: + return res + + response.headers['WARC-Coll'] = cdx.get('source', '') + + response.headers['Link'] = MementoUtils.make_link( + response.headers['WARC-Target-URI'], + 'original') + + memento_dt = iso_date_to_datetime(response.headers['WARC-Date']) + response.headers['Memento-Datetime'] = datetime_to_http_date(memento_dt) + return res + + def _load_resource(self, cdx, params): #pragma: no cover + raise NotImplemented() + + +#============================================================================= +class WARCPathLoader(BaseLoader): def __init__(self, paths, cdx_source): self.paths = paths if isinstance(paths, str): @@ -77,8 +101,7 @@ class WARCPathLoader(object): yield check - - def __call__(self, cdx, params): + def _load_resource(self, cdx, params): if not cdx.get('filename') or cdx.get('offset') is None: return None @@ -94,8 +117,6 @@ class WARCPathLoader(object): for n, v in record.rec_headers.headers: response.headers[n] = v - response.headers['WARC-Coll'] = cdx.get('source') - if headers != payload: response.headers['WARC-Target-URI'] = headers.rec_headers.get_header('WARC-Target-URI') response.headers['WARC-Date'] = headers.rec_headers.get_header('WARC-Date') @@ -103,8 +124,7 @@ class WARCPathLoader(object): response.headers['WARC-Refers-To-Date'] = payload.rec_headers.get_header('WARC-Date') headers.stream.close() - res = StreamIter(record.stream) - return res + return StreamIter(record.stream) def __str__(self): return 'WARCPathLoader' @@ -133,13 +153,13 @@ class HeaderRecorder(BaseRecorder): #============================================================================= -class LiveWebLoader(object): +class LiveWebLoader(BaseLoader): SKIP_HEADERS = (b'link', b'memento-datetime', b'content-location', b'x-archive') - def __call__(self, cdx, params): + def _load_resource(self, cdx, params): load_url = cdx.get('load_url') if not load_url: return None @@ -185,7 +205,6 @@ class LiveWebLoader(object): #response.headers['WARC-Record-ID'] = self._make_warc_id() response.headers['WARC-Target-URI'] = cdx['url'] response.headers['WARC-Date'] = self._make_date(dt) - response.headers['WARC-Coll'] = cdx.get('source', '') # Try to set content-length, if it is available and valid try: @@ -193,8 +212,8 @@ class LiveWebLoader(object): if content_len > 0: content_len += len(resp_headers) response.headers['Content-Length'] = content_len - except: - raise + except (KeyError, TypeError): + pass return StreamIter(upstream_res.raw, header=resp_headers) diff --git a/rezag/utils.py b/rezag/utils.py index 94c67975..126c0f40 100644 --- a/rezag/utils.py +++ b/rezag/utils.py @@ -98,3 +98,7 @@ class MementoUtils(object): # last memento link, if any if prev_cdx: yield MementoUtils.make_timemap_memento_link(prev_cdx, end='\n') + + @staticmethod + def make_link(url, type): + return '<{0}>; rel="{1}"'.format(url, type) diff --git a/test/test_handlers.py b/test/test_handlers.py index 3e911224..1c8ec45e 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -9,6 +9,7 @@ from rezag.aggindexsource import GeventTimeoutAggregator, SimpleAggregator from rezag.aggindexsource import DirectoryIndexSource from rezag.app import add_route, application +from rezag.utils import MementoUtils import webtest import bottle @@ -121,7 +122,10 @@ class TestResAgg(object): assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' - assert 'WARC-Date' in resp.headers + assert resp.headers['WARC-Date'] != '' + + assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/get?foo=bar', 'original') + assert resp.headers['Memento-Datetime'] != '' assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -134,7 +138,10 @@ class TestResAgg(object): assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' - assert 'WARC-Date' in resp.headers + assert resp.headers['WARC-Date'] != '' + + assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/post', 'original') + assert resp.headers['Memento-Datetime'] != '' assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -149,6 +156,9 @@ class TestResAgg(object): assert resp.headers['WARC-Date'] == '2014-10-06T18:43:57Z' assert b'HTTP/1.1 200 OK' in resp.body + assert resp.headers['Link'] == MementoUtils.make_link('http://www.vvork.com/', 'original') + assert resp.headers['Memento-Datetime'] == 'Mon, 06 Oct 2014 18:43:57 GMT' + assert 'ResErrors' not in resp.headers def test_agg_select_mem_2(self): @@ -159,6 +169,9 @@ class TestResAgg(object): assert resp.headers['WARC-Date'] == '2016-01-10T13:48:55Z' assert b'HTTP/1.1 200 OK' in resp.body + assert resp.headers['Link'] == MementoUtils.make_link('http://vvork.com/', 'original') + assert resp.headers['Memento-Datetime'] == 'Sun, 10 Jan 2016 13:48:55 GMT' + assert 'ResErrors' not in resp.headers def test_agg_select_live(self): @@ -168,6 +181,9 @@ class TestResAgg(object): assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' assert resp.headers['WARC-Date'] != '' + assert resp.headers['Link'] == MementoUtils.make_link('http://vvork.com/', 'original') + assert resp.headers['Memento-Datetime'] != '' + assert 'ResErrors' not in resp.headers def test_agg_select_local(self): @@ -177,6 +193,9 @@ class TestResAgg(object): assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + assert resp.headers['Link'] == MementoUtils.make_link('http://www.iana.org/', 'original') + assert resp.headers['Memento-Datetime'] == 'Sun, 26 Jan 2014 20:06:24 GMT' + assert json.loads(resp.headers['ResErrors']) == {"rhiz": "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"} def test_agg_select_local_postreq(self): @@ -193,6 +212,9 @@ Host: iana.org assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + assert resp.headers['Link'] == MementoUtils.make_link('http://www.iana.org/', 'original') + assert resp.headers['Memento-Datetime'] == 'Sun, 26 Jan 2014 20:06:24 GMT' + assert json.loads(resp.headers['ResErrors']) == {"rhiz": "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"} def test_agg_live_postreq(self): @@ -207,7 +229,10 @@ Host: httpbin.org assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' - assert 'WARC-Date' in resp.headers + assert resp.headers['WARC-Date'] != '' + + assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/get?foo=bar', 'original') + assert resp.headers['Memento-Datetime'] != '' assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -229,6 +254,11 @@ foo=bar&test=abc""" assert resp.headers['WARC-Coll'] == 'post' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' + assert resp.headers['WARC-Date'] != '' + + assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/post', 'original') + assert resp.headers['Memento-Datetime'] != '' + assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body assert b'"test": "abc"' in resp.body @@ -243,6 +273,8 @@ foo=bar&test=abc""" assert resp.headers['WARC-Coll'] == 'post' assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' + assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/post', 'original') + assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body assert b'"test": "abc"' in resp.body @@ -255,6 +287,8 @@ foo=bar&test=abc""" assert resp.headers['WARC-Coll'] == 'live' assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' + assert resp.headers['Link'] == MementoUtils.make_link('http://www.iana.org/', 'original') + assert b'HTTP/1.1 200 OK' in resp.body assert 'ResErrors' not in resp.headers @@ -265,6 +299,10 @@ foo=bar&test=abc""" assert resp.headers['WARC-Coll'] == 'example' assert resp.headers['WARC-Date'] == '2016-02-25T04:23:29Z' assert resp.headers['WARC-Target-URI'] == 'http://example.com/' + + assert resp.headers['Link'] == MementoUtils.make_link('http://example.com/', 'original') + assert resp.headers['Memento-Datetime'] == 'Thu, 25 Feb 2016 04:23:29 GMT' + assert b'HTTP/1.1 200 OK' in resp.body assert 'ResErrors' not in resp.headers @@ -285,6 +323,10 @@ foo=bar&test=abc""" assert resp.headers['WARC-Date'] == '2014-01-27T17:12:51Z' assert resp.headers['WARC-Refers-To-Target-URI'] == 'http://example.com' assert resp.headers['WARC-Refers-To-Date'] == '2014-01-27T17:12:00Z' + + assert resp.headers['Link'] == MementoUtils.make_link('http://example.com', 'original') + assert resp.headers['Memento-Datetime'] == 'Mon, 27 Jan 2014 17:12:51 GMT' + assert b'HTTP/1.1 200 OK' in resp.body assert b'' in resp.body From ed1d3555c359fe9bacd647e5379fe572db2f2fdf Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 3 Mar 2016 11:55:43 -0800 Subject: [PATCH 011/112] rename rezag -> webagg rename aggindexsource -> aggregator --- setup.py | 10 +++++----- test/test_dir_agg.py | 4 ++-- test/test_handlers.py | 12 ++++++------ test/test_indexsource.py | 6 +++--- test/test_memento_agg.py | 8 ++++---- test/test_timeouts.py | 6 +++--- {rezag => webagg}/__init__.py | 0 rezag/aggindexsource.py => webagg/aggregator.py | 2 +- {rezag => webagg}/app.py | 2 +- {rezag => webagg}/handlers.py | 4 ++-- {rezag => webagg}/indexsource.py | 4 ++-- {rezag => webagg}/inputrequest.py | 0 {rezag => webagg}/liverec.py | 0 {rezag => webagg}/responseloader.py | 6 +++--- {rezag => webagg}/utils.py | 0 15 files changed, 32 insertions(+), 32 deletions(-) rename {rezag => webagg}/__init__.py (100%) rename rezag/aggindexsource.py => webagg/aggregator.py (99%) rename {rezag => webagg}/app.py (96%) rename {rezag => webagg}/handlers.py (97%) rename {rezag => webagg}/indexsource.py (98%) rename {rezag => webagg}/inputrequest.py (100%) rename {rezag => webagg}/liverec.py (100%) rename {rezag => webagg}/responseloader.py (98%) rename {rezag => webagg}/utils.py (100%) diff --git a/setup.py b/setup.py index cdb646ce..fee3441c 100755 --- a/setup.py +++ b/setup.py @@ -14,22 +14,22 @@ class PyTest(TestCommand): import pytest import sys import os - cmdline = ' --cov rezag -v test/' + cmdline = ' --cov webagg -v test/' errcode = pytest.main(cmdline) sys.exit(errcode) setup( - name='rezag', + name='webagg', version='1.0', author='Ilya Kreymer', author_email='ikreymer@gmail.com', - license='MIT', + license='Apache 2.0', packages=find_packages(), - url='https://github.com/webrecorder/rezag', + url='https://github.com/webrecorder/webagg', description='Resource Aggregator', long_description=open('README.rst').read(), provides=[ - 'rezag', + 'webagg', ], install_requires=[ 'pywb==1.0b', diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py index 7fe75920..2500b9cf 100644 --- a/test/test_dir_agg.py +++ b/test/test_dir_agg.py @@ -5,8 +5,8 @@ import json from .testutils import to_path -from rezag.aggindexsource import DirectoryIndexSource, SimpleAggregator -from rezag.indexsource import MementoIndexSource +from webagg.aggregator import DirectoryIndexSource, SimpleAggregator +from webagg.indexsource import MementoIndexSource #============================================================================= diff --git a/test/test_handlers.py b/test/test_handlers.py index 1c8ec45e..043b6aea 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -2,14 +2,14 @@ from gevent import monkey; monkey.patch_all(thread=False) from collections import OrderedDict -from rezag.handlers import DefaultResourceHandler, HandlerSeq +from webagg.handlers import DefaultResourceHandler, HandlerSeq -from rezag.indexsource import MementoIndexSource, FileIndexSource, LiveIndexSource -from rezag.aggindexsource import GeventTimeoutAggregator, SimpleAggregator -from rezag.aggindexsource import DirectoryIndexSource +from webagg.indexsource import MementoIndexSource, FileIndexSource, LiveIndexSource +from webagg.aggregator import GeventTimeoutAggregator, SimpleAggregator +from webagg.aggregator import DirectoryIndexSource -from rezag.app import add_route, application -from rezag.utils import MementoUtils +from webagg.app import add_route, application +from webagg.utils import MementoUtils import webtest import bottle diff --git a/test/test_indexsource.py b/test/test_indexsource.py index b69206cd..90a3e156 100644 --- a/test/test_indexsource.py +++ b/test/test_indexsource.py @@ -1,7 +1,7 @@ -from rezag.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource -from rezag.indexsource import LiveIndexSource +from webagg.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource, RedisIndexSource +from webagg.indexsource import LiveIndexSource -from rezag.aggindexsource import SimpleAggregator +from webagg.aggregator import SimpleAggregator from pywb.utils.timeutils import timestamp_now diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index 0e5ded7c..c323da13 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -1,16 +1,16 @@ from gevent import monkey; monkey.patch_all(thread=False) -from rezag.aggindexsource import SimpleAggregator, GeventTimeoutAggregator -from rezag.aggindexsource import ThreadedTimeoutAggregator, BaseAggregator +from webagg.aggregator import SimpleAggregator, GeventTimeoutAggregator +from webagg.aggregator import ThreadedTimeoutAggregator, BaseAggregator -from rezag.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource +from webagg.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource from .testutils import json_list, to_path import json import pytest import time -from rezag.handlers import IndexHandler +from webagg.handlers import IndexHandler sources = { diff --git a/test/test_timeouts.py b/test/test_timeouts.py index 263d21f3..04370c5d 100644 --- a/test/test_timeouts.py +++ b/test/test_timeouts.py @@ -1,9 +1,9 @@ from gevent import monkey; monkey.patch_all(thread=False) import time -from rezag.indexsource import FileIndexSource +from webagg.indexsource import FileIndexSource -from rezag.aggindexsource import SimpleAggregator, TimeoutMixin -from rezag.aggindexsource import GeventTimeoutAggregator, GeventTimeoutAggregator +from webagg.aggregator import SimpleAggregator, TimeoutMixin +from webagg.aggregator import GeventTimeoutAggregator, GeventTimeoutAggregator from .testutils import json_list diff --git a/rezag/__init__.py b/webagg/__init__.py similarity index 100% rename from rezag/__init__.py rename to webagg/__init__.py diff --git a/rezag/aggindexsource.py b/webagg/aggregator.py similarity index 99% rename from rezag/aggindexsource.py rename to webagg/aggregator.py index 02885f58..544ffd55 100644 --- a/rezag/aggindexsource.py +++ b/webagg/aggregator.py @@ -15,7 +15,7 @@ from heapq import merge from collections import deque from itertools import chain -from rezag.indexsource import FileIndexSource +from webagg.indexsource import FileIndexSource from pywb.utils.wbexception import NotFoundException, WbException import six import glob diff --git a/rezag/app.py b/webagg/app.py similarity index 96% rename from rezag/app.py rename to webagg/app.py index 23f20bce..baa61a35 100644 --- a/rezag/app.py +++ b/webagg/app.py @@ -1,4 +1,4 @@ -from rezag.inputrequest import DirectWSGIInputRequest, POSTInputRequest +from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest from bottle import route, request, response, default_app, abort import bottle diff --git a/rezag/handlers.py b/webagg/handlers.py similarity index 97% rename from rezag/handlers.py rename to webagg/handlers.py index fdc6cc65..6f05405a 100644 --- a/rezag/handlers.py +++ b/webagg/handlers.py @@ -1,5 +1,5 @@ -from rezag.responseloader import WARCPathLoader, LiveWebLoader -from rezag.utils import MementoUtils +from webagg.responseloader import WARCPathLoader, LiveWebLoader +from webagg.utils import MementoUtils from pywb.utils.wbexception import BadRequestException, WbException from pywb.utils.wbexception import NotFoundException from bottle import response diff --git a/rezag/indexsource.py b/webagg/indexsource.py similarity index 98% rename from rezag/indexsource.py rename to webagg/indexsource.py index 06822150..269df379 100644 --- a/rezag/indexsource.py +++ b/webagg/indexsource.py @@ -9,9 +9,9 @@ from pywb.utils.wbexception import NotFoundException from pywb.cdx.cdxobject import CDXObject from pywb.cdx.query import CDXQuery -from rezag.liverec import patched_requests as requests +from webagg.liverec import patched_requests as requests -from rezag.utils import MementoUtils +from webagg.utils import MementoUtils WAYBACK_ORIG_SUFFIX = '{timestamp}id_/{url}' diff --git a/rezag/inputrequest.py b/webagg/inputrequest.py similarity index 100% rename from rezag/inputrequest.py rename to webagg/inputrequest.py diff --git a/rezag/liverec.py b/webagg/liverec.py similarity index 100% rename from rezag/liverec.py rename to webagg/liverec.py diff --git a/rezag/responseloader.py b/webagg/responseloader.py similarity index 98% rename from rezag/responseloader.py rename to webagg/responseloader.py index 774d7c34..0bacb440 100644 --- a/rezag/responseloader.py +++ b/webagg/responseloader.py @@ -1,7 +1,7 @@ -from rezag.liverec import BaseRecorder -from rezag.liverec import request as remote_request +from webagg.liverec import BaseRecorder +from webagg.liverec import request as remote_request -from rezag.utils import MementoUtils +from webagg.utils import MementoUtils from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date from pywb.utils.timeutils import iso_date_to_datetime diff --git a/rezag/utils.py b/webagg/utils.py similarity index 100% rename from rezag/utils.py rename to webagg/utils.py From 896f81fd1c3a94ed875711da98f7a6a731c18728 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 3 Mar 2016 12:09:17 -0800 Subject: [PATCH 012/112] Add README.rst --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index e69de29b..f06334b6 100644 --- a/README.rst +++ b/README.rst @@ -0,0 +1,6 @@ +Resource Memento/Aggregator +=========================== + +This is a reference implementation of the `Resource/Memento Aggregator `_ +from the `Webrecorder Platform `_ + From bdda1b8c038851ac33a597554f1c0151093eaeb6 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 3 Mar 2016 13:58:09 -0800 Subject: [PATCH 013/112] minor fixes for py2 support --- webagg/liverec.py | 2 +- webagg/responseloader.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webagg/liverec.py b/webagg/liverec.py index 5d8bacf0..e0fe1298 100644 --- a/webagg/liverec.py +++ b/webagg/liverec.py @@ -42,7 +42,7 @@ class RecordingStream(object): self.recorder.write_response_buff(buff) return res - def readline(self, maxlen=None): + def readline(self, maxlen=-1): line = self.fp.readline(maxlen) self.recorder.write_response_header_line(line) return line diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 0bacb440..96e64067 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -12,10 +12,11 @@ from io import BytesIO from bottle import response import uuid +import six #============================================================================= -class StreamIter(object): +class StreamIter(six.Iterator): def __init__(self, stream, header=None, size=8192): self.stream = stream self.header = header From 20ebccc13eb0bbb2e28b777e5457ec7305f95534 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 5 Mar 2016 16:49:26 -0800 Subject: [PATCH 014/112] handlers: return out_headers directly instead of setting bottle response, contains bottle dependency to app.py (to allow alternate impl not using bottle) param parsing: instead of setting custom _src_params and _all_params, use a custom ParamFormatter which will check param dict for params with prefix and custom name --- test/test_memento_agg.py | 21 +++++++---- webagg/aggregator.py | 42 +++------------------- webagg/app.py | 76 ++++++++++++++++++++++++---------------- webagg/handlers.py | 44 +++++++++++------------ webagg/indexsource.py | 24 +++++-------- webagg/responseloader.py | 65 +++++++++++++++++++--------------- webagg/utils.py | 44 +++++++++++++++++++++-- 7 files changed, 174 insertions(+), 142 deletions(-) diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index c323da13..017d1871 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -134,13 +134,14 @@ def test_handler_output_cdxj(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) + headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) exp = """\ com,vvork)/ 20141006184357 {"url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} com,vvork)/ 20131004231540 {"url": "http://vvork.com/", "mem_rel": "last memento", "memento_url": "http://wayback.archive-it.org/all/20131004231540/http://vvork.com/", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"} """ + assert(headers['Content-Type'] == 'text/x-cdxj') assert(''.join(res) == exp) assert(errs == {}) @@ -149,13 +150,14 @@ def test_handler_output_json(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) + headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) exp = """\ {"urlkey": "com,vvork)/", "timestamp": "20141006184357", "url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} {"urlkey": "com,vvork)/", "timestamp": "20131004231540", "url": "http://vvork.com/", "mem_rel": "last memento", "memento_url": "http://wayback.archive-it.org/all/20131004231540/http://vvork.com/", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"} """ + assert(headers['Content-Type'] == 'application/x-ndjson') assert(''.join(res) == exp) assert(errs == {}) @@ -163,12 +165,13 @@ def test_handler_output_link(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) + headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) exp = """\ ; rel="memento"; datetime="Mon, 06 Oct 2014 18:43:57 GMT"; src="rhiz", ; rel="memento"; datetime="Fri, 04 Oct 2013 23:15:40 GMT"; src="ait" """ + assert(headers['Content-Type'] == 'application/link') assert(''.join(res) == exp) assert(errs == {}) @@ -177,7 +180,7 @@ def test_handler_output_link_2(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://iana.org/' - res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) + headers, res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) exp = """\ ; rel="memento"; datetime="Sun, 26 Jan 2014 09:37:43 GMT"; src="ia", @@ -186,6 +189,7 @@ def test_handler_output_link_2(): ; rel="memento"; datetime="Wed, 29 Jan 2014 17:52:03 GMT"; src="ia", ; rel="memento"; datetime="Tue, 07 Jan 2014 04:05:52 GMT"; src="ait" """ + assert(headers['Content-Type'] == 'application/link') assert(''.join(res) == exp) exp_errs = {'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://iana.org/',)", @@ -199,10 +203,11 @@ def test_handler_output_link_3(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://foo.bar.non-existent' - res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) + headers, res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) exp = '' + assert(headers['Content-Type'] == 'application/link') assert(''.join(res) == exp) exp_errs = {'ait': "NotFoundException('http://wayback.archive-it.org/all/http://foo.bar.non-existent',)", @@ -216,12 +221,13 @@ def test_handler_output_text(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) url = 'http://vvork.com/' - res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) + headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) exp = """\ com,vvork)/ 20141006184357 http://www.vvork.com/ memento http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/ http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/ rhiz com,vvork)/ 20131004231540 http://vvork.com/ last memento http://wayback.archive-it.org/all/20131004231540/http://vvork.com/ http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/ ait """ + assert(headers['Content-Type'] == 'text/plain') assert(''.join(res) == exp) assert(errs == {}) @@ -229,8 +235,9 @@ com,vvork)/ 20131004231540 http://vvork.com/ last memento http://wayback.archive def test_handler_list_sources(): agg = GeventTimeoutAggregator(sources, timeout=5.0) handler = IndexHandler(agg) - res, errs = handler(dict(mode='list_sources')) + headers, res, errs = handler(dict(mode='list_sources')) + assert(headers == {}) assert(res == {'sources': {'bl': 'memento', 'ait': 'memento', 'ia': 'memento', diff --git a/webagg/aggregator.py b/webagg/aggregator.py index 544ffd55..2810d3d0 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -17,6 +17,9 @@ from itertools import chain from webagg.indexsource import FileIndexSource from pywb.utils.wbexception import NotFoundException, WbException + +from webagg.utils import ParamFormatter, res_template + import six import glob @@ -28,43 +31,19 @@ class BaseAggregator(object): params['closest'] = timestamp_now() query = CDXQuery(params) - self._set_src_params(params) cdx_iter, errs = self.load_index(query.params) cdx_iter = process_cdx(cdx_iter, query) return cdx_iter, dict(errs) - def _set_src_params(self, params): - src_params = {} - for param, value in six.iteritems(params): - if not param.startswith('param.'): - continue - - parts = param.split('.', 3)[1:] - - if len(parts) == 2: - src = parts[0] - name = parts[1] - else: - src = '' - name = parts[0] - - if not src in src_params: - src_params[src] = {} - - src_params[src][name] = value - - params['_all_src_params'] = src_params - def load_child_source_list(self, name, source, params): res = self.load_child_source(name, source, params) return list(res[0]), res[1] def load_child_source(self, name, source, params): try: - _src_params = params['_all_src_params'].get(name) - params['_src_params'] = _src_params + params['_formatter'] = ParamFormatter(params, name) res = source.load_index(params) if isinstance(res, tuple): cdx_iter, err_list = res @@ -277,18 +256,7 @@ class BaseDirectoryIndexSource(BaseAggregator): self.base_dir = base_dir def _iter_sources(self, params): - self._set_src_params(params) - # see if specific params (when part of another agg) - src_params = params.get('_src_params') - if not src_params: - # try default param. settings - src_params = params.get('_all_src_params', {}).get('') - - if src_params: - the_dir = self.base_dir.format(**src_params) - else: - the_dir = self.base_dir - + the_dir = res_template(self.base_dir, params) the_dir = os.path.join(self.base_prefix, the_dir) try: sources = list(self._load_files(the_dir)) diff --git a/webagg/app.py b/webagg/app.py index baa61a35..5a9bae15 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -1,3 +1,5 @@ +from webagg.liverec import request as remote_request + from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest from bottle import route, request, response, default_app, abort import bottle @@ -7,6 +9,42 @@ import json JSON_CT = 'application/json; charset=utf-8' + +#============================================================================= +route_dict = {} + + +#============================================================================= +def add_route(path, handler): + @route([path, path + '/'], 'ANY') + @wrap_error + def direct_input_request(mode=''): + params = dict(request.query) + params['mode'] = mode + params['_input_req'] = DirectWSGIInputRequest(request.environ) + return handler(params) + + @route([path + '/postreq', path + '//postreq'], 'POST') + @wrap_error + def post_fullrequest(mode=''): + params = dict(request.query) + params['mode'] = mode + params['_input_req'] = POSTInputRequest(request.environ) + return handler(params) + + global route_dict + handler_dict = handler.get_supported_modes() + route_dict[path] = handler_dict + route_dict[path + '/postreq'] = handler_dict + + +#============================================================================= +@route('/') +def list_routes(): + return route_dict + + +#============================================================================= def err_handler(exc): response.status = exc.status_code response.content_type = JSON_CT @@ -15,10 +53,15 @@ def err_handler(exc): return err_msg +#============================================================================= def wrap_error(func): def wrap_func(*args, **kwargs): try: - res, errs = func(*args, **kwargs) + out_headers, res, errs = func(*args, **kwargs) + + if out_headers: + for n, v in out_headers.items(): + response.headers[n] = v if res: if errs: @@ -53,36 +96,7 @@ def wrap_error(func): return wrap_func -route_dict = {} - -def add_route(path, handler): - @route([path, path + '/'], 'ANY') - @wrap_error - def direct_input_request(mode=''): - params = dict(request.query) - params['mode'] = mode - params['_input_req'] = DirectWSGIInputRequest(request.environ) - return handler(params) - - @route([path + '/postreq', path + '//postreq'], 'POST') - @wrap_error - def post_fullrequest(mode=''): - params = dict(request.query) - params['mode'] = mode - params['_input_req'] = POSTInputRequest(request.environ) - return handler(params) - - global route_dict - handler_dict = handler.get_supported_modes() - route_dict[path] = handler_dict - route_dict[path + '/postreq'] = handler_dict - - -@route('/') -def list_routes(): - return route_dict - - +#============================================================================= application = default_app() application.default_error_handler = err_handler diff --git a/webagg/handlers.py b/webagg/handlers.py index 6f05405a..da2ed837 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -2,25 +2,24 @@ from webagg.responseloader import WARCPathLoader, LiveWebLoader from webagg.utils import MementoUtils from pywb.utils.wbexception import BadRequestException, WbException from pywb.utils.wbexception import NotFoundException -from bottle import response #============================================================================= def to_cdxj(cdx_iter, fields): - response.headers['Content-Type'] = 'text/x-cdxj' - return [cdx.to_cdxj(fields) for cdx in cdx_iter] + content_type = 'text/x-cdxj' + return content_type, (cdx.to_cdxj(fields) for cdx in cdx_iter) def to_json(cdx_iter, fields): - response.headers['Content-Type'] = 'application/x-ndjson' - return [cdx.to_json(fields) for cdx in cdx_iter] + content_type = 'application/x-ndjson' + return content_type, (cdx.to_json(fields) for cdx in cdx_iter) def to_text(cdx_iter, fields): - response.headers['Content-Type'] = 'text/plain' - return [cdx.to_text(fields) for cdx in cdx_iter] + content_type = 'text/plain' + return content_type, (cdx.to_text(fields) for cdx in cdx_iter) def to_link(cdx_iter, fields): - response.headers['Content-Type'] = 'application/link' - return MementoUtils.make_timemap(cdx_iter) + content_type = 'application/link' + return content_type, MementoUtils.make_timemap(cdx_iter) #============================================================================= @@ -56,10 +55,10 @@ class IndexHandler(object): def __call__(self, params): mode = params.get('mode', 'index') if mode == 'list_sources': - return self.index_source.get_source_list(params), {} + return {}, self.index_source.get_source_list(params), {} if mode != 'index': - return self.get_supported_modes(), {} + return {}, self.get_supported_modes(), {} output = params.get('output', self.DEF_OUTPUT) fields = params.get('fields') @@ -67,14 +66,15 @@ class IndexHandler(object): handler = self.OUTPUTS.get(output) if not handler: errs = dict(last_exc=BadRequestException('output={0} not supported'.format(output))) - return None, errs + return None, None, errs cdx_iter, errs = self._load_index_source(params) if not cdx_iter: - return None, errs + return None, None, errs - res = handler(cdx_iter, fields) - return res, errs + content_type, res = handler(cdx_iter, fields) + out_headers = {'Content-Type': content_type} + return out_headers, res, errs #============================================================================= @@ -94,16 +94,16 @@ class ResourceHandler(IndexHandler): cdx_iter, errs = self._load_index_source(params) if not cdx_iter: - return None, errs + return None, None, errs last_exc = None for cdx in cdx_iter: for loader in self.resource_loaders: try: - resp = loader(cdx, params) + out_headers, resp = loader(cdx, params) if resp is not None: - return resp, errs + return out_headers, resp, errs except WbException as e: last_exc = e errs[str(loader)] = repr(e) @@ -111,7 +111,7 @@ class ResourceHandler(IndexHandler): if last_exc: errs['last_exc'] = last_exc - return None, errs + return None, None, errs #============================================================================= @@ -137,11 +137,11 @@ class HandlerSeq(object): def __call__(self, params): all_errs = {} for handler in self.handlers: - res, errs = handler(params) + out_headers, res, errs = handler(params) all_errs.update(errs) if res is not None: - return res, all_errs + return out_headers, res, all_errs - return None, all_errs + return None, None, all_errs diff --git a/webagg/indexsource.py b/webagg/indexsource.py index 269df379..1637bc81 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -11,6 +11,7 @@ from pywb.cdx.query import CDXQuery from webagg.liverec import patched_requests as requests +from webagg.utils import ParamFormatter, res_template from webagg.utils import MementoUtils @@ -22,15 +23,6 @@ class BaseIndexSource(object): def load_index(self, params): #pragma: no cover raise NotImplemented() - @staticmethod - def res_template(template, params): - src_params = params.get('_src_params') - if not src_params: - res = template.format(url=params['url']) - else: - res = template.format(url=params['url'], **src_params) - return res - #============================================================================= class FileIndexSource(BaseIndexSource): @@ -38,7 +30,7 @@ class FileIndexSource(BaseIndexSource): self.filename_template = filename def load_index(self, params): - filename = self.res_template(self.filename_template, params) + filename = res_template(self.filename_template, params) try: fh = open(filename, 'rb') @@ -64,7 +56,7 @@ class RemoteIndexSource(BaseIndexSource): self.replay_url = replay_url def load_index(self, params): - api_url = self.res_template(self.api_url_template, params) + api_url = res_template(self.api_url_template, params) r = requests.get(api_url, timeout=params.get('_timeout')) if r.status_code >= 400: raise NotFoundException(api_url) @@ -73,7 +65,9 @@ class RemoteIndexSource(BaseIndexSource): def do_load(lines): for line in lines: cdx = CDXObject(line) - cdx['load_url'] = self.replay_url.format(timestamp=cdx['timestamp'], url=cdx['url']) + cdx['load_url'] = self.replay_url.format( + timestamp=cdx['timestamp'], + url=cdx['url']) yield cdx return do_load(lines) @@ -114,7 +108,7 @@ class RedisIndexSource(BaseIndexSource): self.redis = redis.StrictRedis.from_url(redis_url) def load_index(self, params): - z_key = self.res_template(self.redis_key_template, params) + z_key = res_template(self.redis_key_template, params) index_list = self.redis.zrangebylex(z_key, b'[' + params['key'], b'(' + params['end_key']) @@ -173,7 +167,7 @@ class MementoIndexSource(BaseIndexSource): yield cdx def get_timegate_links(self, params, closest): - url = self.res_template(self.timegate_url, params) + url = res_template(self.timegate_url, params) accept_dt = timestamp_to_http_date(closest) res = requests.head(url, headers={'Accept-Datetime': accept_dt}) if res.status_code >= 400: @@ -182,7 +176,7 @@ class MementoIndexSource(BaseIndexSource): return res.headers.get('Link') def get_timemap_links(self, params): - url = self.res_template(self.timemap_url, params) + url = res_template(self.timemap_url, params) res = requests.get(url, timeout=params.get('_timeout')) if res.status_code >= 400: raise NotFoundException(url) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 96e64067..b0f2ba5b 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -9,7 +9,6 @@ from pywb.utils.wbexception import LiveResourceException from pywb.warc.resolvingloader import ResolvingLoader from io import BytesIO -from bottle import response import uuid import six @@ -52,19 +51,19 @@ class StreamIter(six.Iterator): #============================================================================= class BaseLoader(object): def __call__(self, cdx, params): - res = self._load_resource(cdx, params) + out_headers, res = self._load_resource(cdx, params) if not res: - return res + return None, None - response.headers['WARC-Coll'] = cdx.get('source', '') + out_headers['WARC-Coll'] = cdx.get('source', '') - response.headers['Link'] = MementoUtils.make_link( - response.headers['WARC-Target-URI'], + out_headers['Link'] = MementoUtils.make_link( + out_headers['WARC-Target-URI'], 'original') - memento_dt = iso_date_to_datetime(response.headers['WARC-Date']) - response.headers['Memento-Datetime'] = datetime_to_http_date(memento_dt) - return res + memento_dt = iso_date_to_datetime(out_headers['WARC-Date']) + out_headers['Memento-Datetime'] = datetime_to_http_date(memento_dt) + return out_headers, res def _load_resource(self, cdx, params): #pragma: no cover raise NotImplemented() @@ -91,8 +90,8 @@ class WARCPathLoader(BaseLoader): for path in self.paths: def check(filename, cdx): try: - if hasattr(cdx, '_src_params') and cdx._src_params: - full_path = path.format(**cdx._src_params) + if hasattr(cdx, '_formatter') and cdx._formatter: + full_path = cdx._formatter.format(path) else: full_path = path full_path += filename @@ -104,9 +103,9 @@ class WARCPathLoader(BaseLoader): def _load_resource(self, cdx, params): if not cdx.get('filename') or cdx.get('offset') is None: - return None + return None, None - cdx._src_params = params.get('_src_params') + cdx._formatter = params.get('_formatter') failed_files = [] headers, payload = (self.resolve_loader. load_headers_and_payload(cdx, @@ -114,18 +113,19 @@ class WARCPathLoader(BaseLoader): self.cdx_index_source)) record = payload + out_headers = {} for n, v in record.rec_headers.headers: - response.headers[n] = v + out_headers[n] = v if headers != payload: - response.headers['WARC-Target-URI'] = headers.rec_headers.get_header('WARC-Target-URI') - response.headers['WARC-Date'] = headers.rec_headers.get_header('WARC-Date') - response.headers['WARC-Refers-To-Target-URI'] = payload.rec_headers.get_header('WARC-Target-URI') - response.headers['WARC-Refers-To-Date'] = payload.rec_headers.get_header('WARC-Date') + out_headers['WARC-Target-URI'] = headers.rec_headers.get_header('WARC-Target-URI') + out_headers['WARC-Date'] = headers.rec_headers.get_header('WARC-Date') + out_headers['WARC-Refers-To-Target-URI'] = payload.rec_headers.get_header('WARC-Target-URI') + out_headers['WARC-Refers-To-Date'] = payload.rec_headers.get_header('WARC-Date') headers.stream.close() - return StreamIter(record.stream) + return out_headers, StreamIter(record.stream) def __str__(self): return 'WARCPathLoader' @@ -137,6 +137,7 @@ class HeaderRecorder(BaseRecorder): self.buff = BytesIO() self.skip_list = skip_list self.skipped = [] + self.target_ip = None def write_response_header_line(self, line): if self.accept_header(line): @@ -152,6 +153,11 @@ class HeaderRecorder(BaseRecorder): return True + def finish_request(self, socket): + ip = socket.getpeername() + if ip: + self.target_ip = ip[0] + #============================================================================= class LiveWebLoader(BaseLoader): @@ -163,7 +169,7 @@ class LiveWebLoader(BaseLoader): def _load_resource(self, cdx, params): load_url = cdx.get('load_url') if not load_url: - return None + return None, None recorder = HeaderRecorder(self.SKIP_HEADERS) @@ -200,30 +206,33 @@ class LiveWebLoader(BaseLoader): resp_headers = recorder.get_header() - response.headers['Content-Type'] = 'application/http; msgtype=response' + out_headers = {} + out_headers['Content-Type'] = 'application/http; msgtype=response' - #response.headers['WARC-Type'] = 'response' - #response.headers['WARC-Record-ID'] = self._make_warc_id() - response.headers['WARC-Target-URI'] = cdx['url'] - response.headers['WARC-Date'] = self._make_date(dt) + out_headers['WARC-Type'] = 'response' + out_headers['WARC-Record-ID'] = self._make_warc_id() + out_headers['WARC-Target-URI'] = cdx['url'] + out_headers['WARC-Date'] = self._make_date(dt) + if recorder.target_ip: + out_headers['WARC-IP-Address'] = recorder.target_ip # Try to set content-length, if it is available and valid try: content_len = int(upstream_res.headers.get('content-length', 0)) if content_len > 0: content_len += len(resp_headers) - response.headers['Content-Length'] = content_len + out_headers['Content-Length'] = content_len except (KeyError, TypeError): pass - return StreamIter(upstream_res.raw, header=resp_headers) + return out_headers, StreamIter(upstream_res.raw, header=resp_headers) @staticmethod def _make_date(dt): return dt.strftime('%Y-%m-%dT%H:%M:%SZ') @staticmethod - def _make_warc_id(id_=None): #pragma: no cover + def _make_warc_id(id_=None): if not id_: id_ = uuid.uuid1() return ''.format(id_) diff --git a/webagg/utils.py b/webagg/utils.py index 126c0f40..ea4cec10 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -1,5 +1,6 @@ import re import six +import string from pywb.utils.timeutils import timestamp_to_http_date from pywb.utils.wbexception import BadRequestException @@ -10,12 +11,12 @@ LINK_URL = re.compile('<(.*)>') LINK_PROP = re.compile('([\w]+)="([^"]+)') -#================================================================= +#============================================================================= class MementoException(BadRequestException): pass -#================================================================= +#============================================================================= class MementoUtils(object): @staticmethod def parse_links(link_header, def_name='timemap'): @@ -102,3 +103,42 @@ class MementoUtils(object): @staticmethod def make_link(url, type): return '<{0}>; rel="{1}"'.format(url, type) + + +#============================================================================= +class ParamFormatter(string.Formatter): + def __init__(self, params, name='', prefix='param.'): + self.params = params + self.prefix = prefix + self.name = name + + def get_value(self, key, args, kwargs): + # First, try the named param 'param.{name}.{key}' + if self.name: + named_key = self.prefix + self.name + '.' + key + value = self.params.get(named_key) + if value is not None: + return value + + # Then, try 'param.{key}' + named_key = self.prefix + key + value = self.params.get(named_key) + if value is not None: + return value + + # default to just '{key}' + value = kwargs.get(key, '') + return value + + +#============================================================================= +def res_template(template, params): + formatter = params.get('_formatter') + if not formatter: + formatter = ParamFormatter(params) + + res = formatter.format(template, url=params['url']) + + return res + + From 0823ff4bd0bfa8de9350b434e2793cbe073eb838 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 6 Mar 2016 09:10:17 -0800 Subject: [PATCH 015/112] added 'upstream' handler for connecting to another webagg when 'upstream_url' is set output 'is_live' as string in live index --- test/test_handlers.py | 2 +- webagg/app.py | 3 +++ webagg/handlers.py | 5 +++-- webagg/indexsource.py | 17 ++++++++++++----- webagg/responseloader.py | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/test/test_handlers.py b/test/test_handlers.py index 043b6aea..b4da3ace 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -113,7 +113,7 @@ class TestResAgg(object): res = to_json_list(resp.text) res[0]['timestamp'] = '2016' - assert(res == [{'url': 'http://httpbin.org/get', 'urlkey': 'org,httpbin)/get', 'is_live': True, + assert(res == [{'url': 'http://httpbin.org/get', 'urlkey': 'org,httpbin)/get', 'is_live': 'true', 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) def test_live_resource(self): diff --git a/webagg/app.py b/webagg/app.py index 5a9bae15..437c1105 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -46,6 +46,9 @@ def list_routes(): #============================================================================= def err_handler(exc): + if bottle.debug: + print(exc) + traceback.print_exc() response.status = exc.status_code response.content_type = JSON_CT err_msg = json.dumps({'message': exc.body}) diff --git a/webagg/handlers.py b/webagg/handlers.py index da2ed837..b604bd62 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -1,4 +1,4 @@ -from webagg.responseloader import WARCPathLoader, LiveWebLoader +from webagg.responseloader import WARCPathLoader, LiveWebLoader, UpstreamProxyLoader from webagg.utils import MementoUtils from pywb.utils.wbexception import BadRequestException, WbException from pywb.utils.wbexception import NotFoundException @@ -118,7 +118,8 @@ class ResourceHandler(IndexHandler): class DefaultResourceHandler(ResourceHandler): def __init__(self, index_source, warc_paths=''): loaders = [WARCPathLoader(warc_paths, index_source), - LiveWebLoader() + UpstreamProxyLoader(), + LiveWebLoader(), ] super(DefaultResourceHandler, self).__init__(index_source, loaders) diff --git a/webagg/indexsource.py b/webagg/indexsource.py index 1637bc81..32fd3804 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -51,9 +51,10 @@ class FileIndexSource(BaseIndexSource): #============================================================================= class RemoteIndexSource(BaseIndexSource): - def __init__(self, api_url, replay_url): + def __init__(self, api_url, replay_url, url_field='load_url'): self.api_url_template = api_url self.replay_url = replay_url + self.url_field = url_field def load_index(self, params): api_url = res_template(self.api_url_template, params) @@ -65,13 +66,19 @@ class RemoteIndexSource(BaseIndexSource): def do_load(lines): for line in lines: cdx = CDXObject(line) - cdx['load_url'] = self.replay_url.format( - timestamp=cdx['timestamp'], - url=cdx['url']) + cdx[self.url_field] = self.replay_url.format( + timestamp=cdx['timestamp'], + url=cdx['url']) yield cdx return do_load(lines) + @staticmethod + def upstream_webagg(base_url): + api_url = base_url + '/index?url={url}' + proxy_url = base_url + '/resource?url={url}&closest={timestamp}' + return RemoteIndexSource(api_url, proxy_url, 'upstream_url') + def __str__(self): return 'remote' @@ -84,7 +91,7 @@ class LiveIndexSource(BaseIndexSource): cdx['timestamp'] = timestamp_now() cdx['url'] = params['url'] cdx['load_url'] = params['url'] - cdx['is_live'] = True + cdx['is_live'] = 'true' def live(): yield cdx diff --git a/webagg/responseloader.py b/webagg/responseloader.py index b0f2ba5b..48459345 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -1,5 +1,6 @@ from webagg.liverec import BaseRecorder from webagg.liverec import request as remote_request +from requests import request from webagg.utils import MementoUtils @@ -159,6 +160,40 @@ class HeaderRecorder(BaseRecorder): self.target_ip = ip[0] +#============================================================================= +class UpstreamProxyLoader(BaseLoader): + def _load_resource(self, cdx, params): + load_url = cdx.get('upstream_url') + if not load_url: + return None, None + + input_req = params['_input_req'] + + method = input_req.get_req_method() + data = input_req.get_req_body() + req_headers = input_req.get_req_headers() + + try: + upstream_res = request(url=load_url, + method=method, + stream=True, + allow_redirects=False, + headers=req_headers, + data=data, + timeout=params.get('_timeout')) + except Exception as e: + import traceback + traceback.print_exc() + raise LiveResourceException(load_url) + + out_headers = upstream_res.headers + + return out_headers, StreamIter(upstream_res.raw) + + def __str__(self): + return 'UpstreamProxyLoader' + + #============================================================================= class LiveWebLoader(BaseLoader): SKIP_HEADERS = (b'link', From c1895ae70f374242725167abfdc5d5eb5f0c4b9d Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 6 Mar 2016 23:10:30 -0800 Subject: [PATCH 016/112] loaders: return full WARC record in response, no need for upstream response handler add UpstreamAggIndexSource to simplify upstream aggregator config, add test for upstream config bottle app: wrap in a ResAppAgg, allow multiple bottle apps py2: non-gevent concurrency not supported --- test/test_handlers.py | 119 +++++++++++++--------- test/test_memento_agg.py | 10 +- webagg/app.py | 80 +++++++-------- webagg/handlers.py | 4 +- webagg/indexsource.py | 26 +++-- webagg/responseloader.py | 215 +++++++++++++++++++-------------------- 6 files changed, 241 insertions(+), 213 deletions(-) diff --git a/test/test_handlers.py b/test/test_handlers.py index b4da3ace..f5c96e0f 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -8,9 +8,12 @@ from webagg.indexsource import MementoIndexSource, FileIndexSource, LiveIndexSou from webagg.aggregator import GeventTimeoutAggregator, SimpleAggregator from webagg.aggregator import DirectoryIndexSource -from webagg.app import add_route, application +from webagg.app import ResAggApp from webagg.utils import MementoUtils +from pywb.utils.statusandheaders import StatusAndHeadersParser +from io import BytesIO + import webtest import bottle @@ -30,32 +33,32 @@ testapp = None def setup_module(self): live_source = SimpleAggregator({'live': LiveIndexSource()}) live_handler = DefaultResourceHandler(live_source) - add_route('/live', live_handler) + app = ResAggApp() + app.add_route('/live', live_handler) source1 = GeventTimeoutAggregator(sources) handler1 = DefaultResourceHandler(source1, to_path('testdata/')) - add_route('/many', handler1) + app.add_route('/many', handler1) source2 = SimpleAggregator({'post': FileIndexSource(to_path('testdata/post-test.cdxj'))}) handler2 = DefaultResourceHandler(source2, to_path('testdata/')) - add_route('/posttest', handler2) + app.add_route('/posttest', handler2) source3 = SimpleAggregator({'example': FileIndexSource(to_path('testdata/example.cdxj'))}) handler3 = DefaultResourceHandler(source3, to_path('testdata/')) - add_route('/fallback', HandlerSeq([handler3, + app.add_route('/fallback', HandlerSeq([handler3, handler2, live_handler])) - add_route('/seq', HandlerSeq([handler3, + app.add_route('/seq', HandlerSeq([handler3, handler2])) - add_route('/empty', HandlerSeq([])) - add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) + app.add_route('/empty', HandlerSeq([])) + app.add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) - application.debug = True global testapp - testapp = webtest.TestApp(application) + testapp = webtest.TestApp(app.application) def to_json_list(text): @@ -66,6 +69,15 @@ class TestResAgg(object): def setup(self): self.testapp = testapp + def _check_uri_date(self, resp, uri, dt): + buff = BytesIO(resp.body) + status_headers = StatusAndHeadersParser(['WARC/1.0']).parse(buff) + assert status_headers.get_header('WARC-Target-URI') == uri + if dt == True: + assert status_headers.get_header('WARC-Date') != '' + else: + assert status_headers.get_header('WARC-Date') == dt + def test_list_routes(self): resp = self.testapp.get('/') res = resp.json @@ -120,9 +132,9 @@ class TestResAgg(object): headers = {'foo': 'bar'} resp = self.testapp.get('/live/resource?url=http://httpbin.org/get?foo=bar', headers=headers) - assert resp.headers['WARC-Coll'] == 'live' - assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' - assert resp.headers['WARC-Date'] != '' + assert resp.headers['Source-Coll'] == 'live' + + self._check_uri_date(resp, 'http://httpbin.org/get?foo=bar', True) assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/get?foo=bar', 'original') assert resp.headers['Memento-Datetime'] != '' @@ -136,9 +148,9 @@ class TestResAgg(object): resp = self.testapp.post('/live/resource?url=http://httpbin.org/post', OrderedDict([('foo', 'bar')])) - assert resp.headers['WARC-Coll'] == 'live' - assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' - assert resp.headers['WARC-Date'] != '' + assert resp.headers['Source-Coll'] == 'live' + + self._check_uri_date(resp, 'http://httpbin.org/post', True) assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/post', 'original') assert resp.headers['Memento-Datetime'] != '' @@ -151,9 +163,10 @@ class TestResAgg(object): def test_agg_select_mem_1(self): resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=20141001') - assert resp.headers['WARC-Coll'] == 'rhiz' - assert resp.headers['WARC-Target-URI'] == 'http://www.vvork.com/' - assert resp.headers['WARC-Date'] == '2014-10-06T18:43:57Z' + assert resp.headers['Source-Coll'] == 'rhiz' + + self._check_uri_date(resp, 'http://www.vvork.com/', '2014-10-06T18:43:57Z') + assert b'HTTP/1.1 200 OK' in resp.body assert resp.headers['Link'] == MementoUtils.make_link('http://www.vvork.com/', 'original') @@ -164,9 +177,10 @@ class TestResAgg(object): def test_agg_select_mem_2(self): resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=20151231') - assert resp.headers['WARC-Coll'] == 'ia' - assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' - assert resp.headers['WARC-Date'] == '2016-01-10T13:48:55Z' + assert resp.headers['Source-Coll'] == 'ia' + + self._check_uri_date(resp, 'http://vvork.com/', '2016-01-10T13:48:55Z') + assert b'HTTP/1.1 200 OK' in resp.body assert resp.headers['Link'] == MementoUtils.make_link('http://vvork.com/', 'original') @@ -177,9 +191,9 @@ class TestResAgg(object): def test_agg_select_live(self): resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=2016') - assert resp.headers['WARC-Coll'] == 'live' - assert resp.headers['WARC-Target-URI'] == 'http://vvork.com/' - assert resp.headers['WARC-Date'] != '' + assert resp.headers['Source-Coll'] == 'live' + + self._check_uri_date(resp, 'http://vvork.com/', True) assert resp.headers['Link'] == MementoUtils.make_link('http://vvork.com/', 'original') assert resp.headers['Memento-Datetime'] != '' @@ -189,9 +203,9 @@ class TestResAgg(object): def test_agg_select_local(self): resp = self.testapp.get('/many/resource?url=http://iana.org/&closest=20140126200624') - assert resp.headers['WARC-Coll'] == 'local' - assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' - assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + assert resp.headers['Source-Coll'] == 'local' + + self._check_uri_date(resp, 'http://www.iana.org/', '2014-01-26T20:06:24Z') assert resp.headers['Link'] == MementoUtils.make_link('http://www.iana.org/', 'original') assert resp.headers['Memento-Datetime'] == 'Sun, 26 Jan 2014 20:06:24 GMT' @@ -208,9 +222,9 @@ Host: iana.org resp = self.testapp.post('/many/resource/postreq?url=http://iana.org/&closest=20140126200624', req_data) - assert resp.headers['WARC-Coll'] == 'local' - assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' - assert resp.headers['WARC-Date'] == '2014-01-26T20:06:24Z' + assert resp.headers['Source-Coll'] == 'local' + + self._check_uri_date(resp, 'http://www.iana.org/', '2014-01-26T20:06:24Z') assert resp.headers['Link'] == MementoUtils.make_link('http://www.iana.org/', 'original') assert resp.headers['Memento-Datetime'] == 'Sun, 26 Jan 2014 20:06:24 GMT' @@ -227,9 +241,9 @@ Host: httpbin.org resp = self.testapp.post('/many/resource/postreq?url=http://httpbin.org/get?foo=bar&closest=now', req_data) - assert resp.headers['WARC-Coll'] == 'live' - assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/get?foo=bar' - assert resp.headers['WARC-Date'] != '' + assert resp.headers['Source-Coll'] == 'live' + + self._check_uri_date(resp, 'http://httpbin.org/get?foo=bar', True) assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/get?foo=bar', 'original') assert resp.headers['Memento-Datetime'] != '' @@ -252,9 +266,9 @@ foo=bar&test=abc""" resp = self.testapp.post('/posttest/resource/postreq?url=http://httpbin.org/post', req_data) - assert resp.headers['WARC-Coll'] == 'post' - assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' - assert resp.headers['WARC-Date'] != '' + assert resp.headers['Source-Coll'] == 'post' + + self._check_uri_date(resp, 'http://httpbin.org/post', True) assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/post', 'original') assert resp.headers['Memento-Datetime'] != '' @@ -271,8 +285,10 @@ foo=bar&test=abc""" resp = self.testapp.post('/fallback/resource?url=http://httpbin.org/post', req_data) - assert resp.headers['WARC-Coll'] == 'post' - assert resp.headers['WARC-Target-URI'] == 'http://httpbin.org/post' + assert resp.headers['Source-Coll'] == 'post' + + self._check_uri_date(resp, 'http://httpbin.org/post', True) + assert resp.headers['Link'] == MementoUtils.make_link('http://httpbin.org/post', 'original') assert b'HTTP/1.1 200 OK' in resp.body @@ -285,8 +301,10 @@ foo=bar&test=abc""" def test_agg_seq_fallback_1(self): resp = self.testapp.get('/fallback/resource?url=http://www.iana.org/') - assert resp.headers['WARC-Coll'] == 'live' - assert resp.headers['WARC-Target-URI'] == 'http://www.iana.org/' + assert resp.headers['Source-Coll'] == 'live' + + self._check_uri_date(resp, 'http://www.iana.org/', True) + assert resp.headers['Link'] == MementoUtils.make_link('http://www.iana.org/', 'original') assert b'HTTP/1.1 200 OK' in resp.body @@ -296,9 +314,9 @@ foo=bar&test=abc""" def test_agg_seq_fallback_2(self): resp = self.testapp.get('/fallback/resource?url=http://www.example.com/') - assert resp.headers['WARC-Coll'] == 'example' - assert resp.headers['WARC-Date'] == '2016-02-25T04:23:29Z' - assert resp.headers['WARC-Target-URI'] == 'http://example.com/' + assert resp.headers['Source-Coll'] == 'example' + + self._check_uri_date(resp, 'http://example.com/', '2016-02-25T04:23:29Z') assert resp.headers['Link'] == MementoUtils.make_link('http://example.com/', 'original') assert resp.headers['Memento-Datetime'] == 'Thu, 25 Feb 2016 04:23:29 GMT' @@ -318,11 +336,14 @@ foo=bar&test=abc""" def test_agg_local_revisit(self): resp = self.testapp.get('/many/resource?url=http://www.example.com/&closest=20140127171251&sources=local') - assert resp.headers['WARC-Coll'] == 'local' - assert resp.headers['WARC-Target-URI'] == 'http://example.com' - assert resp.headers['WARC-Date'] == '2014-01-27T17:12:51Z' - assert resp.headers['WARC-Refers-To-Target-URI'] == 'http://example.com' - assert resp.headers['WARC-Refers-To-Date'] == '2014-01-27T17:12:00Z' + assert resp.headers['Source-Coll'] == 'local' + + buff = BytesIO(resp.body) + status_headers = StatusAndHeadersParser(['WARC/1.0']).parse(buff) + assert status_headers.get_header('WARC-Target-URI') == 'http://example.com' + assert status_headers.get_header('WARC-Date') == '2014-01-27T17:12:51Z' + assert status_headers.get_header('WARC-Refers-To-Target-URI') == 'http://example.com' + assert status_headers.get_header('WARC-Refers-To-Date') == '2014-01-27T17:12:00Z' assert resp.headers['Link'] == MementoUtils.make_link('http://example.com', 'original') assert resp.headers['Memento-Datetime'] == 'Mon, 27 Jan 2014 17:12:51 GMT' diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index 017d1871..88f36daf 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -9,6 +9,7 @@ from .testutils import json_list, to_path import json import pytest import time +import six from webagg.handlers import IndexHandler @@ -39,8 +40,13 @@ agg_nf = {'simple': SimpleAggregator(nf), 'processes': ThreadedTimeoutAggregator(nf, timeout=5.0, use_processes=True), } -#def pytest_generate_tests(metafunc): -# metafunc.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) +if six.PY2: + del aggs['threaded'] + del aggs['processes'] + del agg_tm['threaded'] + del agg_tm['processes'] + del agg_nf['threaded'] + del agg_nf['processes'] @pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) diff --git a/webagg/app.py b/webagg/app.py index 437c1105..2745223d 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -1,7 +1,7 @@ from webagg.liverec import request as remote_request from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest -from bottle import route, request, response, default_app, abort +from bottle import route, request, response, abort, Bottle import bottle import traceback @@ -11,49 +11,46 @@ JSON_CT = 'application/json; charset=utf-8' #============================================================================= -route_dict = {} +class ResAggApp(object): + def __init__(self, *args, **kwargs): + self.application = Bottle() + self.application.default_error_handler = self.err_handler + self.route_dict = {} + @self.application.route('/') + def list_routes(): + return self.route_dict -#============================================================================= -def add_route(path, handler): - @route([path, path + '/'], 'ANY') - @wrap_error - def direct_input_request(mode=''): - params = dict(request.query) - params['mode'] = mode - params['_input_req'] = DirectWSGIInputRequest(request.environ) - return handler(params) + def add_route(self, path, handler): + @self.application.route([path, path + '/'], 'ANY') + @wrap_error + def direct_input_request(mode=''): + params = dict(request.query) + params['mode'] = mode + params['_input_req'] = DirectWSGIInputRequest(request.environ) + return handler(params) - @route([path + '/postreq', path + '//postreq'], 'POST') - @wrap_error - def post_fullrequest(mode=''): - params = dict(request.query) - params['mode'] = mode - params['_input_req'] = POSTInputRequest(request.environ) - return handler(params) + @self.application.route([path + '/postreq', path + '//postreq'], 'POST') + @wrap_error + def post_fullrequest(mode=''): + params = dict(request.query) + params['mode'] = mode + params['_input_req'] = POSTInputRequest(request.environ) + return handler(params) - global route_dict - handler_dict = handler.get_supported_modes() - route_dict[path] = handler_dict - route_dict[path + '/postreq'] = handler_dict + handler_dict = handler.get_supported_modes() + self.route_dict[path] = handler_dict + self.route_dict[path + '/postreq'] = handler_dict - -#============================================================================= -@route('/') -def list_routes(): - return route_dict - - -#============================================================================= -def err_handler(exc): - if bottle.debug: - print(exc) - traceback.print_exc() - response.status = exc.status_code - response.content_type = JSON_CT - err_msg = json.dumps({'message': exc.body}) - response.headers['ResErrors'] = err_msg - return err_msg + def err_handler(self, exc): + if bottle.debug: + print(exc) + traceback.print_exc() + response.status = exc.status_code + response.content_type = JSON_CT + err_msg = json.dumps({'message': exc.body}) + response.headers['ResErrors'] = err_msg + return err_msg #============================================================================= @@ -99,8 +96,3 @@ def wrap_error(func): return wrap_func -#============================================================================= -application = default_app() -application.default_error_handler = err_handler - - diff --git a/webagg/handlers.py b/webagg/handlers.py index b604bd62..55529156 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -1,4 +1,4 @@ -from webagg.responseloader import WARCPathLoader, LiveWebLoader, UpstreamProxyLoader +from webagg.responseloader import WARCPathLoader, LiveWebLoader from webagg.utils import MementoUtils from pywb.utils.wbexception import BadRequestException, WbException from pywb.utils.wbexception import NotFoundException @@ -118,7 +118,7 @@ class ResourceHandler(IndexHandler): class DefaultResourceHandler(ResourceHandler): def __init__(self, index_source, warc_paths=''): loaders = [WARCPathLoader(warc_paths, index_source), - UpstreamProxyLoader(), + # UpstreamProxyLoader(), LiveWebLoader(), ] super(DefaultResourceHandler, self).__init__(index_source, loaders) diff --git a/webagg/indexsource.py b/webagg/indexsource.py index 32fd3804..6989b894 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -66,23 +66,33 @@ class RemoteIndexSource(BaseIndexSource): def do_load(lines): for line in lines: cdx = CDXObject(line) - cdx[self.url_field] = self.replay_url.format( - timestamp=cdx['timestamp'], - url=cdx['url']) + self._set_load_url(cdx) yield cdx return do_load(lines) - @staticmethod - def upstream_webagg(base_url): - api_url = base_url + '/index?url={url}' - proxy_url = base_url + '/resource?url={url}&closest={timestamp}' - return RemoteIndexSource(api_url, proxy_url, 'upstream_url') + def _set_load_url(self, cdx): + cdx[self.url_field] = self.replay_url.format( + timestamp=cdx['timestamp'], + url=cdx['url']) def __str__(self): return 'remote' +#============================================================================= +class UpstreamAggIndexSource(RemoteIndexSource): + def __init__(self, base_url): + api_url = base_url + '/index?url={url}' + proxy_url = base_url + '/resource?url={url}&closest={timestamp}' + super(UpstreamAggIndexSource, self).__init__(api_url, proxy_url, 'filename') + + def _set_load_url(self, cdx): + super(UpstreamAggIndexSource, self)._set_load_url(cdx) + cdx['offset'] = '0' + cdx.pop('load_url', '') + + #============================================================================= class LiveIndexSource(BaseIndexSource): def load_index(self, params): diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 48459345..d29b629d 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -1,34 +1,44 @@ from webagg.liverec import BaseRecorder from webagg.liverec import request as remote_request -from requests import request from webagg.utils import MementoUtils +from requests import session + from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date from pywb.utils.timeutils import iso_date_to_datetime from pywb.utils.wbexception import LiveResourceException +from pywb.utils.statusandheaders import StatusAndHeaders + from pywb.warc.resolvingloader import ResolvingLoader + from io import BytesIO import uuid import six +import itertools #============================================================================= class StreamIter(six.Iterator): - def __init__(self, stream, header=None, size=8192): + def __init__(self, stream, header1=None, header2=None, size=8192): self.stream = stream - self.header = header + self.header1 = header1 + self.header2 = header2 self.size = size def __iter__(self): return self def __next__(self): - if self.header: - header = self.header - self.header = None + if self.header1: + header = self.header1 + self.header1 = None + return header + elif self.header2: + header = self.header2 + self.header2 = None return header data = self.stream.read(self.size) @@ -52,22 +62,44 @@ class StreamIter(six.Iterator): #============================================================================= class BaseLoader(object): def __call__(self, cdx, params): - out_headers, res = self._load_resource(cdx, params) - if not res: + entry = self._load_resource(cdx, params) + if not entry: return None, None - out_headers['WARC-Coll'] = cdx.get('source', '') + warc_headers, other_headers_buff, stream = entry + + out_headers = {} + out_headers['Source-Coll'] = cdx.get('source', '') out_headers['Link'] = MementoUtils.make_link( - out_headers['WARC-Target-URI'], - 'original') + warc_headers.get_header('WARC-Target-URI'), + 'original') - memento_dt = iso_date_to_datetime(out_headers['WARC-Date']) + out_headers['Content-Type'] = 'application/warc-record' + + memento_dt = iso_date_to_datetime(warc_headers.get_header('WARC-Date')) out_headers['Memento-Datetime'] = datetime_to_http_date(memento_dt) - return out_headers, res - def _load_resource(self, cdx, params): #pragma: no cover - raise NotImplemented() + warc_headers_buff = warc_headers.to_bytes() + + self._set_content_len(warc_headers.get_header('Content-Length'), + out_headers, + len(warc_headers_buff)) + + return out_headers, StreamIter(stream, + header1=warc_headers_buff, + header2=other_headers_buff) + + def _set_content_len(self, content_len_str, headers, existing_len): + # Try to set content-length, if it is available and valid + try: + content_len = int(content_len_str) + except (KeyError, TypeError): + content_len = -1 + + if content_len >= 0: + content_len += existing_len + headers['Content-Length'] = str(content_len) #============================================================================= @@ -104,7 +136,7 @@ class WARCPathLoader(BaseLoader): def _load_resource(self, cdx, params): if not cdx.get('filename') or cdx.get('offset') is None: - return None, None + return None cdx._formatter = params.get('_formatter') failed_files = [] @@ -112,88 +144,29 @@ class WARCPathLoader(BaseLoader): load_headers_and_payload(cdx, failed_files, self.cdx_index_source)) - - record = payload - out_headers = {} - - for n, v in record.rec_headers.headers: - out_headers[n] = v + warc_headers = payload.rec_headers if headers != payload: - out_headers['WARC-Target-URI'] = headers.rec_headers.get_header('WARC-Target-URI') - out_headers['WARC-Date'] = headers.rec_headers.get_header('WARC-Date') - out_headers['WARC-Refers-To-Target-URI'] = payload.rec_headers.get_header('WARC-Target-URI') - out_headers['WARC-Refers-To-Date'] = payload.rec_headers.get_header('WARC-Date') + warc_headers.replace_header('WARC-Refers-To-Target-URI', + payload.rec_headers.get_header('WARC-Target-URI')) + + warc_headers.replace_header('WARC-Refers-To-Date', + payload.rec_headers.get_header('WARC-Date')) + + warc_headers.replace_header('WARC-Target-URI', + headers.rec_headers.get_header('WARC-Target-URI')) + + warc_headers.replace_header('WARC-Date', + headers.rec_headers.get_header('WARC-Date')) + headers.stream.close() - return out_headers, StreamIter(record.stream) + return (warc_headers, None, payload.stream) def __str__(self): return 'WARCPathLoader' -#============================================================================= -class HeaderRecorder(BaseRecorder): - def __init__(self, skip_list=None): - self.buff = BytesIO() - self.skip_list = skip_list - self.skipped = [] - self.target_ip = None - - def write_response_header_line(self, line): - if self.accept_header(line): - self.buff.write(line) - - def get_header(self): - return self.buff.getvalue() - - def accept_header(self, line): - if self.skip_list and line.lower().startswith(self.skip_list): - self.skipped.append(line) - return False - - return True - - def finish_request(self, socket): - ip = socket.getpeername() - if ip: - self.target_ip = ip[0] - - -#============================================================================= -class UpstreamProxyLoader(BaseLoader): - def _load_resource(self, cdx, params): - load_url = cdx.get('upstream_url') - if not load_url: - return None, None - - input_req = params['_input_req'] - - method = input_req.get_req_method() - data = input_req.get_req_body() - req_headers = input_req.get_req_headers() - - try: - upstream_res = request(url=load_url, - method=method, - stream=True, - allow_redirects=False, - headers=req_headers, - data=data, - timeout=params.get('_timeout')) - except Exception as e: - import traceback - traceback.print_exc() - raise LiveResourceException(load_url) - - out_headers = upstream_res.headers - - return out_headers, StreamIter(upstream_res.raw) - - def __str__(self): - return 'UpstreamProxyLoader' - - #============================================================================= class LiveWebLoader(BaseLoader): SKIP_HEADERS = (b'link', @@ -204,7 +177,7 @@ class LiveWebLoader(BaseLoader): def _load_resource(self, cdx, params): load_url = cdx.get('load_url') if not load_url: - return None, None + return None recorder = HeaderRecorder(self.SKIP_HEADERS) @@ -236,31 +209,28 @@ class LiveWebLoader(BaseLoader): headers=req_headers, data=data, timeout=params.get('_timeout')) - except Exception: + except Exception as e: raise LiveResourceException(load_url) - resp_headers = recorder.get_header() + http_headers_buff = recorder.get_headers_buff() - out_headers = {} - out_headers['Content-Type'] = 'application/http; msgtype=response' + warc_headers = {} - out_headers['WARC-Type'] = 'response' - out_headers['WARC-Record-ID'] = self._make_warc_id() - out_headers['WARC-Target-URI'] = cdx['url'] - out_headers['WARC-Date'] = self._make_date(dt) + warc_headers['WARC-Type'] = 'response' + warc_headers['WARC-Record-ID'] = self._make_warc_id() + warc_headers['WARC-Target-URI'] = cdx['url'] + warc_headers['WARC-Date'] = self._make_date(dt) if recorder.target_ip: - out_headers['WARC-IP-Address'] = recorder.target_ip + warc_headers['WARC-IP-Address'] = recorder.target_ip - # Try to set content-length, if it is available and valid - try: - content_len = int(upstream_res.headers.get('content-length', 0)) - if content_len > 0: - content_len += len(resp_headers) - out_headers['Content-Length'] = content_len - except (KeyError, TypeError): - pass + warc_headers['Content-Type'] = 'application/http; msgtype=response' - return out_headers, StreamIter(upstream_res.raw, header=resp_headers) + self._set_content_len(upstream_res.headers.get('Content-Length', -1), + warc_headers, + len(http_headers_buff)) + + warc_headers = StatusAndHeaders('WARC/1.0', warc_headers.items()) + return (warc_headers, http_headers_buff, upstream_res.raw) @staticmethod def _make_date(dt): @@ -275,3 +245,32 @@ class LiveWebLoader(BaseLoader): def __str__(self): return 'LiveWebLoader' + +#============================================================================= +class HeaderRecorder(BaseRecorder): + def __init__(self, skip_list=None): + self.buff = BytesIO() + self.skip_list = skip_list + self.skipped = [] + self.target_ip = None + + def write_response_header_line(self, line): + if self.accept_header(line): + self.buff.write(line) + + def get_headers_buff(self): + return self.buff.getvalue() + + def accept_header(self, line): + if self.skip_list and line.lower().startswith(self.skip_list): + self.skipped.append(line) + return False + + return True + + def finish_request(self, socket): + ip = socket.getpeername() + if ip: + self.target_ip = ip[0] + + From 107ba9aabc75769c739d79eac938c209213d3f36 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 8 Mar 2016 10:27:13 -0800 Subject: [PATCH 017/112] add ProxyLiveIndexSource for proxying upstream conn directly w/o a second index query liveloader: if 'memento_url' key is set, then memento-datetime header must be present or its an error response liveindexsource: add option to specify custom live path (eg. prefix for cacheing) fix test cases changed due to ia (todo: mock up all external data!) --- setup.py | 2 +- test/test_dir_agg.py | 13 +++++++-- test/test_memento_agg.py | 5 ++-- webagg/handlers.py | 1 - webagg/indexsource.py | 23 ++++------------ webagg/proxyindexsource.py | 54 ++++++++++++++++++++++++++++++++++++ webagg/responseloader.py | 56 +++++++++++++++++++++++++++----------- 7 files changed, 115 insertions(+), 39 deletions(-) create mode 100644 webagg/proxyindexsource.py diff --git a/setup.py b/setup.py index fee3441c..cada7efc 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ class PyTest(TestCommand): import pytest import sys import os - cmdline = ' --cov webagg -v test/' + cmdline = ' --cov webagg -vv test/' errcode = pytest.main(cmdline) sys.exit(errcode) diff --git a/test/test_dir_agg.py b/test/test_dir_agg.py index 2500b9cf..9d2db560 100644 --- a/test/test_dir_agg.py +++ b/test/test_dir_agg.py @@ -5,6 +5,8 @@ import json from .testutils import to_path +from mock import patch + from webagg.aggregator import DirectoryIndexSource, SimpleAggregator from webagg.indexsource import MementoIndexSource @@ -14,6 +16,10 @@ root_dir = None orig_cwd = None dir_loader = None +linkheader = """\ +; rel="original", ; rel="timemap"; type="application/link-format", ; rel="first memento"; datetime="Sun, 20 Jan 2002 14:25:10 GMT", ; rel="prev memento"; datetime="Sat, 01 May 2010 12:34:14 GMT", ; rel="memento"; datetime="Fri, 14 May 2010 23:18:57 GMT", ; rel="next memento"; datetime="Wed, 19 May 2010 20:24:18 GMT", ; rel="last memento"; datetime="Mon, 07 Mar 2016 20:06:19 GMT"\ +""" + def setup_module(): global root_dir root_dir = tempfile.mkdtemp() @@ -124,7 +130,10 @@ def test_agg_all_found_2(): assert(errs == {}) +def mock_link_header(*args, **kwargs): + return linkheader +@patch('webagg.indexsource.MementoIndexSource.get_timegate_links', mock_link_header) def test_agg_dir_and_memento(): sources = {'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), 'local': dir_loader} @@ -133,9 +142,9 @@ def test_agg_dir_and_memento(): res, errs = agg_source({'url': 'example.com/', 'param.local.coll': '*', 'closest': '20100512', 'limit': 6}) exp = [ - {'source': 'ia', 'timestamp': '20100513052358', 'load_url': 'http://web.archive.org/web/20100513052358id_/http://example.com/'}, {'source': 'ia', 'timestamp': '20100514231857', 'load_url': 'http://web.archive.org/web/20100514231857id_/http://example.com/'}, - {'source': 'ia', 'timestamp': '20100506013442', 'load_url': 'http://web.archive.org/web/20100506013442id_/http://example.com/'}, + {'source': 'ia', 'timestamp': '20100519202418', 'load_url': 'http://web.archive.org/web/20100519202418id_/http://example.com/'}, + {'source': 'ia', 'timestamp': '20100501123414', 'load_url': 'http://web.archive.org/web/20100501123414id_/http://example.com/'}, {'source': 'local:colls/C/indexes', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, {'source': 'local:colls/C/indexes', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, {'source': 'local:colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index 88f36daf..934a9474 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -73,10 +73,11 @@ def test_mem_agg_index_2(agg): exp = [{"timestamp": "20100513010014", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100513010014id_/http://example.com/", "source": "bl"}, {"timestamp": "20100512204410", "load_url": "http://www.webarchive.org.uk/wayback/archive/20100512204410id_/http://example.com/", "source": "bl"}, - {"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source": "ia"}, + #{"timestamp": "20100513052358", "load_url": "http://web.archive.org/web/20100513052358id_/http://example.com/", "source": "ia"}, {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source": "ait"}, + {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}, {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source": "ait"}, - {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}] + {"timestamp": "20100519202418", "load_url": "http://web.archive.org/web/20100519202418id_/http://example.com/", "source": "ia"}] assert(json_list(res) == exp) assert(errs == {'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://example.com/',)"}) diff --git a/webagg/handlers.py b/webagg/handlers.py index 55529156..d9c06f96 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -118,7 +118,6 @@ class ResourceHandler(IndexHandler): class DefaultResourceHandler(ResourceHandler): def __init__(self, index_source, warc_paths=''): loaders = [WARCPathLoader(warc_paths, index_source), - # UpstreamProxyLoader(), LiveWebLoader(), ] super(DefaultResourceHandler, self).__init__(index_source, loaders) diff --git a/webagg/indexsource.py b/webagg/indexsource.py index 6989b894..c83d3006 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -2,12 +2,11 @@ import redis from pywb.utils.binsearch import iter_range from pywb.utils.timeutils import timestamp_to_http_date, http_date_to_timestamp -from pywb.utils.timeutils import timestamp_to_sec, timestamp_now -from pywb.utils.canonicalize import canonicalize, calc_search_range +from pywb.utils.timeutils import timestamp_now +from pywb.utils.canonicalize import canonicalize from pywb.utils.wbexception import NotFoundException from pywb.cdx.cdxobject import CDXObject -from pywb.cdx.query import CDXQuery from webagg.liverec import patched_requests as requests @@ -80,27 +79,17 @@ class RemoteIndexSource(BaseIndexSource): return 'remote' -#============================================================================= -class UpstreamAggIndexSource(RemoteIndexSource): - def __init__(self, base_url): - api_url = base_url + '/index?url={url}' - proxy_url = base_url + '/resource?url={url}&closest={timestamp}' - super(UpstreamAggIndexSource, self).__init__(api_url, proxy_url, 'filename') - - def _set_load_url(self, cdx): - super(UpstreamAggIndexSource, self)._set_load_url(cdx) - cdx['offset'] = '0' - cdx.pop('load_url', '') - - #============================================================================= class LiveIndexSource(BaseIndexSource): + def __init__(self, proxy_url='{url}'): + self.proxy_url = proxy_url + def load_index(self, params): cdx = CDXObject() cdx['urlkey'] = params.get('key').decode('utf-8') cdx['timestamp'] = timestamp_now() cdx['url'] = params['url'] - cdx['load_url'] = params['url'] + cdx['load_url'] = res_template(self.proxy_url, params) cdx['is_live'] = 'true' def live(): yield cdx diff --git a/webagg/proxyindexsource.py b/webagg/proxyindexsource.py new file mode 100644 index 00000000..435c9240 --- /dev/null +++ b/webagg/proxyindexsource.py @@ -0,0 +1,54 @@ +from pywb.cdx.cdxobject import CDXObject +from pywb.utils.wbexception import NotFoundException +from webagg.indexsource import BaseIndexSource, RemoteIndexSource +from webagg.responseloader import LiveWebLoader +from webagg.utils import ParamFormatter, res_template +from pywb.utils.timeutils import timestamp_now + + +#============================================================================= +class UpstreamAggIndexSource(RemoteIndexSource): + def __init__(self, base_url): + api_url = base_url + '/index?url={url}' + proxy_url = base_url + '/resource?url={url}&closest={timestamp}' + super(UpstreamAggIndexSource, self).__init__(api_url, proxy_url, 'filename') + + def _set_load_url(self, cdx): + super(UpstreamAggIndexSource, self)._set_load_url(cdx) + cdx['offset'] = '0' + cdx.pop('load_url', '') + + +#============================================================================= +class ProxyMementoIndexSource(BaseIndexSource): + def __init__(self, proxy_url='{url}'): + self.proxy_url = proxy_url + self.loader = LiveWebLoader() + + def load_index(self, params): + cdx = CDXObject() + cdx['urlkey'] = params.get('key').decode('utf-8') + + closest = params.get('closest') + cdx['timestamp'] = closest if closest else timestamp_now() + cdx['url'] = params['url'] + cdx['load_url'] = res_template(self.proxy_url, params) + cdx['memento_url'] = cdx['load_url'] + return self._do_load(cdx, params) + + def _do_load(self, cdx, params): + result = self.loader.load_resource(cdx, params) + if not result: + raise NotFoundException('Not a memento: ' + cdx['url']) + + cdx['_cached_result'] = result + yield cdx + + def __str__(self): + return 'proxy' + + @staticmethod + def upstream_resource(base_url): + return ProxyMementoIndexSource(base_url + '/resource?url={url}&closest={closest}') + + diff --git a/webagg/responseloader.py b/webagg/responseloader.py index d29b629d..82d98e41 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -3,10 +3,10 @@ from webagg.liverec import request as remote_request from webagg.utils import MementoUtils -from requests import session +from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_timestamp +from pywb.utils.timeutils import iso_date_to_datetime, datetime_to_iso_date +from pywb.utils.timeutils import http_date_to_datetime, datetime_to_http_date -from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_http_date -from pywb.utils.timeutils import iso_date_to_datetime from pywb.utils.wbexception import LiveResourceException from pywb.utils.statusandheaders import StatusAndHeaders @@ -62,21 +62,32 @@ class StreamIter(six.Iterator): #============================================================================= class BaseLoader(object): def __call__(self, cdx, params): - entry = self._load_resource(cdx, params) + entry = self.load_resource(cdx, params) if not entry: return None, None - warc_headers, other_headers_buff, stream = entry + warc_headers, other_headers, stream = entry out_headers = {} + out_headers['WebAgg-Type'] = 'warc' out_headers['Source-Coll'] = cdx.get('source', '') + out_headers['Content-Type'] = 'application/warc-record' + + if not warc_headers: + if other_headers: + out_headers['Link'] = other_headers.get('Link') + out_headers['Memento-Datetime'] = other_headers.get('Memento-Datetime') + out_headers['Content-Length'] = other_headers.get('Content-Length') + + #for n, v in other_headers.items(): + # out_headers[n] = v + + return out_headers, StreamIter(stream) out_headers['Link'] = MementoUtils.make_link( warc_headers.get_header('WARC-Target-URI'), 'original') - out_headers['Content-Type'] = 'application/warc-record' - memento_dt = iso_date_to_datetime(warc_headers.get_header('WARC-Date')) out_headers['Memento-Datetime'] = datetime_to_http_date(memento_dt) @@ -88,7 +99,7 @@ class BaseLoader(object): return out_headers, StreamIter(stream, header1=warc_headers_buff, - header2=other_headers_buff) + header2=other_headers) def _set_content_len(self, content_len_str, headers, existing_len): # Try to set content-length, if it is available and valid @@ -134,7 +145,10 @@ class WARCPathLoader(BaseLoader): yield check - def _load_resource(self, cdx, params): + def load_resource(self, cdx, params): + if cdx.get('_cached_result'): + return cdx.get('_cached_result') + if not cdx.get('filename') or cdx.get('offset') is None: return None @@ -174,7 +188,7 @@ class LiveWebLoader(BaseLoader): b'content-location', b'x-archive') - def _load_resource(self, cdx, params): + def load_resource(self, cdx, params): load_url = cdx.get('load_url') if not load_url: return None @@ -187,7 +201,7 @@ class LiveWebLoader(BaseLoader): dt = timestamp_to_datetime(cdx['timestamp']) - if not cdx.get('is_live'): + if cdx.get('memento_url'): req_headers['Accept-Datetime'] = datetime_to_http_date(dt) # if different url, ensure origin is not set @@ -212,6 +226,20 @@ class LiveWebLoader(BaseLoader): except Exception as e: raise LiveResourceException(load_url) + memento_dt = upstream_res.headers.get('Memento-Datetime') + if memento_dt: + dt = http_date_to_datetime(memento_dt) + cdx['timestamp'] = datetime_to_timestamp(dt) + elif cdx.get('memento_url'): + # if 'memento_url' set and no Memento-Datetime header present + # then its an error + return None + + agg_type = upstream_res.headers.get('WebAgg-Type') + if agg_type == 'warc': + cdx['source'] = upstream_res.headers.get('Source-Coll') + return None, upstream_res.headers, upstream_res.raw + http_headers_buff = recorder.get_headers_buff() warc_headers = {} @@ -219,7 +247,7 @@ class LiveWebLoader(BaseLoader): warc_headers['WARC-Type'] = 'response' warc_headers['WARC-Record-ID'] = self._make_warc_id() warc_headers['WARC-Target-URI'] = cdx['url'] - warc_headers['WARC-Date'] = self._make_date(dt) + warc_headers['WARC-Date'] = datetime_to_iso_date(dt) if recorder.target_ip: warc_headers['WARC-IP-Address'] = recorder.target_ip @@ -232,10 +260,6 @@ class LiveWebLoader(BaseLoader): warc_headers = StatusAndHeaders('WARC/1.0', warc_headers.items()) return (warc_headers, http_headers_buff, upstream_res.raw) - @staticmethod - def _make_date(dt): - return dt.strftime('%Y-%m-%dT%H:%M:%SZ') - @staticmethod def _make_warc_id(id_=None): if not id_: From 348fb133e03f6730f914b05c740e07ce7a8b6259 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 8 Mar 2016 10:29:59 -0800 Subject: [PATCH 018/112] add upstream/proxy tests --- test/test_upstream.py | 113 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/test_upstream.py diff --git a/test/test_upstream.py b/test/test_upstream.py new file mode 100644 index 00000000..6ca6bb61 --- /dev/null +++ b/test/test_upstream.py @@ -0,0 +1,113 @@ +from webagg.app import ResAggApp + +import webtest +import threading + +from io import BytesIO +import requests + +from webagg.handlers import DefaultResourceHandler +from webagg.indexsource import LiveIndexSource +from webagg.proxyindexsource import ProxyMementoIndexSource, UpstreamAggIndexSource +from webagg.aggregator import SimpleAggregator + +from wsgiref.simple_server import make_server + +from pywb.warc.recordloader import ArcWarcRecordLoader + + +class ServerThreadRunner(object): + def __init__(self, app): + self.httpd = make_server('', 0, app) + self.port = self.httpd.socket.getsockname()[1] + + def run(): + self.httpd.serve_forever() + + self.thread = threading.Thread(target=run) + self.thread.daemon = True + self.thread.start() + + def stop_thread(self): + self.httpd.shutdown() + + +server = None + + +def setup_module(): + app = ResAggApp() + app.add_route('/live', + DefaultResourceHandler(SimpleAggregator( + {'live': LiveIndexSource()}) + ) + ) + + global server + server = ServerThreadRunner(app.application) + +def teardown_module(): + global server + server.stop_thread() + + + +class TestUpstream(object): + def setup(self): + app = ResAggApp() + + base_url = 'http://localhost:{0}'.format(server.port) + app.add_route('/upstream', + DefaultResourceHandler(SimpleAggregator( + {'upstream': UpstreamAggIndexSource(base_url + '/live')}) + ) + ) + + app.add_route('/upstream_opt', + DefaultResourceHandler(SimpleAggregator( + {'upstream_opt': ProxyMementoIndexSource.upstream_resource(base_url + '/live')}) + ) + ) + + self.base_url = base_url + self.testapp = webtest.TestApp(app.application) + + + def test_live_paths(self): + res = requests.get(self.base_url + '/') + assert set(res.json().keys()) == {'/live/postreq', '/live'} + + def test_upstream_paths(self): + res = self.testapp.get('/') + assert set(res.json.keys()) == {'/upstream/postreq', '/upstream', '/upstream_opt', '/upstream_opt/postreq'} + + def test_live_1(self): + resp = requests.get(self.base_url + '/live/resource?url=http://httpbin.org/get', stream=True) + assert resp.headers['Source-Coll'] == 'live' + + record = ArcWarcRecordLoader().parse_record_stream(resp.raw, no_record_parse=False) + assert record.rec_headers.get_header('WARC-Target-URI') == 'http://httpbin.org/get' + assert record.status_headers.get_header('Date') != '' + + def test_upstream_1(self): + resp = self.testapp.get('/upstream/resource?url=http://httpbin.org/get') + assert resp.headers['Source-Coll'] == 'upstream:live' + + raw = BytesIO(resp.body) + + record = ArcWarcRecordLoader().parse_record_stream(raw, no_record_parse=False) + assert record.rec_headers.get_header('WARC-Target-URI') == 'http://httpbin.org/get' + assert record.status_headers.get_header('Date') != '' + + def test_upstream_2(self): + resp = self.testapp.get('/upstream_opt/resource?url=http://httpbin.org/get') + assert resp.headers['Source-Coll'] == 'upstream_opt:live', resp.headers + + raw = BytesIO(resp.body) + + record = ArcWarcRecordLoader().parse_record_stream(raw, no_record_parse=False) + assert record.rec_headers.get_header('WARC-Target-URI') == 'http://httpbin.org/get' + assert record.status_headers.get_header('Date') != '' + + + From 3477cb0bb556fa9c81b851166fbc18a80fcc7083 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 8 Mar 2016 10:56:03 -0800 Subject: [PATCH 019/112] drop process/thread mixin support (doesn't work as well on py2) could readd processes only if need arises, but for now focusing on gevent rename header Source-Coll -> WebAgg-Source-Coll --- test/test_handlers.py | 26 +++++++++++------------ test/test_memento_agg.py | 18 ++-------------- test/test_upstream.py | 6 +++--- webagg/aggregator.py | 46 ---------------------------------------- webagg/responseloader.py | 4 ++-- 5 files changed, 20 insertions(+), 80 deletions(-) diff --git a/test/test_handlers.py b/test/test_handlers.py index f5c96e0f..c5577c5a 100644 --- a/test/test_handlers.py +++ b/test/test_handlers.py @@ -132,7 +132,7 @@ class TestResAgg(object): headers = {'foo': 'bar'} resp = self.testapp.get('/live/resource?url=http://httpbin.org/get?foo=bar', headers=headers) - assert resp.headers['Source-Coll'] == 'live' + assert resp.headers['WebAgg-Source-Coll'] == 'live' self._check_uri_date(resp, 'http://httpbin.org/get?foo=bar', True) @@ -148,7 +148,7 @@ class TestResAgg(object): resp = self.testapp.post('/live/resource?url=http://httpbin.org/post', OrderedDict([('foo', 'bar')])) - assert resp.headers['Source-Coll'] == 'live' + assert resp.headers['WebAgg-Source-Coll'] == 'live' self._check_uri_date(resp, 'http://httpbin.org/post', True) @@ -163,7 +163,7 @@ class TestResAgg(object): def test_agg_select_mem_1(self): resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=20141001') - assert resp.headers['Source-Coll'] == 'rhiz' + assert resp.headers['WebAgg-Source-Coll'] == 'rhiz' self._check_uri_date(resp, 'http://www.vvork.com/', '2014-10-06T18:43:57Z') @@ -177,7 +177,7 @@ class TestResAgg(object): def test_agg_select_mem_2(self): resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=20151231') - assert resp.headers['Source-Coll'] == 'ia' + assert resp.headers['WebAgg-Source-Coll'] == 'ia' self._check_uri_date(resp, 'http://vvork.com/', '2016-01-10T13:48:55Z') @@ -191,7 +191,7 @@ class TestResAgg(object): def test_agg_select_live(self): resp = self.testapp.get('/many/resource?url=http://vvork.com/&closest=2016') - assert resp.headers['Source-Coll'] == 'live' + assert resp.headers['WebAgg-Source-Coll'] == 'live' self._check_uri_date(resp, 'http://vvork.com/', True) @@ -203,7 +203,7 @@ class TestResAgg(object): def test_agg_select_local(self): resp = self.testapp.get('/many/resource?url=http://iana.org/&closest=20140126200624') - assert resp.headers['Source-Coll'] == 'local' + assert resp.headers['WebAgg-Source-Coll'] == 'local' self._check_uri_date(resp, 'http://www.iana.org/', '2014-01-26T20:06:24Z') @@ -222,7 +222,7 @@ Host: iana.org resp = self.testapp.post('/many/resource/postreq?url=http://iana.org/&closest=20140126200624', req_data) - assert resp.headers['Source-Coll'] == 'local' + assert resp.headers['WebAgg-Source-Coll'] == 'local' self._check_uri_date(resp, 'http://www.iana.org/', '2014-01-26T20:06:24Z') @@ -241,7 +241,7 @@ Host: httpbin.org resp = self.testapp.post('/many/resource/postreq?url=http://httpbin.org/get?foo=bar&closest=now', req_data) - assert resp.headers['Source-Coll'] == 'live' + assert resp.headers['WebAgg-Source-Coll'] == 'live' self._check_uri_date(resp, 'http://httpbin.org/get?foo=bar', True) @@ -266,7 +266,7 @@ foo=bar&test=abc""" resp = self.testapp.post('/posttest/resource/postreq?url=http://httpbin.org/post', req_data) - assert resp.headers['Source-Coll'] == 'post' + assert resp.headers['WebAgg-Source-Coll'] == 'post' self._check_uri_date(resp, 'http://httpbin.org/post', True) @@ -285,7 +285,7 @@ foo=bar&test=abc""" resp = self.testapp.post('/fallback/resource?url=http://httpbin.org/post', req_data) - assert resp.headers['Source-Coll'] == 'post' + assert resp.headers['WebAgg-Source-Coll'] == 'post' self._check_uri_date(resp, 'http://httpbin.org/post', True) @@ -301,7 +301,7 @@ foo=bar&test=abc""" def test_agg_seq_fallback_1(self): resp = self.testapp.get('/fallback/resource?url=http://www.iana.org/') - assert resp.headers['Source-Coll'] == 'live' + assert resp.headers['WebAgg-Source-Coll'] == 'live' self._check_uri_date(resp, 'http://www.iana.org/', True) @@ -314,7 +314,7 @@ foo=bar&test=abc""" def test_agg_seq_fallback_2(self): resp = self.testapp.get('/fallback/resource?url=http://www.example.com/') - assert resp.headers['Source-Coll'] == 'example' + assert resp.headers['WebAgg-Source-Coll'] == 'example' self._check_uri_date(resp, 'http://example.com/', '2016-02-25T04:23:29Z') @@ -336,7 +336,7 @@ foo=bar&test=abc""" def test_agg_local_revisit(self): resp = self.testapp.get('/many/resource?url=http://www.example.com/&closest=20140127171251&sources=local') - assert resp.headers['Source-Coll'] == 'local' + assert resp.headers['WebAgg-Source-Coll'] == 'local' buff = BytesIO(resp.body) status_headers = StatusAndHeadersParser(['WARC/1.0']).parse(buff) diff --git a/test/test_memento_agg.py b/test/test_memento_agg.py index 934a9474..52dc79da 100644 --- a/test/test_memento_agg.py +++ b/test/test_memento_agg.py @@ -1,7 +1,7 @@ from gevent import monkey; monkey.patch_all(thread=False) from webagg.aggregator import SimpleAggregator, GeventTimeoutAggregator -from webagg.aggregator import ThreadedTimeoutAggregator, BaseAggregator +from webagg.aggregator import BaseAggregator from webagg.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource from .testutils import json_list, to_path @@ -25,29 +25,15 @@ sources = { aggs = {'simple': SimpleAggregator(sources), 'gevent': GeventTimeoutAggregator(sources, timeout=5.0), - 'threaded': ThreadedTimeoutAggregator(sources, timeout=5.0), - 'processes': ThreadedTimeoutAggregator(sources, timeout=5.0, use_processes=True), } -agg_tm = {'gevent': GeventTimeoutAggregator(sources, timeout=0.0), - 'threaded': ThreadedTimeoutAggregator(sources, timeout=0.0), - 'processes': ThreadedTimeoutAggregator(sources, timeout=0.0, use_processes=True)} +agg_tm = {'gevent': GeventTimeoutAggregator(sources, timeout=0.0)} nf = {'notfound': FileIndexSource(to_path('testdata/not-found-x'))} agg_nf = {'simple': SimpleAggregator(nf), 'gevent': GeventTimeoutAggregator(nf, timeout=5.0), - 'threaded': ThreadedTimeoutAggregator(nf, timeout=5.0), - 'processes': ThreadedTimeoutAggregator(nf, timeout=5.0, use_processes=True), } -if six.PY2: - del aggs['threaded'] - del aggs['processes'] - del agg_tm['threaded'] - del agg_tm['processes'] - del agg_nf['threaded'] - del agg_nf['processes'] - @pytest.mark.parametrize("agg", list(aggs.values()), ids=list(aggs.keys())) def test_mem_agg_index_1(agg): diff --git a/test/test_upstream.py b/test/test_upstream.py index 6ca6bb61..505b8edb 100644 --- a/test/test_upstream.py +++ b/test/test_upstream.py @@ -83,7 +83,7 @@ class TestUpstream(object): def test_live_1(self): resp = requests.get(self.base_url + '/live/resource?url=http://httpbin.org/get', stream=True) - assert resp.headers['Source-Coll'] == 'live' + assert resp.headers['WebAgg-Source-Coll'] == 'live' record = ArcWarcRecordLoader().parse_record_stream(resp.raw, no_record_parse=False) assert record.rec_headers.get_header('WARC-Target-URI') == 'http://httpbin.org/get' @@ -91,7 +91,7 @@ class TestUpstream(object): def test_upstream_1(self): resp = self.testapp.get('/upstream/resource?url=http://httpbin.org/get') - assert resp.headers['Source-Coll'] == 'upstream:live' + assert resp.headers['WebAgg-Source-Coll'] == 'upstream:live' raw = BytesIO(resp.body) @@ -101,7 +101,7 @@ class TestUpstream(object): def test_upstream_2(self): resp = self.testapp.get('/upstream_opt/resource?url=http://httpbin.org/get') - assert resp.headers['Source-Coll'] == 'upstream_opt:live', resp.headers + assert resp.headers['WebAgg-Source-Coll'] == 'upstream_opt:live', resp.headers raw = BytesIO(resp.body) diff --git a/webagg/aggregator.py b/webagg/aggregator.py index 2810d3d0..8a810a63 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -37,10 +37,6 @@ class BaseAggregator(object): cdx_iter = process_cdx(cdx_iter, query) return cdx_iter, dict(errs) - def load_child_source_list(self, name, source, params): - res = self.load_child_source(name, source, params) - return list(res[0]), res[1] - def load_child_source(self, name, source, params): try: params['_formatter'] = ParamFormatter(params, name) @@ -205,48 +201,6 @@ class GeventTimeoutAggregator(TimeoutMixin, GeventMixin, BaseSourceListAggregato pass -#============================================================================= -class ConcurrentMixin(object): - def __init__(self, *args, **kwargs): - super(ConcurrentMixin, self).__init__(*args, **kwargs) - if kwargs.get('use_processes'): - self.pool_class = futures.ThreadPoolExecutor - else: - self.pool_class = futures.ProcessPoolExecutor - self.timeout = kwargs.get('timeout', 5.0) - self.size = kwargs.get('size') - - def _load_all(self, params): - params['_timeout'] = self.timeout - - sources = list(self._iter_sources(params)) - - with self.pool_class(max_workers=self.size) as executor: - def do_spawn(name, source): - return executor.submit(self.load_child_source_list, - name, source, params), name - - jobs = dict([do_spawn(name, source) for name, source in sources]) - - res_done, res_not_done = futures.wait(jobs.keys(), timeout=self.timeout) - - results = [] - for job in res_done: - results.append(job.result()) - - for job in res_not_done: - name = jobs[job] - results.append((iter([]), [(name, 'timeout')])) - self._on_source_error(name) - - return results - - -#============================================================================= -class ThreadedTimeoutAggregator(TimeoutMixin, ConcurrentMixin, BaseSourceListAggregator): - pass - - #============================================================================= class BaseDirectoryIndexSource(BaseAggregator): CDX_EXT = ('.cdx', '.cdxj') diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 82d98e41..31a5298e 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -70,7 +70,7 @@ class BaseLoader(object): out_headers = {} out_headers['WebAgg-Type'] = 'warc' - out_headers['Source-Coll'] = cdx.get('source', '') + out_headers['WebAgg-Source-Coll'] = cdx.get('source', '') out_headers['Content-Type'] = 'application/warc-record' if not warc_headers: @@ -237,7 +237,7 @@ class LiveWebLoader(BaseLoader): agg_type = upstream_res.headers.get('WebAgg-Type') if agg_type == 'warc': - cdx['source'] = upstream_res.headers.get('Source-Coll') + cdx['source'] = upstream_res.headers.get('WebAgg-Source-Coll') return None, upstream_res.headers, upstream_res.raw http_headers_buff = recorder.get_headers_buff() From 34386578a51a4bc7d5bd673ef36b9653a7a494fc Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 9 Mar 2016 14:29:14 -0800 Subject: [PATCH 020/112] shared setup: move webagg test to webagg/test --- setup.py | 3 ++- README.rst => webagg/README.rst | 0 {test => webagg/test}/__init__.py | 0 {test => webagg/test}/test_dir_agg.py | 0 {test => webagg/test}/test_handlers.py | 0 {test => webagg/test}/test_indexsource.py | 0 {test => webagg/test}/test_memento_agg.py | 0 {test => webagg/test}/test_timeouts.py | 0 {test => webagg/test}/test_upstream.py | 0 {test => webagg/test}/testutils.py | 0 10 files changed, 2 insertions(+), 1 deletion(-) rename README.rst => webagg/README.rst (100%) rename {test => webagg/test}/__init__.py (100%) rename {test => webagg/test}/test_dir_agg.py (100%) rename {test => webagg/test}/test_handlers.py (100%) rename {test => webagg/test}/test_indexsource.py (100%) rename {test => webagg/test}/test_memento_agg.py (100%) rename {test => webagg/test}/test_timeouts.py (100%) rename {test => webagg/test}/test_upstream.py (100%) rename {test => webagg/test}/testutils.py (100%) diff --git a/setup.py b/setup.py index cada7efc..ca843b13 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ class PyTest(TestCommand): import pytest import sys import os - cmdline = ' --cov webagg -vv test/' + cmdline = ' --cov-config .coveragerc --cov webagg/ -vv webagg/test/' errcode = pytest.main(cmdline) sys.exit(errcode) @@ -30,6 +30,7 @@ setup( long_description=open('README.rst').read(), provides=[ 'webagg', + 'recorder', ], install_requires=[ 'pywb==1.0b', diff --git a/README.rst b/webagg/README.rst similarity index 100% rename from README.rst rename to webagg/README.rst diff --git a/test/__init__.py b/webagg/test/__init__.py similarity index 100% rename from test/__init__.py rename to webagg/test/__init__.py diff --git a/test/test_dir_agg.py b/webagg/test/test_dir_agg.py similarity index 100% rename from test/test_dir_agg.py rename to webagg/test/test_dir_agg.py diff --git a/test/test_handlers.py b/webagg/test/test_handlers.py similarity index 100% rename from test/test_handlers.py rename to webagg/test/test_handlers.py diff --git a/test/test_indexsource.py b/webagg/test/test_indexsource.py similarity index 100% rename from test/test_indexsource.py rename to webagg/test/test_indexsource.py diff --git a/test/test_memento_agg.py b/webagg/test/test_memento_agg.py similarity index 100% rename from test/test_memento_agg.py rename to webagg/test/test_memento_agg.py diff --git a/test/test_timeouts.py b/webagg/test/test_timeouts.py similarity index 100% rename from test/test_timeouts.py rename to webagg/test/test_timeouts.py diff --git a/test/test_upstream.py b/webagg/test/test_upstream.py similarity index 100% rename from test/test_upstream.py rename to webagg/test/test_upstream.py diff --git a/test/testutils.py b/webagg/test/testutils.py similarity index 100% rename from test/testutils.py rename to webagg/test/testutils.py From 1499f0e611fcd02ea628d9937ae09fe5227c42a5 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 9 Mar 2016 14:33:11 -0800 Subject: [PATCH 021/112] add shared README.rst and coverage --- .coveragerc | 13 +++++++++++++ README.rst | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .coveragerc create mode 100644 README.rst diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..6c30b88e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,13 @@ +[run] +omit = + */test/* + */tests/* + *.html + *.js + *.css + pywb/__init__.py + +[report] +exclude_lines = + pragma: no cover + if __name__ == .__main__.: diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..b3e267e1 --- /dev/null +++ b/README.rst @@ -0,0 +1,13 @@ +Webrecorder Platform Components +------------------------------- + +See `Platform spec `_ for more details. + +This repo contains an implementation for following components: + + +* Resource/Memento Aggregator `webagg `_ + +* Recorder `recorder `_ + + From 31fb2f926feaf936232c7b0e283153fef125e469 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 9 Mar 2016 14:33:36 -0800 Subject: [PATCH 022/112] add recorder app, initial pass! --- recorder/__init__.py | 0 recorder/recorderapp.py | 173 ++++++++++++++++++++++++ recorder/redisindexer.py | 57 ++++++++ recorder/warcrecorder.py | 283 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 recorder/__init__.py create mode 100644 recorder/recorderapp.py create mode 100644 recorder/redisindexer.py create mode 100644 recorder/warcrecorder.py diff --git a/recorder/__init__.py b/recorder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py new file mode 100644 index 00000000..9d77b6ca --- /dev/null +++ b/recorder/recorderapp.py @@ -0,0 +1,173 @@ +from requests import request as remote_request +from requests.structures import CaseInsensitiveDict + +from webagg.liverec import ReadFullyStream +from webagg.responseloader import StreamIter +from webagg.inputrequest import DirectWSGIInputRequest + +from pywb.utils.statusandheaders import StatusAndHeadersParser +from pywb.warc.recordloader import ArcWarcRecord +from pywb.warc.recordloader import ArcWarcRecordLoader + +from recorder.warcrecorder import SingleFileWARCRecorder, PerRecordWARCRecorder +from recorder.redisindexer import WritableRedisIndexer + +from six.moves.urllib.parse import parse_qsl + +import json +import tempfile + +import traceback + +import gevent.queue +import gevent + + +#============================================================================== +write_queue = gevent.queue.Queue() + + +#============================================================================== +class RecorderApp(object): + def __init__(self, upstream_host, writer): + self.upstream_host = upstream_host + + self.writer = writer + self.parser = StatusAndHeadersParser([], verify=False) + + gevent.spawn(self._do_write) + + def _do_write(self): + while True: + try: + result = write_queue.get() + req = None + resp = None + req_head, req_pay, resp_head, resp_pay, params = result + + req = self._create_req_record(req_head, req_pay, 'request') + resp = self._create_resp_record(resp_head, resp_pay, 'response') + + self.writer.write_req_resp(req, resp, params) + + except: + traceback.print_exc() + + finally: + try: + if req: + req.stream.close() + + if resp: + resp.stream.close() + except Exception as e: + traceback.print_exc() + + def _create_req_record(self, req_headers, payload, type_, ct=''): + len_ = payload.tell() + payload.seek(0) + + #warc_headers = StatusAndHeaders('WARC/1.0', req_headers.items()) + warc_headers = req_headers + + status_headers = self.parser.parse(payload) + + record = ArcWarcRecord('warc', type_, warc_headers, payload, + status_headers, ct, len_) + return record + + def _create_resp_record(self, req_headers, payload, type_, ct=''): + len_ = payload.tell() + payload.seek(0) + + warc_headers = self.parser.parse(payload) + warc_headers = CaseInsensitiveDict(warc_headers.headers) + + status_headers = self.parser.parse(payload) + + record = ArcWarcRecord('warc', type_, warc_headers, payload, + status_headers, ct, len_) + return record + + def send_error(self, exc, start_response): + message = json.dumps({'error': repr(exc)}) + headers = [('Content-Type', 'application/json; charset=utf-8'), + ('Content-Length', str(len(message)))] + + start_response('400 Bad Request', headers) + return message + + def __call__(self, environ, start_response): + request_uri = environ.get('REQUEST_URI') + + input_req = DirectWSGIInputRequest(environ) + headers = input_req.get_req_headers() + method = input_req.get_req_method() + + params = dict(parse_qsl(environ.get('QUERY_STRING'))) + + req_stream = Wrapper(input_req.get_req_body(), headers, None) + + try: + res = remote_request(url=self.upstream_host + request_uri, + method=method, + data=req_stream, + headers=headers, + allow_redirects=False, + stream=True) + except Exception as e: + traceback.print_exc() + return self.send_error(e, start_response) + + start_response('200 OK', list(res.headers.items())) + + resp_stream = Wrapper(res.raw, res.headers, req_stream, params) + + return StreamIter(ReadFullyStream(resp_stream)) + + +#============================================================================== +class Wrapper(object): + def __init__(self, stream, rec_headers, req_obj=None, + params=None): + self.stream = stream + self.out = self._create_buffer() + self.headers = CaseInsensitiveDict(rec_headers) + for n in rec_headers.keys(): + if not n.upper().startswith('WARC-'): + del self.headers[n] + + self.req_obj = req_obj + self.params = params + + def _create_buffer(self): + return tempfile.SpooledTemporaryFile(max_size=512*1024) + + def read(self, limit=-1): + buff = self.stream.read() + self.out.write(buff) + return buff + + def close(self): + try: + self.stream.close() + except: + traceback.print_exc() + + if not self.req_obj: + return + + try: + entry = (self.req_obj.headers, self.req_obj.out, + self.headers, self.out, self.params) + write_queue.put(entry) + self.req_obj = None + except: + traceback.print_exc() + + +#============================================================================== +application = RecorderApp('http://localhost:8080', + PerRecordWARCRecorder('./warcs/{user}/{coll}/', + dedup_index=WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', 'recorder'))) + diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py new file mode 100644 index 00000000..f2b3c520 --- /dev/null +++ b/recorder/redisindexer.py @@ -0,0 +1,57 @@ +from pywb.utils.canonicalize import calc_search_range +from pywb.cdx.cdxobject import CDXObject +from pywb.warc.cdxindexer import write_cdx_index +from pywb.utils.timeutils import timestamp_to_datetime +from pywb.utils.timeutils import datetime_to_iso_date, iso_date_to_timestamp + +from io import BytesIO + +from webagg.indexsource import RedisIndexSource +from webagg.aggregator import SimpleAggregator +from webagg.utils import res_template + + +#============================================================================== +class WritableRedisIndexer(RedisIndexSource): + def __init__(self, redis_url, name): + super(WritableRedisIndexer, self).__init__(redis_url) + self.cdx_lookup = SimpleAggregator({name: self}) + + def add_record(self, stream, params, filename=None): + if not filename and hasattr(stream, 'name'): + filename = stream.name + + cdxout = BytesIO() + write_cdx_index(cdxout, stream, filename, + cdxj=True, append_post=True) + + z_key = res_template(self.redis_key_template, params) + + cdxes = cdxout.getvalue() + for cdx in cdxes.split(b'\n'): + if cdx: + self.redis.zadd(z_key, 0, cdx) + + return cdx + + def lookup_revisit(self, params, digest, url, iso_dt): + params['url'] = url + params['closest'] = iso_date_to_timestamp(iso_dt) + + filters = [] + + filters.append('!mime:warc/revisit') + + if digest and digest != '-': + filters.append('digest:' + digest.split(':')[-1]) + + params['filter'] = filters + + cdx_iter, errs = self.cdx_lookup(params) + + for cdx in cdx_iter: + dt = timestamp_to_datetime(cdx['timestamp']) + return ('revisit', cdx['url'], + datetime_to_iso_date(dt)) + + return None diff --git a/recorder/warcrecorder.py b/recorder/warcrecorder.py new file mode 100644 index 00000000..98d49361 --- /dev/null +++ b/recorder/warcrecorder.py @@ -0,0 +1,283 @@ +import tempfile +import uuid +import base64 +import hashlib +import datetime +import zlib +import sys +import os +import six + +import traceback + +from collections import OrderedDict + +from pywb.utils.loaders import LimitReader, to_native_str +from pywb.utils.bufferedreaders import BufferedReader + +from webagg.utils import ParamFormatter + + +# ============================================================================ +class BaseWARCRecorder(object): + WARC_RECORDS = {'warcinfo': 'application/warc-fields', + 'response': 'application/http; msgtype=response', + 'revisit': 'application/http; msgtype=response', + 'request': 'application/http; msgtype=request', + 'metadata': 'application/warc-fields', + } + + REVISIT_PROFILE = 'http://netpreserve.org/warc/1.0/revisit/uri-agnostic-identical-payload-digest' + + def __init__(self, gzip=True, dedup_index=None): + self.gzip = gzip + self.dedup_index = dedup_index + + def ensure_digest(self, record): + block_digest = record.rec_headers.get('WARC-Block-Digest') + payload_digest = record.rec_headers.get('WARC-Payload-Digest') + if block_digest and payload_digest: + return + + block_digester = self._create_digester() + payload_digester = self._create_digester() + + pos = record.stream.tell() + + block_digester.update(record.status_headers.headers_buff) + + while True: + buf = record.stream.read(8192) + if not buf: + break + + block_digester.update(buf) + payload_digester.update(buf) + + record.stream.seek(pos) + record.rec_headers['WARC-Block-Digest'] = str(block_digester) + record.rec_headers['WARC-Payload-Digest'] = str(payload_digester) + + def _create_digester(self): + return Digester('sha1') + + def _set_header_buff(self, record): + record.status_headers.headers_buff = str(record.status_headers).encode('latin-1') + b'\r\n' + + def write_req_resp(self, req, resp, params): + url = resp.rec_headers.get('WARC-Target-Uri') + dt = resp.rec_headers.get('WARC-Date') + + if not req.rec_headers.get('WARC-Record-ID'): + req.rec_headers['WARC-Record-ID'] = self._make_warc_id() + + req.rec_headers['WARC-Target-Uri'] = url + req.rec_headers['WARC-Date'] = dt + req.rec_headers['WARC-Type'] = 'request' + req.rec_headers['Content-Type'] = req.content_type + + resp_id = resp.rec_headers.get('WARC-Record-ID') + if resp_id: + req.rec_headers['WARC-Concurrent-To'] = resp_id + + #resp.status_headers.remove_header('Etag') + + self._set_header_buff(req) + self._set_header_buff(resp) + + self.ensure_digest(resp) + + resp = self._check_revisit(resp, params) + if not resp: + print('Skipping due to dedup') + return + + self._do_write_req_resp(req, resp, params) + + def _check_revisit(self, record, params): + if not self.dedup_index: + return record + + try: + url = record.rec_headers.get('WARC-Target-URI') + digest = record.rec_headers.get('WARC-Payload-Digest') + iso_dt = record.rec_headers.get('WARC-Date') + result = self.dedup_index.lookup_revisit(params, digest, url, iso_dt) + except Exception as e: + traceback.print_exc() + result = None + + if result == 'skip': + return None + + if isinstance(result, tuple) and result[0] == 'revisit': + record.rec_headers['WARC-Type'] = 'revisit' + record.rec_headers['WARC-Profile'] = self.REVISIT_PROFILE + + record.rec_headers['WARC-Refers-To-Target-URI'] = result[1] + record.rec_headers['WARC-Refers-To-Date'] = result[2] + + return record + + def _write_warc_record(self, out, record): + if self.gzip: + out = GzippingWriter(out) + + self._line(out, b'WARC/1.0') + + for n, v in six.iteritems(record.rec_headers): + self._header(out, n, v) + + content_type = record.content_type + if not content_type: + content_type = self.WARC_RECORDS[record.rec_headers['WARC-Type']] + + self._header(out, 'Content-Type', record.content_type) + + if record.rec_headers['WARC-Type'] == 'revisit': + http_headers_only = True + else: + http_headers_only = False + + if record.length: + actual_len = len(record.status_headers.headers_buff) + + if not http_headers_only: + diff = record.stream.tell() - actual_len + actual_len = record.length - diff + + self._header(out, 'Content-Length', str(actual_len)) + + # add empty line + self._line(out, b'') + + # write headers and buffer + out.write(record.status_headers.headers_buff) + + if not http_headers_only: + out.write(record.stream.read()) + + # add two lines + self._line(out, b'\r\n') + else: + # add three lines (1 for end of header, 2 for end of record) + self._line(out, b'Content-Length: 0\r\n\r\n') + + out.flush() + + def _header(self, out, name, value): + if not value: + return + + self._line(out, (name + ': ' + str(value)).encode('latin-1')) + + def _line(self, out, line): + out.write(line + b'\r\n') + + @staticmethod + def _make_warc_id(id_=None): + if not id_: + id_ = uuid.uuid1() + return ''.format(id_) + + +# ============================================================================ +class GzippingWriter(object): + def __init__(self, out): + self.compressor = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS + 16) + self.out = out + + def write(self, buff): + #if isinstance(buff, str): + # buff = buff.encode('utf-8') + buff = self.compressor.compress(buff) + self.out.write(buff) + + def flush(self): + buff = self.compressor.flush() + self.out.write(buff) + self.out.flush() + + +# ============================================================================ +class Digester(object): + def __init__(self, type_='sha1'): + self.type_ = type_ + self.digester = hashlib.new(type_) + + def update(self, buff): + self.digester.update(buff) + + def __eq__(self, string): + digest = str(base64.b32encode(self.digester.digest())) + if ':' in string: + digest = self._type_ + ':' + digest + return string == digest + + def __str__(self): + return self.type_ + ':' + to_native_str(base64.b32encode(self.digester.digest())) + + +# ============================================================================ +class SingleFileWARCRecorder(BaseWARCRecorder): + def __init__(self, warcfilename, *args, **kwargs): + super(SingleFileWARCRecorder, self).__init__(*args, **kwargs) + self.warcfilename = warcfilename + + def _do_write_req_resp(self, req, resp, params): + print('Writing {0} to {1} '.format(url, self.warcfilename)) + with open(self.warcfilename, 'a+b') as out: + start = out.tell() + + self._write_warc_record(out, resp) + self._write_warc_record(out, req) + + out.flush() + out.seek(start) + + if self.dedup_index: + self.dedup_index.add_record(out, params, filename=self.warcfilename) + + def add_user_record(self, url, content_type, data): + with open(self.warcfilename, 'a+b') as out: + start = out.tell() + self._write_warc_metadata(out, url, content_type, data) + out.flush() + + #out.seek(start) + #if self.indexer: + # self.indexer.add_record(out, self.warcfilename) + + +# ============================================================================ +class PerRecordWARCRecorder(BaseWARCRecorder): + def __init__(self, warcdir, *args, **kwargs): + super(PerRecordWARCRecorder, self).__init__(*args, **kwargs) + self.warcdir = warcdir + + def _do_write_req_resp(self, req, resp, params): + resp_uuid = resp.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') + req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') + + formatter = ParamFormatter(params) + full_dir = formatter.format(self.warcdir) + + try: + os.makedirs(full_dir) + except: + pass + + resp_filename = os.path.join(full_dir, resp_uuid + '.warc.gz') + req_filename = os.path.join(full_dir, req_uuid + '.warc.gz') + + self._write_record(resp_filename, resp, params, True) + self._write_record(req_filename, req, params, False) + + def _write_record(self, filename, rec, params, index=False): + with open(filename, 'w+b') as out: + self._write_warc_record(out, rec) + if index and self.dedup_index: + out.seek(0) + self.dedup_index.add_record(out, params, filename=filename) + + From 7b847311d56d38adb6718f148f085c77e4da313c Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 10 Mar 2016 15:51:01 -0800 Subject: [PATCH 023/112] dir agg: include filename in dir source name --- webagg/aggregator.py | 7 +++-- webagg/test/test_dir_agg.py | 58 +++++++++++++++++++++--------------- webagg/test/test_handlers.py | 6 ++-- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/webagg/aggregator.py b/webagg/aggregator.py index 8a810a63..0f148492 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -228,8 +228,11 @@ class BaseDirectoryIndexSource(BaseAggregator): print('Adding ' + filename) rel_path = os.path.relpath(the_dir, self.base_prefix) if rel_path == '.': - rel_path = '' - yield rel_path, FileIndexSource(filename) + full_name = name + else: + full_name = rel_path + '/' + name + + yield full_name, FileIndexSource(filename) def __str__(self): return 'file_dir' diff --git a/webagg/test/test_dir_agg.py b/webagg/test/test_dir_agg.py index 9d2db560..14d011aa 100644 --- a/webagg/test/test_dir_agg.py +++ b/webagg/test/test_dir_agg.py @@ -45,16 +45,16 @@ def setup_module(): global dir_loader dir_loader = DirectoryIndexSource(dir_prefix, dir_path) - global orig_cwd - orig_cwd = os.getcwd() - os.chdir(root_dir) + #global orig_cwd + #orig_cwd = os.getcwd() + #os.chdir(root_dir) # use actually set dir - root_dir = os.getcwd() + #root_dir = os.getcwd() def teardown_module(): - global orig_cwd - os.chdir(orig_cwd) + #global orig_cwd + #os.chdir(orig_cwd) global root_dir shutil.rmtree(root_dir) @@ -72,7 +72,7 @@ def test_agg_no_coll_set(): def test_agg_collA_found(): res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'A'}) - exp = [{'source': 'colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] + exp = [{'source': 'colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] assert(to_json_list(res) == exp) assert(errs == {}) @@ -88,7 +88,7 @@ def test_agg_collB(): def test_agg_collB_found(): res, errs = dir_loader({'url': 'iana.org/', 'param.coll': 'B'}) - exp = [{'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + exp = [{'source': 'colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] assert(to_json_list(res) == exp) assert(errs == {}) @@ -98,7 +98,7 @@ def test_extra_agg_collB(): agg_source = SimpleAggregator({'dir': dir_loader}) res, errs = agg_source({'url': 'iana.org/', 'param.coll': 'B'}) - exp = [{'source': 'dir:colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + exp = [{'source': 'dir:colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] assert(to_json_list(res) == exp) assert(errs == {}) @@ -108,9 +108,9 @@ def test_agg_all_found_1(): res, errs = dir_loader({'url': 'iana.org/', 'param.coll': '*'}) exp = [ - {'source': 'colls/B/indexes', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}, - {'source': 'colls/C/indexes', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, - {'source': 'colls/C/indexes', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}, + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, ] assert(to_json_list(res) == exp) @@ -121,9 +121,9 @@ def test_agg_all_found_2(): res, errs = dir_loader({'url': 'example.com/', 'param.coll': '*'}) exp = [ - {'source': 'colls/C/indexes', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, - {'source': 'colls/C/indexes', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, - {'source': 'colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} ] assert(to_json_list(res) == exp) @@ -145,9 +145,9 @@ def test_agg_dir_and_memento(): {'source': 'ia', 'timestamp': '20100514231857', 'load_url': 'http://web.archive.org/web/20100514231857id_/http://example.com/'}, {'source': 'ia', 'timestamp': '20100519202418', 'load_url': 'http://web.archive.org/web/20100519202418id_/http://example.com/'}, {'source': 'ia', 'timestamp': '20100501123414', 'load_url': 'http://web.archive.org/web/20100501123414id_/http://example.com/'}, - {'source': 'local:colls/C/indexes', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, - {'source': 'local:colls/C/indexes', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, - {'source': 'local:colls/A/indexes', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + {'source': 'local:colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'local:colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'local:colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} ] assert(to_json_list(res) == exp) @@ -175,9 +175,9 @@ def test_agg_no_dir_2(): def test_agg_dir_sources_1(): res = dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) - exp = {'sources': {'colls/A/indexes': 'file', - 'colls/B/indexes': 'file', - 'colls/C/indexes': 'file'} + exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', + 'colls/B/indexes/iana.cdxj': 'file', + 'colls/C/indexes/dupes.cdxj': 'file'} } assert(res == exp) @@ -185,15 +185,24 @@ def test_agg_dir_sources_1(): def test_agg_dir_sources_2(): res = dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '[A,C]'}) - exp = {'sources': {'colls/A/indexes': 'file', - 'colls/C/indexes': 'file'} + exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', + 'colls/C/indexes/dupes.cdxj': 'file'} } assert(res == exp) def test_agg_dir_sources_single_dir(): - loader = DirectoryIndexSource('testdata/', '') + loader = DirectoryIndexSource(os.path.join(root_dir, 'colls', 'A', 'indexes'), '') + res = loader.get_source_list({'url': 'example.com/'}) + + exp = {'sources': {'example.cdxj': 'file'}} + + assert(res == exp) + + +def test_agg_dir_sources_not_found_dir(): + loader = DirectoryIndexSource(os.path.join(root_dir, 'colls', 'Z', 'indexes'), '') res = loader.get_source_list({'url': 'example.com/'}) exp = {'sources': {}} @@ -201,3 +210,4 @@ def test_agg_dir_sources_single_dir(): assert(res == exp) + diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index c5577c5a..138584d6 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -203,7 +203,7 @@ class TestResAgg(object): def test_agg_select_local(self): resp = self.testapp.get('/many/resource?url=http://iana.org/&closest=20140126200624') - assert resp.headers['WebAgg-Source-Coll'] == 'local' + assert resp.headers['WebAgg-Source-Coll'] == 'local:iana.cdxj' self._check_uri_date(resp, 'http://www.iana.org/', '2014-01-26T20:06:24Z') @@ -222,7 +222,7 @@ Host: iana.org resp = self.testapp.post('/many/resource/postreq?url=http://iana.org/&closest=20140126200624', req_data) - assert resp.headers['WebAgg-Source-Coll'] == 'local' + assert resp.headers['WebAgg-Source-Coll'] == 'local:iana.cdxj' self._check_uri_date(resp, 'http://www.iana.org/', '2014-01-26T20:06:24Z') @@ -336,7 +336,7 @@ foo=bar&test=abc""" def test_agg_local_revisit(self): resp = self.testapp.get('/many/resource?url=http://www.example.com/&closest=20140127171251&sources=local') - assert resp.headers['WebAgg-Source-Coll'] == 'local' + assert resp.headers['WebAgg-Source-Coll'] == 'local:dupes.cdxj' buff = BytesIO(resp.body) status_headers = StatusAndHeadersParser(['WARC/1.0']).parse(buff) From c309637a3a6cf57a1aedcaad82d344c2dc4c18aa Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 10 Mar 2016 16:04:27 -0800 Subject: [PATCH 024/112] tests: webagg test tweaks, create TempDirTests for sharing tests that require a temp dir --- webagg/test/test_dir_agg.py | 305 +++++++++++++++----------------- webagg/test/test_memento_agg.py | 14 +- webagg/test/test_timeouts.py | 18 +- webagg/test/testutils.py | 13 +- 4 files changed, 169 insertions(+), 181 deletions(-) diff --git a/webagg/test/test_dir_agg.py b/webagg/test/test_dir_agg.py index 14d011aa..165b6346 100644 --- a/webagg/test/test_dir_agg.py +++ b/webagg/test/test_dir_agg.py @@ -3,7 +3,7 @@ import os import shutil import json -from .testutils import to_path +from .testutils import to_path, to_json_list, TempDirTests from mock import patch @@ -12,202 +12,179 @@ from webagg.indexsource import MementoIndexSource #============================================================================= -root_dir = None -orig_cwd = None -dir_loader = None - linkheader = """\ ; rel="original", ; rel="timemap"; type="application/link-format", ; rel="first memento"; datetime="Sun, 20 Jan 2002 14:25:10 GMT", ; rel="prev memento"; datetime="Sat, 01 May 2010 12:34:14 GMT", ; rel="memento"; datetime="Fri, 14 May 2010 23:18:57 GMT", ; rel="next memento"; datetime="Wed, 19 May 2010 20:24:18 GMT", ; rel="last memento"; datetime="Mon, 07 Mar 2016 20:06:19 GMT"\ """ -def setup_module(): - global root_dir - root_dir = tempfile.mkdtemp() - - coll_A = to_path(root_dir + '/colls/A/indexes') - coll_B = to_path(root_dir + '/colls/B/indexes') - coll_C = to_path(root_dir + '/colls/C/indexes') - - os.makedirs(coll_A) - os.makedirs(coll_B) - os.makedirs(coll_C) - - dir_prefix = to_path(root_dir) - dir_path ='colls/{coll}/indexes' - - shutil.copy(to_path('testdata/example.cdxj'), coll_A) - shutil.copy(to_path('testdata/iana.cdxj'), coll_B) - shutil.copy(to_path('testdata/dupes.cdxj'), coll_C) - - with open(to_path(root_dir) + 'somefile', 'w') as fh: - fh.write('foo') - - global dir_loader - dir_loader = DirectoryIndexSource(dir_prefix, dir_path) - - #global orig_cwd - #orig_cwd = os.getcwd() - #os.chdir(root_dir) - - # use actually set dir - #root_dir = os.getcwd() - -def teardown_module(): - #global orig_cwd - #os.chdir(orig_cwd) - - global root_dir - shutil.rmtree(root_dir) - - -def to_json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): - return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) - - -def test_agg_no_coll_set(): - res, errs = dir_loader(dict(url='example.com/')) - assert(to_json_list(res) == []) - assert(errs == {}) - -def test_agg_collA_found(): - res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'A'}) - - exp = [{'source': 'colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] - - assert(to_json_list(res) == exp) - assert(errs == {}) - -def test_agg_collB(): - res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'B'}) - - exp = [] - - assert(to_json_list(res) == exp) - assert(errs == {}) - -def test_agg_collB_found(): - res, errs = dir_loader({'url': 'iana.org/', 'param.coll': 'B'}) - - exp = [{'source': 'colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] - - assert(to_json_list(res) == exp) - assert(errs == {}) - - -def test_extra_agg_collB(): - agg_source = SimpleAggregator({'dir': dir_loader}) - res, errs = agg_source({'url': 'iana.org/', 'param.coll': 'B'}) - - exp = [{'source': 'dir:colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] - - assert(to_json_list(res) == exp) - assert(errs == {}) - - -def test_agg_all_found_1(): - res, errs = dir_loader({'url': 'iana.org/', 'param.coll': '*'}) - - exp = [ - {'source': 'colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}, - {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, - {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, - ] - - assert(to_json_list(res) == exp) - assert(errs == {}) - - -def test_agg_all_found_2(): - res, errs = dir_loader({'url': 'example.com/', 'param.coll': '*'}) - - exp = [ - {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, - {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, - {'source': 'colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} - ] - - assert(to_json_list(res) == exp) - assert(errs == {}) - def mock_link_header(*args, **kwargs): return linkheader -@patch('webagg.indexsource.MementoIndexSource.get_timegate_links', mock_link_header) -def test_agg_dir_and_memento(): - sources = {'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), - 'local': dir_loader} - agg_source = SimpleAggregator(sources) - res, errs = agg_source({'url': 'example.com/', 'param.local.coll': '*', 'closest': '20100512', 'limit': 6}) +class TestDirAgg(TempDirTests): + @classmethod + def setup_class(cls): + super(TestDirAgg, cls).setup_class() + coll_A = to_path(cls.root_dir + '/colls/A/indexes') + coll_B = to_path(cls.root_dir + '/colls/B/indexes') + coll_C = to_path(cls.root_dir + '/colls/C/indexes') - exp = [ - {'source': 'ia', 'timestamp': '20100514231857', 'load_url': 'http://web.archive.org/web/20100514231857id_/http://example.com/'}, - {'source': 'ia', 'timestamp': '20100519202418', 'load_url': 'http://web.archive.org/web/20100519202418id_/http://example.com/'}, - {'source': 'ia', 'timestamp': '20100501123414', 'load_url': 'http://web.archive.org/web/20100501123414id_/http://example.com/'}, - {'source': 'local:colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, - {'source': 'local:colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, - {'source': 'local:colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} - ] + os.makedirs(coll_A) + os.makedirs(coll_B) + os.makedirs(coll_C) - assert(to_json_list(res) == exp) - assert(errs == {}) + dir_prefix = to_path(cls.root_dir) + dir_path ='colls/{coll}/indexes' + + shutil.copy(to_path('testdata/example.cdxj'), coll_A) + shutil.copy(to_path('testdata/iana.cdxj'), coll_B) + shutil.copy(to_path('testdata/dupes.cdxj'), coll_C) + + with open(to_path(cls.root_dir) + 'somefile', 'w') as fh: + fh.write('foo') + + cls.dir_loader = DirectoryIndexSource(dir_prefix, dir_path) + + def test_agg_no_coll_set(self): + res, errs = self.dir_loader(dict(url='example.com/')) + assert(to_json_list(res) == []) + assert(errs == {}) + + def test_agg_collA_found(self): + res, errs = self.dir_loader({'url': 'example.com/', 'param.coll': 'A'}) + + exp = [{'source': 'colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'}] + + assert(to_json_list(res) == exp) + assert(errs == {}) + + def test_agg_collB(self): + res, errs = self.dir_loader({'url': 'example.com/', 'param.coll': 'B'}) + + exp = [] + + assert(to_json_list(res) == exp) + assert(errs == {}) + + def test_agg_collB_found(self): + res, errs = self.dir_loader({'url': 'iana.org/', 'param.coll': 'B'}) + + exp = [{'source': 'colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] + + assert(to_json_list(res) == exp) + assert(errs == {}) -def test_agg_no_dir_1(): - res, errs = dir_loader({'url': 'example.com/', 'param.coll': 'X'}) + def test_extra_agg_collB(self): + agg_source = SimpleAggregator({'dir': self.dir_loader}) + res, errs = agg_source({'url': 'iana.org/', 'param.coll': 'B'}) - exp = [] + exp = [{'source': 'dir:colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}] - assert(to_json_list(res) == exp) - assert(errs == {}) + assert(to_json_list(res) == exp) + assert(errs == {}) -def test_agg_no_dir_2(): - loader = DirectoryIndexSource(root_dir, '') - res, errs = loader({'url': 'example.com/', 'param.coll': 'X'}) + def test_agg_all_found_1(self): + res, errs = self.dir_loader({'url': 'iana.org/', 'param.coll': '*'}) - exp = [] + exp = [ + {'source': 'colls/B/indexes/iana.cdxj', 'timestamp': '20140126200624', 'filename': 'iana.warc.gz'}, + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171238', 'filename': 'dupes.warc.gz'}, + ] - assert(to_json_list(res) == exp) - assert(errs == {}) + assert(to_json_list(res) == exp) + assert(errs == {}) -def test_agg_dir_sources_1(): - res = dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) - exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', - 'colls/B/indexes/iana.cdxj': 'file', - 'colls/C/indexes/dupes.cdxj': 'file'} - } + def test_agg_all_found_2(self): + res, errs = self.dir_loader({'url': 'example.com/', 'param.coll': '*'}) - assert(res == exp) + exp = [ + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + ] + + assert(to_json_list(res) == exp) + assert(errs == {}) + + @patch('webagg.indexsource.MementoIndexSource.get_timegate_links', mock_link_header) + def test_agg_dir_and_memento(self): + sources = {'ia': MementoIndexSource.from_timegate_url('http://web.archive.org/web/'), + 'local': self.dir_loader} + agg_source = SimpleAggregator(sources) + + res, errs = agg_source({'url': 'example.com/', 'param.local.coll': '*', 'closest': '20100512', 'limit': 6}) + + exp = [ + {'source': 'ia', 'timestamp': '20100514231857', 'load_url': 'http://web.archive.org/web/20100514231857id_/http://example.com/'}, + {'source': 'ia', 'timestamp': '20100519202418', 'load_url': 'http://web.archive.org/web/20100519202418id_/http://example.com/'}, + {'source': 'ia', 'timestamp': '20100501123414', 'load_url': 'http://web.archive.org/web/20100501123414id_/http://example.com/'}, + {'source': 'local:colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'local:colls/C/indexes/dupes.cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'local:colls/A/indexes/example.cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + ] + + assert(to_json_list(res) == exp) + assert(errs == {}) -def test_agg_dir_sources_2(): - res = dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '[A,C]'}) - exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', - 'colls/C/indexes/dupes.cdxj': 'file'} - } + def test_agg_no_dir_1(self): + res, errs = self.dir_loader({'url': 'example.com/', 'param.coll': 'X'}) - assert(res == exp) + exp = [] + + assert(to_json_list(res) == exp) + assert(errs == {}) -def test_agg_dir_sources_single_dir(): - loader = DirectoryIndexSource(os.path.join(root_dir, 'colls', 'A', 'indexes'), '') - res = loader.get_source_list({'url': 'example.com/'}) + def test_agg_no_dir_2(self): + loader = DirectoryIndexSource(self.root_dir, '') + res, errs = loader({'url': 'example.com/', 'param.coll': 'X'}) - exp = {'sources': {'example.cdxj': 'file'}} + exp = [] - assert(res == exp) + assert(to_json_list(res) == exp) + assert(errs == {}) -def test_agg_dir_sources_not_found_dir(): - loader = DirectoryIndexSource(os.path.join(root_dir, 'colls', 'Z', 'indexes'), '') - res = loader.get_source_list({'url': 'example.com/'}) + def test_agg_dir_sources_1(self): + res = self.dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) + exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', + 'colls/B/indexes/iana.cdxj': 'file', + 'colls/C/indexes/dupes.cdxj': 'file'} + } - exp = {'sources': {}} + assert(res == exp) - assert(res == exp) + + def test_agg_dir_sources_2(self): + res = self.dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '[A,C]'}) + exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', + 'colls/C/indexes/dupes.cdxj': 'file'} + } + + assert(res == exp) + + + def test_agg_dir_sources_single_dir(self): + loader = DirectoryIndexSource(os.path.join(self.root_dir, 'colls', 'A', 'indexes'), '') + res = loader.get_source_list({'url': 'example.com/'}) + + exp = {'sources': {'example.cdxj': 'file'}} + + assert(res == exp) + + + def test_agg_dir_sources_not_found_dir(self): + loader = DirectoryIndexSource(os.path.join(self.root_dir, 'colls', 'Z', 'indexes'), '') + res = loader.get_source_list({'url': 'example.com/'}) + + exp = {'sources': {}} + + assert(res == exp) diff --git a/webagg/test/test_memento_agg.py b/webagg/test/test_memento_agg.py index 52dc79da..784bf785 100644 --- a/webagg/test/test_memento_agg.py +++ b/webagg/test/test_memento_agg.py @@ -4,7 +4,7 @@ from webagg.aggregator import SimpleAggregator, GeventTimeoutAggregator from webagg.aggregator import BaseAggregator from webagg.indexsource import FileIndexSource, RemoteIndexSource, MementoIndexSource -from .testutils import json_list, to_path +from .testutils import to_json_list, to_path import json import pytest @@ -48,7 +48,7 @@ def test_mem_agg_index_1(agg): {"timestamp": "20140107040552", "load_url": "http://wayback.archive-it.org/all/20140107040552id_/http://iana.org/", "source": "ait"} ] - assert(json_list(res) == exp) + assert(to_json_list(res) == exp) assert(errs == {'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://iana.org/',)", 'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"}) @@ -65,7 +65,7 @@ def test_mem_agg_index_2(agg): {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source": "ait"}, {"timestamp": "20100519202418", "load_url": "http://web.archive.org/web/20100519202418id_/http://example.com/", "source": "ia"}] - assert(json_list(res) == exp) + assert(to_json_list(res) == exp) assert(errs == {'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://example.com/',)"}) @@ -80,7 +80,7 @@ def test_mem_agg_index_3(agg): {"timestamp": "20140806161228", "load_url": "http://web.archive.org/web/20140806161228id_/http://vvork.com/", "source": "ia"}, {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] - assert(json_list(res) == exp) + assert(to_json_list(res) == exp) assert(errs == {}) @@ -92,7 +92,7 @@ def test_mem_agg_index_4(agg): exp = [{"timestamp": "20141006184357", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"}, {"timestamp": "20131004231540", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"}] - assert(json_list(res) == exp) + assert(to_json_list(res) == exp) assert(errs == {}) @@ -101,7 +101,7 @@ def test_mem_agg_not_found(agg): url = 'http://vvork.com/' res, errs = agg(dict(url=url, closest='20141001', limit=2)) - assert(json_list(res) == []) + assert(to_json_list(res) == []) assert(errs == {'notfound': "NotFoundException('testdata/not-found-x',)"}) @@ -118,7 +118,7 @@ def test_mem_agg_timeout(agg): res, errs = agg(dict(url=url, closest='20141001', limit=2)) BaseAggregator.load_child_source = orig_source - assert(json_list(res) == []) + assert(to_json_list(res) == []) assert(errs == {'local': 'timeout', 'ait': 'timeout', 'bl': 'timeout', 'ia': 'timeout', 'rhiz': 'timeout'}) diff --git a/webagg/test/test_timeouts.py b/webagg/test/test_timeouts.py index 04370c5d..60080ce6 100644 --- a/webagg/test/test_timeouts.py +++ b/webagg/test/test_timeouts.py @@ -5,7 +5,7 @@ from webagg.indexsource import FileIndexSource from webagg.aggregator import SimpleAggregator, TimeoutMixin from webagg.aggregator import GeventTimeoutAggregator, GeventTimeoutAggregator -from .testutils import json_list +from .testutils import to_json_list class TimeoutFileSource(FileIndexSource): @@ -41,7 +41,7 @@ def test_timeout_long_all_pass(): {'source': 'slower', 'timestamp': '20140127171251'}, {'source': 'slow', 'timestamp': '20160225042329'}] - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(errs == {}) @@ -53,7 +53,7 @@ def test_timeout_slower_skipped_1(): exp = [{'source': 'slow', 'timestamp': '20160225042329'}] - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(errs == {'slower': 'timeout'}) @@ -65,7 +65,7 @@ def test_timeout_slower_skipped_2(): exp = [] - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(errs == {'slower': 'timeout', 'slow': 'timeout'}) @@ -80,28 +80,28 @@ def test_timeout_skipping(): exp = [{'source': 'slow', 'timestamp': '20160225042329'}] res, errs = agg(dict(url='http://example.com/')) - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 4) assert(sources['slower'].calls == 4) assert(errs == {'slower': 'timeout'}) res, errs = agg(dict(url='http://example.com/')) - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 5) assert(sources['slower'].calls == 5) assert(errs == {'slower': 'timeout'}) res, errs = agg(dict(url='http://example.com/')) - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 6) assert(sources['slower'].calls == 5) assert(errs == {}) res, errs = agg(dict(url='http://example.com/')) - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 7) assert(sources['slower'].calls == 5) @@ -110,7 +110,7 @@ def test_timeout_skipping(): time.sleep(2.01) res, errs = agg(dict(url='http://example.com/')) - assert(json_list(res, fields=['source', 'timestamp']) == exp) + assert(to_json_list(res, fields=['source', 'timestamp']) == exp) assert(sources['slow'].calls == 8) assert(sources['slower'].calls == 6) diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index b9f8ab98..61f8b155 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -1,7 +1,9 @@ import json import os +import tempfile +import shutil -def json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): +def to_json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) def key_ts_res(cdxlist, extra='filename'): @@ -14,3 +16,12 @@ def to_path(path): return path +class TempDirTests(object): + @classmethod + def setup_class(cls): + cls.root_dir = tempfile.mkdtemp() + + @classmethod + def teardown_class(cls): + shutil.rmtree(cls.root_dir) + From 46d013ab19d10afb958a3636c6474f5bc8311a46 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 10 Mar 2016 21:35:01 -0800 Subject: [PATCH 025/112] test redis: minor tweak to use @patch for fakeredis mock --- webagg/test/test_indexsource.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/webagg/test/test_indexsource.py b/webagg/test/test_indexsource.py index 90a3e156..40dc825e 100644 --- a/webagg/test/test_indexsource.py +++ b/webagg/test/test_indexsource.py @@ -10,21 +10,24 @@ from .testutils import key_ts_res import pytest -import redis -import fakeredis +from fakeredis import FakeStrictRedis +from mock import patch -redis.StrictRedis = fakeredis.FakeStrictRedis -redis.Redis = fakeredis.FakeRedis +redismock = patch('redis.StrictRedis', FakeStrictRedis) +redismock.start() def setup_module(): - global r - r = fakeredis.FakeStrictRedis(db=2) + r = FakeStrictRedis.from_url('redis://localhost:6379/2') r.delete('test:rediscdx') with open('testdata/iana.cdxj', 'rb') as fh: for line in fh: r.zadd('test:rediscdx', 0, line.rstrip()) +def teardown_module(): + redismock.stop() + + local_sources = [ FileIndexSource('testdata/iana.cdxj'), RedisIndexSource('redis://localhost:6379/2/test:rediscdx') From 3b3e190cf45229e0b125ea7462173899839f9b0a Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 11 Mar 2016 11:10:22 -0800 Subject: [PATCH 026/112] testing: use test mixins for class-scope temp directory, live server creation use processes instead of threads for live server --- webagg/test/test_dir_agg.py | 6 ++-- webagg/test/test_upstream.py | 49 +++----------------------- webagg/test/testutils.py | 67 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 47 deletions(-) diff --git a/webagg/test/test_dir_agg.py b/webagg/test/test_dir_agg.py index 165b6346..b55d3755 100644 --- a/webagg/test/test_dir_agg.py +++ b/webagg/test/test_dir_agg.py @@ -3,7 +3,7 @@ import os import shutil import json -from .testutils import to_path, to_json_list, TempDirTests +from .testutils import to_path, to_json_list, TempDirTests, BaseTestClass from mock import patch @@ -21,7 +21,7 @@ def mock_link_header(*args, **kwargs): return linkheader -class TestDirAgg(TempDirTests): +class TestDirAgg(TempDirTests, BaseTestClass): @classmethod def setup_class(cls): super(TestDirAgg, cls).setup_class() @@ -40,7 +40,7 @@ class TestDirAgg(TempDirTests): shutil.copy(to_path('testdata/iana.cdxj'), coll_B) shutil.copy(to_path('testdata/dupes.cdxj'), coll_C) - with open(to_path(cls.root_dir) + 'somefile', 'w') as fh: + with open(to_path(cls.root_dir) + '/somefile', 'w') as fh: fh.write('foo') cls.dir_loader = DirectoryIndexSource(dir_prefix, dir_path) diff --git a/webagg/test/test_upstream.py b/webagg/test/test_upstream.py index 505b8edb..037b62e9 100644 --- a/webagg/test/test_upstream.py +++ b/webagg/test/test_upstream.py @@ -1,62 +1,23 @@ -from webagg.app import ResAggApp - import webtest -import threading from io import BytesIO +from webagg.app import ResAggApp import requests from webagg.handlers import DefaultResourceHandler -from webagg.indexsource import LiveIndexSource -from webagg.proxyindexsource import ProxyMementoIndexSource, UpstreamAggIndexSource from webagg.aggregator import SimpleAggregator - -from wsgiref.simple_server import make_server +from webagg.proxyindexsource import ProxyMementoIndexSource, UpstreamAggIndexSource from pywb.warc.recordloader import ArcWarcRecordLoader - -class ServerThreadRunner(object): - def __init__(self, app): - self.httpd = make_server('', 0, app) - self.port = self.httpd.socket.getsockname()[1] - - def run(): - self.httpd.serve_forever() - - self.thread = threading.Thread(target=run) - self.thread.daemon = True - self.thread.start() - - def stop_thread(self): - self.httpd.shutdown() +from .testutils import LiveServerTests, BaseTestClass -server = None - - -def setup_module(): - app = ResAggApp() - app.add_route('/live', - DefaultResourceHandler(SimpleAggregator( - {'live': LiveIndexSource()}) - ) - ) - - global server - server = ServerThreadRunner(app.application) - -def teardown_module(): - global server - server.stop_thread() - - - -class TestUpstream(object): +class TestUpstream(LiveServerTests, BaseTestClass): def setup(self): app = ResAggApp() - base_url = 'http://localhost:{0}'.format(server.port) + base_url = 'http://localhost:{0}'.format(self.server.port) app.add_route('/upstream', DefaultResourceHandler(SimpleAggregator( {'upstream': UpstreamAggIndexSource(base_url + '/live')}) diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index 61f8b155..51d91364 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -3,6 +3,17 @@ import os import tempfile import shutil +from multiprocessing import Process + +from wsgiref.simple_server import make_server + +from webagg.aggregator import SimpleAggregator +from webagg.app import ResAggApp +from webagg.handlers import DefaultResourceHandler +from webagg.indexsource import LiveIndexSource + + +# ============================================================================ def to_json_list(cdxlist, fields=['timestamp', 'load_url', 'filename', 'source']): return list([json.loads(cdx.to_json(fields)) for cdx in cdxlist]) @@ -16,12 +27,68 @@ def to_path(path): return path +# ============================================================================ +class BaseTestClass(object): + @classmethod + def setup_class(cls): + pass + + @classmethod + def teardown_class(cls): + pass + + +# ============================================================================ class TempDirTests(object): @classmethod def setup_class(cls): + super(TempDirTests, cls).setup_class() cls.root_dir = tempfile.mkdtemp() @classmethod def teardown_class(cls): + super(TempDirTests, cls).teardown_class() shutil.rmtree(cls.root_dir) + +# ============================================================================ +class LiveServerTests(object): + @classmethod + def setup_class(cls): + super(LiveServerTests, cls).setup_class() + cls.server = ServerThreadRunner(cls.make_live_app()) + + @staticmethod + def make_live_app(): + app = ResAggApp() + app.add_route('/live', + DefaultResourceHandler(SimpleAggregator( + {'live': LiveIndexSource()}) + ) + ) + return app.application + + @classmethod + def teardown_class(cls): + super(LiveServerTests, cls).teardown_class() + cls.server.stop_thread() + + +# ============================================================================ +class ServerThreadRunner(object): + def __init__(self, app): + self.httpd = make_server('', 0, app) + self.port = self.httpd.socket.getsockname()[1] + + def run(): + self.httpd.serve_forever() + + self.proc = Process(target=run) + #self.proc.daemon = True + self.proc.start() + + def stop_thread(self): + #self.httpd.shutdown() + self.proc.terminate() + + From 2003925b75e119986f0f2995333aa02172c8aa03 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 11 Mar 2016 11:11:43 -0800 Subject: [PATCH 027/112] setup: fix pywb py3 version to 0.30.0, add coverage for recorder --- .coveragerc | 1 - setup.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.coveragerc b/.coveragerc index 6c30b88e..726c4a27 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,7 +5,6 @@ omit = *.html *.js *.css - pywb/__init__.py [report] exclude_lines = diff --git a/setup.py b/setup.py index ca843b13..7599878c 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ class PyTest(TestCommand): import pytest import sys import os - cmdline = ' --cov-config .coveragerc --cov webagg/ -vv webagg/test/' + cmdline = ' --cov-config .coveragerc --cov ./ -vv webagg/test/ recorder/test/' errcode = pytest.main(cmdline) sys.exit(errcode) @@ -33,10 +33,10 @@ setup( 'recorder', ], install_requires=[ - 'pywb==1.0b', + 'pywb==0.30.0', ], dependency_links=[ - 'git+https://github.com/ikreymer/pywb.git@py3#egg=pywb-1.0b-py3', + 'git+https://github.com/ikreymer/pywb.git@py3#egg=pywb-0.30.0-py3', ], zip_safe=True, entry_points=""" From 9adb8da3b72e11691186ee9f9ec150c00277edba Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 11 Mar 2016 11:12:25 -0800 Subject: [PATCH 028/112] recorder: add support for filtering collections to record by regex (default: .*) add support for excluding certain headers when writing WARCs tests: add first batch of tests for recorder, using live upstream server --- recorder/recorderapp.py | 136 +++++++++++------- recorder/redisindexer.py | 9 +- recorder/test/test_recorder.py | 248 +++++++++++++++++++++++++++++++++ recorder/warcrecorder.py | 22 +-- 4 files changed, 351 insertions(+), 64 deletions(-) create mode 100644 recorder/test/test_recorder.py diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 9d77b6ca..b7d91251 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -1,3 +1,4 @@ +#from gevent import monkey; monkey.patch_all() from requests import request as remote_request from requests.structures import CaseInsensitiveDict @@ -12,10 +13,11 @@ from pywb.warc.recordloader import ArcWarcRecordLoader from recorder.warcrecorder import SingleFileWARCRecorder, PerRecordWARCRecorder from recorder.redisindexer import WritableRedisIndexer -from six.moves.urllib.parse import parse_qsl +from six.moves.urllib.parse import parse_qsl, quote import json import tempfile +import re import traceback @@ -23,51 +25,56 @@ import gevent.queue import gevent -#============================================================================== -write_queue = gevent.queue.Queue() - - #============================================================================== class RecorderApp(object): - def __init__(self, upstream_host, writer): + def __init__(self, upstream_host, writer, accept_colls='.*'): self.upstream_host = upstream_host self.writer = writer self.parser = StatusAndHeadersParser([], verify=False) - gevent.spawn(self._do_write) + self.write_queue = gevent.queue.Queue() + gevent.spawn(self._write_loop) - def _do_write(self): + self.rx_accept_colls = re.compile(accept_colls) + + def _write_loop(self): while True: + self._write_one() + + def _write_one(self): + try: + result = self.write_queue.get() + + req = None + resp = None + req_head, req_pay, resp_head, resp_pay, params = result + + if not self.rx_accept_colls.match(resp_head.get('WebAgg-Source-Coll', '')): + print('COLL', resp_head) + return + + req = self._create_req_record(req_head, req_pay, 'request') + resp = self._create_resp_record(resp_head, resp_pay, 'response') + + self.writer.write_req_resp(req, resp, params) + except: + traceback.print_exc() + + finally: try: - result = write_queue.get() - req = None - resp = None - req_head, req_pay, resp_head, resp_pay, params = result + if req: + req.stream.close() - req = self._create_req_record(req_head, req_pay, 'request') - resp = self._create_resp_record(resp_head, resp_pay, 'response') - - self.writer.write_req_resp(req, resp, params) - - except: + if resp: + resp.stream.close() + except Exception as e: traceback.print_exc() - finally: - try: - if req: - req.stream.close() - - if resp: - resp.stream.close() - except Exception as e: - traceback.print_exc() - def _create_req_record(self, req_headers, payload, type_, ct=''): len_ = payload.tell() payload.seek(0) - #warc_headers = StatusAndHeaders('WARC/1.0', req_headers.items()) warc_headers = req_headers status_headers = self.parser.parse(payload) @@ -76,7 +83,7 @@ class RecorderApp(object): status_headers, ct, len_) return record - def _create_resp_record(self, req_headers, payload, type_, ct=''): + def _create_resp_record(self, resp_headers, payload, type_, ct=''): len_ = payload.tell() payload.seek(0) @@ -95,18 +102,32 @@ class RecorderApp(object): ('Content-Length', str(len(message)))] start_response('400 Bad Request', headers) - return message + return [message.encode('utf-8')] + + def _get_request_uri(self, env): + req_uri = env.get('REQUEST_URI') + if req_uri: + return req_uri + + req_uri = quote(env.get('PATH_INFO', ''), safe='/~!$&\'()*+,;=:@') + query = env.get('QUERY_STRING') + if query: + req_uri += '?' + query + + return req_uri def __call__(self, environ, start_response): - request_uri = environ.get('REQUEST_URI') + request_uri = self._get_request_uri(environ) input_req = DirectWSGIInputRequest(environ) headers = input_req.get_req_headers() method = input_req.get_req_method() + input_buff = input_req.get_req_body() + params = dict(parse_qsl(environ.get('QUERY_STRING'))) - req_stream = Wrapper(input_req.get_req_body(), headers, None) + req_stream = ReqWrapper(input_buff, headers) try: res = remote_request(url=self.upstream_host + request_uri, @@ -121,24 +142,16 @@ class RecorderApp(object): start_response('200 OK', list(res.headers.items())) - resp_stream = Wrapper(res.raw, res.headers, req_stream, params) + resp_stream = RespWrapper(res.raw, res.headers, req_stream, params, self.write_queue) return StreamIter(ReadFullyStream(resp_stream)) #============================================================================== class Wrapper(object): - def __init__(self, stream, rec_headers, req_obj=None, - params=None): + def __init__(self, stream): self.stream = stream self.out = self._create_buffer() - self.headers = CaseInsensitiveDict(rec_headers) - for n in rec_headers.keys(): - if not n.upper().startswith('WARC-'): - del self.headers[n] - - self.req_obj = req_obj - self.params = params def _create_buffer(self): return tempfile.SpooledTemporaryFile(max_size=512*1024) @@ -153,21 +166,42 @@ class Wrapper(object): self.stream.close() except: traceback.print_exc() + finally: + self._after_close() - if not self.req_obj: + def _after_close(self): + pass + + +#============================================================================== +class RespWrapper(Wrapper): + def __init__(self, stream, headers, req, + params, queue): + + super(RespWrapper, self).__init__(stream) + self.headers = headers + self.req = req + self.params = params + self.queue = queue + + def _after_close(self): + if not self.req: return try: - entry = (self.req_obj.headers, self.req_obj.out, + entry = (self.req.headers, self.req.out, self.headers, self.out, self.params) - write_queue.put(entry) - self.req_obj = None + self.queue.put(entry) + self.req = None except: traceback.print_exc() #============================================================================== -application = RecorderApp('http://localhost:8080', - PerRecordWARCRecorder('./warcs/{user}/{coll}/', - dedup_index=WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', 'recorder'))) - +class ReqWrapper(Wrapper): + def __init__(self, stream, req_headers): + super(ReqWrapper, self).__init__(stream) + self.headers = CaseInsensitiveDict(req_headers) + for n in req_headers.keys(): + if not n.upper().startswith('WARC-'): + del self.headers[n] diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index f2b3c520..ce359672 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -5,6 +5,7 @@ from pywb.utils.timeutils import timestamp_to_datetime from pywb.utils.timeutils import datetime_to_iso_date, iso_date_to_timestamp from io import BytesIO +import os from webagg.indexsource import RedisIndexSource from webagg.aggregator import SimpleAggregator @@ -13,17 +14,21 @@ from webagg.utils import res_template #============================================================================== class WritableRedisIndexer(RedisIndexSource): - def __init__(self, redis_url, name): + def __init__(self, redis_url, rel_path_template='', name='recorder'): super(WritableRedisIndexer, self).__init__(redis_url) self.cdx_lookup = SimpleAggregator({name: self}) + self.rel_path_template = rel_path_template def add_record(self, stream, params, filename=None): if not filename and hasattr(stream, 'name'): filename = stream.name + rel_path = res_template(self.rel_path_template, params) + filename = os.path.relpath(filename, rel_path) + cdxout = BytesIO() write_cdx_index(cdxout, stream, filename, - cdxj=True, append_post=True) + cdxj=True, append_post=True, rel_root=rel_path) z_key = res_template(self.redis_key_template, params) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py new file mode 100644 index 00000000..f5dbdaf7 --- /dev/null +++ b/recorder/test/test_recorder.py @@ -0,0 +1,248 @@ +#from gevent import monkey; monkey.patch_all() +import gevent + +from webagg.test.testutils import TempDirTests, LiveServerTests, BaseTestClass, to_path + +import os +import webtest + +from fakeredis import FakeStrictRedis +from mock import patch + +from recorder.recorderapp import RecorderApp +from recorder.redisindexer import WritableRedisIndexer +from recorder.warcrecorder import PerRecordWARCRecorder + +from webagg.utils import MementoUtils + +from pywb.cdx.cdxobject import CDXObject +from pywb.utils.statusandheaders import StatusAndHeadersParser +from pywb.utils.bufferedreaders import DecompressingBufferedReader +from pywb.warc.recordloader import ArcWarcRecordLoader + +from six.moves.urllib.parse import quote, unquote +from io import BytesIO + +general_req_data = "\ +GET {path} HTTP/1.1\r\n\ +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n\ +User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36\r\n\ +Host: {host}\r\n\ +\r\n" + + + +class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): + @classmethod + def setup_class(cls): + super(TestRecorder, cls).setup_class() + + warcs = to_path(cls.root_dir + '/warcs') + + os.makedirs(warcs) + + cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) + + + def _test_per_warc(self, recorder_app, host, path, other_params=''): + url = 'http://' + host + path + req_url = '/live/resource/postreq?url=' + url + other_params + testapp = webtest.TestApp(recorder_app) + resp = testapp.post(req_url, general_req_data.format(host=host, path=path).encode('utf-8')) + #gevent.sleep(0.1) + recorder_app._write_one() + + assert resp.headers['WebAgg-Source-Coll'] == 'live' + + assert resp.headers['Link'] == MementoUtils.make_link(unquote(url), 'original') + assert resp.headers['Memento-Datetime'] != '' + + return resp + + def _test_all_warcs(self, dirname, num): + coll_dir = to_path(self.root_dir + dirname) + assert os.path.isdir(coll_dir) + + files = [x for x in os.listdir(coll_dir) if os.path.isfile(os.path.join(coll_dir, x))] + assert len(files) == num + assert all(x.endswith('.warc.gz') for x in files) + + def test_record_warc_1(self): + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/'))) + + resp = self._test_per_warc(recorder_app, 'httpbin.org', '/get?foo=bar') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/', 2) + + def test_record_warc_2(self): + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='live') + + resp = self._test_per_warc(recorder_app, 'httpbin.org', '/get?foo=bar') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/', 4) + + def test_error_url(self): + recorder_app = RecorderApp(self.upstream_url + '01', + PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='live') + + + testapp = webtest.TestApp(recorder_app) + resp = testapp.get('/live/resource?url=http://example.com/', status=400) + + assert resp.json['error'] != '' + + self._test_all_warcs('/warcs/', 4) + + def test_record_cookies_header(self): + base_path = to_path(self.root_dir + '/warcs/cookiecheck/') + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(base_path), accept_colls='live') + + resp = self._test_per_warc(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') + assert b'HTTP/1.1 302' in resp.body + + buff = BytesIO(resp.body) + record = ArcWarcRecordLoader().parse_record_stream(buff) + assert ('Set-Cookie', 'name=value; Path=/') in record.status_headers.headers + assert ('Set-Cookie', 'foo=bar; Path=/') in record.status_headers.headers + + warcs = os.listdir(base_path) + + stored_rec = None + for warc in warcs: + with open(os.path.join(base_path, warc), 'rb') as fh: + decomp = DecompressingBufferedReader(fh) + stored_rec = ArcWarcRecordLoader().parse_record_stream(decomp) + if stored_rec.rec_type == 'response': + break + + assert stored_rec is not None + assert ('Set-Cookie', 'name=value; Path=/') in stored_rec.status_headers.headers + assert ('Set-Cookie', 'foo=bar; Path=/') in stored_rec.status_headers.headers + + def test_record_cookies_skip_header(self): + base_path = to_path(self.root_dir + '/warcs/cookieskip/') + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(base_path, exclude_headers=['Set-Cookie', 'Cookie']), + accept_colls='live') + + resp = self._test_per_warc(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') + assert b'HTTP/1.1 302' in resp.body + + buff = BytesIO(resp.body) + record = ArcWarcRecordLoader().parse_record_stream(buff) + assert ('Set-Cookie', 'name=value; Path=/') in record.status_headers.headers + assert ('Set-Cookie', 'foo=bar; Path=/') in record.status_headers.headers + + warcs = os.listdir(base_path) + + stored_rec = None + for warc in warcs: + with open(os.path.join(base_path, warc), 'rb') as fh: + decomp = DecompressingBufferedReader(fh) + stored_rec = ArcWarcRecordLoader().parse_record_stream(decomp) + if stored_rec.rec_type == 'response': + break + + assert stored_rec is not None + assert ('Set-Cookie', 'name=value; Path=/') not in stored_rec.status_headers.headers + assert ('Set-Cookie', 'foo=bar; Path=/') not in stored_rec.status_headers.headers + + + def test_record_skip_wrong_coll(self): + recorder_app = RecorderApp(self.upstream_url, + writer=PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='not-live') + + resp = self._test_per_warc(recorder_app, 'httpbin.org', '/get?foo=bar') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/', 4) + + @patch('redis.StrictRedis', FakeStrictRedis) + def test_record_param_user_coll(self): + + warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') + + + dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', + rel_path_template=self.root_dir + '/warcs/') + + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + + self._test_all_warcs('/warcs/', 4) + + resp = self._test_per_warc(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/USER/COLL/', 2) + + r = FakeStrictRedis.from_url('redis://localhost/2') + + res = r.zrange('USER:COLL:cdxj', 0, -1) + assert len(res) == 1 + + cdx = CDXObject(res[0]) + assert cdx['urlkey'] == 'org,httpbin)/get?foo=bar' + assert cdx['mime'] == 'application/json' + assert cdx['offset'] == '0' + assert cdx['filename'].startswith('USER/COLL/') + assert cdx['filename'].endswith('.warc.gz') + + + @patch('redis.StrictRedis', FakeStrictRedis) + def test_record_param_user_coll_revisit(self): + warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') + + + dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', + rel_path_template=self.root_dir + '/warcs/') + + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + + self._test_all_warcs('/warcs/', 4) + + resp = self._test_per_warc(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/USER/COLL/', 4) + + # Test Redis CDX + r = FakeStrictRedis.from_url('redis://localhost/2') + + res = r.zrange('USER:COLL:cdxj', 0, -1) + assert len(res) == 2 + + cdx = CDXObject(res[1]) + assert cdx['urlkey'] == 'org,httpbin)/get?foo=bar' + assert cdx['mime'] == 'warc/revisit' + assert cdx['offset'] == '0' + assert cdx['filename'].startswith('USER/COLL/') + assert cdx['filename'].endswith('.warc.gz') + + fullwarc = os.path.join(self.root_dir, 'warcs', cdx['filename']) + + with open(fullwarc, 'rb') as fh: + decomp = DecompressingBufferedReader(fh) + # Test refers-to headers + status_headers = StatusAndHeadersParser(['WARC/1.0']).parse(decomp) + assert status_headers.get_header('WARC-Type') == 'revisit' + assert status_headers.get_header('WARC-Target-URI') == 'http://httpbin.org/get?foo=bar' + assert status_headers.get_header('WARC-Date') != '' + assert status_headers.get_header('WARC-Refers-To-Target-URI') == 'http://httpbin.org/get?foo=bar' + assert status_headers.get_header('WARC-Refers-To-Date') != '' + + + diff --git a/recorder/warcrecorder.py b/recorder/warcrecorder.py index 98d49361..c24e9f63 100644 --- a/recorder/warcrecorder.py +++ b/recorder/warcrecorder.py @@ -29,9 +29,14 @@ class BaseWARCRecorder(object): REVISIT_PROFILE = 'http://netpreserve.org/warc/1.0/revisit/uri-agnostic-identical-payload-digest' - def __init__(self, gzip=True, dedup_index=None): + def __init__(self, gzip=True, dedup_index=None, name='recorder', + exclude_headers=None): self.gzip = gzip self.dedup_index = dedup_index + self.rec_source_name = name + self.exclude_headers = exclude_headers + if self.exclude_headers: + self.exclude_headers = [x.lower() for x in self.exclude_headers] def ensure_digest(self, record): block_digest = record.rec_headers.get('WARC-Block-Digest') @@ -62,7 +67,8 @@ class BaseWARCRecorder(object): return Digester('sha1') def _set_header_buff(self, record): - record.status_headers.headers_buff = str(record.status_headers).encode('latin-1') + b'\r\n' + buff = record.status_headers.to_bytes(self.exclude_headers) + record.status_headers.headers_buff = buff def write_req_resp(self, req, resp, params): url = resp.rec_headers.get('WARC-Target-Uri') @@ -80,8 +86,6 @@ class BaseWARCRecorder(object): if resp_id: req.rec_headers['WARC-Concurrent-To'] = resp_id - #resp.status_headers.remove_header('Etag') - self._set_header_buff(req) self._set_header_buff(resp) @@ -208,12 +212,6 @@ class Digester(object): def update(self, buff): self.digester.update(buff) - def __eq__(self, string): - digest = str(base64.b32encode(self.digester.digest())) - if ':' in string: - digest = self._type_ + ':' + digest - return string == digest - def __str__(self): return self.type_ + ':' + to_native_str(base64.b32encode(self.digester.digest())) @@ -259,7 +257,9 @@ class PerRecordWARCRecorder(BaseWARCRecorder): resp_uuid = resp.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') - formatter = ParamFormatter(params) + formatter = ParamFormatter(params, name=self.rec_source_name) + print(params) + print(formatter.name) full_dir = formatter.format(self.warcdir) try: From 49b6ae78a80bff416bdaa3a8b396ef6e1caf1750 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 12 Mar 2016 22:15:24 -0800 Subject: [PATCH 029/112] live loader: remove liverec (doesn't work well with gevent), use regular requests instead of overriden version. reconstruct header block from httplib header pairs list move ReadFullyStream to utils --- webagg/app.py | 3 +- webagg/indexsource.py | 3 +- webagg/inputrequest.py | 16 ++- webagg/liverec.py | 246 ----------------------------------- webagg/responseloader.py | 144 +++++++++++--------- webagg/test/test_handlers.py | 4 +- webagg/test/testutils.py | 5 +- webagg/utils.py | 44 +++++++ 8 files changed, 152 insertions(+), 313 deletions(-) delete mode 100644 webagg/liverec.py diff --git a/webagg/app.py b/webagg/app.py index 2745223d..e3302fae 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -1,9 +1,8 @@ -from webagg.liverec import request as remote_request - from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest from bottle import route, request, response, abort, Bottle import bottle +import requests import traceback import json diff --git a/webagg/indexsource.py b/webagg/indexsource.py index c83d3006..b37604ba 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -8,7 +8,8 @@ from pywb.utils.wbexception import NotFoundException from pywb.cdx.cdxobject import CDXObject -from webagg.liverec import patched_requests as requests +#from webagg.liverec import patched_requests as requests +import requests from webagg.utils import ParamFormatter, res_template from webagg.utils import MementoUtils diff --git a/webagg/inputrequest.py b/webagg/inputrequest.py index 332716a2..f15de60b 100644 --- a/webagg/inputrequest.py +++ b/webagg/inputrequest.py @@ -2,8 +2,8 @@ from pywb.utils.loaders import extract_post_query, append_post_query from pywb.utils.loaders import LimitReader from pywb.utils.statusandheaders import StatusAndHeadersParser -from six.moves.urllib.parse import urlsplit -from six import StringIO, iteritems +from six.moves.urllib.parse import urlsplit, quote +from six import iteritems from io import BytesIO @@ -80,6 +80,18 @@ class DirectWSGIInputRequest(object): return url + def get_full_request_uri(self): + req_uri = self.env.get('REQUEST_URI') + if req_uri: + return req_uri + + req_uri = quote(self.env.get('PATH_INFO', ''), safe='/~!$&\'()*+,;=:@') + query = self.env.get('QUERY_STRING') + if query: + req_uri += '?' + query + + return req_uri + #============================================================================= class POSTInputRequest(DirectWSGIInputRequest): diff --git a/webagg/liverec.py b/webagg/liverec.py deleted file mode 100644 index e0fe1298..00000000 --- a/webagg/liverec.py +++ /dev/null @@ -1,246 +0,0 @@ -from io import BytesIO - -try: - import httplib -except ImportError: - import http.client as httplib - - -orig_connection = httplib.HTTPConnection - -from contextlib import contextmanager - -import ssl -from array import array - -from time import sleep - - -BUFF_SIZE = 8192 - - -# ============================================================================ -class RecordingStream(object): - def __init__(self, fp, recorder): - self.fp = fp - self.recorder = recorder - self.incomplete = False - - if hasattr(self.fp, 'unread'): - self.unread = self.fp.unread - - if hasattr(self.fp, 'tell'): - self.tell = self.fp.tell - - def read(self, *args, **kwargs): - buff = self.fp.read(*args, **kwargs) - self.recorder.write_response_buff(buff) - return buff - - def readinto(self, buff): - res = self.fp.readinto(buff) - self.recorder.write_response_buff(buff) - return res - - def readline(self, maxlen=-1): - line = self.fp.readline(maxlen) - self.recorder.write_response_header_line(line) - return line - - def flush(self): - self.fp.flush() - - def close(self): - try: - self.recorder.finish_response(self.incomplete) - except Exception as e: - import traceback - traceback.print_exc() - - res = self.fp.close() - return res - - -# ============================================================================ -class RecordingHTTPResponse(httplib.HTTPResponse): - def __init__(self, recorder, *args, **kwargs): - httplib.HTTPResponse.__init__(self, *args, **kwargs) - self.fp = RecordingStream(self.fp, recorder) - - def mark_incomplete(self): - self.fp.incomplete = True - - -# ============================================================================ -class RecordingHTTPConnection(httplib.HTTPConnection): - global_recorder_maker = None - - def __init__(self, *args, **kwargs): - orig_connection.__init__(self, *args, **kwargs) - if not self.global_recorder_maker: - self.recorder = None - else: - self.recorder = self.global_recorder_maker() - - def make_recording_response(*args, **kwargs): - return RecordingHTTPResponse(self.recorder, *args, **kwargs) - - self.response_class = make_recording_response - - def send(self, data): - if not self.recorder: - orig_connection.send(self, data) - return - - if hasattr(data,'read') and not isinstance(data, array): - url = None - while True: - buff = data.read(BUFF_SIZE) - if not buff: - break - - orig_connection.send(self, buff) - self.recorder.write_request(url, buff) - else: - orig_connection.send(self, data) - self.recorder.write_request(self, data) - - - def get_url(self, data): - try: - buff = BytesIO(data) - line = buff.readline() - - path = line.split(' ', 2)[1] - host = self.host - port = self.port - scheme = 'https' if isinstance(self.sock, ssl.SSLSocket) else 'http' - - url = scheme + '://' + host - if (scheme == 'https' and port != '443') and (scheme == 'http' and port != '80'): - url += ':' + port - - url += path - except Exception as e: - raise - - return url - - - def request(self, *args, **kwargs): - #if self.recorder: - # self.recorder.start_request(self) - - res = orig_connection.request(self, *args, **kwargs) - - if self.recorder: - self.recorder.finish_request(self.sock) - - return res - - -# ============================================================================ -class BaseRecorder(object): - def write_request(self, conn, buff): - #url = conn.get_url() - pass - - def write_response_header_line(self, line): - pass - - def write_response_buff(self, buff): - pass - - def finish_request(self, socket): - pass - - def finish_response(self, incomplete=False): - pass - - -#================================================================= -class ReadFullyStream(object): - def __init__(self, stream): - self.stream = stream - - def read(self, *args, **kwargs): - try: - return self.stream.read(*args, **kwargs) - except: - self.mark_incomplete() - raise - - def readline(self, *args, **kwargs): - try: - return self.stream.readline(*args, **kwargs) - except: - self.mark_incomplete() - raise - - def mark_incomplete(self): - if (hasattr(self.stream, '_fp') and - hasattr(self.stream._fp, 'mark_incomplete')): - self.stream._fp.mark_incomplete() - - def close(self): - try: - while True: - buff = self.stream.read(BUFF_SIZE) - sleep(0) - if not buff: - break - - except Exception as e: - import traceback - traceback.print_exc() - self.mark_incomplete() - finally: - self.stream.close() - - -# ============================================================================ -httplib.HTTPConnection = RecordingHTTPConnection -# ============================================================================ - -class DefaultRecorderMaker(object): - def __call__(self): - return BaseRecorder() - - -class FixedRecorder(object): - def __init__(self, recorder): - self.recorder = recorder - - def __call__(self): - return self.recorder - -@contextmanager -def record_requests(url, recorder_maker): - RecordingHTTPConnection.global_recorder_maker = recorder_maker - yield - RecordingHTTPConnection.global_recorder_maker = None - -@contextmanager -def orig_requests(): - httplib.HTTPConnection = orig_connection - yield - httplib.HTTPConnection = RecordingHTTPConnection - - -import requests as patched_requests - -def request(url, method='GET', recorder=None, recorder_maker=None, session=patched_requests, **kwargs): - if kwargs.get('skip_recording'): - recorder_maker = None - elif recorder: - recorder_maker = FixedRecorder(recorder) - elif not recorder_maker: - recorder_maker = DefaultRecorderMaker() - - with record_requests(url, recorder_maker): - kwargs['allow_redirects'] = False - r = session.request(method=method, - url=url, - **kwargs) - - return r diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 31a5298e..52906301 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -1,6 +1,3 @@ -from webagg.liverec import BaseRecorder -from webagg.liverec import request as remote_request - from webagg.utils import MementoUtils from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_timestamp @@ -12,12 +9,12 @@ from pywb.utils.statusandheaders import StatusAndHeaders from pywb.warc.resolvingloader import ResolvingLoader - from io import BytesIO import uuid import six import itertools +import requests #============================================================================= @@ -79,9 +76,6 @@ class BaseLoader(object): out_headers['Memento-Datetime'] = other_headers.get('Memento-Datetime') out_headers['Content-Length'] = other_headers.get('Content-Length') - #for n, v in other_headers.items(): - # out_headers[n] = v - return out_headers, StreamIter(stream) out_headers['Link'] = MementoUtils.make_link( @@ -93,13 +87,19 @@ class BaseLoader(object): warc_headers_buff = warc_headers.to_bytes() - self._set_content_len(warc_headers.get_header('Content-Length'), - out_headers, - len(warc_headers_buff)) + lenset = self._set_content_len(warc_headers.get_header('Content-Length'), + out_headers, + len(warc_headers_buff)) - return out_headers, StreamIter(stream, - header1=warc_headers_buff, - header2=other_headers) + streamiter = StreamIter(stream, + header1=warc_headers_buff, + header2=other_headers) + + if not lenset: + out_headers['Transfer-Encoding'] = 'chunked' + streamiter = self._chunk_encode(streamiter) + + return out_headers, streamiter def _set_content_len(self, content_len_str, headers, existing_len): # Try to set content-length, if it is available and valid @@ -111,6 +111,21 @@ class BaseLoader(object): if content_len >= 0: content_len += existing_len headers['Content-Length'] = str(content_len) + return True + + return False + + @staticmethod + def _chunk_encode(orig_iter): + for chunk in orig_iter: + if not len(chunk): + continue + chunk_len = b'%X\r\n' % len(chunk) + yield chunk_len + yield chunk + yield b'\r\n' + + yield b'0\r\n\r\n' #============================================================================= @@ -183,17 +198,20 @@ class WARCPathLoader(BaseLoader): #============================================================================= class LiveWebLoader(BaseLoader): - SKIP_HEADERS = (b'link', - b'memento-datetime', - b'content-location', - b'x-archive') + SKIP_HEADERS = ('link', + 'memento-datetime', + 'content-location', + 'x-archive') + + def __init__(self): + self.sesh = requests.session() def load_resource(self, cdx, params): load_url = cdx.get('load_url') if not load_url: return None - recorder = HeaderRecorder(self.SKIP_HEADERS) + #recorder = HeaderRecorder(self.SKIP_HEADERS) input_req = params['_input_req'] @@ -215,14 +233,13 @@ class LiveWebLoader(BaseLoader): data = input_req.get_req_body() try: - upstream_res = remote_request(url=load_url, - method=method, - recorder=recorder, - stream=True, - allow_redirects=False, - headers=req_headers, - data=data, - timeout=params.get('_timeout')) + upstream_res = self.sesh.request(url=load_url, + method=method, + stream=True, + allow_redirects=False, + headers=req_headers, + data=data, + timeout=params.get('_timeout')) except Exception as e: raise LiveResourceException(load_url) @@ -240,7 +257,47 @@ class LiveWebLoader(BaseLoader): cdx['source'] = upstream_res.headers.get('WebAgg-Source-Coll') return None, upstream_res.headers, upstream_res.raw - http_headers_buff = recorder.get_headers_buff() + if upstream_res.raw.version == 11: + version = '1.1' + else: + version = '1.0' + + status = 'HTTP/{version} {status} {reason}\r\n' + status = status.format(version=version, + status=upstream_res.status_code, + reason=upstream_res.reason) + + http_headers_buff = status + + orig_resp = upstream_res.raw._original_response + + try: #pragma: no cover + #PY 3 + resp_headers = orig_resp.headers._headers + for n, v in resp_headers: + if n.lower() in self.SKIP_HEADERS: + continue + + http_headers_buff += n + ': ' + v + '\r\n' + except: #pragma: no cover + #PY 2 + resp_headers = orig_resp.msg.headers + for n, v in zip(orig_resp.getheaders(), resp_headers): + if n in self.SKIP_HEADERS: + continue + + http_headers_buff += v + + http_headers_buff += '\r\n' + http_headers_buff = http_headers_buff.encode('latin-1') + + try: + fp = upstream_res.raw._fp.fp + if hasattr(fp, 'raw'): + fp = fp.raw + remote_ip = fp._sock.getpeername()[0] + except: #pragma: no cover + remote_ip = None warc_headers = {} @@ -248,8 +305,8 @@ class LiveWebLoader(BaseLoader): warc_headers['WARC-Record-ID'] = self._make_warc_id() warc_headers['WARC-Target-URI'] = cdx['url'] warc_headers['WARC-Date'] = datetime_to_iso_date(dt) - if recorder.target_ip: - warc_headers['WARC-IP-Address'] = recorder.target_ip + if remote_ip: + warc_headers['WARC-IP-Address'] = remote_ip warc_headers['Content-Type'] = 'application/http; msgtype=response' @@ -269,32 +326,3 @@ class LiveWebLoader(BaseLoader): def __str__(self): return 'LiveWebLoader' - -#============================================================================= -class HeaderRecorder(BaseRecorder): - def __init__(self, skip_list=None): - self.buff = BytesIO() - self.skip_list = skip_list - self.skipped = [] - self.target_ip = None - - def write_response_header_line(self, line): - if self.accept_header(line): - self.buff.write(line) - - def get_headers_buff(self): - return self.buff.getvalue() - - def accept_header(self, line): - if self.skip_list and line.lower().startswith(self.skip_list): - self.skipped.append(line) - return False - - return True - - def finish_request(self, socket): - ip = socket.getpeername() - if ip: - self.target_ip = ip[0] - - diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 138584d6..5b9e510f 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -1,4 +1,4 @@ -from gevent import monkey; monkey.patch_all(thread=False) +#from gevent import monkey; monkey.patch_all(thread=False) from collections import OrderedDict @@ -12,6 +12,7 @@ from webagg.app import ResAggApp from webagg.utils import MementoUtils from pywb.utils.statusandheaders import StatusAndHeadersParser +from pywb.utils.bufferedreaders import ChunkedDataReader from io import BytesIO import webtest @@ -71,6 +72,7 @@ class TestResAgg(object): def _check_uri_date(self, resp, uri, dt): buff = BytesIO(resp.body) + buff = ChunkedDataReader(buff) status_headers = StatusAndHeadersParser(['WARC/1.0']).parse(buff) assert status_headers.get_header('WARC-Target-URI') == uri if dt == True: diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index 51d91364..4c5c42b6 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -71,7 +71,7 @@ class LiveServerTests(object): @classmethod def teardown_class(cls): super(LiveServerTests, cls).teardown_class() - cls.server.stop_thread() + cls.server.stop() # ============================================================================ @@ -87,8 +87,7 @@ class ServerThreadRunner(object): #self.proc.daemon = True self.proc.start() - def stop_thread(self): - #self.httpd.shutdown() + def stop(self): self.proc.terminate() diff --git a/webagg/utils.py b/webagg/utils.py index ea4cec10..8913a443 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -1,6 +1,7 @@ import re import six import string +import time from pywb.utils.timeutils import timestamp_to_http_date from pywb.utils.wbexception import BadRequestException @@ -10,6 +11,8 @@ LINK_SEG_SPLIT = re.compile(';\s*') LINK_URL = re.compile('<(.*)>') LINK_PROP = re.compile('([\w]+)="([^"]+)') +BUFF_SIZE = 8192 + #============================================================================= class MementoException(BadRequestException): @@ -142,3 +145,44 @@ def res_template(template, params): return res +#================================================================= +class ReadFullyStream(object): + def __init__(self, stream): + self.stream = stream + + def read(self, *args, **kwargs): + try: + return self.stream.read(*args, **kwargs) + except: + self.mark_incomplete() + raise + + def readline(self, *args, **kwargs): + try: + return self.stream.readline(*args, **kwargs) + except: + self.mark_incomplete() + raise + + def mark_incomplete(self): + if (hasattr(self.stream, '_fp') and + hasattr(self.stream._fp, 'mark_incomplete')): + self.stream._fp.mark_incomplete() + + def close(self): + try: + while True: + buff = self.stream.read(BUFF_SIZE) + time.sleep(0) + if not buff: + break + + except Exception as e: + import traceback + traceback.print_exc() + self.mark_incomplete() + finally: + self.stream.close() + + + From 7a828017d1e0a84bc79b6459f2c1478cdf1f40b8 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 12 Mar 2016 22:18:01 -0800 Subject: [PATCH 030/112] recorder: clean up logging, ReadFullyStream moves to utils, get_request_uri to inputreq --- recorder/recorderapp.py | 26 +++++++------------------- recorder/warcrecorder.py | 2 -- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index b7d91251..3d968edd 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -1,8 +1,8 @@ #from gevent import monkey; monkey.patch_all() -from requests import request as remote_request from requests.structures import CaseInsensitiveDict +import requests -from webagg.liverec import ReadFullyStream +from webagg.utils import ReadFullyStream from webagg.responseloader import StreamIter from webagg.inputrequest import DirectWSGIInputRequest @@ -13,7 +13,7 @@ from pywb.warc.recordloader import ArcWarcRecordLoader from recorder.warcrecorder import SingleFileWARCRecorder, PerRecordWARCRecorder from recorder.redisindexer import WritableRedisIndexer -from six.moves.urllib.parse import parse_qsl, quote +from six.moves.urllib.parse import parse_qsl import json import tempfile @@ -51,7 +51,6 @@ class RecorderApp(object): req_head, req_pay, resp_head, resp_pay, params = result if not self.rx_accept_colls.match(resp_head.get('WebAgg-Source-Coll', '')): - print('COLL', resp_head) return req = self._create_req_record(req_head, req_pay, 'request') @@ -104,24 +103,11 @@ class RecorderApp(object): start_response('400 Bad Request', headers) return [message.encode('utf-8')] - def _get_request_uri(self, env): - req_uri = env.get('REQUEST_URI') - if req_uri: - return req_uri - - req_uri = quote(env.get('PATH_INFO', ''), safe='/~!$&\'()*+,;=:@') - query = env.get('QUERY_STRING') - if query: - req_uri += '?' + query - - return req_uri - def __call__(self, environ, start_response): - request_uri = self._get_request_uri(environ) - input_req = DirectWSGIInputRequest(environ) headers = input_req.get_req_headers() method = input_req.get_req_method() + request_uri = input_req.get_full_request_uri() input_buff = input_req.get_req_body() @@ -130,7 +116,7 @@ class RecorderApp(object): req_stream = ReqWrapper(input_buff, headers) try: - res = remote_request(url=self.upstream_host + request_uri, + res = requests.request(url=self.upstream_host + request_uri, method=method, data=req_stream, headers=headers, @@ -205,3 +191,5 @@ class ReqWrapper(Wrapper): for n in req_headers.keys(): if not n.upper().startswith('WARC-'): del self.headers[n] + + diff --git a/recorder/warcrecorder.py b/recorder/warcrecorder.py index c24e9f63..e75bff05 100644 --- a/recorder/warcrecorder.py +++ b/recorder/warcrecorder.py @@ -258,8 +258,6 @@ class PerRecordWARCRecorder(BaseWARCRecorder): req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') formatter = ParamFormatter(params, name=self.rec_source_name) - print(params) - print(formatter.name) full_dir = formatter.format(self.warcdir) try: From 709d2b1ea23862d1d5986126c2adf3b6647a33db Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 12 Mar 2016 23:29:23 -0800 Subject: [PATCH 031/112] reorg: move StreamIter to utils --- .coveragerc | 1 + recorder/recorderapp.py | 9 ++++----- webagg/responseloader.py | 41 +--------------------------------------- webagg/utils.py | 40 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 46 deletions(-) diff --git a/.coveragerc b/.coveragerc index 726c4a27..4b25f6fc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,6 +5,7 @@ omit = *.html *.js *.css + setup.py [report] exclude_lines = diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 3d968edd..eaec579b 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -1,9 +1,5 @@ #from gevent import monkey; monkey.patch_all() -from requests.structures import CaseInsensitiveDict -import requests - -from webagg.utils import ReadFullyStream -from webagg.responseloader import StreamIter +from webagg.utils import ReadFullyStream, StreamIter from webagg.inputrequest import DirectWSGIInputRequest from pywb.utils.statusandheaders import StatusAndHeadersParser @@ -19,6 +15,9 @@ import json import tempfile import re +from requests.structures import CaseInsensitiveDict +import requests + import traceback import gevent.queue diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 52906301..4e7aeaf3 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -1,4 +1,4 @@ -from webagg.utils import MementoUtils +from webagg.utils import MementoUtils, StreamIter from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_timestamp from pywb.utils.timeutils import iso_date_to_datetime, datetime_to_iso_date @@ -17,45 +17,6 @@ import itertools import requests -#============================================================================= -class StreamIter(six.Iterator): - def __init__(self, stream, header1=None, header2=None, size=8192): - self.stream = stream - self.header1 = header1 - self.header2 = header2 - self.size = size - - def __iter__(self): - return self - - def __next__(self): - if self.header1: - header = self.header1 - self.header1 = None - return header - elif self.header2: - header = self.header2 - self.header2 = None - return header - - data = self.stream.read(self.size) - if data: - return data - - self.close() - raise StopIteration - - def close(self): - if not self.stream: - return - - try: - self.stream.close() - self.stream = None - except Exception: - pass - - #============================================================================= class BaseLoader(object): def __call__(self, cdx, params): diff --git a/webagg/utils.py b/webagg/utils.py index 8913a443..e71357c7 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -145,7 +145,7 @@ def res_template(template, params): return res -#================================================================= +#============================================================================= class ReadFullyStream(object): def __init__(self, stream): self.stream = stream @@ -185,4 +185,42 @@ class ReadFullyStream(object): self.stream.close() +#============================================================================= +class StreamIter(six.Iterator): + def __init__(self, stream, header1=None, header2=None, size=8192): + self.stream = stream + self.header1 = header1 + self.header2 = header2 + self.size = size + + def __iter__(self): + return self + + def __next__(self): + if self.header1: + header = self.header1 + self.header1 = None + return header + elif self.header2: + header = self.header2 + self.header2 = None + return header + + data = self.stream.read(self.size) + if data: + return data + + self.close() + raise StopIteration + + def close(self): + if not self.stream: + return + + try: + self.stream.close() + self.stream = None + except Exception: + pass + From 06978bd8d22e5fec98be88fd6efd70c178f69b61 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 13 Mar 2016 11:17:52 -0700 Subject: [PATCH 032/112] recorder: check for empty input stream (support for direct proxy?) --- recorder/recorderapp.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index eaec579b..694228f2 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -42,11 +42,11 @@ class RecorderApp(object): self._write_one() def _write_one(self): + req = None + resp = None try: result = self.write_queue.get() - req = None - resp = None req_head, req_pay, resp_head, resp_pay, params = result if not self.rx_accept_colls.match(resp_head.get('WebAgg-Source-Coll', '')): @@ -74,7 +74,6 @@ class RecorderApp(object): payload.seek(0) warc_headers = req_headers - status_headers = self.parser.parse(payload) record = ArcWarcRecord('warc', type_, warc_headers, payload, @@ -113,11 +112,14 @@ class RecorderApp(object): params = dict(parse_qsl(environ.get('QUERY_STRING'))) req_stream = ReqWrapper(input_buff, headers) + data = None + if input_buff: + data = req_stream try: res = requests.request(url=self.upstream_host + request_uri, method=method, - data=req_stream, + data=data, headers=headers, allow_redirects=False, stream=True) From 8dc59ef6bd7706d26d0f1d1e84ca1994c536d176 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 13 Mar 2016 16:53:39 -0700 Subject: [PATCH 033/112] webagg: add test for live server config --- webagg/test/live.ini | 17 +++++++++++++++++ webagg/test/live.py | 4 ++++ 2 files changed, 21 insertions(+) create mode 100644 webagg/test/live.ini create mode 100644 webagg/test/live.py diff --git a/webagg/test/live.ini b/webagg/test/live.ini new file mode 100644 index 00000000..c4e4e10c --- /dev/null +++ b/webagg/test/live.ini @@ -0,0 +1,17 @@ +[uwsgi] +if-not-env = PORT +http-socket = :8080 +endif = + +master = true +buffer-size = 65536 +die-on-term = true + +if-env = VIRTUAL_ENV +venv = $(VIRTUAL_ENV) +endif = + +gevent = 100 +gevent-early-monkey-patch = + +wsgi = webagg.test.live diff --git a/webagg/test/live.py b/webagg/test/live.py new file mode 100644 index 00000000..21aa73f7 --- /dev/null +++ b/webagg/test/live.py @@ -0,0 +1,4 @@ +from webagg.test.testutils import LiveServerTests + +application = LiveServerTests.make_live_app() + From 58e8c709aa62cd6f2aed17465790eddb0216e1e0 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 14 Mar 2016 15:53:04 -0700 Subject: [PATCH 034/112] docker: add initial docker-compose, webagg Dockerfile --- docker-compose.yml | 19 +++++++++++++++++++ webagg/Dockerfile | 14 ++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 docker-compose.yml create mode 100644 webagg/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..463ec243 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '2' + +services: + proxy: + build: ./proxy/ + links: + - webagg:webagg + + environment: + - "WEBAGG=http://webrecplatform_webagg_1:8080" + + ports: + - 9080:9080 + + volumes: + - ${HOME}/.mitmproxy/:/root/.mitmproxy/ + + webagg: + build: ./webagg/ diff --git a/webagg/Dockerfile b/webagg/Dockerfile new file mode 100644 index 00000000..9dc3c623 --- /dev/null +++ b/webagg/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.5 + +WORKDIR /code/ + +RUN pip install -U git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.30.0-develop +RUN pip install uwsgi gevent bottle + +ADD . /code/webagg/ +ADD ./test/ /code/test/ + +WORKDIR /code/ +CMD uwsgi /code/test/live.ini + + From cba8e4ee3a6ba82c9c80efbbfcb00cdc36c2f3b0 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 17 Mar 2016 18:22:26 -0700 Subject: [PATCH 035/112] filters: more functional filter impl for header exclusion --- recorder/filters.py | 17 +++++++++++++++++ recorder/test/test_recorder.py | 4 +++- recorder/warcrecorder.py | 15 +++++++++------ 3 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 recorder/filters.py diff --git a/recorder/filters.py b/recorder/filters.py new file mode 100644 index 00000000..809822d4 --- /dev/null +++ b/recorder/filters.py @@ -0,0 +1,17 @@ + + +# ============================================================================ +class ExcludeNone(object): + def __call__(self, record): + return None + + +# ============================================================================ +class ExcludeSpecificHeaders(object): + def __init__(self, exclude_headers=[]): + self.exclude_headers = [x.lower() for x in exclude_headers] + + def __call__(self, record): + return self.exclude_headers + + diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index f5dbdaf7..7838830b 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -12,6 +12,7 @@ from mock import patch from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer from recorder.warcrecorder import PerRecordWARCRecorder +from recorder.filters import ExcludeSpecificHeaders from webagg.utils import MementoUtils @@ -128,8 +129,9 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): def test_record_cookies_skip_header(self): base_path = to_path(self.root_dir + '/warcs/cookieskip/') + header_filter = ExcludeSpecificHeaders(['Set-Cookie', 'Cookie']) recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(base_path, exclude_headers=['Set-Cookie', 'Cookie']), + PerRecordWARCRecorder(base_path, header_filter=header_filter), accept_colls='live') resp = self._test_per_warc(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') diff --git a/recorder/warcrecorder.py b/recorder/warcrecorder.py index e75bff05..a17cd670 100644 --- a/recorder/warcrecorder.py +++ b/recorder/warcrecorder.py @@ -17,6 +17,8 @@ from pywb.utils.bufferedreaders import BufferedReader from webagg.utils import ParamFormatter +from recorder.filters import ExcludeNone + # ============================================================================ class BaseWARCRecorder(object): @@ -29,14 +31,14 @@ class BaseWARCRecorder(object): REVISIT_PROFILE = 'http://netpreserve.org/warc/1.0/revisit/uri-agnostic-identical-payload-digest' + BUFF_SIZE = 8192 + def __init__(self, gzip=True, dedup_index=None, name='recorder', - exclude_headers=None): + header_filter=ExcludeNone()): self.gzip = gzip self.dedup_index = dedup_index self.rec_source_name = name - self.exclude_headers = exclude_headers - if self.exclude_headers: - self.exclude_headers = [x.lower() for x in self.exclude_headers] + self.header_filter = header_filter def ensure_digest(self, record): block_digest = record.rec_headers.get('WARC-Block-Digest') @@ -52,7 +54,7 @@ class BaseWARCRecorder(object): block_digester.update(record.status_headers.headers_buff) while True: - buf = record.stream.read(8192) + buf = record.stream.read(self.BUFF_SIZE) if not buf: break @@ -67,7 +69,8 @@ class BaseWARCRecorder(object): return Digester('sha1') def _set_header_buff(self, record): - buff = record.status_headers.to_bytes(self.exclude_headers) + exclude_list = self.header_filter(record) + buff = record.status_headers.to_bytes(exclude_list) record.status_headers.headers_buff = buff def write_req_resp(self, req, resp, params): From b64be0dff1c266a0865d6cd307ad356bab949007 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 18 Mar 2016 15:28:24 -0700 Subject: [PATCH 036/112] recorder: add tests for single file writer, including file locking dedup policy: support customizable dedup/skip/write policy plugins and add tests --- recorder/filters.py | 23 +++++ recorder/recorderapp.py | 7 +- recorder/redisindexer.py | 22 ++--- recorder/test/test_recorder.py | 151 ++++++++++++++++++++++++++++++--- recorder/warcrecorder.py | 80 +++++++++++------ 5 files changed, 229 insertions(+), 54 deletions(-) diff --git a/recorder/filters.py b/recorder/filters.py index 809822d4..3635a5ab 100644 --- a/recorder/filters.py +++ b/recorder/filters.py @@ -1,5 +1,8 @@ +from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_iso_date +# ============================================================================ +# Header Exclusions # ============================================================================ class ExcludeNone(object): def __call__(self, record): @@ -15,3 +18,23 @@ class ExcludeSpecificHeaders(object): return self.exclude_headers +# ============================================================================ +# Revisit Policy +# ============================================================================ +class WriteRevisitDupePolicy(object): + def __call__(self, cdx): + dt = timestamp_to_datetime(cdx['timestamp']) + return ('revisit', cdx['url'], datetime_to_iso_date(dt)) + + +# ============================================================================ +class SkipDupePolicy(object): + def __call__(self, cdx): + return 'skip' + + +# ============================================================================ +class WriteDupePolicy(object): + def __call__(self, cdx): + return 'write' + diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 694228f2..fc16ac9f 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -39,7 +39,10 @@ class RecorderApp(object): def _write_loop(self): while True: - self._write_one() + try: + self._write_one() + except: + traceback.print_exc() def _write_one(self): req = None @@ -56,8 +59,6 @@ class RecorderApp(object): resp = self._create_resp_record(resp_head, resp_pay, 'response') self.writer.write_req_resp(req, resp, params) - except: - traceback.print_exc() finally: try: diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index ce359672..f864b51a 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -1,8 +1,7 @@ from pywb.utils.canonicalize import calc_search_range from pywb.cdx.cdxobject import CDXObject from pywb.warc.cdxindexer import write_cdx_index -from pywb.utils.timeutils import timestamp_to_datetime -from pywb.utils.timeutils import datetime_to_iso_date, iso_date_to_timestamp +from pywb.utils.timeutils import iso_date_to_timestamp from io import BytesIO import os @@ -11,24 +10,25 @@ from webagg.indexsource import RedisIndexSource from webagg.aggregator import SimpleAggregator from webagg.utils import res_template +from recorder.filters import WriteRevisitDupePolicy + #============================================================================== class WritableRedisIndexer(RedisIndexSource): - def __init__(self, redis_url, rel_path_template='', name='recorder'): + def __init__(self, redis_url, rel_path_template='', name='recorder', + dupe_policy=WriteRevisitDupePolicy()): super(WritableRedisIndexer, self).__init__(redis_url) self.cdx_lookup = SimpleAggregator({name: self}) self.rel_path_template = rel_path_template + self.dupe_policy = dupe_policy - def add_record(self, stream, params, filename=None): - if not filename and hasattr(stream, 'name'): - filename = stream.name - + def index_records(self, stream, params, filename=None): rel_path = res_template(self.rel_path_template, params) filename = os.path.relpath(filename, rel_path) cdxout = BytesIO() write_cdx_index(cdxout, stream, filename, - cdxj=True, append_post=True, rel_root=rel_path) + cdxj=True, append_post=True) z_key = res_template(self.redis_key_template, params) @@ -55,8 +55,8 @@ class WritableRedisIndexer(RedisIndexSource): cdx_iter, errs = self.cdx_lookup(params) for cdx in cdx_iter: - dt = timestamp_to_datetime(cdx['timestamp']) - return ('revisit', cdx['url'], - datetime_to_iso_date(dt)) + res = self.dupe_policy(cdx) + if res: + return res return None diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 7838830b..d83f8773 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -8,11 +8,12 @@ import webtest from fakeredis import FakeStrictRedis from mock import patch +from pytest import raises from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer -from recorder.warcrecorder import PerRecordWARCRecorder -from recorder.filters import ExcludeSpecificHeaders +from recorder.warcrecorder import PerRecordWARCRecorder, SingleFileWARCRecorder +from recorder.filters import ExcludeSpecificHeaders, SkipDupePolicy, WriteDupePolicy from webagg.utils import MementoUtils @@ -20,9 +21,11 @@ from pywb.cdx.cdxobject import CDXObject from pywb.utils.statusandheaders import StatusAndHeadersParser from pywb.utils.bufferedreaders import DecompressingBufferedReader from pywb.warc.recordloader import ArcWarcRecordLoader +from pywb.warc.cdxindexer import write_cdx_index from six.moves.urllib.parse import quote, unquote from io import BytesIO +import time general_req_data = "\ GET {path} HTTP/1.1\r\n\ @@ -45,7 +48,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) - def _test_per_warc(self, recorder_app, host, path, other_params=''): + def _test_warc_write(self, recorder_app, host, path, other_params=''): url = 'http://' + host + path req_url = '/live/resource/postreq?url=' + url + other_params testapp = webtest.TestApp(recorder_app) @@ -72,7 +75,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): recorder_app = RecorderApp(self.upstream_url, PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/'))) - resp = self._test_per_warc(recorder_app, 'httpbin.org', '/get?foo=bar') + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -82,7 +85,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): recorder_app = RecorderApp(self.upstream_url, PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='live') - resp = self._test_per_warc(recorder_app, 'httpbin.org', '/get?foo=bar') + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -105,7 +108,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): recorder_app = RecorderApp(self.upstream_url, PerRecordWARCRecorder(base_path), accept_colls='live') - resp = self._test_per_warc(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') assert b'HTTP/1.1 302' in resp.body buff = BytesIO(resp.body) @@ -134,7 +137,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): PerRecordWARCRecorder(base_path, header_filter=header_filter), accept_colls='live') - resp = self._test_per_warc(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') assert b'HTTP/1.1 302' in resp.body buff = BytesIO(resp.body) @@ -161,7 +164,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): recorder_app = RecorderApp(self.upstream_url, writer=PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='not-live') - resp = self._test_per_warc(recorder_app, 'httpbin.org', '/get?foo=bar') + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -181,7 +184,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): self._test_all_warcs('/warcs/', 4) - resp = self._test_per_warc(recorder_app, 'httpbin.org', + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -190,7 +193,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): r = FakeStrictRedis.from_url('redis://localhost/2') - res = r.zrange('USER:COLL:cdxj', 0, -1) + res = r.zrangebylex('USER:COLL:cdxj', '[org,httpbin)/', '(org,httpbin,') assert len(res) == 1 cdx = CDXObject(res[0]) @@ -214,7 +217,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): self._test_all_warcs('/warcs/', 4) - resp = self._test_per_warc(recorder_app, 'httpbin.org', + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body @@ -224,7 +227,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): # Test Redis CDX r = FakeStrictRedis.from_url('redis://localhost/2') - res = r.zrange('USER:COLL:cdxj', 0, -1) + res = r.zrangebylex('USER:COLL:cdxj', '[org,httpbin)/', '(org,httpbin,') assert len(res) == 2 cdx = CDXObject(res[1]) @@ -246,5 +249,129 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert status_headers.get_header('WARC-Refers-To-Target-URI') == 'http://httpbin.org/get?foo=bar' assert status_headers.get_header('WARC-Refers-To-Date') != '' + @patch('redis.StrictRedis', FakeStrictRedis) + def test_record_param_user_coll_skip(self): + warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') + + + dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', + rel_path_template=self.root_dir + '/warcs/', + dupe_policy=SkipDupePolicy()) + + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + + # No new entries written + self._test_all_warcs('/warcs/', 4) + + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/USER/COLL/', 4) + + # Test Redis CDX + r = FakeStrictRedis.from_url('redis://localhost/2') + + res = r.zrangebylex('USER:COLL:cdxj', '[org,httpbin)/', '(org,httpbin,') + assert len(res) == 2 + + @patch('redis.StrictRedis', FakeStrictRedis) + def test_record_param_user_coll_write_dupe_no_revisit(self): + + warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') + + + dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', + rel_path_template=self.root_dir + '/warcs/', + dupe_policy=WriteDupePolicy()) + + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/USER/COLL/', 6) + + r = FakeStrictRedis.from_url('redis://localhost/2') + + res = r.zrangebylex('USER:COLL:cdxj', '[org,httpbin)/', '(org,httpbin,') + assert len(res) == 3 + + mimes = [CDXObject(x)['mime'] for x in res] + + assert sorted(mimes) == ['application/json', 'application/json', 'warc/revisit'] + + # Single File + def test_record_single_file_warc_1(self): + path = to_path(self.root_dir + '/warcs/A.warc.gz') + recorder_app = RecorderApp(self.upstream_url, + SingleFileWARCRecorder(path)) + + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + assert os.path.isfile(path) + + + @patch('redis.StrictRedis', FakeStrictRedis) + def test_record_single_file_multiple_writes(self): + warc_path = to_path(self.root_dir + '/warcs/FOO/rec-test.warc.gz') + + rel_path = self.root_dir + '/warcs/' + + dedup_index = WritableRedisIndexer('redis://localhost/2/{coll}:cdxj', + rel_path_template=rel_path) + + writer = SingleFileWARCRecorder(warc_path, dedup_index=dedup_index) + recorder_app = RecorderApp(self.upstream_url, writer) + + # First Record + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.coll=FOO') + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + + # Second Record + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?boo=far', '¶m.recorder.coll=FOO') + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"boo": "far"' in resp.body + + self._test_all_warcs('/warcs/FOO/', 1) + + r = FakeStrictRedis.from_url('redis://localhost/2') + res = r.zrangebylex('FOO:cdxj', '[org,httpbin)/', '(org,httpbin,') + assert len(res) == 2 + + assert os.path.isfile(warc_path) + + cdxout = BytesIO() + with open(warc_path, 'rb') as fh: + filename = os.path.relpath(warc_path, rel_path) + write_cdx_index(cdxout, fh, filename, + cdxj=True, append_post=True, sort=True) + + res = [CDXObject(x) for x in res] + + cdxres = cdxout.getvalue().strip() + cdxres = cdxres.split(b'\n') + cdxres = [CDXObject(x) for x in cdxres] + + assert cdxres == res + + writer.close() + + with raises(OSError): + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?boo=far', '¶m.recorder.coll=FOO') diff --git a/recorder/warcrecorder.py b/recorder/warcrecorder.py index a17cd670..edf17c79 100644 --- a/recorder/warcrecorder.py +++ b/recorder/warcrecorder.py @@ -8,12 +8,18 @@ import sys import os import six + import traceback from collections import OrderedDict +from socket import gethostname + +import fcntl + from pywb.utils.loaders import LimitReader, to_native_str from pywb.utils.bufferedreaders import BufferedReader +from pywb.utils.timeutils import timestamp20_now from webagg.utils import ParamFormatter @@ -99,14 +105,15 @@ class BaseWARCRecorder(object): print('Skipping due to dedup') return - self._do_write_req_resp(req, resp, params) + formatter = ParamFormatter(params, name=self.rec_source_name) + self._do_write_req_resp(req, resp, params, formatter) def _check_revisit(self, record, params): if not self.dedup_index: return record try: - url = record.rec_headers.get('WARC-Target-URI') + url = record.rec_headers.get('WARC-Target-Uri') digest = record.rec_headers.get('WARC-Payload-Digest') iso_dt = record.rec_headers.get('WARC-Date') result = self.dedup_index.lookup_revisit(params, digest, url, iso_dt) @@ -221,33 +228,47 @@ class Digester(object): # ============================================================================ class SingleFileWARCRecorder(BaseWARCRecorder): - def __init__(self, warcfilename, *args, **kwargs): + def __init__(self, filename, *args, **kwargs): super(SingleFileWARCRecorder, self).__init__(*args, **kwargs) - self.warcfilename = warcfilename + self.filename = filename.format(timestamp=timestamp20_now(), + host=gethostname()) - def _do_write_req_resp(self, req, resp, params): - print('Writing {0} to {1} '.format(url, self.warcfilename)) - with open(self.warcfilename, 'a+b') as out: - start = out.tell() + try: + os.makedirs(os.path.dirname(self.filename)) + except: + pass - self._write_warc_record(out, resp) - self._write_warc_record(out, req) + self._fh = open(self.filename, 'a+b') - out.flush() - out.seek(start) + fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB) - if self.dedup_index: - self.dedup_index.add_record(out, params, filename=self.warcfilename) + def _do_write_req_resp(self, req, resp, params, formatter): + url = resp.rec_headers.get('WARC-Target-Uri') + print('Writing {0} to {1} '.format(url, self.filename)) - def add_user_record(self, url, content_type, data): - with open(self.warcfilename, 'a+b') as out: - start = out.tell() - self._write_warc_metadata(out, url, content_type, data) - out.flush() + out = self._fh + if not out: + raise IOError('Already closed') - #out.seek(start) - #if self.indexer: - # self.indexer.add_record(out, self.warcfilename) + start = out.tell() + + self._write_warc_record(out, resp) + self._write_warc_record(out, req) + + out.flush() + out.seek(start) + + if self.dedup_index: + self.dedup_index.index_records(out, params, filename=self.filename) + + def close(self): + if not self._fh: + return None + + fcntl.flock(self._fh, fcntl.LOCK_UN) + + self._fh.close() + self._fh = None # ============================================================================ @@ -256,11 +277,10 @@ class PerRecordWARCRecorder(BaseWARCRecorder): super(PerRecordWARCRecorder, self).__init__(*args, **kwargs) self.warcdir = warcdir - def _do_write_req_resp(self, req, resp, params): + def _do_write_req_resp(self, req, resp, params, formatter): resp_uuid = resp.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') - formatter = ParamFormatter(params, name=self.rec_source_name) full_dir = formatter.format(self.warcdir) try: @@ -271,14 +291,18 @@ class PerRecordWARCRecorder(BaseWARCRecorder): resp_filename = os.path.join(full_dir, resp_uuid + '.warc.gz') req_filename = os.path.join(full_dir, req_uuid + '.warc.gz') - self._write_record(resp_filename, resp, params, True) - self._write_record(req_filename, req, params, False) + url = resp.rec_headers.get('WARC-Target-Uri') + print('Writing request for {0} to {1}'.format(url, req_filename)) + print('Writing response for {0} to {1}'.format(url, resp_filename)) - def _write_record(self, filename, rec, params, index=False): + self._write_and_index(resp_filename, resp, params, True) + self._write_and_index(req_filename, req, params, False) + + def _write_and_index(self, filename, rec, params, index=False): with open(filename, 'w+b') as out: self._write_warc_record(out, rec) if index and self.dedup_index: out.seek(0) - self.dedup_index.add_record(out, params, filename=filename) + self.dedup_index.index_records(out, params, filename=filename) From e81457df5fb4a0d42b8335f125988ff76dda757e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 18 Mar 2016 19:49:14 -0700 Subject: [PATCH 037/112] rename WARCRecorder -> WARCWriter, add optional max_size to single warc recorder per-record recorder combines http response/req into single file --- recorder/recorderapp.py | 3 - recorder/test/test_recorder.py | 78 ++++++------- recorder/{warcrecorder.py => warcwriter.py} | 118 ++++++++++++-------- 3 files changed, 111 insertions(+), 88 deletions(-) rename recorder/{warcrecorder.py => warcwriter.py} (79%) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index fc16ac9f..818b0eed 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -6,9 +6,6 @@ from pywb.utils.statusandheaders import StatusAndHeadersParser from pywb.warc.recordloader import ArcWarcRecord from pywb.warc.recordloader import ArcWarcRecordLoader -from recorder.warcrecorder import SingleFileWARCRecorder, PerRecordWARCRecorder -from recorder.redisindexer import WritableRedisIndexer - from six.moves.urllib.parse import parse_qsl import json diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index d83f8773..01648a82 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -12,7 +12,7 @@ from pytest import raises from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer -from recorder.warcrecorder import PerRecordWARCRecorder, SingleFileWARCRecorder +from recorder.warcwriter import PerRecordWARCWriter, SingleFileWARCWriter from recorder.filters import ExcludeSpecificHeaders, SkipDupePolicy, WriteDupePolicy from webagg.utils import MementoUtils @@ -70,10 +70,21 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): files = [x for x in os.listdir(coll_dir) if os.path.isfile(os.path.join(coll_dir, x))] assert len(files) == num assert all(x.endswith('.warc.gz') for x in files) + return files, coll_dir def test_record_warc_1(self): recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/'))) + PerRecordWARCWriter(to_path(self.root_dir + '/warcs/'))) + + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs/', 1) + + def test_record_warc_2(self): + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCWriter(to_path(self.root_dir + '/warcs/')), accept_colls='live') resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body @@ -81,19 +92,9 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): self._test_all_warcs('/warcs/', 2) - def test_record_warc_2(self): - recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='live') - - resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') - assert b'HTTP/1.1 200 OK' in resp.body - assert b'"foo": "bar"' in resp.body - - self._test_all_warcs('/warcs/', 4) - def test_error_url(self): recorder_app = RecorderApp(self.upstream_url + '01', - PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='live') + PerRecordWARCWriter(to_path(self.root_dir + '/warcs/')), accept_colls='live') testapp = webtest.TestApp(recorder_app) @@ -101,12 +102,12 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert resp.json['error'] != '' - self._test_all_warcs('/warcs/', 4) + self._test_all_warcs('/warcs/', 2) def test_record_cookies_header(self): base_path = to_path(self.root_dir + '/warcs/cookiecheck/') recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(base_path), accept_colls='live') + PerRecordWARCWriter(base_path), accept_colls='live') resp = self._test_warc_write(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') assert b'HTTP/1.1 302' in resp.body @@ -134,7 +135,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): base_path = to_path(self.root_dir + '/warcs/cookieskip/') header_filter = ExcludeSpecificHeaders(['Set-Cookie', 'Cookie']) recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(base_path, header_filter=header_filter), + PerRecordWARCWriter(base_path, header_filter=header_filter), accept_colls='live') resp = self._test_warc_write(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') @@ -162,13 +163,13 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): def test_record_skip_wrong_coll(self): recorder_app = RecorderApp(self.upstream_url, - writer=PerRecordWARCRecorder(to_path(self.root_dir + '/warcs/')), accept_colls='not-live') + writer=PerRecordWARCWriter(to_path(self.root_dir + '/warcs/')), accept_colls='not-live') resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body - self._test_all_warcs('/warcs/', 4) + self._test_all_warcs('/warcs/', 2) @patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll(self): @@ -180,16 +181,16 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): rel_path_template=self.root_dir + '/warcs/') recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) - self._test_all_warcs('/warcs/', 4) + self._test_all_warcs('/warcs/', 2) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body - self._test_all_warcs('/warcs/USER/COLL/', 2) + self._test_all_warcs('/warcs/USER/COLL/', 1) r = FakeStrictRedis.from_url('redis://localhost/2') @@ -213,16 +214,16 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): rel_path_template=self.root_dir + '/warcs/') recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) - self._test_all_warcs('/warcs/', 4) + self._test_all_warcs('/warcs/', 2) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body - self._test_all_warcs('/warcs/USER/COLL/', 4) + self._test_all_warcs('/warcs/USER/COLL/', 2) # Test Redis CDX r = FakeStrictRedis.from_url('redis://localhost/2') @@ -259,17 +260,17 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): dupe_policy=SkipDupePolicy()) recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) # No new entries written - self._test_all_warcs('/warcs/', 4) + self._test_all_warcs('/warcs/', 2) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body - self._test_all_warcs('/warcs/USER/COLL/', 4) + self._test_all_warcs('/warcs/USER/COLL/', 2) # Test Redis CDX r = FakeStrictRedis.from_url('redis://localhost/2') @@ -288,14 +289,14 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): dupe_policy=WriteDupePolicy()) recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCRecorder(warc_path, dedup_index=dedup_index)) + PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body - self._test_all_warcs('/warcs/USER/COLL/', 6) + self._test_all_warcs('/warcs/USER/COLL/', 3) r = FakeStrictRedis.from_url('redis://localhost/2') @@ -310,7 +311,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): def test_record_single_file_warc_1(self): path = to_path(self.root_dir + '/warcs/A.warc.gz') recorder_app = RecorderApp(self.upstream_url, - SingleFileWARCRecorder(path)) + SingleFileWARCWriter(path)) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body @@ -321,14 +322,14 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): @patch('redis.StrictRedis', FakeStrictRedis) def test_record_single_file_multiple_writes(self): - warc_path = to_path(self.root_dir + '/warcs/FOO/rec-test.warc.gz') + warc_path = to_path(self.root_dir + '/warcs/FOO/rec-{hostname}-{timestamp}.warc.gz') rel_path = self.root_dir + '/warcs/' dedup_index = WritableRedisIndexer('redis://localhost/2/{coll}:cdxj', rel_path_template=rel_path) - writer = SingleFileWARCRecorder(warc_path, dedup_index=dedup_index) + writer = SingleFileWARCWriter(warc_path, dedup_index=dedup_index) recorder_app = RecorderApp(self.upstream_url, writer) # First Record @@ -352,11 +353,12 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): res = r.zrangebylex('FOO:cdxj', '[org,httpbin)/', '(org,httpbin,') assert len(res) == 2 - assert os.path.isfile(warc_path) + files, coll_dir = self._test_all_warcs('/warcs/FOO/', 1) + fullname = coll_dir + files[0] cdxout = BytesIO() - with open(warc_path, 'rb') as fh: - filename = os.path.relpath(warc_path, rel_path) + with open(fullname, 'rb') as fh: + filename = os.path.relpath(fullname, rel_path) write_cdx_index(cdxout, fh, filename, cdxj=True, append_post=True, sort=True) @@ -368,10 +370,10 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert cdxres == res + # close this file writer.close() - with raises(OSError): - resp = self._test_warc_write(recorder_app, 'httpbin.org', + resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?boo=far', '¶m.recorder.coll=FOO') - + self._test_all_warcs('/warcs/FOO/', 2) diff --git a/recorder/warcrecorder.py b/recorder/warcwriter.py similarity index 79% rename from recorder/warcrecorder.py rename to recorder/warcwriter.py index edf17c79..790c9faf 100644 --- a/recorder/warcrecorder.py +++ b/recorder/warcwriter.py @@ -27,7 +27,7 @@ from recorder.filters import ExcludeNone # ============================================================================ -class BaseWARCRecorder(object): +class BaseWARCWriter(object): WARC_RECORDS = {'warcinfo': 'application/warc-fields', 'response': 'application/http; msgtype=response', 'revisit': 'application/http; msgtype=response', @@ -45,6 +45,7 @@ class BaseWARCRecorder(object): self.dedup_index = dedup_index self.rec_source_name = name self.header_filter = header_filter + self.hostname = gethostname() def ensure_digest(self, record): block_digest = record.rec_headers.get('WARC-Block-Digest') @@ -135,7 +136,7 @@ class BaseWARCRecorder(object): def _write_warc_record(self, out, record): if self.gzip: - out = GzippingWriter(out) + out = GzippingWrapper(out) self._line(out, b'WARC/1.0') @@ -196,7 +197,7 @@ class BaseWARCRecorder(object): # ============================================================================ -class GzippingWriter(object): +class GzippingWrapper(object): def __init__(self, out): self.compressor = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS + 16) self.out = out @@ -227,11 +228,63 @@ class Digester(object): # ============================================================================ -class SingleFileWARCRecorder(BaseWARCRecorder): - def __init__(self, filename, *args, **kwargs): - super(SingleFileWARCRecorder, self).__init__(*args, **kwargs) - self.filename = filename.format(timestamp=timestamp20_now(), - host=gethostname()) +class PerRecordWARCWriter(BaseWARCWriter): + DEF_TEMPLATE = 'rec-{timestamp}-{hostname}.warc.gz' + + def __init__(self, warcdir, filename_template=None, *args, **kwargs): + super(PerRecordWARCWriter, self).__init__(*args, **kwargs) + if not filename_template: + filename_template = self.DEF_TEMPLATE + self.filename_template = warcdir + filename_template + + def _do_write_req_resp(self, req, resp, params, formatter): + #resp_uuid = resp.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') + #req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') + timestamp = timestamp20_now() + + filename = formatter.format(self.filename_template, + hostname=self.hostname, + timestamp=timestamp) + + path, name = os.path.split(filename) + + try: + os.makedirs(path) + except: + pass + + url = resp.rec_headers.get('WARC-Target-Uri') + print('Writing resp/req for {0} to {1}'.format(url, filename)) + + with open(filename, 'a+b') as out: + start = out.tell() + + self._write_warc_record(out, resp) + self._write_warc_record(out, req) + + out.flush() + out.seek(start) + + if self.dedup_index: + self.dedup_index.index_records(out, params, filename=filename) + + +# ============================================================================ +class SingleFileWARCWriter(BaseWARCWriter): + def __init__(self, filename_template, dir_prefix='', max_size=0, *args, **kwargs): + super(SingleFileWARCWriter, self).__init__(*args, **kwargs) + self.dir_prefix = dir_prefix + self.filename_template = filename_template + self.max_size = max_size + self._open_file() + + def _open_file(self): + timestamp = timestamp20_now() + + filename = self.filename_template.format(hostname=self.hostname, + timestamp=timestamp) + + self.filename = self.dir_prefix + filename try: os.makedirs(os.path.dirname(self.filename)) @@ -246,9 +299,10 @@ class SingleFileWARCRecorder(BaseWARCRecorder): url = resp.rec_headers.get('WARC-Target-Uri') print('Writing {0} to {1} '.format(url, self.filename)) + if not self._fh: + self._open_file() + out = self._fh - if not out: - raise IOError('Already closed') start = out.tell() @@ -256,11 +310,18 @@ class SingleFileWARCRecorder(BaseWARCRecorder): self._write_warc_record(out, req) out.flush() + + new_size = out.tell() + out.seek(start) if self.dedup_index: self.dedup_index.index_records(out, params, filename=self.filename) + # check for rollover + if self.max_size and new_size > self.max_size: + self.close() + def close(self): if not self._fh: return None @@ -269,40 +330,3 @@ class SingleFileWARCRecorder(BaseWARCRecorder): self._fh.close() self._fh = None - - -# ============================================================================ -class PerRecordWARCRecorder(BaseWARCRecorder): - def __init__(self, warcdir, *args, **kwargs): - super(PerRecordWARCRecorder, self).__init__(*args, **kwargs) - self.warcdir = warcdir - - def _do_write_req_resp(self, req, resp, params, formatter): - resp_uuid = resp.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') - req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') - - full_dir = formatter.format(self.warcdir) - - try: - os.makedirs(full_dir) - except: - pass - - resp_filename = os.path.join(full_dir, resp_uuid + '.warc.gz') - req_filename = os.path.join(full_dir, req_uuid + '.warc.gz') - - url = resp.rec_headers.get('WARC-Target-Uri') - print('Writing request for {0} to {1}'.format(url, req_filename)) - print('Writing response for {0} to {1}'.format(url, resp_filename)) - - self._write_and_index(resp_filename, resp, params, True) - self._write_and_index(req_filename, req, params, False) - - def _write_and_index(self, filename, rec, params, index=False): - with open(filename, 'w+b') as out: - self._write_warc_record(out, rec) - if index and self.dedup_index: - out.seek(0) - self.dedup_index.index_records(out, params, filename=filename) - - From 3452cf39e03e9921c996eb7067a331e46503d608 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 18 Mar 2016 21:40:41 -0700 Subject: [PATCH 038/112] recorder: use more general MultiFileWARCWriter, supporting both keeping file open and one-warc-per record use cases --- recorder/test/test_recorder.py | 33 ++++--- recorder/warcwriter.py | 155 +++++++++++++++++---------------- 2 files changed, 100 insertions(+), 88 deletions(-) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 01648a82..1941860e 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -12,7 +12,7 @@ from pytest import raises from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer -from recorder.warcwriter import PerRecordWARCWriter, SingleFileWARCWriter +from recorder.warcwriter import PerRecordWARCWriter, MultiFileWARCWriter from recorder.filters import ExcludeSpecificHeaders, SkipDupePolicy, WriteDupePolicy from webagg.utils import MementoUtils @@ -288,8 +288,8 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): rel_path_template=self.root_dir + '/warcs/', dupe_policy=WriteDupePolicy()) - recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) + writer = PerRecordWARCWriter(warc_path, dedup_index=dedup_index) + recorder_app = RecorderApp(self.upstream_url, writer) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar', '¶m.recorder.user=USER¶m.recorder.coll=COLL') @@ -307,29 +307,31 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert sorted(mimes) == ['application/json', 'application/json', 'warc/revisit'] - # Single File - def test_record_single_file_warc_1(self): + assert len(writer.fh_cache) == 0 + + # Keep Open + def test_record_file_warc_keep_open(self): path = to_path(self.root_dir + '/warcs/A.warc.gz') - recorder_app = RecorderApp(self.upstream_url, - SingleFileWARCWriter(path)) + writer = MultiFileWARCWriter(path) + recorder_app = RecorderApp(self.upstream_url, writer) resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?foo=bar') assert b'HTTP/1.1 200 OK' in resp.body assert b'"foo": "bar"' in resp.body assert os.path.isfile(path) - + assert len(writer.fh_cache) == 1 @patch('redis.StrictRedis', FakeStrictRedis) - def test_record_single_file_multiple_writes(self): - warc_path = to_path(self.root_dir + '/warcs/FOO/rec-{hostname}-{timestamp}.warc.gz') + def test_record_multiple_writes_keep_open(self): + warc_path = to_path(self.root_dir + '/warcs/FOO/ABC-{hostname}-{timestamp}.warc.gz') rel_path = self.root_dir + '/warcs/' dedup_index = WritableRedisIndexer('redis://localhost/2/{coll}:cdxj', rel_path_template=rel_path) - writer = SingleFileWARCWriter(warc_path, dedup_index=dedup_index) + writer = MultiFileWARCWriter(warc_path, dedup_index=dedup_index) recorder_app = RecorderApp(self.upstream_url, writer) # First Record @@ -370,10 +372,13 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert cdxres == res - # close this file + assert len(writer.fh_cache) == 1 + + writer.remove_file(self.root_dir + '/warcs/FOO/') + + assert len(writer.fh_cache) == 0 + writer.close() resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?boo=far', '¶m.recorder.coll=FOO') - - self._test_all_warcs('/warcs/FOO/', 2) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 790c9faf..9ae4bf9e 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -8,7 +8,6 @@ import sys import os import six - import traceback from collections import OrderedDict @@ -39,6 +38,8 @@ class BaseWARCWriter(object): BUFF_SIZE = 8192 + FILE_TEMPLATE = 'rec-{timestamp}-{hostname}.warc.gz' + def __init__(self, gzip=True, dedup_index=None, name='recorder', header_filter=ExcludeNone()): self.gzip = gzip @@ -228,105 +229,111 @@ class Digester(object): # ============================================================================ -class PerRecordWARCWriter(BaseWARCWriter): - DEF_TEMPLATE = 'rec-{timestamp}-{hostname}.warc.gz' +class MultiFileWARCWriter(BaseWARCWriter): + + def __init__(self, dir_template, filename_template=None, max_size=0, + *args, **kwargs): + super(MultiFileWARCWriter, self).__init__(*args, **kwargs) - def __init__(self, warcdir, filename_template=None, *args, **kwargs): - super(PerRecordWARCWriter, self).__init__(*args, **kwargs) if not filename_template: - filename_template = self.DEF_TEMPLATE - self.filename_template = warcdir + filename_template + dir_template, filename_template = os.path.split(dir_template) + dir_template += os.path.sep - def _do_write_req_resp(self, req, resp, params, formatter): - #resp_uuid = resp.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') - #req_uuid = req.rec_headers['WARC-Record-ID'].split(':')[-1].strip('<> ') + if not filename_template: + filename_template = self.FILE_TEMPLATE + + self.dir_template = dir_template + self.filename_template = filename_template + self.max_size = max_size + + self.fh_cache = {} + + def _open_file(self, dir_): timestamp = timestamp20_now() - filename = formatter.format(self.filename_template, - hostname=self.hostname, - timestamp=timestamp) - - path, name = os.path.split(filename) + filename = dir_ + self.filename_template.format(hostname=self.hostname, + timestamp=timestamp) try: - os.makedirs(path) + os.makedirs(os.path.dirname(filename)) except: pass - url = resp.rec_headers.get('WARC-Target-Uri') - print('Writing resp/req for {0} to {1}'.format(url, filename)) + fh = open(filename, 'a+b') + return fh, filename + + def _close_file(self, fh): + fcntl.flock(fh, fcntl.LOCK_UN) + fh.close() + + def remove_file(self, full_dir): + result = self.fh_cache.pop(full_dir, None) + if result: + out, filename = result + self._close_file(out) + + def _do_write_req_resp(self, req, resp, params, formatter): + full_dir = formatter.format(self.dir_template) + + result = self.fh_cache.get(full_dir) + + close_file = False + + if result: + out, filename = result + is_new = False + else: + out, filename = self._open_file(full_dir) + is_new = True + + try: + url = resp.rec_headers.get('WARC-Target-Uri') + print('Writing req/resp {0} to {1} '.format(url, filename)) - with open(filename, 'a+b') as out: start = out.tell() self._write_warc_record(out, resp) self._write_warc_record(out, req) out.flush() + + new_size = out.tell() + out.seek(start) if self.dedup_index: self.dedup_index.index_records(out, params, filename=filename) + except Exception as e: + traceback.print_exc() + close_file = True -# ============================================================================ -class SingleFileWARCWriter(BaseWARCWriter): - def __init__(self, filename_template, dir_prefix='', max_size=0, *args, **kwargs): - super(SingleFileWARCWriter, self).__init__(*args, **kwargs) - self.dir_prefix = dir_prefix - self.filename_template = filename_template - self.max_size = max_size - self._open_file() + finally: + # check for rollover + if self.max_size and new_size > self.max_size: + close_file = True - def _open_file(self): - timestamp = timestamp20_now() + if close_file: + if is_new: + self._close_file(out) + else: + self.remove_file(full_dir) - filename = self.filename_template.format(hostname=self.hostname, - timestamp=timestamp) - - self.filename = self.dir_prefix + filename - - try: - os.makedirs(os.path.dirname(self.filename)) - except: - pass - - self._fh = open(self.filename, 'a+b') - - fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def _do_write_req_resp(self, req, resp, params, formatter): - url = resp.rec_headers.get('WARC-Target-Uri') - print('Writing {0} to {1} '.format(url, self.filename)) - - if not self._fh: - self._open_file() - - out = self._fh - - start = out.tell() - - self._write_warc_record(out, resp) - self._write_warc_record(out, req) - - out.flush() - - new_size = out.tell() - - out.seek(start) - - if self.dedup_index: - self.dedup_index.index_records(out, params, filename=self.filename) - - # check for rollover - if self.max_size and new_size > self.max_size: - self.close() + elif is_new: + fcntl.flock(out, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.fh_cache[full_dir] = (out, filename) def close(self): - if not self._fh: - return None + for n, v in self.fh_cache.items(): + out, filename = v + self._close_file(out) - fcntl.flock(self._fh, fcntl.LOCK_UN) + self.fh_cache = {} + + +# ============================================================================ +class PerRecordWARCWriter(MultiFileWARCWriter): + def __init__(self, *args, **kwargs): + kwargs['max_size'] = 1 + super(PerRecordWARCWriter, self).__init__(*args, **kwargs) - self._fh.close() - self._fh = None From c96e419341b7ca05460e1d03d08343ae4448f909 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 19 Mar 2016 10:24:28 -0700 Subject: [PATCH 039/112] recorder: ensure filename is also tracked by the indexer, add tests for redis file mapping --- recorder/redisindexer.py | 12 +++++++++- recorder/test/test_recorder.py | 43 ++++++++++++++++++++++------------ recorder/warcwriter.py | 22 ++++++++++------- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index f864b51a..afa8a305 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -15,13 +15,23 @@ from recorder.filters import WriteRevisitDupePolicy #============================================================================== class WritableRedisIndexer(RedisIndexSource): - def __init__(self, redis_url, rel_path_template='', name='recorder', + def __init__(self, redis_url, rel_path_template='', + file_key_template='', name='recorder', dupe_policy=WriteRevisitDupePolicy()): super(WritableRedisIndexer, self).__init__(redis_url) self.cdx_lookup = SimpleAggregator({name: self}) self.rel_path_template = rel_path_template + self.file_key_template = file_key_template self.dupe_policy = dupe_policy + def add_warc_file(self, full_filename, params): + rel_path = res_template(self.rel_path_template, params) + filename = os.path.relpath(full_filename, rel_path) + + file_key = res_template(self.file_key_template, params) + + self.redis.hset(file_key, filename, full_filename) + def index_records(self, stream, params, filename=None): rel_path = res_template(self.rel_path_template, params) filename = os.path.relpath(filename, rel_path) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 1941860e..d83f3375 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -13,7 +13,8 @@ from pytest import raises from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer from recorder.warcwriter import PerRecordWARCWriter, MultiFileWARCWriter -from recorder.filters import ExcludeSpecificHeaders, SkipDupePolicy, WriteDupePolicy +from recorder.filters import ExcludeSpecificHeaders +from recorder.filters import SkipDupePolicy, WriteDupePolicy, WriteRevisitDupePolicy from webagg.utils import MementoUtils @@ -47,6 +48,13 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) + def _get_dedup_index(self, dupe_policy=WriteRevisitDupePolicy()): + dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', + file_key_template='{user}:{coll}:warc', + rel_path_template=self.root_dir + '/warcs/', + dupe_policy=dupe_policy) + + return dedup_index def _test_warc_write(self, recorder_app, host, path, other_params=''): url = 'http://' + host + path @@ -176,9 +184,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') - - dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', - rel_path_template=self.root_dir + '/warcs/') + dedup_index = self._get_dedup_index() recorder_app = RecorderApp(self.upstream_url, PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) @@ -204,14 +210,16 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert cdx['filename'].startswith('USER/COLL/') assert cdx['filename'].endswith('.warc.gz') + warcs = r.hgetall('USER:COLL:warc') + full_path = self.root_dir + '/warcs/' + cdx['filename'] + assert warcs == {cdx['filename'].encode('utf-8'): full_path.encode('utf-8')} + @patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_revisit(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') - - dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', - rel_path_template=self.root_dir + '/warcs/') + dedup_index = self._get_dedup_index() recorder_app = RecorderApp(self.upstream_url, PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) @@ -240,6 +248,10 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): fullwarc = os.path.join(self.root_dir, 'warcs', cdx['filename']) + warcs = r.hgetall('USER:COLL:warc') + assert len(warcs) == 2 + assert warcs[cdx['filename'].encode('utf-8')] == fullwarc.encode('utf-8') + with open(fullwarc, 'rb') as fh: decomp = DecompressingBufferedReader(fh) # Test refers-to headers @@ -254,10 +266,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): def test_record_param_user_coll_skip(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') - - dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', - rel_path_template=self.root_dir + '/warcs/', - dupe_policy=SkipDupePolicy()) + dedup_index = self._get_dedup_index(dupe_policy=SkipDupePolicy()) recorder_app = RecorderApp(self.upstream_url, PerRecordWARCWriter(warc_path, dedup_index=dedup_index)) @@ -283,10 +292,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') - - dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', - rel_path_template=self.root_dir + '/warcs/', - dupe_policy=WriteDupePolicy()) + dedup_index = self._get_dedup_index(dupe_policy=WriteDupePolicy()) writer = PerRecordWARCWriter(warc_path, dedup_index=dedup_index) recorder_app = RecorderApp(self.upstream_url, writer) @@ -329,8 +335,10 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): rel_path = self.root_dir + '/warcs/' dedup_index = WritableRedisIndexer('redis://localhost/2/{coll}:cdxj', + file_key_template='{coll}:warc', rel_path_template=rel_path) + writer = MultiFileWARCWriter(warc_path, dedup_index=dedup_index) recorder_app = RecorderApp(self.upstream_url, writer) @@ -382,3 +390,8 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): resp = self._test_warc_write(recorder_app, 'httpbin.org', '/get?boo=far', '¶m.recorder.coll=FOO') + + self._test_all_warcs('/warcs/FOO/', 2) + + warcs = r.hgetall('FOO:warc') + assert len(warcs) == 2 diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 9ae4bf9e..99c93e06 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -20,7 +20,7 @@ from pywb.utils.loaders import LimitReader, to_native_str from pywb.utils.bufferedreaders import BufferedReader from pywb.utils.timeutils import timestamp20_now -from webagg.utils import ParamFormatter +from webagg.utils import ParamFormatter, res_template from recorder.filters import ExcludeNone @@ -107,8 +107,8 @@ class BaseWARCWriter(object): print('Skipping due to dedup') return - formatter = ParamFormatter(params, name=self.rec_source_name) - self._do_write_req_resp(req, resp, params, formatter) + params['_formatter'] = ParamFormatter(params, name=self.rec_source_name) + self._do_write_req_resp(req, resp, params) def _check_revisit(self, record, params): if not self.dedup_index: @@ -248,18 +248,24 @@ class MultiFileWARCWriter(BaseWARCWriter): self.fh_cache = {} - def _open_file(self, dir_): + def _open_file(self, dir_, params): timestamp = timestamp20_now() filename = dir_ + self.filename_template.format(hostname=self.hostname, timestamp=timestamp) + path, name = os.path.split(filename) + try: - os.makedirs(os.path.dirname(filename)) + os.makedirs(path) except: pass fh = open(filename, 'a+b') + + if self.dedup_index: + self.dedup_index.add_warc_file(filename, params) + return fh, filename def _close_file(self, fh): @@ -272,8 +278,8 @@ class MultiFileWARCWriter(BaseWARCWriter): out, filename = result self._close_file(out) - def _do_write_req_resp(self, req, resp, params, formatter): - full_dir = formatter.format(self.dir_template) + def _do_write_req_resp(self, req, resp, params): + full_dir = res_template(self.dir_template, params) result = self.fh_cache.get(full_dir) @@ -283,7 +289,7 @@ class MultiFileWARCWriter(BaseWARCWriter): out, filename = result is_new = False else: - out, filename = self._open_file(full_dir) + out, filename = self._open_file(full_dir, params) is_new = True try: From f5ee3c7bcabc3c48d4eebc96bcb303bc3bdbfec9 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 19 Mar 2016 20:32:37 -0700 Subject: [PATCH 040/112] inputreq: add reconstruct_request() to return a bytestring of the request, add test for inputreq --- webagg/inputrequest.py | 43 +++++++++++++++++++++- webagg/test/test_handlers.py | 1 - webagg/test/test_inputreq.py | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 webagg/test/test_inputreq.py diff --git a/webagg/inputrequest.py b/webagg/inputrequest.py index f15de60b..207db397 100644 --- a/webagg/inputrequest.py +++ b/webagg/inputrequest.py @@ -4,7 +4,7 @@ from pywb.utils.statusandheaders import StatusAndHeadersParser from six.moves.urllib.parse import urlsplit, quote from six import iteritems -from io import BytesIO +from io import BytesIO, StringIO #============================================================================= @@ -15,6 +15,9 @@ class DirectWSGIInputRequest(object): def get_req_method(self): return self.env['REQUEST_METHOD'].upper() + def get_req_protocol(self): + return self.env['SERVER_PROTOCOL'] + def get_req_headers(self): headers = {} @@ -92,6 +95,38 @@ class DirectWSGIInputRequest(object): return req_uri + def reconstruct_request(self, url=None): + buff = StringIO() + buff.write(self.get_req_method()) + buff.write(' ') + buff.write(self.get_full_request_uri()) + buff.write(' ') + buff.write(self.get_req_protocol()) + buff.write('\r\n') + + headers = self.get_req_headers() + + if url: + parts = urlsplit(url) + buff.write('Host: ') + buff.write(parts.netloc) + buff.write('\r\n') + + for name, value in iteritems(headers): + buff.write(name) + buff.write(': ') + buff.write(value) + buff.write('\r\n') + + buff.write('\r\n') + buff = buff.getvalue().encode('latin-1') + + body = self.get_req_body() + if body: + buff += body.read() + + return buff + #============================================================================= class POSTInputRequest(DirectWSGIInputRequest): @@ -112,6 +147,12 @@ class POSTInputRequest(DirectWSGIInputRequest): return headers + def get_full_request_uri(self): + return self.status_headers.statusline.split(' ', 1)[0] + + def get_req_protocol(self): + return self.status_headers.statusline.split(' ', 1)[-1] + def _get_content_type(self): return self.status_headers.get_header('Content-Type') diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 5b9e510f..cefaa99d 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -16,7 +16,6 @@ from pywb.utils.bufferedreaders import ChunkedDataReader from io import BytesIO import webtest -import bottle from .testutils import to_path diff --git a/webagg/test/test_inputreq.py b/webagg/test/test_inputreq.py new file mode 100644 index 00000000..7aca5b6a --- /dev/null +++ b/webagg/test/test_inputreq.py @@ -0,0 +1,71 @@ +from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest +from bottle import Bottle, request, response +import webtest +import traceback + + +#============================================================================= +class InputReqApp(object): + def __init__(self): + self.application = Bottle() + self.application.default_error_handler = self.err_handler + + @self.application.route('/test/', 'ANY') + def direct_input_request(url=''): + inputreq = DirectWSGIInputRequest(request.environ) + response['Content-Type'] = 'text/plain; charset=utf-8' + return inputreq.reconstruct_request(url) + + @self.application.route('/test-postreq', 'POST') + def post_fullrequest(): + params = dict(request.query) + inputreq = POSTInputRequest(request.environ) + response['Content-Type'] = 'text/plain; charset=utf-8' + return inputreq.reconstruct_request(params.get('url')) + + def err_handler(self, out): + print(out) + traceback.print_exc() + + +#============================================================================= +class TestInputReq(object): + def setup(self): + self.app = InputReqApp() + self.testapp = webtest.TestApp(self.app.application) + + def test_get_direct(self): + res = self.testapp.get('/test/http://example.com/', headers={'Foo': 'Bar'}) + assert res.text == '\ +GET /test/http://example.com/ HTTP/1.0\r\n\ +Host: example.com\r\n\ +Foo: Bar\r\n\ +\r\n\ +' + + def test_post_direct(self): + res = self.testapp.post('/test/http://example.com/', headers={'Foo': 'Bar'}, params='ABC') + lines = res.text.split('\r\n') + assert lines[0] == 'POST /test/http://example.com/ HTTP/1.0' + assert 'Host: example.com' in lines + assert 'Content-Length: 3' in lines + assert 'Content-Type: application/x-www-form-urlencoded' in lines + assert 'Foo: Bar' in lines + + assert 'ABC' in lines + + def test_post_req(self): + postdata = '\ +GET /example.html HTTP/1.0\r\n\ +Foo: Bar\r\n\ +\r\n\ +' + res = self.testapp.post('/test-postreq?url=http://example.com/', params=postdata) + + assert res.text == '\ +GET /example.html HTTP/1.0\r\n\ +Host: example.com\r\n\ +Foo: Bar\r\n\ +\r\n\ +' + From 4cf935abd139ccc70430c1975ca225dea40a4e19 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 19 Mar 2016 20:34:09 -0700 Subject: [PATCH 041/112] directory agg: add CacheDirectoryAggregator to cache file listing, rescan dir only if changed --- webagg/aggregator.py | 49 ++++++++++++++++++++++++++++-------- webagg/responseloader.py | 2 +- webagg/test/test_dir_agg.py | 28 ++++++++++++++++++++- webagg/test/test_upstream.py | 2 +- webagg/test/testutils.py | 8 +++++- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/webagg/aggregator.py b/webagg/aggregator.py index 0f148492..cb7cf10a 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -221,24 +221,53 @@ class BaseDirectoryIndexSource(BaseAggregator): def _load_files(self, glob_dir): for the_dir in glob.iglob(glob_dir): - for name in os.listdir(the_dir): - filename = os.path.join(the_dir, name) + for result in self._load_files_single_dir(the_dir): + yield result - if filename.endswith(self.CDX_EXT): - print('Adding ' + filename) - rel_path = os.path.relpath(the_dir, self.base_prefix) - if rel_path == '.': - full_name = name - else: - full_name = rel_path + '/' + name + def _load_files_single_dir(self, the_dir): + for name in os.listdir(the_dir): + filename = os.path.join(the_dir, name) - yield full_name, FileIndexSource(filename) + if filename.endswith(self.CDX_EXT): + print('Adding ' + filename) + rel_path = os.path.relpath(the_dir, self.base_prefix) + if rel_path == '.': + full_name = name + else: + full_name = rel_path + '/' + name + + yield full_name, FileIndexSource(filename) def __str__(self): return 'file_dir' +#============================================================================= class DirectoryIndexSource(SeqAggMixin, BaseDirectoryIndexSource): pass +#============================================================================= +class CacheDirectoryIndexSource(DirectoryIndexSource): + def __init__(self, *args, **kwargs): + super(CacheDirectoryIndexSource, self).__init__(*args, **kwargs) + self.cached_file_list = {} + + def _load_files_single_dir(self, the_dir): + try: + stat = os.stat(the_dir) + except Exception as e: + stat = 0 + + result = self.cached_file_list.get(the_dir) + + if result: + last_stat, files = result + if stat and last_stat == stat: + print('Dir {0} unchanged'.format(the_dir)) + return files + + files = super(CacheDirectoryIndexSource, self)._load_files_single_dir(the_dir) + files = list(files) + self.cached_file_list[the_dir] = (stat, files) + return files diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 4e7aeaf3..94a1f153 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -254,7 +254,7 @@ class LiveWebLoader(BaseLoader): try: fp = upstream_res.raw._fp.fp - if hasattr(fp, 'raw'): + if hasattr(fp, 'raw'): #pragma: no cover fp = fp.raw remote_ip = fp._sock.getpeername()[0] except: #pragma: no cover diff --git a/webagg/test/test_dir_agg.py b/webagg/test/test_dir_agg.py index b55d3755..0da78bf3 100644 --- a/webagg/test/test_dir_agg.py +++ b/webagg/test/test_dir_agg.py @@ -7,7 +7,10 @@ from .testutils import to_path, to_json_list, TempDirTests, BaseTestClass from mock import patch -from webagg.aggregator import DirectoryIndexSource, SimpleAggregator +import time + +from webagg.aggregator import DirectoryIndexSource, CacheDirectoryIndexSource +from webagg.aggregator import SimpleAggregator from webagg.indexsource import MementoIndexSource @@ -44,6 +47,7 @@ class TestDirAgg(TempDirTests, BaseTestClass): fh.write('foo') cls.dir_loader = DirectoryIndexSource(dir_prefix, dir_path) + cls.cache_dir_loader = CacheDirectoryIndexSource(dir_prefix, dir_path) def test_agg_no_coll_set(self): res, errs = self.dir_loader(dict(url='example.com/')) @@ -188,3 +192,25 @@ class TestDirAgg(TempDirTests, BaseTestClass): + def test_cache_dir_sources_1(self): + exp = {'sources': {'colls/A/indexes/example.cdxj': 'file', + 'colls/B/indexes/iana.cdxj': 'file', + 'colls/C/indexes/dupes.cdxj': 'file'} + } + + res = self.cache_dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) + assert(res == exp) + + res = self.cache_dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) + assert(res == exp) + + new_file = os.path.join(self.root_dir, 'colls/C/indexes/empty.cdxj') + + with open(new_file, 'a') as fh: + os.utime(new_file) + + res = self.cache_dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) + + # New File Included + exp['sources']['colls/C/indexes/empty.cdxj'] = 'file' + assert(res == exp) diff --git a/webagg/test/test_upstream.py b/webagg/test/test_upstream.py index 037b62e9..cd107811 100644 --- a/webagg/test/test_upstream.py +++ b/webagg/test/test_upstream.py @@ -36,7 +36,7 @@ class TestUpstream(LiveServerTests, BaseTestClass): def test_live_paths(self): res = requests.get(self.base_url + '/') - assert set(res.json().keys()) == {'/live/postreq', '/live'} + assert set(res.json().keys()) == {'/live/postreq', '/live', '/replay/postreq', '/replay'} def test_upstream_paths(self): res = self.testapp.get('/') diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index 4c5c42b6..c46e17f1 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -7,7 +7,7 @@ from multiprocessing import Process from wsgiref.simple_server import make_server -from webagg.aggregator import SimpleAggregator +from webagg.aggregator import SimpleAggregator, CacheDirectoryIndexSource from webagg.app import ResAggApp from webagg.handlers import DefaultResourceHandler from webagg.indexsource import LiveIndexSource @@ -66,6 +66,12 @@ class LiveServerTests(object): {'live': LiveIndexSource()}) ) ) + app.add_route('/replay', + DefaultResourceHandler(SimpleAggregator( + {'replay': CacheDirectoryIndexSource('./testdata/')}), + './testdata/' + ) + ) return app.application @classmethod From 22ead5260437f328480e8dca124bdb579fb5b750 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 21 Mar 2016 11:04:52 -0700 Subject: [PATCH 042/112] webagg: convert StreamIter to generate, remove unused ReadFullyStream loaders: add support for RedisResolver as well as PathPrefixResolver inputreq: reconstruct_request() skips host header if already present improve test app to include replay --- webagg/inputrequest.py | 3 ++ webagg/responseloader.py | 65 ++++++++++++++---------- webagg/test/live.ini | 2 +- webagg/test/live.py | 42 +++++++++++++++- webagg/test/test_upstream.py | 2 +- webagg/test/testutils.py | 8 +-- webagg/utils.py | 95 +++++++++--------------------------- 7 files changed, 108 insertions(+), 109 deletions(-) diff --git a/webagg/inputrequest.py b/webagg/inputrequest.py index 207db397..e7f13a85 100644 --- a/webagg/inputrequest.py +++ b/webagg/inputrequest.py @@ -113,6 +113,9 @@ class DirectWSGIInputRequest(object): buff.write('\r\n') for name, value in iteritems(headers): + if name.lower() == 'host': + continue + buff.write(name) buff.write(': ') buff.write(value) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 94a1f153..33228444 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -1,4 +1,5 @@ -from webagg.utils import MementoUtils, StreamIter +from webagg.utils import MementoUtils, StreamIter, chunk_encode_iter +from webagg.indexsource import RedisIndexSource from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_timestamp from pywb.utils.timeutils import iso_date_to_datetime, datetime_to_iso_date @@ -58,7 +59,7 @@ class BaseLoader(object): if not lenset: out_headers['Transfer-Encoding'] = 'chunked' - streamiter = self._chunk_encode(streamiter) + streamiter = chunk_encode_iter(streamiter) return out_headers, streamiter @@ -76,17 +77,32 @@ class BaseLoader(object): return False - @staticmethod - def _chunk_encode(orig_iter): - for chunk in orig_iter: - if not len(chunk): - continue - chunk_len = b'%X\r\n' % len(chunk) - yield chunk_len - yield chunk - yield b'\r\n' - yield b'0\r\n\r\n' +#============================================================================= +class PrefixResolver(object): + def __init__(self, template): + self.template = template + + def __call__(self, filename, cdx): + full_path = self.template + if hasattr(cdx, '_formatter') and cdx._formatter: + full_path = cdx._formatter.format(full_path) + + return full_path + filename + + +#============================================================================= +class RedisResolver(RedisIndexSource): + def __call__(self, filename, cdx): + redis_key = self.redis_key_template + if hasattr(cdx, '_formatter') and cdx._formatter: + redis_key = cdx._formatter.format(redis_key) + + res = self.redis.hget(redis_key, filename) + if res: + res = res.decode('utf-8') + + return res #============================================================================= @@ -96,9 +112,9 @@ class WARCPathLoader(BaseLoader): if isinstance(paths, str): self.paths = [paths] - self.path_checks = list(self.warc_paths()) + self.resolvers = [self._make_resolver(path) for path in self.paths] - self.resolve_loader = ResolvingLoader(self.path_checks, + self.resolve_loader = ResolvingLoader(self.resolvers, no_record_parse=True) self.cdx_source = cdx_source @@ -106,20 +122,15 @@ class WARCPathLoader(BaseLoader): cdx_iter, errs = self.cdx_source(*args, **kwargs) return cdx_iter - def warc_paths(self): - for path in self.paths: - def check(filename, cdx): - try: - if hasattr(cdx, '_formatter') and cdx._formatter: - full_path = cdx._formatter.format(path) - else: - full_path = path - full_path += filename - return full_path - except KeyError: - return None + def _make_resolver(self, path): + if hasattr(path, '__call__'): + return path - yield check + if path.startswith('redis://'): + return RedisResolver(path) + + else: + return PrefixResolver(path) def load_resource(self, cdx, params): if cdx.get('_cached_result'): diff --git a/webagg/test/live.ini b/webagg/test/live.ini index c4e4e10c..f63d5896 100644 --- a/webagg/test/live.ini +++ b/webagg/test/live.ini @@ -12,6 +12,6 @@ venv = $(VIRTUAL_ENV) endif = gevent = 100 -gevent-early-monkey-patch = +gevent-monkey-patch = wsgi = webagg.test.live diff --git a/webagg/test/live.py b/webagg/test/live.py index 21aa73f7..e24084fa 100644 --- a/webagg/test/live.py +++ b/webagg/test/live.py @@ -1,4 +1,44 @@ +from gevent.monkey import patch_all; patch_all() + from webagg.test.testutils import LiveServerTests +from webagg.handlers import DefaultResourceHandler +from webagg.app import ResAggApp +from webagg.indexsource import LiveIndexSource, RedisIndexSource +from webagg.aggregator import SimpleAggregator, CacheDirectoryIndexSource -application = LiveServerTests.make_live_app() +def simpleapp(): + app = ResAggApp() + app.add_route('/live', + DefaultResourceHandler(SimpleAggregator( + {'live': LiveIndexSource()}) + ) + ) + + app.add_route('/replay', + DefaultResourceHandler(SimpleAggregator( + {'replay': RedisIndexSource('redis://localhost/2/rec:cdxj')}), + 'redis://localhost/2/rec:warc' + ) + ) + + app.add_route('/replay-testdata', + DefaultResourceHandler(SimpleAggregator( + {'test': CacheDirectoryIndexSource('./testdata/')}), + './testdata/' + ) + ) + return app.application + + + +application = simpleapp() + + +if __name__ == "__main__": +# from bottle import run +# run(application, server='gevent', port=8080, fast=True) + + from gevent.wsgi import WSGIServer + server = WSGIServer(('', 8080), application) + server.serve_forever() diff --git a/webagg/test/test_upstream.py b/webagg/test/test_upstream.py index cd107811..037b62e9 100644 --- a/webagg/test/test_upstream.py +++ b/webagg/test/test_upstream.py @@ -36,7 +36,7 @@ class TestUpstream(LiveServerTests, BaseTestClass): def test_live_paths(self): res = requests.get(self.base_url + '/') - assert set(res.json().keys()) == {'/live/postreq', '/live', '/replay/postreq', '/replay'} + assert set(res.json().keys()) == {'/live/postreq', '/live'} def test_upstream_paths(self): res = self.testapp.get('/') diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index c46e17f1..4c5c42b6 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -7,7 +7,7 @@ from multiprocessing import Process from wsgiref.simple_server import make_server -from webagg.aggregator import SimpleAggregator, CacheDirectoryIndexSource +from webagg.aggregator import SimpleAggregator from webagg.app import ResAggApp from webagg.handlers import DefaultResourceHandler from webagg.indexsource import LiveIndexSource @@ -66,12 +66,6 @@ class LiveServerTests(object): {'live': LiveIndexSource()}) ) ) - app.add_route('/replay', - DefaultResourceHandler(SimpleAggregator( - {'replay': CacheDirectoryIndexSource('./testdata/')}), - './testdata/' - ) - ) return app.application @classmethod diff --git a/webagg/utils.py b/webagg/utils.py index e71357c7..79c8bcbc 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -1,7 +1,8 @@ import re import six import string -import time + +from contextlib import closing from pywb.utils.timeutils import timestamp_to_http_date from pywb.utils.wbexception import BadRequestException @@ -11,7 +12,7 @@ LINK_SEG_SPLIT = re.compile(';\s*') LINK_URL = re.compile('<(.*)>') LINK_PROP = re.compile('([\w]+)="([^"]+)') -BUFF_SIZE = 8192 +BUFF_SIZE = 16384 #============================================================================= @@ -146,81 +147,31 @@ def res_template(template, params): #============================================================================= -class ReadFullyStream(object): - def __init__(self, stream): - self.stream = stream +def StreamIter(stream, header1=None, header2=None, size=BUFF_SIZE): + with closing(stream): + if header1: + yield header1 - def read(self, *args, **kwargs): - try: - return self.stream.read(*args, **kwargs) - except: - self.mark_incomplete() - raise + if header2: + yield header2 - def readline(self, *args, **kwargs): - try: - return self.stream.readline(*args, **kwargs) - except: - self.mark_incomplete() - raise - - def mark_incomplete(self): - if (hasattr(self.stream, '_fp') and - hasattr(self.stream._fp, 'mark_incomplete')): - self.stream._fp.mark_incomplete() - - def close(self): - try: - while True: - buff = self.stream.read(BUFF_SIZE) - time.sleep(0) - if not buff: - break - - except Exception as e: - import traceback - traceback.print_exc() - self.mark_incomplete() - finally: - self.stream.close() + while True: + buff = stream.read(size) + if not buff: + break + yield buff #============================================================================= -class StreamIter(six.Iterator): - def __init__(self, stream, header1=None, header2=None, size=8192): - self.stream = stream - self.header1 = header1 - self.header2 = header2 - self.size = size +def chunk_encode_iter(orig_iter): + for chunk in orig_iter: + if not len(chunk): + continue + chunk_len = b'%X\r\n' % len(chunk) + yield chunk_len + yield chunk + yield b'\r\n' - def __iter__(self): - return self - - def __next__(self): - if self.header1: - header = self.header1 - self.header1 = None - return header - elif self.header2: - header = self.header2 - self.header2 = None - return header - - data = self.stream.read(self.size) - if data: - return data - - self.close() - raise StopIteration - - def close(self): - if not self.stream: - return - - try: - self.stream.close() - self.stream = None - except Exception: - pass + yield b'0\r\n\r\n' From cbe7d1c9814ec9a06d44b78f053ca120b30e2291 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 21 Mar 2016 11:44:32 -0700 Subject: [PATCH 043/112] webagg: add tests for RedisPathResolver and errors on missing warc, missing warc keys --- webagg/responseloader.py | 1 + webagg/test/test_handlers.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 33228444..e600533e 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -99,6 +99,7 @@ class RedisResolver(RedisIndexSource): redis_key = cdx._formatter.format(redis_key) res = self.redis.hget(redis_key, filename) + print('REDIS_KEY', redis_key, filename, res) if res: res = res.decode('utf-8') diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index cefaa99d..9ffa26b2 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -17,6 +17,9 @@ from io import BytesIO import webtest +from fakeredis import FakeStrictRedis +from mock import patch + from .testutils import to_path import json @@ -30,6 +33,9 @@ sources = { testapp = None +redismock = patch('redis.StrictRedis', FakeStrictRedis) +redismock.start() + def setup_module(self): live_source = SimpleAggregator({'live': LiveIndexSource()}) live_handler = DefaultResourceHandler(live_source) @@ -54,6 +60,8 @@ def setup_module(self): app.add_route('/seq', HandlerSeq([handler3, handler2])) + app.add_route('/allredis', DefaultResourceHandler(source3, 'redis://localhost/2/test:warc')) + app.add_route('/empty', HandlerSeq([])) app.add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) @@ -61,6 +69,10 @@ def setup_module(self): testapp = webtest.TestApp(app.application) +def teardown_module(self): + redismock.stop() + + def to_json_list(text): return list([json.loads(cdx) for cdx in text.rstrip().split('\n')]) @@ -88,6 +100,7 @@ class TestResAgg(object): '/many', '/many/postreq', '/posttest', '/posttest/postreq', '/seq', '/seq/postreq', + '/allredis', '/allredis/postreq', '/invalid', '/invalid/postreq']) assert res['/fallback'] == {'modes': ['list_sources', 'index', 'resource']} @@ -326,6 +339,34 @@ foo=bar&test=abc""" assert 'ResErrors' not in resp.headers + def test_redis_warc_1(self): + f = FakeStrictRedis.from_url('redis://localhost/2') + f.hset('test:warc', 'example.warc.gz', './testdata/example.warc.gz') + + resp = self.testapp.get('/allredis/resource?url=http://www.example.com/') + + assert resp.headers['WebAgg-Source-Coll'] == 'example' + + def test_error_redis_file_not_found(self): + f = FakeStrictRedis.from_url('redis://localhost/2') + f.hset('test:warc', 'example.warc.gz', './testdata/example2.warc.gz') + + resp = self.testapp.get('/allredis/resource?url=http://www.example.com/', status=503) + assert resp.json['message'] == "example.warc.gz:[Errno 2] No such file or directory: './testdata/example2.warc.gz'" + + f.hdel('test:warc', 'example.warc.gz') + resp = self.testapp.get('/allredis/resource?url=http://www.example.com/', status=503) + + assert resp.json == {'message': 'example.warc.gz:Archive File Not Found', + 'errors': {'WARCPathLoader': "ArchiveLoadFailed('example.warc.gz:Archive File Not Found',)"}} + + f.delete('test:warc') + resp = self.testapp.get('/allredis/resource?url=http://www.example.com/', status=503) + + assert resp.json == {'message': 'example.warc.gz:Archive File Not Found', + 'errors': {'WARCPathLoader': "ArchiveLoadFailed('example.warc.gz:Archive File Not Found',)"}} + + def test_error_fallback_live_not_found(self): resp = self.testapp.get('/fallback/resource?url=http://invalid.url-not-found', status=400) From d38bb5a1fd317c3252e26a2f71168c7549bc18d9 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 21 Mar 2016 11:47:12 -0700 Subject: [PATCH 044/112] filters: add extensible 'skip filters', with default filters to accept certain collections, filter out recording of range requests. Opportunity to skip recording at request or response time RespWrapper handles reading stream fully on close() (no need for old ReadFullyStream), skips recording if read was interrupted/incomplete writer: avoiding writing duplicate content-length/content-type headers --- recorder/filters.py | 41 +++++++++++++ recorder/recorderapp.py | 101 ++++++++++++++++++++++++++------- recorder/test/test_recorder.py | 5 +- recorder/warcwriter.py | 5 +- 4 files changed, 127 insertions(+), 25 deletions(-) diff --git a/recorder/filters.py b/recorder/filters.py index 3635a5ab..b2ffc65f 100644 --- a/recorder/filters.py +++ b/recorder/filters.py @@ -1,4 +1,5 @@ from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_iso_date +import re # ============================================================================ @@ -38,3 +39,43 @@ class WriteDupePolicy(object): def __call__(self, cdx): return 'write' + +# ============================================================================ +# Skip Record Filters +# ============================================================================ +class SkipNothingFilter(object): + def skip_request(self, req_headers): + return False + + def skip_response(self, req_headers, resp_headers): + return False + + +# ============================================================================ +class CollectionFilter(SkipNothingFilter): + def __init__(self, accept_colls): + self.rx_accept_colls = re.compile(accept_colls) + + def skip_request(self, req_headers): + if req_headers.get('Recorder-Skip') == '1': + return True + + return False + + def skip_response(self, req_headers, resp_headers): + if not self.rx_accept_colls.match(resp_headers.get('WebAgg-Source-Coll', '')): + return True + + return False + + +# ============================================================================ +class SkipRangeRequestFilter(SkipNothingFilter): + def skip_request(self, req_headers): + range_ = req_headers.get('Range') + if range_ and not range_.lower().startswith('bytes=0-'): + return True + + return False + + diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 818b0eed..e567a95d 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -1,16 +1,16 @@ -#from gevent import monkey; monkey.patch_all() -from webagg.utils import ReadFullyStream, StreamIter +from webagg.utils import StreamIter, chunk_encode_iter, BUFF_SIZE from webagg.inputrequest import DirectWSGIInputRequest from pywb.utils.statusandheaders import StatusAndHeadersParser from pywb.warc.recordloader import ArcWarcRecord from pywb.warc.recordloader import ArcWarcRecordLoader +from recorder.filters import SkipRangeRequestFilter, CollectionFilter + from six.moves.urllib.parse import parse_qsl import json import tempfile -import re from requests.structures import CaseInsensitiveDict import requests @@ -23,7 +23,7 @@ import gevent #============================================================================== class RecorderApp(object): - def __init__(self, upstream_host, writer, accept_colls='.*'): + def __init__(self, upstream_host, writer, skip_filters=None, **kwargs): self.upstream_host = upstream_host self.writer = writer @@ -32,7 +32,19 @@ class RecorderApp(object): self.write_queue = gevent.queue.Queue() gevent.spawn(self._write_loop) - self.rx_accept_colls = re.compile(accept_colls) + if not skip_filters: + skip_filters = self.create_default_filters(kwargs) + + self.skip_filters = skip_filters + + def create_default_filters(self, kwargs): + skip_filters = [SkipRangeRequestFilter()] + + accept_colls = kwargs.get('accept_colls') + if accept_colls: + skip_filters.append(CollectionFilter(accept_colls)) + + return skip_filters def _write_loop(self): while True: @@ -49,9 +61,6 @@ class RecorderApp(object): req_head, req_pay, resp_head, resp_pay, params = result - if not self.rx_accept_colls.match(resp_head.get('WebAgg-Source-Coll', '')): - return - req = self._create_req_record(req_head, req_pay, 'request') resp = self._create_resp_record(resp_head, resp_pay, 'response') @@ -109,7 +118,13 @@ class RecorderApp(object): params = dict(parse_qsl(environ.get('QUERY_STRING'))) - req_stream = ReqWrapper(input_buff, headers) + skipping = any(x.skip_request(headers) for x in self.skip_filters) + + if not skipping: + req_stream = ReqWrapper(input_buff, headers) + else: + req_stream = input_buff + data = None if input_buff: data = req_stream @@ -121,15 +136,29 @@ class RecorderApp(object): headers=headers, allow_redirects=False, stream=True) + res.raise_for_status() except Exception as e: - traceback.print_exc() + #traceback.print_exc() return self.send_error(e, start_response) start_response('200 OK', list(res.headers.items())) - resp_stream = RespWrapper(res.raw, res.headers, req_stream, params, self.write_queue) + if not skipping: + resp_stream = RespWrapper(res.raw, + res.headers, + req_stream, + params, + self.write_queue, + self.skip_filters) + else: + resp_stream = res.raw - return StreamIter(ReadFullyStream(resp_stream)) + resp_iter = StreamIter(resp_stream) + + if res.headers.get('Transfer-Encoding') == 'chunked': + resp_iter = chunk_encode_iter(resp_iter) + + return resp_iter #============================================================================== @@ -137,12 +166,19 @@ class Wrapper(object): def __init__(self, stream): self.stream = stream self.out = self._create_buffer() + self.interrupted = False def _create_buffer(self): return tempfile.SpooledTemporaryFile(max_size=512*1024) - def read(self, limit=-1): - buff = self.stream.read() + def read(self, *args, **kwargs): + try: + buff = self.stream.read(*args, **kwargs) + except Exception as e: + print('INTERRUPT READ') + self.interrupted = True + raise + self.out.write(buff) return buff @@ -151,32 +187,53 @@ class Wrapper(object): self.stream.close() except: traceback.print_exc() - finally: - self._after_close() - - def _after_close(self): - pass #============================================================================== class RespWrapper(Wrapper): def __init__(self, stream, headers, req, - params, queue): + params, queue, skip_filters): super(RespWrapper, self).__init__(stream) self.headers = headers self.req = req self.params = params self.queue = queue + self.skip_filters = skip_filters - def _after_close(self): - if not self.req: + def close(self): + try: + while True: + if not self.read(BUFF_SIZE): + break + + except Exception as e: + print(e) + self.interrupted = True + + finally: + try: + self.stream.close() + except Exception as e: + traceback.print_exc() + + self._write_to_file() + + def _write_to_file(self): + skipping = any(x.skip_response(self.req.headers, self.headers) + for x in self.skip_filters) + + if self.interrupted or skipping: + self.out.close() + self.req.out.close() + self.req.close() return try: entry = (self.req.headers, self.req.out, self.headers, self.out, self.params) self.queue.put(entry) + self.req.close() self.req = None except: traceback.print_exc() diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index d83f3375..08d400f8 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -61,8 +61,9 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): req_url = '/live/resource/postreq?url=' + url + other_params testapp = webtest.TestApp(recorder_app) resp = testapp.post(req_url, general_req_data.format(host=host, path=path).encode('utf-8')) - #gevent.sleep(0.1) - recorder_app._write_one() + + if not recorder_app.write_queue.empty(): + recorder_app._write_one() assert resp.headers['WebAgg-Source-Coll'] == 'live' diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 99c93e06..aee12072 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -91,7 +91,7 @@ class BaseWARCWriter(object): req.rec_headers['WARC-Target-Uri'] = url req.rec_headers['WARC-Date'] = dt req.rec_headers['WARC-Type'] = 'request' - req.rec_headers['Content-Type'] = req.content_type + #req.rec_headers['Content-Type'] = req.content_type resp_id = resp.rec_headers.get('WARC-Record-ID') if resp_id: @@ -142,6 +142,9 @@ class BaseWARCWriter(object): self._line(out, b'WARC/1.0') for n, v in six.iteritems(record.rec_headers): + if n.lower() in ('content-length', 'content-type'): + continue + self._header(out, n, v) content_type = record.content_type From aa80cd6881169f1cf60fd4caee0347728284ceb3 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 21 Mar 2016 11:50:01 -0700 Subject: [PATCH 045/112] recorder: add simple recorder config indexing to redis --- recorder/test/rec.ini | 17 +++++++++++++++++ recorder/test/simplerec.py | 23 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 recorder/test/rec.ini create mode 100644 recorder/test/simplerec.py diff --git a/recorder/test/rec.ini b/recorder/test/rec.ini new file mode 100644 index 00000000..06a5f8ea --- /dev/null +++ b/recorder/test/rec.ini @@ -0,0 +1,17 @@ +[uwsgi] +if-not-env = PORT +http-socket = :8010 +endif = + +master = true +buffer-size = 65536 +die-on-term = true + +if-env = VIRTUAL_ENV +venv = $(VIRTUAL_ENV) +endif = + +gevent = 100 +#gevent-early-monkey-patch = + +wsgi = recorder.test.simplerec diff --git a/recorder/test/simplerec.py b/recorder/test/simplerec.py new file mode 100644 index 00000000..84f83736 --- /dev/null +++ b/recorder/test/simplerec.py @@ -0,0 +1,23 @@ +from gevent import monkey; monkey.patch_all() + +from recorder.recorderapp import RecorderApp +from recorder.redisindexer import WritableRedisIndexer + +from recorder.warcwriter import MultiFileWARCWriter +from recorder.filters import SkipDupePolicy + +upstream_url = 'http://localhost:8080' + +target = './_recordings/' + +dedup_index = WritableRedisIndexer('redis://localhost/2/rec:cdxj', + file_key_template='rec:warc', + rel_path_template=target, + dupe_policy=SkipDupePolicy()) + +recorder_app = RecorderApp(upstream_url, + MultiFileWARCWriter(target, dedup_index=dedup_index), + accept_colls='live') + +application = recorder_app + From ba66d0bb5e74e9cc6d3fcb7feef760278ab6055c Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 23 Mar 2016 23:55:21 -0400 Subject: [PATCH 046/112] recorder: use res_template() to resolve params, rename indexing method to add_urls_to_index --- recorder/redisindexer.py | 2 +- recorder/warcwriter.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index afa8a305..33d83692 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -32,7 +32,7 @@ class WritableRedisIndexer(RedisIndexSource): self.redis.hset(file_key, filename, full_filename) - def index_records(self, stream, params, filename=None): + def add_urls_to_index(self, stream, params, filename=None): rel_path = res_template(self.rel_path_template, params) filename = os.path.relpath(filename, rel_path) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index aee12072..a5b8ce0d 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -233,7 +233,6 @@ class Digester(object): # ============================================================================ class MultiFileWARCWriter(BaseWARCWriter): - def __init__(self, dir_template, filename_template=None, max_size=0, *args, **kwargs): super(MultiFileWARCWriter, self).__init__(*args, **kwargs) @@ -254,8 +253,9 @@ class MultiFileWARCWriter(BaseWARCWriter): def _open_file(self, dir_, params): timestamp = timestamp20_now() - filename = dir_ + self.filename_template.format(hostname=self.hostname, - timestamp=timestamp) + filename = dir_ + res_template(self.filename_template, params, + hostname=self.hostname, + timestamp=timestamp) path, name = os.path.split(filename) @@ -311,7 +311,7 @@ class MultiFileWARCWriter(BaseWARCWriter): out.seek(start) if self.dedup_index: - self.dedup_index.index_records(out, params, filename=filename) + self.dedup_index.add_urls_to_index(out, params, filename=filename) except Exception as e: traceback.print_exc() From e5ddf9d4f47ea9c9fdc001eddc0a38f7d1c438a3 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 23 Mar 2016 23:58:49 -0400 Subject: [PATCH 047/112] utils: res_template() supports extra params for interpolation --- webagg/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webagg/utils.py b/webagg/utils.py index 79c8bcbc..67f1d6a6 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -136,12 +136,12 @@ class ParamFormatter(string.Formatter): #============================================================================= -def res_template(template, params): +def res_template(template, params, **extra_params): formatter = params.get('_formatter') if not formatter: formatter = ParamFormatter(params) - res = formatter.format(template, url=params['url']) + res = formatter.format(template, url=params['url'], **extra_params) return res From 64b32dc57a28bb72f0399cf8d35d5b88bf7def3a Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 24 Mar 2016 01:17:18 -0400 Subject: [PATCH 048/112] redis support: add RedisMultiKeyIndexSource for using redis SCAN wildcard query and aggregate results from several redis keys --- webagg/aggregator.py | 19 ++++++++++++++++--- webagg/indexsource.py | 21 ++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/webagg/aggregator.py b/webagg/aggregator.py index cb7cf10a..73560ae0 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -15,7 +15,7 @@ from heapq import merge from collections import deque from itertools import chain -from webagg.indexsource import FileIndexSource +from webagg.indexsource import FileIndexSource, RedisIndexSource from pywb.utils.wbexception import NotFoundException, WbException from webagg.utils import ParamFormatter, res_template @@ -51,14 +51,14 @@ class BaseAggregator(object): cdx_iter = iter([]) err_list = [(name, repr(wbe))] - def add_name(cdx): + def add_name(cdx, name): if cdx.get('source'): cdx['source'] = name + ':' + cdx['source'] else: cdx['source'] = name return cdx - return (add_name(cdx) for cdx in cdx_iter), err_list + return (add_name(cdx, name) for cdx in cdx_iter), err_list def load_index(self, params): res_list = self._load_all(params) @@ -271,3 +271,16 @@ class CacheDirectoryIndexSource(DirectoryIndexSource): files = list(files) self.cached_file_list[the_dir] = (stat, files) return files + + +#============================================================================= +class RedisMultiKeyIndexSource(SeqAggMixin, BaseAggregator, RedisIndexSource): + def _iter_sources2(self, params): + redis_key_pattern = res_template(self.redis_key_template, params) + + for key in self.redis.scan_iter(match=redis_key_pattern): + key = key.decode('utf-8') + yield '', RedisIndexSource(None, self.redis, key) + + def _iter_sources(self, params): + return list(self._iter_sources2(params)) diff --git a/webagg/indexsource.py b/webagg/indexsource.py index b37604ba..afb500e6 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -103,19 +103,30 @@ class LiveIndexSource(BaseIndexSource): #============================================================================= class RedisIndexSource(BaseIndexSource): - def __init__(self, redis_url): + def __init__(self, redis_url, redis=None, key_prefix=None): + if redis_url and not redis: + redis, key_prefix = self.parse_redis_url(redis_url) + + self.redis = redis + self.redis_key_template = key_prefix + + @staticmethod + def parse_redis_url(redis_url): parts = redis_url.split('/') key_prefix = '' if len(parts) > 4: key_prefix = parts[4] redis_url = 'redis://' + parts[2] + '/' + parts[3] - self.redis_url = redis_url - self.redis_key_template = key_prefix - self.redis = redis.StrictRedis.from_url(redis_url) + redis_key_template = key_prefix + red = redis.StrictRedis.from_url(redis_url) + return red, key_prefix def load_index(self, params): - z_key = res_template(self.redis_key_template, params) + return self.load_key_index(self.redis_key_template, params) + + def load_key_index(self, key_template, params): + z_key = res_template(key_template, params) index_list = self.redis.zrangebylex(z_key, b'[' + params['key'], b'(' + params['end_key']) From 7cc772329ccf2caa872b91629c8993dfca94ce7e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 24 Mar 2016 10:44:14 -0400 Subject: [PATCH 049/112] redis: add tests for RedisMultiKeyIndexSource --- webagg/aggregator.py | 7 ++---- webagg/test/test_redis_agg.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 webagg/test/test_redis_agg.py diff --git a/webagg/aggregator.py b/webagg/aggregator.py index 73560ae0..8ddbe04f 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -275,12 +275,9 @@ class CacheDirectoryIndexSource(DirectoryIndexSource): #============================================================================= class RedisMultiKeyIndexSource(SeqAggMixin, BaseAggregator, RedisIndexSource): - def _iter_sources2(self, params): + def _iter_sources(self, params): redis_key_pattern = res_template(self.redis_key_template, params) for key in self.redis.scan_iter(match=redis_key_pattern): key = key.decode('utf-8') - yield '', RedisIndexSource(None, self.redis, key) - - def _iter_sources(self, params): - return list(self._iter_sources2(params)) + yield key, RedisIndexSource(None, self.redis, key) diff --git a/webagg/test/test_redis_agg.py b/webagg/test/test_redis_agg.py new file mode 100644 index 00000000..505350f7 --- /dev/null +++ b/webagg/test/test_redis_agg.py @@ -0,0 +1,45 @@ +from webagg.aggregator import RedisMultiKeyIndexSource +from .testutils import to_path, to_json_list, FakeRedisTests, BaseTestClass + + +class TestRedisAgg(FakeRedisTests, BaseTestClass): + @classmethod + def setup_class(cls): + super(TestRedisAgg, cls).setup_class() + cls.add_cdx_to_redis(to_path('testdata/example.cdxj'), 'FOO:example:cdxj') + cls.add_cdx_to_redis(to_path('testdata/dupes.cdxj'), 'FOO:dupes:cdxj') + + cls.indexloader = RedisMultiKeyIndexSource('redis://localhost/2/{user}:{coll}:cdxj') + + def test_redis_agg_all(self): + res, errs = self.indexloader({'url': 'example.com/', 'param.user': 'FOO', 'param.coll': '*'}) + + exp = [ + {'source': 'FOO:dupes:cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'FOO:dupes:cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + {'source': 'FOO:example:cdxj', 'timestamp': '20160225042329', 'filename': 'example.warc.gz'} + ] + + assert(errs == {}) + assert(to_json_list(res) == exp) + + def test_redis_agg_one(self): + res, errs = self.indexloader({'url': 'example.com/', 'param.user': 'FOO', 'param.coll': 'dupes'}) + + exp = [ + {'source': 'FOO:dupes:cdxj', 'timestamp': '20140127171200', 'filename': 'dupes.warc.gz'}, + {'source': 'FOO:dupes:cdxj', 'timestamp': '20140127171251', 'filename': 'dupes.warc.gz'}, + ] + + assert(errs == {}) + assert(to_json_list(res) == exp) + + def test_redis_not_found(self): + res, errs = self.indexloader({'url': 'example.com/'}) + + exp = [] + + assert(errs == {}) + assert(to_json_list(res) == exp) + + From 61921d6c4a2f02c29e004b7a09bee7abe9cc0c7c Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 24 Mar 2016 10:45:48 -0400 Subject: [PATCH 050/112] tests: add FakeRedisTests class mixin for patching in FakeRedis for tests --- recorder/test/test_recorder.py | 16 ++++++++-------- webagg/test/test_handlers.py | 13 ++----------- webagg/test/testutils.py | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 08d400f8..275ef6e5 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -2,13 +2,13 @@ import gevent from webagg.test.testutils import TempDirTests, LiveServerTests, BaseTestClass, to_path +from webagg.test.testutils import FakeRedisTests import os import webtest -from fakeredis import FakeStrictRedis -from mock import patch from pytest import raises +from fakeredis import FakeStrictRedis from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer @@ -37,7 +37,7 @@ Host: {host}\r\n\ -class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): +class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass): @classmethod def setup_class(cls): super(TestRecorder, cls).setup_class() @@ -180,7 +180,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): self._test_all_warcs('/warcs/', 2) - @patch('redis.StrictRedis', FakeStrictRedis) + #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -216,7 +216,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert warcs == {cdx['filename'].encode('utf-8'): full_path.encode('utf-8')} - @patch('redis.StrictRedis', FakeStrictRedis) + #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_revisit(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -263,7 +263,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert status_headers.get_header('WARC-Refers-To-Target-URI') == 'http://httpbin.org/get?foo=bar' assert status_headers.get_header('WARC-Refers-To-Date') != '' - @patch('redis.StrictRedis', FakeStrictRedis) + #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_skip(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -288,7 +288,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): res = r.zrangebylex('USER:COLL:cdxj', '[org,httpbin)/', '(org,httpbin,') assert len(res) == 2 - @patch('redis.StrictRedis', FakeStrictRedis) + #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_write_dupe_no_revisit(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -329,7 +329,7 @@ class TestRecorder(LiveServerTests, TempDirTests, BaseTestClass): assert os.path.isfile(path) assert len(writer.fh_cache) == 1 - @patch('redis.StrictRedis', FakeStrictRedis) + #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_multiple_writes_keep_open(self): warc_path = to_path(self.root_dir + '/warcs/FOO/ABC-{hostname}-{timestamp}.warc.gz') diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 9ffa26b2..b28b31f9 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -16,11 +16,9 @@ from pywb.utils.bufferedreaders import ChunkedDataReader from io import BytesIO import webtest - from fakeredis import FakeStrictRedis -from mock import patch -from .testutils import to_path +from .testutils import to_path, FakeRedisTests, BaseTestClass import json @@ -33,9 +31,6 @@ sources = { testapp = None -redismock = patch('redis.StrictRedis', FakeStrictRedis) -redismock.start() - def setup_module(self): live_source = SimpleAggregator({'live': LiveIndexSource()}) live_handler = DefaultResourceHandler(live_source) @@ -69,15 +64,11 @@ def setup_module(self): testapp = webtest.TestApp(app.application) -def teardown_module(self): - redismock.stop() - - def to_json_list(text): return list([json.loads(cdx) for cdx in text.rstrip().split('\n')]) -class TestResAgg(object): +class TestResAgg(FakeRedisTests, BaseTestClass): def setup(self): self.testapp = testapp diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index 4c5c42b6..a5d8677d 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -5,6 +5,9 @@ import shutil from multiprocessing import Process +from fakeredis import FakeStrictRedis +from mock import patch + from wsgiref.simple_server import make_server from webagg.aggregator import SimpleAggregator @@ -38,6 +41,27 @@ class BaseTestClass(object): pass +# ============================================================================ +class FakeRedisTests(object): + @classmethod + def setup_class(cls): + super(FakeRedisTests, cls).setup_class() + cls.redismock = patch('redis.StrictRedis', FakeStrictRedis) + cls.redismock.start() + + @staticmethod + def add_cdx_to_redis(filename, key, redis_url='redis://localhost:6379/2'): + r = FakeStrictRedis.from_url(redis_url) + with open(filename, 'rb') as fh: + for line in fh: + r.zadd(key, 0, line.rstrip()) + + @classmethod + def teardown_class(cls): + super(FakeRedisTests, cls).teardown_class() + cls.redismock.stop() + + # ============================================================================ class TempDirTests(object): @classmethod From b6e988d9a1af414d2cd914fcc391c6ca7aa976fd Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 24 Mar 2016 16:08:29 -0400 Subject: [PATCH 051/112] self-redirect: if 'status' is a 3xx, call raise_on_self_redirect() to check Location for exact url redirect. supports both WARC and live loaders, addresses #1 --- webagg/responseloader.py | 63 +++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index e600533e..de6be389 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -1,15 +1,18 @@ from webagg.utils import MementoUtils, StreamIter, chunk_encode_iter +from webagg.utils import ParamFormatter from webagg.indexsource import RedisIndexSource from pywb.utils.timeutils import timestamp_to_datetime, datetime_to_timestamp from pywb.utils.timeutils import iso_date_to_datetime, datetime_to_iso_date from pywb.utils.timeutils import http_date_to_datetime, datetime_to_http_date -from pywb.utils.wbexception import LiveResourceException -from pywb.utils.statusandheaders import StatusAndHeaders +from pywb.utils.wbexception import LiveResourceException, WbException +from pywb.utils.statusandheaders import StatusAndHeaders, StatusAndHeadersParser from pywb.warc.resolvingloader import ResolvingLoader +from six.moves.urllib.parse import urlsplit + from io import BytesIO import uuid @@ -77,6 +80,29 @@ class BaseLoader(object): return False + def raise_on_self_redirect(self, params, cdx, status_code, location_url): + """ + Check if response is a 3xx redirect to the same url + If so, reject this capture to avoid causing redirect loop + """ + if not status_code.startswith('3') or status_code == '304': + return + + request_url = params['url'].lower() + if not location_url: + return + + location_url = location_url.lower() + if location_url.startswith('/'): + host = urlsplit(cdx['url']).netloc + location_url = host + location_url + + if request_url == location_url: + msg = 'Self Redirect {0} -> {1}' + msg = msg.format(request_url, location_url) + #print(msg) + raise WbException(msg) + #============================================================================= class PrefixResolver(object): @@ -99,7 +125,6 @@ class RedisResolver(RedisIndexSource): redis_key = cdx._formatter.format(redis_key) res = self.redis.hget(redis_key, filename) - print('REDIS_KEY', redis_key, filename, res) if res: res = res.decode('utf-8') @@ -117,6 +142,9 @@ class WARCPathLoader(BaseLoader): self.resolve_loader = ResolvingLoader(self.resolvers, no_record_parse=True) + + self.headers_parser = StatusAndHeadersParser([], verify=False) + self.cdx_source = cdx_source def cdx_index_source(self, *args, **kwargs): @@ -140,12 +168,23 @@ class WARCPathLoader(BaseLoader): if not cdx.get('filename') or cdx.get('offset') is None: return None - cdx._formatter = params.get('_formatter') + cdx._formatter = ParamFormatter(params, cdx.get('source')) + failed_files = [] headers, payload = (self.resolve_loader. load_headers_and_payload(cdx, failed_files, self.cdx_index_source)) + + if cdx.get('status', '').startswith('3'): + status_headers = self.headers_parser.parse(payload.stream) + self.raise_on_self_redirect(params, cdx, + status_headers.get_statuscode(), + status_headers.get_header('Location')) + http_headers_buff = status_headers.to_bytes() + else: + http_headers_buff = None + warc_headers = payload.rec_headers if headers != payload: @@ -163,7 +202,7 @@ class WARCPathLoader(BaseLoader): headers.stream.close() - return (warc_headers, None, payload.stream) + return (warc_headers, http_headers_buff, payload.stream) def __str__(self): return 'WARCPathLoader' @@ -184,8 +223,6 @@ class LiveWebLoader(BaseLoader): if not load_url: return None - #recorder = HeaderRecorder(self.SKIP_HEADERS) - input_req = params['_input_req'] req_headers = input_req.get_req_headers() @@ -195,13 +232,6 @@ class LiveWebLoader(BaseLoader): if cdx.get('memento_url'): req_headers['Accept-Datetime'] = datetime_to_http_date(dt) - # if different url, ensure origin is not set - # may need to add other headers - if load_url != cdx['url']: - if 'Origin' in req_headers: - splits = urlsplit(load_url) - req_headers['Origin'] = splits.scheme + '://' + splits.netloc - method = input_req.get_req_method() data = input_req.get_req_body() @@ -230,6 +260,11 @@ class LiveWebLoader(BaseLoader): cdx['source'] = upstream_res.headers.get('WebAgg-Source-Coll') return None, upstream_res.headers, upstream_res.raw + self.raise_on_self_redirect(params, cdx, + str(upstream_res.status_code), + upstream_res.headers.get('Location')) + + if upstream_res.raw.version == 11: version = '1.1' else: From 2bfe5d4f9e755d100c9ed179347e43c4971041a4 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 24 Mar 2016 16:17:46 -0400 Subject: [PATCH 052/112] inputreq: only use REQUEST_URI if no SCRIPT_NAME is set (otherwise reconstruct the path) --- webagg/inputrequest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webagg/inputrequest.py b/webagg/inputrequest.py index e7f13a85..19ff1960 100644 --- a/webagg/inputrequest.py +++ b/webagg/inputrequest.py @@ -85,7 +85,7 @@ class DirectWSGIInputRequest(object): def get_full_request_uri(self): req_uri = self.env.get('REQUEST_URI') - if req_uri: + if req_uri and not self.env.get('SCRIPT_NAME'): return req_uri req_uri = quote(self.env.get('PATH_INFO', ''), safe='/~!$&\'()*+,;=:@') From 7deba42851be57d4b721ffa6eb64d173b31ace01 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 24 Mar 2016 16:33:03 -0400 Subject: [PATCH 053/112] add urlrewrite pywb-adapter PlatformHandler for using traditional pywb setup with webrecorder components recorder and webagg --- setup.py | 4 +- urlrewrite/__init__.py | 0 urlrewrite/platformhandler.py | 180 ++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 urlrewrite/__init__.py create mode 100644 urlrewrite/platformhandler.py diff --git a/setup.py b/setup.py index 7599878c..fa3083d8 100755 --- a/setup.py +++ b/setup.py @@ -31,12 +31,14 @@ setup( provides=[ 'webagg', 'recorder', + 'urlrewrite', + 'proxy', ], install_requires=[ 'pywb==0.30.0', ], dependency_links=[ - 'git+https://github.com/ikreymer/pywb.git@py3#egg=pywb-0.30.0-py3', + 'git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.30.0-develop', ], zip_safe=True, entry_points=""" diff --git a/urlrewrite/__init__.py b/urlrewrite/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/urlrewrite/platformhandler.py b/urlrewrite/platformhandler.py new file mode 100644 index 00000000..5b29bacf --- /dev/null +++ b/urlrewrite/platformhandler.py @@ -0,0 +1,180 @@ +from gevent.monkey import patch_all; patch_all() + +import requests + +from webagg.inputrequest import DirectWSGIInputRequest + +from pywb.framework.archivalrouter import Route + +from pywb.rewrite.rewrite_content import RewriteContent +from pywb.rewrite.wburl import WbUrl +from pywb.warc.recordloader import ArcWarcRecordLoader +from pywb.webapp.live_rewrite_handler import RewriteHandler +from pywb.utils.canonicalize import canonicalize +from pywb.utils.timeutils import http_date_to_timestamp +from pywb.utils.loaders import extract_client_cookie +from pywb.cdx.cdxobject import CDXObject + +from io import BytesIO + +from six.moves.urllib.parse import quote, urlsplit +from six import iteritems + + +#================================================================= +class PlatformRoute(Route): + def apply_filters(self, wbrequest, matcher): + wbrequest.matchdict = matcher.groupdict() + + +#============================================================================= +class PlatformHandler(RewriteHandler): + def __init__(self, config): + super(PlatformHandler, self).__init__(config) + self.upstream_url = config.get('upstream_url') + self.loader = ArcWarcRecordLoader() + + framed = config.get('framed_replay') + self.content_rewriter = RewriteContent(is_framed_replay=framed) + + def render_content(self, wbrequest): + if wbrequest.wb_url.mod == 'vi_': + return self._get_video_info(wbrequest) + + ref_wburl_str = wbrequest.extract_referrer_wburl_str() + if ref_wburl_str: + wbrequest.env['HTTP_REFERER'] = WbUrl(ref_wburl_str).url + + urlkey = canonicalize(wbrequest.wb_url.url) + url = wbrequest.wb_url.url + + inputreq = RewriteInputRequest(wbrequest.env, urlkey, url, + self.content_rewriter) + + req_data = inputreq.reconstruct_request(url) + + headers = {'Content-Length': len(req_data), + 'Content-Type': 'application/request'} + + if wbrequest.wb_url.is_latest_replay(): + closest = 'now' + else: + closest = wbrequest.wb_url.timestamp + + upstream_url = self.upstream_url.format(url=quote(url), + closest=closest, + #coll=wbrequest.coll, + **wbrequest.matchdict) + + r = requests.post(upstream_url, + data=BytesIO(req_data), + headers=headers, + stream=True, + allow_redirects=False) + + r.raise_for_status() + + record = self.loader.parse_record_stream(r.raw) + + cdx = CDXObject() + cdx['urlkey'] = urlkey + cdx['timestamp'] = http_date_to_timestamp(r.headers.get('Memento-Datetime')) + cdx['url'] = url + + head_insert_func = self.head_insert_view.create_insert_func(wbrequest) + result = self.content_rewriter.rewrite_content(wbrequest.urlrewriter, + record.status_headers, + record.stream, + head_insert_func, + urlkey, + cdx) + + status_headers, gen, is_rw = result + return self._make_response(wbrequest, *result) + + +#============================================================================= +class RewriteInputRequest(DirectWSGIInputRequest): + def __init__(self, env, urlkey, url, rewriter): + super(RewriteInputRequest, self).__init__(env) + self.urlkey = urlkey + self.url = url + self.rewriter = rewriter + + self.splits = urlsplit(self.url) + + def get_full_request_uri(self): + uri = self.splits.path + if self.splits.query: + uri += '?' + self.splits.query + + return uri + + def get_req_headers(self): + headers = {} + + has_cookies = False + + for name, value in iteritems(self.env): + if name == 'HTTP_HOST': + name = 'Host' + value = self.splits.netloc + + elif name == 'HTTP_ORIGIN': + name = 'Origin' + value = (self.splits.scheme + '://' + self.splits.netloc) + + elif name == 'HTTP_X_CSRFTOKEN': + name = 'X-CSRFToken' + cookie_val = extract_client_cookie(env, 'csrftoken') + if cookie_val: + value = cookie_val + + elif name == 'HTTP_X_FORWARDED_PROTO': + name = 'X-Forwarded-Proto' + value = self.splits.scheme + + elif name == 'HTTP_COOKIE': + name = 'Cookie' + value = self._req_cookie_rewrite(value) + has_cookies = True + + elif name.startswith('HTTP_'): + name = name[5:].title().replace('_', '-') + + elif name in ('CONTENT_LENGTH', 'CONTENT_TYPE'): + name = name.title().replace('_', '-') + + else: + value = None + + if value: + headers[name] = value + + if not has_cookies: + value = self._req_cookie_rewrite('') + if value: + headers['Cookie'] = value + + return headers + + def _req_cookie_rewrite(self, value): + rule = self.rewriter.ruleset.get_first_match(self.urlkey) + if not rule or not rule.req_cookie_rewrite: + return value + + for cr in rule.req_cookie_rewrite: + try: + value = cr['rx'].sub(cr['replace'], value) + except KeyError: + pass + + return value + + +if __name__ == "__main__": + from gevent.wsgi import WSGIServer + from pywb.apps.wayback import application + + server = WSGIServer(('', 8090), application) + server.serve_forever() From 7884d4394b43326e100debc8a46cec6138a1e569 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 26 Mar 2016 13:07:53 -0400 Subject: [PATCH 054/112] recorder: close_file() by params rather than exact path, update tests --- recorder/recorderapp.py | 10 ++++------ recorder/test/test_recorder.py | 2 +- recorder/warcwriter.py | 19 +++++++++++-------- webagg/utils.py | 3 +-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index e567a95d..505f4131 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -182,12 +182,6 @@ class Wrapper(object): self.out.write(buff) return buff - def close(self): - try: - self.stream.close() - except: - traceback.print_exc() - #============================================================================== class RespWrapper(Wrapper): @@ -248,4 +242,8 @@ class ReqWrapper(Wrapper): if not n.upper().startswith('WARC-'): del self.headers[n] + def close(self): + # no need to close wsgi.input + pass + diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 275ef6e5..707f2e95 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -383,7 +383,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert len(writer.fh_cache) == 1 - writer.remove_file(self.root_dir + '/warcs/FOO/') + writer.close_file({'param.recorder.coll': 'FOO'}) assert len(writer.fh_cache) == 0 diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index a5b8ce0d..e0d51154 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -275,11 +275,15 @@ class MultiFileWARCWriter(BaseWARCWriter): fcntl.flock(fh, fcntl.LOCK_UN) fh.close() - def remove_file(self, full_dir): + def close_file(self, params): + full_dir = res_template(self.dir_template, params) result = self.fh_cache.pop(full_dir, None) - if result: - out, filename = result - self._close_file(out) + if not result: + return + + out, filename = result + self._close_file(out) + return filename def _do_write_req_resp(self, req, resp, params): full_dir = res_template(self.dir_template, params) @@ -323,10 +327,9 @@ class MultiFileWARCWriter(BaseWARCWriter): close_file = True if close_file: - if is_new: - self._close_file(out) - else: - self.remove_file(full_dir) + self._close_file(out) + if not is_new: + self.fh_cache.pop(full_dir, None) elif is_new: fcntl.flock(out, fcntl.LOCK_EX | fcntl.LOCK_NB) diff --git a/webagg/utils.py b/webagg/utils.py index 67f1d6a6..6c9121b5 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -140,8 +140,7 @@ def res_template(template, params, **extra_params): formatter = params.get('_formatter') if not formatter: formatter = ParamFormatter(params) - - res = formatter.format(template, url=params['url'], **extra_params) + res = formatter.format(template, url=params.get('url', ''), **extra_params) return res From 0399cc1046eec356c63d70428965866459def8e6 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 26 Mar 2016 22:30:47 -0400 Subject: [PATCH 055/112] webagg app: support bottle debug properly as opt param --- webagg/app.py | 81 ++++++++++++++++++++++++++------------------------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/webagg/app.py b/webagg/app.py index e3302fae..595cef7f 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -1,6 +1,5 @@ from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest -from bottle import route, request, response, abort, Bottle -import bottle +from bottle import route, request, response, abort, Bottle, debug as bottle_debug import requests import traceback @@ -15,6 +14,10 @@ class ResAggApp(object): self.application = Bottle() self.application.default_error_handler = self.err_handler self.route_dict = {} + self.debug = kwargs.get('debug', False) + + if self.debug: + bottle_debug(True) @self.application.route('/') def list_routes(): @@ -22,7 +25,7 @@ class ResAggApp(object): def add_route(self, path, handler): @self.application.route([path, path + '/'], 'ANY') - @wrap_error + @self.wrap_error def direct_input_request(mode=''): params = dict(request.query) params['mode'] = mode @@ -30,7 +33,7 @@ class ResAggApp(object): return handler(params) @self.application.route([path + '/postreq', path + '//postreq'], 'POST') - @wrap_error + @self.wrap_error def post_fullrequest(mode=''): params = dict(request.query) params['mode'] = mode @@ -42,7 +45,7 @@ class ResAggApp(object): self.route_dict[path + '/postreq'] = handler_dict def err_handler(self, exc): - if bottle.debug: + if self.debug: print(exc) traceback.print_exc() response.status = exc.status_code @@ -51,47 +54,45 @@ class ResAggApp(object): response.headers['ResErrors'] = err_msg return err_msg + def wrap_error(self, func): + def wrap_func(*args, **kwargs): + try: + out_headers, res, errs = func(*args, **kwargs) -#============================================================================= -def wrap_error(func): - def wrap_func(*args, **kwargs): - try: - out_headers, res, errs = func(*args, **kwargs) + if out_headers: + for n, v in out_headers.items(): + response.headers[n] = v - if out_headers: - for n, v in out_headers.items(): - response.headers[n] = v + if res: + if errs: + response.headers['ResErrors'] = json.dumps(errs) + return res - if res: + last_exc = errs.pop('last_exc', None) + if last_exc: + if self.debug: + traceback.print_exc() + + response.status = last_exc.status() + message = last_exc.msg + else: + response.status = 404 + message = 'No Resource Found' + + response.content_type = JSON_CT + res = {'message': message} if errs: - response.headers['ResErrors'] = json.dumps(errs) - return res + res['errors'] = errs - last_exc = errs.pop('last_exc', None) - if last_exc: - if bottle.debug: + err_msg = json.dumps(res) + response.headers['ResErrors'] = err_msg + return err_msg + + except Exception as e: + if self.debug: traceback.print_exc() + abort(500, 'Internal Error: ' + str(e)) - response.status = last_exc.status() - message = last_exc.msg - else: - response.status = 404 - message = 'No Resource Found' - - response.content_type = JSON_CT - res = {'message': message} - if errs: - res['errors'] = errs - - err_msg = json.dumps(res) - response.headers['ResErrors'] = err_msg - return err_msg - - except Exception as e: - if bottle.debug: - traceback.print_exc() - abort(500, 'Internal Error: ' + str(e)) - - return wrap_func + return wrap_func From 017e9802f8cf1da3f7bf93a27de2616c7f06240e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 26 Mar 2016 22:32:21 -0400 Subject: [PATCH 056/112] tests: fix fakeredis patch not running on test_handlers, use exc str instead of repr for error message for consistency all tests pass on py2 and py3 again! --- webagg/handlers.py | 2 +- webagg/inputrequest.py | 4 +- webagg/responseloader.py | 4 +- webagg/test/test_dir_agg.py | 2 +- webagg/test/test_handlers.py | 92 ++++++++++++++++-------------------- webagg/test/test_inputreq.py | 8 +--- 6 files changed, 50 insertions(+), 62 deletions(-) diff --git a/webagg/handlers.py b/webagg/handlers.py index d9c06f96..83c22926 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -106,7 +106,7 @@ class ResourceHandler(IndexHandler): return out_headers, resp, errs except WbException as e: last_exc = e - errs[str(loader)] = repr(e) + errs[str(loader)] = str(e) if last_exc: errs['last_exc'] = last_exc diff --git a/webagg/inputrequest.py b/webagg/inputrequest.py index 19ff1960..39d12dd0 100644 --- a/webagg/inputrequest.py +++ b/webagg/inputrequest.py @@ -3,8 +3,8 @@ from pywb.utils.loaders import LimitReader from pywb.utils.statusandheaders import StatusAndHeadersParser from six.moves.urllib.parse import urlsplit, quote -from six import iteritems -from io import BytesIO, StringIO +from six import iteritems, StringIO +from io import BytesIO #============================================================================= diff --git a/webagg/responseloader.py b/webagg/responseloader.py index de6be389..9a619fa8 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -125,7 +125,7 @@ class RedisResolver(RedisIndexSource): redis_key = cdx._formatter.format(redis_key) res = self.redis.hget(redis_key, filename) - if res: + if res and six.PY3: res = res.decode('utf-8') return res @@ -135,7 +135,7 @@ class RedisResolver(RedisIndexSource): class WARCPathLoader(BaseLoader): def __init__(self, paths, cdx_source): self.paths = paths - if isinstance(paths, str): + if isinstance(paths, six.string_types): self.paths = [paths] self.resolvers = [self._make_resolver(path) for path in self.paths] diff --git a/webagg/test/test_dir_agg.py b/webagg/test/test_dir_agg.py index 0da78bf3..bce07046 100644 --- a/webagg/test/test_dir_agg.py +++ b/webagg/test/test_dir_agg.py @@ -207,7 +207,7 @@ class TestDirAgg(TempDirTests, BaseTestClass): new_file = os.path.join(self.root_dir, 'colls/C/indexes/empty.cdxj') with open(new_file, 'a') as fh: - os.utime(new_file) + os.utime(new_file, None) res = self.cache_dir_loader.get_source_list({'url': 'example.com/', 'param.coll': '*'}) diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index b28b31f9..1872e896 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -29,48 +29,40 @@ sources = { 'live': LiveIndexSource(), } -testapp = None - -def setup_module(self): - live_source = SimpleAggregator({'live': LiveIndexSource()}) - live_handler = DefaultResourceHandler(live_source) - app = ResAggApp() - app.add_route('/live', live_handler) - - source1 = GeventTimeoutAggregator(sources) - handler1 = DefaultResourceHandler(source1, to_path('testdata/')) - app.add_route('/many', handler1) - - source2 = SimpleAggregator({'post': FileIndexSource(to_path('testdata/post-test.cdxj'))}) - handler2 = DefaultResourceHandler(source2, to_path('testdata/')) - app.add_route('/posttest', handler2) - - source3 = SimpleAggregator({'example': FileIndexSource(to_path('testdata/example.cdxj'))}) - handler3 = DefaultResourceHandler(source3, to_path('testdata/')) - - app.add_route('/fallback', HandlerSeq([handler3, - handler2, - live_handler])) - - app.add_route('/seq', HandlerSeq([handler3, - handler2])) - - app.add_route('/allredis', DefaultResourceHandler(source3, 'redis://localhost/2/test:warc')) - - app.add_route('/empty', HandlerSeq([])) - app.add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) - - global testapp - testapp = webtest.TestApp(app.application) - - -def to_json_list(text): - return list([json.loads(cdx) for cdx in text.rstrip().split('\n')]) - class TestResAgg(FakeRedisTests, BaseTestClass): - def setup(self): - self.testapp = testapp + def setup_class(cls): + super(TestResAgg, cls).setup_class() + + live_source = SimpleAggregator({'live': LiveIndexSource()}) + live_handler = DefaultResourceHandler(live_source) + app = ResAggApp() + app.add_route('/live', live_handler) + + source1 = GeventTimeoutAggregator(sources) + handler1 = DefaultResourceHandler(source1, to_path('testdata/')) + app.add_route('/many', handler1) + + source2 = SimpleAggregator({'post': FileIndexSource(to_path('testdata/post-test.cdxj'))}) + handler2 = DefaultResourceHandler(source2, to_path('testdata/')) + app.add_route('/posttest', handler2) + + source3 = SimpleAggregator({'example': FileIndexSource(to_path('testdata/example.cdxj'))}) + handler3 = DefaultResourceHandler(source3, to_path('testdata/')) + + app.add_route('/fallback', HandlerSeq([handler3, + handler2, + live_handler])) + + app.add_route('/seq', HandlerSeq([handler3, + handler2])) + + app.add_route('/allredis', DefaultResourceHandler(source3, 'redis://localhost/2/test:warc')) + + app.add_route('/empty', HandlerSeq([])) + app.add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) + + cls.testapp = webtest.TestApp(app.application) def _check_uri_date(self, resp, uri, dt): buff = BytesIO(resp.body) @@ -128,10 +120,10 @@ class TestResAgg(FakeRedisTests, BaseTestClass): resp = self.testapp.get('/live/index?url=http://httpbin.org/get&output=json') resp.charset = 'utf-8' - res = to_json_list(resp.text) - res[0]['timestamp'] = '2016' - assert(res == [{'url': 'http://httpbin.org/get', 'urlkey': 'org,httpbin)/get', 'is_live': 'true', - 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) + cdxlist = list([json.loads(cdx) for cdx in resp.text.rstrip().split('\n')]) + cdxlist[0]['timestamp'] = '2016' + assert(cdxlist == [{'url': 'http://httpbin.org/get', 'urlkey': 'org,httpbin)/get', 'is_live': 'true', + 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) def test_live_resource(self): headers = {'foo': 'bar'} @@ -343,26 +335,26 @@ foo=bar&test=abc""" f.hset('test:warc', 'example.warc.gz', './testdata/example2.warc.gz') resp = self.testapp.get('/allredis/resource?url=http://www.example.com/', status=503) - assert resp.json['message'] == "example.warc.gz:[Errno 2] No such file or directory: './testdata/example2.warc.gz'" + assert resp.json['message'] == "example.warc.gz: [Errno 2] No such file or directory: './testdata/example2.warc.gz'" f.hdel('test:warc', 'example.warc.gz') resp = self.testapp.get('/allredis/resource?url=http://www.example.com/', status=503) - assert resp.json == {'message': 'example.warc.gz:Archive File Not Found', - 'errors': {'WARCPathLoader': "ArchiveLoadFailed('example.warc.gz:Archive File Not Found',)"}} + assert resp.json == {'message': 'example.warc.gz: Archive File Not Found', + 'errors': {'WARCPathLoader': 'example.warc.gz: Archive File Not Found'}} f.delete('test:warc') resp = self.testapp.get('/allredis/resource?url=http://www.example.com/', status=503) - assert resp.json == {'message': 'example.warc.gz:Archive File Not Found', - 'errors': {'WARCPathLoader': "ArchiveLoadFailed('example.warc.gz:Archive File Not Found',)"}} + assert resp.json == {'message': 'example.warc.gz: Archive File Not Found', + 'errors': {'WARCPathLoader': 'example.warc.gz: Archive File Not Found'}} def test_error_fallback_live_not_found(self): resp = self.testapp.get('/fallback/resource?url=http://invalid.url-not-found', status=400) assert resp.json == {'message': 'http://invalid.url-not-found', - 'errors': {'LiveWebLoader': "LiveResourceException('http://invalid.url-not-found',)"}} + 'errors': {'LiveWebLoader': 'http://invalid.url-not-found'}} assert resp.text == resp.headers['ResErrors'] diff --git a/webagg/test/test_inputreq.py b/webagg/test/test_inputreq.py index 7aca5b6a..bdc47705 100644 --- a/webagg/test/test_inputreq.py +++ b/webagg/test/test_inputreq.py @@ -1,5 +1,5 @@ from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest -from bottle import Bottle, request, response +from bottle import Bottle, request, response, debug import webtest import traceback @@ -8,7 +8,7 @@ import traceback class InputReqApp(object): def __init__(self): self.application = Bottle() - self.application.default_error_handler = self.err_handler + debug(True) @self.application.route('/test/', 'ANY') def direct_input_request(url=''): @@ -23,10 +23,6 @@ class InputReqApp(object): response['Content-Type'] = 'text/plain; charset=utf-8' return inputreq.reconstruct_request(params.get('url')) - def err_handler(self, out): - print(out) - traceback.print_exc() - #============================================================================= class TestInputReq(object): From f12be3bc917ace4382b773c6b3d4b63ccab7869e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 27 Mar 2016 17:34:45 -0400 Subject: [PATCH 057/112] urlrewrite app: add bottle-based app, templateview separate from pywb webapp framework --- urlrewrite/platformhandler.py | 91 +----------------- urlrewrite/rewriteinputreq.py | 85 +++++++++++++++++ urlrewrite/rewriterapp.py | 163 ++++++++++++++++++++++++++++++++ urlrewrite/templateview.py | 170 ++++++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+), 86 deletions(-) create mode 100644 urlrewrite/rewriteinputreq.py create mode 100644 urlrewrite/rewriterapp.py create mode 100644 urlrewrite/templateview.py diff --git a/urlrewrite/platformhandler.py b/urlrewrite/platformhandler.py index 5b29bacf..02e0c117 100644 --- a/urlrewrite/platformhandler.py +++ b/urlrewrite/platformhandler.py @@ -2,8 +2,6 @@ from gevent.monkey import patch_all; patch_all() import requests -from webagg.inputrequest import DirectWSGIInputRequest - from pywb.framework.archivalrouter import Route from pywb.rewrite.rewrite_content import RewriteContent @@ -12,22 +10,22 @@ from pywb.warc.recordloader import ArcWarcRecordLoader from pywb.webapp.live_rewrite_handler import RewriteHandler from pywb.utils.canonicalize import canonicalize from pywb.utils.timeutils import http_date_to_timestamp -from pywb.utils.loaders import extract_client_cookie from pywb.cdx.cdxobject import CDXObject from io import BytesIO -from six.moves.urllib.parse import quote, urlsplit -from six import iteritems +from rewriteinputreq import RewriteInputRequest + +from six.moves.urllib.parse import quote -#================================================================= +# ============================================================================ class PlatformRoute(Route): def apply_filters(self, wbrequest, matcher): wbrequest.matchdict = matcher.groupdict() -#============================================================================= +# ============================================================================ class PlatformHandler(RewriteHandler): def __init__(self, config): super(PlatformHandler, self).__init__(config) @@ -93,85 +91,6 @@ class PlatformHandler(RewriteHandler): return self._make_response(wbrequest, *result) -#============================================================================= -class RewriteInputRequest(DirectWSGIInputRequest): - def __init__(self, env, urlkey, url, rewriter): - super(RewriteInputRequest, self).__init__(env) - self.urlkey = urlkey - self.url = url - self.rewriter = rewriter - - self.splits = urlsplit(self.url) - - def get_full_request_uri(self): - uri = self.splits.path - if self.splits.query: - uri += '?' + self.splits.query - - return uri - - def get_req_headers(self): - headers = {} - - has_cookies = False - - for name, value in iteritems(self.env): - if name == 'HTTP_HOST': - name = 'Host' - value = self.splits.netloc - - elif name == 'HTTP_ORIGIN': - name = 'Origin' - value = (self.splits.scheme + '://' + self.splits.netloc) - - elif name == 'HTTP_X_CSRFTOKEN': - name = 'X-CSRFToken' - cookie_val = extract_client_cookie(env, 'csrftoken') - if cookie_val: - value = cookie_val - - elif name == 'HTTP_X_FORWARDED_PROTO': - name = 'X-Forwarded-Proto' - value = self.splits.scheme - - elif name == 'HTTP_COOKIE': - name = 'Cookie' - value = self._req_cookie_rewrite(value) - has_cookies = True - - elif name.startswith('HTTP_'): - name = name[5:].title().replace('_', '-') - - elif name in ('CONTENT_LENGTH', 'CONTENT_TYPE'): - name = name.title().replace('_', '-') - - else: - value = None - - if value: - headers[name] = value - - if not has_cookies: - value = self._req_cookie_rewrite('') - if value: - headers['Cookie'] = value - - return headers - - def _req_cookie_rewrite(self, value): - rule = self.rewriter.ruleset.get_first_match(self.urlkey) - if not rule or not rule.req_cookie_rewrite: - return value - - for cr in rule.req_cookie_rewrite: - try: - value = cr['rx'].sub(cr['replace'], value) - except KeyError: - pass - - return value - - if __name__ == "__main__": from gevent.wsgi import WSGIServer from pywb.apps.wayback import application diff --git a/urlrewrite/rewriteinputreq.py b/urlrewrite/rewriteinputreq.py new file mode 100644 index 00000000..28879e73 --- /dev/null +++ b/urlrewrite/rewriteinputreq.py @@ -0,0 +1,85 @@ +from webagg.inputrequest import DirectWSGIInputRequest +from pywb.utils.loaders import extract_client_cookie + +from six import iteritems +from six.moves.urllib.parse import urlsplit + + +#============================================================================= +class RewriteInputRequest(DirectWSGIInputRequest): + def __init__(self, env, urlkey, url, rewriter): + super(RewriteInputRequest, self).__init__(env) + self.urlkey = urlkey + self.url = url + self.rewriter = rewriter + + self.splits = urlsplit(self.url) + + def get_full_request_uri(self): + uri = self.splits.path + if self.splits.query: + uri += '?' + self.splits.query + + return uri + + def get_req_headers(self): + headers = {} + + has_cookies = False + + for name, value in iteritems(self.env): + if name == 'HTTP_HOST': + name = 'Host' + value = self.splits.netloc + + elif name == 'HTTP_ORIGIN': + name = 'Origin' + value = (self.splits.scheme + '://' + self.splits.netloc) + + elif name == 'HTTP_X_CSRFTOKEN': + name = 'X-CSRFToken' + cookie_val = extract_client_cookie(env, 'csrftoken') + if cookie_val: + value = cookie_val + + elif name == 'HTTP_X_FORWARDED_PROTO': + name = 'X-Forwarded-Proto' + value = self.splits.scheme + + elif name == 'HTTP_COOKIE': + name = 'Cookie' + value = self._req_cookie_rewrite(value) + has_cookies = True + + elif name.startswith('HTTP_'): + name = name[5:].title().replace('_', '-') + + elif name in ('CONTENT_LENGTH', 'CONTENT_TYPE'): + name = name.title().replace('_', '-') + + else: + value = None + + if value: + headers[name] = value + + if not has_cookies: + value = self._req_cookie_rewrite('') + if value: + headers['Cookie'] = value + + return headers + + def _req_cookie_rewrite(self, value): + rule = self.rewriter.ruleset.get_first_match(self.urlkey) + if not rule or not rule.req_cookie_rewrite: + return value + + for cr in rule.req_cookie_rewrite: + try: + value = cr['rx'].sub(cr['replace'], value) + except KeyError: + pass + + return value + diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py new file mode 100644 index 00000000..ca4614af --- /dev/null +++ b/urlrewrite/rewriterapp.py @@ -0,0 +1,163 @@ +import requests + +from bottle import request, response, HTTPError + +from pywb.rewrite.rewrite_content import RewriteContent +from pywb.rewrite.wburl import WbUrl +from pywb.rewrite.url_rewriter import UrlRewriter + +from pywb.utils.canonicalize import canonicalize +from pywb.utils.timeutils import http_date_to_timestamp +from pywb.utils.loaders import extract_client_cookie + +from pywb.cdx.cdxobject import CDXObject +from pywb.warc.recordloader import ArcWarcRecordLoader + +from rewriteinputreq import RewriteInputRequest +from templateview import JinjaEnv, HeadInsertView, TopFrameView + +from io import BytesIO + + +# ============================================================================ +class RewriterApp(object): + def __init__(self, framed_replay=False): + self.loader = ArcWarcRecordLoader() + + self.framed_replay = framed_replay + self.frame_mod = '' + self.replay_mod = 'mp_' + + frame_type = 'inverse' if framed_replay else False + + self.content_rewriter = RewriteContent(is_framed_replay=frame_type) + + self.jenv = JinjaEnv(globals={'static_path': 'static/__pywb'}) + self.head_insert_view = HeadInsertView(self.jenv, 'head_insert.html', 'banner.html') + self.frame_insert_view = TopFrameView(self.jenv, 'frame_insert.html', 'banner.html') + + def render_content(self, wb_url, **kwargs): + wb_url = WbUrl(wb_url) + #if wb_url.mod == 'vi_': + # return self._get_video_info(wbrequest) + + host_prefix = self.get_host_prefix() + rel_prefix = self.get_rel_prefix() + full_prefix = host_prefix + rel_prefix + + if self.framed_replay and wb_url.mod == self.frame_mod: + return self.frame_insert_view.get_top_frame(wb_url, + full_prefix, + host_prefix, + self.frame_mod, + self.replay_mod) + + urlrewriter = UrlRewriter(wb_url, + prefix=full_prefix, + full_prefix=full_prefix, + rel_prefix=rel_prefix) + + self.unrewrite_referrer() + + url = wb_url.url + urlkey = canonicalize(url) + + inputreq = RewriteInputRequest(request.environ, urlkey, url, + self.content_rewriter) + + req_data = inputreq.reconstruct_request(url) + + headers = {'Content-Length': len(req_data), + 'Content-Type': 'application/request'} + + if wb_url.is_latest_replay(): + closest = 'now' + else: + closest = wb_url.timestamp + + upstream_url = self.get_upstream_url(url, closest, kwargs) + + r = requests.post(upstream_url, + data=BytesIO(req_data), + headers=headers, + stream=True) + + if r.status_code >= 400: + try: + r.raw.close() + except: + pass + + data = dict(url=url, args=kwargs) + raise HTTPError(r.status_code, exception=data) + + record = self.loader.parse_record_stream(r.raw) + + cdx = CDXObject() + cdx['urlkey'] = urlkey + cdx['timestamp'] = http_date_to_timestamp(r.headers.get('Memento-Datetime')) + cdx['url'] = url + + self._add_custom_params(cdx, kwargs) + + if self.is_ajax(): + head_insert_func = None + else: + head_insert_func = (self.head_insert_view. + create_insert_func(wb_url, + full_prefix, + host_prefix, + request.environ, + self.framed_replay)) + + result = self.content_rewriter.rewrite_content(urlrewriter, + record.status_headers, + record.stream, + head_insert_func, + urlkey, + cdx) + + status_headers, gen, is_rw = result + + response.status = int(status_headers.get_statuscode()) + + for n, v in status_headers.headers: + response.headers[n] = v + + return gen + + def get_host_prefix(self): + return request.urlparts.scheme + '://' + request.urlparts.netloc + + def get_rel_prefix(self): + return request.script_name + + def get_full_prefix(self): + return self.get_host_prefix() + self.get_rel_prefix() + + def unrewrite_referrer(self): + referrer = request.environ.get('HTTP_REFERER') + if not referrer: + return False + + full_prefix = self.get_full_prefix() + + if referrer.startswith(full_prefix): + referrer = referrer[len(full_prefix):] + request.environ['HTTP_REFERER'] = referrer + return True + + return False + + def is_ajax(self): + value = request.environ.get('HTTP_X_REQUESTED_WITH') + if value and value.lower() == 'xmlhttprequest': + return True + + return False + + def get_upstream_url(self, url, closest, kwargs): + raise NotImplemented() + + def _add_custom_params(self, cdx, kwargs): + pass diff --git a/urlrewrite/templateview.py b/urlrewrite/templateview.py new file mode 100644 index 00000000..758b9098 --- /dev/null +++ b/urlrewrite/templateview.py @@ -0,0 +1,170 @@ +from pywb.utils.timeutils import timestamp_to_datetime, timestamp_to_sec, +from pywb.utils.timeutils import timestamp_now +from six.moves.urllib.parse import urlsplit + +from jinja2 import Environment +from jinja2 import FileSystemLoader, PackageLoader, ChoiceLoader + +import json +import os + + +# ============================================================================ +class FileOnlyPackageLoader(PackageLoader): + def get_source(self, env, template): + dir_, file_ = os.path.split(template) + return super(FileOnlyPackageLoader, self).get_source(env, file_) + + +# ============================================================================ +class RelEnvironment(Environment): + """Override join_path() to enable relative template paths.""" + def join_path(self, template, parent): + return os.path.join(os.path.dirname(parent), template) + + +# ============================================================================ +class JinjaEnv(object): + def __init__(self, paths=['templates', '.', '/'], + packages=['pywb'], + globals=None, + overlay=None): + + self._init_filters() + + loader = ChoiceLoader(self._make_loaders(paths, packages)) + + if overlay: + jinja_env = overlay.jinja_env.overlay(loader=loader, trim_blocks=True) + else: + jinja_env = RelEnvironment(loader=loader, trim_blocks=True) + + jinja_env.filters.update(self.filters) + if globals: + jinja_env.globals.update(globals) + self.jinja_env = jinja_env + + def _make_loaders(self, paths, packages): + loaders = [] + # add loaders for paths + for path in paths: + loaders.append(FileSystemLoader(path)) + + # add loaders for all specified packages + for package in packages: + loaders.append(FileOnlyPackageLoader(package)) + + return loaders + + def template_filter(self, param=None): + def deco(func): + name = param or func.__name__ + self.filters[name] = func + return func + + return deco + + def _init_filters(self): + self.filters = {} + + @self.template_filter() + def format_ts(value, format_='%a, %b %d %Y %H:%M:%S'): + if format_ == '%s': + return timestamp_to_sec(value) + else: + value = timestamp_to_datetime(value) + return value.strftime(format_) + + @self.template_filter('urlsplit') + def get_urlsplit(url): + split = urlsplit(url) + return split + + @self.template_filter() + def tojson(obj): + return json.dumps(obj) + + +# ============================================================================ +class BaseInsertView(object): + def __init__(self, jenv, insert_file, banner_file): + self.jenv = jenv + self.insert_file = insert_file + self.banner_file = banner_file + + def render_to_string(self, **kwargs): + template = self.jenv.jinja_env.get_template(self.insert_file) + return template.render(**kwargs) + + +# ============================================================================ +class HeadInsertView(BaseInsertView): + def create_insert_func(self, wb_url, + wb_prefix, + host_prefix, + env, + is_framed, + coll='', + include_ts=True): + + url = wb_url.get_url() + + top_url = wb_prefix + top_url += wb_url.to_str(mod='') + + include_wombat = not wb_url.is_banner_only + + wbrequest = {'host_prefix': host_prefix, + 'wb_prefix': wb_prefix, + 'wb_url': wb_url, + 'coll': coll, + 'env': env, + 'options': {'is_framed': is_framed}, + 'rewrite_opts': {} + } + + def make_head_insert(rule, cdx): + return (self.render_to_string(wbrequest=wbrequest, + cdx=cdx, + top_url=top_url, + include_ts=include_ts, + include_wombat=include_wombat, + banner_html=self.banner_file, + rule=rule)) + return make_head_insert + + +# ============================================================================ +class TopFrameView(BaseInsertView): + def get_top_frame(self, wb_url, + wb_prefix, + host_prefix, + frame_mod, + replay_mod, + coll=''): + + embed_url = wb_url.to_str(mod=replay_mod) + + if wb_url.timestamp: + timestamp = wb_url.timestamp + else: + timestamp = timestamp_now() + + wbrequest = {'host_prefix': host_prefix, + 'wb_prefix': wb_prefix, + 'wb_url': wb_url, + 'coll': coll, + + 'options': {'frame_mod': frame_mod, + 'replay_mod': replay_mod}, + } + + params = dict(embed_url=embed_url, + wbrequest=wbrequest, + timestamp=timestamp, + url=wb_url.get_url(), + banner_html=self.banner_file) + + return self.render_to_string(**params) + + From 70fbb5f7a647f0c8c5c2bf3396d5c5cbb0875335 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 28 Mar 2016 22:59:22 -0700 Subject: [PATCH 058/112] ulrewrite: fix typos, add full package paths --- urlrewrite/rewriterapp.py | 4 ++-- urlrewrite/templateview.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index ca4614af..d4f37fe1 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -13,8 +13,8 @@ from pywb.utils.loaders import extract_client_cookie from pywb.cdx.cdxobject import CDXObject from pywb.warc.recordloader import ArcWarcRecordLoader -from rewriteinputreq import RewriteInputRequest -from templateview import JinjaEnv, HeadInsertView, TopFrameView +from urlrewrite.rewriteinputreq import RewriteInputRequest +from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView from io import BytesIO diff --git a/urlrewrite/templateview.py b/urlrewrite/templateview.py index 758b9098..b5a293e3 100644 --- a/urlrewrite/templateview.py +++ b/urlrewrite/templateview.py @@ -1,4 +1,4 @@ -from pywb.utils.timeutils import timestamp_to_datetime, timestamp_to_sec, +from pywb.utils.timeutils import timestamp_to_datetime, timestamp_to_sec from pywb.utils.timeutils import timestamp_now from six.moves.urllib.parse import urlsplit From ddee9236c6182b9a940b4c0882657b30524eabc9 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 2 Apr 2016 21:33:23 -0700 Subject: [PATCH 059/112] webagg: rename key_prefix -> key_template --- webagg/indexsource.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webagg/indexsource.py b/webagg/indexsource.py index afb500e6..505e00d0 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -103,12 +103,12 @@ class LiveIndexSource(BaseIndexSource): #============================================================================= class RedisIndexSource(BaseIndexSource): - def __init__(self, redis_url, redis=None, key_prefix=None): + def __init__(self, redis_url, redis=None, key_template=None): if redis_url and not redis: - redis, key_prefix = self.parse_redis_url(redis_url) + redis, key_template = self.parse_redis_url(redis_url) self.redis = redis - self.redis_key_template = key_prefix + self.redis_key_template = key_template @staticmethod def parse_redis_url(redis_url): From 6157cebcc99c012a1bd5f9358d8b4e026e4b65a3 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 2 Apr 2016 21:33:39 -0700 Subject: [PATCH 060/112] testutils: when mock patching FakeStrictRedis, use a subclass with a shared pubsub (to match real redis) --- webagg/test/testutils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index a5d8677d..d0fb361e 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -41,12 +41,21 @@ class BaseTestClass(object): pass +# ============================================================================ +PUBSUBS = [] + +class FakeStrictRedisSharedPubSub(FakeStrictRedis): + def __init__(self, *args, **kwargs): + super(FakeStrictRedisSharedPubSub, self).__init__(*args, **kwargs) + self._pubsubs = PUBSUBS + + # ============================================================================ class FakeRedisTests(object): @classmethod def setup_class(cls): super(FakeRedisTests, cls).setup_class() - cls.redismock = patch('redis.StrictRedis', FakeStrictRedis) + cls.redismock = patch('redis.StrictRedis', FakeStrictRedisSharedPubSub) cls.redismock.start() @staticmethod From 01c21d3a436a9535a4ecd07b718201beb943b3ff Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 2 Apr 2016 21:36:36 -0700 Subject: [PATCH 061/112] recorder: redis indexer accepts arg list, supports separate redis and key_template args add length param to add_urls_to_index() in redis indexer, return cdx list --- recorder/redisindexer.py | 30 +++++++++++++++++++----------- recorder/test/test_recorder.py | 11 ++++++----- recorder/warcwriter.py | 8 +++++--- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index 33d83692..886cb62a 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -15,14 +15,21 @@ from recorder.filters import WriteRevisitDupePolicy #============================================================================== class WritableRedisIndexer(RedisIndexSource): - def __init__(self, redis_url, rel_path_template='', - file_key_template='', name='recorder', - dupe_policy=WriteRevisitDupePolicy()): - super(WritableRedisIndexer, self).__init__(redis_url) + def __init__(self, *args, **kwargs): + redis_url = kwargs.get('redis_url') + redis = kwargs.get('redis') + cdx_key_template = kwargs.get('cdx_key_template') + + super(WritableRedisIndexer, self).__init__(redis_url, + redis, + cdx_key_template) + + name = kwargs.get('name', 'recorder') self.cdx_lookup = SimpleAggregator({name: self}) - self.rel_path_template = rel_path_template - self.file_key_template = file_key_template - self.dupe_policy = dupe_policy + + self.rel_path_template = kwargs.get('rel_path_template', '') + self.file_key_template = kwargs.get('file_key_template', '') + self.dupe_policy = kwargs.get('dupe_policy', WriteRevisitDupePolicy()) def add_warc_file(self, full_filename, params): rel_path = res_template(self.rel_path_template, params) @@ -32,7 +39,7 @@ class WritableRedisIndexer(RedisIndexSource): self.redis.hset(file_key, filename, full_filename) - def add_urls_to_index(self, stream, params, filename=None): + def add_urls_to_index(self, stream, params, filename, length): rel_path = res_template(self.rel_path_template, params) filename = os.path.relpath(filename, rel_path) @@ -42,12 +49,13 @@ class WritableRedisIndexer(RedisIndexSource): z_key = res_template(self.redis_key_template, params) - cdxes = cdxout.getvalue() - for cdx in cdxes.split(b'\n'): + cdx_list = cdxout.getvalue().rstrip().split(b'\n') + + for cdx in cdx_list: if cdx: self.redis.zadd(z_key, 0, cdx) - return cdx + return cdx_list def lookup_revisit(self, params, digest, url, iso_dt): params['url'] = url diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 707f2e95..d763953f 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -42,14 +42,14 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) def setup_class(cls): super(TestRecorder, cls).setup_class() - warcs = to_path(cls.root_dir + '/warcs') + cls.warcs_dir = to_path(cls.root_dir + '/warcs') - os.makedirs(warcs) + os.makedirs(cls.warcs_dir) cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) def _get_dedup_index(self, dupe_policy=WriteRevisitDupePolicy()): - dedup_index = WritableRedisIndexer('redis://localhost/2/{user}:{coll}:cdxj', + dedup_index = WritableRedisIndexer(redis_url='redis://localhost/2/{user}:{coll}:cdxj', file_key_template='{user}:{coll}:warc', rel_path_template=self.root_dir + '/warcs/', dupe_policy=dupe_policy) @@ -335,7 +335,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) rel_path = self.root_dir + '/warcs/' - dedup_index = WritableRedisIndexer('redis://localhost/2/{coll}:cdxj', + dedup_index = WritableRedisIndexer(redis_url='redis://localhost/2/{coll}:cdxj', file_key_template='{coll}:warc', rel_path_template=rel_path) @@ -383,7 +383,8 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert len(writer.fh_cache) == 1 - writer.close_file({'param.recorder.coll': 'FOO'}) + writer.close_file(self.root_dir + '/warcs/FOO/') + #writer.close_file({'param.recorder.coll': 'FOO'}) assert len(writer.fh_cache) == 0 diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index e0d51154..db88e45e 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -275,8 +275,8 @@ class MultiFileWARCWriter(BaseWARCWriter): fcntl.flock(fh, fcntl.LOCK_UN) fh.close() - def close_file(self, params): - full_dir = res_template(self.dir_template, params) + def close_file(self, full_dir): + #full_dir = res_template(self.dir_template, params) result = self.fh_cache.pop(full_dir, None) if not result: return @@ -315,7 +315,9 @@ class MultiFileWARCWriter(BaseWARCWriter): out.seek(start) if self.dedup_index: - self.dedup_index.add_urls_to_index(out, params, filename=filename) + self.dedup_index.add_urls_to_index(out, params, + filename, + new_size - start) except Exception as e: traceback.print_exc() From fd76030cb350584671b008794bb22a7504c3f2aa Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 2 Apr 2016 21:36:54 -0700 Subject: [PATCH 062/112] urlrewriter: allow passing in existing jinja_env wrapper --- urlrewrite/rewriterapp.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index d4f37fe1..f2c45633 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -21,7 +21,7 @@ from io import BytesIO # ============================================================================ class RewriterApp(object): - def __init__(self, framed_replay=False): + def __init__(self, framed_replay=False, jinja_env=None): self.loader = ArcWarcRecordLoader() self.framed_replay = framed_replay @@ -32,9 +32,12 @@ class RewriterApp(object): self.content_rewriter = RewriteContent(is_framed_replay=frame_type) - self.jenv = JinjaEnv(globals={'static_path': 'static/__pywb'}) - self.head_insert_view = HeadInsertView(self.jenv, 'head_insert.html', 'banner.html') - self.frame_insert_view = TopFrameView(self.jenv, 'frame_insert.html', 'banner.html') + if not jinja_env: + jinja_env = JinjaEnv(globals={'static_path': 'static/__pywb'}) + + self.jinja_env = jinja_env + self.head_insert_view = HeadInsertView(self.jinja_env, 'head_insert.html', 'banner.html') + self.frame_insert_view = TopFrameView(self.jinja_env, 'frame_insert.html', 'banner.html') def render_content(self, wb_url, **kwargs): wb_url = WbUrl(wb_url) From d40edfc22db1dd107d584ddf8d6e9ac7532e08ad Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 3 Apr 2016 12:19:54 -0700 Subject: [PATCH 063/112] warcwriter: add create_warcinfo_record() for creating a warcinfo and a SimpleTempWARCWriter for writing records to temp buff/file --- recorder/test/test_recorder.py | 36 ++++++++++++++++++---- recorder/warcwriter.py | 56 +++++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index d763953f..9a07075f 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -12,7 +12,7 @@ from fakeredis import FakeStrictRedis from recorder.recorderapp import RecorderApp from recorder.redisindexer import WritableRedisIndexer -from recorder.warcwriter import PerRecordWARCWriter, MultiFileWARCWriter +from recorder.warcwriter import PerRecordWARCWriter, MultiFileWARCWriter, SimpleTempWARCWriter from recorder.filters import ExcludeSpecificHeaders from recorder.filters import SkipDupePolicy, WriteDupePolicy, WriteRevisitDupePolicy @@ -27,6 +27,7 @@ from pywb.warc.cdxindexer import write_cdx_index from six.moves.urllib.parse import quote, unquote from io import BytesIO import time +import json general_req_data = "\ GET {path} HTTP/1.1\r\n\ @@ -180,7 +181,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) self._test_all_warcs('/warcs/', 2) - #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -216,7 +216,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert warcs == {cdx['filename'].encode('utf-8'): full_path.encode('utf-8')} - #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_revisit(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -263,7 +262,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert status_headers.get_header('WARC-Refers-To-Target-URI') == 'http://httpbin.org/get?foo=bar' assert status_headers.get_header('WARC-Refers-To-Date') != '' - #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_skip(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -288,7 +286,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) res = r.zrangebylex('USER:COLL:cdxj', '[org,httpbin)/', '(org,httpbin,') assert len(res) == 2 - #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_param_user_coll_write_dupe_no_revisit(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -329,7 +326,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert os.path.isfile(path) assert len(writer.fh_cache) == 1 - #@patch('redis.StrictRedis', FakeStrictRedis) def test_record_multiple_writes_keep_open(self): warc_path = to_path(self.root_dir + '/warcs/FOO/ABC-{hostname}-{timestamp}.warc.gz') @@ -397,3 +393,31 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) warcs = r.hgetall('FOO:warc') assert len(warcs) == 2 + + def test_warcinfo_record(self): + simplewriter = SimpleTempWARCWriter(gzip=False) + params = {'software': 'recorder test', + 'format': 'WARC File Format 1.0', + 'json-metadata': json.dumps({'foo': 'bar'})} + + record = simplewriter.create_warcinfo_record('testfile.warc.gz', **params) + simplewriter.write_record(record) + buff = simplewriter.get_buffer() + assert isinstance(buff, bytes) + + buff = BytesIO(buff) + parsed_record = ArcWarcRecordLoader().parse_record_stream(buff) + + assert parsed_record.rec_headers.get_header('WARC-Type') == 'warcinfo' + assert parsed_record.rec_headers.get_header('WARC-Filename') == 'testfile.warc.gz' + + buff = parsed_record.stream.read().decode('utf-8') + + length = parsed_record.rec_headers.get_header('Content-Length') + + assert len(buff) == int(length) + + assert 'json-metadata: {"foo": "bar"}\r\n' in buff + assert 'format: WARC File Format 1.0\r\n' in buff + assert 'json-metadata: {"foo": "bar"}\r\n' in buff + diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index db88e45e..92ba5bce 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -13,12 +13,15 @@ import traceback from collections import OrderedDict from socket import gethostname +from io import BytesIO import fcntl from pywb.utils.loaders import LimitReader, to_native_str from pywb.utils.bufferedreaders import BufferedReader -from pywb.utils.timeutils import timestamp20_now +from pywb.utils.timeutils import timestamp20_now, datetime_to_iso_date + +from pywb.warc.recordloader import ArcWarcRecord from webagg.utils import ParamFormatter, res_template @@ -110,6 +113,25 @@ class BaseWARCWriter(object): params['_formatter'] = ParamFormatter(params, name=self.rec_source_name) self._do_write_req_resp(req, resp, params) + def create_warcinfo_record(self, filename, **kwargs): + headers = {} + headers['WARC-Record_ID'] = self._make_warc_id() + headers['WARC-Type'] = 'warcinfo' + if filename: + headers['WARC-Filename'] = filename + headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) + + warcinfo = BytesIO() + for n, v in six.iteritems(kwargs): + self._header(warcinfo, n, v) + + warcinfo.seek(0) + + record = ArcWarcRecord('warc', 'warcinfo', headers, warcinfo, + None, '', len(warcinfo.getbuffer())) + + return record + def _check_revisit(self, record, params): if not self.dedup_index: return record @@ -159,7 +181,9 @@ class BaseWARCWriter(object): http_headers_only = False if record.length: - actual_len = len(record.status_headers.headers_buff) + actual_len = 0 + if record.status_headers: + actual_len = len(record.status_headers.headers_buff) if not http_headers_only: diff = record.stream.tell() - actual_len @@ -170,8 +194,9 @@ class BaseWARCWriter(object): # add empty line self._line(out, b'') - # write headers and buffer - out.write(record.status_headers.headers_buff) + # write headers buffer, if any + if record.status_headers: + out.write(record.status_headers.headers_buff) if not http_headers_only: out.write(record.stream.read()) @@ -351,3 +376,26 @@ class PerRecordWARCWriter(MultiFileWARCWriter): kwargs['max_size'] = 1 super(PerRecordWARCWriter, self).__init__(*args, **kwargs) + +# ============================================================================ +class SimpleTempWARCWriter(BaseWARCWriter): + def __init__(self, *args, **kwargs): + super(SimpleTempWARCWriter, self).__init__(*args, **kwargs) + self.out = self._create_buffer() + + def _create_buffer(self): + return tempfile.SpooledTemporaryFile(max_size=512*1024) + + def _do_write_req_resp(self, req, resp, params): + self._write_warc_record(self.out, resp) + self._write_warc_record(self.out, req) + + def write_record(self, record): + self._write_warc_record(self.out, record) + + def get_buffer(self): + pos = self.out.tell() + self.out.seek(0) + buff = self.out.read() + self.out.seek(pos) + return buff From fa5d5e6bccf65ced40edeb937760519ce0086a3e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 5 Apr 2016 02:44:04 -0700 Subject: [PATCH 064/112] urlrewrite templates: add get_top_frame_params() callback for adding custom params for top frame, also inject env['webrec.template_params'] if set --- urlrewrite/rewriterapp.py | 14 +++++++++++--- urlrewrite/templateview.py | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index f2c45633..765c39a7 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -49,11 +49,15 @@ class RewriterApp(object): full_prefix = host_prefix + rel_prefix if self.framed_replay and wb_url.mod == self.frame_mod: + extra_params = self.get_top_frame_params(wb_url, kwargs) return self.frame_insert_view.get_top_frame(wb_url, full_prefix, host_prefix, + request.environ, self.frame_mod, - self.replay_mod) + self.replay_mod, + coll='', + extra_params=extra_params) urlrewriter = UrlRewriter(wb_url, prefix=full_prefix, @@ -78,7 +82,7 @@ class RewriterApp(object): else: closest = wb_url.timestamp - upstream_url = self.get_upstream_url(url, closest, kwargs) + upstream_url = self.get_upstream_url(url, wb_url, closest, kwargs) r = requests.post(upstream_url, data=BytesIO(req_data), @@ -159,8 +163,12 @@ class RewriterApp(object): return False - def get_upstream_url(self, url, closest, kwargs): + def get_upstream_url(self, url, wb_url, closest, kwargs): raise NotImplemented() def _add_custom_params(self, cdx, kwargs): pass + + def get_top_frame_params(self, wb_url, kwargs): + return None + diff --git a/urlrewrite/templateview.py b/urlrewrite/templateview.py index b5a293e3..19039567 100644 --- a/urlrewrite/templateview.py +++ b/urlrewrite/templateview.py @@ -92,8 +92,12 @@ class BaseInsertView(object): self.insert_file = insert_file self.banner_file = banner_file - def render_to_string(self, **kwargs): + def render_to_string(self, env, **kwargs): template = self.jenv.jinja_env.get_template(self.insert_file) + params = env.get('webrec.template_params') + if params: + kwargs.update(params) + return template.render(**kwargs) @@ -124,7 +128,7 @@ class HeadInsertView(BaseInsertView): } def make_head_insert(rule, cdx): - return (self.render_to_string(wbrequest=wbrequest, + return (self.render_to_string(env, wbrequest=wbrequest, cdx=cdx, top_url=top_url, include_ts=include_ts, @@ -139,9 +143,11 @@ class TopFrameView(BaseInsertView): def get_top_frame(self, wb_url, wb_prefix, host_prefix, + env, frame_mod, replay_mod, - coll=''): + coll='', + extra_params=None): embed_url = wb_url.to_str(mod=replay_mod) @@ -165,6 +171,9 @@ class TopFrameView(BaseInsertView): url=wb_url.get_url(), banner_html=self.banner_file) - return self.render_to_string(**params) + if extra_params: + params.update(extra_params) + + return self.render_to_string(env, **params) From f4cc143dc758402fe49a997088faac63c7cba07d Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 7 Apr 2016 10:37:40 -0700 Subject: [PATCH 065/112] urlrewrite: generalize support for overridable handle_custom_response() callback for handling modifiers (default support top-frame) pass headers to add_custom_params, include error message on error if available headers: use add_header() to support multiple headers with same name is_ajax(): check for X-Pywb-Requested-With header to make as ajax and not pass to upstream --- urlrewrite/rewriteinputreq.py | 3 +++ urlrewrite/rewriterapp.py | 42 +++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/urlrewrite/rewriteinputreq.py b/urlrewrite/rewriteinputreq.py index 28879e73..10c929fc 100644 --- a/urlrewrite/rewriteinputreq.py +++ b/urlrewrite/rewriteinputreq.py @@ -42,6 +42,9 @@ class RewriteInputRequest(DirectWSGIInputRequest): if cookie_val: value = cookie_val + elif name == 'HTTP_X_PYWB_REQUESTED_WITH': + continue + elif name == 'HTTP_X_FORWARDED_PROTO': name = 'X-Forwarded-Proto' value = self.splits.scheme diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 765c39a7..4d9836f4 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -48,16 +48,9 @@ class RewriterApp(object): rel_prefix = self.get_rel_prefix() full_prefix = host_prefix + rel_prefix - if self.framed_replay and wb_url.mod == self.frame_mod: - extra_params = self.get_top_frame_params(wb_url, kwargs) - return self.frame_insert_view.get_top_frame(wb_url, - full_prefix, - host_prefix, - request.environ, - self.frame_mod, - self.replay_mod, - coll='', - extra_params=extra_params) + resp = self.handle_custom_response(wb_url, full_prefix, host_prefix, kwargs) + if resp is not None: + return resp urlrewriter = UrlRewriter(wb_url, prefix=full_prefix, @@ -90,12 +83,19 @@ class RewriterApp(object): stream=True) if r.status_code >= 400: + error = None try: + error = r.raw.read() r.raw.close() except: pass - data = dict(url=url, args=kwargs) + if error: + error = error.decode('utf-8') + else: + error = '' + + data = dict(url=url, args=kwargs, error=error) raise HTTPError(r.status_code, exception=data) record = self.loader.parse_record_stream(r.raw) @@ -105,7 +105,7 @@ class RewriterApp(object): cdx['timestamp'] = http_date_to_timestamp(r.headers.get('Memento-Datetime')) cdx['url'] = url - self._add_custom_params(cdx, kwargs) + self._add_custom_params(cdx, r.headers, kwargs) if self.is_ajax(): head_insert_func = None @@ -129,7 +129,7 @@ class RewriterApp(object): response.status = int(status_headers.get_statuscode()) for n, v in status_headers.headers: - response.headers[n] = v + response.add_header(n, v) return gen @@ -158,6 +158,7 @@ class RewriterApp(object): def is_ajax(self): value = request.environ.get('HTTP_X_REQUESTED_WITH') + value = value or request.environ.get('HTTP_X_PYWB_REQUESTED_WITH') if value and value.lower() == 'xmlhttprequest': return True @@ -166,9 +167,22 @@ class RewriterApp(object): def get_upstream_url(self, url, wb_url, closest, kwargs): raise NotImplemented() - def _add_custom_params(self, cdx, kwargs): + def _add_custom_params(self, cdx, headers, kwargs): pass def get_top_frame_params(self, wb_url, kwargs): return None + def handle_custom_response(self, wb_url, full_prefix, host_prefix, kwargs): + if self.framed_replay and wb_url.mod == self.frame_mod: + extra_params = self.get_top_frame_params(wb_url, kwargs) + return self.frame_insert_view.get_top_frame(wb_url, + full_prefix, + host_prefix, + request.environ, + self.frame_mod, + self.replay_mod, + coll='', + extra_params=extra_params) + + return None From 00bdddd1e9a9f64071933ed0833b0bb7d907c5c5 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 7 Apr 2016 10:44:05 -0700 Subject: [PATCH 066/112] recorder: SkipDupePolicy only skips if url is an exact match (not just by urlkey) --- recorder/filters.py | 11 +++++++---- recorder/redisindexer.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/recorder/filters.py b/recorder/filters.py index b2ffc65f..c9ab74ee 100644 --- a/recorder/filters.py +++ b/recorder/filters.py @@ -23,20 +23,23 @@ class ExcludeSpecificHeaders(object): # Revisit Policy # ============================================================================ class WriteRevisitDupePolicy(object): - def __call__(self, cdx): + def __call__(self, cdx, params): dt = timestamp_to_datetime(cdx['timestamp']) return ('revisit', cdx['url'], datetime_to_iso_date(dt)) # ============================================================================ class SkipDupePolicy(object): - def __call__(self, cdx): - return 'skip' + def __call__(self, cdx, params): + if cdx['url'] == params['url']: + return 'skip' + else: + return 'write' # ============================================================================ class WriteDupePolicy(object): - def __call__(self, cdx): + def __call__(self, cdx, params): return 'write' diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index 886cb62a..c3fa1c93 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -73,7 +73,7 @@ class WritableRedisIndexer(RedisIndexSource): cdx_iter, errs = self.cdx_lookup(params) for cdx in cdx_iter: - res = self.dupe_policy(cdx) + res = self.dupe_policy(cdx, params) if res: return res From a93f75dca2039e6ee9af2bb6ada126e06ec8f38a Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 15 Apr 2016 02:18:20 +0000 Subject: [PATCH 067/112] webagg: add preliminary 'fuzzy matching' fallback support, currently enabled for all sources (todo: need to only include sources that support it) --- webagg/handlers.py | 41 ++++++++++++++++++++++++++++++++++++++-- webagg/responseloader.py | 3 ++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/webagg/handlers.py b/webagg/handlers.py index 83c22926..d6038fb2 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -3,6 +3,9 @@ from webagg.utils import MementoUtils from pywb.utils.wbexception import BadRequestException, WbException from pywb.utils.wbexception import NotFoundException +from pywb.cdx.query import CDXQuery +from pywb.cdx.cdxdomainspecific import load_domain_specific_cdx_rules + #============================================================================= def to_cdxj(cdx_iter, fields): @@ -22,6 +25,39 @@ def to_link(cdx_iter, fields): return content_type, MementoUtils.make_timemap(cdx_iter) + +#============================================================================= +class FuzzyMatcher(object): + def __init__(self): + res = load_domain_specific_cdx_rules('pywb/rules.yaml', True) + self.url_canon, self.fuzzy_query = res + + def __call__(self, index_source, params): + cdx_iter, errs = index_source(params) + return self.do_fuzzy(cdx_iter, index_source, params), errs + + def do_fuzzy(self, cdx_iter, index_source, params): + found = False + for cdx in cdx_iter: + found = True + yield cdx + + fuzzy_query_params = None + if not found: + query = CDXQuery(params) + fuzzy_query_params = self.fuzzy_query(query) + + if not fuzzy_query_params: + return + + fuzzy_query_params.pop('alt_url', '') + + new_iter, errs = index_source(fuzzy_query_params) + + for cdx in new_iter: + yield cdx + + #============================================================================= class IndexHandler(object): OUTPUTS = { @@ -33,9 +69,10 @@ class IndexHandler(object): DEF_OUTPUT = 'cdxj' - def __init__(self, index_source, opts=None): + def __init__(self, index_source, opts=None, *args, **kwargs): self.index_source = index_source self.opts = opts or {} + self.fuzzy = FuzzyMatcher() def get_supported_modes(self): return dict(modes=['list_sources', 'index']) @@ -50,7 +87,7 @@ class IndexHandler(object): if input_req: params['alt_url'] = input_req.include_post_query(url) - return self.index_source(params) + return self.fuzzy(self.index_source, params) def __call__(self, params): mode = params.get('mode', 'index') diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 9a619fa8..dced8437 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -216,7 +216,8 @@ class LiveWebLoader(BaseLoader): 'x-archive') def __init__(self): - self.sesh = requests.session() + #self.sesh = requests.session() + self.sesh = requests def load_resource(self, cdx, params): load_url = cdx.get('load_url') From 0b255819ff686f36aeeb2532be54a7db435639cd Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 15 Apr 2016 02:19:34 +0000 Subject: [PATCH 068/112] recorder warcwriter: allow skipping writing of only request or only response by overriding _is_write_req and _is_write_resp in subclass (todo: rethink the interface) --- recorder/recorderapp.py | 3 ++- recorder/warcwriter.py | 23 ++++++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 505f4131..5572bc45 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -37,7 +37,8 @@ class RecorderApp(object): self.skip_filters = skip_filters - def create_default_filters(self, kwargs): + @staticmethod + def create_default_filters(kwargs): skip_filters = [SkipRangeRequestFilter()] accept_colls = kwargs.get('accept_colls') diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 92ba5bce..c20d5776 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -44,7 +44,7 @@ class BaseWARCWriter(object): FILE_TEMPLATE = 'rec-{timestamp}-{hostname}.warc.gz' def __init__(self, gzip=True, dedup_index=None, name='recorder', - header_filter=ExcludeNone()): + header_filter=ExcludeNone(), *args, **kwargs): self.gzip = gzip self.dedup_index = dedup_index self.rec_source_name = name @@ -85,13 +85,13 @@ class BaseWARCWriter(object): record.status_headers.headers_buff = buff def write_req_resp(self, req, resp, params): - url = resp.rec_headers.get('WARC-Target-Uri') + url = resp.rec_headers.get('WARC-Target-URI') dt = resp.rec_headers.get('WARC-Date') if not req.rec_headers.get('WARC-Record-ID'): req.rec_headers['WARC-Record-ID'] = self._make_warc_id() - req.rec_headers['WARC-Target-Uri'] = url + req.rec_headers['WARC-Target-URI'] = url req.rec_headers['WARC-Date'] = dt req.rec_headers['WARC-Type'] = 'request' #req.rec_headers['Content-Type'] = req.content_type @@ -137,7 +137,7 @@ class BaseWARCWriter(object): return record try: - url = record.rec_headers.get('WARC-Target-Uri') + url = record.rec_headers.get('WARC-Target-URI') digest = record.rec_headers.get('WARC-Payload-Digest') iso_dt = record.rec_headers.get('WARC-Date') result = self.dedup_index.lookup_revisit(params, digest, url, iso_dt) @@ -310,6 +310,12 @@ class MultiFileWARCWriter(BaseWARCWriter): self._close_file(out) return filename + def _is_write_resp(self, resp, params): + return True + + def _is_write_req(self, req, params): + return True + def _do_write_req_resp(self, req, resp, params): full_dir = res_template(self.dir_template, params) @@ -325,13 +331,16 @@ class MultiFileWARCWriter(BaseWARCWriter): is_new = True try: - url = resp.rec_headers.get('WARC-Target-Uri') + url = resp.rec_headers.get('WARC-Target-URI') print('Writing req/resp {0} to {1} '.format(url, filename)) start = out.tell() - self._write_warc_record(out, resp) - self._write_warc_record(out, req) + if self._is_write_resp(resp, params): + self._write_warc_record(out, resp) + + if self._is_write_req(req, params): + self._write_warc_record(out, req) out.flush() From 0370470e685c15db096c003f81cd6d80f78e657e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 15 Apr 2016 02:21:39 +0000 Subject: [PATCH 069/112] urlrewrite: http range: support skipping record for range requests not starting at 0- and performing async request, support converting unbounded 0- to non-ranged and back --- urlrewrite/rewriteinputreq.py | 43 +++++++++++++- urlrewrite/rewriterapp.py | 107 +++++++++++++++++++++++++++++----- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/urlrewrite/rewriteinputreq.py b/urlrewrite/rewriteinputreq.py index 10c929fc..fec5797b 100644 --- a/urlrewrite/rewriteinputreq.py +++ b/urlrewrite/rewriteinputreq.py @@ -3,10 +3,15 @@ from pywb.utils.loaders import extract_client_cookie from six import iteritems from six.moves.urllib.parse import urlsplit +import re #============================================================================= class RewriteInputRequest(DirectWSGIInputRequest): + RANGE_ARG_RX = re.compile('.*.googlevideo.com/videoplayback.*([&?]range=(\d+)-(\d+))') + + RANGE_HEADER = re.compile('bytes=(\d+)-(\d+)?') + def __init__(self, env, urlkey, url, rewriter): super(RewriteInputRequest, self).__init__(env) self.urlkey = urlkey @@ -38,7 +43,7 @@ class RewriteInputRequest(DirectWSGIInputRequest): elif name == 'HTTP_X_CSRFTOKEN': name = 'X-CSRFToken' - cookie_val = extract_client_cookie(env, 'csrftoken') + cookie_val = extract_client_cookie(self.env, 'csrftoken') if cookie_val: value = cookie_val @@ -86,3 +91,39 @@ class RewriteInputRequest(DirectWSGIInputRequest): return value + def extract_range(self): + use_206 = False + start = None + end = None + url = self.url + + range_h = self.env.get('HTTP_RANGE') + + if range_h: + m = self.RANGE_HEADER.match(range_h) + if m: + start = m.group(1) + end = m.group(2) + use_206 = True + + else: + m = self.RANGE_ARG_RX.match(url) + if m: + start = m.group(2) + end = m.group(3) + url = url[:m.start(1)] + url[m.end(1):] + use_206 = False + + if not start: + return None + + start = int(start) + + if end: + end = int(end) + else: + end = '' + + result = (url, start, end, use_206) + return result + diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 4d9836f4..b0d1e8f9 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -17,6 +17,7 @@ from urlrewrite.rewriteinputreq import RewriteInputRequest from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView from io import BytesIO +import gevent # ============================================================================ @@ -65,22 +66,33 @@ class RewriterApp(object): inputreq = RewriteInputRequest(request.environ, urlkey, url, self.content_rewriter) - req_data = inputreq.reconstruct_request(url) + mod_url = None + use_206 = False + rangeres = None - headers = {'Content-Length': len(req_data), - 'Content-Type': 'application/request'} + readd_range = False + async_record_url = None - if wb_url.is_latest_replay(): - closest = 'now' - else: - closest = wb_url.timestamp + if kwargs.get('type') == 'record': + rangeres = inputreq.extract_range() - upstream_url = self.get_upstream_url(url, wb_url, closest, kwargs) + if rangeres: + mod_url, start, end, use_206 = rangeres - r = requests.post(upstream_url, - data=BytesIO(req_data), - headers=headers, - stream=True) + # if bytes=0- Range request, + # simply remove the range and still proxy + if start == 0 and not end and use_206: + url = mod_url + wb_url.url = mod_url + inputreq.url = mod_url + + del request.environ['HTTP_RANGE'] + readd_range = True + else: + async_record_url = mod_url + + r = self._do_req(inputreq, url, wb_url, kwargs, + async_record_url is not None) if r.status_code >= 400: error = None @@ -98,6 +110,16 @@ class RewriterApp(object): data = dict(url=url, args=kwargs, error=error) raise HTTPError(r.status_code, exception=data) + if async_record_url: + #print('ASYNC REC', async_record_url) + request.environ.pop('HTTP_RANGE', '') + gevent.spawn(self._do_async_req, + inputreq, + async_record_url, + wb_url, + kwargs, + False) + record = self.loader.parse_record_stream(r.raw) cdx = CDXObject() @@ -107,6 +129,16 @@ class RewriterApp(object): self._add_custom_params(cdx, r.headers, kwargs) + if readd_range: + content_length = (record.status_headers. + get_header('Content-Length')) + try: + content_length = int(content_length) + record.status_headers.add_range(0, content_length, + content_length) + except (ValueError, TypeError): + pass + if self.is_ajax(): head_insert_func = None else: @@ -133,6 +165,54 @@ class RewriterApp(object): return gen + def _do_async_req(self, *args): + count = 0 + #print('ASYNC') + try: + r = self._do_req(*args) + while True: + buff = r.raw.read(8192) + count += len(buff) + if not buff: + return + except: + import traceback + traceback.print_exc() + + finally: + #print('CLOSING') + #print('READ ASYNC', count) + try: + r.raw.close() + except: + pass + + + def _do_req(self, inputreq, url, wb_url, kwargs, skip): + req_data = inputreq.reconstruct_request(url) + + headers = {'Content-Length': len(req_data), + 'Content-Type': 'application/request'} + + if skip: + headers['Recorder-Skip'] = '1' + + if wb_url.is_latest_replay(): + closest = 'now' + else: + closest = wb_url.timestamp + + upstream_url = self.get_upstream_url(url, wb_url, closest, kwargs) + r = requests.post(upstream_url, + data=BytesIO(req_data), + headers=headers, + stream=True) + + return r + + + + def get_host_prefix(self): return request.urlparts.scheme + '://' + request.urlparts.netloc @@ -151,7 +231,7 @@ class RewriterApp(object): if referrer.startswith(full_prefix): referrer = referrer[len(full_prefix):] - request.environ['HTTP_REFERER'] = referrer + request.environ['HTTP_REFERER'] = WbUrl(referrer).url return True return False @@ -168,6 +248,7 @@ class RewriterApp(object): raise NotImplemented() def _add_custom_params(self, cdx, headers, kwargs): + cdx['is_live'] = 'true' pass def get_top_frame_params(self, wb_url, kwargs): From b056acd88e149deeefdf1bf10fc323c64f99850c Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 15 Apr 2016 04:01:36 +0000 Subject: [PATCH 070/112] urlrewrite: add support for index query --- urlrewrite/rewriterapp.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index b0d1e8f9..84e536a9 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -210,8 +210,12 @@ class RewriterApp(object): return r - - + def do_query(self, wb_url, kwargs): + upstream_url = self.get_upstream_url(wb_url.url, wb_url, 'now', kwargs) + upstream_url = upstream_url.replace('/resource/postreq', '/index') + r = requests.get(upstream_url + '&output=json') + print(r.text) + return r.text def get_host_prefix(self): return request.urlparts.scheme + '://' + request.urlparts.netloc @@ -255,6 +259,9 @@ class RewriterApp(object): return None def handle_custom_response(self, wb_url, full_prefix, host_prefix, kwargs): + if wb_url.is_query(): + return self.do_query(wb_url, kwargs) + if self.framed_replay and wb_url.mod == self.frame_mod: extra_params = self.get_top_frame_params(wb_url, kwargs) return self.frame_insert_view.get_top_frame(wb_url, From 3b6cab1730e25610c12154799d957addb7337ade Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 25 Apr 2016 12:03:23 -0700 Subject: [PATCH 071/112] urlrewrite: remove dependency on bottle from rewriterapp, add overridable error and query views, with extensible get_query_params() and process_cdx_query() to extend cdx for query view add get_top_url() for adding custom top_url for frame insert add call_with_params() for adding custom params to environ --- urlrewrite/rewriterapp.py | 182 ++++++++++++++++++++++++++++--------- urlrewrite/templateview.py | 6 +- 2 files changed, 140 insertions(+), 48 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 84e536a9..fc630d8f 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -1,30 +1,41 @@ import requests -from bottle import request, response, HTTPError - from pywb.rewrite.rewrite_content import RewriteContent from pywb.rewrite.wburl import WbUrl from pywb.rewrite.url_rewriter import UrlRewriter +from pywb.utils.wbexception import WbException from pywb.utils.canonicalize import canonicalize from pywb.utils.timeutils import http_date_to_timestamp from pywb.utils.loaders import extract_client_cookie from pywb.cdx.cdxobject import CDXObject from pywb.warc.recordloader import ArcWarcRecordLoader +from pywb.framework.wbrequestresponse import WbResponse + from urlrewrite.rewriteinputreq import RewriteInputRequest -from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView +from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView, BaseInsertView from io import BytesIO import gevent +import json + + +# ============================================================================ +class UpstreamException(WbException): + def __init__(self, status_code, url, details): + super(UpstreamException, self).__init__(url=url, msg=details) + self.status_code = status_code # ============================================================================ class RewriterApp(object): - def __init__(self, framed_replay=False, jinja_env=None): + def __init__(self, framed_replay=False, jinja_env=None, config=None): self.loader = ArcWarcRecordLoader() + config = config or {} + self.framed_replay = framed_replay self.frame_mod = '' self.replay_mod = 'mp_' @@ -37,33 +48,55 @@ class RewriterApp(object): jinja_env = JinjaEnv(globals={'static_path': 'static/__pywb'}) self.jinja_env = jinja_env + self.head_insert_view = HeadInsertView(self.jinja_env, 'head_insert.html', 'banner.html') self.frame_insert_view = TopFrameView(self.jinja_env, 'frame_insert.html', 'banner.html') + self.error_view = BaseInsertView(self.jinja_env, 'error.html') + self.query_view = BaseInsertView(self.jinja_env, config.get('query_html', 'query.html')) - def render_content(self, wb_url, **kwargs): + def call_with_params(self, **kwargs): + def run_app(environ, start_response): + environ['pywb.kwargs'] = kwargs + return self(environ, start_response) + + return run_app + + def __call__(self, environ, start_response): + wb_url = self.get_wburl(environ) + kwargs = environ.get('pywb.kwargs', {}) + + try: + response = self.render_content(wb_url, kwargs, environ) + except UpstreamException as ue: + response = self.handle_error(environ, ue) + + return response(environ, start_response) + + def render_content(self, wb_url, kwargs, environ): wb_url = WbUrl(wb_url) #if wb_url.mod == 'vi_': # return self._get_video_info(wbrequest) - host_prefix = self.get_host_prefix() - rel_prefix = self.get_rel_prefix() + host_prefix = self.get_host_prefix(environ) + rel_prefix = self.get_rel_prefix(environ) full_prefix = host_prefix + rel_prefix - resp = self.handle_custom_response(wb_url, full_prefix, host_prefix, kwargs) + resp = self.handle_custom_response(environ, wb_url, + full_prefix, host_prefix, kwargs) if resp is not None: - return resp + return WbResponse.text_response(resp, content_type='text/html') urlrewriter = UrlRewriter(wb_url, prefix=full_prefix, full_prefix=full_prefix, rel_prefix=rel_prefix) - self.unrewrite_referrer() + self.unrewrite_referrer(environ) url = wb_url.url urlkey = canonicalize(url) - inputreq = RewriteInputRequest(request.environ, urlkey, url, + inputreq = RewriteInputRequest(environ, urlkey, url, self.content_rewriter) mod_url = None @@ -86,7 +119,7 @@ class RewriterApp(object): wb_url.url = mod_url inputreq.url = mod_url - del request.environ['HTTP_RANGE'] + del environ['HTTP_RANGE'] readd_range = True else: async_record_url = mod_url @@ -107,12 +140,12 @@ class RewriterApp(object): else: error = '' - data = dict(url=url, args=kwargs, error=error) - raise HTTPError(r.status_code, exception=data) + details = dict(args=kwargs, error=error) + raise UpstreamException(r.status_code, url=url, details=details) if async_record_url: #print('ASYNC REC', async_record_url) - request.environ.pop('HTTP_RANGE', '') + environ.pop('HTTP_RANGE', '') gevent.spawn(self._do_async_req, inputreq, async_record_url, @@ -139,14 +172,16 @@ class RewriterApp(object): except (ValueError, TypeError): pass - if self.is_ajax(): + if self.is_ajax(environ): head_insert_func = None else: + top_url = self.get_top_url(full_prefix, wb_url, cdx, kwargs) head_insert_func = (self.head_insert_view. create_insert_func(wb_url, full_prefix, host_prefix, - request.environ, + top_url, + environ, self.framed_replay)) result = self.content_rewriter.rewrite_content(urlrewriter, @@ -157,17 +192,15 @@ class RewriterApp(object): cdx) status_headers, gen, is_rw = result + return WbResponse(status_headers, gen) - response.status = int(status_headers.get_statuscode()) - - for n, v in status_headers.headers: - response.add_header(n, v) - - return gen + def get_top_url(self, full_prefix, wb_url, cdx, kwargs): + top_url = full_prefix + top_url += wb_url.to_str(mod='') + return top_url def _do_async_req(self, *args): count = 0 - #print('ASYNC') try: r = self._do_req(*args) while True: @@ -180,13 +213,17 @@ class RewriterApp(object): traceback.print_exc() finally: - #print('CLOSING') - #print('READ ASYNC', count) try: r.raw.close() except: pass + def handle_error(self, environ, ue): + error_html = self.error_view.render_to_string(environ, + err_msg=ue.url, + err_details=ue.msg) + + return WbResponse.text_response(error_html, content_type='text/html') def _do_req(self, inputreq, url, wb_url, kwargs, skip): req_data = inputreq.reconstruct_request(url) @@ -213,36 +250,92 @@ class RewriterApp(object): def do_query(self, wb_url, kwargs): upstream_url = self.get_upstream_url(wb_url.url, wb_url, 'now', kwargs) upstream_url = upstream_url.replace('/resource/postreq', '/index') - r = requests.get(upstream_url + '&output=json') - print(r.text) + + upstream_url += '&output=json' + upstream_url += '&from=' + wb_url.timestamp + '&to=' + wb_url.end_timestamp + + r = requests.get(upstream_url) + return r.text - def get_host_prefix(self): - return request.urlparts.scheme + '://' + request.urlparts.netloc + def handle_query(self, environ, wb_url, kwargs): + res = self.do_query(wb_url, kwargs) - def get_rel_prefix(self): - return request.script_name + def format_cdx(text): + cdx_lines = text.rstrip().split('\n') + for cdx in cdx_lines: + if not cdx: + continue - def get_full_prefix(self): - return self.get_host_prefix() + self.get_rel_prefix() + cdx = json.loads(cdx) + self.process_query_cdx(cdx, wb_url, kwargs) + yield cdx - def unrewrite_referrer(self): - referrer = request.environ.get('HTTP_REFERER') + prefix = self.get_full_prefix(environ) + + params = dict(url=wb_url.url, + prefix=prefix, + cdx_lines=list(format_cdx(res))) + + extra_params = self.get_query_params(wb_url, kwargs) + if extra_params: + params.update(extra_params) + + return self.query_view.render_to_string(environ, **params) + + def process_query_cdx(self, cdx, wb_url, kwargs): + return + + def get_query_params(self, wb_url, kwargs): + return None + + def get_host_prefix(self, environ): + #return request.urlparts.scheme + '://' + request.urlparts.netloc + url = environ['wsgi.url_scheme'] + '://' + if environ.get('HTTP_HOST'): + url += environ['HTTP_HOST'] + else: + url += environ['SERVER_NAME'] + if environ['wsgi.url_scheme'] == 'https': + if environ['SERVER_PORT'] != '443': + url += ':' + environ['SERVER_PORT'] + else: + if environ['SERVER_PORT'] != '80': + url += ':' + environ['SERVER_PORT'] + + return url + + def get_rel_prefix(self, environ): + #return request.script_name + return environ.get('SCRIPT_NAME') + '/' + + def get_full_prefix(self, environ): + return self.get_host_prefix(environ) + self.get_rel_prefix(environ) + + def get_wburl(self, environ): + wb_url = environ.get('PATH_INFO', '/')[1:] + if environ.get('QUERY_STRING'): + wb_url += '?' + environ.get('QUERY_STRING') + + return wb_url + + def unrewrite_referrer(self, environ): + referrer = environ.get('HTTP_REFERER') if not referrer: return False - full_prefix = self.get_full_prefix() + full_prefix = self.get_full_prefix(environ) if referrer.startswith(full_prefix): referrer = referrer[len(full_prefix):] - request.environ['HTTP_REFERER'] = WbUrl(referrer).url + environ['HTTP_REFERER'] = WbUrl(referrer).url return True return False - def is_ajax(self): - value = request.environ.get('HTTP_X_REQUESTED_WITH') - value = value or request.environ.get('HTTP_X_PYWB_REQUESTED_WITH') + def is_ajax(self, environ): + value = environ.get('HTTP_X_REQUESTED_WITH') + value = value or environ.get('HTTP_X_PYWB_REQUESTED_WITH') if value and value.lower() == 'xmlhttprequest': return True @@ -258,16 +351,17 @@ class RewriterApp(object): def get_top_frame_params(self, wb_url, kwargs): return None - def handle_custom_response(self, wb_url, full_prefix, host_prefix, kwargs): + def handle_custom_response(self, environ, wb_url, full_prefix, host_prefix, kwargs): if wb_url.is_query(): - return self.do_query(wb_url, kwargs) + return self.handle_query(environ, wb_url, kwargs) + #return self.do_query(wb_url, kwargs) if self.framed_replay and wb_url.mod == self.frame_mod: extra_params = self.get_top_frame_params(wb_url, kwargs) return self.frame_insert_view.get_top_frame(wb_url, full_prefix, host_prefix, - request.environ, + environ, self.frame_mod, self.replay_mod, coll='', diff --git a/urlrewrite/templateview.py b/urlrewrite/templateview.py index 19039567..804727a2 100644 --- a/urlrewrite/templateview.py +++ b/urlrewrite/templateview.py @@ -87,7 +87,7 @@ class JinjaEnv(object): # ============================================================================ class BaseInsertView(object): - def __init__(self, jenv, insert_file, banner_file): + def __init__(self, jenv, insert_file, banner_file=''): self.jenv = jenv self.insert_file = insert_file self.banner_file = banner_file @@ -106,6 +106,7 @@ class HeadInsertView(BaseInsertView): def create_insert_func(self, wb_url, wb_prefix, host_prefix, + top_url, env, is_framed, coll='', @@ -113,9 +114,6 @@ class HeadInsertView(BaseInsertView): url = wb_url.get_url() - top_url = wb_prefix - top_url += wb_url.to_str(mod='') - include_wombat = not wb_url.is_banner_only wbrequest = {'host_prefix': host_prefix, From a82e2785c774ca701f685bb363ea5a85a62a8c9b Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 25 Apr 2016 14:29:28 -0700 Subject: [PATCH 072/112] tests: add basic test for rewriterapp --- setup.py | 2 +- urlrewrite/rewriterapp.py | 1 - urlrewrite/test/__init__.py | 0 urlrewrite/test/simpleapp.py | 46 ++++++++++++++++++++++++++++++++ urlrewrite/test/test_rewriter.py | 39 +++++++++++++++++++++++++++ urlrewrite/test/uwsgi.ini | 18 +++++++++++++ 6 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 urlrewrite/test/__init__.py create mode 100644 urlrewrite/test/simpleapp.py create mode 100644 urlrewrite/test/test_rewriter.py create mode 100644 urlrewrite/test/uwsgi.ini diff --git a/setup.py b/setup.py index fa3083d8..b2829f78 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ class PyTest(TestCommand): import pytest import sys import os - cmdline = ' --cov-config .coveragerc --cov ./ -vv webagg/test/ recorder/test/' + cmdline = ' --cov-config .coveragerc --cov ./ -vv webagg/test/ recorder/test/ urlrewrite/test/' errcode = pytest.main(cmdline) sys.exit(errcode) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index fc630d8f..9ccc13ad 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -13,7 +13,6 @@ from pywb.cdx.cdxobject import CDXObject from pywb.warc.recordloader import ArcWarcRecordLoader from pywb.framework.wbrequestresponse import WbResponse - from urlrewrite.rewriteinputreq import RewriteInputRequest from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView, BaseInsertView diff --git a/urlrewrite/test/__init__.py b/urlrewrite/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/urlrewrite/test/simpleapp.py b/urlrewrite/test/simpleapp.py new file mode 100644 index 00000000..046691ea --- /dev/null +++ b/urlrewrite/test/simpleapp.py @@ -0,0 +1,46 @@ +from gevent.monkey import patch_all; patch_all() + +from bottle import run, Bottle, request, response + +from six.moves.urllib.parse import quote + +from pywb.utils.loaders import LocalFileLoader +import mimetypes + +from urlrewrite.rewriterapp import RewriterApp + + +# ============================================================================ +class RWApp(RewriterApp): + def __init__(self, upstream_url): + self.upstream_url = upstream_url + self.app = Bottle() + self.block_loader = LocalFileLoader() + self.init_routes() + super(RWApp, self).__init__(True) + + def get_upstream_url(self, url, wb_url, closest, kwargs): + return self.upstream_url.format(url=quote(url), + closest=closest, + type=kwargs.get('type')) + + def init_routes(self): + @self.app.get('/static/__pywb/') + def server_static(filepath): + data = self.block_loader.load('pywb/static/' + filepath) + guessed = mimetypes.guess_type(filepath) + if guessed[0]: + response.headers['Content-Type'] = guessed[0] + + return data + + self.app.mount('/live/', self.call_with_params(type='live')) + self.app.mount('/replay/', self.call_with_params(type='replay-testdata')) + + +# ============================================================================ +if __name__ == "__main__": + rwapp = RWApp('http://localhost:8080/{type}/resource/postreq?url={url}&closest={closest}') + rwapp.app.run(port=8090) + + diff --git a/urlrewrite/test/test_rewriter.py b/urlrewrite/test/test_rewriter.py new file mode 100644 index 00000000..66cbffc6 --- /dev/null +++ b/urlrewrite/test/test_rewriter.py @@ -0,0 +1,39 @@ + +from webagg.test.testutils import LiveServerTests, BaseTestClass + +from .simpleapp import RWApp + +import os +import webtest + + +class TestRewriter(LiveServerTests, BaseTestClass): + @classmethod + def setup_class(cls): + super(TestRewriter, cls).setup_class() + cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) + cls.upstream_url += '/{type}/resource/postreq?url={url}&closest={closest}' + + cls.app = RWApp(cls.upstream_url) + cls.testapp = webtest.TestApp(cls.app.app) + + def test_replay(self): + resp = self.testapp.get('/live/mp_/http://example.com/') + resp.charset = 'utf-8' + + assert '"http://localhost:80/live/mp_/http://www.iana.org/domains/example"' in resp.text + + assert 'wbinfo.url = "http://example.com/"' + + def test_top_frame(self): + resp = self.testapp.get('/live/http://example.com/') + resp.charset = 'utf-8' + + assert '"http://localhost:80/live/mp_/http://example.com/"' in resp.text + + assert 'wbinfo.capture_url = "http://example.com/"' in resp.text + + + + + diff --git a/urlrewrite/test/uwsgi.ini b/urlrewrite/test/uwsgi.ini new file mode 100644 index 00000000..ea3a22c3 --- /dev/null +++ b/urlrewrite/test/uwsgi.ini @@ -0,0 +1,18 @@ +[uwsgi] +if-not-env = PORT +http-socket = :8090 +endif = + +master = true +buffer-size = 65536 +die-on-term = true + +if-env = VIRTUAL_ENV +venv = $(VIRTUAL_ENV) +endif = + +gevent = 100 + +wsgi = testapp + + From f119d057247a0a98770c6eb8f88ec5d8d6b07f53 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 27 Apr 2016 09:52:56 -0700 Subject: [PATCH 073/112] recorder: fix simplerec init tests: improve tests for skipping request and response headers --- recorder/test/simplerec.py | 3 +- recorder/test/test_recorder.py | 55 ++++++++++++++++++++-------------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/recorder/test/simplerec.py b/recorder/test/simplerec.py index 84f83736..a5feb8e5 100644 --- a/recorder/test/simplerec.py +++ b/recorder/test/simplerec.py @@ -10,7 +10,8 @@ upstream_url = 'http://localhost:8080' target = './_recordings/' -dedup_index = WritableRedisIndexer('redis://localhost/2/rec:cdxj', +dedup_index = WritableRedisIndexer( + redis_url='redis://localhost/2/rec:cdxj', file_key_template='rec:warc', rel_path_template=target, dupe_policy=SkipDupePolicy()) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 9a07075f..857efb79 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -23,6 +23,7 @@ from pywb.utils.statusandheaders import StatusAndHeadersParser from pywb.utils.bufferedreaders import DecompressingBufferedReader from pywb.warc.recordloader import ArcWarcRecordLoader from pywb.warc.cdxindexer import write_cdx_index +from pywb.warc.archiveiterator import ArchiveIterator from six.moves.urllib.parse import quote, unquote from io import BytesIO @@ -33,7 +34,9 @@ general_req_data = "\ GET {path} HTTP/1.1\r\n\ Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n\ User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36\r\n\ +X-Other: foo\r\n\ Host: {host}\r\n\ +Cookie: boo=far\r\n\ \r\n" @@ -82,6 +85,25 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert all(x.endswith('.warc.gz') for x in files) return files, coll_dir + def _load_resp_req(self, base_path): + warcs = os.listdir(base_path) + assert len(warcs) == 1 + warc = warcs[0] + + stored_resp = None + stored_req = None + + with open(os.path.join(base_path, warc), 'rb') as fh: + for rec in ArchiveIterator(fh)(): + if rec.rec_type == 'response': + stored_resp = rec + elif rec.rec_type == 'request': + stored_req = rec + + assert stored_resp is not None + assert stored_req is not None + return stored_req, stored_resp + def test_record_warc_1(self): recorder_app = RecorderApp(self.upstream_url, PerRecordWARCWriter(to_path(self.root_dir + '/warcs/'))) @@ -127,19 +149,13 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert ('Set-Cookie', 'name=value; Path=/') in record.status_headers.headers assert ('Set-Cookie', 'foo=bar; Path=/') in record.status_headers.headers - warcs = os.listdir(base_path) + stored_req, stored_resp = self._load_resp_req(base_path) - stored_rec = None - for warc in warcs: - with open(os.path.join(base_path, warc), 'rb') as fh: - decomp = DecompressingBufferedReader(fh) - stored_rec = ArcWarcRecordLoader().parse_record_stream(decomp) - if stored_rec.rec_type == 'response': - break + assert ('Set-Cookie', 'name=value; Path=/') in stored_resp.status_headers.headers + assert ('Set-Cookie', 'foo=bar; Path=/') in stored_resp.status_headers.headers - assert stored_rec is not None - assert ('Set-Cookie', 'name=value; Path=/') in stored_rec.status_headers.headers - assert ('Set-Cookie', 'foo=bar; Path=/') in stored_rec.status_headers.headers + assert ('X-Other', 'foo') in stored_req.status_headers.headers + assert ('Cookie', 'boo=far') in stored_req.status_headers.headers def test_record_cookies_skip_header(self): base_path = to_path(self.root_dir + '/warcs/cookieskip/') @@ -156,20 +172,13 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert ('Set-Cookie', 'name=value; Path=/') in record.status_headers.headers assert ('Set-Cookie', 'foo=bar; Path=/') in record.status_headers.headers - warcs = os.listdir(base_path) + stored_req, stored_resp = self._load_resp_req(base_path) - stored_rec = None - for warc in warcs: - with open(os.path.join(base_path, warc), 'rb') as fh: - decomp = DecompressingBufferedReader(fh) - stored_rec = ArcWarcRecordLoader().parse_record_stream(decomp) - if stored_rec.rec_type == 'response': - break - - assert stored_rec is not None - assert ('Set-Cookie', 'name=value; Path=/') not in stored_rec.status_headers.headers - assert ('Set-Cookie', 'foo=bar; Path=/') not in stored_rec.status_headers.headers + assert ('Set-Cookie', 'name=value; Path=/') not in stored_resp.status_headers.headers + assert ('Set-Cookie', 'foo=bar; Path=/') not in stored_resp.status_headers.headers + assert ('X-Other', 'foo') in stored_req.status_headers.headers + assert ('Cookie', 'boo=far') not in stored_req.status_headers.headers def test_record_skip_wrong_coll(self): recorder_app = RecorderApp(self.upstream_url, From 9010e526636b1e92df01771b910c41de4191edbf Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 27 Apr 2016 10:15:48 -0700 Subject: [PATCH 074/112] urlrewrite: refactor simpleapp to support live/record/replay --- urlrewrite/test/simpleapp.py | 27 +++++++++++++++++++-------- urlrewrite/test/test_rewriter.py | 7 ++++--- urlrewrite/test/uwsgi.ini | 2 +- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/urlrewrite/test/simpleapp.py b/urlrewrite/test/simpleapp.py index 046691ea..ee620de3 100644 --- a/urlrewrite/test/simpleapp.py +++ b/urlrewrite/test/simpleapp.py @@ -12,17 +12,17 @@ from urlrewrite.rewriterapp import RewriterApp # ============================================================================ class RWApp(RewriterApp): - def __init__(self, upstream_url): - self.upstream_url = upstream_url + def __init__(self, upstream_urls): + self.upstream_urls = upstream_urls self.app = Bottle() self.block_loader = LocalFileLoader() self.init_routes() super(RWApp, self).__init__(True) def get_upstream_url(self, url, wb_url, closest, kwargs): - return self.upstream_url.format(url=quote(url), - closest=closest, - type=kwargs.get('type')) + type = kwargs.get('type') + return self.upstream_urls[type].format(url=quote(url), + closest=closest) def init_routes(self): @self.app.get('/static/__pywb/') @@ -35,12 +35,23 @@ class RWApp(RewriterApp): return data self.app.mount('/live/', self.call_with_params(type='live')) - self.app.mount('/replay/', self.call_with_params(type='replay-testdata')) + self.app.mount('/record/', self.call_with_params(type='record')) + self.app.mount('/replay/', self.call_with_params(type='replay')) + + @staticmethod + def create_app(replay_port=8080, record_port=8010): + upstream_urls = {'live': 'http://localhost:%s/live/resource/postreq?url={url}&closest={closest}' % replay_port, + 'record': 'http://localhost:%s/live/resource/postreq?url={url}&closest={closest}' % record_port, + 'replay': 'http://localhost:%s/replay/resource/postreq?url={url}&closest={closest}' % replay_port, + } + + rwapp = RWApp(upstream_urls) + return rwapp # ============================================================================ if __name__ == "__main__": - rwapp = RWApp('http://localhost:8080/{type}/resource/postreq?url={url}&closest={closest}') - rwapp.app.run(port=8090) + application = RWApp.create_app() + application.app.run(port=8090, server='gevent') diff --git a/urlrewrite/test/test_rewriter.py b/urlrewrite/test/test_rewriter.py index 66cbffc6..7f10a280 100644 --- a/urlrewrite/test/test_rewriter.py +++ b/urlrewrite/test/test_rewriter.py @@ -11,10 +11,11 @@ class TestRewriter(LiveServerTests, BaseTestClass): @classmethod def setup_class(cls): super(TestRewriter, cls).setup_class() - cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) - cls.upstream_url += '/{type}/resource/postreq?url={url}&closest={closest}' + #cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) + #cls.upstream_url += '/{type}/resource/postreq?url={url}&closest={closest}' + #cls.app = RWApp(cls.upstream_url) - cls.app = RWApp(cls.upstream_url) + cls.app = RWApp.create_app(replay_port=cls.server.port) cls.testapp = webtest.TestApp(cls.app.app) def test_replay(self): diff --git a/urlrewrite/test/uwsgi.ini b/urlrewrite/test/uwsgi.ini index ea3a22c3..7acd4f0b 100644 --- a/urlrewrite/test/uwsgi.ini +++ b/urlrewrite/test/uwsgi.ini @@ -13,6 +13,6 @@ endif = gevent = 100 -wsgi = testapp +wsgi = urlrewrite.test.simpleapp From 7a0dd463cd71b5407f2e7228dee07f35623f9857 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 27 Apr 2016 10:16:54 -0700 Subject: [PATCH 075/112] webagg: responseloader: use urllib3 directly instead of requests to take advantage of connection pooling w/o storing/sharing cookies --- webagg/responseloader.py | 41 ++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index dced8437..576b14d6 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -13,12 +13,13 @@ from pywb.warc.resolvingloader import ResolvingLoader from six.moves.urllib.parse import urlsplit -from io import BytesIO +#from io import BytesIO import uuid import six import itertools -import requests +#import requests +import urllib3 #============================================================================= @@ -216,8 +217,12 @@ class LiveWebLoader(BaseLoader): 'x-archive') def __init__(self): - #self.sesh = requests.session() - self.sesh = requests + self.num_retries = 3 + self.num_pools = 10 + self.num_conn_per_pool = 10 + + self.pool = urllib3.PoolManager(num_pools=self.num_pools, + maxsize=self.num_conn_per_pool) def load_resource(self, cdx, params): load_url = cdx.get('load_url') @@ -237,13 +242,17 @@ class LiveWebLoader(BaseLoader): data = input_req.get_req_body() try: - upstream_res = self.sesh.request(url=load_url, - method=method, - stream=True, - allow_redirects=False, + upstream_res = self.pool.urlopen(method=method, + url=load_url, + body=data, headers=req_headers, - data=data, + redirect=False, + assert_same_host=False, + preload_content=False, + decode_content=False, + retries=self.num_retries, timeout=params.get('_timeout')) + except Exception as e: raise LiveResourceException(load_url) @@ -259,26 +268,26 @@ class LiveWebLoader(BaseLoader): agg_type = upstream_res.headers.get('WebAgg-Type') if agg_type == 'warc': cdx['source'] = upstream_res.headers.get('WebAgg-Source-Coll') - return None, upstream_res.headers, upstream_res.raw + return None, upstream_res.headers, upstream_res self.raise_on_self_redirect(params, cdx, - str(upstream_res.status_code), + str(upstream_res.status), upstream_res.headers.get('Location')) - if upstream_res.raw.version == 11: + if upstream_res.version == 11: version = '1.1' else: version = '1.0' status = 'HTTP/{version} {status} {reason}\r\n' status = status.format(version=version, - status=upstream_res.status_code, + status=upstream_res.status, reason=upstream_res.reason) http_headers_buff = status - orig_resp = upstream_res.raw._original_response + orig_resp = upstream_res._original_response try: #pragma: no cover #PY 3 @@ -301,7 +310,7 @@ class LiveWebLoader(BaseLoader): http_headers_buff = http_headers_buff.encode('latin-1') try: - fp = upstream_res.raw._fp.fp + fp = upstream_res._fp.fp if hasattr(fp, 'raw'): #pragma: no cover fp = fp.raw remote_ip = fp._sock.getpeername()[0] @@ -324,7 +333,7 @@ class LiveWebLoader(BaseLoader): len(http_headers_buff)) warc_headers = StatusAndHeaders('WARC/1.0', warc_headers.items()) - return (warc_headers, http_headers_buff, upstream_res.raw) + return (warc_headers, http_headers_buff, upstream_res) @staticmethod def _make_warc_id(id_=None): From 0fbae1c7f8b3c7e8e1abe7d0c03adc2afc2067b4 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 30 Apr 2016 10:19:20 -0700 Subject: [PATCH 076/112] recorder: ensure warcinfo record has a content-type --- recorder/warcwriter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index c20d5776..f74ddba7 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -120,6 +120,7 @@ class BaseWARCWriter(object): if filename: headers['WARC-Filename'] = filename headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) + headers['Content-Type'] = 'application/warc-fields' warcinfo = BytesIO() for n, v in six.iteritems(kwargs): From 228ca58c5be8910c039c8ac06d0ad6d85916061e Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 30 Apr 2016 13:07:53 -0700 Subject: [PATCH 077/112] recorer: actually fix content-type on warcinfo, add to test! --- recorder/test/test_recorder.py | 1 + recorder/warcwriter.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 857efb79..97a47f46 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -418,6 +418,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) parsed_record = ArcWarcRecordLoader().parse_record_stream(buff) assert parsed_record.rec_headers.get_header('WARC-Type') == 'warcinfo' + assert parsed_record.rec_headers.get_header('Content-Type') == 'application/warc-fields' assert parsed_record.rec_headers.get_header('WARC-Filename') == 'testfile.warc.gz' buff = parsed_record.stream.read().decode('utf-8') diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index f74ddba7..7b82d6a0 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -120,7 +120,6 @@ class BaseWARCWriter(object): if filename: headers['WARC-Filename'] = filename headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) - headers['Content-Type'] = 'application/warc-fields' warcinfo = BytesIO() for n, v in six.iteritems(kwargs): @@ -174,7 +173,7 @@ class BaseWARCWriter(object): if not content_type: content_type = self.WARC_RECORDS[record.rec_headers['WARC-Type']] - self._header(out, 'Content-Type', record.content_type) + self._header(out, 'Content-Type', content_type) if record.rec_headers['WARC-Type'] == 'revisit': http_headers_only = True From ab3af90df22e85f9494c610becdd6b03a2446b51 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 4 May 2016 16:39:47 -0700 Subject: [PATCH 078/112] cookie_tracker: add support for redis-based subdomain cookie tracker, which temp caches cookies with Domain= set in redis and passes them upstream when rewriting. addresses webrecorder/webrecorder#79 --- urlrewrite/cookies.py | 139 +++++++++++++++++++++++++++++++ urlrewrite/rewriteinputreq.py | 5 ++ urlrewrite/rewriterapp.py | 30 ++++++- urlrewrite/test/simpleapp.py | 17 +++- urlrewrite/test/test_rewriter.py | 11 ++- 5 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 urlrewrite/cookies.py diff --git a/urlrewrite/cookies.py b/urlrewrite/cookies.py new file mode 100644 index 00000000..5ebe1144 --- /dev/null +++ b/urlrewrite/cookies.py @@ -0,0 +1,139 @@ +from pywb.rewrite.cookie_rewriter import WbUrlBaseCookieRewriter +from pywb.utils.timeutils import datetime_to_http_date +from six.moves.http_cookiejar import CookieJar, DefaultCookiePolicy + +import redis + +import tldextract +import time +import datetime +import six + + +# ============================================================================= +class CookieTracker(object): + def __init__(self, redis): + self.redis = redis + + def get_rewriter(self, url_rewriter, cookie_key): + return DomainCacheCookieRewriter(url_rewriter, + self.redis, + cookie_key) + + def get_cookie_headers(self, url, cookie_key): + subds = self.get_subdomains(url) + if not subds: + return None, None + + with redis.utils.pipeline(self.redis) as pi: + for x in subds: + pi.hgetall(cookie_key + '.' + x) + + all_res = pi.execute() + + cookies = [] + set_cookies = [] + + for res in all_res: + if not res: + continue + + for n, v in six.iteritems(res): + n = n.decode('utf-8') + v = v.decode('utf-8') + full = n + '=' + v + cookies.append(full.split(';')[0]) + set_cookies.append(('Set-Cookie', full + '; Max-Age=120')) + + cookies = ';'.join(cookies) + return cookies, set_cookies + + @staticmethod + def get_subdomains(url): + tld = tldextract.extract(url) + + if not tld.subdomain: + return None + + main = tld.domain + '.' + tld.suffix + full = tld.subdomain + '.' + main + + def get_all_subdomains(main, full): + doms = [] + while main != full: + full = full.split('.', 1)[1] + doms.append(full) + + return doms + + all_subs = get_all_subdomains(main, full) + return all_subs + + +# ============================================================================= +class DomainCacheCookieRewriter(WbUrlBaseCookieRewriter): + def __init__(self, url_rewriter, redis, cookie_key): + super(DomainCacheCookieRewriter, self).__init__(url_rewriter) + self.redis = redis + self.cookie_key = cookie_key + + def rewrite_cookie(self, name, morsel): + # if domain set, no choice but to expand cookie path to root + domain = morsel.pop('domain', '') + + if domain: + #if morsel.get('max-age'): + # morsel['max-age'] = int(morsel['max-age']) + + #self.cookiejar.set_cookie(self.morsel_to_cookie(morsel)) + #print(morsel, self.cookie_key + domain) + + string = morsel.value + if morsel.get('path'): + string += '; Path=' + morsel.get('path') + + if morsel.get('httponly'): + string += '; HttpOnly' + + if morsel.get('secure'): + string += '; Secure' + + with redis.utils.pipeline(self.redis) as pi: + pi.hset(self.cookie_key + domain, morsel.key, string) + pi.expire(self.cookie_key + domain, 120) + + # else set cookie to rewritten path + if morsel.get('path'): + morsel['path'] = self.url_rewriter.rewrite(morsel['path']) + + return morsel + + def get_expire_sec(self, morsel): + expires = None + + if morsel.get('max-age'): + return int(morsel['max-age']) + + expires = morsel.get('expires') + if not expires: + return None + + expires = expires.replace(' UTC', ' GMT') + + try: + expires = time.strptime(expires, '%a, %d-%b-%Y %H:%M:%S GMT') + except: + pass + + try: + expires = time.strptime(expires, '%a, %d %b %Y %H:%M:%S GMT') + except: + pass + + expires = time.mktime(expires) + expires = expires - time.timezone - time.time() + return expires + + +# ============================================================================ + diff --git a/urlrewrite/rewriteinputreq.py b/urlrewrite/rewriteinputreq.py index fec5797b..18d84905 100644 --- a/urlrewrite/rewriteinputreq.py +++ b/urlrewrite/rewriteinputreq.py @@ -17,6 +17,7 @@ class RewriteInputRequest(DirectWSGIInputRequest): self.urlkey = urlkey self.url = url self.rewriter = rewriter + self.extra_cookie = None self.splits = urlsplit(self.url) @@ -76,6 +77,10 @@ class RewriteInputRequest(DirectWSGIInputRequest): if value: headers['Cookie'] = value + if self.extra_cookie: + headers['Cookie'] = self.extra_cookie + ';' + headers.get('Cookie', '') + print('Cookie', headers['Cookie']) + return headers def _req_cookie_rewrite(self, value): diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 9ccc13ad..a8f5122a 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -17,6 +17,7 @@ from urlrewrite.rewriteinputreq import RewriteInputRequest from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView, BaseInsertView from io import BytesIO + import gevent import json @@ -53,6 +54,8 @@ class RewriterApp(object): self.error_view = BaseInsertView(self.jinja_env, 'error.html') self.query_view = BaseInsertView(self.jinja_env, config.get('query_html', 'query.html')) + self.cookie_tracker = None + def call_with_params(self, **kwargs): def run_app(environ, start_response): environ['pywb.kwargs'] = kwargs @@ -123,8 +126,15 @@ class RewriterApp(object): else: async_record_url = mod_url - r = self._do_req(inputreq, url, wb_url, kwargs, - async_record_url is not None) + skip = async_record_url is not None + + setcookie_headers = None + if self.cookie_tracker: + cookie_key = self.get_cookie_key(kwargs) + res = self.cookie_tracker.get_cookie_headers(url, cookie_key) + inputreq.extra_cookie, setcookie_headers = res + + r = self._do_req(inputreq, url, wb_url, kwargs, skip) if r.status_code >= 400: error = None @@ -143,7 +153,6 @@ class RewriterApp(object): raise UpstreamException(r.status_code, url=url, details=details) if async_record_url: - #print('ASYNC REC', async_record_url) environ.pop('HTTP_RANGE', '') gevent.spawn(self._do_async_req, inputreq, @@ -183,14 +192,24 @@ class RewriterApp(object): environ, self.framed_replay)) + cookie_rewriter = None + if self.cookie_tracker: + cookie_rewriter = self.cookie_tracker.get_rewriter(urlrewriter, + cookie_key) + result = self.content_rewriter.rewrite_content(urlrewriter, record.status_headers, record.stream, head_insert_func, urlkey, - cdx) + cdx, + cookie_rewriter) status_headers, gen, is_rw = result + + if setcookie_headers: + status_headers.headers.extend(setcookie_headers) + return WbResponse(status_headers, gen) def get_top_url(self, full_prefix, wb_url, cdx, kwargs): @@ -343,6 +362,9 @@ class RewriterApp(object): def get_upstream_url(self, url, wb_url, closest, kwargs): raise NotImplemented() + def get_cookie_key(self, kwargs): + raise NotImplemented() + def _add_custom_params(self, cdx, headers, kwargs): cdx['is_live'] = 'true' pass diff --git a/urlrewrite/test/simpleapp.py b/urlrewrite/test/simpleapp.py index ee620de3..d7f05181 100644 --- a/urlrewrite/test/simpleapp.py +++ b/urlrewrite/test/simpleapp.py @@ -1,29 +1,39 @@ from gevent.monkey import patch_all; patch_all() -from bottle import run, Bottle, request, response +from bottle import run, Bottle, request, response, debug from six.moves.urllib.parse import quote from pywb.utils.loaders import LocalFileLoader + import mimetypes +import redis from urlrewrite.rewriterapp import RewriterApp +from urlrewrite.cookies import CookieTracker # ============================================================================ class RWApp(RewriterApp): - def __init__(self, upstream_urls): + def __init__(self, upstream_urls, cookie_key_templ, redis): self.upstream_urls = upstream_urls + self.cookie_key_templ = cookie_key_templ self.app = Bottle() self.block_loader = LocalFileLoader() self.init_routes() + super(RWApp, self).__init__(True) + self.cookie_tracker = CookieTracker(redis) + def get_upstream_url(self, url, wb_url, closest, kwargs): type = kwargs.get('type') return self.upstream_urls[type].format(url=quote(url), closest=closest) + def get_cookie_key(self, kwargs): + return self.cookie_key_templ.format(**kwargs) + def init_routes(self): @self.app.get('/static/__pywb/') def server_static(filepath): @@ -45,7 +55,8 @@ class RWApp(RewriterApp): 'replay': 'http://localhost:%s/replay/resource/postreq?url={url}&closest={closest}' % replay_port, } - rwapp = RWApp(upstream_urls) + r = redis.StrictRedis.from_url('redis://localhost/2') + rwapp = RWApp(upstream_urls, 'cookies:', r) return rwapp diff --git a/urlrewrite/test/test_rewriter.py b/urlrewrite/test/test_rewriter.py index 7f10a280..4fdaff48 100644 --- a/urlrewrite/test/test_rewriter.py +++ b/urlrewrite/test/test_rewriter.py @@ -1,13 +1,14 @@ from webagg.test.testutils import LiveServerTests, BaseTestClass +from webagg.test.testutils import FakeRedisTests -from .simpleapp import RWApp +from .simpleapp import RWApp, debug import os import webtest -class TestRewriter(LiveServerTests, BaseTestClass): +class TestRewriter(LiveServerTests, FakeRedisTests, BaseTestClass): @classmethod def setup_class(cls): super(TestRewriter, cls).setup_class() @@ -17,6 +18,7 @@ class TestRewriter(LiveServerTests, BaseTestClass): cls.app = RWApp.create_app(replay_port=cls.server.port) cls.testapp = webtest.TestApp(cls.app.app) + debug(True) def test_replay(self): resp = self.testapp.get('/live/mp_/http://example.com/') @@ -34,7 +36,8 @@ class TestRewriter(LiveServerTests, BaseTestClass): assert 'wbinfo.capture_url = "http://example.com/"' in resp.text + def test_cookie_track_1(self): + resp = self.testapp.get('/live/mp_/https://twitter.com/') - - + assert resp.headers['set-cookie'] != None From e64ae780c60e4c97fe57d72e576362c1d1c26b65 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 6 May 2016 16:32:13 -0700 Subject: [PATCH 079/112] urlrewrite: improve POST request support for ikreymer/pywb#178 --- urlrewrite/rewriterapp.py | 9 ++++++--- webagg/inputrequest.py | 5 +++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index a8f5122a..7522cd97 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -1,6 +1,6 @@ import requests -from pywb.rewrite.rewrite_content import RewriteContent +from pywb.rewrite.rewrite_content import RewriteContentAMF from pywb.rewrite.wburl import WbUrl from pywb.rewrite.url_rewriter import UrlRewriter @@ -42,7 +42,7 @@ class RewriterApp(object): frame_type = 'inverse' if framed_replay else False - self.content_rewriter = RewriteContent(is_framed_replay=frame_type) + self.content_rewriter = RewriteContentAMF(is_framed_replay=frame_type) if not jinja_env: jinja_env = JinjaEnv(globals={'static_path': 'static/__pywb'}) @@ -101,6 +101,8 @@ class RewriterApp(object): inputreq = RewriteInputRequest(environ, urlkey, url, self.content_rewriter) + inputreq.include_post_query(url) + mod_url = None use_206 = False rangeres = None @@ -203,7 +205,8 @@ class RewriterApp(object): head_insert_func, urlkey, cdx, - cookie_rewriter) + cookie_rewriter, + environ) status_headers, gen, is_rw = result diff --git a/webagg/inputrequest.py b/webagg/inputrequest.py index 39d12dd0..50112959 100644 --- a/webagg/inputrequest.py +++ b/webagg/inputrequest.py @@ -68,14 +68,15 @@ class DirectWSGIInputRequest(object): return url mime = self._get_content_type() - mime = mime.split(';')[0] if mime else '' + #mime = mime.split(';')[0] if mime else '' length = self._get_content_length() stream = self.env['wsgi.input'] buffered_stream = BytesIO() post_query = extract_post_query('POST', mime, length, stream, - buffered_stream=buffered_stream) + buffered_stream=buffered_stream, + environ=self.env) if post_query: self.env['wsgi.input'] = buffered_stream From 464eca2fa04b4b7958be0d7e914b6e5d7a441500 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 6 May 2016 16:33:18 -0700 Subject: [PATCH 080/112] test apps: enable debugging for test apps test recorder: write to a temp dir for each run --- recorder/test/simplerec.py | 20 +++++++++++++++++++- setup.py | 2 +- urlrewrite/test/simpleapp.py | 8 ++++++++ webagg/test/live.py | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/recorder/test/simplerec.py b/recorder/test/simplerec.py index a5feb8e5..f9c73b99 100644 --- a/recorder/test/simplerec.py +++ b/recorder/test/simplerec.py @@ -6,9 +6,27 @@ from recorder.redisindexer import WritableRedisIndexer from recorder.warcwriter import MultiFileWARCWriter from recorder.filters import SkipDupePolicy +import atexit +import tempfile +import redis + upstream_url = 'http://localhost:8080' -target = './_recordings/' +target = tempfile.mkdtemp(prefix='tmprec') + '/' + +print('Recording to ' + target) + +def rm_target(): + print('Removing ' + target) + shutil.rmtree(target) + +atexit.register(rm_target) + +local_r = redis.StrictRedis.from_url('redis://localhost/2') +local_r.delete('rec:cdxj') +local_r.delete('rec:warc') + +#target = './_recordings/' dedup_index = WritableRedisIndexer( redis_url='redis://localhost/2/rec:cdxj', diff --git a/setup.py b/setup.py index b2829f78..75d69c2e 100755 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ setup( 'proxy', ], install_requires=[ - 'pywb==0.30.0', + 'pywb>=0.30.0', ], dependency_links=[ 'git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.30.0-develop', diff --git a/urlrewrite/test/simpleapp.py b/urlrewrite/test/simpleapp.py index d7f05181..b741962d 100644 --- a/urlrewrite/test/simpleapp.py +++ b/urlrewrite/test/simpleapp.py @@ -26,6 +26,14 @@ class RWApp(RewriterApp): self.cookie_tracker = CookieTracker(redis) + self.orig_error_handler = self.app.default_error_handler + self.app.default_error_handler = self.err_handler + + def err_handler(self, exc): + print(exc) + traceback.print_exc() + return self.orig_error_handler(exc) + def get_upstream_url(self, url, wb_url, closest, kwargs): type = kwargs.get('type') return self.upstream_urls[type].format(url=quote(url), diff --git a/webagg/test/live.py b/webagg/test/live.py index e24084fa..b6c10a22 100644 --- a/webagg/test/live.py +++ b/webagg/test/live.py @@ -7,7 +7,7 @@ from webagg.indexsource import LiveIndexSource, RedisIndexSource from webagg.aggregator import SimpleAggregator, CacheDirectoryIndexSource def simpleapp(): - app = ResAggApp() + app = ResAggApp(debug=True) app.add_route('/live', DefaultResourceHandler(SimpleAggregator( {'live': LiveIndexSource()}) From c45f5cb749b859c633b3ab8c8581c582f518612b Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 10 May 2016 16:31:44 -0700 Subject: [PATCH 081/112] webagg: use werkzeug routing instead of wrapping Bottle app --- webagg/app.py | 135 ++++++++++++++++++-------------- webagg/handlers.py | 9 ++- webagg/test/test_handlers.py | 2 +- webagg/test/test_memento_agg.py | 24 +++--- webagg/test/test_upstream.py | 2 +- webagg/test/testutils.py | 2 +- 6 files changed, 99 insertions(+), 75 deletions(-) diff --git a/webagg/app.py b/webagg/app.py index 595cef7f..c640b654 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -1,98 +1,115 @@ from webagg.inputrequest import DirectWSGIInputRequest, POSTInputRequest -from bottle import route, request, response, abort, Bottle, debug as bottle_debug +from werkzeug.routing import Map, Rule import requests import traceback import json +from six.moves.urllib.parse import parse_qsl + JSON_CT = 'application/json; charset=utf-8' #============================================================================= class ResAggApp(object): def __init__(self, *args, **kwargs): - self.application = Bottle() - self.application.default_error_handler = self.err_handler self.route_dict = {} self.debug = kwargs.get('debug', False) - if self.debug: - bottle_debug(True) + self.url_map = Map() - @self.application.route('/') - def list_routes(): - return self.route_dict + def list_routes(environ): + return {}, self.route_dict, {} + + self.url_map.add(Rule('/', endpoint=list_routes)) def add_route(self, path, handler): - @self.application.route([path, path + '/'], 'ANY') - @self.wrap_error - def direct_input_request(mode=''): - params = dict(request.query) + def direct_input_request(environ, mode=''): + params = self.get_query_dict(environ) params['mode'] = mode - params['_input_req'] = DirectWSGIInputRequest(request.environ) + params['_input_req'] = DirectWSGIInputRequest(environ) return handler(params) - @self.application.route([path + '/postreq', path + '//postreq'], 'POST') - @self.wrap_error - def post_fullrequest(mode=''): - params = dict(request.query) + def post_fullrequest(environ, mode=''): + params = self.get_query_dict(environ) params['mode'] = mode - params['_input_req'] = POSTInputRequest(request.environ) + params['_input_req'] = POSTInputRequest(environ) return handler(params) + self.url_map.add(Rule(path, endpoint=direct_input_request)) + self.url_map.add(Rule(path + '/', endpoint=direct_input_request)) + + self.url_map.add(Rule(path + '/postreq', endpoint=post_fullrequest)) + self.url_map.add(Rule(path + '//postreq', endpoint=post_fullrequest)) + handler_dict = handler.get_supported_modes() + self.route_dict[path] = handler_dict self.route_dict[path + '/postreq'] = handler_dict - def err_handler(self, exc): - if self.debug: - print(exc) - traceback.print_exc() - response.status = exc.status_code - response.content_type = JSON_CT - err_msg = json.dumps({'message': exc.body}) - response.headers['ResErrors'] = err_msg - return err_msg + def get_query_dict(self, environ): + query_str = environ.get('QUERY_STRING') + if query_str: + return dict(parse_qsl(query_str)) + else: + return {} - def wrap_error(self, func): - def wrap_func(*args, **kwargs): - try: - out_headers, res, errs = func(*args, **kwargs) + def __call__(self, environ, start_response): + urls = self.url_map.bind_to_environ(environ) + try: + endpoint, args = urls.match() + except HTTPException as e: + return e(environ, start_response) - if out_headers: - for n, v in out_headers.items(): - response.headers[n] = v + try: + result = endpoint(environ, **args) - if res: - if errs: - response.headers['ResErrors'] = json.dumps(errs) - return res + out_headers, res, errs = result - last_exc = errs.pop('last_exc', None) - if last_exc: - if self.debug: - traceback.print_exc() + if res: + if isinstance(res, dict): + res = json.dumps(res).encode('utf-8') + out_headers['Content-Type'] = JSON_CT + out_headers['Content-Length'] = str(len(res)) + res = [res] - response.status = last_exc.status() - message = last_exc.msg - else: - response.status = 404 - message = 'No Resource Found' - - response.content_type = JSON_CT - res = {'message': message} if errs: - res['errors'] = errs + out_headers['ResErrors'] = json.dumps(errs) - err_msg = json.dumps(res) - response.headers['ResErrors'] = err_msg - return err_msg + start_response('200 OK', list(out_headers.items())) + return res - except Exception as e: - if self.debug: - traceback.print_exc() - abort(500, 'Internal Error: ' + str(e)) + else: + return self.send_error(out_headers, errs, start_response) - return wrap_func + except Exception as e: + message = 'Internal Error: ' + str(e) + status = 500 + return self.send_error({}, {}, start_response, + message=message, + status=status) + def send_error(self, out_headers, errs, start_response, + message='No Resource Found', status=404): + last_exc = errs.pop('last_exc', None) + if last_exc: + if self.debug: + traceback.print_exc() + + status = last_exc.status() + message = last_exc.msg + + res = {'message': message} + if errs: + res['errors'] = errs + + err_msg = json.dumps(res) + + headers = [('Content-Type', JSON_CT), + ('Content-Length', str(len(err_msg))), + ('ResErrors', err_msg) + ] + + start_response(str(status) + ' ' + message, headers) + return [err_msg.encode('utf-8')] diff --git a/webagg/handlers.py b/webagg/handlers.py index d6038fb2..9385e21c 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -111,7 +111,14 @@ class IndexHandler(object): content_type, res = handler(cdx_iter, fields) out_headers = {'Content-Type': content_type} - return out_headers, res, errs + + def check_str(res): + for x in res: + if isinstance(x, str): + x = x.encode('utf-8') + yield x + + return out_headers, check_str(res), errs #============================================================================= diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 1872e896..7c5a1aff 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -62,7 +62,7 @@ class TestResAgg(FakeRedisTests, BaseTestClass): app.add_route('/empty', HandlerSeq([])) app.add_route('/invalid', DefaultResourceHandler([SimpleAggregator({'invalid': 'should not be a callable'})])) - cls.testapp = webtest.TestApp(app.application) + cls.testapp = webtest.TestApp(app) def _check_uri_date(self, resp, uri, dt): buff = BytesIO(resp.body) diff --git a/webagg/test/test_memento_agg.py b/webagg/test/test_memento_agg.py index 784bf785..2255b951 100644 --- a/webagg/test/test_memento_agg.py +++ b/webagg/test/test_memento_agg.py @@ -129,13 +129,13 @@ def test_handler_output_cdxj(): url = 'http://vvork.com/' headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait')) - exp = """\ + exp = b"""\ com,vvork)/ 20141006184357 {"url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} com,vvork)/ 20131004231540 {"url": "http://vvork.com/", "mem_rel": "last memento", "memento_url": "http://wayback.archive-it.org/all/20131004231540/http://vvork.com/", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"} """ assert(headers['Content-Type'] == 'text/x-cdxj') - assert(''.join(res) == exp) + assert(b''.join(res) == exp) assert(errs == {}) @@ -145,13 +145,13 @@ def test_handler_output_json(): url = 'http://vvork.com/' headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='json')) - exp = """\ + exp = b"""\ {"urlkey": "com,vvork)/", "timestamp": "20141006184357", "url": "http://www.vvork.com/", "mem_rel": "memento", "memento_url": "http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/", "load_url": "http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/", "source": "rhiz"} {"urlkey": "com,vvork)/", "timestamp": "20131004231540", "url": "http://vvork.com/", "mem_rel": "last memento", "memento_url": "http://wayback.archive-it.org/all/20131004231540/http://vvork.com/", "load_url": "http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/", "source": "ait"} """ assert(headers['Content-Type'] == 'application/x-ndjson') - assert(''.join(res) == exp) + assert(b''.join(res) == exp) assert(errs == {}) def test_handler_output_link(): @@ -160,12 +160,12 @@ def test_handler_output_link(): url = 'http://vvork.com/' headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='link')) - exp = """\ + exp = b"""\ ; rel="memento"; datetime="Mon, 06 Oct 2014 18:43:57 GMT"; src="rhiz", ; rel="memento"; datetime="Fri, 04 Oct 2013 23:15:40 GMT"; src="ait" """ assert(headers['Content-Type'] == 'application/link') - assert(''.join(res) == exp) + assert(b''.join(res) == exp) assert(errs == {}) @@ -175,7 +175,7 @@ def test_handler_output_link_2(): url = 'http://iana.org/' headers, res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) - exp = """\ + exp = b"""\ ; rel="memento"; datetime="Sun, 26 Jan 2014 09:37:43 GMT"; src="ia", ; rel="memento"; datetime="Sun, 26 Jan 2014 20:06:24 GMT"; src="local", ; rel="memento"; datetime="Thu, 23 Jan 2014 03:47:55 GMT"; src="ia", @@ -183,7 +183,7 @@ def test_handler_output_link_2(): ; rel="memento"; datetime="Tue, 07 Jan 2014 04:05:52 GMT"; src="ait" """ assert(headers['Content-Type'] == 'application/link') - assert(''.join(res) == exp) + assert(b''.join(res) == exp) exp_errs = {'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://iana.org/',)", 'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://iana.org/',)"} @@ -198,10 +198,10 @@ def test_handler_output_link_3(): url = 'http://foo.bar.non-existent' headers, res, errs = handler(dict(url=url, closest='20140126000000', limit=5, output='link')) - exp = '' + exp = b'' assert(headers['Content-Type'] == 'application/link') - assert(''.join(res) == exp) + assert(b''.join(res) == exp) exp_errs = {'ait': "NotFoundException('http://wayback.archive-it.org/all/http://foo.bar.non-existent',)", 'bl': "NotFoundException('http://www.webarchive.org.uk/wayback/archive/http://foo.bar.non-existent',)", @@ -216,12 +216,12 @@ def test_handler_output_text(): url = 'http://vvork.com/' headers, res, errs = handler(dict(url=url, closest='20141001', limit=2, sources='rhiz,ait', output='text')) - exp = """\ + exp = b"""\ com,vvork)/ 20141006184357 http://www.vvork.com/ memento http://webenact.rhizome.org/vvork/20141006184357/http://www.vvork.com/ http://webenact.rhizome.org/vvork/20141006184357id_/http://www.vvork.com/ rhiz com,vvork)/ 20131004231540 http://vvork.com/ last memento http://wayback.archive-it.org/all/20131004231540/http://vvork.com/ http://wayback.archive-it.org/all/20131004231540id_/http://vvork.com/ ait """ assert(headers['Content-Type'] == 'text/plain') - assert(''.join(res) == exp) + assert(b''.join(res) == exp) assert(errs == {}) diff --git a/webagg/test/test_upstream.py b/webagg/test/test_upstream.py index 037b62e9..59854f90 100644 --- a/webagg/test/test_upstream.py +++ b/webagg/test/test_upstream.py @@ -31,7 +31,7 @@ class TestUpstream(LiveServerTests, BaseTestClass): ) self.base_url = base_url - self.testapp = webtest.TestApp(app.application) + self.testapp = webtest.TestApp(app) def test_live_paths(self): diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index d0fb361e..fc5a0a8a 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -99,7 +99,7 @@ class LiveServerTests(object): {'live': LiveIndexSource()}) ) ) - return app.application + return app @classmethod def teardown_class(cls): From 94d6098238872c6f9e282b9c9fb7fd8bcbad937c Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 11 May 2016 11:38:59 -0700 Subject: [PATCH 082/112] app: separate json_encode() func compat: py2 fixes --- recorder/warcwriter.py | 2 +- setup.py | 2 +- webagg/app.py | 47 +++++++++++++++++++++++------------------- webagg/handlers.py | 12 ++++++----- 4 files changed, 35 insertions(+), 28 deletions(-) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 7b82d6a0..896ff626 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -128,7 +128,7 @@ class BaseWARCWriter(object): warcinfo.seek(0) record = ArcWarcRecord('warc', 'warcinfo', headers, warcinfo, - None, '', len(warcinfo.getbuffer())) + None, '', len(warcinfo.getvalue())) return record diff --git a/setup.py b/setup.py index 75d69c2e..6bc77d7a 100755 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ setup( 'pywb>=0.30.0', ], dependency_links=[ - 'git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.30.0-develop', + #'git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.30.0-develop', ], zip_safe=True, entry_points=""" diff --git a/webagg/app.py b/webagg/app.py index c640b654..cb5e8bb0 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -6,6 +6,7 @@ import traceback import json from six.moves.urllib.parse import parse_qsl +import six JSON_CT = 'application/json; charset=utf-8' @@ -66,31 +67,32 @@ class ResAggApp(object): out_headers, res, errs = result - if res: - if isinstance(res, dict): - res = json.dumps(res).encode('utf-8') - out_headers['Content-Type'] = JSON_CT - out_headers['Content-Length'] = str(len(res)) - res = [res] + if not res: + return self.send_error(errs, start_response) - if errs: - out_headers['ResErrors'] = json.dumps(errs) + if isinstance(res, dict): + res = self.json_encode(res, out_headers) - start_response('200 OK', list(out_headers.items())) - return res + if errs: + out_headers['ResErrors'] = json.dumps(errs) - else: - return self.send_error(out_headers, errs, start_response) + start_response('200 OK', list(out_headers.items())) + return res except Exception as e: message = 'Internal Error: ' + str(e) status = 500 - return self.send_error({}, {}, start_response, + return self.send_error({}, start_response, message=message, status=status) + def json_encode(self, res, out_headers): + res = json.dumps(res).encode('utf-8') + out_headers['Content-Type'] = JSON_CT + out_headers['Content-Length'] = str(len(res)) + return [res] - def send_error(self, out_headers, errs, start_response, + def send_error(self, errs, start_response, message='No Resource Found', status=404): last_exc = errs.pop('last_exc', None) if last_exc: @@ -104,12 +106,15 @@ class ResAggApp(object): if errs: res['errors'] = errs - err_msg = json.dumps(res) + out_headers = {} + res = self.json_encode(res, out_headers) - headers = [('Content-Type', JSON_CT), - ('Content-Length', str(len(err_msg))), - ('ResErrors', err_msg) - ] + if six.PY3: + out_headers['ResErrors'] = res[0].decode('utf-8') + else: + out_headers['ResErrors'] = res[0] + message = message.encode('utf-8') - start_response(str(status) + ' ' + message, headers) - return [err_msg.encode('utf-8')] + message = str(status) + ' ' + message + start_response(message, list(out_headers.items())) + return res diff --git a/webagg/handlers.py b/webagg/handlers.py index 9385e21c..e7e1acf0 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -6,6 +6,8 @@ from pywb.utils.wbexception import NotFoundException from pywb.cdx.query import CDXQuery from pywb.cdx.cdxdomainspecific import load_domain_specific_cdx_rules +import six + #============================================================================= def to_cdxj(cdx_iter, fields): @@ -112,11 +114,11 @@ class IndexHandler(object): content_type, res = handler(cdx_iter, fields) out_headers = {'Content-Type': content_type} - def check_str(res): - for x in res: - if isinstance(x, str): - x = x.encode('utf-8') - yield x + def check_str(lines): + for line in lines: + if isinstance(line, six.text_type): + line = line.encode('utf-8') + yield line return out_headers, check_str(res), errs From 45c8fcddbdac25c53889bccc77632b4a1e262ced Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 11 May 2016 21:40:02 -0700 Subject: [PATCH 083/112] recorder: add max_idle_secs / close_idle_files() to close any open files that have not been modified longer than set threshold, in prep for webrecorder/webrecorder#92 indexer: add 'full_warc_prefix' for setting full path prefix in add_warc_file() (eg. for http load) for webrecorder/webrecorder#95 --- recorder/redisindexer.py | 7 ++++-- recorder/test/test_recorder.py | 43 +++++++++++++++++++++++++++++++++- recorder/warcwriter.py | 29 +++++++++++++++++++---- setup.py | 1 + 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/recorder/redisindexer.py b/recorder/redisindexer.py index c3fa1c93..577bf036 100644 --- a/recorder/redisindexer.py +++ b/recorder/redisindexer.py @@ -29,15 +29,18 @@ class WritableRedisIndexer(RedisIndexSource): self.rel_path_template = kwargs.get('rel_path_template', '') self.file_key_template = kwargs.get('file_key_template', '') + self.full_warc_prefix = kwargs.get('full_warc_prefix', '') self.dupe_policy = kwargs.get('dupe_policy', WriteRevisitDupePolicy()) def add_warc_file(self, full_filename, params): rel_path = res_template(self.rel_path_template, params) - filename = os.path.relpath(full_filename, rel_path) + rel_filename = os.path.relpath(full_filename, rel_path) file_key = res_template(self.file_key_template, params) - self.redis.hset(file_key, filename, full_filename) + full_load_path = self.full_warc_prefix + full_filename + + self.redis.hset(file_key, rel_filename, full_load_path) def add_urls_to_index(self, stream, params, filename, length): rel_path = res_template(self.rel_path_template, params) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 97a47f46..1283e022 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -365,6 +365,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) self._test_all_warcs('/warcs/FOO/', 1) + # Check two records in WARC r = FakeStrictRedis.from_url('redis://localhost/2') res = r.zrangebylex('FOO:cdxj', '[org,httpbin)/', '(org,httpbin,') assert len(res) == 2 @@ -388,7 +389,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert len(writer.fh_cache) == 1 - writer.close_file(self.root_dir + '/warcs/FOO/') + writer.close_dir(self.root_dir + '/warcs/FOO/') #writer.close_file({'param.recorder.coll': 'FOO'}) assert len(writer.fh_cache) == 0 @@ -403,6 +404,46 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) warcs = r.hgetall('FOO:warc') assert len(warcs) == 2 + def test_record_multiple_writes_rollover_idle(self): + warc_path = to_path(self.root_dir + '/warcs/GOO/ABC-{hostname}-{timestamp}.warc.gz') + + rel_path = self.root_dir + '/warcs/' + + dedup_index = WritableRedisIndexer(redis_url='redis://localhost/2/{coll}:cdxj', + file_key_template='{coll}:warc', + rel_path_template=rel_path) + + writer = MultiFileWARCWriter(warc_path, dedup_index=dedup_index, max_idle_secs=0.9) + recorder_app = RecorderApp(self.upstream_url, writer) + + # First Record + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.coll=GOO') + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + # Second Record + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?boo=far', '¶m.recorder.coll=GOO') + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"boo": "far"' in resp.body + + self._test_all_warcs('/warcs/GOO/', 1) + + time.sleep(1.0) + writer.close_idle_files() + + # Third Record + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?goo=bar', '¶m.recorder.coll=GOO') + + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"goo": "bar"' in resp.body + + self._test_all_warcs('/warcs/GOO/', 2) + def test_warcinfo_record(self): simplewriter = SimpleTempWARCWriter(gzip=False) params = {'software': 'recorder test', diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 896ff626..3008d9e9 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -259,7 +259,7 @@ class Digester(object): # ============================================================================ class MultiFileWARCWriter(BaseWARCWriter): def __init__(self, dir_template, filename_template=None, max_size=0, - *args, **kwargs): + max_idle_secs=1800, *args, **kwargs): super(MultiFileWARCWriter, self).__init__(*args, **kwargs) if not filename_template: @@ -272,6 +272,10 @@ class MultiFileWARCWriter(BaseWARCWriter): self.dir_template = dir_template self.filename_template = filename_template self.max_size = max_size + if max_idle_secs > 0: + self.max_idle_time = datetime.timedelta(seconds=max_idle_secs) + else: + self.max_idle_time = None self.fh_cache = {} @@ -300,7 +304,7 @@ class MultiFileWARCWriter(BaseWARCWriter): fcntl.flock(fh, fcntl.LOCK_UN) fh.close() - def close_file(self, full_dir): + def close_dir(self, full_dir): #full_dir = res_template(self.dir_template, params) result = self.fh_cache.pop(full_dir, None) if not result: @@ -371,13 +375,30 @@ class MultiFileWARCWriter(BaseWARCWriter): fcntl.flock(out, fcntl.LOCK_EX | fcntl.LOCK_NB) self.fh_cache[full_dir] = (out, filename) - def close(self): - for n, v in self.fh_cache.items(): + def iter_open_files(self): + for n, v in list(self.fh_cache.items()): out, filename = v + yield n, out, filename + + def close(self): + for dirname, out, filename in self.iter_open_files(): self._close_file(out) self.fh_cache = {} + def close_idle_files(self): + if not self.max_idle_time: + return + + now = datetime.datetime.now() + + for dirname, out, filename in self.iter_open_files(): + mtime = os.path.getmtime(filename) + mtime = datetime.datetime.fromtimestamp(mtime) + if (now - mtime) > self.max_idle_time: + print('Closing idle ' + filename) + self.close_dir(dirname) + # ============================================================================ class PerRecordWARCWriter(MultiFileWARCWriter): diff --git a/setup.py b/setup.py index 6bc77d7a..16d913e6 100755 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setup( ], install_requires=[ 'pywb>=0.30.0', + 'werkzeug', ], dependency_links=[ #'git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.30.0-develop', From 80d9805a58a66132ba049ae67781fcebad0a40ea Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 19 May 2016 17:01:09 -0700 Subject: [PATCH 084/112] webagg: tests: flush fakeredis for reentrancy utils: add load_config() with option for main and override configs --- webagg/test/testutils.py | 1 + webagg/utils.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/webagg/test/testutils.py b/webagg/test/testutils.py index fc5a0a8a..c9ba5be0 100644 --- a/webagg/test/testutils.py +++ b/webagg/test/testutils.py @@ -68,6 +68,7 @@ class FakeRedisTests(object): @classmethod def teardown_class(cls): super(FakeRedisTests, cls).teardown_class() + FakeStrictRedis().flushall() cls.redismock.stop() diff --git a/webagg/utils.py b/webagg/utils.py index 6c9121b5..5617d048 100644 --- a/webagg/utils.py +++ b/webagg/utils.py @@ -1,6 +1,8 @@ import re import six import string +import yaml +import os from contextlib import closing @@ -174,3 +176,25 @@ def chunk_encode_iter(orig_iter): yield b'0\r\n\r\n' +#============================================================================= +def load_config(main_env_var, main_default_file='', + overlay_env_var='', overlay_file=''): + + configfile = os.environ.get(main_env_var, main_default_file) + + if configfile: + # Load config + with open(configfile, 'rb') as fh: + config = yaml.load(fh) + + else: + config = {} + + overlay_configfile = os.environ.get(overlay_env_var, overlay_file) + + if overlay_configfile: + with open(overlay_configfile, 'rb') as fh: + config.update(yaml.load(fh)) + + return config + From ea3efdf84dd11eb9e7c73c6089286889c172fa76 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 24 May 2016 18:01:44 -0700 Subject: [PATCH 085/112] responseloader: use PreparedRequest() to ensure url properly formatted tests: update tests for latest, live data --- webagg/responseloader.py | 7 ++++++- webagg/test/live.py | 2 +- webagg/test/test_memento_agg.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 576b14d6..7da983cb 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -18,7 +18,8 @@ from six.moves.urllib.parse import urlsplit import uuid import six import itertools -#import requests + +from requests.models import PreparedRequest import urllib3 @@ -241,6 +242,10 @@ class LiveWebLoader(BaseLoader): method = input_req.get_req_method() data = input_req.get_req_body() + p = PreparedRequest() + p.prepare_url(load_url, None) + load_url = p.url + try: upstream_res = self.pool.urlopen(method=method, url=load_url, diff --git a/webagg/test/live.py b/webagg/test/live.py index b6c10a22..2e4f84a9 100644 --- a/webagg/test/live.py +++ b/webagg/test/live.py @@ -27,7 +27,7 @@ def simpleapp(): './testdata/' ) ) - return app.application + return app diff --git a/webagg/test/test_memento_agg.py b/webagg/test/test_memento_agg.py index 2255b951..73bd0409 100644 --- a/webagg/test/test_memento_agg.py +++ b/webagg/test/test_memento_agg.py @@ -63,7 +63,7 @@ def test_mem_agg_index_2(agg): {"timestamp": "20100511201151", "load_url": "http://wayback.archive-it.org/all/20100511201151id_/http://example.com/", "source": "ait"}, {"timestamp": "20100514231857", "load_url": "http://web.archive.org/web/20100514231857id_/http://example.com/", "source": "ia"}, {"timestamp": "20100514231857", "load_url": "http://wayback.archive-it.org/all/20100514231857id_/http://example.com/", "source": "ait"}, - {"timestamp": "20100519202418", "load_url": "http://web.archive.org/web/20100519202418id_/http://example.com/", "source": "ia"}] + {"timestamp": "20100510233601", "load_url": "http://web.archive.org/web/20100510233601id_/http://example.com/", "source": "ia"}] assert(to_json_list(res) == exp) assert(errs == {'rhiz': "NotFoundException('http://webenact.rhizome.org/vvork/http://example.com/',)"}) From 30f9d0aca7f46af35f03db32d31df7a7ed4410c1 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 26 May 2016 20:49:40 -0700 Subject: [PATCH 086/112] recorder put custom record: add support for put/post of a custom record. If `put_record=` param is included, the request body is written to the specified record type. move record creation functions to the warcwriter add tests for custom record --- .coveragerc | 1 + recorder/recorderapp.py | 86 ++++++++++++++++++-------------- recorder/test/test_recorder.py | 73 +++++++++++++++++++++++---- recorder/warcwriter.py | 91 +++++++++++++++++++++++++++------- webagg/test/test_handlers.py | 4 +- 5 files changed, 190 insertions(+), 65 deletions(-) diff --git a/.coveragerc b/.coveragerc index 4b25f6fc..bc40de9d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,5 @@ [run] +concurrency = gevent omit = */test/* */tests/* diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 5572bc45..73d8500f 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -1,10 +1,6 @@ from webagg.utils import StreamIter, chunk_encode_iter, BUFF_SIZE from webagg.inputrequest import DirectWSGIInputRequest -from pywb.utils.statusandheaders import StatusAndHeadersParser -from pywb.warc.recordloader import ArcWarcRecord -from pywb.warc.recordloader import ArcWarcRecordLoader - from recorder.filters import SkipRangeRequestFilter, CollectionFilter from six.moves.urllib.parse import parse_qsl @@ -27,7 +23,6 @@ class RecorderApp(object): self.upstream_host = upstream_host self.writer = writer - self.parser = StatusAndHeadersParser([], verify=False) self.write_queue = gevent.queue.Queue() gevent.spawn(self._write_loop) @@ -62,8 +57,8 @@ class RecorderApp(object): req_head, req_pay, resp_head, resp_pay, params = result - req = self._create_req_record(req_head, req_pay, 'request') - resp = self._create_resp_record(resp_head, resp_pay, 'response') + req = self.writer.create_req_record(req_head, req_pay, 'request') + resp = self.writer.create_resp_record(resp_head, resp_pay, 'response') self.writer.write_req_resp(req, resp, params) @@ -77,47 +72,66 @@ class RecorderApp(object): except Exception as e: traceback.print_exc() - def _create_req_record(self, req_headers, payload, type_, ct=''): - len_ = payload.tell() - payload.seek(0) - - warc_headers = req_headers - status_headers = self.parser.parse(payload) - - record = ArcWarcRecord('warc', type_, warc_headers, payload, - status_headers, ct, len_) - return record - - def _create_resp_record(self, resp_headers, payload, type_, ct=''): - len_ = payload.tell() - payload.seek(0) - - warc_headers = self.parser.parse(payload) - warc_headers = CaseInsensitiveDict(warc_headers.headers) - - status_headers = self.parser.parse(payload) - - record = ArcWarcRecord('warc', type_, warc_headers, payload, - status_headers, ct, len_) - return record - def send_error(self, exc, start_response): - message = json.dumps({'error': repr(exc)}) + return self.send_message({'error': repr(exc)}, + '400 Bad Request', + start_response) + + def send_message(self, msg, status, start_response): + message = json.dumps(msg) headers = [('Content-Type', 'application/json; charset=utf-8'), ('Content-Length', str(len(message)))] - start_response('400 Bad Request', headers) + start_response(status, headers) return [message.encode('utf-8')] + def _put_record(self, request_uri, input_buff, record_type, + headers, params, start_response): + + req_stream = ReqWrapper(input_buff, headers) + + while True: + buff = req_stream.read() + if not buff: + break + + content_type = headers.get('Content-Type') + + record = self.writer.create_custom_record(params['url'], + req_stream.out, + record_type, + content_type, + req_stream.headers) + + self.writer.write_record(record, params) + + return self.send_message({'success': 'true'}, + '200 OK', + start_response) + + def __call__(self, environ, start_response): input_req = DirectWSGIInputRequest(environ) - headers = input_req.get_req_headers() - method = input_req.get_req_method() + + params = dict(parse_qsl(environ.get('QUERY_STRING'))) + request_uri = input_req.get_full_request_uri() input_buff = input_req.get_req_body() - params = dict(parse_qsl(environ.get('QUERY_STRING'))) + headers = input_req.get_req_headers() + + method = input_req.get_req_method() + + # write request body as metadata/resource + put_record = params.get('put_record') + if put_record and method in ('PUT', 'POST'): + return self._put_record(request_uri, + input_buff, + put_record, + headers, + params, + start_response) skipping = any(x.skip_request(headers) for x in self.skip_filters) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 1283e022..9c4acef6 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -52,9 +52,16 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) cls.upstream_url = 'http://localhost:{0}'.format(cls.server.port) - def _get_dedup_index(self, dupe_policy=WriteRevisitDupePolicy()): - dedup_index = WritableRedisIndexer(redis_url='redis://localhost/2/{user}:{coll}:cdxj', - file_key_template='{user}:{coll}:warc', + def _get_dedup_index(self, dupe_policy=WriteRevisitDupePolicy(), user=True): + if user: + file_key_template = '{user}:{coll}:warc' + redis_url = 'redis://localhost/2/{user}:{coll}:cdxj' + else: + file_key_template = '{coll}:warc' + redis_url = 'redis://localhost/2/{coll}:cdxj' + + dedup_index = WritableRedisIndexer(redis_url=redis_url, + file_key_template=file_key_template, rel_path_template=self.root_dir + '/warcs/', dupe_policy=dupe_policy) @@ -340,10 +347,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) rel_path = self.root_dir + '/warcs/' - dedup_index = WritableRedisIndexer(redis_url='redis://localhost/2/{coll}:cdxj', - file_key_template='{coll}:warc', - rel_path_template=rel_path) - + dedup_index = self._get_dedup_index(user=False) writer = MultiFileWARCWriter(warc_path, dedup_index=dedup_index) recorder_app = RecorderApp(self.upstream_url, writer) @@ -409,9 +413,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) rel_path = self.root_dir + '/warcs/' - dedup_index = WritableRedisIndexer(redis_url='redis://localhost/2/{coll}:cdxj', - file_key_template='{coll}:warc', - rel_path_template=rel_path) + dedup_index = self._get_dedup_index(user=False) writer = MultiFileWARCWriter(warc_path, dedup_index=dedup_index, max_idle_secs=0.9) recorder_app = RecorderApp(self.upstream_url, writer) @@ -472,3 +474,54 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert 'format: WARC File Format 1.0\r\n' in buff assert 'json-metadata: {"foo": "bar"}\r\n' in buff + def test_record_custom_record(self): + dedup_index = self._get_dedup_index(user=False) + + warc_path = to_path(self.root_dir + '/warcs/meta/meta.warc.gz') + + recorder_app = RecorderApp(self.upstream_url, + MultiFileWARCWriter(warc_path, dedup_index=dedup_index)) + + req_url = '/live/resource/postreq?url=custom://httpbin.org¶m.recorder.coll=META&put_record=resource' + + buff = b'Some Data' + + testapp = webtest.TestApp(recorder_app) + headers = {'content-type': 'text/plain', + 'WARC-Custom': 'foo' + } + + resp = testapp.put(req_url, headers=headers, params=buff) + + self._test_all_warcs('/warcs/meta', 1) + + r = FakeStrictRedis.from_url('redis://localhost/2') + + warcs = r.hgetall('META:warc') + assert len(warcs) == 1 + + with open(warcs[b'meta/meta.warc.gz'], 'rb') as fh: + decomp = DecompressingBufferedReader(fh) + record = ArcWarcRecordLoader().parse_record_stream(decomp) + + status_headers = record.rec_headers + assert len(record.rec_headers.headers) == 9 + assert status_headers.get_header('WARC-Type') == 'resource' + assert status_headers.get_header('WARC-Target-URI') == 'custom://httpbin.org' + assert status_headers.get_header('WARC-Record-ID') != '' + assert status_headers.get_header('WARC-Date') != '' + assert status_headers.get_header('WARC-Block-Digest') != '' + assert status_headers.get_header('WARC-Block-Digest') == status_headers.get_header('WARC-Payload-Digest') + assert status_headers.get_header('Content-Type') == 'text/plain' + assert status_headers.get_header('Content-Length') == str(len(buff)) + assert status_headers.get_header('WARC-Custom') == 'foo' + + assert record.stream.read() == buff + + status_headers = record.status_headers + assert len(record.status_headers.headers) == 2 + + assert status_headers.get_header('Content-Type') == 'text/plain' + assert status_headers.get_header('Content-Length') == str(len(buff)) + + diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 3008d9e9..39d791f1 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -21,8 +21,11 @@ from pywb.utils.loaders import LimitReader, to_native_str from pywb.utils.bufferedreaders import BufferedReader from pywb.utils.timeutils import timestamp20_now, datetime_to_iso_date +from pywb.utils.statusandheaders import StatusAndHeadersParser from pywb.warc.recordloader import ArcWarcRecord +from pywb.warc.recordloader import ArcWarcRecordLoader +from requests.structures import CaseInsensitiveDict from webagg.utils import ParamFormatter, res_template from recorder.filters import ExcludeNone @@ -51,6 +54,8 @@ class BaseWARCWriter(object): self.header_filter = header_filter self.hostname = gethostname() + self.parser = StatusAndHeadersParser([], verify=False) + def ensure_digest(self, record): block_digest = record.rec_headers.get('WARC-Block-Digest') payload_digest = record.rec_headers.get('WARC-Payload-Digest') @@ -62,7 +67,8 @@ class BaseWARCWriter(object): pos = record.stream.tell() - block_digester.update(record.status_headers.headers_buff) + if record.status_headers and hasattr(record.status_headers, 'headers_buff'): + block_digester.update(record.status_headers.headers_buff) while True: buf = record.stream.read(self.BUFF_SIZE) @@ -100,11 +106,6 @@ class BaseWARCWriter(object): if resp_id: req.rec_headers['WARC-Concurrent-To'] = resp_id - self._set_header_buff(req) - self._set_header_buff(resp) - - self.ensure_digest(resp) - resp = self._check_revisit(resp, params) if not resp: print('Skipping due to dedup') @@ -113,13 +114,45 @@ class BaseWARCWriter(object): params['_formatter'] = ParamFormatter(params, name=self.rec_source_name) self._do_write_req_resp(req, resp, params) + def create_req_record(self, req_headers, payload, type_, content_type=''): + len_ = payload.tell() + payload.seek(0) + + warc_headers = req_headers + status_headers = self.parser.parse(payload) + + record = ArcWarcRecord('warc', type_, warc_headers, payload, + status_headers, content_type, len_) + + self._set_header_buff(record) + + return record + + def create_resp_record(self, resp_headers, payload, type_, content_type=''): + len_ = payload.tell() + payload.seek(0) + + warc_headers = self.parser.parse(payload) + warc_headers = CaseInsensitiveDict(warc_headers.headers) + + status_headers = self.parser.parse(payload) + + record = ArcWarcRecord('warc', type_, warc_headers, payload, + status_headers, content_type, len_) + + self._set_header_buff(record) + + self.ensure_digest(record) + + return record + def create_warcinfo_record(self, filename, **kwargs): - headers = {} - headers['WARC-Record_ID'] = self._make_warc_id() - headers['WARC-Type'] = 'warcinfo' + warc_headers = {} + warc_headers['WARC-Record-ID'] = self._make_warc_id() + warc_headers['WARC-Type'] = 'warcinfo' if filename: - headers['WARC-Filename'] = filename - headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) + warc_headers['WARC-Filename'] = filename + warc_headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) warcinfo = BytesIO() for n, v in six.iteritems(kwargs): @@ -127,11 +160,29 @@ class BaseWARCWriter(object): warcinfo.seek(0) - record = ArcWarcRecord('warc', 'warcinfo', headers, warcinfo, + record = ArcWarcRecord('warc', 'warcinfo', warc_headers, warcinfo, None, '', len(warcinfo.getvalue())) return record + def create_custom_record(self, uri, payload, record_type, content_type, + warc_headers=None): + len_ = payload.tell() + payload.seek(0) + + warc_headers = warc_headers or {} + warc_headers['WARC-Record-ID'] = self._make_warc_id() + warc_headers['WARC-Type'] = record_type + warc_headers['WARC-Target-URI'] = uri + warc_headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) + + record = ArcWarcRecord('warc', record_type, warc_headers, payload, + None, content_type, len_) + + self.ensure_digest(record) + + return record + def _check_revisit(self, record, params): if not self.dedup_index: return record @@ -171,9 +222,10 @@ class BaseWARCWriter(object): content_type = record.content_type if not content_type: - content_type = self.WARC_RECORDS[record.rec_headers['WARC-Type']] + content_type = self.WARC_RECORDS.get(record.rec_headers['WARC-Type']) - self._header(out, 'Content-Type', content_type) + if content_type: + self._header(out, 'Content-Type', content_type) if record.rec_headers['WARC-Type'] == 'revisit': http_headers_only = True @@ -320,6 +372,11 @@ class MultiFileWARCWriter(BaseWARCWriter): def _is_write_req(self, req, params): return True + def write_record(self, record, params=None): + params = params or {} + params['_formatter'] = ParamFormatter(params, name=self.rec_source_name) + self._do_write_req_resp(None, record, params) + def _do_write_req_resp(self, req, resp, params): full_dir = res_template(self.dir_template, params) @@ -340,10 +397,10 @@ class MultiFileWARCWriter(BaseWARCWriter): start = out.tell() - if self._is_write_resp(resp, params): + if resp and self._is_write_resp(resp, params): self._write_warc_record(out, resp) - if self._is_write_req(req, params): + if req and self._is_write_req(req, params): self._write_warc_record(out, req) out.flush() @@ -420,7 +477,7 @@ class SimpleTempWARCWriter(BaseWARCWriter): self._write_warc_record(self.out, resp) self._write_warc_record(self.out, req) - def write_record(self, record): + def write_record(self, record, params=None): self._write_warc_record(self.out, record) def get_buffer(self): diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 7c5a1aff..84bf4e5a 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -353,8 +353,8 @@ foo=bar&test=abc""" def test_error_fallback_live_not_found(self): resp = self.testapp.get('/fallback/resource?url=http://invalid.url-not-found', status=400) - assert resp.json == {'message': 'http://invalid.url-not-found', - 'errors': {'LiveWebLoader': 'http://invalid.url-not-found'}} + assert resp.json == {'message': 'http://invalid.url-not-found/', + 'errors': {'LiveWebLoader': 'http://invalid.url-not-found/'}} assert resp.text == resp.headers['ResErrors'] From d7c74b68de98c2e849b13b221342141c207a5f1b Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 28 May 2016 15:01:33 -0700 Subject: [PATCH 087/112] video loader support: add VideoLoader, which uses youtube-dl to create a metadata record of video info. Activated with explicit content_type param 'application/vnd.youtube-dl_formats+json' --- recorder/recorderapp.py | 12 ++++-- recorder/test/test_recorder.py | 43 +++++++++++++++++++-- recorder/warcwriter.py | 40 ++++++++++++-------- urlrewrite/rewriterapp.py | 60 +++++++++++++++++++----------- urlrewrite/test/simpleapp.py | 17 ++++----- webagg/aggregator.py | 4 ++ webagg/handlers.py | 3 +- webagg/indexsource.py | 1 + webagg/responseloader.py | 68 ++++++++++++++++++++++++++++++---- webagg/test/test_handlers.py | 42 +++++++++++++++++++++ 10 files changed, 229 insertions(+), 61 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 73d8500f..d61e3df1 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -57,10 +57,16 @@ class RecorderApp(object): req_head, req_pay, resp_head, resp_pay, params = result - req = self.writer.create_req_record(req_head, req_pay, 'request') - resp = self.writer.create_resp_record(resp_head, resp_pay, 'response') + resp_type, resp = self.writer.read_resp_record(resp_head, resp_pay) + + if resp_type == 'response': + req = self.writer.create_req_record(req_head, req_pay) + + self.writer.write_req_resp(req, resp, params) + + else: + self.writer.write_record(resp, params) - self.writer.write_req_resp(req, resp, params) finally: try: diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 9c4acef6..5320800f 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -25,7 +25,7 @@ from pywb.warc.recordloader import ArcWarcRecordLoader from pywb.warc.cdxindexer import write_cdx_index from pywb.warc.archiveiterator import ArchiveIterator -from six.moves.urllib.parse import quote, unquote +from six.moves.urllib.parse import quote, unquote, urlencode from io import BytesIO import time import json @@ -67,7 +67,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) return dedup_index - def _test_warc_write(self, recorder_app, host, path, other_params=''): + def _test_warc_write(self, recorder_app, host, path, other_params='', link_url=''): url = 'http://' + host + path req_url = '/live/resource/postreq?url=' + url + other_params testapp = webtest.TestApp(recorder_app) @@ -78,7 +78,10 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert resp.headers['WebAgg-Source-Coll'] == 'live' - assert resp.headers['Link'] == MementoUtils.make_link(unquote(url), 'original') + if not link_url: + link_url = unquote(url) + + assert resp.headers['Link'] == MementoUtils.make_link(link_url, 'original') assert resp.headers['Memento-Datetime'] != '' return resp @@ -303,7 +306,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert len(res) == 2 def test_record_param_user_coll_write_dupe_no_revisit(self): - warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') dedup_index = self._get_dedup_index(dupe_policy=WriteDupePolicy()) @@ -524,4 +526,37 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert status_headers.get_header('Content-Type') == 'text/plain' assert status_headers.get_header('Content-Length') == str(len(buff)) + def test_record_video_metadata(self): + warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') + + dedup_index = self._get_dedup_index() + + writer = PerRecordWARCWriter(warc_path, dedup_index=dedup_index) + recorder_app = RecorderApp(self.upstream_url, writer) + + params = {'param.recorder.user': 'USER', + 'param.recorder.coll': 'VIDEO', + 'content_type': 'application/vnd.youtube-dl_formats+json' + } + + resp = self._test_warc_write(recorder_app, + 'www.youtube.com', '/v/BfBgWtAIbRc', '&' + urlencode(params), + link_url='metadata://www.youtube.com/v/BfBgWtAIbRc') + + r = FakeStrictRedis.from_url('redis://localhost/2') + + warcs = r.hgetall('USER:VIDEO:warc') + assert len(warcs) == 1 + + filename = list(warcs.values())[0] + + with open(filename, 'rb') as fh: + decomp = DecompressingBufferedReader(fh) + record = ArcWarcRecordLoader().parse_record_stream(decomp) + + status_headers = record.rec_headers + assert status_headers.get_header('WARC-Type') == 'metadata' + assert status_headers.get_header('Content-Type') == 'application/vnd.youtube-dl_formats+json' + assert status_headers.get_header('WARC-Block-Digest') != '' + assert status_headers.get_header('WARC-Block-Digest') == status_headers.get_header('WARC-Payload-Digest') diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 39d791f1..7fce2dd1 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -94,13 +94,9 @@ class BaseWARCWriter(object): url = resp.rec_headers.get('WARC-Target-URI') dt = resp.rec_headers.get('WARC-Date') - if not req.rec_headers.get('WARC-Record-ID'): - req.rec_headers['WARC-Record-ID'] = self._make_warc_id() - + #req.rec_headers['Content-Type'] = req.content_type req.rec_headers['WARC-Target-URI'] = url req.rec_headers['WARC-Date'] = dt - req.rec_headers['WARC-Type'] = 'request' - #req.rec_headers['Content-Type'] = req.content_type resp_id = resp.rec_headers.get('WARC-Record-ID') if resp_id: @@ -114,37 +110,47 @@ class BaseWARCWriter(object): params['_formatter'] = ParamFormatter(params, name=self.rec_source_name) self._do_write_req_resp(req, resp, params) - def create_req_record(self, req_headers, payload, type_, content_type=''): + def create_req_record(self, req_headers, payload): len_ = payload.tell() payload.seek(0) warc_headers = req_headers + warc_headers['WARC-Type'] = 'request' + if not warc_headers.get('WARC-Record-ID'): + warc_headers['WARC-Record-ID'] = self._make_warc_id() + status_headers = self.parser.parse(payload) - record = ArcWarcRecord('warc', type_, warc_headers, payload, - status_headers, content_type, len_) + record = ArcWarcRecord('warc', 'request', warc_headers, payload, + status_headers, '', len_) self._set_header_buff(record) return record - def create_resp_record(self, resp_headers, payload, type_, content_type=''): + def read_resp_record(self, resp_headers, payload): len_ = payload.tell() payload.seek(0) warc_headers = self.parser.parse(payload) warc_headers = CaseInsensitiveDict(warc_headers.headers) - status_headers = self.parser.parse(payload) + record_type = warc_headers.get('WARC-Type', 'response') - record = ArcWarcRecord('warc', type_, warc_headers, payload, - status_headers, content_type, len_) + if record_type == 'response': + status_headers = self.parser.parse(payload) + else: + status_headers = None - self._set_header_buff(record) + record = ArcWarcRecord('warc', record_type, warc_headers, payload, + status_headers, '', len_) + + if record_type == 'response': + self._set_header_buff(record) self.ensure_digest(record) - return record + return record_type, record def create_warcinfo_record(self, filename, **kwargs): warc_headers = {} @@ -220,7 +226,11 @@ class BaseWARCWriter(object): self._header(out, n, v) - content_type = record.content_type + content_type = record.rec_headers.get('Content-Type') + + if not content_type: + content_type = record.content_type + if not content_type: content_type = self.WARC_RECORDS.get(record.rec_headers['WARC-Type']) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 7522cd97..b1938407 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -13,6 +13,8 @@ from pywb.cdx.cdxobject import CDXObject from pywb.warc.recordloader import ArcWarcRecordLoader from pywb.framework.wbrequestresponse import WbResponse +from six.moves.urllib.parse import urlencode + from urlrewrite.rewriteinputreq import RewriteInputRequest from urlrewrite.templateview import JinjaEnv, HeadInsertView, TopFrameView, BaseInsertView @@ -31,10 +33,13 @@ class UpstreamException(WbException): # ============================================================================ class RewriterApp(object): + VIDEO_INFO_CONTENT_TYPE = 'application/vnd.youtube-dl_formats+json' + def __init__(self, framed_replay=False, jinja_env=None, config=None): self.loader = ArcWarcRecordLoader() config = config or {} + self.paths = config['url_templates'] self.framed_replay = framed_replay self.frame_mod = '' @@ -76,8 +81,6 @@ class RewriterApp(object): def render_content(self, wb_url, kwargs, environ): wb_url = WbUrl(wb_url) - #if wb_url.mod == 'vi_': - # return self._get_video_info(wbrequest) host_prefix = self.get_host_prefix(environ) rel_prefix = self.get_rel_prefix(environ) @@ -95,13 +98,12 @@ class RewriterApp(object): self.unrewrite_referrer(environ) - url = wb_url.url - urlkey = canonicalize(url) + urlkey = canonicalize(wb_url.url) - inputreq = RewriteInputRequest(environ, urlkey, url, + inputreq = RewriteInputRequest(environ, urlkey, wb_url.url, self.content_rewriter) - inputreq.include_post_query(url) + inputreq.include_post_query(wb_url.url) mod_url = None use_206 = False @@ -119,7 +121,6 @@ class RewriterApp(object): # if bytes=0- Range request, # simply remove the range and still proxy if start == 0 and not end and use_206: - url = mod_url wb_url.url = mod_url inputreq.url = mod_url @@ -133,10 +134,10 @@ class RewriterApp(object): setcookie_headers = None if self.cookie_tracker: cookie_key = self.get_cookie_key(kwargs) - res = self.cookie_tracker.get_cookie_headers(url, cookie_key) + res = self.cookie_tracker.get_cookie_headers(wb_url.url, cookie_key) inputreq.extra_cookie, setcookie_headers = res - r = self._do_req(inputreq, url, wb_url, kwargs, skip) + r = self._do_req(inputreq, wb_url, kwargs, skip) if r.status_code >= 400: error = None @@ -152,7 +153,7 @@ class RewriterApp(object): error = '' details = dict(args=kwargs, error=error) - raise UpstreamException(r.status_code, url=url, details=details) + raise UpstreamException(r.status_code, url=wb_url.url, details=details) if async_record_url: environ.pop('HTTP_RANGE', '') @@ -168,7 +169,7 @@ class RewriterApp(object): cdx = CDXObject() cdx['urlkey'] = urlkey cdx['timestamp'] = http_date_to_timestamp(r.headers.get('Memento-Datetime')) - cdx['url'] = url + cdx['url'] = wb_url.url self._add_custom_params(cdx, r.headers, kwargs) @@ -246,8 +247,8 @@ class RewriterApp(object): return WbResponse.text_response(error_html, content_type='text/html') - def _do_req(self, inputreq, url, wb_url, kwargs, skip): - req_data = inputreq.reconstruct_request(url) + def _do_req(self, inputreq, wb_url, kwargs, skip): + req_data = inputreq.reconstruct_request(wb_url.url) headers = {'Content-Length': len(req_data), 'Content-Type': 'application/request'} @@ -260,7 +261,15 @@ class RewriterApp(object): else: closest = wb_url.timestamp - upstream_url = self.get_upstream_url(url, wb_url, closest, kwargs) + params = {} + params['url'] = wb_url.url + params['closest'] = closest + + if wb_url.mod == 'vi_': + params['content_type'] = self.VIDEO_INFO_CONTENT_TYPE + + upstream_url = self.get_upstream_url(wb_url, kwargs, params) + r = requests.post(upstream_url, data=BytesIO(req_data), headers=headers, @@ -269,11 +278,14 @@ class RewriterApp(object): return r def do_query(self, wb_url, kwargs): - upstream_url = self.get_upstream_url(wb_url.url, wb_url, 'now', kwargs) - upstream_url = upstream_url.replace('/resource/postreq', '/index') + params = {} + params['url'] = wb_url.url + params['output'] = 'json' + params['from'] = wb_url.timestamp + params['to'] = wb_url.end_timestamp - upstream_url += '&output=json' - upstream_url += '&from=' + wb_url.timestamp + '&to=' + wb_url.end_timestamp + upstream_url = self.get_upstream_url(wb_url, kwargs, params) + upstream_url = upstream_url.replace('/resource/postreq', '/index') r = requests.get(upstream_url) @@ -362,8 +374,15 @@ class RewriterApp(object): return False - def get_upstream_url(self, url, wb_url, closest, kwargs): - raise NotImplemented() + def get_base_url(self, wb_url, kwargs): + type = kwargs.get('type') + return self.paths[type] + + def get_upstream_url(self, wb_url, kwargs, params): + base_url = self.get_base_url(wb_url, kwargs) + #params['filter'] = tuple(params['filter']) + base_url += '&' + urlencode(params, True) + return base_url def get_cookie_key(self, kwargs): raise NotImplemented() @@ -378,7 +397,6 @@ class RewriterApp(object): def handle_custom_response(self, environ, wb_url, full_prefix, host_prefix, kwargs): if wb_url.is_query(): return self.handle_query(environ, wb_url, kwargs) - #return self.do_query(wb_url, kwargs) if self.framed_replay and wb_url.mod == self.frame_mod: extra_params = self.get_top_frame_params(wb_url, kwargs) diff --git a/urlrewrite/test/simpleapp.py b/urlrewrite/test/simpleapp.py index b741962d..e0137b99 100644 --- a/urlrewrite/test/simpleapp.py +++ b/urlrewrite/test/simpleapp.py @@ -16,13 +16,15 @@ from urlrewrite.cookies import CookieTracker # ============================================================================ class RWApp(RewriterApp): def __init__(self, upstream_urls, cookie_key_templ, redis): - self.upstream_urls = upstream_urls + config = {} + config['url_templates'] = upstream_urls + self.cookie_key_templ = cookie_key_templ self.app = Bottle() self.block_loader = LocalFileLoader() self.init_routes() - super(RWApp, self).__init__(True) + super(RWApp, self).__init__(True, config=config) self.cookie_tracker = CookieTracker(redis) @@ -34,11 +36,6 @@ class RWApp(RewriterApp): traceback.print_exc() return self.orig_error_handler(exc) - def get_upstream_url(self, url, wb_url, closest, kwargs): - type = kwargs.get('type') - return self.upstream_urls[type].format(url=quote(url), - closest=closest) - def get_cookie_key(self, kwargs): return self.cookie_key_templ.format(**kwargs) @@ -58,9 +55,9 @@ class RWApp(RewriterApp): @staticmethod def create_app(replay_port=8080, record_port=8010): - upstream_urls = {'live': 'http://localhost:%s/live/resource/postreq?url={url}&closest={closest}' % replay_port, - 'record': 'http://localhost:%s/live/resource/postreq?url={url}&closest={closest}' % record_port, - 'replay': 'http://localhost:%s/replay/resource/postreq?url={url}&closest={closest}' % replay_port, + upstream_urls = {'live': 'http://localhost:%s/live/resource/postreq?' % replay_port, + 'record': 'http://localhost:%s/live/resource/postreq?' % record_port, + 'replay': 'http://localhost:%s/replay/resource/postreq?' % replay_port, } r = redis.StrictRedis.from_url('redis://localhost/2') diff --git a/webagg/aggregator.py b/webagg/aggregator.py index 8ddbe04f..9ca59b52 100644 --- a/webagg/aggregator.py +++ b/webagg/aggregator.py @@ -30,6 +30,10 @@ class BaseAggregator(object): if params.get('closest') == 'now': params['closest'] = timestamp_now() + content_type = params.get('content_type') + if content_type: + params['filter'] = '=mime:' + content_type + query = CDXQuery(params) cdx_iter, errs = self.load_index(query.params) diff --git a/webagg/handlers.py b/webagg/handlers.py index e7e1acf0..a8e067f3 100644 --- a/webagg/handlers.py +++ b/webagg/handlers.py @@ -1,4 +1,4 @@ -from webagg.responseloader import WARCPathLoader, LiveWebLoader +from webagg.responseloader import WARCPathLoader, LiveWebLoader, VideoLoader from webagg.utils import MementoUtils from pywb.utils.wbexception import BadRequestException, WbException from pywb.utils.wbexception import NotFoundException @@ -165,6 +165,7 @@ class DefaultResourceHandler(ResourceHandler): def __init__(self, index_source, warc_paths=''): loaders = [WARCPathLoader(warc_paths, index_source), LiveWebLoader(), + VideoLoader() ] super(DefaultResourceHandler, self).__init__(index_source, loaders) diff --git a/webagg/indexsource.py b/webagg/indexsource.py index 505e00d0..a52bb11a 100644 --- a/webagg/indexsource.py +++ b/webagg/indexsource.py @@ -92,6 +92,7 @@ class LiveIndexSource(BaseIndexSource): cdx['url'] = params['url'] cdx['load_url'] = res_template(self.proxy_url, params) cdx['is_live'] = 'true' + cdx['mime'] = params.get('content_type', '') def live(): yield cdx diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 7da983cb..d38c27b2 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -13,11 +13,12 @@ from pywb.warc.resolvingloader import ResolvingLoader from six.moves.urllib.parse import urlsplit -#from io import BytesIO +from io import BytesIO import uuid import six import itertools +import json from requests.models import PreparedRequest import urllib3 @@ -105,6 +106,12 @@ class BaseLoader(object): #print(msg) raise WbException(msg) + @staticmethod + def _make_warc_id(id_=None): + if not id_: + id_ = uuid.uuid1() + return ''.format(id_) + #============================================================================= class PrefixResolver(object): @@ -230,6 +237,9 @@ class LiveWebLoader(BaseLoader): if not load_url: return None + if params.get('content_type') == VideoLoader.CONTENT_TYPE: + return None + input_req = params['_input_req'] req_headers = input_req.get_req_headers() @@ -340,12 +350,56 @@ class LiveWebLoader(BaseLoader): warc_headers = StatusAndHeaders('WARC/1.0', warc_headers.items()) return (warc_headers, http_headers_buff, upstream_res) - @staticmethod - def _make_warc_id(id_=None): - if not id_: - id_ = uuid.uuid1() - return ''.format(id_) - def __str__(self): return 'LiveWebLoader' + +#============================================================================= +class VideoLoader(BaseLoader): + CONTENT_TYPE = 'application/vnd.youtube-dl_formats+json' + + def __init__(self): + try: + from youtube_dl import YoutubeDL as YoutubeDL + except ImportError: + self.ydl = None + return + + self.ydl = YoutubeDL(dict(simulate=True, + youtube_include_dash_manifest=False)) + + self.ydl.add_default_info_extractors() + + def load_resource(self, cdx, params): + load_url = cdx.get('load_url') + if not load_url: + return None + + if params.get('content_type') != self.CONTENT_TYPE: + return None + + if not self.ydl: + return None + + info = self.ydl.extract_info(load_url) + info_buff = json.dumps(info) + info_buff = info_buff.encode('utf-8') + + warc_headers = {} + + schema, rest = load_url.split('://', 1) + target_url = 'metadata://' + rest + + dt = timestamp_to_datetime(cdx['timestamp']) + + warc_headers['WARC-Type'] = 'metadata' + warc_headers['WARC-Record-ID'] = self._make_warc_id() + warc_headers['WARC-Target-URI'] = target_url + warc_headers['WARC-Date'] = datetime_to_iso_date(dt) + warc_headers['Content-Type'] = self.CONTENT_TYPE + warc_headers['Content-Length'] = str(len(info_buff)) + + warc_headers = StatusAndHeaders('WARC/1.0', warc_headers.items()) + + return warc_headers, None, BytesIO(info_buff) + diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 84bf4e5a..603f109d 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -14,6 +14,7 @@ from webagg.utils import MementoUtils from pywb.utils.statusandheaders import StatusAndHeadersParser from pywb.utils.bufferedreaders import ChunkedDataReader from io import BytesIO +from six.moves.urllib.parse import urlencode import webtest from fakeredis import FakeStrictRedis @@ -330,6 +331,47 @@ foo=bar&test=abc""" assert resp.headers['WebAgg-Source-Coll'] == 'example' + def test_live_video_loader(self): + params = {'url': 'http://www.youtube.com/v/BfBgWtAIbRc', + 'content_type': 'application/vnd.youtube-dl_formats+json' + } + + resp = self.testapp.get('/live/resource', params=params) + + assert resp.headers['WebAgg-Source-Coll'] == 'live' + + self._check_uri_date(resp, 'metadata://www.youtube.com/v/BfBgWtAIbRc', True) + + assert resp.headers['Link'] == MementoUtils.make_link('metadata://www.youtube.com/v/BfBgWtAIbRc', 'original') + assert resp.headers['Memento-Datetime'] != '' + + assert b'WARC-Type: metadata' in resp.body + assert b'Content-Type: application/vnd.youtube-dl_formats+json' in resp.body + + def test_live_video_loader_post(self): + req_data = """\ +GET /v/BfBgWtAIbRc HTTP/1.1 +accept-encoding: gzip, deflate +accept: */* +host: www.youtube.com\ +""" + + params = {'url': 'http://www.youtube.com/v/BfBgWtAIbRc', + 'content_type': 'application/vnd.youtube-dl_formats+json' + } + + resp = self.testapp.post('/live/resource/postreq?&' + urlencode(params), req_data) + + assert resp.headers['WebAgg-Source-Coll'] == 'live' + + self._check_uri_date(resp, 'metadata://www.youtube.com/v/BfBgWtAIbRc', True) + + assert resp.headers['Link'] == MementoUtils.make_link('metadata://www.youtube.com/v/BfBgWtAIbRc', 'original') + assert resp.headers['Memento-Datetime'] != '' + + assert b'WARC-Type: metadata' in resp.body + assert b'Content-Type: application/vnd.youtube-dl_formats+json' in resp.body + def test_error_redis_file_not_found(self): f = FakeStrictRedis.from_url('redis://localhost/2') f.hset('test:warc', 'example.warc.gz', './testdata/example2.warc.gz') From 3fec766e390b5d926efe8d52c3bcbd23e5f22af9 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 7 Jun 2016 12:54:28 -0400 Subject: [PATCH 088/112] webagg: redis lookup: if url contains wildcard, scan redis keys to check multiple keys until one is found webagg tests: fix test to include mime in live cdx --- webagg/responseloader.py | 12 +++++++++++- webagg/test/test_handlers.py | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index d38c27b2..00f01069 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -133,7 +133,17 @@ class RedisResolver(RedisIndexSource): if hasattr(cdx, '_formatter') and cdx._formatter: redis_key = cdx._formatter.format(redis_key) - res = self.redis.hget(redis_key, filename) + res = None + + if '*' in redis_key: + for key in self.redis.scan_iter(redis_key): + #key = key.decode('utf-8') + res = self.redis.hget(key, filename) + if res: + break + else: + res = self.redis.hget(redis_key, filename) + if res and six.PY3: res = res.decode('utf-8') diff --git a/webagg/test/test_handlers.py b/webagg/test/test_handlers.py index 603f109d..70b239e2 100644 --- a/webagg/test/test_handlers.py +++ b/webagg/test/test_handlers.py @@ -124,7 +124,7 @@ class TestResAgg(FakeRedisTests, BaseTestClass): cdxlist = list([json.loads(cdx) for cdx in resp.text.rstrip().split('\n')]) cdxlist[0]['timestamp'] = '2016' assert(cdxlist == [{'url': 'http://httpbin.org/get', 'urlkey': 'org,httpbin)/get', 'is_live': 'true', - 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) + 'mime': '', 'load_url': 'http://httpbin.org/get', 'source': 'live', 'timestamp': '2016'}]) def test_live_resource(self): headers = {'foo': 'bar'} From 4c7da0f6ef145d02117b7086b74dc238b4720dd1 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 7 Jun 2016 12:55:04 -0400 Subject: [PATCH 089/112] recorder: support overridings get_params() in subclass multiwarcwriter: support multiple warcs in same dir, support random component in path, and a custom key template for selecting current warc file, not related to current directory --- recorder/recorderapp.py | 5 ++++- recorder/test/test_recorder.py | 29 ++++++++++++++++++++++------ recorder/warcwriter.py | 35 ++++++++++++++++++++++++---------- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index d61e3df1..74d58248 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -115,11 +115,14 @@ class RecorderApp(object): '200 OK', start_response) + def _get_params(self, environ): + params = dict(parse_qsl(environ.get('QUERY_STRING'))) + return params def __call__(self, environ, start_response): input_req = DirectWSGIInputRequest(environ) - params = dict(parse_qsl(environ.get('QUERY_STRING'))) + params = self._get_params(environ) request_uri = input_req.get_full_request_uri() diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 5320800f..7dbc2e75 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -168,10 +168,10 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert ('Cookie', 'boo=far') in stored_req.status_headers.headers def test_record_cookies_skip_header(self): - base_path = to_path(self.root_dir + '/warcs/cookieskip/') + warc_path = to_path(self.root_dir + '/warcs/cookieskip/') header_filter = ExcludeSpecificHeaders(['Set-Cookie', 'Cookie']) recorder_app = RecorderApp(self.upstream_url, - PerRecordWARCWriter(base_path, header_filter=header_filter), + PerRecordWARCWriter(warc_path, header_filter=header_filter), accept_colls='live') resp = self._test_warc_write(recorder_app, 'httpbin.org', '/cookies/set%3Fname%3Dvalue%26foo%3Dbar') @@ -182,7 +182,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert ('Set-Cookie', 'name=value; Path=/') in record.status_headers.headers assert ('Set-Cookie', 'foo=bar; Path=/') in record.status_headers.headers - stored_req, stored_resp = self._load_resp_req(base_path) + stored_req, stored_resp = self._load_resp_req(warc_path) assert ('Set-Cookie', 'name=value; Path=/') not in stored_resp.status_headers.headers assert ('Set-Cookie', 'foo=bar; Path=/') not in stored_resp.status_headers.headers @@ -201,7 +201,6 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) self._test_all_warcs('/warcs/', 2) def test_record_param_user_coll(self): - warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') dedup_index = self._get_dedup_index() @@ -234,6 +233,25 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) full_path = self.root_dir + '/warcs/' + cdx['filename'] assert warcs == {cdx['filename'].encode('utf-8'): full_path.encode('utf-8')} + def test_record_param_user_coll_same_dir(self): + warc_path = to_path(self.root_dir + '/warcs2/') + + dedup_index = self._get_dedup_index() + + recorder_app = RecorderApp(self.upstream_url, + PerRecordWARCWriter(warc_path, dedup_index=dedup_index, key_template='{user}:{coll}')) + + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.user=USER2¶m.recorder.coll=COLL2') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + resp = self._test_warc_write(recorder_app, 'httpbin.org', + '/get?foo=bar', '¶m.recorder.user=USER2¶m.recorder.coll=COLL3') + assert b'HTTP/1.1 200 OK' in resp.body + assert b'"foo": "bar"' in resp.body + + self._test_all_warcs('/warcs2', 2) def test_record_param_user_coll_revisit(self): warc_path = to_path(self.root_dir + '/warcs/{user}/{coll}/') @@ -395,8 +413,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) assert len(writer.fh_cache) == 1 - writer.close_dir(self.root_dir + '/warcs/FOO/') - #writer.close_file({'param.recorder.coll': 'FOO'}) + writer.close_key(self.root_dir + '/warcs/FOO/') assert len(writer.fh_cache) == 0 diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 7fce2dd1..4ae1a20d 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -332,6 +332,7 @@ class MultiFileWARCWriter(BaseWARCWriter): filename_template = self.FILE_TEMPLATE self.dir_template = dir_template + self.key_template = kwargs.get('key_template', self.dir_template) self.filename_template = filename_template self.max_size = max_size if max_idle_secs > 0: @@ -344,9 +345,12 @@ class MultiFileWARCWriter(BaseWARCWriter): def _open_file(self, dir_, params): timestamp = timestamp20_now() + randstr = base64.b32encode(os.urandom(5)).decode('utf-8') + filename = dir_ + res_template(self.filename_template, params, hostname=self.hostname, - timestamp=timestamp) + timestamp=timestamp, + random=randstr) path, name = os.path.split(filename) @@ -366,9 +370,14 @@ class MultiFileWARCWriter(BaseWARCWriter): fcntl.flock(fh, fcntl.LOCK_UN) fh.close() - def close_dir(self, full_dir): - #full_dir = res_template(self.dir_template, params) - result = self.fh_cache.pop(full_dir, None) + def get_dir_key(self, params): + return res_template(self.key_template, params) + + def close_key(self, dir_key): + if isinstance(dir_key, dict): + dir_key = self.get_dir_key(dir_key) + + result = self.fh_cache.pop(dir_key, None) if not result: return @@ -376,6 +385,11 @@ class MultiFileWARCWriter(BaseWARCWriter): self._close_file(out) return filename + def close_file(self, match_filename): + for dir_key, out, filename in self.iter_open_files(): + if filename == match_filename: + return self.close_key(dir_key) + def _is_write_resp(self, resp, params): return True @@ -389,8 +403,9 @@ class MultiFileWARCWriter(BaseWARCWriter): def _do_write_req_resp(self, req, resp, params): full_dir = res_template(self.dir_template, params) + dir_key = self.get_dir_key(params) - result = self.fh_cache.get(full_dir) + result = self.fh_cache.get(dir_key) close_file = False @@ -436,11 +451,11 @@ class MultiFileWARCWriter(BaseWARCWriter): if close_file: self._close_file(out) if not is_new: - self.fh_cache.pop(full_dir, None) + self.fh_cache.pop(dir_key, None) elif is_new: fcntl.flock(out, fcntl.LOCK_EX | fcntl.LOCK_NB) - self.fh_cache[full_dir] = (out, filename) + self.fh_cache[dir_key] = (out, filename) def iter_open_files(self): for n, v in list(self.fh_cache.items()): @@ -448,7 +463,7 @@ class MultiFileWARCWriter(BaseWARCWriter): yield n, out, filename def close(self): - for dirname, out, filename in self.iter_open_files(): + for dir_key, out, filename in self.iter_open_files(): self._close_file(out) self.fh_cache = {} @@ -459,12 +474,12 @@ class MultiFileWARCWriter(BaseWARCWriter): now = datetime.datetime.now() - for dirname, out, filename in self.iter_open_files(): + for dir_key, out, filename in self.iter_open_files(): mtime = os.path.getmtime(filename) mtime = datetime.datetime.fromtimestamp(mtime) if (now - mtime) > self.max_idle_time: print('Closing idle ' + filename) - self.close_dir(dirname) + self.close_key(dir_key) # ============================================================================ From c1d7111841330a5e18797e904194a3c71a2d2c97 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 14 Jun 2016 00:13:01 -0400 Subject: [PATCH 090/112] webagg: store original 'source' value in cdx for properly mapping in WARC file resolver error handling: ensure 'last_exc' is a string --- webagg/app.py | 2 ++ webagg/responseloader.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/webagg/app.py b/webagg/app.py index cb5e8bb0..38177fa0 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -74,6 +74,8 @@ class ResAggApp(object): res = self.json_encode(res, out_headers) if errs: + if 'last_exc' in errs: + errs['last_exc'] = str(errs['last_exc']) out_headers['ResErrors'] = json.dumps(errs) start_response('200 OK', list(out_headers.items())) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 00f01069..5a8326b0 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -187,7 +187,8 @@ class WARCPathLoader(BaseLoader): if not cdx.get('filename') or cdx.get('offset') is None: return None - cdx._formatter = ParamFormatter(params, cdx.get('source')) + orig_source = cdx.get('source', '').split(':')[0] + cdx._formatter = ParamFormatter(params, orig_source) failed_files = [] headers, payload = (self.resolve_loader. From bc36ae13022bee2085a9762251bea406e58990be Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 14 Jun 2016 00:14:29 -0400 Subject: [PATCH 091/112] rewriter: update for moved RewriterAMF in pywb --- urlrewrite/rewriterapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index b1938407..9a3e2889 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -1,6 +1,6 @@ import requests -from pywb.rewrite.rewrite_content import RewriteContentAMF +from pywb.rewrite.rewrite_amf import RewriteContentAMF from pywb.rewrite.wburl import WbUrl from pywb.rewrite.url_rewriter import UrlRewriter From ae290587f6f44ab90572e3435f54293cf63aee81 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 1 Jul 2016 10:15:59 -0400 Subject: [PATCH 092/112] temp cookie store: add add_cookie() function for explicitly adding cookie, make expiry configurable related to webrecorder/webrecorder#79 --- urlrewrite/cookies.py | 54 +++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/urlrewrite/cookies.py b/urlrewrite/cookies.py index 5ebe1144..9823da47 100644 --- a/urlrewrite/cookies.py +++ b/urlrewrite/cookies.py @@ -1,6 +1,7 @@ from pywb.rewrite.cookie_rewriter import WbUrlBaseCookieRewriter from pywb.utils.timeutils import datetime_to_http_date from six.moves.http_cookiejar import CookieJar, DefaultCookiePolicy +from six.moves import zip import redis @@ -12,42 +13,54 @@ import six # ============================================================================= class CookieTracker(object): - def __init__(self, redis): + def __init__(self, redis, expire_time=120): self.redis = redis + self.expire_time = expire_time def get_rewriter(self, url_rewriter, cookie_key): - return DomainCacheCookieRewriter(url_rewriter, - self.redis, - cookie_key) + return DomainCacheCookieRewriter(url_rewriter, self, cookie_key) def get_cookie_headers(self, url, cookie_key): subds = self.get_subdomains(url) + if not subds: return None, None with redis.utils.pipeline(self.redis) as pi: - for x in subds: - pi.hgetall(cookie_key + '.' + x) + for domain in subds: + pi.hgetall(cookie_key + '.' + domain) all_res = pi.execute() cookies = [] set_cookies = [] - for res in all_res: - if not res: - continue + with redis.utils.pipeline(self.redis) as pi: + for res, domain in zip(all_res, subds): + if not res: + continue + + for n, v in six.iteritems(res): + n = n.decode('utf-8') + v = v.decode('utf-8') + full = n + '=' + v + cookies.append(full.split(';')[0]) + set_cookies.append(('Set-Cookie', full + '; Max-Age=' + str(self.expire_time))) + + pi.expire(cookie_key + '.' + domain, self.expire_time) - for n, v in six.iteritems(res): - n = n.decode('utf-8') - v = v.decode('utf-8') - full = n + '=' + v - cookies.append(full.split(';')[0]) - set_cookies.append(('Set-Cookie', full + '; Max-Age=120')) cookies = ';'.join(cookies) return cookies, set_cookies + def add_cookie(self, cookie_key, domain, name, value): + if domain[0] != '.': + domain = '.' + domain + + with redis.utils.pipeline(self.redis) as pi: + pi.hset(cookie_key + domain, name, value) + pi.expire(cookie_key + domain, self.expire_time) + @staticmethod def get_subdomains(url): tld = tldextract.extract(url) @@ -72,9 +85,9 @@ class CookieTracker(object): # ============================================================================= class DomainCacheCookieRewriter(WbUrlBaseCookieRewriter): - def __init__(self, url_rewriter, redis, cookie_key): + def __init__(self, url_rewriter, cookie_tracker, cookie_key): super(DomainCacheCookieRewriter, self).__init__(url_rewriter) - self.redis = redis + self.cookie_tracker = cookie_tracker self.cookie_key = cookie_key def rewrite_cookie(self, name, morsel): @@ -98,9 +111,10 @@ class DomainCacheCookieRewriter(WbUrlBaseCookieRewriter): if morsel.get('secure'): string += '; Secure' - with redis.utils.pipeline(self.redis) as pi: - pi.hset(self.cookie_key + domain, morsel.key, string) - pi.expire(self.cookie_key + domain, 120) + self.cookie_tracker.add_cookie(self.cookie_key, + domain, + morsel.key, + string) # else set cookie to rewritten path if morsel.get('path'): From 9588e8622f29d9cc3e83a75bd2c21c93ee54fb99 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 23 Jul 2016 21:57:24 -0400 Subject: [PATCH 093/112] responseloader: quote/unquote Webagg-Source-Coll header as source may contain unicode chars --- webagg/responseloader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 5a8326b0..0afe6442 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -11,7 +11,7 @@ from pywb.utils.statusandheaders import StatusAndHeaders, StatusAndHeadersParser from pywb.warc.resolvingloader import ResolvingLoader -from six.moves.urllib.parse import urlsplit +from six.moves.urllib.parse import urlsplit, quote, unquote from io import BytesIO @@ -35,7 +35,7 @@ class BaseLoader(object): out_headers = {} out_headers['WebAgg-Type'] = 'warc' - out_headers['WebAgg-Source-Coll'] = cdx.get('source', '') + out_headers['WebAgg-Source-Coll'] = quote(cdx.get('source', ''), safe=':/') out_headers['Content-Type'] = 'application/warc-record' if not warc_headers: @@ -293,7 +293,7 @@ class LiveWebLoader(BaseLoader): agg_type = upstream_res.headers.get('WebAgg-Type') if agg_type == 'warc': - cdx['source'] = upstream_res.headers.get('WebAgg-Source-Coll') + cdx['source'] = unquote(upstream_res.headers.get('WebAgg-Source-Coll')) return None, upstream_res.headers, upstream_res self.raise_on_self_redirect(params, cdx, From 34a710e51a083d28d56adab217e0ad1321547b38 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 24 Jul 2016 00:14:43 -0400 Subject: [PATCH 094/112] custom response: add utf-8 encoding, unless framed replay --- urlrewrite/rewriterapp.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 9a3e2889..4dbca2f7 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -79,6 +79,11 @@ class RewriterApp(object): return response(environ, start_response) + def is_framed_replay(self, wb_url): + return (self.framed_replay and + wb_url.mod == self.frame_mod and + wb_url.is_replay()) + def render_content(self, wb_url, kwargs, environ): wb_url = WbUrl(wb_url) @@ -89,7 +94,13 @@ class RewriterApp(object): resp = self.handle_custom_response(environ, wb_url, full_prefix, host_prefix, kwargs) if resp is not None: - return WbResponse.text_response(resp, content_type='text/html') + content_type = 'text/html' + + # if not replay outer frame, specify utf-8 charset + if not self.is_framed_replay(wb_url): + content_type += '; charset=utf-8' + + return WbResponse.text_response(resp, content_type=content_type) urlrewriter = UrlRewriter(wb_url, prefix=full_prefix, @@ -398,7 +409,7 @@ class RewriterApp(object): if wb_url.is_query(): return self.handle_query(environ, wb_url, kwargs) - if self.framed_replay and wb_url.mod == self.frame_mod: + if self.is_framed_replay(wb_url): extra_params = self.get_top_frame_params(wb_url, kwargs) return self.frame_insert_view.get_top_frame(wb_url, full_prefix, From 14cf68e4e5292dce7c26da41902ef72e63ead328 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 26 Jul 2016 19:41:47 -0400 Subject: [PATCH 095/112] custom record: don't override WARC-Date if provided in request header, return chosen WARC-Date in json response --- recorder/recorderapp.py | 6 +++++- recorder/test/test_recorder.py | 3 +++ recorder/warcwriter.py | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 74d58248..6b9a813e 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -111,7 +111,10 @@ class RecorderApp(object): self.writer.write_record(record, params) - return self.send_message({'success': 'true'}, + msg = {'success': 'true', + 'WARC-Date': record.rec_headers.get('WARC-Date')} + + return self.send_message(msg, '200 OK', start_response) @@ -262,6 +265,7 @@ class ReqWrapper(Wrapper): def __init__(self, stream, req_headers): super(ReqWrapper, self).__init__(stream) self.headers = CaseInsensitiveDict(req_headers) + for n in req_headers.keys(): if not n.upper().startswith('WARC-'): del self.headers[n] diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 7dbc2e75..33796802 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -512,6 +512,9 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) resp = testapp.put(req_url, headers=headers, params=buff) + assert resp.json['success'] == 'true' + assert resp.json['WARC-Date'] != '' + self._test_all_warcs('/warcs/meta', 1) r = FakeStrictRedis.from_url('redis://localhost/2') diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 4ae1a20d..c7b5e238 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -180,7 +180,9 @@ class BaseWARCWriter(object): warc_headers['WARC-Record-ID'] = self._make_warc_id() warc_headers['WARC-Type'] = record_type warc_headers['WARC-Target-URI'] = uri - warc_headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) + + if 'WARC-Date' not in warc_headers: + warc_headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) record = ArcWarcRecord('warc', record_type, warc_headers, payload, None, content_type, len_) From a5696fc2d461ee0c127165e41c6cc43c27da8c1d Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 26 Jul 2016 19:42:32 -0400 Subject: [PATCH 096/112] rewriter: range massage for patch as well as record --- urlrewrite/rewriterapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 4dbca2f7..c6a8c6a4 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -123,7 +123,7 @@ class RewriterApp(object): readd_range = False async_record_url = None - if kwargs.get('type') == 'record': + if kwargs.get('type') in ('record', 'patch'): rangeres = inputreq.extract_range() if rangeres: From 498f87fb54811be3b0c41718ac8dd71f671a3a71 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Tue, 26 Jul 2016 19:42:59 -0400 Subject: [PATCH 097/112] add Dockerfile to git! --- Dockerfile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e7333e0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.5.1 + +RUN pip install gevent uwsgi bottle urllib3 youtube-dl + +RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.31.5 + +RUN pip install git+https://github.com/t0m/pyamf.git@python3 + +RUN pip install boto + +ADD . /webrecore/ +WORKDIR /webrecore/ + +RUN pip install -e ./ + +RUN useradd -ms /bin/bash apprun + +USER apprun + + From 1b0901595496b3b50040fc7d0a41c9775d912363 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 30 Jul 2016 13:11:12 -0400 Subject: [PATCH 098/112] recorder: split up _open_file() into get_new_filename() and allow_new_file() to customize skipping recording by returning false from allow_new_file() create_warcinfo_record() - switch to dict args over kwargs, update tests --- recorder/test/test_recorder.py | 2 +- recorder/warcwriter.py | 22 +++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/recorder/test/test_recorder.py b/recorder/test/test_recorder.py index 33796802..e6f75d9e 100644 --- a/recorder/test/test_recorder.py +++ b/recorder/test/test_recorder.py @@ -471,7 +471,7 @@ class TestRecorder(LiveServerTests, FakeRedisTests, TempDirTests, BaseTestClass) 'format': 'WARC File Format 1.0', 'json-metadata': json.dumps({'foo': 'bar'})} - record = simplewriter.create_warcinfo_record('testfile.warc.gz', **params) + record = simplewriter.create_warcinfo_record('testfile.warc.gz', params) simplewriter.write_record(record) buff = simplewriter.get_buffer() assert isinstance(buff, bytes) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index c7b5e238..0ddec1d4 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -152,7 +152,7 @@ class BaseWARCWriter(object): return record_type, record - def create_warcinfo_record(self, filename, **kwargs): + def create_warcinfo_record(self, filename, info): warc_headers = {} warc_headers['WARC-Record-ID'] = self._make_warc_id() warc_headers['WARC-Type'] = 'warcinfo' @@ -161,7 +161,7 @@ class BaseWARCWriter(object): warc_headers['WARC-Date'] = datetime_to_iso_date(datetime.datetime.utcnow()) warcinfo = BytesIO() - for n, v in six.iteritems(kwargs): + for n, v in six.iteritems(info): self._header(warcinfo, n, v) warcinfo.seek(0) @@ -344,7 +344,7 @@ class MultiFileWARCWriter(BaseWARCWriter): self.fh_cache = {} - def _open_file(self, dir_, params): + def get_new_filename(self, dir_, params): timestamp = timestamp20_now() randstr = base64.b32encode(os.urandom(5)).decode('utf-8') @@ -354,6 +354,12 @@ class MultiFileWARCWriter(BaseWARCWriter): timestamp=timestamp, random=randstr) + return filename + + def allow_new_file(self, filename, params): + return True + + def _open_file(self, filename, params): path, name = os.path.split(filename) try: @@ -366,7 +372,7 @@ class MultiFileWARCWriter(BaseWARCWriter): if self.dedup_index: self.dedup_index.add_warc_file(filename, params) - return fh, filename + return fh def _close_file(self, fh): fcntl.flock(fh, fcntl.LOCK_UN) @@ -415,7 +421,13 @@ class MultiFileWARCWriter(BaseWARCWriter): out, filename = result is_new = False else: - out, filename = self._open_file(full_dir, params) + filename = self.get_new_filename(full_dir, params) + + if not self.allow_new_file(filename, params): + return + + out = self._open_file(filename, params) + is_new = True try: From db3b92e2281f567f9577941a06c928484c80ffdb Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 31 Jul 2016 00:49:57 -0400 Subject: [PATCH 099/112] writing: add write_stream_to_file()function to be able to write to a WARC an existing input stream refactor _do_write_req_resp to pass callback to actual writing (eg. _write_to_file) --- recorder/warcwriter.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index 0ddec1d4..e45cf408 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -7,6 +7,7 @@ import zlib import sys import os import six +import shutil import traceback @@ -410,6 +411,26 @@ class MultiFileWARCWriter(BaseWARCWriter): self._do_write_req_resp(None, record, params) def _do_write_req_resp(self, req, resp, params): + def write_callback(out, filename): + url = resp.rec_headers.get('WARC-Target-URI') + print('Writing req/resp {0} to {1} '.format(url, filename)) + + if resp and self._is_write_resp(resp, params): + self._write_warc_record(out, resp) + + if req and self._is_write_req(req, params): + self._write_warc_record(out, req) + + return self._write_to_file(params, write_callback) + + def write_stream_to_file(self, params, stream): + def write_callback(out, filename): + print('Writing stream to {0}'.format(filename)) + shutil.copyfileobj(stream, out) + + return self._write_to_file(params, write_callback) + + def _write_to_file(self, params, write_callback): full_dir = res_template(self.dir_template, params) dir_key = self.get_dir_key(params) @@ -424,23 +445,16 @@ class MultiFileWARCWriter(BaseWARCWriter): filename = self.get_new_filename(full_dir, params) if not self.allow_new_file(filename, params): - return + return False out = self._open_file(filename, params) is_new = True try: - url = resp.rec_headers.get('WARC-Target-URI') - print('Writing req/resp {0} to {1} '.format(url, filename)) - start = out.tell() - if resp and self._is_write_resp(resp, params): - self._write_warc_record(out, resp) - - if req and self._is_write_req(req, params): - self._write_warc_record(out, req) + write_callback(out, filename) out.flush() @@ -453,9 +467,12 @@ class MultiFileWARCWriter(BaseWARCWriter): filename, new_size - start) + return True + except Exception as e: traceback.print_exc() close_file = True + return False finally: # check for rollover From 20b161bf9008c265dd609d160868094ffa3c6cc8 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 1 Aug 2016 02:12:15 -0400 Subject: [PATCH 100/112] debug: print stracktrace when debugging --- webagg/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webagg/app.py b/webagg/app.py index 38177fa0..e045480b 100644 --- a/webagg/app.py +++ b/webagg/app.py @@ -82,6 +82,8 @@ class ResAggApp(object): return res except Exception as e: + if self.debug: + traceback.print_exc() message = 'Internal Error: ' + str(e) status = 500 return self.send_error({}, start_response, From c93d7ecafcab5d426bc43ed041a568286bf6ce0a Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Thu, 4 Aug 2016 16:53:24 -0400 Subject: [PATCH 101/112] webagg: Fix loading of url-lookup (url agnostic) revisits, ensure all params passed to cdx lookup, add tests for url-agnostic revisit lookup --- testdata/example-url-agnostic-orig.warc.gz | Bin 0 -> 1354 bytes testdata/example-url-agnostic-revisit.warc.gz | Bin 0 -> 946 bytes testdata/url-agnost-example.cdxj | 2 ++ webagg/responseloader.py | 21 +++++++++++++----- webagg/test/test_handlers.py | 16 +++++++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 testdata/example-url-agnostic-orig.warc.gz create mode 100644 testdata/example-url-agnostic-revisit.warc.gz create mode 100644 testdata/url-agnost-example.cdxj diff --git a/testdata/example-url-agnostic-orig.warc.gz b/testdata/example-url-agnostic-orig.warc.gz new file mode 100644 index 0000000000000000000000000000000000000000..987003732a24124cc5cbbbe0cd15a8028316a63f GIT binary patch literal 1354 zcmV-Q1-1GgiwFP!0000018tGtYJ)Ho#_t8+AukY3{L|Jj2GXvqFxJruh2BVvNeyTs zF}B;Deo zI3baXA#`0#d)abDyCn^^kvE9u6HG#I`(BJE&a|oFVMoBzXcQSoS(D!a;d-5JL9%Lh`mA{%mht-E89+lGyir zZ_vUzpG!&#AH0H0cb!&4;E3S)J^whr9t-GzV;rG1ilY_Iz{{sKlv!Ux)!MaU%W;!8h*(L8NrELn$(m07 zz2HT+L=Tl_NN^W>cK7Tq@G^d!SWe4s7%zH7=DQSq$WW#tk+>JqY=w$EKTdsEE0y|| zg>N)YBW!V+(3Z$nR=xF_DsAuDPMg?$V!Lx^;CDN|?S8E{%;+u>l#y$`LaBU^YwGwP z+`IY9%k25#%lNkcJe_^&e|?^P>U_Tc@br1o?u~2P>5PmS)4@p}2JM#Pby}{|YdOxK z-tdT_$QYSk`w-+=;^#SMerPW|>NzfPsN;}M$Q&|oyaDNYi_pVGuhZ=>M-5{l5`~GX z8K7wz@sKJm601o03SzlZS~Wa!v$wdzWTnz zd2QDxlZ!t1X;ayDMNS$TB$nyPZ$<8bI ze=_-gSe+AjLgqPRXLY}z`+*w!TzlaAK-n>f>KRyNi;jx55m?nc8bOho9#0))-N0BMMoIRF3v literal 0 HcmV?d00001 diff --git a/testdata/example-url-agnostic-revisit.warc.gz b/testdata/example-url-agnostic-revisit.warc.gz new file mode 100644 index 0000000000000000000000000000000000000000..3770ed0a8aceb2567092233be67b6daac53c2db3 GIT binary patch literal 946 zcmV;j15NxNiwFP!0000018tFAYlAQphVKRcL;gTCM*CqHgVL_7FxJruh2BWaM-6Bq zF}B-&{i4WrvjoE7<$XElIk{|3+c=3(7+ib>i?2=)@Sv6{EzKq#(UIXvtLcp9uG!=O0%E!GRZSP7X! zRB;S5nl4~Pvn9;4C1WxdDbM9|7;KGpLc4il+I5X$)G4FmslQaB;r*ZOP3W7eJw=v= zK^XK#I`8vIMd3qKkeRkMx{o{)Jil)~&acM;dgL_0XoXU|Omgt@=`3Z=*f5aBw$u?Y z@_-Q?>?Z|Gt7JxxhQ2Ta)XsGzjw@B^p^YjnBWdF?Ha+Fxd@S*LflzrDw)f*FW#iY~ zJ8r3TQO9K%bVJpsz802%gQu}tIIyv-R(IihS>gTs|NlvFb{%oY`vZXnsdnlC001A0 z2ng^e7E=HNb#iQ9VP|e{b963uVRB;tZIWMan=ll`-;wwZR-ZO$4%iU>xa+huNtbMm znx>@L`etm0FcBLW(>33ICMDh0P9T9U_a2|~<8DT)v4;k}>ul^B%nr36K#Rw+Db1-y zbs-E~ujT~ojH!v|8PS-};)(9-N(`9VdwuJK8as}C6hJ?QIL%N>hREIzeZ!I-Dn&V* zl!cWbpxIGGF7b7?*(}#LKSr~7Jzw6&59{S^c)z&3yB`P9sGprL;fQlBnudTh7@#B^ zU=$4yN>AciZOcl$xRk=wwq58)F;Kejeo@W3tX`kbYjp`}Ays2a21_oaebNfny-9vF zd7_|O$!t7MKqX{hc71&%wn8^BQv`f$wIpp@a>D#Pp$WneQG{T~ID#okQaDU`Q|z!#zbx4ib~6vfc#)r>h*a>JQ;h?zB&hNM|C62#X7r!iR(OvUMTqi z;1qmqt0Mq#+BWcIk-5$^qXhwHapFflhT%4XA Date: Tue, 9 Aug 2016 19:53:22 -0400 Subject: [PATCH 102/112] urlrewrite misc fixes: - ensure content-length is converted to str - templateview: support optional extensions - fix test --- urlrewrite/rewriterapp.py | 2 +- urlrewrite/templateview.py | 13 ++++++++++--- urlrewrite/test/simpleapp.py | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index c6a8c6a4..9bfb5b3d 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -261,7 +261,7 @@ class RewriterApp(object): def _do_req(self, inputreq, wb_url, kwargs, skip): req_data = inputreq.reconstruct_request(wb_url.url) - headers = {'Content-Length': len(req_data), + headers = {'Content-Length': str(len(req_data)), 'Content-Type': 'application/request'} if skip: diff --git a/urlrewrite/templateview.py b/urlrewrite/templateview.py index 804727a2..c33f854e 100644 --- a/urlrewrite/templateview.py +++ b/urlrewrite/templateview.py @@ -28,16 +28,23 @@ class JinjaEnv(object): def __init__(self, paths=['templates', '.', '/'], packages=['pywb'], globals=None, - overlay=None): + overlay=None, + extensions=None): self._init_filters() loader = ChoiceLoader(self._make_loaders(paths, packages)) + extensions = extensions or {} + if overlay: - jinja_env = overlay.jinja_env.overlay(loader=loader, trim_blocks=True) + jinja_env = overlay.jinja_env.overlay(loader=loader, + trim_blocks=True, + extensions=extensions) else: - jinja_env = RelEnvironment(loader=loader, trim_blocks=True) + jinja_env = RelEnvironment(loader=loader, + trim_blocks=True, + extensions=extensions) jinja_env.filters.update(self.filters) if globals: diff --git a/urlrewrite/test/simpleapp.py b/urlrewrite/test/simpleapp.py index e0137b99..b651e24f 100644 --- a/urlrewrite/test/simpleapp.py +++ b/urlrewrite/test/simpleapp.py @@ -33,6 +33,7 @@ class RWApp(RewriterApp): def err_handler(self, exc): print(exc) + import traceback traceback.print_exc() return self.orig_error_handler(exc) From 594aff86d3ae2b605f42f2b524d1d761aa2cf888 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 10 Aug 2016 00:50:43 -0400 Subject: [PATCH 103/112] webagg: response self-redir: don't check if live, throw correct exception --- webagg/responseloader.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index ed3c72ba..37ac7efa 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -88,6 +88,9 @@ class BaseLoader(object): Check if response is a 3xx redirect to the same url If so, reject this capture to avoid causing redirect loop """ + if cdx.get('is_live'): + return + if not status_code.startswith('3') or status_code == '304': return @@ -104,7 +107,7 @@ class BaseLoader(object): msg = 'Self Redirect {0} -> {1}' msg = msg.format(request_url, location_url) #print(msg) - raise WbException(msg) + raise LiveResourceException(msg) @staticmethod def _make_warc_id(id_=None): From 82d3b61523e07eeb5de6c8e5019f265c41926be7 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 12 Aug 2016 01:19:30 -0400 Subject: [PATCH 104/112] recorder: catch exception in close_idle_files() if file no longer exists and ensure it's removed --- recorder/warcwriter.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/recorder/warcwriter.py b/recorder/warcwriter.py index e45cf408..b125da5e 100644 --- a/recorder/warcwriter.py +++ b/recorder/warcwriter.py @@ -376,8 +376,11 @@ class MultiFileWARCWriter(BaseWARCWriter): return fh def _close_file(self, fh): - fcntl.flock(fh, fcntl.LOCK_UN) - fh.close() + try: + fcntl.flock(fh, fcntl.LOCK_UN) + fh.close() + except Exception as e: + print(e) def get_dir_key(self, params): return res_template(self.key_template, params) @@ -506,8 +509,14 @@ class MultiFileWARCWriter(BaseWARCWriter): now = datetime.datetime.now() for dir_key, out, filename in self.iter_open_files(): - mtime = os.path.getmtime(filename) + try: + mtime = os.path.getmtime(filename) + except: + self.close_key(dir_key) + return + mtime = datetime.datetime.fromtimestamp(mtime) + if (now - mtime) > self.max_idle_time: print('Closing idle ' + filename) self.close_key(dir_key) From c8b6a480055147757e5158c2f192f8165315a110 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 12 Aug 2016 21:22:17 -0400 Subject: [PATCH 105/112] webagg: use prepare_auth() to ensure Authorization header is set for http://user:pass@host urls --- webagg/responseloader.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 37ac7efa..55a51f30 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -277,6 +277,13 @@ class LiveWebLoader(BaseLoader): p = PreparedRequest() p.prepare_url(load_url, None) + p.prepare_headers(None) + p.prepare_auth(None, load_url) + + auth = p.headers.get('Authorization') + if auth: + req_headers['Authorization'] = auth + load_url = p.url try: From 2fb1df34c9ed76c938c534a82274e88fa8d7a9ad Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 12 Aug 2016 21:23:25 -0400 Subject: [PATCH 106/112] recorder: add upload/streaming support with put_record=stream where the content being uploaded is already in WARC record form --- recorder/recorderapp.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/recorder/recorderapp.py b/recorder/recorderapp.py index 6b9a813e..eeded251 100644 --- a/recorder/recorderapp.py +++ b/recorder/recorderapp.py @@ -94,6 +94,15 @@ class RecorderApp(object): def _put_record(self, request_uri, input_buff, record_type, headers, params, start_response): + if record_type == 'stream': + if self.writer.write_stream_to_file(params, input_buff): + msg = {'success': 'true'} + else: + msg = {'error_message': 'upload_error'} + + return self.send_message(msg, '200 OK', + start_response) + req_stream = ReqWrapper(input_buff, headers) while True: @@ -123,6 +132,13 @@ class RecorderApp(object): return params def __call__(self, environ, start_response): + try: + return self.handle_call(environ, start_response) + except: + import traceback + traceback.print_exc() + + def handle_call(self, environ, start_response): input_req = DirectWSGIInputRequest(environ) params = self._get_params(environ) From 5c499753f8b7de962211ed35abfa1f161b2f97c0 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Fri, 16 Sep 2016 18:43:07 -0700 Subject: [PATCH 107/112] webrecore Docker: update Docker file to latest pywb, python, starting to use versioning! --- Dockerfile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e7333e0f..583665a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,11 @@ -FROM python:3.5.1 +#webrecorder/webrecore 1.0 + +FROM python:3.5.2 RUN pip install gevent uwsgi bottle urllib3 youtube-dl -RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.31.5 +#RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.32.0 +RUN pip install pywb RUN pip install git+https://github.com/t0m/pyamf.git@python3 @@ -13,7 +16,7 @@ WORKDIR /webrecore/ RUN pip install -e ./ -RUN useradd -ms /bin/bash apprun +RUN useradd -ms /bin/bash -u 1000 apprun USER apprun From ccc13b427f4f0b52de95e1d8289dfa08e35cab92 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sun, 2 Oct 2016 11:29:51 -0700 Subject: [PATCH 108/112] dockerfile: update to latest pywb urlrewrite: upstream url avoid adding empty '&' --- Dockerfile | 4 ++-- urlrewrite/rewriterapp.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 583665a7..39598278 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ FROM python:3.5.2 RUN pip install gevent uwsgi bottle urllib3 youtube-dl -#RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.32.0 -RUN pip install pywb +RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.32.2 +#RUN pip install pywb RUN pip install git+https://github.com/t0m/pyamf.git@python3 diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index 9bfb5b3d..cadd67ef 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -391,8 +391,9 @@ class RewriterApp(object): def get_upstream_url(self, wb_url, kwargs, params): base_url = self.get_base_url(wb_url, kwargs) - #params['filter'] = tuple(params['filter']) - base_url += '&' + urlencode(params, True) + param_str = urlencode(params, True) + if param_str: + base_url += '&' + param_str return base_url def get_cookie_key(self, kwargs): From 003d84c371f6f1e76998a0bf3c29889f8a1c97df Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 19 Oct 2016 10:48:05 -0700 Subject: [PATCH 109/112] responseloader: self-redirect: if no status code (eg. for revisits), always parse and look at the actual status code --- webagg/responseloader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webagg/responseloader.py b/webagg/responseloader.py index 55a51f30..ecda0723 100644 --- a/webagg/responseloader.py +++ b/webagg/responseloader.py @@ -208,7 +208,8 @@ class WARCPathLoader(BaseLoader): failed_files, local_index_query)) - if cdx.get('status', '').startswith('3'): + status = cdx.get('status') + if not status or status.startswith('3'): status_headers = self.headers_parser.parse(payload.stream) self.raise_on_self_redirect(params, cdx, status_headers.get_statuscode(), From 3d507c5d68caa7a4869af44a4984c2ae61fcc6dc Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 22 Oct 2016 00:13:41 -0700 Subject: [PATCH 110/112] urlrewrite: webassets: add webassets support to JinjaEnv, if 'assets_path' is set, the specified webassets yaml file is added to the env --- Dockerfile | 2 +- urlrewrite/templateview.py | 43 +++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 39598278..075b3e47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.32.2 RUN pip install git+https://github.com/t0m/pyamf.git@python3 -RUN pip install boto +RUN pip install boto webassets ADD . /webrecore/ WORKDIR /webrecore/ diff --git a/urlrewrite/templateview.py b/urlrewrite/templateview.py index c33f854e..e6b8cdd3 100644 --- a/urlrewrite/templateview.py +++ b/urlrewrite/templateview.py @@ -5,6 +5,12 @@ from six.moves.urllib.parse import urlsplit from jinja2 import Environment from jinja2 import FileSystemLoader, PackageLoader, ChoiceLoader +from webassets.ext.jinja2 import AssetsExtension +from webassets.loaders import YAMLLoader +from webassets.env import Resolver + +from pkg_resources import resource_filename + import json import os @@ -27,6 +33,7 @@ class RelEnvironment(Environment): class JinjaEnv(object): def __init__(self, paths=['templates', '.', '/'], packages=['pywb'], + assets_path=None, globals=None, overlay=None, extensions=None): @@ -35,7 +42,10 @@ class JinjaEnv(object): loader = ChoiceLoader(self._make_loaders(paths, packages)) - extensions = extensions or {} + extensions = extensions or [] + + if assets_path: + extensions.append(AssetsExtension) if overlay: jinja_env = overlay.jinja_env.overlay(loader=loader, @@ -47,10 +57,19 @@ class JinjaEnv(object): extensions=extensions) jinja_env.filters.update(self.filters) + if globals: jinja_env.globals.update(globals) + self.jinja_env = jinja_env + # init assets + if assets_path: + assets_loader = YAMLLoader(assets_path) + assets_env = assets_loader.load_environment() + assets_env.resolver = PkgResResolver() + jinja_env.assets_environment = assets_env + def _make_loaders(self, paths, packages): loaders = [] # add loaders for paths @@ -182,3 +201,25 @@ class TopFrameView(BaseInsertView): return self.render_to_string(env, **params) +# ============================================================================ +class PkgResResolver(Resolver): + def get_pkg_path(self, item): + if not isinstance(item, str): + return None + + parts = urlsplit(item) + if parts.scheme == 'pkg' and parts.netloc: + return (parts.netloc, parts.path) + + return None + + def resolve_source(self, ctx, item): + pkg = self.get_pkg_path(item) + if pkg: + filename = resource_filename(pkg[0], pkg[1]) + if filename: + return filename + + return super(PkgResResolver, self).resolve_source(ctx, item) + + From adce15123acd94d81aa65b001888d09fdeff57c0 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Sat, 22 Oct 2016 07:19:46 +0000 Subject: [PATCH 111/112] rewriter: mark 'is_ajax' in urlrewriter --- urlrewrite/rewriterapp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/urlrewrite/rewriterapp.py b/urlrewrite/rewriterapp.py index cadd67ef..ab7eba17 100644 --- a/urlrewrite/rewriterapp.py +++ b/urlrewrite/rewriterapp.py @@ -196,6 +196,7 @@ class RewriterApp(object): if self.is_ajax(environ): head_insert_func = None + urlrewriter.rewrite_opts['is_ajax'] = True else: top_url = self.get_top_url(full_prefix, wb_url, cdx, kwargs) head_insert_func = (self.head_insert_view. From de44110391c811a3bb2610f5d56d16a786ebbaec Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 24 Oct 2016 19:05:45 +0000 Subject: [PATCH 112/112] update to pywb 0.33.0 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 075b3e47..98b9385d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.5.2 RUN pip install gevent uwsgi bottle urllib3 youtube-dl -RUN pip install git+https://github.com/ikreymer/pywb.git@develop#egg=pywb-0.32.2 +RUN pip install git+https://github.com/ikreymer/pywb.git@master#egg=pywb-0.33.0 #RUN pip install pywb RUN pip install git+https://github.com/t0m/pyamf.git@python3