1
0
mirror of https://github.com/daniviga/bite.git synced 2024-11-23 05:16:13 +01:00

Merge pull request #28 from daniviga/reloaded

Reloaded
This commit is contained in:
Daniele Viganò 2023-09-08 23:33:06 +02:00 committed by GitHub
commit da92935001
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 186 additions and 42 deletions

View File

@ -4,7 +4,7 @@ Playing with IoT
[![Build Status](https://travis-ci.com/daniviga/bite.svg?branch=master)](https://travis-ci.com/daniviga/bite) [![Build Status](https://travis-ci.com/daniviga/bite.svg?branch=master)](https://travis-ci.com/daniviga/bite)
![AGPLv3](./docs/.badges/agpl3.svg) ![AGPLv3](./docs/.badges/agpl3.svg)
![Python 3.9](./docs/.badges/python.svg) ![Python 3.11](./docs/.badges/python.svg)
![MQTT](./docs/.badges/mqtt.svg) ![MQTT](./docs/.badges/mqtt.svg)
![Moby](./docs/.badges/moby.svg) ![Moby](./docs/.badges/moby.svg)
![docker-compose 3.7+](./docs/.badges/docker-compose.svg) ![docker-compose 3.7+](./docs/.badges/docker-compose.svg)
@ -13,13 +13,6 @@ This project is for educational purposes only. It does not implement any
authentication and/or encryption protocol, so it is not suitable for real authentication and/or encryption protocol, so it is not suitable for real
production. production.
![Application Schema](./docs/application_chart.png)
### Future implementations
- Broker HA via [VerneMQ clustering](https://docs.vernemq.com/clustering/introduction)
- Stream analytics via [Apache Spark](https://spark.apache.org/)
## Installation ## Installation
### Requirements ### Requirements
@ -36,8 +29,10 @@ The application stack is composed by the following components:
- [Django](https://www.djangoproject.com/) with - [Django](https://www.djangoproject.com/) with
[Django REST framework](https://www.django-rest-framework.org/) [Django REST framework](https://www.django-rest-framework.org/)
web application (running via `gunicorn` in production mode) web application (running via `gunicorn` in production mode)
- `mqtt-to-db` custom daemon to dump telemetry into the timeseries database - `dispatcher` custom daemon to dump telemetry into the Kafka queue
- `handler` custom daemon to dump telemetry into the timeseries database from the Kafka queue
- telemetry payload is stored as json object (via PostgreSQL JSON data type) - telemetry payload is stored as json object (via PostgreSQL JSON data type)
- [Kafka](https://kafka.apache.org/) broker
- [Timescale](https://www.timescale.com/) DB, - [Timescale](https://www.timescale.com/) DB,
a [PostgreSQL](https://www.postgresql.org/) database with a timeseries extension a [PostgreSQL](https://www.postgresql.org/) database with a timeseries extension
- [Mosquitto](https://mosquitto.org/) MQTT broker (see alternatives below) - [Mosquitto](https://mosquitto.org/) MQTT broker (see alternatives below)

View File

@ -151,6 +151,10 @@ STATIC_URL = '/static/'
STATIC_ROOT = '/srv/appdata/bite/static' STATIC_ROOT = '/srv/appdata/bite/static'
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': []
}
SKIP_WHITELIST = True SKIP_WHITELIST = True
MQTT_BROKER = { MQTT_BROKER = {
@ -158,6 +162,11 @@ MQTT_BROKER = {
'PORT': '1883', 'PORT': '1883',
} }
KAFKA_BROKER = {
'HOST': 'kafka',
'PORT': '9092',
}
# If no local_settings.py is availble in the current folder let's try to # If no local_settings.py is availble in the current folder let's try to
# load it from the application root # load it from the application root
try: try:

View File

@ -22,23 +22,27 @@ import asyncio
import json import json
import time import time
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from kafka import KafkaProducer
from kafka.errors import NoBrokersAvailable
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from asyncio_mqtt import Client from aiomqtt import Client
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from api.models import Device from api.models import Device
from telemetry.models import Telemetry
MQTT_HOST = settings.MQTT_BROKER['HOST']
MQTT_PORT = int(settings.MQTT_BROKER['PORT'])
class Command(BaseCommand): class Command(BaseCommand):
help = 'MQTT to DB deamon' help = 'MQTT to DB deamon'
MQTT_HOST = settings.MQTT_BROKER['HOST']
MQTT_PORT = int(settings.MQTT_BROKER['PORT'])
KAFKA_HOST = settings.KAFKA_BROKER['HOST']
KAFKA_PORT = int(settings.KAFKA_BROKER['PORT'])
producer = None
@sync_to_async @sync_to_async
def get_device(self, serial): def get_device(self, serial):
try: try:
@ -47,24 +51,23 @@ class Command(BaseCommand):
return None return None
@sync_to_async @sync_to_async
def store_telemetry(self, device, payload): def dispatch(self, message):
Telemetry.objects.create( self.producer.send(
device=device, 'telemetry', {"transport": 'mqtt',
transport='mqtt', "body": message}
clock=payload['clock'],
payload=payload['payload']
) )
async def mqtt_broker(self): async def mqtt_broker(self):
async with Client(MQTT_HOST, port=MQTT_PORT) as client: async with Client(self.MQTT_HOST, port=self.MQTT_PORT) as client:
# use shared subscription for HA/balancing # use shared subscription for HA/balancing
await client.subscribe("$share/telemetry/#") await client.subscribe("$share/telemetry/#")
async with client.unfiltered_messages() as messages: async with client.messages() as messages:
async for message in messages: async for message in messages:
payload = json.loads(message.payload.decode('utf-8'))
device = await self.get_device(message.topic) device = await self.get_device(message.topic)
if device is not None: if device is not None:
await self.store_telemetry(device, payload) message_body = json.loads(
message.payload.decode('utf-8'))
await self.dispatch(message_body)
else: else:
self.stdout.write( self.stdout.write(
self.style.ERROR( self.style.ERROR(
@ -74,13 +77,28 @@ class Command(BaseCommand):
client = mqtt.Client() client = mqtt.Client()
while True: while True:
try: try:
client.connect(MQTT_HOST, MQTT_PORT) client.connect(self.MQTT_HOST, self.MQTT_PORT)
break break
except (socket.gaierror, ConnectionRefusedError): except (socket.gaierror, ConnectionRefusedError):
self.stdout.write( self.stdout.write(
self.style.WARNING('WARNING: Broker not available')) self.style.WARNING('WARNING: MQTT broker not available'))
time.sleep(5) time.sleep(5)
self.stdout.write(self.style.SUCCESS('INFO: Broker subscribed')) while True:
try:
self.producer = KafkaProducer(
bootstrap_servers='{}:{}'.format(
self.KAFKA_HOST, self.KAFKA_PORT
),
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
retries=5
)
break
except NoBrokersAvailable:
self.stdout.write(
self.style.WARNING('WARNING: Kafka broker not available'))
time.sleep(5)
self.stdout.write(self.style.SUCCESS('INFO: Brokers subscribed'))
client.disconnect() client.disconnect()
asyncio.run(self.mqtt_broker()) asyncio.run(self.mqtt_broker())

View File

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# BITE - A Basic/IoT/Example
# Copyright (C) 2020-2021 Daniele Viganò <daniele@vigano.me>
#
# BITE is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# BITE is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import time
from kafka import KafkaConsumer
from kafka.errors import NoBrokersAvailable
from django.conf import settings
from django.core.management.base import BaseCommand
from django.core.exceptions import ObjectDoesNotExist
from api.models import Device
from telemetry.models import Telemetry
class Command(BaseCommand):
help = 'MQTT to DB deamon'
KAFKA_HOST = settings.KAFKA_BROKER['HOST']
KAFKA_PORT = int(settings.KAFKA_BROKER['PORT'])
def get_device(self, serial):
try:
return Device.objects.get(serial=serial)
except ObjectDoesNotExist:
return None
def store_telemetry(self, transport, message):
Telemetry.objects.create(
transport=transport,
device=self.get_device(message["device"]),
clock=message["clock"],
payload=message["payload"]
)
def handle(self, *args, **options):
while True:
try:
consumer = KafkaConsumer(
"telemetry",
bootstrap_servers='{}:{}'.format(
self.KAFKA_HOST, self.KAFKA_PORT
),
group_id="handler",
value_deserializer=lambda m: json.loads(m.decode('utf8')),
)
break
except NoBrokersAvailable:
self.stdout.write(
self.style.WARNING('WARNING: Kafka broker not available'))
time.sleep(5)
self.stdout.write(self.style.SUCCESS('INFO: Kafka broker subscribed'))
for message in consumer:
self.store_telemetry(
message.value["transport"],
message.value["body"]
)
consumer.unsuscribe()

View File

@ -17,20 +17,20 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
FROM python:3.9-alpine AS builder FROM python:3.11-alpine AS builder
RUN apk update && apk add gcc musl-dev postgresql-dev \ RUN apk update && apk add gcc musl-dev postgresql-dev \
&& pip install psycopg2-binary && pip install psycopg2-binary
# --- # ---
FROM python:3.9-alpine FROM python:3.11-alpine
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV DJANGO_SETTINGS_MODULE "bite.settings" ENV DJANGO_SETTINGS_MODULE "bite.settings"
RUN apk update && apk add --no-cache postgresql-libs \ RUN apk update && apk add --no-cache postgresql-libs \
&& wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-alpine-linux-amd64-v0.6.1.tar.gz -qO- \ && wget https://github.com/jwilder/dockerize/releases/download/v0.7.0/dockerize-alpine-linux-amd64-v0.7.0.tar.gz -qO- \
| tar -xz -C /usr/local/bin | tar -xz -C /usr/local/bin
COPY --from=builder /usr/local/lib/python3.9/site-packages/ /usr/local/lib/python3.9/site-packages/ COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
COPY --chown=1000:1000 requirements.txt /srv/app/bite/requirements.txt COPY --chown=1000:1000 requirements.txt /srv/app/bite/requirements.txt
RUN pip3 install --no-cache-dir -r /srv/app/bite/requirements.txt RUN pip3 install --no-cache-dir -r /srv/app/bite/requirements.txt

View File

@ -36,6 +36,10 @@ services:
ports: ports:
- "${CUSTOM_DOCKER_IP:-0.0.0.0}:8000:8000" - "${CUSTOM_DOCKER_IP:-0.0.0.0}:8000:8000"
kafka:
ports:
- "${CUSTOM_DOCKER_IP:-0.0.0.0}:9092:9092"
data-migration: data-migration:
volumes: volumes:
- ../bite:/srv/app/bite - ../bite:/srv/app/bite
@ -44,6 +48,10 @@ services:
volumes: volumes:
- ../bite:/srv/app/bite - ../bite:/srv/app/bite
mqtt-to-db: dispatcher:
volumes:
- ../bite:/srv/app/bite
handler:
volumes: volumes:
- ../bite:/srv/app/bite - ../bite:/srv/app/bite

View File

@ -29,6 +29,10 @@ services:
volumes: volumes:
- ./django/production.py.sample:/srv/app/bite/bite/production.py - ./django/production.py.sample:/srv/app/bite/bite/production.py
mqtt-to-db: dispatcher:
volumes:
- ./django/production.py.sample:/srv/app/bite/bite/production.py
handler:
volumes: volumes:
- ./django/production.py.sample:/srv/app/bite/bite/production.py - ./django/production.py.sample:/srv/app/bite/bite/production.py

View File

@ -62,6 +62,30 @@ services:
ports: ports:
- "${CUSTOM_DOCKER_IP:-0.0.0.0}:1883:1883" - "${CUSTOM_DOCKER_IP:-0.0.0.0}:1883:1883"
zookeeper:
image: confluentinc/cp-zookeeper:latest
networks:
- net
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- 22181:2181
kafka:
image: confluentinc/cp-kafka:latest
depends_on:
- zookeeper
networks:
- net
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
ingress: ingress:
<<: *service_default <<: *service_default
image: nginx:stable-alpine image: nginx:stable-alpine
@ -104,13 +128,21 @@ services:
- staticdata:/srv/appdata/bite/static - staticdata:/srv/appdata/bite/static
command: ["python3", "manage.py", "collectstatic", "--noinput"] command: ["python3", "manage.py", "collectstatic", "--noinput"]
mqtt-to-db: dispatcher:
<<: *service_default <<: *service_default
image: daniviga/bite image: daniviga/bite
command: ["python3", "manage.py", "mqtt-to-db"] command: ["python3", "manage.py", "dispatcher"]
networks:
- net
depends_on:
- broker
handler:
<<: *service_default
image: daniviga/bite
command: ["python3", "manage.py", "handler"]
networks: networks:
- net - net
depends_on: depends_on:
- data-migration - data-migration
- timescale - timescale
- broker

View File

@ -17,9 +17,9 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
FROM alpine:3.15 FROM alpine:3.18
RUN apk update && apk add chrony && \ RUN apk add --no-cache chrony && \
chown -R chrony:chrony /var/lib/chrony chown -R chrony:chrony /var/lib/chrony
COPY ./chrony.conf /etc/chrony/chrony.conf COPY ./chrony.conf /etc/chrony/chrony.conf

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
FROM python:3.9-alpine FROM python:3.11-alpine
RUN pip3 install urllib3 paho-mqtt RUN pip3 install urllib3 paho-mqtt
COPY ./device_simulator.py /opt/bite/device_simulator.py COPY ./device_simulator.py /opt/bite/device_simulator.py

View File

@ -94,7 +94,7 @@ def main():
parser.add_argument('-s', '--serial', parser.add_argument('-s', '--serial',
default=os.environ.get('IOT_SERIAL'), default=os.environ.get('IOT_SERIAL'),
help='IoT device serial number') help='IoT device serial number')
parser.add_argument('-d', '--delay', metavar='s', type=int, parser.add_argument('-d', '--delay', metavar='s', type=float,
default=os.environ.get('IOT_DELAY', 10), default=os.environ.get('IOT_DELAY', 10),
help='Delay between requests') help='Delay between requests')
args = parser.parse_args() args = parser.parse_args()

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="93.0" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="round"><rect width="93.0" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#round)"><rect width="65.5" height="20" fill="#555"/><rect x="65.5" width="27.5" height="20" fill="#007ec6"/><rect width="93.0" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><image x="5" y="3" width="14" height="14" xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0icHlZZWxsb3ciIGdyYWRpZW50VHJhbnNmb3JtPSJyb3RhdGUoNDUpIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI2ZlNSIgb2Zmc2V0PSIwLjYiLz4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI2RhMSIgb2Zmc2V0PSIxIi8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJweUJsdWUiIGdyYWRpZW50VHJhbnNmb3JtPSJyb3RhdGUoNDUpIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzY5ZiIgb2Zmc2V0PSIwLjQiLz4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzQ2OCIgb2Zmc2V0PSIxIi8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KCiAgPHBhdGggZD0iTTI3LDE2YzAtNyw5LTEzLDI0LTEzYzE1LDAsMjMsNiwyMywxM2wwLDIyYzAsNy01LDEyLTExLDEybC0yNCwwYy04LDAtMTQsNi0xNCwxNWwwLDEwbC05LDBjLTgsMC0xMy05LTEzLTI0YzAtMTQsNS0yMywxMy0yM2wzNSwwbDAtM2wtMjQsMGwwLTlsMCwweiBNODgsNTB2MSIgZmlsbD0idXJsKCNweUJsdWUpIi8+CiAgPHBhdGggZD0iTTc0LDg3YzAsNy04LDEzLTIzLDEzYy0xNSwwLTI0LTYtMjQtMTNsMC0yMmMwLTcsNi0xMiwxMi0xMmwyNCwwYzgsMCwxNC03LDE0LTE1bDAtMTBsOSwwYzcsMCwxMyw5LDEzLDIzYzAsMTUtNiwyNC0xMywyNGwtMzUsMGwwLDNsMjMsMGwwLDlsMCwweiBNMTQwLDUwdjEiIGZpbGw9InVybCgjcHlZZWxsb3cpIi8+CgogIDxjaXJjbGUgcj0iNCIgY3g9IjY0IiBjeT0iODgiIGZpbGw9IiNGRkYiLz4KICA8Y2lyY2xlIHI9IjQiIGN4PSIzNyIgY3k9IjE1IiBmaWxsPSIjRkZGIi8+Cjwvc3ZnPgo="/><text x="422.5" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="385.0" lengthAdjust="spacing">python</text><text x="422.5" y="140" transform="scale(0.1)" textLength="385.0" lengthAdjust="spacing">python</text><text x="782.5" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="175.0" lengthAdjust="spacing">3.9</text><text x="782.5" y="140" transform="scale(0.1)" textLength="175.0" lengthAdjust="spacing">3.9</text></g></svg> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="93.0" height="20"><linearGradient id="smooth" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="round"><rect width="93.0" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#round)"><rect width="65.5" height="20" fill="#555"/><rect x="65.5" width="27.5" height="20" fill="#007ec6"/><rect width="93.0" height="20" fill="url(#smooth)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><image x="5" y="3" width="14" height="14" xlink:href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0icHlZZWxsb3ciIGdyYWRpZW50VHJhbnNmb3JtPSJyb3RhdGUoNDUpIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI2ZlNSIgb2Zmc2V0PSIwLjYiLz4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI2RhMSIgb2Zmc2V0PSIxIi8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJweUJsdWUiIGdyYWRpZW50VHJhbnNmb3JtPSJyb3RhdGUoNDUpIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzY5ZiIgb2Zmc2V0PSIwLjQiLz4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzQ2OCIgb2Zmc2V0PSIxIi8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KCiAgPHBhdGggZD0iTTI3LDE2YzAtNyw5LTEzLDI0LTEzYzE1LDAsMjMsNiwyMywxM2wwLDIyYzAsNy01LDEyLTExLDEybC0yNCwwYy04LDAtMTQsNi0xNCwxNWwwLDEwbC05LDBjLTgsMC0xMy05LTEzLTI0YzAtMTQsNS0yMywxMy0yM2wzNSwwbDAtM2wtMjQsMGwwLTlsMCwweiBNODgsNTB2MSIgZmlsbD0idXJsKCNweUJsdWUpIi8+CiAgPHBhdGggZD0iTTc0LDg3YzAsNy04LDEzLTIzLDEzYy0xNSwwLTI0LTYtMjQtMTNsMC0yMmMwLTcsNi0xMiwxMi0xMmwyNCwwYzgsMCwxNC03LDE0LTE1bDAtMTBsOSwwYzcsMCwxMyw5LDEzLDIzYzAsMTUtNiwyNC0xMywyNGwtMzUsMGwwLDNsMjMsMGwwLDlsMCwweiBNMTQwLDUwdjEiIGZpbGw9InVybCgjcHlZZWxsb3cpIi8+CgogIDxjaXJjbGUgcj0iNCIgY3g9IjY0IiBjeT0iODgiIGZpbGw9IiNGRkYiLz4KICA8Y2lyY2xlIHI9IjQiIGN4PSIzNyIgY3k9IjE1IiBmaWxsPSIjRkZGIi8+Cjwvc3ZnPgo="/><text x="422.5" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="385.0" lengthAdjust="spacing">python</text><text x="422.5" y="140" transform="scale(0.1)" textLength="385.0" lengthAdjust="spacing">python</text><text x="782.5" y="150" fill="#010101" fill-opacity=".3" transform="scale(0.1)" textLength="175.0" lengthAdjust="spacing">3.11</text><text x="782.5" y="140" transform="scale(0.1)" textLength="175.0" lengthAdjust="spacing">3.11</text></g></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -4,3 +4,4 @@ ipython
flake8 flake8
pyinstrument pyinstrument
django-debug-toolbar django-debug-toolbar
urllib3

View File

@ -4,7 +4,8 @@ djangorestframework
django-health-check django-health-check
psycopg2-binary psycopg2-binary
paho-mqtt paho-mqtt
asyncio-mqtt kafka-python
aiomqtt
PyYAML PyYAML
uritemplate uritemplate
pygments pygments