From 398202e6fc3afe4b439de78365385895f50730c7 Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Wed, 26 Aug 2015 17:08:42 -0400 Subject: [PATCH] Implement some new methods on the storage engines. --- data/database.py | 25 +----------- data/fields.py | 38 ++++++++++++++++++ data/model/blob.py | 5 ++- endpoints/v2/blob.py | 15 ++++--- storage/basestorage.py | 53 ++++++++++++++++--------- storage/cloud.py | 87 +++++++++++++++++++++++++++++++++++------ storage/fakestorage.py | 49 +++++++++++++++++++---- storage/local.py | 35 ++++------------- storage/swift.py | 2 + test/data/test.db | Bin 851968 -> 856064 bytes 10 files changed, 211 insertions(+), 98 deletions(-) create mode 100644 data/fields.py diff --git a/data/database.py b/data/database.py index ccfad8b82..cad01b4ac 100644 --- a/data/database.py +++ b/data/database.py @@ -11,6 +11,7 @@ from random import SystemRandom from datetime import datetime from peewee import * from data.read_slave import ReadSlaveModel +from data.fields import ResumableSHAField, JSONField from sqlalchemy.engine.url import make_url from collections import defaultdict @@ -750,35 +751,13 @@ class RepositoryAuthorizedEmail(BaseModel): ) -class ResumableSHAField(TextField): - def db_value(self, value): - sha_state = value.state() - - # One of the fields is a byte string, let's base64 encode it to make sure - # we can store and fetch it regardless of default collocation - sha_state[3] = base64.b64encode(sha_state[3]) - - return json.dumps(sha_state) - - def python_value(self, value): - to_resume = resumablehashlib.sha256() - if value is None: - return to_resume - - sha_state = json.loads(value) - - # We need to base64 decode the data bytestring - sha_state[3] = base64.b64decode(sha_state[3]) - to_resume.set_state(sha_state) - return to_resume - - class BlobUpload(BaseModel): repository = ForeignKeyField(Repository, index=True) uuid = CharField(index=True, unique=True) byte_count = IntegerField(default=0) sha_state = ResumableSHAField(null=True, default=resumablehashlib.sha256) location = ForeignKeyField(ImageStorageLocation) + storage_metadata = JSONField(null=True, default={}) class Meta: database = db diff --git a/data/fields.py b/data/fields.py new file mode 100644 index 000000000..123811ccd --- /dev/null +++ b/data/fields.py @@ -0,0 +1,38 @@ +import base64 +import resumablehashlib +import json + +from peewee import TextField + + +class ResumableSHAField(TextField): + def db_value(self, value): + sha_state = value.state() + + # One of the fields is a byte string, let's base64 encode it to make sure + # we can store and fetch it regardless of default collocation. + sha_state[3] = base64.b64encode(sha_state[3]) + + return json.dumps(sha_state) + + def python_value(self, value): + to_resume = resumablehashlib.sha256() + if value is None: + return to_resume + + sha_state = json.loads(value) + + # We need to base64 decode the data bytestring. + sha_state[3] = base64.b64decode(sha_state[3]) + to_resume.set_state(sha_state) + return to_resume + + +class JSONField(TextField): + def db_value(self, value): + return json.dumps(value) + + def python_value(self, value): + if value is None or value == "": + return {} + return json.loads(value) diff --git a/data/model/blob.py b/data/model/blob.py index 5820ba3b1..a547a6f97 100644 --- a/data/model/blob.py +++ b/data/model/blob.py @@ -67,7 +67,8 @@ def get_blob_upload(namespace, repo_name, upload_uuid): raise InvalidBlobUpload() -def initiate_upload(namespace, repo_name, uuid, location_name): +def initiate_upload(namespace, repo_name, uuid, location_name, storage_metadata): repo = _basequery.get_existing_repository(namespace, repo_name) location = ImageStorageLocation.get(name=location_name) - return BlobUpload.create(repository=repo, location=location, uuid=uuid) + return BlobUpload.create(repository=repo, location=location, uuid=uuid, + storage_metadata=storage_metadata) diff --git a/endpoints/v2/blob.py b/endpoints/v2/blob.py index 705794b6b..9867917a1 100644 --- a/endpoints/v2/blob.py +++ b/endpoints/v2/blob.py @@ -119,8 +119,8 @@ def _render_range(num_uploaded_bytes, with_bytes_prefix=True): @anon_protect def start_blob_upload(namespace, repo_name): location_name = storage.preferred_locations[0] - new_upload_uuid = storage.initiate_chunked_upload(location_name) - model.blob.initiate_upload(namespace, repo_name, new_upload_uuid, location_name) + new_upload_uuid, upload_metadata = storage.initiate_chunked_upload(location_name) + model.blob.initiate_upload(namespace, repo_name, new_upload_uuid, location_name, upload_metadata) digest = request.args.get('digest', None) if digest is None: @@ -205,11 +205,13 @@ def _upload_chunk(namespace, repo_name, upload_uuid): input_fp = wrap_with_handler(input_fp, found.sha_state.update) try: - length_written = storage.stream_upload_chunk({found.location.name}, upload_uuid, start_offset, - length, input_fp) + length_written, new_metadata = storage.stream_upload_chunk({found.location.name}, upload_uuid, + start_offset, length, input_fp, + found.storage_metadata) except InvalidChunkException: _range_not_satisfiable(found.byte_count) + found.storage_metadata = new_metadata found.byte_count += length_written return found @@ -222,7 +224,8 @@ def _finish_upload(namespace, repo_name, upload_obj, expected_digest): # Mark the blob as uploaded. final_blob_location = digest_tools.content_path(expected_digest) - storage.complete_chunked_upload({upload_obj.location.name}, upload_obj.uuid, final_blob_location) + storage.complete_chunked_upload({upload_obj.location.name}, upload_obj.uuid, final_blob_location, + upload_obj.storage_metadata) model.blob.store_blob_record_and_temp_link(namespace, repo_name, expected_digest, upload_obj.location, upload_obj.byte_count, app.config['PUSH_TEMP_TAG_EXPIRATION_SEC']) @@ -278,6 +281,6 @@ def cancel_upload(namespace, repo_name, upload_uuid): # We delete the record for the upload first, since if the partial upload in # storage fails to delete, it doesn't break anything found.delete_instance() - storage.cancel_chunked_upload({found.location.name}, found.uuid) + storage.cancel_chunked_upload({found.location.name}, found.uuid, found.storage_metadata) return make_response('', 204) diff --git a/storage/basestorage.py b/storage/basestorage.py index 55035901c..af51a45e3 100644 --- a/storage/basestorage.py +++ b/storage/basestorage.py @@ -42,18 +42,9 @@ class StoragePaths(object): class BaseStorage(StoragePaths): - """Storage is organized as follow: - $ROOT/images//json - $ROOT/images//layer - $ROOT/repositories/// - """ - - # Useful if we want to change those locations later without rewriting - # the code which uses Storage - repositories = 'repositories' - images = 'images' - # Set the IO buffer to 64kB - buffer_size = 64 * 1024 + def __init__(self): + # Set the IO buffer to 64kB + self.buffer_size = 64 * 1024 def setup(self): """ Called to perform any storage system setup. """ @@ -99,31 +90,55 @@ class BaseStorage(StoragePaths): def get_checksum(self, path): raise NotImplementedError + def stream_write_to_fp(self, in_fp, out_fp, num_bytes=-1): + """ Copy the specified number of bytes from the input file stream to the output stream. If + num_bytes < 0 copy until the stream ends. + """ + bytes_copied = 0 + while bytes_copied < num_bytes or num_bytes == -1: + size_to_read = min(num_bytes - bytes_copied, self.buffer_size) + if size_to_read < 0: + size_to_read = self.buffer_size + + try: + buf = in_fp.read(size_to_read) + if not buf: + break + out_fp.write(buf) + bytes_copied += len(buf) + except IOError: + break + + return bytes_copied + class InvalidChunkException(RuntimeError): pass class BaseStorageV2(BaseStorage): - def initiate_chunked_upload(self, upload_uuid): - """ Start a new chunked upload + def initiate_chunked_upload(self): + """ Start a new chunked upload, returning the uuid and any associated storage metadata """ raise NotImplementedError - def stream_upload_chunk(self, uuid, offset, length, in_fp, hash_obj): + def stream_upload_chunk(self, uuid, offset, length, in_fp, storage_metadata): """ Upload the specified amount of data from the given file pointer to the chunked destination - specified, starting at the given offset. Raises InvalidChunkException if the offset or - length can not be accepted. + specified, starting at the given offset. Returns the number of bytes uploaded, and a new + version of the storage_metadata. Raises InvalidChunkException if the offset or length can + not be accepted. """ raise NotImplementedError - def complete_chunked_upload(self, uuid, final_path): + def complete_chunked_upload(self, uuid, final_path, storage_metadata): """ Complete the chunked upload and store the final results in the path indicated. + Returns nothing. """ raise NotImplementedError - def cancel_chunked_upload(self, uuid): + def cancel_chunked_upload(self, uuid, storage_metadata): """ Cancel the chunked upload and clean up any outstanding partially uploaded data. + Returns nothing. """ raise NotImplementedError diff --git a/storage/cloud.py b/storage/cloud.py index 91dadfb3e..91d2b3fdc 100644 --- a/storage/cloud.py +++ b/storage/cloud.py @@ -3,18 +3,25 @@ import os import logging import boto.s3.connection +import boto.s3.multipart import boto.gs.connection import boto.s3.key import boto.gs.key from io import BufferedIOBase +from uuid import uuid4 -from storage.basestorage import BaseStorage +from storage.basestorage import BaseStorageV2, InvalidChunkException logger = logging.getLogger(__name__) +_MULTIPART_UPLOAD_ID_KEY = 'upload_id' +_LAST_PART_KEY = 'last_part_num' +_LAST_CHUNK_ENCOUNTERED = 'last_chunk_encountered' + + class StreamReadKeyAsFile(BufferedIOBase): def __init__(self, key): self._key = key @@ -37,9 +44,13 @@ class StreamReadKeyAsFile(BufferedIOBase): self._key.close(fast=True) -class _CloudStorage(BaseStorage): +class _CloudStorage(BaseStorageV2): def __init__(self, connection_class, key_class, connect_kwargs, upload_params, storage_path, access_key, secret_key, bucket_name): + super(_CloudStorage, self).__init__() + + self.upload_chunk_size = 5 * 1024 * 1024 + self._initialized = False self._bucket_name = bucket_name self._access_key = access_key @@ -135,12 +146,9 @@ class _CloudStorage(BaseStorage): raise IOError('No such key: \'{0}\''.format(path)) return StreamReadKeyAsFile(key) - def stream_write(self, path, fp, content_type=None, content_encoding=None): + def __initiate_multipart_upload(self, path, content_type, content_encoding): # Minimum size of upload part size on S3 is 5MB self._initialize_cloud_conn() - buffer_size = 5 * 1024 * 1024 - if self.buffer_size > buffer_size: - buffer_size = self.buffer_size path = self._init_path(path) metadata = {} @@ -150,16 +158,20 @@ class _CloudStorage(BaseStorage): if content_encoding is not None: metadata['Content-Encoding'] = content_encoding - mp = self._cloud_bucket.initiate_multipart_upload(path, metadata=metadata, - **self._upload_params) + return self._cloud_bucket.initiate_multipart_upload(path, metadata=metadata, + **self._upload_params) + + def stream_write(self, path, fp, content_type=None, content_encoding=None): + mp = self.__initiate_multipart_upload(path, content_type, content_encoding) num_part = 1 while True: try: - buf = fp.read(buffer_size) - if not buf: + buf = StringIO.StringIO() + bytes_written = self.stream_write_to_fp(fp, buf, self.upload_chunk_size) + if bytes_written == 0: break - io = StringIO.StringIO(buf) - mp.upload_part_from_file(io, num_part) + + mp.upload_part_from_file(buf, num_part) num_part += 1 io.close() except IOError: @@ -217,6 +229,57 @@ class _CloudStorage(BaseStorage): return k.etag[1:-1][:7] + def _rel_upload_path(self, uuid): + return 'uploads/{0}'.format(uuid) + + def initiate_chunked_upload(self): + self._initialize_cloud_conn() + random_uuid = str(uuid4()) + path = self._init_path(self._rel_upload_path(random_uuid)) + mpu = self.__initiate_multipart_upload(path, content_type=None, content_encoding=None) + + metadata = { + _MULTIPART_UPLOAD_ID_KEY: mpu.id, + _LAST_PART_KEY: 0, + _LAST_CHUNK_ENCOUNTERED: False, + } + + return mpu.id, metadata + + def _get_multipart_upload_key(self, uuid, storage_metadata): + mpu = boto.s3.multipart.MultiPartUpload(self._cloud_bucket) + mpu.id = storage_metadata[_MULTIPART_UPLOAD_ID_KEY] + mpu.key = self._init_path(self._rel_upload_path(uuid)) + return mpu + + def stream_upload_chunk(self, uuid, offset, length, in_fp, storage_metadata): + self._initialize_cloud_conn() + mpu = self._get_multipart_upload_key(uuid, storage_metadata) + last_part_num = storage_metadata[_LAST_PART_KEY] + + if storage_metadata[_LAST_CHUNK_ENCOUNTERED] and length != 0: + msg = 'Length must be at least the the upload chunk size: %s' % self.upload_chunk_size + raise InvalidChunkException(msg) + + part_num = last_part_num + 1 + mpu.upload_part_from_file(in_fp, part_num, length) + + new_metadata = { + _MULTIPART_UPLOAD_ID_KEY: mpu.id, + _LAST_PART_KEY: part_num, + _LAST_CHUNK_ENCOUNTERED: True, + } + + return length, new_metadata + + def complete_chunked_upload(self, uuid, final_path, storage_metadata): + mpu = self._get_multipart_upload_key(uuid, storage_metadata) + mpu.complete_upload() + + def cancel_chunked_upload(self, uuid, storage_metadata): + mpu = self._get_multipart_upload_key(uuid, storage_metadata) + mpu.cancel_multipart_upload() + class S3Storage(_CloudStorage): def __init__(self, storage_path, s3_access_key, s3_secret_key, s3_bucket): diff --git a/storage/fakestorage.py b/storage/fakestorage.py index f351ca150..d72b5ddc4 100644 --- a/storage/fakestorage.py +++ b/storage/fakestorage.py @@ -1,8 +1,14 @@ -from storage.basestorage import BaseStorage +import cStringIO as StringIO +import hashlib -_FAKE_STORAGE_MAP = {} +from collections import defaultdict +from uuid import uuid4 -class FakeStorage(BaseStorage): +from storage.basestorage import BaseStorageV2 + +_FAKE_STORAGE_MAP = defaultdict(StringIO.StringIO) + +class FakeStorage(BaseStorageV2): def _init_path(self, path=None, create=False): return path @@ -10,16 +16,26 @@ class FakeStorage(BaseStorage): if not path in _FAKE_STORAGE_MAP: raise IOError('Fake file %s not found' % path) - return _FAKE_STORAGE_MAP.get(path) + _FAKE_STORAGE_MAP.get(path).seek(0) + return _FAKE_STORAGE_MAP.get(path).read() def put_content(self, path, content): - _FAKE_STORAGE_MAP[path] = content + _FAKE_STORAGE_MAP.pop(path, None) + _FAKE_STORAGE_MAP[path].write(content) def stream_read(self, path): - yield _FAKE_STORAGE_MAP[path] + io_obj = _FAKE_STORAGE_MAP[path] + io_obj.seek(0) + while True: + buf = io_obj.read(self.buffer_size) + if not buf: + break + yield buf def stream_write(self, path, fp, content_type=None, content_encoding=None): - _FAKE_STORAGE_MAP[path] = fp.read() + out_fp = _FAKE_STORAGE_MAP[path] + out_fp.seek(0) + self.stream_write_to_fp(fp, out_fp) def remove(self, path): _FAKE_STORAGE_MAP.pop(path, None) @@ -28,4 +44,21 @@ class FakeStorage(BaseStorage): return path in _FAKE_STORAGE_MAP def get_checksum(self, path): - return path + return hashlib.sha256(_FAKE_STORAGE_MAP[path].read()).hexdigest()[:7] + + def initiate_chunked_upload(self): + new_uuid = str(uuid4()) + _FAKE_STORAGE_MAP[new_uuid].seek(0) + return new_uuid, {} + + def stream_upload_chunk(self, uuid, offset, length, in_fp, _): + upload_storage = _FAKE_STORAGE_MAP[uuid] + upload_storage.seek(offset) + return self.stream_write_to_fp(in_fp, upload_storage, length) + + def complete_chunked_upload(self, uuid, final_path, _): + _FAKE_STORAGE_MAP[final_path] = _FAKE_STORAGE_MAP[uuid] + _FAKE_STORAGE_MAP.pop(uuid, None) + + def cancel_chunked_upload(self, uuid, _): + _FAKE_STORAGE_MAP.pop(uuid, None) diff --git a/storage/local.py b/storage/local.py index 9495aed9c..1753e4d85 100644 --- a/storage/local.py +++ b/storage/local.py @@ -14,8 +14,8 @@ logger = logging.getLogger(__name__) class LocalStorage(BaseStorageV2): - def __init__(self, storage_path): + super(LocalStorage, self).__init__() self._root_path = storage_path def _init_path(self, path=None, create=False): @@ -54,28 +54,7 @@ class LocalStorage(BaseStorageV2): # Size is mandatory path = self._init_path(path, create=True) with open(path, mode='wb') as out_fp: - self._stream_write_to_fp(fp, out_fp) - - def _stream_write_to_fp(self, in_fp, out_fp, num_bytes=-1): - """ Copy the specified number of bytes from the input file stream to the output stream. If - num_bytes < 0 copy until the stream ends. - """ - bytes_copied = 0 - while bytes_copied < num_bytes or num_bytes == -1: - size_to_read = min(num_bytes - bytes_copied, self.buffer_size) - if size_to_read < 0: - size_to_read = self.buffer_size - - try: - buf = in_fp.read(size_to_read) - if not buf: - break - out_fp.write(buf) - bytes_copied += len(buf) - except IOError: - break - - return bytes_copied + self.stream_write_to_fp(fp, out_fp) def list_directory(self, path=None): path = self._init_path(path) @@ -124,14 +103,14 @@ class LocalStorage(BaseStorageV2): with open(self._init_path(self._rel_upload_path(new_uuid), create=True), 'w'): pass - return new_uuid + return new_uuid, {} - def stream_upload_chunk(self, uuid, offset, length, in_fp): + def stream_upload_chunk(self, uuid, offset, length, in_fp, _): with open(self._init_path(self._rel_upload_path(uuid)), 'r+b') as upload_storage: upload_storage.seek(offset) - return self._stream_write_to_fp(in_fp, upload_storage, length) + return self.stream_write_to_fp(in_fp, upload_storage, length), {} - def complete_chunked_upload(self, uuid, final_path): + def complete_chunked_upload(self, uuid, final_path, _): content_path = self._rel_upload_path(uuid) final_path_abs = self._init_path(final_path, create=True) if not self.exists(final_path_abs): @@ -140,7 +119,7 @@ class LocalStorage(BaseStorageV2): else: logger.debug('Content already exists at path: %s', final_path_abs) - def cancel_chunked_upload(self, uuid): + def cancel_chunked_upload(self, uuid, _): content_path = self._init_path(self._rel_upload_path(uuid)) os.remove(content_path) diff --git a/storage/swift.py b/storage/swift.py index 73f743290..a3e3bcfb0 100644 --- a/storage/swift.py +++ b/storage/swift.py @@ -13,6 +13,8 @@ logger = logging.getLogger(__name__) class SwiftStorage(BaseStorage): def __init__(self, swift_container, storage_path, auth_url, swift_user, swift_password, auth_version=None, os_options=None, ca_cert_path=None): + super(SwiftStorage, self).__init__() + self._swift_container = swift_container self._storage_path = storage_path diff --git a/test/data/test.db b/test/data/test.db index e0423a2c8f2e5171feb3a2cb6d68f8f5334a807f..4bc9e762cd00534a6217c1c35c318488cae4aee3 100644 GIT binary patch delta 21252 zcmeIa2Xs``_BcFuN~YaOAr;a?s9}=ZOCUgc?}gBr8Ui7KkWfSn6Qn4DV32JER0Ksp z5oN51^?RL(|vSr+| zt=#(9u%A>a)tmTx@n`pjQ7i(jU+8oQGK9RNAolK^+Z+;qhbI=lS9>h@oWBGgGn(;H zQjZUI+`dyGk;}T?4EZhORLBn@$3wm_-(Wg#nrr;TSZ8?2AnAAN>%PO;yV5M9(r=4X)mg5AE zInJW0_l&>-TCLV#5Cx};=X(~dZz0F%27FSdAJkS5a!XCm_v^b!lWE1=;NU0TP~JV) zZ0L>~_Wer@;;{$wRjS(_sNL)_5AYh5_sqPxn~aZh zr44^}Qd{Cnn%JIGH+RRW@4xeV%@euHmAA(nc9Ds%J7D?}GPUQiO%-v$dw!E5*4^}# zF#qiok2y4uYDWuF6+uPZfRrC1209Zer#;Ye#A6N#pz7JYJGJLMBuah$?oZf7C2eJ^ zm%QdN2L}+rUZujBw^YQb>qef{mD;1NHlOyYR~FhD+VRsQ8p|iSSu9$^3HgV+3ZKu^W&`@BysG+H1&hrm|>HzAK24z zTQ{jc8++1hJ^1vHxx2sXQQzJ@D86IEnGs`No1(lu7Q1L~;E;{M zpNB9ZJ5_EKBySBG0y}$%Tj81w#OpAphtR{4n}|pddI-87%!{zQO(Q+3q5A0DM8cES z-0E~Kn%C*-bTxOl=1gyQb+k9T=Hq*gt+}<9g7d8_#F$zv?CTgNa>Z2ng36I!e zv$wd~+j@VkFl`G_zj7s2wR~E~81-XTF6)bjwPtJZ~QmAzmF^2}Dn4-U!0&i!r5q2x*5?L$fw%M#U+R0c2 z!R->A0w>xy*JUwVyIc#~+MC+jP}G9@i3Rm})pQMA&E)Wn)pTQykWF5WYh{sv<$m z&B@7=^Qt+iOfG1cRFGHAPhe$<7MaS4VnrQYR+`^Xl}%SLG}jm)Xa2>JXGMln1ihLJ zQPn4^G7K~hyE5)*zU&lZ6RmDaa-oz?m(?y%ZY$66G$rEX#V&^W(`v3OU?x$tK*>tO zhnTV9$sXMZqY4=+9A(bt#3_zbM|9&jF)qO>@hoK(B{xSoL>EJgj<(K@`OQujj!i0K zOQo6A&INf?jW}DLv!F4(wYt81ekP&j*4gvZ9c^<0TJCE&oh~pmMKOw?DJB#&)?ZKu=de2jj!Dy6KTdpi%vV;w<1wWSrN0os-dm6y>QmN+|~(m>pMytn?>QO&iX~APUo!L zi8Gy5werlWTEUhxZ)Rg>lWlfQ=fvVtICw@K<=1_5I?GDDz$;QE8&Rr({!+Pl3B3)s zY!z$*cAreiIK$mGE5%E8#%+_GyiGtg;dHh4bzLo;S7#SX@`P5&$rsq2^MtE}+RCdc z^PRN|D;5du`3) zSU2myzH%zdP)2RxMwxjxC(t%jA)<@wY+c}LZ+17koQj}b*n4bcr%2DPDlRW9nI#v{ zZMAuY&26ssQul(nRq3r9HNUacNl}X?Itpv9a#oh$M9FEI*V@Map`cW{D2OcW(-%YZ z{U@W#N%1zu!CJd$p2Jn>5IL*taW+EU z4fXjm877ZvXmfVBvKN&#%`2Qab9O;tM_H%a-6^t-_3p*RMN$P#=b@n79|+o&Nm1#X zC<~l#+=m6Bq_V%hkZ4|ZxE!+8PTR3dZ4zshC>C`FM>{0D%f@kT(cSKvKU0x2&DPr1 zG1JxV(-@6xLtbM};e^7Ra-p`SgfC|+D(cH~D++TaRp&Bg#bq@U%L}`5iz^BXiVGVX zr4ov-sVUB_Vt95^L5VD8U%7A;Q5iv&l+n-7p{R)--MC9D+~Kyfb{B_Z&Y_f?jTNmj zMcb?{!S1GPsA=pJe@WE7S;{jUQ`+uYoI7WJlcUI$v7k1O%e$)bs)n|iQcVM2Ji%2n zFQ-|qDy%DMD=tIfSjNHGKzB}^?6-8NbXwxk3s>Yy)1q9sbh0kX#j|$1!-XPolFf>$ z%VCuSC%W@2?Uo&)>~snA+)jH-v!hR_)^gW`W;u63k)1BCYfE1++r6OKnLQ0a% z`gAsxaXqS_fM4F`>w6l4>;_&!ZGtCI8 zh1C^{n&!7I$d=@KM{O%zQ`p{KUfEDw#Ma0q`E4}|(kJAkJLu|pjD1mCaeBRT&TP6C zUdhnJL&7<=VL+cf@yRBj#TNw_cR?LBljj_q)5c1uOej>#c8<2%DHb0#7l)P!2T?|g z+S_4$<2b9O#%1$BC;zZ5LZa^BxAsCA(xCDe>mi7J%{21+wLNwrWlpD0ZWicX9q=K0)F z#qpcoYMbNp0MmOshDi8y3lX2VV3a;OKA!N5^NnbS%Qi=$y!g(5dnnB$!V2dpO%g#t zBCX-0(tQr)s#0GGWqB*Vw09P*IYJN_uy#EWPcXh34z4GL#1&FO(aDiSlTQqVbh>ZS zC|?{r!)nseq5h853?%rAWpt3jX<`Wm#&ViuLVy{ZW(>i?ot!3~5aCHqBN80^#A${T zk~famFa*IvrJ!LqiJAo1DrhpW%wa)85t%@UnnEm7ENZeLQ_@h-CTcA3HxXG#a6#0J zCdT+`t&%h~gbepcnslHtH3nT>c1~G7%*fP4!=R1CV2F`5pCP|yAHB4HLwI z0dC0BBtc>pN;?*+vaq2!FmJgw0_<6uLGWaj#!8HXud_6xh+J>fSj{hl*AlAvErhf@ z7MvHd%yQn+9(=(Yk)&~0y8fyY9-6Q9ScpIKk^iHM+|}nJhxpk1RsS{E$K`+VE+c-= zy@c!1HA}|-S0?8VEDq#lXtKxu7i#VgwH&;dp&7k@ye2FCFAvp{rKLK(iXc3eun^7v zK4M*gQ4Mm__Hhf={MCU&W|}@`7R`Sczpno}JV0*hK5jvpzdDS_jPNnbvxI3he|0#$ z=i4- z^B)HF;+S^*Y0RhTWAsF#qhG?0)Z+h6wxD*qzTz& zbsU#lQzvDMxpY-cX)a%vQ&m=7TPDIYCp1H#=!B+n*aW7&DyO2XvOsPqpi9apRMlho zn!HJq_=<}BlIn7)T&`)TFZLcdq4^=Bnr0|BZR1^5r=<8+GULRAEz4Ok(ZI+&$2w?< zX?M+S%d|P?G`DuR=67_o&33i=dq?PgP?;R&M$qYznf*!H4?{4}`*XJYDebr_Vj_%R zrX4{{gL$9m2B|y8!^&k^8g_WJN$~D6Z35vBDD-HD64}t|(aMn3txbj-JX$mS!=oLE z7V5l5I}Scjpwqgw*+edE?beQlxyzB!)7{!&FfP}QBJx03uFWKh6ni#-$cLAgYb8su z&sydu_1afrr8Wk(uFz^>3 zBq(}FB+=&cNEAZ!NYFcK#-PbIrxLWQwalPWpYP1h;Mk^Ba@b@*^YN8qvZ_p+Y4GNooLHAm1j8>@@Hm}tVgQ;t^Lx@^fzE(RN;?`*m z@b(&Q7>rTAUR$e8B4^^}==9RALg$xt$oaYX3Sd>|dt~ zN|jxV1JfL$)oHV1>@H9k7`ZV{a@bugXTva_p*T3QPRA?JLw09Vk}?x7=I7`nD}_HP zqmoiO+NL{g9j=tjq?8FR=cuFrYNj`}(MP@1Z!*(jU)DpWB#vV|TEeKRCyX9lw!!A) zT!O@4$VOrC%SFl(W0f)YgQ*#GyzMqO@1iBR|3+QD;+Oj|LbSYpz3z<+bou?^N(Jh8 zO}6p(>-QBqsM{A({@?V8dYGo(*iXdYm2m%I-J*~#8)w5<1ygA*heNV*B89<$fC@mC z91c#lJ2-}CwCW7CK}FOX(WsLwE3%YKGgb`7Fr;ueF$?QvG1?IYiec<7r^6{|(987| zAXEhDl6Z_hD4UhBVM>(4%#~GQ?ShpTD9)*uS*K03NpSvSoeSoj(51kEaJ?RGIHI!< z6X5luxN>f&JgQQfd!M7nNDZq0wf!Q4-fIOY>w8quhf9tcxE(S^gm z6)1k`q307_Qh>^dg)^V%Qg9l^eX3)K>E6mubw)KY86NyXHv+mp*A0S#FLZ<9na_3E z#1tUD(B;FA3ZNOkh^Y!$F0B6&0dIVv3x!i(>Ehw|=Q<;leyKAXrs<=zS;8|6W_*sK ztouqA1WUg}d1Al90>1w~{w20+Ivo8{mkCcO(6L|XGKd+_`jxJXaKnZ#b;DufS6F1p z7sxsMYh7ZxO&^_{On7pAYGpz5{APP|OLNEK%U?|a>TKROy6~PNa*Wz(hrM6xh7dD- z3-S2Z*hdbCIfnhw3}G?)7;p74oj?#ySpAJI9r)we^uyoi7=ue6osacpz=q?xWJo)% zGZC|a2-C+v#c|ze6!QAxI+1Acaczyzn-uT>_~|$@Yl4yA>Nto$p)){Cj6Mu3;fTL! z5i!hr-?uu62poA5sv`6T*mFXc+B+MHPUuFUa7VwxdV5ajVmF29hj{;SLPy<@>UK&F zoUphUMT${U0-{xN+T2#jAqk9VbJ%ct!LX}?#-xg@OJ)QK=eH9#BRRXwTWxj<({472 z!yQSU;wdMrx;n_3>XK=fgJPXlic#{oyeJ^0gR_bPYZF|&({5*Ec<<^UK9v*QE?f+_ zJz&G#2u{RlZFlmtm6141t;n(x@dLdyC^MC%Y?y|^1%N3@Tm^0(lNc24hoX!k&)aC4 z6L`3BX;4b4%ws~*!BAG4AmBC!jXKXxqnsG43lwd4-~y82pG$+rOmU!@WjU6$$_`mX zo*bq_oHT{COEl$l@^02C2|;?5F_}=+H{q^`t+k`m)~d$sSG(OQ$Y|h|Jf6*tUo@_H z7t7O_L7`|`0In_*#A7xo0}rX7OQh85eDeH0gF)@?Y7Xa^iN%FZwjOoYKhT|uK#WfaCq(ISq6 zfIT5#=Gn%|oE7baU~`}c!lFO2EJ(LLgE*}sPOER%jMDs|-LE?oR2TG{{z>C~Cdu@v zxtmPD4YyU6W5IKS?+-~0xmEQ*PZ%`}4%rO_8c)(JI1sAOfjh(Wsqk^A-U6==(uYDu zxIP#v!}MXe7;ItsF~mG&O$>&N2vlQ<;rbC^iO?tiwco7NccGdA4yTTsC=vMw@AGitdV-Jp$!$2s3@ky zXnic`qx1ve(J1{8$dA&e5p!W)ls=DG2+2{{hZqKj3V z;B|$;ZPEHHq8;9g)=R`?l9V#T+sQTacYmnAns4npG-6dU9#i+ z{_*%-U!}hgLImxfp+6bokKaxa4Mx4*2+V!ysygN61)s_Vn2R))6 zqQ6!Dt6{p~N8?uW)#hVl1Nn|6FZiC2LgGFmlMps!57KxBbitDj14pd%O`t7ygBe1d zhH+|-3aXrjcw!ZF-AY7x<86jqLQy2&t6kT?noykJCme=k*l9N;{4Y%M2uQqM8w;Ph z3^NG=%G`$GaMW#xfg9Y0k%St&_=4Id1e|dj(g_U=Z!)C8K?U&OOoLBl1;I5!UYu#5 z40?U^WTa^FqnOcbFqm4}np~|N?Tb}6!m~|=Xr<~XKuj)SfNjl&!NmHWtsw(}8><=S zHOw+BAP6IL%{HVF8{mQ2hHSzF-_AA+CvH;uv}at18H5%?3SowcEe0C2a}4MPwip-w{r41dbTpzlv|#fXFl^(VR5 z<6W0#e5md}(Of3c_9wZbU-g<~9S+Q8rEvvCk~&GD=o_1GoPzyHyyrhQY!8`SBnv_* zmBSU_jy}&86%|cj#eBLj9}m>;#`rR zm|bJKavh-~!HUStd*OuP*~IaGwTAtn#@jOfE62loV4N{;YM@Ia3yds#BknX#C;B01 zffu|hHeZ2|MBMI$Q(KG+i3DifYK$RLz;f2aTOLqiTijUGu4wRt!^>NZX~gZl(W~Vy z<3=?R0XOV4QZV`fVN4Q$Bo=&jDv&+j1gMT2v0DSZ8H`^_PxfT(6-Ik=ggnj zY1AnO!wg?+GY&9>=%XjdYELZ?JFsZny~ZK%y;1_89M)M;rz+ z$#l+dVle3#Lt~Z+s^RPwA@?_ z4-Gbb7l>zh368#GPK1MLrUdvV)%0y3V+_VI8V?&m#>3nN=6om{YT6b+froiGtmB83 zqzxRyOyB!?y$l|!U@-red<)8a?Qqj~0R?H6r;zovBTN?pM51wt!d+>mp>TM2KydxF_9o%ugP~ zvmC|CP?bR@!hsBvg?q!J$5r*}Vtl;Pf{&MG;N!(we7sPIj{{@y@idK(Cx+nT@qzew z%zGijL{h}-UUG)X5kfo_1;L)ill7GcC)9FLNi5pB&vB)wirC&*|y_XAZlzrJ2@czgiu3^UTc2piU#BCtnajEwD9Xo^w6M-J)-o?!5S z2lfNk=oN5y%%v(824#)2YHRPl zngV=2GQ6Jvz2K+;m--3w1bFhJX{3)lJnRyW1xz|MycZbS2P6#!g>#Bd;S3M1Geu&L zJRcq$P$tob9cbyT7w-Y|&gVf|Ec6)uJ{L*$6De9jJr`xZBoZ&tHq0J|Y7}_5=@NUT z@?Kz+v6r|D*NenqwvCrUeAH2fOI-RI)(a2UU*dudV=TR_gZzPXA5iBHr22r`OF%^k zgWA^+jd5(?1nPwkP%FenWi2bjzSc$H*2%zLSZ{y$p!?UGp3Tr;BfT#?W4a^J_vQH5 zG&KSb^gR2Msh03wPAi@L*%S(2{$vX8O}Ukw!mq1-HpSv8@=ZUR%8C6-hH4<3``J`Y zq(S9LQy%eXztYd2G*QGz`1z!19PvnR9Efaw#N+6De=)_tgkMYph<&~?UB8%UJhqPD zk9$q}>Z-Q(`EciHQ!F%|HVq--;Tn8_{){OS z9y^WNIsv{oZAymO3Lx%`X&8|RdCKcP*l-hC_pUQ0mPmqqXG}bCKh{17yr)gU5cVs! zAQ>8dMarGMUd^UoO&BKkIzRB@SyMbrM>h!1{@i@lL_xth1Uz`wWPoF5O{qjGgq$;p z@SXx4an2M1FP}F>!*%CO5yTMKe9n|f>;Yfme)3sU7|b|t(!uHDxNC9sSrqfT^Cp9C zgs0jw6y)=!65_$jJ$aElQel`!41?ecroqHRUgm;n165o;p@E)MQ&e75Tf$fHb=he| zkc!}~M)j=uj+t%k?c*<=G*b>3S6Aia7cmpd^Q$UynWB|b{Oc(XCt&#e8Q-*Cwl`v1Uh@E&M0_XN5nvLMp}y!k$QK;84q zrvrvYW^kuN_TJEL4h}4Yu0AJ0b~<`wNlVOG5Y*AHq{PX1^d4-tlcS-u(|js`t%%N` zAo_eZFZyg!Aa4;53)3*~IdiWY6uZcL!p}`&FsUlbeFBeOY(5^ynO62TK)%ZSSg^mB ziEiAo6=bp(h2;Wk!^01>+}jeLgL&80=92-{@hm4ver}uT8uO2V1T;_4m)tgQTbEf- z`w_6|c*l7dJ9Z!J3h@%AD#)Y@L&5s9^VMROu54JpX7JVIjX1V#apOeX^Gq^9r`WXA1%;41l1BYcPbcxwsCsVx6 zeA2Ib*>qYNIng`zdb7?yC`1H{M^AiS_#}96O~8O5lE!N-eqH>{4d!nH+Ct$WVhDQ+ z-P&Fcex3OtKbaYHj3r)F7}%lhMl)n~s6yIF=3y-IaVsE&==V0dk3ggpP$>B3a$4dEMs#F zpR+UhQ@puZdgBZ@kFW&Ip#eYWO3S?})rQ>*(>xZd-@Q`2-+TDJRCsQ?xgt)gJM>Y- zzXu8`)!NUUnI6k1KROt>!`u|iQf(&w@iy$-fi-f^F1Hka;9aCr?LOO6 z44L=g-gC&)(rrv$`^Q#RPpV&pF!l0ezir|RTS()*ZS07WU)=aEA1RXn(UDDx2jP?8b3KrPH*Ma&0 zgk;(;EUkX}waEJQw@-of3MAHg`^Q7c9XjO=3A@bQalzlryg%a8QHc$w@RqeDC9uNp znz6zoyUY~{16J-TA9&(`PNmv7cj^ZoOL72V&r>_GuXm$h@<%V-TCIKX9+k@T_1<-G zXg5|t{8n?1`lazpi%iE(dMt^7C4(PGh3ofV$&{FzZ_0Q|d#g&d<@I@+;j=v`Zem9H zthD>KY+U;2ha|K=h>(~Cr>auM54mUB%5`^dddU1^fUQBVgk$MmLo|4=`L_V1glD=q z)=NEXHv6ZO*mQ}LnBH`e&us44*Uy6C)gl^Q51(K1^&@8gBo&)3N-Twj^Kve!YQOoE zpCv*HbOw5T6QJHA^Wh1v`Ii6!L1ZKrDl^d5fAUzrx^XXYnk4ml z)P-|V3D<4Nj)1D~aUE#x*>aaICjY1$EIk7sDG+|vtqCQE+D<8NX!!xPknZ?V$xwRN zBPwF(@Q`>otH2`fetUI7$&>>sB5AbsbGYG0gpDC4WY!T!u2m7HTP9A0xStdaHq!c0 zvHtzTD%IY3%m-_y;9!Yk4~##{7&LE1_}f=#fhveO#qlx$Bi*J?65@ zs~htpF#aksb<;`Iqv6+P9Ez>EzjS8ZoM$}d(m<;0R#Y?Vk&GPoW=8zP#^C4b4rl!W z$4_Fj43pnYHSc@rwTKJB|AMPdp*kV&5%-7fEkCckJ(i-&taPZjmu^diF~6WX89U~C z*MyY#NvKL~2Oon?zaTGD!-k&V#SFK2iR}wWJ&llo8@XQwzp->;Tk7Fjco-qU;+%I) z<+r}HL!~jkf9bs#L2YhQI1D=UkRU z3v+)pcaWMHAw#r}@A>cp__i6xoCVA*dQ(Be!dF!Qbxx7WRJ=Gi*!`hO zy{9Q`4jfcqLg(bzXsO-(O$WeIZw+p7Z4vfFlZQ$fs!|c+)#U zDz!NN>l~PS!Mxlubi#Y_cSLMdsl%oo82AX8mF%ZfIJ^)q$-b4>7hT++O!~((NPwrp zd1dhqfrD$vX!t6Hyb!>Fmn8J*m0WeN&ptVoJnN^SxpWLO@a{akc_#)TuMHvn13*OL zEl0U8$oOd}=^tcpcmoq}x>Md?hmiql5)pXjjP_yzeO2I+=5S5ZiXKVu?GboC8%d4~ zq+lf)6_qbsRU!;;_b76h|GMVzW-`Y!7&GA}fj2IlOzDTEc}8Nqn0X)MAKLNhG$Yfj zqI@UAJ2d&2UsQvcQ2}rEc{0f??*~j^QxJz2NPSw};jQDy03Cx!3NOm{$^tL*2 z+lT~cWsrqp8@)$HlYC$YVO)sE414!jR%emN1Llh8Q>ec9_ujGO_JHPy3@TgMJ9Qjc z=+BW?3^|XX=Kk^6b$%rfLD6`4+^61QSPpq20Ety`IO}}o-H}WBZx!)qywRrkoA(#; z$en(Y;`S?U`LI5l=L?RH<_AnoKAjWr)Ma1i>kCPLhXs+il|_kOa}kbb09RJVvzC`F zDJ(7y=pa6w6)BVp7gOJAd$^>ZTzC|g?~9;)5%S^EfCa@X##+X_2Tor*WR{aZ1at}> z{-$KAcgjqu=+|R33XSO$^rRwSUL|=O|4DF-%5iq#$26sr1;;y|&0{6>v&{jQrs3pU0@y zuU6i0Z8g~)OqA1M#9IqLQ>i|kS13b74Z>1y&5C5loQPMcK1q!l4%-yiuw_k8isjSL zPks9O_rrm!CA$+wm4DqmHfVRHO0~%}uhCvOu6tcV-_a8{9YX7%~z zr&eLsr8$u7p@m4cx{jft2?V(9+(6Z5f_yOlT8 zO~eMX?jQAqr&b!V!O^F`^q8jywj}!+Y)Q-{Y)SIbYeF(wKiy;B8*~IZCMhBexVJ@K z=Ap*FvHDF=PbRwuML+eaD|z&`gUZ`unR1zx4mM9FJAy|wX5BwFa&RKXKW9FugM=x_ zWt9Be(^WIrX$T3yzI}(HAkpg2! zBg05mnzkt?vqe1KOo_^&W5$7(tSN`gNUF8oY!_J*=eJq~v;>$D^ltNzG5&f7;kb-A zpZ1sxBbEo4Ljf~xidT5a1VQJuSi%U#uX{1mY=%SN4<>eTt`M75TH@ z5*BcqS~(T1Ea4T(mxT4IGR%~wkvzp-UBz1Re^me z379e!y-6F;P6bGdw`&x~BkLyekpMwONy5Ez#ejR0ddLxhg+y!w7V;_mO&bGLr;v_! z_<6cF7y8J}x$1Jc*?aX|hYNQ2s2IiHXW*|!9=Ur6V1M^D_5b1UiJ zZwO{ZLGV-edu}7|3TO--+{dxJtnTl)o%}X{fD`aIvBJRt=k6e%3&dgE<1^I0?6{ZN zOp<}Ufw@)*&mZ71jAuhLl=NtVdA(U}>M(v`Y&5)L7^B~#PYhZgMBpEW`&3(}c~K*% zcc^2C>r}t0W}_NUgV_&}%Lj$*7&bQYnC5Ba?J?t@6}$Ae-@ve!>?GNJFBcTq2OcVw zrZ0z8d&%Ww^tGF^l8(Psue>4ZVT6c(AGNk3aqRRu$8$8?t3cv5-MnphkoIBa4ctD2 zgiSu#6jOfv^HT1cXW)4SGW5$2ZxR;1?pEGV^9VvL3-hZ6{LA`Iarw28a8!YeerN29 zwJ)v-SKcu9QH5sZ+{FV<%*v%6``|cSP#}Yu+4JdwO>>kttlN)}0Z%*U4jMDDIca@m zB*c4_Qg0o*H+<5Peaahlc~PfCZr80$sCaudzd&e(%*PZhbC~*S)O8<)DQ`HWK+K*G z^F!mf8RPX2ra{Bw2;o+|^+oaOVL8ehK2sn%{i8qYO|PzwpFCy)K-#`@%8RPXnzJF!#nTzT-tWueC56WAh|iz zZx)K^it~z_g)uS=c#}Ngw_*^7PB9Qm@Wc+LNWKgx#!8GxLH2RfVnzQT{ZHG7ir!^$ zqowF=a(@6j+~*L0=pm=TtbdaJXM;pks<=^9bu;c8_);VHzC-$-{6RdWD083B+8d=j z{cb;5aj%1i@#!W9B)(7jpW+cQ0f9-Aq+7^j_~HEkMLu_&g(=)Mfjfse^E#(+-6 z1dc%Ujz!ur(toc^Oc!vNe4&vY)_z0Y6Cf>K0aUi0Pzfi2{dj;15>dCY6c01LCA|T4 w;otqj)QT_OS^gdAe|!mxV2tHEzSQf*;#Fl*2qlBRe`|5Xv4gn@aK`)p01~mSm;e9( delta 20990 zcmeHvd3+RA@_6?gnWK9$;Yv7i69`F|(05OB2)Xb3Bp@&+gd>5Fa4E(?4iyg|LLrxU z--3b+Dk9*8H@dpIy6d7}bypV^K@J7wS3MKROkj2Q=jZp=@9RhUO{(hEt9Mkrs(P>c z`t8E?*9&Wg^f^Hg#1Z^=@xRB`j^q(&`%14zkkR^Q6|wWlxy{zN&A!3-dzFvIXWJ5d zOr3#`qFQ_iSM54&9dNyH#Cpzp+WLd_nDr}a4W%)+o4zpB8ecLBhWiZtBbMt=>u2ab z)|G1aYcn<5G?Co}{4nD-G^eD ze3pTs1pAO+^k;lyb;~vV3F6)}_ut{O#Do$QceO{3w9U&H_ZPbn36|bBz3cU^Wpt!+ zkm&bK`<)9b`LSJ%cPxt?u`h3(Vz}eZ=IFMuKB|8Y3!Tx}_3mRSU4z$`)BSw?xgq5h zizhUVe59*w{W3agDEDYIJYF~QF9R2?+psJ)_USL*8o_>2t(HDZpB^rHqY1aa34s>)}H3Zd&thxYi?Q!Ad6f2e&AyX>0vw9jGtKRNI@qu{=KcnJ0D3*0r+NZOVYM-WFDuFPkl!^s9ckDy6?rG2;%U_zrF6WP$6Vp z$M0;9wEZ&VtqIPi1cF#Q`HO*Fb2lxcZ52^N$G7ZcTvHF1bp5(%S*#SbcSOmPK_46~Ny(Tas%6(<1f-U3yjs?MP|+N{zq+e_ z+cH}GdFpE=Yo962dVScbt}hUv|M28P#>~2S>YZ0ccD3ELaOj%>C3$5oF3ba}emrN# z$Fs2-e3I5b;gdMLi{Adam3*~p(t~MF?Tk3uSW0M0HQ#6-(M{A75f2*j4Sz8%H#M04 zYA!avLye=}rb{g?mX9LyA`e;%th=n7^${YQ_)=v>5Cx+mVv+`s)0Mssn7^<~t8Z)6}s5XvdyL`q0aNSVtFlg^0?}T$}$hZCY7(&9Q>qv|LsxH!P z*323XsoTgj_+TqJ2%5K%>Bdw;%=iS-Hx#nAk^>{AI=tZNA`9`n*ojdH!Fi7PBFE-cQfEXxs#OF5>j zs*)9&q(-)SVii-*=Sr0mOF6z`Vx!10tY~BGoi>(BGG_8)}Ur z>R7^OF%m|T(U_$*xm}X%aEQE(<3!dbI6a(AaWGz+%s4zw!K*kNUcN`r)`cw|Rm|dQ zF0VYhuykU5ac#X&S}K&(HRQ|173F2+a$$|gcGgRhP}IOzO{}c1$jKFQYO?vfoXUw} zO>Ie8qg+zfEoZoxEYB#cD(3J=6jKfrQ{)-Z>z3R$CnMl^x@FGhlvIHgkKLni9*%Je zJz}Sd~RWGu27T9GKE52UTIE)T*eg^6;(79 z)s_}d%$FH^NnVA$u)toE-&9t2MM)V+2o7k91tsNsN$L#-j#*M6+u_J}=jRvZ3wimjw$8Su>;>%fx|&?Ok~gEx+gMy(($dOJXrJL| znKeJt)jB7v=W53pC6g5;yAW9N$z~LENT{G*598q+4!ce9xVO#vw|Q zjr9m#-r-PqkI12!aC_Rihpw9M$W`XmOwVewb8RIp)k_vuRu^?twJd1n%bP~E%&wR| zPimJt8|?F27tdNSJGXU7y|AF8rIr27p|fX-vLp(@IW~6BF$Zyyo8q*i zkPObY-QkgK93y(rRtO%Jvv;&C@U%61n>}t-Q0#w-ATKB=o;Pn)V+rqKiyc*qtFrTi zikW#ug48f^dR_gJ`GvW&^I3;H+cCF}uUcH#(#F@cw2HqYC@0&+;Ajpr^j?gzTM_Mc zQLuG#Xnbt~S~;7;<3$5(_ex%e+wJ99N1LO?-8x6za=M2!bBR20dd@6mQDIAc%j{;i zvogD*q@ulg*7SnR;@nY8J}$pme14f&T{bJ*T|J||U9lImvGPX zsSzlt5^66Tis12jyj~mQ;_T{>I&F5R1OHSlhsz^MyufHrm~u<8eWv}Szl9AT+h`t*~3*ajy1a>YSWB~eiViZe=&q9*wC<1cAAC$k;}|F^kOr_l&|JvN8a<+Vw&OH@28 znmbXxBx?7NiUln-QuP9NTTW(Ku5(r?Gu^?+4IM3f<}7x};{2AHl2Mhj+veuYUNoz% zu%)S~ym682kw9dn}ZGv4=Y<4&P z>2}MEBC?7{aEWs}oU@x<-9mBhS=EKrOFWee^Bd~K+FH+&x#b0(68GHM_WDJvTWD%) z$Xd9>$~)^k^$o&Y=lnT*OI0)MwrCT>gyLmE6r_MYYf&g$=)5`^*~zi8(dF4H< z&Eb_$M#dwu0=fmF%(y)Z0^UZ3yFD)}>Rpsm`wYSae$nh@bgz2S1X<^RHj`pUR&G34KHV)z~ zT4Uc!`w7a%*f~MsVZ2ovJwxGeF>wrMb30kIEpDg7=5V0eDy+*PqG5KqIOoLy>z+rc zP;6mJ+vc^@7L~V@&Qi*jG|egPV5@4Jc9ENr43Med7bjc8_C@>hfPKGvVGuZ4)^XeofRSlA|CYNh?NQnVlcm9VN-$fz0qBzZ3jq zk_>z|R6B^|1JOlX3O2;TRVmseQiN$K+OZ@DTT?JLlHj=%txWQ8GDSOrl>M>8v>Zta zP%&J~lXf6eQR%i1*N(z!M}}(|ST~mb<`$v-qxy@? z$N;lkOCPQFkLt1i*G{cDk+!BqerBB)d8hT6$f{j^m~oMv1$vwpQu{~ka6~ts|8{yi zdk3)kZbpA}W_8_s{>`LboYT(VjsLXWjQ;3+YVy^QHuaiG?uyRHZ<^#iJGJM^N91Q! z=hfHeR+N@j)t4*POqJ5q#8$9kNmG?Fv7o%EvapV=X9z< z9A*aGUa!O9aczF+_`>QqO zKViFPf^K{z*$5NX=+a>B3H=aF$9Pz|M#qvlss&4c57+48$#77?THSCoa4l0sGhKEMV%=jYQs3mu@tC zvJNY+>C)NBTzCx&OJSI4B{%2>!uIt#9i-o(`;^QB(|TP2SqxR{bsl5BAtpy5eHjq7 zUKa}=uh$KR%JsV8FnEJ*WOPAbXIx%&Y4cMzzEc0>4Z5KuSqRsIE`=axjFxUdoXSOpWd=mz?8@6=V0*w`JLbOKooZ*9gg{pTiK26S!G#RI!p7Xybk;V{hD zjCCqt{brpEg_{v@Y_pCdDI@q#JpP0fsFy_Gs!{*{8 z!6u-aZF91$*M@m2hsVuuUc11-(QSHBjTy2!rei{m6}z)?jFO0v0K+7YOiXTXo$7Y9 zdy+>dCO3H8BNM}@*?H+jeE`6TrjfXJe7+%=3UH{G~HXN=tpo!CFnXcS^pxP1r zE^FC;GA5c-ZJnu?h*y+w&r$s%Yo{!WZV&HfFhF);Brm&NHoKkYFt6m1c}{R+c);j1 zqclc>tTW;EO?%u<#j9X`0wZ2ELx?kkmH=ZMuhYXwidS?9j23;OKn0Q@Y02s3F|UPz ztW$R5M5;MZ-ietQ48~;x=0zpN?SWsv*L%q6@Y%0=LDOCjZ~vfA(6rRSlppjX$tIYG z>JJb6pijjWhNw*aq@3!1kA<6m(8t2S6Z#CYLFF(Au0Emf2j^8N{xU$<34LOSWr>Bz zbb}dEPw1me9UaZ?s$WkK`&W@q#4=Jv%szr10#Yu-71zpg@htj6ttCx6lpBc}&+@z_r| zTuvDHGY-uRIG$n{2$nR1!Qc2Zwoba>#&h~ivKfAO+B6W}Ij851ZbM8SHaiN|p4TTq z#(BLN_MFoXhKlq0(WD1%KChR_Spn9!55>_fKdSKpW=V-f}Xtv zlZZ}6VK@;pCYaO{B+NJ3S*INrHJ%h;4w4aB52W4_F*e1Cc@?icpsc92f*o@~PDNdk zBI^=dtc)2%C#ySJ)4&|_6LXopMc(>i=kVKwyiyntd6oI`pVswg2@yLwKVtO4l4O_Qkaujz`n=CUf zyNlsPW zxiC*;6VNE(83P`Muy&r6J(zaJY>gCQAWTUlQ8yh=L>w*c9gY@_L-yE31*_P-3ieM% z9mcp`vGI!2CCe@uD|=ziZ4nbvSk@!E@E`!Qk*K$VAYoq8>BigvCO=%FS8+-l1Ml7z zk(%PrsExU zTXK2S%sA#%)X~LcpMd#y%=9rF%RAg&hTXF!LceB|rZ1uCt9e?RucLHF^q)tx7zP+V zFdZ^in}4CU&~{6;Tx{xZN5Nrp6X7K|EdNYwt{)+{FHUDPNy ze3)cF&n4Mlgt;k(NO)qW$qcQiYUKQjGy{SyBf5~B1J9%wB(h!Ax{t{gcw?Ah41Ax0 zwF6p~Fx()Mtxw;5Q&v_*y(b^r;t*$i<|XEXGN12z<6F??$?WWZxKgR$#IVPT^1v{u7*!*;cBP)_e>MgbPhHzt9#-8i1?gvxef z9J$ip(r#=bFB%1MSwKT>n{P~mX>G>izgP2bBuPN&0^11ez5 zB4esikJ=Nb@nylNg~lPUZ;{aix2cbzi-O$pjS+^JNk}n0gksuaylH}DGT^Tq(g61@ zHV!3k@7iu11VW}Z)o)y4TtJdW=)A_5L9T&EuQ6tkCiwOm?8;ho47$c!DUhx;CX;5E zc&(8o*TL#*jb$XI)))vsTx;wHH(zUvg~X*OBW;LL6wS>hII`2!AMRReG{DTI#u)OB zpl>ktSyMm%<4cXlDpe;x=t*B(UK|~d8MaHxco~}dZM6Bf(WYML&R&fhd*OSb4Wa1E zG=qx>L-$q*PJcOihI-ea+dy!-Fnn+I;IzMu4vRD{Q!f97cZ@3|uQc3=Ix>r-eq+FY zkHGwU1g1*_o$56U^_->GxEe1}{2Ky8IKdT$z8AsegRc^F_H^_zi^b`BvABE!5_Ck* z(!6YlIZZs`F9my-pOq1CFAe1 zF&cW2Ts|ZHB6^ctoRiK=D?Up9?`V2RbiGL~n-_yNNskkA$!J_Y@`;*6m7;rY`e6Xk zn~DF|?~M;wCvn9!jcj3lp}^*rN)3&TSxTdT?siVKXy@`LmWo9jrqU`)YisIC#X_zj zx3a9FERUJ~5<4Erx!Fdj^p@JCzNlW5vB>OXQL{QE0RxmSlf zDCnNBqQC#+rm18&g15_p;$QK^HriZQ@C* z%4jIO`LrnovYtj1{<6UB`%D9ZY;yJ>nfKA zUsgE(oXJK;t3#x~+bVR~K9ik{f#>#_ByjCRK-BXl&NvX`bL259$YbjBI3_=+Nc=^^ z1J9e%@M_+X=S>1!^E^V2J#R8X#(vZAL4yr3F)^BzN+6xkJjXHJ6L=8!`e*Jp-93+d z2WZCpOV6sn`#^ZXM`U@Z+(9S87}k6;9LbAzM)6PJ%sR5igH&s=GRBSb-lHbLPTu@o z7|t#;3P_T6wv(eD!bGjew9B+<<<#zLTVPZ=%%PTA_SIif}_(~!t%8+#x3YEB;8VBEx zHs2G*ShP!$|L!qnTX=6pUSTANQs{UH2%J5xSA%$!0yBN^!v@*`_g-cGCahIKRs;#& z{Tc^&|9G=Sc-v<{m3nC%xx~xU@bOX&K3*)q$Ma+Hv5&>aGsEz)cMv}O{tM&H zG(*1Sr{|hoR`L+c?KBsW2VrxYIe)|TW{P~nPhW4IY9(KQ*|(cZ$o&xKGZ*ezV}60E zdgJlc14o&NI1Q=8+e))Bp*p{vm5J4sIu(=I8zT`n1u?eX7?D^Nz(CX%b4=$OkKa5n zqc;gp+@O-&JTSdChQTW!Dkd85#SI}tL{U0}@DaV>_N761YA?8aO#ogLJ-in}S{$q} ztQTBd7=Xv5ph%$&vs_020pD*iM-Nd&OYT)uXbTb~ZZ!{>tP&*lBH&t7f}8p!n1}&E z)^poh-ELY14v04qgF@R2g8RjV(N3dOT3EZ)+#g5urU65GRhn!H@EY6;++alTjROYt z0#_SUaKC|s84(0VbpvUmQRSSh$L3bTj&0@vI3g?i zMTXT$2w(?Ug6-m!@!)zMtiwvzsuc&&y+n#pQ7faVOCpJO*1_3@D6I!>LBhnDIW%+Ar!U2Je~wsHeu0yHqb>5R^qwKfq1{H3%9ERA2bSKqbR!12v3Hg(nSEI{9RP zMW=zH;THra4~dA$h|ydVI2H@+TvHt_E;LuIZS#X0*?o{}q!cn1t}{}@Aje1zfW1b_ zhHlGOMk)#JRROUkDwT|fTr9v~LcsMViX#(Xmx&U|hp@%L;5Sl{(8r9ONQ8Pbl0O{u zYBrcDjF5xQ4=goP-3hiyG`5)x>uHLCY?_LL9Wg*CMGH zxXD8GCsSaXg&Ga{7DP>@DGN-Cr1Wt1JRU4uOQTrdMN&q6ny<U;2s-`+8+lq zm6RzdY@_{O=OM%^`G4#@`1f~GT_Ju6%VY|8_8D3QWCe9PoPfu-00O?7A6+4PxI7p| zNdd>s2}5qAz7La!lN3B}gv#+W`olL-$HI{!BikiVZl?D3_+uyztKgXmI(i9y^w)&& z##o-^y<{Hd$gw4j5DCtToolpX_Dt?xCs_$C03Lcl*g zT0-Z;!yBj*VeDmvV-+~Qfp$XV#$KFdba^kzE8j_-3hRuFS!=uCziJbu4_^(ctIUh) zYQSUC&D8f{!;g^xI`Zq$Q~uW$D#VA!nUNS40{}-Y?Sj&6)XuQRL{4JGZg05tE(#PU zVQq{2BC_6c*fN@ajE?``*9rgkb;AE2UnhjN9n`W?{h9Zc8=Fq9P)qe*ZY(k7%KGt@ zsor?lINI=tAvWSR{jd5K-KVkh?xzdoO&h z!crfD>*Up=mlEWT_YS-aZBHVs4>@;3%-cuaBgh+vrxk$aDTIxR@0Xl)`X3tza$(Be zCt;lmV}D5+n|JP9EkPDW+MkkO{)HSm-QlgL*OmTZZ6kRZ;U zo@{{Z-3T*`eu_57|CCD*hkm>xAKp`8NjZCSGS`u3@cqeWJ|7Lvrx6wjV}I;Fr>>45 zHZ4dy11As`oATvLHvXZ$_Y=hGPrudrC|k(8pRi^R)joJ!`|x4)CJpcXX~ds>)W}eL zqMvG~hx|{WIo14Yd-JsWcECM;Y$xTOXIAmW(s6tX_E^g#;P&F{$^GAK92FNoAkta1 z`+Yc|LWZ85_#-#z$>g%7lkbP}XHb?H`?S-E%aR&VmQ?mNAC(%~*0E=>t*G(R^yvLx zzBvE8BY%RHXOYY3ba<|+<^9>N9h+vuc@>hv{&b9c?CLMlR%o7to1a67zQ*{np?&-k zwS|e3<)Dtld+R@3^gzKu_+mc_I&9|EUcvG7DMx)~DYU(Skob>w+>t15V~xsJtJ24(4+@51xIMIunj( z6bTX^qLbkB!(m${n~6K7AlUt1zlK|8SRLH>(F0nI@;1Feoef9fdp2B}hVIyGrk%lCZFWEVE;T9~fgTjr4Q%E9 zH{YWMg}O^@rYtZV7xXREtel^FpNa|Z7iQHsm{v=>{m*}Z;Y-*UqnjWGd?}|t_d_Zw zykShMvE8nnf5k_Xr56_cFj4g1_ZKQI93k4#eGR%Ee%mLMAzT^}U(E@)et{&S-}hIX zhA@$E5I8B|f%t#^8>IY4~-5dE!xryy~3C1IT#c{H-pa=^mS+vzCkdh-dh z)AXJx+xV0*#jwF(j98*Srk|{PQ#V%om^M~(3wfTLO?*mJuFs{{(c*V^7nS%5`NGfK zlh=PvFQfaOc(LE$Rec7lCCnx1Ws%XJ4_TCGn)nbwF539#s0|tfjVi1y&gQI}2-30V z*^RJ9g_(Yin_)R~=p};WTD;|us6`l8{x@rW%Zw)oazuOKSFlrs=~rYR?B{iXM%z*qxaPFoMMd!r*4y`B4Y$qLwLKu%+gnvZHJIddKpdp`^} zB80j(CN`@0nIFXwgWiJO2#FlKBzsuxqiNF#()gfm0*34eOMdaGkC;ch&Jo0$BM%LM z_YoGKRP^QQ%uNp-!p|KvTXTGt>>er9&nrB53wCLv8M~A`_rCWTxoiY>DIskyoG`0` zq%GrXN5?+yd1Cn&a6N@h#JRa$X~T}*L1=FJ=HXWSwA}T6+ynARcM8|R{91@pNMEUqX2165y`7}QUXr; zB!NC3qCSXIQ`T_gaoP=Uigaj@hnYFajtTaP@tCnbDbb;k1mf^*WsEC-mg!#lsNlO| ziXW5YtHRY>!DFYOgk$|*j;7N{>!nma%i=+T9WF@b#DFpMFB?mzhAT22Sz)}12Ms|* zj=hRb4rhUaXuQnV6y+UJo?v=T@H~vz9ZZnHJ;60{wIPDcMAFfW0ltf!j7 zMB*~l)rV_A9s1yQeq>TFT~fE}kl|OZrb9z|T&8$&Q-q|4P}e3;2^%fM@whPTfG$&i zm^YQ)Bwjg&e#K}H#t0FFzD@Uqu2%b!R?yt1=}#^vP7^aQMi>DzrlBR&*j9VB^zEmF zTM9ThtwOjd&%K$yeEV{>gyjyjQIVz>y81mh9!+w?{_0NX??jjo{n^UGV;|HJWYO+* z$KXB{HdcIXCBA!!umM}2^n&0*GcE7>=Fes4KA%Dmf0_P|qdv>ba5H`5LNwE7U37V@ z<@5CNey#80$5Za!^^4hOX$~QTX>PhbGPW`Pfz&6~mJ>wRyYKCTqiz&7CFfW)_wv%c z3m5N>h87P(_)~|UD%{^WwMINlz9>2zdqXzFPgF}7 zI2|FT7ITM%e`s7=bo_aEK!r$!1Ai|0^`dTG8jA?d6=)wEsK2oC;O0?Z$`mGDukT(^EVp(+0UXj-~TyCa}YvHH$Ty5 z(1=^DX^)(Rmk=^ob2715mt+&v(nmFfwll4TZXc}u=fPL>i>-xoHs3MIN7aYok0YKQ zRPpegLt9swnxA^F8QNO04TJBccJs2ut(C43M>fo*Z`*a-_FX!ERtH@j8=_8proy1h z=f7_)Juuv4;5Q~z#YQcf1ZiDin;4&IXL-r4ZercX$sKplzeM(IAw0gM3>R+3rwKaAXcW$bk31a3g&@%+{a@<5BhT={Tj9gabZE4~XJT>;Hy_Lpy5Op4@qJgcs0Jkbs%qa3B3hNbiMAPGZn)SGVNgkng@fY_mcF4E4B6dp5*A7&hzz`Yc%g z$k!NjWj#b6597`9X!9P-ewYrm(O3h6u>i+O@WdnZ-C-TUU4fNZKgv2Hyb{AGm>>?s zg}*#T9}202D-ro|qW|5;={cd55W(41i+`R@^tbMy?}-{^ryrqXsoN;R+=kyj3npY9 zGsIy+=7N5%?lWDr_C++d_iLibRm9K4EHt(mFzWz)!{EN=IXebry?IbAeJ0KkJ&cCO$X^4Xye=OrqH$fJ!%O94}owL@nWU6++h7r;;E4bGhmK8=pYqYX~tu+;uITxVbSoY1i zBcc6uge324yMeiTPMlhT<_(05SvqKWP2uy^1>$!TV2ui)_P)O_syXd_wS@RL5yHMd z@wvQ1U+BHHV@|+h2pKGNel8bv{<%yoeU^?M^QMQww{Yj-lQ!Q`uzo&%4(|zTHVud0 zQu(&)o{cj8Y37n|A6NwSZzDuAp!qe;H?Nk?yPaAEU#Jj1Z|!{rufFkR>*BljL)$wD z87o8$t}XnZ2DRLCgx(evvefYio)z$TBCtIBenx*Ef)g_FLnA>W5wGSg{t;qe5QkZo zkjpQH|3imdeqj-h-%`S8yzmQpPsoxN@Dn>CD|RdV%rARYLUmA$OF-f8|B4Q|j)NRA zu^nK zD|!AGzNgQcLh6cW-js`%gaSKC#?N7184`%-(sdti+ zhaQ?B65no;VA?OVKdePONx`$Ez=f$me{c9TEMUQ