diff --git a/README.md b/README.md index 3c6580e..afe3542 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ -# Checkpoint_4 | Человеческий комментарий для проверки +# Checkpoint_7 | Человеческий комментарий для проверки -Пункты 1 и 2 чекпоинта выполнены в полном объёме (статика, proxy). Для проверки отдачи статики открыается *url* админки. Всё умещено в 1 образе nginx. -Команды для запуска актуальны, всё накатиться и запустится автоматически. Желательно до git pull сделать docker compose down на предыдущем коммите. +Пункты 1,2,3 (node exporter, экспортер для статусов очереди задач, django-prometheus) выполнены. Пункт 4 для БД - нет. Для проверки при запуске скрипта из инструкции будут открыты все необходимые url. +Команды для запуска актуальны, всё накатиться и запустится автоматически. Желательно до git pull сделать docker compose down на предыдущем коммите. Возможно, что основная ссылка http://127.0.1.1:8100/video_feed/ с вебкамерой 8100 будет недоступна при открытии, страницу нужно всего лишь обновить. + +Для проверки 7 чекпоинта в ссылке с grafana нужно залогиниться (пароль и логин: admin и admin). Далее нужно указать источник - прометеус (ссылка : http://prometheus:9090/), перейти в раздел dashboard, new->import (загрузить из папки проекта: ./grafana/celery-monitoring-grafana-dashboard.json). Далее показываем руку, себя несколько раз в кадр, запуститься красная нить django - celery - flower - prometheus - grafana и вы увидите изменяющиеся графики. Ниже показан скриншот как работает проект. + +![image.png](./templates/proof_of_work-min.png) ВНИМАНИЕ: при отсутствии вебкамеры подготовленное видео не отображается в фронте, но отображается в очереди и приходят уведомления в телеграмм (нужно допиливать кодовую базу, чтобы работал StreamingHttpResponse с mp4, отложили на попозже). Для тестирования с своей вэбкой уведомлений и очередей необходимо оказаться человеку/части тела человека в объективе ВНИМАНИЕ: для запуска также необходимо поместить файл .env в папку с проектом. При желании можно подписаться на бота. Для этого необходимо написать боту https://t.me/JsonDumpBot , узнать свой "chat": { "**id**":** **123456789** и поместить его в файл .env через запятую. Важно, чтобы в конце OVCTECH_TG_CHAT_IDS была цифра, а не запятая. Токен трогать не нужно. В личку направили Павлу файлик. diff --git a/docker-compose-template.yml b/docker-compose-template.yml index fda29dc..04d203e 100644 --- a/docker-compose-template.yml +++ b/docker-compose-template.yml @@ -102,6 +102,38 @@ services: networks: - django_network + grafana: + container_name: grafana_videoanalytics + image: grafana/grafana:latest + ports: + - 3000:3000 + volumes: + - ./grafana/init.sh:/init.sh + - ./grafana/celery-monitoring-grafana-dashboard.json:/celery-monitoring-grafana-dashboard.json + command: sh /init.sh + networks: + - django_network + + prometheus: + container_name: prometheus_videoanalytics + image: prom/prometheus:latest + ports: + - 9090:9090 + volumes: + - ./prometheus:/etc/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + networks: + - django_network + + node_exporter: + container_name: node_exporter_videoanalytics + image: prom/node-exporter:latest + ports: + - 9100:9100 + networks: + - django_network + volumes: static_volume: diff --git a/grafana/celery-monitoring-grafana-dashboard.json b/grafana/celery-monitoring-grafana-dashboard.json new file mode 100644 index 0000000..2dcb611 --- /dev/null +++ b/grafana/celery-monitoring-grafana-dashboard.json @@ -0,0 +1,759 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "7.5.2" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Basic celery monitoring example", + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel shows status of celery workers. 1 = online, 0 = offline.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "hiddenSeries": false, + "id": 4, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "flower_worker_online", + "interval": "", + "legendFormat": "{{ worker }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Celery Worker Status", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:150", + "format": "short", + "label": "", + "logBase": 1, + "max": "1", + "min": "0", + "show": true + }, + { + "$$hashKey": "object:151", + "decimals": null, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel shows number of tasks currently executing at worker.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 9, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "flower_worker_number_of_currently_executing_tasks", + "interval": "", + "legendFormat": "{{worker}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Number of Tasks Currently Executing at Worker", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:79", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:80", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel shows average task runtime at worker by worker and task name.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 8 + }, + "hiddenSeries": false, + "id": 11, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "rate(flower_task_runtime_seconds_sum[5m]) / rate(flower_task_runtime_seconds_count[5m])", + "interval": "", + "legendFormat": "{{task}}, {{worker}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Average Task Runtime at Worker", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:337", + "format": "s", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:338", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel shows task prefetch time at worker by worker and task name.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 17 + }, + "hiddenSeries": false, + "id": 12, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "flower_task_prefetch_time_seconds", + "interval": "", + "legendFormat": "{{task}}, {{worker}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Task Prefetch Time at Worker", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:337", + "format": "s", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:338", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel shows number of tasks prefetched at worker by task and worker name.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 26 + }, + "hiddenSeries": false, + "id": 10, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "flower_worker_prefetched_tasks", + "interval": "", + "legendFormat": "{{task}}, {{worker}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Number of Tasks Prefetched At Worker", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:337", + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:338", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel presents average task success ratio over time by task name.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 35 + }, + "hiddenSeries": false, + "id": 2, + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(sum(avg_over_time(flower_events_total{type=\"task-succeeded\"}[15m])) by (task) / sum(avg_over_time(flower_events_total{type=~\"task-failed|task-succeeded\"}[15m])) by (task)) * 100", + "interval": "", + "legendFormat": "{{ task }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Task Success Ratio", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:63", + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:64", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "This panel presents average task failure ratio over time by task name.", + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 35 + }, + "hiddenSeries": false, + "id": 7, + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": true, + "min": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.2", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "(sum(avg_over_time(flower_events_total{type=\"task-failed\"}[15m])) by (task) / sum(avg_over_time(flower_events_total{type=~\"task-failed|task-succeeded\"}[15m])) by (task)) * 100", + "interval": "", + "legendFormat": "{{ task }}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Task Failure Ratio", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "$$hashKey": "object:63", + "format": "percent", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "$$hashKey": "object:64", + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "10s", + "schemaVersion": 27, + "style": "dark", + "tags": [ + "celery", + "monitoring", + "flower" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Celery Monitoring", + "uid": "3OBI1flGz", + "version": 9 +} diff --git a/grafana/init.sh b/grafana/init.sh new file mode 100755 index 0000000..95a8a0c --- /dev/null +++ b/grafana/init.sh @@ -0,0 +1,3 @@ +#!/bin/bash +sleep 10 +curl -X POST -H "Content-Type: application/json" -d @celery-monitoring-grafana-dashboard.json http://admin:password@grafana:3000/api/dashboards/db diff --git a/nginx.conf b/nginx.conf index 7db0a89..8f58201 100644 --- a/nginx.conf +++ b/nginx.conf @@ -19,8 +19,12 @@ http { location / { proxy_pass http://django; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_redirect off; } + + location /metrics { + stub_status on; + } } } diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml new file mode 100644 index 0000000..544a6aa --- /dev/null +++ b/prometheus/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'node_exporter' + static_configs: + - targets: ['node_exporter:9100'] + + - job_name: 'flower' + static_configs: + - targets: ['flower_worker:5555'] diff --git a/scripts/run.sh b/scripts/run.sh index c8b34e7..789a1bb 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -70,6 +70,8 @@ sudo make sleep 15 open http://127.0.1.1:5555/ open http://127.0.1.1:8100/admin/ +open http://127.0.1.1:9090 +open http://127.0.1.1:3000 if test -n "$(find /dev -name 'video*' -print -quit)"; then echo "Webcam found!" open http://127.0.1.1:8100/video_feed/ diff --git a/templates/proof_of_work-min.png b/templates/proof_of_work-min.png new file mode 100644 index 0000000..fd9bba7 Binary files /dev/null and b/templates/proof_of_work-min.png differ diff --git a/videoanalytics/backend/settings.py b/videoanalytics/backend/settings.py index fdf3098..f44996e 100644 --- a/videoanalytics/backend/settings.py +++ b/videoanalytics/backend/settings.py @@ -25,7 +25,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False -ALLOWED_HOSTS = [".localhost", "127.0.0.1", "[::1]", "127.0.1.1"] +ALLOWED_HOSTS = [".localhost", "127.0.0.1", "[::1]", "127.0.1.1", "django"] # Application definition @@ -40,6 +40,7 @@ "rest_framework", "videoanalytics", "celery", + "django_prometheus", ] MIDDLEWARE = [ @@ -50,6 +51,8 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_prometheus.middleware.PrometheusAfterMiddleware", + "django_prometheus.middleware.PrometheusBeforeMiddleware", ] ROOT_URLCONF = "backend.urls" diff --git a/videoanalytics/backend/urls.py b/videoanalytics/backend/urls.py index 3f4e0ad..b56b5a7 100644 --- a/videoanalytics/backend/urls.py +++ b/videoanalytics/backend/urls.py @@ -15,7 +15,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include from django.conf.urls.static import static from django.conf import settings import sys @@ -27,4 +27,5 @@ urlpatterns = [ path("admin/", admin.site.urls), path("video_feed/", views.video_feed, name="video_feed"), + path("metrics/", include("django_prometheus.urls")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/videoanalytics/entrypoint.sh b/videoanalytics/entrypoint.sh index 8efd8e7..0fdd61a 100755 --- a/videoanalytics/entrypoint.sh +++ b/videoanalytics/entrypoint.sh @@ -5,6 +5,6 @@ sleep 5 python manage.py migrate python manage.py createcachetable python manage.py collectstatic --noinput -gunicorn backend.wsgi:application --bind 0.0.0.0:8000 -w 6 #--insecure +gunicorn backend.wsgi:application --bind 0.0.0.0:8000 -w 6 --timeout 300 #--insecure exec "$@" diff --git a/videoanalytics/poetry.lock b/videoanalytics/poetry.lock index 0c3b86e..631c998 100644 --- a/videoanalytics/poetry.lock +++ b/videoanalytics/poetry.lock @@ -443,6 +443,20 @@ files = [ django-six = ">=1.0.4" excel-base = ">=1.0.3" +[[package]] +name = "django-prometheus" +version = "2.3.1" +description = "Django middlewares to monitor your application with Prometheus.io." +optional = false +python-versions = "*" +files = [ + {file = "django-prometheus-2.3.1.tar.gz", hash = "sha256:f9c8b6c780c9419ea01043c63a437d79db2c33353451347894408184ad9c3e1e"}, + {file = "django_prometheus-2.3.1-py2.py3-none-any.whl", hash = "sha256:cf9b26f7ba2e4568f08f8f91480a2882023f5908579681bcf06a4d2465f12168"}, +] + +[package.dependencies] +prometheus-client = ">=0.7" + [[package]] name = "django-six" version = "1.0.5" @@ -2408,4 +2422,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10.0" -content-hash = "2ac92cdf21c13b305638725e1f84892cfbe954c07e05964f66d827837d848f89" +content-hash = "fc027ae6a52c2ead93da36645be6273394b6ba61eeb2cb2a4e5a727e218ffb68" diff --git a/videoanalytics/pyproject.toml b/videoanalytics/pyproject.toml index 149a270..fa9fcae 100644 --- a/videoanalytics/pyproject.toml +++ b/videoanalytics/pyproject.toml @@ -18,6 +18,7 @@ celery = {extras = ["redis"], version = "^5.3.6"} redis = "^5.0.1" flower = "^2.0.1" gunicorn = "^21.2.0" +django-prometheus = "^2.3.1" [tool.poetry.group.dev.dependencies] ruff = "^0.2.1"