From 959e02806b7d2e6190e18c2687699dfecb4d1b87 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 20 May 2025 11:53:45 +0200 Subject: [PATCH] add aiohttp (again?!) --- internal_filesystem/lib/aiohttp/__init__.py | 277 +++++++++++++++++ internal_filesystem/lib/aiohttp/aiohttp_ws.py | 278 ++++++++++++++++++ .../lib/aiohttp/compi/__init__.mpy | Bin 0 -> 3687 bytes .../lib/aiohttp/compi/aiohttp_ws.mpy | Bin 0 -> 3634 bytes 4 files changed, 555 insertions(+) create mode 100644 internal_filesystem/lib/aiohttp/__init__.py create mode 100644 internal_filesystem/lib/aiohttp/aiohttp_ws.py create mode 100644 internal_filesystem/lib/aiohttp/compi/__init__.mpy create mode 100644 internal_filesystem/lib/aiohttp/compi/aiohttp_ws.mpy diff --git a/internal_filesystem/lib/aiohttp/__init__.py b/internal_filesystem/lib/aiohttp/__init__.py new file mode 100644 index 00000000..f2e4f17d --- /dev/null +++ b/internal_filesystem/lib/aiohttp/__init__.py @@ -0,0 +1,277 @@ +# MicroPython aiohttp library +# MIT license; Copyright (c) 2023 Carlos Gil + +import asyncio +import json as _json +from .aiohttp_ws import ( + _WSRequestContextManager, + ClientWebSocketResponse, + WebSocketClient, + WSMsgType, +) + +HttpVersion10 = "HTTP/1.0" +HttpVersion11 = "HTTP/1.1" + + +class ClientResponse: + def __init__(self, reader): + self.content = reader + + def _get_header(self, keyname, default): + for k in self.headers: + if k.lower() == keyname: + return self.headers[k] + return default + + def _decode(self, data): + c_encoding = self._get_header("content-encoding", None) + if c_encoding in ("gzip", "deflate", "gzip,deflate"): + print(f"__init__.py of aiohttp has to decompress {c_encoding}") + try: + import deflate + import io + + if c_encoding == "deflate": + with deflate.DeflateIO(io.BytesIO(data), deflate.ZLIB) as d: + return d.read() + elif c_encoding == "gzip": + with deflate.DeflateIO(io.BytesIO(data), deflate.GZIP, 15) as d: + return d.read() + except ImportError: + print("WARNING: deflate module required") + return data + + async def read(self, sz=-1): + return self._decode(await self.content.read(sz)) + + async def text(self, encoding="utf-8"): + return (await self.read(int(self._get_header("content-length", -1)))).decode(encoding) + + async def json(self): + return _json.loads(await self.read(int(self._get_header("content-length", -1)))) + + def __repr__(self): + return "" % (self.status, self.headers) + + +class ChunkedClientResponse(ClientResponse): + def __init__(self, reader): + self.content = reader + self.chunk_size = 0 + + async def read(self, sz=2 * 1024 * 1024): # reduced from 4 to 2MB + if self.chunk_size == 0: + l = await self.content.readline() + l = l.split(b";", 1)[0] + self.chunk_size = int(l, 16) + if self.chunk_size == 0: + # End of message + sep = await self.content.read(2) + assert sep == b"\r\n" + return b"" + data = await self.content.read(min(sz, self.chunk_size)) + self.chunk_size -= len(data) + if self.chunk_size == 0: + sep = await self.content.read(2) + assert sep == b"\r\n" + return self._decode(data) + + def __repr__(self): + return "" % (self.status, self.headers) + + +class _RequestContextManager: + def __init__(self, client, request_co): + self.reqco = request_co + self.client = client + + async def __aenter__(self): + return await self.reqco + + async def __aexit__(self, *args): + await self.client._reader.aclose() + return await asyncio.sleep(0) + + +class ClientSession: + def __init__(self, base_url="", headers={}, version=HttpVersion10): + self._reader = None + self._base_url = base_url + self._base_headers = {"Connection": "close", "User-Agent": "compat"} + self._base_headers.update(**headers) + self._http_version = version + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return await asyncio.sleep(0) + + # TODO: Implement timeouts + + async def _request(self, method, url, data=None, json=None, ssl=None, params=None, headers={}): + redir_cnt = 0 + while redir_cnt < 2: + reader = await self.request_raw(method, url, data, json, ssl, params, headers) + _headers = [] + sline = await reader.readline() + sline = sline.split(None, 2) + status = int(sline[1]) + chunked = False + while True: + line = await reader.readline() + if not line or line == b"\r\n": + break + _headers.append(line) + if line.startswith(b"Transfer-Encoding:"): + if b"chunked" in line: + chunked = True + elif line.startswith(b"Location:"): + url = line.rstrip().split(None, 1)[1].decode() + + if 301 <= status <= 303: + redir_cnt += 1 + await reader.aclose() + continue + break + + if chunked: + print("__init__.py of aiohttp received chunked, creating ChunkedClientResponse") + resp = ChunkedClientResponse(reader) + else: + resp = ClientResponse(reader) + resp.status = status + resp.headers = _headers + resp.url = url + if params: + resp.url += "?" + "&".join(f"{k}={params[k]}" for k in sorted(params)) + try: + resp.headers = { + val.split(":", 1)[0]: val.split(":", 1)[-1].strip() + for val in [hed.decode().strip() for hed in _headers] + } + except Exception: + pass + self._reader = reader + return resp + + async def request_raw( + self, + method, + url, + data=None, + json=None, + ssl=None, + params=None, + headers={}, + is_handshake=False, + version=None, + ): + if json and isinstance(json, dict): + data = _json.dumps(json) + if data is not None and method == "GET": + method = "POST" + if params: + url += "?" + "&".join(f"{k}={params[k]}" for k in sorted(params)) + try: + proto, dummy, host, path = url.split("/", 3) + except ValueError: + proto, dummy, host = url.split("/", 2) + path = "" + + if proto == "http:": + port = 80 + elif proto == "https:": + port = 443 + if ssl is None: + ssl = True + else: + raise ValueError("Unsupported protocol: " + proto) + + if ":" in host: + host, port = host.split(":", 1) + port = int(port) + + reader, writer = await asyncio.open_connection(host, port, ssl=ssl) + + # Use protocol 1.0, because 1.1 always allows to use chunked transfer-encoding + # But explicitly set Connection: close, even though this should be default for 1.0, + # because some servers misbehave w/o it. + if version is None: + version = self._http_version + if "Host" not in headers: + headers.update(Host=host) + if not data: + query = b"%s /%s %s\r\n%s\r\n" % ( + method, + path, + version, + "\r\n".join(f"{k}: {v}" for k, v in headers.items()) + "\r\n" if headers else "", + ) + else: + if json: + headers.update(**{"Content-Type": "application/json"}) + if isinstance(data, bytes): + headers.update(**{"Content-Type": "application/octet-stream"}) + else: + data = data.encode() + + headers.update(**{"Content-Length": len(data)}) + query = b"""%s /%s %s\r\n%s\r\n%s""" % ( + method, + path, + version, + "\r\n".join(f"{k}: {v}" for k, v in headers.items()) + "\r\n", + data, + ) + if not is_handshake: + await writer.awrite(query) + return reader + else: + await writer.awrite(query) + return reader, writer + + def request(self, method, url, data=None, json=None, ssl=None, params=None, headers={}): + return _RequestContextManager( + self, + self._request( + method, + self._base_url + url, + data=data, + json=json, + ssl=ssl, + params=params, + headers=dict(**self._base_headers, **headers), + ), + ) + + def get(self, url, **kwargs): + return self.request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.request("PUT", url, **kwargs) + + def patch(self, url, **kwargs): + return self.request("PATCH", url, **kwargs) + + def delete(self, url, **kwargs): + return self.request("DELETE", url, **kwargs) + + def head(self, url, **kwargs): + return self.request("HEAD", url, **kwargs) + + def options(self, url, **kwargs): + return self.request("OPTIONS", url, **kwargs) + + def ws_connect(self, url, ssl=None): + return _WSRequestContextManager(self, self._ws_connect(url, ssl=ssl)) + + async def _ws_connect(self, url, ssl=None): + ws_client = WebSocketClient(self._base_headers.copy()) + await ws_client.connect(url, ssl=ssl, handshake_request=self.request_raw) + self._reader = ws_client.reader + return ClientWebSocketResponse(ws_client) diff --git a/internal_filesystem/lib/aiohttp/aiohttp_ws.py b/internal_filesystem/lib/aiohttp/aiohttp_ws.py new file mode 100644 index 00000000..af05e267 --- /dev/null +++ b/internal_filesystem/lib/aiohttp/aiohttp_ws.py @@ -0,0 +1,278 @@ +# MicroPython aiohttp library +# MIT license; Copyright (c) 2023 Carlos Gil +# adapted from https://github.com/danni/uwebsockets +# and https://github.com/miguelgrinberg/microdot/blob/main/src/microdot_asyncio_websocket.py + +import asyncio +import random +import json as _json +import binascii +import re +import struct +from collections import namedtuple + +URL_RE = re.compile(r"(wss|ws)://([A-Za-z0-9-\.]+)(?:\:([0-9]+))?(/.+)?") +URI = namedtuple("URI", ("protocol", "hostname", "port", "path")) # noqa: PYI024 + + +def urlparse(uri): + """Parse ws:// URLs""" + match = URL_RE.match(uri) + if match: + protocol = match.group(1) + host = match.group(2) + port = match.group(3) + path = match.group(4) + + if protocol == "wss": + if port is None: + port = 443 + elif protocol == "ws": + if port is None: + port = 80 + else: + raise ValueError("Scheme {} is invalid".format(protocol)) + + return URI(protocol, host, int(port), path) + + +class WebSocketMessage: + def __init__(self, opcode, data): + self.type = opcode + self.data = data + + +class WSMsgType: + TEXT = 1 + BINARY = 2 + ERROR = 258 + + +class WebSocketClient: + CONT = 0 + TEXT = 1 + BINARY = 2 + CLOSE = 8 + PING = 9 + PONG = 10 + + def __init__(self, params): + self.params = params + self.closed = False + self.reader = None + self.writer = None + + async def connect(self, uri, ssl=None, handshake_request=None): + uri = urlparse(uri) + assert uri + if uri.protocol == "wss": + if not ssl: + ssl = True + await self.handshake(uri, ssl, handshake_request) + + @classmethod + def _parse_frame_header(cls, header): + byte1, byte2 = struct.unpack("!BB", header) + + # Byte 1: FIN(1) _(1) _(1) _(1) OPCODE(4) + fin = bool(byte1 & 0x80) + opcode = byte1 & 0x0F + + # Byte 2: MASK(1) LENGTH(7) + mask = bool(byte2 & (1 << 7)) + length = byte2 & 0x7F + + return fin, opcode, mask, length + + def _process_websocket_frame(self, opcode, payload): + if opcode == self.TEXT: + payload = str(payload, "utf-8") + elif opcode == self.BINARY: + pass + elif opcode == self.CLOSE: + # raise OSError(32, "Websocket connection closed") + return opcode, payload + elif opcode == self.PING: + return self.PONG, payload + elif opcode == self.PONG: # pragma: no branch + return None, None + else: + print(f"Warning: aiohttp_ws.py received unsupported opcode {opcode} with data {payload}") + return None, payload + + @classmethod + def _encode_websocket_frame(cls, opcode, payload): + if opcode == cls.TEXT: + payload = payload.encode() + + length = len(payload) + fin = mask = True + + # Frame header + # Byte 1: FIN(1) _(1) _(1) _(1) OPCODE(4) + byte1 = 0x80 if fin else 0 + byte1 |= opcode + + # Byte 2: MASK(1) LENGTH(7) + byte2 = 0x80 if mask else 0 + + if length < 126: # 126 is magic value to use 2-byte length header + byte2 |= length + frame = struct.pack("!BB", byte1, byte2) + + elif length < (1 << 16): # Length fits in 2-bytes + byte2 |= 126 # Magic code + frame = struct.pack("!BBH", byte1, byte2, length) + + elif length < (1 << 64): + byte2 |= 127 # Magic code + frame = struct.pack("!BBQ", byte1, byte2, length) + + else: + raise ValueError + + # Mask is 4 bytes + mask_bits = struct.pack("!I", random.getrandbits(32)) + frame += mask_bits + payload = bytes(b ^ mask_bits[i % 4] for i, b in enumerate(payload)) + return frame + payload + + async def handshake(self, uri, ssl, req): + headers = self.params + _http_proto = "http" if uri.protocol != "wss" else "https" + url = f"{_http_proto}://{uri.hostname}:{uri.port}{uri.path or '/'}" + key = binascii.b2a_base64(bytes(random.getrandbits(8) for _ in range(16)))[:-1] + headers["Host"] = f"{uri.hostname}:{uri.port}" + headers["Connection"] = "Upgrade" + headers["Upgrade"] = "websocket" + headers["Sec-WebSocket-Key"] = str(key, "utf-8") + headers["Sec-WebSocket-Version"] = "13" + headers["Origin"] = f"{_http_proto}://{uri.hostname}:{uri.port}" + + self.reader, self.writer = await req( + "GET", + url, + ssl=ssl, + headers=headers, + is_handshake=True, + version="HTTP/1.1", + ) + + header = await self.reader.readline() + header = header[:-2] + assert header.startswith(b"HTTP/1.1 101 "), header + + while header: + header = await self.reader.readline() + header = header[:-2] + + async def receive(self): + while True: + opcode, payload = await self._read_frame() + send_opcode, data = self._process_websocket_frame(opcode, payload) + if send_opcode: # pragma: no cover + await self.send(data, send_opcode) + if opcode == self.CLOSE: + self.closed = True + return opcode, data + elif data: # pragma: no branch + return opcode, data + + async def send(self, data, opcode=None): + frame = self._encode_websocket_frame( + opcode or (self.TEXT if isinstance(data, str) else self.BINARY), data + ) + self.writer.write(frame) + await self.writer.drain() + + async def close(self): + if not self.closed: # pragma: no cover + self.closed = True + await self.send(b"", self.CLOSE) + + async def _read_frame(self): + header = await self.reader.read(2) + if len(header) != 2: # pragma: no cover + # raise OSError(32, "Websocket connection closed") + print("aiohttp_ws.py got len header not 2") + opcode = self.CLOSE + payload = b"" + return opcode, payload + fin, opcode, has_mask, length = self._parse_frame_header(header) + print(f"aiohttp_ws.py _read_frame got {fin} {opcode} {has_mask} {length}") + if length == 126: # Magic number, length header is 2 bytes + print("aiohttp_ws.py Magic number, length header is 2 bytes") + (length,) = struct.unpack("!H", await self.reader.read(2)) + elif length == 127: # Magic number, length header is 8 bytes + print("aiohttp_ws.py Magic number, length header is 8 bytes") + (length,) = struct.unpack("!Q", await self.reader.read(8)) + print(f"actual length is {length}") + if has_mask: # pragma: no cover + mask = await self.reader.read(4) + print(f"mask is {mask}") + payload = await self.reader.read(length) + print(f"payload is {payload}") + if has_mask: # pragma: no cover + payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) + return opcode, payload + + +class ClientWebSocketResponse: + def __init__(self, wsclient): + self.ws = wsclient + + def __aiter__(self): + return self + + async def __anext__(self): + msg = WebSocketMessage(*await self.ws.receive()) + print("ClientWebSocketResponse doing __anext__") + print(msg.data, msg.type) # DEBUG + if (not msg.data and msg.type == self.ws.CLOSE) or self.ws.closed: + raise StopAsyncIteration + return msg + + async def close(self): + await self.ws.close() + + async def send_str(self, data): + if not isinstance(data, str): + raise TypeError("data argument must be str (%r)" % type(data)) + await self.ws.send(data) + + async def send_bytes(self, data): + if not isinstance(data, (bytes, bytearray, memoryview)): + raise TypeError("data argument must be byte-ish (%r)" % type(data)) + await self.ws.send(data) + + async def send_json(self, data): + await self.send_str(_json.dumps(data)) + + async def receive_str(self): + msg = WebSocketMessage(*await self.ws.receive()) + if msg.type != self.ws.TEXT: + raise TypeError(f"Received message {msg.type}:{msg.data!r} is not str") + return msg.data + + async def receive_bytes(self): + msg = WebSocketMessage(*await self.ws.receive()) + if msg.type != self.ws.BINARY: + raise TypeError(f"Received message {msg.type}:{msg.data!r} is not bytes") + return msg.data + + async def receive_json(self): + data = await self.receive_str() + return _json.loads(data) + + +class _WSRequestContextManager: + def __init__(self, client, request_co): + self.reqco = request_co + self.client = client + + async def __aenter__(self): + return await self.reqco + + async def __aexit__(self, *args): + await self.client._reader.aclose() + return await asyncio.sleep(0) diff --git a/internal_filesystem/lib/aiohttp/compi/__init__.mpy b/internal_filesystem/lib/aiohttp/compi/__init__.mpy new file mode 100644 index 0000000000000000000000000000000000000000..524ce853e0ca068f46a76a459c723f1b5a7dc5bc GIT binary patch literal 3687 zcmeZeV~}rT7E(*h%+Dw(DbSCP&&WIWsrjlK~yjZ zg$Mfjn~THt1BIcJpSWv8Z~3G2jTcas!E zU2tk~ab|uV1DAoGfu1o#lQXk2Uoyz`c_j>d@hPdv`6;Okd>N^UDXB%p42`;cDXD2W zi6yBFESdQXLM{-Fr#}OqQ)NkNv8O)+N0g7J6GLM)M|xFe0RxA7lxF|~2PiNYc%Y_n z<>V)(6f>Cf6qh8Hlom6HB!e6tUz}N$%FtNfq-@Vslv~(5W4~t0}OoeMPOGk@FXVZ>zJOJSHd6#Q4I56lf6_tD4yfXQj5UBA`o8$iBSeIC?mco zv7Di@Nxeys4P*tAJp+>(Lt`LUN@;FEF$0^sYX}2JfPZiZ11}fIBr66U5LIl&z+}ZB zmtT;Y7oUu95?gU`4g-fresKu{3ojQ#qc2Zt9yr8#63dG+OHvv5ptdq_q$HLkGVl~6 z7A58uGjJ4uw6GPFmN2jdgoZG16(p7_*fA(&B<7_QXC!8)!b5`x z8umgq>8W|C6$M3h3|!T;`xoN+^hoN9Gh37>k0~g zkl9?!tSsOp%*qZ+@~k`{0UbD3A>1*@&(qJ{N&zaQkei=Unv<#ka!qDYY6=4@AIw~z z)V%bP3bU1_oAH8?1R5u2v|dC^4@%Ewu>l zTq_1Pc5pUIO<`c;^vO?71jVuy1FKkQUU6wbL4Hw5YKlTZQGQ8&a(<4L0s|YrYO#X8 zYO#W9F)tSgGq4II78K-ULUihbBZ!p;=6rC8#43TTB0sq#wM4hLq$o8pmw`|*SZ0-R!*0$gHQAw~=W+$Kz7+#yDc0z87;Cd^_yAx2CBye3Ryydg%+ z0(>S+VtfMpV%dSoEX^hyLfjpKDJ_B-nJI!fVz~m`VtGakVnRlYVj}fgjKRiCVln~( zAO^FTyb+5Svk|M9QeZM$h7^}jenNxL#tjm}fyo^nx0sYf^8Fh`wbYeVUDXV=)Qp0) zHVBJ~h$%CeF&^5a%y`%3QGB5- z`_Zi;GG+|PF|kfbn*?NUy2KzUkn1;NMpohAB^WMLaNor%Qm9aw$%HY|i!pX%a-+v4 zM=qKCq6W1MA|e}vg+*jGnlZ)&W|%Q1Hz^!)GtiMAVd78_k#+LJtX*Y?@%gv_V1zqACKUN~Q_y znZuiv*={;J3#q3nvnPYag-SPW5LPi^0!ts+5H74DRJO&0@mUm1dni~Ly7mnsB3lq1 z_EhFTc(}2Ifrq!lH$?%Gba)~DHDVOgGGY?b4oqfllu+gq%GWG!@Kf2eK|*8W1_{l; zsy5Rd^5YCf?%0YM!G!AvdYU}H$~g}5OwFi?j>QGuYy;}wem z(|lsFfyw;M4hLEp#f0)JL@OJjRaEqK0yjwLY}_EBufrr@AYhmzU?gCiv_ZmTgRqH+ zY4D~7^CpJK%^M`l0+T&D9YDTM+K}9+0P;A8c78)Dl2@m&9J$!S7;m0mW#iB65g^)- zrKD`Atf`@)q@by(q^_x^q8VjmlR?3`aj4ocvT>I|2I0c<`iUkRAn=!hC>=UszV-QgbY&2tP3>8r@V>smG z;Iye;sY!QFc=RndHl-&0TTV__qIhDKIb!Rjaa@Fvfv0>8Gbr5;h)u zMvO*GyG$5O823wvdU1sd)!cV+2Ic;p8zgl0Co?w*+;ZScY-DO=YUmE$7#!>^a7Z9Y zBzco6!=_L(ru0ZBy};zAh)AxKMy7^79fl3@-m(Wbil_;=HJUNihl-?{F~E2Yq56_e ziph-$j$CPtObz|oAjNzKH-dZ(Ql~HCWRN_U<=Vwo#$pRtjMY{)EYi`?QC6{3iqLga zQ&Uq>b@5WuaVv39RZ-VcPF0XkRyNQIax+dHMvb560l1YB>r_$oD9pW7OE&&4U|sFRma zBZD%}u1_Y6sRC|$?z#l+frg6#)0uNZCHqVm&j~S!xbN8~;IY-Li775H!+`0$n!wJ$ zdoC^;gw0M0cy7OY*~M$Sh^c_*9y3s)cTQL4-MDw>c9&2AFA;kq#_b~BW(?rS6!7}w z z#STKe8#aiTZ`>fPzv+{J|7HQ^Edm}}1p>AShzivQZxA*K-riu^!eG|LWWto(XmF~* zflDmkrs3lT5kCRPjo`utCLWk?)9?i(?u05HlyB4U9V8x%Djt$=)9?!<9*Qa+mT%MW z7bG5zDjtzj8!3dMq?IMs~RgxCU27!Pk2i32GRh~FS=Vb%hwzauvbCv1?=5n|nJ!k7#IwZc=k literal 0 HcmV?d00001 diff --git a/internal_filesystem/lib/aiohttp/compi/aiohttp_ws.mpy b/internal_filesystem/lib/aiohttp/compi/aiohttp_ws.mpy new file mode 100644 index 0000000000000000000000000000000000000000..37f3b61d59e22f410e075715c19caddfa171f8ff GIT binary patch literal 3634 zcmeZeV~}rT=hI5e%+Dw(DbR<~@#V#O1(gi^e2K-CdC8gi3_L}Nc`5n13>;a-`FRWi zNttu3}VUoIXS7xC7Jno#SDDO`MCv| zIjIb6p+TMu3gM|q!THJAsU^Ot#l?x~sSHBl!M?@mA(aKG403Qu=bX&cyb=aI2n`nw zN-ZwP&nr%4Fo+Ki4oWR7O)V~Q&d)1JttjzL%u7s9En?s@&@<38W)LVX$|*=JDo$nK z%1tau&S2n5FUl`1U|=gRE@ogUFJ@@eRBp26NJ%V7WZ)@CEK1BRX5dNA$uCY#Vc;oB zO-xBGV&Ew+$}CAO0{I{>FEzP@!Cs)CD8D2>IX{O%C?hd1r8pxoJC#8#9&Ay3T2W$d zYJ3J%Z)sjZVsbVEo1&8wgF$>jQGRl2adCWkYEm)S_Yl<#93id|Aq-8*Ree(nr<@u_*q`6;OwdU+t?3>;vKI24_nJQzS^AOnk{Cxcje zY6&RXk}^w*88|@E%fJO9ix~tm@{3DAvBkhqkY7{+(qEFnz$=nulo+3sSe$BR!oXEs zYgJv#z~PZ!T*AN?T996pn3BpM;+zi-hs^vu1|hgl7+4IA8APgUt@QOFx_JDHGSf5j z7}(rhLm1eKi*p$GAc0cMAdy)dj|e!vvecqtkYNHIAt3?!hI)n!jrC3Hd_}3rshMS| z3}W%1h=7JvlW3DCS4vT0W*$Q$e`7U^q6Y(uVjzQ1e0(A(lH%jR%)Hc!lK6NAf#TG> zl=$M3A_fsKE2*+1wU|K&%m$}nu9VW;f?@_Ss8wL4QZNogwFHa{(U6m$m{QEZRg_wo zoDWJV;PfdHAD@_-SCU!;wLY~X6J$Lf*x{fQln72z3|z%Isi_4FLN@8Cd8rizMRp8a z@nBW+(t%`+|=CsqRO(&)N%$9s5SA)`3&5wh8pF? z#Wm%{npXPy8qtorQHi=$2D+BIF?zAuni}?2F;*JU2D+B9+M1g78v1(Ln)VD_EUX;x z?8C|dPlT*2;8e)UQjl1Z!N4jKoSc!Go2pPzs1|85uqtC$07^5unZ+3}RY8z6 zoT88mDdrSFc|)-X?5Vu`5|Dumtg3`mflMuC=+qG~U`%jO0|OtetQOJCgv^A_g`N7H zybXcUOkzw8fzr%k%ngCkEMjR5fzqsEtPO$EY+`H;fzs?^>{Ax10$ z{3c9d{2@lH0sgww5YN~2#s%}0)=_0}#%oq=D zbh+NJQADKCjPcM$5s@Y{#={#$M4HVQk8BhXX)$9wx=BFvri=HMzDq*72@8sStxsp3FB51mgKlm{N(XF^kO|x%Ase_sLM|}5+2X(vMhW?%OqY@dM>RDKWhEh@ zjV6o-H#$2DDWq-`Rs;o{>n3NXphFu(ls0bKY{ncHm=PJ6+*}YPqFh?m;FYVDqU9#U zE}|l!y3vfufa%a-q4G^ijSTNi7>{h~e}7b{d^3c1Y_n2B{rlrvwr<<5#gg2rp{<}+ zR9w*zuCAr7Ef)+oE!+^_V`qr>y{Q*G^DA!sii1rrFf~U_-d&6sj1~9 z8>$*|LacDxB%)`=a7d_XlL_PDpztF{x45_}H8MCkHXPfw@xigJFy8UaN)2__T>`gn zesElfT|{3%b=!6`<`Yhpo0VD`7_Pg7Y*%WigGw1}+rHV1<%Co0W@Q$LOg&V_5Gvy= zl$8oL#Yl)*#CU-jqZyMr=MGiQrzeD}cPcP23e{{fVT?0jG+{inW7DT(#wLMV4t%MN zY>jLUZNVFZgS`bnp}Sd?VN=VG(0A#PPI`gK{VFp;jFgHpU1}S;l=V%m4Aj)L)dK4i z613I*m9%vjj)0@^j*E+ciIbCn=}~1?5%H}Xh0Qh!n{O1haAgv(G-Ej?#42JXRBgl{ zR5tlOjGAOUv)50eh2xWEhprVSFZ8zkg52+N9inlZ!$ zW}Mg&Sf|GndSZu&mokSLqqCck!VVM0RHq;;N}RPpnJ?j>16OJzTSIT~hTvdto`awe z?J#3Zk90B$Om1a3=pf-;)UaGZM_XB2UBzRAh!5Bv1EzzAHi)Qg+yqLfhmUM_@!lZf zyJfQ($W@V$H0Bw!K|*HZ7A>aaEiSHL+KDkRxjEyoqmXZrRB^**K2 z3RRjgxwvi+R(EpVAgsC3jOp+OVL1`M&1Q^|AcjBK(#XK%CV_JiGiI?9IZpfCt*GGmMj%s41i zwb6vJ>5hxf24Ss3%AAKcZPsE+-XNm9(Tvf6<;bC<$F?ao)ZKK60Ov<==s|UwF&Qu( zbBf=l)KCwT4h9?C0X7&S?XcCw7cAHU7F35=y$#GcywxR8sCqj%nQn%r5{UK4>GV9V zblRfBpv2#CB_*YWJtf5<#UUjnAteD^;PFFBR3k>Q5F;kBP$Oos03#N$Fe6s6a3eOc z2qSi}C?gKBXd_Osn80MN0x2GeqDF>>s~a~+hy^A)aB_qcHQd;c+-$MXfiJYE;dX$z ziV)8RVKEVJaNswZG9KJ0EamLIQCM<=u$ZvcO=ouyE#u-M)X12+F}YD;q60@jQNtsU zo&c~OP=r;ia^MRql4^LWsRxZ`XFs7vCS@)m$&DtAKTH@?LF#=sg2M(>99nF0;0rI3 zYItP`)=(|f$Sl;zqQz{&1kuQiLt|sWCI_yFBB_S=8$`l{(nKORnlZu+jo9eG7gf~o z$w1o((^FvosNZo30=bI^luso$3QL(WnJ}hq6qZVE25F8iYWRU$vnEtCFNWqukA)6g zF+~mkG&hJufdl2BP?|{WMl;6b#u5e|ff>vx3Mt^|6M#owf)SHgVqh|Jqk^)4L{WSr z>jV~+O&cWQHg1rJ4@@qY=)jRsG=XD-a9mtq1}H}yXmH?4ESkWryFoZ!I7uWKY)zyP LlSoPfH~^9X!B0(2 literal 0 HcmV?d00001