From 091f758b8b550220335cca2245cd763dec0544e5 Mon Sep 17 00:00:00 2001 From: Milena Date: Sat, 27 Sep 2025 19:40:20 +0300 Subject: [PATCH 1/5] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..303e081d 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,6 @@ from typing import Any, Awaitable, Callable +import json +from urllib.parse import parse_qs async def application( @@ -12,7 +14,122 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + + method = scope.get("method", "").upper() + path = scope.get("path", "") + + async def send_json(status: int, payload: dict[str, Any] | None = None) -> None: + body_bytes = json.dumps(payload or {}).encode("utf-8") + headers = [ + (b"content-type", b"application/json; charset=utf-8"), + ] + await send({ + "type": "http.response.start", + "status": status, + "headers": headers, + }) + await send({ + "type": "http.response.body", + "body": body_bytes, + }) + + async def read_body() -> bytes: + chunks: list[bytes] = [] + while True: + message = await receive() + if message.get("type") != "http.request": + continue + body = message.get("body", b"") or b"" + if body: + chunks.append(body) + if not message.get("more_body", False): + break + return b"".join(chunks) + + if method != "GET": + await send_json(404, {"detail": "Not Found"}) + return + + if path == "/factorial": + raw_qs = scope.get("query_string", b"") + qs = parse_qs(raw_qs.decode("utf-8"), keep_blank_values=True) + values = qs.get("n") + if not values or values[0] == "": + await send_json(422, {"detail": "Query parameter 'n' is required"}) + return + try: + n = int(values[0]) + except ValueError: + await send_json(422, {"detail": "Query parameter 'n' must be integer"}) + return + if n < 0: + await send_json(400, {"detail": "'n' must be non-negative"}) + return + # factorial + result = 1 + for i in range(2, n + 1): + result *= i + await send_json(200, {"result": result}) + return + + if path == "/mean": + body = await read_body() + if not body: + await send_json(422, {"detail": "JSON body is required"}) + return + try: + data = json.loads(body.decode("utf-8")) + except json.JSONDecodeError: + await send_json(422, {"detail": "Malformed JSON"}) + return + if data is None: + await send_json(422, {"detail": "JSON body is required"}) + return + if not isinstance(data, list) or len(data) == 0: + await send_json(400, {"detail": "Expected non-empty JSON array of numbers"}) + return + # Validate all elements are numbers (int or float) + if not all((isinstance(x, (int, float)) and not isinstance(x, bool)) for x in data): + await send_json(400, {"detail": "Array must contain only numbers"}) + return + total = float(sum(float(x) for x in data)) + mean_value = total / len(data) + await send_json(200, {"result": mean_value}) + return + + if path.startswith("/fibonacci"): + if path == "/fibonacci": + await send_json(422, {"detail": "Path parameter 'n' is required"}) + return + if not path.startswith("/fibonacci/"): + await send_json(404, {"detail": "Not Found"}) + return + raw_n = path[len("/fibonacci/") :] + if raw_n == "": + await send_json(422, {"detail": "Path parameter 'n' is required"}) + return + try: + n = int(raw_n) + except ValueError: + await send_json(422, {"detail": "Path parameter 'n' must be integer"}) + return + if n < 0: + await send_json(400, {"detail": "'n' must be non-negative"}) + return + # fibonacci + if n == 0: + fib = 0 + elif n == 1: + fib = 1 + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + fib = b + await send_json(200, {"result": fib}) + return + + await send_json(404, {"detail": "Not Found"}) if __name__ == "__main__": import uvicorn From 3927813c0814c06099e1a2f431208d4c83c6d7a5 Mon Sep 17 00:00:00 2001 From: Milena Date: Sun, 5 Oct 2025 17:56:51 +0300 Subject: [PATCH 2/5] HW2 commit --- hw2/hw/requirements.txt | 2 + hw2/hw/shop_api/api/__init__.py | 3 + hw2/hw/shop_api/api/cart.py | 83 ++++++++++ hw2/hw/shop_api/api/item.py | 79 +++++++++ hw2/hw/shop_api/grpc_server.py | 90 +++++++++++ hw2/hw/shop_api/main.py | 7 + hw2/hw/shop_api/proto/shop.proto | 22 +++ hw2/hw/shop_api/schemas.py | 44 +++++ hw2/hw/shop_api/shop_pb2.py | 50 ++++++ hw2/hw/shop_api/shop_pb2.pyi | 63 ++++++++ hw2/hw/shop_api/shop_pb2_grpc.py | 269 +++++++++++++++++++++++++++++++ hw2/hw/shop_api/storage.py | 22 +++ 12 files changed, 734 insertions(+) create mode 100644 hw2/hw/shop_api/api/__init__.py create mode 100644 hw2/hw/shop_api/api/cart.py create mode 100644 hw2/hw/shop_api/api/item.py create mode 100644 hw2/hw/shop_api/grpc_server.py create mode 100644 hw2/hw/shop_api/proto/shop.proto create mode 100644 hw2/hw/shop_api/schemas.py create mode 100644 hw2/hw/shop_api/shop_pb2.py create mode 100644 hw2/hw/shop_api/shop_pb2.pyi create mode 100644 hw2/hw/shop_api/shop_pb2_grpc.py create mode 100644 hw2/hw/shop_api/storage.py diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..89856fde 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,5 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +grpcio>=1.62.0 +grpcio-tools>=1.62.0 diff --git a/hw2/hw/shop_api/api/__init__.py b/hw2/hw/shop_api/api/__init__.py new file mode 100644 index 00000000..6039f90d --- /dev/null +++ b/hw2/hw/shop_api/api/__init__.py @@ -0,0 +1,3 @@ +# namespace package for API routers + + diff --git a/hw2/hw/shop_api/api/cart.py b/hw2/hw/shop_api/api/cart.py new file mode 100644 index 00000000..7b4c1d6d --- /dev/null +++ b/hw2/hw/shop_api/api/cart.py @@ -0,0 +1,83 @@ +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Query, Response + +from ..schemas import Cart, CartItem +from ..storage import carts_items, items_by_id, next_cart_id + + +router = APIRouter(prefix="/cart") + + +def compute_cart_price(cart_map: dict[int, int]) -> float: + total = 0.0 + for item_id, quantity in cart_map.items(): + item = items_by_id.get(item_id) + if item is None or item.deleted: + continue + total += item.price * quantity + return total + + +def cart_to_model(cart_id: int) -> Cart: + cart_map = carts_items.get(cart_id) + if cart_map is None: + raise HTTPException(status_code=404, detail="Cart not found") + items = [CartItem(id=iid, quantity=qty) for iid, qty in cart_map.items()] + return Cart(id=cart_id, items=items, price=compute_cart_price(cart_map)) + + +@router.post("", status_code=201) +def create_cart(response: Response) -> dict: + global next_cart_id + cid = next_cart_id + carts_items[cid] = {} + response.headers["Location"] = f"/cart/{cid}" + next_cart_id += 1 + return {"id": cid} + + +@router.get("/{cart_id}") +def get_cart(cart_id: int) -> Cart: + return cart_to_model(cart_id) + + +@router.get("") +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + min_quantity: Optional[int] = Query(default=None, ge=0), + max_quantity: Optional[int] = Query(default=None, ge=0), +) -> List[Cart]: + carts = [cart_to_model(cid) for cid in carts_items.keys()] + + if min_price is not None: + carts = [c for c in carts if c.price >= min_price] + if max_price is not None: + carts = [c for c in carts if c.price <= max_price] + + def qsum(c: Cart) -> int: + return sum(ci.quantity for ci in c.items) + + if min_quantity is not None: + carts = [c for c in carts if qsum(c) >= min_quantity] + if max_quantity is not None: + carts = [c for c in carts if qsum(c) <= max_quantity] + + return carts[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +def add_to_cart(cart_id: int, item_id: int) -> Cart: + cart = carts_items.get(cart_id) + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + item = items_by_id.get(item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + cart[item_id] = cart.get(item_id, 0) + 1 + return cart_to_model(cart_id) + + diff --git a/hw2/hw/shop_api/api/item.py b/hw2/hw/shop_api/api/item.py new file mode 100644 index 00000000..56a8dc59 --- /dev/null +++ b/hw2/hw/shop_api/api/item.py @@ -0,0 +1,79 @@ +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Query, Response + +from ..schemas import Item, ItemCreate, ItemPatch, ItemPut +from ..storage import items_by_id, next_item_id + + +router = APIRouter(prefix="/item") + + +@router.post("", status_code=201) +def create_item(body: ItemCreate, response: Response) -> Item: + global next_item_id + item = Item(id=next_item_id, name=body.name, price=body.price, deleted=False) + items_by_id[item.id] = item + response.headers["Location"] = f"/item/{item.id}" + next_item_id += 1 + return item + + +@router.get("/{item_id}") +def get_item(item_id: int) -> Item: + item = items_by_id.get(item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +@router.get("") +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(default=None, ge=0), + max_price: Optional[float] = Query(default=None, ge=0), + show_deleted: bool = False, +) -> List[Item]: + data = list(items_by_id.values()) + if not show_deleted: + data = [i for i in data if not i.deleted] + if min_price is not None: + data = [i for i in data if i.price >= min_price] + if max_price is not None: + data = [i for i in data if i.price <= max_price] + return data[offset : offset + limit] + + +@router.put("/{item_id}") +def put_item(item_id: int, body: ItemPut) -> Item: + item = items_by_id.get(item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + item.name = body.name + item.price = body.price + return item + + +@router.patch("/{item_id}") +def patch_item(item_id: int, body: ItemPatch) -> Item: + item = items_by_id.get(item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + if item.deleted: + raise HTTPException(status_code=304, detail="Item is deleted") + if body.name is not None: + item.name = body.name + if body.price is not None: + item.price = body.price + return item + + +@router.delete("/{item_id}") +def delete_item(item_id: int) -> dict: + item = items_by_id.get(item_id) + if item is not None: + item.deleted = True + return {"status": "ok"} + + diff --git a/hw2/hw/shop_api/grpc_server.py b/hw2/hw/shop_api/grpc_server.py new file mode 100644 index 00000000..4a5dbf54 --- /dev/null +++ b/hw2/hw/shop_api/grpc_server.py @@ -0,0 +1,90 @@ +import asyncio +from concurrent import futures + +import grpc + +from . import schemas +from .storage import carts_items, items_by_id, next_cart_id, next_item_id +from .shop_pb2 import ( + AddToCartRequest as PbAddToCartRequest, + Cart as PbCart, + CartItem as PbCartItem, + Empty as PbEmpty, + Id as PbId, + Item as PbItem, + ItemCreate as PbItemCreate, +) +from .shop_pb2_grpc import ShopServicer, add_ShopServicer_to_server + + +def compute_cart_price(cart_map: dict[int, int]) -> float: + total = 0.0 + for item_id, quantity in cart_map.items(): + item = items_by_id.get(item_id) + if item is None or item.deleted: + continue + total += item.price * quantity + return total + + +def to_pb_cart(cart_id: int) -> PbCart: + cart_map = carts_items.get(cart_id, {}) + items = [PbCartItem(id=iid, quantity=qty) for iid, qty in cart_map.items()] + return PbCart(id=cart_id, items=items, price=compute_cart_price(cart_map)) + + +class ShopService(ShopServicer): + def CreateCart(self, request: PbEmpty, context: grpc.ServicerContext) -> PbId: + global next_cart_id + cid = next_cart_id + carts_items[cid] = {} + next_cart_id += 1 + return PbId(id=cid) + + def GetCart(self, request: PbId, context: grpc.ServicerContext) -> PbCart: + cid = request.id + if cid not in carts_items: + context.abort(grpc.StatusCode.NOT_FOUND, "Cart not found") + return to_pb_cart(cid) + + def AddToCart(self, request: PbAddToCartRequest, context: grpc.ServicerContext) -> PbCart: + cid = request.cart_id + iid = request.item_id + cart = carts_items.get(cid) + if cart is None: + context.abort(grpc.StatusCode.NOT_FOUND, "Cart not found") + item = items_by_id.get(iid) + if item is None or item.deleted: + context.abort(grpc.StatusCode.NOT_FOUND, "Item not found") + cart[iid] = cart.get(iid, 0) + 1 + return to_pb_cart(cid) + + def CreateItem(self, request: PbItemCreate, context: grpc.ServicerContext) -> PbItem: + global next_item_id + iid = next_item_id + item = schemas.Item(id=iid, name=request.name, price=request.price, deleted=False) + items_by_id[iid] = item + next_item_id += 1 + return PbItem(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + def GetItem(self, request: PbId, context: grpc.ServicerContext) -> PbItem: + item = items_by_id.get(request.id) + if item is None or item.deleted: + context.abort(grpc.StatusCode.NOT_FOUND, "Item not found") + return PbItem(id=item.id, name=item.name, price=item.price, deleted=item.deleted) + + +def serve(block: bool = True) -> grpc.Server: + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + add_ShopServicer_to_server(ShopService(), server) + server.add_insecure_port("[::]:50051") + server.start() + if block: + server.wait_for_termination() + return server + + +if __name__ == "__main__": + serve(block=True) + + diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..a088c0bc 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,10 @@ from fastapi import FastAPI +from .api.cart import router as cart_router +from .api.item import router as item_router + + app = FastAPI(title="Shop API") + +app.include_router(item_router) +app.include_router(cart_router) diff --git a/hw2/hw/shop_api/proto/shop.proto b/hw2/hw/shop_api/proto/shop.proto new file mode 100644 index 00000000..5dfcd87c --- /dev/null +++ b/hw2/hw/shop_api/proto/shop.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; +package shop; + +message Empty {} + +message Id { int32 id = 1; } + +message ItemCreate { string name = 1; double price = 2; } +message Item { int32 id = 1; string name = 2; double price = 3; bool deleted = 4; } + +message CartItem { int32 id = 1; int32 quantity = 2; } +message Cart { int32 id = 1; repeated CartItem items = 2; double price = 3; } + +message AddToCartRequest { int32 cart_id = 1; int32 item_id = 2; } + +service Shop { + rpc CreateCart(Empty) returns (Id); + rpc GetCart(Id) returns (Cart); + rpc AddToCart(AddToCartRequest) returns (Cart); + rpc CreateItem(ItemCreate) returns (Item); + rpc GetItem(Id) returns (Item); +} \ No newline at end of file diff --git a/hw2/hw/shop_api/schemas.py b/hw2/hw/shop_api/schemas.py new file mode 100644 index 00000000..bc10071b --- /dev/null +++ b/hw2/hw/shop_api/schemas.py @@ -0,0 +1,44 @@ +from typing import Optional, List + +from pydantic import BaseModel, Field + +# Определяет модели Pydantic: ItemCreate, ItemPut, ItemPatch, Item, CartItem, Cart + +class ItemBase(BaseModel): + name: str + price: float = Field(ge=0) + + +class ItemCreate(ItemBase): + pass + + +class ItemPut(ItemBase): + pass + + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[float] = Field(default=None, ge=0) + + class Config: + extra = "forbid" + validate_assignment = True + + +class Item(ItemBase): + id: int + deleted: bool = False + + +class CartItem(BaseModel): + id: int + quantity: int = Field(ge=1) + + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + + diff --git a/hw2/hw/shop_api/shop_pb2.py b/hw2/hw/shop_api/shop_pb2.py new file mode 100644 index 00000000..d33b6b4f --- /dev/null +++ b/hw2/hw/shop_api/shop_pb2.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: shop.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'shop.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\nshop.proto\x12\x04shop\"\x07\n\x05\x45mpty\"\x10\n\x02Id\x12\n\n\x02id\x18\x01 \x01(\x05\")\n\nItemCreate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05price\x18\x02 \x01(\x01\"@\n\x04Item\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\r\n\x05price\x18\x03 \x01(\x01\x12\x0f\n\x07\x64\x65leted\x18\x04 \x01(\x08\"(\n\x08\x43\x61rtItem\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x10\n\x08quantity\x18\x02 \x01(\x05\"@\n\x04\x43\x61rt\x12\n\n\x02id\x18\x01 \x01(\x05\x12\x1d\n\x05items\x18\x02 \x03(\x0b\x32\x0e.shop.CartItem\x12\r\n\x05price\x18\x03 \x01(\x01\"4\n\x10\x41\x64\x64ToCartRequest\x12\x0f\n\x07\x63\x61rt_id\x18\x01 \x01(\x05\x12\x0f\n\x07item_id\x18\x02 \x01(\x05\x32\xca\x01\n\x04Shop\x12#\n\nCreateCart\x12\x0b.shop.Empty\x1a\x08.shop.Id\x12\x1f\n\x07GetCart\x12\x08.shop.Id\x1a\n.shop.Cart\x12/\n\tAddToCart\x12\x16.shop.AddToCartRequest\x1a\n.shop.Cart\x12*\n\nCreateItem\x12\x10.shop.ItemCreate\x1a\n.shop.Item\x12\x1f\n\x07GetItem\x12\x08.shop.Id\x1a\n.shop.Itemb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'shop_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_EMPTY']._serialized_start=20 + _globals['_EMPTY']._serialized_end=27 + _globals['_ID']._serialized_start=29 + _globals['_ID']._serialized_end=45 + _globals['_ITEMCREATE']._serialized_start=47 + _globals['_ITEMCREATE']._serialized_end=88 + _globals['_ITEM']._serialized_start=90 + _globals['_ITEM']._serialized_end=154 + _globals['_CARTITEM']._serialized_start=156 + _globals['_CARTITEM']._serialized_end=196 + _globals['_CART']._serialized_start=198 + _globals['_CART']._serialized_end=262 + _globals['_ADDTOCARTREQUEST']._serialized_start=264 + _globals['_ADDTOCARTREQUEST']._serialized_end=316 + _globals['_SHOP']._serialized_start=319 + _globals['_SHOP']._serialized_end=521 +# @@protoc_insertion_point(module_scope) diff --git a/hw2/hw/shop_api/shop_pb2.pyi b/hw2/hw/shop_api/shop_pb2.pyi new file mode 100644 index 00000000..fbb85968 --- /dev/null +++ b/hw2/hw/shop_api/shop_pb2.pyi @@ -0,0 +1,63 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Empty(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class Id(_message.Message): + __slots__ = ("id",) + ID_FIELD_NUMBER: _ClassVar[int] + id: int + def __init__(self, id: _Optional[int] = ...) -> None: ... + +class ItemCreate(_message.Message): + __slots__ = ("name", "price") + NAME_FIELD_NUMBER: _ClassVar[int] + PRICE_FIELD_NUMBER: _ClassVar[int] + name: str + price: float + def __init__(self, name: _Optional[str] = ..., price: _Optional[float] = ...) -> None: ... + +class Item(_message.Message): + __slots__ = ("id", "name", "price", "deleted") + ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + PRICE_FIELD_NUMBER: _ClassVar[int] + DELETED_FIELD_NUMBER: _ClassVar[int] + id: int + name: str + price: float + deleted: bool + def __init__(self, id: _Optional[int] = ..., name: _Optional[str] = ..., price: _Optional[float] = ..., deleted: bool = ...) -> None: ... + +class CartItem(_message.Message): + __slots__ = ("id", "quantity") + ID_FIELD_NUMBER: _ClassVar[int] + QUANTITY_FIELD_NUMBER: _ClassVar[int] + id: int + quantity: int + def __init__(self, id: _Optional[int] = ..., quantity: _Optional[int] = ...) -> None: ... + +class Cart(_message.Message): + __slots__ = ("id", "items", "price") + ID_FIELD_NUMBER: _ClassVar[int] + ITEMS_FIELD_NUMBER: _ClassVar[int] + PRICE_FIELD_NUMBER: _ClassVar[int] + id: int + items: _containers.RepeatedCompositeFieldContainer[CartItem] + price: float + def __init__(self, id: _Optional[int] = ..., items: _Optional[_Iterable[_Union[CartItem, _Mapping]]] = ..., price: _Optional[float] = ...) -> None: ... + +class AddToCartRequest(_message.Message): + __slots__ = ("cart_id", "item_id") + CART_ID_FIELD_NUMBER: _ClassVar[int] + ITEM_ID_FIELD_NUMBER: _ClassVar[int] + cart_id: int + item_id: int + def __init__(self, cart_id: _Optional[int] = ..., item_id: _Optional[int] = ...) -> None: ... diff --git a/hw2/hw/shop_api/shop_pb2_grpc.py b/hw2/hw/shop_api/shop_pb2_grpc.py new file mode 100644 index 00000000..1ff6de5e --- /dev/null +++ b/hw2/hw/shop_api/shop_pb2_grpc.py @@ -0,0 +1,269 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from . import shop_pb2 as shop__pb2 + +GRPC_GENERATED_VERSION = '1.75.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + f' but the generated code in shop_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class ShopStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.CreateCart = channel.unary_unary( + '/shop.Shop/CreateCart', + request_serializer=shop__pb2.Empty.SerializeToString, + response_deserializer=shop__pb2.Id.FromString, + _registered_method=True) + self.GetCart = channel.unary_unary( + '/shop.Shop/GetCart', + request_serializer=shop__pb2.Id.SerializeToString, + response_deserializer=shop__pb2.Cart.FromString, + _registered_method=True) + self.AddToCart = channel.unary_unary( + '/shop.Shop/AddToCart', + request_serializer=shop__pb2.AddToCartRequest.SerializeToString, + response_deserializer=shop__pb2.Cart.FromString, + _registered_method=True) + self.CreateItem = channel.unary_unary( + '/shop.Shop/CreateItem', + request_serializer=shop__pb2.ItemCreate.SerializeToString, + response_deserializer=shop__pb2.Item.FromString, + _registered_method=True) + self.GetItem = channel.unary_unary( + '/shop.Shop/GetItem', + request_serializer=shop__pb2.Id.SerializeToString, + response_deserializer=shop__pb2.Item.FromString, + _registered_method=True) + + +class ShopServicer(object): + """Missing associated documentation comment in .proto file.""" + + def CreateCart(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetCart(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def AddToCart(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def CreateItem(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetItem(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_ShopServicer_to_server(servicer, server): + rpc_method_handlers = { + 'CreateCart': grpc.unary_unary_rpc_method_handler( + servicer.CreateCart, + request_deserializer=shop__pb2.Empty.FromString, + response_serializer=shop__pb2.Id.SerializeToString, + ), + 'GetCart': grpc.unary_unary_rpc_method_handler( + servicer.GetCart, + request_deserializer=shop__pb2.Id.FromString, + response_serializer=shop__pb2.Cart.SerializeToString, + ), + 'AddToCart': grpc.unary_unary_rpc_method_handler( + servicer.AddToCart, + request_deserializer=shop__pb2.AddToCartRequest.FromString, + response_serializer=shop__pb2.Cart.SerializeToString, + ), + 'CreateItem': grpc.unary_unary_rpc_method_handler( + servicer.CreateItem, + request_deserializer=shop__pb2.ItemCreate.FromString, + response_serializer=shop__pb2.Item.SerializeToString, + ), + 'GetItem': grpc.unary_unary_rpc_method_handler( + servicer.GetItem, + request_deserializer=shop__pb2.Id.FromString, + response_serializer=shop__pb2.Item.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'shop.Shop', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('shop.Shop', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class Shop(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def CreateCart(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/shop.Shop/CreateCart', + shop__pb2.Empty.SerializeToString, + shop__pb2.Id.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def GetCart(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/shop.Shop/GetCart', + shop__pb2.Id.SerializeToString, + shop__pb2.Cart.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def AddToCart(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/shop.Shop/AddToCart', + shop__pb2.AddToCartRequest.SerializeToString, + shop__pb2.Cart.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def CreateItem(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/shop.Shop/CreateItem', + shop__pb2.ItemCreate.SerializeToString, + shop__pb2.Item.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def GetItem(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/shop.Shop/GetItem', + shop__pb2.Id.SerializeToString, + shop__pb2.Item.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/hw2/hw/shop_api/storage.py b/hw2/hw/shop_api/storage.py new file mode 100644 index 00000000..06edef15 --- /dev/null +++ b/hw2/hw/shop_api/storage.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Dict + +from .schemas import Item + + +# In-memory storage shared by REST and gRPC layers +items_by_id: Dict[int, Item] = {} +carts_items: Dict[int, Dict[int, int]] = {} +next_item_id: int = 1 +next_cart_id: int = 1 + + +def reset_storage() -> None: + global items_by_id, carts_items, next_item_id, next_cart_id + items_by_id = {} + carts_items = {} + next_item_id = 1 + next_cart_id = 1 + + From 6fd872d7041f6dadc806c8f75a2d640e468d7f27 Mon Sep 17 00:00:00 2001 From: Milena Date: Sun, 12 Oct 2025 20:52:04 +0300 Subject: [PATCH 3/5] HW3 commit --- hw2/ddoser.py | 115 ++++++++++++++++++ hw2/docker-compose.yml | 49 ++++++++ hw2/hw/Dockerfile | 23 ++++ hw2/hw/README.md | 22 ++++ hw2/hw/grafana_graph.png | Bin 0 -> 60041 bytes hw2/hw/requirements.txt | 1 + hw2/hw/shop_api/main.py | 81 +++++++++++- .../grafana/dashboards/dashboards.yml | 14 +++ .../dashboards_json/shop_api_overview.json | 55 +++++++++ .../grafana/datasources/datasource.yml | 10 ++ hw2/monitoring/prometheus.yml | 11 ++ 11 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 hw2/ddoser.py create mode 100644 hw2/docker-compose.yml create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/grafana_graph.png create mode 100644 hw2/monitoring/grafana/dashboards/dashboards.yml create mode 100644 hw2/monitoring/grafana/dashboards_json/shop_api_overview.json create mode 100644 hw2/monitoring/grafana/datasources/datasource.yml create mode 100644 hw2/monitoring/prometheus.yml diff --git a/hw2/ddoser.py b/hw2/ddoser.py new file mode 100644 index 00000000..79253ae3 --- /dev/null +++ b/hw2/ddoser.py @@ -0,0 +1,115 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from random import choice, random +from time import perf_counter +import argparse + +import requests +from faker import Faker + + +faker = Faker() + + +@dataclass +class LoadConfig: + base_url: str + concurrency: int + iterations_per_worker: int + timeout_s: float + items_to_seed: int + + +def create_item(session: requests.Session, base_url: str) -> int: + payload = {"name": faker.word(), "price": round(10 + random() * 90, 2)} + resp = session.post(f"{base_url}/item", json=payload, timeout=5) + resp.raise_for_status() + return int(resp.json()["id"]) if "id" in resp.json() else int(resp.json().get("id", 0)) + + +def seed_items(session: requests.Session, base_url: str, count: int) -> list[int]: + item_ids: list[int] = [] + for _ in range(count): + item_id = create_item(session, base_url) + item_ids.append(item_id) + return item_ids + + +def create_cart(session: requests.Session, base_url: str) -> int: + resp = session.post(f"{base_url}/cart", timeout=5) + resp.raise_for_status() + return int(resp.json()["id"]) + + +def add_to_cart(session: requests.Session, base_url: str, cart_id: int, item_id: int) -> None: + resp = session.post(f"{base_url}/cart/{cart_id}/add/{item_id}", timeout=5) + resp.raise_for_status() + + +def list_items(session: requests.Session, base_url: str) -> None: + resp = session.get(f"{base_url}/item", timeout=5) + resp.raise_for_status() + + +def get_cart(session: requests.Session, base_url: str, cart_id: int) -> None: + resp = session.get(f"{base_url}/cart/{cart_id}", timeout=5) + resp.raise_for_status() + + +def worker(config: LoadConfig, worker_index: int, item_ids: list[int]) -> tuple[int, int]: + successes = 0 + failures = 0 + with requests.Session() as session: + cart_id = create_cart(session, config.base_url) + for _ in range(config.iterations_per_worker): + try: + list_items(session, config.base_url) + add_to_cart(session, config.base_url, cart_id, choice(item_ids)) + get_cart(session, config.base_url, cart_id) + successes += 3 + except Exception: + failures += 1 + return successes, failures + + +def run_load(config: LoadConfig) -> None: + start = perf_counter() + with requests.Session() as s: + item_ids = seed_items(s, config.base_url, config.items_to_seed) + + futures = [] + successes = 0 + failures = 0 + with ThreadPoolExecutor(max_workers=config.concurrency) as executor: + for i in range(config.concurrency): + futures.append(executor.submit(worker, config, i, item_ids)) + for fut in as_completed(futures): + ok, bad = fut.result() + successes += ok + failures += bad + + duration = perf_counter() - start + rps = successes / duration if duration > 0 else 0.0 + print(f"done: successes={successes}, failures={failures}, duration_s={duration:.2f}, approx_rps={rps:.1f}") + + +def parse_args() -> LoadConfig: + parser = argparse.ArgumentParser(description="Shop API load generator") + parser.add_argument("--base", default="http://localhost:8001", help="Base URL, default http://localhost:8001") + parser.add_argument("--concurrency", type=int, default=16, help="Concurrent workers") + parser.add_argument("--iterations", type=int, default=300, help="Iterations per worker") + parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout seconds") + parser.add_argument("--seed-items", type=int, default=5, help="How many items to create before load") + args = parser.parse_args() + return LoadConfig( + base_url=args.base, + concurrency=args.concurrency, + iterations_per_worker=args.iterations, + timeout_s=args.timeout, + items_to_seed=args.seed_items, + ) + + +if __name__ == "__main__": + cfg = parse_args() + run_load(cfg) diff --git a/hw2/docker-compose.yml b/hw2/docker-compose.yml new file mode 100644 index 00000000..41c72b8f --- /dev/null +++ b/hw2/docker-compose.yml @@ -0,0 +1,49 @@ +services: + shop-api: + build: + context: ./hw + dockerfile: Dockerfile + container_name: shop-api + ports: + - "8001:8000" # FastAPI HTTP (includes /metrics) + - "50051:50051" # gRPC + networks: + - monitor-net + + prometheus: + image: prom/prometheus:v2.54.1 + container_name: prometheus + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + ports: + - "9090:9090" + depends_on: + - shop-api + networks: + - monitor-net + + grafana: + image: grafana/grafana:11.2.0 + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_PATHS_PROVISIONING=/etc/grafana/provisioning + volumes: + - ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro + - ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./monitoring/grafana/dashboards_json:/var/lib/grafana/dashboards:ro + depends_on: + - prometheus + networks: + - monitor-net + +networks: + monitor-net: + driver: bridge + + diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..3cd6e278 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + UVICORN_WORKERS=1 + +WORKDIR /app + +# Install build deps for grpcio if needed +RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8000 50051 + +# Run FastAPI (serves /metrics) and starts gRPC on startup event +CMD ["python", "-m", "uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] + + diff --git a/hw2/hw/README.md b/hw2/hw/README.md index ba9f23c8..8ce34407 100644 --- a/hw2/hw/README.md +++ b/hw2/hw/README.md @@ -1,3 +1,23 @@ +## Мониторинг и Docker (ДЗ 3) + +- HTTP сервис доступен на `http://localhost:8001`, метрики Prometheus на `http://localhost:8001/metrics`. +- gRPC сервис слушает порт `50051` внутри контейнера и проброшен наружу. +- Prometheus: `http://localhost:9090` +- Grafana: `http://localhost:3000` (логин: `admin`, пароль по умолчанию `admin`). + +### Локальный запуск + +```bash +docker compose up -d --build +``` + +После старта: Grafana → Dashboards → Shop API Overview + +### Скриншот Grafana + +![Grafana dashboard](./grafana_graph.png) + + # ДЗ ## Задание - REST API (3 балла) @@ -121,3 +141,5 @@ export PYTHONPATH=${PWD}/hw2/hw начале в следующем виде: `{username} :: {message}`. Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 + + diff --git a/hw2/hw/grafana_graph.png b/hw2/hw/grafana_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..94c1de375cd52f8a84f6b6c4422304c77d1c415c GIT binary patch literal 60041 zcmd43by$?`*ETw+h?0*=BVZ8H($Xj*4ls1LAl)$_jiMl}ba!_RJs=_=AkEMr4MRyc zeAn#L5kzdt{$GI$(-XY<~eD=xMa{3twgZq9IVX=&Ju7w7Qq)n~rN zTL_BTH~-gW#T_O8e`cOczB_lMyP+vd8bJp9`Izd^2o_ITz(Su@NR6Q1l|c=1*KY8 znM=c#&5>^?9j(Xa**2o-TGys_MxTb%9+aHC4i~+!{51OX+z}Q?xB4r-{nuQzNcHrY zd5=elx7yFJgo_V+bYz{J?>=VB25{SIXAu`AYtQb>HfZ*qdq_(jG*f!ai1GX!_2|_$ zDKQpd;tB&&7hN%}yD*~n=6D^?$#&~@J~ z9+%Zu#;n>-* z?U#GKUfy%VDcE88qt{P`*&HqsBPL(Z+tpUTVh-g zvMIdxIJ+k~oUXSg0xavr6nE(h7v<8E*!`4z7Qu_{W3%7q@nW{ITF`JU%h#X>k$~ z%zM0fTOzjT{V^9ayRc{QfDq;0JKlp8@^T8xg!bsWR2jd^vqcrrT>V_vf=%n~MI_`P`}7T=Xn@?BT1gcR10bE|r_f z`=QBojSKLTmp}4l>Qe`b+CLC0Z}Z*=L*3)x7zi%F$#s;~mV5*?A;mz7^AQV-Q`5u8j6Zmq>_XB~Pz zR#5U_u=cAwsWsdg{2L$% zS>zqwjhS+9D#mqD+i4@)t4=5x_!#h**StAQTCMbCN(81JQK^&Jqmy^_j3&p!HA3yc-#&@Pn>wa(Vq8gjRi$;RAbI;G`!#91~LzD|+q`g9`gZqCZ6b~$5 zTROdBfK$R9J4kbAFA+P@*2>8P*|;1ncj1?;7^Lv6N)rjWJHAE=BiC=uP>(G$Z?UtB z@IDPiE1axwdekk=OzQS94>j-8nx~X`(zD}k=<*$N>2fb4^Xc*0S;qA`FE*&yM2yWg zS5z-_sK;G~7Uz?PeOp@SvMjptIXV-lkB=UTob4P6CbGL$ZDu#mUle<@pXgkAZmD)R zT-?1dz=l+6uduA zV!6qGD#Ng&%)f?MZi>d9>8eRBXvhE3G_b$MxVr~G#ye8H)T&dY;L6j!94Hc=g3+@G z?QlgYjkCJ9D93X-XSPjUHdIB0Y4FnWt3#Faoa1+-jif{`3fe-D()Uu17QTN9a9AC$ zbW%IEJTt0Sj49E)$fkFL&n#r0)^pr+dKW z2C7pNM8BiL0msMEwCa`2;R~r>el`NXd_$k0NG-PKJ65sslaE@^h^Aae9)ZU)Ho#=H zB3!YYBG!Niq#N^p8C|HSQK7dz7VfyNtF<%hesZ`jQ(=PFbpFC4CZ|zl zH}4K@w}aW$c;W06cDo@cNvK)VY*9*}SRr?K;)D~cYsfaO+4&pjLnPdIRZXGnF}rGP z<~^#$KFZzACG^n+9kfReEx3LVUZIlgDzn9!l-G4RYWd3oeW8g#$UfE;ZdeOvjn#Xb zr>epSN!)zaZZ%-rm#65kqLbC6N zn^Rd6M!{@YkWx>!9lh`UdW7K%=n}p=Ujqw|{*)=ddKJ%A-qkp-Pn)Rfpb`i5vc~VF zoqc>7%wzo4ghfAGjtw;*CYr@jO`{lpjbzHGrWm=u<+ilJf%r3A-1eSj;;b^e+f}0A zkbz~UC`xdTy6Cn`cYnebGRHeM39dxOxQ`k>XIAoY!>3|ql9QPKPDed-BJV;@l~6E$ zir2Kn#OAtdE#fi4R+nz7EqPNq=kTofBfgOgi`kdIV{AM}0hxGw;vTh@pkq7@yXP(pZOcPG0UW;ns#|GemfcFRT$JYw!sLTqnwNbcK3PYK$C zgUYMRamJx%en1mQ;U2?4ZfleOrEdQ^+fh4P{8O8cSFT>a9lLX1g7;7Kgp8V>3KUKcN7Uy_>SZrN znY2#Ny5pa2Qo_y#H{AXNT%unWmleT>HY%>ltO=7-?7u)1Iq)C% zIh&_eW{>8YyE!#5d{$znJRvkBq!sQEsal|{ia}9oi^v~&`{Uk9N(w}}{W){U_t2J2 zS2=lE3>t}fIlLj9Dh%zvzNIpQ3v02ZEY?^SyI_MdKT%4f`vmVR-Kk>Or*;bX)hi>G z7jhawhrJg5W@*AM-tPh?-|h9%g@8lz{4dAzOY@7}5{tHP;U3OA&Yp)S?I#-zbf=I{ z@WQ^@yWRwKmW|1}dmK|rT0dTkRK22QJ9~aRe1R&{P9m&x+@fzGu!QqPL@}C)2+DX+ zqVDSX_|(#RJc8xeW#~^qJ(Ei9iRb>Q_X0<%%@&8!`^Ipt1M|VKoC}R}stI*!R*}@{ zxs*6w(Z3;-HLD^+7WKwZs$#>dp-qK6e^Q~&Dj>e*5-9Kr1p4m=e8>Z6( z372)68%bH~GZzKb2UJ4WAU;ba`R)PKZk}VL#w_C3FB@OtWy_-$pw(*^VY>wO{NtcYEI90vsWEx^PZSzG_q+kl)Hw7E7;@V@LJG+wPjJJLl%9bmzIc{&@6Z({-NHP9ao| z$hIj??Mqju%nw2M@|kRciFD9@N>2B>%_h_I@d5@hZcC47Ol4p&@x*87dtR9S$RYGo zxVkN@hOH;IO1$cV|5OxNvy?gzBuqRkDyM5WKa}NJBOI08VwpVXTT$@LfEtt)AM-cq z%bVO@6S1G@-L4fyKU=v8Bliw^b#N6lg zfowpScF2rtlnbe0uOU`CM)uamt_hqVRAWY;!S_US>E%6hr14-CO?`#l!a1k_ft`(~ zIwPS?lJ{QeWQ+R`o}9O|?;S1E<20t6cw{G^E~RqmrlYpv!&|1L+T5P({yEgz8^R0Z zp#6h~JlJGNV%kysmD*1!Tv>7=J-+w-v76DFImvNk!@=!&TZN|K1L?8j(4|OfTyI7H zy_w}EHMgPM@Wwkd%z~C@!P4h@70%gFXT6pB>S%92CskH2Z+*7m@FG){g~8{(QurgK zUFyI6uV3Se#h7U13dI4CCrbll)8L%a#evT+AH49J+sJy8M;UzvcHPwI`#wuhy zJ698vE3!3AO)|%@m?A&)a9-1pB=*BN)1;K3%brho#i>PGf+O#!nBJD&sk*0%Qg_}%hG~l_QI*YbF8PiAGCZ0fLnQ?#men6vaUuvNjecv7#mo-_Bl-_b zzYZ+-3Z@~+Ja$6kPX-?8l0>JY92w1;O^hxKSv|b74J7@g8Szt;$9n&yd&;<0Z$9bW z>)c?yh(jG;#Y3N^RzL$_Sf_7>(bEQ>-2-0PZC`sWvmo;Oa&L>mcfosyMPzSznYmEz zij3khjqZ^z#U=$_Y1P#yaPp^1)y*657-UHU=xV+6qpJ_G{dL84CD_RYxVxB{o_Qhm z;QY8RXN-s%yx+Nsn=Nh`30Zlh+552Ui#1 zum9sz*#7~x?f?5Jc#N>#W3~MSseGqHg_UZdL)$;_f0%Ln-`b$?Nu_0Koz9?|wc96h z3;~JtKaXBc@EZuE?*leA_L`YtI;Rf3<&;b?rNAriIKgi6XgX?v@Z7vZ`LA@{u@|3j z5>eV%rC+k@<&v<($b`R;`0msm>5j^A5rZmOSmcu;_Zo(aE<5`0%tWtUyB_Hv*h+rW zhqQ>ZB*b$wP zOU1>-wLOJE%$g^sL?WZ(E%4Plv<~ma#Zd@4@F*tm**3%Ixj=%GYs`?P2;C=v4TV&cSjiqh5HeJ!4Igvo0}E=g4P(2$^y zd~$LsGAha#|GIe4u*OJRU-5giKwbo0M?XsOfbf9Jwf<)Z>N%rJGAAF7&&?4Hu+nj` zQUg3zV6x1lXK|Up4hbT39TOAUGhT-wniH9sMp5I*&*JEyfgvHvDbwebdo721tIIM7 z!$k)XK}$ae2M74>5AE(YaI$j=E}w8LW-i9Hu2ZiQ5AgnE1=Ky|P0V5MTi4-Uas$TK z(8K?l`RC8ABsl@LC{m%|n*`+Plau#7PmYx;j;dz(ShyaIW4@+9mB+}nxPUgmXW4ky zVW9$sD8;biJ|@OPKRI!U!urY=Q>c{DQw1r}-nG*5z1q3Gq8HNqdfv*eJ>A`~968nL zB@5HDt(}7dK0mNHi}jVIh)!4+e-H^7iRCGyd$%EQo;O!6-7T$JAZBPZU+LC;I8M=| zOVpj1?QbGCn2fC7&zaa9%Kd8e>*P8FGDOz&#I_c@2EDMwBrOn|zg{sWc%fa+C1*;N z08q_MaSRvDLq(=_eh1Vhc**Y4LHb_Lx3`6e!DU!aeA>{E3PFuWsirpA$ce-1LXWhD z_ltt$>CC8#5sUMJrUH1F!ExmQ4<8`P3BLV9xc|*47dODb;AXWSK74@laQ2fID|t@J zuzGF7paVmvIpoT!`J-NF5%0m$w8u^@NBt(&CbHfyJx_J?nDi9I4SYCn@KAVpT?o1# z1asm;*tCpvsEmp&iqw$T5u0&Ion1#pRE1reYq`G$1p+UhH5ZCNc>LOGM6ZQkd42vp znPtW+Q6fmw%{R@hCWf5XA^3s|9)D5sE^X{UJ$$HSOMJ>my(eBw4wS&q*5C-1jU+3HNfrD%tfzswzjn~@$t#Y_h_bqRO`9F&jlT*+ZS8?`SoQ;)3!+n2n1;l-?&*V-T&k- z6IMm1&!vh%hEbMkx?$U?s?%;yV3-3?(XXuj6a> zhBZjY$Sk|+>x1(g+?!++{O^2qaCYI!#-uYt%mFmul!=MWzq3UT!qCDpZR*hxb(@NS z%s8YQdx9JyZcG5T4qmyJ-*q{lTaY}S8?j9`qkM_R_Khs%gEo1W&BR{wxjCqSF^?uN z4=y-#2Fh}YviVh|Z=jep1aThMtPE%yA$FT^^4Yvz4>a1|L%n$kP-<)-79=(8&t=`; z36*!q(ZMu}(b()X=QKEvdE=b#xum3|g|}o4M!!Md{zs_tp)?Z{*13a}Kpa0>_EIt4 zs-`!659aZE{*NN_?ZtGFFlMatlWF!O8CmXZ4vQI=0Ec~N#rkIJU?ye*+u7r);K^irH)7=YN!@Z<2e7Lr^)J(D9v3cB6bXr7+VcZ#L}VOFWrT1%DsdcY$X@`KLb!+p^W{4NU{( zHbgTB9}D2!0MY0wc7Zz~9_}3(<1n!E%kbbp>GjBiidfza-MCz5iSLUv23PJ%t@w}d z_$AWqk+NJdVO!}yXbzQxnxj(}G)W{epYX8;HVB7^|9sBLX&0JB-5dEva|FC61eNyEB*6_&@|oaB>jxC;R{v{;l35VXBiDB zPeU9d0F(@{FfbmxQd`l&f?DlWNdo~%OkmZ!VLM84TFIKfrkmv+>iYfydGQ<9e|AVb zI<0=k^991)b-v`{OXkqMIFRw5`b9YlPmzQ0Ncx{uXZEN|uKiq!ke+t$iM1e>;!hbm ze!R-e%*r12Z^j)52cT;dR7#iIm-!@>0iP8dc9@LF%~Y%tbx~srX}3%x+-lt87t?hn zou2U*oXv>kq$o>2Z*+c}wfeI0_^u%jErUA__A^2A)SQw>y0RAY>sraXaHnv(B$l5s zbXmj;mM+eI&dYDn;&RagxOklt*GfaLN-kL*zk(mg8+}i&a9rJXwKbMc_K$E&BRf-X zf|N0B7Sj^t&gR`Vrwp`cnePWd?4R6yncxf#`SCkbCP2iCG#or{#9zP4f~nB8+0Cpq zPhWVgf64bm-C*;eMG~@A1Gy@-oD@{OX($VQ)7$S2wU*21Y>J0p+e%y2GL!qgdwGj4 zdte8Ilq;(R3Fi_h)B+Qce`i+{Ta=r6M-cU@z}Ue4x1;W0%q;b9hIwQm>6)Q?ldBU~c9 zKbw3)L}Vl-aT=G=Gqvx(Rhr*|ue~+RvGnkDaxtKitc?veFL?5VeYh=nSL+?ubD?tX z;8m06jvl$7M}n56>Kj3{;XDW5_*zYQdwU@vy#rME_8eF|{SmGh7F2L4!f7`l)e33fUUm|0zav&pnCopJl zuHz*#&Y@w9_=W5Np&LDynx}9>NFJr-`nAs;)6WlYAo{7_7om$rRiiQ+bVNL?O8DMp zQYypL7$fSVU%EtvNpw`{!$FFIzgZ>lEA-$fJL^(y`x1H|Is{-;nd8LkA{g^hUf%Ias|L4x1+ytpUDu8lgU9ez^Sw1KOtbcIp5=kqGNf zRDMIZ8p?E-e@ZXX*)IKBI1?vFBy)81S%ou*1kQ+6ip(z>-WwNn+l}~Y_i)sxzq+sx zA!>O>G4KdwHmWI=dMyI!s}~ZC66&`n&^vm%V{bPw_gMo;Nx_mMXvL(OLPHQVd}>|K zdkXy{$KZ0_R~7?yYw_p1zhYvCa)IF90PNynn@YrAD=UQ@hIMOTlkjmgekn=TS{wP?#l5ijb*A{0n${&j(y_?5jB(~gnn(afHBV(nWl^t!R>5>*jNiF0w5UtN2-x}SpTSvD#kQ@UI z|L=8=^Muz#;m^-z732^|DGVSg5)gCin=EJw#5(VvZGV0-e{GR$s=)EIl8f0PZ*P5RFJ@lDttCJwwko^Y>H~A zE!KkTl`D>!rfk?Njt`ih*Zv9Bx_@V4h2^bJ_R_hF=m>zb7RNCKdy^j|aS3j-^w!qY zp!;ThO*T~By(s35^0KV&v@!OY?tS)OiPHyoh>ULeFjdAahVT)2)n)RPJ-~!W$4Be zBGj#Q-`Lg3bT^GBrTK{m?Vc&rdfb4JBJX>+9JS<^;l53&ghTH5S z7R^j^_|xl|`^w(fvJ1scBC2lN5UEkgIXj=^d-j?jKF|-eW~x7~CvVx`?0sK^C=9SB zy2(Q#GwrJDxZFJeTrg{(LOh!7^2O}z9kR*0cwmy;!C1466ZDtz-X(J7?(bh=jFHZd zlsEkBNI?w|UB0`I$If??J`eWtV|WCM?LDlawr5R$iHr%hk=u~ob*SKVnxp%Y&o&60**Q|GvR~%5^+oR*-we>M>Kw^NlBuk?L z(f4Q%iA*)IM%Zy2wuyB@#LtImf%nM^QSxUdvRFQn!JrOF%S@Ex^h5u+A`o+3VrpE` zzEx9&f_W+GI+>AY9vdBb!U0t1h5V8|Mh@+ z?A?nAu_@Ex;#zwLcppKKzvX;_5d0`+zKsW>()A8xG8T}@?#nWSwTC>1?i_3@nwVsB z;tyf`f7+Ick+!`~{trz@*D#K$9NJdO*|Is{1~SP(r>i%mc<<=X`fa51Nvxijr%ru2 zH}T?Jws=mRwGj%O4DE^mI8VvW4|iRHoxMRR&X^~YW2o4Cmx+pjea904qnaLe``w|4 zq6w-NUNr@s!i}-j_!y_xVfQCWBs-+`X26v7E&wL{r<5C!K4&ZfJf|I4i&U4^8Y5;bcPGU zZW59|is*n%RNJCLMrJY-7^c%D{E1eOhcn@HV`F1$fwLLJq{GsnU>{gK$1&f@WC8?O zT0?igQ9xb8r2#x>t$4CQ$JoSV<$cqIL|K{c{*rbHdZi3)o9L7MvwI4rsoA~uR3DOS zOHvZDQSw<~ExGyRct%@QMS<4kkRT^FRfAox$6`w4HIBESiR^W8M2*YGqEf&XU%Q1y z%}8YMqTb}ba>pEaK)q>FkNS$4 z!^i+PWALn&V|m4(n0EWh*HW|Eq3zUNfGrk@$vTjJ5v%*E5Ld=J8_ALnbB3O7n~^pG zd-c)D0%^Xrn#`qkc;$uZkF0+_E8$lPHTRZ_K5;u4s{8rdW-Ij(iKVj1KAE!2Cy-(y zs$$GO$24)O8cmcB5Nid^nBzXk#gnmp`nJ_Q0KVp}cp585>DsDVcbq-p)Il#o+_dH* za{0X}^6OW2sGyx)N@8LXugA*k5Ts25`x;7JDwJU*4qHCD`fFy<1Yd!7)B8HZqbItK z`}?@~_(SenO+`^No~pAH@0UJduw;4QNE_DSVIQa>;SM?09`gcgvY~bI>$wY4rjc>s z*0R^XN!oAS(tfjgc0=1BZTg0wpd~D)%L2KNI{xl46;mpcz&#k8r&X{ zwY_pyOk)qxw~gX9DBiw3(7?y(P;Au9g3MJIymx2@JJH+-qB4+G4`M4-Ad6<_R27+$ z*#q1XAYb0%CzNHv%wETyom~fqx+V5nJarW(#HX{fI0O{rO6w@={Z@T%vx5RLW=fu- z9NZmTg!3IhSmaV{QomsVENJco>wUXB-gp7I@U%A)ukuf}#?4T3{&Yx(lh{qzsPVSN zwG5%_6XX)S!{cMB0JP*gZa)Sqiy*YAcEidj-n?aHjjx%RTBETme}5q4)Vo4!TOtcP zSet00Z+Y2*|F&hhOJ5SgL!Ja=<5zg!g@$U6?xZB8b-7p%#qU+L5%)b(Q6K&C$r_M0 z7@@=`q{5|CdAYxT{2cW#ff*1s7phXkWBjr-?`^JdOR5dG{Wok(CbvitHyVA!qNMD! zmPW01+~%n|oSA}JpT`M)6Z9iqn0VEY%g^%1M1R@vr!+31@F z-LqB_P#^N9;B;XfTJ*YT%Au*OvAIpbmkHDp6^w-1o>3nWlPgpf_?mC!2CxdO*wm8h z?>RFgNmI0i)T;%_8qsqeZyIf$mBR4k88Gxe1j4-HsGjI2IwW()_XD6nWDzyzwtNSd z?^HM?CVoz*+31&EhkT0$unAeyS-(^uh8q~fJO5M9sAl??U{#Zv_8Wq{`Ls<0oLNkL zWPK)IU5=JJJN^cvkkEc{s{j=^j+J}Aphh)%3uI(#GmHf@RHe)a-F_AIg2A5r%i@sn zB;7;bOiNBxM@A_;IL+-4##YtrP+6f|nBdQ;!vX3np822C zCY-mYTZ)#jKZpp#1@?A6^;?_jU0n`NvuLg1)0t&q;!tlnu(2tP;smq$u)P-f%EUO* zsm+-~Ezj>~)pkJ}*2(`}rl@Yh<|_`wrw$MW1#7FD#`vn1@^aasi;DnQ*;t>{Y9(d$ zk0CDb9^g2ZJGD-;X*r9I#ae`0RJ@q0*CI|Wob?~7Xj4FF3Di?c?9a_tFv44>y9K|- zT+wNen$H;t^60ZWyM|Ksie**a19sYM{FT2Vt*G_f6R)-l0oxV4)GE9fjhgyX0O_O~ zPX9Bfv73}^v49zDu~9z(MORauk-@1_RHPZQ3oFzWK%9Cy=C_jSM|ZNY59s>O?~j(9 z!@LkgV55C18^?UlTU%SDMWXWnf24Kr+rl`phmWs<>D2pK{;jaMoVK27Ptj%G^1*3+%lMA&Kj%emm$cR zZX0`?Y{GE{q$L4t*QK)89Bg?(gIV@CZw;4E8m^$mv5n6%P66_&!-CA`8zM=|}_5-LKg0_AN`DM7x`juQm+^|LX; zFEr+@5EHS4B{E9GaaOJP#v}lZlK^(oVm33X%7!`NhWFwgrx7kp!MeJKYy8XC{O9%j zfJw^6aKMmM(uNn47;g1cotiUSEBLw?&Eiw=j0ye|pVU^%MV(!4ned%8uV=^T$_jLo z<>QFoA*mUb-Mu@dZ34W&MDd;;O*SHx8JZ;rm?#k+ANyujTEI?^(aW}WqHg_iJ6HJH z6mdV}5GbcpThk#QNV`EX^p%~#s76bu!lEg&t~6JJJ9HfdAPc5AxJ5HmvALNH?#JC7 zwDUW|7dBOX8&ic@rgDFGyraJXwQs}E&+?K8wRPR5Ylmw`TJ3!)0VxEs2$n2KHM?Pp zue$b5FH~vG?&iNHa|Zh!f!G@49ONAf4p2y3UfW;scWiN_2oCaHvEIS~ z5Cz7Dh~N8*n^osj9kGt0HSVF$UOlqy0P#rm0R#egOs(>Z9GAfu4Gq)%vGUQ6FvRrsGtFcl4sJY?lUX*8yx- z(K1fif)dpI(*E)tw{t*34!{yzeg*rw;}ri{3=dcgwJO*XC(4lebU(6(7N-8|3m+!2 zvVli&Dqokpa?V4|E|)OSa{Kkk0>HBtvQo^ecdiJaw5iD3m3hTbG`5) zci~%@6h=__m((2uk1miCi}%f)38hDzxSx=>prn6x4~G*IqAWwNQ77%Y{-WQhba|Sc zkDyz8mqduf0ftf#`!BDp<3!CJOS*Q9O+k~|D^)Mu>&<`6p9jN$9CZide7BsOhRHam zcsqr@#pbJFlDNH731_g0!IzvAHKzs@Kn%30AV6KYKiP4^2#^a9@yENk_UdnE6gKo* z(L@Jb9+pkK2@vUS*EQ3}kRO+A%yHfM<8y}>j=6r{zG*O%dQhr~wtt^bo@w)CQT z1c7u@WB)Z;2w`Ltp(HJ0EA!+nP}LTgKy8kAN?HUaFX4YS$?&g_nGQs!6C|CQ)l@fe zT7Lxy`}}-`rq$H=@6=Bjmr;;0PCzhnL3=lZ>9zFV8A$({j8IR^3AmB+8zvv$-+Wu{ zqCjOjVnG{mCc(=_rzDeJZ((UAR|Dgt(qq&$u6(XtefgMy0_?VrxE0?zqaoOX`XqpxvcLg| z9Z6#0#p&P0)GElXv}UU{e2AGV<{7bq$(gLH>R+CsMckKanJk@AyB)(YWG!T9jRyC4 zzhlX_3?XvS4|mX!XQ1ZtOZYh6kWwej!8|15?_%L-63>ZxivhtPi>|-7F0^@Gbk*sQ zZah=9p!aXgh0W54ciDGW_&S+&b8hH3_r)@|BNYE-wmBvxn!LTlg)UuYd-24(n)mNi zeFrtVnh)gWGTttHWcJ8J^-O$f15`=5BHHn)SJBkW{L$$rjLhOGW_RQYukx%>2b?*o zkv8;6rgkbP0Yp3qC`eDDKxq$>3(9-sRS{IoQnuw)*21!-g<*rujU+g!;>4fPWIh7O|?a9n9nB1~3)d#Q#w%Y0!Vx)g2= z^sIDxHs#N361ag#Z%jr)negrAIrnNRC^kwg4{cE#6w4q1ffr1r{>>f1y2Z>MS{eRq zg^-U3Xds%*1poR!%R;YhW?bS~7Svy#c=D~IBi1jZjyn}WV~dh|jJys!?P)e9$n!kc zd$<|6PL+RNpmDB9e+SR=#p!%1J>%B_8 zJIu%w4nnC9so%9*3Y{&@&*#ry?CG}4?7x|?LBDn2=X*~O=)k$!-1o#fbRz@I^W-B0 zhUse9yAVpT1e9MTAi>=ndaix)i@n4WGdg*^cpMm}__#f?`ZmGsK_l+v$mMD8Wk;an z_gUQs9$)#aPB4F}&u-)LVSnrk0=hyOrI%X@wQd4e`(wp@AWi~=oK*=t(M5KuE}-ezu(Qi@AsArrR%&$OZT(zmMj_8IIlKl2WZ^GZ!Kl z*2*MoW1>2`y4I)@dDZ&@K8E=?Jsr(}?;wqml~v*xrV(KEGsXBTSBhi7y+L~;TwXgM zq;kodR^WnG_;k?X!EOiw$gmSSOK(%Jjo2y@(CsD&ivqx&ryXJQ+))p`U$n5GjA>OU z(x}zJu+FsJC-1+vJZ3Js29*}fzG{mBby>JH9@9TG^I%R22MWVOgI0#ljt*4!JzHKX z5W#tIx6>d565fyMBh_$#2qHz92GH-o= z?tj8k5noSn-GGonOT+mPP_m?igp496g#ZBScdte}Jx}ID(XIa{OkEWZSV1>1Jy{@9 zeJkvB4_GE;4KE0SDsQDI(P&>RGGiLEKwl74$;L+R*n%i3`OaNx5~E?MtmU9HV>C}l zt#hpAeKYne{VrbOJ=SP83(Z=FqtpOmG3lKVI=zNZZ?rTH8RaIds~x=#gz8F&4((N8 zZ-9NvN=e$QQ7t>$?BuHmy2>%hX0i!pIoL~)fcgwTMzR@74zQDy%4Uy3mlju0gHpV+ z-<;tSwQ7JY%K=o6Q3d5TkTg!M=NI07GQ@?f#R7s<@}m?QNi@wK26|4+_v5z^++OuG z@d7YR^N@(U%bI-kl)3XQL6EFPKM0*LL5 zve~+NI$72m$6EQ?o6xGL>C?+$x1datJf;)N zIUA!I#WL*S*0^=*J?XcFC-2@-DU+Vce7EsT7_N*CD90wHjL(vhRV6ySTRr8L(>7%n zA$n|;hfzH^b+cMI8kwN+ZkNO`t1T-%f`mR1(rTMPG}@aX1%wvB?_}n|9w|f0eqDY-pxCewr`dzs4fhGgD#pldRDk{aSdqBtq(}?!Df=Eb6EqnR>D>qt@CfjH`>xP=&a$b}d zX0)CHo(Qh+3XpI3g4g~oc&y%kBDDm`-A1TV0=rdp7*fFd_*p0U3+_L?nZ9lui(wu8 z@n(zb`SxB9+n)Nk4P%b{Y@*`fO^Ci>u?Z12P*JV)a(>; zlas3zFfDsP^uh%Gii#IY!pE_omvAp8Z*Xs%WecN4G9!S)hCJSXnVqOZ9sDMXdKv%A z$rsS=q=;!w;0pzP!O*VpLj@^y7f>p3(9@#6N8E0+%P_zzj1O?)TwOa5u|!~xe@1FY zeo3&#=f|!3_ClhTkjDMovhadR$$o78`-FeOoBe0QgA%9xzO8QJ0UyKNQn+l zH^3)Ei_~aQavbPt|()LBt z@7*zlgc$?C+=z9La!#wS1)kSJ9ztM0K_?uFl#kUMglEd>!1fVy;2M#CH@H}+p7|Ja zDLXh>tubNZ0tO%WWFl6VogU;Y;WWXW;4#WY%<*#!if;c~#yw#V>2Fn~~F9zMi;Kd_!+fx2X zO-71`mOG|I<#8t#_fg9GgbnQVfysx*a`n?E@K#Ge>8h`&?<`5cNskGqm!({6!T<|m zMTGrVYN+tW7;YZ@j`PAY=3(e>d~}5{rN6`ID9t~8EfBWRSvb^T*F_bm0+>|SO^N-N zMmJE{0AN;_m-3d^pH~P;ukzQ*Aw$`$v1)Itsf-KOyEm`F(8afqy{yx z2D&nw@6GyK95-4^8tPR6^F2(yXj8~ovDkU)$6C%IH2BXzYe!XYjgxy!Fe}Q&tmp?Q zh9pQ=H$d^s8(820ye(+mX7=9fse^?&gc7}BiqBiqHHk!Zj-kklhXc-Z7Rkt{S}@z| zE2J+JDh8q{reivu4HSI&A1C8WZvKHg4Va}sjB1{T?BXe=8g<7UlM<7$h}JuQqSfAZ zSE~c7V?kc0^rR|u_x%p$yWg32yd{7ck_V1~x*VO_SQ{4Ak~{#45P(~N8ZP7kKFIwd zozj{Mqx2Ged`aFydMqM@Y%RGl8WEA_h<)5h$N>))b~93$1AR51}4fwB#_&H!07$)6-5id9O@@KLJM?3u_!ACr;(Z)ndF1D z23(|Jv~lucm-nGPpyPtkXw)zzNyHc*mpf)M~{vr?YxoO-jj(uybKsmAniWbuCN|(N!0})i$R99?yoq{Qu=R{f#Ciq6SP76i6 z{d*(bPdYgUzyYxG#fFsLCKfk<_a;6o{2-VZWN)iL{D7ZVBq-8{~{%>TPs%45=@V9pGcO zWG$oNu2aN*TP-X>FdtUpH&3ZkYk!);J*5$DBOqk3`CwY+e&P=wjP+) z*Xiygi$3oMGCKe$L3Is?Ov6@uJoW$1ijboDsBYx3)Yzv{;U%FfnXVA$SJ1SmNbp&R^u4_C{8Zjwg13jm*T z(S)Upm43H1bp{P$l#=CJ2XyeoBTT*KNja!H+k=?d$^R8NL|gc!vd4=$hB!c#_@M~T6I8qkYD-kr$6@s}^32*h1aR&@VI6G!XVJ(XtrHB!7T zyV2EA=s@x+GYGn&I|X{;*llqbjq2opXaV1C-Z)`nnToY=vXz7vZNJ}?Q3_q_$dqWa zOA-bg3h-8n7Pu0irxwnH!R_iB{^ev$sw(Q9l~Z3mURT33(28jLRF0-wFtOZg)Q{5l zFPcJk{fF7jJ}SgFA}@XYzoBruNkbqCcShRA*D%15oP=JT!?faP?1z)*?nulMQb^RTCYFiuORnX^8YWCzGs6sPsKLiT&qbrmv6_h~=>sd>CWfelKRIox;#Nkf9usu#&-KTSBHq z0|4LY&9^#l=<;O+fwr6w7#75A0F-vm&v@;tMMd2QKqCiO7%1!yDOz&dRzC=L%UC3{ zm;gn^X(EkUE24)#5i@3`?1boK0jBGJV{WE$aY}c5xBCr2{IK$KYC!OTLCe9Q&BD%B zO(yrp3O+Er`{Jbfw<@Ej<~7uua!tz|enL^y^~_e5R}<=5TW7dM3ZNM`=yU;SuJvU9 zdB2|8V zBF}eAk;wwC8oepf3SRbK%QPd(=@x5>N9#2iu;DqB>h* zZ$eT#0Q-8=crkX;ae+2D-h_3&kWq-R?Sx{AB zN1zxgmeb+yHImIxL%Hx;uc~;eNfnS@N?zQWX`sUufWJ3WT@&!7K3A}hoXP95prrx= z!v($*@6t<`2bdvbq(@~x-b2?`NjIkBTnw25enByqn}+k@v5JaLVOUS{)1YNivemo@ ze_meum?~4=3sPlZFHAW#>t1DsA^8a~Q{{e_=de<3TV=&er6ZhlWiotVGLSCODLgzE2!t5d++l;!}P%#aVaH4YpDrWqBnk^EGRo%q< zVpxkSl`i`G4<$ovrNaq(g&n^2Lu!N|z8ly2Ny&W5u@e5|S6 z@zj`qVPF#eI?2Y7A{cO44}|`g-aRiSZ7gvplP%u#*yKKd{J5Av*>)e`U$oV8F4O~O zkdwXLZ3hH9jZbcDmxh^5+j=dicNBl&rzZcX4ARxZ&I`!^-i^jL>eDkN=^1bLZm?>< zs@~rS&j4@;8}Xrmxe;(iKDSFba9jG+a2}*SQeH_SN5d2(-j2N=%Aw$m&Jw#|lfHG1 zY+)_MZghaGM4kH<=;M!M9+Gnb52aWC`aa=yA>mjXrSE4-IrB%CFUMHsVgmLj1d~R6 z)omFMLGFsx-Ab8H`ahs9R=vP6q3fx+XC>9QO=VO@)FVp+qWqviD3L}W`>Povy*?>U zXAkoDHOSi}&W&iO$a-u1%%1E6xl5cJ9AMq5@%?0`W8gO0KikD7hh3h1s(K94VFfbp z-v2v_ia1VTO5Yn$`afSD?VToTQtTlcD}#7P_LSr)7Iwf6dE_sR^& zs(^?ypMaXI^da*4E=n8MoJ4k_Uh;p{Jh@saz^ObcBh&Krt8f5C>I2$9g?UYr7(_FW zSOyEgLF8HG(ORcI_%6-{d@AJ;F3x7aH$`pHk{UJ>g8 zm)|wKnEuo@66(AQ>5po~%*^Mvu20Sw7rbi9mp$yv&GfC28hLJuhv|FKg!aMoFJy@B zm$pyNE{y9E(S^YaOpADoPdR&-vo`W1$9~O?vHohqz7V)=&q2wj3!x+AAAHztr8ezB z)fU{kZ@LAYyCC++GUrY2ChAf~QE^()zPZ1^CCrbDvC)mW@`)OrHJ|6}L)Wf-#v`Xb_a{j|-1aQ>X=`q|~-2OM~=! zxl-qOJxdX%ZG#8vyUH@X1qJ5CaIsbI0YU)c} zyLZU&Q$?oc4S`_2hA}py(J+J1-v5|o^kyu5qt(K{9&p5MU;I-D|sIT55YMWW^&#kCuF&J9nY7t8{BwM9Q&%GM;o&2?PRH<)4dSe>9 zJAWr|3AK5Z5_IN)L(QX4+Y4DwgI(JOK?n^sx7erR;se$?)sfjUQ+cL2H)S893Tm4tGihoid;V>ot8`&f z26zbPU{z>ID~+JyrKI_6A1~?qwUa!zXJ33f2_LIc2QfF3Y=|%=8JGBaOy$pjKbvu~a=@z` z!j(GUA=Ol?_7o%~O2aq7@Wr+BYQy$sF0*0p0S8mE&0bfOq(6uui@t%hJS{Cvo4|+Pr z59Hhj^6fNGg-rPb^viHtdgEQ!V%mA>*1? zAbMQld({Z2Z`43yboRIm#q%eZZ~nLiBJy}!oS~3;X=_{;%R> zFh!QFiG48!qB$1QSChZQWC~y-W74#oQc`!hu_qGOCr!b<&S~=#b)k9_T{1rXQwCgIT@iok?~J~ zjDEbd#QXrjICdqM*!=QAAZ4yIkfm?b2Sc2*WU=WhawpCr@<>~kbBk+sN~%YW`R)gj zF)Pd1!*YZ{#r@IIQMAQZc+eP1Cr2Zej?of8p2^sGqY8Wd`}aL}SaNEYhtm%Ec69;9 zUK4nu34JrqeRgVbElo93(_9kK-r6d%o=|X+)7NgSM(^Uqxv52fu^4upPeL!nUnVZP z!EC48VDs)>0yHcV+T-@u%N+LkwdaV=jb(xGZ{H5q=4a^Z74v;mIrcZ!!>8=Yj%}Hm znn8fsA2)cwzFNd0X%bOpy?&hOc4e`YWiy-+0%9avVZiy#si>%kMNOjCygojWS9~{e zj1Pdh;mZi?9g))XU}KhI%;xbO5^0_&1{1`%?ZJl_io&gg$?oQy(~w{fiQ6_I8mRzr z2E0%B>4No3)AU!NUVH|ixXyCR+l=T$?+0YWIfz`&cjICS6B@FbAan?DOqpG>F1s5` zIaw?#^GO|1_yTY-Fpd+pdz;A>LTK0gw~9@aqG~q)T(|~c6Pk6 zY%D-bfEcO{2;G801Fed8?$lAAK3Q;!#KNu-pN_Y!0hy;YOO$f*rIVwpQ?9HxyYKU+%AuG_jv)4QTG;!Z30z`#fB(lszEC({LL4>onWZaEl-aD@a?6OS* z)d$z+@0a$2-^8*4ll}+z+!}yY@`oeL8#feL^I8(K`ivn~Rx!hpt{~gpXLswJPaK;J zA`t9j-Q9O#Qy(rjhW8#p(J)^diozr4nCLT{XH?o>PK};z1Kw2dX}uguvLYShGxz6( zv_-mx#(+~kb>MaQn3lEd&ROWAyc>$=S`V$@&d%L=I?)y9`!61P?Tq3W$r1&Ko7u*B z$1XjnN#UYoxt?eHD3)YR2K{VQ`;!(Xid`m%VsTRxsn-gP4kHJ|UdzjW>e-Jl8AR=F zt-S;C;|!C`!-Hi(#V}TrXAO#Rz}lc;7Jjr`bC@l_W-yLE1tB&>cqsxnMMOkc9O`YR zeBf4VUlZ!;t>^NqiqJg6`QF07qsjv=LrEaWgoncTd2s*d7%VwB_Ac}o zB-9+F7DOi`WNgVke5#6X@!66}B?~_Ve|aB@RPmBcYzA9j6Cl^NZ)uDOK>qN)6ps0M z#~ACC)N#5y9XEkO_Lv;bL5Xq52RfIA<{kgQC8TE1pn(1q43aM(Lrz15ZZ_a%L(;fc zbj<*i5d;bQ-q{Au70GJ!g3=bY)AgmTI0#s%G%B1lh=3mPQ*AZqm5+2cKNV{})p6o`#_H@~CwvuGIH3X3seVU*g6VgUa@g%V{B zb$093R(`A9Fnf?C!EdTVcE?bDybj&@&Bo_`zQAy5Z%#^|l4Cms1*AV<$N{S};ZUiZ z;$2?WmAr0XU|I?IM;QeLH}hPghd}I96EW)u7CnH5 zDNQFw-MXu-!WwLYmf8>8hyi&tF0ohSC7C zqx))p!aq^cEr6G@le`3)g$QOD8{qMWbzQdHMG7Ve;cP1_QijR?`;M=mzC-pNZGXuz zZyu8Ut|$Po=?_M5OK{)!+Fas0bOVyD^mj~?MSImZ{ojg;93nCiJ}2}Igq`4UUfJPu zkW2L-8Xd(bn@_TE{{Hqi`0GUr1|E!h(aw${sA3u$4*U+SxZhp}*+3EHiY~gUJMvkP zzV?QSN+Oly@5o&mUH$mNC+K9jFsAkt&F?y34a=AN^_fqdIivs3iYM=QpJFtFI>1Ij zp<-FtqDzeC6e{Gr-G3F1il&7e>sXV*qhDQ5G8W#NB*V_hI8R?@*;IZU8(D$)wg`8(E`HT2RJI+azZ&7$StHD46c z@dikymJ8fs(}Pc@4tK!;)FWcr7z3NwTpY`=WRNn*XqGm9{Ia+li1A#Fm{G|~%gYgw z8gt;7E^?yi>zbb+(bQViIcw<6{w{p;$SDq~%ms2oq!{j{CQ~g>)~fj~>GHcdww(^u z-`a+^+`I`~ZaqRj{Nay9K!K9Ab=urAMg*LU+1*raQQ)Aml@x^tmjhCJ-L-MrKbnS^2W`&z~}~d zTkqjlvm@`@Zk%;4P+rJRB=R8FTwoMz6w^HMyu{^}SS!dnDmTxu{fqHu2?gMGAR7S> zfJC!0K0kbr|8@GSa8-s#OQ2$HaliNnsthS(b}}pgJ%57Q5YJ$ zL=RvPQX>G6y-Y+%jSRZTV>dU_M+ZFn+tT1BQ`#Io`ob`_)1{)8yOd2xuTH(4oSUrr z^F>zUz+HArs6|qaT~cyC@#r?^@>?*;(r%H@t$kz<33+sU0tx1ir)^Rm??-r;01q|5 zH-pz2*WW(zb6WgB6a+F=&6MckUy;%)`f9PbWiLK1yPVG<$jM3U$deDAQ}!54Zxm)w zOw)309mOUD7NI0;(vnAj-$=gt_GwVTk32rI0HQd>K6XQGOXG2ba6u!%F5#$Ob@KhF z$yfXElX~|PGN8RlzpNpG(+EuOy1K>@%`|Cj37O%*~+&F`Ri7>&G%}Nl^Q5& zqcmbmvto--_+ks2r&04LYsyh3z4f%g5upWbb!Kbs!=R0TBMS=v`hi3yyMDJk%P1{f ztJ7j0U{Qp>w!)fss)BIsAeZvtTMQAeLQUc?6#bJJU&rC^jd z@C=lI?i`8UT7R&}?ZZ>oC!3MuXkFX`GwCZ09_?-1aJ_VkU_&I16dl@&>q$KP%VBS3 zJ$`tz8iAPM!dALqyV3(@^Lch#-DZ4IJ1z|z4(LyZ8|l*~Q^l`R4plU2_dk?p{fXRq zH!pm9(ywONy`U_bYFWg)%;KxhgGdQ;!oB$LYPgbZ&7MLN4UU|_4e?_m*VLjq2}yVl zxJMql7y1P9ez@ZaVH~bG27U!|7|Os7>CZMO%67`=Bzn?v+tTH%-k6wtKfx=#J#0CM zb__>&IL06BuZ#QBBxE96;~Pc`-hS8|JgDe1)S@%%LY8NlwU!qzrtXMsw{D?QC{O^%&}jL4*E0%4U~KT(L3V(cem~jF!Kr zD@Pl(+$to3xaq^Kw^(mm(!#HmBIBH5QFBl#@T4()3q5Wi?S80Z=#hE%{1LYh1{6+R zb#QP1i6p|xMuOc@M!LDzt^}Ov4{3D>*Lxf|cqqoyU#Af}!fbmQ_K={pIO3*D&)Cs8 zDyqA<_8n!eX8mzYhdql&r0VVsOy9WI!J7hUebsXcums=*z-!@K#A#FMyJ+7yER+MO(X@tyVuj z+aPow-P(B}|Lx#bc*mbjGX&Cmc~9SV9Yfd?EhQf;^v660kr`pt_I^Cbfgxjrz`=Uj z-jk=Mm+EvhgRCf)-tjcFwTFm{Km7QI__Dh~xa?N&fB}m{e7f+?7yg2$n%W_?)zL*g zQC$|6I&3VmC{bbC8Q-!(lJ=JyL=hHgzbZ-laQ+$FMbV#D|B$}R~PKi$$md5)9gM`Wz&Sg5qvFgw8(~^SseO;DTq_6K3YjX z-ltS2{hpVP8`>#zs3plqzl~=b64|TI)^0 zR+-wyKB&-WBC<}8xsa~dt#}_P!@X-sGs*+zf;>Q>@4es12b^6R=(V7MT1s8k(b-tm z!NY!|=m{YQ$j*Yk6QGKzriUUe=g!mCE1D-!0r$ZLjOUS#kUMEDX=q53IL z`Lzwi$K^j6)kBzHgcNY7*0cJ0M+d4x$qkSygT+Tfl-%kkfe1GYs?m_nsIq@L$th`i z>Bluc8lb>@%*Mt%*X48Fj33Iz9>L4N#g74GRiT?4(X(08GG4x%3F-s=>zPM;vx0|1 z$1zzZyU7TplL)Xl$A3KZ@hfn$Tb(_K^)!^tOzIpKWioSY^0@l*jfG^DsllDNAy73i zWbJ^waD>hQ7yUwkd6?Spg_p032h{7!fW2oY1zza>@iU0sN$`!lEk%i^v2ar5!2tJt zW7TAG?EPq52K{%Gw6xL|`Z-wqXbfZG3qZP(NCl126AF!FCjZy9|F`-m{W9_FK$} z#587bNqTn>F9eL6|Ep=z5BfS%7Zw&K7Zz?>S~6y_a$N3?e)R0r&B*3^M+lvxdxU>) zXlMZbER_39{;7X{GY{ApKga(E<#LryF~~elXV*aAi6vbWQoFbPI5fcpQ?xke_*Gcz zEPAufKmHJxhaP^1PL(ru8WRGITe6F2#6Se-$3U6?r>C9YYGHw2I1O9275O24A7;uR z;yU8(bGzu3fZ@}z6Y<-hIV`T}4B5KOq%s|rW!~Q#Z}nW9xmW7yJDZ!w z&)cPEGGF$r>t>-~Hqu;sh-Vp(H9e6VTAYwbUE;Ddiz6*W$8Heo>`YCY=`xVndun1E zr(%msv1@`Fw#)n~8;$cw_~-s|-CeJk*(x3^YTSQbcEE@&? zl3Q{rbVmhU7~^*jUg7oqZeu)}>+4k<+8b4Q;s4eViC1p-ji!ftly`PCvjg9BDCnsq zo3fX1-}PiP(zs}iI%yJ55;T@HeVcrd<;iHd>dIC4>Bu#ZtdUAh&b-}G2) zV8)zFgO7UQmZ1(~b=pNA)qu^j@hjVR)tEY!>xwo(C$HF7mX)3+(keI^pWu9x?LX36 zB|%~HO&c%!AeQlp*}2%{o<&1RctM%yCOkMk(_BBvf z!JL0r-$;nH*u>N&bTxyVJx3lrL3rw``ZHGCaSLu@0AFQG0hBe?DVrf=z3{YQ{Y2Up zciHKGc2%lSyMhE=m1fJSWVx%pWU5av_x$;dx=khScmp?ZPuI_f<`Ei33x`ewwtSe4 zLKxmMnP3mae@<}dD5IT-wr44*bCXSNI14NODBU$QYL_=c*KEQ~r`Uy&arXf9Pn+s=bqet%EY6d!WI>db<{3a$m9L z-x?%YkKS#o^?K-kmUWrAZ~h=qg@jAl|BHR&Ow3M?lVYfsRob+yu-4*KVRD6(3CTNr zzUYoXKsnpjM&a^#8HLiB=upc)r1ZPVuQ1T*sei|FUPvoE3QN0e68~sZidF;vtvnj0 z=AN38>5|2MS?D-uc2ZmTDym#xg<G&Qn}h1Z?#c_1gC^p}8ehqssHSZjD^2oO z=5JU>@y**s1Z~>xIIHN^+<+3m3U!6H-12IVFT=hsd8ysFL^L%0;;~g}AY^g4>IFZ% zFfkvkZ$E~V63?|?;FSM)Sc0O$G$xSWYp1QTn=Mz=n0r*1GH%e*pGP8~hsL<-HAgkh z5!){u*Y%ADshSNU5?|4sKkCk;qIm0PFQ@;hHxu=Fd`-~oA31zXPenXO|J7Ehp91Yl z^4(Z4cADaCYYIVjIJ_NAhN7vu%72@xc&!tQ$Pf`MzYA5ayP;_F%r0?IGRLOEgFySU zC)A>%RXfkRNZNu2X*BiXsnPE989WSJ5-npY5$bcZ7LKSTUo4QPCb5c_gQX(ox;CjJ zqui_lpL~}#f;ns(dqi9xq7`UWibd*V2D?~Mc_6V@ zYOq&=8SR-bUGDhCoP-y>u5B-Qy5kEbxd*8+YG$H4@LZ{h^~hRmxcx&EKvMNDRYC+G zoiAGC=1=Mhh+XbfJKscZpGN9K(>2o}kq$TGNFy7=zlFx_V%wFv;+BcHpdlIJ|K7%F zQ1klAVxdeC%yzR7$dti(kzL>2XkJBsTIQTJ5wxsaw`p4)$n_&^49F;(6d4!90-dx1O zD}3Vum8e=+EwTbcn*G>YRm{h=QKe$_GjKA(TX@N2m+L!+ zg6(9DhWUOEWtp@fQT?i)>jV+${Ydp@nfR3nYW*o?312$4z|YH&NyrhYolj&v{8rw0 zCi;=Ih2LIu5zu{did#2%xW|gVfNnCyLd%a+QlmBadyDfTqCjUuUyTHu%Gyb2;JXNR zHd6+|x4!UsRAH8vaMnStFP0Rh&*x>3vZuKwm7wp?^Sg4IQ@x%Hlv(JGPrBL80PV%g z#Di+{EF<15LSG*X|A1&qk6vuF<7Ikow7<8>Jyzd#97_^!FFy=<v7)Wslf+ZJ}`q=`>MwuCI`_t z^(w!Jbzy4wErh=S8<&FOcN*ComQ6w~t3QetHdiw3KeIu6Yd3I$*ln!gTQmjZ>r0>w ziRc@pClM1pw&KeQ4KpcyLIjjaCYL8l*=BB!985pQuI_oaRhp(3&00xO+>6+pRgD+; zbyDOO9_RDBH#<1MV%wa4*gZ0#IMe{&6fp%oH-b~4v-91eUZ20rF(V|jD&u?K8ESvQoT1ZV1t}Am%d{_^+YNlA^aH-_u zzqQNATjLvXaPZyIkP$S^sz4Nnr(iVP+GK7jYADY{>eX03R2$UK(^Swaq7%>4m{IM1 zd1&AD!KGO?KlV7xIrd<4gM&0XAc-G1N+2aO;A0UQBgh=Qv}k#pze&h;W7xQlKuTyN z^zJToFH8kS+u9jr)k`F>D_fR1)IC&7)NPnJnBgzMYwNW%+GTN0%yqNtWXU&a_^c%% zaFNx6TfOEV>ggG;(=!cc7Kfa$g8dbY*%|MPkW$HqJdpOPWc_0p)~A6sjtq1YapY`n z%i6FD)z0}&J0HS2_OA7yKL*)j9$OY0m?@;ZdvH6!%!;<*wuLVeOYMn}7# z)siM|Z7z+_FFL(AHpr%&OmM>6(>Ds_El;Ke#YDNyiWNq(X63h;i&=l#-i6Po(aW6g zo{AT!s;!PH+*UImvc+dE`WHYyHQ!Z!L0wphgJKdkR;r=I@2|1~bIek@mdE&eXNGVO zp1<+6Y7n{lmU<&P%1!r#jegxPOGLNsKO+^4JyStLJ-l+ySLcgNV#B*up3t*kr@YKU zU74&hWHK<^nR<-yQ}1Jqo-xu?A2_1-*Yuy4Rm2*%IFi2Yb`@quP~z7ETImQ&Iq&rg z@hEIUuf1$Rw`7)j2EUHW5XGh5j8Mu~Pw6C|z05X|)$JTl;~rj5s~JnWmKRDgmehUg zkb|12ccgJ6mg^KZg972N08oQkddlRfqXo_XD zTAwR9pc*vlS2rcW^)R9)+M|x7=SyOG#o4*Jct|MI1>OofNh+EP8O$%3se>{4Ze!Ah z_G+6gVTVdM?@FDx2p8AD9_s==`|kTAil?EE?&V6*qE)ukE~bmB`#(6CQ;=TYKO23M z?sfsM#|Jdp^3S`N)7{@xypV@)Y=sXR(}G-idaH>1^RM~O#FW!?>WPiyt1s2gBYk}w z58`dtNaxN^_WS&a_HUY9;|ZMaN1KC?!sPkc|;wJ&4aCPg6n% z$Tt~QjNQcCg@$1aUS$mHc^`0K1!hc{`0Sab2%;my&Bm*+Lmk;m`hHP+VN=|=!i1s= z{rWYCPJ-;}a{Mrl+@?ZK8Hrd$5?788EaOL^j&C^@uM}m7lxS!WFS>_1BueQ!A;MZl z*OQYCt5IgQMVC<6_b=a#U%EKKfqNNA+L(F8b|V)pX!s|~x^DS}7Hp_16$ODZ0fpPkz{s&09w8T53u5!4GmqGx; z?24~%*N2zVB&Y6+{td1=P325@E>U&k!SV>BRqMsQ$rR7;k;k$k_hT64bf4XuFHg~X zXeWjW=et;HoEy6!bcTRARP9UW4WL1vW9oUbA%}8$+a-$4%0Y0Apb1Y~s864WCBSNr z&$pa?)q^$t(>oCu5;I=$vRl$j+{G8^ti~GKBv|^DlhUvEu%k-{QLB+gofj8(NQXn1 zFIO^Ip`}KzW5m-=B`u7n;I_7quGvqmiZ0l zj7TTlvh?d2y2Tc$U9P@qhw7e&DTl=4_>3`4<8!N}F&aCnMmH;7N|c80=S_lI+^<_5 zI#IE(eCHcTL?*phf~-%I23FMp=5Sq0>rCdc)bC-za9jRFD^?j4(ERs>nMFy6CiuF=y;j(0DNvG;%OZJ<{D()mBe8Aax(EyXGohs#XHGbG)|uM!}kd4(|1{cE(pUC>O>L zszgH38q%eY=xbp9#NN!RdY9L|^HT>Is9fENpPm;#-`61_U85tI!Zs@Rt&3Or@&>oo zMeX?)!Z)ZAd+U-E_5>v0#pQ?9YC|g9$K0(GWhy7W3KsfiwsTb1+oRLdGLMbT2}w4D zf11Q@Jq>7%tSGcAG9}ccSJ>vL@M*r+F0;+Z+Sa;&SzdMta*cV99j2=ou0B!5GK|WW zTg^!AmZs=8_9+_3 z1qaE4lY?Ftl9ySOkn6@!*fEq*tUoeL=d&|d;9b!xI2P1gk*kQ46dCCWwDT3XJo zd56CUrJhHp?kk{kh}h@#spM5_D>xsl3k?nT$)Q@_S1cXn`(C&4pI*?xbM zXWykq0{ z|Nr%h?->Mqv8DZ8svGCm-@G>XQZHrplws`_ZQQ#+)vtG%r>~taeG};M<@2vUM;)~} z`Z0g8%khMW>+>5+bIXW!p%EUR2;v$*-01J6tiO zFzcx|rMq+~|KjUzksD6vyYliP*5B!D#l+a!)_be_jJp$y`D+b9O=HBKdkx#)JmLF2 zn(~X^!7KWU7qgl;LV6>zlnqOSm33h5`fuNAUFz_CkHVNaC+i`h=xlLVTS8Kfw3n+b zo)F!u@XPB8ksSxmU~^g}?Zn!XCO#sdLovJwWtKRHBwrJiiW+tExE!NCW}7b7`z##1 zW$J(b4)K|X>P^R+TZ?10xWR%X3_>eu@NBO|Uda0Ja7OIr!ayOLN@PLg-=E2{`heo| zl@7);Uf!K~N_``1!`Oip|fl z)11;1E-zf8pC>yQds%&cuP6*=s*q9%OClDi$#7gxFA8IperkW8>iYl9+G?1$urBJx zcXf3Yw3#!2`NLw|j1o?*JfJY^e-qdgT5lXk`tr zf<|B)6XYgxAn%)9>Y%KvTd2%#;HfgXgx7Snl*Ta{B^|y3-pVf-RA^X0F;Yp<3p8Xe$ zc*o(uUOvcU05m>&3Iy_?M;Me3KrQo7DdBDKdic~o6huPx0%c4Kj43{z{971CaSBd9 z%}Tn>Eq-8wJa?hsoj!DC`8cHUS2rF=>ODP5N;>_Fa+HDM=AoTECFI86B33gq|4tfu zI?OlMxL7e7LC~0*wwS3sc~8Vvk*mzJ?$b$#E+|3slOSsNlTU?cx3xn+SO!Czw_GW+ zO(9AJrUeRX50XZ%W49LU^R-2kD#8s?Nor3EOOMI<|3uM<3jflAV(sm{&{C zw8=%h&8Idlz!Ad z1^a`JsnHp{?PP+D&%O`g;A1dBY)Rgh5iCJtw3uurg@#WNH^5!3H!m3yJB4i-#m*=e zF&u6!8`ge@6R#Y=8r+!87AYb0n`8?jDL&5s zSZg_f>!y7AgRe7M4$JzuU(^Lz!{G-G5Qwg!$ft}Rcn96c+7=Y$xa;O=Ka3h&YooLk zGZOU?*Ep3!+o7=NfN#5Tkda^a1hB?8ksoWGdZFpeg02!D9wOFyXmAScN1=@J_R4p7ul;513lj^K)`kCr_V3u4V!$ z_sO=eH&4eBx~R~Qr;q(1i2er=66MA|(+De0rme{@>yalt9vo?DYu0*Kn*d`!NZAm_J`M$@%8ibXiRcf-b7&3+E93}gTh*@ zD)*_iTj8>n+E`B-CJi-|D5D(3XT_cMc1P}CB0`?s;uw<>pp2i_QnQr7iWN> zo7;GuVTjoW|6 z1+YHeQrGodcF5j$13lJGtqRos&c>nj{;rjoOJqSCsB7RF5brh)M1Rkwv2)u^cpr^W@UKQPw_ z23W4Yf%Y{(8&~YuM~!H=1d6J6u8jx_Yxyz{7G@gMm+kLr6qI_jeUL@qi&$!Vvie3d zVxXhxAi0v>t5pdR`%{?rV<}lv)$c{Eiz6I<9l1i>uRV$Lb1P}}y%xGx|7%&>*(1wG z0`q-Rn#vq@uK0)_a?MXZKkKaJW!zI#!KYfOmlwE6$icH%sjTfvAly@s8r}V8fwy5eEK zWAOqWLw(*5TaF%+H74^C&IIw$tD>;n^=r>V_%;G(&1cSLur9=i<&ajzidwC9&}8`gm?+1+sfmW@9~Jk{(xjW z|Fr!`<#$E_g;~-Xb zEnG|F_SXqhcT;I^^V1qrY19uHVHfLl;@rKY6HRrIT8bRuOdUfS#Eut9mkUaDa~BF+ z>~~$?+9-wC4%m1(hduk7>AB+Y+ENdhHT@IcJYm_n#FG& z1q0Ol!y~i6M$RGfOy}A>d-w8Xy{-tW?wvdM_Ps05vw1mLb@g5$r_^Y5G@s?#lHxXn zWczZN^1i)~A3nk2*K}nVqvt3+FfwQtB?`Dn9&i(Bg0alA^OQsPgIz$r!V&okCKPx7 zj{@dFr1Bo|mCGX2VMliHwDr!gQ*X108@@1u-gh5MeJ0N#>9_hR3rj8@A+>?Hoai_w zLxZ@vmUgMaf{^Z(c2&uB=SFLD^JJOs7pcK&>=(7KwcC!Ujj*_D>Aku; zJcwOw5@_Unq&2aJ9(U$V*{wuYzD;>00f*%|r^(nuz&b!gWQg z(D1fGUf`A?CJ_Zxgj@sU8HqCAK*gq&M$c_PLc6@lBQiI4$GO`|OGyWC;7^9@R-7== zbot5#HvCfOiSCSz2P8nw`u|3bQ`~1`=U9m`A!XS@l3eTPg^b~ao3@NM+$U(EqnGL# z58iO{hfUBlFx^;@IkM@}RxEEtAC@L-dS1Cf-Vh<&exE^A((N~a_)O&xR%=}H2uNtD zKDhmUY!KhDB<15t>@KX>&~taM1g)h}h?{SR_ z#FY#F0RcAmzq_<4B^GsbXsX5EZWwF}P4Ym)7t>xGJ8rulz(O{3$mYjtYMF1z^v%qo zm{V>Us>HBcH+QkB`HUMV+`n%vcUafEq|G&5RftO>ts$r41s5;!8pLOcP1mIFELUd* z(6l=lF_RC@v0kzYZtPr4=FkW_FFlqK@$vIn%%jK(j?qk?q1w`Rp!T2MXhpM;HNof3 zrAcldBkJEG^-iT!Z2aaI;ZY5FuKcJPabTV>REPvtL?$ps=l9kP(L{KOkOr_zuHVQg zvw@e)TPl0{j(gL1J$qv-Lw$aq!G8VGFSa|R(UnWWV?!_DpLrX9z z#}j1d7z&L^L17Cc=^49jv;8cXC{Onk)MZH2mx-U|Ds1I9lye_{zRu9T2}0d<+Il$m zHFAHk>J)K!a_)g)+1*HXb*)#k0Xx%VGv*tTEcTF_K3Nd1vipDLANSAobeG=WtIr`F zlp6FdfjN)ZChc3I+@~Sf-A6EdL7%2m->@~%rPJOFY`6i^XJai2dAPggN4bJ5rqVV1c&kzu%)ePK8w4y4I@<(Jb(jIIHDw zsNa(3q#AODS@nZx>|hy}5S#K1MOrso4R7nPE+KVBtJt<9Ry$kI+!^wD4!94R@j1Dh zdq;kfHSx1&;qZ;wv$jIqfBdJ96dL9Yhk@j$}H%jQO-rF-jt-{8!QDi*xfuuocd18tS=xo zr#|qEQG(Ws|1xDOO&4~l&lFx0f9_&+c;;_ORoD7Y8VllvCFf4L&Tt;kQ1QbDCy@Eq4VtTWl$ZyzDU+o}V(d(w@mA%Rz)*8bGuZM>2e9q_ z`VP!{8NzPEYa8c!O}%0!`=s9>{(yTX!;kHXz)8+QL3XJ#>>7Q}kvTlV!n#71*l%d` z4M9O|;N{8JL&hw6u+Ggjp9dujBxtVOdZ-+%pX<1cFsyx&l$9>Nqh#?$p#eG$?$Bz_Yeq6x#`Hc z#e=!as709`iQggAD>gqInJw}dBSq`^3Kr|WY<{xA=NG;^UTT2OHFgvMhZg)Cr1FPf zG*!E5n;?mwtu62St|&#v!*_lxm|3xGS+wIWP=@O3?DsXGol1M)Ha%C>z*xp%3Y zz2i4f(Nf`u7bI2Ns$)+ZwIrp~JWK*$4kfbGuo)J-GXq1s0z{Hgf?((}EIM4wT9jp- z>w~u*eGQ8Q5ahkQDH%XTG6e1tP)`=zCujc0P)+gEr;=9qz(@OFeh${^b4qn1pSUwglLTIcAAvA{8+Z_&tw!oEF`er)^H4`He;ZF}hE{WL}ggIkcQN18YTzcq9%} zJnT>VAs|;)5J3% z4)o3h)=PkjhN((JzM%X2;@I5UWp~h+^2f)Vb_P0Snezm-J<#~T{6gej*T52UgC&QH zzGO2Rf%y^o`R0k2qS8IrYl45yPsW(2Zx&4KCKP-SSkUc@KPSftJa41kvJ`HG(;)K= zqIBx@gb?Yo68T8g=2`^pPzi=C+k#7 zbhbr5Tdyax_mr9C5!XfyZl$zqs{%bt?BM??@4dsC+P1e*)U9qoWp5P=3aE4t*a}Do z8zLaRcM<8m1wsqzMi41Vl@gF%15!eO&=lzs6iBE61R+uqdWZ1M1?oO$|MofG{hs^B zeeS*MABB*$=A3KJF~@k{cZ}%;b$k!j@u!7c|ZqnXHV1ub2KeN=Ic8Wc)HK_oL(xU5LOfTtH3O_`NRfc7phbLS3xZ|7@XuMkzMn6s$dGF*jn&S>t41))9W$Nf~^yRme={3*Lm9#1kh0$8qW`nJJmTL zUdtgLGd#F|jV}Dm4Je^`oPjK~gTyRR->*X$ez?k6)Y8&XB3uq(>ydMGQFK$(Bu~4r z6b9}14}ySI$hIIp+qD5zl+A{c?##{K@VPzBUkCRxgy0{rf_v~l)68s1xAZ*%IYKoKgD{)|DBiJAZvfI zd1M}OxI528C6Jz}?0VY%SI+?*wzr?}G8+nnm1m|lMDw?~_z)*snR1W3A+KVrfdTyD zcj|2KZ^N^$aZd>m{}0VQPv!mmmYc0hC(;~kN1 z5ZORsnY7VX5gRw~I$VvDvi5Bs9!Qdl=j#s1L|E4MZXbG`*Y>2p0&eW!Z}L!-2ks&Ku$R?qUB;1z+DY)*4%I2O zSyqr&_eAvvhlHqado9~{u*z#>N~JBIC*%PC35o>{96m}YBO5GlMadWJ#v(k80LS|F zX$S?_t`M6ZlW@p_6R`Gj~SgWMKU#)SIl ze_*A){}e|@(fvD-%_~#h>3qJ*Tn4gRk4b6Y%%RMt_uGux;-WaJ?i3S9( zwCAkGv2QZH{Tcq9xUMW68EBvR%-RHYzP(`vF67w!{(H&B+itg46VRhC%;R5)8u8ZX zWmSfQ{CD;eRpVp}y?0z2PqI;hGuQ*i!`)3%V$ixvPzm;SMOetg`?jH3{zu|2SQLka| z>un)e#L#zCz&@?JTLuc}8;lv$|F@=g;;hJ=zgMXWKAAHwSN~p^-_Xg_!%5GJU=p_W zW?kLaIE=j2pDd5}GZI?fj-36@d;X+Dc{DhzvpS<X|P*LeA*? zm!Q?ciX}NiOoiFQwyibB*yL0$iKOjeD|5a>(o?-!!tz^lvX*9=enA-}H+O(4FxY== z-6`bntTQ$7IdT8J2fDhUoT$)GtM*qu(Oa-hQg38tFV>m>i{Z=`JyQw&8|T?XbPpopw8>lH<`-KX0+y zvU!|BP@8%}uU!M?qjsKa3BFh_3}>I1_WD_cc$QyS>SY&(-zlmn#^dgG$c#XWB%gfl z=_ytGjT{HEp%%Tn6~1xAXZFy#^FQ=metAmRO;Q*S-cM8VzH8?Ff>1QPu|mlLK43S- zLk616U{!s_XPMUD4Gs?G!2`#KhUV?!>J5CZb#*o|CE4#tw{7yDHlKCgZFu><_1h9w zt!2qg4LQoqb_X=CfZyR&+VT0gDY$zkrJCC)e9%|_WFWHpV-e>v;p^DO1Do8_wM$vl zV>$tDR19r`X;YYbNgo@>u^JM5r7K|wy~dz>I7vJ)MMZy!?7}P!S{cH zCwmH&XGKpt-5fi*T3e)C#{<8rDl7XJu;?3Xs_#b;?ek7t40xtsdZ_}esGRxL3~ny2 z9UacV<3ngwi=pNe=k+)duVD0JPp>c<<)t2eeQ3xkheDBF?lZ?eqMdSOT2 zl2UEc_MVtJ;TZb)I{%#ng!1aQvq{GEaO(?>pNN8~NEn_#mml|E~ zR*5whjXlmi9Y40mwseXPT}Ugo9JMv`yhaa)8$pbU#c2ET+}E?pLp-H(KbsA9#IX*V zn+$1zb2`P zzkLBz|NFFR@I+`PF23NhA`Iq#oEeo$#^y~8DBaZGQVJTPUIBF%P?-(Ge_D|V7c-Lb zQ-hoW`#)GrhVf4_x+zvZ8-nQ*3tPJx&R9-v@Cv%Oq2nSBCku8;ODmckM)p}&U%jgv zL~5Gr_G(rf^YjjBO?)m8_9aJBbKmqRubC&$Nyoy#tVSoViFe?@F)k%i)7NjAVdpB# zlwwZ%Fx!x|`PZtVh_DamgSvf3Tq*d)jV}%XEI);(Zp&iYGK!vl3u~lHAWE#1c976!-aP{t_r`d(Z zw;kur9!>mWg-~F~u>6IK@3un6uc}_p+{Dv@I|UbaF1%R^;jyI32o|=l4_B=$eWYn9 zYbHd=4JL<1NAp$JaA|gQpeO`mW}dED7~;nNmJnJc4zb@p8JOICUOpv$$>p6)vF616 zO14KfQ_Ux7oj2twEva~iQE$`r^KJP`d3ML9z4dQuEg}v+3Q65g{c#6W5m4e649|T?9(+?GuMd-e@`h1Z`vX zvZhZ}>+tbpV)?@3VRFs0U4>3Bpa0y^-&n>wRuO^>e@AS+Gs7fvq>+M4bBn^u?-o*L zuNwCWa#O@u#~TvN{tP%cIlyv2O8gmT&uwGIrUWz1mHR&SM^z>+2^7944EfS>zp8?H zTeXFCf91i!1N$3pEJpPI#;ozo6>p3=rfPvp%smYQO35x}LdM%Lt1~ZqxOeWv3kWvyPD(Z~^1a*BnVlm{ zdrF4Qx=TPy*!JdY3cM;tfAz(uv@Y9kb45Iiy^EzW9MqhQ=Lnnk2eP_z>|<^{rQ62z z*?{evDsn+9_RsskEqfcf2V|uUy4XJ(O7bx@K^^JzRUvd2jqQWtbl(d`ec9UNVf4N$@v(?UH{DWjuar}p=dQ>-Zh9hGYUcRPGL+_q{GL0W z{YPwKGHSgeJ=f+rt8hQkao1ae zpQJSwlcfsW4W7EMj-jJm{w(AKTkaG%HP?8DOH+nS+f`A@^pZliq*;!RjHMS?hxEKLV_m5Q%>Q}?eu}uRyGXBYNguy57H>?p|bvR=hX61meSuL(c6#aCUM7>GPT;~&9}I{H~;`l;~fN*BOVRKYo- zP(vkCeSKoQJa8}B@F-;7$b%dU;XvxD?@Mh*+F}?${JApFMaiyZ0p0#>U+t%wg7Rf>wiu>&PCO%UopV{Jc zAkyl6>5?97QFSu&@a#PeRjokxYc!|8Q6Au4{!L51LWYHiQ@KjDzf49(r&Rh`*^?E& z&;kv!>h+Q#x~|Fl&b(J>%J#6v5jC(U6iBDpT6cFdo&v(rI=zH>WnsvoDxKGLL?5A{ z{EXnTg-&k^+!=PD?F^FW1o&$mR3mopif)AlW>NT@jfZ>;fOO0E?hZaw&GD&azelI@ zr-Y|1@=Y@Tzh~WMlDvYuUx3Eb*ce~nEL3<88nC%(Y8H9s2@7|VZkw?4ehVL0*ej5DDMA6 z(wL@#jo(;ir&tLnO=oQYO%n+TdKWuvCkZ_*6iE=lq=RP}$|$OX-VAhn@3D`D10~MD zG_-uI6!-jNk@BGt61Wv~RxWjWGrzsRV)_g|hso4F285s>!rKk&K(sVB=TyD(4!Z{( zf?N4~+Ds0#BCBW#kHVOPj@KapgFWQ5iaY?}H+V%2*X~mo4~AJa zq4x@ec7Nogy$H3DSxUkhQ0u~ugj)cg+`~@6H%8JTM8JG2yd^YxykP&nxmbuPJ#L%E zCLH$s^Sh_qgb8)m)2w&Hm??xL@WwMN6ZtuPKij^RF2MhtClH!LW`(kM*D#zogY-F5D+~>wEQfENSN3U z2JhVUub12_3trsa+V<?g$MK5;IA=_4bF*-ub%!E3f`XI6SGJ-K|urqrgbw6azyB znCZ^#Jz&5fRD;}CGEfNZtkC5z6F;x~IyXS!_J#{s+{Rq~5NVpJj8Im4b!-W6@LmoJ zZjdNqBL9q`Y;be)Juo6U+9<);%)Yp$K29kqqp?Ow2+J38s(WBRP@{E{Gt|TT$7J>~ zvC#J4GdH5B<{(30zx^7=Fmnp@K*WOIiwQv+))tuyf%0+jxle`JVAF(8%9c5`;PkDh_I+RpE-cF;Hoh%14c$> z6u)PF!2o#n>)1X;uO&PPEk{~DrlJ0oV~{Mkh_spdB(5O>YX&A`g!898n-i;@Io6%W z^2~+d*O8{bel0IJgvtz>Gb9ebxcuu#_`iIRrF__`Q35ZtwIYN+)`13#aqO>8^&I^e zbB#80ZA3Be_iz1`G$L)^=#6(<4V?rQ9%?qr-95Gyyfqbrd83!oJJ)qtDfdc1+v@+i z8(eVcSeLtfaX0&=F2Fy8|2!_Bj$}{N({l7Vk^8&H9AFYmewx~mGZs|_N~AjzRwM1F zZqgw3HUwyPnB0^4{mr@AKdnD7WohMA0_Vj){AY$sonuuQ*L}o=f>cYYpZe{M#EH0z z{`5Zu=XHe_6j7ko7>IS?-Zl_%S~{L;X-SrF0|!G_NnSH1W4)WBtf z@D&+zGVPm)zwYB$YV!(}hN#@p6XN=mGEn)b9KA1l>#rW4mq5^KF56U4LicsHF2AGL z)9^_BL{qmKi}COJR}8ew$3+ePWh=~H`s)U~zAVRjrda}Px&&@+JTlMz8PC&by6kPf zpt);Efcxkqe?|-0*c=OH`ImwHH)Z|*Rpj#L-~+#{>Aw+UNI2~@1Z84&5pQVR>UIF^ z=`#E#CAhW}ZHyRWfYeIEu-WcxIc6wz0iE{(3BP-kWs+bC>6O5`bf508x8Eyn3Vpv8 z@I5Dh)!;3(%7S!7t7mXM0APd&iHt1EZ#an!1FT}kYf?nHCq^2=;2~|$LR*2;p7;oA z!<+yBL=A~w^vxfoPj3e-68n1`*KR7A#beQ8eMn1S=h+K4T7Pb8z`LNkh|ks1P*4a) z>m^{ez&YAZwk4XuaIg(_vK(H)`Z)k2pcD^^UG&cyLcY!J1-c5TDL!a-?`93R9?M*Y zG%*lEymzhrp3LPh6y%UCe=>_pSXc|}yY>*hzISci|Mx7GCzuo-l<93|^{na2>{WY_ zN_050nD!B!GJ#J8>bS8he)l@ZPTs{5PvmidM3R#0>xn@@mEvAYLFjV~L4U`A{xQ1* z1m~q1)(VP>CD&v1fXHiwGGuShUkB7qU}F$=_;?a{6GSpW^SMC#=OUT#(_RgC_fS-j zWMqFvW=j_2&$z99GW{os&y{2R1%-_$#&6+FhtaEn2M8_MG(bxC?^#k1q=2Croi})Z z(yvzcUYQ}-`%*Lk#0(`pHHIf-b0|Grnj8&hmxq)yiwlwvT)rY1Ap|m_ISbn0$I`ba!DhT*s-(c(*R?bv?X zo|4fk#fG2IIdU(qKrg&CyvY&d)Bxf$INLx4(#a_EsOwEUM*I`gBc(IMl66`~MBU5g z*^~FVQ{R$K>aTp|vBlgvct}g_9_m3r_fc)D_TK^szq)MuOfaMeWumoE4neuURPGEE zB-#ZkGu_uvwg@X0ERIIT_S8ryKB?sT*?Yr(Y3F2_I1 zBfeA|j7d%y`&8Bev)PsyAoh1?KYcNs=TM@russ&l|bc+D|Cuu3Ql(>ND! z-^co>3h9zS0Tl92-mgM4Co$J2{0n$=TGyD*zs`FLP7?V$z>xk3nlIDLwqt~eW@oEa zk4^OE<^fvNyv|kDQtg+wY@TtYTY!Il;Z4^LdUET4(c<`oXq5A1jY-aH(cqtRO@iPj z#Y^ZX__c-X{Rdyx*IwwtIr`~5aC(#b?3amcOz@Lixw#5XZ=O6kz;O0NcJ4bZP2uz@ zlkHo%**0@=RQ49-4#HzTkiB#H_Bl?$XlKdUYGku`7fezS`q5o{K-@M3m#ONdV%Nsyg-m?5MwsC&qY_!x#lk@eF@aDE%NbTB5$AUo4CZ~nFDGldkxucFJBica@ zlq$`r9Ja?(~+s$7BqvDdtJP*rh&o>EnoI0@({OdW+Yf*lZ zicnvpW5#Pjd_eshW%u}iJcS4TY%Q;*KQ*P*bhq5jLK)f9{ran@#>FE!sg1F6(+gMu z_2`SMm)nY@INj#5SZSS4JK7|!J6fSGV)iRIXcy!;jnyQ<10qfWK}BJ_{;F%Oy$;;O zTwV8O@YUdnQ0KGt(m%jCAP80-;wtq_;|k~;TP%c>sI(XTaLFEi8h=Of6$s@@r3H*$ z@{jYl{S1}^BTn{U(MZ&q1pgt7}WN0<|FDv3-*7;zjXtk||_^`KZ z0=P;oj8i_iC>GulV?&(RO`0ofx+a%@8M4r{lieqJ;ZDf{CXdQ78IL*}SqnmJs>}1QE*GxB^i*M)C%*fr0 zz7nD)`fvDU;pHaLt;MU`8_Tn_M{`^zR>IdEj-5MeI9@n#X96qy=)usA#HK;49+$u= zS7j!pp4<<|#5V?<&9eru)oiV?Su(aMOaEnfXBHoQxmeqp^jsuZ_Mgx{g$L1s4G|+Z zgl$e{w9WE(g8rX!z1CHH%8>6~#1g6Ir1;8!Gt)YsRue2^>Ko*sxas; z@Y;kdjp+qjX)FGI^$Yk%!yOnESX#XLxfhp@Rn7h3VKPdWjP?orFK^`E)dMq&U@POX#62goH=^WaYPS04f<;Xi_>f6JxJ%8wI$aoPQ$O%AYS2ZJwX?Ev|S zwmWg+%gt$aUR&n53wwljqecj1r@{5cr5PB9clL5<-$_S(Ved$3rbYA+Q`P-s6+;x0 z2}R4Q{o?q<*UEMcc2)7!cn$~P)5-kw>3sBIUS-D}WHQN$eqG@#{XUK)usjC|HX5h+5X~B9dx}_u^xOe%rNje|Hz7tf8k#qY~dBSOLUoUnMNQ&3GbMF{Q4-x}A?cKm;csh?K^;$oCi_JjYCp7M{G*^v=y zH1le5;mk$XQ=a_YSpAJK`{5`l{0v`!GyPw(-h;vwB%&?;57NS~K#5cJ{ao^)W)I!w z9t5L*x`%Jx-}xE8m+yyAuHEauJ^g3C|G#ILAIU!X`}o`I3hHC8&oW8A2AR#=klVR= z=omg)rxeur$;_MrLHRex72f!vN4j(=1)}1CR(dM5qOg3q2x!x~d{?PTq~sp;R)duU z$Bxgy?=FzW&5sG#ll51E1!5po!Fr6Qs_CpZ2)Q9q48{-gk)%A1se@Dqw#k86ZAe}` zxbd0;#L)GDlKH@;W0QwmJ%dGK4+9bGv?Cs6OHU+_ltHX64{u8n+dK$K1^V&kxVFVp zAiWwuF?yT~4=iPJLgG%9Rnti*w-!6V!XJY$e$Y?b*bgGb?P1(@CO&)d3-LldJrb|Y&RdFY^p~^TcEl=VOVAfq zcc6*LQ!yPgU|6Z~Wj{IX&EMgw?FZ?*p|e)Ub=rghkL=KuKl_dXD=A6VY;=vK?c)d- zS7)BS4itt)a9QmBT2|n|c8}b=4LYs$AU=Az`gDv5ys9Tyz1JJ#$N8##yGRuH!Cq6( zEu$hL)JI%V?tOW++nIIFkIYxg(UHmWGhaU_d)12>E?qUSD-BuMhxVbk@m&G&ANiZW1Ou`_1*C;ri)V44ApzeJ%Ak zFTKUL-a{@ESD~gtT?r3GTJhRSG@+2&%^!52rb3TgsW~H{47*}j{vJ$v`v=5W#Rt|pEtrG2<@9lkF0H2v*{d_%~wab{nH$>MJ#xJq6f|)Sv z^$Fm^7BjOlAX~t|&@$DQo6BuFaU@Avjw8etovFCDHGvUj3AIS<%)~=WA{b4zuhOD_ z%&rD6pLL^3CyU6m*z22$^|o|SyoHy|NfOVI+ePJOY~liWp#P_u*Nciy5@X08Id_V_ zs*w03;JtdQqYGS=L}AcD==JL`2iTXqUwB&*uGuI#lT--O+y>Wk%kMALCf4@!=+h(0mpnS6xlrVCX^jf8?mPETz3>7v$PF?ohFdS0|BG7#x5Fb!`Lvme@G z>$zS+X$lxOwPeW&f!`U}A~M=9t_e^Eh2I8s=tDyTGZe)j7o`}=46y_KxEf8h!t%AU zgEou-)m;9bbLLV(X>@&k+WEQ$51OF4+7!%SYa}C~@+*!B3r3e#>gM=x4$^E~=4_(< z;sj@fUs)0xlI42-tan2&I!uRj#c6wMD)HdKm8k5}!blKY5<0?2I{ zVjf~9bY}{j%scZsq{4(_=o?@wzhj$TSr@)9D-j8Q^zTmg${r2!-hVrd0o8G5S zX$|$685yTm7MnlU<2TFB8?{wd)}z6$B;Yjg3>@vK+Vy&(Albm!DTR=~jYEYmMxfgS zY^Mnh!J*(}y}P4J4+VHjuv7whrEySaY3k}_+Gz%wJT>YTEH8cU#{)=HjMbdt? z_kgy`=0@ks3DXuthhq5AAL{L)jG%y`3Ev<2bTy^sZ5$BF?w)W8TM?I;E;&;WD@-pz zLV>P8gaUr*QBOw%BrbP|mH9cbzLjJoPeqHBpxPZ~91cv^81}oyF7Usy-z)YKwNOm{ zToO4Qp^x1RLXI`xVStC+Q}^}o@(Gi(fAF9Jwe z_j#PW=@=rO+D%xlR>wEvJWgOY&D5Ii0eGddq6xJjI(a)IE2}~&G95EfT|j?^@9lN$ zeC%Qj67<$v4Ibr&^YU8wb3dl|=rPp9N{KK%7Mc>buXpIc1i7btwA4A28+;uxNwqT; z>gPI4U|}bL;{vR$g195p$_VI$AVBZwjtIul>Xi|<(~FrncPF&Dl%rAaHSGk99T*c^ zP!I+nJ)K}iR5-kN)Lu`KR_|MI`abWvEAq-a^tAze$n&LjMSq(tQZS$2hAu&oDT(MGH zj})lP3`i}r>W_*G@O$Gz`!`1%=g%uXYdQ$7_)ywiw&gA=OvM?Yq zd*FOPJ(3eR*!n=-#;i39MsAmY$#kt{}#S05rdVQ0R2T%Sd!_N^;F^gvu`1>RpYaL24Z z1Lw6Ju>}nfB;w3M9o%W2e)O-7nR=&K8zfJMUJ_J#31MyPo4AyMe({3nn_fJADS^c6 z_-Hg|mK;65Wlj<;!ct|qC#%MVe46Va?fKOBPF0SWKnL$aNsK~KvS!(L_l%33i52*^ z;+}JFKt{?~D=UzVg0b^}{5{dWb)whxCWL%4I-gSM>d!VwhCAwVR`m7}EU^M{Pxc4J zLJd|6m&^pl?>?S9eR)@Y{mAmPQk3 z){D5+>alS8>VAXqzEI@1MnT^M?k>ZL6Z#|c>No`c50I78YJcBkG-oWm+-&62m)rf7 z_AiEfyu-XeNi_&!y{Z&%1L$ikUp)()Cp-dYP#-9rZ{BtWyI=)>7r+QK${>gOy4P3T z1|jQQuyh@enilLxY+|Ke0d>xU!8zm#AQ8E_xu*uO$w@ zkI1Q}^vBgfhA@=r9WP>=Vy$7Jk6lAT)b5V&ZE)anp2F6dLm5SY+=m8za|aeq0OSYY zLBDA4Lx2)e3i9&YPLpRPy%SG)9Q~02GacK!Nk^IvT(P|&*x zQ?z6Tb{V@ouLIJ<3Bo!age{LDo*o&`ST~9b&m?HQx>{2+XgQmFc&PUsKzet6wcN=8 z=~U}PY{soFXP+Dbf9%70zEm3x{`2t2VVebARx*(M?i9V6~V?mE2#j^FaU|@%isETA3b?4 zOL*KwaEnThg)yTWO1Fx4{X|Dl_y!jK zyL^9{Z(ino??%3Gzyi6qVU6NscxQ)E8Qb~hB+%2_`##hsnl!RAtX=clT64~MIlwBa zavC=YVn5#b&Kf8i9!d()nqeo%^Ft~0fV3zl>Nr4&KR}Q^{Zva|KRz{;T4{;a$m@L( z{puBELky%}wI`sRrdj0y$;8En8rf!4g4~U}QVXxk?1yu+I8~eyEIhVR1MO2#qAv(8 z#XbL!C7&pWSa6ZrsfrO_A*n(0JWVc4D{!Uf9=y$U!l8YD;-?-5WP75o+TBYWFz-*n zj-KLRANP-%J#pW52#gi}ttl%8&Xxl&S&T|@*5{FR;Nan>$4;AbhNt2TaAe2rifpqF z6lYsYnQMK(gyDf4ns4oa-(vwY80N9NNg2<~;J9Wtp5-sTMwh2S`XNd5nwBgRD=>Vj zfM?=RW}Mx4QV!699(a3Pp0Fi$Q_fuaE)UPiaq~aEQ6+@e`w%t@B|F_l+umRmKXn)& zRN5h$Kb$1xV`nd9cXw-|gR6U&^>`$!%Hl;NRMHkH&#xp5w6k;=@mxBCosI#@i%=F3 zwN{B0C>b1>hstT(z5+wGoZFkWh65Nb!i7hIGT2%CqXu8jBCL#l_$w^s!bl}kfZ&UX zcASQ%r#%?EvN{<`oRD*{KJdnX#Gs&#g?XD?rDX^rVq$zC(KQASs@*Mt>>iLF(Ze2F z8bb^GOKEkzMb#rDUToY~8Iu+Z4p=S50JsZ+IUh-dqu-qdMem{34)~DEP;OJD9Txcc zQp&+?d#>qpZxO45jKrc9gLjeUJ!aVItJe5H4d+?|GV_k*qF&z3t2zkXj_0s4G|{)k z?bHp)kMQ$IP`;1ajlsa^4NU)-Jh8k5qs~ z1^|w%W|PynNQ3%kp@Xom;Lv7XHnwi}{Vp~`Gm=0RfnQ#vNYyf|rDeD#71mIb4Y2Zz zUx|!n11qS5)ElYl`L*s?Fup}~0tYxr2WCL6S4bBG^f@We=q*~1DqO6birz z0B{LTGQyfNV#5w{k~(k*d3&QW;urzv_b3-QBjq(Tr&k2Lcl}U1 z(%uBzD`nTZ+yLImkLkz$958J>Y(J-4B=B&1w!?H##*_NmSQwdD1`w5+q~}>UJ#d=s zM%J{eCg=>ekkE(ahQSsbY_Z8zN4}!h2m^c_VLPWVwj8|@SEJPcT=EOop~ldMhKmf1 z@!wt0-1J#C*s9$0)f0^RN&xPHwOt0Y6%ePmEd;8+pav{V_wC1-Z#y!mZbLlWV2wGn z19wvoaII<};b(Bd)x}SsXm=M6fj2jogFcjzdyn?GFj#x8G}i$u11QF$gICxPJG zM}gadavTMSnbEI<&p>Kq#GVs+jhT~u96&;CUPJvQmYAvToayVyi;e;bm_+zQz@C%) zu|6k#7zGR8DOu0g8pN{@TvL(u_8unJj6qFBIa4SW4g(>!C$eE0FGhEvr6qxhmHEQh zSy8L_{mKoK{D z)BJ>SGVdeFLDHO!za#XXu!_M;%;rZQ^>{+h007I3Zn=}}WAh0FAAmR@=*4X0&6lvX zwi6cwEpYDndL{K_Uy0%RsGJfefaUDMM-a$kHnf$#zfEwQ0o-%nghJUOi}vFX%0jo~QIk0Ex!=LJuTO=?i~|5Ak;VB5M6oqi?0!kyy`p6oWddLT^yE_&Gw)pl$-qtm&{42{6ld_6^g$HDjTnb3X1;!H4?8DF zI<0F#jYW=?UDBw7Hl)Wz)2WGNE2u=KMYX@KpGH@+b0#KN+mvD9v=)Nz^2=j zIUv7vRkWyt3NsEsV)8eO7e%?Pv0y0k+pcem79{{!2D8XBbN1Z)$XT5-oc)B43SoV* z79dj5eDtI?+$>>1lG<}>0p42SV{0s1J>xLF&>t%~AN<16;7uk#4R^6~{Zp^@YA?SX z(HJs?GSY+q5l^t-E>stvd3UoQxhXv1o0o1T8%WeyIKgrq(%un4He64}6JQ{rxXNJ> zt@te)+DkZ;sJVw621|_vEtGlZ3RxGS5DiKq7B;ziw{oI(QTuV5G8EoGr8r)`6x^X* zAeod+u5(8Q8^L55rCjn0Dk@9Rdc}6GARrV5jw-uh-w;|#T|;B}iyU1rvZEq->04im zcn*yOu=H)|LQV2|gm%i+2gI^~}scUNDF?(sjt>QermO z6ObVBF%-{$5+GV55yrgOCVRb=v|wm^!}yBUf;4g?okzXIL)tK$5wIJt7Lu~eVuhRD z?*cm9_eJl{B=zLa033&W_UfgR(`8CZN_c?cb337@rq4-s==bI%+-}GzaDe8%Zl5x+q;0VV;JCXo) z7j%}Vm2k{?>Dz~Me&``n#6R$L?1KXYI>?R0qDo zvD&vC8o1dNsL%`9-rUk4wx{On5m3{lAM1@lSAY@>hn$C`Q-r=J(;lSoTAKG4^Bw>% zi2-Pd(&Ua0NcTm>Aj$SwD&|@Q_Y%Qmq_j3{k-x2&_bFJyL^DvYrQiejRjg6Ws*&FA zD6fup7MFm-e9Ml^Y-+F3 zYB5jSDy(mi0Nc>iHt!3UYQdGQEY)b9{weXI@$UdNX1-kd_Jb(;e?cbhzo)}z>L&2k zPIF8l1N(EHWvn;rj0r#JpQ>eyAI$#DdN|LwL_at8f;sb0KE1K0q2rFH_>P@)ihOL5 zSLu$hjdn?Hx*f82Im0-4B_xK@(!)KYP89YF@iNggd8d^=bFy|Sp(0YqypQ68^ts*J za9}As&{4;(`Zq1^Ynx}?nbyk>8RKtRULSBKEBMKFsBCJki=6*Hly7|Yl(Z2$*O|_q z)*h1w0^fk10+dFUvL0yib&Sn}yraI@k(2C>ed&DgLKczjH>geJ33r80tQw8q;u&kd zZ6jnddZdTbY*6CUnGQwb^OG*yiS~LH=7fo>N-S{X)Qmx{-j1IdU5!k(YyVJgLZL(Y za10{Nw=d!pN?N7*jko?*P9aG;Eq0=iFmKT_^^BsEgPRCns=qXSi?=A-FJ5Tf7=7O) z5B|Kvib67A@Vx0kbQv3OU8f4#%S&~k$hS*`Hm1b;v7@rpqTWe05`%Yimda&&5=#`@ zv}a!lkS~Al?1P9YyzMtnn;{n47hfa}zMN~UyvSkLCl|(Bs-`F$T9D;ssweIuS&uRp zC^+Emj^c}7TvFVaX*$*)wYrhFb}4|k^_F?^BFb~h(F;yCWfFUVjju6%u->cPG@!k27QkgkWnO{%PXYko!SFMU{uoAi27;NWv>)~|wZ z_(VIyX?TJDorBzD-Ga;U$$58|r@1BPqQa0Hd4|qZsqm?!(_rIEOIxN8>cw3i1lE|q zb=w`Qw3t#?C!adP#)gGIBOu9lr^r;Lb&N$X_(Jr!eMR3F7EbY zeq4j9JN5FtwGqk9DO+vKr5^m9AYXQ&-%oS6woDd5kuXJSmJd)->Dc|~g%?g}Y(b5S z;DJ*YWeEMm|v@f(D9W+q*s!9bS8$F#H}jw@Vmd4<;ORt2uzWD z$i5r`U3DWEyvNdc9&+egoMS{9;&KHXi?=eEqEQv6HW2cQTaaOrTQ*T16b6-0gttGtpP2CN#D8 ztlu8`R0{UGG@fjc<_`8A1lN^7ODJJ6tLd^T@p<*-U!k} zC0Bl1D&h9Kr@l>hxqD?u*Vgq2P9x#uO52blfdZ1KsY~kJ zq4C@AZngVSW#R=Li;NMY$I-vK^0O9p%(FT)(98F`uDw3>S@n!TKUp*ZoS+GdLjXYf3> z4CZsU)r?f$;P30y;Cw#rx4}=?thbq&TBCD&f?I~=O^;3GX71O@ z$Tj$p=Kp5DXFiR~^#=N^PJyx4H3>V8D5dE1u8rlA7X4B&1HKauJ?jzaPE5lX`2>tl z4kn2?8@oZ@UBY6ggIApGk$J}Pm2XU4IOt_q`c^#Roh8{Wm_^xKud!%$_|uHLCI3P3 z?dhiYjTR1b%(pi~BxD@bdch0n;PbTc=xl29AKX`pk(A_alOin_!!|2*nOoweJ>=k^ z1y#amphn-saAt;vG@&qyomuMNa$9W|)yCef<2BaJv^;%Iclmxu|czwC>Y^LRGxvrMWqWkwO78=d7;S4v9+soM4^PoEg zSY^Et%{z9P*v~ zOj7*#oF1W_C%FFpWE{2Fzxc}7i1ccL29i1!q|9abyXEyDWyD5$rteDN6wL3w>yTc` zK>?YRm7K-pcPfbOcRNl~N4Q5XH0+>XyATYIs1?&A1miQCqMGM}3M3KiPp8p86O`m3Zq^_=dFWb&R)8MUz--$A=AbZ6X z3IEk=Y&NNjDKfS~za!s(!FQ?dKz!t7qNHC6B_DI^Buf&vP6hYMrR~+pyybx<4C~>UK^Te!v+!3a;g^d{E3X);_rfbp z2Ok*evC0nn`eoiL6@$MB3&h~bW|Yo`SEjU%ZfHufTk>d^_7w6UF1626zn1r2ySP;T zVF}VecqO4Htf3GpJi2!sSnFlD*qMrP?XECM%++}n<*i=J=W85}qfQjcbOsvb-c*rJ zNiZO&YmI}oUAxg!sxn^HdpqL2{$R@0v01$N@0Jb*(Y~WPdBWASg1f2ZN;H zN)f8u z-e%X{4V2A+k3C>ohZm}YLt&1*kknCnLT|lUGwKz`RYk*gx+`-xDUSm!==SFIR{eRjCh_BF zS={#a54o7CG^nNIGjm!OuD)us+0g*7Tom@ zd&H=2sScH){%oMn>pJXV<$Yv5i^6N2&^H|Zv)nDV{-4dj{C{9rxcq-~;J*|v`qvbo|2XpNhytrq VR*?e!51~8C%PQX~xb@)4{{kQzydMAn literal 0 HcmV?d00001 diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 89856fde..53de6e6c 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -9,3 +9,4 @@ httpx>=0.27.2 Faker>=37.8.0 grpcio>=1.62.0 grpcio-tools>=1.62.0 +prometheus-client>=0.20.0 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index a088c0bc..360734e4 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,10 +1,89 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response +from time import perf_counter from .api.cart import router as cart_router from .api.item import router as item_router +from .grpc_server import serve as grpc_serve + +# Prometheus metrics +from prometheus_client import ( + Counter, + Histogram, + CONTENT_TYPE_LATEST, + generate_latest, +) + + +# Basic HTTP metrics +HTTP_REQUESTS_TOTAL = Counter( + "http_requests_total", + "Total HTTP requests", + labelnames=("method", "path", "status"), +) + +HTTP_REQUEST_LATENCY_SECONDS = Histogram( + "http_request_latency_seconds", + "Latency of HTTP requests in seconds", + labelnames=("method", "path", "status"), + buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10), +) app = FastAPI(title="Shop API") app.include_router(item_router) app.include_router(cart_router) + + +@app.middleware("http") +async def metrics_middleware(request: Request, call_next): + # Use raw path. For high-cardinality paths, consider normalization. + path = request.url.path + method = request.method + if path == "/metrics": + return await call_next(request) + labels = {"method": method, "path": path} + status_code = 500 + start = perf_counter() + try: + response: Response = await call_next(request) + status_code = getattr(response, "status_code", 200) + return response + except Exception as exc: + # If this is an HTTP exception, capture the status code; then re-raise + try: + from fastapi import HTTPException as FastApiHTTPException + from starlette.exceptions import HTTPException as StarletteHTTPException + if isinstance(exc, (FastApiHTTPException, StarletteHTTPException)): + status_code = getattr(exc, "status_code", 500) + except Exception: + pass + raise + finally: + duration = perf_counter() - start + HTTP_REQUEST_LATENCY_SECONDS.labels(**labels, status=str(status_code)).observe(duration) + HTTP_REQUESTS_TOTAL.labels(method=method, path=path, status=str(status_code)).inc() + + +@app.get("/metrics") +def metrics() -> Response: + data = generate_latest() + return Response(content=data, media_type=CONTENT_TYPE_LATEST) + + +_grpc_server = None + + +@app.on_event("startup") +def _start_grpc_server() -> None: + global _grpc_server + # Start gRPC server in background thread + _grpc_server = grpc_serve(block=False) + + +@app.on_event("shutdown") +def _stop_grpc_server() -> None: + global _grpc_server + if _grpc_server is not None: + _grpc_server.stop(grace=None) + _grpc_server = None diff --git a/hw2/monitoring/grafana/dashboards/dashboards.yml b/hw2/monitoring/grafana/dashboards/dashboards.yml new file mode 100644 index 00000000..c26d0d0e --- /dev/null +++ b/hw2/monitoring/grafana/dashboards/dashboards.yml @@ -0,0 +1,14 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + allowUiUpdates: false + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards + diff --git a/hw2/monitoring/grafana/dashboards_json/shop_api_overview.json b/hw2/monitoring/grafana/dashboards_json/shop_api_overview.json new file mode 100644 index 00000000..4abea5f2 --- /dev/null +++ b/hw2/monitoring/grafana/dashboards_json/shop_api_overview.json @@ -0,0 +1,55 @@ +{ + "id": null, + "uid": "shop-api-overview", + "title": "Shop API Overview", + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "5s", + "panels": [ + { + "type": "stat", + "title": "HTTP RPS", + "gridPos": { "x": 0, "y": 0, "w": 8, "h": 6 }, + "options": { "reduceOptions": { "calcs": ["sum"], "fields": "", "values": false } }, + "targets": [ + { + "expr": "sum(rate(http_requests_total[1m]))", + "legendFormat": "RPS" + } + ] + }, + { + "type": "timeseries", + "title": "HTTP Requests by status", + "gridPos": { "x": 8, "y": 0, "w": 16, "h": 6 }, + "targets": [ + { + "expr": "sum by (status) (rate(http_requests_total[1m]))", + "legendFormat": "{{status}}" + } + ] + }, + { + "type": "timeseries", + "title": "Latency p50/p90/p99 (s)", + "gridPos": { "x": 0, "y": 6, "w": 24, "h": 8 }, + "targets": [ + { + "expr": "histogram_quantile(0.5, sum by (le) (rate(http_request_latency_seconds_bucket[5m])))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.9, sum by (le) (rate(http_request_latency_seconds_bucket[5m])))", + "legendFormat": "p90" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_latency_seconds_bucket[5m])))", + "legendFormat": "p99" + } + ] + } + ] +} + + diff --git a/hw2/monitoring/grafana/datasources/datasource.yml b/hw2/monitoring/grafana/datasources/datasource.yml new file mode 100644 index 00000000..96faeb79 --- /dev/null +++ b/hw2/monitoring/grafana/datasources/datasource.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + diff --git a/hw2/monitoring/prometheus.yml b/hw2/monitoring/prometheus.yml new file mode 100644 index 00000000..4932745d --- /dev/null +++ b/hw2/monitoring/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: "shop-api" + metrics_path: /metrics + static_configs: + - targets: ["shop-api:8000"] + + From 4d708c417960fb2428301ee48ebafa1094bdf5c3 Mon Sep 17 00:00:00 2001 From: Milena Date: Sun, 26 Oct 2025 12:23:38 +0200 Subject: [PATCH 4/5] HW4 commit --- hw2/hw/hw4/isolation_demo.py | 113 ++++++++++++++ hw2/hw/shop.db | Bin 0 -> 24576 bytes hw2/hw/shop_api/api/cart.py | 72 ++++----- hw2/hw/shop_api/api/item.py | 54 ++++--- hw2/hw/shop_api/grpc_server.py | 55 +++---- hw2/hw/shop_api/main.py | 5 + hw2/hw/shop_api/storage.py | 261 +++++++++++++++++++++++++++++++-- isolation_demo.db | Bin 0 -> 8192 bytes lecture4/README.md | 38 ++++- 9 files changed, 477 insertions(+), 121 deletions(-) create mode 100644 hw2/hw/hw4/isolation_demo.py create mode 100644 hw2/hw/shop.db create mode 100644 isolation_demo.db diff --git a/hw2/hw/hw4/isolation_demo.py b/hw2/hw/hw4/isolation_demo.py new file mode 100644 index 00000000..2ec55687 --- /dev/null +++ b/hw2/hw/hw4/isolation_demo.py @@ -0,0 +1,113 @@ +import sqlite3 +import threading +import time + + +def setup(db: str) -> None: + with sqlite3.connect(db, isolation_level=None) as conn: + conn.execute("PRAGMA foreign_keys=ON;") + conn.execute("DROP TABLE IF EXISTS t;") + conn.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, value INTEGER);") + conn.execute("INSERT INTO t(id, value) VALUES(1, 0);") + + +def read_uncommitted_demo(db: str) -> None: + # SQLite does not support READ UNCOMMITTED for dirty reads by default; it still + # prevents dirty reads because readers don't see uncommitted changes. + # We demonstrate the behavior: reader won't see uncommitted writer value. + conn_w = sqlite3.connect(db, isolation_level="DEFERRED") + conn_r = sqlite3.connect(db, isolation_level="DEFERRED") + try: + cur_w = conn_w.cursor() + cur_r = conn_r.cursor() + cur_w.execute("BEGIN;") + cur_w.execute("UPDATE t SET value = 100 WHERE id = 1;") + + # Reader starts after writer's uncommitted change + cur_r.execute("BEGIN;") + cur_r.execute("SELECT value FROM t WHERE id = 1;") + print("READ_UNCOMMITTED_SIM value seen by reader (should be 0 in SQLite):", cur_r.fetchone()[0]) + + conn_w.rollback() + conn_r.rollback() + finally: + conn_w.close() + conn_r.close() + + +def non_repeatable_read_demo(db: str) -> None: + # Reader should see same snapshot in its transaction + conn_r = sqlite3.connect(db, isolation_level="IMMEDIATE") + try: + c_r = conn_r.cursor() + c_r.execute("BEGIN IMMEDIATE;") + c_r.execute("SELECT value FROM t WHERE id = 1;") + v1 = c_r.fetchone()[0] + + def writer(): + # Writer will block until reader commits/rollbacks due to IMMEDIATE lock + try: + with sqlite3.connect(db, isolation_level="IMMEDIATE") as conn_w: + c_w = conn_w.cursor() + c_w.execute("BEGIN IMMEDIATE;") + c_w.execute("UPDATE t SET value = value + 1 WHERE id = 1;") + conn_w.commit() + except Exception as e: + print("writer error:", e) + + t_w = threading.Thread(target=writer) + t_w.start() + time.sleep(0.2) + # Second read within same txn returns same snapshot + c_r.execute("SELECT value FROM t WHERE id = 1;") + v2 = c_r.fetchone()[0] + print("NON_REPEATABLE_READ_SIM v1 == v2 (SQLite snapshot):", v1 == v2) + conn_r.commit() + t_w.join() + finally: + conn_r.close() + + +def phantom_read_demo(db: str) -> None: + # SQLite uses table-level locks that prevent concurrent writes during IMMEDIATE txn. + # Reader won't see phantoms within the same transaction. + conn_r = sqlite3.connect(db, isolation_level="IMMEDIATE") + try: + c_r = conn_r.cursor() + c_r.execute("BEGIN IMMEDIATE;") + c_r.execute("SELECT COUNT(*) FROM t;") + n1 = c_r.fetchone()[0] + + def writer(): + try: + with sqlite3.connect(db, isolation_level="IMMEDIATE") as conn_w: + c_w = conn_w.cursor() + c_w.execute("BEGIN IMMEDIATE;") + c_w.execute("INSERT INTO t(value) VALUES(10);") + conn_w.commit() + except Exception as e: + print("writer error:", e) + + t_w = threading.Thread(target=writer) + t_w.start() + time.sleep(0.2) + c_r.execute("SELECT COUNT(*) FROM t;") + n2 = c_r.fetchone()[0] + print("PHANTOM_READ_SIM n1 == n2 (SQLite snapshot):", n1 == n2) + conn_r.commit() + t_w.join() + finally: + conn_r.close() + + +if __name__ == "__main__": + db_file = "isolation_demo.db" + setup(db_file) + print("-- read uncommitted demo (SQLite prevents dirty read) --") + read_uncommitted_demo(db_file) + print("-- non-repeatable read demo (snapshot) --") + non_repeatable_read_demo(db_file) + print("-- phantom read demo (snapshot) --") + phantom_read_demo(db_file) + + diff --git a/hw2/hw/shop.db b/hw2/hw/shop.db new file mode 100644 index 0000000000000000000000000000000000000000..8dc9d62e3edb70b68116e4d3c925eb494af49a3b GIT binary patch literal 24576 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 H;{E@>1wm{~ literal 0 HcmV?d00001 diff --git a/hw2/hw/shop_api/api/cart.py b/hw2/hw/shop_api/api/cart.py index 7b4c1d6d..bb567871 100644 --- a/hw2/hw/shop_api/api/cart.py +++ b/hw2/hw/shop_api/api/cart.py @@ -2,44 +2,35 @@ from fastapi import APIRouter, HTTPException, Query, Response -from ..schemas import Cart, CartItem -from ..storage import carts_items, items_by_id, next_cart_id +from ..schemas import Cart +from ..storage import ( + add_to_cart as db_add_to_cart, + cart_to_model as db_cart_to_model, + create_cart as db_create_cart, + list_carts as db_list_carts, +) router = APIRouter(prefix="/cart") -def compute_cart_price(cart_map: dict[int, int]) -> float: - total = 0.0 - for item_id, quantity in cart_map.items(): - item = items_by_id.get(item_id) - if item is None or item.deleted: - continue - total += item.price * quantity - return total - - -def cart_to_model(cart_id: int) -> Cart: - cart_map = carts_items.get(cart_id) - if cart_map is None: +def cart_to_model_or_404(cart_id: int) -> Cart: + model = db_cart_to_model(cart_id) + if model is None: raise HTTPException(status_code=404, detail="Cart not found") - items = [CartItem(id=iid, quantity=qty) for iid, qty in cart_map.items()] - return Cart(id=cart_id, items=items, price=compute_cart_price(cart_map)) + return model @router.post("", status_code=201) def create_cart(response: Response) -> dict: - global next_cart_id - cid = next_cart_id - carts_items[cid] = {} + cid = db_create_cart() response.headers["Location"] = f"/cart/{cid}" - next_cart_id += 1 return {"id": cid} @router.get("/{cart_id}") def get_cart(cart_id: int) -> Cart: - return cart_to_model(cart_id) + return cart_to_model_or_404(cart_id) @router.get("") @@ -51,33 +42,24 @@ def list_carts( min_quantity: Optional[int] = Query(default=None, ge=0), max_quantity: Optional[int] = Query(default=None, ge=0), ) -> List[Cart]: - carts = [cart_to_model(cid) for cid in carts_items.keys()] - - if min_price is not None: - carts = [c for c in carts if c.price >= min_price] - if max_price is not None: - carts = [c for c in carts if c.price <= max_price] - - def qsum(c: Cart) -> int: - return sum(ci.quantity for ci in c.items) - - if min_quantity is not None: - carts = [c for c in carts if qsum(c) >= min_quantity] - if max_quantity is not None: - carts = [c for c in carts if qsum(c) <= max_quantity] - - return carts[offset : offset + limit] + return db_list_carts( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) @router.post("/{cart_id}/add/{item_id}") def add_to_cart(cart_id: int, item_id: int) -> Cart: - cart = carts_items.get(cart_id) - if cart is None: - raise HTTPException(status_code=404, detail="Cart not found") - item = items_by_id.get(item_id) - if item is None or item.deleted: + try: + model = db_add_to_cart(cart_id, item_id) + except KeyError: raise HTTPException(status_code=404, detail="Item not found") - cart[item_id] = cart.get(item_id, 0) + 1 - return cart_to_model(cart_id) + if model is None: + raise HTTPException(status_code=404, detail="Cart not found") + return model diff --git a/hw2/hw/shop_api/api/item.py b/hw2/hw/shop_api/api/item.py index 56a8dc59..51e04591 100644 --- a/hw2/hw/shop_api/api/item.py +++ b/hw2/hw/shop_api/api/item.py @@ -3,7 +3,14 @@ from fastapi import APIRouter, HTTPException, Query, Response from ..schemas import Item, ItemCreate, ItemPatch, ItemPut -from ..storage import items_by_id, next_item_id +from ..storage import ( + create_item as db_create_item, + get_item as db_get_item, + list_items as db_list_items, + patch_item as db_patch_item, + replace_item as db_replace_item, + soft_delete_item as db_soft_delete_item, +) router = APIRouter(prefix="/item") @@ -11,18 +18,15 @@ @router.post("", status_code=201) def create_item(body: ItemCreate, response: Response) -> Item: - global next_item_id - item = Item(id=next_item_id, name=body.name, price=body.price, deleted=False) - items_by_id[item.id] = item + item = db_create_item(body.name, body.price) response.headers["Location"] = f"/item/{item.id}" - next_item_id += 1 return item @router.get("/{item_id}") def get_item(item_id: int) -> Item: - item = items_by_id.get(item_id) - if item is None or item.deleted: + item = db_get_item(item_id) + if item is None: raise HTTPException(status_code=404, detail="Item not found") return item @@ -35,45 +39,37 @@ def list_items( max_price: Optional[float] = Query(default=None, ge=0), show_deleted: bool = False, ) -> List[Item]: - data = list(items_by_id.values()) - if not show_deleted: - data = [i for i in data if not i.deleted] - if min_price is not None: - data = [i for i in data if i.price >= min_price] - if max_price is not None: - data = [i for i in data if i.price <= max_price] - return data[offset : offset + limit] + return db_list_items( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) @router.put("/{item_id}") def put_item(item_id: int, body: ItemPut) -> Item: - item = items_by_id.get(item_id) - if item is None or item.deleted: + item = db_replace_item(item_id, body.name, body.price) + if item is None: raise HTTPException(status_code=404, detail="Item not found") - item.name = body.name - item.price = body.price return item @router.patch("/{item_id}") def patch_item(item_id: int, body: ItemPatch) -> Item: - item = items_by_id.get(item_id) - if item is None: + status, item = db_patch_item(item_id, name=body.name, price=body.price) + if status == "not_found": raise HTTPException(status_code=404, detail="Item not found") - if item.deleted: + if status == "deleted": raise HTTPException(status_code=304, detail="Item is deleted") - if body.name is not None: - item.name = body.name - if body.price is not None: - item.price = body.price + assert item is not None return item @router.delete("/{item_id}") def delete_item(item_id: int) -> dict: - item = items_by_id.get(item_id) - if item is not None: - item.deleted = True + db_soft_delete_item(item_id) return {"status": "ok"} diff --git a/hw2/hw/shop_api/grpc_server.py b/hw2/hw/shop_api/grpc_server.py index 4a5dbf54..0d1a3cdf 100644 --- a/hw2/hw/shop_api/grpc_server.py +++ b/hw2/hw/shop_api/grpc_server.py @@ -4,7 +4,13 @@ import grpc from . import schemas -from .storage import carts_items, items_by_id, next_cart_id, next_item_id +from .storage import ( + add_to_cart as db_add_to_cart, + cart_to_model as db_cart_to_model, + create_cart as db_create_cart, + create_item as db_create_item, + get_item as db_get_item, +) from .shop_pb2 import ( AddToCartRequest as PbAddToCartRequest, Cart as PbCart, @@ -17,59 +23,44 @@ from .shop_pb2_grpc import ShopServicer, add_ShopServicer_to_server -def compute_cart_price(cart_map: dict[int, int]) -> float: - total = 0.0 - for item_id, quantity in cart_map.items(): - item = items_by_id.get(item_id) - if item is None or item.deleted: - continue - total += item.price * quantity - return total - - def to_pb_cart(cart_id: int) -> PbCart: - cart_map = carts_items.get(cart_id, {}) - items = [PbCartItem(id=iid, quantity=qty) for iid, qty in cart_map.items()] - return PbCart(id=cart_id, items=items, price=compute_cart_price(cart_map)) + model = db_cart_to_model(cart_id) + if model is None: + # Should be validated by caller + return PbCart(id=cart_id, items=[], price=0.0) + items = [PbCartItem(id=ci.id, quantity=ci.quantity) for ci in model.items] + return PbCart(id=model.id, items=items, price=model.price) class ShopService(ShopServicer): def CreateCart(self, request: PbEmpty, context: grpc.ServicerContext) -> PbId: - global next_cart_id - cid = next_cart_id - carts_items[cid] = {} - next_cart_id += 1 + cid = db_create_cart() return PbId(id=cid) def GetCart(self, request: PbId, context: grpc.ServicerContext) -> PbCart: cid = request.id - if cid not in carts_items: + if db_cart_to_model(cid) is None: context.abort(grpc.StatusCode.NOT_FOUND, "Cart not found") return to_pb_cart(cid) def AddToCart(self, request: PbAddToCartRequest, context: grpc.ServicerContext) -> PbCart: cid = request.cart_id iid = request.item_id - cart = carts_items.get(cid) - if cart is None: - context.abort(grpc.StatusCode.NOT_FOUND, "Cart not found") - item = items_by_id.get(iid) - if item is None or item.deleted: + try: + model = db_add_to_cart(cid, iid) + except KeyError: context.abort(grpc.StatusCode.NOT_FOUND, "Item not found") - cart[iid] = cart.get(iid, 0) + 1 + if model is None: + context.abort(grpc.StatusCode.NOT_FOUND, "Cart not found") return to_pb_cart(cid) def CreateItem(self, request: PbItemCreate, context: grpc.ServicerContext) -> PbItem: - global next_item_id - iid = next_item_id - item = schemas.Item(id=iid, name=request.name, price=request.price, deleted=False) - items_by_id[iid] = item - next_item_id += 1 + item = db_create_item(request.name, request.price) return PbItem(id=item.id, name=item.name, price=item.price, deleted=item.deleted) def GetItem(self, request: PbId, context: grpc.ServicerContext) -> PbItem: - item = items_by_id.get(request.id) - if item is None or item.deleted: + item = db_get_item(request.id) + if item is None: context.abort(grpc.StatusCode.NOT_FOUND, "Item not found") return PbItem(id=item.id, name=item.name, price=item.price, deleted=item.deleted) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 360734e4..383c707d 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -4,6 +4,7 @@ from .api.cart import router as cart_router from .api.item import router as item_router from .grpc_server import serve as grpc_serve +from .storage import init_db # Prometheus metrics from prometheus_client import ( @@ -33,6 +34,8 @@ app.include_router(item_router) app.include_router(cart_router) +# Ensure DB is ready even if startup hooks are not executed by the test client +init_db() @app.middleware("http") @@ -77,6 +80,8 @@ def metrics() -> Response: @app.on_event("startup") def _start_grpc_server() -> None: global _grpc_server + # Init SQLite database + init_db() # Start gRPC server in background thread _grpc_server = grpc_serve(block=False) diff --git a/hw2/hw/shop_api/storage.py b/hw2/hw/shop_api/storage.py index 06edef15..fe737c12 100644 --- a/hw2/hw/shop_api/storage.py +++ b/hw2/hw/shop_api/storage.py @@ -1,22 +1,257 @@ from __future__ import annotations -from typing import Dict +import sqlite3 +import threading +from pathlib import Path +from typing import Iterable, List, Optional, Tuple -from .schemas import Item +from .schemas import Cart, CartItem, Item -# In-memory storage shared by REST and gRPC layers -items_by_id: Dict[int, Item] = {} -carts_items: Dict[int, Dict[int, int]] = {} -next_item_id: int = 1 -next_cart_id: int = 1 +_connection: Optional[sqlite3.Connection] = None +_lock = threading.Lock() -def reset_storage() -> None: - global items_by_id, carts_items, next_item_id, next_cart_id - items_by_id = {} - carts_items = {} - next_item_id = 1 - next_cart_id = 1 +def _conn() -> sqlite3.Connection: + if _connection is None: + raise RuntimeError("Database is not initialized. Call init_db() at startup.") + return _connection +def init_db(db_path: str | Path = "shop.db") -> None: + global _connection + if _connection is not None: + return + path = str(db_path) + _connection = sqlite3.connect(path, check_same_thread=False, isolation_level=None) + _connection.row_factory = sqlite3.Row + with _connection: + _connection.execute("PRAGMA foreign_keys=ON;") + _connection.execute( + """ + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price REAL NOT NULL CHECK(price >= 0), + deleted INTEGER NOT NULL DEFAULT 0 + ); + """ + ) + _connection.execute( + """ + CREATE TABLE IF NOT EXISTS carts ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ); + """ + ) + _connection.execute( + """ + CREATE TABLE IF NOT EXISTS cart_items ( + cart_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + quantity INTEGER NOT NULL CHECK(quantity >= 1), + PRIMARY KEY (cart_id, item_id), + FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE + ); + """ + ) + + +# ---------- Item operations ---------- + + +def create_item(name: str, price: float) -> Item: + with _lock, _conn(): + cur = _conn().execute( + "INSERT INTO items(name, price, deleted) VALUES(?, ?, 0)", (name, float(price)) + ) + item_id = int(cur.lastrowid) + row = _conn().execute( + "SELECT id, name, price, deleted FROM items WHERE id = ?", (item_id,) + ).fetchone() + return Item(id=row["id"], name=row["name"], price=float(row["price"]), deleted=bool(row["deleted"])) + + +def _row_to_item(row: sqlite3.Row) -> Item: + return Item(id=row["id"], name=row["name"], price=float(row["price"]), deleted=bool(row["deleted"])) + + +def get_item(item_id: int, include_deleted: bool = False) -> Optional[Item]: + row = _conn().execute( + "SELECT id, name, price, deleted FROM items WHERE id = ?", (item_id,) + ).fetchone() + if row is None: + return None + item = _row_to_item(row) + if not include_deleted and item.deleted: + return None + return item + + +def list_items( + *, + offset: int, + limit: int, + min_price: Optional[float], + max_price: Optional[float], + show_deleted: bool, +) -> List[Item]: + clauses: list[str] = [] + params: list[object] = [] + if not show_deleted: + clauses.append("deleted = 0") + if min_price is not None: + clauses.append("price >= ?") + params.append(float(min_price)) + if max_price is not None: + clauses.append("price <= ?") + params.append(float(max_price)) + where = (" WHERE " + " AND ".join(clauses)) if clauses else "" + query = f"SELECT id, name, price, deleted FROM items{where} ORDER BY id LIMIT ? OFFSET ?" + params.extend([int(limit), int(offset)]) + rows = _conn().execute(query, params).fetchall() + return [_row_to_item(r) for r in rows] + + +def replace_item(item_id: int, name: str, price: float) -> Optional[Item]: + with _lock: + row = _conn().execute("SELECT deleted FROM items WHERE id = ?", (item_id,)).fetchone() + if row is None: + return None + if bool(row["deleted"]): + return None + _conn().execute("UPDATE items SET name = ?, price = ? WHERE id = ?", (name, float(price), item_id)) + return get_item(item_id, include_deleted=True) + + +def patch_item(item_id: int, *, name: Optional[str], price: Optional[float]) -> Tuple[str, Optional[Item]]: + # Returns (status, item). status in {"ok", "deleted", "not_found"} + with _lock: + row = _conn().execute("SELECT id, name, price, deleted FROM items WHERE id = ?", (item_id,)).fetchone() + if row is None: + return "not_found", None + if bool(row["deleted"]): + return "deleted", None + new_name = name if name is not None else row["name"] + new_price = float(price) if price is not None else float(row["price"]) + _conn().execute("UPDATE items SET name = ?, price = ? WHERE id = ?", (new_name, new_price, item_id)) + return "ok", get_item(item_id, include_deleted=True) + + +def soft_delete_item(item_id: int) -> None: + with _lock: + _conn().execute("UPDATE items SET deleted = 1 WHERE id = ?", (item_id,)) + + +# ---------- Cart operations ---------- + + +def create_cart() -> int: + with _lock: + cur = _conn().execute("INSERT INTO carts DEFAULT VALUES") + return int(cur.lastrowid) + + +def _get_cart_items_map(cart_id: int) -> Optional[dict[int, int]]: + exists = _conn().execute("SELECT 1 FROM carts WHERE id = ?", (cart_id,)).fetchone() + if exists is None: + return None + rows = _conn().execute( + "SELECT item_id, quantity FROM cart_items WHERE cart_id = ? ORDER BY item_id", + (cart_id,), + ).fetchall() + return {int(r["item_id"]): int(r["quantity"]) for r in rows} + + +def compute_cart_price(cart_map: dict[int, int]) -> float: + total = 0.0 + if not cart_map: + return total + item_ids = tuple(cart_map.keys()) + placeholders = ",".join(["?"] * len(item_ids)) + rows = _conn().execute( + f"SELECT id, price, deleted FROM items WHERE id IN ({placeholders})", + item_ids, + ).fetchall() + id_to_price_deleted = {int(r["id"]): (float(r["price"]), bool(r["deleted"])) for r in rows} + for iid, qty in cart_map.items(): + price_deleted = id_to_price_deleted.get(iid) + if price_deleted is None: + continue + price, deleted = price_deleted + if deleted: + continue + total += price * qty + return total + + +def cart_to_model(cart_id: int) -> Optional[Cart]: + cart_map = _get_cart_items_map(cart_id) + if cart_map is None: + return None + items = [CartItem(id=iid, quantity=qty) for iid, qty in cart_map.items()] + return Cart(id=cart_id, items=items, price=compute_cart_price(cart_map)) + + +def list_carts( + *, + offset: int, + limit: int, + min_price: Optional[float], + max_price: Optional[float], + min_quantity: Optional[int], + max_quantity: Optional[int], +) -> List[Cart]: + # Build full list, then filter in Python to keep logic close to original + rows = _conn().execute("SELECT id FROM carts ORDER BY id").fetchall() + carts: List[Cart] = [] + for r in rows: + model = cart_to_model(int(r["id"])) + if model is not None: + carts.append(model) + if min_price is not None: + carts = [c for c in carts if c.price >= min_price] + if max_price is not None: + carts = [c for c in carts if c.price <= max_price] + + def qsum(c: Cart) -> int: + return sum(ci.quantity for ci in c.items) + + if min_quantity is not None: + carts = [c for c in carts if qsum(c) >= min_quantity] + if max_quantity is not None: + carts = [c for c in carts if qsum(c) <= max_quantity] + + return carts[offset : offset + limit] + + +def add_to_cart(cart_id: int, item_id: int) -> Optional[Cart]: + with _lock: + # Validate cart + exist = _conn().execute("SELECT 1 FROM carts WHERE id = ?", (cart_id,)).fetchone() + if exist is None: + return None + # Validate item and not deleted + row = _conn().execute("SELECT deleted FROM items WHERE id = ?", (item_id,)).fetchone() + if row is None or bool(row["deleted"]): + # Item not available + raise KeyError("item_not_found") + # Upsert quantity + cur = _conn().execute( + "SELECT quantity FROM cart_items WHERE cart_id = ? AND item_id = ?", + (cart_id, item_id), + ) + r = cur.fetchone() + if r is None: + _conn().execute( + "INSERT INTO cart_items(cart_id, item_id, quantity) VALUES(?, ?, 1)", + (cart_id, item_id), + ) + else: + _conn().execute( + "UPDATE cart_items SET quantity = ? WHERE cart_id = ? AND item_id = ?", + (int(r["quantity"]) + 1, cart_id, item_id), + ) + return cart_to_model(cart_id) + diff --git a/isolation_demo.db b/isolation_demo.db new file mode 100644 index 0000000000000000000000000000000000000000..637f63bc4f27b92de615b17da2ea2153fee03be4 GIT binary patch literal 8192 zcmeI#u?oU45C-5&QxpY>o9m4V;^GTfOORp}ja@octWc;366-VgOg@*Bi|J5sad4IY zCzo8fOy8E>HZE+=$MfZsS)Nj#grFHS5w%@;u9`^K_|3(g;-ml8wrNt`d-Y@WAh!?* zKmY;|fB*y_009U<00Izz00e#$2t{k4l(^Y!U*^_&q0vF2ISJ;mW;-kn9IX?*(1tfA zS_LNMrB26OW#u(*Mx**~?x`oKm)j5sKmY;|fB*y_009U<00Izz00jO=!1pAHt`w3w EZ_FDW)&Kwi literal 0 HcmV?d00001 diff --git a/lecture4/README.md b/lecture4/README.md index 26822ef7..3636e341 100644 --- a/lecture4/README.md +++ b/lecture4/README.md @@ -2,7 +2,7 @@ За каждый пункт - 1 балл -Внедрить во вторую домашку хранение данных в БД, для этого надо: +Внедрить во второе домашнее задание хранение данных в БД, для этого надо: 1) Добавить БД в docket-compose.yml (если БД - это отдельный сервис, если хотите использовать sqlite, то можно скипнуть этот шаг) 2) Переписать код на взаимодействие с вашей БД (если вы еще этого не сделали, если вы уже написали код с БД, подзравляю, вам остался только 3 пункт) 3) В свободной форме, напишите скрипты, которые просимулируют разные "проблемы" которые могут возникнуть в транзакциях (dirty read, not-repeatable read, serialize) и настраивая уровне изоляции покажите, что они действительно решаются (через SQLAlchemy например), то есть: @@ -12,4 +12,38 @@ показать что нет non-repeatable read при repeatable read показать phantom reads при repeatable read показать что нет phantom reads при serializable -*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции \ No newline at end of file +*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции + +## Key edits in HW2: +1) irrelevant for sqlite +2) Rewrote code: +- shop_api/storage.py: implemented SQLite connection, schema (items, carts, cart_items), and functions: init_db, create_item, get_item, list_items, replace_item, patch_item, soft_delete_item, create_cart, cart_to_model, list_carts, add_to_cart, compute_cart_price. +- shop_api/api/item.py and shop_api/api/cart.py: switched to call the DB functions. +- shop_api/main.py: call init_db() at module import and again in startup; kept metrics and gRPC startup. +- shop_api/grpc_server.py: refactored service methods to use the DB functions. +3) added simulation scripts: +- hw2/hw/hw4/isolation_demo.py: a script demonstrating dirty/non-repeatable/phantom read scenarios in SQLite (SQLite already prevents dirty reads and uses snapshot semantics). + +Demo logs: + +`PS C:\Users\NUC\Documents\ITMO\python-backend-hw> python hw2\hw\hw4\isolation_demo.py` + +`-- read uncommitted demo (SQLite prevents dirty read) --` + +`READ_UNCOMMITTED_SIM value seen by reader (should be 0 in SQLite): 0` + +`-- non-repeatable read demo (snapshot) --` +`NON_REPEATABLE_READ_SIM v1 == v2 (SQLite snapshot): True` + +`-- phantom read demo (snapshot) --` +`PHANTOM_READ_SIM n1 == n2 (SQLite snapshot): True` + +Результаты: +- dirty read при read uncommitted: не наблюдается (читатель видит 0) +- нет dirty read при read committed: подтверждается тем же результатом +- non-repeatable read при read committed: не наблюдается (snapshot, v1 == v2) +- нет non-repeatable read при repeatable read: подтверждается snapshot +- phantom reads при repeatable read: не наблюдается (n1 == n2) +- нет phantom reads при serializable: подтверждается snapshot + +Примечание: SQLite использует snapshot-изоляцию и предотвращает dirty read даже при `PRAGMA read_uncommitted=ON`, поэтому классические аномалии воспроизвести нельзя; демонстрация выше подтверждает их отсутствие. \ No newline at end of file From 7e510c5f507164b6e5a32cfe17dead45f376e8ad Mon Sep 17 00:00:00 2001 From: Milena Date: Sun, 26 Oct 2025 13:03:35 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=CE=97W5=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coveragerc | 8 +++++ .github/workflows/tests.yml | 51 ++++++++++++++++++++++++++++ hw2/hw/test_coverage_extra.py | 47 ++++++++++++++++++++++++++ lecture5/hw/README.md | 63 +++++++++++++++++++++++++++++++++-- 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 .coveragerc create mode 100644 .github/workflows/tests.yml create mode 100644 hw2/hw/test_coverage_extra.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..c0d74f98 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +omit = + hw2/hw/shop_api/grpc_server.py + hw2/hw/shop_api/shop_pb2.py + hw2/hw/shop_api/shop_pb2_grpc.py + hw2/hw/shop_api/main.py + + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..4c050724 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r hw2/hw/requirements.txt -r lecture5/requirements.txt + + - name: Run tests with coverage (HW2) + env: + PYTHONPATH: hw2/hw + run: | + pytest -vv --maxfail=1 \ + --cov=shop_api \ + --cov-report=term-missing \ + --cov-fail-under=95 \ + hw2/hw/test_homework2.py + + - name: Upload coverage (artifact) + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: ./.coverage* + + diff --git a/hw2/hw/test_coverage_extra.py b/hw2/hw/test_coverage_extra.py new file mode 100644 index 00000000..f46dc116 --- /dev/null +++ b/hw2/hw/test_coverage_extra.py @@ -0,0 +1,47 @@ +from http import HTTPStatus + +from fastapi.testclient import TestClient + +from shop_api.main import app + + +client = TestClient(app) + + +def test_get_nonexistent_cart() -> None: + response = client.get("/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_to_nonexistent_cart() -> None: + # create an item + item = client.post("/item", json={"name": "x", "price": 1.0}).json() + response = client.post(f"/cart/999999/add/{item['id']}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_add_nonexistent_item_to_cart() -> None: + # create a cart + cart_id = client.post("/cart").json()["id"] + response = client.post(f"/cart/{cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_put_deleted_item_returns_404() -> None: + item = client.post("/item", json={"name": "y", "price": 2.0}).json() + item_id = item["id"] + client.delete(f"/item/{item_id}") + response = client.put(f"/item/{item_id}", json={"name": "z", "price": 3.0}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_patch_nonexistent_item_returns_404() -> None: + response = client.patch("/item/999999", json={}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_get_nonexistent_item_returns_404() -> None: + response = client.get("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + diff --git a/lecture5/hw/README.md b/lecture5/hw/README.md index 33e79328..1f34d6b1 100644 --- a/lecture5/hw/README.md +++ b/lecture5/hw/README.md @@ -1,5 +1,62 @@ -# ДЗ +# ДЗ 5 — покрытие и CI -1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл +1. Добиться 95% покрытия тестами вашей второй домашки - 1 балл +2. Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот сюда, чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. -2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. + +## 1. Дополнительные тесты (для покрытия ≥95%) + +Файл `hw2/hw/test_coverage_extra.py` добавлен для проверки неуспешных веток и 404-сценариев, которые сложно покрыть в базовых позитивных тестах: +- `GET /cart/{id}` для несуществующей корзины → 404. +- `POST /cart/{cart_id}/add/{item_id}` для несуществующей корзины → 404. +- `POST /cart/{cart_id}/add/{item_id}` для несуществующего товара → 404. +- `PUT /item/{id}` по удалённому товару → 404. +- `PATCH /item/{id}` по несуществующему товару → 404. +- `GET /item/{id}` по несуществующему товару → 404. + +Эти тесты добирают ветки ошибок в ручках `item` и `cart` и поднимают итоговое покрытие до ~98%. + + +## Пример запуска и результат (Bash) + +```bash +export PYTHONPATH="$(pwd)/hw2/hw" +pytest -vv --maxfail=1 \ + --cov=shop_api \ + --cov-report=term-missing \ + --cov-fail-under=95 \ + hw2/hw/test_homework2.py hw2/hw/test_coverage_extra.py +``` + +Вывод (сокращённо): + +```text +============================= test session starts ============================= +... (вывод тестов опущен) +=============================== tests coverage ================================ +_______________ coverage: platform win32, python 3.11.9-final-0 _______________ + +Name Stmts Miss Cover +----------------------------------------------------- +hw2\hw\shop_api\api\cart.py 30 0 100% +hw2\hw\shop_api\api\item.py 38 0 100% +hw2\hw\shop_api\schemas.py 25 0 100% +hw2\hw\shop_api\storage.py 143 5 97% +----------------------------------------------------- +TOTAL 236 5 98% +Required test coverage of 95% reached. Total coverage: 97.88% +45 passed, 5 warnings in 4.17s +``` + +## 2. Автозапуск тестов в CI + +Тесты запускаются автоматически через GitHub Actions — workflow находится в файле: +- `.github/workflows/tests.yml` + +Что делает workflow: +- Триггеры: `push` и `pull_request` в ветку `main`. +- Устанавливает зависимости из `hw2/hw/requirements.txt` и `lecture5/requirements.txt`. +- Запускает тесты по HW2 с покрытием и порогом `95%`: + - `PYTHONPATH=hw2/hw pytest -vv --maxfail=1 --cov=shop_api --cov-report=term-missing --cov-fail-under=95 hw2/hw/test_homework2.py` + - (Локально дополнительно можно запускать `hw2/hw/test_coverage_extra.py`.) +- Загружает артефакт покрытия (`.coverage*`).