diff --git a/hw1/app.py b/hw1/app.py index 6107b870..1410a582 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,163 @@ from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs +import json +from http import HTTPStatus + + +class NegativeParameterError(Exception): + pass + + +def b_to_queries(scope: dict[str, Any]) -> dict[str, str]: + raw = scope["query_string"].decode() + return {k: v[0] for k, v in parse_qs(raw).items()} + + +def parse_numbers(raw: str | None) -> list[float]: + if not raw: + raise ValueError("missing 'numbers' param") + try: + return [float(x) for x in raw.split(",")] + except ValueError: + raise ValueError("all values must be numbers") + + +async def _send_json( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: int, + payload: dict[str, Any], +): + body = json.dumps(payload).encode() + await send( + { + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", b"application/json"]], + } + ) + await send({"type": "http.response.body", "body": body}) + + +async def factorial_handler( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + params = b_to_queries(scope) + raw_n = params.get("n") + + if raw_n is None: + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "missing 'n' param"} + ) + try: + n = int(raw_n) + result = factorial(n) + except ValueError: + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "param must be integer"} + ) + except NegativeParameterError as e: + return await _send_json(send, HTTPStatus.BAD_REQUEST, {"error": str(e)}) + + return await _send_json(send, HTTPStatus.OK, {"result": result}) + + +def factorial(n: int) -> int: + if n < 0: + raise NegativeParameterError("n must be >= 0") + res = 1 + for i in range(2, n + 1): + res *= i + return res + + +async def fibonacci_handler( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + parts = scope["path"].strip("/").split("/") + raw_n = parts[1] if len(parts) > 1 else None + if raw_n is None: + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "missing param in path"} + ) + + try: + n = int(raw_n) + result = fibonacci(n) + except NegativeParameterError: + return await _send_json( + send, HTTPStatus.BAD_REQUEST, {"error": "param must be integer"} + ) + except ValueError as e: + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": str(e)} + ) + + return await _send_json(send, HTTPStatus.OK, {"result": result}) + + +def fibonacci(n: int) -> int: + if n < 0: + raise NegativeParameterError("n must be >= 0") + if n in (0, 1): + return n + a, b = 0, 1 + for _ in range(2, n): + a, b = b, a + b + return b + + +async def mean_handler( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + event = await receive() + raw_body: bytes = event.get("body", b"") + + if not raw_body: + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "missing body"} + ) + + try: + data = json.loads(raw_body.decode()) + except json.JSONDecodeError: + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "invalid JSON"} + ) + + if not isinstance(data, list): + return await _send_json( + send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "body must be list"} + ) + + if not data: + return await _send_json(send, HTTPStatus.BAD_REQUEST, {"error": "empty list"}) + + if not all(isinstance(x, (int, float)) for x in data): + return await _send_json( + send, + HTTPStatus.UNPROCESSABLE_ENTITY, + {"error": "list must contain only numbers"}, + ) + + result = mean([float(x) for x in data]) + return await _send_json(send, HTTPStatus.OK, {"result": result}) + + +def mean(lst: list[float]) -> float: + return sum(lst) / len(lst) + + +routes: dict[str, Callable] = { + "factorial": factorial_handler, + "fibonacci": fibonacci_handler, + "mean": mean_handler, +} async def application( @@ -12,8 +171,35 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + if scope["type"] == "http": + + path = scope["path"].strip("/").split("/") + route = path[0] if path else "" + + handler: Callable | None = routes.get(route) + if handler is None: + await send( + { + "type": "http.response.start", + "status": 404, + "headers": [[b"content-type", b"text/plain"]], + } + ) + await send({"type": "http.response.body", "body": b"Not found"}) + return + + await handler(scope, receive, send) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/.dockerignore b/hw2/hw/.dockerignore new file mode 100644 index 00000000..852216e6 --- /dev/null +++ b/hw2/hw/.dockerignore @@ -0,0 +1,134 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +venv/ +.vscode/ +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# macOS +.DS_Store diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..937bab83 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +WORKDIR /usr/src/app +COPY requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +FROM base as dev + +ENTRYPOINT ["uvicorn", "shop_api.main:app", "--port", "8000", "--host", "0.0.0.0"] diff --git a/hw2/hw/artifacts/grafana_ab_after.png b/hw2/hw/artifacts/grafana_ab_after.png new file mode 100644 index 00000000..13f34a42 Binary files /dev/null and b/hw2/hw/artifacts/grafana_ab_after.png differ diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..3709fc59 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,47 @@ +services: + + fastapi-app: + hostname: fastapi-app + build: + context: . + dockerfile: ./Dockerfile + target: dev + restart: unless-stopped + ports: + - 8000:8000 + networks: + - python-backend-network + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - ./settings/grafana/provisioning/dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro + - ./settings/grafana/provisioning/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yaml:ro + - ./settings/grafana/dashboards:/var/lib/grafana/dashboards:ro + restart: unless-stopped + networks: + - python-backend-network + + prometheus: + image: prom/prometheus:latest + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + networks: + - python-backend-network + restart: unless-stopped + +networks: + python-backend-network: + driver: bridge diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..3b8f68b2 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,7 +1,7 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 -uvicorn>=0.24.0 - +uvicorn[standard] +prometheus_fastapi_instrumentator # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/hw2/hw/settings/grafana/dashboards/dashboard.yml b/hw2/hw/settings/grafana/dashboards/dashboard.yml new file mode 100644 index 00000000..528c704c --- /dev/null +++ b/hw2/hw/settings/grafana/dashboards/dashboard.yml @@ -0,0 +1,7 @@ +apiVersion: 1 + +providers: + - name: 'Default' + folder: 'Services' + options: + path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git a/hw2/hw/settings/grafana/dashboards/fastapi-dashboard.json b/hw2/hw/settings/grafana/dashboards/fastapi-dashboard.json new file mode 100644 index 00000000..6fd70144 --- /dev/null +++ b/hw2/hw/settings/grafana/dashboards/fastapi-dashboard.json @@ -0,0 +1,1008 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "panel", + "id": "bargauge", + "name": "Bar gauge", + "version": "" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "11.1.3" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "gnetId": 16110, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(http_requests_total[40m]))", + "instant": true, + "interval": "", + "legendFormat": "", + "range": true, + "refId": "A" + } + ], + "timeFrom": "24h", + "title": "Total Requests", + "transformations": [ + { + "id": "seriesToRows", + "options": {} + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "Time" + } + ] + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 4, + "y": 0 + }, + "id": 16, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": true + }, + "pluginVersion": "11.1.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "http_requests_total", + "instant": true, + "interval": "", + "legendFormat": "{{method}} {{path}}", + "range": true, + "refId": "A" + } + ], + "timeFrom": "24h", + "title": "Requests Count", + "transformations": [ + { + "id": "seriesToRows", + "options": {} + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "Time" + } + ] + } + }, + { + "id": "partitionByValues", + "options": { + "fields": [ + "Metric" + ] + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-GrYlRd" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 6, + "options": { + "displayMode": "lcd", + "maxVizHeight": 300, + "minVizHeight": 10, + "minVizWidth": 0, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.1.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(http_request_duration_seconds_sum[1m])) / sum(rate(http_request_duration_seconds_count[60m]))", + "interval": "", + "legendFormat": "{{method}} {{path}}", + "range": true, + "refId": "A" + } + ], + "title": "Requests Average Duration", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 4, + "x": 0, + "y": 6 + }, + "id": 22, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.1.3", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(http_requests_total{status=\"404\"}[50m]))", + "interval": "", + "legendFormat": "", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(http_requests_total{status=\"4xx\"}[1h])", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "timeFrom": "24h", + "title": "Total Exceptions", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 1, + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0.8 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 4, + "y": 6 + }, + "id": 18, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(http_requests_total{status=\"200\"}[60m])) / sum(rate(http_requests_total[60m]))", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "rate(http_requests_total{status=\"2xx\"}[1h])", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Percent of 2xx Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "axisSoftMax": 1, + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.1 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 14, + "y": 6 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum(rate(http_requests_total{status_codes=\"404\"}[60m])) / sum(rate(http_requests_total[60m]))", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "http_requests_total{status=\"4xx\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Percent of 4xx Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 13 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[60m])) by (le))", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + } + ], + "title": "PR 99 Requests Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 13 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "http_requests_total{app_name=\"$app_name\", path!=\"/metrics\"}", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "http_requests_created", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Request In Process", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 13 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "exemplar": true, + "expr": "rate(http_requests_total[60m])", + "interval": "", + "legendFormat": "{{path}}", + "range": true, + "refId": "A" + } + ], + "title": "Request Per Sec", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "definition": "label_values(fastapi_app_info{}, app_name)", + "hide": 0, + "includeAll": false, + "label": "Application Name", + "multi": false, + "name": "app_name", + "options": [], + "query": { + "query": "label_values(fastapi_app_info{}, app_name)", + "refId": "StandardVariableQuery" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" + }, + { + "current": { + "selected": false, + "text": "", + "value": "" + }, + "description": "query with keyword", + "hide": 0, + "label": "Log Query", + "name": "log_keyword", + "options": [ + { + "selected": true, + "text": "", + "value": "" + } + ], + "query": "", + "skipUrlSync": false, + "type": "textbox" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "FastAPI Observability", + "uid": "fastapi-observability", + "version": 2, + "weekStart": "" +} \ No newline at end of file diff --git a/hw2/hw/settings/grafana/provisioning/dashboards.yml b/hw2/hw/settings/grafana/provisioning/dashboards.yml new file mode 100644 index 00000000..07462e3d --- /dev/null +++ b/hw2/hw/settings/grafana/provisioning/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 +providers: + - name: "Prebuilt" + orgId: 1 + folder: "" + type: file + disableDeletion: false + allowUiUpdates: true + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/hw2/hw/settings/grafana/provisioning/datasource.yml b/hw2/hw/settings/grafana/provisioning/datasource.yml new file mode 100644 index 00000000..e8795836 --- /dev/null +++ b/hw2/hw/settings/grafana/provisioning/datasource.yml @@ -0,0 +1,9 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + isDefault: true + editable: false + url: http://prometheus:9090 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..508ee30c --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,13 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: ["prometheus:9090"] + + - job_name: "fastapi" + static_configs: + - targets: ["fastapi-app:8000"] + diff --git a/hw2/hw/shop_api/chat/chat.py b/hw2/hw/shop_api/chat/chat.py new file mode 100644 index 00000000..5c580ed5 --- /dev/null +++ b/hw2/hw/shop_api/chat/chat.py @@ -0,0 +1,57 @@ +from faker import Faker +from fastapi import WebSocket, status +from typing import Dict, Set +from collections import defaultdict +import asyncio + + +def _random_name(): + return Faker().name() + + +class ChatManager: + def __init__(self) -> None: + self._rooms: Dict[str, Set[WebSocket]] = defaultdict(set) + self._usernames: Dict[WebSocket, str] = {} + self._rooms_lock = asyncio.Lock() + self._room_locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + + async def connect(self, room: str, ws: WebSocket) -> str: + await ws.accept() + username = _random_name() + async with self._rooms_lock: + self._rooms[room].add(ws) + self._usernames[ws] = username + return username + + async def disconnect(self, room: str, ws: WebSocket) -> None: + async with self._rooms_lock: + if room in self._rooms: + self._rooms[room].discard(ws) + if not self._rooms[room]: + del self._rooms[room] + self._usernames.pop(ws, None) + + async def broadcast( + self, room: str, payload: dict, exclude: WebSocket | None = None + ) -> None: + async with self._rooms_lock: + targets = list(self._rooms.get(room, ())) + if not targets: + return + send_tasks = [] + for ws in targets: + if exclude is not None and ws is exclude: + continue + send_tasks.append(self._safe_send(ws, payload, room)) + if send_tasks: + await asyncio.gather(*send_tasks, return_exceptions=True) + + async def _safe_send(self, ws: WebSocket, payload: dict, room: str) -> None: + try: + await ws.send_json(payload) + except Exception: + try: + await ws.close(code=status.WS_1011_INTERNAL_ERROR) + finally: + await self.disconnect(room, ws) diff --git a/hw2/hw/shop_api/handlers/__init__.py b/hw2/hw/shop_api/handlers/__init__.py new file mode 100644 index 00000000..4af10e49 --- /dev/null +++ b/hw2/hw/shop_api/handlers/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from .cart import router as cart_router +from .item import router as item_router +from .chat import router as chat_router + +router = APIRouter() + +router.include_router(item_router) +router.include_router(cart_router) +router.include_router(chat_router) diff --git a/hw2/hw/shop_api/handlers/cart.py b/hw2/hw/shop_api/handlers/cart.py new file mode 100644 index 00000000..7b3b8750 --- /dev/null +++ b/hw2/hw/shop_api/handlers/cart.py @@ -0,0 +1,106 @@ +from http import HTTPStatus +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse + +from shop_api.models.cart import CartLineOut, CartOut +from shop_api.models.item import ItemRecord +from shop_api.storage.in_mem import get_store + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("") +async def create_cart(deps=Depends(get_store)): + items, carts, lock = deps + async with lock: + cart_id = len(carts) + 1 + carts[cart_id] = {} + + content = {"id": cart_id} + headers = {"Location": f"/cart/{cart_id}"} + return JSONResponse( + content=content, headers=headers, status_code=HTTPStatus.CREATED + ) + + +@router.get("/{cart_id}", response_model=CartOut) +async def get_cart(cart_id: int, deps=Depends(get_store)): + items, carts, lock = deps + async with lock: + cart = carts.get(cart_id) + if cart is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="cart not found" + ) + + return build_cart_response(cart_id, cart, items) + + +@router.get("", response_model=List[CartOut]) +async def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + min_quantity: int | None = Query(None, ge=0), + max_quantity: int | None = Query(None, ge=0), + deps=Depends(get_store), +): + items, carts, lock = deps + async with lock: + outs: list[CartOut] = [] + + for cid, cmap in carts.items(): + resp = build_cart_response(cid, cmap, items) + if min_price is not None and resp.price < min_price: + continue + if max_price is not None and resp.price > max_price: + continue + if min_quantity is not None and resp.quantity < min_quantity: + continue + if max_quantity is not None and resp.quantity > max_quantity: + continue + outs.append(resp) + return outs[offset : offset + limit] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int, deps=Depends(get_store)): + + items, carts, lock = deps + async with lock: + cart = carts.get(cart_id) + if cart is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "cart not found") + rec: None | ItemRecord = items.get(item_id) + if not rec: + raise HTTPException(HTTPStatus.NOT_FOUND, "item not found") + if rec.deleted: + raise HTTPException(HTTPStatus.BAD_REQUEST, "item not available") + + cart[item_id] = cart.get(item_id, 0) + 1 + return build_cart_response(cart_id, cart, items) + + +def build_cart_response( + cart_id: int, cart: dict[int, int], items: dict[int, ItemRecord] +) -> CartOut: + lines: list[CartLineOut] = [] + total_price = 0.0 + total_quantity = 0 + for item_id, quantity in cart.items(): + rec = items.get(item_id) + if not rec or rec.deleted: + continue + line_total = rec.price * quantity + lines.append( + CartLineOut( + id=item_id, + quantity=quantity, + ) + ) + total_price += line_total + total_quantity += quantity + return CartOut(id=cart_id, items=lines, price=total_price, quantity=total_quantity) diff --git a/hw2/hw/shop_api/handlers/chat.py b/hw2/hw/shop_api/handlers/chat.py new file mode 100644 index 00000000..d7d0a132 --- /dev/null +++ b/hw2/hw/shop_api/handlers/chat.py @@ -0,0 +1,63 @@ +from __future__ import annotations +import asyncio +from typing import Final + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status + +from shop_api.models.chat import ChatHello, ChatSystem, ChatMessage, ClientInbound +from shop_api.chat.chat import ChatManager + +router = APIRouter(tags=["chat"]) +manager: Final = ChatManager() + +PING_INTERVAL = 20 +IDLE_TIMEOUT = 60 +MAX_MESSAGE_BYTES = 4096 + + +@router.websocket("/chat/{room}") +async def chat_ws(ws: WebSocket, room: str): + username = await manager.connect(room, ws) + await ws.send_json(ChatHello(room=room, username=username).model_dump()) + await manager.broadcast( + room, ChatSystem(text=f"{username} joined").model_dump(), exclude=ws + ) + + stop = asyncio.Event() + idle_task = asyncio.create_task(_idle_watchdog(ws, stop)) + + try: + while True: + raw = await ws.receive_text() + if len(raw.encode("utf-8")) > MAX_MESSAGE_BYTES: + await ws.close( + code=status.WS_1009_MESSAGE_TOO_BIG, reason="message too large" + ) + break + + data = ClientInbound.model_validate_json(raw) + idle_task.cancel() + idle_task = asyncio.create_task(_idle_watchdog(ws, stop)) + print(username) + await manager.broadcast( + room, + ChatMessage(author=username, text=data.text).model_dump(), + exclude=ws, + ) + except WebSocketDisconnect: + pass + finally: + stop.set() + idle_task.cancel() + await manager.disconnect(room, ws) + await manager.broadcast(room, ChatSystem(text=f"{username} left").model_dump()) + + +async def _idle_watchdog(ws: WebSocket, stop: asyncio.Event) -> None: + try: + await asyncio.wait_for(stop.wait(), timeout=IDLE_TIMEOUT) + except asyncio.TimeoutError: + try: + await ws.close(code=status.WS_1001_GOING_AWAY, reason="idle timeout") + finally: + stop.set() diff --git a/hw2/hw/shop_api/handlers/item.py b/hw2/hw/shop_api/handlers/item.py new file mode 100644 index 00000000..f25bff64 --- /dev/null +++ b/hw2/hw/shop_api/handlers/item.py @@ -0,0 +1,134 @@ +from dataclasses import asdict +from http import HTTPStatus + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from shop_api.models.item import ItemCreate, ItemOut, ItemPatch, ItemPut, ItemRecord +from shop_api.storage.in_mem import get_store + +router = APIRouter(prefix="/item", tags=["item"]) + + +@router.get("", response_model=list[ItemOut], status_code=HTTPStatus.OK) +async def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: float | None = Query(None, ge=0), + max_price: float | None = Query(None, ge=0), + show_deleted: bool = Query(False), + deps=Depends(get_store), +): + items: ItemRecord + items, _, _ = deps + result: list[ItemOut] = [] + for iid, data in items.items(): + if not show_deleted and data.deleted: + continue + if min_price is not None and data.price < min_price: + continue + if max_price is not None and data.price > max_price: + continue + result.append(ItemOut(id=iid, **asdict(data))) + return result[offset : offset + limit] + + +@router.get("/{item_id}", response_model=ItemOut, status_code=HTTPStatus.OK) +async def item_by_id( + item_id: int, + deps=Depends(get_store), +): + items, _, _ = deps + rec: None | ItemRecord = items.get(item_id) + if rec is None or rec.deleted: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail={"error": "item not found"} + ) + return ItemOut(id=item_id, **asdict(items[item_id])) + + +@router.post("", response_model=ItemOut, status_code=HTTPStatus.CREATED) +async def create_item( + payload: ItemCreate, + request: Request, + deps=Depends(get_store), +): + items, _, lock = deps + async with lock: + new_id = request.app.state.last_item_id + 1 + request.app.state.last_item_id = new_id + items[new_id] = ItemRecord( + name=payload.name, + price=payload.price, + description=payload.description, + deleted=False, + ) + return ItemOut(id=new_id, **asdict(items[new_id])) + + +@router.put("/{item_id}", response_model=ItemOut, status_code=HTTPStatus.OK) +async def put_item( + item_id: int, + payload: ItemPut, + deps=Depends(get_store), +): + items, _, lock = deps + async with lock: + if item_id not in items: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail={ + "error": "item not found", + }, + ) + items[item_id] = ItemRecord( + name=payload.name, + price=payload.price, + description=payload.description, + deleted=False, + ) + return ItemOut(id=item_id, **asdict(items[item_id])) + + +@router.patch("/{item_id}", response_model=ItemOut, status_code=HTTPStatus.OK) +async def patch_item( + item_id: int, + payload: ItemPatch, + deps=Depends(get_store), +): + items, _, locks = deps + async with locks: + item = items.get(item_id) + if item is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail={ + "error": "item not found", + }, + ) + + if item.deleted: + raise HTTPException( + status_code=HTTPStatus.NOT_MODIFIED, + detail={"error": "item is deleted"}, + ) + + update_data = payload.model_dump(exclude_unset=True, exclude_none=True) + for k, v in update_data.items(): + setattr(item, k, v) + return ItemOut(id=item_id, **asdict(item)) + + +@router.delete("/{item_id}", response_model=ItemOut, status_code=HTTPStatus.OK) +async def delete_item( + item_id: int, + deps=Depends(get_store), +): + items, _, locks = deps + async with locks: + rec = items.get(item_id) + if rec is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail={"error": "item not found"} + ) + rec.deleted = True + return ItemOut(id=item_id, **asdict(items[item_id])) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..ce429950 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,33 @@ +from shop_api.storage.in_mem import lifespan +from shop_api.handlers import router +import datetime +import platform +import sys + +import fastapi +import pydantic from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + + +VERSION = "1.0.0" +AUTHOR = "Neimess" +BUILD_DATE = datetime.datetime.now().isoformat() + + +print("=" * 60) +print("Task API started") +print(f"Version: {VERSION}") +print(f"Build date: {BUILD_DATE}") +print(f"Author: {AUTHOR}") +print(f"Python: {sys.version.split()[0]}") +print(f"Platform: {platform.system()} {platform.release()}") +print(f"FastAPI: {fastapi.__version__}") +print(f"Pydantic: {pydantic.__version__}") +print("=" * 60) + +app = FastAPI(title="Shop API", lifespan=lifespan) +app.include_router(router) + -app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) diff --git a/hw2/hw/shop_api/models/cart.py b/hw2/hw/shop_api/models/cart.py new file mode 100644 index 00000000..9cceb9c2 --- /dev/null +++ b/hw2/hw/shop_api/models/cart.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass, field +from typing import List + +from pydantic import BaseModel + + +class CartLineOut(BaseModel): + id: int + quantity: int + + +class CartOut(BaseModel): + id: int + items: List[CartLineOut] + quantity: int + price: float + + +@dataclass +class CartRecord: + id: int + items: dict[int, int] = field(default_factory=dict) + + def add_item(self, item_id: int, qty: int = 1) -> None: + self.items[item_id] = self.items.get(item_id, 0) + qty + + def remove_item(self, item_id: int, qty: int = 1) -> None: + if item_id not in self.items: + return + self.items[item_id] -= qty + if self.items[item_id] <= 0: + del self.items[item_id] + + def clear(self) -> None: + self.items.clear() diff --git a/hw2/hw/shop_api/models/chat.py b/hw2/hw/shop_api/models/chat.py new file mode 100644 index 00000000..47889dfa --- /dev/null +++ b/hw2/hw/shop_api/models/chat.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field, ConfigDict + + +class ChatHello(BaseModel): + type: str = Field("hello", frozen=True) + room: str + username: str + + +class ChatSystem(BaseModel): + type: str = Field("system", frozen=True) + text: str + + +class ChatMessage(BaseModel): + type: str = Field("message", frozen=True) + author: str + text: str + + +class ClientInbound(BaseModel): + text: str = Field(min_length=1, max_length=2000) + + +model_config = ConfigDict(from_attributes=True) diff --git a/hw2/hw/shop_api/models/item.py b/hw2/hw/shop_api/models/item.py new file mode 100644 index 00000000..0d1c2a38 --- /dev/null +++ b/hw2/hw/shop_api/models/item.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict, Field + + +class ItemCreate(BaseModel): + name: str = Field(min_length=1) + price: float = Field(gt=0) + description: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ItemPut(BaseModel): + name: str = Field(min_length=1) + price: float = Field(gt=0) + description: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ItemPatch(BaseModel): + name: str | None = Field(default=None, min_length=1) + price: float | None = Field(default=None, gt=0) + description: str | None = None + + model_config = ConfigDict(extra="forbid") + + +class ItemOut(BaseModel): + id: int + name: str + price: float + description: str | None = None + deleted: bool = False + + model_config = ConfigDict(from_attributes=True) + + +@dataclass +class ItemRecord: + name: str + price: float + description: str | None = None + deleted: bool = False diff --git a/hw2/hw/shop_api/storage/in_mem.py b/hw2/hw/shop_api/storage/in_mem.py new file mode 100644 index 00000000..42195680 --- /dev/null +++ b/hw2/hw/shop_api/storage/in_mem.py @@ -0,0 +1,30 @@ +import asyncio +from contextlib import asynccontextmanager +from typing import Dict, Tuple + +from fastapi import FastAPI, Request + + +def _ensure_state(app: FastAPI) -> None: + state = app.state + if not hasattr(state, "items"): + state.items = {} # type: dict[int, dict[str, Any]] + if not hasattr(state, "carts"): + state.carts = {} # type: dict[str, dict[int, int]] + if not hasattr(state, "lock"): + state.lock = asyncio.Lock() + if not hasattr(state, "last_item_id"): + state.last_item_id = 0 + + +def get_store( + request: Request, +) -> Tuple[Dict[str, dict], Dict[str, Dict[str, int]], asyncio.Lock]: + _ensure_state(request.app) + return request.app.state.items, request.app.state.carts, request.app.state.lock + + +@asynccontextmanager +async def lifespan(app: FastAPI): + _ensure_state(app) + yield diff --git a/hw2/rest_example/api/pokemon/routes.py b/hw2/rest_example/api/pokemon/routes.py index ab935c9a..44843296 100644 --- a/hw2/rest_example/api/pokemon/routes.py +++ b/hw2/rest_example/api/pokemon/routes.py @@ -91,7 +91,7 @@ async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse: HTTPStatus.NOT_MODIFIED: { "description": "Failed to modify pokemon as one was not found", }, - } + }, ) async def put_pokemon( id: int,