From fff68bd217c066d2d79c50a029c64aac63391f28 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 00:48:49 +0000 Subject: [PATCH 001/146] ORM for _allocations set on Batch --- orm.py | 40 ++++++++++++++++++++++++++---------- test_orm.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/orm.py b/orm.py index 6a3e728e4..3e6b4a70a 100644 --- a/orm.py +++ b/orm.py @@ -1,5 +1,5 @@ -from sqlalchemy import Table, MetaData, Column, Integer, String, Date -from sqlalchemy.orm import mapper +from sqlalchemy import Table, MetaData, Column, Integer, String, Date, ForeignKey +from sqlalchemy.orm import mapper, relationship import model @@ -9,21 +9,39 @@ order_lines = Table( "order_lines", metadata, - Column("orderid", String(255), primary_key=True), - Column("sku", String(255), primary_key=True), - Column("qty", Integer), + Column("id", Integer, primary_key=True, autoincrement=True), + Column("sku", String(255)), + Column("qty", Integer, nullable=False), + Column("orderid", String(255)), ) batches = Table( "batches", metadata, - Column("reference", String(255), primary_key=True), - Column("sku", String(255), primary_key=True), - Column("_purchased_qty", Integer), - Column("eta", Date), + Column("id", Integer, primary_key=True, autoincrement=True), + Column("reference", String(255)), + Column("sku", String(255)), + Column("_purchased_quantity", Integer, nullable=False), + Column("eta", Date, nullable=True), +) + +allocations = Table( + "allocations", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("orderline_id", ForeignKey("order_lines.id")), + Column("batch_id", ForeignKey("batches.id")), ) def start_mappers(): - mapper(model.OrderLine, order_lines) - mapper(model.Batch, batches) + lines_mapper = mapper(model.OrderLine, order_lines) + mapper( + model.Batch, + batches, + properties={ + "_allocations": relationship( + lines_mapper, secondary=allocations, collection_class=set, + ) + }, + ) diff --git a/test_orm.py b/test_orm.py index 9dc98719f..6626a13cc 100644 --- a/test_orm.py +++ b/test_orm.py @@ -26,10 +26,14 @@ def test_orderline_mapper_can_save_lines(session): assert rows == [("order1", "DECORATIVE-WIDGET", 12)] -def test_batches(session): - session.execute('INSERT INTO "batches" VALUES ("batch1", "sku1", 100, null)') +def test_retrieving_batches(session): session.execute( - 'INSERT INTO "batches" VALUES ("batch2", "sku2", 200, "2011-04-11")' + "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" + ' VALUES ("batch1", "sku1", 100, null)' + ) + session.execute( + "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" + ' VALUES ("batch2", "sku2", 200, "2011-04-11")' ) expected = [ model.Batch("batch1", "sku1", 100, eta=None), @@ -37,3 +41,51 @@ def test_batches(session): ] assert session.query(model.Batch).all() == expected + + +def test_saving_batches(session): + batch = model.Batch("batch1", "sku1", 100, eta=None) + session.add(batch) + session.commit() + rows = list( + session.execute( + 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' + ) + ) + assert rows == [("batch1", "sku1", 100, None)] + + +def test_saving_allocations(session): + batch = model.Batch("batch1", "sku1", 100, eta=None) + line = model.OrderLine("order1", "sku1", 10) + batch.allocate(line) + session.add(batch) + session.commit() + rows = list(session.execute('SELECT orderline_id, batch_id FROM "allocations"')) + assert rows == [(batch.id, line.id)] + + +def test_retrieving_allocations(session): + session.execute( + 'INSERT INTO order_lines (orderid, sku, qty) VALUES ("order1", "sku1", 12)' + ) + [[olid]] = session.execute( + "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", + dict(orderid="order1", sku="sku1"), + ) + session.execute( + "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" + ' VALUES ("batch1", "sku1", 100, null)' + ) + [[bid]] = session.execute( + "SELECT id FROM batches WHERE reference=:ref AND sku=:sku", + dict(ref="batch1", sku="sku1"), + ) + session.execute( + "INSERT INTO allocations (orderline_id, batch_id) VALUES (:olid, :bid)", + dict(olid=olid, bid=bid), + ) + + batch = session.query(model.Batch).one() + + assert batch._allocations == {model.OrderLine("order1", "sku1", 12)} From 8dae12639c6247185877d199e3055d0cacb126ea Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 01:10:30 +0000 Subject: [PATCH 002/146] repository tests --- test_orm.py | 8 +++--- test_repository.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 test_repository.py diff --git a/test_orm.py b/test_orm.py index 6626a13cc..07a634914 100644 --- a/test_orm.py +++ b/test_orm.py @@ -47,12 +47,10 @@ def test_saving_batches(session): batch = model.Batch("batch1", "sku1", 100, eta=None) session.add(batch) session.commit() - rows = list( - session.execute( - 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' - ) + rows = session.execute( + 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' ) - assert rows == [("batch1", "sku1", 100, None)] + assert list(rows) == [("batch1", "sku1", 100, None)] def test_saving_allocations(session): diff --git a/test_repository.py b/test_repository.py new file mode 100644 index 000000000..7c668a39a --- /dev/null +++ b/test_repository.py @@ -0,0 +1,67 @@ +# pylint: disable=protected-access +import model +import repository + + +def test_repository_can_save_a_batch(session): + batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None) + + repo = repository.SqlAlchemyRepository(session) + repo.add(batch) + session.commit() + + rows = session.execute( + 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' + ) + assert list(rows) == [("batch1", "RUSTY-SOAPDISH", 100, None)] + + +def insert_order_line(session): + session.execute( + "INSERT INTO order_lines (orderid, sku, qty)" + ' VALUES ("order1", "GENERIC-SOFA", 12)' + ) + [[orderline_id]] = session.execute( + "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", + dict(orderid="order1", sku="GENERIC-SOFA"), + ) + return orderline_id + + +def insert_batch(session, batch_id): + session.execute( + "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" + ' VALUES (:batch_id, "GENERIC-SOFA", 100, null)', + dict(batch_id=batch_id), + ) + [[batch_id]] = session.execute( + 'SELECT id FROM batches WHERE reference=:batch_id AND sku="GENERIC-SOFA"', + dict(batch_id=batch_id), + ) + return batch_id + + +def insert_allocation(session, orderline_id, batch_id): + session.execute( + "INSERT INTO allocations (orderline_id, batch_id)" + " VALUES (:orderline_id, :batch_id)", + dict(orderline_id=orderline_id, batch_id=batch_id), + ) + + +def test_repository_can_retrieve_a_batch_with_allocations(session): + orderline_id = insert_order_line(session) + batch1_id = insert_batch(session, "batch1") + insert_batch(session, "batch2") + insert_allocation(session, orderline_id, batch1_id) + + repo = repository.SqlAlchemyRepository(session) + retrieved = repo.get("batch1") + + expected = model.Batch("batch1", "GENERIC-SOFA", 100, eta=None) + assert retrieved == expected # Batch.__eq__ only compares reference + assert retrieved.sku == expected.sku + assert retrieved._purchased_quantity == expected._purchased_quantity + assert retrieved._allocations == { + model.OrderLine("order1", "GENERIC-SOFA", 12), + } From b635a154d8ddf58a3bbf40a1d5c96c65430bc49d Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 01:10:39 +0000 Subject: [PATCH 003/146] repository for batches [chapter_02_repository_ends] --- repository.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 repository.py diff --git a/repository.py b/repository.py new file mode 100644 index 000000000..73b85e314 --- /dev/null +++ b/repository.py @@ -0,0 +1,26 @@ +import abc +import model + + +class AbstractRepository(abc.ABC): + @abc.abstractmethod + def add(self, batch: model.Batch): + raise NotImplementedError + + @abc.abstractmethod + def get(self, reference) -> model.Batch: + raise NotImplementedError + + +class SqlAlchemyRepository(AbstractRepository): + def __init__(self, session): + self.session = session + + def add(self, batch): + self.session.add(batch) + + def get(self, reference): + return self.session.query(model.Batch).filter_by(reference=reference).one() + + def list(self): + return self.session.query(model.Batch).all() From fda077db691e09e69eb54e7fbf29feb732da37b5 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 04:10:52 +0000 Subject: [PATCH 004/146] first api tests [first_api_test] --- config.py | 15 +++++++++++++ conftest.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++ test_api.py | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 config.py create mode 100644 test_api.py diff --git a/config.py b/config.py new file mode 100644 index 000000000..f3b55cc90 --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +import os + + +def get_postgres_uri(): + host = os.environ.get("DB_HOST", "localhost") + port = 54321 if host == "localhost" else 5432 + password = os.environ.get("DB_PASSWORD", "abc123") + user, db_name = "allocation", "allocation" + return f"postgresql://{user}:{password}@{host}:{port}/{db_name}" + + +def get_api_url(): + host = os.environ.get("API_HOST", "localhost") + port = 5005 if host == "localhost" else 80 + return f"http://{host}:{port}" diff --git a/conftest.py b/conftest.py index 9f7b74b08..f43dd1145 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,13 @@ +# pylint: disable=redefined-outer-name +import time +from pathlib import Path + import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, clear_mappers from orm import metadata, start_mappers +from config import get_postgres_uri @pytest.fixture @@ -17,3 +22,60 @@ def session(in_memory_db): start_mappers() yield sessionmaker(bind=in_memory_db)() clear_mappers() + + +@pytest.fixture(scope="session") +def postgres_db(): + engine = create_engine(get_postgres_uri()) + metadata.create_all(engine) + return engine + + +@pytest.fixture +def postgres_session(postgres_db): + start_mappers() + yield sessionmaker(bind=postgres_db)() + clear_mappers() + + +@pytest.fixture +def add_stock(postgres_session): + batches_added = set() + skus_added = set() + + def _add_stock(lines): + for ref, sku, qty, eta in lines: + postgres_session.execute( + "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" + " VALUES (:ref, :sku, :qty, :eta)", + dict(ref=ref, sku=sku, qty=qty, eta=eta), + ) + [[batch_id]] = postgres_session.execute( + "SELECT id FROM batches WHERE reference=:ref AND sku=:sku", + dict(ref=ref, sku=sku), + ) + batches_added.add(batch_id) + skus_added.add(sku) + postgres_session.commit() + + yield _add_stock + + for batch_id in batches_added: + postgres_session.execute( + "DELETE FROM allocations WHERE batch_id=:batch_id", + dict(batch_id=batch_id), + ) + postgres_session.execute( + "DELETE FROM batches WHERE id=:batch_id", dict(batch_id=batch_id), + ) + for sku in skus_added: + postgres_session.execute( + "DELETE FROM order_lines WHERE sku=:sku", dict(sku=sku), + ) + postgres_session.commit() + + +@pytest.fixture +def restart_api(): + (Path(__file__).parent / "flask_app.py").touch() + time.sleep(0.3) diff --git a/test_api.py b/test_api.py new file mode 100644 index 000000000..04baaf437 --- /dev/null +++ b/test_api.py @@ -0,0 +1,43 @@ +import uuid +import pytest +import requests + +import config + + +def random_suffix(): + return uuid.uuid4().hex[:6] + + +def random_sku(name=""): + return f"sku-{name}-{random_suffix()}" + + +def random_batchref(name=""): + return f"batch-{name}-{random_suffix()}" + + +def random_orderid(name=""): + return f"order-{name}-{random_suffix()}" + + +@pytest.mark.usefixtures("restart_api") +def test_api_returns_allocation(add_stock): + sku, othersku = random_sku(), random_sku("other") + earlybatch = random_batchref(1) + laterbatch = random_batchref(2) + otherbatch = random_batchref(3) + add_stock( + [ + (laterbatch, sku, 100, "2011-01-02"), + (earlybatch, sku, 100, "2011-01-01"), + (otherbatch, othersku, 100, None), + ] + ) + data = {"orderid": random_orderid(), "sku": sku, "qty": 3} + url = config.get_api_url() + + r = requests.post(f"{url}/allocate", json=data) + + assert r.status_code == 201 + assert r.json()["batchref"] == earlybatch From 15902a6f69b914015c5d5a4b90cc4d04c5e046d7 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 11:30:31 +0000 Subject: [PATCH 005/146] all the dockerfile gubbins --- Dockerfile | 10 ++++++++++ Makefile | 21 ++++++++++++++++++--- docker-compose.yml | 26 ++++++++++++++++++++++++++ requirements.txt | 5 +++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..901a93fcd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.9-slim-buster + +COPY requirements.txt /tmp +RUN pip install -r /tmp/requirements.txt + +RUN mkdir -p /code +COPY *.py /code/ +WORKDIR /code +ENV FLASK_APP=flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 +CMD flask run --host=0.0.0.0 --port=80 diff --git a/Makefile b/Makefile index 77cbd229f..1e2481876 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,23 @@ +# these will speed up builds, for docker-compose >= 1.25 +export COMPOSE_DOCKER_CLI_BUILD=1 +export DOCKER_BUILDKIT=1 + +all: down build up test + +build: + docker-compose build + +up: + docker-compose up -d app + +down: + docker-compose down + +logs: + docker-compose logs app | tail -100 + test: pytest --tb=short -watch-tests: - ls *.py | entr pytest --tb=short - black: black -l 86 $$(find * -name '*.py') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..313cf9463 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3" +services: + + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + - postgres + environment: + - DB_HOST=postgres + - DB_PASSWORD=abc123 + volumes: + - ./:/code + ports: + - "5005:80" + + + postgres: + image: postgres:9.6 + environment: + - POSTGRES_USER=allocation + - POSTGRES_PASSWORD=abc123 + ports: + - "54321:5432" + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..01a974f5d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pytest +sqlalchemy +flask +psycopg2-binary +requests From 8fc086ef836fd5fcf726d37e285e0e9d4ace49f4 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 04:21:51 +0000 Subject: [PATCH 006/146] first cut of flask app [first_cut_flask_app] --- flask_app.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 flask_app.py diff --git a/flask_app.py b/flask_app.py new file mode 100644 index 000000000..21be2902f --- /dev/null +++ b/flask_app.py @@ -0,0 +1,26 @@ +from flask import Flask, request +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import config +import model +import orm +import repository + + +orm.start_mappers() +get_session = sessionmaker(bind=create_engine(config.get_postgres_uri())) +app = Flask(__name__) + + +@app.route("/allocate", methods=["POST"]) +def allocate_endpoint(): + session = get_session() + batches = repository.SqlAlchemyRepository(session).list() + line = model.OrderLine( + request.json["orderid"], request.json["sku"], request.json["qty"], + ) + + batchref = model.allocate(line, batches) + + return {"batchref": batchref}, 201 From 158e760fed9a7d530d34b3acf4e4159477eac73d Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 11:58:59 +0000 Subject: [PATCH 007/146] test persistence by double-allocating. [second_api_test] --- test_api.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test_api.py b/test_api.py index 04baaf437..9fd51a344 100644 --- a/test_api.py +++ b/test_api.py @@ -41,3 +41,26 @@ def test_api_returns_allocation(add_stock): assert r.status_code == 201 assert r.json()["batchref"] == earlybatch + + +@pytest.mark.usefixtures("restart_api") +def test_allocations_are_persisted(add_stock): + sku = random_sku() + batch1, batch2 = random_batchref(1), random_batchref(2) + order1, order2 = random_orderid(1), random_orderid(2) + add_stock( + [(batch1, sku, 10, "2011-01-01"), (batch2, sku, 10, "2011-01-02"),] + ) + line1 = {"orderid": order1, "sku": sku, "qty": 10} + line2 = {"orderid": order2, "sku": sku, "qty": 10} + url = config.get_api_url() + + # first order uses up all stock in batch 1 + r = requests.post(f"{url}/allocate", json=line1) + assert r.status_code == 201 + assert r.json()["batchref"] == batch1 + + # second order should go to batch 2 + r = requests.post(f"{url}/allocate", json=line2) + assert r.status_code == 201 + assert r.json()["batchref"] == batch2 From d0ee7ada3dbb034dbe5fce7a06e209486e88cccc Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 11:59:20 +0000 Subject: [PATCH 008/146] need to commit [flask_commit] --- flask_app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flask_app.py b/flask_app.py index 21be2902f..5ae4320e7 100644 --- a/flask_app.py +++ b/flask_app.py @@ -23,4 +23,5 @@ def allocate_endpoint(): batchref = model.allocate(line, batches) + session.commit() return {"batchref": batchref}, 201 From f20c4792525ee9b138bd6586eb88359d1d70a8db Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 12:10:34 +0000 Subject: [PATCH 009/146] test some 400 error cases [test_error_cases] --- test_api.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test_api.py b/test_api.py index 9fd51a344..2061119d7 100644 --- a/test_api.py +++ b/test_api.py @@ -64,3 +64,26 @@ def test_allocations_are_persisted(add_stock): r = requests.post(f"{url}/allocate", json=line2) assert r.status_code == 201 assert r.json()["batchref"] == batch2 + + +@pytest.mark.usefixtures("restart_api") +def test_400_message_for_out_of_stock(add_stock): + sku, smalL_batch, large_order = random_sku(), random_batchref(), random_orderid() + add_stock( + [(smalL_batch, sku, 10, "2011-01-01"),] + ) + data = {"orderid": large_order, "sku": sku, "qty": 20} + url = config.get_api_url() + r = requests.post(f"{url}/allocate", json=data) + assert r.status_code == 400 + assert r.json()["message"] == f"Out of stock for sku {sku}" + + +@pytest.mark.usefixtures("restart_api") +def test_400_message_for_invalid_sku(): + unknown_sku, orderid = random_sku(), random_orderid() + data = {"orderid": orderid, "sku": unknown_sku, "qty": 20} + url = config.get_api_url() + r = requests.post(f"{url}/allocate", json=data) + assert r.status_code == 400 + assert r.json()["message"] == f"Invalid sku {unknown_sku}" From 80dece9d1736a3abf2ae605af25606c85b39bccb Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 12:17:15 +0000 Subject: [PATCH 010/146] flask now does error handling [flask_error_handling] --- flask_app.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/flask_app.py b/flask_app.py index 5ae4320e7..3ffa1f4f8 100644 --- a/flask_app.py +++ b/flask_app.py @@ -13,6 +13,10 @@ app = Flask(__name__) +def is_valid_sku(sku, batches): + return sku in {b.sku for b in batches} + + @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): session = get_session() @@ -21,7 +25,13 @@ def allocate_endpoint(): request.json["orderid"], request.json["sku"], request.json["qty"], ) - batchref = model.allocate(line, batches) + if not is_valid_sku(line.sku, batches): + return {"message": f"Invalid sku {line.sku}"}, 400 + + try: + batchref = model.allocate(line, batches) + except model.OutOfStock as e: + return {"message": str(e)}, 400 session.commit() return {"batchref": batchref}, 201 From a1903fad959426a0d7bee8c186b0bfb73a2755d2 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 13:44:27 +0000 Subject: [PATCH 011/146] first tests for the services layer [first_services_tests] --- test_services.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test_services.py diff --git a/test_services.py b/test_services.py new file mode 100644 index 000000000..92dcd5175 --- /dev/null +++ b/test_services.py @@ -0,0 +1,22 @@ +import pytest + +import model +import services + + +def test_returns_allocation(): + line = model.OrderLine("o1", "COMPLICATED-LAMP", 10) + batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None) + repo = FakeRepository([batch]) + + result = services.allocate(line, repo, FakeSession()) + assert result == "b1" + + +def test_error_for_invalid_sku(): + line = model.OrderLine("o1", "NONEXISTENTSKU", 10) + batch = model.Batch("b1", "AREALSKU", 100, eta=None) + repo = FakeRepository([batch]) + + with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): + services.allocate(line, repo, FakeSession()) From 9fe9a3de7ea0afabf652bacf035e86b39ac02d6c Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 00:54:06 +0000 Subject: [PATCH 012/146] FakeRepository [fake_repo] --- test_services.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test_services.py b/test_services.py index 92dcd5175..fb15f4280 100644 --- a/test_services.py +++ b/test_services.py @@ -1,9 +1,23 @@ import pytest - import model +import repository import services +class FakeRepository(repository.AbstractRepository): + def __init__(self, batches): + self._batches = set(batches) + + def add(self, batch): + self._batches.add(batch) + + def get(self, reference): + return next(b for b in self._batches if b.reference == reference) + + def list(self): + return list(self._batches) + + def test_returns_allocation(): line = model.OrderLine("o1", "COMPLICATED-LAMP", 10) batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None) From 15b9bf79d134384a1cac645eba4b055f9bf4ac1d Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 00:54:48 +0000 Subject: [PATCH 013/146] FakeSession [fake_session] --- test_services.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test_services.py b/test_services.py index fb15f4280..380f46c9d 100644 --- a/test_services.py +++ b/test_services.py @@ -18,6 +18,13 @@ def list(self): return list(self._batches) +class FakeSession: + committed = False + + def commit(self): + self.committed = True + + def test_returns_allocation(): line = model.OrderLine("o1", "COMPLICATED-LAMP", 10) batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None) From eded7ebf6cc5f0fb05dc547af6daee6549e171ca Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:52:08 +0100 Subject: [PATCH 014/146] test commmits [second_services_test] --- test_services.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test_services.py b/test_services.py index 380f46c9d..abb13a1ef 100644 --- a/test_services.py +++ b/test_services.py @@ -41,3 +41,13 @@ def test_error_for_invalid_sku(): with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): services.allocate(line, repo, FakeSession()) + + +def test_commits(): + line = model.OrderLine("o1", "OMINOUS-MIRROR", 10) + batch = model.Batch("b1", "OMINOUS-MIRROR", 100, eta=None) + repo = FakeRepository([batch]) + session = FakeSession() + + services.allocate(line, repo, session) + assert session.committed is True From d1e2e6e59fb8fe4020bec8425bdbd30b9648ff08 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 13:44:44 +0000 Subject: [PATCH 015/146] services layer with valid-sku check [service_function] --- services.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 services.py diff --git a/services.py b/services.py new file mode 100644 index 000000000..cb007393f --- /dev/null +++ b/services.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import model +from model import OrderLine +from repository import AbstractRepository + + +class InvalidSku(Exception): + pass + + +def is_valid_sku(sku, batches): + return sku in {b.sku for b in batches} + + +def allocate(line: OrderLine, repo: AbstractRepository, session) -> str: + batches = repo.list() + if not is_valid_sku(line.sku, batches): + raise InvalidSku(f"Invalid sku {line.sku}") + batchref = model.allocate(line, batches) + session.commit() + return batchref From cf2f52ba9696b7fa2336b46a55c6f11f180fa203 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 14:33:19 +0000 Subject: [PATCH 016/146] modify flask app to use service layer [flask_app_using_service_layer] --- flask_app.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/flask_app.py b/flask_app.py index 3ffa1f4f8..3412cbe94 100644 --- a/flask_app.py +++ b/flask_app.py @@ -6,6 +6,7 @@ import model import orm import repository +import services orm.start_mappers() @@ -13,25 +14,17 @@ app = Flask(__name__) -def is_valid_sku(sku, batches): - return sku in {b.sku for b in batches} - - @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): session = get_session() - batches = repository.SqlAlchemyRepository(session).list() + repo = repository.SqlAlchemyRepository(session) line = model.OrderLine( request.json["orderid"], request.json["sku"], request.json["qty"], ) - if not is_valid_sku(line.sku, batches): - return {"message": f"Invalid sku {line.sku}"}, 400 - try: - batchref = model.allocate(line, batches) - except model.OutOfStock as e: + batchref = services.allocate(line, repo, session) + except (model.OutOfStock, services.InvalidSku) as e: return {"message": str(e)}, 400 - session.commit() return {"batchref": batchref}, 201 From b537364dcb7b22e0348fda4b66219e3bfe4d259b Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 19 Mar 2019 14:48:36 +0000 Subject: [PATCH 017/146] strip out unecessary tests from e2e layer [fewer_e2e_tests] --- test_api.py | 40 ++-------------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/test_api.py b/test_api.py index 2061119d7..5386ab881 100644 --- a/test_api.py +++ b/test_api.py @@ -22,7 +22,7 @@ def random_orderid(name=""): @pytest.mark.usefixtures("restart_api") -def test_api_returns_allocation(add_stock): +def test_happy_path_returns_201_and_allocated_batch(add_stock): sku, othersku = random_sku(), random_sku("other") earlybatch = random_batchref(1) laterbatch = random_batchref(2) @@ -44,43 +44,7 @@ def test_api_returns_allocation(add_stock): @pytest.mark.usefixtures("restart_api") -def test_allocations_are_persisted(add_stock): - sku = random_sku() - batch1, batch2 = random_batchref(1), random_batchref(2) - order1, order2 = random_orderid(1), random_orderid(2) - add_stock( - [(batch1, sku, 10, "2011-01-01"), (batch2, sku, 10, "2011-01-02"),] - ) - line1 = {"orderid": order1, "sku": sku, "qty": 10} - line2 = {"orderid": order2, "sku": sku, "qty": 10} - url = config.get_api_url() - - # first order uses up all stock in batch 1 - r = requests.post(f"{url}/allocate", json=line1) - assert r.status_code == 201 - assert r.json()["batchref"] == batch1 - - # second order should go to batch 2 - r = requests.post(f"{url}/allocate", json=line2) - assert r.status_code == 201 - assert r.json()["batchref"] == batch2 - - -@pytest.mark.usefixtures("restart_api") -def test_400_message_for_out_of_stock(add_stock): - sku, smalL_batch, large_order = random_sku(), random_batchref(), random_orderid() - add_stock( - [(smalL_batch, sku, 10, "2011-01-01"),] - ) - data = {"orderid": large_order, "sku": sku, "qty": 20} - url = config.get_api_url() - r = requests.post(f"{url}/allocate", json=data) - assert r.status_code == 400 - assert r.json()["message"] == f"Out of stock for sku {sku}" - - -@pytest.mark.usefixtures("restart_api") -def test_400_message_for_invalid_sku(): +def test_unhappy_path_returns_400_and_error_message(): unknown_sku, orderid = random_sku(), random_orderid() data = {"orderid": orderid, "sku": unknown_sku, "qty": 20} url = config.get_api_url() From 952a3d2f53ec56ca320b8415097350799f38de10 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 26 Mar 2019 21:21:17 +0000 Subject: [PATCH 018/146] fix conftest waits and travis config [chapter_04_service_layer_ends] --- .travis.yml | 4 ++-- conftest.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8a74f94bf..f601680f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,10 @@ language: python python: 3.8 install: -- pip3 install sqlalchemy +- pip3 install -r requirements.txt script: -- make test +- make all branches: except: diff --git a/conftest.py b/conftest.py index f43dd1145..da82d88a6 100644 --- a/conftest.py +++ b/conftest.py @@ -3,11 +3,14 @@ from pathlib import Path import pytest +import requests +from requests.exceptions import ConnectionError +from sqlalchemy.exc import OperationalError from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, clear_mappers from orm import metadata, start_mappers -from config import get_postgres_uri +import config @pytest.fixture @@ -24,9 +27,31 @@ def session(in_memory_db): clear_mappers() +def wait_for_postgres_to_come_up(engine): + deadline = time.time() + 10 + while time.time() < deadline: + try: + return engine.connect() + except OperationalError: + time.sleep(0.5) + pytest.fail("Postgres never came up") + + +def wait_for_webapp_to_come_up(): + deadline = time.time() + 10 + url = config.get_api_url() + while time.time() < deadline: + try: + return requests.get(url) + except ConnectionError: + time.sleep(0.5) + pytest.fail("API never came up") + + @pytest.fixture(scope="session") def postgres_db(): - engine = create_engine(get_postgres_uri()) + engine = create_engine(config.get_postgres_uri()) + wait_for_postgres_to_come_up(engine) metadata.create_all(engine) return engine @@ -78,4 +103,5 @@ def _add_stock(lines): @pytest.fixture def restart_api(): (Path(__file__).parent / "flask_app.py").touch() - time.sleep(0.3) + time.sleep(0.5) + wait_for_webapp_to_come_up() From bdf8fe94baffb69097e727135b0c6ce77ac87674 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 23 Dec 2019 16:37:06 +0000 Subject: [PATCH 019/146] move to a more nested folder structure --- orm.py => adapters/orm.py | 0 repository.py => adapters/repository.py | 0 model.py => domain/model.py | 0 flask_app.py => entrypoints/flask_app.py | 0 services.py => service_layer/services.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename orm.py => adapters/orm.py (100%) rename repository.py => adapters/repository.py (100%) rename model.py => domain/model.py (100%) rename flask_app.py => entrypoints/flask_app.py (100%) rename services.py => service_layer/services.py (100%) diff --git a/orm.py b/adapters/orm.py similarity index 100% rename from orm.py rename to adapters/orm.py diff --git a/repository.py b/adapters/repository.py similarity index 100% rename from repository.py rename to adapters/repository.py diff --git a/model.py b/domain/model.py similarity index 100% rename from model.py rename to domain/model.py diff --git a/flask_app.py b/entrypoints/flask_app.py similarity index 100% rename from flask_app.py rename to entrypoints/flask_app.py diff --git a/services.py b/service_layer/services.py similarity index 100% rename from services.py rename to service_layer/services.py From 1bb572af7f8fce0a5236fc005c4d5bc305464c94 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 31 Dec 2019 13:18:04 +0000 Subject: [PATCH 020/146] nest the tests too --- test_api.py => tests/e2e/test_api.py | 0 test_orm.py => tests/integration/test_orm.py | 0 test_repository.py => tests/integration/test_repository.py | 0 test_allocate.py => tests/unit/test_allocate.py | 0 test_batches.py => tests/unit/test_batches.py | 0 test_services.py => tests/unit/test_services.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename test_api.py => tests/e2e/test_api.py (100%) rename test_orm.py => tests/integration/test_orm.py (100%) rename test_repository.py => tests/integration/test_repository.py (100%) rename test_allocate.py => tests/unit/test_allocate.py (100%) rename test_batches.py => tests/unit/test_batches.py (100%) rename test_services.py => tests/unit/test_services.py (100%) diff --git a/test_api.py b/tests/e2e/test_api.py similarity index 100% rename from test_api.py rename to tests/e2e/test_api.py diff --git a/test_orm.py b/tests/integration/test_orm.py similarity index 100% rename from test_orm.py rename to tests/integration/test_orm.py diff --git a/test_repository.py b/tests/integration/test_repository.py similarity index 100% rename from test_repository.py rename to tests/integration/test_repository.py diff --git a/test_allocate.py b/tests/unit/test_allocate.py similarity index 100% rename from test_allocate.py rename to tests/unit/test_allocate.py diff --git a/test_batches.py b/tests/unit/test_batches.py similarity index 100% rename from test_batches.py rename to tests/unit/test_batches.py diff --git a/test_services.py b/tests/unit/test_services.py similarity index 100% rename from test_services.py rename to tests/unit/test_services.py From db8921818a5aaeb3495f0405fa089648ade4b306 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 31 Dec 2019 12:44:27 +0000 Subject: [PATCH 021/146] get all tests passing --- Dockerfile | 2 +- adapters/__init__.py | 0 adapters/orm.py | 2 +- adapters/repository.py | 2 +- domain/__init__.py | 0 entrypoints/__init__.py | 0 entrypoints/flask_app.py | 7 +++---- service_layer/__init__.py | 0 service_layer/services.py | 6 +++--- tests/__init__.py | 0 conftest.py => tests/conftest.py | 4 ++-- tests/integration/test_orm.py | 2 +- tests/integration/test_repository.py | 4 ++-- tests/unit/test_allocate.py | 2 +- tests/unit/test_batches.py | 2 +- tests/unit/test_services.py | 6 +++--- 16 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 adapters/__init__.py create mode 100644 domain/__init__.py create mode 100644 entrypoints/__init__.py create mode 100644 service_layer/__init__.py create mode 100644 tests/__init__.py rename conftest.py => tests/conftest.py (95%) diff --git a/Dockerfile b/Dockerfile index 901a93fcd..d4803afbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ RUN pip install -r /tmp/requirements.txt RUN mkdir -p /code COPY *.py /code/ WORKDIR /code -ENV FLASK_APP=flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 +ENV FLASK_APP=entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 CMD flask run --host=0.0.0.0 --port=80 diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/adapters/orm.py b/adapters/orm.py index 3e6b4a70a..8499c2dc4 100644 --- a/adapters/orm.py +++ b/adapters/orm.py @@ -1,7 +1,7 @@ from sqlalchemy import Table, MetaData, Column, Integer, String, Date, ForeignKey from sqlalchemy.orm import mapper, relationship -import model +from domain import model metadata = MetaData() diff --git a/adapters/repository.py b/adapters/repository.py index 73b85e314..7e7434b97 100644 --- a/adapters/repository.py +++ b/adapters/repository.py @@ -1,5 +1,5 @@ import abc -import model +from domain import model class AbstractRepository(abc.ABC): diff --git a/domain/__init__.py b/domain/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/entrypoints/__init__.py b/entrypoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/entrypoints/flask_app.py b/entrypoints/flask_app.py index 3412cbe94..62e40d44d 100644 --- a/entrypoints/flask_app.py +++ b/entrypoints/flask_app.py @@ -3,10 +3,9 @@ from sqlalchemy.orm import sessionmaker import config -import model -import orm -import repository -import services +from domain import model +from adapters import orm, repository +from service_layer import services orm.start_mappers() diff --git a/service_layer/__init__.py b/service_layer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/service_layer/services.py b/service_layer/services.py index cb007393f..7c7ff8f18 100644 --- a/service_layer/services.py +++ b/service_layer/services.py @@ -1,8 +1,8 @@ from __future__ import annotations -import model -from model import OrderLine -from repository import AbstractRepository +from domain import model +from domain.model import OrderLine +from adapters.repository import AbstractRepository class InvalidSku(Exception): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conftest.py b/tests/conftest.py similarity index 95% rename from conftest.py rename to tests/conftest.py index da82d88a6..11e66b353 100644 --- a/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, clear_mappers -from orm import metadata, start_mappers +from adapters.orm import metadata, start_mappers import config @@ -102,6 +102,6 @@ def _add_stock(lines): @pytest.fixture def restart_api(): - (Path(__file__).parent / "flask_app.py").touch() + (Path(__file__).parent / "../entrypoints/flask_app.py").touch() time.sleep(0.5) wait_for_webapp_to_come_up() diff --git a/tests/integration/test_orm.py b/tests/integration/test_orm.py index 07a634914..3a551977a 100644 --- a/tests/integration/test_orm.py +++ b/tests/integration/test_orm.py @@ -1,4 +1,4 @@ -import model +from domain import model from datetime import date diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 7c668a39a..3fa66d9f1 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -1,6 +1,6 @@ # pylint: disable=protected-access -import model -import repository +from domain import model +from adapters import repository def test_repository_can_save_a_batch(session): diff --git a/tests/unit/test_allocate.py b/tests/unit/test_allocate.py index 7e1893070..4e434e28d 100644 --- a/tests/unit/test_allocate.py +++ b/tests/unit/test_allocate.py @@ -1,6 +1,6 @@ from datetime import date, timedelta import pytest -from model import allocate, OrderLine, Batch, OutOfStock +from domain.model import allocate, OrderLine, Batch, OutOfStock today = date.today() tomorrow = today + timedelta(days=1) diff --git a/tests/unit/test_batches.py b/tests/unit/test_batches.py index 62288330a..7feda3d70 100644 --- a/tests/unit/test_batches.py +++ b/tests/unit/test_batches.py @@ -1,5 +1,5 @@ from datetime import date -from model import Batch, OrderLine +from domain.model import Batch, OrderLine def test_allocating_to_a_batch_reduces_the_available_quantity(): diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index abb13a1ef..64ef3158a 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,7 +1,7 @@ import pytest -import model -import repository -import services +from domain import model +from adapters import repository +from service_layer import services class FakeRepository(repository.AbstractRepository): From c5822aa764b767efabc4975060b9533cef094fbf Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:11:47 +0100 Subject: [PATCH 022/146] rewrite service layer to take primitives [service_takes_primitives] --- service_layer/services.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/service_layer/services.py b/service_layer/services.py index 7c7ff8f18..464cdb7c7 100644 --- a/service_layer/services.py +++ b/service_layer/services.py @@ -13,7 +13,11 @@ def is_valid_sku(sku, batches): return sku in {b.sku for b in batches} -def allocate(line: OrderLine, repo: AbstractRepository, session) -> str: +def allocate( + orderid: str, sku: str, qty: int, + repo: AbstractRepository, session +) -> str: + line = OrderLine(orderid, sku, qty) batches = repo.list() if not is_valid_sku(line.sku, batches): raise InvalidSku(f"Invalid sku {line.sku}") From f341bee916afc28fbc23d50be2d1979dadc9978d Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:12:16 +0100 Subject: [PATCH 023/146] services tests partially converted to primitives [tests_call_with_primitives] --- tests/unit/test_services.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 64ef3158a..9c114ed3a 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -26,28 +26,25 @@ def commit(self): def test_returns_allocation(): - line = model.OrderLine("o1", "COMPLICATED-LAMP", 10) - batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None) + batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None) repo = FakeRepository([batch]) - result = services.allocate(line, repo, FakeSession()) - assert result == "b1" + result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) + assert result == "batch1" def test_error_for_invalid_sku(): - line = model.OrderLine("o1", "NONEXISTENTSKU", 10) batch = model.Batch("b1", "AREALSKU", 100, eta=None) repo = FakeRepository([batch]) with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - services.allocate(line, repo, FakeSession()) + services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession()) def test_commits(): - line = model.OrderLine("o1", "OMINOUS-MIRROR", 10) batch = model.Batch("b1", "OMINOUS-MIRROR", 100, eta=None) repo = FakeRepository([batch]) session = FakeSession() - services.allocate(line, repo, session) + services.allocate("o1", "OMINOUS-MIRROR", 10, repo, session) assert session.committed is True From 1e1f2381e348b29e04a9812114feb8ca6f92bbc9 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:59:52 +0100 Subject: [PATCH 024/146] fixture function for batches [services_factory_function] --- tests/unit/test_services.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 9c114ed3a..0d404f4a4 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -5,6 +5,10 @@ class FakeRepository(repository.AbstractRepository): + @staticmethod + def for_batch(ref, sku, qty, eta=None): + return FakeRepository([model.Batch(ref, sku, qty, eta),]) + def __init__(self, batches): self._batches = set(batches) @@ -26,25 +30,19 @@ def commit(self): def test_returns_allocation(): - batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None) - repo = FakeRepository([batch]) - + repo = FakeRepository.for_batch("batch1", "COMPLICATED-LAMP", 100, eta=None) result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) assert result == "batch1" def test_error_for_invalid_sku(): - batch = model.Batch("b1", "AREALSKU", 100, eta=None) - repo = FakeRepository([batch]) - + repo = FakeRepository.for_batch("b1", "AREALSKU", 100, eta=None) with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession()) def test_commits(): - batch = model.Batch("b1", "OMINOUS-MIRROR", 100, eta=None) - repo = FakeRepository([batch]) + repo = FakeRepository.for_batch("b1", "OMINOUS-MIRROR", 100, eta=None) session = FakeSession() - services.allocate("o1", "OMINOUS-MIRROR", 10, repo, session) assert session.committed is True From 5c953dada5e1a136ec21f8eaf15a588e32d4b858 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:37:17 +0100 Subject: [PATCH 025/146] new service to add a batch [add_batch_service] --- service_layer/services.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/service_layer/services.py b/service_layer/services.py index 464cdb7c7..36c7bd9ac 100644 --- a/service_layer/services.py +++ b/service_layer/services.py @@ -1,4 +1,6 @@ from __future__ import annotations +from typing import Optional +from datetime import date from domain import model from domain.model import OrderLine @@ -13,6 +15,14 @@ def is_valid_sku(sku, batches): return sku in {b.sku for b in batches} +def add_batch( + ref: str, sku: str, qty: int, eta: Optional[date], + repo: AbstractRepository, session, +) -> None: + repo.add(model.Batch(ref, sku, qty, eta)) + session.commit() + + def allocate( orderid: str, sku: str, qty: int, repo: AbstractRepository, session From 262eec06146958ed6b8a47d700f9c32fd92638ce Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:37:39 +0100 Subject: [PATCH 026/146] service-layer test for add batch [test_add_batch] --- tests/unit/test_services.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 0d404f4a4..f5574ef86 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -29,6 +29,13 @@ def commit(self): self.committed = True +def test_add_batch(): + repo, session = FakeRepository([]), FakeSession() + services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session) + assert repo.get("b1") is not None + assert session.committed + + def test_returns_allocation(): repo = FakeRepository.for_batch("batch1", "COMPLICATED-LAMP", 100, eta=None) result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) From c8fbb60a7bbc918e5ea9a3f7ea70d35118c41620 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:37:57 +0100 Subject: [PATCH 027/146] all service-layer tests now services [services_tests_all_services] --- tests/unit/test_services.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index f5574ef86..7d9d87388 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,14 +1,9 @@ import pytest -from domain import model from adapters import repository from service_layer import services class FakeRepository(repository.AbstractRepository): - @staticmethod - def for_batch(ref, sku, qty, eta=None): - return FakeRepository([model.Batch(ref, sku, qty, eta),]) - def __init__(self, batches): self._batches = set(batches) @@ -36,20 +31,24 @@ def test_add_batch(): assert session.committed -def test_returns_allocation(): - repo = FakeRepository.for_batch("batch1", "COMPLICATED-LAMP", 100, eta=None) - result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession()) +def test_allocate_returns_allocation(): + repo, session = FakeRepository([]), FakeSession() + services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session) + result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session) assert result == "batch1" -def test_error_for_invalid_sku(): - repo = FakeRepository.for_batch("b1", "AREALSKU", 100, eta=None) +def test_allocate_errors_for_invalid_sku(): + repo, session = FakeRepository([]), FakeSession() + services.add_batch("b1", "AREALSKU", 100, None, repo, session) + with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession()) def test_commits(): - repo = FakeRepository.for_batch("b1", "OMINOUS-MIRROR", 100, eta=None) + repo, session = FakeRepository([]), FakeSession() session = FakeSession() + services.add_batch("b1", "OMINOUS-MIRROR", 100, None, repo, session) services.allocate("o1", "OMINOUS-MIRROR", 10, repo, session) assert session.committed is True From 96301d2ca4aeb0f884906a500d2044821839dab5 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 23 Apr 2019 12:44:09 +0100 Subject: [PATCH 028/146] modify flask app to use new service layer api [api_uses_modified_service] --- entrypoints/flask_app.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/entrypoints/flask_app.py b/entrypoints/flask_app.py index 62e40d44d..aace66e70 100644 --- a/entrypoints/flask_app.py +++ b/entrypoints/flask_app.py @@ -17,12 +17,14 @@ def allocate_endpoint(): session = get_session() repo = repository.SqlAlchemyRepository(session) - line = model.OrderLine( - request.json["orderid"], request.json["sku"], request.json["qty"], - ) - try: - batchref = services.allocate(line, repo, session) + batchref = services.allocate( + request.json["orderid"], + request.json["sku"], + request.json["qty"], + repo, + session, + ) except (model.OutOfStock, services.InvalidSku) as e: return {"message": str(e)}, 400 From 6b404b52471786fccd439875e311985cecf35ea8 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 24 Apr 2019 12:21:59 +0100 Subject: [PATCH 029/146] add api endpoint for add_batch [api_for_add_batch] --- entrypoints/flask_app.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/entrypoints/flask_app.py b/entrypoints/flask_app.py index aace66e70..15ba414bf 100644 --- a/entrypoints/flask_app.py +++ b/entrypoints/flask_app.py @@ -1,3 +1,4 @@ +from datetime import datetime from flask import Flask, request from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -7,12 +8,29 @@ from adapters import orm, repository from service_layer import services - orm.start_mappers() get_session = sessionmaker(bind=create_engine(config.get_postgres_uri())) app = Flask(__name__) +@app.route("/add_batch", methods=["POST"]) +def add_batch(): + session = get_session() + repo = repository.SqlAlchemyRepository(session) + eta = request.json["eta"] + if eta is not None: + eta = datetime.fromisoformat(eta).date() + services.add_batch( + request.json["ref"], + request.json["sku"], + request.json["qty"], + eta, + repo, + session, + ) + return "OK", 201 + + @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): session = get_session() From fd45a6ff9e0ef20fc4dc698d169139f4f830c775 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 24 Apr 2019 12:22:34 +0100 Subject: [PATCH 030/146] api tests no longer need hardcoded sql fixture [chapter_05_high_gear_low_gear_ends] --- tests/conftest.py | 37 ------------------------------------- tests/e2e/test_api.py | 24 +++++++++++++++--------- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 11e66b353..447164f41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,43 +63,6 @@ def postgres_session(postgres_db): clear_mappers() -@pytest.fixture -def add_stock(postgres_session): - batches_added = set() - skus_added = set() - - def _add_stock(lines): - for ref, sku, qty, eta in lines: - postgres_session.execute( - "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" - " VALUES (:ref, :sku, :qty, :eta)", - dict(ref=ref, sku=sku, qty=qty, eta=eta), - ) - [[batch_id]] = postgres_session.execute( - "SELECT id FROM batches WHERE reference=:ref AND sku=:sku", - dict(ref=ref, sku=sku), - ) - batches_added.add(batch_id) - skus_added.add(sku) - postgres_session.commit() - - yield _add_stock - - for batch_id in batches_added: - postgres_session.execute( - "DELETE FROM allocations WHERE batch_id=:batch_id", - dict(batch_id=batch_id), - ) - postgres_session.execute( - "DELETE FROM batches WHERE id=:batch_id", dict(batch_id=batch_id), - ) - for sku in skus_added: - postgres_session.execute( - "DELETE FROM order_lines WHERE sku=:sku", dict(sku=sku), - ) - postgres_session.commit() - - @pytest.fixture def restart_api(): (Path(__file__).parent / "../entrypoints/flask_app.py").touch() diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 5386ab881..991db8db5 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -21,28 +21,34 @@ def random_orderid(name=""): return f"order-{name}-{random_suffix()}" +def post_to_add_batch(ref, sku, qty, eta): + url = config.get_api_url() + r = requests.post( + f"{url}/add_batch", json={"ref": ref, "sku": sku, "qty": qty, "eta": eta} + ) + assert r.status_code == 201 + + +@pytest.mark.usefixtures("postgres_db") @pytest.mark.usefixtures("restart_api") -def test_happy_path_returns_201_and_allocated_batch(add_stock): +def test_happy_path_returns_201_and_allocated_batch(): sku, othersku = random_sku(), random_sku("other") earlybatch = random_batchref(1) laterbatch = random_batchref(2) otherbatch = random_batchref(3) - add_stock( - [ - (laterbatch, sku, 100, "2011-01-02"), - (earlybatch, sku, 100, "2011-01-01"), - (otherbatch, othersku, 100, None), - ] - ) + post_to_add_batch(laterbatch, sku, 100, "2011-01-02") + post_to_add_batch(earlybatch, sku, 100, "2011-01-01") + post_to_add_batch(otherbatch, othersku, 100, None) data = {"orderid": random_orderid(), "sku": sku, "qty": 3} - url = config.get_api_url() + url = config.get_api_url() r = requests.post(f"{url}/allocate", json=data) assert r.status_code == 201 assert r.json()["batchref"] == earlybatch +@pytest.mark.usefixtures("postgres_db") @pytest.mark.usefixtures("restart_api") def test_unhappy_path_returns_400_and_error_message(): unknown_sku, orderid = random_sku(), random_orderid() From d9c340c26624145c4114f7b2b6f7bf963999885f Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 27 Feb 2019 06:13:15 +0000 Subject: [PATCH 031/146] start moving files into src folder and add setup.py --- Dockerfile | 15 ++++++++++----- {adapters => src/allocation}/__init__.py | 0 {domain => src/allocation/adapters}/__init__.py | 0 {adapters => src/allocation/adapters}/orm.py | 0 .../allocation/adapters}/repository.py | 0 config.py => src/allocation/config.py | 0 .../allocation/domain}/__init__.py | 0 {domain => src/allocation/domain}/model.py | 0 .../allocation/entrypoints}/__init__.py | 0 .../allocation/entrypoints}/flask_app.py | 0 src/allocation/service_layer/__init__.py | 0 .../allocation/service_layer}/services.py | 11 +++++++---- src/setup.py | 5 +++++ 13 files changed, 22 insertions(+), 9 deletions(-) rename {adapters => src/allocation}/__init__.py (100%) rename {domain => src/allocation/adapters}/__init__.py (100%) rename {adapters => src/allocation/adapters}/orm.py (100%) rename {adapters => src/allocation/adapters}/repository.py (100%) rename config.py => src/allocation/config.py (100%) rename {entrypoints => src/allocation/domain}/__init__.py (100%) rename {domain => src/allocation/domain}/model.py (100%) rename {service_layer => src/allocation/entrypoints}/__init__.py (100%) rename {entrypoints => src/allocation/entrypoints}/flask_app.py (100%) create mode 100644 src/allocation/service_layer/__init__.py rename {service_layer => src/allocation/service_layer}/services.py (79%) create mode 100644 src/setup.py diff --git a/Dockerfile b/Dockerfile index d4803afbf..73024d182 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,15 @@ FROM python:3.9-slim-buster -COPY requirements.txt /tmp +# RUN apt install gcc libpq (no longer needed bc we use psycopg2-binary) + +COPY requirements.txt /tmp/ RUN pip install -r /tmp/requirements.txt -RUN mkdir -p /code -COPY *.py /code/ -WORKDIR /code -ENV FLASK_APP=entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 +RUN mkdir -p /src +COPY src/ /src/ +RUN pip install -e /src +COPY tests/ /tests/ + +WORKDIR /src +ENV FLASK_APP=allocation/entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 CMD flask run --host=0.0.0.0 --port=80 diff --git a/adapters/__init__.py b/src/allocation/__init__.py similarity index 100% rename from adapters/__init__.py rename to src/allocation/__init__.py diff --git a/domain/__init__.py b/src/allocation/adapters/__init__.py similarity index 100% rename from domain/__init__.py rename to src/allocation/adapters/__init__.py diff --git a/adapters/orm.py b/src/allocation/adapters/orm.py similarity index 100% rename from adapters/orm.py rename to src/allocation/adapters/orm.py diff --git a/adapters/repository.py b/src/allocation/adapters/repository.py similarity index 100% rename from adapters/repository.py rename to src/allocation/adapters/repository.py diff --git a/config.py b/src/allocation/config.py similarity index 100% rename from config.py rename to src/allocation/config.py diff --git a/entrypoints/__init__.py b/src/allocation/domain/__init__.py similarity index 100% rename from entrypoints/__init__.py rename to src/allocation/domain/__init__.py diff --git a/domain/model.py b/src/allocation/domain/model.py similarity index 100% rename from domain/model.py rename to src/allocation/domain/model.py diff --git a/service_layer/__init__.py b/src/allocation/entrypoints/__init__.py similarity index 100% rename from service_layer/__init__.py rename to src/allocation/entrypoints/__init__.py diff --git a/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py similarity index 100% rename from entrypoints/flask_app.py rename to src/allocation/entrypoints/flask_app.py diff --git a/src/allocation/service_layer/__init__.py b/src/allocation/service_layer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/service_layer/services.py b/src/allocation/service_layer/services.py similarity index 79% rename from service_layer/services.py rename to src/allocation/service_layer/services.py index 36c7bd9ac..d9e1108f3 100644 --- a/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -16,16 +16,19 @@ def is_valid_sku(sku, batches): def add_batch( - ref: str, sku: str, qty: int, eta: Optional[date], - repo: AbstractRepository, session, + ref: str, + sku: str, + qty: int, + eta: Optional[date], + repo: AbstractRepository, + session, ) -> None: repo.add(model.Batch(ref, sku, qty, eta)) session.commit() def allocate( - orderid: str, sku: str, qty: int, - repo: AbstractRepository, session + orderid: str, sku: str, qty: int, repo: AbstractRepository, session ) -> str: line = OrderLine(orderid, sku, qty) batches = repo.list() diff --git a/src/setup.py b/src/setup.py new file mode 100644 index 000000000..731007b0c --- /dev/null +++ b/src/setup.py @@ -0,0 +1,5 @@ +from setuptools import setup + +setup( + name="allocation", version="0.1", packages=["allocation"], +) From c843a10031e471cfd019bbf6f090afa95a251085 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 27 Feb 2019 06:29:22 +0000 Subject: [PATCH 032/146] fix all the imports, get it all working --- docker-compose.yml | 2 +- mypy.ini | 7 ++----- src/allocation/adapters/orm.py | 2 +- src/allocation/adapters/repository.py | 2 +- src/allocation/entrypoints/flask_app.py | 8 ++++---- src/allocation/service_layer/services.py | 6 +++--- tests/conftest.py | 6 +++--- tests/e2e/test_api.py | 2 +- tests/integration/test_orm.py | 2 +- tests/integration/test_repository.py | 4 ++-- tests/mypy.ini | 6 ++++++ tests/unit/test_allocate.py | 2 +- tests/unit/test_batches.py | 2 +- tests/unit/test_services.py | 4 ++-- 14 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 tests/mypy.ini diff --git a/docker-compose.yml b/docker-compose.yml index 313cf9463..cc8341f96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - DB_HOST=postgres - DB_PASSWORD=abc123 volumes: - - ./:/code + - ./src:/src ports: - "5005:80" diff --git a/mypy.ini b/mypy.ini index ead5ef09c..65ab939b8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,9 +1,6 @@ [mypy] ignore_missing_imports = False +mypy_path = ./src -[mypy-pytest.*] +[mypy-pytest.*,sqlalchemy.*] ignore_missing_imports = True - -[mypy-sqlalchemy.*] -ignore_missing_imports = True - diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index 8499c2dc4..444a8cbb8 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -1,7 +1,7 @@ from sqlalchemy import Table, MetaData, Column, Integer, String, Date, ForeignKey from sqlalchemy.orm import mapper, relationship -from domain import model +from allocation.domain import model metadata = MetaData() diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index 7e7434b97..85e460765 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -1,5 +1,5 @@ import abc -from domain import model +from allocation.domain import model class AbstractRepository(abc.ABC): diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index 15ba414bf..2164c9b68 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -3,10 +3,10 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -import config -from domain import model -from adapters import orm, repository -from service_layer import services +from allocation import config +from allocation.domain import model +from allocation.adapters import orm, repository +from allocation.service_layer import services orm.start_mappers() get_session = sessionmaker(bind=create_engine(config.get_postgres_uri())) diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index d9e1108f3..845dec39d 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -2,9 +2,9 @@ from typing import Optional from datetime import date -from domain import model -from domain.model import OrderLine -from adapters.repository import AbstractRepository +from allocation.domain import model +from allocation.domain.model import OrderLine +from allocation.adapters.repository import AbstractRepository class InvalidSku(Exception): diff --git a/tests/conftest.py b/tests/conftest.py index 447164f41..bcf0fa4ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,8 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, clear_mappers -from adapters.orm import metadata, start_mappers -import config +from allocation.adapters.orm import metadata, start_mappers +from allocation import config @pytest.fixture @@ -65,6 +65,6 @@ def postgres_session(postgres_db): @pytest.fixture def restart_api(): - (Path(__file__).parent / "../entrypoints/flask_app.py").touch() + (Path(__file__).parent / "../src/allocation/entrypoints/flask_app.py").touch() time.sleep(0.5) wait_for_webapp_to_come_up() diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 991db8db5..29b857616 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -2,7 +2,7 @@ import pytest import requests -import config +from allocation import config def random_suffix(): diff --git a/tests/integration/test_orm.py b/tests/integration/test_orm.py index 3a551977a..db3a7a689 100644 --- a/tests/integration/test_orm.py +++ b/tests/integration/test_orm.py @@ -1,4 +1,4 @@ -from domain import model +from allocation.domain import model from datetime import date diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 3fa66d9f1..0fa69ab3f 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -1,6 +1,6 @@ # pylint: disable=protected-access -from domain import model -from adapters import repository +from allocation.domain import model +from allocation.adapters import repository def test_repository_can_save_a_batch(session): diff --git a/tests/mypy.ini b/tests/mypy.ini new file mode 100644 index 000000000..39fadd90d --- /dev/null +++ b/tests/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +ignore_missing_imports = False +mypy_path = ../src + +[mypy-pytest.*,sqlalchemy.*] +ignore_missing_imports = True diff --git a/tests/unit/test_allocate.py b/tests/unit/test_allocate.py index 4e434e28d..48dcfe5c6 100644 --- a/tests/unit/test_allocate.py +++ b/tests/unit/test_allocate.py @@ -1,6 +1,6 @@ from datetime import date, timedelta import pytest -from domain.model import allocate, OrderLine, Batch, OutOfStock +from allocation.domain.model import allocate, OrderLine, Batch, OutOfStock today = date.today() tomorrow = today + timedelta(days=1) diff --git a/tests/unit/test_batches.py b/tests/unit/test_batches.py index 7feda3d70..8735f41e7 100644 --- a/tests/unit/test_batches.py +++ b/tests/unit/test_batches.py @@ -1,5 +1,5 @@ from datetime import date -from domain.model import Batch, OrderLine +from allocation.domain.model import Batch, OrderLine def test_allocating_to_a_batch_reduces_the_available_quantity(): diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 7d9d87388..af0ae807d 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,6 +1,6 @@ import pytest -from adapters import repository -from service_layer import services +from allocation.adapters import repository +from allocation.service_layer import services class FakeRepository(repository.AbstractRepository): From 12d2bc2ca5f9ef644ad762eb022c6bb3a9bb6d0d Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 27 Feb 2019 06:44:34 +0000 Subject: [PATCH 033/146] get tests working in docker container --- .travis.yml | 3 --- Makefile | 17 +++++++++++++---- docker-compose.yml | 3 +++ tests/__init__.py | 0 tests/mypy.ini | 6 ------ tests/pytest.ini | 2 ++ 6 files changed, 18 insertions(+), 13 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/mypy.ini create mode 100644 tests/pytest.ini diff --git a/.travis.yml b/.travis.yml index f601680f7..fcd3ceeaf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,9 +2,6 @@ dist: xenial language: python python: 3.8 -install: -- pip3 install -r requirements.txt - script: - make all diff --git a/Makefile b/Makefile index 1e2481876..6409e9553 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,22 @@ up: docker-compose up -d app down: - docker-compose down + docker-compose down --remove-orphans + +test: up + docker-compose run --rm --no-deps --entrypoint=pytest app /tests/unit /tests/integration /tests/e2e + +unit-tests: + docker-compose run --rm --no-deps --entrypoint=pytest app /tests/unit + +integration-tests: up + docker-compose run --rm --no-deps --entrypoint=pytest app /tests/integration + +e2e-tests: up + docker-compose run --rm --no-deps --entrypoint=pytest app /tests/e2e logs: docker-compose logs app | tail -100 -test: - pytest --tb=short - black: black -l 86 $$(find * -name '*.py') diff --git a/docker-compose.yml b/docker-compose.yml index cc8341f96..039400e98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,11 @@ services: environment: - DB_HOST=postgres - DB_PASSWORD=abc123 + - API_HOST=app + - PYTHONDONTWRITEBYTECODE=1 volumes: - ./src:/src + - ./tests:/tests ports: - "5005:80" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/mypy.ini b/tests/mypy.ini deleted file mode 100644 index 39fadd90d..000000000 --- a/tests/mypy.ini +++ /dev/null @@ -1,6 +0,0 @@ -[mypy] -ignore_missing_imports = False -mypy_path = ../src - -[mypy-pytest.*,sqlalchemy.*] -ignore_missing_imports = True diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 000000000..bbd083ac1 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --tb=short From 28afa9a64c7b109837475ad683a99fef1f9101a2 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 24 May 2019 00:10:48 +0100 Subject: [PATCH 034/146] make mypy slightly stricter --- mypy.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy.ini b/mypy.ini index 65ab939b8..62194f352 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,7 @@ [mypy] ignore_missing_imports = False mypy_path = ./src +check_untyped_defs = True [mypy-pytest.*,sqlalchemy.*] ignore_missing_imports = True From 53ad7987bfc8b15d029c45a143743903a973215e Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 23:08:10 +0000 Subject: [PATCH 035/146] better requirements.txt [appendix_project_structure_ends] --- requirements.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 01a974f5d..8c7792540 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,10 @@ -pytest +# app sqlalchemy flask psycopg2-binary + +# tests +pytest +pytest-icdiff +mypy requests From 833d48bedf2599eaaf645def0ee419864bb68fe0 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 11 Mar 2019 23:11:44 +0000 Subject: [PATCH 036/146] basic uow test, uow and conftest.py changes --- src/allocation/service_layer/services.py | 11 ++---- src/allocation/unit_of_work.py | 50 ++++++++++++++++++++++++ tests/conftest.py | 9 ++++- tests/integration/test_uow.py | 40 +++++++++++++++++++ 4 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 src/allocation/unit_of_work.py create mode 100644 tests/integration/test_uow.py diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index 845dec39d..051de589d 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -16,19 +16,16 @@ def is_valid_sku(sku, batches): def add_batch( - ref: str, - sku: str, - qty: int, - eta: Optional[date], - repo: AbstractRepository, - session, + ref: str, sku: str, qty: int, eta: Optional[date], + repo: AbstractRepository, session, ) -> None: repo.add(model.Batch(ref, sku, qty, eta)) session.commit() def allocate( - orderid: str, sku: str, qty: int, repo: AbstractRepository, session + orderid: str, sku: str, qty: int, + repo: AbstractRepository, session, ) -> str: line = OrderLine(orderid, sku, qty) batches = repo.list() diff --git a/src/allocation/unit_of_work.py b/src/allocation/unit_of_work.py new file mode 100644 index 000000000..d89e11382 --- /dev/null +++ b/src/allocation/unit_of_work.py @@ -0,0 +1,50 @@ +# pylint: disable=attribute-defined-outside-init +from __future__ import annotations +import abc +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm.session import Session + +from allocation import config +from allocation import repository + + +class AbstractUnitOfWork(abc.ABC): + batches: repository.AbstractRepository + + def __enter__(self) -> AbstractUnitOfWork: + return self + + def __exit__(self, *args): + self.rollback() + + @abc.abstractmethod + def commit(self): + raise NotImplementedError + + @abc.abstractmethod + def rollback(self): + raise NotImplementedError + + +DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine(config.get_postgres_uri(),)) + + +class SqlAlchemyUnitOfWork(AbstractUnitOfWork): + def __init__(self, session_factory=DEFAULT_SESSION_FACTORY): + self.session_factory = session_factory + + def __enter__(self): + self.session = self.session_factory() # type: Session + self.batches = repository.SqlAlchemyRepository(self.session) + return super().__enter__() + + def __exit__(self, *args): + super().__exit__(*args) + self.session.close() + + def commit(self): + self.session.commit() + + def rollback(self): + self.session.rollback() diff --git a/tests/conftest.py b/tests/conftest.py index bcf0fa4ef..3dff0b836 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,12 +21,17 @@ def in_memory_db(): @pytest.fixture -def session(in_memory_db): +def session_factory(in_memory_db): start_mappers() - yield sessionmaker(bind=in_memory_db)() + yield sessionmaker(bind=in_memory_db) clear_mappers() +@pytest.fixture +def session(session_factory): + return session_factory() + + def wait_for_postgres_to_come_up(engine): deadline = time.time() + 10 while time.time() < deadline: diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py new file mode 100644 index 000000000..5ee11051e --- /dev/null +++ b/tests/integration/test_uow.py @@ -0,0 +1,40 @@ +import pytest +from allocation import model +from allocation import unit_of_work + + +def insert_batch(session, ref, sku, qty, eta): + session.execute( + "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" + " VALUES (:ref, :sku, :qty, :eta)", + dict(ref=ref, sku=sku, qty=qty, eta=eta), + ) + + +def get_allocated_batch_ref(session, orderid, sku): + [[orderlineid]] = session.execute( + "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", + dict(orderid=orderid, sku=sku), + ) + [[batchref]] = session.execute( + "SELECT b.reference FROM allocations JOIN batches AS b ON batch_id = b.id" + " WHERE orderline_id=:orderlineid", + dict(orderlineid=orderlineid), + ) + return batchref + + +def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): + session = session_factory() + insert_batch(session, "batch1", "HIPSTER-WORKBENCH", 100, None) + session.commit() + + uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + with uow: + batch = uow.batches.get(reference="batch1") + line = model.OrderLine("o1", "HIPSTER-WORKBENCH", 10) + batch.allocate(line) + uow.commit() + + batchref = get_allocated_batch_ref(session, "o1", "HIPSTER-WORKBENCH") + assert batchref == "batch1" From 658e61acd072c7f2bdec8fb0e9b50a9adc0edfdb Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 20 Mar 2019 10:53:14 +0000 Subject: [PATCH 037/146] use uow in services, flask app --- src/allocation/adapters/orm.py | 4 +- src/allocation/entrypoints/flask_app.py | 18 +++----- src/allocation/service_layer/services.py | 24 ++++++----- .../{ => service_layer}/unit_of_work.py | 8 +++- src/setup.py | 4 +- tests/integration/test_uow.py | 5 +-- tests/unit/test_services.py | 42 ++++++++++--------- 7 files changed, 55 insertions(+), 50 deletions(-) rename src/allocation/{ => service_layer}/unit_of_work.py (88%) diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index 444a8cbb8..c189a739d 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -41,7 +41,9 @@ def start_mappers(): batches, properties={ "_allocations": relationship( - lines_mapper, secondary=allocations, collection_class=set, + lines_mapper, + secondary=allocations, + collection_class=set, ) }, ) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index 2164c9b68..602a09c1a 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -3,20 +3,16 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from allocation import config from allocation.domain import model -from allocation.adapters import orm, repository -from allocation.service_layer import services +from allocation.adapters import orm +from allocation.service_layer import services, unit_of_work -orm.start_mappers() -get_session = sessionmaker(bind=create_engine(config.get_postgres_uri())) app = Flask(__name__) +orm.start_mappers() @app.route("/add_batch", methods=["POST"]) def add_batch(): - session = get_session() - repo = repository.SqlAlchemyRepository(session) eta = request.json["eta"] if eta is not None: eta = datetime.fromisoformat(eta).date() @@ -25,23 +21,19 @@ def add_batch(): request.json["sku"], request.json["qty"], eta, - repo, - session, + unit_of_work.SqlAlchemyUnitOfWork(), ) return "OK", 201 @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): - session = get_session() - repo = repository.SqlAlchemyRepository(session) try: batchref = services.allocate( request.json["orderid"], request.json["sku"], request.json["qty"], - repo, - session, + unit_of_work.SqlAlchemyUnitOfWork(), ) except (model.OutOfStock, services.InvalidSku) as e: return {"message": str(e)}, 400 diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index 051de589d..c5d46772f 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -4,7 +4,7 @@ from allocation.domain import model from allocation.domain.model import OrderLine -from allocation.adapters.repository import AbstractRepository +from allocation.service_layer import unit_of_work class InvalidSku(Exception): @@ -17,20 +17,22 @@ def is_valid_sku(sku, batches): def add_batch( ref: str, sku: str, qty: int, eta: Optional[date], - repo: AbstractRepository, session, -) -> None: - repo.add(model.Batch(ref, sku, qty, eta)) - session.commit() + uow: unit_of_work.AbstractUnitOfWork, +): + with uow: + uow.batches.add(model.Batch(ref, sku, qty, eta)) + uow.commit() def allocate( orderid: str, sku: str, qty: int, - repo: AbstractRepository, session, + uow: unit_of_work.AbstractUnitOfWork, ) -> str: line = OrderLine(orderid, sku, qty) - batches = repo.list() - if not is_valid_sku(line.sku, batches): - raise InvalidSku(f"Invalid sku {line.sku}") - batchref = model.allocate(line, batches) - session.commit() + with uow: + batches = uow.batches.list() + if not is_valid_sku(line.sku, batches): + raise InvalidSku(f"Invalid sku {line.sku}") + batchref = model.allocate(line, batches) + uow.commit() return batchref diff --git a/src/allocation/unit_of_work.py b/src/allocation/service_layer/unit_of_work.py similarity index 88% rename from src/allocation/unit_of_work.py rename to src/allocation/service_layer/unit_of_work.py index d89e11382..bf9196c83 100644 --- a/src/allocation/unit_of_work.py +++ b/src/allocation/service_layer/unit_of_work.py @@ -6,7 +6,7 @@ from sqlalchemy.orm.session import Session from allocation import config -from allocation import repository +from allocation.adapters import repository class AbstractUnitOfWork(abc.ABC): @@ -27,7 +27,11 @@ def rollback(self): raise NotImplementedError -DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine(config.get_postgres_uri(),)) +DEFAULT_SESSION_FACTORY = sessionmaker( + bind=create_engine( + config.get_postgres_uri(), + ) +) class SqlAlchemyUnitOfWork(AbstractUnitOfWork): diff --git a/src/setup.py b/src/setup.py index 731007b0c..b2b0839aa 100644 --- a/src/setup.py +++ b/src/setup.py @@ -1,5 +1,7 @@ from setuptools import setup setup( - name="allocation", version="0.1", packages=["allocation"], + name="allocation", + version="0.1", + packages=["allocation"], ) diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index 5ee11051e..23f27619e 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -1,6 +1,5 @@ -import pytest -from allocation import model -from allocation import unit_of_work +from allocation.domain import model +from allocation.service_layer import unit_of_work def insert_batch(session, ref, sku, qty, eta): diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index af0ae807d..091dbb2c1 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,6 +1,6 @@ import pytest from allocation.adapters import repository -from allocation.service_layer import services +from allocation.service_layer import services, unit_of_work class FakeRepository(repository.AbstractRepository): @@ -17,38 +17,42 @@ def list(self): return list(self._batches) -class FakeSession: - committed = False +class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): + def __init__(self): + self.batches = FakeRepository([]) + self.committed = False def commit(self): self.committed = True + def rollback(self): + pass + def test_add_batch(): - repo, session = FakeRepository([]), FakeSession() - services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session) - assert repo.get("b1") is not None - assert session.committed + uow = FakeUnitOfWork() + services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) + assert uow.batches.get("b1") is not None + assert uow.committed def test_allocate_returns_allocation(): - repo, session = FakeRepository([]), FakeSession() - services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session) - result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session) + uow = FakeUnitOfWork() + services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow) + result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow) assert result == "batch1" def test_allocate_errors_for_invalid_sku(): - repo, session = FakeRepository([]), FakeSession() - services.add_batch("b1", "AREALSKU", 100, None, repo, session) + uow = FakeUnitOfWork() + services.add_batch("b1", "AREALSKU", 100, None, uow) with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession()) + services.allocate("o1", "NONEXISTENTSKU", 10, uow) -def test_commits(): - repo, session = FakeRepository([]), FakeSession() - session = FakeSession() - services.add_batch("b1", "OMINOUS-MIRROR", 100, None, repo, session) - services.allocate("o1", "OMINOUS-MIRROR", 10, repo, session) - assert session.committed is True +def test_allocate_commits(): + uow = FakeUnitOfWork() + services.add_batch("b1", "OMINOUS-MIRROR", 100, None, uow) + services.allocate("o1", "OMINOUS-MIRROR", 10, uow) + assert uow.committed From 7526014be1a288f967393ed7bb544655c87a2817 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 12 Mar 2019 11:52:13 +0000 Subject: [PATCH 038/146] two more tests for rollback behaviour [chapter_06_uow_ends] --- tests/integration/test_uow.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index 23f27619e..3887e3caa 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -1,3 +1,4 @@ +import pytest from allocation.domain import model from allocation.service_layer import unit_of_work @@ -37,3 +38,28 @@ def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): batchref = get_allocated_batch_ref(session, "o1", "HIPSTER-WORKBENCH") assert batchref == "batch1" + + +def test_rolls_back_uncommitted_work_by_default(session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + with uow: + insert_batch(uow.session, "batch1", "MEDIUM-PLINTH", 100, None) + + new_session = session_factory() + rows = list(new_session.execute('SELECT * FROM "batches"')) + assert rows == [] + + +def test_rolls_back_on_error(session_factory): + class MyException(Exception): + pass + + uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + with pytest.raises(MyException): + with uow: + insert_batch(uow.session, "batch1", "LARGE-FORK", 100, None) + raise MyException() + + new_session = session_factory() + rows = list(new_session.execute('SELECT * FROM "batches"')) + assert rows == [] From c50ab2c54cdc00d829eb60c6776a2cc2e93ac2e9 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 23 Mar 2019 10:26:16 +0000 Subject: [PATCH 039/146] start on a Product model with an allocate fn [product_aggregate] --- src/allocation/adapters/orm.py | 14 ++- src/allocation/adapters/repository.py | 15 ++-- src/allocation/domain/model.py | 19 ++-- src/allocation/service_layer/services.py | 16 ++-- src/allocation/service_layer/unit_of_work.py | 4 +- tests/integration/test_orm.py | 89 ------------------- tests/integration/test_repository.py | 67 -------------- tests/integration/test_uow.py | 8 +- .../{test_allocate.py => test_product.py} | 18 ++-- tests/unit/test_services.py | 28 +++--- 10 files changed, 73 insertions(+), 205 deletions(-) delete mode 100644 tests/integration/test_orm.py delete mode 100644 tests/integration/test_repository.py rename tests/unit/{test_allocate.py => test_product.py} (68%) diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index c189a739d..2af008a2c 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -15,12 +15,19 @@ Column("orderid", String(255)), ) +products = Table( + "products", + metadata, + Column("sku", String(255), primary_key=True), + # Column('version_number', Integer, nullable=False, default=0), +) + batches = Table( "batches", metadata, Column("id", Integer, primary_key=True, autoincrement=True), Column("reference", String(255)), - Column("sku", String(255)), + Column("sku", ForeignKey("products.sku")), Column("_purchased_quantity", Integer, nullable=False), Column("eta", Date, nullable=True), ) @@ -36,7 +43,7 @@ def start_mappers(): lines_mapper = mapper(model.OrderLine, order_lines) - mapper( + batches_mapper = mapper( model.Batch, batches, properties={ @@ -47,3 +54,6 @@ def start_mappers(): ) }, ) + mapper( + model.Product, products, properties={"batches": relationship(batches_mapper)} + ) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index 85e460765..7e18dbed9 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -4,11 +4,11 @@ class AbstractRepository(abc.ABC): @abc.abstractmethod - def add(self, batch: model.Batch): + def add(self, product: model.Product): raise NotImplementedError @abc.abstractmethod - def get(self, reference) -> model.Batch: + def get(self, sku) -> model.Product: raise NotImplementedError @@ -16,11 +16,8 @@ class SqlAlchemyRepository(AbstractRepository): def __init__(self, session): self.session = session - def add(self, batch): - self.session.add(batch) + def add(self, product): + self.session.add(product) - def get(self, reference): - return self.session.query(model.Batch).filter_by(reference=reference).one() - - def list(self): - return self.session.query(model.Batch).all() + def get(self, sku): + return self.session.query(model.Product).filter_by(sku=sku).first() diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index b32033250..c432783c8 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -8,13 +8,18 @@ class OutOfStock(Exception): pass -def allocate(line: OrderLine, batches: List[Batch]) -> str: - try: - batch = next(b for b in sorted(batches) if b.can_allocate(line)) - batch.allocate(line) - return batch.reference - except StopIteration: - raise OutOfStock(f"Out of stock for sku {line.sku}") +class Product: + def __init__(self, sku: str, batches: List[Batch]): + self.sku = sku + self.batches = batches + + def allocate(self, line: OrderLine) -> str: + try: + batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) + batch.allocate(line) + return batch.reference + except StopIteration: + raise OutOfStock(f"Out of stock for sku {line.sku}") @dataclass(unsafe_hash=True) diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index c5d46772f..5311ca6e0 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -11,16 +11,16 @@ class InvalidSku(Exception): pass -def is_valid_sku(sku, batches): - return sku in {b.sku for b in batches} - - def add_batch( ref: str, sku: str, qty: int, eta: Optional[date], uow: unit_of_work.AbstractUnitOfWork, ): with uow: - uow.batches.add(model.Batch(ref, sku, qty, eta)) + product = uow.products.get(sku=sku) + if product is None: + product = model.Product(sku, batches=[]) + uow.products.add(product) + product.batches.append(model.Batch(ref, sku, qty, eta)) uow.commit() @@ -30,9 +30,9 @@ def allocate( ) -> str: line = OrderLine(orderid, sku, qty) with uow: - batches = uow.batches.list() - if not is_valid_sku(line.sku, batches): + product = uow.products.get(sku=line.sku) + if product is None: raise InvalidSku(f"Invalid sku {line.sku}") - batchref = model.allocate(line, batches) + batchref = product.allocate(line) uow.commit() return batchref diff --git a/src/allocation/service_layer/unit_of_work.py b/src/allocation/service_layer/unit_of_work.py index bf9196c83..2b41b7a9d 100644 --- a/src/allocation/service_layer/unit_of_work.py +++ b/src/allocation/service_layer/unit_of_work.py @@ -10,7 +10,7 @@ class AbstractUnitOfWork(abc.ABC): - batches: repository.AbstractRepository + products: repository.AbstractRepository def __enter__(self) -> AbstractUnitOfWork: return self @@ -40,7 +40,7 @@ def __init__(self, session_factory=DEFAULT_SESSION_FACTORY): def __enter__(self): self.session = self.session_factory() # type: Session - self.batches = repository.SqlAlchemyRepository(self.session) + self.products = repository.SqlAlchemyRepository(self.session) return super().__enter__() def __exit__(self, *args): diff --git a/tests/integration/test_orm.py b/tests/integration/test_orm.py deleted file mode 100644 index db3a7a689..000000000 --- a/tests/integration/test_orm.py +++ /dev/null @@ -1,89 +0,0 @@ -from allocation.domain import model -from datetime import date - - -def test_orderline_mapper_can_load_lines(session): - session.execute( - "INSERT INTO order_lines (orderid, sku, qty) VALUES " - '("order1", "RED-CHAIR", 12),' - '("order1", "RED-TABLE", 13),' - '("order2", "BLUE-LIPSTICK", 14)' - ) - expected = [ - model.OrderLine("order1", "RED-CHAIR", 12), - model.OrderLine("order1", "RED-TABLE", 13), - model.OrderLine("order2", "BLUE-LIPSTICK", 14), - ] - assert session.query(model.OrderLine).all() == expected - - -def test_orderline_mapper_can_save_lines(session): - new_line = model.OrderLine("order1", "DECORATIVE-WIDGET", 12) - session.add(new_line) - session.commit() - - rows = list(session.execute('SELECT orderid, sku, qty FROM "order_lines"')) - assert rows == [("order1", "DECORATIVE-WIDGET", 12)] - - -def test_retrieving_batches(session): - session.execute( - "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" - ' VALUES ("batch1", "sku1", 100, null)' - ) - session.execute( - "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" - ' VALUES ("batch2", "sku2", 200, "2011-04-11")' - ) - expected = [ - model.Batch("batch1", "sku1", 100, eta=None), - model.Batch("batch2", "sku2", 200, eta=date(2011, 4, 11)), - ] - - assert session.query(model.Batch).all() == expected - - -def test_saving_batches(session): - batch = model.Batch("batch1", "sku1", 100, eta=None) - session.add(batch) - session.commit() - rows = session.execute( - 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' - ) - assert list(rows) == [("batch1", "sku1", 100, None)] - - -def test_saving_allocations(session): - batch = model.Batch("batch1", "sku1", 100, eta=None) - line = model.OrderLine("order1", "sku1", 10) - batch.allocate(line) - session.add(batch) - session.commit() - rows = list(session.execute('SELECT orderline_id, batch_id FROM "allocations"')) - assert rows == [(batch.id, line.id)] - - -def test_retrieving_allocations(session): - session.execute( - 'INSERT INTO order_lines (orderid, sku, qty) VALUES ("order1", "sku1", 12)' - ) - [[olid]] = session.execute( - "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", - dict(orderid="order1", sku="sku1"), - ) - session.execute( - "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" - ' VALUES ("batch1", "sku1", 100, null)' - ) - [[bid]] = session.execute( - "SELECT id FROM batches WHERE reference=:ref AND sku=:sku", - dict(ref="batch1", sku="sku1"), - ) - session.execute( - "INSERT INTO allocations (orderline_id, batch_id) VALUES (:olid, :bid)", - dict(olid=olid, bid=bid), - ) - - batch = session.query(model.Batch).one() - - assert batch._allocations == {model.OrderLine("order1", "sku1", 12)} diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py deleted file mode 100644 index 0fa69ab3f..000000000 --- a/tests/integration/test_repository.py +++ /dev/null @@ -1,67 +0,0 @@ -# pylint: disable=protected-access -from allocation.domain import model -from allocation.adapters import repository - - -def test_repository_can_save_a_batch(session): - batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None) - - repo = repository.SqlAlchemyRepository(session) - repo.add(batch) - session.commit() - - rows = session.execute( - 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' - ) - assert list(rows) == [("batch1", "RUSTY-SOAPDISH", 100, None)] - - -def insert_order_line(session): - session.execute( - "INSERT INTO order_lines (orderid, sku, qty)" - ' VALUES ("order1", "GENERIC-SOFA", 12)' - ) - [[orderline_id]] = session.execute( - "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", - dict(orderid="order1", sku="GENERIC-SOFA"), - ) - return orderline_id - - -def insert_batch(session, batch_id): - session.execute( - "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" - ' VALUES (:batch_id, "GENERIC-SOFA", 100, null)', - dict(batch_id=batch_id), - ) - [[batch_id]] = session.execute( - 'SELECT id FROM batches WHERE reference=:batch_id AND sku="GENERIC-SOFA"', - dict(batch_id=batch_id), - ) - return batch_id - - -def insert_allocation(session, orderline_id, batch_id): - session.execute( - "INSERT INTO allocations (orderline_id, batch_id)" - " VALUES (:orderline_id, :batch_id)", - dict(orderline_id=orderline_id, batch_id=batch_id), - ) - - -def test_repository_can_retrieve_a_batch_with_allocations(session): - orderline_id = insert_order_line(session) - batch1_id = insert_batch(session, "batch1") - insert_batch(session, "batch2") - insert_allocation(session, orderline_id, batch1_id) - - repo = repository.SqlAlchemyRepository(session) - retrieved = repo.get("batch1") - - expected = model.Batch("batch1", "GENERIC-SOFA", 100, eta=None) - assert retrieved == expected # Batch.__eq__ only compares reference - assert retrieved.sku == expected.sku - assert retrieved._purchased_quantity == expected._purchased_quantity - assert retrieved._allocations == { - model.OrderLine("order1", "GENERIC-SOFA", 12), - } diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index 3887e3caa..8bf0c69d6 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -4,6 +4,10 @@ def insert_batch(session, ref, sku, qty, eta): + session.execute( + "INSERT INTO products (sku) VALUES (:sku)", + dict(sku=sku), + ) session.execute( "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" " VALUES (:ref, :sku, :qty, :eta)", @@ -31,9 +35,9 @@ def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) with uow: - batch = uow.batches.get(reference="batch1") + product = uow.products.get(sku="HIPSTER-WORKBENCH") line = model.OrderLine("o1", "HIPSTER-WORKBENCH", 10) - batch.allocate(line) + product.allocate(line) uow.commit() batchref = get_allocated_batch_ref(session, "o1", "HIPSTER-WORKBENCH") diff --git a/tests/unit/test_allocate.py b/tests/unit/test_product.py similarity index 68% rename from tests/unit/test_allocate.py rename to tests/unit/test_product.py index 48dcfe5c6..366611d67 100644 --- a/tests/unit/test_allocate.py +++ b/tests/unit/test_product.py @@ -1,18 +1,19 @@ from datetime import date, timedelta import pytest -from allocation.domain.model import allocate, OrderLine, Batch, OutOfStock +from allocation.domain.model import Product, OrderLine, Batch, OutOfStock today = date.today() tomorrow = today + timedelta(days=1) later = tomorrow + timedelta(days=10) -def test_prefers_current_stock_batches_to_shipments(): +def test_prefers_warehouse_batches_to_shipments(): in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow) + product = Product(sku="RETRO-CLOCK", batches=[in_stock_batch, shipment_batch]) line = OrderLine("oref", "RETRO-CLOCK", 10) - allocate(line, [in_stock_batch, shipment_batch]) + product.allocate(line) assert in_stock_batch.available_quantity == 90 assert shipment_batch.available_quantity == 100 @@ -22,9 +23,10 @@ def test_prefers_earlier_batches(): earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today) medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow) latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later) + product = Product(sku="MINIMALIST-SPOON", batches=[medium, earliest, latest]) line = OrderLine("order1", "MINIMALIST-SPOON", 10) - allocate(line, [medium, earliest, latest]) + product.allocate(line) assert earliest.available_quantity == 90 assert medium.available_quantity == 100 @@ -35,13 +37,15 @@ def test_returns_allocated_batch_ref(): in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None) shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow) line = OrderLine("oref", "HIGHBROW-POSTER", 10) - allocation = allocate(line, [in_stock_batch, shipment_batch]) + product = Product(sku="HIGHBROW-POSTER", batches=[in_stock_batch, shipment_batch]) + allocation = product.allocate(line) assert allocation == in_stock_batch.reference def test_raises_out_of_stock_exception_if_cannot_allocate(): batch = Batch("batch1", "SMALL-FORK", 10, eta=today) - allocate(OrderLine("order1", "SMALL-FORK", 10), [batch]) + product = Product(sku="SMALL-FORK", batches=[batch]) + product.allocate(OrderLine("order1", "SMALL-FORK", 10)) with pytest.raises(OutOfStock, match="SMALL-FORK"): - allocate(OrderLine("order2", "SMALL-FORK", 1), [batch]) + product.allocate(OrderLine("order2", "SMALL-FORK", 1)) diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 091dbb2c1..9055d03ad 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -4,22 +4,19 @@ class FakeRepository(repository.AbstractRepository): - def __init__(self, batches): - self._batches = set(batches) + def __init__(self, products): + self._products = set(products) - def add(self, batch): - self._batches.add(batch) + def add(self, product): + self._products.add(product) - def get(self, reference): - return next(b for b in self._batches if b.reference == reference) - - def list(self): - return list(self._batches) + def get(self, sku): + return next((p for p in self._products if p.sku == sku), None) class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): def __init__(self): - self.batches = FakeRepository([]) + self.products = FakeRepository([]) self.committed = False def commit(self): @@ -29,13 +26,20 @@ def rollback(self): pass -def test_add_batch(): +def test_add_batch_for_new_product(): uow = FakeUnitOfWork() services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) - assert uow.batches.get("b1") is not None + assert uow.products.get("CRUNCHY-ARMCHAIR") is not None assert uow.committed +def test_add_batch_for_existing_product(): + uow = FakeUnitOfWork() + services.add_batch("b1", "GARISH-RUG", 100, None, uow) + services.add_batch("b2", "GARISH-RUG", 99, None, uow) + assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] + + def test_allocate_returns_allocation(): uow = FakeUnitOfWork() services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow) From 9d6eed1b45c97ec9136a9cf9d4b07273e27e3d81 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 23 Mar 2019 22:59:22 +0000 Subject: [PATCH 040/146] first cut of a product with a version number --- src/allocation/domain/model.py | 4 +++- tests/unit/test_product.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index c432783c8..a48e1f69f 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -9,14 +9,16 @@ class OutOfStock(Exception): class Product: - def __init__(self, sku: str, batches: List[Batch]): + def __init__(self, sku: str, batches: List[Batch], version_number: int = 0): self.sku = sku self.batches = batches + self.version_number = version_number def allocate(self, line: OrderLine) -> str: try: batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) batch.allocate(line) + self.version_number += 1 return batch.reference except StopIteration: raise OutOfStock(f"Out of stock for sku {line.sku}") diff --git a/tests/unit/test_product.py b/tests/unit/test_product.py index 366611d67..05d753559 100644 --- a/tests/unit/test_product.py +++ b/tests/unit/test_product.py @@ -49,3 +49,13 @@ def test_raises_out_of_stock_exception_if_cannot_allocate(): with pytest.raises(OutOfStock, match="SMALL-FORK"): product.allocate(OrderLine("order2", "SMALL-FORK", 1)) + + +def test_increments_version_number(): + line = OrderLine("oref", "SCANDI-PEN", 10) + product = Product( + sku="SCANDI-PEN", batches=[Batch("b1", "SCANDI-PEN", 100, eta=None)] + ) + product.version_number = 7 + product.allocate(line) + assert product.version_number == 8 From 16109430b6fa180f349a7a09f537718ec6911caf Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 23 Mar 2019 23:39:49 +0000 Subject: [PATCH 041/146] start adding tests for consistency on version_number [data_integrity_test] --- src/allocation/adapters/orm.py | 2 +- src/allocation/adapters/repository.py | 1 + tests/__init__.py | 0 tests/conftest.py | 9 +++- tests/e2e/__init__.py | 0 tests/e2e/test_api.py | 18 +------- tests/integration/__init__.py | 0 tests/integration/test_uow.py | 62 +++++++++++++++++++++++++-- tests/random_refs.py | 17 ++++++++ 9 files changed, 86 insertions(+), 23 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/random_refs.py diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index 2af008a2c..ddd1f3abc 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -19,7 +19,7 @@ "products", metadata, Column("sku", String(255), primary_key=True), - # Column('version_number', Integer, nullable=False, default=0), + Column("version_number", Integer, nullable=False, server_default="0"), ) batches = Table( diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index 7e18dbed9..e25220cb5 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -20,4 +20,5 @@ def add(self, product): self.session.add(product) def get(self, sku): + print(sku, type(sku)) return self.session.query(model.Product).filter_by(sku=sku).first() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py index 3dff0b836..d51c20f61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,12 +62,17 @@ def postgres_db(): @pytest.fixture -def postgres_session(postgres_db): +def postgres_session_factory(postgres_db): start_mappers() - yield sessionmaker(bind=postgres_db)() + yield sessionmaker(bind=postgres_db) clear_mappers() +@pytest.fixture +def postgres_session(postgres_session_factory): + return postgres_session_factory() + + @pytest.fixture def restart_api(): (Path(__file__).parent / "../src/allocation/entrypoints/flask_app.py").touch() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 29b857616..79345fce0 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -1,24 +1,8 @@ -import uuid import pytest import requests from allocation import config - - -def random_suffix(): - return uuid.uuid4().hex[:6] - - -def random_sku(name=""): - return f"sku-{name}-{random_suffix()}" - - -def random_batchref(name=""): - return f"batch-{name}-{random_suffix()}" - - -def random_orderid(name=""): - return f"order-{name}-{random_suffix()}" +from ..random_refs import random_sku, random_batchref, random_orderid def post_to_add_batch(ref, sku, qty, eta): diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index 8bf0c69d6..a95907cf2 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -1,12 +1,18 @@ +# pylint: disable=broad-except +import threading +import time +import traceback +from typing import List import pytest from allocation.domain import model from allocation.service_layer import unit_of_work +from ..random_refs import random_sku, random_batchref, random_orderid -def insert_batch(session, ref, sku, qty, eta): +def insert_batch(session, ref, sku, qty, eta, product_version=1): session.execute( - "INSERT INTO products (sku) VALUES (:sku)", - dict(sku=sku), + "INSERT INTO products (sku, version_number) VALUES (:sku, :version)", + dict(sku=sku, version=product_version), ) session.execute( "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" @@ -67,3 +73,53 @@ class MyException(Exception): new_session = session_factory() rows = list(new_session.execute('SELECT * FROM "batches"')) assert rows == [] + + +def try_to_allocate(orderid, sku, exceptions): + line = model.OrderLine(orderid, sku, 10) + try: + with unit_of_work.SqlAlchemyUnitOfWork() as uow: + product = uow.products.get(sku=sku) + product.allocate(line) + time.sleep(0.2) + uow.commit() + except Exception as e: + print(traceback.format_exc()) + exceptions.append(e) + + +def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory): + sku, batch = random_sku(), random_batchref() + session = postgres_session_factory() + insert_batch(session, batch, sku, 100, eta=None, product_version=1) + session.commit() + + order1, order2 = random_orderid(1), random_orderid(2) + exceptions = [] # type: List[Exception] + try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions) + try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions) + thread1 = threading.Thread(target=try_to_allocate_order1) + thread2 = threading.Thread(target=try_to_allocate_order2) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + [[version]] = session.execute( + "SELECT version_number FROM products WHERE sku=:sku", + dict(sku=sku), + ) + assert version == 2 + [exception] = exceptions + assert "could not serialize access due to concurrent update" in str(exception) + + orders = session.execute( + "SELECT orderid FROM allocations" + " JOIN batches ON allocations.batch_id = batches.id" + " JOIN order_lines ON allocations.orderline_id = order_lines.id" + " WHERE order_lines.sku=:sku", + dict(sku=sku), + ) + assert orders.rowcount == 1 + with unit_of_work.SqlAlchemyUnitOfWork() as uow: + uow.session.execute("select 1") diff --git a/tests/random_refs.py b/tests/random_refs.py new file mode 100644 index 000000000..096dd5195 --- /dev/null +++ b/tests/random_refs.py @@ -0,0 +1,17 @@ +import uuid + + +def random_suffix(): + return uuid.uuid4().hex[:6] + + +def random_sku(name=""): + return f"sku-{name}-{random_suffix()}" + + +def random_batchref(name=""): + return f"batch-{name}-{random_suffix()}" + + +def random_orderid(name=""): + return f"order-{name}-{random_suffix()}" From bdd3d906711039eafa3e43f6456f2715fbf23805 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 23 Mar 2019 23:40:06 +0000 Subject: [PATCH 042/146] select for update is one approach [with_for_update] --- src/allocation/adapters/repository.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index e25220cb5..e08b49156 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -20,5 +20,9 @@ def add(self, product): self.session.add(product) def get(self, sku): - print(sku, type(sku)) - return self.session.query(model.Product).filter_by(sku=sku).first() + return ( + self.session.query(model.Product) + .filter_by(sku=sku) + .with_for_update() + .first() + ) From dfaa46ac4c6f1b0cef307d09d22dec9f71d6edb6 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 23 Mar 2019 23:53:32 +0000 Subject: [PATCH 043/146] alternative: isolation=SERIALIZABLE [chapter_07_aggregate_ends] --- src/allocation/adapters/repository.py | 7 +------ src/allocation/service_layer/unit_of_work.py | 1 + 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index e08b49156..7e18dbed9 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -20,9 +20,4 @@ def add(self, product): self.session.add(product) def get(self, sku): - return ( - self.session.query(model.Product) - .filter_by(sku=sku) - .with_for_update() - .first() - ) + return self.session.query(model.Product).filter_by(sku=sku).first() diff --git a/src/allocation/service_layer/unit_of_work.py b/src/allocation/service_layer/unit_of_work.py index 2b41b7a9d..0c9a17e45 100644 --- a/src/allocation/service_layer/unit_of_work.py +++ b/src/allocation/service_layer/unit_of_work.py @@ -30,6 +30,7 @@ def rollback(self): DEFAULT_SESSION_FACTORY = sessionmaker( bind=create_engine( config.get_postgres_uri(), + isolation_level="REPEATABLE READ", ) ) From 5d5f194c33d4475e2be4dbb7722f28c34ff07bb9 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 9 Apr 2019 10:59:34 +0100 Subject: [PATCH 044/146] Mocky test for email [mocky_test_for_send_email] --- src/allocation/adapters/email.py | 2 ++ tests/unit/test_services.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/allocation/adapters/email.py diff --git a/src/allocation/adapters/email.py b/src/allocation/adapters/email.py new file mode 100644 index 000000000..18b17d602 --- /dev/null +++ b/src/allocation/adapters/email.py @@ -0,0 +1,2 @@ +def send_mail(*args): + print("SENDING EMAIL:", *args) diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 9055d03ad..a5a07a296 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,5 +1,7 @@ +from unittest import mock import pytest from allocation.adapters import repository +from allocation.domain.model import OutOfStock from allocation.service_layer import services, unit_of_work @@ -60,3 +62,16 @@ def test_allocate_commits(): services.add_batch("b1", "OMINOUS-MIRROR", 100, None, uow) services.allocate("o1", "OMINOUS-MIRROR", 10, uow) assert uow.committed + + +def test_sends_email_on_out_of_stock_error(): + uow = FakeUnitOfWork() + services.add_batch("b1", "POPULAR-CURTAINS", 9, None, uow) + + with mock.patch("allocation.adapters.email.send_mail") as mock_send_mail: + with pytest.raises(OutOfStock): + services.allocate("o1", "POPULAR-CURTAINS", 10, uow) + assert mock_send_mail.call_args == mock.call( + "stock@made.com", + f"Out of stock for POPULAR-CURTAINS", + ) From 62aff873ead08c898b6404d7a3417ecb89658918 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 9 Apr 2019 11:13:48 +0100 Subject: [PATCH 045/146] email in model [email_in_model] --- src/allocation/domain/model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index a48e1f69f..a9f03fd07 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import date from typing import Optional, List, Set +from allocation.adapters import email class OutOfStock(Exception): @@ -21,6 +22,7 @@ def allocate(self, line: OrderLine) -> str: self.version_number += 1 return batch.reference except StopIteration: + email.send_mail("stock@made.com", f"Out of stock for {line.sku}") raise OutOfStock(f"Out of stock for sku {line.sku}") From 1a3b48d2b352ea6e2270e368ad62d446a91f8563 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 9 Apr 2019 11:23:24 +0100 Subject: [PATCH 046/146] putting it in the services layer isn't lovely either [email_in_services] --- src/allocation/domain/model.py | 2 -- src/allocation/service_layer/services.py | 11 ++++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index a9f03fd07..a48e1f69f 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from datetime import date from typing import Optional, List, Set -from allocation.adapters import email class OutOfStock(Exception): @@ -22,7 +21,6 @@ def allocate(self, line: OrderLine) -> str: self.version_number += 1 return batch.reference except StopIteration: - email.send_mail("stock@made.com", f"Out of stock for {line.sku}") raise OutOfStock(f"Out of stock for sku {line.sku}") diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index 5311ca6e0..622c86790 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -2,6 +2,7 @@ from typing import Optional from datetime import date +from allocation.adapters import email from allocation.domain import model from allocation.domain.model import OrderLine from allocation.service_layer import unit_of_work @@ -33,6 +34,10 @@ def allocate( product = uow.products.get(sku=line.sku) if product is None: raise InvalidSku(f"Invalid sku {line.sku}") - batchref = product.allocate(line) - uow.commit() - return batchref + try: + batchref = product.allocate(line) + uow.commit() + return batchref + except model.OutOfStock: + email.send_mail("stock@made.com", f"Out of stock for {line.sku}") + raise From 05f7256fc1993c266f1cb0c7e284b8001e0621ba Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 28 Mar 2019 15:45:20 +0000 Subject: [PATCH 047/146] first cut of out of stock event [domain_event] --- src/allocation/domain/events.py | 10 ++++++++++ src/allocation/domain/model.py | 6 +++++- tests/unit/test_product.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/allocation/domain/events.py diff --git a/src/allocation/domain/events.py b/src/allocation/domain/events.py new file mode 100644 index 000000000..332de9315 --- /dev/null +++ b/src/allocation/domain/events.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +class Event: + pass + + +@dataclass +class OutOfStock(Event): + sku: str diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index a48e1f69f..20c8238c9 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import date from typing import Optional, List, Set +from . import events class OutOfStock(Exception): @@ -13,6 +14,7 @@ def __init__(self, sku: str, batches: List[Batch], version_number: int = 0): self.sku = sku self.batches = batches self.version_number = version_number + self.events = [] # type: List[events.Event] def allocate(self, line: OrderLine) -> str: try: @@ -21,7 +23,9 @@ def allocate(self, line: OrderLine) -> str: self.version_number += 1 return batch.reference except StopIteration: - raise OutOfStock(f"Out of stock for sku {line.sku}") + self.events.append(events.OutOfStock(line.sku)) + # raise OutOfStock(f"Out of stock for sku {line.sku}") + return None @dataclass(unsafe_hash=True) diff --git a/tests/unit/test_product.py b/tests/unit/test_product.py index 05d753559..8fcd7ca30 100644 --- a/tests/unit/test_product.py +++ b/tests/unit/test_product.py @@ -1,5 +1,7 @@ from datetime import date, timedelta import pytest + +from allocation.domain import events from allocation.domain.model import Product, OrderLine, Batch, OutOfStock today = date.today() @@ -51,6 +53,16 @@ def test_raises_out_of_stock_exception_if_cannot_allocate(): product.allocate(OrderLine("order2", "SMALL-FORK", 1)) +def test_records_out_of_stock_event_if_cannot_allocate(): + sku1_batch = Batch("batch1", "sku1", 100, eta=today) + sku2_line = OrderLine("oref", "sku2", 10) + product = Product(sku="sku1", batches=[sku1_batch]) + + with pytest.raises(OutOfStock): + product.allocate(sku2_line) + assert product.events[-1] == events.OutOfStock(sku="sku2") + + def test_increments_version_number(): line = OrderLine("oref", "SCANDI-PEN", 10) product = Product( From ab4080ca3ed689b31a5c1dbb1bc9d8e821404593 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 9 Jun 2019 10:14:18 +0100 Subject: [PATCH 048/146] remove commented-out line from model --- src/allocation/domain/model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index 20c8238c9..159e2c752 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -24,7 +24,6 @@ def allocate(self, line: OrderLine) -> str: return batch.reference except StopIteration: self.events.append(events.OutOfStock(line.sku)) - # raise OutOfStock(f"Out of stock for sku {line.sku}") return None From 845286e3f655fc6eb55a4db49b40b0ebfeb3dc58 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 9 Jun 2019 10:14:28 +0100 Subject: [PATCH 049/146] Adjust unit tests now we're no longer raising out of stock exception --- tests/unit/test_product.py | 22 ++++++---------------- tests/unit/test_services.py | 4 +--- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/unit/test_product.py b/tests/unit/test_product.py index 8fcd7ca30..1a1482b68 100644 --- a/tests/unit/test_product.py +++ b/tests/unit/test_product.py @@ -1,8 +1,7 @@ from datetime import date, timedelta -import pytest - from allocation.domain import events -from allocation.domain.model import Product, OrderLine, Batch, OutOfStock +from allocation.domain.model import Product, OrderLine, Batch + today = date.today() tomorrow = today + timedelta(days=1) @@ -44,23 +43,14 @@ def test_returns_allocated_batch_ref(): assert allocation == in_stock_batch.reference -def test_raises_out_of_stock_exception_if_cannot_allocate(): +def test_records_out_of_stock_event_if_cannot_allocate(): batch = Batch("batch1", "SMALL-FORK", 10, eta=today) product = Product(sku="SMALL-FORK", batches=[batch]) product.allocate(OrderLine("order1", "SMALL-FORK", 10)) - with pytest.raises(OutOfStock, match="SMALL-FORK"): - product.allocate(OrderLine("order2", "SMALL-FORK", 1)) - - -def test_records_out_of_stock_event_if_cannot_allocate(): - sku1_batch = Batch("batch1", "sku1", 100, eta=today) - sku2_line = OrderLine("oref", "sku2", 10) - product = Product(sku="sku1", batches=[sku1_batch]) - - with pytest.raises(OutOfStock): - product.allocate(sku2_line) - assert product.events[-1] == events.OutOfStock(sku="sku2") + allocation = product.allocate(OrderLine("order2", "SMALL-FORK", 1)) + assert product.events[-1] == events.OutOfStock(sku="SMALL-FORK") + assert allocation is None def test_increments_version_number(): diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index a5a07a296..97db3230e 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,7 +1,6 @@ from unittest import mock import pytest from allocation.adapters import repository -from allocation.domain.model import OutOfStock from allocation.service_layer import services, unit_of_work @@ -69,8 +68,7 @@ def test_sends_email_on_out_of_stock_error(): services.add_batch("b1", "POPULAR-CURTAINS", 9, None, uow) with mock.patch("allocation.adapters.email.send_mail") as mock_send_mail: - with pytest.raises(OutOfStock): - services.allocate("o1", "POPULAR-CURTAINS", 10, uow) + services.allocate("o1", "POPULAR-CURTAINS", 10, uow) assert mock_send_mail.call_args == mock.call( "stock@made.com", f"Out of stock for POPULAR-CURTAINS", From 290914b7c1b3c20f79139aa3a9f4bb549ce467af Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 02:01:07 +0000 Subject: [PATCH 050/146] first cut of message bus [service_talks_to_messagebus] --- src/allocation/service_layer/messagebus.py | 20 ++++++++++++++++++++ src/allocation/service_layer/services.py | 13 +++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 src/allocation/service_layer/messagebus.py diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py new file mode 100644 index 000000000..497d555fa --- /dev/null +++ b/src/allocation/service_layer/messagebus.py @@ -0,0 +1,20 @@ +from typing import List, Dict, Callable, Type +from allocation.adapters import email +from allocation.domain import events + + +def handle(event: events.Event): + for handler in HANDLERS[type(event)]: + handler(event) + + +def send_out_of_stock_notification(event: events.OutOfStock): + email.send_mail( + "stock@made.com", + f"Out of stock for {event.sku}", + ) + + +HANDLERS = { + events.OutOfStock: [send_out_of_stock_notification], +} # type: Dict[Type[events.Event], List[Callable]] diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index 622c86790..3e66dd30f 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, TYPE_CHECKING from datetime import date -from allocation.adapters import email from allocation.domain import model from allocation.domain.model import OrderLine -from allocation.service_layer import unit_of_work +from . import messagebus + +if TYPE_CHECKING: + from . import unit_of_work class InvalidSku(Exception): @@ -38,6 +40,5 @@ def allocate( batchref = product.allocate(line) uow.commit() return batchref - except model.OutOfStock: - email.send_mail("stock@made.com", f"Out of stock for {line.sku}") - raise + finally: + messagebus.handle(product.events) From e1faf88d80dd3698ec0d4bebc6abb397638a3632 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 9 Apr 2019 12:57:11 +0100 Subject: [PATCH 051/146] uow now does messagebus magically. breaks tests [uow_has_messagebus] --- src/allocation/adapters/repository.py | 8 +++++++- src/allocation/service_layer/services.py | 10 +++------- src/allocation/service_layer/unit_of_work.py | 16 ++++++++++++++-- tests/unit/test_services.py | 2 +- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index 7e18dbed9..99d912841 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -1,4 +1,5 @@ import abc +from typing import Set from allocation.domain import model @@ -15,9 +16,14 @@ def get(self, sku) -> model.Product: class SqlAlchemyRepository(AbstractRepository): def __init__(self, session): self.session = session + self.seen = set() # type: Set[model.Product] def add(self, product): + self.seen.add(product) self.session.add(product) def get(self, sku): - return self.session.query(model.Product).filter_by(sku=sku).first() + product = self.session.query(model.Product).filter_by(sku=sku).first() + if product: + self.seen.add(product) + return product diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/services.py index 3e66dd30f..0a9359984 100644 --- a/src/allocation/service_layer/services.py +++ b/src/allocation/service_layer/services.py @@ -4,7 +4,6 @@ from allocation.domain import model from allocation.domain.model import OrderLine -from . import messagebus if TYPE_CHECKING: from . import unit_of_work @@ -36,9 +35,6 @@ def allocate( product = uow.products.get(sku=line.sku) if product is None: raise InvalidSku(f"Invalid sku {line.sku}") - try: - batchref = product.allocate(line) - uow.commit() - return batchref - finally: - messagebus.handle(product.events) + batchref = product.allocate(line) + uow.commit() + return batchref diff --git a/src/allocation/service_layer/unit_of_work.py b/src/allocation/service_layer/unit_of_work.py index 0c9a17e45..9a0a784cf 100644 --- a/src/allocation/service_layer/unit_of_work.py +++ b/src/allocation/service_layer/unit_of_work.py @@ -5,8 +5,10 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.orm.session import Session + from allocation import config from allocation.adapters import repository +from . import messagebus class AbstractUnitOfWork(abc.ABC): @@ -18,8 +20,18 @@ def __enter__(self) -> AbstractUnitOfWork: def __exit__(self, *args): self.rollback() - @abc.abstractmethod def commit(self): + self._commit() + self.publish_events() + + def publish_events(self): + for product in self.products.seen: + while product.events: + event = product.events.pop(0) + messagebus.handle(event) + + @abc.abstractmethod + def _commit(self): raise NotImplementedError @abc.abstractmethod @@ -48,7 +60,7 @@ def __exit__(self, *args): super().__exit__(*args) self.session.close() - def commit(self): + def _commit(self): self.session.commit() def rollback(self): diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index 97db3230e..fde65a786 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -20,7 +20,7 @@ def __init__(self): self.products = FakeRepository([]) self.committed = False - def commit(self): + def _commit(self): self.committed = True def rollback(self): From 46c62d5d386b7888abaea65af3f07f0fbe247852 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 9 Jun 2019 09:46:47 +0100 Subject: [PATCH 052/146] implement .seen on repository [repository_tracks_seen] --- src/allocation/adapters/repository.py | 29 ++++++++++++++++++--------- tests/unit/test_services.py | 5 +++-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index 99d912841..07946f93e 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -4,26 +4,35 @@ class AbstractRepository(abc.ABC): - @abc.abstractmethod + def __init__(self): + self.seen = set() # type: Set[model.Product] + def add(self, product: model.Product): + self._add(product) + self.seen.add(product) + + def get(self, sku) -> model.Product: + product = self._get(sku) + if product: + self.seen.add(product) + return product + + @abc.abstractmethod + def _add(self, product: model.Product): raise NotImplementedError @abc.abstractmethod - def get(self, sku) -> model.Product: + def _get(self, sku) -> model.Product: raise NotImplementedError class SqlAlchemyRepository(AbstractRepository): def __init__(self, session): + super().__init__() self.session = session - self.seen = set() # type: Set[model.Product] - def add(self, product): - self.seen.add(product) + def _add(self, product): self.session.add(product) - def get(self, sku): - product = self.session.query(model.Product).filter_by(sku=sku).first() - if product: - self.seen.add(product) - return product + def _get(self, sku): + return self.session.query(model.Product).filter_by(sku=sku).first() diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index fde65a786..0366fb2b8 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -6,12 +6,13 @@ class FakeRepository(repository.AbstractRepository): def __init__(self, products): + super().__init__() self._products = set(products) - def add(self, product): + def _add(self, product): self._products.add(product) - def get(self, sku): + def _get(self, sku): return next((p for p in self._products if p.sku == sku), None) From 2c901784e5d498827262706069c91ae15a4a6551 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 9 Apr 2019 13:07:55 +0100 Subject: [PATCH 053/146] a little hack in the orm so that events work --- src/allocation/adapters/orm.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index ddd1f3abc..ea76eed5e 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -1,4 +1,13 @@ -from sqlalchemy import Table, MetaData, Column, Integer, String, Date, ForeignKey +from sqlalchemy import ( + Table, + MetaData, + Column, + Integer, + String, + Date, + ForeignKey, + event, +) from sqlalchemy.orm import mapper, relationship from allocation.domain import model @@ -55,5 +64,12 @@ def start_mappers(): }, ) mapper( - model.Product, products, properties={"batches": relationship(batches_mapper)} + model.Product, + products, + properties={"batches": relationship(batches_mapper)}, ) + + +@event.listens_for(model.Product, "load") +def receive_load(product, _): + product.events = [] From 1ba5848e49aabead9626c1aeef085a116790fe15 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 9 Jun 2019 10:21:30 +0100 Subject: [PATCH 054/146] remove now unused out-of-stock exception [chapter_08_events_and_message_bus_ends] --- src/allocation/domain/model.py | 4 ---- src/allocation/entrypoints/flask_app.py | 5 +---- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index 159e2c752..02e913019 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -5,10 +5,6 @@ from . import events -class OutOfStock(Exception): - pass - - class Product: def __init__(self, sku: str, batches: List[Batch], version_number: int = 0): self.sku = sku diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index 602a09c1a..f30bd7105 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -1,9 +1,6 @@ from datetime import datetime from flask import Flask, request -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from allocation.domain import model from allocation.adapters import orm from allocation.service_layer import services, unit_of_work @@ -35,7 +32,7 @@ def allocate_endpoint(): request.json["qty"], unit_of_work.SqlAlchemyUnitOfWork(), ) - except (model.OutOfStock, services.InvalidSku) as e: + except services.InvalidSku as e: return {"message": str(e)}, 400 return {"batchref": batchref}, 201 From 28b604996600e00ad6c96b4186f1a2a6c398471e Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 26 May 2019 21:29:23 +0100 Subject: [PATCH 055/146] allocationrequired and batchcreated events [two_new_events] --- src/allocation/domain/events.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/allocation/domain/events.py b/src/allocation/domain/events.py index 332de9315..5ea87bba2 100644 --- a/src/allocation/domain/events.py +++ b/src/allocation/domain/events.py @@ -1,10 +1,28 @@ +# pylint: disable=too-few-public-methods from dataclasses import dataclass +from datetime import date +from typing import Optional class Event: pass +@dataclass +class BatchCreated(Event): + ref: str + sku: str + qty: int + eta: Optional[date] = None + + +@dataclass +class AllocationRequired(Event): + orderid: str + sku: str + qty: int + + @dataclass class OutOfStock(Event): sku: str From 3c45aa397f97f5459857834c1be528a6509c9ab9 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 02:58:58 +0000 Subject: [PATCH 056/146] change exception import in flask app --- src/allocation/entrypoints/flask_app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index f30bd7105..b6b2f3f9f 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -3,6 +3,7 @@ from allocation.adapters import orm from allocation.service_layer import services, unit_of_work +from allocation.service_layer.services import InvalidSku app = Flask(__name__) orm.start_mappers() @@ -32,7 +33,7 @@ def allocate_endpoint(): request.json["qty"], unit_of_work.SqlAlchemyUnitOfWork(), ) - except services.InvalidSku as e: + except InvalidSku as e: return {"message": str(e)}, 400 return {"batchref": batchref}, 201 From 6718fb2cd4a49cccb95fdb1f1005bb9e4c9f8f16 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 26 May 2019 21:45:42 +0100 Subject: [PATCH 057/146] move services.py to handlers.py rename test services to test handlers --- src/allocation/service_layer/{services.py => handlers.py} | 0 tests/unit/{test_services.py => test_handlers.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/allocation/service_layer/{services.py => handlers.py} (100%) rename tests/unit/{test_services.py => test_handlers.py} (100%) diff --git a/src/allocation/service_layer/services.py b/src/allocation/service_layer/handlers.py similarity index 100% rename from src/allocation/service_layer/services.py rename to src/allocation/service_layer/handlers.py diff --git a/tests/unit/test_services.py b/tests/unit/test_handlers.py similarity index 100% rename from tests/unit/test_services.py rename to tests/unit/test_handlers.py From 94c0c0c984423cdaf70e58361c55b1d3f2a555c3 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 6 Aug 2019 13:50:45 +0100 Subject: [PATCH 058/146] Start moving to handlers [services_to_handlers] --- src/allocation/adapters/email.py | 2 +- src/allocation/service_layer/handlers.py | 33 +++++++++++++++------- src/allocation/service_layer/messagebus.py | 13 +++------ 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/allocation/adapters/email.py b/src/allocation/adapters/email.py index 18b17d602..1c37d4272 100644 --- a/src/allocation/adapters/email.py +++ b/src/allocation/adapters/email.py @@ -1,2 +1,2 @@ -def send_mail(*args): +def send(*args): print("SENDING EMAIL:", *args) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 0a9359984..7cbe1e8bc 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import Optional, TYPE_CHECKING -from datetime import date - -from allocation.domain import model +from typing import TYPE_CHECKING +from allocation.domain import events, model from allocation.domain.model import OrderLine if TYPE_CHECKING: @@ -14,23 +12,25 @@ class InvalidSku(Exception): def add_batch( - ref: str, sku: str, qty: int, eta: Optional[date], + event: events.BatchCreated, uow: unit_of_work.AbstractUnitOfWork, ): with uow: - product = uow.products.get(sku=sku) + product = uow.products.get(sku=event.sku) if product is None: - product = model.Product(sku, batches=[]) + product = model.Product(event.sku, batches=[]) uow.products.add(product) - product.batches.append(model.Batch(ref, sku, qty, eta)) + product.batches.append( + model.Batch(event.ref, event.sku, event.qty, event.eta) + ) uow.commit() def allocate( - orderid: str, sku: str, qty: int, + event: events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork, ) -> str: - line = OrderLine(orderid, sku, qty) + line = OrderLine(event.orderid, event.sku, event.qty) with uow: product = uow.products.get(sku=line.sku) if product is None: @@ -38,3 +38,16 @@ def allocate( batchref = product.allocate(line) uow.commit() return batchref + + +# pylint: disable=unused-argument + + +def send_out_of_stock_notification( + event: events.OutOfStock, + uow: unit_of_work.AbstractUnitOfWork, +): + email.send( + "stock@made.com", + f"Out of stock for {event.sku}", + ) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 497d555fa..d1d5997b9 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,6 +1,6 @@ from typing import List, Dict, Callable, Type -from allocation.adapters import email from allocation.domain import events +from . import handlers def handle(event: events.Event): @@ -8,13 +8,8 @@ def handle(event: events.Event): handler(event) -def send_out_of_stock_notification(event: events.OutOfStock): - email.send_mail( - "stock@made.com", - f"Out of stock for {event.sku}", - ) - - HANDLERS = { - events.OutOfStock: [send_out_of_stock_notification], + events.BatchCreated: [handlers.add_batch], + events.AllocationRequired: [handlers.allocate], + events.OutOfStock: [handlers.send_out_of_stock_notification], } # type: Dict[Type[events.Event], List[Callable]] From c0a89dd6b23dcb6ff483c218d16133526684f3dd Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 9 Feb 2020 12:02:51 +0000 Subject: [PATCH 059/146] use classes in services/handlers test [tests_use_classes] --- tests/unit/test_handlers.py | 91 ++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 0366fb2b8..aca2180be 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,3 +1,4 @@ +# pylint: disable=no-self-use from unittest import mock import pytest from allocation.adapters import repository @@ -28,49 +29,47 @@ def rollback(self): pass -def test_add_batch_for_new_product(): - uow = FakeUnitOfWork() - services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) - assert uow.products.get("CRUNCHY-ARMCHAIR") is not None - assert uow.committed - - -def test_add_batch_for_existing_product(): - uow = FakeUnitOfWork() - services.add_batch("b1", "GARISH-RUG", 100, None, uow) - services.add_batch("b2", "GARISH-RUG", 99, None, uow) - assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] - - -def test_allocate_returns_allocation(): - uow = FakeUnitOfWork() - services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow) - result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow) - assert result == "batch1" - - -def test_allocate_errors_for_invalid_sku(): - uow = FakeUnitOfWork() - services.add_batch("b1", "AREALSKU", 100, None, uow) - - with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - services.allocate("o1", "NONEXISTENTSKU", 10, uow) - - -def test_allocate_commits(): - uow = FakeUnitOfWork() - services.add_batch("b1", "OMINOUS-MIRROR", 100, None, uow) - services.allocate("o1", "OMINOUS-MIRROR", 10, uow) - assert uow.committed - - -def test_sends_email_on_out_of_stock_error(): - uow = FakeUnitOfWork() - services.add_batch("b1", "POPULAR-CURTAINS", 9, None, uow) - - with mock.patch("allocation.adapters.email.send_mail") as mock_send_mail: - services.allocate("o1", "POPULAR-CURTAINS", 10, uow) - assert mock_send_mail.call_args == mock.call( - "stock@made.com", - f"Out of stock for POPULAR-CURTAINS", - ) +class TestAddBatch: + def test_for_new_product(self): + uow = FakeUnitOfWork() + services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) + assert uow.products.get("CRUNCHY-ARMCHAIR") is not None + assert uow.committed + + def test_for_existing_product(self): + uow = FakeUnitOfWork() + services.add_batch("b1", "GARISH-RUG", 100, None, uow) + services.add_batch("b2", "GARISH-RUG", 99, None, uow) + assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] + + +class TestAllocate: + def test_returns_allocation(self): + uow = FakeUnitOfWork() + services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow) + result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow) + assert result == "batch1" + + def test_errors_for_invalid_sku(self): + uow = FakeUnitOfWork() + services.add_batch("b1", "AREALSKU", 100, None, uow) + + with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): + services.allocate("o1", "NONEXISTENTSKU", 10, uow) + + def test_commits(self): + uow = FakeUnitOfWork() + services.add_batch("b1", "OMINOUS-MIRROR", 100, None, uow) + services.allocate("o1", "OMINOUS-MIRROR", 10, uow) + assert uow.committed + + def test_sends_email_on_out_of_stock_error(self): + uow = FakeUnitOfWork() + services.add_batch("b1", "POPULAR-CURTAINS", 9, None, uow) + + with mock.patch("allocation.adapters.email.send_mail") as mock_send_mail: + services.allocate("o1", "POPULAR-CURTAINS", 10, uow) + assert mock_send_mail.call_args == mock.call( + "stock@made.com", + f"Out of stock for POPULAR-CURTAINS", + ) From 5647d0f2909c34c40b3fbd3a20c0d823f7962e8c Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 9 Feb 2020 12:08:05 +0000 Subject: [PATCH 060/146] tests change to use bus [handler_tests] --- tests/unit/test_handlers.py | 43 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index aca2180be..bb041b949 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,8 +1,10 @@ # pylint: disable=no-self-use from unittest import mock import pytest + from allocation.adapters import repository -from allocation.service_layer import services, unit_of_work +from allocation.domain import events +from allocation.service_layer import handlers, messagebus, unit_of_work class FakeRepository(repository.AbstractRepository): @@ -32,44 +34,53 @@ def rollback(self): class TestAddBatch: def test_for_new_product(self): uow = FakeUnitOfWork() - services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow) + messagebus.handle( + events.BatchCreated("b1", "CRUNCHY-ARMCHAIR", 100, None), uow + ) assert uow.products.get("CRUNCHY-ARMCHAIR") is not None assert uow.committed def test_for_existing_product(self): uow = FakeUnitOfWork() - services.add_batch("b1", "GARISH-RUG", 100, None, uow) - services.add_batch("b2", "GARISH-RUG", 99, None, uow) + messagebus.handle(events.BatchCreated("b1", "GARISH-RUG", 100, None), uow) + messagebus.handle(events.BatchCreated("b2", "GARISH-RUG", 99, None), uow) assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] class TestAllocate: def test_returns_allocation(self): uow = FakeUnitOfWork() - services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow) - result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow) + messagebus.handle( + events.BatchCreated("batch1", "COMPLICATED-LAMP", 100, None), uow + ) + result = messagebus.handle( + events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), uow + ) assert result == "batch1" def test_errors_for_invalid_sku(self): uow = FakeUnitOfWork() - services.add_batch("b1", "AREALSKU", 100, None, uow) + messagebus.handle(events.BatchCreated("b1", "AREALSKU", 100, None), uow) - with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - services.allocate("o1", "NONEXISTENTSKU", 10, uow) + with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): + messagebus.handle( + events.AllocationRequired("o1", "NONEXISTENTSKU", 10), uow + ) def test_commits(self): uow = FakeUnitOfWork() - services.add_batch("b1", "OMINOUS-MIRROR", 100, None, uow) - services.allocate("o1", "OMINOUS-MIRROR", 10, uow) + messagebus.handle(events.BatchCreated("b1", "OMINOUS-MIRROR", 100, None), uow) + messagebus.handle(events.AllocationRequired("o1", "OMINOUS-MIRROR", 10), uow) assert uow.committed def test_sends_email_on_out_of_stock_error(self): uow = FakeUnitOfWork() - services.add_batch("b1", "POPULAR-CURTAINS", 9, None, uow) + messagebus.handle(events.BatchCreated("b1", "POPULAR-CURTAINS", 9, None), uow) - with mock.patch("allocation.adapters.email.send_mail") as mock_send_mail: - services.allocate("o1", "POPULAR-CURTAINS", 10, uow) + with mock.patch("allocation.adapters.email.send") as mock_send_mail: + messagebus.handle( + events.AllocationRequired("o1", "POPULAR-CURTAINS", 10), uow + ) assert mock_send_mail.call_args == mock.call( - "stock@made.com", - f"Out of stock for POPULAR-CURTAINS", + "stock@made.com", f"Out of stock for POPULAR-CURTAINS" ) From ea72d42a63908d8d4a2299083b720b512f3b9569 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 02:46:32 +0000 Subject: [PATCH 061/146] messagebus takes a uow [handle_takes_uow] --- src/allocation/service_layer/messagebus.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index d1d5997b9..cd9ba7efb 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,11 +1,18 @@ -from typing import List, Dict, Callable, Type +from __future__ import annotations +from typing import List, Dict, Callable, Type, TYPE_CHECKING from allocation.domain import events from . import handlers +if TYPE_CHECKING: + from allocation.service_layer import unit_of_work -def handle(event: events.Event): + +def handle( + event: events.Event, + uow: unit_of_work.AbstractUnitOfWork, +): for handler in HANDLERS[type(event)]: - handler(event) + handler(event, uow=uow) HANDLERS = { From b7c6e7e42addde1014a4a9ec2afa5e0a1782456e Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 22:07:35 +0000 Subject: [PATCH 062/146] messagebus has uow, manages queue [handle_has_uow_and_queue] --- src/allocation/service_layer/messagebus.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index cd9ba7efb..23b6c49ea 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -4,15 +4,19 @@ from . import handlers if TYPE_CHECKING: - from allocation.service_layer import unit_of_work + from . import unit_of_work def handle( event: events.Event, uow: unit_of_work.AbstractUnitOfWork, ): - for handler in HANDLERS[type(event)]: - handler(event, uow=uow) + queue = [event] + while queue: + event = queue.pop(0) + for handler in HANDLERS[type(event)]: + handler(event, uow=uow) + queue.extend(uow.collect_new_events()) HANDLERS = { From b40f8ed0325902b5531354ebbe0d84232cbd99de Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 22:08:03 +0000 Subject: [PATCH 063/146] Uow no longer puts events directly on the bus [uow_collect_new_events] --- src/allocation/service_layer/unit_of_work.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/allocation/service_layer/unit_of_work.py b/src/allocation/service_layer/unit_of_work.py index 9a0a784cf..586ae027e 100644 --- a/src/allocation/service_layer/unit_of_work.py +++ b/src/allocation/service_layer/unit_of_work.py @@ -8,7 +8,6 @@ from allocation import config from allocation.adapters import repository -from . import messagebus class AbstractUnitOfWork(abc.ABC): @@ -22,13 +21,11 @@ def __exit__(self, *args): def commit(self): self._commit() - self.publish_events() - def publish_events(self): + def collect_new_events(self): for product in self.products.seen: while product.events: - event = product.events.pop(0) - messagebus.handle(event) + yield product.events.pop(0) @abc.abstractmethod def _commit(self): From 8f3e8b217467e4e6902966790b874c36ccd2a95e Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 6 Aug 2019 12:57:30 +0100 Subject: [PATCH 064/146] Ugly hack for messagebus to return results [hack_messagebus_results] --- src/allocation/service_layer/messagebus.py | 4 +++- tests/unit/test_handlers.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 23b6c49ea..9c0217fc4 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -11,12 +11,14 @@ def handle( event: events.Event, uow: unit_of_work.AbstractUnitOfWork, ): + results = [] queue = [event] while queue: event = queue.pop(0) for handler in HANDLERS[type(event)]: - handler(event, uow=uow) + results.append(handler(event, uow=uow)) queue.extend(uow.collect_new_events()) + return results HANDLERS = { diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index bb041b949..fea84fd7d 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -53,10 +53,10 @@ def test_returns_allocation(self): messagebus.handle( events.BatchCreated("batch1", "COMPLICATED-LAMP", 100, None), uow ) - result = messagebus.handle( + results = messagebus.handle( events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), uow ) - assert result == "batch1" + assert results.pop(0) == "batch1" def test_errors_for_invalid_sku(self): uow = FakeUnitOfWork() From 1f4068237964d5fa46ea42fa7cbe2dbdc434a98e Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 6 Aug 2019 12:33:23 +0100 Subject: [PATCH 065/146] modify flask to use messagebus [flask_uses_messagebus] --- src/allocation/entrypoints/flask_app.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index b6b2f3f9f..51922760b 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -1,9 +1,10 @@ from datetime import datetime from flask import Flask, request +from allocation.domain import events from allocation.adapters import orm -from allocation.service_layer import services, unit_of_work -from allocation.service_layer.services import InvalidSku +from allocation.service_layer import messagebus, unit_of_work +from allocation.service_layer.handlers import InvalidSku app = Flask(__name__) orm.start_mappers() @@ -14,25 +15,21 @@ def add_batch(): eta = request.json["eta"] if eta is not None: eta = datetime.fromisoformat(eta).date() - services.add_batch( - request.json["ref"], - request.json["sku"], - request.json["qty"], - eta, - unit_of_work.SqlAlchemyUnitOfWork(), + event = events.BatchCreated( + request.json["ref"], request.json["sku"], request.json["qty"], eta ) + messagebus.handle(event, unit_of_work.SqlAlchemyUnitOfWork()) return "OK", 201 @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): try: - batchref = services.allocate( - request.json["orderid"], - request.json["sku"], - request.json["qty"], - unit_of_work.SqlAlchemyUnitOfWork(), + event = events.AllocationRequired( + request.json["orderid"], request.json["sku"], request.json["qty"] ) + results = messagebus.handle(event, unit_of_work.SqlAlchemyUnitOfWork()) + batchref = results.pop(0) except InvalidSku as e: return {"message": str(e)}, 400 From e4e48bd28d10a1ea32906fbe5fcf959d4d30c945 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 6 Aug 2019 13:27:32 +0100 Subject: [PATCH 066/146] new event for new input [batch_quantity_changed_event] --- src/allocation/domain/events.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/allocation/domain/events.py b/src/allocation/domain/events.py index 5ea87bba2..135ae74b7 100644 --- a/src/allocation/domain/events.py +++ b/src/allocation/domain/events.py @@ -16,6 +16,12 @@ class BatchCreated(Event): eta: Optional[date] = None +@dataclass +class BatchQuantityChanged(Event): + ref: str + qty: int + + @dataclass class AllocationRequired(Event): orderid: str From d13475bfa2cb9e18f82d6a27c26f04815f13aa2f Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 25 May 2019 10:11:42 +0100 Subject: [PATCH 067/146] new test and put them into classes [test_change_batch_quantity_handler] --- tests/unit/test_handlers.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index fea84fd7d..0ac970478 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,4 +1,5 @@ # pylint: disable=no-self-use +from datetime import date from unittest import mock import pytest @@ -84,3 +85,38 @@ def test_sends_email_on_out_of_stock_error(self): assert mock_send_mail.call_args == mock.call( "stock@made.com", f"Out of stock for POPULAR-CURTAINS" ) + + +class TestChangeBatchQuantity: + def test_changes_available_quantity(self): + uow = FakeUnitOfWork() + messagebus.handle( + events.BatchCreated("batch1", "ADORABLE-SETTEE", 100, None), uow + ) + [batch] = uow.products.get(sku="ADORABLE-SETTEE").batches + assert batch.available_quantity == 100 + + messagebus.handle(events.BatchQuantityChanged("batch1", 50), uow) + + assert batch.available_quantity == 50 + + def test_reallocates_if_necessary(self): + uow = FakeUnitOfWork() + event_history = [ + events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None), + events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()), + events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20), + events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20), + ] + for e in event_history: + messagebus.handle(e, uow) + [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches + assert batch1.available_quantity == 10 + assert batch2.available_quantity == 50 + + messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow) + + # order1 or order2 will be deallocated, so we'll have 25 - 20 + assert batch1.available_quantity == 5 + # and 20 will be reallocated to the next batch + assert batch2.available_quantity == 30 From 1c365c798472560d97a3b3d0dc5ded96cabf0a7e Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 25 May 2019 12:14:35 +0100 Subject: [PATCH 068/146] start on handler for change quantity [change_quantity_handler] --- src/allocation/service_layer/handlers.py | 11 +++++++++++ src/allocation/service_layer/messagebus.py | 1 + 2 files changed, 12 insertions(+) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 7cbe1e8bc..957aacfe6 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,5 +1,6 @@ from __future__ import annotations from typing import TYPE_CHECKING +from allocation.adapters import email from allocation.domain import events, model from allocation.domain.model import OrderLine @@ -40,6 +41,16 @@ def allocate( return batchref +def change_batch_quantity( + event: events.BatchQuantityChanged, + uow: unit_of_work.AbstractUnitOfWork, +): + with uow: + product = uow.products.get_by_batchref(batchref=event.ref) + product.change_batch_quantity(ref=event.ref, qty=event.qty) + uow.commit() + + # pylint: disable=unused-argument diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 9c0217fc4..b5b72844a 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -23,6 +23,7 @@ def handle( HANDLERS = { events.BatchCreated: [handlers.add_batch], + events.BatchQuantityChanged: [handlers.change_batch_quantity], events.AllocationRequired: [handlers.allocate], events.OutOfStock: [handlers.send_out_of_stock_notification], } # type: Dict[Type[events.Event], List[Callable]] From 71a6fa34af07669df4814dead1e3731ef65297f7 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 28 May 2019 10:40:46 +0100 Subject: [PATCH 069/146] get_by_batchref in abstract+real repo, and a new integration test for it [get_by_batchref] --- src/allocation/adapters/repository.py | 19 +++++++++++++++++++ tests/integration/test_repository.py | 15 +++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/integration/test_repository.py diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index 07946f93e..f8821758c 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -1,5 +1,6 @@ import abc from typing import Set +from allocation.adapters import orm from allocation.domain import model @@ -17,6 +18,12 @@ def get(self, sku) -> model.Product: self.seen.add(product) return product + def get_by_batchref(self, batchref) -> model.Product: + product = self._get_by_batchref(batchref) + if product: + self.seen.add(product) + return product + @abc.abstractmethod def _add(self, product: model.Product): raise NotImplementedError @@ -25,6 +32,10 @@ def _add(self, product: model.Product): def _get(self, sku) -> model.Product: raise NotImplementedError + @abc.abstractmethod + def _get_by_batchref(self, batchref) -> model.Product: + raise NotImplementedError + class SqlAlchemyRepository(AbstractRepository): def __init__(self, session): @@ -36,3 +47,11 @@ def _add(self, product): def _get(self, sku): return self.session.query(model.Product).filter_by(sku=sku).first() + + def _get_by_batchref(self, batchref): + return ( + self.session.query(model.Product) + .join(model.Batch) + .filter(orm.batches.c.reference == batchref) + .first() + ) diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py new file mode 100644 index 000000000..a73bcd519 --- /dev/null +++ b/tests/integration/test_repository.py @@ -0,0 +1,15 @@ +from allocation.adapters import repository +from allocation.domain import model + + +def test_get_by_batchref(session): + repo = repository.SqlAlchemyRepository(session) + b1 = model.Batch(ref="b1", sku="sku1", qty=100, eta=None) + b2 = model.Batch(ref="b2", sku="sku1", qty=100, eta=None) + b3 = model.Batch(ref="b3", sku="sku2", qty=100, eta=None) + p1 = model.Product(sku="sku1", batches=[b1, b2]) + p2 = model.Product(sku="sku2", batches=[b3]) + repo.add(p1) + repo.add(p2) + assert repo.get_by_batchref("b2") == p1 + assert repo.get_by_batchref("b3") == p2 From 8f67133de3e3dd94ede7df2bcb5ce4aa024fdaba Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 28 May 2019 10:41:12 +0100 Subject: [PATCH 070/146] fake repo get_by_batchref [fakerepo_get_by_batchref] --- tests/unit/test_handlers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 0ac970478..42b5b8952 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -19,6 +19,12 @@ def _add(self, product): def _get(self, sku): return next((p for p in self._products if p.sku == sku), None) + def _get_by_batchref(self, batchref): + return next( + (p for p in self._products for b in p.batches if b.reference == batchref), + None, + ) + class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): def __init__(self): From be5b945aa5541b23346d1321d6db94e72adc2068 Mon Sep 17 00:00:00 2001 From: Harry Date: Sat, 25 May 2019 18:01:26 +0100 Subject: [PATCH 071/146] change_batch_quantity on product, needed change to batch, also emit allocated event. [change_batch_model_layer] --- src/allocation/domain/model.py | 14 +++++++++++--- tests/unit/test_batches.py | 13 ------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index 02e913019..7aa994170 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -22,6 +22,15 @@ def allocate(self, line: OrderLine) -> str: self.events.append(events.OutOfStock(line.sku)) return None + def change_batch_quantity(self, ref: str, qty: int): + batch = next(b for b in self.batches if b.reference == ref) + batch._purchased_quantity = qty + while batch.available_quantity < 0: + line = batch.deallocate_one() + self.events.append( + events.AllocationRequired(line.orderid, line.sku, line.qty) + ) + @dataclass(unsafe_hash=True) class OrderLine: @@ -60,9 +69,8 @@ def allocate(self, line: OrderLine): if self.can_allocate(line): self._allocations.add(line) - def deallocate(self, line: OrderLine): - if line in self._allocations: - self._allocations.remove(line) + def deallocate_one(self) -> OrderLine: + return self._allocations.pop() @property def allocated_quantity(self) -> int: diff --git a/tests/unit/test_batches.py b/tests/unit/test_batches.py index 8735f41e7..d6464b116 100644 --- a/tests/unit/test_batches.py +++ b/tests/unit/test_batches.py @@ -44,16 +44,3 @@ def test_allocation_is_idempotent(): batch.allocate(line) batch.allocate(line) assert batch.available_quantity == 18 - - -def test_deallocate(): - batch, line = make_batch_and_line("EXPENSIVE-FOOTSTOOL", 20, 2) - batch.allocate(line) - batch.deallocate(line) - assert batch.available_quantity == 20 - - -def test_can_only_deallocate_allocated_lines(): - batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2) - batch.deallocate(unallocated_line) - assert batch.available_quantity == 20 From 1cad6e8a2f2c936700dec65ea7c51b731c8e2514 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 29 Oct 2019 12:37:46 +0000 Subject: [PATCH 072/146] sort-of fake messagebus on fake-uow. [fake_messagebus] --- tests/unit/test_handlers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 42b5b8952..c887c84c4 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,6 +1,7 @@ # pylint: disable=no-self-use from datetime import date from unittest import mock +from typing import List import pytest from allocation.adapters import repository @@ -126,3 +127,14 @@ def test_reallocates_if_necessary(self): assert batch1.available_quantity == 5 # and 20 will be reallocated to the next batch assert batch2.available_quantity == 30 + + +class FakeUnitOfWorkWithFakeMessageBus(FakeUnitOfWork): + def __init__(self): + super().__init__() + self.events_published = [] # type: List[events.Event] + + def publish_events(self): + for product in self.products.seen: + while product.events: + self.events_published.append(product.events.pop(0)) From 68ce4019440c7b86ebc0329737782a000e645f52 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 29 Oct 2019 12:39:06 +0000 Subject: [PATCH 073/146] isolated test for a handler [test_handler_in_isolation] --- tests/unit/test_handlers.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index c887c84c4..4b6c15405 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -138,3 +138,28 @@ def publish_events(self): for product in self.products.seen: while product.events: self.events_published.append(product.events.pop(0)) + + +def test_reallocates_if_necessary_isolated(): + uow = FakeUnitOfWorkWithFakeMessageBus() + + # test setup as before + event_history = [ + events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None), + events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()), + events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20), + events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20), + ] + for e in event_history: + messagebus.handle(e, uow) + [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches + assert batch1.available_quantity == 10 + assert batch2.available_quantity == 50 + + messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow) + + # assert on new events emitted rather than downstream side-effects + [reallocation_event] = uow.events_published + assert isinstance(reallocation_event, events.AllocationRequired) + assert reallocation_event.orderid in {"order1", "order2"} + assert reallocation_event.sku == "INDIFFERENT-TABLE" From 6e6937ca5d7a77a0e9e402298a9b7850a848aa0f Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 29 Oct 2019 12:40:06 +0000 Subject: [PATCH 074/146] Revert fake messagebus stuff [chapter_09_all_messagebus_ends] This reverts commit 8f2c36f43fbbc9b4cfa42f9ee75acb4f486d4342. This reverts commit d3988118e478db2748d7b41d3ad70c6a22ac9f9f. --- tests/unit/test_handlers.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 4b6c15405..42b5b8952 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,7 +1,6 @@ # pylint: disable=no-self-use from datetime import date from unittest import mock -from typing import List import pytest from allocation.adapters import repository @@ -127,39 +126,3 @@ def test_reallocates_if_necessary(self): assert batch1.available_quantity == 5 # and 20 will be reallocated to the next batch assert batch2.available_quantity == 30 - - -class FakeUnitOfWorkWithFakeMessageBus(FakeUnitOfWork): - def __init__(self): - super().__init__() - self.events_published = [] # type: List[events.Event] - - def publish_events(self): - for product in self.products.seen: - while product.events: - self.events_published.append(product.events.pop(0)) - - -def test_reallocates_if_necessary_isolated(): - uow = FakeUnitOfWorkWithFakeMessageBus() - - # test setup as before - event_history = [ - events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None), - events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()), - events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20), - events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20), - ] - for e in event_history: - messagebus.handle(e, uow) - [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches - assert batch1.available_quantity == 10 - assert batch2.available_quantity == 50 - - messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow) - - # assert on new events emitted rather than downstream side-effects - [reallocation_event] = uow.events_published - assert isinstance(reallocation_event, events.AllocationRequired) - assert reallocation_event.orderid in {"order1", "order2"} - assert reallocation_event.sku == "INDIFFERENT-TABLE" From bfbee2e9a3ae7e39a8d1ab3f3640e267a5850928 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 17:12:54 +0100 Subject: [PATCH 075/146] add commands [commands_dot_py] --- src/allocation/domain/commands.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/allocation/domain/commands.py diff --git a/src/allocation/domain/commands.py b/src/allocation/domain/commands.py new file mode 100644 index 000000000..5473eff4b --- /dev/null +++ b/src/allocation/domain/commands.py @@ -0,0 +1,29 @@ +# pylint: disable=too-few-public-methods +from datetime import date +from typing import Optional +from dataclasses import dataclass + + +class Command: + pass + + +@dataclass +class Allocate(Command): + orderid: str + sku: str + qty: int + + +@dataclass +class CreateBatch(Command): + ref: str + sku: str + qty: int + eta: Optional[date] = None + + +@dataclass +class ChangeBatchQuantity(Command): + ref: str + qty: int From 6157d486314c64cc37c78e47d61eff15383f4470 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 12:44:24 +0000 Subject: [PATCH 076/146] top-level messagebus.handle dispatches events vs comannds [messagebus_dispatches_differently] --- src/allocation/service_layer/messagebus.py | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index b5b72844a..0765ee06d 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,23 +1,33 @@ +# pylint: disable=bare-except from __future__ import annotations -from typing import List, Dict, Callable, Type, TYPE_CHECKING -from allocation.domain import events +import logging +from typing import List, Dict, Callable, Type, Union, TYPE_CHECKING +from allocation.domain import commands, events from . import handlers if TYPE_CHECKING: from . import unit_of_work +logger = logging.getLogger(__name__) + +Message = Union[commands.Command, events.Event] + def handle( - event: events.Event, + message: Message, uow: unit_of_work.AbstractUnitOfWork, ): results = [] - queue = [event] + queue = [message] while queue: - event = queue.pop(0) - for handler in HANDLERS[type(event)]: - results.append(handler(event, uow=uow)) - queue.extend(uow.collect_new_events()) + message = queue.pop(0) + if isinstance(message, events.Event): + handle_event(message, queue, uow) + elif isinstance(message, commands.Command): + cmd_result = handle_command(message, queue, uow) + results.append(cmd_result) + else: + raise Exception(f"{message} was not an Event or Command") return results From c31d7651a8edb9bb2b427f2478436c1ccc512817 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 14:33:15 +0000 Subject: [PATCH 077/146] subhandler for events [handle_event] --- src/allocation/service_layer/messagebus.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 0765ee06d..f6f44c17d 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -31,6 +31,21 @@ def handle( return results +def handle_event( + event: events.Event, + queue: List[Message], + uow: unit_of_work.AbstractUnitOfWork, +): + for handler in EVENT_HANDLERS[type(event)]: + try: + logger.debug("handling event %s with handler %s", event, handler) + handler(event, uow=uow) + queue.extend(uow.collect_new_events()) + except Exception: + logger.exception("Exception handling event %s", event) + continue + + HANDLERS = { events.BatchCreated: [handlers.add_batch], events.BatchQuantityChanged: [handlers.change_batch_quantity], From de1a7b4454ce5ba60ab5d73dbf78759f314b88ad Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 14:33:45 +0000 Subject: [PATCH 078/146] subhandler for commands [handle_command] --- src/allocation/service_layer/messagebus.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index f6f44c17d..e1bb4589a 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,4 +1,4 @@ -# pylint: disable=bare-except +# pylint: disable=broad-except from __future__ import annotations import logging from typing import List, Dict, Callable, Type, Union, TYPE_CHECKING @@ -46,6 +46,22 @@ def handle_event( continue +def handle_command( + command: commands.Command, + queue: List[Message], + uow: unit_of_work.AbstractUnitOfWork, +): + logger.debug("handling command %s", command) + try: + handler = COMMAND_HANDLERS[type(command)] + result = handler(command, uow=uow) + queue.extend(uow.collect_new_events()) + return result + except Exception: + logger.exception("Exception handling command %s", command) + raise + + HANDLERS = { events.BatchCreated: [handlers.add_batch], events.BatchQuantityChanged: [handlers.change_batch_quantity], From 3a18a9d86b3d725bffcededc68ec501115ddebc8 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 12:45:59 +0000 Subject: [PATCH 079/146] handlers dicts split in two [two_hander_dicts] --- src/allocation/service_layer/messagebus.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index e1bb4589a..8bac77a2c 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -62,9 +62,12 @@ def handle_command( raise -HANDLERS = { - events.BatchCreated: [handlers.add_batch], - events.BatchQuantityChanged: [handlers.change_batch_quantity], - events.AllocationRequired: [handlers.allocate], +EVENT_HANDLERS = { events.OutOfStock: [handlers.send_out_of_stock_notification], } # type: Dict[Type[events.Event], List[Callable]] + +COMMAND_HANDLERS = { + commands.Allocate: handlers.allocate, + commands.CreateBatch: handlers.add_batch, + commands.ChangeBatchQuantity: handlers.change_batch_quantity, +} # type: Dict[Type[commands.Command], Callable] From 8fdf1197f5904d674b73a701c0e88ebff3dd5357 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 17:32:38 +0100 Subject: [PATCH 080/146] remove old events --- src/allocation/domain/events.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/allocation/domain/events.py b/src/allocation/domain/events.py index 135ae74b7..ba90ea05d 100644 --- a/src/allocation/domain/events.py +++ b/src/allocation/domain/events.py @@ -1,34 +1,11 @@ # pylint: disable=too-few-public-methods from dataclasses import dataclass -from datetime import date -from typing import Optional class Event: pass -@dataclass -class BatchCreated(Event): - ref: str - sku: str - qty: int - eta: Optional[date] = None - - -@dataclass -class BatchQuantityChanged(Event): - ref: str - qty: int - - -@dataclass -class AllocationRequired(Event): - orderid: str - sku: str - qty: int - - @dataclass class OutOfStock(Event): sku: str From 1d2b68c75b1256b6a06e583aabe9087fd4288ae9 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 17:37:40 +0100 Subject: [PATCH 081/146] handlers take commands now, modify tests too --- src/allocation/service_layer/handlers.py | 25 +++++------ tests/unit/test_handlers.py | 57 ++++++++++++------------ 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 957aacfe6..8c7b23d59 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from allocation.adapters import email -from allocation.domain import events, model +from allocation.domain import commands, events, model from allocation.domain.model import OrderLine if TYPE_CHECKING: @@ -13,25 +13,23 @@ class InvalidSku(Exception): def add_batch( - event: events.BatchCreated, + cmd: commands.CreateBatch, uow: unit_of_work.AbstractUnitOfWork, ): with uow: - product = uow.products.get(sku=event.sku) + product = uow.products.get(sku=cmd.sku) if product is None: - product = model.Product(event.sku, batches=[]) + product = model.Product(cmd.sku, batches=[]) uow.products.add(product) - product.batches.append( - model.Batch(event.ref, event.sku, event.qty, event.eta) - ) + product.batches.append(model.Batch(cmd.ref, cmd.sku, cmd.qty, cmd.eta)) uow.commit() def allocate( - event: events.AllocationRequired, + cmd: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork, ) -> str: - line = OrderLine(event.orderid, event.sku, event.qty) + line = OrderLine(cmd.orderid, cmd.sku, cmd.qty) with uow: product = uow.products.get(sku=line.sku) if product is None: @@ -42,18 +40,15 @@ def allocate( def change_batch_quantity( - event: events.BatchQuantityChanged, + cmd: commands.ChangeBatchQuantity, uow: unit_of_work.AbstractUnitOfWork, ): with uow: - product = uow.products.get_by_batchref(batchref=event.ref) - product.change_batch_quantity(ref=event.ref, qty=event.qty) + product = uow.products.get_by_batchref(batchref=cmd.ref) + product.change_batch_quantity(ref=cmd.ref, qty=cmd.qty) uow.commit() -# pylint: disable=unused-argument - - def send_out_of_stock_notification( event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork, diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 42b5b8952..d73726fed 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -2,9 +2,8 @@ from datetime import date from unittest import mock import pytest - from allocation.adapters import repository -from allocation.domain import events +from allocation.domain import commands, events from allocation.service_layer import handlers, messagebus, unit_of_work @@ -42,52 +41,54 @@ class TestAddBatch: def test_for_new_product(self): uow = FakeUnitOfWork() messagebus.handle( - events.BatchCreated("b1", "CRUNCHY-ARMCHAIR", 100, None), uow + commands.CreateBatch("b1", "CRUNCHY-ARMCHAIR", 100, None), uow ) assert uow.products.get("CRUNCHY-ARMCHAIR") is not None assert uow.committed def test_for_existing_product(self): uow = FakeUnitOfWork() - messagebus.handle(events.BatchCreated("b1", "GARISH-RUG", 100, None), uow) - messagebus.handle(events.BatchCreated("b2", "GARISH-RUG", 99, None), uow) + messagebus.handle(commands.CreateBatch("b1", "GARISH-RUG", 100, None), uow) + messagebus.handle(commands.CreateBatch("b2", "GARISH-RUG", 99, None), uow) assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] class TestAllocate: - def test_returns_allocation(self): + def test_allocates(self): uow = FakeUnitOfWork() messagebus.handle( - events.BatchCreated("batch1", "COMPLICATED-LAMP", 100, None), uow + commands.CreateBatch("batch1", "COMPLICATED-LAMP", 100, None), uow ) results = messagebus.handle( - events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), uow + commands.Allocate("o1", "COMPLICATED-LAMP", 10), uow ) assert results.pop(0) == "batch1" + [batch] = uow.products.get("COMPLICATED-LAMP").batches + assert batch.available_quantity == 90 def test_errors_for_invalid_sku(self): uow = FakeUnitOfWork() - messagebus.handle(events.BatchCreated("b1", "AREALSKU", 100, None), uow) + messagebus.handle(commands.CreateBatch("b1", "AREALSKU", 100, None), uow) with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - messagebus.handle( - events.AllocationRequired("o1", "NONEXISTENTSKU", 10), uow - ) + messagebus.handle(commands.Allocate("o1", "NONEXISTENTSKU", 10), uow) def test_commits(self): uow = FakeUnitOfWork() - messagebus.handle(events.BatchCreated("b1", "OMINOUS-MIRROR", 100, None), uow) - messagebus.handle(events.AllocationRequired("o1", "OMINOUS-MIRROR", 10), uow) + messagebus.handle( + commands.CreateBatch("b1", "OMINOUS-MIRROR", 100, None), uow + ) + messagebus.handle(commands.Allocate("o1", "OMINOUS-MIRROR", 10), uow) assert uow.committed def test_sends_email_on_out_of_stock_error(self): uow = FakeUnitOfWork() - messagebus.handle(events.BatchCreated("b1", "POPULAR-CURTAINS", 9, None), uow) + messagebus.handle( + commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None), uow + ) with mock.patch("allocation.adapters.email.send") as mock_send_mail: - messagebus.handle( - events.AllocationRequired("o1", "POPULAR-CURTAINS", 10), uow - ) + messagebus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10), uow) assert mock_send_mail.call_args == mock.call( "stock@made.com", f"Out of stock for POPULAR-CURTAINS" ) @@ -97,30 +98,30 @@ class TestChangeBatchQuantity: def test_changes_available_quantity(self): uow = FakeUnitOfWork() messagebus.handle( - events.BatchCreated("batch1", "ADORABLE-SETTEE", 100, None), uow + commands.CreateBatch("batch1", "ADORABLE-SETTEE", 100, None), uow ) [batch] = uow.products.get(sku="ADORABLE-SETTEE").batches assert batch.available_quantity == 100 - messagebus.handle(events.BatchQuantityChanged("batch1", 50), uow) + messagebus.handle(commands.ChangeBatchQuantity("batch1", 50), uow) assert batch.available_quantity == 50 def test_reallocates_if_necessary(self): uow = FakeUnitOfWork() - event_history = [ - events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None), - events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()), - events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20), - events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20), + history = [ + commands.CreateBatch("batch1", "INDIFFERENT-TABLE", 50, None), + commands.CreateBatch("batch2", "INDIFFERENT-TABLE", 50, date.today()), + commands.Allocate("order1", "INDIFFERENT-TABLE", 20), + commands.Allocate("order2", "INDIFFERENT-TABLE", 20), ] - for e in event_history: - messagebus.handle(e, uow) + for msg in history: + messagebus.handle(msg, uow) [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches assert batch1.available_quantity == 10 assert batch2.available_quantity == 50 - messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow) + messagebus.handle(commands.ChangeBatchQuantity("batch1", 25), uow) # order1 or order2 will be deallocated, so we'll have 25 - 20 assert batch1.available_quantity == 5 From 7aed316fe580b32a7369b585d8f09048f062210d Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 17:39:20 +0100 Subject: [PATCH 082/146] model method for change batch qty now raise commands hmmm --- src/allocation/domain/model.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index 7aa994170..9d74742ca 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from datetime import date from typing import Optional, List, Set -from . import events +from . import commands, events class Product: @@ -27,9 +27,7 @@ def change_batch_quantity(self, ref: str, qty: int): batch._purchased_quantity = qty while batch.available_quantity < 0: line = batch.deallocate_one() - self.events.append( - events.AllocationRequired(line.orderid, line.sku, line.qty) - ) + self.events.append(commands.Allocate(line.orderid, line.sku, line.qty)) @dataclass(unsafe_hash=True) From d958887750de7c9a5b7ddfb388e067c03ce8f285 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 17:39:28 +0100 Subject: [PATCH 083/146] flask now uses commands [chapter_10_commands_ends] --- src/allocation/entrypoints/flask_app.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index 51922760b..4d8e3204e 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -1,7 +1,7 @@ from datetime import datetime from flask import Flask, request -from allocation.domain import events +from allocation.domain import commands from allocation.adapters import orm from allocation.service_layer import messagebus, unit_of_work from allocation.service_layer.handlers import InvalidSku @@ -15,20 +15,22 @@ def add_batch(): eta = request.json["eta"] if eta is not None: eta = datetime.fromisoformat(eta).date() - event = events.BatchCreated( + cmd = commands.CreateBatch( request.json["ref"], request.json["sku"], request.json["qty"], eta ) - messagebus.handle(event, unit_of_work.SqlAlchemyUnitOfWork()) + uow = unit_of_work.SqlAlchemyUnitOfWork() + messagebus.handle(cmd, uow) return "OK", 201 @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): try: - event = events.AllocationRequired( + cmd = commands.Allocate( request.json["orderid"], request.json["sku"], request.json["qty"] ) - results = messagebus.handle(event, unit_of_work.SqlAlchemyUnitOfWork()) + uow = unit_of_work.SqlAlchemyUnitOfWork() + results = messagebus.handle(cmd, uow) batchref = results.pop(0) except InvalidSku as e: return {"message": str(e)}, 400 From 120f5987c1bd720bf5bf1c76bb157d16b7154804 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 26 Jul 2019 14:58:39 +0100 Subject: [PATCH 084/146] Add redis to docker-compose and config, conftest --- docker-compose.yml | 6 ++++++ mypy.ini | 2 +- requirements.txt | 2 ++ src/allocation/config.py | 6 ++++++ tests/conftest.py | 31 +++++++++++++++++++++++++++++-- 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 039400e98..74ac28b65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: dockerfile: Dockerfile depends_on: - postgres + - redis environment: - DB_HOST=postgres - DB_PASSWORD=abc123 @@ -27,3 +28,8 @@ services: ports: - "54321:5432" + redis: + image: redis:alpine + ports: + - "63791:6379" + diff --git a/mypy.ini b/mypy.ini index 62194f352..601283d7d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,5 +3,5 @@ ignore_missing_imports = False mypy_path = ./src check_untyped_defs = True -[mypy-pytest.*,sqlalchemy.*] +[mypy-pytest.*,sqlalchemy.*,redis.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index 8c7792540..5ae975d20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,11 @@ sqlalchemy flask psycopg2-binary +redis # tests pytest pytest-icdiff mypy requests +tenacity diff --git a/src/allocation/config.py b/src/allocation/config.py index f3b55cc90..30a8eb078 100644 --- a/src/allocation/config.py +++ b/src/allocation/config.py @@ -13,3 +13,9 @@ def get_api_url(): host = os.environ.get("API_HOST", "localhost") port = 5005 if host == "localhost" else 80 return f"http://{host}:{port}" + + +def get_redis_host_and_port(): + host = os.environ.get("REDIS_HOST", "localhost") + port = 63791 if host == "localhost" else 6379 + return dict(host=host, port=port) diff --git a/tests/conftest.py b/tests/conftest.py index d51c20f61..918a121f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,14 @@ # pylint: disable=redefined-outer-name +import shutil +import subprocess import time from pathlib import Path import pytest +import redis import requests -from requests.exceptions import ConnectionError +from requests.exceptions import RequestException +from redis.exceptions import RedisError from sqlalchemy.exc import OperationalError from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, clear_mappers @@ -48,11 +52,22 @@ def wait_for_webapp_to_come_up(): while time.time() < deadline: try: return requests.get(url) - except ConnectionError: + except RequestException: time.sleep(0.5) pytest.fail("API never came up") +def wait_for_redis_to_come_up(): + deadline = time.time() + 5 + r = redis.Redis(**config.get_redis_host_and_port()) + while time.time() < deadline: + try: + return r.ping() + except RedisError: + time.sleep(0.5) + pytest.fail("Redis never came up") + + @pytest.fixture(scope="session") def postgres_db(): engine = create_engine(config.get_postgres_uri()) @@ -78,3 +93,15 @@ def restart_api(): (Path(__file__).parent / "../src/allocation/entrypoints/flask_app.py").touch() time.sleep(0.5) wait_for_webapp_to_come_up() + + +@pytest.fixture +def restart_redis_pubsub(): + wait_for_redis_to_come_up() + if not shutil.which("docker-compose"): + print("skipping restart, assumes running in container") + return + subprocess.run( + ["docker-compose", "restart", "-t", "0", "redis_pubsub"], + check=True, + ) From f99ab968d5379f8c560dbafe1fa35fba8d1e8c16 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 26 Jul 2019 15:02:06 +0100 Subject: [PATCH 085/146] refactor e2e tests, move random_refs and api_client out --- tests/e2e/api_client.py | 25 ++++++++++++++++++++++++ tests/e2e/test_api.py | 43 +++++++++++++++++------------------------ 2 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/api_client.py diff --git a/tests/e2e/api_client.py b/tests/e2e/api_client.py new file mode 100644 index 000000000..646ac4f7e --- /dev/null +++ b/tests/e2e/api_client.py @@ -0,0 +1,25 @@ +import requests +from allocation import config + + +def post_to_add_batch(ref, sku, qty, eta): + url = config.get_api_url() + r = requests.post( + f"{url}/add_batch", json={"ref": ref, "sku": sku, "qty": qty, "eta": eta} + ) + assert r.status_code == 201 + + +def post_to_allocate(orderid, sku, qty, expect_success=True): + url = config.get_api_url() + r = requests.post( + f"{url}/allocate", + json={ + "orderid": orderid, + "sku": sku, + "qty": qty, + }, + ) + if expect_success: + assert r.status_code == 201 + return r diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 79345fce0..048838933 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -1,16 +1,6 @@ import pytest -import requests - -from allocation import config -from ..random_refs import random_sku, random_batchref, random_orderid - - -def post_to_add_batch(ref, sku, qty, eta): - url = config.get_api_url() - r = requests.post( - f"{url}/add_batch", json={"ref": ref, "sku": sku, "qty": qty, "eta": eta} - ) - assert r.status_code == 201 +from ..random_refs import random_batchref, random_orderid, random_sku +from . import api_client @pytest.mark.usefixtures("postgres_db") @@ -20,24 +10,27 @@ def test_happy_path_returns_201_and_allocated_batch(): earlybatch = random_batchref(1) laterbatch = random_batchref(2) otherbatch = random_batchref(3) - post_to_add_batch(laterbatch, sku, 100, "2011-01-02") - post_to_add_batch(earlybatch, sku, 100, "2011-01-01") - post_to_add_batch(otherbatch, othersku, 100, None) - data = {"orderid": random_orderid(), "sku": sku, "qty": 3} + api_client.post_to_add_batch(laterbatch, sku, 100, "2011-01-02") + api_client.post_to_add_batch(earlybatch, sku, 100, "2011-01-01") + api_client.post_to_add_batch(otherbatch, othersku, 100, None) - url = config.get_api_url() - r = requests.post(f"{url}/allocate", json=data) + response = api_client.post_to_allocate(random_orderid(), sku, qty=3) - assert r.status_code == 201 - assert r.json()["batchref"] == earlybatch + assert response.status_code == 201 + assert response.json()["batchref"] == earlybatch @pytest.mark.usefixtures("postgres_db") @pytest.mark.usefixtures("restart_api") def test_unhappy_path_returns_400_and_error_message(): unknown_sku, orderid = random_sku(), random_orderid() - data = {"orderid": orderid, "sku": unknown_sku, "qty": 20} - url = config.get_api_url() - r = requests.post(f"{url}/allocate", json=data) - assert r.status_code == 400 - assert r.json()["message"] == f"Invalid sku {unknown_sku}" + + response = api_client.post_to_allocate( + orderid, + unknown_sku, + qty=20, + expect_success=False, + ) + + assert response.status_code == 400 + assert response.json()["message"] == f"Invalid sku {unknown_sku}" From c69661dbf6c6872a50f981940661dca64a916ae5 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 26 Jul 2019 15:02:41 +0100 Subject: [PATCH 086/146] redis client for tests [redis_client_for_tests] --- tests/e2e/redis_client.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/e2e/redis_client.py diff --git a/tests/e2e/redis_client.py b/tests/e2e/redis_client.py new file mode 100644 index 000000000..3392d026b --- /dev/null +++ b/tests/e2e/redis_client.py @@ -0,0 +1,18 @@ +import json +import redis + +from allocation import config + +r = redis.Redis(**config.get_redis_host_and_port()) + + +def subscribe_to(channel): + pubsub = r.pubsub() + pubsub.subscribe(channel) + confirmation = pubsub.get_message(timeout=3) + assert confirmation["type"] == "subscribe" + return pubsub + + +def publish_message(channel, message): + r.publish(channel, json.dumps(message)) From 8ee0a932d18fc4be73c52716b11ca2edc5a23322 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 26 Jul 2019 15:07:14 +0100 Subject: [PATCH 087/146] Test for our external events [redis_e2e_test] --- requirements.txt | 3 ++- tests/e2e/test_external_events.py | 38 +++++++++++++++++++++++++++++++ tests/pytest.ini | 2 ++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/test_external_events.py diff --git a/requirements.txt b/requirements.txt index 5ae975d20..882cb3524 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,10 @@ flask psycopg2-binary redis -# tests +# dev/tests pytest pytest-icdiff mypy +pylint requests tenacity diff --git a/tests/e2e/test_external_events.py b/tests/e2e/test_external_events.py new file mode 100644 index 000000000..49dbd4b93 --- /dev/null +++ b/tests/e2e/test_external_events.py @@ -0,0 +1,38 @@ +import json +import pytest +from tenacity import Retrying, RetryError, stop_after_delay +from . import api_client, redis_client +from ..random_refs import random_batchref, random_orderid, random_sku + + +@pytest.mark.usefixtures("postgres_db") +@pytest.mark.usefixtures("restart_api") +@pytest.mark.usefixtures("restart_redis_pubsub") +def test_change_batch_quantity_leading_to_reallocation(): + # start with two batches and an order allocated to one of them + orderid, sku = random_orderid(), random_sku() + earlier_batch, later_batch = random_batchref("old"), random_batchref("newer") + api_client.post_to_add_batch(earlier_batch, sku, qty=10, eta="2011-01-01") + api_client.post_to_add_batch(later_batch, sku, qty=10, eta="2011-01-02") + response = api_client.post_to_allocate(orderid, sku, 10) + assert response.json()["batchref"] == earlier_batch + + subscription = redis_client.subscribe_to("line_allocated") + + # change quantity on allocated batch so it's less than our order + redis_client.publish_message( + "change_batch_quantity", + {"batchref": earlier_batch, "qty": 5}, + ) + + # wait until we see a message saying the order has been reallocated + messages = [] + for attempt in Retrying(stop=stop_after_delay(3), reraise=True): + with attempt: + message = subscription.get_message(timeout=1) + if message: + messages.append(message) + print(messages) + data = json.loads(messages[-1]["data"]) + assert data["orderid"] == orderid + assert data["batchref"] == later_batch diff --git a/tests/pytest.ini b/tests/pytest.ini index bbd083ac1..3fd8685e7 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -1,2 +1,4 @@ [pytest] addopts = --tb=short +filterwarnings = + ignore::DeprecationWarning From 28b8093c44f5ba721272738ddab8c604782e7ea1 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 15 Nov 2019 14:46:04 +0000 Subject: [PATCH 088/146] use tenacity in conftest --- tests/conftest.py | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 918a121f3..dc695f4d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,11 +7,9 @@ import pytest import redis import requests -from requests.exceptions import RequestException -from redis.exceptions import RedisError -from sqlalchemy.exc import OperationalError from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, clear_mappers +from tenacity import retry, stop_after_delay from allocation.adapters.orm import metadata, start_mappers from allocation import config @@ -36,36 +34,20 @@ def session(session_factory): return session_factory() +@retry(stop=stop_after_delay(10)) def wait_for_postgres_to_come_up(engine): - deadline = time.time() + 10 - while time.time() < deadline: - try: - return engine.connect() - except OperationalError: - time.sleep(0.5) - pytest.fail("Postgres never came up") + return engine.connect() +@retry(stop=stop_after_delay(10)) def wait_for_webapp_to_come_up(): - deadline = time.time() + 10 - url = config.get_api_url() - while time.time() < deadline: - try: - return requests.get(url) - except RequestException: - time.sleep(0.5) - pytest.fail("API never came up") + return requests.get(config.get_api_url()) +@retry(stop=stop_after_delay(10)) def wait_for_redis_to_come_up(): - deadline = time.time() + 5 r = redis.Redis(**config.get_redis_host_and_port()) - while time.time() < deadline: - try: - return r.ping() - except RedisError: - time.sleep(0.5) - pytest.fail("Redis never came up") + return r.ping() @pytest.fixture(scope="session") From 42d1dd8541e81310cbf6805353052cc2a4223778 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 26 Jul 2019 15:07:35 +0100 Subject: [PATCH 089/146] Docker infrastructure for new redis event listener container --- Dockerfile | 2 -- Makefile | 12 ++++++------ docker-compose.yml | 32 +++++++++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 73024d182..1a3c97655 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,5 +11,3 @@ RUN pip install -e /src COPY tests/ /tests/ WORKDIR /src -ENV FLASK_APP=allocation/entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 -CMD flask run --host=0.0.0.0 --port=80 diff --git a/Makefile b/Makefile index 6409e9553..fb56493b6 100644 --- a/Makefile +++ b/Makefile @@ -8,25 +8,25 @@ build: docker-compose build up: - docker-compose up -d app + docker-compose up -d down: docker-compose down --remove-orphans test: up - docker-compose run --rm --no-deps --entrypoint=pytest app /tests/unit /tests/integration /tests/e2e + docker-compose run --rm --no-deps --entrypoint=pytest api /tests/unit /tests/integration /tests/e2e unit-tests: - docker-compose run --rm --no-deps --entrypoint=pytest app /tests/unit + docker-compose run --rm --no-deps --entrypoint=pytest api /tests/unit integration-tests: up - docker-compose run --rm --no-deps --entrypoint=pytest app /tests/integration + docker-compose run --rm --no-deps --entrypoint=pytest api /tests/integration e2e-tests: up - docker-compose run --rm --no-deps --entrypoint=pytest app /tests/e2e + docker-compose run --rm --no-deps --entrypoint=pytest api /tests/e2e logs: - docker-compose logs app | tail -100 + docker-compose logs --tail=25 api redis_pubsub black: black -l 86 $$(find * -name '*.py') diff --git a/docker-compose.yml b/docker-compose.yml index 74ac28b65..dc2cc369f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,51 @@ version: "3" + services: - app: + redis_pubsub: build: context: . dockerfile: Dockerfile + image: allocation-image depends_on: - postgres - redis environment: - DB_HOST=postgres - DB_PASSWORD=abc123 - - API_HOST=app + - REDIS_HOST=redis - PYTHONDONTWRITEBYTECODE=1 volumes: - ./src:/src - ./tests:/tests + entrypoint: + - python + - /src/allocation/entrypoints/redis_eventconsumer.py + + api: + image: allocation-image + depends_on: + - redis_pubsub + environment: + - DB_HOST=postgres + - DB_PASSWORD=abc123 + - API_HOST=api + - REDIS_HOST=redis + - PYTHONDONTWRITEBYTECODE=1 + - FLASK_APP=allocation/entrypoints/flask_app.py + - FLASK_DEBUG=1 + - PYTHONUNBUFFERED=1 + volumes: + - ./src:/src + - ./tests:/tests + entrypoint: + - flask + - run + - --host=0.0.0.0 + - --port=80 ports: - "5005:80" - postgres: image: postgres:9.6 environment: From 75b50c5c39e75f9bad716030bd4ff6ee33ee6d42 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 14 Aug 2019 15:02:48 +0100 Subject: [PATCH 090/146] add Allocated event [allocated_event] --- src/allocation/domain/events.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/allocation/domain/events.py b/src/allocation/domain/events.py index ba90ea05d..e2428f505 100644 --- a/src/allocation/domain/events.py +++ b/src/allocation/domain/events.py @@ -6,6 +6,14 @@ class Event: pass +@dataclass +class Allocated(Event): + orderid: str + sku: str + qty: int + batchref: str + + @dataclass class OutOfStock(Event): sku: str From b54e10e68087136e2fbacd45ced20242d105e11d Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 19:11:39 +0000 Subject: [PATCH 091/146] redis eventconsumer first cut [redis_eventconsumer_first_cut] --- .../entrypoints/redis_eventconsumer.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/allocation/entrypoints/redis_eventconsumer.py diff --git a/src/allocation/entrypoints/redis_eventconsumer.py b/src/allocation/entrypoints/redis_eventconsumer.py new file mode 100644 index 000000000..e04a81423 --- /dev/null +++ b/src/allocation/entrypoints/redis_eventconsumer.py @@ -0,0 +1,32 @@ +import json +import logging +import redis + +from allocation import config +from allocation.domain import commands +from allocation.adapters import orm +from allocation.service_layer import messagebus, unit_of_work + +logger = logging.getLogger(__name__) + +r = redis.Redis(**config.get_redis_host_and_port()) + + +def main(): + orm.start_mappers() + pubsub = r.pubsub(ignore_subscribe_messages=True) + pubsub.subscribe("change_batch_quantity") + + for m in pubsub.listen(): + handle_change_batch_quantity(m) + + +def handle_change_batch_quantity(m): + logging.debug("handling %s", m) + data = json.loads(m["data"]) + cmd = commands.ChangeBatchQuantity(ref=data["batchref"], qty=data["qty"]) + messagebus.handle(cmd, uow=unit_of_work.SqlAlchemyUnitOfWork()) + + +if __name__ == "__main__": + main() From c063c2744019f8e1ba0be3748ed51609235ed01e Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 3 Jan 2020 19:12:02 +0000 Subject: [PATCH 092/146] redis eventpublisher first cut [redis_eventpublisher_first_cut] --- src/allocation/adapters/redis_eventpublisher.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/allocation/adapters/redis_eventpublisher.py diff --git a/src/allocation/adapters/redis_eventpublisher.py b/src/allocation/adapters/redis_eventpublisher.py new file mode 100644 index 000000000..6100956f9 --- /dev/null +++ b/src/allocation/adapters/redis_eventpublisher.py @@ -0,0 +1,16 @@ +import json +import logging +from dataclasses import asdict +import redis + +from allocation import config +from allocation.domain import events + +logger = logging.getLogger(__name__) + +r = redis.Redis(**config.get_redis_host_and_port()) + + +def publish(channel, event: events.Event): + logging.debug("publishing: channel=%s, event=%s", channel, event) + r.publish(channel, json.dumps(asdict(event))) From 9bd4a608fc14e05fd89f2e148f3f42c7de173e8a Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 18 Jul 2019 22:29:20 +0100 Subject: [PATCH 093/146] sneak in a redis patch so unit test dont need redis --- tests/unit/test_handlers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index d73726fed..c62a55dc8 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -53,6 +53,12 @@ def test_for_existing_product(self): assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] +@pytest.fixture(autouse=True) +def fake_redis_publish(): + with mock.patch("allocation.adapters.redis_eventpublisher.publish"): + yield + + class TestAllocate: def test_allocates(self): uow = FakeUnitOfWork() From 7a75df790e56e506765d9eecc2964592d19bccee Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 14 Aug 2019 15:20:18 +0100 Subject: [PATCH 094/146] test and Product change to emit event [model_emits_allocated_event] --- src/allocation/domain/model.py | 8 ++++++++ tests/unit/test_product.py | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index 9d74742ca..dcdd639a8 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -17,6 +17,14 @@ def allocate(self, line: OrderLine) -> str: batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) batch.allocate(line) self.version_number += 1 + self.events.append( + events.Allocated( + orderid=line.orderid, + sku=line.sku, + qty=line.qty, + batchref=batch.reference, + ) + ) return batch.reference except StopIteration: self.events.append(events.OutOfStock(line.sku)) diff --git a/tests/unit/test_product.py b/tests/unit/test_product.py index 1a1482b68..fc2b70153 100644 --- a/tests/unit/test_product.py +++ b/tests/unit/test_product.py @@ -43,6 +43,17 @@ def test_returns_allocated_batch_ref(): assert allocation == in_stock_batch.reference +def test_outputs_allocated_event(): + batch = Batch("batchref", "RETRO-LAMPSHADE", 100, eta=None) + line = OrderLine("oref", "RETRO-LAMPSHADE", 10) + product = Product(sku="RETRO-LAMPSHADE", batches=[batch]) + product.allocate(line) + expected = events.Allocated( + orderid="oref", sku="RETRO-LAMPSHADE", qty=10, batchref=batch.reference + ) + assert product.events[-1] == expected + + def test_records_out_of_stock_event_if_cannot_allocate(): batch = Batch("batch1", "SMALL-FORK", 10, eta=today) product = Product(sku="SMALL-FORK", batches=[batch]) From 070eec61435bad3308720d65f45ebb70a5225e23 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 14 Aug 2019 15:22:23 +0100 Subject: [PATCH 095/146] add handler for Allocated [chapter_11_external_events_ends] --- src/allocation/service_layer/handlers.py | 12 +++++++++++- src/allocation/service_layer/messagebus.py | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 8c7b23d59..2d6657f09 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,6 +1,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from allocation.adapters import email +from allocation.adapters import email, redis_eventpublisher from allocation.domain import commands, events, model from allocation.domain.model import OrderLine @@ -49,6 +49,9 @@ def change_batch_quantity( uow.commit() +# pylint: disable=unused-argument + + def send_out_of_stock_notification( event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork, @@ -57,3 +60,10 @@ def send_out_of_stock_notification( "stock@made.com", f"Out of stock for {event.sku}", ) + + +def publish_allocated_event( + event: events.Allocated, + uow: unit_of_work.AbstractUnitOfWork, +): + redis_eventpublisher.publish("line_allocated", event) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 8bac77a2c..fa6d0a711 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -63,6 +63,7 @@ def handle_command( EVENT_HANDLERS = { + events.Allocated: [handlers.publish_allocated_event], events.OutOfStock: [handlers.send_out_of_stock_notification], } # type: Dict[Type[events.Event], List[Callable]] From bd080f2d6e39866a47456d78772f244f417032ff Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 21:22:46 +0100 Subject: [PATCH 096/146] modify api tests to try and do a get after a post [get_after_post] --- tests/conftest.py | 2 ++ tests/e2e/api_client.py | 7 ++++++- tests/e2e/test_api.py | 27 +++++++++++++++------------ 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index dc695f4d3..b9d181756 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,8 @@ from allocation.adapters.orm import metadata, start_mappers from allocation import config +pytest.register_assert_rewrite("tests.e2e.api_client") + @pytest.fixture def in_memory_db(): diff --git a/tests/e2e/api_client.py b/tests/e2e/api_client.py index 646ac4f7e..9ce00e285 100644 --- a/tests/e2e/api_client.py +++ b/tests/e2e/api_client.py @@ -21,5 +21,10 @@ def post_to_allocate(orderid, sku, qty, expect_success=True): }, ) if expect_success: - assert r.status_code == 201 + assert r.status_code == 202 return r + + +def get_allocation(orderid): + url = config.get_api_url() + return requests.get(f"{url}/allocations/{orderid}") diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 048838933..13d86f6f3 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -5,7 +5,8 @@ @pytest.mark.usefixtures("postgres_db") @pytest.mark.usefixtures("restart_api") -def test_happy_path_returns_201_and_allocated_batch(): +def test_happy_path_returns_202_and_batch_is_allocated(): + orderid = random_orderid() sku, othersku = random_sku(), random_sku("other") earlybatch = random_batchref(1) laterbatch = random_batchref(2) @@ -14,23 +15,25 @@ def test_happy_path_returns_201_and_allocated_batch(): api_client.post_to_add_batch(earlybatch, sku, 100, "2011-01-01") api_client.post_to_add_batch(otherbatch, othersku, 100, None) - response = api_client.post_to_allocate(random_orderid(), sku, qty=3) + r = api_client.post_to_allocate(orderid, sku, qty=3) + assert r.status_code == 202 - assert response.status_code == 201 - assert response.json()["batchref"] == earlybatch + r = api_client.get_allocation(orderid) + assert r.ok + assert r.json() == [ + {"sku": sku, "batchref": earlybatch}, + ] @pytest.mark.usefixtures("postgres_db") @pytest.mark.usefixtures("restart_api") def test_unhappy_path_returns_400_and_error_message(): unknown_sku, orderid = random_sku(), random_orderid() - - response = api_client.post_to_allocate( - orderid, - unknown_sku, - qty=20, - expect_success=False, + r = api_client.post_to_allocate( + orderid, unknown_sku, qty=20, expect_success=False ) + assert r.status_code == 400 + assert r.json()["message"] == f"Invalid sku {unknown_sku}" - assert response.status_code == 400 - assert response.json()["message"] == f"Invalid sku {unknown_sku}" + r = api_client.get_allocation(orderid) + assert r.status_code == 404 From 9b4bd65dbdf48dff4a3a452da6b8381b5c8d80ca Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 21:23:22 +0100 Subject: [PATCH 097/146] modify allocate handler to no longer return anything --- src/allocation/service_layer/handlers.py | 5 ++--- tests/unit/test_handlers.py | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 2d6657f09..15e7f08f3 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -28,15 +28,14 @@ def add_batch( def allocate( cmd: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork, -) -> str: +): line = OrderLine(cmd.orderid, cmd.sku, cmd.qty) with uow: product = uow.products.get(sku=line.sku) if product is None: raise InvalidSku(f"Invalid sku {line.sku}") - batchref = product.allocate(line) + product.allocate(line) uow.commit() - return batchref def change_batch_quantity( diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index c62a55dc8..04fb96308 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -65,10 +65,7 @@ def test_allocates(self): messagebus.handle( commands.CreateBatch("batch1", "COMPLICATED-LAMP", 100, None), uow ) - results = messagebus.handle( - commands.Allocate("o1", "COMPLICATED-LAMP", 10), uow - ) - assert results.pop(0) == "batch1" + messagebus.handle(commands.Allocate("o1", "COMPLICATED-LAMP", 10), uow) [batch] = uow.products.get("COMPLICATED-LAMP").batches assert batch.available_quantity == 90 From 94111f5bc9533e3ebb18df02ffaa5b27c26ebda7 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 21:50:01 +0100 Subject: [PATCH 098/146] modify flask to add new view endpoint and return 202s --- src/allocation/entrypoints/flask_app.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index 4d8e3204e..1070e173e 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -1,10 +1,11 @@ from datetime import datetime -from flask import Flask, request +from flask import Flask, jsonify, request from allocation.domain import commands from allocation.adapters import orm from allocation.service_layer import messagebus, unit_of_work from allocation.service_layer.handlers import InvalidSku +from allocation import views app = Flask(__name__) orm.start_mappers() @@ -35,4 +36,13 @@ def allocate_endpoint(): except InvalidSku as e: return {"message": str(e)}, 400 - return {"batchref": batchref}, 201 + return "OK", 202 + + +@app.route("/allocations/", methods=["GET"]) +def allocations_view_endpoint(orderid): + uow = unit_of_work.SqlAlchemyUnitOfWork() + result = views.allocations(orderid, uow) + if not result: + return "not found", 404 + return jsonify(result), 200 From 3a55cde57ba2ddee9e0c4e658daac9da3063fb0e Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 12 Jun 2019 21:57:35 +0100 Subject: [PATCH 099/146] session_factory -> sqlite_session_factory (needs backport) --- tests/conftest.py | 6 +++--- tests/integration/test_repository.py | 4 ++-- tests/integration/test_uow.py | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b9d181756..3b2a80662 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,15 +25,15 @@ def in_memory_db(): @pytest.fixture -def session_factory(in_memory_db): +def sqlite_session_factory(in_memory_db): start_mappers() yield sessionmaker(bind=in_memory_db) clear_mappers() @pytest.fixture -def session(session_factory): - return session_factory() +def sqlite_session(sqlite_session_factory): + return sqlite_session_factory() @retry(stop=stop_after_delay(10)) diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index a73bcd519..9b637f1a6 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -2,8 +2,8 @@ from allocation.domain import model -def test_get_by_batchref(session): - repo = repository.SqlAlchemyRepository(session) +def test_get_by_batchref(sqlite_session): + repo = repository.SqlAlchemyRepository(sqlite_session) b1 = model.Batch(ref="b1", sku="sku1", qty=100, eta=None) b2 = model.Batch(ref="b2", sku="sku1", qty=100, eta=None) b3 = model.Batch(ref="b3", sku="sku2", qty=100, eta=None) diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index a95907cf2..55c4f24d6 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -34,12 +34,12 @@ def get_allocated_batch_ref(session, orderid, sku): return batchref -def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): - session = session_factory() +def test_uow_can_retrieve_a_batch_and_allocate_to_it(sqlite_session_factory): + session = sqlite_session_factory() insert_batch(session, "batch1", "HIPSTER-WORKBENCH", 100, None) session.commit() - uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) with uow: product = uow.products.get(sku="HIPSTER-WORKBENCH") line = model.OrderLine("o1", "HIPSTER-WORKBENCH", 10) @@ -50,27 +50,27 @@ def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory): assert batchref == "batch1" -def test_rolls_back_uncommitted_work_by_default(session_factory): - uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) +def test_rolls_back_uncommitted_work_by_default(sqlite_session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) with uow: insert_batch(uow.session, "batch1", "MEDIUM-PLINTH", 100, None) - new_session = session_factory() + new_session = sqlite_session_factory() rows = list(new_session.execute('SELECT * FROM "batches"')) assert rows == [] -def test_rolls_back_on_error(session_factory): +def test_rolls_back_on_error(sqlite_session_factory): class MyException(Exception): pass - uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) with pytest.raises(MyException): with uow: insert_batch(uow.session, "batch1", "LARGE-FORK", 100, None) raise MyException() - new_session = session_factory() + new_session = sqlite_session_factory() rows = list(new_session.execute('SELECT * FROM "batches"')) assert rows == [] From 0866c72d552248b8a5024ae41bb0f6d604fd9d45 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 21:50:10 +0100 Subject: [PATCH 100/146] integration test for our view --- tests/integration/test_views.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/integration/test_views.py diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py new file mode 100644 index 000000000..99db03b0e --- /dev/null +++ b/tests/integration/test_views.py @@ -0,0 +1,23 @@ +from datetime import date +from allocation import views +from allocation.domain import commands +from allocation.service_layer import messagebus, unit_of_work + +today = date.today() + + +def test_allocations_view(sqlite_session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) + messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None), uow) + messagebus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today), uow) + messagebus.handle(commands.Allocate("order1", "sku1", 20), uow) + messagebus.handle(commands.Allocate("order1", "sku2", 20), uow) + # add a spurious batch and order to make sure we're getting the right ones + messagebus.handle(commands.CreateBatch("sku1batch-later", "sku1", 50, today), uow) + messagebus.handle(commands.Allocate("otherorder", "sku1", 30), uow) + messagebus.handle(commands.Allocate("otherorder", "sku2", 10), uow) + + assert views.allocations("order1", uow) == [ + {"sku": "sku1", "batchref": "sku1batch"}, + {"sku": "sku2", "batchref": "sku2batch"}, + ] From f63f7a5cc1648a106332d689f75d8d229e4cbfd6 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 21:50:25 +0100 Subject: [PATCH 101/146] first cut of a view with raw sql [views_dot_py] --- src/allocation/views.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/allocation/views.py diff --git a/src/allocation/views.py b/src/allocation/views.py new file mode 100644 index 000000000..a564b8d0d --- /dev/null +++ b/src/allocation/views.py @@ -0,0 +1,16 @@ +from allocation.service_layer import unit_of_work + + +def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): + with uow: + results = uow.session.execute( + """ + SELECT ol.sku, b.reference + FROM allocations AS a + JOIN batches AS b ON a.batch_id = b.id + JOIN order_lines AS ol ON a.orderline_id = ol.id + WHERE ol.orderid = :orderid + """, + dict(orderid=orderid), + ) + return [{"sku": sku, "batchref": batchref} for sku, batchref in results] From 5e545e1a48591e4e915fc26f6dbd6acef356694a Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 6 Nov 2019 13:26:44 +0000 Subject: [PATCH 102/146] use repository and go via Product [view_using_repo] --- src/allocation/views.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/allocation/views.py b/src/allocation/views.py index a564b8d0d..50dc92e3f 100644 --- a/src/allocation/views.py +++ b/src/allocation/views.py @@ -3,14 +3,10 @@ def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): with uow: - results = uow.session.execute( - """ - SELECT ol.sku, b.reference - FROM allocations AS a - JOIN batches AS b ON a.batch_id = b.id - JOIN order_lines AS ol ON a.orderline_id = ol.id - WHERE ol.orderid = :orderid - """, - dict(orderid=orderid), - ) - return [{"sku": sku, "batchref": batchref} for sku, batchref in results] + products = uow.products.for_order(orderid=orderid) + batches = [b for p in products for b in p.batches] + return [ + {"sku": b.sku, "batchref": b.reference} + for b in batches + if orderid in b.orderids + ] From 074c8205b72de7c3605dc9955cb2279c73fd12c8 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 6 Nov 2019 13:27:20 +0000 Subject: [PATCH 103/146] arguably-unnecessary helper property on model. [orderids_property] --- src/allocation/domain/model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index dcdd639a8..e5aa4c37d 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -71,6 +71,10 @@ def __gt__(self, other): return True return self.eta > other.eta + @property + def orderids(self): + return {l.orderid for l in self._allocations} + def allocate(self, line: OrderLine): if self.can_allocate(line): self._allocations.add(line) From 38f89db439442a86e7a36eb9749f3c13abbf0c84 Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 6 Nov 2019 13:27:47 +0000 Subject: [PATCH 104/146] finder method on repo [for_order_method] --- src/allocation/adapters/repository.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index f8821758c..b02a00a44 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -52,6 +52,17 @@ def _get_by_batchref(self, batchref): return ( self.session.query(model.Product) .join(model.Batch) - .filter(orm.batches.c.reference == batchref) + .filter( + orm.batches.c.reference == batchref, + ) .first() ) + + def for_order(self, orderid): + order_lines = self.session.query(model.OrderLine).filter_by(orderid=orderid) + skus = {l.sku for l in order_lines} + return ( + self.session.query(model.Product) + .join(model.Batch) + .filter(model.Batch.sku.in_(skus)) + ) From 71091ba64f923b60d9dd531fd8902626c5a55d4b Mon Sep 17 00:00:00 2001 From: Harry Date: Wed, 6 Nov 2019 13:29:39 +0000 Subject: [PATCH 105/146] Use the ORM instead [view_using_orm] --- src/allocation/adapters/repository.py | 9 --------- src/allocation/domain/model.py | 4 ---- src/allocation/views.py | 14 +++++++------- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/allocation/adapters/repository.py b/src/allocation/adapters/repository.py index b02a00a44..15a6b0ae3 100644 --- a/src/allocation/adapters/repository.py +++ b/src/allocation/adapters/repository.py @@ -57,12 +57,3 @@ def _get_by_batchref(self, batchref): ) .first() ) - - def for_order(self, orderid): - order_lines = self.session.query(model.OrderLine).filter_by(orderid=orderid) - skus = {l.sku for l in order_lines} - return ( - self.session.query(model.Product) - .join(model.Batch) - .filter(model.Batch.sku.in_(skus)) - ) diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index e5aa4c37d..dcdd639a8 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -71,10 +71,6 @@ def __gt__(self, other): return True return self.eta > other.eta - @property - def orderids(self): - return {l.orderid for l in self._allocations} - def allocate(self, line: OrderLine): if self.can_allocate(line): self._allocations.add(line) diff --git a/src/allocation/views.py b/src/allocation/views.py index 50dc92e3f..80fc9aa67 100644 --- a/src/allocation/views.py +++ b/src/allocation/views.py @@ -1,12 +1,12 @@ +from allocation.domain import model from allocation.service_layer import unit_of_work def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): with uow: - products = uow.products.for_order(orderid=orderid) - batches = [b for p in products for b in p.batches] - return [ - {"sku": b.sku, "batchref": b.reference} - for b in batches - if orderid in b.orderids - ] + batches = ( + uow.session.query(model.Batch) + .join(model.OrderLine, model.Batch._allocations) + .filter(model.OrderLine.orderid == orderid) + ) + return [{"sku": b.sku, "batchref": b.reference} for b in batches] From 50905cf8da30ab56c8d31be20f90445b756fa79a Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 22:33:33 +0100 Subject: [PATCH 106/146] Simpler view based on a new read model table --- src/allocation/views.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/allocation/views.py b/src/allocation/views.py index 80fc9aa67..ca56ceed7 100644 --- a/src/allocation/views.py +++ b/src/allocation/views.py @@ -1,12 +1,12 @@ -from allocation.domain import model from allocation.service_layer import unit_of_work def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): with uow: - batches = ( - uow.session.query(model.Batch) - .join(model.OrderLine, model.Batch._allocations) - .filter(model.OrderLine.orderid == orderid) + results = list( + uow.session.execute( + "SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid", + dict(orderid=orderid), + ) ) - return [{"sku": b.sku, "batchref": b.reference} for b in batches] + return [dict(r) for r in results] From 7e58f4b171806b1d8c003c86340e290c1964680e Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 22:33:43 +0100 Subject: [PATCH 107/146] new table in orm --- src/allocation/adapters/orm.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index ea76eed5e..a87068a5a 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -49,6 +49,14 @@ Column("batch_id", ForeignKey("batches.id")), ) +allocations_view = Table( + "allocations_view", + metadata, + Column("orderid", String(255)), + Column("sku", String(255)), + Column("batchref", String(255)), +) + def start_mappers(): lines_mapper = mapper(model.OrderLine, order_lines) From 011d640b18ab8289ce6a27ed0001f4fd6b170d1f Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 22:33:58 +0100 Subject: [PATCH 108/146] handler for view model update --- src/allocation/service_layer/handlers.py | 15 +++++++++++++++ src/allocation/views.py | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 15e7f08f3..34df09bee 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -66,3 +66,18 @@ def publish_allocated_event( uow: unit_of_work.AbstractUnitOfWork, ): redis_eventpublisher.publish("line_allocated", event) + + +def add_allocation_to_read_model( + event: events.Allocated, + uow: unit_of_work.SqlAlchemyUnitOfWork, +): + with uow: + uow.session.execute( + """ + INSERT INTO allocations_view (orderid, sku, batchref) + VALUES (:orderid, :sku, :batchref) + """, + dict(orderid=event.orderid, sku=event.sku, batchref=event.batchref), + ) + uow.commit() diff --git a/src/allocation/views.py b/src/allocation/views.py index ca56ceed7..a952887f4 100644 --- a/src/allocation/views.py +++ b/src/allocation/views.py @@ -3,10 +3,10 @@ def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): with uow: - results = list( - uow.session.execute( - "SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid", - dict(orderid=orderid), - ) + results = uow.session.execute( + """ + SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid + """, + dict(orderid=orderid), ) return [dict(r) for r in results] From ac940910754c9cbec96cc1e18b05a9fad1088781 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 22:34:34 +0100 Subject: [PATCH 109/146] add handler for allocated --- src/allocation/service_layer/messagebus.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index fa6d0a711..fef5a91d7 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -63,7 +63,10 @@ def handle_command( EVENT_HANDLERS = { - events.Allocated: [handlers.publish_allocated_event], + events.Allocated: [ + handlers.publish_allocated_event, + handlers.add_allocation_to_read_model, + ], events.OutOfStock: [handlers.send_out_of_stock_notification], } # type: Dict[Type[events.Event], List[Callable]] From c015f2aa5be4dd7adb4dac9aa5c802a34c2c96a3 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 06:21:27 +0100 Subject: [PATCH 110/146] handle_command no longer returns --- src/allocation/entrypoints/flask_app.py | 3 +-- src/allocation/service_layer/messagebus.py | 13 +++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index 1070e173e..ed482390d 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -31,8 +31,7 @@ def allocate_endpoint(): request.json["orderid"], request.json["sku"], request.json["qty"] ) uow = unit_of_work.SqlAlchemyUnitOfWork() - results = messagebus.handle(cmd, uow) - batchref = results.pop(0) + messagebus.handle(cmd, uow) except InvalidSku as e: return {"message": str(e)}, 400 diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index fef5a91d7..9527625ae 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -13,22 +13,16 @@ Message = Union[commands.Command, events.Event] -def handle( - message: Message, - uow: unit_of_work.AbstractUnitOfWork, -): - results = [] +def handle(message: Message, uow: unit_of_work.AbstractUnitOfWork): queue = [message] while queue: message = queue.pop(0) if isinstance(message, events.Event): handle_event(message, queue, uow) elif isinstance(message, commands.Command): - cmd_result = handle_command(message, queue, uow) - results.append(cmd_result) + handle_command(message, queue, uow) else: raise Exception(f"{message} was not an Event or Command") - return results def handle_event( @@ -54,9 +48,8 @@ def handle_command( logger.debug("handling command %s", command) try: handler = COMMAND_HANDLERS[type(command)] - result = handler(command, uow=uow) + handler(command, uow=uow) queue.extend(uow.collect_new_events()) - return result except Exception: logger.exception("Exception handling command %s", command) raise From f98c68d8a7c2af5a8a823b9c46b5a58df7bc739e Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 06:21:35 +0100 Subject: [PATCH 111/146] pylint thing --- src/allocation/service_layer/handlers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 34df09bee..66a64b1c8 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,3 +1,4 @@ +# pylint: disable=unused-argument from __future__ import annotations from typing import TYPE_CHECKING from allocation.adapters import email, redis_eventpublisher From 7d6be2cdc3a1af9d0fe83500e252b6f81c877186 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 13 Aug 2019 06:20:52 +0100 Subject: [PATCH 112/146] fix redis e2e test --- tests/e2e/test_external_events.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/test_external_events.py b/tests/e2e/test_external_events.py index 49dbd4b93..c4fde79fb 100644 --- a/tests/e2e/test_external_events.py +++ b/tests/e2e/test_external_events.py @@ -14,8 +14,10 @@ def test_change_batch_quantity_leading_to_reallocation(): earlier_batch, later_batch = random_batchref("old"), random_batchref("newer") api_client.post_to_add_batch(earlier_batch, sku, qty=10, eta="2011-01-01") api_client.post_to_add_batch(later_batch, sku, qty=10, eta="2011-01-02") - response = api_client.post_to_allocate(orderid, sku, 10) - assert response.json()["batchref"] == earlier_batch + r = api_client.post_to_allocate(orderid, sku, 10) + assert r.ok + response = api_client.get_allocation(orderid) + assert response.json()[0]["batchref"] == earlier_batch subscription = redis_client.subscribe_to("line_allocated") From 02e6b5d57aca230b9840a0e6d578ecebb502a0ef Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 10 Jun 2019 22:43:35 +0100 Subject: [PATCH 113/146] make sure deallocation fixes view model too [deallocation_to_readmodel] --- src/allocation/domain/events.py | 7 ++++++ src/allocation/domain/model.py | 2 +- src/allocation/service_layer/handlers.py | 26 ++++++++++++++++++++++ src/allocation/service_layer/messagebus.py | 4 ++++ tests/integration/test_views.py | 12 ++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/allocation/domain/events.py b/src/allocation/domain/events.py index e2428f505..47634f64f 100644 --- a/src/allocation/domain/events.py +++ b/src/allocation/domain/events.py @@ -14,6 +14,13 @@ class Allocated(Event): batchref: str +@dataclass +class Deallocated(Event): + orderid: str + sku: str + qty: int + + @dataclass class OutOfStock(Event): sku: str diff --git a/src/allocation/domain/model.py b/src/allocation/domain/model.py index dcdd639a8..dd4ac7823 100644 --- a/src/allocation/domain/model.py +++ b/src/allocation/domain/model.py @@ -35,7 +35,7 @@ def change_batch_quantity(self, ref: str, qty: int): batch._purchased_quantity = qty while batch.available_quantity < 0: line = batch.deallocate_one() - self.events.append(commands.Allocate(line.orderid, line.sku, line.qty)) + self.events.append(events.Deallocated(line.orderid, line.sku, line.qty)) @dataclass(unsafe_hash=True) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 66a64b1c8..fa2698a43 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,5 +1,6 @@ # pylint: disable=unused-argument from __future__ import annotations +from dataclasses import asdict from typing import TYPE_CHECKING from allocation.adapters import email, redis_eventpublisher from allocation.domain import commands, events, model @@ -39,6 +40,16 @@ def allocate( uow.commit() +def reallocate( + event: events.Deallocated, + uow: unit_of_work.AbstractUnitOfWork, +): + with uow: + product = uow.products.get(sku=event.sku) + product.events.append(commands.Allocate(**asdict(event))) + uow.commit() + + def change_batch_quantity( cmd: commands.ChangeBatchQuantity, uow: unit_of_work.AbstractUnitOfWork, @@ -82,3 +93,18 @@ def add_allocation_to_read_model( dict(orderid=event.orderid, sku=event.sku, batchref=event.batchref), ) uow.commit() + + +def remove_allocation_from_read_model( + event: events.Deallocated, + uow: unit_of_work.SqlAlchemyUnitOfWork, +): + with uow: + uow.session.execute( + """ + DELETE FROM allocations_view + WHERE orderid = :orderid AND sku = :sku + """, + dict(orderid=event.orderid, sku=event.sku), + ) + uow.commit() diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 9527625ae..104111fa2 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -60,6 +60,10 @@ def handle_command( handlers.publish_allocated_event, handlers.add_allocation_to_read_model, ], + events.Deallocated: [ + handlers.remove_allocation_from_read_model, + handlers.reallocate, + ], events.OutOfStock: [handlers.send_out_of_stock_notification], } # type: Dict[Type[events.Event], List[Callable]] diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index 99db03b0e..4dda3e54d 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -21,3 +21,15 @@ def test_allocations_view(sqlite_session_factory): {"sku": "sku1", "batchref": "sku1batch"}, {"sku": "sku2", "batchref": "sku2batch"}, ] + + +def test_deallocation(sqlite_session_factory): + uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) + messagebus.handle(commands.CreateBatch("b1", "sku1", 50, None), uow) + messagebus.handle(commands.CreateBatch("b2", "sku1", 50, today), uow) + messagebus.handle(commands.Allocate("o1", "sku1", 40), uow) + messagebus.handle(commands.ChangeBatchQuantity("b1", 10), uow) + + assert views.allocations("o1", uow) == [ + {"sku": "sku1", "batchref": "b2"}, + ] From d090fb8e7acc1869208e215d9e9b53370b5b9777 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 26 Nov 2019 12:22:25 +0000 Subject: [PATCH 114/146] handlers talk to redis [redis_readmodel_handlers] --- src/allocation/service_layer/handlers.py | 30 ++++-------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index fa2698a43..0ad6d8497 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -80,31 +80,9 @@ def publish_allocated_event( redis_eventpublisher.publish("line_allocated", event) -def add_allocation_to_read_model( - event: events.Allocated, - uow: unit_of_work.SqlAlchemyUnitOfWork, -): - with uow: - uow.session.execute( - """ - INSERT INTO allocations_view (orderid, sku, batchref) - VALUES (:orderid, :sku, :batchref) - """, - dict(orderid=event.orderid, sku=event.sku, batchref=event.batchref), - ) - uow.commit() +def add_allocation_to_read_model(event: events.Allocated, _): + redis_eventpublisher.update_readmodel(event.orderid, event.sku, event.batchref) -def remove_allocation_from_read_model( - event: events.Deallocated, - uow: unit_of_work.SqlAlchemyUnitOfWork, -): - with uow: - uow.session.execute( - """ - DELETE FROM allocations_view - WHERE orderid = :orderid AND sku = :sku - """, - dict(orderid=event.orderid, sku=event.sku), - ) - uow.commit() +def remove_allocation_from_read_model(event: events.Deallocated, _): + redis_eventpublisher.update_readmodel(event.orderid, event.sku, None) From 856ec5fcda774cb53ca1edf830de66846a53e3aa Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 26 Nov 2019 16:16:46 +0000 Subject: [PATCH 115/146] new helpers to update read model [redis_readmodel_client] --- src/allocation/adapters/redis_eventpublisher.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/allocation/adapters/redis_eventpublisher.py b/src/allocation/adapters/redis_eventpublisher.py index 6100956f9..a0d1246b2 100644 --- a/src/allocation/adapters/redis_eventpublisher.py +++ b/src/allocation/adapters/redis_eventpublisher.py @@ -14,3 +14,11 @@ def publish(channel, event: events.Event): logging.debug("publishing: channel=%s, event=%s", channel, event) r.publish(channel, json.dumps(asdict(event))) + + +def update_readmodel(orderid, sku, batchref): + r.hset(orderid, sku, batchref) + + +def get_readmodel(orderid): + return r.hgetall(orderid) From ec9fb19af5723a58aee2aa9dcff4d4d06bfe60c2 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 26 Nov 2019 16:17:08 +0000 Subject: [PATCH 116/146] view now users redis, tweak tests+app. [redis_readmodel_view] --- src/allocation/entrypoints/flask_app.py | 3 +-- src/allocation/views.py | 16 +++++++--------- tests/integration/test_views.py | 20 +++++++++++++++++--- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index ed482390d..c1cb915bf 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -40,8 +40,7 @@ def allocate_endpoint(): @app.route("/allocations/", methods=["GET"]) def allocations_view_endpoint(orderid): - uow = unit_of_work.SqlAlchemyUnitOfWork() - result = views.allocations(orderid, uow) + result = views.allocations(orderid) if not result: return "not found", 404 return jsonify(result), 200 diff --git a/src/allocation/views.py b/src/allocation/views.py index a952887f4..b9dc175fe 100644 --- a/src/allocation/views.py +++ b/src/allocation/views.py @@ -1,12 +1,10 @@ +from allocation.adapters import redis_eventpublisher from allocation.service_layer import unit_of_work -def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): - with uow: - results = uow.session.execute( - """ - SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid - """, - dict(orderid=orderid), - ) - return [dict(r) for r in results] +def allocations(orderid: str): + batches = redis_eventpublisher.get_readmodel(orderid) + return [ + {"batchref": b.decode(), "sku": s.decode()} + for s, b in batches.items() + ] diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index 4dda3e54d..f38f75a41 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -1,11 +1,25 @@ from datetime import date -from allocation import views +import pytest +import redis +from allocation import config, views from allocation.domain import commands from allocation.service_layer import messagebus, unit_of_work today = date.today() +@pytest.fixture +def cleanup_redis(): + r = redis.Redis(**config.get_redis_host_and_port()) + yield + for k in r.keys(): + print("cleaning up redis key", k) + r.delete(k) + + +pytestmark = pytest.mark.usefixtures("cleanup_redis") + + def test_allocations_view(sqlite_session_factory): uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None), uow) @@ -17,7 +31,7 @@ def test_allocations_view(sqlite_session_factory): messagebus.handle(commands.Allocate("otherorder", "sku1", 30), uow) messagebus.handle(commands.Allocate("otherorder", "sku2", 10), uow) - assert views.allocations("order1", uow) == [ + assert views.allocations("order1") == [ {"sku": "sku1", "batchref": "sku1batch"}, {"sku": "sku2", "batchref": "sku2batch"}, ] @@ -30,6 +44,6 @@ def test_deallocation(sqlite_session_factory): messagebus.handle(commands.Allocate("o1", "sku1", 40), uow) messagebus.handle(commands.ChangeBatchQuantity("b1", 10), uow) - assert views.allocations("o1", uow) == [ + assert views.allocations("o1") == [ {"sku": "sku1", "batchref": "b2"}, ] From d4f651dfe6cdf6b5a822898b3100d5fdfce6c24b Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 26 Nov 2019 16:17:16 +0000 Subject: [PATCH 117/146] Revert "view now users redis, tweak tests+app. [...]" [chapter_12_cqrs_ends] This reverts commit 6dd38db04ea7c637f2d2510e7f587ac2009dd2ac. Revert "new helpers to update read model ..." This reverts commit dbaebdf249d952f2c3915526fd4d1bc6fe3cd18f. Revert "handlers talk to redis ..." This reverts commit 00658e2181de3e118579fa0a0da9b7ebb9e081f9. --- .../adapters/redis_eventpublisher.py | 8 ----- src/allocation/entrypoints/flask_app.py | 3 +- src/allocation/service_layer/handlers.py | 30 ++++++++++++++++--- src/allocation/views.py | 16 +++++----- tests/integration/test_views.py | 20 ++----------- 5 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/allocation/adapters/redis_eventpublisher.py b/src/allocation/adapters/redis_eventpublisher.py index a0d1246b2..6100956f9 100644 --- a/src/allocation/adapters/redis_eventpublisher.py +++ b/src/allocation/adapters/redis_eventpublisher.py @@ -14,11 +14,3 @@ def publish(channel, event: events.Event): logging.debug("publishing: channel=%s, event=%s", channel, event) r.publish(channel, json.dumps(asdict(event))) - - -def update_readmodel(orderid, sku, batchref): - r.hset(orderid, sku, batchref) - - -def get_readmodel(orderid): - return r.hgetall(orderid) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index c1cb915bf..ed482390d 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -40,7 +40,8 @@ def allocate_endpoint(): @app.route("/allocations/", methods=["GET"]) def allocations_view_endpoint(orderid): - result = views.allocations(orderid) + uow = unit_of_work.SqlAlchemyUnitOfWork() + result = views.allocations(orderid, uow) if not result: return "not found", 404 return jsonify(result), 200 diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 0ad6d8497..fa2698a43 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -80,9 +80,31 @@ def publish_allocated_event( redis_eventpublisher.publish("line_allocated", event) -def add_allocation_to_read_model(event: events.Allocated, _): - redis_eventpublisher.update_readmodel(event.orderid, event.sku, event.batchref) +def add_allocation_to_read_model( + event: events.Allocated, + uow: unit_of_work.SqlAlchemyUnitOfWork, +): + with uow: + uow.session.execute( + """ + INSERT INTO allocations_view (orderid, sku, batchref) + VALUES (:orderid, :sku, :batchref) + """, + dict(orderid=event.orderid, sku=event.sku, batchref=event.batchref), + ) + uow.commit() -def remove_allocation_from_read_model(event: events.Deallocated, _): - redis_eventpublisher.update_readmodel(event.orderid, event.sku, None) +def remove_allocation_from_read_model( + event: events.Deallocated, + uow: unit_of_work.SqlAlchemyUnitOfWork, +): + with uow: + uow.session.execute( + """ + DELETE FROM allocations_view + WHERE orderid = :orderid AND sku = :sku + """, + dict(orderid=event.orderid, sku=event.sku), + ) + uow.commit() diff --git a/src/allocation/views.py b/src/allocation/views.py index b9dc175fe..a952887f4 100644 --- a/src/allocation/views.py +++ b/src/allocation/views.py @@ -1,10 +1,12 @@ -from allocation.adapters import redis_eventpublisher from allocation.service_layer import unit_of_work -def allocations(orderid: str): - batches = redis_eventpublisher.get_readmodel(orderid) - return [ - {"batchref": b.decode(), "sku": s.decode()} - for s, b in batches.items() - ] +def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork): + with uow: + results = uow.session.execute( + """ + SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid + """, + dict(orderid=orderid), + ) + return [dict(r) for r in results] diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index f38f75a41..4dda3e54d 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -1,25 +1,11 @@ from datetime import date -import pytest -import redis -from allocation import config, views +from allocation import views from allocation.domain import commands from allocation.service_layer import messagebus, unit_of_work today = date.today() -@pytest.fixture -def cleanup_redis(): - r = redis.Redis(**config.get_redis_host_and_port()) - yield - for k in r.keys(): - print("cleaning up redis key", k) - r.delete(k) - - -pytestmark = pytest.mark.usefixtures("cleanup_redis") - - def test_allocations_view(sqlite_session_factory): uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None), uow) @@ -31,7 +17,7 @@ def test_allocations_view(sqlite_session_factory): messagebus.handle(commands.Allocate("otherorder", "sku1", 30), uow) messagebus.handle(commands.Allocate("otherorder", "sku2", 10), uow) - assert views.allocations("order1") == [ + assert views.allocations("order1", uow) == [ {"sku": "sku1", "batchref": "sku1batch"}, {"sku": "sku2", "batchref": "sku2batch"}, ] @@ -44,6 +30,6 @@ def test_deallocation(sqlite_session_factory): messagebus.handle(commands.Allocate("o1", "sku1", 40), uow) messagebus.handle(commands.ChangeBatchQuantity("b1", 10), uow) - assert views.allocations("o1") == [ + assert views.allocations("o1", uow) == [ {"sku": "sku1", "batchref": "b2"}, ] From 286f106616cfa23779f661981043ca46175c695d Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 15 Jul 2019 16:57:14 +0100 Subject: [PATCH 118/146] handlers now have all and only explicit dependencies [handler_with_explicit_dependency] --- src/allocation/service_layer/handlers.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index fa2698a43..bcaf1f44d 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,8 +1,7 @@ # pylint: disable=unused-argument from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING -from allocation.adapters import email, redis_eventpublisher +from typing import Callable, TYPE_CHECKING from allocation.domain import commands, events, model from allocation.domain.model import OrderLine @@ -65,9 +64,9 @@ def change_batch_quantity( def send_out_of_stock_notification( event: events.OutOfStock, - uow: unit_of_work.AbstractUnitOfWork, + send_mail: Callable, ): - email.send( + send_mail( "stock@made.com", f"Out of stock for {event.sku}", ) @@ -75,9 +74,9 @@ def send_out_of_stock_notification( def publish_allocated_event( event: events.Allocated, - uow: unit_of_work.AbstractUnitOfWork, + publish: Callable, ): - redis_eventpublisher.publish("line_allocated", event) + publish("line_allocated", event) def add_allocation_to_read_model( From 257f29fc1c1bfd22439bd453b339a2d510cc5df1 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 07:47:34 +0000 Subject: [PATCH 119/146] change reallocate handler to avoid cmd/event clash --- src/allocation/service_layer/handlers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index bcaf1f44d..f56e6f771 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -43,10 +43,7 @@ def reallocate( event: events.Deallocated, uow: unit_of_work.AbstractUnitOfWork, ): - with uow: - product = uow.products.get(sku=event.sku) - product.events.append(commands.Allocate(**asdict(event))) - uow.commit() + allocate(commands.Allocate(**asdict(event)), uow=uow) def change_batch_quantity( From a0c2c47c9710258e4957cc41775e735287f2ccdd Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 07:47:56 +0000 Subject: [PATCH 120/146] bring static handlers dicts across from messagebus --- src/allocation/service_layer/handlers.py | 15 ++++++++++++++- src/allocation/service_layer/messagebus.py | 21 +-------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index f56e6f771..6ab2bbe9c 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,7 +1,7 @@ # pylint: disable=unused-argument from __future__ import annotations from dataclasses import asdict -from typing import Callable, TYPE_CHECKING +from typing import List, Dict, Callable, Type, TYPE_CHECKING from allocation.domain import commands, events, model from allocation.domain.model import OrderLine @@ -104,3 +104,16 @@ def remove_allocation_from_read_model( dict(orderid=event.orderid, sku=event.sku), ) uow.commit() + + +EVENT_HANDLERS = { + events.Allocated: [publish_allocated_event, add_allocation_to_read_model], + events.Deallocated: [remove_allocation_from_read_model, reallocate], + events.OutOfStock: [send_out_of_stock_notification], +} # type: Dict[Type[events.Event], List[Callable]] + +COMMAND_HANDLERS = { + commands.Allocate: allocate, + commands.CreateBatch: add_batch, + commands.ChangeBatchQuantity: change_batch_quantity, +} # type: Dict[Type[commands.Command], Callable] diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 104111fa2..12b2965be 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,7 +1,7 @@ # pylint: disable=broad-except from __future__ import annotations import logging -from typing import List, Dict, Callable, Type, Union, TYPE_CHECKING +from typing import Union, TYPE_CHECKING from allocation.domain import commands, events from . import handlers @@ -53,22 +53,3 @@ def handle_command( except Exception: logger.exception("Exception handling command %s", command) raise - - -EVENT_HANDLERS = { - events.Allocated: [ - handlers.publish_allocated_event, - handlers.add_allocation_to_read_model, - ], - events.Deallocated: [ - handlers.remove_allocation_from_read_model, - handlers.reallocate, - ], - events.OutOfStock: [handlers.send_out_of_stock_notification], -} # type: Dict[Type[events.Event], List[Callable]] - -COMMAND_HANDLERS = { - commands.Allocate: handlers.allocate, - commands.CreateBatch: handlers.add_batch, - commands.ChangeBatchQuantity: handlers.change_batch_quantity, -} # type: Dict[Type[commands.Command], Callable] From 1da2541b7916cdacd178f7c06340ca26fafdd1bb Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 5 Jan 2020 17:18:11 +0000 Subject: [PATCH 121/146] messagebus becomes a class, requires handlers [messagebus_as_class] --- src/allocation/service_layer/messagebus.py | 34 ++++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 12b2965be..d210d66fb 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,9 +1,8 @@ # pylint: disable=broad-except from __future__ import annotations import logging -from typing import Union, TYPE_CHECKING +from typing import Callable, Dict, List, Union, Type, TYPE_CHECKING from allocation.domain import commands, events -from . import handlers if TYPE_CHECKING: from . import unit_of_work @@ -13,16 +12,27 @@ Message = Union[commands.Command, events.Event] -def handle(message: Message, uow: unit_of_work.AbstractUnitOfWork): - queue = [message] - while queue: - message = queue.pop(0) - if isinstance(message, events.Event): - handle_event(message, queue, uow) - elif isinstance(message, commands.Command): - handle_command(message, queue, uow) - else: - raise Exception(f"{message} was not an Event or Command") +class MessageBus: + def __init__( + self, + uow: unit_of_work.AbstractUnitOfWork, + event_handlers: Dict[Type[events.Event], List[Callable]], + command_handlers: Dict[Type[commands.Command], Callable], + ): + self.uow = uow + self.event_handlers = event_handlers + self.command_handlers = command_handlers + + def handle(self, message: Message): + self.queue = [message] + while self.queue: + message = self.queue.pop(0) + if isinstance(message, events.Event): + self.handle_event(message) + elif isinstance(message, commands.Command): + self.handle_command(message) + else: + raise Exception(f"{message} was not an Event or Command") def handle_event( From e9ae6dbd85deaf9893a847d8222d5cdeb295954c Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 14:48:53 +0000 Subject: [PATCH 122/146] use self.handlers for handle_event and handle_command [messagebus_handlers_change] --- src/allocation/service_layer/messagebus.py | 46 +++++++++------------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index d210d66fb..456793416 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,4 +1,4 @@ -# pylint: disable=broad-except +# pylint: disable=broad-except, attribute-defined-outside-init from __future__ import annotations import logging from typing import Callable, Dict, List, Union, Type, TYPE_CHECKING @@ -34,32 +34,22 @@ def handle(self, message: Message): else: raise Exception(f"{message} was not an Event or Command") - -def handle_event( - event: events.Event, - queue: List[Message], - uow: unit_of_work.AbstractUnitOfWork, -): - for handler in EVENT_HANDLERS[type(event)]: + def handle_event(self, event: events.Event): + for handler in self.event_handlers[type(event)]: + try: + logger.debug("handling event %s with handler %s", event, handler) + handler(event) + self.queue.extend(self.uow.collect_new_events()) + except Exception: + logger.exception("Exception handling event %s", event) + continue + + def handle_command(self, command: commands.Command): + logger.debug("handling command %s", command) try: - logger.debug("handling event %s with handler %s", event, handler) - handler(event, uow=uow) - queue.extend(uow.collect_new_events()) + handler = self.command_handlers[type(command)] + handler(command) + self.queue.extend(self.uow.collect_new_events()) except Exception: - logger.exception("Exception handling event %s", event) - continue - - -def handle_command( - command: commands.Command, - queue: List[Message], - uow: unit_of_work.AbstractUnitOfWork, -): - logger.debug("handling command %s", command) - try: - handler = COMMAND_HANDLERS[type(command)] - handler(command, uow=uow) - queue.extend(uow.collect_new_events()) - except Exception: - logger.exception("Exception handling command %s", command) - raise + logger.exception("Exception handling command %s", command) + raise From 82d79086ed30d1d5146ea1074dab09a668b7d61a Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 15 Jul 2019 17:08:54 +0100 Subject: [PATCH 123/146] conftest change to backport, session_factory -> sqlite_session_factory --- tests/conftest.py | 11 +++-------- tests/integration/test_repository.py | 5 +++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3b2a80662..8c1efcf72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,24 +18,19 @@ @pytest.fixture -def in_memory_db(): +def in_memory_sqlite_db(): engine = create_engine("sqlite:///:memory:") metadata.create_all(engine) return engine @pytest.fixture -def sqlite_session_factory(in_memory_db): +def sqlite_session_factory(in_memory_sqlite_db): start_mappers() - yield sessionmaker(bind=in_memory_db) + yield sessionmaker(bind=in_memory_sqlite_db) clear_mappers() -@pytest.fixture -def sqlite_session(sqlite_session_factory): - return sqlite_session_factory() - - @retry(stop=stop_after_delay(10)) def wait_for_postgres_to_come_up(engine): return engine.connect() diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 9b637f1a6..245b26f23 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -2,8 +2,9 @@ from allocation.domain import model -def test_get_by_batchref(sqlite_session): - repo = repository.SqlAlchemyRepository(sqlite_session) +def test_get_by_batchref(sqlite_session_factory): + session = sqlite_session_factory() + repo = repository.SqlAlchemyRepository(session) b1 = model.Batch(ref="b1", sku="sku1", qty=100, eta=None) b2 = model.Batch(ref="b2", sku="sku1", qty=100, eta=None) b3 = model.Batch(ref="b3", sku="sku2", qty=100, eta=None) From 36b2c82aa36dcb5214db90a5ec0b68ebc78c50d1 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 15 Jul 2019 17:09:18 +0100 Subject: [PATCH 124/146] conftest change to backport to ch 5, isolation serializable in all pg tests --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8c1efcf72..b0ceb0f2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,7 +49,7 @@ def wait_for_redis_to_come_up(): @pytest.fixture(scope="session") def postgres_db(): - engine = create_engine(config.get_postgres_uri()) + engine = create_engine(config.get_postgres_uri(), isolation_level="SERIALIZABLE") wait_for_postgres_to_come_up(engine) metadata.create_all(engine) return engine From 5b055701740ec9fcf190dd5d5b8faee2b61a498d Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 15 Jul 2019 17:09:59 +0100 Subject: [PATCH 125/146] uow tests maybe backport, pass explicit uow to threads --- tests/integration/test_uow.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index 55c4f24d6..db705849b 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -1,4 +1,4 @@ -# pylint: disable=broad-except +# pylint: disable=broad-except, too-many-arguments import threading import time import traceback @@ -75,15 +75,15 @@ class MyException(Exception): assert rows == [] -def try_to_allocate(orderid, sku, exceptions): +def try_to_allocate(orderid, sku, exceptions, session_factory): line = model.OrderLine(orderid, sku, 10) try: - with unit_of_work.SqlAlchemyUnitOfWork() as uow: + with unit_of_work.SqlAlchemyUnitOfWork(session_factory) as uow: product = uow.products.get(sku=sku) product.allocate(line) time.sleep(0.2) uow.commit() - except Exception as e: + except Exception as e: # pylint: disable=broad-except print(traceback.format_exc()) exceptions.append(e) @@ -96,8 +96,12 @@ def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory) order1, order2 = random_orderid(1), random_orderid(2) exceptions = [] # type: List[Exception] - try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions) - try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions) + try_to_allocate_order1 = lambda: try_to_allocate( + order1, sku, exceptions, postgres_session_factory + ) + try_to_allocate_order2 = lambda: try_to_allocate( + order2, sku, exceptions, postgres_session_factory + ) thread1 = threading.Thread(target=try_to_allocate_order1) thread2 = threading.Thread(target=try_to_allocate_order2) thread1.start() @@ -121,5 +125,5 @@ def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory) dict(sku=sku), ) assert orders.rowcount == 1 - with unit_of_work.SqlAlchemyUnitOfWork() as uow: + with unit_of_work.SqlAlchemyUnitOfWork(postgres_session_factory) as uow: uow.session.execute("select 1") From c3282c9fb6a74e8eb6e1ac7424606eea642c11eb Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 7 Oct 2019 14:29:59 +0100 Subject: [PATCH 126/146] bootstrap script preps DI'd handlers and start orm [bootstrap_script] --- src/allocation/bootstrap.py | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/allocation/bootstrap.py diff --git a/src/allocation/bootstrap.py b/src/allocation/bootstrap.py new file mode 100644 index 000000000..805b13f5f --- /dev/null +++ b/src/allocation/bootstrap.py @@ -0,0 +1,44 @@ +import inspect +from typing import Callable +from allocation.adapters import email, orm, redis_eventpublisher +from allocation.service_layer import handlers, messagebus, unit_of_work + + +def bootstrap( + start_orm: bool = True, + uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(), + send_mail: Callable = email.send, + publish: Callable = redis_eventpublisher.publish, +) -> messagebus.MessageBus: + + if start_orm: + orm.start_mappers() + + dependencies = {"uow": uow, "send_mail": send_mail, "publish": publish} + injected_event_handlers = { + event_type: [ + inject_dependencies(handler, dependencies) + for handler in event_handlers + ] + for event_type, event_handlers in handlers.EVENT_HANDLERS.items() + } + injected_command_handlers = { + command_type: inject_dependencies(handler, dependencies) + for command_type, handler in handlers.COMMAND_HANDLERS.items() + } + + return messagebus.MessageBus( + uow=uow, + event_handlers=injected_event_handlers, + command_handlers=injected_command_handlers, + ) + + +def inject_dependencies(handler, dependencies): + params = inspect.signature(handler).parameters + deps = { + name: dependency + for name, dependency in dependencies.items() + if name in params + } + return lambda message: handler(message, **deps) From f92376002916f73e61aa0f76c93222038d93c9fd Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 15 Jul 2019 17:03:43 +0100 Subject: [PATCH 127/146] use bootstrap in service layer tests [bootstrap_tests] --- tests/unit/test_handlers.py | 110 +++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 53 deletions(-) diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 04fb96308..cb21461e3 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,10 +1,12 @@ # pylint: disable=no-self-use +from __future__ import annotations from datetime import date from unittest import mock import pytest +from allocation import bootstrap from allocation.adapters import repository -from allocation.domain import commands, events -from allocation.service_layer import handlers, messagebus, unit_of_work +from allocation.domain import commands +from allocation.service_layer import handlers, unit_of_work class FakeRepository(repository.AbstractRepository): @@ -37,81 +39,83 @@ def rollback(self): pass +def bootstrap_test_app(): + return bootstrap.bootstrap( + start_orm=False, + uow=FakeUnitOfWork(), + send_mail=lambda *args: None, + publish=lambda *args: None, + ) + + class TestAddBatch: def test_for_new_product(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("b1", "CRUNCHY-ARMCHAIR", 100, None), uow - ) - assert uow.products.get("CRUNCHY-ARMCHAIR") is not None - assert uow.committed + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "CRUNCHY-ARMCHAIR", 100, None)) + assert bus.uow.products.get("CRUNCHY-ARMCHAIR") is not None + assert bus.uow.committed def test_for_existing_product(self): - uow = FakeUnitOfWork() - messagebus.handle(commands.CreateBatch("b1", "GARISH-RUG", 100, None), uow) - messagebus.handle(commands.CreateBatch("b2", "GARISH-RUG", 99, None), uow) - assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] - - -@pytest.fixture(autouse=True) -def fake_redis_publish(): - with mock.patch("allocation.adapters.redis_eventpublisher.publish"): - yield + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "GARISH-RUG", 100, None)) + bus.handle(commands.CreateBatch("b2", "GARISH-RUG", 99, None)) + assert "b2" in [ + b.reference for b in bus.uow.products.get("GARISH-RUG").batches + ] class TestAllocate: def test_allocates(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("batch1", "COMPLICATED-LAMP", 100, None), uow - ) - messagebus.handle(commands.Allocate("o1", "COMPLICATED-LAMP", 10), uow) - [batch] = uow.products.get("COMPLICATED-LAMP").batches + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("batch1", "COMPLICATED-LAMP", 100, None)) + bus.handle(commands.Allocate("o1", "COMPLICATED-LAMP", 10)) + [batch] = bus.uow.products.get("COMPLICATED-LAMP").batches assert batch.available_quantity == 90 def test_errors_for_invalid_sku(self): - uow = FakeUnitOfWork() - messagebus.handle(commands.CreateBatch("b1", "AREALSKU", 100, None), uow) + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "AREALSKU", 100, None)) with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - messagebus.handle(commands.Allocate("o1", "NONEXISTENTSKU", 10), uow) + bus.handle(commands.Allocate("o1", "NONEXISTENTSKU", 10)) def test_commits(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("b1", "OMINOUS-MIRROR", 100, None), uow - ) - messagebus.handle(commands.Allocate("o1", "OMINOUS-MIRROR", 10), uow) - assert uow.committed + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "OMINOUS-MIRROR", 100, None)) + bus.handle(commands.Allocate("o1", "OMINOUS-MIRROR", 10)) + assert bus.uow.committed def test_sends_email_on_out_of_stock_error(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None), uow - ) + emails = [] - with mock.patch("allocation.adapters.email.send") as mock_send_mail: - messagebus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10), uow) - assert mock_send_mail.call_args == mock.call( - "stock@made.com", f"Out of stock for POPULAR-CURTAINS" - ) + def fake_send_mail(*args): + emails.append(args) + + bus = bootstrap.bootstrap( + start_orm=False, + uow=FakeUnitOfWork(), + send_mail=fake_send_mail, + publish=lambda *args: None, + ) + bus.handle(commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None)) + bus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10)) + assert emails == [ + ("stock@made.com", f"Out of stock for POPULAR-CURTAINS"), + ] class TestChangeBatchQuantity: def test_changes_available_quantity(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("batch1", "ADORABLE-SETTEE", 100, None), uow - ) - [batch] = uow.products.get(sku="ADORABLE-SETTEE").batches + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("batch1", "ADORABLE-SETTEE", 100, None)) + [batch] = bus.uow.products.get(sku="ADORABLE-SETTEE").batches assert batch.available_quantity == 100 - messagebus.handle(commands.ChangeBatchQuantity("batch1", 50), uow) - + bus.handle(commands.ChangeBatchQuantity("batch1", 50)) assert batch.available_quantity == 50 def test_reallocates_if_necessary(self): - uow = FakeUnitOfWork() + bus = bootstrap_test_app() history = [ commands.CreateBatch("batch1", "INDIFFERENT-TABLE", 50, None), commands.CreateBatch("batch2", "INDIFFERENT-TABLE", 50, date.today()), @@ -119,12 +123,12 @@ def test_reallocates_if_necessary(self): commands.Allocate("order2", "INDIFFERENT-TABLE", 20), ] for msg in history: - messagebus.handle(msg, uow) - [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches + bus.handle(msg) + [batch1, batch2] = bus.uow.products.get(sku="INDIFFERENT-TABLE").batches assert batch1.available_quantity == 10 assert batch2.available_quantity == 50 - messagebus.handle(commands.ChangeBatchQuantity("batch1", 25), uow) + bus.handle(commands.ChangeBatchQuantity("batch1", 25)) # order1 or order2 will be deallocated, so we'll have 25 - 20 assert batch1.available_quantity == 5 From e883f2049f24805b21f9a606d5db6f99e9935a42 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 07:32:55 +0000 Subject: [PATCH 128/146] fixture to start mappers explicitly, use in repo and uow tests --- tests/conftest.py | 9 ++++++--- tests/integration/test_repository.py | 3 +++ tests/integration/test_uow.py | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b0ceb0f2b..f91f93a07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,8 +26,13 @@ def in_memory_sqlite_db(): @pytest.fixture def sqlite_session_factory(in_memory_sqlite_db): - start_mappers() yield sessionmaker(bind=in_memory_sqlite_db) + + +@pytest.fixture +def mappers(): + start_mappers() + yield clear_mappers() @@ -57,9 +62,7 @@ def postgres_db(): @pytest.fixture def postgres_session_factory(postgres_db): - start_mappers() yield sessionmaker(bind=postgres_db) - clear_mappers() @pytest.fixture diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 245b26f23..7961be2a3 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -1,6 +1,9 @@ +import pytest from allocation.adapters import repository from allocation.domain import model +pytestmark = pytest.mark.usefixtures("mappers") + def test_get_by_batchref(sqlite_session_factory): session = sqlite_session_factory() diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index db705849b..61f3aae55 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -3,11 +3,14 @@ import time import traceback from typing import List +from unittest.mock import Mock import pytest from allocation.domain import model from allocation.service_layer import unit_of_work from ..random_refs import random_sku, random_batchref, random_orderid +pytestmark = pytest.mark.usefixtures("mappers") + def insert_batch(session, ref, sku, qty, eta, product_version=1): session.execute( From c6f9429aa8116133c0bf86e2a2fb045a35472f62 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 05:07:03 +0000 Subject: [PATCH 129/146] fix view tests to use bootstrap. [bootstrap_view_tests] --- tests/integration/test_views.py | 51 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index 4dda3e54d..1eea394e3 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -1,35 +1,48 @@ +# pylint: disable=redefined-outer-name from datetime import date -from allocation import views +from sqlalchemy.orm import clear_mappers +import pytest +from allocation import bootstrap, views from allocation.domain import commands -from allocation.service_layer import messagebus, unit_of_work +from allocation.service_layer import unit_of_work today = date.today() -def test_allocations_view(sqlite_session_factory): - uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) - messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None), uow) - messagebus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today), uow) - messagebus.handle(commands.Allocate("order1", "sku1", 20), uow) - messagebus.handle(commands.Allocate("order1", "sku2", 20), uow) +@pytest.fixture +def sqlite_bus(sqlite_session_factory): + bus = bootstrap.bootstrap( + start_orm=True, + uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory), + send_mail=lambda *args: None, + publish=lambda *args: None, + ) + yield bus + clear_mappers() + + +def test_allocations_view(sqlite_bus): + sqlite_bus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None)) + sqlite_bus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today)) + sqlite_bus.handle(commands.Allocate("order1", "sku1", 20)) + sqlite_bus.handle(commands.Allocate("order1", "sku2", 20)) # add a spurious batch and order to make sure we're getting the right ones - messagebus.handle(commands.CreateBatch("sku1batch-later", "sku1", 50, today), uow) - messagebus.handle(commands.Allocate("otherorder", "sku1", 30), uow) - messagebus.handle(commands.Allocate("otherorder", "sku2", 10), uow) + sqlite_bus.handle(commands.CreateBatch("sku1batch-later", "sku1", 50, today)) + sqlite_bus.handle(commands.Allocate("otherorder", "sku1", 30)) + sqlite_bus.handle(commands.Allocate("otherorder", "sku2", 10)) - assert views.allocations("order1", uow) == [ + assert views.allocations("order1", sqlite_bus.uow) == [ {"sku": "sku1", "batchref": "sku1batch"}, {"sku": "sku2", "batchref": "sku2batch"}, ] -def test_deallocation(sqlite_session_factory): - uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) - messagebus.handle(commands.CreateBatch("b1", "sku1", 50, None), uow) - messagebus.handle(commands.CreateBatch("b2", "sku1", 50, today), uow) - messagebus.handle(commands.Allocate("o1", "sku1", 40), uow) - messagebus.handle(commands.ChangeBatchQuantity("b1", 10), uow) +def test_deallocation(sqlite_bus): + sqlite_bus.handle(commands.CreateBatch("b1", "sku1", 50, None)) + sqlite_bus.handle(commands.CreateBatch("b2", "sku1", 50, today)) + sqlite_bus.handle(commands.Allocate("o1", "sku1", 40)) + sqlite_bus.handle(commands.ChangeBatchQuantity("b1", 10)) - assert views.allocations("o1", uow) == [ + assert views.allocations("o1", sqlite_bus.uow) == [ {"sku": "sku1", "batchref": "b2"}, ] From d4a867494a4338aa3b67f1f80ed2f1fced98672f Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 7 Oct 2019 14:44:15 +0100 Subject: [PATCH 130/146] use bootstrap in flask [flask_calls_bootstrap] --- src/allocation/entrypoints/flask_app.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index ed482390d..f50f3edd7 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -1,14 +1,11 @@ from datetime import datetime from flask import Flask, jsonify, request - from allocation.domain import commands -from allocation.adapters import orm -from allocation.service_layer import messagebus, unit_of_work from allocation.service_layer.handlers import InvalidSku -from allocation import views +from allocation import bootstrap, views app = Flask(__name__) -orm.start_mappers() +bus = bootstrap.bootstrap() @app.route("/add_batch", methods=["POST"]) @@ -19,8 +16,7 @@ def add_batch(): cmd = commands.CreateBatch( request.json["ref"], request.json["sku"], request.json["qty"], eta ) - uow = unit_of_work.SqlAlchemyUnitOfWork() - messagebus.handle(cmd, uow) + bus.handle(cmd) return "OK", 201 @@ -30,8 +26,7 @@ def allocate_endpoint(): cmd = commands.Allocate( request.json["orderid"], request.json["sku"], request.json["qty"] ) - uow = unit_of_work.SqlAlchemyUnitOfWork() - messagebus.handle(cmd, uow) + bus.handle(cmd) except InvalidSku as e: return {"message": str(e)}, 400 @@ -40,8 +35,7 @@ def allocate_endpoint(): @app.route("/allocations/", methods=["GET"]) def allocations_view_endpoint(orderid): - uow = unit_of_work.SqlAlchemyUnitOfWork() - result = views.allocations(orderid, uow) + result = views.allocations(orderid, bus.uow) if not result: return "not found", 404 return jsonify(result), 200 From 4ca05b0d65f558bb3cd09a9efb6b735f725d16a9 Mon Sep 17 00:00:00 2001 From: Harry Date: Mon, 6 Jan 2020 06:59:59 +0000 Subject: [PATCH 131/146] use bootstrap for redis --- src/allocation/entrypoints/redis_eventconsumer.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/allocation/entrypoints/redis_eventconsumer.py b/src/allocation/entrypoints/redis_eventconsumer.py index e04a81423..9b5b93d01 100644 --- a/src/allocation/entrypoints/redis_eventconsumer.py +++ b/src/allocation/entrypoints/redis_eventconsumer.py @@ -2,10 +2,8 @@ import logging import redis -from allocation import config +from allocation import bootstrap, config from allocation.domain import commands -from allocation.adapters import orm -from allocation.service_layer import messagebus, unit_of_work logger = logging.getLogger(__name__) @@ -13,19 +11,19 @@ def main(): - orm.start_mappers() + bus = bootstrap.bootstrap() pubsub = r.pubsub(ignore_subscribe_messages=True) pubsub.subscribe("change_batch_quantity") for m in pubsub.listen(): - handle_change_batch_quantity(m) + handle_change_batch_quantity(m, bus) -def handle_change_batch_quantity(m): +def handle_change_batch_quantity(m, bus): logging.debug("handling %s", m) data = json.loads(m["data"]) cmd = commands.ChangeBatchQuantity(ref=data["batchref"], qty=data["qty"]) - messagebus.handle(cmd, uow=unit_of_work.SqlAlchemyUnitOfWork()) + bus.handle(cmd) if __name__ == "__main__": From 5f9b143583c3d784543c582370cb1fbd2a5a77b3 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 22:32:52 +0000 Subject: [PATCH 132/146] experiment with nonmagic DI zzzz [nomagic_di] --- src/allocation/bootstrap.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/allocation/bootstrap.py b/src/allocation/bootstrap.py index 805b13f5f..94f26dd1b 100644 --- a/src/allocation/bootstrap.py +++ b/src/allocation/bootstrap.py @@ -1,5 +1,6 @@ import inspect from typing import Callable +from allocation.domain import commands, events from allocation.adapters import email, orm, redis_eventpublisher from allocation.service_layer import handlers, messagebus, unit_of_work @@ -14,17 +15,24 @@ def bootstrap( if start_orm: orm.start_mappers() - dependencies = {"uow": uow, "send_mail": send_mail, "publish": publish} injected_event_handlers = { - event_type: [ - inject_dependencies(handler, dependencies) - for handler in event_handlers - ] - for event_type, event_handlers in handlers.EVENT_HANDLERS.items() + events.Allocated: [ + lambda e: handlers.publish_allocated_event(e, publish), + lambda e: handlers.add_allocation_to_read_model(e, uow), + ], + events.Deallocated: [ + lambda e: handlers.remove_allocation_from_read_model(e, uow), + lambda e: handlers.reallocate(e, uow), + ], + events.OutOfStock: [ + lambda e: handlers.send_out_of_stock_notification(e, send_mail) + ], } injected_command_handlers = { - command_type: inject_dependencies(handler, dependencies) - for command_type, handler in handlers.COMMAND_HANDLERS.items() + commands.Allocate: lambda c: handlers.allocate(c, uow), + commands.CreateBatch: lambda c: handlers.add_batch(c, uow), + commands.ChangeBatchQuantity: \ + lambda c: handlers.change_batch_quantity(c, uow), } return messagebus.MessageBus( From 2e5e5de872a88e5fcc7522ec37106dbbdabf862b Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 7 Jan 2020 22:36:23 +0000 Subject: [PATCH 133/146] Revert "experiment with nonmagic DI zzzz" This reverts commit f42b193857eec8560443767de9fd7ada9f2acb96. --- src/allocation/bootstrap.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/allocation/bootstrap.py b/src/allocation/bootstrap.py index 94f26dd1b..805b13f5f 100644 --- a/src/allocation/bootstrap.py +++ b/src/allocation/bootstrap.py @@ -1,6 +1,5 @@ import inspect from typing import Callable -from allocation.domain import commands, events from allocation.adapters import email, orm, redis_eventpublisher from allocation.service_layer import handlers, messagebus, unit_of_work @@ -15,24 +14,17 @@ def bootstrap( if start_orm: orm.start_mappers() + dependencies = {"uow": uow, "send_mail": send_mail, "publish": publish} injected_event_handlers = { - events.Allocated: [ - lambda e: handlers.publish_allocated_event(e, publish), - lambda e: handlers.add_allocation_to_read_model(e, uow), - ], - events.Deallocated: [ - lambda e: handlers.remove_allocation_from_read_model(e, uow), - lambda e: handlers.reallocate(e, uow), - ], - events.OutOfStock: [ - lambda e: handlers.send_out_of_stock_notification(e, send_mail) - ], + event_type: [ + inject_dependencies(handler, dependencies) + for handler in event_handlers + ] + for event_type, event_handlers in handlers.EVENT_HANDLERS.items() } injected_command_handlers = { - commands.Allocate: lambda c: handlers.allocate(c, uow), - commands.CreateBatch: lambda c: handlers.add_batch(c, uow), - commands.ChangeBatchQuantity: \ - lambda c: handlers.change_batch_quantity(c, uow), + command_type: inject_dependencies(handler, dependencies) + for command_type, handler in handlers.COMMAND_HANDLERS.items() } return messagebus.MessageBus( From 09c0214e55b6d1781f577518c111e80463d7319f Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 14 Jul 2019 02:55:46 +0100 Subject: [PATCH 134/146] switch to a notifications class [notifications_class] --- src/allocation/adapters/email.py | 2 -- src/allocation/adapters/notifications.py | 28 ++++++++++++++++++++++++ src/allocation/bootstrap.py | 13 ++++++++--- src/allocation/config.py | 7 ++++++ src/allocation/service_layer/handlers.py | 5 +++-- 5 files changed, 48 insertions(+), 7 deletions(-) delete mode 100644 src/allocation/adapters/email.py create mode 100644 src/allocation/adapters/notifications.py diff --git a/src/allocation/adapters/email.py b/src/allocation/adapters/email.py deleted file mode 100644 index 1c37d4272..000000000 --- a/src/allocation/adapters/email.py +++ /dev/null @@ -1,2 +0,0 @@ -def send(*args): - print("SENDING EMAIL:", *args) diff --git a/src/allocation/adapters/notifications.py b/src/allocation/adapters/notifications.py new file mode 100644 index 000000000..db29f7c86 --- /dev/null +++ b/src/allocation/adapters/notifications.py @@ -0,0 +1,28 @@ +# pylint: disable=too-few-public-methods +import abc +import smtplib +from allocation import config + + +class AbstractNotifications(abc.ABC): + @abc.abstractmethod + def send(self, destination, message): + raise NotImplementedError + + +DEFAULT_HOST = config.get_email_host_and_port()["host"] +DEFAULT_PORT = config.get_email_host_and_port()["port"] + + +class EmailNotifications(AbstractNotifications): + def __init__(self, smtp_host=DEFAULT_HOST, port=DEFAULT_PORT): + self.server = smtplib.SMTP(smtp_host, port=port) + self.server.noop() + + def send(self, destination, message): + msg = f"Subject: allocation service notification\n{message}" + self.server.sendmail( + from_addr="allocations@example.com", + to_addrs=[destination], + msg=msg, + ) diff --git a/src/allocation/bootstrap.py b/src/allocation/bootstrap.py index 805b13f5f..22112a064 100644 --- a/src/allocation/bootstrap.py +++ b/src/allocation/bootstrap.py @@ -1,20 +1,27 @@ import inspect from typing import Callable -from allocation.adapters import email, orm, redis_eventpublisher +from allocation.adapters import orm, redis_eventpublisher +from allocation.adapters.notifications import ( + AbstractNotifications, + EmailNotifications, +) from allocation.service_layer import handlers, messagebus, unit_of_work def bootstrap( start_orm: bool = True, uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(), - send_mail: Callable = email.send, + notifications: AbstractNotifications = None, publish: Callable = redis_eventpublisher.publish, ) -> messagebus.MessageBus: + if notifications is None: + notifications = EmailNotifications() + if start_orm: orm.start_mappers() - dependencies = {"uow": uow, "send_mail": send_mail, "publish": publish} + dependencies = {"uow": uow, "notifications": notifications, "publish": publish} injected_event_handlers = { event_type: [ inject_dependencies(handler, dependencies) diff --git a/src/allocation/config.py b/src/allocation/config.py index 30a8eb078..bda1bbf25 100644 --- a/src/allocation/config.py +++ b/src/allocation/config.py @@ -19,3 +19,10 @@ def get_redis_host_and_port(): host = os.environ.get("REDIS_HOST", "localhost") port = 63791 if host == "localhost" else 6379 return dict(host=host, port=port) + + +def get_email_host_and_port(): + host = os.environ.get("EMAIL_HOST", "localhost") + port = 11025 if host == "localhost" else 1025 + http_port = 18025 if host == "localhost" else 8025 + return dict(host=host, port=port, http_port=http_port) diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index 6ab2bbe9c..2d7aa8d45 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -6,6 +6,7 @@ from allocation.domain.model import OrderLine if TYPE_CHECKING: + from allocation.adapters import notifications from . import unit_of_work @@ -61,9 +62,9 @@ def change_batch_quantity( def send_out_of_stock_notification( event: events.OutOfStock, - send_mail: Callable, + notifications: notifications.AbstractNotifications, ): - send_mail( + notifications.send( "stock@made.com", f"Out of stock for {event.sku}", ) From 0943337cb8197a4d56b8f1f29935f76fab533438 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 14 Jul 2019 02:56:00 +0100 Subject: [PATCH 135/146] tests for notifcations [notifications_unit_tests] --- tests/integration/test_views.py | 3 ++- tests/unit/test_handlers.py | 30 ++++++++++++++++++------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index 1eea394e3..ccd5d542e 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -1,6 +1,7 @@ # pylint: disable=redefined-outer-name from datetime import date from sqlalchemy.orm import clear_mappers +from unittest import mock import pytest from allocation import bootstrap, views from allocation.domain import commands @@ -14,7 +15,7 @@ def sqlite_bus(sqlite_session_factory): bus = bootstrap.bootstrap( start_orm=True, uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory), - send_mail=lambda *args: None, + notifications=mock.Mock(), publish=lambda *args: None, ) yield bus diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index cb21461e3..f12185403 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,12 +1,14 @@ # pylint: disable=no-self-use from __future__ import annotations +from collections import defaultdict from datetime import date -from unittest import mock +from typing import Dict, List import pytest from allocation import bootstrap -from allocation.adapters import repository from allocation.domain import commands -from allocation.service_layer import handlers, unit_of_work +from allocation.service_layer import handlers +from allocation.adapters import notifications, repository +from allocation.service_layer import unit_of_work class FakeRepository(repository.AbstractRepository): @@ -39,11 +41,19 @@ def rollback(self): pass +class FakeNotifications(notifications.AbstractNotifications): + def __init__(self): + self.sent = defaultdict(list) # type: Dict[str, List[str]] + + def send(self, destination, message): + self.sent[destination].append(message) + + def bootstrap_test_app(): return bootstrap.bootstrap( start_orm=False, uow=FakeUnitOfWork(), - send_mail=lambda *args: None, + notifications=FakeNotifications(), publish=lambda *args: None, ) @@ -86,21 +96,17 @@ def test_commits(self): assert bus.uow.committed def test_sends_email_on_out_of_stock_error(self): - emails = [] - - def fake_send_mail(*args): - emails.append(args) - + fake_notifs = FakeNotifications() bus = bootstrap.bootstrap( start_orm=False, uow=FakeUnitOfWork(), - send_mail=fake_send_mail, + notifications=fake_notifs, publish=lambda *args: None, ) bus.handle(commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None)) bus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10)) - assert emails == [ - ("stock@made.com", f"Out of stock for POPULAR-CURTAINS"), + assert fake_notifs.sent["stock@made.com"] == [ + f"Out of stock for POPULAR-CURTAINS", ] From 3f1d63d3a2819c917d78832671b9fb84585c755b Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 14 Jul 2019 03:23:07 +0100 Subject: [PATCH 136/146] add a mailhog fake email server to docker-compose --- docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index dc2cc369f..f964ab746 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,12 @@ services: depends_on: - postgres - redis + - mailhog environment: - DB_HOST=postgres - DB_PASSWORD=abc123 - REDIS_HOST=redis + - EMAIL_HOST=mailhog - PYTHONDONTWRITEBYTECODE=1 volumes: - ./src:/src @@ -26,11 +28,13 @@ services: image: allocation-image depends_on: - redis_pubsub + - mailhog environment: - DB_HOST=postgres - DB_PASSWORD=abc123 - API_HOST=api - REDIS_HOST=redis + - EMAIL_HOST=mailhog - PYTHONDONTWRITEBYTECODE=1 - FLASK_APP=allocation/entrypoints/flask_app.py - FLASK_DEBUG=1 @@ -59,3 +63,8 @@ services: ports: - "63791:6379" + mailhog: + image: mailhog/mailhog + ports: + - "11025:1025" + - "18025:8025" From c112af908849cc645e4a6f29e4b00495ca9864e1 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 8 Oct 2019 12:56:58 +0100 Subject: [PATCH 137/146] logging.info needs backport --- src/allocation/adapters/orm.py | 3 +++ src/allocation/adapters/redis_eventpublisher.py | 2 +- src/allocation/entrypoints/redis_eventconsumer.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index a87068a5a..81d704ca0 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -1,3 +1,4 @@ +import logging from sqlalchemy import ( Table, MetaData, @@ -12,6 +13,7 @@ from allocation.domain import model +logger = logging.getLogger(__name__) metadata = MetaData() @@ -59,6 +61,7 @@ def start_mappers(): + logger.info("Starting mappers") lines_mapper = mapper(model.OrderLine, order_lines) batches_mapper = mapper( model.Batch, diff --git a/src/allocation/adapters/redis_eventpublisher.py b/src/allocation/adapters/redis_eventpublisher.py index 6100956f9..d607d6ac7 100644 --- a/src/allocation/adapters/redis_eventpublisher.py +++ b/src/allocation/adapters/redis_eventpublisher.py @@ -12,5 +12,5 @@ def publish(channel, event: events.Event): - logging.debug("publishing: channel=%s, event=%s", channel, event) + logging.info("publishing: channel=%s, event=%s", channel, event) r.publish(channel, json.dumps(asdict(event))) diff --git a/src/allocation/entrypoints/redis_eventconsumer.py b/src/allocation/entrypoints/redis_eventconsumer.py index 9b5b93d01..6d0d49a76 100644 --- a/src/allocation/entrypoints/redis_eventconsumer.py +++ b/src/allocation/entrypoints/redis_eventconsumer.py @@ -11,6 +11,7 @@ def main(): + logger.info("Redis pubsub starting") bus = bootstrap.bootstrap() pubsub = r.pubsub(ignore_subscribe_messages=True) pubsub.subscribe("change_batch_quantity") @@ -20,7 +21,7 @@ def main(): def handle_change_batch_quantity(m, bus): - logging.debug("handling %s", m) + logger.info("handling %s", m) data = json.loads(m["data"]) cmd = commands.ChangeBatchQuantity(ref=data["batchref"], qty=data["qty"]) bus.handle(cmd) From 5d8136c646d28dad43696509082fddb9fe416f25 Mon Sep 17 00:00:00 2001 From: Harry Date: Sun, 14 Jul 2019 03:23:44 +0100 Subject: [PATCH 138/146] integration test for emails [chapter_13_dependency_injection_ends] --- tests/integration/test_email.py | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/integration/test_email.py diff --git a/tests/integration/test_email.py b/tests/integration/test_email.py new file mode 100644 index 000000000..4aade37b6 --- /dev/null +++ b/tests/integration/test_email.py @@ -0,0 +1,37 @@ +# pylint: disable=redefined-outer-name +import pytest +import requests +from sqlalchemy.orm import clear_mappers +from allocation import bootstrap, config +from allocation.domain import commands +from allocation.adapters import notifications +from allocation.service_layer import unit_of_work +from ..random_refs import random_sku + + +@pytest.fixture +def bus(sqlite_session_factory): + bus = bootstrap.bootstrap( + start_orm=True, + uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory), + notifications=notifications.EmailNotifications(), + publish=lambda *args: None, + ) + yield bus + clear_mappers() + + +def get_email_from_mailhog(sku): + host, port = map(config.get_email_host_and_port().get, ["host", "http_port"]) + all_emails = requests.get(f"http://{host}:{port}/api/v2/messages").json() + return next(m for m in all_emails["items"] if sku in str(m)) + + +def test_out_of_stock_email(bus): + sku = random_sku() + bus.handle(commands.CreateBatch("batch1", sku, 9, None)) + bus.handle(commands.Allocate("order1", sku, 10)) + email = get_email_from_mailhog(sku) + assert email["Raw"]["From"] == "allocations@example.com" + assert email["Raw"]["To"] == ["stock@made.com"] + assert f"Out of stock for {sku}" in email["Raw"]["Data"] From 2c62b9d1ed973462f3d6c7cbb4725d16bd6a9955 Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 25 Feb 2021 01:02:38 +0000 Subject: [PATCH 139/146] update travis config --- .travis.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index fcd3ceeaf..bf0b9de28 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,11 @@ dist: xenial language: python python: 3.8 - script: - make all - branches: except: - - /.*_exercise$/ + - "/.*_exercise$/" +env: + global: + secure: cNctlzVCjUj1oOrRW0aryxhJHB/u0b6vmn532jcgyCRS1aQjMJtT61O7tW6yMk4wOaH4Lr0kAI0J6+1lnRYf5g11H1M+IpEHGMWgJgImsysDpLRWUGmDJTez/ii8psk0SfOP/0ZwQp+QxOB92CHdPeOPOmu2HFa00V82/H7gousXR7ywQRNthLHwso36O8+UoHc4qw8nIbjcHzbfD6ysJNynmaUMlB3mRTU1hkjGKKpA2Xyl4tmkIhp3NCPJc0WR4SgB3y0u3dVOC+RtbRzl/XpEbjsZHHNloBirK+8ERn9ISBBh/mvfo6qTix743e+xvhtBlLJjk3o4H0VMH+wQ3zIpIh4TKbhPCMqWY3gvtKDVRHD+Sywk2TE6zSz0sDPWk248MC2QsL7sgeFwcnFHOWy2iKf4YyuZtoaJuX+2tw23cDCdMS6wbARlT8Kb5QwMlsxuKYN/04kQB+9nXTVsWKJGIwLKdYRzshnlzqB/UEe2vrjZcbBixCp4pbZ2jSzw2881he4KSbVGIJdZYSFetMuaN0P9obtdaJU4V+IhwzFyyapjZhEGCTl+l/m8uGdJ5DOhFlZ7OczHja7DKUuQvB3AbnMGvN518C+fJkJpWxAn5UeIp3d0ZZm32XVKt3k8PJaP7LBYdxnr3JCRit7+kNnlP7Ho0NjvX6GTHQ+r+JM= From 4157d6db43206ed4f6e3350767e6c58b7977bd09 Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 25 Feb 2021 01:32:55 +0000 Subject: [PATCH 140/146] readme fixes --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index df5c823cf..636ed7c3a 100644 --- a/README.md +++ b/README.md @@ -3,23 +3,23 @@ ## Chapters Each chapter has its own branch which contains all the commits for that chapter, -so it has the state that corresponds to the _end_ of that chapter. If you want -to try and code along with a chapter, you'll want to check out the branch for the -previous chapter. +so it has the state that corresponds to the _end_ of that chapter. +If you want to try and code along with a chapter, +you'll want to check out the branch for the previous chapter. -https://github.com/python-leap/code/branches/all +https://github.com/cosmicpython/code/branches/all ## Exercises -Branches for the exercises follow the convention `{chatper_name}_exercise`, eg -https://github.com/python-leap/code/tree/chapter_04_service_layer_exercise +Branches for the exercises follow the convention `{chapter_name}_exercise`, +eg https://github.com/cosmicpython/code/tree/chapter_04_service_layer_exercise ## Requirements * docker with docker-compose -* for chapters 1 and 2, and optionally for the rest: a local python3.7 virtualenv +* for chapters 1 and 2, and optionally for the rest: a local python3.8 virtualenv ## Building the containers From 79392e864d041e2edda02c57bd87c627290fba20 Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 25 Feb 2021 01:45:17 +0000 Subject: [PATCH 141/146] attempt docker login --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index bf0b9de28..d69e8e141 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ dist: xenial language: python python: 3.8 +before_install: + - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin script: - make all branches: From 3ed6ff0fab52e14edba6ced4b258af68c521115f Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 25 Feb 2021 01:48:56 +0000 Subject: [PATCH 142/146] upgrade travis to focal + 3.9 why the heck not --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index d69e8e141..8b4550c1e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ -dist: xenial +dist: focal language: python -python: 3.8 +python: 3.9 before_install: - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin script: From 2045cd7ffe66ad6129caebce653c47ef5159b0d2 Mon Sep 17 00:00:00 2001 From: Sergey Fursov Date: Fri, 2 Apr 2021 18:00:45 +0300 Subject: [PATCH 143/146] fix requirements installation command in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 636ed7c3a..63e8510ce 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ pip install pytest pip install pytest sqlalchemy # for chapter 4+5 -pip install requirements.txt +pip install -r requirements.txt # for chapter 6+ -pip install requirements.txt +pip install -r requirements.txt pip install -e src/ ``` From 28b984ed155c3b3c4de5e40ead8f64d16727f09a Mon Sep 17 00:00:00 2001 From: adamculp Date: Sat, 22 May 2021 16:23:39 -0400 Subject: [PATCH 144/146] Corrected commands of the individual tests make commands --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 63e8510ce..561cbb702 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,9 @@ pip install -e src/ ```sh make test # or, to run individual test types -make unit -make integration -make e2e +make unit-tests +make integration-tests +make e2e-tests # or, if you have a local virtualenv make up pytest tests/unit From fb7b36497e2674f0ecf3d717f827ea7a09182a99 Mon Sep 17 00:00:00 2001 From: Lemondouble Date: Wed, 24 Nov 2021 18:17:47 +0900 Subject: [PATCH 145/146] add some images --- img/XL.jpg | Bin 0 -> 245583 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 img/XL.jpg diff --git a/img/XL.jpg b/img/XL.jpg new file mode 100644 index 0000000000000000000000000000000000000000..266961f61f3529cc73799d73ba434275d39ca1a9 GIT binary patch literal 245583 zcmeFZ2T+sWx;L88i-1UzW<;b(?^S{#ARxU-jnV`{k=_XwKtXyFX_4MLA~n*b_uiY* zYk&YrxN+}&&Yg4aclP(4x%bR}=FWZzGnq-U*1Mkdl;87vp7mZ&UoQi0swt}~1Assv z;05jva6Jc51mNT0{dwUo0^Ey`gpiPcfRK!sn26*C*^L|IWaQ)&l(%kDP*PKpli#Gj zNlimbM@M&qis3dr?d@B%bhLj40pjC+LqJGMNJvUcK~6#Y-@dMY0H{d_D)960fz$vz zY9Kx}@VWzV2LJ>B3Gi{Z`|lSqArT2FJ^>ltpU+?41OV}IH%~x9NJd3LNQg&;hg$&v zNJM;#nudgy?H(zeu*jpwI=bvHEnVNxi%KcFdB%RNtYKihFD9vHyGX z0*Oe7ai6N-?i~+^M?ip2ghxU^Kn%bK0P(2t3D|B4KhmLj>H3E7UMwxU$k)nNq7h=z z$GVnoD~EJqRY&3y?r-VuE9%E(wT-TFT+ae--~(||z^4X)0ki1*Tmo(YP_qlpY10cK-L$ zaUU2z949ina!PoxT)|e37Er~5+)EVuj(+^Q?Pp=(7NGO*=7DMvVNa_mc8W-eo4&rT z^|N`;5j$>BUB34H0E?YMA6^5xnrh6?0+!}s%5vVU*MOzjYd{7FH9CAH@3IfhmgS$n zzb=^tFSs)zAikye9=@#><#2~p<(39h#)Mf`>{SuZOLXl<*3Z=*4%Z0i@Y>P5_cZ_p z#rnY3)Ri({4Pd&FR^6^k7)<%a$~7R8pQ`#7cI{F$mQ>kUehD%TDVllG!{2HrN$TmN2RUqv z?|Ew&ec;S(S`k;CsvGIbw)@haq&9A9@oC=#?PQS%s=*81x=~G|mn3K!dr8zAc>v35 zsxdpXQJZJ=6K%9eNEhE={Pr+HMu`R)9!^ny0qs+YZI4r1qo4ZPJJYK<`s+TNo8dH` zm#%p9)}O_v3@NsU*t}`5{`&c?)o*G@Na>^7^)l-2Z+N_TKfl#tF0%O*{6Mn#8bG+y zr&Lajj-IBh?*TpSyXfkV7L(=7I)-QbWA@@q^BMH1%xR5E z64wCsIP$xnda_%Q&)yj;znT_cEP0R*`y6^+>-QzD_GeVKMy6`)>iDBJ^_+OE&E7=6 zSAR`NBO%Y_QnMS3KJ2M+h;#F`TCNwfRQf>!nxjR)1Cx;GMKhC9-5-T6Ts@$XYrs3i zJNIh<%`aaO`5QOUy4y?E&RhWKHEDqj?*LeUF zzRaeKUk0}^nX28k>Rpn+SnMV9X1tRbUIdpWu{NF)WJ++rQf(eARc;h7OABFRt^uL1 z;@ht@V0&>86Yfh`>1FCj_dQNPB5U^D36a-0 zeNuu-?XEVL6+K3DAVN3L;zvzPE*}rx^a@7geC@0+{N?bq`%QbWcYi*T5uIj(A#m`R3D7<&%Xaw?zM7)ii>T1W-Ry<7$+goBd&bSqdp(` z+L?=NqJVoKYt!gv4H*AUZ0R-N^BGv_8lbdv;RpU5%cSV>a)K|MBO%9`=HBVM@kj06 zq|*|gZyqsUKxNt!i?uxhondQKZ|vQ*h@ABGSGV&0#j91^uVPQ%p%oI*q&TnE{KtP) zMPsF~IHz{Nei08-l#l-MF@N@yMl#mYCChTeVV%}vb?sY+>dnYTL8M_RCrjmETDH3T z#nREikxDC;>yV|v=}w+#s;fuY!qb=lF2u4X^?46$*WnuQ!7Z%t1Tu^h$yqtN5~DWt zj`xh_C+yP$taFIzS8VC!XK*e8{fZbHcMT}BW%cN|;;24Yd4u_2B&dUN9NJ&{k$K^w zf;GAZ=t(R@z}^XiTB)(Q#anDB9aOaGH9#~TJ#_k@R+Jkqwg3lPPcopb6|WpF%s^8r z`cySO9;#BgH@UD|%;X?g#Ok|?ah%8vUHf=tk+AM*%v`x-!oNo7F0 z7=bb%Z@eOY3%Vf>VIJN`Vz0OLPh2Jr9TEYikhh3TH0rrd#{F!5LjM#FkGX~33?Z?o_Q3r1=adJ9R|HO7=V&CF7v5{ifT<%+_lFUS7+yn{|s?kUd?N(Y8%B1~I?BimdS70LYj3-_xGKT>!8^vxlAeL2IBD{^eHq+v5|Kk0K(rZRsG zAh`z6Zi7Q>M}Fq|SWmI|i7)tOTQuJkXWNh2*;Vvrkl5W|nZ2W0e<`3;;|#f7*nTuc zr5?N1n;DpL2}Au}k!q1XWT_WE;Dbg{`0~Q^ot_G%`AV;*-R2%i;q}hp8z7ib}q`GR-3fr^_dmn!WusT0QoT*RALNG@4m{*rLr`er<4agU~ z%z7R~URBY$4Pu_1XQ{>(O&GObXib0~VT5?c?V7nRI|eOY__v$CI~gM1eR=-gU9b!B zc=GviU!7|lH%5FG&H*j>KoQ>MeP$YhGU8pOiN*^#aERUqj~Ko)$1OagN*GE-u5ql< z^0cOjp+B8W`l@g7%+h**ZEc4K%Im*$2ss*;XT>0_YB56Aot@d8nA|Q*I<@3`mECan z^TP5gC}-W}`DS1p5;t6OHrAIJZ*x=8iEwO?5IWbvcLh6CZu|@`ip~Oy!f0_zVx8Q( z28?H7p2hmUIiRozK#=bSJ%8UcH@6A5&n83vyWfMx9^KJC1tVaJ*MO4-#Buj2uf>h> zX)QLaQ1L|roo(1<-S!J?(ly{H4RooFt^e#f#;}__1Pi4)V>4^kI;{pb!th`l&7cbi z=GmMVKlVlkj6mfwsXQlaMP>D&3wluN1Uz0e-k%jb7W5`C-Uo6NOSQMNQE?402kD)A zA8}!gCt#a>*myU(Q?3x=mSg{X)aE7OZcB`a;*6WdINQ+l50>^Bt@ z`HZ{0UDa{Fay9!NE*F@;f#`%zI!RF7@wnihIYo3`13HGWj41E0e9tf5w*`9KgSw_u zC6ppOh93#5(sum-NG!8Mefb?O-yP}y=8JPn{dNt|O^;jEu8-gqO^vXBFs|;Cvq!Dl zW-;elXM=V4l+E}cuG%8ryl_~nb9DW6a(uCQN?)`UwN%>cim)iB+x==AKOGX|DJ74W zLW(=-=yYbNCmc?3KvN>m*=>KJs#sSf{IB3a_!g`+3~It831k_hB(p#dhvn%EA+3Un z^@j{}M^5aSg111L_o1S2%RTKa;ZLW1XKh~b;k0=PFt(Awa+&7f>M`apW;P}Zn_ntHtV*b3qddXHlIM&nIH-LM zpuk~EA8bMIonRTG^ktdI*rwj2Ej!zYju~2mpu`^8J$FG1IU^a&gk0vDPb z+61-vQXPdr28N5pz!L~PVr1Pl;M4LoU=(uN-(L0^Vcp`77J^lhZEV9zQ!sNVnri?* z4p#JD1Ga7FDqRk3*-`hv2S*o7#pO?E}mO!XI|I zd70UW_y|58EWQRPLy)O4<%M99aJ;#piVx{39X7%}IfNY^ZzYu;t;|Xuf267iMUySD z7SX(SQ)*MU^$21l*Orwq5}vkl+c^%7-<@_49x-kPE$*l??@ zD}-epn@k|5g-ULZ-wR^I-bF3KqS1<+jq+1f=InPqAc{_Y<80-xNg64MDOyK~!oONQ z?OIiYX_Q-Jv}xYbjmV3XbBJOU9mzCjc$D5lx0(@w$|szCklh&WjaXiR<+bSD=gRLL z{Z*&3U;Yk?TT zKA=euqWDgS<*0V3@`4$aj50MxOBTK!V#ayo8&jMJTXnh|)EIMvA+qqpNWVQ&Oc9xG zJE+&7F$Xsk$n9DPFW#-IT7;aPoSNi*lxm$vC|)KXLJn>7*JeLEh~IP_Q<`&eet_*2 zT7&P$$&NSMy%cJ&*13J0h3V~_vou*KBwpFOns~eW_#d;W?h;%62^)zK(7*=BMaT5x-5 z#vc+{el**Xam)B#K0`H_Y6mHeWiIZ?9)FO={A@Dw|Ar{PPZ3iH#na4x$0!7wzpwD^ ze=ib2rX#(DUDDgyV%{Hv*2oa4Uk&YG#X)&gTQ1iCMjVjMLF}Z2nF|N?1e~K@TmvlW z`o^yTrlMDMhg?`wTy~p?13?3ImWbtkmBS~nLWj%eSlS@=Nm%~|E+50Fl|Jp|rjXpE znh_1bjdv3}+hR&}I&up3x(3{WZKOl635YF8Hi&M}Eu3wQc{K*cr(_ugI8mFk_9E%AD} zB~Yj^UvS=J8Ks82qvq_n%J#t@Nui0?Pyv!j!Y*lCs+TU++zt+dm1@~OS-qmfkAn7Q zPxA>3s2F(X!XYUm5JD_VyI>8!JnhwCTg1}S2I_73f}fnfjKfhi(o1gTxo^*&mmc3j z_?@E;+Ll<&BUF>Q)GS^NqP;I4qa<4^zuW(+wl>RVu=neWlhDa!(gSAuymR!vG~w{ zsXOb=Gek`rxp^b4b5Rmd!_)%JzqwcIematH#6jmg#eP|bv@ahGkX;iUDCw>2Zg-ed zHBC?lw=U$*NMyACCb6y%5prmFJv>ZGbp9lPnJlS!4~jlRiyOYB2~UkHGaEcW<{oKK zb)4AWi4b$+E?gqltW&l!(#PJmB-Wygq7vt1OR|aR;=8&Zy}U4H__f~1=^&_YcJg>$ z5a+?f56nuv(g*lr3~_1w9%7{K$O8QSS7v+djMVEPkGWh%!C565xZCB&{IblK(iWhH ztkLzWdl6Hg=rlNY{8rp}eaZV=qLx?{pB!7cu&j>y=Xt<#KBJjeW5YVIprUTM%2`!a zNsf`rEb!Jq_C=RV*-npwrMsvOmftb+`HTKZipjOEd8Zlu9pUvP=zAxfQ_#p%cMAiK zR(`|BH1Lc~K-!7t{#J~PA7jbS{1;l}eLtL>zTLE43$Z@Lkr$qsqo5;(!?A_!iaM-a zCIlW@a}D@7lYGe@?jwz8=)MNr#Cd5Pf{=otvos&x5bM)*H4pd@(^I({E_0u$0nb8} zj*yKvcgA^faPF|wM9!BzMOrCWD+T=md#fbs!-(CbsiS2UzU8 zcwEKG-O88IZTW|+Tp3Y6zh<<_>)I=IHjX>T#+Va0S+2moTm#;g47X`_jZesKTVmr| z*s@WXov$}Wa5Qygu6vd%VZyrnv3_<%buX@fXF#9jP$ zm0XnrfA$sEq4yC(D-s46T^@J%%Gf0^7H4oGB8|a6&7Wv@7euPiX}wFmD!D{p*|3b) zfPGw!bnztUt;`sf%NDZf%(HBZ-2tH$(W2LYc^vP4{4tXi@90x1^{BL=vA#!w;~Vv& zf^5!|x5=7Y98kLVjXqu$jV8C%q-NvtT~aQxFI6WuJ(n1|;~&L|hT$2JIiNDFfa`v8 z+oc-|QZB*P?T5b<;3~X(7sVf!e=ThU42_}?;qv&)T_Tff>xepa`V68wn59!CdPLg_|5?Q>gYtBblm=Pb^s{engE~z*FQEH}nYxwGvJG~rke)4$>nAk7W_R2z8 zD$~HjJgfDw#*;DxYnwqwUj$#ZYJp5O@*401_5n+kk0Q#{N;Znrunf2F;R=w8ES?(8 zjQq<065(Hk%>Fg;UqRLVOX~lU`u}hN`v0e09>Dj30&*M!4LsX3E9A?(*2(lF%{yOt zY?sW|X1;AP#D{$gt`QS$TkSJB^`>pr9aCD>^xUkf9{Wt^n0T=DeTLZ;Ek@L~`Bxht zh|>#LPj?pCEJhULIn?&CWM_Nd@Hsuyu0#P;@X5+wxz=czpzNN1!qf7(_UJJM`xQ_^mky*ov5sd^;GnIbcZu#=N?1ZHV z!s9V$PHS2!aHPT+=dBVAWZ&Cs24;Hv72Q}?M(-s}4!&aOMS6(#rA0+aQAx64=>ry1 zl@^m1JktkAli-sX1#XHNAJA#uMc12_){Ij3*bbIy-AZ5Q5&ziy_SM?++2A3E|22Jz zuOiQIP<7G0$O%a(AhCkG%!K7E!E~+xPQcA?|8RFe5HfgdYPLGdxQp@jN~1C9bTaj( zz(F*RsJ4K_y%p$E$oETr1&3l}g18mhs~tSpbPdQ>c*XT@E=xdbgodnl#m(0z#rxJV zX_M;HIKyQ;p55k%&-g)NM9+um+m|&`P7IKc&_ch!OOLv zMHUxWyMpcMe~C^AoJMtxk;E#cYVn_ZY62qoJg-6rQ!^%RY2OsN#e69Uot=V_{W^~wbv(D*NX`8guekRp|*#_MtC1{TlZOLqGR7jjp`3S3m+ z|LYimud&F;?|4};5MQ#7}YMyWh{a zkunQoUtSY5SeIkT=x}ykK2@7lAhAKlk7ATqqV2*qo|CG7`UqL0w}MMo`gpZ(<-b!m zb;@RaAlxV8?vW(|xgy;tpO!OVTTdP?4Le*6UTXf&f<%c{Q2Z>faZ>Q$kL$RV)~Ht1 zkg=CW3~KEPE&x97>X2M;aRQ#=l#&GnY3ch7@<|EOg|kJyLD=4j55%ZoYKXIDfeL!j z_rf)tb$Y7l0{gy`Gc2zucz2XoL1!@u#1sFR5f8jYdIfZV9NB7}7;saGAc?jD$PiYX zMrbF6@5}dzC^5g83CxEgt_wj0Z*JUo@3kbttT;qJyNWppS z$IuyPFN`T8$ayk&=%2J?0>}J?+9c4dEsm4e&dHnTI=Hk+)1zZUovNO)bc}U)r%R`T z`|v**;6KayZxNA9!ahl5`T6@rb-vn|h!57)=W0t6szUWR5(uyud?H)q7s6IF`!UYu zIC+gt4D+gYcJa}oH!ooeudjSKzAct;fWG>vT%G=!)KKdyW9{C#nbM85rydRhh*!_cu5iN~yv z*mPG<(c8yQ#s&QkYf)Z{=ZG-{y(ZQ`c2DrbE%%Ui>&u8Z&BxqSiLWQs3=Cp;@x=!_ z9(%9M_-iwQNAug(BJJ1mefAS-T$1~9JLpnh!_NE&kEOgyVPDSbY_41spx(NEj!>@> zSp(Gtl{5`Hvd-}}vv|(-6V9kPZTcI^HQPk;=sU{4rCIzdT2_pks+JAWgbIG-n=Yr8 zb;>1lXZ}L_?%c62?TH044u4G>Hjh6e4@(ZdIU5`&+J6NaV==(0CaBegzPDta@>)Op zFgM~7b$)Czi3<*ei*XsZ9rHQ-_PL%iMu}25v)R|v5*if;yhumAg=sMl-)2kQu?Q@T z8h1^!jj!(VUh|iaG0>^&m*oEj=PNy5=#D)1w<9m7n<$whd`)gu|9+S0Yhatss5)Ew zMXrMF(35CVi|?g{s4_Z%trocGm$=7r%Fp;xV;aIqoU2-Q*LGatl1d78zuPcp5UHz< zzT-q7o`-DT%R`HJKlhmRTil(B%IcgOb5jiaIiF{-Cis$mPf*qz7R?sCDqlxmbWP3K zZza@~z?kfO`H@F(AzmFnwe5@&ZZ#XfH(+7t(rH6^zvVRY5tlFy(~gXT&1> zzJ}NqRiTU&l4a$hsht78&Wc3tL6J9+!<~!=%XKrvbHBfz|@bjt5 zW^*~zB6wKk90K*eFdqQtv*4O$f@kyJa_PA(Xpb~aDhl@4xV?nU8Jo)r+j1Sa#VQn%y+x~fgSUQcz7_ zWx)x8gY!p?_JLwVotGRwW(&m+h8QOs*(MTs?TZ}}7 z(vC&YI(@z@=zY`oU6^mpz@Bom`wqNIu!Cg)yy-+^M_k*#m{&IFtY3C7nAm*V(5wb;qdJ~;fL&UoAatq zWrFH`^*t&dC?OYvm!WsJ-ezULBCFF6w2oTy!!sQ#OP(Fdg>nSA>$(vY0!=Um?V}!Z z=+VY^N7~GRhpryq2W1RbrFlPfaX!5D)|h_fL3_BD*DK3TCMjWthF8w2i{nJOAdhzwq{@`W^5X%eCz3o19COlt zwm#%afb)Jn`7rec(06sVJ>x~C4Qu4Dddi0^YUa?Ihy?ShC*F1B8RNZ^YYGOi|G4*Y zEtKg_C)~t(?8XU zs?-(-7 ziuli|+{JJ430;?UU%b*0SFYV?J1P}Evh~h-I@IFUpT^VR+S-(>&!wP2i)wS_A?8Q& z#&mHA{AZo@F^T1WH^-9+%v8ey)<_h*)eSy^OF)*9ThEhtN5FhT;t|pTdqgu2N=AHz zb{7P^s`Ul9*`a4kV{MbjMXVY==u*+=g?rON`-cT#>kKOLbw*-(yT@TL#SzOwy#)+~# z%iCy5zbvk}dM7t@--|-?(?l0&P*B^O~D2MSj47hJkgq~1|UUX_r@33K%n$W}9O zsr!UyS7W;2U0Q6YhrGisYr&i77@T@pC=i^YLWp)Sr(PZTX zHW&CMW9ahF=f1Ig0M^?Vv~K37LtENWNJ4O~S7dH^I_N}>H|fE{fS-|(eG?v;fy~zc zDJPe_;1iFHh7u#4h99Z|DdwWjN`JgBo$jZRQ>@oaFPogkiahNR}A^f+J2aZ8+Kgx4gR z;DI)an+4$w9!5?|wo^QZfl;@V?@;auju8D4v#0zX7Xebk%*@m-w7qQ-8pVcN7jnY@ z<{(~^aiY+syXhhzWX5>znx%Qn*6zLaG*j2LmtKZzmL4#0)?!4^UEyND53W;1fygf5 z51qNc)@iGrN<8s*fjJ#)&n}uSB8jGu_&*jtKJLuAGUQ}gzqL*AVKs}c(d)xfG;5vS zkRHYa|6X@%K#Ti^e&uDT&>t8vB^aI9UXB`SG)J=kCfj!wAtx zV39ylI_%`B7bwRZ!m?Rszn>FEyCs(JdF;yPD7- zB6OhZ7qWK%++R`fbAOktc=vsDf>RKGMx92l5u(A$vJOW8Fnd&9F`T|am2zBEB@lnv z#&@$I7@B65#o}1{hHM2}hNsSvpI@o&>V(RsW*ym0YVlpLS^wGuiTrrvELoTRW-+)^ z!{pG=PM$H>H|Y^mn-8R;ttntBpz`L-lNixE63vbN%E6Y3;o!lm3h6p;)gk%}P$!g+c; z10BVlrZk233}h`w2@Okd{QhF@Fm!G5aj(HPu&e@}}M*VH1ak5Q( zT}8s)-kIqdTns~V^XhJbJu}n9t12pbqP;n|Y=W_(03JTn*TsLBIe4>pIkrRChsga) z+ehpAd)YQ*)Z!l)Yc4omH2y7;OU$XVpKr$u-JpM{mq^1$Sg5Wso+h z-0uQuZSE*`ix{qWDwC(W+n$6p4KD@pfL__44p9XZat#hm?zyJlpANr{H5b)*w5(vb zi-TIjxBv_GNOk|bs%xSmznKIBM5ebhwYZ9`y%a0Gnz5LdzlZyAtTwJEcI;Iyjb?a* z&AWOdT{8Tu~CXRHi9mmj`|Yy4*W$Y~z0Jb!8I_ZT;<@*dqUF86!WnRrO_ zEqiT-@~0V}e6-Y9+yrQ(l(u)-)6G}uEq`s&C44uNb#Vzt5^?O5zy19fd7|Xrlyy(u zZ8T=s1ysvn^C7gC^W*2E+PyZ+H@H}jg5yx@)G%1>Qr(WZzgXPqK~s@v)NN9cpr>Jc zGzz6NP_MJZ%^R5*Zi{H}dC((_0PJ{}Auq;9TFhOsmY!X_Go_zPlEIU-d7+1?aL&&< z9TiRW(jvI`0sf;F+G{|y|3l2G?8$v@GQoFRyK`eyD+0I<0E=KGBd%Z%EGnlIOwWOd zC0{iIi(9tKlGZZTip#Y@197+B^n5qlVuf2=up~B>u9)GA5@& z9Uk9vXTHZeJ0-GTula0xTu`6CZF1FSW*siNq!vIf{iIRHv%omQ%sbj?x;=YxqVBld zp9+`9YtSEZZw1CJ8@Cv$U7c;8p-s6-!C9bpc{Eu;h3Xsq#|BlV(ox%dZ`K~E8a76C zilWkG4j;uMhVElMwlKhxH7d&?`GC*^9ha z!Uew1GE`%5GdQTaz22IgT2*7zX6zYOZ_QeRtj_)Rd*4`)#;Q5Vmzo&TpI#a?x^`C6 z0_HzhKO1J2=xm_c6u9%lL})b4Mw@!BKgLp!PB3k9-Uuy55LnW3!-;&tE+j6hGp02c z6>uX-jwO{4t4ojI+dFz)H>6+>$>DsrZq9rvV?@@Y$J}J^&;2f^^iXqHP*@)uv^XS~ z6r6@;X60f*!lvvt!^-w=2kAEGjPigi?TU;lveq`6QIOTi z10U%e|IoR&D)Fr@-tCFm6^7TjZo~l}vHX3+)Zw=p_eWwL>JH17YxxWay9I3limtP( zV%$loo@QXn1E7K(sLeRba3m^`kz@8-gASu0reW}G7Zh^uH`Q=$#NljkxF6pRQv+%F zcUx!97n2WBccB5$ZM8an$T}%`1J`!1m$~N8(rxA}$}z+F0|un_puh zxL7TWIJEDYLdI%M=_YYW6dgZun27`~sj>WNb9_n*8G4z{J+5`H_xns%uJicqNE8)S zBd^8zMQHFe-QO7#Ch0>&VDg~+kqA0hL6H?dsQ)47)nznJTa;>Wye-rG;p~%~viPm_ zq&d&`oQ5uz7n~sn{;*;Pd@X(Ad{&CR5e+a_PzCFl4s=2O)6nB+5*&T`IOvB-GTJyE zJ%KZtW?%yZuWouE5ymuy9w`M@$N&{w1-^?vnbhAUB`kYlgftl^{t^{?S@GLUd`NsC zSwtu~PJ7OD!C7)TIFK~l>yrCM?cqba_GiCMe~Es)uVgqK@w(A8@k(a)DwzCt#q=h{ z*dJZ7(`x&eM^Q7DyZG$c*m})RZDkLD~*4& z8}p>h=-6BmjDMjvPW*KnUl~iEZfUCHrzz8=qaVe4)8PhEJgDu`AT-u;<;D@*NckUjKcgy%pc)R<^@NeXuxCha`9IgOv>6m|1`1{wM-HpS) z;X;=X@b2Oe%qI%nLxvZzJa#34Y)7dh`lsLziL`Nxb|`L-_-zjw;J_XzIE;ktbbtfu zHsBA{yI}q%K}b;CIN{eaL`BqB7=txf0>{>Y6r5#$e3_sgVL9!LzvF5xp}B3^=UQ!D z4h@pQx6=Qk3(&(phu#^^njbGn>CliUq3T+ix|p=@duvFF(BO9YXqo3G6Q$n`X`o;1 z22^uqJQaIMj#di;NoUXD4|yIx7V290+|gO1`Uy%4thW6b4N$$yOcGMMTU!d%X59uV zK%wngc^d0teU#$_k0%2e*nJYm*ESx#5UoX|G2jY0nLqYppNF2)FdtP?0Jr?JqXO~@ z?Km?xB9#_>tG3&lcagz1^bA{iq(8ns-&fLu62*+UQ&{@n3 zOe`0LJFi){FT&ZJIQ(+F3RiIMXU8|mA#9L0WRptEgE*xu#`o4tP{TIuXlK9V4E{O2 zV7xckYq$vF2d>KSTg|)5Au@azb(PFBH?5rY^axGphwnB14-F(K&wVxBT@30p1`p`N zZ}DKJOPTM{Yuq>O z;rn7gyI309so;kF#JGgoiG`^P;;I(_%xzJ`(bPR`5(Mjj|K_Z~R>X1A_M9&@UaxCGx%Eo6T)^MSC%97yof^*!*Nv|ZVEM@*Q2~P4VtA*bCofmA6X24 ziisr;@W;JqFQnxp2v?0(<3lMX$EB%%Fgug{d?sM+fJU%apFnt6a<_fd z1yOJfz}czkpG_{yq{!T*cnibryCGa`PQmZ>Fk)I=3lV{9hE3&C(3W|QiMXsr8^-;? zu>+pKdO@6`Q2e{1@GGcb*uXFN)v!8fM6w!Mf*DAsN=c$yV}t5K!w zss4U4VE$BYXN{_N=`jcFO#6;Y!HqAQb;E+PThMTI%~jFcNOzy^B+CYO@{*MO@1#d} zOjI_Tfuh*Bu3A{+ZSW-&fx!u$BNBJ=l1T`LoPu3C058>z3AF7{=&$!9R0usBnWc3P#>akKO{Yo${atmQM8nX7O~kb-aE__tn> zakE@P6^D;%GI$reh1!gWW#b@<2GtD;m*4fBli#la9DV+a!3(&h{4G}ebs<>mN6Ho6 zZ&(-PT;Gl&{115XMQo+b?p_*iZwEyedH+Tk@($AmMaz&&kLu|ezxceid3LLu+SocV zPPZ}ViFvb08@cn zML{oZ9Pj~69D$UsgApm5oL3}{TPzkf4Ie%?SnQawas|mrEzNR3u}t_kg&Z3cwW>+v zvN%k$xhOBV2J|r#>@=Vr1wX4Xxihf7$2m)1mf8lZL@UG3qvH<+^!#0OP|6On%aOm-iEH&9h=+5t@&$Eb zjIq3`L5?^hZwQ`Gej|EDG>AJ7gV-+^W~PU;V`DNa=yAY<{vYLCK?UdJAEjraHju8` zYk(M3!I3gpoBk}zvOUcns}aSz_u_MeC&+(ky68#fHNXJ)i&FWaPyV2>%(Gax8C@m) zzFtm_AEgS82R{-ExT|oM%jQ<`;FM@|KpCsMF&}3XcJelO?jKnS8UCP=2JWcpNp4Vq z;)2LCL5aq3|Df?z^dVZ!B)Fl8rQvgE67qZNlAP@QUR7v&iOqWLylD~(EYTJIknoQg z{ZT<&fjZOb&i|%BO+kdx!w&48pMN;$kAZV%P-s=VKLfvSKo9ug4?F!yz>1orTD)S> zqmX?hQR(fVCdKoNxi5KPQZ5R{4~fKYh#_)=z6>=O#dIA% z?rdw4+SB+E$AGN`ZWjHY+u7fI8E)w}^bS8mT{eYBOz+Vb&oF*U#;3EFF#XUwquqtkYru*CLIg*@F#ic!bRJ(v^kp8~X_~$* z>|+q&ckzPqI&W(}J`+>lhsH2(fcwfJ*YmG3&md3{k6xAe*F-voH^ zYBNSG&u-<-Wm{ODF!oLgwQ^118Tg-1O3eM`dNPz$9ACVwJo1U{-Lg!bg1FwgQU(s3| zPw72#GWqC;=g{xoo{?UCg=@scs z^qP{Nin_Xli)**rn67zT((X-tgc-+?cX{`(<^mG(?6cZ_58sIrn9Tcw`Ur^Iz=v-U z3F?*lYS=bhNEvQvg*Q-wN*KstCt1;~1b4nzn5g{ZxV`JO> zjw?i-`{5&Z-)+%_c3Vl`59br-d26WZ)t4C7WhsGq9oZ}&C2@Rob_5q?3f5*-RaMrG z-I0&=c}Mg#YQwBSUr$5U|7k=>DRP)TDS5|&rj(?gKg62CjebO+AwK`cf!i~KIm<-w({cKDXzl?*LbxdM_!M#YQEK5(fEK(oK?)t zdJ*d?X-xY>|KfYdo`R$9A87w1h`z+v&LZW4yp!Lbi~n}(cEH0V1#QITPmAC;exeRp zCS2v;1)Mi&C4`Rci`E~{tH%bdHp$By;|$|tl(nmG06FP*qo1lBh$m*^KGVDQB`KyK zVip%oErJWRSzjH@DT^A+b&fiWP}dhUbiV-9HcK|66NtVv@u%N={m{AR@bf25C%NFt zIoX|Z#zW3|gLS70-gQ9Isg9f0kvIaeFX2N(3YSZh|{$%6L)S(uU{dT6bZA*(3EsBpwdv zS_F^5S$~u$2Ie~8`^!_6rB$Tgt?=nrzaOVjmZN-^`$5Fp+Hy`HG(g5;NrlOX1;P-p~tL*+iA&);E zkxA$92lV-Gzq39f>)LYWu)5jOu%5}OiZ5wFvJ`K}iFlE=qa3wk<`pG(2`+9`@kC@% zE|i9?FbIAXZU$~ir;1snWKC3AMtD5#$%*kIi7aiK{2Psj8=8fZIp?C`10P-y8O4~8 zzz8p->Z}>~YbuvMrueJLMe0k2XRS?+ZL-1I9E;+pZUF3S&HM&?T9Ar zQXi}up6EDb`sI1y#|Mj0Jx&_O@|SGPt}hraZ{CEnQ%K?-6}6zzRYmf{4Qv0Y*!w4E z;)h9NZT)9XE*b|xw8Z)J0=t-a?|VcsCFuzjJkA7iPTe7UdvLLkf7+=kKDJVgc~haa zCU{jePLyg#rOCu_MQVS3n-XtSd2UWrhVd0~YR2~K2GPoqi@-xhvYQ&$f<2pGC zD7h%cJl=jN!OK#k=m>5~uHB@PH5c_0>O)#gsy-*2;|=iK?v;5P7&L62d(bs`RZ+Xk zp`eIzb^+(7;2Zbbn(xxKLywf!2tRVD;Ao!skrvv)*w{~1RL!@9Y&_G)oK!aQ@Ho_j$70Wnl`j8e6Yw4fe;KPNx@M}c=6L|;8*a+|n&!kv2) zkM~si{^N5yQAPO@SPb590$6iuw$1 zS^2O#tmboL4WP${%k*ekB6SppQQtj3ttww~LJsz58M~T*_Pou-z1cUWezspP_PV5}3J2kd~!XW>YYYnfuNiR$|&*)t0^=!xy{P{QXkU z)dW|SQ*ingyz^A$|6=bwgQ8m3ZDE?MWF%*hjDTb$wjenQl9Q5iOAwIMEkSb5xh0DP z$&w`tNJetbIZJHXJ6(IPQ+w@m?ydcu+V}kU>i)3G0-ZIx=R2P;o-xK#pJcw0ksoY5 z^nh7MwK)jG8N(iJC`83S^2~(1Hb9#q*12qDQgdEbk+dm#*7R;}7I}Q~Ii=Sg^>K&W zeuNr72^*h5QLNShvhHUl*V=58$>O3`G9#STla3S_7m=%Ur_+;no2yyo=`a2o&S0XpLtR2O z>47``B>fxF+VrRmc@@3P)34H`prxV4$M&pgJ1>OcR*!L>TAUt!c7DNCsC;jM z>oSPf!E695=lI{vI3J$eqmP%B3QM$&xSINwVZ*IQ+gFkn)dd3xAqPIQu+b@X4XkLD z(K;)nQdrF!$-)StM;VFlcgx5jv-7?tb;}hAa~Ji~ zFItLmlgIzCr%{xYu+EV84%*czp6%dJhbP?5F150YXrGLbC&b{yCKbk0J@&{3jR=13O5bubHAK*@Vs0zqiaFnX*_G= z`e)im$g6r{o;gjr^vW<1ZP5vp%kM8Dymv}ec)FpIjX`7$Y|u!E4`9*4nfN=z|4Ug8 z6qHsUprQB%i#NTm@buA-@6AoMrj5E|cn2+V_cwel>e~v>F|J1k+E)thDBQ*ePHeP~ zHhCsyL8{_PnyUC03w2*(U$b~rgOv8&`C_fV*cS4}dlSkpge0g#-oW~>2h}m$K z!o%m~Cl~eV3lX9K)j%rN6i-rQhp~kJ$)ehmc!4C}c=q(?<%H;^97UD~9bIu|eGcBX z`0qoncm)TZ*2-gv!Ls|Zlx2%^L-QC`&=kw%ifYv)lsW(WIy!V{4x(G-3TocqUPCCvEk?WnlN8IQJ|*in2Zp>nm>L8+Rm&cFnVxE-oxK zzQ5)(i{S0JT=7P`f3*B+r0EyR#Uca(pvS@h^acdq(S+RAhojPDNwCgE zt9G47h3)tQFf1ETeJ(7`76W9rYn`|%)*HzkWBnSpiXzF!yQ%Bak@qRcs2SrV>cATf zs~UoOk*3xxLa}Hh9mVE9YX3R^gs<(iR0QFU0U!VBgU;#f`H5hX{tf%H&R-~drmz&j zUnuPuAN0dvrGrIor1A(yen^87REUUR0BkKx9o%krC_F&llba#aQAHi-!Hs5aHy1-k zRzK`BLRx|vlDa~H`~a@FmmpJ_9cosotA%~Pk9>&59u4;m+t=GX%D@D-=!UcoUGbAf z)s*T)DVSc=FO*3*<6C;rLPPy7Rl_k9@eJ<^l4bjTCIRe*l7~F~hvyLF@N)>d)0LV9 zoGnZESaX{UrNp5%B>sD5RP)Yd#`xO7Mm|T(O6@joRZpdtuP`lkDn;SzN~eQbR!2e- zEgWANVW5@M0UF;Qu`y1M20b9u`8(u$3tE3ewIU$ zJ9U~Dy~!l;Yqx!2Sj)A=xSkoubTtnvpV{~K#pIiI=zsBJsx3xmgPo)%9r1p`;efnJ zyQ@00H`^Q~tY29JTWzwv|GKQ{v{L!S`Y|8( zK|Aa7!EW?`CBmpOd(>($JC2Bspby^U_gAV=;og~bA}ha+1o)~Fc{7Id2yZkn&<=W1 z^bVrNnw=5p#+%bK)@EC)Pwa&TEph@Zs6h6D$8=xAGqQHO0y+=#u+yQxPz=w}+lpGT z{qA*6Vr&v;eovx4nc=)mPC3&ANE+Or)GygAcmWorFI~1LM~=U1ZwQ-@Ji5n3X9^H~ zBx*2BVckQqt@tMNJNNItH%)h+WXAQrRkCFw3Dep?&=H?q_SdkgI2obH5LlMdD>dt; zsw0Cyb=K*L>(BI7z`B(Ls5Cehl~z(tUNpeIpn+nSW;3yjX8%u=?7o%X01$!k$(xVqLDGwDuNl#jYC zlo7a3H5s^~?waqPW8O!`Pewu`XL4?gAU8n4OjxK8X3{mfkBQ2$i$2OZq|; znK9e{@zUNbEM?qhgnZ$TyZaZj*#G;t|9}6Y3};Jzb~&e79nSJNWz~mxRdKQTqhqpX zb(NjWUetoNif*glj3X*G&Bg&E%pMrj!=n+fZ`mKXv4dWX5F8FonVq(zLDhh99Tn(q zrf~aA+ zizBTMutOdJ6TqXvA=nuK*cuu*{K1)T9CPNYWp&laBGay?%!%F3@I*>-fSr)Hqkv#UE zEr53#TxhE-9WB%S?Ent#(96jXU)eW=+QU!#{Z-PHjXodrR8B%brFd0ec`I2;2qh+1Eo>mdc+aaq-w zmTxSkLLxl%Oe?!3px2gdxtDN}g+D4;p}m#NocP`vGc;3vxe3>TSi(3#<9MceUF~su zG|i=4Ycr&f+AkC(ZU7tx?bs;45UOdye=gHd{QCR1(2AbRmJxiB7SNl2yt99}$^VJJ zq7F|=Pe%YUrQ0(%xJ2g&wbn7sosYi1ZQErDn*9XOB2e6o$K>rp6{BO)m}R1Zwk(Xe zSa8=SmHI7QYs;XoX6GPhh!*z5VgaXSl^id;aiJ>U`7OsH)^lnn26FEfklg2=Uh%>{+^N9tuk60TG`E; z#A1$4o9V8qf_1gT&Ntz|P{3J7VAEDZv?#@g71YQ2|Y`J}NUK}@bs1(I+jA(Ki^%J`)*7JU~vPTW09OF_hOxk!n(JZ%` zy($m|+*m*_*er@R_Zr2Z5!Q(1%#>@@r3~BpTlWA0zZUZfBvG>ALcK?jBzM0P(g}W( zYDwf;RGFBd5CYM`=sLQp0KfPHSj2e1G93 zJvnY5dvHKomGV=vUno;tnq*D>35GHU&RSx5AynB1MTWmnhQX$>PkiyUvL=EvqEm3Z zzta}Bv|%rv1Le@a{d5}K^xhKL(0k?hJG*10dZXE!qUBUL5Dbzde}bJ1f8&mkUsau1 zoH}$5;1c!gJB7&#oayuiSoYyiHw!?lWp_o}t>WIIH-N-%Swfrx14-wrOUKqFG1#c5 z5*VgzH79ZdEgn?qoJIP7?=w{Ift7ys{9=-L{P9T1uUa`e< z*H;c5Vrl^?0zJ`IF0H^%2QYp8BMTnye0#kk*>9Zc2DB}iRc$uI&Lo(7H`MOEJvYWL zcUvuEyR3*NiIRdI&MPh=?cYHS_*eYN&)@QbEpaL)`|k#S$Gwdo`v`cmv9I;fUhu^k zoTJ>N{HLk;Zu+j-q-MP-4B^~Z23Txr#%ubH5wG}Y(his0GrJw#x;}%`zaVbA%o{%2 z0((E)7W`N-ildasKW~Myc-ew<7@^8NuIz8I8RvN`NXHZ4&lPi6Sk|*-a8-;e@P_l} zRcM1p(>v;u&m|v7LE~^v+1S3&X?eNns1};jf4Hzy%-7hv0fekhU&m&p0m84$?cA4Y z0`vPec5gntVx)AQE_$nWN=u{S$%de1R6Wm6XBx+Ji=DPYKxsLFnQ|lVrScPEVk%}w z$+L#h9a>?zxM(C17B+>Wa2hMq(E0K0vu=e#nzQD_DDwI;D?ynEP$ z$^AIBLxPmPcGB=JwK(Ijs+LwcLLLUFS~1?&^9RRKa_)&b64H<5n(^VG#_{V*fd-8A z$vY=)?sp>e?fY;6SC}T9pP1*Sz-SAvkP>FKEBDB~fX{^yc|>#r-rU}(s>k}fEhosh zga?%VTAx(?#mT(^b1so!cP(o65I_~pe4cy!bx6J84OU)MvjVBl>sC32=q(4M*?74H zjV&IL{c}yKtHD}@#}f@0bXIB4T4lD6dUh3?pF{UZK??7Dm^w^KDsdVhEUg^@krNDh zen&GM7jQ}5?QAaIAUirn3~AO~YnMoK>{j2O<_0<`pN;&!9nJY)Qu~o$!fte}8*q3M zysH+P5xIC{{rT;J3Est_QCS?@YhV?B0`=rdNjf**CDx&hg8UJ7oVr)^7D5xQtA|7_ zFcGuhijJv+bE7DfskZy-pLcIlxL;cO!x?ytp5`;9RL9e^`3hE`8exL`$)Oi?1@<&Y+IB3Fv-E+E!WMif5kvB60MSp2u<$WbEEa*lyr~l1F-WJ zTtRI!i|a(>c{RsF+8kL{=DRB4Wq{B>V{^}F)M04=B6qak0!n^?ky0ww6lH6jJz)LT zVvp~Aw1&%4zkSv%#$$T@{=4K_P=RTD_5+jWQwD5md^>FVbSyoqiHn z1m>G7sT-5Mpv~45eXJi5h!DC2Ps@ec^gBKGIh6SDSWdr_HXO6@eYl9lpNP}HD~f?^ z_UQNJ0NV?{>xL1!SKD|Wz3}~8N~HzOZhwGk?EyRu+os^K;q&%2+(2Dx<7)w@GjlO4cxZQVs*U=1tuq(|T|weU$50rG zQ`?m~hP{0oznKRNq`z~`ooEuHoa>Il6q20SI!be@yYQgvaHc53V_RF{k8f2Ms1ThmE6>L?P^%Rx39 zJvG5ZuTLrW;;@Zib1-({{d${t*wk>aY;J3kEq>z4X(~R$5{>VT^?@-@MDRodizV(C zU#8%l+G0G55$coiHY})uU5iu?Go4)mX;xE&ePjQdOTRVXGeIkSAYfzZf`-w0L^9yF zrv4UyC}4?#Zh1r5?od0{IgOJ|?6 zOe?~1`?1dlQtq3<3RCgE0j~#XnZwdHT;k}wXGfBIEJr`TO7A{rBAS-)=eRBZo&#?G z;f)-;ZUAM2bJ5;!q#inN98R)L?bY@4TaUV5HhneO z7DFkWfaQ%(H8}R6CFHAY_GqJ1a%!(-^VI`aXTBN;E=oZL{KNg9WwH5%$DEb@W)s}A zhn>}qpY3=5Fl%E2NHH?g+1x@ILp1@tYqZ}7TEv+H$=KsdN=ygvit*lW+q}!*?3)#d zpV*I*P$K1SJmE9vF*Ta8(2jZJA2orgEqlcvCf&>U(qrjivi8`gj}B2sNJmWA*1Ka3 z+s%i?qC-09n=T(@zA-YcUaHG=dNRjNzwdU4s&H_ruY(N&#KstvoT>~xXp}27iv#R? z?!LX1be=tY5VmSQLBqOuIN#)5!Ai!>WApePvn7->l8hl8IXSfgL2UKJ44<|EkB^QMflkR9@-DBcBZ#yI#HkSOwcKtk4mE3|XFbUef_Pa# z5t=ALcN4Iu?rmb%Swq@r+ou>Lo?$k~Jnu`s(Nb})N%!6sPuk1ojo(Ch;hLD^f=UrJ zVgFckJTN3Qaainx!kw;;z%N@v?3nACc3Xws$ruyIroXX#RF&zB9sGVXHyHAgmDNlR z)OY1?el@D&6G3NY?V;IOIx8R`4rVDM8TJwQ&I&V?`hdzl1l!8zPc8^Q? z_9|F}+agR1R2;_6Pi2GKu6@vW+_&&yrS+x$5+fhw{^%D9^*3gE{%VYL zk?;gn5*?wm1F?@?Q(Mq{5AwG(Ayp{ zHlX3nYowVtOxI##mz~QkmcefZ<;v;pTR#J6yLvnIzZrsU)@>h>f1w1Uv;}wOm9^cv zI^0m&qY6%#;o;{z6B0ZwRkvY!3#e8!ESN$IJ0g-_QtrX1nDH2L%4$x0*->T!JY_DfUI4$NpSfyW(x%N_5?^}p$Kki>-P$mb<$@(nq;1V+WYf=Fspu{ z{7J0>bnXKyb$5ZE>DP#UQ~ipl(8f38k=^<>i@Ijh7$6UzpvP@66cQTx=^(;z*R203p2Jf*R_CV{%kb=3vSAPA4K@4HVw5JEmVDjX6b>fqRQzPH}61g zvy|SxCV=Eof(&{)e3b@RSI7a-88^xu_F2w}03$No3~7kE+Iu6o+xnuoh^R2l;&y31 z*u}x0js#P6dnrIb{|&|%*GBF*N@$y%$!_k-)SzVmg8Lj`U`>eeyMxCM!;d)yyY&;o zJRdqpX8L^1dU90(ec^Rew|~8T574pPN5z^zX3kRQ^N>2k+S8aU6?FQrkFk9s=ys9s z?%aVh;KpTTtC)+K#1#d+gbZO7rXMsuvgmQs-@XJoN18ZF8Mg7EfOkvA8ot^;J_*T z6@X~|D**AgLsV{tSHDjS`-MUg0kAjmL`Udy1XC_JJEcymkYCwxssIAf=i`-OVyqFBDZ5u(bM~*e?{;>7yHqaL|pX!PK>IFLaznlWILV_9Q>AH}iu^=_3c%-a*># z-e6E=8!BhMSY`3f(jdDa!LjHHau)413078lPL4HC$=$VdpFOJnvFu$cpuzB3LaJy| z5B_n?=p4O`?-FO55Dp259c45Q1Wu0Sq!L2U>?|2Dh58LU2oA4&zGXbFSS|MVT7`9% zN9I`i1Hd4ag|wJD?7Rv5(lb~gS1`NrWo23`>-QWh(SCc>@6YKtC9L&UW$4*?yUI4! z92Q|?2Ek)oVTP*~{^Z1PHYdWJic(s7>fB!9f%!-=&z1l>*}!CvwT)SsLe7|v2x}Yl!D=AQQ=ePv#`y7`%6{*GvEznxQ!&Ti7UL_<*jkJ%c{%qNmSHaz4B6H{3~Z>J%B7?J6v!XWG1>p z&2z(g@2puHwB{ZkXvJ7$q#b|#8k~8W9)wse=yA|_m|-;C!90nq zdIz?UR!4@!Wf4?4D$~AK+3zrhfp-Th&g5-%umJbK3!@GxBp+g#ok0B@ z6*fvAT(m#(4SCUvgxI4F9oze5q?;jFmNr#J=mP)vpqLH5!b_h$&=BUlgNblKAinvI znN5Gfz3m^kMS=7517gF7&iCH~j1;qt1{;Y(?<9=O?rJd0*zr=oUXg6FEw8PQ{zyHM z+jE~sxr#mN=n-x^SR5FkT^f@B;wjhjr4eAj1TgdC){m|Yqme<}EtY97-d$lF&$`l+~>y|maz;~@WZRPx`IOy_tyME-2r zNxDCO!msFbFObA>{c4uOf=eHl7s}+>a_e+{!;UvM^50J%?0-Mdf4#y#C6)h-{r!=4 zD**fM!rZ1FcQpF$*{rYp1}5bm*UsU9zPBx_{#cVr4=zc0&NEURg}!|k-u2i%-4&Ah zf$`C{)6(mmXI+sMKDVOB^@Cvb8dTT>`TL`b=i?g9L(sZf5Ula(E%ypNlmZEax+36t z*v52>U)WkgeJlWpV=c(WOGs*P5MV(b{R0cqxRn|*Ee=BRk#F{EQUUaTDLdYMo%enl zR(CK>ax0+zIaXryp(%~wYH}TaL=I4aJn~{heh0ww=HCO9V!X{i z9tDB0B#(c;lL=C&59wY2NA1)zhT1IiB{5z0x~*AuZtoel;bp zQ6&xJwkW=wEH<$B-^@H|tjS(Dvp5hYeIZN&6`SGM$kAA+;A3f3Vy}PDO!Zc^T${!3 za$a3kM(q&ZuRZ!&=3_Jic6f=Kz{kj400HQs>NiiNTJ`d$U(9_vbYsEpTdluH4+W*H zwQS=w{+sb)GXF|smqG6$+fqgrXp;^$n*!U~o9#T4`-Re?Nv->5kalqEbP8{P=BK}n zEhJ|>sphKIy*M{=@@4 zbE`mmnGSq=*2e=jUoK2a^&PO4JX~WK^T=f6gv0NrIDX`E2&E@rx$(3Ov zIeD~PEq=={u?*vYhEHn8jDo%SV|-@~;&~~{6AH^w`KM7+0_4nEunSJm z56!U$#G0ZIYO@-z5t~;LN?Xk+v+NxUnT#_O6v<0o0bELB zuP0)Vg3%P#w^3H|AD6Y2@0Aqe?|pWearPwO?zyNf^YNX6i@XW9`rI&qrqt}MlRqK; zd6VHfyrV>!psw3_V1yKS3+1Rr$1+rEKmngN(|mp1Vre_6((80~b6i6gH=ylc6Doxg z!kgj3E2IP38wnjidY8*(IH}3)KM-cPs4@%C>u_5C3Js{xVMD8lrH=roq&|DJw?G^K zD)qm1>{in6-1gJ^q{H`+u7FTa5_kzQ(-6Y+mQth$9fDyRO^P?83Hvc)ZELDFTS_Bz zDYn?vnl~DD$?;p2U;HhS`FmL{4Gq>D6is)S?B);2R?BYl{2%%cEG{aeDuyRpiMF#< z4iowh8ab&Mb&RELvSD;;}COf7wh-#C6-es}Zry}a8`&>|mka=7N zA?f_{$e*4RRQfG7MZ3!B;dHJ_i^pe%@ZX+Mp34sb->37FB8rydD2eWqx#Zg}x!RZp zqw#)4*_fLejNX4U-ua(K!A>e)-F_aty3YNDvRI7w=}%boVRmdI8_^iGVihj2J)-c` z_E>2!Buv;C(jX+iILZY~nBK$A2Fla%Qr*3VXjW%3%_B{Vd={|CY7YbR5y{UVt}Ifa zMJT@6k{B^qO>O7b`*K#x{mSqgX&4(>Eh3!R7>vx&qy)#jM>8XK7!Y!)O}cB&J%V(b z*A`>vXg7L03{~g4oTjaZKbkk}Bk^UAV9=WPotvT7kJ*89JE?9r6pP6grY^drbkZ=J zf;XN~343;#7}@G~O{gQSq8HTxtSu{_zG{)^^5j2SUlcE0F~l6-`$Pijy`X z%O5AFSewdsbDE@)5ryv?t+jb}zE3*4iz;0QORvkOSU$l#HXGN-=jgg!Nd-F$tcz&AF-6x+rB*3oLk=v^7f zPx(u&#g19s1+dbgn1?&eJ!(u+zt2%>u74 z9M+?3j)uOjB;CCND4-JDU;KWdQX z^u2$%0+`w0cETkN+!42YZ}g7(fPRO#H-LV>E9Ms1mjV=gi@$|>sIW7Ov90~WA4_Y- zIa|t_lp4#%x2#nfs{oHAvmP#@@f$NJgyMzF5ClU>WzTv^FlpT_Zy%mlT#Zv+kGW$N z%%aJfl3agGL#8zFSGmOK^V>&wU5&ZNq40#ia_Y%}zuzS?i)yLCdksbjnaI>~?d_;Fr${HiP<+W!sV#Umh zUezi6n9GN{b2tH6=p{-LBT?-)z{HRW%?^+sV$F}MW{c>)VG>g8*{`r6)Eo*4DB+lK zaV@?P7k6^F_TfDP)u#X3fVAJi7r5AcUj&zNGQK?H_WVqnbDmBYq~j8eI?U+U^c<7% zA2hV6R@+CT)as2qnKLgPO{<%`SX=pMN6!vG28f zCgCd09Km%`?YV8B7JP((fSUpH2cW*@KNi_emRZXpc$i>8|E1O&pSXVHGZC~5tocgf z{;Yoc(DkEUjzrmuSY|?m2hIqkWbPe~pc{Ce^^CYZl<$z-wN17 zY-jWqXNNlUVU!Kg?l(E6ZBtfkQ9)cCqA%0+cn45MQ&`}#FIg5nUqdo~q)WqNufY!I z=S-2TmSp+d&}&$ALM!;G1O$shB8gsnFEA-nH1Z`;Ld(e{LULEZ&Vb| zWrNWl^G7P2-(wXnGM+g2_|wUOJXc$>qhr4820$n+DM@=r*|=P4kwJ2Rip{D$`NUHlsRIqs){GHypu~KbWxjnZ!KIcZ@C>O*jB=DVszuHXx&oAd zfetqjod1FH4Vi6rWP{uycTpvGKJgiSGqKk?0yZ3E^h3Q8hpKHt2eoQQ>Hq!npG#OS|wyZo} zF4-8EV7fS@0=>4yIeTy{xy>qJlQaLD3q@A?{kvw-Ge*lz0!G$p+_6CWn>3<3d;U*fN_n#`%-zMWpB7qGEcIfOmtP( zW;YpI(}wWrD6d;}^cw<$ltoxmjra$txO2AwnsqNasZ~~;PA%))l5byJ9P;JNTfVSc zLD*+uS-<;5hC7dKivmezS9yVkeVi~mRyD^#4B}XmmS8U0;Sluxc^~CFz#7|fwH3ED zCe>8sfizKQvl9o|4qKUS^LWQ}iIq9(${R1UV8GRaqxe3nNsvVYLMF~Yki=P*%HK^% z9=^f_Sd;+L7K-jHuSa4Mn4{47sw1&W4bi8gzw+%g@KL6dDR4Ndoll zH;rv~WN>jpe~c)Omvz??O&rN87qNnWdpS7rB(dDhWN4XhDLT>?O?XBU;~KB>CMRPp!OvK3H>!M6y0gx0&&0#dta z>}l(vs5qZ!+k|sE($^(8;!`##&AFcGK2@Pe8Mj9RsM>$OL#oUjr5Z^?TF>(zzHit(B@PQyxTR25p7p{I?e?f{|2Ge8 zdk!vSIyB}aF69DX>`1~zZkJnbvE6|QdN$&xHyYsRWq(-KJa8j8DTmz1L9UDOpVevzI2I(T@MJ3enFyYMo27~^pP~6ngkc3;^KO>4PGCB_`yLNSs?B)~n!S^=F6il&MyJ7*5~9A9 z+PnZBOwLMLKHUckRaac+=QZEC)~}-$YbE=NrGOf_@vn#CzDjcyzGORm*IKe_W!kQE z8zp3`_NZq9Yu;aj;L%!ak3mHDmGefu>wR(p2o4}x{*9BEZv`#wGfY%0&iH!=gSK{I zOGg2h`;6nmNT0JG+qj?pZ$9;q8*O)|@SW#{4}*ZAbg-4px7NK!==aq6rpp{W%Y0rR znZmjV#+1jGPD2u)X#lvx4N702t&x|`E060~=5tjqNbO#5C_Gq>N|G|qFSwd^>a5!8 zfW_P5N*-6<1zp=5Zyy0zp`ZZRo9Wos@4cAzWeGq9{0O!b0xDFjY$$g9%*T=(Ut+`z zC+gj?l3&^gz4yBGXShiHU*9%Ri5b51-U9vIq4sGlT*w0Kyo7rE+?Gx$&?5eDTH|NY zXj$ebmt-y@mwXog0RjRwkntV6GvJKr0c

oH1#Yc+nPNEy}BlGtXRZNFZG1Ep6Fp zK(481#Vm~=33KBvq+~h${LI-3k0}+Q>tj`-xx&mKEAm8S|!3TGp0h0V!3?efc9?x^es0aTLnYx z|B5rA4f6)6>`(kKYvn*XCV^P4m|DMkwKd25ft_vr51)zq0*GHEu82*I0AWc@NDdlj)e0J*df(>W* zs~)6^3veNlictbmK#=tT5M%)}-gMZf&#_MI6EroiBkFdILLUVrKjuv>+kkVVVY)@F zIxr`0J|)F1shdw#lvG0%_i?fSicwIADT&&%u2bibC)yO5*;LOD7C$pO4?QJ)(NelC zdZAyj57_Dmx_}|K@b^WpM*IkCS|Xzj(A!t}d~}>QmC(QQ4r*6jG#J9Nq4G}BzfkBZ zD|S49wecgQinxIB#ytDt1E#Q46TU?|1z;gH(Wy%dtis0-RzC8742K}vEfXg9Ce@_I zC%W*p*Xm9ij;CsjMu^3OrEpkGNC(d7eBz9gZcqbs6#$ID%Bh%@%jqTWxI(N6(;mm< z*w*jxJiU|=X*mGqLd~{+=4rGStu)Z7M(Nx6q74VIeGlyXGT*FHiu4i)_#m(eQ2T09 zxgGaz2hxaW^DR7!kom@zkpIbZH4Aa6I6F^reaPZ0Ed(kTLmPuWLtOV1pF535X06QV z4X+p_BwQvFO&1{JiQ&9kX|3WkJM!gQt8-7(5qPB8te7Gfrc*q^aNKG~52Z1ubdByt z*Mcil_GZ=Z6(@$>J?9tz%mjo%?G0D#FGT96u4)fU3yxkk)pm0)3GdBCfi&(2Ps~oz z>F*?YuZ!ULSkxhPmn&fB zwsUJh6?C#kg-ihgo+QdYUD||up6RYiow@`EUOe6YY#{UxBhv%01>65IjAx~0MEB~m z-{{=hPAXj=(w1_tN~E5awU z6;W={2z1(}-Av$qqF%vSQ?D_{O2<3~JJi!T#S;wBD&Nh`nFFf<4*sb8@2jgMx39Pq zJ&aZDZC41%+*KM8QC{j7pPfKw6j;I7K0+<*H7vva06q558NL9&z=D$PjKiNK#6qY` zz8oZr9~1hn?jQL8+l%zs?^Ow_<##e?KX#9p44-p#sMqSjfP`NF@wDW31bFuT6&m60 zL5^B4zne47^VbK0L-W2_DJ>OX-_PGquJyn(iTe({Y@4|YtJYR$hEr=`z>=L>e9A;@ z>~`LDev7_Wr+(JbGG57S)41NB6IGxsh&-<)amG(KXyF%1#BWkE%MP+}XY!Lv9eu{v z^>Cy9NcRBc5@}Jx+cMyIw-*0-y#Jbq|2IDc6%6BMr$q}?jHLOvEKjjUe*WZg`sV%R z3?R?zfVDe{uy_V;d^UK%b>KsMjkWDJE6+)=4wsZLpLw9#MiH9z(zl1-iP-y{kGAF` zi>vSn3@upE@Sso+eBEk^o+AOXU(EP!;;XY9wB2EZ&O+#+z?LC- zG)Xi1_vAjknO-c-@6nD!(W&xBOG7Ci+Yn|`71q`HPyXV7`T$@!VSp&kGbSSJ$Q9;) zq3~2Xn)1_jL>)oqzIi+ZAe5)zw*_#Kg5`v9oR?W7GlQim+!rtI>>eJR=Ckx`oM0TC zkIX0N7k(tw#X^wB-NH&*{|KjqWR?dQbVU==V0IVDsn?H z{igeJd}r@87UUP&Xuph5-}|#JvQO!Zxw!=F7JU*CWwvmvPVO0=1adX>Rl-|&! zKASwDwk`CROs4<1gX2eQ0N4q?`P*cUE<=#u;{JHf>O5rg;|O8zKSiaXGRfoQ@3wk! z%?fHYHw;?e($M04#93;ejWtH=XiYxlWx2%}pE=Q_Ncm{ko#B96yIw7ktNrk8Z)jp* zJfFrP+8{wLtO_`wV&~R7&|(v^s%LZ-S`klzgdS;9MSkXb zx1m)~Szu&Zgd4B1Xl@z^wAAN%01H8`ygPJTD$X>2qdh`1^pDBA>$3?@-pf6VQzDw zv~>tOcuj{UVW`J#{In0_<%!Y9gbcD;R_SUGvO(HKT=brPD6mqakL* zsO(&mybJ_GQXI&)3XBf1kttSKohoToPWv_|a(|c3i|$8LC460e`XrgiQM++ChbQC3 zgUiSP+c{wQ-1%CesH}(_k_9Qa?OQ071&!i zLSeDOwf(b@%4L*`CA0hdma2D?*y9^$-_ekrlSv(Os-CD8;JO_EWI;o?NcBiao_zeK zAIbTwtD9P-U6^O3&%6{Ae?dp5P4E`t6SaM>_cm5g1QEL;J;L3#4(D+sm#sNdS=WPx z0CIhvpYEjr_Nc6rQy0)q(?Um8MnI^UW=n?v2nB9PT(zi6R;SFwwLqh|V!jO?r)5-m- zBX2YohDIqnfBq38IlD>DcAU2$Z0j}1F5vWE;Lbius}pTdI+@v?n;Td+%ruL}20953 z(}(nKt%21ad`9IyY8vgZlQcj zF9zq`>j@`qLC^X@%`UCD_*;eYN7(}8Y$27E-a8+F)!EYTuUmszYD=KuEqD81#q2F5 z^d$+#-0$F7U-!|BclLgxy)a1y2E)|=ae_e@}#%_8F%PRG}QHcWCo_CBa@8FFd zadm!uq-8JCRNT>W<>Zl6u4J~;W^-ChAbMz2(%lg?r5bO-^o7dG8Q5z#Fnf+UgTOUB zc`M<-GG?=9ltJY$909OJpA7_bo|6L8nEn<}H87np@E-oC>JCxnZ=RJ6o_pH+=yU~I zM-lT{%2hJ&e1y){Rq68D_dIhweSK41u0MLd)Xed@3==;>1TOq@*=gh#%123#?1}oa z3+X<+T~&zB@$w+`#nQF%3DT(;FGLz}P3Z1q3uWEO3+f|{d*O)+(_NO)ZA+f21p!Tx zD<)v06;H!{}e zXdnKAJJdPA8%sMn$^o~%nv8qgwy(-OoSjLSP!)3?U49Z+e76F&kp8E%G;Q;A7?WBm zR{xRdht0Q~k(HjCMLOZ3E0al8_QY0iNVrVxjIdtho8v`EzjP=Sh{C%0&{>Jm*P0@yw2?D`Hxoe$fhR80XEeGPwW&7N`bX@IqsP)+N-FxB(Uub zY>xgZJthh7#Js=Xr<2JzuM;lOSCOKJ`Qe`RL?UG8ix&qg+xw>@)B>=cRTusBycP4A zE|`xSX2OK2 zmJvLWdqrt0K`R$I&3cKsTx35jNZZnxO{=WtI*_K4iI`*-Pab8{$114HN*W5AO1^ge zF^f_AOo#e}m-37%d+qiOxNBEl926 zcGih+d)92!3<7OHyC8N5l}gj_r4z_@ExB=D`Y*_t-DW$8Y9>f}dZ9PqqMt zf4~-se>qoztQ~}N9+upwq`n$B{;V!1YGDq92?6yB;v1Y`!1dyLX^>B7(GX>$YP{=$ z%pX3lBFnmP;jCq)3A4ZnRXj5VCc8HJGtT214^3(fxRB0D^+eX*&LL{l?E}&xnt4AO ze+(|OJ8v|6G4$<+QgMC{bNSx7JJ%39F0{n1Y=aboq|c%_QSbFL(3u;M*fjez=i7HLxYdaI%AO|wgk^7{{F}S%S;!*VR8Ex%8_eMQW@wI724}mz4zkEe2)B> zl*S6Y2DVoh2iRkB-S-ZO6K-U#gzGrad1bs9f0u@WS; z7j&Kx)wX7JU$f)t0WFRB#DPJ$8&-sntj5YDbzyEK4OK-V!P2d$hliO{8Te)2pZYkx zfcg)Q-YX=^@9|jCslgvsSiz}ECAZ2&FOBCeAFue+ja^rOolXIuh3PogmUo+PObt;j zh|S=-!r@U*eN1G!(cTXwce?t8!a0HdaMZzI-rZ-)QA0$1*Pt3phF4po|C8*MP{=W0 z!K4P-!4!Mi+=O|QR*FycL{&%5@~BJo{=B2^HQ*{#3wDU<6e^R}o_!Uy^GWCs|CC(n zv9E;m3vV$*tf|y`ylm3>L7GDRxCQ9q$p) zp8n=A0l(LzXjl=s#TMeatTW600DgP(pGOK9psq%g6;fiYIUxG!0xZk?xc(rMtUfQWJ>@OlhWP@PAjH zwfEU;opq3DcjPZ=;x$pZ|L(rDDwOko*%XwH=VmWridYby_zXKi|!53T- z=)Xv^62g2biYq8e=3KkV-pbHl174>1KSs*z!OL^;Z_Cm=DMAG*3$pH0TsB66vbSX= zot6H{tTK4uF|^L!M<8&lg9AUwi@Les%ZDqSWb=l$&1!-9z>9-r&$} zab=81cTUj0a|5Cw)Y4x505n66Sy%SPNd4mN{LLF62r(#|pakn-lFHwomMry_5!N!k zPUp8Q+j7PV#3EjToDI!xR!T5Opp1}M8fuBzC(n)r(DTt}t0B*uxJ;>4yMuc-p21Vi zRDIv>meD2m#mF$LHC><7K=lAejSoslx=`Lj4Y2Qn*q18(9jjVz-lq3L$ERJ;*r$h{j4sN=5jJ?4Lsmk0+pulLr4 zOY?zGqFaWcw}8TZ>Hw4V|GSn)Q<^04+p@fSc0RE+wE?h7-`NDiWH+zxFg=P-b^jnV zXax`21Gg7P6pjn3*VYxk27=>sQ13Pq?s#&=e-8$kOM)KflgQfQ6zb4a1;G6b*hft| z5G(z8?m0igDZ(y8BzWaGeVK4c2vd=emYSaOwU+Rphg~{qpL>aTnvjN&SF)J)CD=W zrK=NX8l*E*O+WEN?8^Hugri>w9kuGMa&;$9$Eb9)f!4JlTcC*GiN@n;t2JIt6)jn= z8l<-(j{HptKsE6gxb)IT4Mq%eJeXm6NTB(C!swP}+w(pA1r;`a(>M|MK0||yAXlJv zvvk41enM_<@z>l@#U4d&KdVzahibJm`vmx88GN6P%nRJLEb)eVz&b`MO{Gb{;`IRM z_LuG^@|C#I%~c8994_@;4N{y<_sJ0(qYkc;iIvrSSG_B#G{WoUQ@ti^i6Lb`EGg;y zmo@N&z$fjanMX(@@DUR6`TqrpoX-Hdo*=8P&&3@nKgG>S?nC^ZS!Dzj zZ12r#uSl!d+Ze=1y<@`Ra9%6B86K%Hp3^z8FY#ILwirmtJZPZcu!ZG z70wIzUe5nDqkcaGX~HJ?gP?3;X!GSM?Qdy_16Bt`&t6btaI-%pKFd#!$?4tTWM>}0=D%|Ly2z&7NcqLb4Rvy(;0t9|mOVGs)sQ|#TjzPcIgJiW znr(p7j^Tkkg8`9{Sw*7PImuXvs$@A(x(AK9208)TELLX(GQL0GviZUp8;-b?Sntp2 zniJ(kmRK%{-@yl1Adh#mU6bBsE^NKS+v_ZbtyMmM5U%`E`nt}CVQ3GnCiZV3>T+T- z!wEW4UeFPS$w7PROYWsL%wjhGb4M(dQv2dNq6_`Y1p4GrSZF zFrLqG{IK>W(Qo%(A>H^bbatJ* z1^Nn<8>dC{^=8t-yf*K-F-%BbJ9yz1ny=lKI&TN$5R*S@viH1zl!eG#o6kY+S8{LE z5Jv)Kz&rjT|Nh#NF2Mwq=PShOM;pK;%^?g;cM>?t-rEfZb=8@5C&61Z z$OWN*94x@o{p!oa@OJPrnje+cB8OZrd-H04i;UB83mtHbI3ez?hMAi#gBqbVa?Ie3Hm_5eqV-+2E$QJnme}bc9}i)yWTTw zldV1}bidZRMUFneQ64j<>8n$nL{udBI8=nZgWF_XDJf+l;sJy;PswX+PAjMXW;&0z z7xnRH^;>cX(z?8Tha>EPHqvShOJckDIg*Eo`lFOS;aExf>Zs$CLbR|=oX?Rs_Z83U z?+_=WxqA_8xrMHnUE?~MD{WdT^8jQ3LmVDU;2NE@U*b}1MYNh$@nfR}3^Gcv`w%|| z{=e{toPWR{)E@DNAtPiQf3_fVal?CIk&6PLx16N<(oRin9+UKN*mYT^L5lU)diz%s zt}cH(GaHX5>n%7(OjyT{E6LurCmfCkp@s@c{A$YOtNwR(2Q%yOfxXxI>vDeyejhLN z57pH|P$;JqXBtFQt*F0gPtju7Q-4*=hGk>q{F-_ibQn zkjwx&Sgb-V)ZH_iJNDNNY9p`%es^;C23r~D+?^yM4%wHkt~Oz;^(kh*fodxG>}khR zQG5dRN;dl)x(z@8HSu3R1CHoAu6n(?Zs2txP=cT&Z${j4ZNtwc0J*O~&g1Qf%*GF@ z3DN5Ev%Wbbc#>0w9Mp-iQ4^*vadSGMpUYkKc#@2$y?%H|fD;t|9u8g_2tK|0Ri4u> z7pPBwworn@XTr!kW%3e&nq#4a+DluwYKS9GwDYDn5~30soaIkQRuPl#Q5R-&=KPxC zTV9;)Qvd2qbGle(3=5+}05970<85L;mK@9XSb35?TsT+9mb z0RDj&6|mU(=O_8U{17tI+mIFUp5iwv+t@P0n$^fWQp1rR5IHA0c-V-(ufoU4+)HV6 zf33)fiGKCF#s0ZL7a^jOF;k*3u~RI5A3Eg|dkNLzGS(7=w44c7l}E*Q-=_Nv`ECF$ z-FZWZyNctZ?ypc)$p`#NKw?61#6~(Fd~9}EW=@=HJshGwO!mVx&(6`Dh^4V z%TY`vW!bWHi0EemfAA$Xj$nq~3IKb$fIAR+^6w~&v$hmJyYfxu2-Q0{x9J+Riueb6 zq*9MazM~0VH~ZLDz`&R7;W+>m8EksZ+K(t#Hc;YK7m@FYIitfK#NKQcW}E1QnDHHz z%9iWH02Q|#D<3r()ShTYdd+vA%zJn^AEDd%&I; z*ctr{n!j+hj^->NthYlY#CZ4mxuUc7@HEAD>%}|GZ#ug4yTx_WvMLt~Lm%dzY`@&p zyE(vMc})5GgYX*u&BIh(!z|xwr*7`prm5pjHbI~x?4ZTD3&`Sl>+v{&K%Qv&R7Kj$ zsOqFtAH3qNMlP%bKew6VAkJmZLTo5wl)P2jz7ai7k$!&k-ERQZ0V>580^$c4YuCWa zro`f*j+bqWkMLiY4T-D!()O;TBq1b7A?~c<0DF44XicP~G{P>r?J=W6Qrq>Y3ZFx& z8|rpsqv71JQc?coMKeQTrNio@UvZy<>8jK2aW2=$-+2cAMl7$!q?RMbW2%||LiDL} zxqywamvfnUrS&7+O!l-9=fh zFvuZL==v>pkhej_?Q&*g-APj;<=_+zNZL99SE%2Ln~^9DjB~lMr~O%_ zN+fc5Bi!S6emVeNamnSolXPzT0WM1I7Zx40E{{F}GbD1DjEoF|oDBlVt31#GfK`ir z&+!Mr{Cny~io^m7vaH9GZxU#yi;9I^KQaToIuH}Sqv8wnfcgIM1>(U3ULel*d}!N$ zZIb7(;)8ErJ@m#`Ur+EFjSCAv*$;ZGPJykKYz z&Y@IM?C|AtiP>o?80n1g;d_?NJI`JV~{;C`tGlCg_@{5Z!Z!EUq5 zyU$2c60*mC5vAEtkq(G)od#}r(NH1cnF@y|1z-b5$&(=d2L@(Sc+vT$e9#y9aI-x? zFug_M@0?>k`E^v7A4K?kjDcONsVv>xoEku{|3-HNw7Cign(URBhW4h|KMLG@Qr7h3 zxDdC?=Nmgt7_vH8o{(SqIt4`p&xoWP*0_~n6e(1ty}{ypV)x7#h*~K@zvL@@A4m{S zA8W1c!kn7PmB7-}01!s>a!HAb_DFrb)%;j9GSt`N<8_06B_CiF_~7vnQfwO~MwVLA zEw?+-JQ~iIpgaNnMhhMade*flRch8zcmEsMZ_`{DQ8#5%16YXq{x>=QW)(ikbC6K=&kb?{oAf}rCjn_XF&@drdB zxG_-7^X|{c|8$6lM1`!DE0Hu0xuPm0X^~=>$k(oAg1Xn5bR{?koGCk(k zYJmWZ?roD##0`kAEKIn64?KOFAsC=FWXyy8Q&zvXId;rizy&BzWFfevzO7-jT;s)c zXqR2Mj=ygbh=9D9gjRAul*jK7R-w83ecHX*xHXs7t8ZNf-6X(j&e-QG`;Jhe9;TEqJsRXDIW947s zqW@&2nM*WZPJan{5nX}~xPY()XiQ;Z7-hTyWp39l3A3prIpRG7)ye2>>Nia|03p>9 z%{=-0H}n8@IaLjq10b3AxD1Mk^?$WoT($IhC--v1v9IJ<^(EL3+RzC|NGbtmlf_w8 zp1uZK3PH?ntfJSL_UOGs2hM7H;&v{VKrA>AL-?<+Xx3fxg*ykNTjr3Ba z=5o3oO^ay{D6=}KD7Xr^W@^Ir^b@|w*nmyweHV4=`gvNE-& zhJ=LJ>s-`%kOO=VG1|XAc*YVOR*xNa%T+>3dmlnO^HnIkc%WlsLE%l3PDfK08FEAM z8X<3_Qk|6>g*-y&z}^(Hsz612a|w;!rXF};#vV!kvHy&1kk5x_IT#%gjk}VpX2~JF zUCb$V-!*7KO`3ciXoPgtb5ChP2`vxjoe^9(#EILT)%^{SF^cr=bX&r#zp4Y-ZY9Cbl)-BUilZ{1g_btDI&hF67pg@?Zq8L325?I3_7Yreq5Ue*(|Bzd~pAudhjw~ zz+bhn8KA1ZLJzL62z&;Qx$X%z>=$8&oX-hcm!9{}Eh3jV&X4`Qewg9#(y( zkp!buctZ4tTfd`t!e+t&08{-RYz!Xn=7_K~9Y0z`JpG)g%60y91*qJF9_K*7z4%{t zV1Mrypb^4gkSoS1oL42rO;GL`ry9B$%Ui=kT*!talyMGc2@vV=eZy5Dt zcwooQ7Wiz}_2w3Msd0R@VDQ=Oqyr-sgB9@g*HUX~bnacN(aN3Hfxz=j5kJusFyK9D zf-!lYU%-*qzqOdb?eZ{nrV9jv6qWi8m*|~imEg#vW8^^ts6C3FpbZQ}GWB|;@9@{Q z7(JA+`HNX_2;A$Sd;lKNf6e{)kKS@*lQofc@}+&nN+naJ?g^@)!48;m8hTkpP|=^2l*Kc-QL@IBE)ok6>e_*zkv zxd}MqegQBSjR6J^@+Z-PjB-(W(64WFGw@b9&Sm{hJ)p(HV8@RR6zSvnFDfl;`Qi0Atej0!i;G7ACNxGtIn>va)Rt^hI(nK< zo(b+^jaDS2umUbSzDOts)idsx5D;wwPah2f82c%%rr$_G9H=gp6?@$%?8G#95RenZ z&53f*fO;qDu8FkneSA84)0vyWN4o$mD$v!H98OD;lM^)n1WC*5!4rrlR0{m>UEUb=p?NXrBbkctlGfXjqd^^MYkDk-cz{ zP7fH);eAFVw+f)=&3GQ<#bcBl(bwK^ZPJ$Z3E}u+dAC2fjZ5W}Y=YEek1wvzik*Ge zl=;wEdP4}&bQaYsxUPZN2+B^wK%2hze6eA}`EWkj=c&6Fbgjt~$a*H%ATqnMI}o@r z?fHXH2$;R319=tMAwkJU61OJQHzgQ<5Y#>Oapt}b;0f9&lP1s+n{ zE=X7CmKXz#hwPEebPI1nbfV%mbD>v=XPApCYgXjY`8&*rTnPc=70Jgx2EO|hn!-;C z-CK56^g$NieqebRK3&tQWnx}&inwe_s)PW;8j4m)DUY-nqLzNap}VI8*hG#@gI~}a z=|=3lo)I<&@S?gi&PQRO50hAG-$c>zO9B~wbF@^Qs*D(?VjpVI@0G*7yo`c7n9$|) zQ@S^6u=lx1_2O!d`un#ob}JGTu}&RaX}YZ2K$0$7@7G02CKbmlW*oVne#T6EQag@n z^(FKkeg0*~asMo8eU;JK7Z1bo;BQ#PIUdJY&;6;7K2TX}*?|zIOkSiOK9$ zn$cA*YxF7uHoTVy87 z2X!?GMMNEyYioYSGsn5?rcqh+uIov+zMW=KHSy80iswVeV!(t{&-s1^I>I1 zQh#CL_?3O@7xt3t1Xjj?qV&W{nNax1@}ZY$@Tui;-v{gI52UCW;6yEXhgH%6WdRe5 zhRXh{8pQRhN7TFKJTP_HJKSN%G-gmJj$&HwU9fIOh!be@V6WR)}d8xBpTPiz<%&x8Z_uIlUz zq9j3qL|sW=oM4mBPp^VJ0ML^4IrTmc;2i1pG0Qx$dtjtbSoO#%#AA)mE%`d4lw>jZ z_{?+xaJ3-jg>;0zU8Da7Q#?`u7HnR00}&kogSZ2h1$QNN6VA^S}d>Mkc;rl|J7;bJ&LN%|h`ZpSB(AHeOzJSd)# zcn_<49*n#sD zw`*A_m+=%mOi8E`efl8N4Bw|9P&t__SY4wF)tFape($;2q^#ef&z;~Tu3pwP1sCL) z-ua4axG2M(BGKFDQZe?1+!VRL!`a{^)Rs4d?)PfYjXD@Ra7 zvcX9yodPkcCQ?^24f!cf@rtCODH&)c5B7Fx{AtIH<>;zO?APE{KBtFQx6k?IA&+lKu^407_^_uPH8%M5~9(nn<~;%*PQy^*e8{x z?*%DK*_ynbLO8k0TApZDB?Aoh%8M>LxKbfUhNC3Q(h@lF zvGVCT@A;zBGXQGAuV{eO;4LWUfK##^1QtZkK};$T1jLch;yWmZ*;)7KrG zE}<3+tP*-d+nd>UPsKI4-w1jgP3KAll_duq$W#1JRuTc)ol@^Of{|6Y9mY?4%V zGwu%aTi~5SkdvflJ6D$l4Rybq*;2Kn?k%xL;nY2A-gt(Ty1gC`&a3Vhcy4`1d5UH6 z29bba`35+bA?IX#GWNU;aK%&s_=J3j-gfKFkju(e-xZ9}Ew|()gp8!^?2lIp%5@9u zOl2pZv$95hudIUj`3M9Y}z{%&x$Ik2gt_sjfE|D(Xc9+^N`=$h61caEZw z?Q&(xj&;&5d%r9z-y82-bYnN0S{4SSG^5L|{1Fg4;@M=IM!oo#0M2Zf(@0@bQeSqZ zhb5M?06{X{Ngg^qNJkeSiyv=EdbG1kSf_;lK|lfBJRD%vX^@@7Lpk5#abugLeA}gQ zVD$t!AQ5i?VoHYKZg@5&Hdl6}5D`|a#e23QMdmm|3r&IhPG+P2uY~|w4|H6>kcV6Y zQcAc@_0czsbRbr%|8o;{bDbqXV|W4O4G5n$>-P>?1hDSoZ(rv>?X;ei4*u$tJf#X% zok@t%EM=i{_?@@ra6Y3wC20e^GPd7R;&-%KON&|@duKxlsmk{=Rl8H9TJz@yTWsQ- zZ%?sDCZ9VTJN4d5`{?p6yy|(=?2TR@%3hKGi6b0@5*urMfH9MQ1z&uSdVuTSv1;JU ztBmLPzK*bZ?-gOs|0I5ARGVn~-jF3hq1u9A6}95crigE+8kKrXa8K9NuiSw@2q7o> z4@^O~!^mR3q_B&bj)1{sNDS^5b<5~2^Oop#OAqG+r%50l@=AvW>_Aw#a+J#zQeX>T z%o2y=W8E-e)lQPE%#_O{&9k#Q@BC)o`zmLtRL=R5ZHka^dsUNtzPTvZ>~njkF%W=C z`16)>;7~l>iYz47qj|8vYb*#b?=nm5MTVDqkV|{-EdC&a`2EkO6sC= zQowz}{Twt8nz}sLM6=fga=9~fTYcYg@P`^l4BO+|xF6$tQf-CTc)O{( z;vr$IYIQU(wuu0KM_~Y#zf_*nB^RU%kl6r5$=^R$us`i_r?etFxSwl>Q7F*D0*g2= zHSnH%zI(ATg^Y*T`Pj!%N?kfc1GNS$Q{lYt`o`4_S-Z%059+ZED_k# z>40~0LjK2;6ti@%h%rCZ4l%VJ7S4YPch>)!b|XlLNaG{R^=3rP%)?t-n;u?TxLD_s znbc4vhaD|24@8Jiwin_zO-VW%hU{1^HF?cxn$&Hw6qMXNMPGID3uHmo^|fk!4+_np z55jQOOeD-U916>W$o;ksx+uY>#u7Lx%$N8H_~<%+MobZZPL)y)dpDi*^=FO$KKD=} zzbi&jxCzlf&j)7rtz}Dx{HbrZPwQA)*hjw0 zG_UbyFm~TBpyWJnHC@?Q^3%YbtxB{9sioewP2=H~r+`V0=`Fq*I4iZ*d2bENU0y^J zd;(pp&!10{3ZyuE!cj7NVp?i_T9Y!hRA4MOMo*s{e#smCqX$Mrb9+FAm1d%S##qkXjcF?5l&o%ff%MJL2 z$m2lTT0#bm>`4^(0!oI%PIk*n5dxtjQDi!Yl$npCe8y519m@46(1Ha zXV;iup?sZB?N*fr9DjNWOp^RYDd4-%<)=tZIGwRMWWXNO3j0r7Uk{ zpujczJnsU>h?h6$xC_7+DwlaON3V5Vj)tzt-u(V)zZ zTfUo;Fs=2h36PxiLhCC(Gn6}CaQPVAY8<+@g(Hub%QXWdhk61sfUO7a3%C*DE!MU0 zAA|uHcp4A|!;Z-A5jczeIg5mDcjuL=oYhco9&_&ANp_2Cuk=+%vk^GR2{DTcZ;UZ- zY3S7(idwHPf>Lk0aCwJUWhyV0FLU`iwpwFdsxlxMlMN?R&*bgOdnV<+IKoRcNPm5+ zSE&;t{;>nrsjHxr;?~8drtd#^f{tv|K|fDX-ThD$^{wp$O!*pltO-`^=2A?ZM!p|;UJ9x$z~xbjNxc!QmQxlunhy>Okd5>TXBi6?j{K^R8sNsg80+jgGM7|bjdWf>_h zhmIXKQfbH6KJ^L%70*b@xtXGqGBxWblAbEu_{r5Nq_}O#4WlbE@PplGG{>%8a=Dsv zo8-TCI6>kU-mVU*K>A<6to>~{hzK*6xI31^SJwI12R?TYbs&E4%<hamNm9$= z0M1ql@Qtv+Iz}O&uc>LSgen>S(;g6lm+FoJ)Ga-@TBqIzeM9!HH6hWeCndtF>?AXf zcp2GQpoQ>wrvX@4a<|0Q%}^iN;}Ug^cPoThgE=qzy7_nVmd0TT92DXrff$qIf)LA6 z-=BHY*ZT*|1yMX2{vY971_n&}AlK&e<31W3PLTCO5?Y%@eU{X$#Iu;R(L-kjK^vWd z%y+B=&(i(0a5vLjxH}H8Rws#wy5x*(r5gx8AGeOX4iPj5%viHa+FyM0*j>DS)v6F% z$VV03b!JFIfO55Y9C2uh)&6Y&dtqq2`Wg?=>;O*R0S6pehwHLa8G5#7m}5*P;*l5| zFGJ}^pB=Zn0sU~yy1oh&(I9^c*a>}Vk>6P29Mi~sWtqz+FVFmT^tgJQlm^kJlj&C# zMd2Mt0K{+!KD+_^G(ToTeeJ~MNt2|8>YVuSb|`sWMt3vG?86c+o17k((wh}UqF#yna2|KtXDoG>ttmV&T2`7os}F$D6aa%C%}s3NOOyeGR@J}15k`a- zQ+{q5-vZ2AX9{`EluuIPTx&?xq73U5Y|Dh5+l{r94hj3WF8d)^7i7k%GZvWzC8YPnRhwt}W_ zJJu?>zL|3ejH=OKO<1((286r6$ zAX@QzqqH8!>SY!NvhGMidv57!A_2@8)m1h(H3P`z$ayUV(Rp=N@(&hkOF{JQ8I^vI zh18Q%sVftbuPVDjkvm0vWIkRZy0opqQ>-cHDr#_7T<P%qWm+~P4(LUtpHuUw6O-?14QjpzgZNwfoHg|bGWcAFw z+O&_%rPf*=OolCFS27Bq9?vMyio;4+>V7Ww0P>Gbj}iQs{@Sm-G%snV>r|JUsE~jJ zOYg3ftFDybuv=?8hoi1MuW#EVDo8ydH$zV6Cf1^uY@MVf&6o4Zp| zwbBpt*^6lOWb*_bd-iL3%n$_6Z41zXyH^86Ch<#uWv6|8o}Ih>N#LlXns+7a_rX(l zy6N#REe^ZXN&|M5Ve-ok$VG~PuuHtB0nzU*j~Mm$!m=;s=gaS-Xo}63*)Qk>?yv9~ z0Amm0UelUQZID!^vr~+MsCG>Mv2{ktH0p^e>|+832UIPh_q3$D4}AJt79g*2ipSr5@Ur#lV?Zn$r30_HBdFb7p(8@tOIo7=ERh#S`p_8VpL zIrcyKIBh+Mk5NC*}6J?%cwYC>;oqo#85k(%^cn^JHaZ*lH` zY^B+ZqdRZJbR=I)mQ}_qo+lkl_qm8`Lu5!KfVdEwLEd@T0mFggphD`Pgfa)|sBDyRnxh;!p?eIK^e z1}jmc<(NM}$S_ta1Vxfp-a9SVb*$HU!RW+NuG_z(jrxkfeEV8??Rr#{iDjzd%+il< zH%xsyj@NaS??6Y7RD8oV3Gp5$gS+9N1u`U@f7#5_XD5EmPCxKU9@{_u&Pg^_klrk} zPw|PH$k4B>29f9&jW=u=u7r&Dww)FNeq#O7m%5awxbp`aK=^semCMe2lCELMUnhT< zlp|9S-tX0u>NTA$>Lq|}09D_tSg^IFwnhx_;9aQXn~%xh*fn|wOae;k6gu6`P5Kqe zBcB~zYqS{`5H(RV_DTACEI??>(jQ9e9>~8}V<$yx9eTdEb$((WGX>j-P0w&xekk<2 zgtH*24*C;)1+RZOTXq}U)|;T6YcElM4n?IVF&Zuo92bp*HdKPxn~mT`$loSeP5HX< zHDaasl%M!RdXbIetXQ*W94Gzo_lVy$W310V*Wm2GciPRRp^DGBv+%gPRuS0AOQ z&ALO>>mr#T6V?FYTpEr~8+Sstn(*`Dy~bSb#2BZk4Upc3^{IU+FX(-77i()@!W-9u@~1m1M7D>@iKFWaJt$ z6y{YE?-KKr?Td1p2CE$|qaO&+6jPqygxMu!^My;*hsL0BXgiRT1?R=ZhXeHK<@qMO zuKX2AbDWPN(iaZ|4y7M5w-W?^4v`}{*df%c8Kb5)T>Gz7UcbW%`wQ{;`>6)l-@nH9 zx8gQ#x5_&huPpf@8)ZLPV;qGoFwM!jjjDl2pHwayHtc?7Ssrl`Aa6W9zj2auoPMN1 z8;TP>(2q4xYr;zBnZs3gzSP<8jq8NR>h{HN*O_{DuO*?4PTfuhjFn)i`@At$;)8G& zCOVq0E7OzWWfUTo(fNCc@@y?#Xy9SpznZ3siCv-xh7WJeZtW3oU8W`AI4z8@@E}OZ zGk`Bz9gM~ zX{4#lJC}B1+GtAREL9LdGJ)-`2>AYnydW@#D?A%GP*o)*6dh##AoRZhKZq5>(}PcB ze8^$;WV!$-D>|CwSecB=Sm`X#(Lr5#fO!rWU*@pcE6e5Gs@bQfFAxfGMI{w{saHa2 zEaULqH#ghDKU!%GPKH4H}UxAR8o`C^-s-5L=DPnZw?gAmm+ zaOwzs@c=lNNt~1)+sc>AaN~Q5j-|1Rzr0z3Eas2Dn$QcBS)}>A4)4 z{R3rNyr6~Vt16-l2X=li55ru?0Z8#mvSzX9Xh!q>uyX?y<)6 zFxB=zb=HIY()j07C)V^-G6||n^-70jr68)GQ z=_6%Te2Z>RX?YKFaLp9~;PH96HVA_Q*Oq=a_nu(EOod5(NqHrABTW(>(sbw2>@zZs*594&>7+$L2TefJyigVCAvN29bRaVQgkW$x z!CNejS5x6q@g~1hL)gr~l%#U@vmSc7X}<1xnavZ+M9jy+@zkNT*O6dl+!9sZO_c)(kQskIa(a=nHb{asy>3YtQ+ZP zb39Fq&L=2w+7b5;~=N%y5RPI2OSO(#5L2d$3tuO z(l)B+dyeU@r?tl0IAN{b{Wr-qCtcL)7gSbu*b`QKOgZ_e4@Um^VW75?|`Jx{wh z{YvAVcPV9K#5F2=ljX)aiUm?=2ReG{W3Bxg>lypGl!1L42xqTQ6~l~lkrz9Hq8=Rma%O6J+Jdi`fH(G#^s!2k&2<}zue1pekC(1(U7rx86c^RVR&LaN>uKag%V8b0 zclK1SHNY8jgIn7dBelM%aiP*EDDu$dFu&kBl#iIRdmfUkt{UUB{s!-R+XeGE>4_&- zX&M?|G$ZbCG|KGi?SRFmwD?4J{myk*Z@vF(j#!0!jp7S4215#ApUs#zO&ll)&YmMr3j0 zTw!f+q1cb*I=9qJP1Ty| z=84uF=i0FW8-pXhYwhtX2HM-`H|+)T=z!~Q4)9pK9)p6aH$*HYSTTc80Jx`?Dfp%|f&Zg+* z2d{FovtMhL_Y*-koM#k5^Y2p|W-ooRd2v%2*?cDx&m`EZjN9b*8uY8LLt7=D$3!aj z09A8hPZ-9J3+s+7O7GimK+aG~gblPpH`GK*p3#9C5ZkLPdQa8(Mc zD)Rf~PxtUz?bpjx;)*Y8Q#f!ln%mc#d%9g>4S4=KgdV<3MRAAOk6YC?tM-&FhI@bv ziyCnFeSdsvkOfIloAI4=6*Y7L zP8z%Mo8RFN!XBOG_n=G5+0!kl2g?d-v_A-ap6ew|x&SQn2@K(Mh<^*ozrDOb9@^Rk z_cXS{88(iFsUxE`IRPTaZ_Z?jubfM?*?$m_<Ju0RSA&s?NwD5vNeG2{ z+IKkdji5$zNX7^b)5MU)){+^PEB6d0?TaA=GL?^Y+bOmMb2sIQ%k&u^F1KEkKSE@;EJ1k#3KRUC4w zj=v}kQJee=IPGtW!}@0rTz28}{GGJ|rBKT;CRoCQT9lgAIj7X_S@}9#*#16Oo7#8_ zqyFqoS(Nb7_O-Yw^&bRH<(}I*x8B0C7}IF8$rg`8ED8rRYMK1S47Pw)qU%$0_2^&4 z&{YiOv$VHw@?%&ba{d?ZdCSx*(nJ?FD9ry8cn^OcbE0^HTS!n5E!=cov&R#Ua$OB@ zIBkZLO_YR;gu1+0b~p_G2^|veYbX#58O293N57xeoN?3IdAIQ;6~E&eF1+VYE1xKp zADY9eTE08tWmY;uv1F=nZk>>wWxIIko~UX$@e*I!WP>N@Gy|po-=i)C*f4U5=*9 zO_BFo@o+qY-Ec{ha%}5y9W$o}r0@@27$z0bk>a4KlF@stG;#XQcR8IJ!GPi5M50zh z(DR`IrgKFtSs}hx2KQ{Z1veY~+H@XQLQ%8f_Zj5g*obt`ihn}W2tPTBPsGe+F!prP zMw+D>79?q9u^%5rTIQfY2Z(eoMQYPE#p0|+ZQawp)1!;uEe&uhL2}dX7J!_Fxy4TW z(%D1WlIs{<4k}Hh1S_gyDnU^nTK$DmV{OJPEDJdl=P?D^SVsD{$0j@t*WMi|9j8mi zrsZ0lOyBQBo!M`S_jI?wcM{EF6OWOy$D-iSq&r!`l{D{UT`wVFP9O` zqM=ZVU80Us3nO+O5@gex zF*!sk_N+sV9&R?%RvAM~jZ5wp!fGT@1P6)Wr0g_RwT2fRjceL3yRav0ojhP>PmM%{ zoc#^CO@@9uPNZsYkBjJ7PJV}0AnVj*1~3^w*gT2CSHI{O^69Xe{vMq2mjDY>V5o-r zOqI2ii?vgHlH&i8plImNd_<@$W2nl9DK za;(&lrWUMA;KiHBkU0b{BFJK}sW(J5Dsc4EdPzm4eCyw7RKG=5sa8%UUs^Pqt~*q~ zXK)5i(i1Pc684=bKEL@TDDt{uiJ0ru@$kys?|93>Ust}rlQL%;w{Md-lin|3+BTG! z6F*VM<7ZVZsBwR>e`7tlOh;U7-Ul9)d~m0*Iqq@C=2z-?-flN)Vc%#y=VW_#>ac#r zz7}$^T)uEGU?S%4*{UeZN47rUvfo|PE~LG-)P1T{H~Jf_ULL+@FVmEn zvo5=$ncCg3d@}&2O%~)l>eB*^Cd_Vi=X{{t(0lV94dp~#yVmhDs&O$j!N*dj55^sS z$<^j7N9PO5n$Vlw>rkvWMwrL#2p(m5B!onquf4tgoRf%ckmZFTnJrK zQ3NE9r2g3DjCiOIcQIKoR_JiRoAJ@@;@ttthUnVRW|eU|M*V*Kk4=f*gD!TzFJ_quJCL5h1pkYw}vkU zh^4?5Bn&B4YWWHhqVDI)*n7`P0}dN@UMubyrBvm!hGOmzM@eCDjvv~NTzI=&F7&h8 z_N})Zh#I&SdUyZopftl!-mF;VXmJ<$f2cdFsJ7d{zqfr$jY12>iWUhjh2qxIVj%<% z7NodK0zrzkr9gpVK~lW9JHe#|f+R?B34ube;_mQ{?d)P_XWM_f+IF4WJiq5WU-^9L zD+)Ch4*O;ur$Is`;y6w|GagXxyMXHqB96ttQrgZ zHZZbjD z-H};-Bw!^=#$xgwHrT%Q8P!OAv){SXr`(Z^Yxd95$?LN|O-j7V{f#gGq<-!}%x=H; zbyXlODz9{C6s@M(oGfE~@oY!?`<8jt2_p{C38tD(R7upc@zb8k$XE z><<>0hV(9>1s>M(biDi37=hmEGa8Mj7II~3adC^TbA(@-N#)j_{#h8otCui(4)S%y zs~7yO%dwuO@fM37s)}}m**`2+S0BV0+DcIU&L#OPa^Ox4-aD3{&5poOE0 zJrPpEb(@zXzC{n^6vk`^=gOCBfVaU-j+qpSepkcw+2gAn9yYkL2^Ef1ZKT>6pq;ho zi&fY&wN%A2b3+fM|7Q;Ve>oMTcJhfCVIRe+dViqe*{a`1L^x9rr`xXa#*LXzaYdb< z4`Yd$qqFUgVOT_x-q%`D8a>uaZ{Oy7KTHYprdJia(2C;>=A9mM-nQGR9TzG$7D4|! z^wbWN_H_ddZ1ALWnG}*E6}Z=X)n>|G0@%8?P!vugq$}Z>kI*h&@;|W}Loe z;7@*8!Gu1A)HnP}qC3bf*cV*hdYwwc^ZYqYn~;DcX(hRvw#(q{&C1y)|K7rlnhq#8 zZ+>40H3Wz*wm(A8B*!qbP-E2`bT_L0u{=GAjMnTnT!9Y&(&?kL!m8N5=LP9?AMMhc zN`$TwEe5FY-&}#x+=2Zkkn3dveEs*3lq`+ITQCE?E4bP0v1hWyXeqDZ0;mLq1S)wplgt zc89llDSFl8ewf`OOaZ2(Z_m_x8qO^`KGUpJr%0Z=?0z_+YE+}|>wxmg@)Du=ADSWJkWOI*~ z5O3gG0CoJI6vifh-J!zk8ut3-X_}2!DjW$Gr&d+K;fiTKy|%Oa%Q{v5wAVqV*PhVg z>jK)Wmy^?JRsXMD0?PYM|9tJo%EZD63sRm%{6(Y~0jI>vwv@B9_Rbl`X+RQiHobL* zkV-5ccu-hSz0aQRGQ8<@e{>Z&>>v$XbmY9_D6LiYGqS!W%73*-o-Fsby-n&Pf3>Ez z()sLhn}K9yO_y`ZIR#b#LmsFljNCa}`?E{;1hK1>=FnhrwJ(*n(`1(~C3>zFzE=p6 zvtF7eTX>q{mqgrJf;+e3{-Rwh5qA41MWpVCIJ5Y|>Bi!rp*(HL&+7FfvL*j*7hUN_ zyF!cvqgLU$X(E9mk#i;o@Gu%Ili7)MvLRDZ29Y9Kf9qTu6L$T}Stv3xfpJSeQ4&p> z)i%b3u=lz+=I7Ui$ePwy5ksT)Zs1OX{+-5}l?eER+ReDn#=6+Qw;p%ykxAHYkt5DtLIS=>qvGg1 z6P|(U=)|TZKppKbAu<2qP`Fh?CCakNyy%00egC_2@w?J-#Q;CE z4jqNgn87@Mw_owbS8iuNI4Go?&+*`-1Y#}K1mS66I z44ruxCJ{3-cWGL{h!|N%Qn}J?VpU-Y%qG%%jIi>5B*Cz|PG; zDeN*HLFFvLH&1MTw7Pui6(ibEZ4nvxwMl9pqZ6-MqO2$q>SDY3{Ko*P@3R}rGKi$a zj9+qBC74RjAPJ+BmcOkrO(+@q|4_e|-Zo|X)l|}8_*6l7){z%_0|sr~d82ez4+#8RxCH^>@s)xGH{b;&qz_p1vpl)7EJf6Ssq1AT>R=ZP@r1v(g=!)FyG9A&IKnDQlc<$uzMUrgT7nWR( zIR>6M^dMU{@QL(G;VK5R`G)ehKQc$nT8;Pgc~@7`Km4ez__Wk!o|(g=+bWKzdC^_I zNNSMaI=~su$4>j=ak83!jRG{wvCOgHxsR{@j`aw7M{zDi#Kdq8TPC4i#-c75yIZL> zVi@Sg8mGc9?%1U3OiV9~qi!xX(mVCwO*5zXoRF2AJ6U|0Yzw}dM~9ZH(ZIqq&xZvA=m7wjNgdij zTaheicFuhNWytq$Z<;x5_OhG{_x8g{zTTUppO!NZi30KFU&x4bf)>H0t|L2D{>nZo zM{s&IGZ7t-X70rMq&=@(=G!d))9m}feW$h#iyUYu=cl>y^MK(;lF-sK5zT0^a6IgRP^+MGxgtF18+rS^en|cIpSAxJngoep5Kjn(-gIoX z`GHs6e)(1J3z}=~xrrOZ%A3Ch+2;-(YUZ!M8tsN&=g_yNm+PN~$csVKbn1IkdN}_I z^ar{v06z`o=hD45oJq1^o}Ki59nTA%{=vRuL~%1r5#4y+YB#OpC{EyLYTk^ZtGx!y z7Pqsd3y-)Nz?CE9W3UUqzdF(w&7984l@ngJZ^bYc{wQ$!su*2W@zo{`s#ReYbh8dn zqae3zjfk^LsryT3;OgPu(T`%k(pp}gKKj{iyP(kIkc6ZCZaN|@r*G`)8Wv3cbtx@0 z&NIx|7a%%{M2F+*_oJyP4k^{B$J+gt!oAh1hqMzBuDA(Vw=7UXPg7UCr=G;m->KW+ znm(?yqjtpWFQ<1q=LGj4<_>)i>Up-qeY9M-dn~610HRKsNVRvoQAqDvL8acngKm*D z8LuC*TV>kyElLf=E%ar|)j=LS9Ibp4n#bW@U~ztmi>|VpN{F=;-++1FLmS)5%|>UV z2yA(ds_j|up4Whod+)@K*%854@I_9y@GJ}c4`Y4ybW`|Vzo4R1P+ZpBC z3W}xig_mQL@bOpZmfZD@l*CQ=`T`FOBUxUOYNje{Y=m)F%e@Yd-%j>1bCX|VwX6MO zIyeaQg@BoG#IkXkJqhWIMgLbSmVu9s%|1L!7SU-t@8<_-D zy^LO*{n7@w8!m%i;jI(V0cIyFVMaxy{{ei(ekYv*>gzs^>|F@{XnNBu;&$`X8+w%Z zzw?T^_lc!K4mqd!3M)n{{=Jf*BK+f8ir7Uk6u`XH8#`q!XFSqL-PO}w4ieErEaZQq z{kjZkIx|~V%FU|m{%-45-N<=nR1a=V3%*xD9DX)y)lZ>u@y|G+8Q~!dN~^T$PSXuA zftF7Wq+Y&`vrYTk1mzfMTdsWcw_LD9!WG~Kkl`r)yr}%rR5AF~4or}ilH?wS(C~Gl zF`$Ou{IMfnPLYwiK8nFar|!H&McyEdC~oKRPQ>>31IGHOz^eNh zaGdU?T_&+>%x1VxE%Sp-BYZLJPj4E^es?}Gr!BgubHEy0&8ROUl$7C#cPHBScV)3G& zBY$td%slDF6r#F*RBQcvxsOh)Baek+g0KVs_Mz7bt{Gl%6OtRXEUc>*3l8b#nBtON z_KGMJNbTow-Dha-itqBGwRmD*)ZmEzkzu6C*kHf~Tf1D`M6VMcbUV(2FM^V(Fh?%$ z5>WcQ6qqcQZ3HW0!Y`Ph#h&24zc_wM%x$X8@U-S^Tz6q>{oNK6*BOI7D;HeW(D8Yd z-*1ykBjs-_Y_ab2;;Ny)+xTRkSB<1kZ`17P?aL1c?#|`ZwXrmotc?`xSY8XQ-U#wY zur#LVNPI8%Dvj%X@2l@|!Ia>i+vg)IzoXa}%}o0yWO=@T47dYNsG zT7oeqq+CBO<|C$$3$MC~ZgWKbQS*SN(w zSGQfa$19kfu^>^X_RUs^u4z$Ou7Y(>IA#&4VdjE4sXe29fEsP6J$==|%8^<4Jz0&| z^D?+j?e+=AHpjBcMPP1wX5Z{)=EGaf>tCDTY(>vLb(A9?@~4M4$H3 zUvlNzO-8NRHT))dZ?JBg@IJ{eV%@m~slM}mk5fP88JT|DHe)3`*@LodZ(_i9w^}8$ zI7r#*12^tjh6HZ-I#l<)Jatms)^v2aL;v9O3CYdGDCUOV*7P=Lp!Ay6P^8!?T%)D` z`OBrAQ=+oX-_Y9k=fh8kvRquzy)(wM8k-YU^g&*M@*aew-4`!y~cc7##_ah!aJ{MJ2m^VK)+E+R%3b%Z?<> zdXb99v86=LT^EA{@On1mOFi%?(Po)ELW-~h%(}1{d;)Q+e&Kw`aYQSn@MX|J>NSsb z`CiAg;OgrBo2S(%`k~`#5N#x&25$#i$6fbP$9KetwXF}fBxnd1G@Ka6ceAdXg;`@@ z@H<%o76+~m5|7E&zZ|UgY4?E!l2O#N1QH)LN6h3jK9X~St zw!Egeb~8Qm)ck!-yH5=a7_8qfgzJ@rA18Gc4Ih=8i|iX)J+mKW2z9|!3HN5ETnjgt zx25?1V!cz}ZqokM`d75@ujEGH&OUF+I&POA=_V*SX5j5*ZS|nK*V-x%AgrQ=WHMW8 zqs9~$#*lZA7457{UF~=^T=R_*lO&fGHBq8CAAO)X8B-X&z_SE6UH4C7`9mA8l2<(R zvr12ozC7wXiJ5|WMH%IdEf0vF&X|>_vc@yMZzsFpd$blv7pmNBFys9MS^mz}F;XBR z8XrS8!BIyyF`fzub>M-0fb#n6sR@iTDV8MMd>8SJh$(%4a9o zxYN*L@!1CCX419u(cdb0wbsFjns5v zNxm;=Cg04b;ETKk_^@NO2o>U_zqs#8gHK$TZPDWIWOS%s``6u+_d`b9C7c z<^TFifwBp6|0n0jd+k4}RmFx$FT2wdSbCs2C}z!0zE>^qFfql8V8`PpMNtafT&TB8 zK&E;qref5v%DvGBR=#azeZE}qT^H#hlloJs`35^x6HXQ4 zWWFuodI`BNS?+wCy7X>(=Zn=_&oi6UM(kv~&(}DH8-l|)E4rn{4OV$A9TR?Uz3TyLzi@+|BAqJnJSY~MTQ&QXhg!#)E69z(gO^X#WKQ(Nq|Zl3%ZP>R?E6vCH8qn5 z3KBu_jkR{lq{xY@mBJm}ab0zAA#i9s{$Mo}v`ZMauHEl3=SHz zSpa_KpHI)%A3b8pdI6+R*ElK8Fezt*Mfr-Onz7aSHh#aP+}q?bc7{f)8MVSF{}5dMZma}?2GMcw=uJh8 zt-uX&D89fkWe|ct8Xvs8xeNcP%X;ijaR%4%FX9DNbFOxsgrHZp&XcRY$yoM~g?3xS zSy3>s&`%Q=6to~Tj>Q=ZV!eJPE$x$XV5g}t=h~RUFgnbbM2_-C zE2O=eR!;$r&PKnHf(dVaZM;a>q*dwcKP?_lv;uW`OL(HkCS zSvnnZrs_NJJ>=_0)qe)WzO`BwdtUP|E@!&dHgWyd43X|uHeO&SBTLF1BnQ$yDve%N z0!U=bk~Z=3ZKU^l(}xh~(XvKXTudW)*DrxS=;0djb5+xC<5~E3RRI$0+*PsQa_blC zzn1U)+PJ@+2fb!Mf})ljDI&~yS1K-s+GEEp&4oy?I<4og-?Dc5TO+YuN}}+EnNBBF z^g4J<(6nXS=;^!*qM+k?-$iG+Sy(09gspz&SP6kS)9SHb$~AT%jIja;qSiTc?lJ$= zZEx-9y~ec4mN)hB3Ydp%8BQiZx#>2nlE0~xt7Mmp4ERNC)U6lF;aA;Pn9PwNMgnLx z;53CbN;Y=xHSeK%At5dJK}UoS_BY)@2_n?h#_|(ljIc6;jqnnjDH{|)h0|gHHAOo3 zUZjrppOl5jWmui*X_7*`eek9aF~z3IgSU`ipc|)=pqHL&28xjT)cLna<<*U+t*c|& z7+^#juAGA;lfS%a>I+N1_E8f1)U4$Zk0mxb%gxWo>ZXeCjR5e=6mQC{gSg^8d^?(> z8UxTGT+1pd*b;*yW)K@SuiDk5E#9ZZZncQURRr5$pD_Hi4z^vtS!RLh|9k6RYnv$c zCK>3`nMvcbXPKBIyEnv8#LH#e^6@`X+HPktdPscoeBSk(S8&gBM80%+l(4Ng#dvFU zwKUW3q>BDb<+FQ&NS;mrkN!>>`(J(g1{~9jncuZ@4RPPAhF*P#*@10uw1ko@H8;L~ zrR1X!+6C`>$qF)$P~EU-lqqeNy0c^xDimx*uOia%1Z-N> zxjf5}^2)b%KiV4NQx@42Z~eu~ZC0jSsrGGKfO62Y&wVB6PApGut6z>^nP?i!Ex>Ww zeqH%~{)gd={M8q63D{M)*5c|Yv*Km%FBwZA0yzYYKGcWh+Gk}?aTTKqwIgQXfEAU_ zQETM%^z(}06N-^mZOqgP!u5o*0DdWF+^BhfuYFYs#(55Q|f)BF3=4s zc$mHo`D2Aa@@g#J{7H1qChW;%oEK)LQxMDB}~p2{fpJK8U# zw;V)Pa7jL5<`Q}{c?Mv*eRtv)xCLUzIXqY*RuT{=DIt!8KxLjQducba>0h{f@vCp` z!I@I$g)xsb>=$TUMg)#?F-=^JXM@6jmwAj+&4P)rk@A#Yad1d0LH|3jZ{ofsJxjzWoFI2@nsSQ-J+R zNdn4jGvMnYjdC(RgdRAI3kHnZ#V}_}wz*_CA0F~bClBKx_tlB3r9=i?wq+NpHkUU$ zEz+Gumg)}G_zhiLd&JQ*hW#4@_#jfQzoua}F>joWFKJxT=+xQTgbTnTOjUN$ZVh~N z*?bW(DEA@FLwhB+>F69KQnS$*_EJr$cn_|zP4NAG{81B9 zot{sgIu#bg@}Y_!rgN5~A;&OnVAwmhGBZ7cGF}bfh-gk%&QLQZrard+=zzEKuI z2yrMuC`ao6f6?ASkEoZ`z9t&`=&Slr21zfs#5;{;fji7Z4SLVEXiCeKm)95Eh%2nN znL;s5GW^AlN%V>I=KNIDcjKk|D_bfPOdOqD{_gn|&o(X_E@e{xI!2of9&`ILc6^^J z=lj06lQ)&UEZ7p?Z?UK<=@!IRUj;b4reFIjGO%DcHv)CDWzyj0j|Ta)joFYVA0AJb z@^?{Wn)AKWfpv{D^TRngBGI?5qJLJnvhVGcX^%_9cO*ERPbV3>#Vvg)dk#LgjS{(( z!(Bep;L2Tks(GaQR|NP#U(N5~pTy0AeHX_J7k&Om^6dH7r8&&ct((@Yyy~q3_|P_9 zebz4zmLugF;>0l&>zlp?$03oae)ti-M33bXUsj$D2Z8m>*B)6h{(u{oWMd?(NgML=P+3`{QE}&UvrF_}!{{+2+=X zZGsSoPK|1dgUM&sR2)cIQ6Lbm32g-~rUksc`^A~HC33Ee-q+?&YIi(xCBm$OMjm>Wd;sd& z3_j;6?GsxsJ8ZTo-?j1<3D1a%g6J>UI#hhi=1amoxm`V&mNaCH<^jSqC=o*e_50eh z`Pu8Qp!?HibFNUTf>oP+$(3l4sbh*gfp*sUgU8 ztz^YR%>xwHT+{fBHeua4e6i|P$liW! z-sOUg$7aCi&-M;7*Jh0Q$&);~TJ<{(CEt~xLRW|P4eG|~P-58bWK*#~R^Bs`c=>kG z{nDgNlvy}e@48szUG@k0H=rRA8_cc||Jt-qI-9;+%x%5&C#(`I9MqI;#1m|pq3upt zLQ4dLkz!;Zo?ZUR^qF@ z0Qrm#Hi%D_Vz*#D$pcdRZJX~DfQ#kDCVXr-BL_-BsSjSpr=4qCR*uKaKV z^u^{d)ZqZJ=@pxR>azl^%T_g@22i~=gg%R#6)RKD#1;+(5R&5iE1q=V zd#h4m_fo~X64Q5`(G5Ab8E_MUUF zeGR*lXm*|89WS}USX3Vy?~x5I3M=icIWWVn?YCsG)#%+huo0T{j!Wj#YBIC#S2Q1K zVOqAV4UJ3Z*h@j^&oV=KKv!taCEP6qo;(sem&~*yRMy6 z?#K36f59dm#y<>JE^yhtX&OA~J(@SK)2i+-6)yv*T}HU*q@=STBHqetcO>vI52DO_E~Ze3i8LG!N!M)7GTsKxmO~y<`4(MXt&RoQZFyr&t92 z3^E~qCUC-Rx7b06v$dC9c%0_^U6e$a&o8S)&YbAH)8$A2jON8%BwXCI%hVAh4e zcGyCNu}?f~A!3zlU`i8{#}~iM91)#;Bk}7#rBrA7f$@%pRI&YgtN38dJuxigCoS6y zkDpicV$mp0lz7uJ)*NkQFkBp~cbl86u-ah5T4y%6#dbBqXMej2Bzpe(QRO`Ob!!>$ z{?cZZT7~(h%&VKo6+oyrHCkE~+JLoZ=T>CP#8vDY{&n_tRbjf9%}TsGo7*tAb$W$1 zGp7`*SYzH#T*?aXcKd`qKH4{p;~oNFU;ya3Z~LniC}UDr8V^xvRwf2w*6^3Tio3)G z^OG8$wDmdb{4Tb&NA|ca(;Mo~#z3~^S~KTV%}M3R;A~#Z9TmX- zNbBVY&n;O}=t`(2R({XTerr2SdGKaiUgsd5d2JG;8N)24DXs<&_@J}?&6hDZNpfF_ z;gPDXsccwG;2t9c$PP^yze7F83Ol{G7IJm}x){;xDg_78D2~~*;iV+{4AjK=zUk!* z_~m}BC@}l6DA}D^@CgXF66rB;-sgL6ag=1$+_b3BlTtyoQJ+0*`z1+fpBS}?dj0O6 z$!`>HP_wAQfyd$Tgmw^FY_2it{;h*cLS1u8BHd>Jdh}wo!e4luX zP53~G$(ar`9rMxJoQIu*i|27ATt5Lse>bG+Ofw-pN|?RM+YQp4#Z2cSh`_iU*EdRd z_=8P+&B?US`0h2nsdad{FT`-q5dm}HqEfe}dFRdS#%wfM)ti9-W|BH@2UCv;opsdF z2DyxjM>t7Ms4BEe*2izYA6{ge<)S3r5t^-=`s$|Zv)Y|7W|Dbo4p^aD_{DV}Kz$7u zIHMGd`#bUO2GeagCR@sm;OJ-M;{?s9jxNfCr4{oK^y}IP^>UqcMQ#j>eEp{x2(dx3h~}!=tNKr!r_|I8rYUUij~+w1PFNvGSs_ ztMYqu%avau+H5X?fE2?8?H;qO!yCStom&Wahws?5PlQxS)SPO-L#2qb^5>6H%wkE@ zpia2^qpti$(=W5sB28= zvmKC#p;2v`E6Yb^Re%{CC6EFV+sS(0hV~N?D7GHsE-cvLTri|V>sg#kT+FP@amphe zgs+#N(W68%l`n@?*rgT?W*YLmuut>j4_B{K$*4(}TM z1-FPIqOKs7UMn!;$5x02hB6j#Z?VvvpjEHmZ-_8j*>$vV51Mlvx!LuhzHuCrOmy;_ zxilxRszBrId$Meh)AU@i|5(VFENav3I zAuGoFINg&*`b0m2Dh^x2^Yc{Hua&c;R3&T)4Kx8<9O}qJxbi#;#Zy4fx3$i>K0=65IDM)#BAS)a`^zrj z2Kmg&diHXw-3J+kkE-PEj!7m|G(Hp_ZZ+wyku8=ee%uvJnf-X_xzTdMrY`JOUe8ed z08~t>R{NXY{1c|p^1{%}Es=PiNor34)s}o3cK+m+d|7mcpkn%>DO-5Kg#Q;_*FSG0 zcpDdS{MrlYd6R((HeJU)zza0z7hTLmgkzlpLE98H->-a`k%=W(}w48&8Jn?bzlB<5`%( zQV#=KAf6B;k}!_rFa^4n#9u1|t$e8TKp!*4Yc+J>upDpUQ$5mmt!W>WaNTQ3iwj%Q zXK77oHJVR43Cd&zQu1cn&TD!1#*P>X&Zw0U03)rg`39#AMHCSx$h1h8xxN^#s`LJ1 z6WPzt`jmXPzS8@XR{CDPCQ`lYYrpRv{Atm`~> z0=kI-PcXEKQ=07@oMTuqO$evd$h?C1g3Fm!Fi^$FlB7K)`(MeG|P;X%};W(lXe% zI5YCM`!ptxMph-t+uinOg}fN>0BTS)gsqtjL3uS*S6ubR_g_M#SR;VyFUC21 zHDD@RcwT)VYvUuLW!*@1+TbIy_dVsf6hI%clCQD5Dn zaMeccfOnoRvbDZDFDjyLlW+Ka@}~a7-lzonv#u$Fdv2K|(_P=lYu2WyC(GLx0{|at z-UO7spgsiqesfi((Pa0;aF)-!01!o99C^dfl*K8vO4+gM5rwaDA%Fn*YB*GzTqKirn{Eya%zQgV17w+7U0v&m~61T-d$YG0{B=0}@fep?7G zB70u5D*So(cFxkisPe{>sM(K2%!$`cG=jg*6yL2Hd{5Pm*S#T21l(PR>le{*jWfS0 za#N5hvacgDqvsIHZU%wb*krevYKFSpiEBjsg$*>xC3mI4xGhzf#9*OWqg5MTKhDEH zVjTfuj{%WTl`C#Pg#hpW1XLoy0d=ebbA^d|`?hS%o9JNIwFC8rn zu~koyS{X|OPXncs+dGIjg9WYDu`c)wxL|Xl<*!NzO`X& zQU&oGKEc|RU=pZ6%})^alfE|Mp+syWiI$CzxYlN5t4e*COE`fy68Dp16JxXRBsEDZ zs+5uqFVn}bzPHp#m23$)#rlN&Ie1v}HL#H`x^wDBjp^IE`MF+ICuRRB*kxnNw^na` z?HI=NhV52yo3}EI7!h3-J_PFn78OOKaMg4WE1ZajH{8!M+1O)}HTdmo^3I5-j0HtDlUVCxALyx1( zjrqsU^hz1-vL6BmWYZJB@-snrMoqOiaP!W4VA`35Atfhp&V(z8;!Ym@R` zt-4avFV&b=9M-(Q{G)c1xadA;UKYGe`0R77E(dYBYW=80c+Hi48OS@En_DKMY@4!T z-KxI(KWW`Cs_{O^RpWm9kgOq&)~4)+#*R4Yu*hlRj?9N0+)i6^Fn7uR6Z8F!xj+wT zzWBLYP{ikN^kYvRk+-hvdZsLD!+f9;m(85b9O0e0KgSwKwzJdmW+(MYokllt=;+gQ z$hF#ZF}-tqvB|%;yp^SvX7D9OXnZKBxKWglDYzW*7>ebyFzjr~^ukz7DVy*#YgN1-w4QxZMIc=!(Md7gPC(aH8e9naESdX4fhyYmQhngn{g1JS#)y-K*h) zh@5zO!$snKUa(th!Q4gM9@F4bHeS=EsA9>+)A7j+jzhB%S+zCTHEi(pDd$00isP-b zf_=3%GH*DpJ z%9Ul=6ug0GSiFWo(G%g-o&6-;X+$9=t8%&(*uy;UCrZJ98$UJ4u~_2HHA302ay_>N zIM(QK6)GFwiC{1brE--2pAp3IX|Zgx{-<|ps*4b^v+{;x?e9+AlDCH_n*GRQwL-g?t}eDYUPLO<0uqh5lDpMlJhwE{8V4 z7DVu5zuutC1Rr^pX(o{9D>7co3tBd5GbNm5?{&X?NDkkB_B7soO@g7p$~yf17LDcU|q? z39-e#UYHWtOfh+fxVpo%{uf)j=dXjWE->@ze{=wJ@0mvgLRnt^t{Pf?1~%*pS;u&G zP$fZ9ZSCAlCbcT&3N<;E$nqQzxp-5gA~BjA+T%Sk0~)CFrstFXyVlafD8!2Vm786@ zJP6?;3u%vaPE0y-QLHKyOLXssphb5EW90X+d(FrVhvNNeg8SY>-@De0k!U1@uK|AU zURq2JsZ82Rst(Lla?o;*?9p%UtWnMC1MZn!`zI$}F`#2!rF55tIdrE8@7>T2t)G*I zEe#;I%Vj=vCOY$cylGNZWQ_ZZOTi^FwN93)TREIDMKE*knEC#6Yxi(_XGpNYw4#4n zY1XuumhC5T#Xy4&f1ZMi>_ikN_X4ty?#9#2p6VMEkkB{Nwj2Vp8J99TBWBY3MmJ>&6cOLTO-&_8L z^>+IcT{Q()9Q$&D&Oam*RoEj`{l2eBN|E5XEH&5rpo^un~mF|7SCW<$peLk z{Y36}sQ?beVuwKX|su9Y~hAa2?p-6sqe zP8(Us=T?~e_KOV-()RKH>r$&2Ef)51DUfTd=bM6Z5sO~m4^VL_fy)xO)kx_VB4hvy zsFFV8$I`)WGIvUZf@XZNsm3uE|EL>Bv0BmLiO|`ay<#O}qW#qC3x&u6O{DYBZ%O_N zXvHM_D&OEtCI$V^D$71uSHU8VmUsbQ4<*fT57#U07y@e+`kBUXd_Sz^*C04V#{F}Q zvFpr=$lP-G#0<~;Kw}hWVcIhV#l-3F|0-(IL7u3ErjtK^Rl70H)<4>}H;~l5*ODUO zj)@59ED$mvPMCvCq>9)!q6e}pcsEvhccpA}>Orm%5*pgVi(%i0(aq=egUQL1Daw=D z*=&GD{&C#*X7HF^{Jub8EU(0}Yn9;_Dtz6t(Sq3B#krvqfEawaiQB`coHf}k#aK-8 zGI7kWTjY`COnluRPM#*$)iJ$~jlq_@f7W5^qGcU|e^T%nSM=hGNwV0P{Lypzjd3RS z)Df_P-$RLB?WVigE@mY+Onu->rrg((NW}36Q(HOL2P-{}7p|ku#3c;B6eD1Ctv_<@ z;)|lq`NJ?Yq?xuNzIV1oJo*btRf~dZma@nX@9XCjR@^6fsq8mqqO-ZNd}Wa6^Dhuxtpe0?RAbG14P?{TXZ9-DXUy{JulM9PeoN7&SI$z&o%fji+y< z9``%e-QL*_XkxPoq6~@M_?Y=3wfd>c%U{2o@4VI%>R;h3-zgj0ZvZeR+c>%Ujj~cp z0g2s3R{~U<9Xd~xkLY$A`QJT{!OUCz*b~R*^H#dcUPaVbn^REUjEEMtinw*FEeCCV zwVo!r#yKtPye!@8usmnVAPogcX;U*OatmM@=uIer7WjZkgQXbHM#O-z;pvVjG-1*^ zz&pnv{KQ|#`gqe&I+LDWCFjh#n@=KHH1v)eGv+zh`>MLe60`vip9Qs&;J^>SR(7>6?vL0#AoA^d|HQ!AY zX82f8WwR+RHeMOy;7UOs9L*nY)dMbTNU1mQ2=6-c@`fGox z?=$KzD0)v^&|yh>sRSdN=c!-ysIEGQ&plCvC31JXM}h}fckGD#>o7AL4T;fN7e$)Z z*U%g=yav=Zu`L&fMoeM5M4=rg=+B3A;TQ77dJqk3cu^$ttrBt4+ zZZGC_zfHg54^qkNUSO~d?{ zH(;^Qx-*fp3BKy`)i(0z;4MgJ$|3TO?#t%_uiuTwfGi5vT&CE=_^J79>zjS4)wvnQ9=b@j}IYj;`;YFM>xMhdvWcs{*Z`gtufH^XNfO8U=k1R|2@=Uk7k}0za}S@CL$5)< zNtcsw&_1BVvw4`m;yzI*2GgtDy~)A06!-MQSn$BT3AD#y)9Y{txSEK+iJ9MrX)pB@ zlS&Q2lzrCRb9?d5-D_7C?!*0uVQOuDENo*CW!bM6MI1|GgkPc;MR5=HCwG1A2Ip+F zq9Ed_N2*y)R2KWquIF&+c!DJJlJA&b!7mBn=$f}n6!3HiALne|^OoSYqA!WO#+(Vj$ z;(h;DY~Ns*y7^2%GHdW-JN^rOiqv9-oAM4Y-H_>?iIYmhL zrempezbC4|=f#xbOuF#jJS3&g&&5$hs}RV#04X^G>&?yRuZNqy?W+pM<7Hw{pa(km z8>GiZa;`Qw;+;$>mS70@({cU4gVQljBbX@S_si;7m;bie5Le>EK#Dy@w5k!cGQ; zQ5yD}ypi+$)*-v3#>Sv?pY3l=pxl>-iKlK)pblhqW7g5x30@U;)>Rl{vwtrzMuowS zbH+t=g|}1M)gJrirI$8G^~G0-u%hmx*`DIQPJW-v0cITpFDPf-!Y9X32Pz-w(sz$5 zhxzvXD0C?HHzIvu1T}>`$m>n%hO{<^dpJXtDDicemF=<^oj-H_PXfV$ngfE%@=WCq z_g9Q1<@24~RJf|kJ>w3qEB}hRACwNU*_Lmx|K%*6N582@3l2pzoyxO6jyHr~)&vL= ze2;~fM#Y%9x4->dJ6ZsYFnQAm6RmRM0zDwWNj{P)%(?H1K_hD7nQ7=2sr zLe$^}QY>-gS^m9}jsHZ~TfQ~@zwzIGRFn{u?hrW=2 zJ4S4@(gOi$*cdswK|lojUi@xc$8p{GU4O#+b?1E^=lOg*p1UnuY;fx}hZbdvHOyJ8 ztWe~Y*0k}c`I{NW@*p1O9v5`SsZDxIi|dSnzT_eZGJg@?%Yt~~D;woJXyh&#=R7A* zCf^0n^3b_x`v|MAvXe1hl^3V+3{<)DoKU%~`n0Ssnc^-7L6)HFGI25TeJ$~wHmm?- zWB_@&6Qzz1iS$RnMbP~qqz9%j-G$JBG_taST&6_oFr2KPZGr&dN>TJF*eF#2tD;40 zyWvF9OFe3IR$`a0=#PPoBA9XirV@0v@^Fhy)X`!MRl3#2PQ;6iy4j9nr3W%p<53rF zH7C`03yrn6;&?i|XO9UkaQQ@zpV^nVbfUgpKKxOjl+(8>TlL#phXviT=_;RtDo_bP z`^jmqI8GBiEkpVqE_0yU!b2s9wOfC`rNbct)fWco+a?HJz~v5^`Qz10{=|vgW{lf~`AG3}gL9qt|H*)Bs3+EySzw=?AJ_36X5?{(v&w1?cgTnm(H6@#Zk zyPR2RE5iF-jiEccdhdP~@*{zw1PI7aAW4@A10R6wyuXb9jVThCBs8p zAQ%m`u=QR#^mK{1U`nac#`a94mY=T%-TW84p{z&re!b}zMv<}!yj;lUK}3FVby9xC z1kW|?@pkS~F@C}-Bx|ZyvA{$tl>oQ#-`?`3PE;b1w#|^~^)CWl@2J3nc(>-@YYvuatHhaoX=F`Ig115JO3_58 z^-`s(#J{9VcQ(Qz-6|iq-p_dVRtsDW05X1W#qlDnGS;i`Pa4_smAdnU3EFyoXOft( zr~s6`zT`$n|F#;9@;BY{dV&7TQ5S0<&OfH1A{={n=VNeh#&LyIsDb{J{hyrkP$6jo zwCaN6QLK)W4Zc_OQ>}5IWB_o3_dAKjej0tP_^|+~{AP)?&??tjp8 zM;mP2N{5og0^f$3_K8{HI@7#Qdl+MTl6~6~ zFyOX=*{qf#`7YSRvCU)BeFPDujrCqN=)4kF_v2iAx}JqobF3PR6%38qn*lFm9=W3@!OJ z?^~mhHLq}1ZaMzzVh{41%==hC!M!;<&&=IfJN5UkVnZPm>s~IzSS4w5lW_y~FY6i# z|E+x2nnn3*K~Cb@dys#80)^c!k=W-z|B!U{QQ=!n2C^uBs=0_=$>Z*P07VuxUm^W} zYf}JADb^GS>c6&*?_qDN%Yg?its(pjOPQHylf_}EYf*bbV2FTJN5CwIL$hM4cK`Yq}q*roeY0Zr7cF7W^nm-BkGrNGJr*YIZm{mVyrP5tbTSff)eXe>cv& zDO-ZY2oW>CKKF{?yaAdyPW7;0DpdxejB#82)?K#0FPH{P%$;ZZAA>7esh^N?X~#Kv zN~bRuf2g*?AJH+XP@^$&aQzy)U zE146Ete%cPq!o0Y=$GEpIR4qPA0;zm)q3Xa+2{x<72^bI-^7XKxRvM>p4PWKlA3oZ zpF=a;=aBP3yV*8`70E3y{!Y%ep~T{*46S2QVk0Kp9cv+eTP;}!1Qp5c2 zFtO4H6C^#&TekmRotK8=gN5dX3pWe}{HzmtDz*Fmf03jIXh{|2G_~uOV#s$$WhlWH zr)nypRm2m(?}TgDz8N=^G?ctBVfjPQcu*Jiv%0U8W4k6T>;c<&kX=kUNO~7>S{Ua^ zC;!5~pYn$$wgL1q+2dZLiOv`VD=sZ|??|~z4(4a(_b^R?Q_%Q*VSVISPpa8jJ7*5nytcd__EjI$_n4;zT%DiKqU#7 z?2!THj+x!CX=b^CpqEulz*cygk-ns1fsp2KyB;96h1uu7R(x1n+v6D_N5jvyB5pas zHYc3Y#j7AfqPufDsov&bopB#rKV3s@7`BiaSYw52H6{|&aNmLpieIsH&MdeipKnCX z30|Q1w|%Hss>j!ueo{`ltRHW4jLdE%X194_o~OEfGyJ2n4RfBHbiW0Eu!`* z3;I{xS={%{K*EnvJ=d!>@l$dnZ0`Tobsj-aRJt6Jtey<6^SmCG{7%VfxDWQN}`j2LX}C?2rqBGzc9Y^9A=waK>~7W{0Xf;!_WP_ojZ~k}$5|vn_r6 ziI=KH;%6fQ>|EWEb_Uub4%^CQiBWI2;SL9T7dClCX=kS-vC|xF$vHm!%5jw13q^;1 z=D?caATk}&(#12N_TNW=W^F(fvE12*k3>(4^)h%6dzWk3l8yTaS^B~TxCfWz8lYQ_ zH$T@$N&cFaR-c{3h+Ft8|8`xvsFcv~Cqs6Vb%yK&qqs#(WlOlVN&~ z;o~ijoe-1BxR<=bT2f9ajbcVlFwu%Ky;w=fx>;LcmbHjPIehl-24SC2Dd;Y2D4?PR zYQ-+r&&cO4Oc(!^-Z760#JwE9>(cC;cBXqE^N=lYm$h+8XK7~M%?J+LNW)im2tH9Y z{{qrlN72A0#_N-wj_cj$`sFmdaV&N}`KcI}9AQy{yE@}7eJl{z)`7p?M>?jqYw`ms zkml=}ZkjxPDRTNxPSEq8>^_-f4m^9xFN7aaPdYyp-9oRH4X_Nr669GAF2%MViqpCp z#G&VXM4eWhC9(Gsj$g$-N}yk_$epgwVC2|&gKWF=a6~+R+%oWZdzLl#)%N9?6C3Ri zZl%TNz#08&gvXmUNv~Q^=8Lo`v9d%+I?9>`IKaO=I0Z5p4A-mN@C&X2`%x!H6{Q+4 zWqJDP5~A7G9{ee9zF}w9jNv7TRLlu2Dr%NTthI`Mk@;`Aj7$0c0c+TOZ7=G|$$of1 zciaxlvz%#+t#?%WSgUs{tce*Z zpR!*z>|@C~ooBk9HInq`wz*Dihz8T_u($ZA90x0Dj!@Z!Kzc)Q+wDwVY7X5Pn(d}= zXRRSH;KV85Z|;cHcxbvU5%9W~+g4XzPe~P!EA$DVC02X)&Vt!#2XW_T=D#r=9-Yzv zzn_|Hk~UVm^Ym551rJIbsdqa+_XiIR60G>A&D0iGI(LK6Vn-pQ4;sW{R)*zbl`1-2 z(BISKk4vGb$oJnbC)0w4=jJ5RrXqq^+HSW46%yDcOG3619TnuKHj4Ns4Px-f_r$OQ zSKV}A+vrXX`&n~+Zuhb>eYw?ZkvHvW()P7<*f_6xi4zOWu9O4^$xnK^L*$@89=am* zBOY9)Z8W7#tUSV&T&y<_&YrvIX*^3>ba&cNpUkE6o(+wloRFTC#Nrdzrw|BB08->Y2o{wkVS`Ori?Q&s39>ym%@d>_tL*v5l0{r7<(L%fr zgd1$eQyzOw~yHg+0dIGFmY3Rq~?N+qb!UjLa_HOtv z17-#oDA4zy>v1&w3*&YEG-xSaIje)Vrl~yLKP!y<4NEB|63atNh0%&|Azx91oTs=_iu zq)8`26AzvSTICehTN!9kGgUW+e=#HDl0kb}ZCHXiB5g1YrGM4$p2=_@eRu>(e z<~xmN{7lyC1Vzw&N#njUL}KphL$)7Vjq0~7z59`=oE6|+vCFv;Ked^7v@!p$_{Z6E z<*N8n%GWyvbUHqb*)^=zLOcZaII5M-CCi4j>yU7K;rz6uT#9?i$wU(E&@*$I$DBO= zFuimeuV8McMbVgm0I0ZpWlOZ#iqNOllYh>dkus3!H07kHmBI2eT+-W(QybjzNfN*m zIg@;i!tcq;SQ=b`3{+2*(t0s8v9I=E!eDnQN#s)cD6k+EaqtSPPeD@QO1uQUSRFxe z)rs?P(31C*W%adXceJjuMPhCy-kFQqLX_9yU~m{8N-5@Wp=R;qf?%uhe&8!vr#JUrDx zdH*mJ(^^!ml)KivsJmv?!q6uAyew6@pq8=ZwRUS?A7tQAOH32 zo_bK*s{Kb_az7v@=X39`kkYvr8Sd6HQFpD5j$x+{w>l5CkmEh7CCH)4kne=`ysrcf za-zYF#zZky%A^_cLFdVAfUc2&NR`x5ztR_O^ z=-W_n(}5z(_?Kq0YV^Hk04pxuuyR?+bBONL^Sr_zI(!N?o>o?VqjrNA$+6wuI;EC6 zjr#t&%CPP4O1hl#Nr8oZBoch*;Bu&`>C|(=!?cX#f!8^IXuRh^fYwQ3L3)b(Kri&d z6^)yfT`E`e7NDs^31vqC>QGd#+0V}mciB85?(H&Ghs+=!CV7JtAxn7+#CNMkpR+5A z_qzZ-cdIvMQlJG?_?BQu8Yo}!{I0rYEKBsZaB_&ZIa0(;{GEA+r)PnBI+Z4`J^?~( z%bA2HnzmQ{@6K!WAyf6qWb4!x3JG+Ml9+F)*T*-wX`4c6m-ub+g z)RD)xE&Ju^iORr^jBH~)(@V;fCuhcVI-SUN0=|+6d&zXhFmqC)O+2hzC64y;tgbZf z)aDICr?eF9q6Uc&eFxV2wlyG@@zrvMSu%iw!;?+#lY;|#-{NSV5qic}bgNESl0BB= zVQ8~;uPp48C)12Kvs1wo`2a?J$WfCb^}9lwx2-LN(x+(wnzns#e$dvSPfr5z9L~g# zHTE!e*Uz%7&hUaryn*@kp*=MjRfO_ZVTupp7TQ|0Fm8XIL_;5&2B@?)BP?x#YN(ISoW zywLy*0w3Gx1Mx%4ME3s0;(#gI4%GYNH>F$%JSi%IVz^}N{>ske{GGj2JYUz zBJ2J^3Ldd(mJqncE?tL11dOG-EqpFr-2@9q3dyr<7bPKGH#d7 zLaqz_o8iY^oR%oJ-xDL*w1~ENkZNk8BKYOA?5(Wuul2nOm>NlOTrqrLgG1S+;-%2( zlV`@&ybfo9GuIZ*zvrJh61rWRiX#{`f=4Q^f|XK6BKv1zc9o1|Y+tjDM^RH^#A&^12&;RnD zF&BKN)lmHnw=L;O{|+OClHqg9^RqC+syT0mNkKPBDDk}W=d{Au>$PZ>V)_nhS)&UR znOT}gaqZj?pT-j9Sy8&9l;Z%KwZz75 zar0Z>XpMwYfDd4RDIS^C_^K~s)KOTd)Z_)V>V07E%Nbpb&O(lWwk#7gNPJ-(!wXVb z#G}#^w}^S~xl&chGs6rqZf$UU{U>Udlz`-{7SFX4wT9uwZ))5;Uz%D4Et7<0SHJglm3stF;62p|ZqgGY9*?XU!RH}Z z0*Ww@sl_W<)cVNY6M(w4??AC6*;04+HUt?Ci`UqcHkZI@kMqj&|H5sQvABqCo6ZQ(j=6_wGXYhi5QuQ?v z&jL>j*W@)uqlWmOirGCtQ;Qam0+fDMZ&!JW3A}fAU&>AA6~m{MbZY!~`ztqSCWs5m z8@1oZ<+@4?Tu{*m6ztp#mdMlsvlLVhA8~*#Pqc?lHMy;WI3MW+i_QGnQP4HwuVxEn zp}5Shb-%=icwT2+rCw_Kur}**zx(A{RrtuZpJ9|T*)&fAg~ z6xy=}66ff$Ak{hoasl zqtTa~n&Z3nFbCewpU_zyS~(@KzA~)JT#|lXZ62U$>VUCKEZx@qw%ys--_sgYG8z~1 z*rq!mHr|yRU(@{L?lRkOnabMhFjG=QO{X=@0NK0#Kdr5}!}=srH`Y@@CQ1BSU{dm}OrgN&(lWn+lZB=b#hosa&!_v{2Z#2_Aoe4wc)7u>E9DH@v z6Xu@IS2;PId=>7!A;B=s??2+KcX+_wkcS>D+cw~0}v{jSu=vv|9D<#!aeKRe5cq%=H5 zmTKz4EB)^}EeQ+o_Uf$u$AbSh`ECk=oCrD8A2%!HQ`SCi3vI?_#?dgJzfb%6tU`ZG zi=sy?gsKYkOpZuheBuddN?Aa~X-sxWtT!~5t@u4*pU02C}SNDS-;D?Jrh zV)T#snxNCtXW|wYjcF-xR2tO1@4r_QUbz}5F(h9bdj|5BjX!opbqW^S2B@D0>OQzT z#{7f+JBO;Dj601HYfDN6)nTN$3vv+R`C^|NZ_S7>uZW*2FYA|^D7q3IsbH#WitWdr z&cRX29S6Ju+iYK%BI@fmL|GLbAK|4mJ*iyCUFWnsWNayALnmydMODxt^WoN6fxtgT zrI(!1QN49P)U+;J%9XR7S6xQwUfJ%S%mX&XXC{kfW2QZ3!at=;2f z*qlDAT2HfuQ>qG6yxaaH7uM1C0m+(T_K#_;m=`7S2j#zd5f*pvhQagUgPR#utwuFQ zv*QrW?V^faMh+!IiFp&p$yajpt*vLGedT#V)w^59_10#*ljrMP-ut|ccL(7#DQ+z` zsyw|&^xglcn+a$<_*+qwG$L*E`nGAQrLDMiFHGCl-8(u~4>*V!duw@gQ1_-5Oz*jC zBW?jP9Qii5y2xyWA8cdjVkw5pT&8aSWaXX8@#dd=?3e?X?oPZmN>>>yi0trFRV)ZR zS?Na!BTlVqW#(xHvKhb_EFj*zPb$!3m`wB637lz?u7)5d#jRZsbHEzQlumsWNnVyF zI85Q!y>5*J+XpU888=q@D1p0-wq1nQJf=T+^PlBc%#y;=HKX4Y++OGWt_ZG^ZZeki zob+-y6}##a4G&zHs>wBfS9naXNW8%b<>SFTiHvAx=RWqznvu7!%_s?d(aCo?FZY`dD(lL1=nu^I(*27#C{l3VDYwLoC5-5Yp zQb^~Y;^YA4!u}DR*>nHIa7$PLf&GSJVfW%%9(wOv7&~nTads;7xL#}XMXRnwgEmhD zKi|0N40My-Cw!Z++xw{o(KetvF>nJ1b`TB+$pC=7j4L1`d~PX&)q!Z5WLmQ0@=PSS zV-jp$E+llILCAe8g=Syb#{q4D%PR@i*E^a0$u-YRTnjPU zBzi;_4t(C!ZMsF0GbQuC9e`o$ehQ}16;675Eat1uP z)Ih%~|2@O|QwJsa2NmHIW}6tw z#cc8^-tQG~LuJJ>BR;ROhE~FDsQaGiKFtE4Z4CFv zy{ddl$@w(M(2~nsAFhb9uPf)vi4cLAA)S(cj&FY?+7K*5d`}c%S}F#dqehxZMQiX_-EG{W{LIuk2(m`v&Zrvp|pmtdF(n{07QQ4)ffps4;RA6B%J5 zvVLm|4*B=wXVkxrovr>8ul}8d?5$nLxV$+$5~ed^y9wS)jRL2ziZ~UXC9b}~4Mv2{ ziedb{RXTAE90IHTdCtw~dEq^CgcMe`-Xm9BZpT2;@0X8cHWoQ>%DC-7B@#p7tpDI3 z=S!IlKEh1cW0gBp&YTr|oDl4GLs@uO*Z*n0bhL)V-sh&{(S@6eMhKAS$R!ak{n&>26a+2d zjz>{)U{vo}Wx_Ut$OhThFGWHDsU-N!;2z&egO5So1%9O4g@|}Qb67~QxudfAgEScw zHz>1F<{=9fJfsL-l%EYrW~^-UiGDybBolG1^s&wFL;JcgN9L{2f@TT%mj!k-VobEP ztjxc!uahLv*Yj@rE{LM}1MgoX{_Q$>=~Y(jNk8#<(@gpDK$ei6v0ayajNTf;dQ#M{ z=^piDqRbeVBshc|_1Ome_pp}(vCMvMLecmsBF?NzzF793RhMYWBe?aSiuPefwr%nF z7b}?WrRf?2m7Ls>gO(AMCwuc^f_tm7&vRdvJC1KaZ8=8HE~v+1jq`TvFK?zXeCAnB zQX?B#J=tmQZE7Tz6#{a$Yjx9gOHpUZqqtjXbxFyIJRU+Xa=fx)G*|0H5a22)v;^{I z{J3YK{sdfehU5r=*X^MfEJ9|5ZoL8ll?}uh!j|LByZHCwom#C8PJx~VO;nP>Vm9*dSTog^Z=Gme z5OpRYI$3IOb!+iqL-hS{8%n`0CJJ>H4Pga)do+_@XMYd2FX zDL4e5_t~^=rH+e(bPSm^CbUthHzK|%M`>?&7qz}rkGJ&>h>;5_M$gnrtbth({?k|7 zKJx%1OvosC(Gm8d0n5+wyRJ)KnENI>#H?+>YtaE}^B|AB+E(Z1iUE zqx6N4cI8oVZ-`DhW;d`t$< z`oOqUAs;j|VTS_Ntu2mGfgq>EAD(S{rawd=UVHP&6D;>4-G<-2e!>)z{z#q7u>=pw z=F>XjIE@@^$-Y^o+f~h$b#a73yj88N3N@?)+hwdf{JEL`#fYDVhDyE|wnzx^|6DJP zo#lfXf}YESe0Z=FNlkD>Ls=$EX4VD-?M+JZ&bpdVEpPL*%CXj==NEG>$r_h}dmo!M zWhWeX9TWEw*aRyF4S_L%6$Lq}bupCY`8Geq8(dd0c_;gJZ7O}yz3nZ;ZJ1=Ts+vF= zQxwBWpqb$=OEQ4_*hDTx^zGbyt#IS4)WSC#qY9LMUAwiPLdZBhXZxyQV}j>Q)tExG zSNj^~XAi{y*;$4ZKeu7;ww@X(1RY_f=dLmR+zWxu)F!i!+v4fhe<aB0gtB^`RHCCfL;`n&KcXj)4a%F!aKbU56<-7< zA7D+>!P<_fsX`~A!jwoVRh_-A5D3&r+m_>BQ-hRzbIvU!Kl<-k1WK4|y5c@nP}iOH zyK_WLy8q0o8BN8h4Ba|EI-mO|Xrw6ZpY`ate9~tJdoOa~XMC2Pbv2*PHAEsu22HXB4QQZz5X*Hx;pi8#%mxh>A(NbZ`qS>j+%D@p#`NXv$kdo z+Ay!AvBgBhABA8)kx3r+V#Sn{vnVlYAR9~#7f~yya}F-3Jl;RC#2){t-fpr<(${%U zG)xl`cW|Zm*80DsjsMSjOD5xo-)k*wvaL&n6TwI)%eDHX3b-5kt#-p_q8%yFG3&Ey zrcYtsfVb~TvNu@gP&Dw2%PEpI(@3k7pz=LC;s7E6mf$@9$mbGU&Vi zN`5WnTPzU;Rx&T9c8>HlcgdnE!rCvEi|x!@X!>EM>hzgTKwj6wBaLl_8|n+@Bj}R7 znz!sUeh+9l($aDeLv>XymaGK>s~d2&mn%CNbI_**PI-5+P`4*RPDzrf!A;OK;_)p7 zT{ICEfAMgOx!8xvBRFZn5kds?DR2<0_p;m;yI)kd6b4U68=bsV0HEC`olOy(_lxPV z?AFEwR8JXG1B=;e0SFJ#$qYlPms;cz1?!HVr_r3aooE4lzcHu%U%4P*#rd(9?M5s? zVD)v>11)7O=30iI@P)N%F*7Y_MV=G?t&_jWcha`jF@%0quEU3a&jI-RANjMV+yznp zeZ&3qh?g{B5GIZJZ(e`#%;acgSVknfEp&coSYG4XAW8xn7wDsW_4G?n>Ig{LZHsA; zcC<#62uHoNk1fjbXU9VK%tx#30GRp{Lp(I0hFB2>HhceV`+*ueIRWsBEsy}D^`C~_ zRY&ZLVqxbo?}bnul55r~Iy2_ohu_>cZfNL3B!i6LgB^jo&_0bZrSVakdisgY#DVcG zakFA&Fi&!iQSnm6Z{;X01@Bu28s7_GenkZwjQ)2alv6g!GXZeixI^G&;HdS)N%l0gH8LO9RR!bZBEA#)F+j|NJYob z)eRJU0@bjY#HkDIn{=2=Ey&FG#Tk3|5((zglYZ9;6Ila{LbV#1ZeK4)=@lMIAjHWJ z`NNaNBvN;-muQN@R%~Xg%?P0aFvMcbNib0V_YFwHP`Rj;wN`XIs6)9e|l&D${5e3KPY{8#1- z_~-;iqo=h?n@Y5)fQidpmKBW1&}s#q(k{QkXmrAYGut8s2FxIUq2fvTTT>N>m-#kIL zTO9pJV(n-lpC-&)Y^s&{#5oNdn4_fH7r-;qu`XQr1M%q~;Jx;<1-5^uj%JOWIwV0~Qk!L~sCbzfga!sRdt}9<%&v5kzH3{h zGEnb{%NC{(O->NktAj@7jf59KlJZ|f@f_D z>WSLclFhMl%pl7m-&La1e*j5=U(glaj4VM(x4{=?B&0PdvgR2X-mjw@Mi*qvM0_qW z#a+2Vy!`CtkiQAGK#?9Ao6}8gxk1)FUn1z+r?j?#*2I=Rd)EDvJVIldTq1wEKkX_~ z%}9_+asj(@#7rYT`Q=A1(rrVfHNs6%g5$26PzpeUwac~kO(`#`q#dUmO6|Kuw64m~ z*(e8&X%Cw0sdovkm{TWB3w z9#{TZuucNrBR?@GO#n=(?P&AWAwq($E9s~4`!yx`vdKo}`*HyjnaTV(>BD#4rnZr_ zNscOS)+Upz#UVYJj0u{E_-bAaF zes6^SRQkCU`hRztfpyGM__ZfgIUp@bo7jpGGC>Vc78RVb2TLE?+$G-TOTQq~_9Jc> z{w&VT1gBS$y=&L5o5vHlOe9oS7wfm$?ei3R*kn3+c)G?&CI@HI+QQ=dpPGMHt7d`4i;t3*o?3l%ODAe#QUp)K9-fY$}T8%I3QLN3B8$6F!R|0XfdJ zFnWeWlrMz6t%SXqboXkPT9mrJ9l5gO#h8z)vu=LYPv&LLI~OZ=QXpKXQGwQ(^6J2^R{UV?ouxA||1w&k{b*UK$y(*iF-%WrML?` z%S;dQ{_G!ol<6SnijuItFFreEykPk=mVk>8LPe$Y(c3ISG>d1Z?)u>x5%qve$jhMb z)?Cqz_y@dR^d`7RG`EDpSvCx4x@Y9q+zi^AySUHfgb~PE-j>7;zF;v&7Ft?Pp5#$2 z=itzkbg)*IoFJ2oiD=*YU0fXUY%Y)Od|U8ulOKT(X2&5%v@grh+G?-!bdPsv+52rQ0x5K6c7l zC)>;7)3iXM(2{T0k%!$g|IEo}vfRcq`>HcDs}}(Kcfj-{>L#pD1Q=I&-7m%6ejjc8 zpFz03=@Zyn^kKKH6F3LuZAckCWV3Gm{H5!|)X{(OmW8eyMN2$71PMi}=i3UIot_F@ z&Em;Dd<7{PX9z|znslA$?kAU{V!)n&to+A$^#fwMM)=so(J)k|)qq=WEYO!`~ zh=2FoZ5VLFxZIFXBHe4}qopRhpTeQ_m63zJNS$oItlX<{^&befgAc)X_AUfYAsAZ|ZBgbNW4aXyc#Y-ePms9)XuOVr^}{omJUY z<2mV;+ouIZ>*|+67XT!(GBNAwX!bDN!ANH&DwL3#N`3Mv1(Cl z^qq;o1UsYbd4$HK#nr%n4~F?ttYQl4qc8dZA}a0SGXNk8W0@Q?#`n-ON@v5{$|HZp zV?JJ|M|z_198EaW8e-?{b?|)*TxU~GY;-nY!G>c#6flsP!U;_#v-tLw2vz9LhP&aPXd^7edAQ+&Y>48;uYOYds46XDw{ePtsv=Lw)g-eEVR=zxuo7Z9qrrdfz{(kEX zC0+z**G8qmTzSy}&AqlRKDV-cr9nj<+>laO@|zKGOCUF*6SMn4B!vl1UM4$_sWr2o zR8vfCwrOuk$pcLn<4=i}w3fLUjwhL92E>WFQFgBt+Ju7ak1NDEjjZg5rKoC*hD2aN z)&U{BG>?cfza;@gFF7oc=CP!}Fmb}Agod1&8?JLCim&!P{LW5!Lmc*d-Ef1m zaQdEG1F3E;{e}Kfj!sk7^Bl@AJa}!tQh*!nRRKC$_Mam6JBn zklxQGH07(=snydp0V85lm()29q!aqNrheg-nf?xCcYUC6_l!2*X^nKRXjQKfoU8Y# zh#-9o1EGfJK#5B^zyE6ZlLVqdkpGCXg4oIVZJ$j;96{mBREOCj)mJmqC zB~)9lDH#xnrE}$&ZyeJ4<~!HZ!BKIr_H*?s&yZ*|fgN9&n@q=_=q=mU)N0%8F-R<# zGU{B&{w+2>_$^&_(x(CT%QIgL9?33?Hg)N13I0xYOxrQAya&`wVy5j&yv?-|=&z65 z!0dJbgZcBj2d>iu)p*i5U3bJ=X5VH-*cyQW>tDpC)7GLrvQlN3+SPbYltqA)Y>Nky zH|3K^M-_eeT)=s{)1tkX|He0!y0X_4W*4NeI~BEGTtCb0$d{yd%u zq6m7FJI?YpMK|3-ujZgn4`se;^J({SU|gfdV>X1oQ5@mW+Mo#3o?{3F@m)fO+a$Wo z{yrZ4@q$OLB;)u0?!-@`70eOhubnGCqW4|$MUB?%)JkSY`2IZ=xSeH&u@Oq5LI`J3 z;Rl~}+ssfAE2iTnK%yrA4$Fidl3P~!hTjV#H+%eu(;dQ0{@cl_4o)gCDXV9UFkIJ17B-- zDuwy|>=Nha9=NZ2&jX%Qx& ztU1{}cAnV{8S+~Dp%WBnI*0cr;59r_K>LGpH2KW$r#03T*Ef=i-6YR-PEwnjDIR zDBjFf#2>cIe7D`}JAU^$B8fiFG$0~xV&NO3NrkAJXAZci8^O;3mJm3j`np~*Gt4~L-@(qBBI0j&7rJl~)!7Vgikr8Uhjgq~42 z`Cwu!A^X6z-=nzV?YkzA9lb2KS$=+U3*yfIuF<9%)H@e=MWwap|B9s7$K(i?3-~Q_ zd>ww`u}8voWwA#X1r~C>r`R=}NeI63Olt!@%>w(^#j$;K1I*r272{DwcH*eEz*AC- zQlupIfKNNRQy(BSB$73%Pa5I0-qJY`Q0w66_wNL0eaplMuTbuF_iAAcwgxsuuf#L#7GE=|n`e z0U?Q20x%ZXG;~RI{#pKxX|k1Q;(wuFLAcSg)jmZp-{abtGmB{}%PCr6=!f{Fu5`21 z+9HWSf5m$9FPOW`3(f6Q4VYt@n?f?7g#FH1>3_g3)2#_Q>l=hn5PwmcoG{|oe~eyt zeten|FxY!H-m|jQdm2pdZWuz3=BCR(eOEp4ziS*9-|f9gqVJy85?_Z>-%#FhyYr(Q z__t=(ehUC?8UQeR&Ibn|{^gM`t(JnStkL)ncKrPm3! z-d=gTYXXUTg*E){(Q}ixdQKE z=$4cf%HRLu^E_1rs)J)G^vws)4w9FuhFAfM48uTEp(dxTWgCBvSyTdIrYXO> zP4|=C{`Y%?u>X>PKtXzgMW1VOAq<42#?iy^s`xn;D4PK!>oA<+fnccdAm`FkgS{ zpZbXOuDT>yI^BZkU%dN-QsU`4PILoqgv;ZZ|pBZLgA2b7iMClRm5J=V7beD zo%kNn%XD*?)N-Ed?3-q9HkQ~L+u2MsT!^o?{K*JVckd{7Fr_#bQ=Jzzm$N8Nb)5vh z*~wG;-!&JW6N$F1#APj+v7ivT_blxU6N1K)(fQdQAW!@m25vRFzR{IXXm@)mJ2@xh zEz{3}zBm{ARkqH(t>*cmB*m;l6w-{HqJ)%eenpNc`=o)DH+EFqKa@QO;+41@ovh?3MNI?LsxEPwd~|@xoo!3ey742 zvpLTVuh$$tEI-f}B=z*PXS#EL=AF3rDdmY*llu=Tk>^GtRh9~XC6rEi`p)K&+C|2z z<}$7j&2}_LE-DjIp{O$k)F&`qQC#KR@!sE=vU;0$#h-&Yb$4F7H0%uwQqr?l!B>_X z2yoadrZjY)&H}7B)bGp8tfN1=yU_avH;IcSr^K|Xt0FX)F6Cbws#a0qFzp;ErAk4Z zikz|Ogh6$rF*de8QK*FEoyosb=GIqsglL}nI#b>Ed?OA;e#FJb2H}+Y@ z{|v6ZVonC8IiDfG&NblT#=zRjaZ5h6%jgjLe70Nu&$kS(K8p8QOT6-7KYw)F(s6;Y zu*kcA2KEvY0Rv}tCtv`feQuzCp@Zoxt!dTA8x5M3oVd|tX8?Je7Nea>ZM;B}qAFgE zyC?xl&TKa71x|8Q%(M*Y@8rl)oT?>giTQp;hHGGdo^87k`jIb*PqX?}*M2>cNTH0G z>W|Sb_?LM-9`kiZWrhA5mohqZ=#PEUAH@lmgCDzG82`PZ!lZcn{VgyWkpZ#cT2u(+YEG857!-KF1mAb z1jAHc>>$W10~6yGaXiUxeYx0a$ew$=-A?WMlSzI3des;VAi?Ma2B9s4bQ;OrBbC)& zE{}S*2juP&N*=LU37uZDoS+bv+`<`#NAPw1Ht9v?9<)R40cd9{(VCfyZSbDzxSB}( z5K*;=1v#PGZ0>T+*Pk$?G_*2qe@g0Y@@#-j^8%T|rZscUSeIi4QiF`vWX4?E1K^C< zf>7hFtRfcLkMEb@8&0uuEE&EmJ8~mGEB{{&dQxq`q#@W&=VJ?}# zIXMq`Xm6R1@Nmod-J!1+#w;tnKAd}YLT#pvK-%z5NhQRmTFQ6P=BmFk zw(T_IR;jomxM!=_npnT+JYu-2*`${3_K64fAQAypB5hDI%KrLDl3?$Cx(o|`_0uES zfgNC;Mocl*ZTv9%$&{GkzJj74s^j?qcnk#3f>DdS>s_d|x^HC>tl5_OTTuI-TQb3Q z@vgR6yMD36lZ(cdY~!c2cUD=$`f3B|3<%?!b%J```KN_dU_|VP_GA4AelJ#d0)eK3 zBdJ1jKj=PpE}N@R=@ikS&8GSj$5T8u`V*on=oXltv6;IaxvbQ@?Wi#DxyyYfxq;8Y zBrM0JB2ggUxjt7*j!i|P{rLCgMYTKJ0a~5O1_@NRS6E;MxC@wX^xkYcZ~5<9C+{;f zXv|3fZ4Pa;70^;*;nU!85{|E^KLGrAYP z^*NFBWZWAET5n3ztmNO=v<^s6lb`B1Xo5}&5$%N3uIw7F8efH0#v`f6?+AD>U$wUXLT6ab@kFSTcfvUt|ly_0N)=P1fLdPg4TbZ$_5&A z81^7xS#Dmx_6hZc1)#I^5PjD~-a+KOyl2k&Ix6n?=!nL$4?G7h-VW&o-j%@h z0{JhoA|~tYYF>P4jqzYSwd&0&O)CVpH}qD1xaFDom6neGhpK8xcHXlzVSz$C1Hgl;N{(#TP9vs!&drPGao%6xHsw-*rrv}H|3U7WV zl;A9?eCIh*FqV4{u8{Ci_fDf^+)DkmFw8f(3Cb`E%%!1lBcn^=d6-_wFXNcTc2RSi zf>ePEeua{%x>nPmAx6#)0OdU2?6d|CsT#QW^u2*k<~W0;`$>3zF5MGwT_}7pztHLq zsN#0pg#c4m+($~H=5hVhm-6mB_84BsQE^_GyN1(o`7rP@Hq-Y2w$bAN60Q%})Y@Ab zpw)kIaPg(wv*Q6%MQ*F1<*_=qHcDqPxalfU0s+f1IXAW|;>(PuI~p`6xvctRG%$uE zd)9e0{1YsnJb)|$>%INL^a6zHGmN~|+id^ykS#(%r~uRbmOFA9sbDMFADVE1yezkd z;s0HW;gj(YD){4GYu67Z?+~AcL~XyD);x+o(zs%hJq8)ZIUrTrLwidTCsrTXyH)i9 znpI_15{!M^KL7$3wNkB=eikgYQW^B^SJnLG1b0dAhfJ$K?k1b5om{;$-40&#tmZ85 z;T5cU3UbF`ZD$?mrkIgUijK9#0rDHnKJ>~oaCV-!l_M@S^BPI>pN})uxD|HTzQC(R zhPBc>0b@GHKHy^^uc5~JF=O0n^j)6r?Dbkj?%tZIhdc$d)3z`w_#5xI3DFw2vuKxU z=0P4_orMX-^{Q^_MCrXgCDD*Uf7CVS`nMUs3T@{7C^RNAYNAhC_-0k}*c&+3>O-dv z|7$glyrUf9$5@WH`tb+SEiHU}Fre>6#*Vf}J<6%O@_;l*V=q=oqc^?t_8HfM!VYh( zj46s9O^OG{+xImQD@2{RE{~v?wl=X1@^PHP2QYqF`+H`9tX(v zZ%p59^=IZRMJ18DAit(0%hreV|FcC@A&frWCSMIQ0~*fOd8@X`J$!q+Q{;6YGYRP> zmPeync6C~VVi9OMsE$E@UGU$l$Vb&+HhSfwzk~ZY)bK0XJE+9n`1RKO$y>;~qI=j- zkpI7k$B}NcS#TWfF-o`a3qk#QB3jLWI{BG8F)W;_H|k+8w)EGi;qnfaUwWR+HGT59 z(aFlFC7-<7?5T_<;o1e3`)nrlo_wQ@%VXpZWoaO#9}6=#&-Z+QFUrkShS%9P7>A(1 zPf|r}4vSVZt=$H4Ve;s$Ozi@g;8b=v`Z)Z~A$j)wS}@uF(F45-5ejGrjr*G`-48(b z1DT;4)xUk;IC8&Qq4e4y`H3wrxwX=AMLnd1tGP{P9MjmDQA=su$G@l0JDT!*iuXub zA!TP(J+rC(rGABpXU_e-Jr(3m<$<`&Pru|z=9V2AV}Gbjo00t#UgN{ooRh6yTM~QZ zTJTjHdR6wupQWUAf!LSv%ZUayW%`xj7?tYx_4$$w?Z%w$n^EN-VlQ)^<=Q{|f6>}| z^inCdOT1$gRqw%D?mqrL zu{XK~caN-WD-VvA|Fj-71&dSXop|Uu5#+@c^KpzZJlTI@rr1CzA~x{t;=L0Y0h4IQ zH4Iat9q16+EC02Jd%tzW($b;Grfg;0CH8ewJdL8MMW!pjaviGTZd&=MZ>7a5fptTU z{=uV^O=`2L8n~FYajMOd`H%IccZ+@DSTgn`IXV zeOIeHpiw<)6>%>%>~C5Aepr2UVC$S61pRt;yg(M(WMay)u9p2ltmH_&ijSpVM)2Vi z&^JLc`ZuT(qw>1-&+}g&Jd6#0uIPP`XmY8s9!#7ZPxRYKPq}Jj6{M*STena%oTD_Z zmfR=q#>RMX+*SBu!yra=>da*!=I+heV9zL`R=BCjz} zVHwILJ$akaCfpEGbXI5VhYrsB4Rg{G&F)ntI5UsNfbKXxtNft@7~~KYyIiL+{IDBq zjMYT!{Z)7ORmMt9TIEZujFO;Vj_~?PbUIg${@P|ZJn_!@%%(P;hGRye)iRf_TS^9T z0GnQwcM%AJ=eL}-^@bimqq$aD$w#}182)}S2_rGo#<{;WSzPG2D~JT&3H8wt^r5zr z`mi%E=UywZf*I6Zse)~e;f3N0_{_(89v&d+L$(aX>Yb5XlM?Q@Z&?x5s|P(lY0K#Z zG|eP&CdWp&F8_=J+r?)4plUmO+h~ZIfj6kL_Q%i3ovX-|*)nwz3obr_j^23l%7ZDz z)aLxY*<~#R_{VS@+$V(+Z^7-jo(7DoQGJKPT3gU+{E<&flH=;UAK1>T7d1#&KGh_m zD+wA;IOdcgts|A0GWJATo+f(R^lsNas(PINv|fi#=iK}KdrMm-Ni50kEwhDhZ=O2b zf;`0D4LK3Z7|>aPNOQ#y5iae93f)ekGQDiW>)nCrZ8FCe?(uc+rsLALPfIr#TgSLm z1-LWZV58YX)}gBPpx0IKkO%0lSWutN)tPkq4|19lo)_~LfCBMD{``4`H1sPp;<(4yzkU<+s?G9)HAJqVrm(tFa2B$`o-HY?W;Q8#X-Mn zR+5L_BdzzpW&nny@SkyHKz8+gxa3$dS~IT#$FzS1J&G6AK`ii=ab?q?@7aaoK~i-S zvtR-gT(8bS+1|a&?rJjUxlJp5H&bHOT$jzP>G|jMonwf%dh@tlqUMfn5&UGM-{obe zzT*#zt9^@#FsVzu)Vt8(xb(X-_scq35ljxnb$=%)UJH#j_d`=3-zOGgo=5*P2y_-P6IG9+X%Uvi6toUo%72=F1V*1& zViV(sl>44Qn#{wV1({xVlelpah3RnL2t28tZP2C3)w<%+VStvi+-1F<1M#e2>#G@d z3aYU;ZNM;KRiaFTdm-d$J{3Sm3N8XP&FZEo!Wu#Zn{%ZMrmI>7;CBA6mJLf-r{Img z#sTS?>sj6LhOue;36cM<$THBC(G2_9|FadJ2veoyttCE-D*QROK$mlx+R7UIOo$lf-9R%PcLzbx#A1BB|q$4SF>mI!>$2a!~QcotVb8dzWO8$;t2l z8a1wM+-As^B4s@TboS4W9$XUbdloIT99Lg8(L1-AlJYPb>0i{*_%%3BQCsU-TDOoi z(M`I2U%kvGRb+6etSx&Sg9e%p>gKM?9>u6nC(5p+isTc%Vt<;f+Nw>J@7Mx+%=18 zzmP68nWL{#bGuVcXS;_F+jIgDwl)^W*K4gqfTJ*_l;KQ?v|eyy2@eW&W*g{X_L+90 z>}ipEgJ6rWoyzv_!xlTkA79FrMwd5_2UyL=nY4MV}BC2UmI0W`X~%v zk0?XEtxtyQ>yAe|?tY3N3)9Y_DNXs`wcW}pIZcY{FPpW;{nhgFs2=l|aPg&9_kVQK zpG!Q_cNDDBUA zk7XcGEx)$$x7@xnZTf}lTT^ADZckZXKDw0lmq{P8npT%Sl(}v~IpdPzUTJzxSgBBE zQ7}Bhy7N3h6KOS5Gb=O63V-kYpo{~yNeOc&br2ufuPLVk2^h!v{|divUVnp+iNxGy zR?<+9y-`^iTrk&u?|v|+QvS&7TZfo4UlLMR`wl0%$m?W zjD;60PQMf2v89iZc-XIj37b(c{uw0Tp@5HqkAgJGB|Z1RD?Nl2Q*zZG+pHjYVaMv; z&R~_~%uxlS_sNTO{+x{oI>!!9b4Zb=z%e%;_ix79t+y1BOI3w?i<9ruSyHC|G8&ac zI)g~CKJVkQjX49&6;&X`LXnXOjBQ-l?7EVY@q3Dwb3deudk{p+MlXc-G%XRm$Pin0 z=bEuRvQ3Hg?iu%+$V|6-knR#cTv(u}ok_1@OFAn|>r#B#UDL5>5hUg>Ub~R$U*2$* z**1;RGdolpTWJuD6B-S1*In99d><`s$dF{LtjY5dUM3r-k-9WtB*%W+uqjij71~B0 zrJuGoSf6J!p(L@Y*MRPzk7KIGS5BpzCH<*QCZ_7L6Jd!0@$#$Gm>ME;19R@&V$*E# zvZAO?MhH_45BdfrvofpjG7yzm5>td;iQ9siMQL29W@q_f(EQz?t|~gG^v?p=k&&XK zhIGxY_Rui0LCx}ri=lZ&M9Y z0Ku2LzZ?s_2D@+P6VN)of^rThGPjf0ihEGv=5E}2wR0qhC`wU7Z~m-qP5_1Ka?S&% zSo)#`PYV_!^xQTfnbD=Dw?ozyu*TFJ#M@|QN>s*19JmV`qROfFGp6^?$lb?`NB+we zA%!{c+?BHe+-%Y5iSc7or$-x2cT`zWT^$F>L^?`Q;HSH()~@=2FM65GWh4Z(?h8NJ zw>Tt$?P7lRX3%C6hxnRs2iO9$zPtCmC;h(*VNtcohQu&5zri6Ui zs1jZIUzmOxEiomH0z3ipSR-+H(^9aX(*x^Rz|C{3=x3C~GfS9v2_hf^?y9M0iCkW#(5hyc z3IL%hP#^0T*UzXh%cTZ=V^)F~T490WVPtn^VAs@SC1=HW;izO;jd%IQ$LFLlnOn3? zLiy0NN56ly{+sU#Kbpuv}`mX5euB0H?hOAvgA0~y0}j1=BN%<$Jf$7oC9Jk zsCqWp1;2~b_;P33)dy3mldt9!J!W$lQ+iq{-GW$1@x}D$x%k&=dg?azKv#54#}M>= z%PltV3YQWYv8mn`Zl|OT(b*So-W`RHfPDnSMx739BVqhbZ-Bw3AB-$&m*eeN@J$X^ zb>Xf*$F`J3|Bs)D#BN;oh}{4!GhX%OmG+u2LM`?DENt^(Fn1PpxNv5y)4UNmdlqGY z5h6doXxN-=w%9(aT(Jcd3e)95ckV~l^UMMcolemIsLK`0;(b4nnS$L1BWPNNb_A;SLp)b=PI!w(bterPZFT9S2TOk7y&4S@1ZzEXk+VJK$$1fR3G&l2 zkza?bXh^r;X^MVRfmA$v`*DuEGg2O8wu(5H12TNO{C5~>z%P|pH-QsZOSHe z%0qeS6Cib8fGRcFV3#NGD5vph#s2VdYR-`=fsNWZE~6@?yl4yH?|ji_v7nOKyCvyyhqmUmLC zP!?GTL*Q-N+mMEzicB9`EX7+Cx9jaIGRoEWTG6Lu^c@tRZgv@Jo(>j@65Ao!>!Hen zFSglvENC~jD&L3a1!$3{@6CaP1wOnjPf0heEPE2z%-~Edaze z9W_gXl^X)lq0TbUGFgb1&#zSJv@U@U0~H$UXhCM(&j_#obGIIwkK?mlhRA${}xcJ+MU7|NzDY)z`rVeiK$h;eEKe-wo=yE>?Ce*ltTK-hl zdAUXveYHw{`l(GvA97Z!#eVDLD&h!0XjdoOZ!N5FCuACe=L*jd9-I>eBQFIcwaDc6 zD`g*0Irfpb)JphSAf3D%HYo8;ob!^NaZH3%^1oN{@WuYxSz+7od&zSxzHZbN`4OJ% z^~E_51(DXkh(Q)jF$@w$PAZwFr{ns%qwhl9}t`=bvWp$%ZzBf%M9}rY{r=z)gleZOu0U+$q zJU2gcj7YN~um)|BG?zI+s#z}hLK_V0h!+Dg8<+}s-{C^{)Qto9qg zC82%~T>R4){=S(qjiu2`doe!*WUj8OM?O5qYqwg7yXdg-cp{1>w%yt~xrpLbL*3cE zgB|L0!jDxpV#WwtM0$2>FreezvlW)?&_Sp09fnoPIw^ErjGVw|ik z`DwY^OkZRC02fR;(K!w&3@FHOQxDB^43cD9>%=LHR=gUs8UNnE1V3)`A)V1L6XivV zxB2JrC!$@EUTGWRDXL4mzq-|%;HVfkgit1Tihp(+3)pITLdFKeU%Hh}j_z9OAvC#` zON{p1`9_yMOtsZ^ez5eOiDtQp-GIEYZ2O(xbE!q4j8n6JLJcKSSHh#RZuUu9#*fjc zmVO|-;@~A^xqPf?JEmw^z!b7L?dh0ZvxbVpxRp<2G&~(FE3=71B?S*W^?zQh_&Wo8 zApM{pX{O~VIJ*s3@}~W>VF~bC|Lg6zI(2igVRJ%kaf_(9`k=m}XM?taKhzmc+w702 z37Azh=1dCvTMB4@eZV&#chzq|)J0m$u&J!q1zC%bK6=S)0f#4WEs2-<6^%drL8U?!sCAd$}U61073gf}w@A1M?NDD78-x znnNH_eSFtnI#lF#Yk0syv)aVBf~uX3i2JTgc9cHR%Br+#1htlYb>zh5!;(Kf>)ag> zjv-n7sPRUBHYr9y{~wJUjdM4BNb}pJPyI{WOP015hLa3$%Nx`W!L<#$fQ(q%35)+P zByrR7)DpEZS=5yk-=gBZUONP=)u2xKz>(9qMT3L5{>K-vtiHHsL~37k4=8En{|yph zeg0KkwngVb@jD#v0aCja2nu*mg_8fuAv@0^y-!vdI?xH4Ml8)-FP1JWpwh^`$(n?+ zmZZC~6tIk|1d*cx>MN4{S3_F#*yUpX)4PB3N{B!TxQ1o<6GMB=_%&fU9!d3q`_C;m z)E|d2ma?BX{|L4EARfDjt=H(=Cy>dOFSQBWW#v>;+}6Wdm7?fTv(jQelh4alcB1Ok z1&py8dC=#(o|gtIJaXZq*O)pmY!5{Ixyv|pcrXBh_;%fc)iq8^P4TDrz%)j%+U#xK zPj8+XcU1G2%lhooVC{aGQd2@cua_v7R|vPR5= zLo_e?`Rk?>l_glrswBUje}TzsasM>7Ohs*_gmFemAvfLHw$lcIqtGy8E*lXjoTnSx zs<~^)`>m0N?{6p$yQh6);=yz(v{nl2T5Wq?pB!(@t1w2MQUYuZvhw|?fr(l_ITY*2 zvHdiEkJQtcES`Vm09XySsjJurz7OK@E?+vbXj;5mj=Dn zVN{(_4Fxpf7iVXS%0J?`EE=(+@%Sp32v<}UX$X2wqEv$ zBQ-F;HI=$$(Y{~#Wotk6*FU>c@#*Um3iza0QIAHaY|3da$k{W^RJ_S8eOT!4g9Ew&Lw#c?&ya-RN2YA#lZdb+5)eiTD6r%~^XIX1H7O7-;{Y`j&&9cm zE9$Rh#@)*u6n&$|NIpp#y|9SlP~}FJGrFqH{SN7?ZbDUjEnH960(sD zZqxi5^9JV9g#<+=#5^83ievHetL1-Ds71%aG=W{7P-VZ015RBH*LmC7UPzJ#7C7hl z)8&s0vn%U!iLH5nNNrQU(L#KE6Lf1wgUOv|`Dag|?5t+?7Y2MUqGgqJjt^$pRXH6{ z&f39Z={MdqS5@7L0GwnKcsxlir z_xUGcY5LN&6EipI>-dN$>8e*IIj)nxOKLp!r30x;?#sM`-o`mHRF$hDJ+JFbwJo$Z z9?w*uqL>$q{>~Ar6_;PEd~e&_7(3vHnLh|s;p7rk{Hi)Pvo6Y765J$k{i&JC@QSe$ zLErynA_mPpO5~!_na_ZyZT)p~Q3xpDv8Jr^;wK)$%^(A;$INMjETjqEnXk)Yt#Q(> zaCyx83@O7_hcc$zYiFF)y_toM})A3n(lm>!WueF4ySLNJ@t%r3AAh&*lJ?njjD?31_Erx8@8?0 zk7OxqmXb@I?t%2F!On5}2vsAes`GrUj&3quw{f5%7xW^OAMkA?8L_TH;^*U4HgNid zZ+%~RdMNzCFL1&j+<(}%X+G5o%_Z<547oD+Hy`%R{bOtQV$8+nfI2Xm2G1wyJ`R}W z@t|;jWdC$!F9fwDC%GXGhLsYyc=v76cxv4Os>=R&0>fV{WcqORhuA4Wao|B`$zS$V z#aYW1t|_U`mx0tvPj_jY=pUJOKOebZzms;FFwY?^=82UfIm-GieC}g|qJMgXlfTJx0W3M6 zq-t$4edT;7p$ETO>Uctnq4 zQT=(C+crzPCDn59^IE2$KL4~LckFtSSNb9+>tqvUoZ zWJUVg;2dU8tH}L6o$8e$=c(0-5o>2K;kx`mG-PcMxa#zdq6=g6r>(kXpt?ZS|*zZnHJR_+3LDVUlxm{y=2=@Zj#RU z?XPZKDveVC#r8_KFvPgA?QHZYK=;7%tbGDxkY;o9+j>wobsQw9&fX`HOIEj>uVk`a3<>TvvZQ&YeRCV+01qLb*w}W z$kmo5U_aO3ur4}}eAG{BrcfYblqn3gvKrg?vb7mPKEiqu)3wsTNU}JdO^Z#s4N1Q> zb|@#4E|NZ+byOrWK0{QIP({XZBo|``tA9NL-VC`n@4o7Ig9$8pCZ=H>BA{ zf?(a5mvWdYh02J?^#L!2_4>-%Rg@>|+h+bDGm0gBNopUOso}+dMa=J{qj+D3VE50fNt2YT_(K7+jv_&{WnD;EC?5H?~FRxlO*gDik1Gu$&0c z>qGw{i~5ZEQ@?`mKPXc?x*=18&+Q`Bb++YJD_w1jibVusJDL+dee`+cm zgMhf;DYTs_$NV|IOBgooIDmc5Hm)St_~bxr4`k!igUPA(Aq`ygzDSbJy9>9-1U`4x zVM#So5^VO^5%5L{MbiJtGsIhLfZT0x&7sX5T;E{Yc;BxCR+F2U`rT=OvL9K z{m}Sb$)SXY^S2ROC;bC<)Uw!CoK zXIXXBSCZn45eT&jbR#$D^pBUXR>b9SLD$_|9&a_>?b~f=Z4`(U8QU`ueejF#IxEW{ z$~XQEoYndDkM-E|=q8VYG9!GC1l^9#NtM3NjF6Q$vu1uddgE9djpL#Gl+D1h#8-!E zzqA?E%OBMWysI32{HzGM&1_cvziTTbXkOe(f8l6T7?S&Bqx!tGN%Ko-zqzEo*2t6> zkHZ%PFQCCi@js848-|WRFwZ|K)F9_iX4aI;(hAu4Q1c86wc!JX+R9YR^<6|TIHOix z2N6eZSl4Lv1a()w=6MzwEW)s7kP7QlkzcJO*XbTxQusG|js4)%> zW!O6yh~P5a$g;+a_qM>029Qyl^|`i;b0y+1Y}=XVocuDa`*GAIE1jS7i6BeALFRgT zS=?3_H{9`V{4Anw!pjKdu$%5;e9e?D4P>?_;K z_Z1|YBSpN80RDG}eHXN2m+xSM{Ml~ouPo=RJY39iiPIjz@u!}aW85{S55`)E*U z!v5pdYam2(Qma0$)u2m0F4mtTA8*k(EUM1R#VDghCWew^Bh|&kjeyE1aKSfZ z{z8i3`$C71btrXTrjMnYdheW)vh;osQER+EShZ()b#`5D>pE_LyOMh)KHh4wAh%KF z{wCImGn!Wp`_?zq{Dpc6I9k341uc zKo*_(Vrmc&YdeG*m|>w8n8S1HJkE= z6?AIlGltE-zv-Y+DXv!IwSD* z8AJFtAO7S19a88#l4)6M!u1?*Nhw?Ywrw5m3zhiW7|6JFwJ*HW*P})(W69&^-F_Vf z55s7QGyJmaji{qr8^)iwM4Sc*jj4L#@?1)7$Ci51RNYOkkbn@umAgF4jX*)cd@*Q} zl#Zr(77Idx#~`^A-)7+W8LM@}z{L}IG^Q$kW^-P=L3}_(>^Yy$7s^$K!lHF`5qE3- z9%d3@KgI+to)s=m%-9x`N0}RUBsXQ=829~eWZiJQeu7K$%woCJuN{#?0yM{?1f)zE z{9gh-Sw9{zU>*1+_`={(`lSh2?+m?C{Gnm0-NEP7N$vP#RjV@r7g(3{P*amxDDYWA zYEb9Fm{tLe;5d!r_|NOBmH5GBsY9eO%~(I1Iz66;CB<*|i-XfL=_2$lzk$LgvfA;iPOUV(gKC-yK`RKOI$i1h^;;V{5M+k%enw% z8`qqK7{s|qhKjPirS1PJOAK!4>LtvZ)DVQ}WcI~dc1Q8%&cq(+iQ4qG?Bxs=SBT_B zstkw=8IJo+^9b~relwr?1l7>nSe${3jbo2c%DK8?&EpT`y_OP(aH7>r!yAuTP(8nz zbmXlb#>@`CO4CENe?Ml$cb1(sR-3qDcdxV@-bsG%8(1FicbUDqWzUF4Cf*?H=WD^a z9e*`bm6}lGhT$YGqvcM5Z12wTv4SBbP$)%n+-V&Q`uL$lx}S@=4(0CGF!oMa!I*Q4 zL*HO$|BQBeZ0&;(zh)UyY`ME^*Zt2cr1@X2)tPG}<+?02c}{{O28a=ixZ%@hI}s;; z=e|x^1Y`sC{b?01QrzDJ`IQ>WSNq%N7}>s)z9uv11UI|MdW2(MQ zZH4C}`pVlBWo9W;E1oVp2HwR&oJqG}0D$B#lXga*?ElupEQpOd>4$n;;iO}qBtQeL zKpt)R6&W;cM=j*&_dC74-Y_%YtHMsoMNcXtT2`9m0nh*RvP}^JUDZvjb-6jyN0nis&T?coo@4;h`X-26gbKhFb zu+??8NB{-X#BsW&2Yo(0|2}>I`}eyjq%iqkS?*i0-{$wR>t(3U z$1i`<%zRPvvmC5 z_sulHr&mpxwOGzZy~_-gyh-=_yvVN~*u}dJY`AbuK#P*ff8xZ4_&w}+R0bV{e*(6f z%OL`2aK>A1li74K8fFKntNgMrBEx&|w_O=#QxsrW+~T=>YlgMABbv-J))zzL!Vl1D z)`OeO)#Y)Pi1!_UxXf zpnku+bM&K?c&)^4^72^o6cWhiA+zs6G}Ni~V&hYf&20G9J%lnZ*4VP4&q5_xzRKaB zWE17e8EbzLg4+hNNEYW!j?Qp4J6o`rEw7T!yP)6((-AsnW~luA&pVCtgUPO9ou8CU z-pAg4!FMns&f>UK_Z>*mxeT_UbwxkdOc_msN}*|>>kgv~}%xU0LWJTGusmGxX& zi=#IHA5NEm$ZWFL;;IkVS?bc)t|qU!v1x)50u$MoE=<|nTY;AIGq$z5vHvVELM@QB z-4VK6u;BPX+>yZ>wD+@Gs?SmWl9Qyy^^aE5>!w2-yCz#s1mB7IkYX9>Zton^<)F81 zNzQJ}(%^AFI4(xUId{y1nJ_os>BEwO)`4DBr|if8Msdfhp2O5qMJ?+0X*Q${7i0pd zULCpGgY5)BT{uVxz&PyFY05Y#b8BYCATfA8o>0TjlRpWRs$l(xwEAOj(??(l=XK z9%eSdMre@WGIrMdF9F9u)~wi0d42=9K=AK3s;!M*2f6otyBSgSSUjpyLDzguIQdY1 z!eyxy$4PRY_ogwMP}qe#_|&On-7GZtU`uB%9I-|seBD`c>j>fa;C)*k?N+?xggD^2 zRaUs(cBxyWztVHdDvosj{+=Fd<_j!A5UROe$`;&sran|!a_C3UnbIoS$$M!f9M#cO z)NbFBS6cCz;6W@c9pUMng*Le~t^*VBZp1ZYWxxrXmQT*5UHszrVW6jGbrbjhk#$y4 zZS3K@?yeVFTHK+yyK7rKXp!Jj90ExoxVELmJwb}MIKhe~A=oaSP-t-tF2#a7bRYib ztaEP8-CWMBHEYd$@9+Jdr?qM^aJTYH8iQ0S+rtdQE}MS1cFbH$|5Qsr$oLLb8m5HB zH~+LvtDTl1q4^g1e=MulXUgrfyCGbaIF5z<@IHTg{ABma?L`m>-_p5sc)Yv0+HVES zG6-+G2_2rIQJ~3NrDWVC^O6RciJ1g?Q)*mcW}$iLA@W)()Ti{z(?i!*g`vuei=wje zqSYmpWo!Zyih>7O<G80pJ&j{j`Gc&rjFf>h)ur!q2atmW*3zQ6#%EaFuA-q`&>ElDn+SFl6Rrw(jy^j%r)h8JOX_ z_78OI8CU;<6|T6OF2eK#Ici~bEHjpt%Qjpu{AY6i_WSh$Vv>0+jr6+{m#hb!15Nic zT)v%Ad8K-juXxY#r6jGUpO9jK+sj~Y&CEZYNIwn~(PHf1nitw7_jfPd;i=XC!aZ*~ zm2y`fxAvfei?&VORbEs$o7n_47HF>8(Bn<(upwZaG`3da;AI1*`N6miE!fo%0;oeJY8m*wVF}3oPCw<)nfy-@#z2cJnPnhvl}Mzvxv7)tTc<{%*(M&Dn+-YeQ47v zCdIaP-V7Ay9|?8d{opDWmA?uAywTwxs~HLo`%ME0onLzX&Xie0ks$#>&*712rI8yh zEN5-wcjy9KW)>=Ly2ldxo{>y6>-t^^EB6|L%u&u|;`>Q&rMZ$#$xqbQN;Q)&Y1j2z zs7FXoyY=96nHgnJh46q&YD&-q`Gcw%mque8lvd4v1ZKEH$1xc1y9X70*6@jWXUzC| z8fe}m+S8*5D{b!8$%GmAZWzKFFTa$*kM)fLG?l9*#}gWLzRgR>s!iWZM;EG*2#yKN6Fp`N5)-W<_6I<()Ze z*zC<>SNZ)jNCrN+6dziX4d1!$aV*noY(92Bv(>zx^u+bW_i7EXA|7E%YyvlBptQCY z#9Ez8HvL~3G(#JR0r9_msK+n&%NSNK6{i2YQxPCkdHa@Qu5I|9R5^ejSk}R!<+s>h za}CUW$?PotdZ{kI(Wgr1k`X{RM7F>mFn#QndmwKJ8VTI7{7~sGRPuA303^XjEOKBT7$__~Y&X-Z=qo=6mE-M!>W?OC1Dlu?htp)K ziePK2_|osIVm@+AWadAeC~~8NAOA|Q>>kT-b9k-8d)MkiS3s`K2*c*=uPAevx|Nl5 zwfsCcad*$Rm>O%V&7);x3xb98@} zNV!B%xS1bU2D+IP!vn13t!pckWx-MzXpX+AGDWh3wZ5OZ6krXdQ2kb6<< zU>^{69=w7BW$}jPYYUrhpAwv5edvj-h3I#b7n)60rFi@y`F?s!&i-AMZiVKi=l|dVgi_1WBAQBHhf>w*Bji@-!{SED7!wN3 zkIGIT1CW z`52m%>L!pIiGAx1n?(g~uBQ$V&=8 ze!Se*3SR!{usAsA#xEpl{E@R$P^en**&O;)7ev58bIirua8uZ{#WRc6)3UcVUE{-Z zwkw$@NymApvuc>I#hB!;^}tJ|D67iQi4C(m>3N;?33eR#dzC9tW;-Utv$j70B0q|v zYDC@xsi|0OwyFYcbt&W5(Od|2VGD^5xAUxjlwTGWiNQ{u%wbB7fBT^ z4)$|-6100eE9$R;)= zJmQZcP~*>wtPi1FiMA|Tj)W?6pH-vGSYgJb;dz1Z(w`XbMhs=T(kk4uY#UM{KN>gm)|7f+cG{vB0e1C_=8Fqg~Xr4iY1A$^t>Tg?_kZw8ul!ev9z|1h&C_q zpoxYIx0J$^8!JD*gX>pp0GxJPY$~W>+KRVK$|RRNdxA4+3wogH?3!pSY_NmVDk^rM(kZ*`~a*!ne3t3pB` za2mEYx(xDZP3q{5b>11+8UQ%sgjOS$Or@ zAwe}JI>k338?)8Bw9S1F%S@}La%VCO<5xl{Y|met&9(1neWTB-LEgtZ8`>gI1a;1p z@Rez0?Zpg&@*PEM$N%9ieo8`xLJ%Z+{528>*m+r|C7dqlG-TZSlap zsjvO(<{BcLuxFOM3k;t$c9%zVVB4*}>iad6Iztj-M1snZMrnpJPbzFM!TFNr?e45O5=PQ4VUm{*P69nzp}S$ ze9e+LkV;Sa-?glePnKyj0RJ6zWAK++WEdms%aiK~c4Tq^@8X!P5;D&m#j94fEPR68 zv6z|Vz+76r>8kP%Z#2K?3VJH9nVQ0Xo~J9T;gu)q-fzn1WB?)x9WXd!kGrq6LSwlR zi8B8A2Wop8An<4fxXE-dE$~sC6B8OH{if2@h?sx=R}sgyb#^ZE`s?M_ZxkS4v-483 z25Wp2fBw~xi4K))nya!Yp=K^GJr>;@s`5uiSHCP4ze37QsNWf8u6N6BZK6TnJy*S) zwpMc5vUH6Q>FUO$-E5XPNs&rs(wONhPb4&r+`_EB|E}kei*cwk_Bw4S>l%OB{kr8r zKlOQPL^>kUD3GnYWTLr49H>_{QW{&pl;7`HH)_H4Jt2bSfyU|E6i8PkWzqS?=ldy> zjK*d80I7dBm1B+nyQ4A(`wXlQ2)m>fEE4@$XQt0FwGw&1hx~G@`VQp|D|Xfpa!WF2 z;tS#o#_+VHcNkILyzn`zoi8Fu<qocu`^put~If{5=Oh52j1=uToz@fBD;<_ z0CN$b7xZ)A(9tc-KECkcOuwy{1kI&Yl({2)3u+~Gcs?Vv z0kx{)p$yr(m!fVjI?sa*cMligaG*fZ_W;U|rw_Btkd2+jIpk$`|BgaUmL>$6?cO9k-hvD(L? zHfq)_rwmb4VD*xtnOjlmZ_j&ab&|SK-Og8bqG?s~d+}X>rv=W|w^9{i^{uh$r=^99 z_W{bL*)w**05`!{Yebj96Q%pLb6EFV=RU8E34FGB;Om2@)z}Tp{e-skpf-TKS544f<_~Ag_4&K6vzSh^NJtyA{|~Mt*SS;vx0Q| zM~uGLMkzD&k0RO@#Lp@g*4FBD#&l~bJUHTpq+&FE-7(M?dMf}E8(C5Pt(JQtHHU_J zdc@GN?JeI+3;R!RO;D>oT?tqY7SxdBWvMT zATH&JbWLF``!tiHWrbbeOPb|`L%u+0q*+PPLs#dQob@>M#Yx{0Nn7TkkB5^ck>4|a z>=|8?CZ0otn&y^r9rbKyFE8mFmI?D4u)QfcL}P+P(-$o%YVsEJExzER6Xx!>ZJz}6 zew8-8WUx~;S7chNM!A(&az zEh$k4H^kGF>^nUV2)XApquH>nH)-Wc5yax)0*YRO##Zm7o=V_w$W3>5(= zt%+HJd^AM!-8XE;iTKjRjFWI;3?I5_vcA4*Ean0&***6-;4MIG2J_r`Q2a!De%I&{ ziZ0OkGcda0@qS#me6zf5uE>datXJO_t6)RE$a7_xZ_^~j5YW{DB~TM;dm6Uw5U&iN zT)@Oi-)5PaO>(W^DTgfyw%^bxTS^0N*wtw45M1( zLWRx$x`&C3#lx4E5akz8+sJ=t`K$4u`E63CTIbB$n5=`*pW@>VG1G0lmH(B*`i1YQ zlZ}A}YI6sx&kYK|i1osI^>@cc?5hFTwZtKWM0}euU zj`j9n5QSafd63=6b)1JRQ#jkUdYt#Dt9D5bmk>B!^VPj0GgAMZj<0<;=_>;ToR*7v zB7VH>bw}KFE1wMF3f8lU-tv%L3k{?kxA$&|kr>e41QM~%X?m{DTBwOkWnn+1I)j$QaH_zDo}X*Ye(ZJ;BmR(S%8{Jx(eVjb91{gt@2Y%iKOY z&EJd;)$&$_pWae&hRqO(EuF?7pLwoE2$zS|Kqp}vi1ZLBSGL+w-51HJkoXgoFKOu< zzvB>86{X5a(AcT-&6VBefAS~HHWZ_~EHZU_L~T##E#Q-5R#CUMIQ=-uhI3K81g?%``tSL#dG9IbMwC zc!~_Q>Iim@eY#mJ`BniO!-R;6HP7dHnX$^+WaZaA$UF-dTOt4j#z&1{OBvejz(J{z z%S)|OCVs`Yk&_Cmquc65@D<3r2`GF0lM04?&!xga7M~BQcs}S+)ag+OVQV~I5XN(V zvpNrXR+#XmBsl;Zif(u*4nfpe2$+(~F85M}k=xOEYx3k3q?)yk(-N6q5jRS^aI1H}xXJkD49lYIU_p;v%GZ679}4kPW}RiOQRHxvVN9S0RcAR|QVu)!}$!sPAp zuB(Fpb1zMPa@-)#hWBb!-b|GUqtBDX9Yy0;`o{regcgkzTLW&lP7BHXgaV~JyUTnN zF3vLU%mS6RMAuPf*3yqZ1+Nt2q?###OUxwYbg>Iz=l>+egztbt1&kSREHuA_mfs6B z>d~3$w7yZa;t1iK_BWsmEsFBydlr-X&*_Jm(;b!vYy7_un#!DrM!ToOWlr>IAho}! zgAnPzL~`{$s=Bn>eeV~+D4hWx`R0k7wTQ~Ulk(wC?^>OEWWc^_;gN-DPf@NPl@Iz&5sM=_;QRotl!6 zm_q%A4HCf)IxvnV3wn;^!rZM}%}z5_kA*oqG)%u?AyQ-AFec`B zE_I^QvNUh4{7Wm@+5wa5tph#AQI;HG#teXU00%;lrx7TAJ<%7$qk*O;`_`#su})E{ z`xUQpa>ZE{QYq5Ll@sevhm7UsmeNopA7mvHF0KuChX;kqz)bU)!;@#eH0;<5_9-Xn zbzUguwkAv~UcvaA)=2oD+>|qxO-YV9!YRfG)J z((vNIoRe_$MIN(i-%_3bX|{p3^PAlFWcF%nAK9lRJ(ZSx`(OnRSyE?B9sAcSllTVk zc^Bdmai5@3dLl}dgS5F_^aP#0Z?p#v(=~U&@}2*DQt^}Ft@NIf(F@^g4QVegGwcbO zg#d`fQ?=wwJYC)qAZ%UH zur-^hgh9D!zYycp*sd=dXc$&L_YucxN`2Azqp+fsEv9bf=OGAy_Q5_CXa)P@Z zj}X2*V^jgWLonUkNL|M9IW_xSKma0~{Z5}vmNCk9N;%=a2IqenAAO<++>RjUrb;iD z@ocQZDi>5bllNCC7Rzt&d8^y`hXWE|K!>{DUpEO*ACq>j(TU$NoX>nndy7oNMOjtu zAT&hEvp$hpU*0`pP&e*hGwYZq-Y4OdpigE-iL$+WRe^}6zqI|xxoW`p`{3+a6R$Tc zjPcITozi}?i}vG4Pt4C`IsUOJ{&1l-0V<3;%q=q-;B#8CCUf^e>%AIq2}&Z5Z0Dl6 zf@I5srKC`?PKR+n*POEeP7t8FU=qD=6`&65KE?RK_H>J1;Pe+f4^*)lVNOc|@EmOs zAF~(0@3#MJE$Wg>djEtk)0UK?E6ql2ly1^p7?Qy};Y{}d#kj-S*Y@rNU$@#5Zx&}u zm77!2R9cx6R`omg_)v%a)R^6L#oi%2n+!CqV!B zOf_KQgXWs21aiXG`~YT`LmaSG!W=3sTowLzrw3^2${~l_WOv?Zc=%Y@D&FnDWOC2? zt-%d6aCJp!Xzu?IF=O*z%cOV{ZD6rwu?nO+6qp~m zWKo1!pLSF@uVOw++{!R~75&dG=YT%q-=6<4kGmRMT2KCt8gq@s=gaQl=li~=+hA?g zc+xn3d_Kfniu0cTj2Pf72+1w9H4=TU7&Q--fXoa{=rT5WY*n>s%Te2|Dj_Yco*?Ni zUY7~g2Vu1-U6Fh1WP7jP_sefO;CP$hai!_97O}$La|a&5)*=sHPT;38?rMx)Z}W z15Qeaf*Se?$@_I4xfEE&M8+z4UVW;Uad4g*gh3^f5oXj|NOM;&gA{Q)mVdOI2Mx7F z6P+$6#&qRkBdi0#Gs=G3rcy~Do0q$%1f^LCr)6N~-f<8NKFESHH?wcyR2{cL-`@;Jt)V5@#)UcYRIXnh<2fLp;VcSHDn(5#uMT1bC$v zD~;B=F9H%Y>K!{Q;n_z2j;6Ayf@FSkcD8IT2hZnvhKrAzSGW6mpjuKHgVChb4i&8< zOCiNqKopIvW#gZ?5-CQRwD49-UIYHC^a(}Vj3Xh`NmY8>&CH5{uqWJG-)yakSyoUi zvAlK+UEikBw@~x;^22|3s`=w}DC%Gd1)49}U`Jy>@qhR?H%rPFjhEWvi$qtGgt=?5 z7u3=_z7v|%+VUcLUbp?O-Da*IU$N$zXFv9+jsCJ#_VF*5t!H^kesRj#AUd*C>Tde8 zy0b+?rIH7E172L_g;$#N|1|>fg%yD|-MzODWn1eLGHp3?YPl0GY%>```N~%Rd^oJu z#={=}qQtEZtvjT8{{+bJLxrUeS+fx{Fi>}d4!%4prt1+eB3iuwP$rq--~lhuEN(h1OQ*&lKk_3ee?h4 z@*jO|S(Ohvq+(y?9*|moxLTW?ejUF1^_AQIiKE8ee>PTp<}@K!j#9`aqe)-3lwn7k z0c^LD3uFnmjd4m}&E(udzy9&{rR>BJ>nqfSIXNdZo5(Xfq9d;w^KesAIW$`!0TkzjpO2_ z&8se&kyiWwaqkc~w(sa3s0WzS4F9N_wcL22_tlbrQ-?&s_z3tmG(u^OrY7@gu6^pS z*qU&cXEi!esem!X>ZdNQ&-Esx+Qn$C3#ej_w17bNhu<0oIZ)%I(3!qAv!x{6>ee_o zarYAa`}3SbxlAQav1bYGSiR@zeu*7pp&d@j6wWiVcCZi)b~ zKG)BISzFKgqEU8;UHQzOEM(Fg3f;sJ0rOXW#0(`FeyZZFrWCvAYF8P)Mi&)=sd{db3+ z8k8^tcABdAA(<;9JFQ>-G1(m0IR_VNp_y(;b|o>O2!$`;ahd|wgvq=|TRA}RI3YN5 z7JuX)>Zm2mh2>B%aPi`Q!ZENK%b!0GQ=V#j%P0%ZMhE;IULQs-T%?~*O@}7G+#+8l zs=RrJHU``es6M7{d9=b`TKH`M(iSglZPj*Kcos8k@du|_T_32Jj;XL`%z3!tl=dnh zxSPG{Dztl`&BST&e!663$0h8ELIC87=5NtblIX@KmYdGRA8OV9v*yV8p}QMXYD(Y2 z_ja9tF8SX5ty+6~zTz_Hw)1faK5_5PGE=a-Z^=DZ=}F7$ue0F;R*C0xw?H-iluWsO z<3j>Gmt}al!}z1Aj18NArD;{oWCTcf(BYr;%aU*=jY)JFI5)>FI6zPXc~@eSVA*2`*KA=sV4 z9SVRE3mk-(=O-41Yu2PXlNc+VGuFwaXYSUKT`>e4sUg=o$Aw!i#mhVbMl$QhyxBa^ z-?<2CF+{C7PnEY4K_n6#Q{bDm>;K5rVml^7eu~?T`MYO>`0aMh^L&S^CGW z#d0QL#lZJx#Q@{#v2J zo&L#x9q;4pRFjAZVfEF4EX=+R-L}>lfSOcN)%A);Q!i+~N62V$=DM0pg06=rL*r@^ z;|#&EjhLbicvmZY(?+Pfsdg)Wc+ave9spPkU{!WsU>B0nVWS<>iyCyI|iaxp;`rTW{$%I`J z3>qs?eLWeh%y#%a7%9ZI%*Z%q&A+Uq?)kt6n<2pe@ne9V)I&nX(dX6$Ayw{&hzx+d zJI~H3d}mm8O|Se11Z{^sc1G6KhrKvGeZ3roH-{U1q|l~I4ibsEB76omd24ut+=S_N z2!1`S9sP}CNw#>%Uc_sUe>aA3`4d~IkwA!pq?&dSR`c+4fmcYgUt2|rL7b!0kW20Y zvp>+zl~gXPwicp+s;8#WVXt?V^WPe-1Ji9v-XPw?gj7qVw`(y~Sm%9|!Rn+)&zO5r zUs>WxxO<5~p=6c$;b5O2(gUAV97P%FHc^i5mppnTSj+kaIwa_v^Dgi&bEmSmc}$H% zT)NzrxQ93Q#=jE_(!=nu$9+t`Bi>8P258Ai6WfM&tKa-7o1Y-5@U5R@H- z&W-zQGlI>J;3p)>Su-nLogKOaVffy6AQi&&$*(yxt$nYU2D3j!u#|ElG?|w%#qM@V z^Be?%@l_^GE=UKDR`Xyl%`2*)T*>Msrv#wiPTe-rU>>Q$bI@sQcPez&L{S{j9@)-m zOVBbly5xDY4saTxf0t3}7<*RqF6*HuE$Ab_$d^%Zqd} zf^itM9uy6n%57qMi194FE1o(SYJNfZ(*N`@&s*-!Ta2`YxLMMQZaf+qs8PRJCbygo z2f0gU&qBgTB~(On-Dg4|Mp7*rqVpbn{Zm<<;h|& zZTH&+G}!|#?%R(JX#Dh0bhYyUcWk58V%Vr-05hOQU`a{(U6#2y`@WWq{ z7Vf-Bg69>?wkFwo-R$f(!j}0{m0tJs5l6olAmQ68x5T4Zep;`Doj8ZWDg?!%N(A6y zvLmN4svPtp&AyNO%1&n8ydF>{OqE$CqRNexyiI(+*=cEY0PT>0AN7u$7w#al2Y<@I zD~exPa^k-sBpdkk5gbe&jHs%%Dc$8mQCFI}gb9u8hGEYFzdBAroCC7Z>sr z6bzO;SZ}$YwM{q^Hh1d7ul_Wl<0k5*q;YiD`S(xd3NIoc2h&qvkEzG!rZy$~o{>Vynev+Q;+p+t;3wkeEF9sSBQ-e-zgwEjOnutk8HLM-dx_l_ z=2LD}Yvu!ql44{%moS<^DZ@T?5%;<6Cu3D6*;;%ZAP+(Pu&Z( zoEdC&s+koSG?F>iG3)CP6s_4B4Egq3LrjI1?kAi4yV04FR9}$wzb%B%YP@3=tI)7X z)6o#>syIF{8dPk1VVaqCD)5Yp6#h&7<)%sg6REiiKe(4eRL=!I=)9kt_q+otwAdzpJT$+CPLlcP^XWSUo%Q^ckp$YcMC{Hb=umNGN9^3(EhcC%gy^!KI z^Nfw{d`w|LhKyvzjSkALkVP^rO7;^I+_~zX{xpGddPBWS1Bw=yp-|MDx$^V#ab?z9 zF_NEtRh%)h4UToyk|$N^ufeU~OT{f8W^`D%h}hU&DX3j610)xWXrS>OMM)JLAh)$wmOA+N#kCtTIb#872pv9{uqyq46<#OF76&HesBE-t7Y*-rm{)>!|0Yw5|P3+Fq zNSL;@GC0A0=WRT*$^i4yWNLGNU$xNkH18z8KVQHzoKj81s?t`ojxk^8)Ck;$x`J!^ z*ah;Gd`%$!sI(w^%hpSYhZC#7Aw;qviYgOJf$3<}5Vm;v=f*k$O;0}R94X#(=g*V| zx+0hIAD43tjdp;9^ks#dLk&vAb^7EDMO`K+pX0?eUs&+txQ+fwx`FB)eh09cf1j)2 zPex`1wWL+eQ-#W{W}M&N&_W&eT6ZA|aas!t>y{cjAs<%X%q2;Bxjeole8{tcCs#;1 zqLLvSedP{})u3BR73cuj`kUY9w zy|pPWXhgVo>Dfhxj$mjGQ({aaBxfg?KAM_ji=U6vD%P%LhXaWKWm3#S^fGtb=dS7n zf@nUC19DdAm21icU)_0~?F^NywpyIfDpR-4nd?!4jr51fh8OS4^3WL#UE;q#8%Ua* z(5jNzIa0yO*6Y9bP1k`#(b92Dj-+XMeR)9{_Zx$El9bLKqhgBVDro-*+ z0QN+hb@tcOI?ascx*&_XssmUYcEfrX>`XV?JCaj;m1UM>e`w)$lnZ*QNEIA2d-W&U zJfMSUER+*&x^;0Kc)l0UC89TY)IZ6)@W*QUmHM`Be_wx+Ufc%6B2xkRs(()6jViiw zJSad14DJbBTw;oqS*`jdkb{17GPP({Ok-bKm0!V^tlTrzBv81nA}75^4}R`YP8}05 zp1z!uNyap>fCLwAN7`yhO=8)ru1dy2@tYbn`dhEzV82J%ix+%TEn33D8YhqTcU_>Z z18o5}7#*70hny>kW@URWLR-p}y-Gp$k)LA8KxS_jiho;TsG-=|4>>78O3`+zg~Lvt z!~g0vjm!dXL5oASp~DCa|7q>-_y+^in(Wz?h)5dZ)Lg~!MbGMrO00Oc%h)Q6)s@+bT$u=VpT(E_3-u-qb+Q&Z{>?z~ zKBawN$_b8u(uSYC3?>|{5@^}J!`HQu^^xz>v`O9&kEm+MENq>*8mz6GQCN{VqC=#E zS7@Hkw1~9%qWra4C+q`+lc`^J~6XoYE}lN zo%wIpm{z0QaL8W;B;6AOXv?X7wU7ByML!Zc5kc0G*}Pf+ZLM?)syRS zv)flL8Q#c7d7@M8_r~k_qaymb1>?eou#CSHB0UWs`P92hDeF z72maK`mJM7)V;4l(@d)%-G= zJZfJrdKRt-i}Tio6>oPq%w@}Aa23kb*uTzU(t<;a+*-FTfp0hI0R}!DeN(x!K{vU7zU4sI~n-j@ZGgTEUtd}+yCoTTwSxbMwnNZkx zNu`}QVSLXz&UCW#)NJFI<5G)hfG)yh_>CwdMf~AUv}Mogh0F;EJdkaAV?dE;?Au1h z($p^3>E1{=V`^2gs~e@iS-qwStk~`@9p$757o??ZcX=x0^#*?)*f?s$Wvf3wH~@Y*#oHrH3MI>sQMH z^?TM-w?ir)-a_*E${uc4OQ-HdGbNojUt~(x`+oe;qe9D?DEP4`vqnZsPV%iUoX|w?r?T~k8nsM0)a6Lf@w)q9eUDxdXJgszf2K&i} zb^PsX;ZV7XSZ}HfBC#z@AU;aIbaMRaOoIvR?NfHl|Dad`k?aYr17bJ54e#4~(rlMU zw9pDotU#R40z9bvWDnHK*JghOgTXs?T!L`)vujj+AD)4aQOK=W4c?Y%sMZQf95K-f zf12~Tu#iaE>sTJO@TNT`Qn)1Bri78zeOukl=ch$gIpwMymH1jPxcb-<4k9isdbO*&nAdObXC zIVoAiUiXss`sEzS+18{8s%%%SO&R~qXD#4jQgc1NKzRJZ4!HX3M}I97jw%fj|2fhK zTWzv5V|H{(-9BNvhXlU5mt>l!)Gau`Jzb18fT)-o1+%CzY5S4EYPuV% zQ``Bzq_^-mL`RO@+=#HZ8>lnr8Jk9c74);J^aB1&X8A%2-etTV@C?7H+Mr8C$SBHQ z&cn1rRKBh>Y&#>hC$%}sQhwgud@26bwF9p9=c>B*Y-JElrVw#k;%EaE%5k+UUHTo0 z1TI(Hnn$w^z00Q~iAAMfPwK2fG!={}7_IORpvpbQMgaq^?nt;L zB6H=-Ph6Oumg0;dZ232<;9RqFIQY!#oLX`GVY{nxHUpEyVC(_cAS6>c-)$?bFu05Fv8(Wjwm7dPN3}L$D8WG9oxGeCQbYf2~S1F@t<}^Ltos(Ln#DW&8 z4QZ}7gQ+kHk6!j>12kF#H+j5kOw?zdY5K4ECtiG&S>%4m)zShCG>RyxPuxKl_P~Yq z+D<~4#rp?n)1WDo-L~xxJWT_SIMo2w{^ZsnTKe;w&)$xtWkw5K{C8f3ym$?w6K zx|mW;L$++>r2L*>-T%8Y`&pCl+*#+8TXzS&zXP^TiRnP*Fc3eH^>C@ zU%}K)4=OQ(2vw?HhIO^Qo`at>zMBX$-e~b%VGU_Eu1BrZ$8>&QPGp&uxVNWs&*IGM zIVXt;o;b0fryfEnePv63OKmNTAV?UE$QbCO*N({RiGlK&P`z*`Vo9n`4-8fMUI5GZ~feLQqq5NUkCIQAPyXe%E6G}4i-uOyJK06L$Gk4 zO`lCefOPoql7j4|C%V0O2WR%;ogl9u)|Wr-0wwhctwhs#kos0ZwV}1SINv+d0;R2C zaMgG30e+8+N!^}jHl3oFCobEnIlaS`cAmMGUxjU?O;>)XGh#72HVPLLbuZEbBrT*Q z^qfm4d2mwGeMMqU`;)>p!Hw4iMpAE{^mCOmstR80C;%yKW}OWsN8YM^j?Q10wxNbg z7%oq@99>E%^p0kdZJKFc7ICI-ivKe5E*2&;?uA7P7AvF)m;SckdDD>lapAP-fjB1J z(b6@}0J(82c!0@taq@T?LHV$c`d>WP3m1bgdX7-bZ%qTV#Yk|XM{wC5-DPKq({hHE zgqpAa8U7~w7QC`~1>F&->)fd2H+(r`tE2%}Kp zrY5&4-fpY*9AagKh}$h5IlxeUU9s@R1u5OjY3e-97H`h@!ewn_kJ!?|f&7p1v;%wi zKvTOnfE#PkK#`=Ix_?%JL;tF#Z7|QgkW5@{*j4lF6XX&Wvu^c!K-p)`wC_= zv%&Wc&?|~aw##afl1gv6wO_6w<$wDO6^cJf66bCG#DUL2V;x3>O2;2m$A6@hA|_6X z8V@y-KQpg$G^}#>k_qt5IZ_J+pympGwpN#{4pg_px2U3+6)*BRsz}k^_J+I>xEVBd z(YAZIefybPPdrDiHIu+_$5;`!Id7A&z=>U*)}Pv80Rz8WXQ%o}3 zS~ZGe)vf5e8OOUvQFVeuMGF^9w%%!or=6Mk%3+yplyy@Ez?=#ZYoemm$(QDaesMqb z-t!2mi%{vvxO{N z&}5@Bsm8)eT#Z{3f9Epx6*4qF8!+ncgOh(7+XYcXJA^}jWD14YZK#8|(`pMYJ^#DI z{icl5{EN!aw-skEm@nUb-PwLg;4Ior@P3iqI-mLXvIl~+=T0j-XBxRVO&QI_e{;JH z*$0%fAOy~y#^oyUyKkClg(Q4aU;e3*IRg7d`?&?>>ki5^EQJpm^Y4$bd6e8zW4jaJ z_NB=@RM0wq{%-B(zAcr7+m)4nYXiVQ_Z`)0h4=gWe3&xX<2wA6f6a`1u9ICytyoz>A*Go_~Gw z?B82%5bK6ZRl$VRU*pP*FKwI81rpZ{_gNxmsp{(24jlN}+)nuMK+L>rGx=-ea zgw{xgmmNl{z>}z5sRfm+uvcW&oX2?{@3;PanB~l~)hB#?lbuiJ?bE1#di5`|eW}_ap1i@?fLmC{_2mbPyVzvE zq{!E4?c)b5n;-o}{^moZ6U;Kz`NC@mc>!bi+d1Xzd9T3qOKAbWEIQ-~YzF~lna2r3 z)q->9P1|W-@-HGZ$hPsJ@oPu zH4DGaipF%K4a>AXMJt@3E>#3kMTT$m!pC|Xp3{l4+t|B3bm(R_ioh1oDi9&Vt*rw5 z3hJ&8VrOgNcs^|@OAcE|&%1=XOOvG{znvm7I@8Ok58s_}^(`gsbsL2U-n~<+!V?Iz zYOTL2yngrN0Lx^L;^&r{QsL~KW3h2N0HR^lzEQGD5n31pyKvojW>0Rub>GD7m2hjP z@!AC)eT4^u*d^u9$GlKrD<~o5f$VM>ITm^)%G=0SI$&McDA5h$efH6QLn0PFxVw$3 zDlBcP?!h3RDSo2qFlz#`{YM#j71*6K{xeJg(e`37rq68YjvZ7i>vFu33z?dtlf@73`8Tlg>a@13Y*IU5&<%{E`R$a=lG@ihh>4U*q2zpsRr-d|@IW1oB2 zZ_BfX;=K9Wzr{McUxiD-=MGCWjNTZ#6Ro09R&9&w+%l1%7L(2Rl}5{L^%O{J?6+)` zSo1WRJH=G;`m6#S-ygx^bWO>JZrE}%alSTFunP%1^8IQ2+sZsJ4V}8I*GyM5V;~}i zE9M#lxFZV&1Ug&j1^Ghd22hXWj3#8yN6>S#nxXdoJRB(ejPc(LA~_8dvvHp;T?{mC z^#I{ztg&%z=n_50y4#k$@}^O8n?MY(xCnRxNG%o$aV5hKN?T+Ssq&^7Ht%48*GO4?pRP&VA0=)9 zin)68yAtO%R@0y|3h=P1SdvGWhXFOu>iOA2&rh7Qf;?UwtS15Gi1$B5dqL%_I?`Eb z;sE4K>{9vNp8p@t{0~&6M{nje$0PD2iTA(R(K{d631@k@)%sY_>tk;Lm}QP%oVQ7- zy71c~p(rJb!LLQjrDo-^``Wc1&}M2I+AS?#i>iK~;@}TA^}iRplpfaijcYVb*%|OYW@TAzE#Wap~ev%>WQw znW7Lpo%Fn?H;*hx-Y;-ye@nI5&Ku`CDx$*n@@g+xF<9{VpU<9UywzUrsR09*(zy4x zohNB65t@BM*iY|W1!j)0Sv*S6&0x9DD6VfU2NH_z;6{R@sjO<1lTxtf2F*;{Jl;%)h@*zP z&4T(A?p$i?*~$sxU%H=uevkp9Gpp~xZ58W&TgnMYoODUIU3_=)ZK^U~5O?}EYbtk; z%##UgUwXhK;+jEhZJU57kBO)*Pp2I|Z5%D7?~QL$kH{`{NC>M?M?*-a>J?XV*uOP2 zPTE)>H>XNlPm|d+%GUQs0!pLyA&|}wTkKaiMIFin)IL~SlTwCzH;Old0hA5w+f2FJ zHx%Vc(t~IA0APSs-q!ZcREE6VZ-+stS!HcM=k88k8JB)dR8vv(=gYnqZ3d3G{vY9S zEzgssi^8wd`;89w*clPO{LZbEcqo@uYmbMPw_CLxsDk#jFjpTCz2WRG_J{$DRZ2~% z3STE#JPu{=BFPADOX__e>$VYZuKP6ch1)lcUS(ihGwv_X3XMUHa#KDmV{=VhyLGIn zm5OKN)dk06S-%s}(jlALm=cmU7Rpvceayxq zD6Y)7*CVIxhB`YU+Y_2jRHPJqP_6bGYq=$nPV=Qk>FrWW>+$eDeo_-e81b6)8Ix+M zy0{ixXx|;>Q-U7d-V<&lhhkZN%)^Q=MG|6{08@^e@;6eXxO<+}SaiP%EJ2;`T)wlK z^>;DFgO{ayBQwe&IAm^hKr;eM+*`K?2j1k|hnz^4=as#bX@28##GR!$dPl3{V>j@1 zJh0??pNoQ4RV`$MB((Q@b2c@A7}#Yh;W{4!tlS=*bDWetO^3NGB?-SsD&9PKsSNL_ z?|RrtL=+FR)Fvai$5IoL1v7Z4&hm#$Cf}_F5zZGGszkI-_6MZ@a~C-}^MN*< zVn_;Fnk_umng}jD!+N~rB(-yEc-bi5#<{DEaJ2VBF}4X~u^(HOi0+$4MjWvouikwJ zzP#DVTiYxhkI~G0u~Z|+SnY-Q1HfG5p_nSAyfnmr1yxK&g6~&7;Xi%hhk?~XCTgLN z+G)exx<;U19&KZ;?K4+M85w1i{<|W0AOaPTx!-m%G#`>7z>Js1^+rj81k*L8HI6XS z3yVg98k1zb1O_g$^rY`tf6eX)$ScQTa%fdRWUt_o$Ng5zz1Zwcx}v&hNM~!C;Hhg* zyogS)e*kJvLu`I+_=!{aPiZFSJKON2$zN-tQbs#lf3^%QEG#TJ7W?JPDkqm?aQ9H7 zG^sMNZsW{)&|wg`xXFsDClBEN-7RMdRd0X^7 z^5a(kVL-WTgswFlb2ZGNSrA&Ci7&+`80VrTi+!@V&rE#0U=&@QeY?QkR{hVZd5=}A z*W6^iBVLyE!|wE#v?SOTEc^%Kv~P`ElLgujqP++EVq^~n+4xK^YknH%ld^agK4f=T zCJJvjTh;CboVt4SEEfqr%&*tI-9Y^^)s1&VdkAO~g( zTVze%`OFHhfL@ZxYpK^doo8mI?m?ofEGt1%i#tAz>p68&RZOkEg7Im?ORn*z;;T4Y zjRxClM9S1A!p2O*$)}6BJXL1UBQ$JpNW_n|g&Ueq($WM}maGPYb8nSL9aHfhf^Mzz z7F-Up2=6!bpqr4oP#R(|rS#9}WxBuJe9(4b*J(jKh3A*e89i}mc9&L)#>RdA{B~Ay zqXd9-vZxXK>teNzlYz90y3l}MKIG-Y3|z)u9Nav0WTp%KXdQKm>4;_gh}t(L*mIIa zU)hJ2c-zW;B=fhsFz#k<0B^BKeu18jYp{TiSN{@xx~g6CX|S_xQOMR%Fd1i{^Bzry;-!2NN^`4A3iK*EG1(7NxoAX8QqOdsPuu^RIDt~EzG#WyQ z*2^#(o+f{R-n{9?d>@!l*^3=RUlyWV7rqbR@-l<`(qV!7$D1SFOr9;&$&5UO=B6NV zBU8EcNu6Vn%MA6u-TTkTtz{Bjy;+8bCWj9OJ4Pn+t8wj)jRE~*oVlt{Pi^SvU9?|!r zAPkV>H)B1JI4P=MoNck+#Xst`z@1GPRwp31VGDaA8;%(}gvVD73YcWL4rsl4%2!nk zxwGyC)lgL-yj$(9xUI`rIS!Hl-Ct#UHJR3|MdG~n9k#8--%n#tl?rcL=5#F5>l8k& z^LSeR-#VOz<}FJ92Xke!(i(bx^QFRU*)D9cm1H92TAG`vDpl)&CTm$`Gv+&3O;;)Z zyYdhf{(yg3f-325#du|xEmT3%BL!RB@p;mht*M4D2@qtl;T^o%2LYB0l(&r_t_28k z@OJLYtM0ej%SER0^?MEded^*L$-yt^?vV7yyzE%_C#vBtTxVyx+Q{lrW7(p)p+pQH zzkFxgsgwQXr4ywvTds6s;1oS5>BP)R{yF{Q+)b!Yik3Iq zF4fL-W8%w~eo@(UX82y`$M36XQE9BxzLe`5W(5mWOd?1<%i`S@Tb#Ufu9)fkz> zvH%O3C6^+*wGJf1t*H;0mSr~XD6#Je5eyD6*5lW9Acw9EDQ*;EPMO_@qq&nym+e!Q zayo&hzku;Z<9pLrO$Z^MCStlwa|TP|)c_O=(V~sSLXYTPO#H~PKQ?o3igS}$+ zdQLlmOQd2oB$!lNX}Z)tjGz2>g>p$D$LOBrDUG?PJ&;{6wf=j+flBOCSI2c7kzn26IPI;s*5FT>cu0TzMzDh)ys?UIC-?G6u2AK>nZlLSx)^#t1D5)XY#UpPDQ;kgaKPS9n#SqU*cGezM&(L9Igp9 z{z+o>kJljv7aEOc=X2)>Csmj8x!A>Q;DoY~kLrZVrX0j(r3$$tu{p|DzQFxK-5hQr6i~FuJz{I9BihD zy6hhNqj(H4ygQ=-2h9w#J@uI8|G%=~)n#}noS&-ScrP}F9en8g%Wu?8=;ha#i zY3#)Lc_5GL&Yo=lqwU)0ee8g8aRErrz@V#>80@NSpnu}4+i!5$PZ$_ppv?bgLw0$! z_<-xDnletd&U+Asi;mwhNoX||45P$C_7|D9GfSB$wW(;AVQwN`twmdSKrJjBz_(u& zMmVVEWqdIYn!XrX^R(Aru~(2LoIVub8c%DD(g&%)EyWol1V42!k%^kED{;PG8zhe# zG$&4{6K5RnNRRbBNHbQ66WRhs{(yGqJJ0>fs2py8xB@$X?7GBx!`GQ3YfY$8<;w&g zz}P%g_sI+h!@bk2f<*Un?~O3IE{(Mn5HgBoSkob~nje}U+`nEUCZZ8C^D}|n)L})_ zcX_9HdjM|VCj>4J6y>qhPO!m_p@LPr@~@Y|u>bW)^Cxg}k2+$!ZV}})cCNJsT~te3 z__I(7j^|vZZTfCg&$o;B3^YhTGkRod9F_1nXrV=9=F2^{Xj9FBl1X9Rt}Hwhm+y2J zc4EdRnkOGEk>KrDk@RAQ)gAD5S(R2!S`hX}##pu6z_b^|Aw?)|;~BGo7OLMVScWvkFbYCYGm) z4|$-93fG4h0%W^b;3+z-&UzLD8joi!YTu{~09~5SH27`YL8H+PXeM{2*qwL%3VYnv;oZZud;7Y9GkspB6CuCNo3R+_ z{741ZR&e5vpCwg7wR4}6wujWwK_#la3LsT@L6YX69G!3@p9bAyIlUkXv01XuHxm>i z7F#S?X{k2trFvY?b>s~{4mNY^WIcqt2Q%i61)Mkp{HSN>gU3{dTyyFhJFNXWQSmvv z#=g{G$rmpYg=b3&|3!ivEDg6+Ocmw4qxB+J2^Kr&AHJGC*w*>61Neh{le)XJ9wQA# z=L8a}Bh8q*`GntsG}plA<%Il7-5bWzmm*c4_v<&>P74516~OPX_l#2wuqgvbFXk@gE8d8kf>oR#TlKvcAeP@j92vU3cHr*0xk8I$=dx? z1@RkPiBL|G!}`pubHJoTxzvvy*-n24&5)pJpCtdMD{L?fhHG#5W|RFyaC>ktTCl(@5B>@cEkJ&cr(`coP(B~c(}X;yQFcIISvshFx_A*oako#JFk0isQ^!b zprvb;YHrf7`;`;)br!3LaeuCwfvB@=Shg1Ccegyo8XDmaL4jL4UWx3jQdjieB9}{E zdoz+08n@dyq)7@Bm9Qcrz^7EdH(UZiU%BZjQ*)c~N}rPxJEsRK@T`F^@%^%jU0`|M z`Rd=oQEYpuzHFH7@{8M5W6Yy?8Io2~dlksam#VKDr&)MFw=gTEo#7YJy*DhNBd94> zvfaYJ%e~i>xJ_GJiEnPF>Gj?}X;9(?G2?>edB|D!{QlVW;eB^5&+*zK)z_`^uUB3+ z)PI{cEEZ_%k^Ufwa?yev`A7*@*6jZIX!rO{HW)d)HBh+9vM-y zzFI%X4^I+!)40=e{_9bW$i)ZF9U3>!cpipP$WH5L69qT>m@2hv)(kl5T*L|T zUm*srkV^R=e<=<5cnhGl8)9o2i*kUC4O3Q3jE;+9nfq&_f!(9^NTR&y4WY_WVsju3 zF0wQs+lP#S`IfIG#Vp3_{M9Lv7MGUM_-geFm>=RCP5Y3;arEF~IX5LNslORbSs-`p z^U%;lIw4)p-)Kc__#KYBOt9b>t{^Qf^K1bh?vGIvdJpk`BGrmVHB3?Ez{)L=)#<_n zM3zu|hPBDE{n(U2FEs$++WBYyxx)gJ_?B8g6JdN)uOi38Jj#0XO*_hB>P>gVQVhNu zNjw9>ldXXwC6fZ4n%i@;FuLuXU4&}ox^+HQcT~H;8$PP7;NzG%3m|jjE23wq_4D+hm1UwDdx+F zKVVcstY;=q1Nzr%$QPX4lp=MX=?Fv;=oFE_g$imWw`Z(JZ5N90?=NR8w~saZtxjN+ zU1sZg**X+rJhwheBCTw`mDI*1IHWg8RZdYB`ck}=rcyX{RXoPD8~UD5&fvObV#_hw z9S4@fAEQl-x{Ylush(U7>M6LNlzZ*Ln!=BS>uYix4ok#jX&qEJc+uUEwR-=xO}lOB zc*&u>6wfGgUVN^XS5g4lbgG(u%d%8k;>l*tR=dxy>KWAj@%>ua-HC~mrQqSjB-D=f zMM$^^$v$s!^WrtDq%>90Ez;Z9%39%JJvQw&LEbBP*`|(z_7V&I6b2M1_--hj_EzHS ztDm(EB!BQApEAk>-u@ZLXx}GQln9-b`3!L8JWGSf6h3Nj_wD4cVT+(a<&B*+aRGF65SZG|lgr zEfP2(JOv9N zIXx8yy=0b7x}QJEl1T6NMNz39>vkv3tgI`}H95t~7u$esH>fa3iLh)}u7E$Cdz$D) zV(npNw73ghl9md%41s>sVt%Pgvu|~Hn3sarWn7Wa?gpwr4B(MLK3IB>rQN%ig{fm?@-EebHY;d_ySs zis_isa773E3h+gcZh}UVXH??-A+|lbeb~I)%fBEOoGqFj? zat2`S#ReDp;z?U=<<23Jq=4jkSZP{QB1+5J*f{57MD!H^=ms}4ofqJOQW(6cUoZ); zh&}!h6z<0?JmjjX9F+C%N=wO?Y5l)LWun_QKqL0sxBY}e$e|IcT=gZ*TmI5jq;MRjX#p3Zf zh4)5aLSii?{CSlt+r~C-(@FvyG8pKNbWayfKeI4KR74%tMDV;23EWg23zD6?8_dDg zkeL0F`ZITOFEH_Xa{3_z&*F-ESNY8YmwK~-2tL}K`GTG~=3vY1iMgB6Rw{L-{t^A} ziexs?zj`@ly@^N?nAK&aMHJZMBpCBD=jTuR1D24gdUg?cSi366MQvUxDq@xtDlSdzr%@H=h?e zdsH(-;^taQZSMiZ79f(UH>%-f!e_I%So7+bnOuSanhuN?eG<}YX)i;EP zHpao75Ry`&ojQ6UVE+P_@Lzl9Bk3Y8mdc%2%{8z4ic8%=j>_cd2I(xwi~g*3=ZAuT ztD)Xfcf>gwOym~FgG3B0XgZXjGcJdRl74;-V!8<5F{D;^kDWE;VSKMxhF^IvRN)tMGo_-#puv*pA!@$#$I_@y1QyPJs+{7 znDi%ptuQT;@4xynQt|DN;QCJi>wFCwx%QF#P0Hj@e`4`e?4M#mq{b1^!TIwcXN8ZP zNMxSP-aP}$sN1oh-#rb3CoXNwd1M6!+-)|;@Z();wimt!r#qYmxXhy`PsBIe6o2wN zQo6@{Z`EmPh_hSGd-WqeqK`TJ3F14t5*KC*as#}J)dwlG|1Y)XI+70Pr!j?imF2_?SaS(IW zQ3~;3QSX4qYm?$%JhQwY^M=<=Afnbf$z7#bjI!FGo)D@~4{6ytvox>wT;Iy}+erd5 ztEv^ACMNcNAw%9ogN!D$CzjqNxI8)^JXTHmK}jkiF8=*tw%Gjavb(9}ZR=mIpFHmF zKIz(kRyXKPNSWTEthB1-^(a9ivNhJ8lxjB znVi>i{2kSqngl({Sb07wgKXHDfk|Lfx-A^zibB5jbBX`b_H&a?GUZ8uH5^2>YAnm& zYf%c{3^*V{svJ@hT#>Swtz#Ac&Dk6b$lr1YYc+CXOCIl8+7Fz+8aVTrLIM_+#x9v< zN&3?UpjJLyVxydmY)SD-+r%Wd2684B!{!<*o}3TMu?v=mgLEzRykUs?x?t^Blu-rxGr9F@sIVhP1K+`Gvjv!yDe# z08J0>K(qy&d5}UoA@QhjA(4{~y-u~@z#&L|pFAO+UIJ4=+{l70&e=e3l9CXpPRzm} zQieGifG9u@rWb}3-lyxa0L(0Frkf${(x;0q0jsVPP%%d|1bse)YLqkgq2k{iKNs(l~n;_< z3E}_C1^T&j;O?`uxs5WKQ0xA+cA=U&z{*>J;aH^C*7G>!c{+X+b;ob^rEPR2Y=Hz% zvj`MSND)!vtyP{Yl(1(oXn|J;$NEid?rmqh8r0^;g&3f(I;UgpEtz3%dNjD6C-4;1=$MMzZ#KGD%F`%6fRC1hExB};2e$+^;2JPm|2x!n?27gzZM zH_+^FUFgm}lUY_}K|P~m>*?MWS!=uU%b;yp6mRnU>_S2}6p)GrI?iV+AgVi$kF+v3 z*n~B`$)V34HPD(_yV9%MRhb?dMLg5jw|S22$8g%tleL-?UR_DKMv#J_9Ei#_B zOhzVuPI%XEA;i|eQb`7qKjLBJWeyTbKapQ`iBGc{&~+bGbzy-YbvQ6opWi-i8XJt%o)@2CzimV%NJwHwnw_$ zsA~at+75l=yr`dBMZB58|Iy}Rqh;q|AMPsrZp(j_KR58!#6oD#D`yF@ESnTpqikJt zhW)}mCT0n$&qb>1rmIRg+i@BkQa9IPu|@TF?oXioJn zP9)+bGoL7TVJ$aYL3Qb#CB{G?&&9)Qz9#{zY~1*x)>fekRLm6<`+V1eg}e_m*|uxe zFu?cd9@IIR`|Llv^`qi(H=B53QZ=2eu00K0#`Q6?)K5$B-b~KqgjjVvz9b;-?VgGO zB3N$X5^Vwd<2Rmx;}UjFbfJ!v9fq&&rCN5q=H3ge_M$;Bs#n}v)ATnoQ$GE6T3QMg zqfiFfSX>M(W=tTA11>7xxgkDFHJ>yz6*5NbKpp0CeZo!xjf{7HB^{anGM1Q!u2OyC zXks}9L@L71G}}C|`ND(T3%>x1I0X@7L3W{2a}GMAVdmi^RRK!TQ|SF=W0@Gw9a^ts z&SSM!i?s;{wzNrJq9sm+HNPjK$tGyDT4qA4V5t^f(W&rkLtWNyE2R8RbBbUlS@dJ= zHyII%^y!S9iOYQS-n%lO1qSO85IkqBkM?K2?{-O3zE7x~IZv@AXa1LpFP>v%IzqwI zk_Lx}uIeQg(pmo@{)2Z%yr!>8=DFty#DWdPqy&Z=aNA%+&&QkK%X9%CtW~FMd5itsq^FQ--VgAShev@^H+Mtdm>TLFw2%STy-0dIPiQu$}&= z(U2zAKoa=5-(7!TFVw=%`2LcRSk4_7Zi*K+Rd(st5cemfz4LgS|0yM^i~myC(Z z1v?{py$F-*O}-?L#%~%vbjBpp2_0snPx)+$JSU~eJRW~xtoA3FS_q)u6(54H`dYxfOzV*m8U~gDWYuO zesya;3ugLBABJ{)6}D+ky7|VnnYa~I@*=`g(N)5q<-`zDe@xzT8&GwenYWm!vTRT1 zVk>?)P?#avPhOY>^f3Dm38?noN139_Xc_QrulCx@jl$gqH6aRdC18E?mNwU{S@-*9 z73zv+cLkxRjX$>edA@lBXDZ-=ZOX?TbMY@1|7s9FB4^vvxkuvY>=M@pshoV``acH@ zd!1Vg6^%2A3Xd+&ZK#AQH;nvA&K9EP4lyo#a{9hJM75P`q5s#h_RFJ|p?F#usOuPGMPnqR@IieLU6yy3?f`sR1%`4DZhW-Z+|A_u^#wHs^5!Fmj9!c;=HJWb zFIw`hYw*I#ERmVc8>IwT3cBxoR5wWYKooxXl1-yZY)k#_VhIl>y~6Fotc;c~Jf_tk zqWZ3m6(34YYH|R!9%xWZQ4!%#C?t*u6*n2&TE)Z}KF#U)>`IU`4QmE8zbapEdNoGT z{0d(D3vEqcFh}+lLJL6#2eW1B@<}c_<=!KcDftTe2C2PX6EEsAI~k=+1v+*t-ln)P zRU*)>m0x{@A8m{Xx0?cx;d}VP$0CA37>|OMf|xNKg?duZMa^$V&~MW<+JvDbtn0r1 zWPCnIk`iPK9`_oZbv6V0f|gFcgu3gdSdtYNe>F944WcqzN(JPXmhBHfCu-&zh)Fgy zvEJ_o^BCVXEJ*yexd&T9zb#pH;Hw)C`>II#<}4Q=@9P}lxd@lht?OPNcl8&V0TE(_ zN$a)-ROJ%}VF7P172x~3x&!JLFNIC`Rf;jeS?q@oRe0ly-p9-Ln)8>I z^w@W$e~UjpWM&_zRQmwIP5amDCL~mf4&XELV|fKd^QvbSYo9o5)|PX9ba05K>2dp8 z0y5~QM~j%5^atq##0K;r8C(E!kr$O7YED|YFC+|5m|(Ykd;Ev>_YS|zJCG(xwbwKx zeCgL}pc_{deW$AUzUo4^%4tcHqP!WMnYF)-2ug7mbMulFZCskfkkuBhy)=Qf)%pI< zwsh)ysSvsbKIJ@OBb^6V^EG^m_bZY@Gs^y94U_e_kFu;qY@o(AZ!a~ zGqzz)$j+tZ{)Or@qmy^nAahA(^}^4VQLSDWrk~}o#qvIDiMiU%Eic9NRw8i_eg+1Q zWiU@ed6#_Tk&KSpWO2skiluylaJ14AbKS2oy}&|a2TEAH*LsHTcRXsL9IfP|>i)pW zGSZEGU4-d%jPOW+2;I$_-YkfOaJYNI2uM(XtY4PV>|HDZLbCYfP)|b|BtcZ;IZPYh z18eK(ZVRJqbvSJ-g|@Cnzg`Ubnj)=dpB~xe0W)bSofb0;Q&~TFSY}-4m}Kc&e`07d z%=E{K4ZKGBJ5;NW?rqR+ePp3c^1W!BEXXv`>%*2G0)6Q!L9l|A?;mpqe=!X0!?S_yE&n1rFRcRcv7h~|wS8624$OC%oo9W;qGhWdyh!3AN;F zW~$i&wFW6WQ*@T<5}qHo-$^Dz!9c0*iw3*nml@__PA5z8^xCQ!M4udx#djyF0kI&- zxomts?&qYOThUBXF#)SQyVl6NCdie&WL6Q9$hybQ%po#gk;5?d-SF~Yb;*AwP|1bK znFMnPQ6kH%4j$8tpFg`kv9?r$(bIl5`^=Zd;bsl5Hu;1G>j2XeO3z+{>eRIa;9Z{X z^DZ{JIl1B9j&;`I`7mRlT*atCTWk%j52cp=6_Rnw&zB%FCo`K6n%$6qY2c=?jT?J!3=O zto~xqhm7d3u(nUvo$7f{mNBsM@bV%{XmUEL>YB)G-1Uivq9;wvDT;D;AMA=L1mGeo z40fB#G`5e9-&zFefH6BES|#>F6z0eeez6GN13^08^>4Wa?y*#rHAbR+PD!MD)T<>F z_{U@ei0~x1Z3wOMxPVyn?~16Aqo@jd%E2^C;*4*)dW;;BW7p)VMY|>HtW^bn+F6~e z(Ni|&_j#|LGiDSHJ+*(%aM=(`^Yk|YMziQF^{5oe3zNhc_?ZXy$l}nMA_+_7q zf$jCE_(OMMi|OkZWmQkPO0T`McL;Ui!#1jMF!;{?yW*`?MRJ%yFf-#Dtjr&m$yTK) zLV57KV{)wZER9cajpcRT1nzAf@LteME{(+$k(B=L!dfzgLI19ZZ-stI8+p&&5~-h4 ze4`9+hApw({9yGCJh9x&zU5|3+ts}!Oa$R(Vw4E-8VWqkK9nrB=ZY35gQ}HD{Q=)= zow)z%qP`hze`1+q^ZYb#S=`M%P|Eg3X zz=pd+ztmvEde^C0OVlX=Vf3*C$Gsa?b*4Tg(_Yy3z#S#i)JZ#7k2OlmEchYx6q>X{%{n z&P)Dq-RZl5h?(q+N-JEPBWxK$SEZAGFZX4u4|?tW+_B>(ME1O_+Ku;~sGf?MU7R2L zbIY-Eu5wjZ@8p@BWc}s8t;2&YL2c`A47$>QO%0Hv*_P_^XF^_;5Ua`aMC#tfri&z3 zcE2LgkEGw1bbB-P@2Z#$nuTms>5A z35%6-i(HJxx4YF$M%4@@r#}5^-M(w%aPMPO%DWVw=AG`C6nfn08MDZ($;4iE;qi(4 zUjoj52Oa2d++8^6zOkgl-b7!T=xB&ZH6fZrhK8AkzEnwvJcr z?Hji_$hYb$M18VnaV$aV`>tsSGM^YvsB(&P{W~)6ovQpVmWk}+=fvIT?Hmdo<(AsQ zcG+^4J6mlNc7srH>m57xz1)ySs*aMu4B1&vz`Rl7i>)0y3c^0Hb?F`=Ex4gwh*CE& z581?kkCFRt80>fZRtj30eKdzNOMnHx*qfZVmpU==PITj>=k$$d<=i(zZ*y5>+YJ}wIKBIj;{4xKP|LuH zB~&vb8N};Vzg9Bl^YdBHYPM*4mumO2dHf${ebN8IGTgcFD!~&Y6?r+g&&VbT(vpjN zgAvE`OY1+9xi3{A4eMrd=nq{kZH7*pKkOzMt|hPq_>cTK4zpJqu>UL8*wxIOj>$5w zaa%$n?gP!^&-GW<7pBTg0ky9UgE=<)F{bb+p=O3WRM(WRoe7x z`7`CsUJMTMo3Yz%4K~<4pMH|!HDWTa4c~XhY`o#AM&Z8a9(3-VyO>aKl~WTqfwU1kSz>SVY{)UI2VEfuiPl4C2HX9Nmb`4UdET2jZK7V<%`-lJI2H#n{qs0SL>!|-_q;ho(S zQmfcA#(J9?dKiOG@Lgg-Ei1x1;wlK*1*p-LvqU^vjyIdH?QCN4@AWSa(p~wNs<3szT5Xw_dc_vICKue4bC zpH&ZE=Rf~x#Y0YZi0_3fFMdW(RAR2b$S2kdapm&uH%FLDrh6Ue0K8+E8taKcThWUy z0-mJVv)`IE@nS5&e4M?IFPb+%om-u%UA5a;IbKPa5sQv?JoTh6uQ%21Ky{XvW^)QV zpPU7>*}w7h$+32RF4rt~&;DQ;85?`zez&(iZfB}&l5?SkmDXo=m+&APh?na{7Gn!i zRN4(r)Yxp-h+xQYTlJ6C(&yW|S`IB%^CrJ0lZN43;t#hI6h4(*xjv}w+Cv2!WI>5| zoP>&(r(Elw`1>+vU#KmhY(PfUd_b1_tZ>+nIHRa>5q}81=<*vvxER0nO^f26@6?X6 zlziJ9bVe0}py_j*Wj;Cul)57WWqw;~+TdKE=-HxgiY)R%wXjU@gjXji1*Ooq7`*b0~g;Hs4ioEOW+AO+0R7 z-d~L;nj<|E$;#Y`(Q94w<(1KBN*`kMXIi@`+LR0MR(;btz?q*Z_W1TTBg(9anhh%H zk5hl)rAJp2DxkAE$vLbH)FuYg0EEjeaA?(ncN61};7&RGVzO-J0H9L%YZyGE#Ih=yPlmf13f`}k@h(Zb`#9+Q-0+YU=bGf6#hu9irB#(oyc+F4 z$v#}TXFQ~b=(`7xs|txQY!Cp-yLlKs%loaPDK0CP;D_KFV9bWg*|RN$9;1u*EDliivyXlIYWk_aKa^;*%KK>K5x{g;YxTwd=m`obM7&*hb%mP@ zyDOKmkKZ@pW_9%mtRv*j`aOF9fD$34}w;uUmh-u~B z{?AW6YOepzi2g8SBW9`5BYyO!eynJ5NaHQYx%@Yj3(M`WGi9_m=QGb)8YFLZOQv8j z0THopwC4M)G(_m!PovU%sw>|{jwhkn-?6_b0s4EpVf2B5q{0#ED8ZT6dZ@anN#LN^ z+A!@vR%!D0OLOh7ZqJkBR1!0dDrJ~Y z1i2xyggZk>16yV^-YB{s1QpvX2EDo?b>rp?p9o_fr6FsbYSY)qSyqpK6S}-7{bsv;w{~$*8b96)w zG{aX63Vlm!TWx#L@p}2uQ_Hai|4&SBM+c5ja>i?INm};!$9$db_XqQGz0GAi{|soUXy3!Po~e0X4S3pACMAA*Iv4w2o~~WR$}zw{Z9wGn^8PFaD&W3vio z*G`mrSJw?IFP9@KXUPmL%l=UgW@8nW-Nvs7opD9;DcotY7F0eBxQx9iU-rXMLoDc% z^c_#&f^HFM1DCI=7!Bk-%ThcuwXdms(}k~6EI6&SBS16WDqYk2Y_h>@?{hhUK-yh+ zR8w~qvHq~ZPnE5N|3}wZMYZ`xUz!TkC@zH{#T|+}l;RRRxU^^q5FohLfEI@Y*A@#- z(S%^dofZimC=lF3arq6i=4SqD&0K!B@6C74K6^h~5UhmL??-`_bdfmA*(Z8(0ppsq zBsHoFvXGuSjixtlauLM-e1{J2AvONS5B;vXQ&hpi>_@lka0q4}?a^K!e~73ngOq#c z7=HP693sAI0v(XE+dtZre*5R%_TWPFHqz(bub|%*83-&$oVQA{w1>|{AWs&wC{)9H zxSrxMlzKLMk)qj>;+!aoRh^Xm>{MID!k+b`;lB7qnsqt6J>VX&jF!6v125_O;^rM= zY8rC7o#@}JmvRa5a)Bipy-0nD84jXzh!iG5Zw}hWJnk<-FCcl`v34A@Ne{xi_Da3^ z3KU65JlK{ELjym|CJXX#mq5n+-%rLoi4*+-u98Laq(hpGSEujfRz3)mSB*F1IPh7h zm!Q*lMf$sV`q*sIHX8JNbSV9olxDmQ^s8w3o^T@PDOeUVJf3cPI!=zF;0EyeY4JLnodWLg zqm`KY2^Voa_^r;jszsS|OiB|4h28oYve4w&x1$DBprGV+!&H;kwD=_*k7r;W|DF?% zWTK9$vq1I&NY&P2v6@~n1NG0j+yQ~1fP7l#f8~Y;2q$}UAF@UmK!sf&w>Z1*|KtZ; zl|>-^qD=X0KL^E%PAGBo+(DTUGnhy%5^1Z-@*T8j-LvTuH$0g^ga?l-r2y%DLEE#ud(gsv+?X7#)6?NvGHeZl4Dd3$6 zhBM?;1&c(PP~4dwl*f9jfT`i6zS1skNw3s-YUKnPmF>(WZ#KtEQ!S@+UF}%C4ly_0 z=TlG}#z*`q%;FRt4Nr?%CrCYi-QOinC^v=!~@@q(0RSd+#PZj#_UcT z3RgH&4^%QsWs6xj4bwW5mhcqeTbn})^L%<6RqeZaxHSv0-j?Qi5AlXztSzV7x8kfc zD-Vic^ELt>jVN|lOUPG@UV<~J_e!OqyHNL@#rE_qPL+LWd3Itm?{>;h&2YrJKGB~0 zPZ3pXwzV&@(fW*Rpf>Hh`?*WTJN;czQc~k(4#lZ(@Ku61ANMM-SK0I-iU zSK*&*_bg+p^^rIc2t=lbetJRu&6f1=Ux0)WqK<*Ief_ zR-x^HYI+R=2tir-9@~2E^Nz?rHM}a)oIi7pkyr{i16@#T)7zI@e3iR3AJ@OMWja5j z0GF~#zmXf zMCHiws@PYneFJnh=0jP6-*gku_VLiCiUfUzAZBr2v-{B!^*-zVv|WiJH9^E0O<`M0gBn8R@fTT-|u!{ z?p`41q{up>Sv#u(?rxnT)pCJb4J7s;#mPpWj2WJVw)mGwFw29a zL*yrSN@F^Dx;_i$jZJJ{&%#2y_1M9~Z`JbJXWFe2!k0(YBm%#Gi!O5C zE^?gUAa;kYd#;Fv2STnZbL^KD>dF1h#1FFW z%Dmy#K{gTTdr;GT)r(Xf>5uRz_q~;j$~xE0jl#9WgIyN3#!m9~f(+x8@T2A}EF>m- zGR6BuxY=6tF~1X|M%3)R_v^1GI7SjERbSnINmdI$rQFz6&is@fSnXk4yn!v`d&h!C z(zjy!19mNJECrzXl@mK#096V8{@!Z}{9#R#5i0%ai?wE77d+r-9CuK5t5GGg8f`9O zxv_OyQEH!9S}seU-cb8zDg-82kl{b4;T!Bt$mR>71-%jCA@u?C3ElS&Zac_C&gavy z+=O4}Kz%nb8H|9Wbry-eHjZ!ygY+x%8upgj@a2H9laHo9k2T~wS?E{5M^&^*+HDwj znq~N$_<^tEuP(7c&c91_&J3e^d+`%Q9gBcd4>J|N#QDW}TWL*M4|_zYOsbk+mnrK@ zdSq9w*wLG4da$z4pbsxsaA<|`0&iUFe_EvT~4A>QHbO3ViNbqk0}l`yBsa_U?VY z&em#5Qu~hh+LYAvrx)+9r)Pik{Q22Iu4o)}rChJoczt5kE;cy*{LZ$S|2?}EHbeUm z_XA_NUcu|i{SE7$6_}>mGaDQ{Rn{W8SlHg6xffEAM$u+UJ3hB?Ef+5wfHRWNm6@?v zn|4D|_gDsgwZD{o-45?8{OMrm5oNSxX$n$W{}N}iQ+W`&+r(Pi;HWQ>r8SbncLz`~k&Ua{m#?prcJKc$C=D3{kRvml-C*XM`57cmMwkU^|cS9S-04e_|T=+A&CB(VpJd?XlJ^^O^ zy@->kDQjh~kn2)n20oFYBnP0i23?j)kY7r9AP02IEi#U{m!sWJErldzNuB|5@M`N>U5o(f z=GJH4g`fiakbK#g?N9!%_TaO(H8+EHs>a*CGV^AKn&=G!;5>d)n@LVU+5vQ+@!f@! zd92!mCYW~RwOo&I!apzrT~Z~%xt$F$ogpeT)PzIdShW>SwvK*GhdsNMD- zpSj|jIC-0qf=CWGF!1JLB+*`sulyQ22tu{7l*gteP)+-y?SkCI&KVVl6My!h+Y6F} z1O8bYd84kuuQ7#TN+gBQm6Vn-!av7toA9b)#cxN0+4Qyt1;VWYoRbx4%i@!OeRY{W z=C@i=1Th<0&q{h8x7yU^O|>6|yOt(M$YtlQtH&FxN5?m89b=Wd3@sTGEtrKPWC!hQ z<23wPw||+;&v$E3>oob7TNnZrbSm9UWu!LFrz{gBGy5#h3JHj`b1fGeyl`SUd>$@?!pPspi&I9uE_FHNCY1(c~JZkFy=S;i7P*YrSG3Mm!c++W*Lci7MneN633`# za=q(MeGQcb(pNwcZx1ivS=zOa>-$Tp_lfu%(Zes_*l4432ZE1 zO`CmLfvL>ux9Og541E@+;skiQeMK|E5z!;7(J?*4KpaLtC(7!~fiRbI{TfRHuXB7} zn6HWohg82^^0-{JP6vx!2Q=BM-R~j zgI9{&LDTfH0YB)tHDy9Sh;714#koCM})1o>zzZpMVzC#T?@I<4#F_nC%$|9#pT-h zrTLd;ia}KD1FBw^`!&TLnMjejtd3(-=k>gD3n^5krxB=mb6Qh;qKrQ%b@~r^}^TD&~*TNT~uLwafMB9f|yR&B_W= zFAY+%yLDJw&_;6!!U{;o&*@kH)H(tE_;gNVx zf>Wbwiuj-3siKzJ!Pvkox*llB@E~FKq--I!5B+?gjGg05IgF{HJl|39JmU>S`L)I$ zQM;0apzvp<=#}rr9Yc*Dx2uLh&;F37R&PdfRLr2yZykvi{djZ_qYD~T^p0J^~5Chv@)yspMmhfGH!}1aOUTBvI&Z4n$HOU|4HT;J!UZ^rD zB=o=P10?lAyQ56jO$49DI5Pzs@dX+xe%Y;{^K=;Ki?>wM&Rox^W-{yO)wHy}5cxBJ zR{HG-NqO^}kp4kjkt=;+ZRvta;er&~S^N&fxHJ`qaTwz2 zYB(5PN!fb5b}KS2G9U-tNyRMR8RxuH@!$OKjq?V***b${m?oaPy_41gS(aI4GyNOm z*2#*O`)fH0;litOLPX~Xmz z>pgHLjXwUpBbhcsAT%`Qhm@8`z_M0anai=N9w3@0N3Q=!fV=KyJtLOXtaaE&I;?`5YN1H_17hEp(TUz2@ zoN<>Y^`YxTOhiMw>5_JJyVk@i9J>~;eKNxsQYH>AMf=5jbM9P4uV~{xNesr1Tl5!d z9X?+OnP0+|@59|bn{0`cMmo43UVr8VnRPp)#wG#>8hlS0eub1$gVF%MpD0Jet);6) z^fSNcRu(%Jf7)pdp_iVb4{&HU87E23=yR<4=e93T_;s>%qBYwB)g(459X7e1JD>@& zv^KRjhH{ZUEiXIcW$%N7~F-A6xS5u&HE0s4%nsk^qjWWLo)+VwSSGVXh>ydi^ga{(%z;D_xp8( zXz%e04Stz-xf%tFwJ~Y)YZ5sHcDB@1EC|0t_hLIl z#0$A~RglFy@i?P+H%qY>>L%Q6^-VN2Cx@o?TW5n=U?_0VrPMKyW#fLCiCNR?0DEC3 z_}s{)G3Q9_E(K-Voo%#-DIC|k;_t1rm|!RfD#ZUu zAz~~eG!;0u2GSt`T-qLC?;9-oRf~ZU+ERcMHpKYL(G=HUIs44na9)Dj1HFL0~Rh~vn1;*V6qy&C`UwgFSy(r9N`yM*nPmx zknOwlxI@_9y_v^#LMGwwaYtIYdrwHIO85UTGx5nh_?Z48sGjJ-xke5WHhh@v6$AH5 zVjWXeN(^c=FhCy1mkn_|ZpN9%Nt~|xy|Sw2c4TOl6Xy@(6XZg#C_2KM zfBog6BaB#&_0lwThSX^!hRj6!@#hK(?pY)dK!fKJ8H!$sE)0v9_!}Z`UC3ch^TQ$2 zz&Nj|Jfg>AxZaUz_%BhpRaD79uaw4=)*Eq85MhMy;+yZM?m{qSYi^E&pxLYZ$n%jf zaHA=0rn3-^E*}vy&v<<%8~1DB{FG+yQh}R^9&%{L0x{#Ke`HBDq%J$D1^D?9aw)e{#3&-H+sl_Pp;GmYG z7bZP^lA(}e^Gu42{=tU@4RxI9sGcEiQQ{Q8Sg}|$ULVcmSYdV@GfTG|ndsR9!Rs1) zdgdamwdlod0=Rc{_D!V>QIs{b%%uhT4QH>ddc@n<%vfykl}9QuzYq#xXt7v`=>Dkr z%W{O{bEk{Yhh+Kf4mdI8EFOO&Q=lRugHw^|U}HG*mitQJ#k)WHgq=gWcVTYvY=1s4 zj8l$O_gms2MM}pJwE|h887t%8XdpcjF3-FJX;;-BKHEiR3RrXW z7$VCdr*pe2eZ@nMP6KV#weDhM(L(kN_;tO5P|)#dNdM6_RrkM$s)p2-vX0TUmbs@* z=M##J(@Ktg(XwIG^FFP?)B)ai_m%Fe-6e!Kf~vjb>g9r~iXVrfC@M%hgtlQIjepY)2pr8=1a6oL8 z(CBmx#0{2QTILEdz8Pv}3|UnD5=oNO)T~Nk897R{p8xLMtye_*D984+EWcyCmRE47 z(1qfE1m*xQEfK5d9z$VyBOk6*77kQW9pm2R8qD1`OwB)4<&QDnU7=S8GDS3zfTNBDaqkZcVJe998bj zZB9{9010tTWU#VgL2|dT&JC0eGy?qyIU>HVML&J!8qvpI(0%GV;BM+M&WqCheLn>B zJIo{4Qpo>2N|H#OxC8H|U??2kW-HJ9P-MwQ77>Wy#idpJ~Eo21s{}4t9 z2d(|rKL6y(M!}#&PrTVB#mn+5^xXH94u3T2H4C?%xX!wCgwYA|2gie5YhCRy}#>_#58`3hoo4{O%y|p(ps` zN#|)PwSe*EHobqc!6kYP>i@6Jyud+=*zX0-INK>6K~CUB7FJk4zx%cLacm~Xj`lb8_5ncdb z_rs%V$v@cFt7dt*RI-Nylf?zwgIXsEioZ3Ct~1c}M)LYc`Tkj>b*`+vb@vfIV0R4W ze2vx^;J?y8z8*B-gfMC9*gStI{WjD@KF#ap(vG#RT{O+y7@Qr-T>%**d_;7RLFdW| zp#8Z0w`b*GjWt&?P6-b##09z!PYaszhgvSEstkvW)5nf?C&RFFCxTxB1r)Sm7+Xk` zp0kcje2ufLIx5OhON#%uB>CXj_?bUUtiN%|-Ef(fYUhT|&Rx&uXV#c-Z69d&YxFgSw`kd6!ylq|D5Hu)u+^JAyXYwwV zljWv1#|?Wp?laa$j=JwBXc2YUY+U68)4BeEWic-S`3-Te>7NXNo$42LE3HcM?DW7Qo@XRVN4KKVl zAt#_fb!D{PG8`tx+$RnC+2YOQRsJk8E{`-bB=l|+4HGghFQR=?#H$zMG!|E?+Td$F z@ZjkXMPxstbSTpZS`-M;eIek*9VpzMtb2L`3Gu)RACoLbZ#Z}KB}7uV_Z;6Ye8{M# z(WP6+FSETBx}eQcspjKgd5EW0aSmjyH*%r+mCKzJSV^phzIjHPZfCW8r?(j0)y#XN zTC?^esY!Bl5~1<5U()EkCc9I0N~W^S|1NjWX$HU z;^cKdqI}q~n?(oKgi?$yeK5-CR7sapzi0->xCRcrVff#*0am6H9SS)HR2kc1DJx-r zbk?5Dm(-6d1^~u;%wV;AI~fn+eQnls&3ujL{Ye^{{v2I@o^W*!o){xu9Q$ahwrNbJ z=~nu8_zrC5N*6+fH<1RCh8bNmpWV*?i2Qv>Q}cLFQAxliASswLE6gF;0$V`JQh-rq zg+;@i%9Hid1*nV-P2cbD&4oOy-f?Hd;L0?6wVljqrndHdKBl99D|(i2N!jNr$ z`aD0)-NsxrwkkO4$`)XeKqK0>wO*fN^y`XDv@7rK1(JJOi=_$WUMvU-LnIP-7455O zYio~;sb*OX7f2BDBTcH2(S$e?1{Bk>0;pTsbcc*ZJWFo28*1;G==&g`1m|H~PQQ@2 zG@D#C=Tk3^Bm<&YOFVo*K3sX^)biUY)(-q)-r-YI0xCj)7+rR(2YAHSpTg9r z?(1NCA_P>-_lXC04QkfTp_0iefwmXr1k$)jKFqAZYMhOX34T!A>w&IF%x^EbnAW)O z=OFsU8bQD?XY&%cFyzWv{Jw0lGQRcQt&4H)8B?JNhE-?0RXJ(@dyc(X9kgAu{8Q~y_HFB$-?ax8Z41Y9;*S)Q%;$q)JGsUhng{@CEZ`N50JBij z#N?kdLQ7ayXWbC!M|Yb`W9w#v{iHUm;PesEB zPI_GEO^lTHu`TdaXj=pU-9AJ+Wzy8-9f!l2`&jMM-Q znI>!?4Ngz#+%qv`PFbJyb|j#{P-3evvBdtgyMMA=8@ej9Li}N4JR0}g} z(#6k%K7glWDyI=Ff0q}rLA5)#>jI2E1l(P6#;uNkCg_J9bNb0Tr%K%Iw}P&(cJh-7 zpZ*wzV16z{b@Q%uHx6=^7AkukSgZ{;xb&3`&>CrJZ}!W1Av(8`a|r(IId5)dWw%M3 z0s86C&+_ps=95{R=b->(Ad;dg8ne2mW=o_7RB`2OproeqEgQ`mLh7;2}j+MtwHVMq48YUx*zoZE**2FhJeyH*LY7>=Z);EGSZyjQ2O zDM@wz5!hU-Ra@fuus&R=1j%rHpAjFMdZSOA&Ax=jL+gBA^!G~^UkqWs^FBZ75;FN% zCfPHD{mvWbr3Zg){HLc6`{Th+ss5|_M;nuv*0~4aHC|`$pXnLkmc{y>W(`5P7_}%^ zYS2CnU%lLs-ycoMUJ`}JDYcJgfOG3~o$n}Ik9jsAl;2DgTuOj_dH#bbC~~e{W@Fc{ zM|3?My=g;w>%g9?0z`hCIM=`uCtOWw%Opl)yE($4C=u4r68Js6pLdPD(6Z{$A_oPM zT#(7%r+j1OJiq0Ub}*v$UtNI=@VvP#_*m5S*;|8t@lX9x*`*aveN~@)Q!aUBKQ3VUd2vaME&59*$2bJ<1|{-nH;Rr&io+Ld-peRMFt;4t+G{2i0e`o( z;aeC*wxQIm%6_53+xMwY;wxz>xtF)(_Te0U37ogDwcVrtBWSZho7J2*nXsR(U&BPT zAmrYa7cYvwsn62jCRKOB8H=B^wLp3JRlVDPaX#{D=e<4g6Q@M>)q=O@|95w3^4~Zv z#x2aaULHs2vuSEDWfRn(b8jN*{3P6>c{F!Voy zr)n`5v`Dc^xruKa^jxJL%zdY^VP=(WzUjMMa4 z30n4oFNVZjV`B9PpX6(>yJbW6SX^-wMmUJ3#}Y63gf!3?wNPE&u--MGxu;2!9bdjG zD4~YRP%|Im3eM!-n(>_~Wo$0Q_&x$R%L`b2n;NFKy0+4lH3eh+PYx(5GQ6gK``l5z zXQFK#&WG3!;#h$5-aK#_D(O*MV`P@-RoJ?!4p6cgez^{Y(Lq`t}fTw}r*0dh|(QVLj z+F8NCT?c zCqatP*JEmqoGbF!1=irx=Y7!9neNXw5%z$i-BuT`*c6cn&YXOu4=)S5NP>G~)LX$x z+@r5aGi%x2HbJLPsxpGUe(1AGUp1RI@XHf7&b?mUSlDVhkCNtf^|@%kT{i_x~fY%6Da|7Orp|H`JA_A4<;D2+0%9gi;7eAj*JvY+O# zG#MrHX46i4%}ekzIkDmkH=!437dlqb*?Le3`8_iFMlpT_!pqbWSef@#kT+0bE;IP} zz|q{3gMBrGDY){Rt?~)^DaoMu4y*68#uY(MR#jF{f&Sk0sHd$H&o22O?Z5=tc)6J9 z3oc(5w>_xnR*@8!)SgY6&Gpcdxi6~WzI$1^pVU!KH<`J)=9&N-JF@D@%upOmeQFJ1 zyyasRi3$6;_<4nA}H?qJ$d9Tg`nfOENHFf^t{a&D@km;f>pf>5Kn=tQ;c+U zs#q&~o6g$+#eK(bvr6_;#+TZYip}x51*zgbS>?er5>HC#?vHu=yQ0r_C5><4V0c`m zNTTkw+?Q$D@9{w@LT0RVVl^hdT0u6m2+3|5iI;xYBNKyc7YQ8~(1>uRH#6QN_o1O=IUt#*ut&Kjz@JV^Aq~PCt@2l<6_}0G&I3{w(#nAfxEvv( z9ncYbt($c-uJ+vIZ@#sC=9A&akU4CBRkTCsxW1Csri-XnZl2(lZ}XXmV{}8RtfCgD z{L8Huo!XBfTh99w@nmSby?YD)G~{rBrhq+t-q=~WXXcEi&0!HWeJdV}yGiCcYe6T` zCnmz>Z_mr3KbEtw{t!G>YHj&TFc0bam%H6ZoZ~iAG3fS-a>tf#Bw6#?SXW0);DYnTJdaYmNLW;(o>vD6`~@aIxWMqo$39 z2oeW5(^w>dlI*<2evG)3HgZ{$jqYUzcStm)maaCOg;Cc-V#(~?kt(sdhMhZ&*=d4U zvg!dEkttEbHp4Ey77&oLpSK+~myDscQKM7A74vY2EysKneGYP_-z$6gaQWo{j4yJp zIbrlZMOmh~T?YH2w3zWabRl^>lHm(&C<`ZdwxuS@eenCTP!+pn^{)trZ^nN)7SFXD z6QZJuFNmRkGkw%DzmAJ=2AjQPO@*uoU#8Ep?wb=4P6f>osU^)w90yZwL`RWLxop+8 zGE*74E0{Kj%s6z{P59(qBXz=q-feu!mgpO$&i=)YDM}3aq4s>m0L02?HOKRAF^wD_ z=kZ64Z#}t6ufR2CY-+gLaEZNC&^U*Tmw)%x#UA{Zu_0)99Pp-ca5nf9t58*aYJ)7d z3_!g_rHCD@n;y8H@Ky0#S4~(;oEC?@u^)v3`YhzXNFzrk=gOZIzK4HoJ%5r$vc8mT zsSyfpG%|n!>+>viGc2^U1mUj5+hec*E_LDuHug z^3hgsSb<<`JVjjcNCU4sS<($^oL{P5=Wmmf5WDp9Pc~t|g%dg=!yyCi*J`k=xSxN2nrsV{*va%WGc##+f$%AIjL$FitXvM00 zOyICyvZ12pxZme7|1iAV?OzZAog9{ZGsu!fe@D4V3VRqSn(O%Rg-w}oN;0UXwl3xiCcEJ@|#PwWaGelV`n2%b za?MgTbExK0tBZ$-Zo=+jE;h&@2tI9RK?b!KW*K9AqHH=_arp#z->I&=( z(%_LMQYhKfK!m=0&ulVPVykT{;eLpqw(qv)>EV~RaRqZ|WyZlpNgplFFG)@dfRfj8 z8i7blci=`J0GPsW6iSj>=rcIt)$#`L()mk6#Me}FF~bk6wXmm1%3W$x@QmAAMWE?v zdh3to6HAy`o?(Zr(BzG^3v2Umrc%Hglg+}%4JOotC{hZ>oM^HS+~3O6 z)Xco6ji>ewMFx#m&1Z#nL`u|8o^yUNi*Q`fN$>(OHWFz@BX^nZeGm)VZVuSUV)fvS zPAG@O!p&-?Bj?kKSmy&^UKD(sTE%zcfa1gQu#x3R&E|&tq0g5Zr@MlQT`kWx<@Tux^^-l?axrvIpwzGcYo> zKGOkRF~P2<9=N+8K!YXu(=u%#qZOVN<=t>qlExp56_4GUJlcC)jdP4PRlV07E()xf zDAJ(ysUW+DbSWRxDb$f!UMnvhcWC?Rap03$O?9HZ6Nz`7IW5^q^4k^Ab2a)7shP>6 z4prkOV9rY?K62W7fRAwzf{N&;psv26NUOA56HJ@m$VC4tX)|c(he6D6hG;yv7zh_& z68t>KLHk(V(f2OEnzwMBQ(zdGNe;A+@d)#QG4XAWUk?_`e|y;v3QcG=e8TgHp@$9s zB$8o+aS@>Z>>>6=StZd$FUKt>U#_b>n| z>J#)e(hMvuHgoFLn>mm6V@tI}@H!eKi>+QzaGB!@pxBef(wc|b`$U@{>d$e&_?ak3uBL&mM_F z3t4K;#I-uempPq|B#QzA8DtG^(Y@B|`=7)P24&L{Tjn+$42B5h$YNX(w)S#>*vtV= zt1+o$E!`wTFn=vOjle)`eeF&@a#X@vn9o#%emE(`&}M)>_r-{}$LLUMzz>%DvYZj| zaqsoZ7jkQyW(?V9Uf`$64w!Zxb(C5h{qFGqq#m5ST<(Nd+5WKbsvKN-TBw6jot5E> zVW&6`Yr2}@+6cwGVQ^`qMxV<2OdU9dN=>l-D2>KEEZ~Jl3s_ z=DDitiI>lk32G`0gb?je*rz-M)D8+j?V0cY@XmW2*j-zMCHtB7HqJd>F8A3$5`M`6 zOIIc5%RJohCl~f5xKWd6M;*1aa`tjJhpS0Zj(7mTbk`e=ohzP5_=dFfDb8>v*%xyD z-I`w)?=V`tK?dQUJE0~`Ew6D?$qa+7n5r#S8vmWwbi?|{YgG+7_KBrXz=G-n(3)=? z)YN`wFMVJ<-Jj2t4Ffs##R$Gf@`z;#K!kl#>aAYjib41P(R|V7?thWbIQG`^(foq= z9b-zs%)`Wf<3L)BC_}n)4L~>d&syslH)=tmb+p5g66(LQ&9jji&b} zf)eO~xWN^!cLKlct+S{*Y$M&+TR7OTu50 zmK%Qer-;9I;`+z$UV1|QM_jE$!@ZM1W=DdxxsL^o84M<*v4eU#Eous!;x$b^Tb-!{ zz*k7XKHneu;wb^!jIF7P2h+pG9rb%iYbi?!Pu>A(-&~L8S^c>jr&Wuw1sKVw(*TZ-{I>dcM(mMGk5tm z7t^PB94wWq6Yd9}yKEO5{qi^5qQ7#ldDmfIuw1OrUW;+(TWKeOQ$YrMkD(_Z##ywV zZ&%+~r~i)W|MfW{!RhLR*IzHn0xu+4D=0#}{ucM5n(0)0(1R_nfrS;Q&$v=@m2dMt zDQq(XRJ{%q2U7FKiR~%8M+`W#aY#&QD!m9K!nrgiDk{OO1c=Jz|cQ_MV3WIJ(&5nAl|2qNeXD{eJse*fIU>8-+}Z^dlr_I$s^u zUxVJaXCEyEPsXihR9wo?2gWpua~~Qda1N4vOx^K+9U6f~Rs_Aj>xYj4?xx6WN_jT} z*{^5G8ilpA+@?}xZw)kpS73( zjv!=RQ{)e7)rpOqnw~bgb!T+bM_d1l$sh!Awib=}1&5T`j+rR%2M;v)&hc=~DSVBI zbX}XrG)@P)Pp11c>n%2ZwdZoo8Lq;JZfjEbDVP0#p3CA}wJVYSK|i`}!)OOCX>j_mmH4ea z*B^j^)fax6vEm{9b27b;z%GhToAY&=BR7Vt-31Iw{*Ec@5;A+MxmMDSbV51DAV~Y| z^hKC`mN-L_@u^L)?d}kTDADKMIq9QaOQ*6Y))}Sj>@Q7NmmP9H9!a>`{)GGF;$jjI z5eL>Oc?ki72flY;VEG$jx#t$(_9eqKSwy18ij=6T28J!M1~97ECH`y)t#?n5wN~<< zobrVduFSp^mU683JMY30_a8wQ>(ZmlVDZ~C{4oz1YJ!>}f57Pt^sHTsH7+!&z2WMf z35ojD={*QVwa6MwS93BLb9iPLBWBk}i^sGrRFCo_xn?9C%J$8$U4LC6a%)-9x|Tf6 zJCN~q^7i{DZM>LBs0)wVQciJmPclwRVzo8U$i`4zkin%wkRg9Qxpfk?ps!rly8Vc0 zJCFX+Mo|4EXQ|Di$IWy}m+qY9h0w$WZK`-g^C05r8p2_WEAz?_3egGZ`E-YVclAO(7KqdgZu(GG^GYiEc*vIwsHLeS8n3s^W1%gBR z9fn%F9@E9dKc9lw)+aj#{-2`FgR13DegN-s=l9P~o)1szqaMdKdjv23BjVWpk3h=` z!KJjIkitpX58AC4Poe}A+%%NKapgA$rY{dTCLFKSxUM2;d8Swp>bpso?(2;c1! zfrYf0tci{6;-<}DPDjc@yf}u3U01xT&MQ+0TJ*-?ST!J?w6k4)P+h~_?1t2iWyQZHF}Y4b-vF83T=WvBJv9TUdSdY!)kAevjT>^pHuC|$D} zN&gmly+ygWfJqC({>EFp?_K=Qwjtg_{m05Jf@>M7A28ZI#0nYXEca~fUH|kHAi222 z?Uy2kwhCHm9^lfxlD!MN;IXu81M=eHgdWu&iidj^x;O_KV4+-;>shk~i%}H!dE$rs z_OLs)`{hkvNCeoKBYcsvkYRJEo>-Y{=oN=b+UI|6sT%YZTX|wO;Bb`9iH6zJdNxF`;$lqEx+G%G!pj4JNOEKy(Kjd8(8w-f+s+dJB7U_x^t&=Z!J-yD zj5(K_7E=tt1N$Ybd0UEKYJ4!=DnYQStRwULR1u9B4GoB8g)HhChBfRvyuPm0b{>(x z{j^>4AAyr>ewO(7R^f5+=f0=fRnU9os@Ny{T(p7@ADS4o&{mS2LqC_#{MepfjJo0& zztN1^d0bjRCQTI(Il0iOLupz7J-x@{$rl2nNAB|U zgx)?h;K1Ayte0exSW(7u$cN|o8dS=-yMFP4&4$@-s&nQ_(^A>|M^Js4TCLuKWSyvd z8=*R7nEQg4{e2P3-KW@^0P<(wl+GyzeJ#}><8&^yRXlASd(I&z?K0SOyw;S*^VZ`i zR5warnewZP*q@&4U6Ox^+5XK5>|K6S+xJ8FN-r=SLnXaOeXQ=~i1y`U@lFb(WTr*y z>-cp?#NgIw$eExsF>?FkquGb#Turo{X&$lkDb0mHLu(YRTpbGQRPDiYWxLa^JaMay zxxcq#8@jzy(Gg!n`d%%loRz)<>iqlrN*D{>magNwJH^nBH65YMr5piRx?yaEUJPyaIeJW=bUxQuwk z7;&|cGijiStE5WsXH!kD8k2@q{y1I9P$WxNRPU-#DT$t7_#DOM;&hb&c{+b{Uvati zamI=LS{<3zYI_Q1;<$B<_ePKMf^EcfU0ru23yK@m{XV=_gy;~aSuXAPRk5faSVp1B z1Wun}xOdr~C)&obJ|!=PD&hx#wd9GW^}H3JM&>L7?^rF3*-~)5YF_K^m8K${h7(c4taUVGT!ERnKL_Uz_YjEH5}?p3 z>6s9h4j&s>Kz{WxrZ3Qrms(44bm1(WSkIk`G@1Jn?Z1U#_L9kZmg*82{IJlc!r1Ta zfExdRiHeZ}M4|o1lri6~3 z`PZ%=4E<1^-t$N%cCnHHJoq+#t+a=vqM&KOSp}&&6NWAIfR~EL-W}0|NlFV;&8vkp z)q1u-qoc!&hr<}eyTT0(%D!ok7Y(E84X1H8OH9;bM=M!+;DgE1x9;xkh}W|tEA%a@ zY+}Z2_t-J294gUEidkwcYFU;MrEC%>EUZMk^C+F_?nFV8oy!GjN06M? z{@n4>iGVrBEZ4sq(%KwN6WsS+o*{}Fp~dF~_~u9d5ftYF^p1j<&Z7|coYL5}g=E&) z)MQ$(sFcgPH*5;PeK|>zmBu;yo(>MJylu5WO5HP6BnR!}Py)}42p z(aU`!?&<$U)>%I^;lAP8LIDZsMx{m$K^g?5VT|q((z%U|PCo+DAsd}aGhj5MQKVBk zMo4!HNPZ8$o^$?+_kHf?ey$4~h2<@kUej@kjwWpyUbZZapg+R7PuJ;X#M&gL$m!0T zks-i)H!(GAAi;DVE=s~a%w%ioTIEo*K-|pLZ!h(@ zE=f&Lwqjydh63488>r>*)yI^=P4aQr{?qsR>(QPOL_ZEzD@ipU8MSIrC95_RixXT4 zlXCJQON49QCKr-C%p|GlEkr8j!y~f~JgUfsd2_@Mf^-Z_MBnz%9!r6m>(#6pITIj3 zO5w>H|M&;*S)vlh&5IUYGDh>S!5o1~;xr*^Gx}_0|Jo)`oiojS-Mwe(${^OQz1RG` zo7y9+B{9wL$CMEm;N+HT*x<hI4eFU6M)h)2>;nq01i2RV~lZEJJ-}xUJVe>)R zG<(qUNmeN>2ZxUvS(tSrvP8O#m8It+hX;pRIPg(`qI@NjN&2MB| ziNl5d49z)33x#>RNpghrrhnfx5xNMtJ8U)x$Yf;-0NGv_Z9q;Dhytw~%!!l&fb};c zCY|n)0nH1qB{;<&3k9wC|2;^tGx;U%)TTduVi$lgFoz1qvQC|lYpXc-Qx@5tzsf%4 zTdJO_yn-8vtM?m;*nLugaymo}EsnohXD-*_B#eC??Kt)M>|~SfLDORS`DN12sZG;} zDanBLjB_bQA0KF=z(AV-kqQ42K#yWD7vBP&3(ZX6Dgh30-ZK;}9A2o3T^eYpMzuoa zpGT3^&w669@wzb0g7&l-%My8wF5JK)tNGI59V$4I(&s1D!Y=7}W$fXR9kgW13<(Bx zuCuLTlVg+^gk2QIf*yO0c7uWLL2n-mF`0Z*N1@Ue?PM*GLIWpVqJH|9Ylv4%AI9IJDF{ps~OZ#JK0d6WiE z5w7`|I)iQ?;ABmix9*@flaGquNx~2ao zT7`hC$`*r9Y}eBe?kis&8KR+@s;{19ZUc6cH0lV9>p}<5*XZ9GSo~0XPod{^eAY6` z7(U}})me}NSb=|0@!G{Sv5Ek=@>tCdQPm6(txD({^Y^{NXF`!G9c7uegU=^TdngSB z@}uurEyfywuOPT-Plrk8m4awWlzuwuvZ6WRivlg3ZkG7${$D=&VI!U-yD}kTnJ*cE zC6CunI1)Iro0M-eUf8#%Z{l%o`xOqs5>_kyf_3gi8`JVSgBv#@x0j}W?G{LjQem0H z3IHa`KWv<&UMHP`1_mGWpQoe0w8H3KYs1)$@9^gllJ-fWPTp9st0k#;*xiA&@P3?U z{FyMydGfurB}wpZJIB>@5j07!NxVJ7iml98xWwKk(>Y^n)^$=LYOmT&<4V*CfE+&V znBW>8)@R^~e9wC<@`Lb;*nJFGNs5p6>WB?w)fiFX#;XAj(|5M%*<&r_^Ps9fZl!d|4vZh1( zv2M}frQ-I3CTF3Knu;8!qZ~irsDA&~)A!DeJu1S+2Cx(%Yp8(S&y^dMvMG1T^ zpewm2Ntx8F65ES^l*-V<5W9pkg@}@-vyB8TV7)hAj7yG3$?pN}@p4?CPBndsKxVgy zIp)uP$vyR)Fe!!>N7hkg+E%;9a;y)39|*gTe(tBRhqNw&s%2VAQq4RH?mP+aU@lev zMS;?fd&#X{lKlirTlVLMvcneVinvs*<7rh?Gld4&i%adFy3pDsyM%pTJvikwFL`K3 zg^K?@!6K#N~z`dH<(z_pVeblsNgfLxLdJTQ$*ylZB4mQ_41 zGd(7s8f;{o-|@q2lK&%zYQ;{m70!vsce@0VEaT%q2pHaN&l5rn@Z0eK`||3`@)gQ- z>}#~0Rj^w6oIO*{b(u7iT>aO2KiyVCth z7U?a|6%B@#hv%TRuMx|nT;V=Q=C9>hsFOc9=&}v;3wx_3_h$$ucF3zt(AI{M6tW#Z z;&d{cg!jj&X-fKFD~3}VM~_K!q(cl|bkA0K@ZsoIG?{P2zbiGsUTfb?iEh%!n`s)B z{DpW~nDBdT%Tig!+-4&nsKg- zbanxNNot&D0!}vZWU2*Icxd}~-mRc)KxR^YVUcgc>VPpScqj5LkAl{)bUw?J zekoN*+`eg$fAY_wzHz1PE7~~BFG1RQ`pKQQmGDR<@!Lhy3QMlo9g;)jcMD^hp#v1# zvM;w+Z5mHSdFf?-rFWnLvvlTq`ls3_b>^JkxVDb}e+*VNc9;P~~Q z7j2p*+2#4Umc)lmfVNe z8l1jf1XHA~YxBOoZJ*~Y`fFHGLaimTS#+8H6n>FXi*vh^m$I9yM3De`bQ^?vg7%_* zlMwod+8Z(R4KkwT{k2s$uZM8M2>7YO<4RaNa}l7v`}h8L9Jcj|QSy`O9vJqVT(71i zTvf0E%SGt=SWdqIl@&Fv729!DP@OB4cC6&>e*k5D)kf`th)6#A--9=f@7p2xV9z(m z-^369zhsCYvcz;K*2#@6Ca<)D_YVx3n@%p&Uyn&7{*7O$xRrhqB!)`Cn)wF}tzwCFkXXR*P8)QuW6)PSs5 zO*T3E$FpK7(s|uoRF zcKt?h4}{nvIwQSw4?-MKufB}qc;qRs(dFp@$3sP#4&oe*P0I-L`y$PsAv$`4L!-`& z6Mm<%f`hL+@=+ff=`4B*V7xEBg!N*)_YB?=NJKfuzw9$A=kPS9oGi`sJ2P*j@Jk8j z^9#%aW{zIdMGD3H^9~C$F6o?lt+FKB8lM)J)$)<8fSK~8Rd+U5j(ULi_sTF-(e)lnVaIcaJO+LtQLVG#Xk0uG=!&=SzVq z7Ob+$QmHRH4z!kARyHnTniqB#n+@Xv4U%TK3t&G7dTguJUU_qPk)6-~+<#*U_u*Gq zYc-IGj#P3F8qU#&FvGv}omlKlBmbrvlnMwb?TVAL6A$YKjp9t{z__K_|A4Yq5B&<3 z7`x(3SW7>Tt6%Af+Q!I`$$~HE#0kPj0`;wq+#z1niT>W9vfJEWvpjW%Bv1Ig*hRuk z*|ZZQa`3q-^7tr&y2;>ZSY0d zmQW}Ri5S&>{#a$E3rPhmX|{#z8@t;W*QsN#N@v!5^kOoPWfgOZZn+U<1Z-VrkC_p9 zSi`z;H2@)TB_B&q*sh$)$kO(vjuYkmaKwKg3t{z{*E*EEL>U%oyq#cSYvZ93WJ;Cn z7gOYDw!T&9*KR0n+fJBy)Ekw_n~C&sV1is3&`Oka)m8Inr(wS{IJz{PJD>4?{)?BG z_f%bfVr0MTO&z^f_=p#NDO#fimIQgWUeSo`a}IU~$Qf<^y);nSamv<-{c715Rl@=Y|ly#s-szQ?bnj*X`N!L+Ex ztk!RHM{5~0b*RJ4MQ$dhRS-s!PItF!n)?=g_fY(OyE}Gm4CYjA)U-!bnmh3me>Qnc z8AUwt;AH=aeP=rFE9v^LVvP?m6{U919jBznx0p-{hVKlT)sk;FJQ|FEWzkLVSLuVk zY#*;9O%gXi#Ru37!`z=kbNDpG0<6+ntQAeJuC_;cKf;QLjvXEKabuJGeNB{I-G8!5 z0s~>VH=p0B_Syon)J(0c(i8q#okU9OLhw)MS=* z&C)5}9B zFCAsTk`~f<835#mrEj-hQ*vdxByOOA!EfnL+;tqWx&4oW4cJZe_7{e*7Ddbd>e|J4 z#_=`!EwRRRCL(yd*z)T_{3&PUbmdxUQf?@b=2v&hrySnqFHWKZ>-pf=xBqO-ZWvKf zSK_y;&JzG(@w^f1ZqYbo~oX*zdhAzF}Mt@P@$e5dHhxn$ew( zR4>RnPkog`7+7T0XS;kHT%*jO!t10yzLEYqR`Y4PN_bUB4^p<2SAE|+&pX%t!+KMc z>k7O7G(>Upm}G3IxCdcYUtz0PygX*D{fqZg#;Gmsbr@@+B(%`xm7e1s~NBa6+Hwon3nHfuG(yAYjIgcF* z7b@NmfYJ{HT#VrtGX8MwV<1pxX_U%%d!qhA@7XG_&Pj#c(~`bd6|jA#Gr)j2b!8Jt((kacz;aht>H7hOkt=Q{P`HXPf))vy(5()>vmaN^}giU{V&vx zMX8%CNl*XQV77|eDcuJbR5B+O$O$+l8Ge_k>?lNzq(y+*+T(<}Or;~87OJ-ab)eu4 z_QeI=@hOrNFrUti+OZdsQj3Fidv6JV^N5(;<=)G7NMj_qXc48abR9?!-W6-0iR|dW z7>Q&+?vwkP?nJi30()3#EqE_+U{pC|EbmU-_r#W24{0-iaQ}0XO&wJJKls+f!cvQv4fUKqQf?ay%uUBpZ zycrZwj|t>%8BfE_=dHfPy4VE}J}sABWeZf~sy+Kc|$eq+z*&ZwkGb55w2Q8O=yD`(wvDPxw3 zmWQrn$dgQ*!+DjZXWEpn3)bzYkRH`%-0&YAhXUdYvQ8@NN$uTZWRfVy7$#_=t8h{t zF3QmeySyc9<9qk`zn!Nac+jn(Bqfov5-T5&Y9U--n%(MXRB;3oOFI|NgN?|YN_im+$4PBEX304)@`b0YoB={j=qfvN@F!`o`~r1)E~IU4wm(A*!|E9+ zRYb<0u70hUsCRJRZC6SI#0#FO^|!Pu8%v(+ND`4C_#PLYqELk5UGvx*79cO`=5aPA zzzbzF0KCY8)TZB~&~9^qE9g21jqRj;s0^8wb0CPxB&Y88hEPL3QAnTu;Z%-h+e0@@ zkN+{7hE_yAo#6Xv7t&31@42PIm*OFQUsh(#M88ugTHkr=W21e_nz4p^#Iz)g?XA1S zU*Q&}w?U{MIS?>kQmAiR;;w8{QSih+Gr!g$Kc0T)x!S|z`MBttV5Q`I+k)hGSLrq! zRw5a&VieYlGT+cBoIi2y!MEvWVm!`>UtA(ZqE7vD>Nx!@m?y$MnT~sj#Ny1z%Z&8z z6$4h3h4V35!cZ15IpM0)Ir;CPeqB;p<}Z3-Gv1?=*2$rVZ^9E^^TP<)t zOHQyi$$GBOCIKO5FvBacy8X-$&NMkrpzYb|6M49BWXb4a0F(4MF%g0@7_HQS{~D-7 zhp;(AMDhN-gxhyjoA3=*YqVPD;3^gvOiHRh4(`Y`^VRfqno-qfydvctO25Y%)n4hH zyjk}-#s*xNyIzI=b_Cls$3#wLN>OK+?yBc;?Lh#-ds;0Evf%O|)n{o(;R|e~9;pue z#Oll7t9H?VvQiOLhL(Q?T=zRhSeu2jPW>@q6qx4kI1!kr&%6ZLHSsxoM@%|UTTOU_ zTXYJ}%CBZUcki&ZQ`ZC!eE;B7pvNdDvFC9O$m(1XaFK4Jh-Qra`sg%jUjximf9TRSQn|!F{(o_x*AwtMXk%Ddp}BJ0+EQJ7 zN77)~)&K`T)i5jf40_d2R9kV3sIV~R!21)#fhTF3N&SZFL~SV}LqHG0eT-^+dD!SG zl5ly!w10?b?sQl__Lax=6Cc{BkDnp~A{^Md*buhn>g!%SzG<|}9GIxyLvsgfC75m| z$ZPn-|49&8o3~{;kS-&rWm@$2&c_hVs{Qc zAj*w?{JzP)n*)rPbB)n-gq!io!5+ryVQe7yZKCy`U|A=O@!qbI-b@ap$&3=Qh3^c9 z_Sab-O;D%s=mr(&uB6=o%BA(WGjl3o@kSy&f15|eOFU5_6yml{e zFNzFr;?V0g=A$&ubCAOWud1$TOZGl1B;dG6EtY;_M9w2z9) zgruL8<9YN_sJeFq|K`eAK2Np};V~J6{h|-?&1WePQbbPO-e$_4C|YI5D0Rst-_WJkpABT zCzW-#KH`F>#pj%hr>OOaeUU`0!w|tKpXS0j&_M3THuhF?jpVc}oZ!>rHwzi_IV}2% zO7((MV`{Yf`}dftFr=(J>sHACZy_1kF?fIU6mmCMRU(DjFs)97kZSg%*&0XpJs4oD z)%Y$hH-{M-Wz)`+yelcRgs@?jli#?oYIVfhJ<~*N47`h3F?S^$lpQ>_;g%>|>hD=Nz0Wz{fAT?1Ldqhu{rCNw z^_nV(;<#U?heBFa)kf#wg6~C;o4TUlCNdBv*Jm`)Jqh3zWggBy*L89F6b5R96Q?7C zXJOL@n=jcye9kM1{HC>IbK)};8&f_AIVX07Pr<})UH=90_~Hb64kIRbaC9%dnGP3O zZL>uddOU8?fA`s>bLf0-L{Y9gWB+ooBqqSt`bOJ=dlyG>$=Zsnli^K(B-g(FwCqsO zRv#yKmuK`V^is>ch&E^=hW_LI>Fs#U$2W8pAif>b}y8PKZMTlOP8GP2j#qV8AF0 z(1A)G8ED#G8=O4yw;ExR91OeX+A-er@4&(M;imWD zL|@j;zNH}VP0XLLtF0-t(3|DedBzulwJrUrvi}!g=b>u%$v^(S*z6x*70tKp37sza z^MIQ_t^4l{w#Y-IAIwKxD^EP}PWA{J9~AP?PUvS?=N&f zea4YVRB@6Z6#L_Cmc*SW5jEXzm#W1qg`hQY5knJ}EL`2!`okWxy{3cIq#AvZ|2zw#Wx;t={eVU@3%g)!8tqd3nO&=8VpP(na8bq)0`3cl2=MJEZ{EN( z0}eK~QA^&y^tN#mf~0nHOU6`rP10q{eVl7MY0Hv&;274UhBM77PL8rs3W2)2zkSFE zNum65;Z$fto9ohPsD$E*fk+s;rlmDo>yZlm#fBV7(+&j_&HT7<#V^crnrBMM9Guvr z6JI{Z@;YH}-)S@oFGFk$I9-y8wmc9}+0f1|8uzEeb&rLU4X0h=g>?Gaeq+sO%=!K`$QQ@}opLe0OeD1M= zG0{*r7oE~}I#-v#B+H4dihE;Z>&9&_o&K4>@F2bW=vNYdmfa`mY+-9jkS3!=^#kqx zX?*>{&Yn}1(=jNRcMW%_Y?dvuyrMQ1)T!T1tHmk8p32qLq?QZvo{zG@&!`}UPnMwY zhSf%pckduNNgkj>_$Y@`p67mboes&&vkUEmT|DBrKX}b|JoyJ%OCISjoj9joG_NY3 z&XJR^iF2QAb}T@nB1G}C6goMSHF3wJQ$n)a5yrEx*!D| zgrkczS?y#mXWpLH^SZ@kyg<05{LEh_H|CckOwX0BpAuO{%RPdP)s}iL6kUoq1^65_ zijkpmFRwvjE0NMMmR_LxX3JvfoFH41=ko|Wc{_Sk7TDGt$%>>aejjRnoSJE$AkBm3k=`_j+{9s4Y?kHX3UhTl6XSIcRlu zO=_mzW1fHBH75|W^3v3#>K3O?le#M74ejY zGaQ%Mo$_SWVab;Me-EB>U9p&0p&h5gvs%XsX%~(Ubc>U}e?TbffKSwzafqGChMmZkj&sUwFxgxUvbt!7a6W^J!+RbQu%Blf0M%b-_R-gc4~-o* z7KO8@>p`dM@45gL%cgem-CF8)^nSXBnU5_>n2!E*VxQP*T2Pn`Z|~!4y&{vefNDF4 zx-WgTSWL!t^^5LfkrNK|>H1EuLjKcI8tSU9a}qwpIWx0X`eBW;#sZ&%FD6*<#|M%gJFu6L4dTqtKi+#MqGS6}%bEWyPOfy{fxE?$1k0=QDj1nqzR|b1J87VM8e=*P z-9os&ITp|^-WCj9BubrWBfTf}JyIpbq+wSr5&eM1$;nK{oW|N24_Xbk%T#L+C>bs(8Qkf=?$| zjgtl*6i%J^cE;0pELdV9m^X@(QeYBxaDH-lNVc>|<4?z&P<+#O1{GoHHWEy_8<<{o-0 z62fm0tv*LT@ymrHh9>0=_41a0ROBC)vjV2b6d`tywYtAJ>|vcUJ%TUFXcvA)&ZPow zpZRBsguHrhifP`AbXQVx%|i+CU3)E6!j-+%LGDN+#z`l-Ja_IU5KA zmdlx^)@8DLkRP~0Y4t6r!inA3C>H`-0t-HYF%-yk{m44j^N}S6-e3@0ACj8+)>GDa zu4NfqB-+FbHI1H43a}SiR0S_Hac6x|+N+M_GF&keH{A?e|G3Afp1Y_r>TBuJ z-vb;IUN{#dtk;D>u;lc zB?C;4!u|5NOv(^({3RphbMW7pLS220@+oW4{x3fyLyBGg6|5hZxu>FUESt-r&D9`ImEIN^j&z@2}tkX0U7mw*W;x02Z7}S3GEI$Y0nEKUC zM{chETGfSBL+DyMyBeuB4bZ5wg|o#GmRf9meZwEkWNH#d6|-F_kS4m|nWgwm)2t`9 zCsM{0yb!(9~HthBupsKh0zEDd@H}o_LN?uvIdN9! zCpbiN3zA2*QW+f#CyNT-j(>BBS%w2_X=RbS-TOZKzbU*m?(jDSyi3&sGC7f`(gKmB zAcZdD>4BG{^{p&gDKp}6^d+Ugi+SCL~ZbgdS7#j{YH^BX83>2nUxQl`s`3vaw zrJ2U&n~^ttuveAmYy7+Hsi%eUSx6D82DB%q*NaXFF%jt>uUxO67H#wS^I=q@)Bk)D z@YnINZiKlL@E>m&0G<`gUqYU5ZPya|O~zMg&!ha+yUeCb#@@*fkszZ_ioAC`8s74g zQ>D8sIqxgE=S{_dN_mQpe_wr~B%}1n*T?TIFLd)n5z|qBC9f~7Vzqp5S-N#Mlp?FT zF}TYJ0fNORFE2C0b6EM|w4Cm6?;(*UKWg0{i;jgQw*Tf2>;PY}{gUXdP@y}xK6MhO zWXdDKa@W^$G(-QAbC^Aj`)H^SGD_PGT}B;}HL81nyjUZHt+<%sXD^znuI?MMdIpgs z9(YcRkX-nnT(G~V*iurEgV!0eX;}rBU&q3$r4h4Lnn1L@OtL+I#%G;!kT`JeHjje? z*HSO^4pR8rgzNwJVAp8ajcaWl+In!&E;d}KSk>j$&bBx?!%@S0Kiec~5<|;^DRsul z#XW5e$whpVSLBVb7ad3y>g@CUX1oKte&$LFSHzOQFEe{8M>~e6+_@DDxTiax=3B^M;M1{ou+GrGIpC{LK41QxOCxz0` zHYv|246!)HspAJ}dm)jSqDtkqq%HJ#nWVv9O4^`FPGlEpM8=B_qyTuL{`oq0+mCPS zD-Yo%PruX+qDZkksVCKf zTm}?$_dZ@<#UrdK~x2lo~#S4Yh>qz}Yr4K`)#U|||LQEFp13AxXM80)=N zXp%m$vx|~5XUrxprn8v`5ady#^+V82>&PXK>u%|Qbw1Jl`4y~L;~r|uJX%$0+<*;M32WQs<(Yrr%H9;MY~;zQX1~81^uP7D5Rg_**JXhASyI2 z$7h+%uO%@sGBLA%t}wtuA@q z`T`$6+FDqHfMf4*{bE*EQwtg@(c@CBvxZ+nNwgI)zS^XYCW_?Gb@orrt&V(=pC|F- zajRBq7F#;%I(=?M$cSI4=U&~+R(M%#Ln^JQ* zCl&zB!!Pc`nbw_CYU^YB7#!@%VmFC0Bf!nY4tJFWtb6<4fc>$%dY#b@il>KXK%el5 zj~LK!SdK4MoH0tTyM{*^sg;b^5lmqVF-r<6unT3@N-K`e ziwo+HIDfG^Ym743eHoSn_dHW%o}K4e;>cOJQ-|l8;m3vkG26k6GKIQt=$&CQIhw)u9PA|3!%F3s8e%n6c zD@$JFkO}>VQoAX~m+H9>@F?pV+(C?XuF|H-BuY3AZzk@dzk4q;DofVJo@hjR9~xkkVNNJLs zep{#S?e)0;xj`9J=`J?uiyR>tcdppdVt$<X zP1vtG)H#EsArk)-4_F;b240sasqmt^F=OqD9~xIZfi4NO&bzbav|PR%XtBy;vU~w1 zv5=ZG;Vb1NN+CQ8@f9q+j93w>%(%e$T6QJ+-y_*n5Kvjt$)*JD)g0`%@eiJX5@ zM%=u6yT-gt2q-Luwq3g}6?==@9mS1a6_9)Vs18bRewra0)^KNMu)BVfMuHCWU!FaE zHBIkI(m9wC^J+B0X9%$ygKufTxdhHX{|t$n!W_)=LA<$J_Lu#326SZ9MfT-N@4>+- zHX;a~CFOR(zueyAUnBcnGhI(3eo4=>#s2TXj~5NN?7p8f<-JC&GZt2XqPvqkD=_W4 zNq2lcYA3Q>I%ZovM>FF*Fbyga+?!|3T(4tE+tcXl1xqmAuLHcEV;*gvs@RhnJF!Yc zLu$)?i|~!=VXB3VTz9eFdx!<63Jee7tc&!s-Dd(T&UtqFx*aTwuY;IIrm8L{4;N%~ zvZV?Ja^+8W8rtC5J-P%Fp&%>d(8I4E|NLwY^N-m3ewBy7K`RcvPB+?+#O2?w$@k)g z8Wns>HOpxTgr7|%(2dx1>{)UKRoR7V9*k0+9l~A`!F{}v448HPAi6KiS~6~Z?!T+o`HXZ_O$)XrH)iB9*SlkLDGn^WTqHNA+4wgcrrWQ!llxe+^LU zOUN$SWEY-NHZ>Vl=@n94&g!tkb|N;w30GYS`JT+>&8ui3{>Xy)P!xM&mVQUzYM)lE!{qFEcjTL3qX&I}%D)Q*1(|ybHP9e_nWvV!4 z^`bR%b=;^6K5a+#t5Y?ut5XnhFt2y8*eqoxG}ioc`dFz?Ut_lL6q-`RmDemyF*-}8 z!?BP#nuvYR|5J4Ro)?zh37_q+GL8woGaS_|0m;4}f&wW!{ z1z^Tl-k&Xt^3_K76bQ2qdAgMsv2I$Qc`N$Zczai6az2&J1_kw<2zKL)EAQ9Ef=FrD zY*`3^(skM|F~k-f0Q4q)4i}+*t*RJE%ck`ox%V>f<;DOJzdOdO8KY58kC13dXx6`$ zc1pHaA$Au3+%u&!kibbmy8y)}&b86GPk}kbGyfTJR+UK&4fm;*yXMBBsClbK@Q-9pDQJ*nEXK+6^ zk{x$s0f!K_d`^&1qj6oX-lpJa7VQ}_pu+hOw>XK6D<)wTA7^>E_r+v-uAwIy%9~Mo zNU$B%OJgA2AJk2d8mC#w7F&i-mPx%-i2=x+@l-Q{e#0mM7$*Idhg=LJW@7A=+{IcA zIDXV3iNh78Xdin@IZos`1v+L~4dR!mW&UR5dcw3eLBRK0c0Fnnvv%#KdH-cYRQ z$c0Ae&A+4xt8=QIV|lAZ9Q7$nn8Dq-y0E%_dX)CY0yXk3g2(PRy$^zNqZdRZ{z3XG zuc4>S=r6HkB0!`j4o)2?ncyFlt>HcX{z#Fo?Pv8vWEI2J zcav&(%XF2OxLUESy~fr-?jKVcfXL6So{W6fN6)`$M(%F2awBA|ih6t@UU=wSY7(-J zTo275!;Jt;qaYb>J&!_L)W*$mu3wV2NmE}(*Zn%?QtPURGP{CGn$^ZIT#)kHNNB4| zb_R+2Z4pNn_8i3!J>nXvqp$*us1mzt1#AIMJn^}fgCvHGht{wJ4RQ{R942eH>} zbwWP$A8&Y+l*C4mK8+kLs`U%(ytR2Md9}eNHIiEgxP-P~_}5p;W`7~^qKdH`|H!8w zw47ArOxtF=BC;g|;-+ni3UzuUm31zYWH&y%jZVyrLrAP}YB+K=Qsfsm-kMf%O@F4m zkw7*?>{l95>7@Xk2wP_r-?VL%1&DL^%UqmuH)7G&+igj%IzqD^X3>h4wY!=Gxkm>A z^TJ9U3azit$_;I%Z_{;-)}49+vA61*;ayshdRnl=53}(K0m*EVVG2dFO_y9ADaH;y z429dgdC7}MjG21vgGI~OyXY0aKuykg5vN9hMN<&eo$-Q6GtY4+_>h% zVeHCQH2{S|;ivS!igbZ9k~<^Ef1=maM4#iEFp&V43%K5Km342Gy?zeYLg6*cr)sV5 z8JenTD@mkzPbESHXHvc8$uZZic};f6deUeIm%4^`;3U;V4yI8xygE+%T2oQ(#U}vg zD58Q&)u77yzdiB&rB;F|pN91KT)aW2wFc=nWxwA~c`Y|G1|gl=wGaUvkGwH5CUQU8 zO78xeR&@md+R>I(Q#u6ekTRhXo|TQhSvx(>i$~t)7Y+JJABqxgMqp;Y8oBcNd=sat z4-(#Ia8z-9rd^p6TQMrNixW5&ZB)-h<)2Ic_Sr9d$>kD^h_A{a|yLMJ{*$vbwAD zTVU^0*On650FN`M1|SVBD5x)_;T-p7tGmU@+^@;I1f?8j6tP`>fb~{71?uc=(<>97 zIr=k#46!465d~)JcO$<%H%o%!B<;EszbJ;S|C%%3S>Ncb9cyWXpz(H2mU>?NaN#mH z{g%r_K9KN4XIfPobvKY9Din8{{*Cg0P+APIHcwZ<%$eKhQ{K1VI@VTJO&M2Eusmko z*d(2Xj&WQuhy`g`h$0vG3W@uWdH-qNIbCI7vZO}3t9`bM=GH*s;byW_VDJ2U0i!W3 z%~KStYV(A0BYCoSvLXNb&AIMsN1YaHjUH+_f&1@40*aiB`;d0lF}KB-5=C5w(4^rr zFOC8UPL}VIf2!Q|U%eqh0GkW!D3%w&lUGfAaW$9Bu?9pN>RL84^ZMY>VC#7nx4-m- zhI@BhXhT!$LA+L&qAZe-?&a-9qK zm*=T@-WR&1{xxu5rOAA-ka&eo*qTPqMdBf8gcj1FRf(|l?YPWa-#f=)c;Glkbx8$j z{)EwYWZi$i8Y`tgsao5NM)oggmJ)!)j#ymL_B z`tUi%Xn)Z?wAAkE;jDG_o5q%WS=nP1=8o<$S|o7bKeu(58t1(*eZ1M(GjA`On)+3F z`Fe6^A(L`?Q z>Y^7!nd)Xg3FqTc-Dov$&qmn5M%>@d4f^up8OY3~lifyBO=spS0ds*`61_hKLpqg( zsd%?Z(ElE!|6&HdHRFNRGp%Iir_a~WTl;cVSqMsLA_XuzYy8Sj1Xz6Hl z_g|B4ZuXjlNkMs&2}rXK7SBgrSv6v9SBkdl=rk;zqphoi$+Z50w(#@p+AzHdzvy{2Z9r%;v#NW#Cf9@bG@hq zFW8jKQb&kU%kfk}`YF{Sby8m2zS6F3t4*(^#y2o+(P2II^C>iO?=QczZ5?i!E9j+4 z@0OPQEdSHup5G*Z61Eu#zJR1mrGMygwvae(2{i@MM~67K1T;OejoLnc!D!d%ZCd)z zi$4xOTTyaQsH4O{D%f>wZhbG_6xWxR7d0u=C1JtNC%lm*{?e&GXHe%Xv&n{&lz>4~@3*qw(N^!~6=U|YVlIQqJ&-C?`WOD5`RQwe zmF`-0OGP5R<63nF#!$UPX3un~1c_AcT;z+Phu++=1D|nzN6jUsOZD7!S{ii-bnD91 z$+-<*mq?TFz&62HJxCzYE~dMoB}{p0hc4>x!vA6GEdQE(|2}M?qN36%2#hZ228jV9 zrDF`F1-1c$(V&9TxzVX~H;hK9oI?qqMk7Kh!tG_Sl zZC&b%pEtId@cwcxLPf=9Dbt{Hn{TE?n6C~~-qly~q!Af=TA7yr?ogRVx{4FmLW{WV zja}`!Cz~F_Z-aaN`P9+UZRc}Kw#_UXkC@V${8EE1yTv{x;|GyJZj@67akjtE%s|4A zZ?mGFOCweA^%reJnx&Z%3*!L-+mZT3w`3W|gklv(-XoLWzZ0KuD*?17HaL!OP^aIE9Qf|+kdAk+(HfUGb zzfzFb1pnLrQ(f12VpEU(*yKa<%L+6RDj4YSuDnU9Ne&iHpVn}$(lta^ZX1ExE%%F| z6}jFWd3O9dX(ClEoE=KK>MtVYbROly((LppxmhLq4NMnqvfeN|%gH8DACUUnC#j~B zaH-jJo6c0$n7OOm38%B z0^YrCDUmW{rhN3-GGt=@W?P*M8;#|e{qN2(5b3&#%E=Ry1&WlPH>BV!*y&VRb@ZxNI{IW(OE27{AAAsvv5r58Z?N zCHtk{^~r@BE|`MDVzJ!Ez5NR{vZYSA>nUn)i7A!+ztr`!^ECs)o;-3Q1@LhsU2Zs(To_{`AlGC65<7;TY!qGeK|zj_A{6GKE{MZWI-XmY0M^0*KX0y3zl zQ=2xk^k9?e9VuHQP*IZP3o1-6`L9&Q;?C{{Hdl|lqUxa9wJ5CW^%YK+3#F#x?QaVB9NFa zby#`&+W628Yl&ZlGBRP^Qa^rHPP(WHfzmB%psFn+W^`Je*>k4(-H><7TAl-IS9v2$ zbC;U~1afzs+@q@7*#fhRZF*KO=4DAI)<=XQQ4MHB409s)4~=dOlk0c_bMwuulVNxs zgErd;1rSF3#U0%Mw|~_ElZc&uM1-Zvm5(r!FyAZtBU)+_>x8~VW=<&8d#brS|4fWI ze&3?TDQq8FNKkK%n;akp-*&n?+V&<#my5-cmPRoIUJdIBnhS;jL`0*JU}lEU>iJ-) z#f?nz>D#cM?@r~U+SdD$_vhBPmOh?t`Yr3c8kU2!yc(ahY7y6TV21Kh+a^wcis3}` zmkK*rlHCD)8M-ucY|zHW%#Ubp1(QW)mM=+xHAY&_Ww7yBm8`*J|PX^2tV173pzlfxAW* zrIK&%TP+EEK#|%155j-O!)F~hh3`kk@R{}&n>uhkoUX7XdqYoNM_F}lq~rBrrnfBq z@CH0P0nwd5{0C>UIp6-4pOuhof@IG?`xOe1H&Uuty?$;=!xqT$T>qp%8mL~+X?r=5 z_jf9>C3X;;)8xqZ`Gb$i(06}+Q!pQ{OoTe4a^@wNG%&|It>C-L5G~hKn7sJowjM4%@qxp2@Hm*@h{*kI zyA;W|bF3MAx(^&W6QtYW>PnrHlN}(=fs)jxYr3>gqI|y*hVCtT5=duFMV!3X5AppC zI+?{luUKi3^}_`tRcd(aL|{OaVgFj5M+b9ew0l%}j80g(MTK(|+Rl`3@!|RtH!_{y zIY?Yrj8u^l0O8itEYEWP>Ebjjr<0bEH`*$<*Tsw84sCHPDV$&z?XfOW-rr=Mxzge5(YM0A!ZuDS$$^GiO=vTF)vF(hx9! z?8O#ksdE0g6}6*Ip}1#mK^Cm@^(}#YLs@prEIU0P|0vUO%_sB--VI#l%SSXEh`w&0 zl88V?qK;T@Kwg{K`|udq3nBkVjM2Lw$yR@l7Ek`3xPr$hySfM9@O#($OzfT9T$pKY zw*f?Oe`R(6uB7Jlai@?ja&X?k15C;tqWgON-TZ=9&xGfv<0i#4PWalB48K->zH4XO zna@Pgaz;(=E|~$Eae*RPDorzHc}8sHoeeqNfKZn4~jB1$=;(tphd^Y8 zRiQQist$1*M(c|*;SH#OGtYM@Y;->Cl1Ek8ks37kxm%MMwRg##5f;W#i>=x({*^WZvED`p*#8QF_9A$UO zU_dl9Ae}|==tDLB{KNC6x25~PD4@Ya(F3$d+Wv-`vcCqn*W7!TM)>u_yFn#e*JY2& zyKAvEP}dZ-vS|gh51H$jl4d)xh2C|@`~+aKVeZm0e7k*+|5=<~Frh@%Ml0Mdy4PT= zD@kff{HPPhJIa)_o%0}a7V4^pV6$Ff9o4Kbcjm4475PUX;ok6XZD|-Z;=OhqL`eD1 z=u>aR*pR_HRXdvN;ba8SdXwd6swJ8sf>vyRd9f!FGN0v&1Gz zn_((vYJqH6PSHNb?f}-(FfM7w$Kzy>W_u%2s~=rAqc5IAOst$~?OGLdrY)y9K};_D zDW$Sn8OzEOP_I0Rv#N~iE53EC_a(w)lRuj6#w?njDRvE`^MHEUiB;ov~?WFZVWt3c7j z@O$eSV5_x@SwBx}1EF6d0&?J^K2u8d)JwZ}JM4^zCj^_3w17+tcA}aY zI^_yjiaBaOmg7zxV;{pDU8sw|%;X*mwqg;pVI$lcG7=pG?Mz@!>4)v>Oo z?mIoQ1tkc~sKhK&Bui9FqC;FoOHMSB8{=32^o@MvxF1(mZzjxy3hF`-5oH981l!?c z=VbCS@?=-ht`;?k9sD4tlZiuo59joBrq?-lXKvvzqc~_uUCONTSLKVzh1u&URysX# zTKh4!HG3NnxGb_0L-~#(VLv#c^1NFrxKq8}yHTjlrw1zq)%FE>uji&Vv}TGc z1G7DRjmVg}!X&W0hcS03Xld8zF`(Ci_<99gqYK!hd3FsQV+ySQJ8x$eEh3nARD5fZ zCpBQFXG>C-uzB`*0HNx}&-4&w=CUwv_Kw6fa65V5dt`(k$69Qe&DgE`vDb;L6a zq9E*1-u~NM($Q>*OR?-ZVqDsBMDI#qVdj~$UW31&r4f?A5Wad7Q6*4YQ5*F-!qb$q zk7N#`cYbY}*+~2mU|7j4smc_|fD|8?aG8!|VA=RVFgNtHm^O2lRFFp5J?!;W*I{yE zVWUW&GI;w*=GVQod9Ta1`g|Mz7h-Dpa?yEdHCb#u%Qk4Y-Sk2r5A#TS)faH`A3!z6UnxOF#)sqCmMP*!pU)6@cE?eD4~^=!r6CLsP*hv8^gJi4GYtQ~N^?ke>OvmSm& z9BT>uYSUJyJdP*&%8vgrfBdn(MUaHm<^Hob#T$a&6BNDJu;rH^VO7|*TJ%6S2G(0s zW+pQXC1^~w2YzieJu3X0V6&kH6CK}h4`a;O_KyJzm3~6XbV`{{bRiZVCF9sXdxYD` zkx1=!J(@j|@wU-5R&UVkTk%G1B~ZE1 zChiUKT5Ou1fOM|iM+XLxf(DQlrtai*7WCp{t8Q%TMc zC6cKN_xLo?>m9$OS3SP1t!v1fsvuC-=!{rHVVIndffx|c`Na%(hoTg;!lg4=&-;s$ zqcUcpdE`-|^}4|Rg7?|XFPk1+@KW(G0+(`(cCJYI@Vsh`N7K%&Sghad_2KLrvr#JI z2VU&?O~jN#@EoLK-=Fo21h|mKn22d0yF@9@?97(71j0$X{ro*GmGFI$E$`{b-rA#i zg(zSO-$pq?f24i9g1z)zlK1c)D8JeNCKo;u!l2XTW}$VmwrwNlFrLL6?BIq8iW;%J z#6VL8R!-#hugizaQ#{h5rcip`<0l=q#xlR6d-ypnN#Sl4lD*()iKx^xi3Jn>lq^1m zqU!gVgTn^%%o`+a16zOk7+gZhVJ*0Z`HJ+^wPOyMa6m(Ty(J{VS=M1PfZve)aP-#^ z)jTKKgZ17pXJ;Jou3g0-0}znJ-zm2(BhS9~)9Aarh*4|lZK&D=>k-guL;196O8)4VCC zFTeIazbQ`U_}aF#tM%+k*TyG?`TqT|>hL3Pluf_F?H&b4Zj9dbSi5JntVxJNG?t~{ zx`ekc)_$U{1^)V!)_Xyrn=VYsvo-h+S3 z%k_0@;g)G98s1q4Ua4zEt^vdRegTRdWLH$ZUK7StU9PqXLt1d>9&*>=T9F=O9+&`P z4b~#NQcmMKXh^wy=|^en{B)tWX_Q#nG|K7swuD`RHKZy;ici1#nB?&)U!%o)0 z@5yQma`h&;iFDgRLuMbMLQ0yl;)+%1D>{0R%ytJ2x6rTVHy{5ar#=>}nL*~B*DyQZ zp;v}Zuw5d-bil&q`3B1~)$;GN2_<>fjVB?FnVtHw-$?7z&psH})%xBAUz9^adz1KO zN@V!F{N%;@8;=fuw&+~n4uO9`f=^iXx2^{8rS;7@0(oO%{Dwdt%Fj96u`hX+ChDbf z;-IQGkiXq{+&4mW4m8FI`N7+9t)RYLw^rI$EuZ+}IA#??a9sifg4F=rL3 z6SUC&Zo0A9)`j6!{fOn)8N2HEL%cb{pjkDo{Q!|1Yh!Bf_ZZvuo1Espc>*nExWPCN zxDNeRts(m^enHdevP^=VfIZlZu;ER8U!9LB6`IlV=%U=*6DEIMs^{COoTPn>;_{jV z?6VDOhBn91IT^FcmH5!D%(0hw+dM6wePcTZmW^?m%-xfP>yj#7AE z-qfrm3ruUN7u(=ggX^$vyOU=*nER(}#geTNC@nid-Vm$t7l|6#1Ah1U?+?2FaM@mH zBC?*Z`>LL#`s1&ff@G!6(80>(nQ?9!I=z-A{whAylv)qVjH7^5Hct7^@{d2ry^IKDv=hOHKM^HBh8|=SlgH|JKE-E<-9WMFY%fz@()w@Iy@kGa=OP_QWKGn z!p8ASggPTa5&MNFe_5aD_fs({Y6IE={pU3G+qKZykg=4gYIkYX5zvI4+)JJrl+M1; zo}BS`XK!hDU#rh$((|G%DuEz z&MyFg{HyENOS6B;ibvsO7ylKAJFPVFsELJ(z}FqG%E=?pSa|ucWXSou=G3CZzUC6U zLG51P(0vU@V+Wn|ry1Y7i*K_!?);jxRuo(F|K0fz_mRuof4`E3#>l;8-?9^X=w9CSecJBNCcSm)xLqD~^IJO~Kd+b_2+LKx^ z#>)@w>GbM?`}e>)>pvzb*32IR#pre{AQG|eT7dK#XZ0D z^^~Js*n_t3v%Y(~du#si<6tc2aWVSu4vdng2Q`8B%(UF&ynGZJdRRU+^WUA70mw3Q zVw4|y8beVi(7qrykeiEUV?ai^(HteW$fQ$%YdEsiQs_u^nFW0mq9j>TJ=M9Si1NH& zigJN5dphMfw0V?Yjo_EaJK7(}sn_yrTyjs`jJnzUTcR{xh1uk4r?M5$nPDr9wMw^a zj%p{#UbWU5s5)7`ox1pT>mq9TFpOrwii;ErnK~~@Rh7lNPPZL>V^_0nz|f_=HZ1xd z6Xorv$ZAY^*H4}0)DO0caufdOQ~Cv$*YCuYn=~8&$*sw)+=>45KC# z|GB_`*l(uhbm+vNe|7m(W!xf0Y@xP!&0Umf#e76430qlD>TuV&%kq}N97Y#eS=`*Q zPP$f-M%S!-a@O#r+&|SOW)#=r!Li0L)fdkib>Yt+Md}7nG=zZ#gxVRsG1Z@dU_k1N z=T)I~U;MfV6Xj&}0>o128?o)Bb4P+hd?6EAIlo6m>j<7in-ZKQ&XiG`nPqb;KUaMH zT)|+P{>=YgTY4{ki1MXLM0G3bi$o3HtrsVY(HZtQ4R?wS=kDW4Te`J~M%p?#NP;sW zDSUNlHC!niOloW$TU_>$1sqpL^uB7IWvoWnE($7{`Jex&{p3L&c5YF53)Q4(}&SzuL#5TL<*Oh)ZX`6P_#dAN~ zd?2;_vce&ko*lx>CxdVCW1@?W$JAA!)6%mA>fhQp%w;``A#YeO!Ws&V@IRA*DeWw3>SzZo*V$KGMca5*a1ZDhq!3Kcy}=tq)HQ0^$GNxiLPL`@DK2 zT;9aJbfX#Dsrh-gwyMcHUSGfDam>CMtDUT++8)#TjAfU$VDODo<#0v^=@F>1tIdby zt#b(4gM+R1=)s!0VZoE48H0HBEVtd4ifE|~@PHXKo11Z>Zd78~D zP%3tg?eei!0(LpJ&>ga7n4StbkJB&Tnt|C$PH!$a7uAU62QKprA>TrVra;^Msr}43 zX3=Gi$Gc}Ib{Z$*sr@2@e3TD4_+m}pnZae~$*q8MA0c86+lTtgWrj@yMmHQkEzX)v z6)X2#J{CVYN&134wS3z1u02IUgY&Df6JmIOYC@HSYTh}=mZQ-p(Djw-a%x>Orv^10 z4)Zr}$pBp3CsU)Z8$hiR6*vW@Q&PQ+N0PcrjPFs-#mibdzFs+Fd;kuPFm9SL2Bk^u zb2f1mZa{PS953vmCF)GcstErb?hX)&w3S%Yyb^;J-gd+OJ$77@I#{<)aJtrRRv}aGp(OJBMSX3uny4{ctb2d= z&)YV$FsVrVTW|~KV-7Qs*^GyZOX4(RhN7rCXl_FMVh!urSJ>b1MECj&gCjR&%=)mH zN`BYEC((zzl>OB9f`F&Fhk!Xg*@b$zj1O0Ido#vI&MFxxFV@bg57*{{n9a9rwqDn^ zMIPdgxl%%&=#6J_z^&aQBx>C}ZJP3Yz;$0F0s@={BXwXs@~N=F4_97xHCXklKq&T^ zyP})9xJRCE1>-RL2}$R{L1Z+(@>%-gZq7luGvf(BHGbSV(U%bIN}?RI9IxV&3!W z@>C{7v#HTLZye11yL<)0AaYyBzYrb~4~mH}(W z3b?EP?y$7LBnW;14~7b-l&d77y_XoQkKon)qGBj^;^)Uz^ZQ;zru>rXIgJLGle{qQ zhyXJ#Vd39tCOcxT#d6P*#DH!fBSo;eh`B_GcEn?xX0#-J8pr$~5vNp~GfA-E%4@_|u50L@K0L zO|Oq}8O*Lc=o*(CXAbWucj}ShnhC?n_1tJJgHtwj{80IaV)TzJ9I>z`>@=lIFtn!3iKfJ@?1o9TTQ=6ib<9+e8<|SwMrEOCG&{ zCtnq>FI{4%Gc4FSWVMs{y5P>vA8WX?pN;pVq5p;*=Vg6&lo2T!R$Pe*HlXlGryj-U z&p1Ia!mTm0TI%rEvypaoS3A0v z&L33Do6&lWXQo||$)x|P4gLjAZFv4AeSCQJPh%=+2<^?@n{Kq9(J>z|$er!RlU>c+ z;d>LXl4oHGp9*Z>sF1+dcwPCuIzO&<&YKg>P}EE*#OcCSPA7S8a#k#d=M7b<)G#PL z%#K?9YM(l{NE=^5F=#ORsR%SuEd1s%41TY$udziV8WKzC!r_uFRJ^uD3G#WB^jo?1 zd|LA}={sd&$N({n{%U6VT~Z+f<<}T3G-LXSuVx&vf2t4FxozeG^Gm^W5S*x6JgxPS z)eNiQky_WP?(Au;5g(7=65l1|lt=ob@kGo1jXlU6DcSOu5Y)LAY(5r21g8&nWEYdd zJS-F|C>CkY1~a1>1TD_~bts|&rJM^F1qRS*VV(4(WjzT|+$~|eK#x$bpRs#(8O4CcHoj(3R&ysjOsa#fO_?q z^e0Kr_$Jo(=daM$3yDdNuw66Lh5**0U*YZ9wglF@a4-9oimg3*(|J0a^Kzc-wP`JTx7hv@K${ZxxMS1 z|G3HFvfz?K*}U>9Gbd8(mCAtNMosilMla0!*U^)H_R zVXnbY9`G!2ELy`hgtymjC6!yqZP&LXRS5txW=R|kf07|_48EFPX9x2;KHBng^YRfH4xYU> zOd690vFrTHiLUF=_BeD)j!+N6*eurv&o~>}_9tvbCdy7__{E}H0DJ!^%GRzWtO%@K_d$3a`|ubB890^r{;*Yc zDgJhYY~$hd5VC=@Crdn(jl@}w-I0CjYnk@rHikRuZG*i3-EsP2bHNk1(2qB1C6+wC z=~D}U{QNEwEc}@v=0%?X>odJbr@wbin%Xg$R;5T?_bwUT^$AP8GHERk0T^G{CL*Gt z<3SMOV4b2V6nZw%mPqxdv~suibRW%d^e09pVvfL8oZt}O*cOxIGv4)RMn;mICWNgh zb%I9@dYl<+^R@0PoA+LIiBa*k$e@UfBn*4JtNPqH!u-SE)Gg7n*1Iwvb8Awr>7&t) zeCh~V9|@stx`W9&1(tpyVV$%>9f_u|)~*UGQ=Ne-O zuXRMecL{yQ&}J5KGaLzw=Se7?@KlmYeB8Tr&n&czHm~#@DG}?MeSD1Cmw*SKT%-n0 zuNnt~pL1f?5CUdUEelXxTl{mmnMOYO7T9PWr321CEvKBffZd8@ z#^zf>Ep#HVBWgANcp775pU6Dr+wq%iAnJF+KWa0X+tN->C z?^gx?d&Jpz+GPnEi`$J*iGnh5CI(+)}d*;*ZyLNlT}x+tKtM zPkVbEm2r%=c+K9#uXU$W;5#7Am;J4huz2>LX?{^LxJBo>G60)X@lTaSW1W`2EQ9*Z zCQ}uIrzpvUiFK|v_!PkfZ^0@5$nMiit*xU21lGbNPi`P+3e+=5uO(otF;ap3o5qz7 zm^jW4Og4B&kB@g&Z=-Joq>8j%U_8Ewdl+L9lm@7Ij4;V85&qKcr5^Ss-@9k124^#( za!BpC)pI#`VwFp+D6*TJJXzZMuz{uqcH$EHQ4VZlqSs$zWQ@D<)KitKNUQp%fbMKb zGmh7uI75mix?(QB+1mlkl;l>`259x`%oIfz;0V*jVi`TX2TM21ePCgV&MH$*8>M@V z1BT#|r8z4zkcs6Ka4j6H7Tz9GSFeAmx3OkqeDpkV_K%cHbPQ+L>(rVy2;f)KlP}*c zag*Em-@9mznkIct4CKa83lgxThh$FC$ynBUwR|I+0o6;-_7t{&d*|*WW=QkpB$J}cDXE<)Hung z$w_l-ChGw<;6On|9s#;U)WqYp?l3%+89nWD=k zOMaH7#pa*6gd}^yFQcoSer$Naic+C{tl)=FnbyTpmgPDYRaggjD4xBF^4=xGx3ns= ztyZ#f)iu%w>TPYX=zJDm;jSpHyws?r-n=fJi+!dxyO5yG!UCWFF7jsxE5N_QBFce& zD3IDC$=_d5vpNeZYTr$&u84}8Lw;E{wJ{MWMG`o~b!$e)adoH#r-HO(=Gsip@ zs;6&XnMp&Xq+b+n=LoI5qx9)fanaCwvby7UHq%S&x^62@$`)r8x|IYM`!sGWoakevt+Q(U^w2gn^-jo*s`RPO35>!4Z@IKrpe|@U)*gHd_26@|KI>H|w0DP#l zRmXw7lDFs^XZ-JWkF;E?q$2$+CRNKQ1pS3fnyiI}a)C>x-q&l7!(r zbeh>G5$b7$8|fq_g%s zw?_2^i=E$V30*?|a)KYA?>e2h=qcxwc=LmkaGm*!ntj92@6YG+AnQAbe!s*99;)1R zbir95%*K{2&Ki3iCk-_*ZP@l{oTcLMD=Fk7Gw$o90F}) z*}&y<8tCKYTksVwRG(oO@4pm)4lwTctL#`LI5t)}yC!RB>RqqYGciCXMvjO-*YmPE zrP`gd*xoyUkjT_XXz2J0f%^ir++)-JyK^bD{!YujRL9Y$aKNJDzdJzEv!q%BgAY7U zVlyA-;rf@H)VU#N+w)^sk5}`45z~#%fOh)^aQo?$Z+DKF-QaC_*YQfNKFvDkOx*ci z;AB&H!E$)AUpa^KG5P%LVI~G}aZ_EcKk{85t<68%QhQC}ut~|GW53z=Oo~d~TAamf zcxc^)GnMPWyN3+tm$^Kn}BogQ?bj_94!(`$Y>E9VQ z>sha^t&gFpl>R%vwj~I~N^7%cdjH*_P8ger@%JZPTs+13`1B-bX>Ng|>RQ9_7v&-T z-QD)|1)3<1C+y&?Z;O9VD(p_XTd=Rp>u68;g+>9WQuF<6_DfRVFdHrF+v%`e_oFy_ z$JQU!Lp~WNwcRO;BkB45O3teG05G}H!ZTQ7>)h7u+0SNoRN$FUZ%8(3qTP`2*8O5T zZ=sGpm5yQ~1Q-w4EGgn+@%HHiy2hkFeWYJOoF{G4c>8#zKd&zo5tZNbYEG{L8q07D zl2FN-FBK^BQqXwg{m+I^@?0-(3y72PVF`l8z3?%d(%X)=Tx6!p@g2$zB@xA_!v1}& zno#%%cE&-q%y^aMpbCZJwTzk-QPK14=}zrcA%oMj?gvSbw}7HsVt;&rq%nH zMz=|3$wvM-G9EHLtyi($yV=VD8+GR@w%%jY_g1GFwmWKIer?8oJ30b0$HjDn1zTZ-*9y-kPyjyNqpdT0*ZUPgV$PYwONs`XM-Q^j)gVsc{KM?3W&f zPU-gijJrU)u2U;0Q>2p(f*ZeM_ezW?dep&hYWyP?lzupVFeD2XFw{JLpN7FOiOz&R zT*AP+KJ&=zk0Yw|`7txdkheG}V|ad`>b)D5b0c9NR|+Ao2xj{!<0R8zq$*J%8g3Im z3N9C`_3tUs(_A4W*%lQX^+YwPd|Qf8&&IgMg&Ti#6z{uu1sxK(a;H71xtr(1$g=a! zX0fl)FiK+uQUhe^=^Q8^mJ-%_do>r7MPM#Vu|hGJ3gTzM8@2oK&^bpgjp`3Oi}J{Wgm0hW-`knxTwUogb`EqrSofiiwuP4_ z4Hm}x)F$^LLdS|AcltOC4+PO+D|5r65y&*A}aZiAk)NCmkyROccf<=k++z zX8Y{MQ3BQ#GX+KOZ9;8w&pHY-%LJu_)!eMMN!`Gt!XM z)0h0kKDC=~>`hn7!4RJGeM>3$%(jyi^`Npi@gpq#7d$xpiR;+eyfxuGeB!Hg2WLs^ zJ6HIWc>4nCAC%w{uIHJwSb52^IUp2DqfAp)>#=&A@$IV2-%Q=@fckyD3z9;$Rdt6F zj28cD*y1N3e*$!k4d?gcn`ha@D42Z60P_}8Ley(x)rhc9g4m|T&kfH#eKB+!8VZq$ zt;pusZ1B2sUrCk)uYcutHRKAA#=>D+t~N&6W!r8rg|@=3YN#Z z!{?nq`Chr{8x24{nuJRv5ux22-;>5I*q4lgEs3Ew!KS<_j**=*8Twjl8G_Mk&Bngb z=47(C_4mZ(?mUGARiv#iQITah``P+1lhbltV*V;}d?4pkl)t3cvE>W#YyaRzjLf*s zLer%x#%p@A;{JFW59ZYj@`~QbFdioGz1J*&dS*5%S+M(@edo1KAhGpR>gP^e@HtjO zNJAg*5eEmI&flqox`OS!oaA&ofd?Hh%A^8h_k5SxQ@7rvWf?p0mI;w+bYqsHebr$G zB;1r}NXed01uTa%RCbr)%}2X#2!)#1U=+0Uq6ny(W6iGf_?-unN^w?0S%i}!&WYLJ%oA3AL&#% z@_H(iNypMZT#s%U$%>4;tQcFa`qK5w_FJK$^QNcR3pIuBv+MaBg3rn}^hHtX__5%Te7vmF&{)hw6){Di*F_TYTzIp)%B z!>|Yp?4SlW_Vh)G>=<2|Zr7{DrSJ6aetd&o2-aa`)P)vqO08kY2wtYj#lndLZTnpr7Zs&R4KQq~GtMa>>n>*pbH9Oi?ihM@AdP{KP zLyInR-noUi`WP-Caz$m+Dhh*cxB_VS8WyIUC0ZX#8ad{dSvUWHsC1qI7HWWnbDxC_ zDx?p#*u*)3<_mKfwO)+u4O>%}X@V<3T4Ko^iZvC*5YC?z6Vb8pp@o=4<& zO1_jw?%-C0SWgvOc%Hq^5|=^~;K|PRI;0JGxsE<5m!*H%>_quJnF&xnvJ9VNlp2Jr zz*&*x!|Ep|Hj_B9>PCz6gj_0!YSzSv)z}VKz2u~3S)?Ik##xoQR4<`({C zO&-XBRC!;S@_rT-sQ(NC41Y{`a<4dP`*JAz*>FM9;YzGW*=Co1Eccwj-w$i9|5)_7 zq3f#ZM{GXE#};qyu<(S=Q)=0Ul^SKQn0owPlqcikxiJSNtd92qE*i~FN+eeghlIs= zST>$Ug*s9F;j6y+FE$m;l)% zc>>4vH`xGvz5M^~yh^s)O+C1g;_zv|TX1O0hxZuN&h3AMOpb?~bY>5;%o!$GGE9N~ z&RMDq8U;TwW`DPO>h&-PwauJhWl~bk5Kiu`spI(K#cS|~^K!k)ux*>}$+X$yD8n52 zn+Eo&iurkdg)MpX&>KiQ)r>^{-woIpEVhb;0Bh*pH7wpjrh1KLf0Y|qS_5kE-$G}N zW0vv*^D#`~Wptrz(ZLVi3)U4jlGl;p&1Z}Z@E0K{^WD}bj>&d-so8Y6{&vv$zB1L9~ zIV`|bSps9nIs}MNg#R!tF>hP9VP2Yhf5n2~0qu)U%Fsku3FWy_PqDLfXCBIOzfE0+ zr?wpQW_x%5ll|%^abWbgp>i4q@WYF{IfBGg?}$p|QvZ~- zyV1NftLGM&!h$!uk{?nj1k`$mnFn;|d1F{X4%&B%Uvrlk9qT{n(I$RDC>3KEJ6e3O zSLkxd!;g*7pc|Ot=s$>b>Af2jsZFg7JM5jMjTw7I)W|Snt;C8c_?R z>yva?@@(>npOj8BX)wrMRh?5U_Wok@EvNQEWy@;hy`q#kKyRbq=ue(H#HPvZ0Oyod zO7(r&N$7fn$FQEKggL}ya<6Z#Brw0rUrFcmDP2fXnC&^ac;|6>!IYkgq{E$$4`Kzi z0b7?9w za=G?H1NPT!>(AsCVn8UkSajxj!pFY(K2?c3wF90C7^fwobh;td zk#}@!3>ZgB6mj>j!Q}T03+HW?NU;)!;{i>=pW7|kQe?llkgy~uZKd#Dus zTYh`P#IQcu9zfIOB$M&(BW7a(U%1}!5=0SUi?I;cViu=`V%s`ZK53zuFqrA z(wVdqWL)xZEaFAP%gZIpQe0c|k+^5#ZH(S-t(@ zdaZi($X+}Lw&YyVC0f)UbH-ZKxG?&{A%D+YI3WH{xoy|$XrE`HvI_LyQk&KPq%4Ga zChZsqAtGW%W@L*{egoI9_KbVTEY+)@ZSyOe@(bjS`8KOP=G-LrUVGSAtt0l|o#J2k z=GcrHRUg3BF32NtRtR09H;kkw!6x~mc=K2X<~5QUBkUY94C(VyO`<*;aDp|?ntMV4 zNhYf<_8oH~wh@0ESqpZAP?pq2vKuCbgPRE%ulIDB72jUfIK|KE?jK!<4Acy2Et@&7 z!LCie+nG|!p2?k>23`JR7<|+9$!2}NhaCGj88RMV+fL#~HU66$!swk!ogq3t&g*%k zZqzJS)RP?3h`LQ7%M6_g(@h1&kZSj*auugMxl7Ob^0290G=$O{+MINCAmEIs35KW@ z+YJ7cE|%8f*ZLPitxe77oJQchsA%P2`O=;$?!%X8b;%$Y>_ZMWj(cxj0sE=jf$WI` zT&ph*V9^@9$kI9T$bw2o_MN%GX*=nzVL5FKkcv7_U~03w&mo>AgoBrvlvxe{mf8kG z1<-tLJ>;5bo=Kq_$v+5eUh?RxN&oCKOEh0dxov1@nzUTIbFt3E-bWp#{&*~N&o-j? zPF+8xe_TnYAx}z>x&-r{W?P|=lZWu|i>EvW=h0GbNO()4%_JvwMCNL7>k9SwB(!%7 zYxq8AefrI3`<3q!lw$QfdyuB=y|B1~92MCKiirxB8)2!4K}v+jHM|MrBi}jJ=5%zc z7!p&T31}|dG@t98tYLWjHpgVxwkjqe4Zdd3W6|~l)9PY?w6=>W9Aa@ea$)H zgk6iT`W4-x6%3Y+(IWymi^(p^7aarc`S~l$hjzLs4X^t4e|cb%_`$9P>uey`>+v^< z&^;oFSp}Dth)t6=ut{jDi)+i&be4>d9D%HmA5xk7M$tWX0PhI7LIPDR#ljKApn{s- zmPDztG-*%HUB0>WUnfr%({C!Xies`_Vne}v_$mIQKw!I!OJs+<+_;5r)5_rd3P*EncZ85hG}0vYim2ok_&>VNDxk?XZvQrd zSfsRcgCJc>GsZw-#H33_r9(DgG)lv0*kGfS9x2^5Mt8Trm-p=bANTgavs{{13B*pdc32He@cJtfEgH|YP$ayJT{U%7pl-Lu`X6q{~CCeODm z|AYn0)x+I|RyLW2o6~SulmhpG2lr;znw<0-+Q-2K7O%B9Ghb;c=5*B^Fy!)<>2x}> z)MmN9{Jynsv8DzqK0nq6 zRQF8nC>lhy6RmZ+6<#ts`hlBRm4AD`d}LK4cUt=%1!pqj=God zZvy2i$UyKDZJb1MqWI)A$T*_KU8hr910F$o@%B1j5#uQtG+oFTR?ArI7%P%I_6f&H_h%Iddd!w zOniWJ7xZh1*f)@_-3j|MYEo*QC=-mdXiwgZvG{lNo=f(7Xf8oGd7bso5h21*shq{%s zU@r2171`^x47@fy{R;OFYM;Jtgh2kgMTLLZp-D?~>Xr)Ha_P+19;X)5y~_xVbR=4+ zMhc|);XmI`269Ba$R>10RtXCC9#T!k4zBuW=)ZG%Dt5S<#JI75FBmUW zSqI=Qj@2xzex?8JUFuz0-k-xtY06j~x86Vh_7$4%DEJ2$yZh~3$ghHS>PTs8`e4ir zZ!=+rp~jmww8vyArC#E!_t6X}s>rU)gp;R8R`t;pE18Ys_Waz6X+5Qf59TT*#WW)KbM<`>O-ta zl=;=A`uj^62h_w_#rfjRt4TIh^xj87n(eR0##ZpyGLD zmU{)UAe)+UH=*T{d?Uysfk@*c_qr)y)%%@FgU6t&|`E1G#QgSe4* z@%k+HX?5?X?%eBQEL733bYox&`Dd~2k0xm;=*~jwEw?Ss`ZcNin6mrC3i>RRJO#*x zbQKx5*YenLHqAm#UGJWRT_pCzea}bWW5G@9+ZN5eb7zO`)?MY*X7j&N%wPDsi~dke zk}qIvf+UJzgjfgnu1x6$SdF5; zZIMx0Vronm`!-3kHSlqM4wZZh?ewexaQjPb!+lFxGRYcB%hq1sHb#qZ~?k}i@u2Z+^8bP z3tiXXlqBx;x3d7hIAgPhLmvw2>mDUyUX5BI(!Rp_Jq~~Z`YKp*7Gij$AVUI#p#*^r>o*+ zTN7^+AE1A3;dtEl`6qqnv+-PMeJZx8jVY54dXX*r5oxBcRBu}VN=Jc?c_gULmTGFe%{Tm9t=;^s4SN+I#{QG5fooBF_CX$i)Yg&*d;b1}mwSmrr^Y-k+GVUo}SV0yZH&ta@vAE6*Mh>(=1S<=b6`?;!G-``NF{sT4m_IMx0hDPyo z--b#QufQ;4tNU4a<8~)ijP4Dv2gAIE^L)4?);a_an))LEF9@TmwVawY@>dJDHX?{` z_xI#bx3IpDha}gvdidr`kZ20WsKps_mdH`4s(K#+cOFzN_oN9lFs9H<&zb~!v^hsdm{G=e@AuzFQ`rBM`ccyqy-=R87w}9Oz$DM}2fifHJ_C?^~!w1d*t1IA6 zJ&$jW<)ROJy?_eiE^~IgB|XL>UjoLMq&28H45td=FOAsH-z5E%>R;?H4YxbJR^Zpf z5HE&AYUN@GooJdSyQNnn=IW2%Wb~LJZfOqK1VVZd)LKyy>@|@c2JHgA74llF^^<1H zE1FJzqP{|AA17&;(ZGU;h6JlIJvI{(hhtrv_|zy2i}8poZ2~Yn{CQw$Vznv>oKIWO zXfVNvKFU#6tGd#`=pA2YomcE+Lh3hWVemoZ7d{_hnJzQ5&aoO!Zg-`|mYCGGD?d1` zcSvrY{&meULX64`h56yh&iJ`lb(Jje>Km%@2TwyxXHhk zDx{JSrm0#}S6IY!&wiY>^?NJ7{G`~wu+IYx2m%P`8J5E=x^Myu7odX3lCQqr*;R#X zD>hRcLdIw|Lh(<@OQ#UIn$7fMJtMB|CLlJ?8swg*Y9;ZJ?o)b2J7LMJg|G6MM&r$Q zu^%_q_=XrUAqh@H19b=5v^}p(Ay`zB1!UeEl$)WR;<9P+a6q%1x)77;#T~6w@47!G zD>KMdW|L9iC4+++srYyonw7*qcKf|gJZGgz<4Mo2b!mA07~F|=W~d>zQ0ZjvJ1Ai0 zj-Bw(CTkD{&}r?MHkk9^gPr?L)~fu}e-% zl5XjK7B=q}ShTuB)6uqQt!F|->?ZB>1B_iKEej>BWmowV^)uW5Zhib!Xlfqq#Nplv z>a2BNeuaakpVPWBPcs#lm_{rn-u!D4VpVahj?7VRl|_pB&-*$4uDa+S13#FywkuXC z!zvAnoGbeZmpgqXp;*nKKDStdz+ZW()zShR&rkB&D!r4dB3&oxwWyL7}DW%JF%yqYE!w1YOLozxQnyO0B6?pfSow~Mn!3To|M!V-7vcy%crjxgstl-B4{l%-|pFP!8)nA6^U1om+TR5!tRt(Rb z1|poDj|AKHt^KUc6wFN_Yt^$GhlNn1j%87jcyg0fax)v)-G`MNF4o@EC2b?k={~9q zKL2ED6%h6*ypl}CSj>5j1mpZQx523ZRI0!8;Ydo4qS9*zM1+C8l5oRfLZFoy^4Ysn z*Ga7QUsScV-d;5kxj5a2$8kpLMdCw6R-3a`27WDZXF2Uz`*D<;nA4?!fC*7H?Fe!3 z3@-4;VE~P;QuD5twz+0!CLKca^>miZ3f`)f@F7zMtA3rUMA68$!pc!=_ zcDBv3%Ujg<)Z#_wNt)ARrFB0yv$0euwCIT7Ok!@*w3kaxyI)1;qe<1nU^}^e-3`ZA zPH?D!-DFE-YQ&^CsqD$=_2zb2(%|`WBN~ho-R1m44YHJvUHY!cYgYIhS1o>Dl9#*Q zW3KzD@bLO!Hws{Lr8$LO#t{Fzg}RdE))@$jI#fU8sj-?-?(L|DhgvnuhqF4TP$oVN zF??COOy5`9&Q(UCsgrR68E{9tIRlRaqvMl%tvpV*?@RU!z+vN2@MI@G*oz8QMV0i= zu1)i0&>8T-i=$z<+un8?B7de{W z{(U?TL+!;lE~KEFZ!(T;^afFoiyE}lJYtoSlcYMCKj3`Gq~P^9K6;pXUgHD&i_NIU zy|_Z0f=R*ePtGF3QYZm?cNk$(W=991SsRcl#VLUDMgFB-cN%=j6#`iLLKs1)+?(Wy01}JUF&dO~t9yhfc8fF*>U&t~t} zXoDeVH>HV)2fioBRY422oKRci3r+G6@@Ee?eochEKMit@FqA}aAWWpQkFtw85gd8e zOBZsszB?9;^Yvd6gP3zR^U!)Rfi{W1g$x&ui5i>Qa&8xGrBn?O1&<24(4sC1FiCC7 z(JS{1DM}~TeACy;u!zX%k|!<_yVNDT1-Qmk{|I2KCt>Z7!59_e(&NUtA+uW|6Hz!3 zo&DD&T|H_nG=Q=EcYL(ZE4c8qzCLoUT-2F6^wqxgLH%@D|F>5iuA0K0V3EKE>b8nr zdHY<%axNh`>3SRc8EUBYT|N)GRO5^!3~*%Hl$G8zq`A090+~((9)sLC^Uj{=e(O@u zZF$O;K-FMH#Qey%!l|U(#9cmdDxy4xlFZd3PcT^6yj4T7NPA0dHmcBl;@_uyokK3s zNF@#Rs?Ba;JXqHRhkuDH5};X7;U1%6YBh8`=6?bX4Zuv@JG5C0)craiklW{*F1i#t%6(p= z7Vgv!{`$LY+q)Ifmc5Wx?^E+fx2i~hgBCN3p550fhv{(^RN85>AJZolrNNhJU$ zchsOSD|^G#c8;=grC)Px=1)FrYMlrn*f#IL-2>kQl+{$*wWIdVg4HbzQO9ZztzxwV z=X=Z7qLf*Bn3niwjqWTDRTlBg@d5>u5S-zskkW1RKg#<3v8&`HFwlAS)FJ!nmwKZc zuljJD!?t0?;L0`57$f?8`{c(C`sP3^=NegNNm?(xlpzzRIV&EwDBx0Q5G$EU8`0xQ z`bI<3CoS1D^qc|W+{-WU;RSwrW9yVVOd4N`{Kc{MFOOK7Z3s{YD#Y4*KC^+;m*jp- zbLA?)%o#-2W?ORVq*iQ-O^9ly=I>mXZtji@=Jz2P&9sNgWr7}6GR85+#+q>ELK&h# z--#x*k0xSpK$_0p?VZBNmQ;N3>HZk0ifr-*=$ zs(DG}9(cV3lVNJ8j5&DY^1A^=qT6Ku<@bO{*HZ|usfhYc9ffn4*I=DOW?^)G0}vWG zbCJh6KA7~+c=7AAZzepIPIXd^Lna<8A-5#?g+|{UV>SKWd@Pk>mzM6(Lz_!LkKo6A zy^Hs0UkqOiZPKo3GBJpiG8v493g~(lj7&>llKgJ~rrlLJ9(D!Yt4}tmoqng6_UW7X zNzT%tDRjXxQ=bg#U)!pgMMq*751Se1Z&a`p{>685j?iCsAW_cU8cy#E|Nb9$+W$U2 zR%B7XI*=ezO4KI!Lv>(W< zqoo+0m=o{aDNX?1@obup086?41DmsiT7lbnB^ov&GppHg0Lyq^l_TYj@2jTiT`+;q z%?_JZf1VzKzVQco)_Gt?s4vV!8z!0i%_u@|=be=$S8UXkDRev+Hcl(piws;1<^bmw z*6TUhzx8<*ZqR6Yr*ME)JjnD;RS((gW1dGz><=fD>v+_9{$%paq^9z}OfBM})fyI= z;gH;7Z6u|~XY0!P0c=o(y;ZCNA#_KF_$h;x@+t+_ zW9MZT`E#Q9d}6hKqDs+br57oAPl{YYFOn4Facv^!4lJ~&7n-$P+Owp5x`Zm_539HQ z{shfbbM(}kiYTJ?S-NwRW={Fjz_k)ltM}9TtUr>+(cwkb{E8%FhJv>rjw{8P02Dd= zel@62qw(^2D?i57Z$Q6`HP@0NO|f;Yx6`kq|Bmi`?T}J9YR&E~+wU`}#H4hO><(o=LY-L+f*4j@hrK@v(E|w); zJxe>j)nGoKRTJ5sqDn&q;cS~s;Lx>;EwvnWYLBb}(q{4KDceFr4bSiIOhk|1OY*6x! z&1B!U>)ll!gs^STcM$QMZH7J=?0o*?-T6(Ez2AX7I7&?b_)K|XNm9Se+(WC zI1!-%AD_L^`1jQIaHW-e&vyl5TUQ07O33MZj+j-ueq@*q6n32>vtW7xb9kIk^)z+b zAQie3zF<_QqA7Jaq1(6B51F{B|$>=r^f0^4y232TY#zC82 z&HM^J%liNr+Ucw8N=Pprx*8}M%yG%q6s9xLbz|<8bL5FsuF-Y9JHv3@5;Tv~_hP=3 zM~W6gf`g%3Mmb1L@$W0lqMknv65WgFtr!!+Rh59yCLKWrgOPR2pqcse7miKOR`g}? z)xAY2_u&0~>Hha;HhyL~ZA6$&DW$T3$wkj+s?@TCZTpgJz|;?AHw@9i7OH~MUF<|^ zmG)M4C)^WCYJL4rf8b_~RIGDCo;lY%c5m33NOm z$escYYEuP}*g#~}=FhAc0tbr?wf?p<^O{)#x1s{&T-}^3E2I54QN|s2Oa87CLZ9oJ z+@8|sjUtjYAZcs;3?9rbh&%tR6tsBr9OVcd>NZp_K#~P0*iaYx4@BD0X$8!PG#*uL zPf-)?ruhnSNPgaR*9eGome0TLHS4y;7$xV(rtq$n^mGJD(C#$8k5ljmPzK>lW5!VJ z_oG3UXHSy)@*BICPV8=}1byaT$aAxEPqO8DsnvY;y?n_USycuyrx(Th?QKS`>T^@G z{PAC5|MVim$Kx-1bON3d1>`bKWrF$So#iV4=GIk(ld==K6vAaZd7b{9p8y7xO1?9R z(D~-@Y?@Ft-T5uI!?^+ab>FewM=)2UjTW&Rbk{_w`Z zz8ww>*lN_y)~vn0X3zcE;9W{dSa0>xbQLN5peU^*Rm1$Zx8df555rKHyqUFwMt&z6#W7 zLgIv-43|Qi(U!-1ha!*5HMFkVzJ|!*6zjdx)0Q+CQ%Psdn>M_TP>T5>;CpjD@ja%| z&2wc1GZwYPi4JLYda``CIh2pef497k^~Ghidg9XaHU}ye@yPb|Zk*m0aGOq~HSFId zya7k=5&d4!X2PbEK`P3b<}89;GB&-C>G+QrnX(>9ooV-KaC$c}-i0Qf{+u)y2Q+ng9r4qEHhxuctp9Fx$cm|$q zMW52Z&G3j?wX$#L%vv9{?3S(FS4!#xmkyv~M(U34BDddtekb45=zpx+b)qx>VWF<< zld?cfSa$yX9Gy)2K8`V0-33<&QJOv-%r*A(8z*@kto!)OhNu%~OXi&FhDb+gY0vg! z6}R}FATWq5Lgd;lw&meA4DjN7+HIwHslT}{=3|x9qO_djh|wFX<%m2lXu=UwH^`=1 zC$%nmv(JUJNt!muLR66d^iQ_j10r`2KK_Q2hGI)c!m#N ziz{M%8#)XP`!3ETMRF7#b*?%tMea-|rwPm(PKIk%nzWrC!HwoZUBB?t=Z-8gNtwVR z`smC@(Eu@tAH~%{NG)O+7+eoypPP(ilY5k&KWE!6GZ{wTPJ-mGpQKxr6Vx5o(hp4ZFAO82ZFIW-zY zqv>_@e$KI>FWO3E#m&|6(C09rcxEfR6w8K}g{Nf-g>~8$4Q>LQ4D(qyiB=8|fI^&` zo#B2Fwf#?HJCFsJTY}Y`hS|gL^Iv}q$`5p$*8aPdR%o3?Wq_2A<4UmEZ!$}Ytss`u z3AMmD0-erjfl6Op*ZDc=w-x-AYscyg^FmG2i#qcz^Nk4VRP;fP7h-BvGJJ{mJ?*2K!nVfpzYjZ8@L zWdm)7>pvI=>Apc1W)*|mM;()RC6apA9S?mYxK7pY+h$n3f2 zp)FK1qglvSm~&uR^hI)FB=w*iT~b_b2HFN#6GJsn2mhw}fupn@I&z#)sv}lObP^+E zPtSX2StY-Z{^dICnl#P5;;ti?nTbpQ$%TACAb)JAcbuW$Y9yF0x9b~Y<~?Qt#*MZ$ z4(~aqHt|(+3_TzZK$p8i+n&>5nFMQ#vC<4ot7RR%e5TS$<@L* zJ0>DdDnHY5s8_0OQr+QL`#ZPH5DqX4E}}b7X6B})J{0dsb3uKe<1(Gop|$7yD#>Uj zmZ3~n%{v`jq?@LDmsLyNpseEXv1#M_hecao82D-oI~{cWP;MywXCAYepB}4p(5IC5 z>r{+lu5+fhOK=jV(x7z#w@eDx8+^Yznf$bC>DdwLaS0@MT){J^Y9^V>lOiLm< zCmN$kdmDPx9=~`Ud<7_5y0+_k^Y!-gV|BcK`UAtEp5HUUpUwVv7xW-OrZac26BPMsfNJ$n!7$$2WmgE$ z^Qch9yre?K8f=1koV_?UEl%AVm6Q_rk`!9|kIrzn!AJp^l}79i2Ice}96$zVw52z{ zF>0g*KcW>pFeCAkT6vNG5Lq1_`CWJEn=^d?zj<-33zB>aR(cX#TcvKQO=XiD)#jqM zs5TY|8HG;#IHjt^pDAt8S2JrrSeV0)&7Nmtt(Odwwk@kB6TXdpr1~c?W@6j~!bX%# z56p0w8Pq-%JOxjKfZr|G*T4;oz8Sjm4t5n{zblR2>Gd%+hx+VQ%?vREiae21bE`Y4 zR`=Z!E#>!_$k8S=5ZB!1r~YW{$j}CR=u<|d!YB1g5l@aWV$BrHh$-O*WBlUdF*chf zg8QstgLg>5CYP!SdZIz@>^M^Hm0?Di;U;_s2r_XWxIc4VE?=^I{mfjo)h3mayd+dz zo>FXGqo}FA`$l?@4lH=`Euf>=Ds%d=gU-{tqkV?b?oDRX=*pymwu(|>N#ZY@(l7bk z3VLo<`lXGmbgin7PTUQO@&;YL9`#}!DH{Ho!9p0PZ$&barK;6s%Ast%Cd=YR>~M95_H$u6ro8`N$J@O) zOu`EEz8C1TRbRM(w^fC*aCJYYgE$+wX%8U|SH1Pd!_$D*4+;8WZDO+eAA8l92yyO& z0Q$2j(&yatG$QWhJ_P#a7VP!ZdvK=fZz_?U?FdzZq z6z(B~F-=oDpvX&Ogucqw<(2 zY|8D6>hu@H+lDqp8h~wa1awcfk-D;Oex_UucX!Ij2$RN#wDm)ihzD*I0)~oF--m)H z!qak#iK(Sy$Kirn#>-KXVX*)#FQ-nL=CCq*s8%2tpvyp{j99Gnliw<(ocjv zXXBx&;J4p&QjwVTe6Jj#?}Sdqv(c&LQSQkftr^liPe7=(Db^!aj#nvk$e6OIj*_kA z?4?iQD(4wDK8}+6N8>@n(0q|-jB8e-2V;AzFC@2C^B=NBKdn{DzeBJ*Rw6p05GY~k z^SkiJ9<#*J9Kk%%Ej)+!X6IqC{=;}$7h)NCe&ldguW_D0WPI6fXY-RyK%e4F@MPN7 zH*s@1Q-NyP%V!S9Y#{0(PQPs4Fi>rAC|XHM)RUVSG0UH!d0PUhm~K;E#q3N)0vWmX zI&!Cj%dy*M@W?)X0Owj<8E{pQ&?WRJw~HEFw}TlO)`|c>Xou~(&sxk-BGuO47*$bo z<&5$N_rMBB*>e1P;IV4*80B6-*PX1={*7IY9r-jDGFbP#I9S~G#BlMvIA2@5v#Hy= zkqOCF02GmHc`7eu=c=YEU$xHDr7B52C4_%6|WbnL7^rMGp7f zFD}jS)bUbDD2P?y$e7#gE6qrg9u(w_91>KB2)hYbC&YnO@@SR&-B;a$PxWvlQq4oV ziMM?S`?-s{Ke-ui0uLGiXpXlHLyy0!2)NyU!Mhuj0(hm z6VljkjgXJZo)YBVjH;PF{R*wSc6|GMzCT|b=-;EaMA|3^k6e3+26!2TOD!-(IK*H zqCDp*7Bqko%`KAdsWwK;BY2UsH)6$d6j8v)=~^taETqWASX$9KH9oWrPZci(M&<8P zMgcr=yK=t~23SC1mw`@y-Dy3#$GGl>>QZZmahS+!Ff}V{)V;MC8YdYf*Td7XOgO$1 zq%m@3AKgH~y(tM`d;47hVCG=C=|h3qUo}1&+~-&#sS2z!n-AC$wMO(#0nh`zt+X%x zD)$jXjjIOVZ6{XoVNz7}z%geYBF#J{pSXSMEmf-atwWZuZ+6f+8#eDCdV%89vyzrd zvfd zPOX(16ZIJ>{^mPyMXfkge)AZKxYlKzit~ZA!)%uwT)U| zbbktx|94C0{V_cw`)bOgwr9pnl6teFWz$WNv<+0k`a{W89y;Oq)=-qV&1#}xTCkLE72|s%PKyW=I3Q8E6g(~`s7X`3Z)0Yb|c}F zFHWF(wJVU%Io|sGjSVi&CYAgMq(t2lkGlz_hN1#&mW( ztkZpOb}YC|WX=*=a?|Vgo&@{)E8f=Pr(eow-x^n`y?|L>M*RchjxasuIY`P_L!MlH z*m2k&N!a20@T_8lVa^Q0{2*-B)bt&&?Pjezi%%$kj*I_0QNczY$@iNmY7RIFNxzy> zz~2_9J|}&Yr`3i@3(=2+=+v8JV(6$W67#dx*GJG+PGc6Iuu1V!4{Vu22vZ*@^Auxi zTr=i1xty!P(<}9Lserj|NV_=IP9~wu^S~yWI8)k_D5Ux~n% zgRx3ktb1=|#3lKnU_c;DM-!ksT^*>ohqL^Gwjy zQP9nFkvM7CPbXDOafQitPg0;+W*G>O(q7{D+_2%wG9xLl51kG#cMAwh&Hx3Lhiq1| zbkr4J{&%Y>e5*Qqqc{>!&&*bM_)e@#n#%&XEo|RlKJ_)@=x9D=1C0I$iI~>#=dr~h zc!Wdr-Y~UK#7oP?cXL`B&H)Q=1rgBK_rOS@S{jBpX@wj3PN<(Gv04As5h|xiGS3^< zEhJ~XWLZ!-^53n07yEC7d8nK3xTIV-uYSBz)4MYJ7pBgf%I_Hw$KJ)p@~8TXnzTl* zlf1#Ojo{y@+-!oJ$~&djj~u@nzHWl)UhA+KzjB>_!%FT%i(t=NF*VI99NNyx-<3=ki@KZ~?q<5BF2^VTo9)*9bQR3{hfhOZ~E_lc!+dZHe6 zK>%`ryCl{e>-K(*>J05n8uYnN8GSOOFfsHbT_1>StbrI$!v87g<4Fq+mB-JMLOFl5 zFK*laGOp#t8mENqZ)MYmA0tUA8b&&2Z|(Yw2(67&SQqd#)|stN?u4&ojMV2Hn~7#T z4wnk`8|P-?=9XjiR8>h}SCZ#HIW}xF-+33%J|}bG@;ut3jM@-Z1?twGbbsu~FZft* z*!v>>=5yd{QmulVxZZa97`ZFBvG}ncYB>q=a%FK*I!?X3qUF+a;YcO|$nC)N3s>G< zosN>X-+Vsj$mm4=4(l&Z9r<8vx9zeSyv~$v!mP1%sw$x`xVdd-ZNRi99_N7-{>OHS zfoHB|bEJmVyN%xn$pXtj0VpqbH^#CwLm+vfw8LQY&~}rAe$%Y7H;1lsj{7N#{D2d% z>@QNc*+|HN3g*UraSHaE= zG{t}9{yNatkozl+&;t7S7U!?c_yOHl(Z9iz$8sp*KJY9rhc|d;789$o)-z`zb{$pp3 z5owM3YD2|Pu_#&>D|29>obID-aqZMe|rSrcyR&UMUc4L zu>d)!!unDAqX2HM6$6IC1+9R3Wse_$<2#RC2DmeP2W%ge2bG}6X}I=EXNsDkYuid3 zh&qyer<(d@GqPwIgzJzlv>o%O_0*QKkf>+Eo+zW8abgkNW$od+9D{NiM;)BnN-$7L z`cA)8Q5>$0_UNPPJkDI!oKC1MHq+ND!>5G_q)mDV`B!2;w=ojdjMsPmd7++Gh~(CNSCe_iANLy@x6xyrZ>R-|hKzRqu4*$g_?|b$eo;SQqsDa?&%&1W6pFvm zGPCITX=X7j8I)h^NEzL&)j4PCN+CHrPs@j@7VrL^#)uvwj-T4s2kaCAl-U*#HMRDY zgwS>Uz^LH|Bg*ZWeSqq!K2bkf)h@WXlBnMJtnG8w!vF)0ZX!5LA;T`(1H^r=7J{l4 zv&JvtiBUzh$2$=^+SSSDSO1jXWyw*d+b4WX(h75AXIA3*=;M0m!iiae%&KK94GA(D z_OqA>t~JxB3M%(s0N1c|_vKNV8)NDrt?h_dHHpy)Y|?6~Qg&Gu!V~^3$nz52HBS?- zE|ZV-g7WNJBGfvc$n_Dtn-X241`P8V?YH*qd}r8NQP2)0F+Jsl?;69g{BoMh*GU`A z%0;1Clr866#fh_x*kC)T8n2p1)~BwTDC3k39kpI(4)=Wx)jf%~`C~+qXn%!&}qD$+!Rio@ly-dJ6%Eiy!GsL7`-i;6V8(w9S^XYTf&a|zDo zzfAl(_=kU_zytmVi59wyH!FZY?MoPs@AEv=j}_9ra05pGu4EndwY;}(Gr*gDJpFQmO}myYX3>p;~Oim?&+P zym_z`*&~qF!V4TkH_|J-%%M|NPeX5|TqZgTy+*n1HMp>5HfZ0YLBeYytt7M;*#A&< z`UFZoT`GU&P`&m6$)9?@!5@5WB7pscO1H`Z*PpOj^)nG<&@8MX9Op#4WlEE(&1Vzw ziY;S`{8#ZWwMxy=U+ji7rS$Z?hEbZ6-|8t;d^0j!)KbOGak{!ulqHoVscR!NrB&)^AIWi) zCO_>F7`X%Au6?pw;$;VM29XFra-0VXt7lB)On8W9xYbb!)3af0x-&V#i=LSN@`2BcxO6*Zm~0tD%}Tw-iq%I5y8b?@>U66)Y}r@dEWct zzw&6JeBVRZ@YmrwdsM_Up!AX}N-wn7gjWw5>}^((KFzov`f6PnmJ?O@+0xBt@rx*B zZTC#77y5>@u4It-M!uwv&KF|LyghZ1Zh|o4>D9*Lc@{FF5H@f^fCeP5XGTroOWisD z?CYpba^JC0x53F3TOny;*C_^jtPbvZ;I@BC+6l>JtX8aUffZ*E^_Ta+{7~$ zUFj#<<3g?{pOl`6xCb(zb+R}|nGBv=vVu$Q3F3#?e85%CNy95}WiQ`Pyb9fw=6OlUn#+7bYn;%2%ZkbwFQj~si&TWjT4>1~r-GeBfH{ed0&G2$Msk8o~uRB{~ zr70$-PSpiUl&u;ZqY@?R9BmVHb!X=ek|&y23tKswpjZ(pVPvqw6|#M6Qc^oj%5C&M zPM89i5)jYv3wp(7K2+|pg2~}*MDKjt8-mKibtaLN5!yK0H|Mb5cdP8|#hj=fnTU_H+L> z?X`;8OyS*%akAStiVG?b@bcKnfGqL_L3v|DQCCUu)v?q2ZLSfL!MCspL8rSLH(aZ` z5uPPKEn{v+2XT1bhEyI`L%4lv1!Z;DZ!Z})>CQYJDZIE zX8-bdb4-Z-SleW_9uqf6ZD41_@*VJg`z3!6RI&V~ITZP5Vq+UF5xFA`U+*Ca6^dvc z85(wq;#J8&a;jh=QrOC-q4yGQUOo$@*B2F=r0U#b7jMBUzdn4eo%t!j>IIR!y6ZH4 z_alwq6pnTpBM~^X5hodGR>qO~wRZuGqz!8@g_a8y(KHD;4jr%cM3Pu|?o#9T(yX0T zDtT2QA{p;P7<8SA9Z7!@mOK_{>P~$O&e$r?;=%vUvuS4P%$nM*EIGphNaHI_O%<%n zi+E~iEJj7lZ65-Yf6#GA&UjzUTHzUuIJuh@Kqi4w7{^aPM;JZ+!TL!!BEG(8CqPUN zKJvmaCGbKQFm?W&}5wzeC_0MoS1+?YfX<@t6dqSP8=Gf@+WDKxfZ1u(PfgM9nn>>I^FaDfSYb5X& z1o@Wo9wo@a=yk%+LW$drL3}-Q(&`)>>HddaUQW?`6p`AZpf|R283{bcMB>(Cypby; z`$0kEOx0@cOH1_mbg{Ib5)#_LR#sb{jQ`0uv$k5$$4C#d5x)CjbVP zNX928-vTtqPWV0QvZ~!jnP~w3s9OUpxmEvhP*3C5P7S@tr;9=h>&bN;U#yZr0K+S} zn%PEgzy#TwH8@xiMN83VB5HuNum11WR5!*q?VF$i8h5JxKH27MBYMn#wt?QLl6xtM zDdYzvAW7E{xocohVN1{rw9qNfj^BOY;z_Iv9M>_Tm`kyx5+3Izy?^DM-Jw}L5K zdh9#@W$N@CT>O-qY$2|@eyf|~ms`Zuz$~q@wAH<7HF=XviM|i~(AcL8weCh+*EgV| zTW=D?zqk5G6-d-yl?Dt|8`xJxXOVrnM4U*A1k67&&RR?(*|1R*b0@CXvU}xsj)n?L+5Kd1b@1L18upzx!mTiIeocQ4uY*S8;)ot?j`U zcJdp|AT7(6cmtp3+53Abl$ReHJQ(0V_+hf{GX+Kr_l?@)1k9Xps^n18Is1Y-J z86Pk?+3=a#QRaBg<+^a@FE4HPWSWBHQvKpXsQ)m5qckw$LgHVNZ>8(-dFqMAm6Fps zPLiC`RNm*djo)AleRv7YEDrxYw1uWN57ce}=+?{~F550X=BB>4!@E1l9sJFaH{j7J z>2w`K-40mW@x%=O5$##IdsDIHBt5(`zH8e`_KdeBwSNL9{u`K?z1;q2k9E32#l`%a zc}|@Z1&jcAmEbtV5q;+_XTQDn{aC*B*-2v>-M0=x6!r~jOO^bIY2SAg9E%`*Y7iM; zU#uu+u;=`Lw}vIG^t)!P8Or&y-8}|bc*w^%?dkq@*}xug4Whf5d%OakC2jIHV94Gh zq3)G|`pjCQ#(N{w&Fx0(=4RqOKym(r?PehrY{3-A0lgAGU8E7Ei4B@QARR@U_OwL9Ql)f&f4*#5mBd^gu66mM6?o z%cLFc`SY@9pUt$D_y#>%_bZB=S>So$@od%WkLd}y+ZR+G_DSe37Me6b23J(yCi&M+ zRGf63>jdlG`?oP+$v092#CY9C4{F3Yifq>g)gXiVJ~=1lvlkwOv}coB2*DdVKZ5ji z!Fh%U<`z!#4|$)alKQAM)hp-uJM81pK@yt$gI+uR3ND%ax#hz}wi+g}3N!TPL)uiv zZ;jk2@)w^UO{zA2JZGOR)fDa8!TMasuTq4RT{nAs4q4lD3~iZaCpZ+@;d$A9&ckYt zPo^iY22pO86aj}5`OP&+Gqpl4)}-lvqF=o`YVt-5T+<#fEt@|-ISsv%xPQG=oF^qU zYgDe(mYXGe-PXy~T8wB-t0@=~~~8oPJiSHE=&pe)i*3QBv0R-pZUi%}^P~EAweb zg^|h1!&g&8b3zo~viY|5z6|?q|<05PhMKZmSZQXiKTG@tF#| z%{Jj&_J)7jwDQBAQADcYI1r;qBut5{nA5G07ERc}JJed1pY81FyCCCoN$Pp6I$XTG&0H!@aQZZDVlJv*NTIse?5W$FRb~$Yp|Jrk;dwFHT&&yTAkP~^? z+@H$VvExR2+Psru2#*r^nh>Q1QshkTQjEOBM3AZRVg9>?H-nm1&Y0?Wr(VEVJ8?*- zPv$9)-%lY$H#MG8M@3NEJWNvWAlqKf<_gp(*9j$L1_HpG4o)*3Hm=)SkiUQX-s4+h zRIodbU}YM$0=50%oZM`&1Hyr$dGTq_n)E%kOAj;Sgv`MeH2SK1i?9W4Aos2-!goT9+*v(>5W#}K`m9ai{@_*=h?{7B$`2W9q z&}!`#)!Lg%zB4TT8Vy_@*jUZyw9ua%bhT7HF$NPJ}KYTy$ zbH0DUb)9oP&$+Ja{&?JPw2fBUP?V2C#Rq{W5`1;XU&Z*S+A4}l`1Q%~ju-TpR zRDpA5Tjw%gUQQ)HSUdmoQ>#lXsNmuWdj(}mZolo#gv3a2T|R~03XyX-x6qKm@BGT@^h=i z4>oRoz0V9pzG)Bi0`T#sd${YKjZFzLOH0xG(n#J~mjt)_Tu}247Ws3fa|GWKL3-hr`RQM#Sx4YLwS_B^8;oe)SWDXds9-)anjERW?;kNj zoBmeSxl~-TJW3j|fdM##(tQDDtP%0h_B@>ET3bSRpMK3Wc)?z9PDu->d8OMTd$_dm zM5JNkM{N&Ss>@a71Q3|+Zj{J<823YLunwKX^Rn3g8JcrT$vT!JxO1U3Z-|=0WaST4 z|C4_&^kWl^V)oZDf3#*VMx5tFXBf)D|IW_*E5w8;i{s5(f;LjJAl}g`)sE3nQ$ckQ z-@f@1B0m%v2IQ`RgezR?i5fwctz=e zx880Q6`rJE8jy22#4;bZu&0$MF1W#D;Lg~bV(pJcuHLa=OqJT8slM0zzt}nsZfQ8Uu#srtrH@?f zF!#7)#A@CRC!80e>wiq5fOxwK zO)mjDMKz9Ik~NDVj~se37P6_jGIUr;FN#Cc1$X{^7`-hSA8$p1n|kUmKgR>8g{eee zQm2dsR|k!(dz&1w>L<>w3_YyEm(se0kgsn3c-b$uIa8Lr*;7&wlB&&+H9Fcz=4H2#J-XxA=G}bP5S_j#(TFaHr>HT$JV_+O z!D}VO08MOW>>xC*?YL;SNUyF%w3;rTR#fI06%-=)TXXrgj$SlRbDa$Zi~_^!PwTer zQTr7(;&bKZ2j1^ifXlc?Y0UhwxzvC1cGK3FP@EF|$Z<3dW>Q%4TlnA+i1#;Qr|1Q- zN1N_LlY52u9}|ZmnF*sj!1FD~x=yOu{cAb#_zs)J?N5&a`~Y*t^}3=DVC_qMv46e@ zhEmd68(X7tkSq=C6 zA*uh}NOak*1IF9Nc`o@n$~RzrjEee5QkMN?S?vu5-bV2O`Ed#A509tMGCbZVeM;=0 zda&=25jsT6>*!u$J;=Bm=i2rowXaEigI~MJMo8U4j4mJWq|CI)EcT4qYdp(DcVGl< z$+@e*qS?D5Xt7eU&rI=bm}y%W?m7srx7H_#s8hxFt0Un&w`nr2MN>*}m$>NiSaGP4 z(D?osd*pgva2t}U&7x<#$$fuCt9@&MQ@`VI?nL{fUCUQy^x6%DVt<@gv!i3E8v}cM zO2(cr-gBzOvdHH2bf386$@{dIGmqd^It56Z-fpjx{n2~33fAZ1S z%+AUElgkj^DkcV9_e8Rtc_b@tP=bT2`y!y-=0)x9Y~8ZyXh3pt!`(nVy*Z8GDksAj zc!%w8WpLDoW5g+VIYvF$L8+_$>{gCRFVAlSnu=)Z=CXSRZ>W2DIS`$j9yKLJ=e4O1J;}C<<%iGyHpp`ldXPd5<&C-Qm;pa z1v&U5k+gxRQ(6-P541*x*WH7d_h#NojlVuiS|xQ7S@y@1glNJ){;pCueaHQOyZ31B z-v>Uixp{26;&^+%!|B7z{}1__=g|3XCw;@s=wKF6_93$%0=-RpAT&}pHsl~aGw~Ry zGtP_6)mg?)y)k5JXFL?%c{*;d<}i?;F7KyjhjZChxb-ec6G-)n`VC9<{s(~cdEC6T zZX(6WP~%$)D27i&x~lNkd7!`D*e?;u*1GXYJBBhXoVdiaIIxM{ewR33J>fy}urX(1 za7Rs_kdX<+XoP@M1+7Odv`O{!S{#s~6)W4_nSwg<7^1y%@n^LH<#BRB*Wf!W4%sKSSgwXOWZus9cDdEX(nB<36BA@4V2y#c|a!uU@>&a-k@ zOr@D0!o~1#N!uXvha&{}t!4kn&_Y{9Io33ITfLQDGVkyn`#VTsH#w%h^RjVYyUvjf zsU_LTyCOFR<_abkc3X?fc75p+{TO54rq^Ls@dm}Cs)$o12?*am?CNs*vE7!dMY5Q9b~3}K48MIp&KfdE`v3-zcqrv!NUS%Q&J8%N zTv*Z55^vlvxO&uY`dF;)RO=onDhp+k9I%9`hl{FjQmL_5pSBjefP&`wNsA?-D^A3Q z3pmIHP^y0dK%`PE3wG;yl2u8dJ@(J_1aRQ<0wtJ_5%lCdPZJw$rVtY_3oz2;?KecN2vpWIv7KF5?5t&#bQJ0Yobj-o~34KK#3 zI%og8QL>C{#L!9xV`&r~gH zHFQUn3y+&i4?m47l`A*tFH6)|ti>yOGWW$Ddtvg%At3Cr}&n!Lt27?%}JFLmYN7<#+F+!V(_(vl}QSwRKQ*9)I$I6^1rb zR{9k2>GD>ZSDmwcFLu4<;hK9*CDof}8X{fUWbDx?DAsUlAwtR=9`9XR`7EmTt^pTp zT?z^bUWgys*q06(b}t7J}Vg>!ktbiFJh*;=}lKCk-#Df%;Q1bqd4hOqB{~j;^M1=!m>g+1zD+ z2Hfy5kksJCKgv3v=$1f%f)3F6Z?f-<32_tbQEHr11AzV)WAuJvMz`yocAMT z;pRPoh5_TVjDTxqej9rGhmtc?!BBGQP>@N8si&ozq>?ecT&{aZZ<|VG<}&X`*29ce zjnv#qMVY9wQ{*a%eX325rL(EY%FHc!TK;WhzqSU%^HGlaW1Mjl`dd)9?(W@Gs^cBr z#Q6B2LCy6AgGHYt;DVSwA5zoYqY|`SgJt}o*fv-@3eZy(%&;3 zW{fR0Ck0lK{291<6KP36BeA-}*S#PH9Ckf{%v_=SOH1iZ=^eFWJ!ZCE8yGr6{EyMZ z786H8O;29ikh#6A2ayoctGM~vlXpf%8;|XmXhKcihvWHt(!gJtZ)0$lbAgVhjzZ9X zg^rmUHGx|`X)5Y<&v~MKn!ZRjuloH{b?0`R#^5BLh?1}I%rg}8ujY~&UWSSokN!Cd ztwETCJQ7`z>av^cu6pD`aY!s<2=9qT5~ziejYf+a%-i`qi*RZl3h8 zh?ut#H~9VskzIKO@hisw6C0!vd^^%A4V_59vfowIZ!p;Y+EwH@3Qeu#FP+3Ov5j25 z`sFO0&JYNF z*)Vapa2>Ymwi@VPBz7a4>x4vH4zEl-F3E?`S^s)f@tq2|UooES(| zpd?VFTGl&CPs+Q$*t40JU*z{@x0Hp)pIrxxf|0*jQmmdV5kDQ_ zcy4mW7O<;@r$}u-jz#-mQ?$#CxHV{xe*B96je}HT6F-Ty0}Imz#v-!mr8u1z0`ph~ z5)I1Duf}xW9(jL#JWK^Yw5TcO!ofb6yPop8+D;|l>dPEdwtC^wQfcV9M6^3lWB)P} zDXcy|*Hj~o@=#Xi`^t(bq;uQJYGql0b>}G$!d&*~zC3uke;1s$;p>{WW>UN2N<%v3 zzE00ojQSE;xr&a0960wGOJ6eQRhW|{e2GwGaTJ(%(eD0mkJy9?dBI;8@0Tgp=3LAP zDLV5-2}(|{lbkzHq7f|*&-2740qih?jd{8a|=1MVqED@}uCa91_j`{S+)aZpyT8Qwlvu zrPs&4KJ=e;C`wujiCP0zBEQ!xR_z}A%UNupq*ic<^E?& z;;YRbRV4XmRTl*o(LL^j3eSb#l{_MQ{cqAiyMpen)M|Ii@RFdL1T^Y6;`~$Pfys$- zM!2!pg|nZh0#79k?0CswYEosOc>_+9OO8GV*Z+%ZEKJXM*DIquYI}}?u97-rDSlLRRKP`FPZniDpkb2?$Jr4=wG*sNi zC=w^Pc$y|OHkx7_7EW8x_mWo-HmUn3`%|2%f`yPoVlN_)Ymm06sXn|3Yo>#N${yQk z=EV3i+aJa(uNkh8(xr5~cld@M^jG&raBvVpI}7)ecWAP}{b!l(M}$m^^ofCm=5eEE zt3A?!5!<{)nN#21mS)5hPu|d;JfVMGr9W0!ppVUjx0sm;Qb}cwH$kw2p1T#(h+a!K z&1=yuW51LxeX=Cc*_=3M}AOV^o#Z)1&hpSz8`87I>P`z26Kj)WhqHC?KV>= zXv^PZg$L&o%eT3ngaL_}=tP$Z51P@tA3$P$^H(EnV@?ER-6l+g}Heu?HMfq*YHR<*pif&6>zwtGPWc-ZHW(tCj7j^9k)|@?sC$d89oL z-PMuJE`UmUZf8Wvc@eQLKWWjGc~MnlNC`rxJ~(1#>FIJDaff>K24SeWq;a;6uITmf zlC~&12-CQCMRw>w=se`H;25q|Pt4X0h&vG?7k4N)o&nwDkXd+88kX<^3~jri{!MH@ zLW2&pyox5&YN@<6VJND#PSAFB_1V`fuok$fl5_}=^qQosTjaWtwEuzj&b=K;s$7b+3uFR!EgF@1i?aRU|J9`zQ>H<>j z(C634Z)Fe7B^oN1b1!`=m}3%8edB3h*H4|*Qxw64r!Dg#2WRRQsISv|+~KVr?<&4R zyhTv8ZeTrn@5y=uv*;FNKD)|<3Ftyrju*JuB|KMYY5HCg2w%w_8x5rA4$Evc*{I+z zQ#WpVPbGL9^be{tvk-?+*22ynFlf#&x$-W#hR{rnw?et?ZuM$;E*Z-3I%K}@XlEBM z%i}Am@}ISFZ$d=3Sp|R`8mRG!zklPZRGI_dDE43XVnSo4#*=EU-W#dbf+*A8()F+K zR!se}omSFU$43{~KS1=RIFzJw88=AFR>OWX?>CuUA5PJw!AD6UtZP1~)OCh!ve!}T zZ>WF4Vx*#NF-A4tLOg1<^^`l@)z1DhFX_8oG#oSd+4@PoDTCKvuM=mQPMJvVb?C%0 z)|a(uxH#xs+A7rjq_O#!XFxkf=vLm8oddtkHeUfgYuYd7_~V|h;WLYrRK7AOZ>n~V zcs3QiT=y`3fu_z27I3>muFVF4|7_6@{!reMCB$wXq~k)5zP?I&2va*#>+_?Ai@b2@ zmZr(j+j37~+ZDE0{_QQU#_hXv06wy2rfJre#x^67%xIis z_t#gwkheh9lDRSaRn?tW$<0r?g8PBZHRXSwlXfR8G95%D3x1Q9Qn!fy?6bVv%A$h> zhw{AKaHAPicI-E>hP|j;aKss(sGWc)OCzbL`5ec(|6n8KQtC%Pf6ezg?9-mYJ5;Q; zTdHM3c$HHCJ;m%{*p$f1Wudo+gF!9!*px44PsPxav{CLsunQK~*3HLoM8{3$ulamD zDuuWY5#pR)^U;f(>;l1b2Mtk2yygC_a^iwwZZMxqAM-UBcG(X1^Pks5$iZ~Iu`=;Z zyy!0#g}Js#mtWYBJAC_VG02rJ=&5f7cj9 zPZD-$t8|_CS2Mie7cg@s)(NoFrnfct8;%>tKF?W~Qwv@>hbaFPPa>U`1EnOmxW&|) zFOtJ_uOsxflRtc<8u#JCPDL@uY|)zjHstV98uO(acyNIcTAu`IyM-rQ6Q(MPYl@!6 ztrsUct*uV3L;pB#&QHqOJ1+wbXdUpF=`;+BJG)EasSDV5&t_mP-Yoo4{t@J_&dKZ* zNX5A(7yqQ|?`f5i4fFQ6jeNJ-CV9fWS4e}v`a2_$D_P&D2zp8nd177EOeX#x@aX>n z5C88R;npX#$Ae_IWM9V{okWX~W+pa{%>Gw%oGE03<9>U|MXD1-CTa|^|TOS|(uk9YKK&*@Cp;CE;7v%qD@AqUJ6s?_}63)yV!o$7Q?;v`D}FOt>Rfiu(K|zFSwvLO30C$=YL*fG;>%ZeE-U8Q z(FWl!lY&}PMgOwZ|K68u&6?V%aj^Cns_~^ui4CaZ>Xh54=!0)#F!u+KDgwrkOrAnd z*r7gw#OI{JmGi}|Q?M@Fj9OaP*3Y?%U-{vQcDA&;mDRdh-`(1X4R*)9AC!7c7EedZ zjmkT-9ApZztKQpY@@6mfH&tQE-Yh&1l}x>E*Q7(ZYa>$6Y~%~^PWN^`oqqmRlD;n{ ze!|Pnfx&>rznA>1#S#C-Nop{w#`D_TCiA`@vr3)pCo4+v&O_x#?8)nUJN)CP+!$5W zkXw$%DZ2;0@UmLSN=B-b2_dr{^vEltulP( zHb_%;){l4ShkF;($%cd4nsdCyr)7E-nHEc$gVBn!f#%!iLV5y27aWWHTsg&2e6r70 zQX%d;+U3IUUzJ*5I*gcZ8t4--9u5<=Q?^jC{wXo2U|AkZB#+osNbA1>i;`gK!(dt5 zinXwBoX5;isjrgpSPhGdA#3|(35TmeV8eH(#eAWLpqe9xqNsB1sc_7NKC8d1wg)H^ z*)I)dca$J{tiM1Ie?$_q1i@GN+mo9R?ERm}+q|%>)PramL)%huGduni8n~7StxW05 zKy;#b*A|KsZE)(l-u^XucQ0h1jKR}33{T@(XaC?%Lb8PWX%gnT#Y**OaR?(e{vsaB19ReY$)>S%Xutec3q&)#RGhRJiN zaxB&_>+Rxq?@K}c5KK{J<}qt(5kYB*;?=!HJO#;@by!}Fuy0X@@`JFH?}Wjh9H7S5Xa~v1c=>kc~(ASP2MM5 z{6&ba_JNSi#0KY`;<8(5dnJQCNVXKbx2@t(sY(Z{WkNsEV6o@L z=-89kD@zcU0-&>EbzZ|Mq&P_Z`m67PE=v>NHxl|9yJFlU(s!r~@_txWKu2_E@AgX_ z{&+tkH-0>$x;VYX>dpID!(uo?QTofl}5a5aI_EyTQH` zBSfNj?-$%FOs#ED|AY4XfSi}=p4W9|H%2;bOLGq`OCKsl2^RJ=b`)F zAodh{r)-WA&lKdxo(U)J&J)F_Bh^>RqeoAa>k_@Zb0(jw3{;;%_F#60844L^+(vDAtCSm4XM6cxfKuH2P;M5Y#-^MOF~bZo>c!1(MO`tj z*!?MFJ)GzQG+3TWws9~r4UJfG`0vI=6OD&%w{n^_$tc^y-1x!k|LpnWqbd5BdnhMCn)bq?7Q*RpJ8ss`39xhGZ04gE2NFDeV9 zYonjije--1m-m-h3Hqi&jC~jogJaN>7G+j{?`f}u{v0u*6ppZ?JB|Bvt9R#0h9=7_ z*7zgjWR1>Z_YQy>BE59e0>7+u;4C+1GhXql`obEa%Usu~=6lw^R7X|g5neyIv;>|Q z%F&0_r*m*_>H`&znOQBn`Iw`NBRDG$CiWf2iLd}Co>BsT?K5wDJe8dn zuk4Z4?Zvr#^z_*1D@$1Bt&g4o@ZL_l^=Xyzn<_WE@;i@MHX*}Ldxcd0Q*DI^uLaNE zE*APCks0g$lDnfdEAaCyN)-@OQu6lM{3mQMa8s~EnpBkCvVEzmOZh3q$103f(QtXf zd{k|hm*=O3Tf7afI8vh`vlyT4E4nf)If2n|egCMAwoXlU{tTZLGMzH(Yl^f#vNz^0 ziMfpza$}6Kw0=n>tGz-}JTNzMFj_lu-5%tVJ;d=l=KOafhY-Lz7O%i(TiTABAO)Fb z+L%f{IQUFOVf6ax6Q8NGw_SQ!?Mq#^nw2Ln54>hptwH*OwGM5Kvz4*OmG+smE5cDJ zS^P(^OqwJ$AM$XT2qEczm226l+v{?2rH&#k;PyY))Cv2{NfY<3MBm!IK%)cJnP;V~ zGn(;SiHtvAe;DI^JI7n+(?o=bg4T z7@uV4?`QMU<-#)0kbEV#&Mz|CDcN?(7l`vjo68168>F~0f=_+R$LU;*YR;UQTNXOz z9g=wUTrJkz>5Vd;dm|!#MJXlj)4hFhucpi;mLm4JU>te^mSO{T z-Ld(a;Y!?x3-tByZ>;Z^&zYq)E9%MBsgPAPi|@i~yGtzQ_wy|){JHqu%Ju43_=3S+ z$vHd_#Gt;mD2BsMGvUl!t+q1BN7H~_P?U~J4YUFsbWN)a1Q{ZE9_jFWP406ZOnD=7%wf$?#2Y3+2kc-F|MSwz^~|w6=y{E{ zqyX+9sG65{{GnZ=dBudT0`2#)cxfz z*|r*VMfwA4TAYPCNM-Yqmcx&_-3Wq~$;QNtb(i8+aFys>lv@s)!%3J=%!J-cuUmJ2HHF2 ziunm3RHz-L(}x6mNZ9zW3xkLEtT=KVu$;yITQx+ev&~H6oMxa-Qy^Z$_(M?P^7%IF z*8Du{1fw+PGQ{wxTq%8G7789_%qVW~Z)avLpLHbz%YZNW@{&fVKMsxH#3;om4udT_ z!*Y#n*_sruwYEfp9rxPLb{z_Q(>-js_Hw3z?L@(D>6HLasOl0wGc;+&pGTeB$GbALYk7lp_w>% znUxpK|BbN5Wje^uRT9=!29@Fo5X}xT03*7X=6<69sr#5U;fw6z*G;K!m#3O@-oK`N z3_;AYE+wHE590%a3iP$>w-+UJ<#z+#fGas&DY>G(8i37qE zA9J$iGhsl7WJfA0rv{q${>u2L(h5p62l8rgYI>{$_UNgXHTe|pnyfr8rmX`U`r33b zukrkOmdkU!qf)T8PDhLX%;v@T3h+7ppPqMoaRkl`TcSF-x;viH4_{+n6H-?z{CGt6 zHdM<0oi5j7;qVxRTY{WQf%;T?e}Xu7CgZi`pLKPut`}Y+95%W)uyr5YFLuR5WUa(> zxu9RNWw@NAJ^LBckUSk=zu5&2xrrAv^Vc<JQY? z0P1qKV-Ba4hQKbwHQNdQkPA3AdUYy^1zfS_D^=K;)ANWUC(t&G&-!iMdv%P9a09TY zQqDXhH4S@7Oo*<4C3?^L+({)^UKR)Fulh>lF$+v5+2lxYPfXEImR%+An+OhZJncGU z;kSwV%CdsCBQ_OfkUe`5d6lK9UYHT|Iw0$6{r@1x&ia6$! zvQIa_0y1wB@doD(Vzhm5N_6p#xw$-H+-vYELxVr`@h%Y!{#V>QN*e1G!kY2$O*hc= zrZXJzvL7oXI&P%=ikH9q-jW%@=U#nU1x^Kx>w_EGF9qV-nJj4KkQ>c&iVu}mhd87d zkLkIS;594N&6b0N#`(??*<7xum6ok3UuD$G0aEETCIUKSNG2j)l9$O>!5SOEOcl-M zL>3jM7$_+5$2jt@Ny%t9zxrOt(E{BEzrYt7;m%+`{oO^Z@4>mp>>a{Jc!-Y z1`!607xTWE2JQ{UbCvBz+mi!r%A$uHMn^(Qrz)C%d^d|<*VpQUH1*uhmOVPS=UL8N z&DJ8Z{j|5oDUO|cqe-glIa|7koW{TLOooFq8H7{AaE{#4d)rik4DWwL50POg{^nWa7i&3VO^WGwr?6Q>X8VVwQUD3y4EPVc`Yh zzZ=I)0iJ{(CjXS{{5V7{XtSKW*&yz{v@gQTk823l1O77&=rvz+-ow?fW$~>UKOn^?>uaz4+IF_AfMxODC;- zu_X`#g~UH5NUeIPH-^Ng6Gu=~>UJ8|#Ujobj*bq$x%0~XSIew?EL!gkt)?Y&p&jCb za&*WAD+7#6I(ah)wwLGGw9Bm;{F(6izsX_^eUQ4%MnC&ri7>lKYPa%yQ7p%^mY2rl zb!~@gzEHi!PA6FdYX-oSZHCCyS187Y>t(7d-Fzsccj6#m=n0K^?MB$LAT_aHUiUGK zecx<{D#vXqho%OHooyc)_Dh+V^`5&Je%i%5)>>Yey5bZsr4G0AOg4m~Y*L&H>;{@%Y*^TqRNT6wr}he2c9i;+@-91g)cJsFI#PLsAEW_qeO zexy>|BbireB&)uJ`8!MGp+?!DdD9lJdfaco)Sx<`eQ;MhU#4%0%1kP-z#{bf5OR56 zxJcL!v*|l9#~t0RqfLhLuILIe>hPqJXO*d>%?0w0>N)5KiX)26nnnEle9&llF978) zDRVLY4}Q+hPH{OPL>Bv483!(&axR&-sf;I;SU7`ch-7j!o+7W`9|Qk`6!WHTmo>kv z)%(Sxcu#tm#-BC`?F%s-?iY8*xz<=zHv5I;KIt8QJYhhyLjNe5J#)2c(|8MGIur*h zl}r9?oFy%3>YF~1XXG5FnIpMB_pq<>s;bbGY5MY(8aby( z`N)-e3XRF7#Eaf=*ayR5bYyu(HQES*IB)3Hwy`CO6DAL%66}Dz3 z=S>;jHa9sTc){-N-COsqz9;}XSKIs^yYAm@nKnx}Uwi0iAv3HD6E3i`-66r)XUd0w zViiZSeD2kZTJ8DLq6|kfi|h9lTJ`t9ul|k92!Uq4kiF|AfwU%$goU^4y3Ahn17;sb zMrvwCbQ3?zi0~wd=w7Tjxv|ZCw}2j)2><2MNffOWCmxtl(xduG1&v$lo|er!#VD&; zKgl4rsGcFgb=@?)$7t&7xuPj;3Fqq1zKzMyD$kGVQex(8I{8cUJSR%lFtFv40eWM2 z0w%9e&YySe%j|62T^}Mt&zv{#D*?pHuB9~zbPZqoD%dIG%=%6&kuC$|b(CV8MKG$X zXmPRNk9p2o$e>YUD~UI^OYc7V%gU#AUnJB zOB{-!v*-{*yBu>Kbo4c+*_wqk6qUIA`q?#h25`WDq0%&7zEnjOZ7D8QN6x)-rd0_- zQE=ApL`a)u*3Jo&M1*Jw7D~3v$36yJ62SohV%C3g0)GDVSa+g_FkGpbB|nEKQj{BZu!M(`IX!pFjEedn5CWLF7z|o zw(A;?Q#cahJeKN|06q?d$uQKtCcgNEVIkVRyPAB7YdpUoZ+NK--whb6dyhH)aOK%nf;# zflTq;_-?hGFG z%AyhtKQ*OUs+bqvG`9)+8*{4_|A6wCTctnG*byw-k6^ylrFY8nZd2&X&Ha77=wynP zKgQk1_~+bNsnR_TCRcg@ayPGmw$j|9yvJ65Vm80#*pDHyAOg(mex6?AG#q6qOfa++F}ZQs)`|x$h|PTZsn&>RPimbF6c4c zEMA?Lk9$n7K$1U@38#rdN~S_@=qd6U`;^|5#6nb4KQj==yQ%f2_Aln{UrP5rmNm$c zt-F4SWY;_T7c3Vhh$3^Hxcwg~p;&b|O-DT0DR!3(PC*7=d>_La+IC#1U;LnT#KM`X z?f`i!hX;n?Oe?3uzP?0%rdrSSAvvZnIeO@MnYUaI_?@ymVE4T5OaG;#;7TvA>G4D^R8n@!{2xJ9aOgL`zW8S^=(%Jx~-OEwc-4qTQ^t3cy5gMO6pJQDQ5 zDH<6W&c9(1e znUX_-^LW?yE8Pj4!Xtoz%&)bvY#wUV0y(~&4640DeC2sGAnOF5d|lJ4My)_rdFm{l ze!_n@+TMDiUrNyn-FQ?up#XDaaE@1~hLwKuhBW;(rKTmr>iTUYa?x=F2W1n;KBP!` zNN;U4u!Twarn`yV&N5?)5q_^N08{w54${}gJxD#c{w+Oc%~m&+WDKHz=ox|xRi|9W z*C7WQHO8A4)=54j3}&_i@y5eRJXV2~eZ+v-lZ@dYTv8|3pOm-kLn*V^eHtdj$j9b!teD_#YFvggI&Mkd0`^6+7|P+ zQK09tSB$WVKZ}6kOb@CbQMaw`p71`t<`eD1FP~s^eoycz+~Ec(x_ZqPle#v@MbX{_ zzgi73Q{S@rxon|4MGzZ|{CYl`GoijPQQPsDVfG(;Px6LD=d*+vx;h>Gx6FqNKaN!`i*X7hg+FounZ%8Fi4@ z1)DP=FiYB3CSGkrkszi5L!)Fyka$HWQ#z{^tjMb70a@VBaJ(f8(Qs=68%d_?8XG%H z9IK5(h4arM+S|UrO zT1JJ%Yty7j+5LTC-yN-x4Um4Vl#fHHMZu@RLiAulM2N~1oo^BQ2PCDe=y zBaf(P-h-MIuR;7Fksx{Yok@75@>>y~diBHKC}niKufL*C>8tbt0KwIp@22k;0gQK9 zfLBp|vij6xzTyy{D7z*pYxiQ`sZHMv)+%8k&b<}dSNTNtx~KwxT=vH<$B(x|wca!R zl~fT%QUkGV69+bRU!!@{H6mmxnj0Eu`l1X9a_X#;=Gs$8wfy8ytqQ?iue*p&z2dM*?TRAI3YpvpC0tI}Nuk1s2Z^o(1~ z<_c8-GyUr2#-SYBO9teGHjAS^27MhJ9K+cr8}6{@tu)k$G#`n7L;iO|*Ra(uRgAc| zz1an4ua(D#DnkgEV3T)O%vTR+o9tm;C8ze9slVqm23Z-Na->Bixv*XTuGZdc{B-fZ zposrJBV#5(#+Z%}d6Qec{UmocvZwLy(xWp-BSSNRg|;l_G;|vEca^(*S$^Nopir4_ zXj4{QqAn9zinO0pwn;nGpl)gr9%i#0V)?5n2zVhzEBOi|pZ5=>r=kWB2)h=OmCet` zV+^a(hri)tMBK{^Cg)0tFXE(2R$&XiPPsDDGm)=))fRd-`fnv#6=Z&~ue`GIYgNY~ zDK*^P4d7s3uQnhbd(Fl*P$;XS*sD=1^m`DqtiNeS!A08^(z#D3*r;c9L^6emQ%P-J1?Pv-s6 zxiCIaP?u~fuZ=Qg@J;^e0{;00huifggpb$viB?ld+bEgZdihoCIqy7Qzz$vi>i43d zRHfOFK#Ks5hsb?dy%p97%Vetyj~F#J0fyz59&sA36!p`}8fk@>AD^BEZMRFN0+$vM zc+`3>YP;X`ulT$&`d4{%a*t=)x_i??eD+$LNyWHnub`m5*XQ6;Ph$1E?HNT=c_xE= zfpb3aX?2E9H&j&5%2c6~-D3U4W7FR3F(^3b=F)}JeMYir4-C609lkg2vgW+)1YKcSRAWk!)6`cIgvazwB8 zWyb7{5bt(jyXbX>OKBW8)KB~k)l=TcuRQG2pVr1T{^Ustc}67rhFEbY)>;`~2AgS~7rgrN z46HRECq=uNy(_Arf0XA_Ip7ee$?AYD2yHdTU!ZwUz35aBGZ*rrpQ9;+bygi%>TuXI zUUp!O$Hg}odfJ|+h!+iXikVQzi@1v#+G;~g57|Kxy+NXU+OZFDax7$_-)WNQDZ&(W+ahKiIB!mUx@|i)CqpP=Y}NNe4o%R@XvfIng>9AcGkLw0$79zvk3;b=2HGTnsGb8nMdP zxp|@sQ)}}NJnKzbow%VfN$yEqb9cuj^GS4soR<}BRTt(hUM)`8Rn%A>2Dm8n(}KxDQ#GwD=!qZF8LoIUhwHFj$n4e=rd z(3YNgH1Ij~kMLNxF^Z`gP+yehhU;oKZInwY{QYX+aWRSZ_*-~y>0^a^cMbMEGtSx0 zpNZfxf65u;K`k)m^JvBDkM~kJPA+>InNd~pSVZs&fc3vU2T+4X|iqg3v8E@&;N!5zoZS<0d6VEV%d(vV){ zwQwWcKAh2GR<$+z=~@Krm6|J_`Z6CCx|AEdBIQv5V>@ZVF1a1O^<>H{NGNzO6^;lj z`c>@`C-I$Ou%jfyM^F|;yJM?G!C&pSz%@OXC`-a=dGfg@is7TYoK=AyKKB3b+3#t#S5D419DnFvQ-Z5Z4D?n@$`%YQ;uz4;j+s6JBarE! zafTuu&bPP*4L8ptd1;rsN@TbH+$f=eGAyFl*2R6z`?OJK*>DqwN7sJx*xUUk?|=K9x2Ys_e5;NPy6>C2;V$pHH$5cTRw6@+rGW1>?b!JZO6QTNW&2sH^Hya48_W5VDos<*2MljW?x_H6{fh06I;tr9=|Rd^NXk)sxY zKUJMh^r4F-kuJeV-&Noh?I(>j@REd4x_ggtK9Wmo)(l(}7^C-P?ZGi&#za!|eD9li zQ@p7UcUhnOT{W4qSD(167=teL`3#QTNmfNICZ}Fe4!MzYFHJ)FXAD$yw(okln0*}A z`ap-@qg6|ogB$R_rQ}STkR>;$%ExRqZqe}sr#EbEv=63Urs~BRC^{;C_SlV&P04$u z0EuRyynf)q(i6|EVSY9EP_DLOFFUu4a#+HAroJh@LUr`fhOtf4ruSF&AbkG8UTq{2 ziNkBXF4r}l<0u~30)`B44?>6I?XZ})#v3(FCnay)vh=j_46EOlLvjdgKNZAG-A@l1 z6x-UR_<+a6EHeiFR7B;RL9v^36klOP;L=L>@D?{rorB|yKFhBu|i@ZZv*9L9S@^U~F zo^b}#j)2ZgAjyBuHCI(k=(2AO3B$c?6sIedP8hkzg4Xiz5}X)Q--xnQK5lk#fm50t zG@O?pvNlD`D)-xy4Gv9yu44B~IaA0y3DC*v&ttu3!nm*N%)DpSln zDqv%$9yS>EdQ5I|ztM4GR&;R?#5gzwJ*PA;{;Axx`rMwJgGrH1FeQIulfkOx<6blO zU{f%Tl0+~OZ7$|Fp7=W`TDjHK0%w6U#BFpp>V#Y#vTccJ*`EsP2m11y)xgxL?4ycg zzS?x?a9lnJ|C)BMIG0xfU%gi{J*7HNZor?#rvKAr@gO^YAf2;B)AkP-r~ib z;!Z=0JHfrhEkJN@3WXMj;1o$Iu0?`Ni@Otw1d0~7Qk-^jXT5pz-kW>h+s#rEK8!Xmvvt=-4guwl<$$$G= zaVu+o=IV?XEwM+K*lDgvy>Ra%2JxN>HI>@jQ0SzTImj3)@ zPvS@H6!*sH*sk?H*@cLr@vTy5lg@kdHA(AwPvZUto8da!X6356XVxrnnJZGWxzqL8 zQX7)RLge_giCdQ|`lv3!hI$I!n(*fER!<^2keg~kd)qoCjC0nru7E%nsEfhxIzcpXa0t>`w7TTA{h z4$pNn{`|dHeI*Ff=_O{OA!;zh=g}0gkc?l;38h@fc9EOr;c1AY+Wa3(L5jvpBMA6W+fL+ zT=J9RC6~#F^(w-j*YrDCT)1?OJlyk^09RXmT00pLq@d5S-x|94WF)5vZ?>zV_95|r zRU}qKZDrk#%Ke0j@Aqnb69i`^0cYHkPRk(9I6l+5H^2X$Z9y|MK*yUAz8j z-o0_HcY^9HtlBDye=lF1enu~Po1btLzbwIa4_=K(;s{>&L4JhlM<2}XR}q4&8t@C*BacZn~xB_CD|g* zeP(An`VWNFO6q^^`Exv#IuIADkEX#$DN5d^g!}{E*y5+)^cj7pKN#Y5n*$N-8zt8jXPteurp&2_q578v z`ZJ+=W1RM^kYybMo5vrS?Ti)hn^^S9Oq$bXNYvX1f4|XgX=yN_IQ3d{*QkJ@I*+F8 zye3XwusC1KMQcc<4u~ew8^ic4ME0p!{MN;lMy}ZhC1Jxnbxs$nYx0{elIPc6>UHW% zJIx@qK=y=Jw_B5GViAF)rQcwyMk3mWa~wf81SGelWCjd`t7bY|M&H)Ka#cT9 zi_Uohsj6c~26rXWQ@b0M_EH`X5AEl+sg1@|r8yP!niD+n%FZyfA`Il6AbnuBKC_;F zYvv>18%pEFJ!h#j;WMGRiq_=OPWH4ipWGoU3u-OkM&qui7IPDq@*fP|8NDeL+zQ@+ z#sOO29_`zqPRA^Z)8|E3`T09>LD4-H!$Wi`pIAD9om=z>cQ}5G!hYUZui;{#M}RTr z-f>p0O;u5d&KQ5fh8wl%nyK=BsZjLgEY*lN7#%E7TZ+vhUK=Pu6I3*SGv;xfy!K)9 z;^(HplPtUGJzk=B%4+t6@n88*x`9I?KV$b8ow%gxXA zyaM**BIu(LkCLC7%<+q!Gw40>EZ(ZI2%N!E9WcoAFR!^-xD+5OzXIqL2g$E^?)Zw! zUSk$6qh`C8QHCZM?K2bvLCeIt)1HB#)haI?k9285l!=<$zrzKt4h+bbmdo@o*Ida7 zf)G10-`~H3K{x-^U_%NcqDhDDwj|<sjf4iq2%VH$L9fY;ZL-8F+#46MRUeryC~J z0i?az#rj?wp078Xhb2%geWHA+%y55HA=rZT6IP&R>82Ci8JuZcdBZAHM8eoK3~71r zD*UT2j^fO|Q)!)TEe4*^(MjiFpT)P0SzAa~R#gqGq|L`8@TncBDrL0|QDpmmcT66L)&robD>n`+#ZYwBlZ{C zz2gcnf7KKN@YvEzrH*8Id2r21q!}v#x4+Kr*zj=9JgGxxd#cmssZXPNxi^uFq(jaC z5!;Wfc70(nV)H~2Y^0aE2l7$r-yf*^Xmi|FyiWh09k=^Ri3ofN(c^kV_uXkNSmmw-~+nYin3{ z!(0vYTP<(aAjj(T^pJl$}c}<40QV%CdSSQ)6@2uTeN&V z2?|fqYuGj4{avCH2PqTQj&zDe@h;Tk-o~zhA>Ro-4%b}rCv?}Y+nSbI3%XLaJK6-F zi6p2Ymii=2IW@c(^M!Y~j2ib7YamT1PlUR_=8XAAF+J@IdvDFi)Tn|U1jnW1FUdE| zMlIgzLsb2n;%BSA4s+PxgmY9nj(BW4w6opV+Fxj7FN+=9rkyZQXkOEmFNl0%<&XEsdss zAlN~q{?MN?h!X7;*_j^pvx?wz)e2FA(*d449Shm)VpLq(dzzWZ^U<%{O8%I8Hx2el%M6P(>`Eu1vKu zvaxh)vN{`>d~hWbqsIEWxad=VErO4r-6O-U{Lt#QQCOZ`knC>cr+o&`<~t74z}vzr zqzjKFWok1tnf*i|cxeo+Ce;?L88MX2|7fGgSg`^hhApu+|KgfU3Hoh0ooH{z>i$b~ zd^2~-YR^+0mG}DVWqGMo`9QRREzd>O2yLX)rawo`)XIPrjTePU_9UN#EXuC0yIqmm z-|Mnwhj?hVj%_W?A$_%gcOWESg}rNUydHnf1532+lBFWxlAu`g{m2?+$M7<^XSb|9 zHi3{RP#`pU)8>j}^TP0GxTjPU&H+i5cWWEqU+UcRAiHAOc5i|3`o9MoykM*EVNr*H z!4ozMwkDiJ{?2mO?i$TZO6Z^~*_XR@T4Znf=_T6=8OI6O%HKiOMzP;@{3I}XZv*c4 zT2u?OjNwdU=YycEZyhvu=M^nP$YY(P2w!BYG3WD9Cu=XKs`xXM6`8Rn2!V^VvbLnO z*j5H}$QNP27#n7o?JF%Ih5}X(qaF~?Gbha(GFCW`Cd=4YQopxE{S?F!^fIZ~i3u+Y z(x;i>Y^6?5U9cAPaRRVjp1|cx|E2)<@7a9*Vz`EszAtm9f%BKOJ23#zSxvEL7Od`P z58*8B13Hg3$=q3sSr&%}s(v&CK+CgUbDZN!+TD|W=s!cwjJA4Ucr8~07bJM3?3Zqn zsf_tN(@vE~47M0G<9=zdBjd$;N+1VykHKoa0>xL??6=*^5)fW)JIROh5}ZBX&s5l~ ziIhwC%;E~>7KRE#1(tV5ZOM|qwD#o#SguV{--zD+=&{*F38_{=s=xr{E?lnobAj_*{w#j394`3l?@vBMON+5 zA|M>Urx@}T8?F2j!wjvkR_F8UpV=S#RHFv3d)-zu5E>IX0ehpoYjao5GS-~?5dzPW) zx|d~-yhhD`Qp(=fsS{;JCiPqW!MJWMKr*A17gMF?44*gbywCF_*dGjU?b;iPe(2tl z4`#S{e1ry(k9kV=w4VDB{!oZ4q}Cv0{Rg}@IT1xb<(Nmm+ae{U(l~yfEoUmE#;(pE zRDP+fv=P`lp!{xc>+yrOP|c)FY>LEug)mU^x*r)^YkdRwv&OVDlEC&>g>->7++xEx z)E^QuCC`}{+ppD8c6P7b2%#fl{QfhOx~kt<$#8Fl(^4Ydg6Crhckvi?Z_;Yp>egW~ zAKHFLU*l*oPaNDBw1Ll?DE7l%=$;DDZdL_IQacGGk))p(-pOLWOzBw~c^K zpt-2=i~MI~6}{xsN>?V#i<<-e^w%;eX>~vQZXWGv-Uff-{3RD93hGvNTFN=8Iyz-! zzuGr|upKtuTlKQ1A@Ji%T#Rt^zZze zS=Fc>1ItFUB?ECj2 zRoQ!akB^|h%}&eEdaG>L;%V9WqxxZcY3tmr~|(7 z=Mw4U9cAcNfgeV2dhzfa?ynj$+O#W(Tz$bCqaBO3o9Pdv1uMfWcirbJ1P9l$bz__* z{BF$`=*73~#;e<03+gX&Kz>;eQrbgyYJ*vRX%hV zqO3DM_C1^i3GApU!xGpZSJyXW6H18h6k@GFTvyw^Ss&DR$p#8gEDEHd#8)p=H}oUb z=@1_2Ljom1diGh*YXjGDZOCJJyk%iPGOg^jy9kh1`=wN+XcV0B zr8rkFA>0=|E~R0_Q>KzxbiKdck@Ls55GMGiyp#8k{=)9v6}<8@p(?^(5;C2b20AdB zzP9fHU;hjpc1f->fw%CLL$@f}!ixNsjGioP3I7U4Eh4n_U0URhE||Z={Kvo}-f`*m zz4|Qsg4Vw@Sdv%nKCryB5XU2Z1~r&fS$2E4@?_Fp5>~HVdO}Zr9*^GnSJg+!eom(=hIMTYcI)?XZ3S12BpQfagLOtk z8?Wbxl7of{7bLgf(edRCrSiTDKiTMAZ-i7I zU5DAvktVi&(0&_>Dn_#oDxC1T6cw#$9G6V;_<@Oj(o9Ua15=XHT%y9KvS zq`ouz>kontI1Bn~wAd*eO%8m5@z0iF`mTgwjc2^?A<_$cWFcD8UP1%9^Mo!}Y8CW5 ze0`&q)1$)k6V#?*pX((dTGYL-eO`F+95<{=A#?yE0mF5Z$Ten_5VxbW<+sQ_9`JqoJE4^G97NO&3M z{7{$NK%6VZlW|LvM#N-u`j->QcihGQjwTQI_p8Z&BnJGqK5!-ux>7>(t0Yex6r67% zhmYQUk!lN5KI_Y%1eowWU(;3j*l1%sfX>lOB;Mg5%~c40`4S=!gf(cP{u>b&Jo_!J zj{5baIlz;?!FobM1Kj&AFTC)@>ogyKg8;o_3Z*WYNmBA;@Z7geuVk(hz-EM9oW{G# zuv#ke65E(5SN=&3Gcv`rw_`AqNDbamj^2euJ7yyiv$mM_fSErS!Q>#P?*i{{s{0m& zKc#)yteI$SlbRixRx@AUE`DLq{^%+IRHLGZbmlhq-*4$>pc6v?tlw7ZJ8d4L-o>UH zwz`^a?XBDe8va;-Tr{C7>|V+>~CY_S6nae%`f#T;wheOmn zw7XcgT_;O4{lj5HT6Wst;0$?X-6N&DaVi%z9`*N@urRu!0Ehe%yVGR{0p3fW4+MPH z((igpgDf4;$zPJCq>EUWV88{x<_^ivfJnn%PQZ%#zB%JrxPaurqmfy-AeS4~7}`;^ zOA}R?l;{Pm`IuP9x;^`KWSz1j6>s7_?2?PL=3x=qOB978=m)*QSL|5CO0@xz8bPr#HQz#~gvhBl8mhB6@5~YAZtp_U_oP46jU9B}+!~4WcU-sPwn& zSyVnM3%8Lu%(D;Da?agkk#P|wOQ5>awwGEA@AGMBs670Sm+KE7O9ptjTcKja4JYL> z185gI4Y@K5mB*&FrZG$L%~9VKKnd&#SC$ejG-<=lX+^avI`G&izDMv8Nv(n^dSAHr z`t1?V%wMW>+RFb2CdG5A_QU-_6MVWd^vYzoY3G31fCZe=a`ciY(Eo- z?8~fGkjfcSA#EKn^SXNMpT}^w-h5O#Ks(^Ws3KrL-1CX0a#AfjP(d`2tva@0vEfQ7 z!rS$FUQp&2kFW7U?P+_i_2KK${6u+1#`g!pJHaJi_QvxA)(yXBD-t!o_GyfH@fpwM zIio@qkC2l1@GVJPt0{fam@JE!J${orq{R$Y7-)Y?6SXnPf<>3uJ8<|jsEsY+j5tvB z0u3xaCfDZg@7m4D0Jv-@H^)lS6)8=TAyA?1tO6J~Ch*i$JjaGMKvhxsm18JzQ^7q6^8oPO}PBdKcM^k5BZ2*U4n{>&~{KU$Nd1U3Gs=s`}^}? z)H`^R9fJum2Vq~e_?MnTn{sW+-_UqLcc|q{gHrJ;}GI!`pVZvYtLiv@os zoi)h>{Ia9DrOppR&z?he%(9yXywC@hpkHVqj*lB%5Jk!8#NDB7Rf8S!OeS1JMNto7 zRz;dLSh5hd4t31Zw@&PPlMJ=0Dnoy_VIFX%U~@LfAhz*FuXA6Q`YwS7cH5SUlh0l9 zCAcvX;H>71x?Oq>CxRT|mbxG5QaV0z*=TD4>KpcFFoB?Zd9pPvKnOkUr@cvfD{f;H zTs{O;(*_K=D)K3!SRPgcSoMe&K!@!bxW45<7v`@@ z9MA>i^GtrSKF%BsG~C=D2jJVkUM7KV`575JqXOuU&4*6*`3p74Kd_nnoA1)wy-x!m zt)#@zF6$>qQCNF$Dh?I$A*{0qpl%GnLM@kK=5^*K2BTY+hOP$CCssR53GgMz%W4bI zcSCP|(TbJJFC~bUpb>xh*eJ{S8)+HPi1vFKUK!BPY+a3g&)}=Ua~w2dEmmhNhRzhw z59bM5kp5rMME|Eptm2Ex?SN1X{Acx+ngvq{;N`W7l9Q){f6)=rC5Q=ra2FIsF4^3($XY z7hPd}9Ls9Lq@YwBy$5O-=)R44bf$(*SPtdt?fFZEKNABkSpV*k^EbC~ya|DoVXTdc zNWhua^WQP(5x#rNMVe1la&YH^HxL85bi#{CL9g~xG1@CdfMK%T!~*CR^%qe*R0^Fg zlQ|lf{hxcrgjheorKEYlNhUcHIrNrdKxz-Tp4Gq5Ws(D;uYbhOpie`Up8e(Bhy`G% zFA#n0=S~fmzxG3)wf*yd{7=^Rzkc?w#`S+<<^4Ca`@c^A=4AX&mz)3JdR^sapmK^< zBoRC1Vnn?~_kdfe)^kBZgR~OJTb`K~wPLl1$vPqJ{q@y}0^2;JH)rs=hgT$>? zFMBf0#Zom`I#ocG2e^%ks6;QwuwjPEiZU<91-sCdEE{{SN{mGXm;Ly*`Dgxt zM8|@293#O(O`oH(my#dw5p&KN>w8gs{#?>xJ3;*#&yjQ8DseD2AyH z2t(dEmLZGhOOD$h1;A zLFwau@iVfSx2JBrdt=6`ToSFjrb^vAP;2BB(McDR7T~2)fO3P2gClkV7*>md6&6%y zSgt!25!aG{4|WnRKHql90eH?s<~h&|>(QeV1H#mmkH+QRDcCK?zC}g&iZ6UYa~sR} zEkf6jjLBC~Z?_~ZjBHMxENpI{kZ1UmvP~5&74leT+Xs81T9Hf%&;;Y%jdyK5L_Co% z3oG#*-+#YMZflr!vl#2Dh}2#pr$+r;f#z=uU+4t{S0>lFb7rz2k16mKTi2_@ds>l- zFA{~fJlLzY;LO*ZIggMc5BJP$b;O=?a2NQSj{Im2PEA(i-(?YGFC|Jb3NqZFm0*m^ zLCR%xycGwqRrYFLw9;JF0L@!6G)2KRVbReSiF-RO~aqLXKYZ8;WoA_Wo5GzRio>rG8cy=acl0IMro? z^*+`6J~};)q_EXL&+*(nJ@;MbZeEuBMZkHImfG5ftzF z!6a;X`)c^f!I&6K{i1eRBh#{DUzbKu1#G8Nh?}I~SlU~LpIWtSVp5JsW4qR-RK8P3 zB5L|~SAOeg0ZeB@)xgEc&d>Y_WQ3QSP#RwcQrh6?tVYM&_l6EAzi3Puz4gP;-)8ZoFrF5TS{JGvME0WaMTj*FtA3#0hOq7bi2&T0?Wg^4&Fqv7FWDe8M}B4V&rNFLk4zYb)P!GBiV9l8{r+EBP4cL~}hO2t{_0I!*v>hFb%Zfjf*T zQO2YnUV$d1MxS%iG2dbGD5>ZZHnUfLqm&!QMeF6wo(FKwQ2Q^4U^7I*Vr;^Tio{Qa z~%4Js*l!`2z!D&|~yLVhDG=7PDs!_}8sX;_l zUH!X+ufQ%6*7ktkv{;Pw-1rP!b35*zpiRC=n`N{|)kS8I}b6i`RVqVfcTuj|%f9P?cOC^wtwr6oL zkX9TEbLRwx%Ip)d6|x0ORB#DOX(cR|__Z1zbhLLaO+M-q|AV1~M>Oy`YB8F<6lAJg zu53pd8RDHJFlPwVoYrI2Vv8h4w1ov>y}Y*a2zL^o?{nUjjN^O+OnHgix1bYKzxAkP zMl|CuL02QI78ZcE#_C5Yiz*ci4E60VsOH?nde3`^8r(@70F=jGLWryUPxbKt8&_7r zsR~_|**Moy+Fbb2=gm(F4B{yd?T(Z2 zVxH(oWQOVrht^Y!6bR!naM_*z&^x`$mmSX}%5fKYm{wQE>HtA1$s96H>HF8CrCBHX zpog>(?>9*sepqq*6tWT0&~VW3&J_M(BJlmtHwQw7a=npF#V-54c1wKntSuSsD2m9& zRGA-mESn&Z#~R%yu&u+rtz5?66X6uKDNF5D7L3H~;*+yoJLmx5x;#+Ug8G;aogan5 z#!YN4VjPK+kLgNS`X`dH-9p=hAmDRuho3v^< zCWPo2U$??tJJDxx@@iEBGh{1U(F{vh`ZD+&0vDv$C`|C`50mnr;biIGdtW_gmx72B ze9d380~ax7R@g6n%w}s$J?=a9X;LarGky%CV zhSNIVn@@GxwmM1)IN`{0*GsiS_l&Vs$6eNvNzk4|Qa^zZBeOI2Fy*1$J>L{|Z{ z;puAvM#I7mRs@Vw`~~amBkB~A%-#Vdc*Y(b$F9P;=fP6?u+RrW8W8rs8)F={m+XB_73)FTsO0 zf{EOe#s@$?HFo;ea#NC}Vr(AbDvz%~x9)%1)bTfVN6h}&Q1WhsJ+8 zkmeNga-Qev?6ySTs$7?G?=#MIX+9{I)w#j&6MA7p7^HPbIRC(+bK>BhsDsa1+5|`Z z%LZHHkZHsl997i;M~0>i&nEC8&{7nqrc~NBaqq2NNSN~QarWnAFwZiMzV`Pmtd-H_%j0i7t@H^k|T^f=RwAd0$yE8+8#TwlaoMiV{Bgg(Wi|g_+R_bzP zb_MGYuQy+2TKL!C6FNU#8uyxAS?VU|*?tPx!q^(Sl90E9t0j8?W;pzWH&EuQ3nst$ zM+YfRPTV7J*;%(NE@$!SLsPBIy%w+|7vp)t8su;-2s6xU3NQL>nI(JobOWJKYkpz{ z-46&C`F-U4`{nC3pr3h@TDg%H+gIkM^2|d7z{Wm^D119h_%UT6A&)lOeO!jM>X4Y0 zpT7)(rFmu9jlO5iBLUhLOB2P?La9NQcGuMQa^rO$&`ab^e4=?aQm(>2}l8~5m8l4f7Et5^*?BAxciSrr;s{MLpN||b? zc;L4bCQD%dj2N&m(?J@Hk(6<1aT+a{#s}0<^T$gdA7dKo4q|aM{=YiwC&*&p>>T>o; za*K(l(juwB>E?%O*^P!;?HWtn%c0_M@z+2Xe`W9X{I3ytd$EIfAa&g27hv8$j!OvQycm!e?5hVKDti*;n%Xv(XjQ6TnF0j5dh7#NpWq z9i-zfF9s|B%fN3ump>S%(SirV(1O)Zj_UCkyA5ro3AL|SvD(RRk-)#PKHqHObw z{s2RSpm<>1hZ#*xh{doBKZ7?U+%#{7o!d>Z-Sm}V*~UNLrr z%IJ{B4px}RuY~4RNTE1H9ClxH0Gxd?$E1Ni?Wr^UDGZEt7IN*_eYvPFB0i-_S!hUH zuNOT-K+PKd*E2=0UaQRh`h)S?!Uw0MJV|lAhciBoRa{_1-;m31y)#1TW|GG5QSPfI}4)}9Z08Ihb4y|uEg zI(D9;9PD$*Ku%U012UGQ>=S7;J}5q)>9*p6RDA(jA2b9*e;8T7FTh6(ox33lV*b*I0^# z@`o+m&Ehm+m7C=kEuOyJMSfD>xck@k4IY=92G03kqxhm<|&GnNo2%xU1}Nu zz6h$B;nY2KPj=dPUl6C^Pz`e_7A>4rV>^z{5ZG&#@o>>dB!(MrxL}%2lO%hHi4O0F+b7zhQDgOG?S_m`J~-OwhVXT@#HM=uOu`14sk=@k9N z{ABpy#37o4N}9L_A__b>w-HbmJ8<1yjuis-#Kdkj{i+pc74>4HEc4E2h~!E(Yo72x456}oSd72IBebM zhDO!$8xPrwjQXb2<&N$J2M?PjC=csX*HacuEd`erEfe_!^58jQ>p*DGh+GTP5<=q* z(?aL7)b@nSsBdP#4H9O8r52h^SqdO!J$$46u4DZBtrJm_S9^SQ>noFj09+|H2zRUVJL~TA~)>o z5KCdWpUqf!Wm;VgwQ0dn_)A%tfR%GxTBj}*u<~rE-T<==#5DbAJ}B*Gc+^l${HrZu zNM9zt@molH7!TI89H4ARJN4S}MP7Q)rc^P1#+HybsGMG3G($I8jU=d?qab9QwQ)DM zJ#QEjbT9c{=S(8&4@TJ^3_?t|9p1_-f!eS3N1sFaIy0E(*5%O16ov25p@AK#-!qpI ziO`msC}xnb5v*kS4lvUE>-!d z>G`=vrEm85w1V;wTmO@}+1BXd1FacjZ0vlFew1*l%aP#QwM{tNW7!FV*~xoQlFR2$ z8I2j1)v^k7VchIq%0m#%=AV)t0xJw14cMd<=hP_V@M24WN5BH`2wz46(&G!yW00%MZOu ziH<)xs&bF%C8s2gJg$okMo=WwE0fYL1fSuks9BBUE^A>v#q8)1#YrTZW;J0EQbaW-B5*~3(ik@!aH=Hl8JEMdgezNTR!AQ~Cqe!q-X>nP=!gH~#mEgMy$)0@VNG2^&zJEb9aS&M z&rZ=|>p|Yzy}3Fk=SQEWu|fS>2DNDnHq}GD`ZjbWc3<91E}}-3hPs!X(0N}G{YsCm zqQ6xX+L|dwx2GZ&h1dn&Qx(#i-3Go@+SynN!u&Wxcy(aJ3*deVrQ6!<=7tTg%xkLY ziV51(w!7q3h3Pnxr>`vlZIJTo4{}&O6h0LvHSi|z*L#pt*;+T;83s1iV+SR7G+}Z_ z9yfj1y()bGMLL|^$W%TOo~&!`C;x-N>~b&h*vbm`=8((j<^j7 zl%vEG&;Egza-Q?neM7oOj6t`LEmd2E#>39QPR=>L8t;`~M>XfdYo*%;T;2c8W5r?d zco;rpDdf#h%*3%9jZ%(ncggNwzTZ4D>Rh_>gY%r@u#i3s?>7!=m*ooq9UE-78m1Ub zapD~_^dnKP6V7q0{$;=cel{*uTFA`e&HY~)WPgWWVnYub*%T`DthI?21D`GJD%^(V zT6_)YrORM)g{r3M>C z8LfFKkP)q{DwUGl|G#SQ|K?Ht9X@ix?oiKg)!XY8+rYW-hs&Og<8SeyC}TP&1&ybe1KJ@^49#CqD7 z&4pOOOOaQ^RY06NW;T{2{ET!Yo!R{L4r%W~lj4CIJd>*Z?Gslk=xwWlFJI9Y|HWlK X3z>jm$w?-k|C*!!)T3tlv+&;lZIf=N literal 0 HcmV?d00001 From 695f385efef9d311d7218af9bf22606f7333e60f Mon Sep 17 00:00:00 2001 From: LemonDouble <31124212+LemonDouble@users.noreply.github.com> Date: Wed, 24 Nov 2021 18:51:05 +0900 Subject: [PATCH 146/146] Update README.md --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 561cbb702..a0ae387b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,14 @@ -# Example application code for the python architecture book + + +## 파이썬으로 살펴보는 아키텍쳐 패턴 + +파이썬으로 살펴보는 아키텍쳐 패턴 책을 읽고, 예제 코드를 살펴보며 주석을 다는 리포지토리입니다. + +단순히 책만 보는 것으로는 이해에 한계가 있어, 예제 코드에 직접 주석을 달며 이해력을 높였습니다. + +현재 도메인 모델링을 지원하는 아키텍쳐 구축 (Chap 07) 까지 읽었으며, 작성한 주석은 해당 리포지토리의 브랜치를 통해 확인할 수 있습니다. + +--- ## Chapters