Functioning mqtt ingestion and db write, formating changes, device manager initiated

This commit is contained in:
Andrej Mickov
2025-10-30 00:32:25 +01:00
parent 0b96c72f45
commit 12d3720421
45 changed files with 2168 additions and 820 deletions

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ demo.db-wal
**/__pycache__/ **/__pycache__/
iotDashboard/db_create.py iotDashboard/db_create.py
.venv/ .venv/
.ruff_cache/
*.key
*.crt
*.csr

23
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: FastAPI",
"type": "debugpy",
"request": "launch",
"program": "uv",
"cwd": "${workspaceFolder}/services/device_manager",
"args": [
"run",
"uvicorn",
"main:app",
"--reload"
],
"jinja": true
}
]
}

View File

@@ -9,7 +9,8 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Load environment variables # Load environment variables
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '../.env'))
load_dotenv(os.path.join(os.path.dirname(__file__), "../.env"))
# Import your models # Import your models
from models import Base from models import Base
@@ -19,9 +20,9 @@ from models import Base
config = context.config config = context.config
# Set database URL from environment # Set database URL from environment
database_url = os.getenv('CONNECTION_STRING') or os.getenv('DATABASE_URL') database_url = os.getenv("CONNECTION_STRING") or os.getenv("DATABASE_URL")
if database_url: if database_url:
config.set_main_option('sqlalchemy.url', database_url) config.set_main_option("sqlalchemy.url", database_url)
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
@@ -76,9 +77,7 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(connection=connection, target_metadata=target_metadata)
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@@ -5,6 +5,7 @@ Revises:
Create Date: 2025-10-28 22:06:45.637836+00:00 Create Date: 2025-10-28 22:06:45.637836+00:00
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@@ -12,7 +13,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'dae12d7f4ddf' revision: str = "dae12d7f4ddf"
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -21,30 +22,43 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('devices', op.create_table(
sa.Column('id', sa.Text(), nullable=False), "devices",
sa.Column('name', sa.Text(), nullable=False), sa.Column("id", sa.Text(), nullable=False),
sa.Column('location', sa.Text(), nullable=True), sa.Column("name", sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True), sa.Column("location", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.Column("is_active", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
) )
op.create_table('telemetry', op.create_table(
sa.Column('time', sa.DateTime(timezone=True), nullable=False), "telemetry",
sa.Column('device_id', sa.Text(), nullable=False), sa.Column("time", sa.DateTime(timezone=True), nullable=False),
sa.Column('metric', sa.Text(), nullable=False), sa.Column("device_id", sa.Text(), nullable=False),
sa.Column('value', sa.Float(), nullable=False), sa.Column("metric", sa.Text(), nullable=False),
sa.Column('unit', sa.Text(), nullable=True), sa.Column("value", sa.Float(), nullable=False),
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.Column("unit", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('time', 'device_id', 'metric') sa.ForeignKeyConstraint(
["device_id"],
["devices.id"],
),
sa.PrimaryKeyConstraint("time", "device_id", "metric"),
) )
op.create_index('idx_telemetry_device_time', 'telemetry', ['device_id', 'time'], unique=False) op.create_index(
"idx_telemetry_device_time", "telemetry", ["device_id", "time"], unique=False
)
# Convert telemetry table to TimescaleDB hypertable
op.execute("SELECT create_hypertable('telemetry', 'time', if_not_exists => TRUE);")
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_telemetry_device_time', table_name='telemetry')
op.drop_table('telemetry') # Note: TimescaleDB hypertables cannot be easily converted back to regular tables
op.drop_table('devices') # The table will be dropped entirely, which removes the hypertable as well
op.drop_index("idx_telemetry_device_time", table_name="telemetry")
op.drop_table("telemetry")
op.drop_table("devices")
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -0,0 +1,60 @@
"""add device_certificates table
Revision ID: f94393f57c35
Revises: dae12d7f4ddf
Create Date: 2025-10-29 21:57:58.983071+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f94393f57c35"
down_revision: Union[str, Sequence[str], None] = "dae12d7f4ddf"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"device_certificates",
sa.Column("device_id", sa.Text(), nullable=False),
sa.Column("certificate_pem", sa.Text(), nullable=False),
sa.Column("private_key_pem", sa.Text(), nullable=True),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["device_id"], ["devices.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("device_id"),
)
op.add_column(
"devices",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
)
op.drop_index(op.f("telemetry_time_idx"), table_name="telemetry")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(
op.f("telemetry_time_idx"),
"telemetry",
[sa.literal_column("time DESC")],
unique=False,
)
op.drop_column("devices", "created_at")
op.drop_table("device_certificates")
# ### end Alembic commands ###

View File

@@ -7,42 +7,61 @@ To modify schema:
3. Review the generated migration in alembic/versions/ 3. Review the generated migration in alembic/versions/
4. Run: alembic upgrade head 4. Run: alembic upgrade head
""" """
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index,Text, DateTime
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Text, DateTime
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
Base = declarative_base() Base = declarative_base()
class Device(Base): class Device(Base):
"""IoT devices registered in the system.""" """IoT devices registered in the system."""
__tablename__ = 'devices'
__tablename__ = "devices"
id = Column(Text, primary_key=True) id = Column(Text, primary_key=True)
name = Column(Text, nullable=False) name = Column(Text, nullable=False)
location = Column(Text) location = Column(Text)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self): def __repr__(self):
return f"<Device(id={self.id}, name={self.name})>" return f"<Device(id={self.id}, name={self.name})>"
class DeviceCertificate(Base):
"""X.509 certificates issued to devices for mTLS authentication."""
__tablename__ = "device_certificates"
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), primary_key=True
)
certificate_pem = Column(Text, nullable=False)
private_key_pem = Column(Text) # Optional: for backup/escrow
issued_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked_at = Column(DateTime(timezone=True))
def __repr__(self):
return f"<DeviceCertificate(device_id={self.device_id}, expires={self.expires_at})>"
class Telemetry(Base): class Telemetry(Base):
""" """
Time-series telemetry data from devices. Time-series telemetry data from devices.
This will be converted to a TimescaleDB hypertable.
""" """
__tablename__ = 'telemetry'
__tablename__ = "telemetry"
time = Column(DateTime(timezone=True), primary_key=True, nullable=False) time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
device_id = Column(Text, ForeignKey('devices.id'), primary_key=True, nullable=False) device_id = Column(Text, ForeignKey("devices.id"), primary_key=True, nullable=False)
metric = Column(Text, primary_key=True, nullable=False) # e.g., 'light', 'temperature' metric = Column(Text, primary_key=True, nullable=False)
value = Column(Float, nullable=False) value = Column(Float, nullable=False)
unit = Column(Text) unit = Column(Text)
__table_args__ = ( __table_args__ = (Index("idx_telemetry_device_time", "device_id", "time"),)
Index('idx_telemetry_device_time', 'device_id', 'time'),
)
def __repr__(self): def __repr__(self):
return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>" return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"

18
gpt.py
View File

@@ -7,7 +7,7 @@ from openai import OpenAI
load_dotenv() load_dotenv()
client = OpenAI() client = OpenAI()
REDIS_HOST=os.getenv('REDIS_HOST') REDIS_HOST = os.getenv("REDIS_HOST")
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0) redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
data = redis_client.get("last5").decode("utf-8") data = redis_client.get("last5").decode("utf-8")
@@ -17,26 +17,28 @@ def analysis(environment_data):
completion = client.chat.completions.create( completion = client.chat.completions.create(
model="gpt-4o-mini", model="gpt-4o-mini",
messages=[ messages=[
{"role": "system", {
"role": "system",
"content": "You are an assistant that analyzes environmental data for an office working space and provides " "content": "You are an assistant that analyzes environmental data for an office working space and provides "
"concise numerical insights."}, "concise numerical insights.",
},
{ {
"role": "user", "role": "user",
"content": f"Analyze the following environmental data. The goal is maintaining optimal working " "content": f"Analyze the following environmental data. The goal is maintaining optimal working "
f"conditions in the office and peak working brain. Focus on any outliers or necessary adjustments. The data is as following: {environment_data}." f"conditions in the office and peak working brain. Focus on any outliers or necessary adjustments. The data is as following: {environment_data}."
f"The output should be only the recommendations in numerical form with postitive and negative " f"The output should be only the recommendations in numerical form with postitive and negative "
f"numbers and also provide small summary in a sentence or two of the current conditions and " f"numbers and also provide small summary in a sentence or two of the current conditions and "
f"easily computable in json format. Be consistent with the + and - signs and the summary" f"easily computable in json format. Be consistent with the + and - signs and the summary",
} },
], ],
response_format={"type": "json_object"} response_format={"type": "json_object"},
) )
output = completion.choices[0].message.content output = completion.choices[0].message.content
return output return output
output = analysis(data) output = analysis(data)
redis_client.set("gpt",json.dumps(output)) redis_client.set("gpt", json.dumps(output))
print(output) print(output)

View File

@@ -13,8 +13,9 @@ services:
ports: ports:
- "1883:1883" - "1883:1883"
- "9001:9001" - "9001:9001"
- "8883:8883"
volumes: volumes:
- ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf - ./mosquitto/:/mosquitto/config/
restart: unless-stopped restart: unless-stopped
timescaledb: timescaledb:

View File

@@ -1,2 +1,17 @@
allow_anonymous true # Standard MQTT listener (for testing without certs)
listener 1883 0.0.0.0 listener 1883 0.0.0.0
allow_anonymous true
# mTLS listener (requires client certificates)
listener 8883 0.0.0.0
protocol mqtt
# Certificate-based authentication
require_certificate true
use_identity_as_username true
# CA certificate to verify client certificates
cafile /mosquitto/config/ca.crt
# Optional: TLS version restrictions
tls_version tlsv1.2

View File

@@ -1,6 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
class IotDashboardConfig(AppConfig): class IotDashboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'iotDashboard' name = "iotDashboard"

View File

@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "iotDashboard.settings")
application = get_asgi_application() application = get_asgi_application()

View File

@@ -5,7 +5,7 @@ from iotDashboard.models import Device, Sensor, SensorType
class DeviceForm(forms.ModelForm): class DeviceForm(forms.ModelForm):
class Meta: class Meta:
model = Device model = Device
fields = ['name', 'ip', 'protocol'] # Exclude sensors from the fields fields = ["name", "ip", "protocol"] # Exclude sensors from the fields
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# No need to handle sensors in the form # No need to handle sensors in the form
@@ -19,36 +19,37 @@ class DeviceForm(forms.ModelForm):
device.save() device.save()
return device return device
class SensorWithTypeForm(forms.ModelForm): class SensorWithTypeForm(forms.ModelForm):
# Add fields for SensorType directly in the form # Add fields for SensorType directly in the form
type_name = forms.CharField(max_length=50, label="Sensor Type Name") type_name = forms.CharField(max_length=50, label="Sensor Type Name")
unit = forms.CharField(max_length=20, label="Unit", required=False) unit = forms.CharField(max_length=20, label="Unit", required=False)
protocol = forms.ChoiceField( protocol = forms.ChoiceField(
choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], choices=[("mqtt", "MQTT"), ("http", "HTTP")], label="Protocol"
label="Protocol"
) )
topic = forms.CharField(max_length=100, label="Topic", required=False) topic = forms.CharField(max_length=100, label="Topic", required=False)
endpoint = forms.CharField(max_length=100, label="Endpoint", required=False) endpoint = forms.CharField(max_length=100, label="Endpoint", required=False)
class Meta: class Meta:
model = Sensor model = Sensor
fields = ['enabled'] # Exclude 'device' from the form fields fields = ["enabled"] # Exclude 'device' from the form fields
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.device = kwargs.pop('device', None) # Get the device from kwargs self.device = kwargs.pop("device", None) # Get the device from kwargs
super(SensorWithTypeForm, self).__init__(*args, **kwargs) super(SensorWithTypeForm, self).__init__(*args, **kwargs)
def save(self, commit=True): def save(self, commit=True):
# Create or get the SensorType # Create or get the SensorType
try: try:
sensor_type = SensorType.objects.get(name=self.cleaned_data['type_name']) sensor_type = SensorType.objects.get(name=self.cleaned_data["type_name"])
except SensorType.DoesNotExist: except SensorType.DoesNotExist:
sensor_type = SensorType( sensor_type = SensorType(
name=self.cleaned_data['type_name'], name=self.cleaned_data["type_name"],
unit=self.cleaned_data['unit'], unit=self.cleaned_data["unit"],
protocol=self.cleaned_data['protocol'], protocol=self.cleaned_data["protocol"],
topic=self.cleaned_data['topic'], topic=self.cleaned_data["topic"],
endpoint=self.cleaned_data['endpoint'] endpoint=self.cleaned_data["endpoint"],
) )
if commit: if commit:
sensor_type.save() sensor_type.save()

View File

@@ -5,40 +5,85 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Device', name="Device",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=50)), "id",
('ip', models.CharField(max_length=20)), models.BigAutoField(
('protocol', models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20)), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50)),
("ip", models.CharField(max_length=20)),
(
"protocol",
models.CharField(
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='SensorType', name="SensorType",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=50, unique=True)), "id",
('unit', models.CharField(max_length=20)), models.BigAutoField(
('protocol', models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20)), auto_created=True,
('topic', models.CharField(blank=True, max_length=100, null=True)), primary_key=True,
('endpoint', models.CharField(blank=True, max_length=100, null=True)), serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50, unique=True)),
("unit", models.CharField(max_length=20)),
(
"protocol",
models.CharField(
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
),
),
("topic", models.CharField(blank=True, max_length=100, null=True)),
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Sensor', name="Sensor",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('enabled', models.BooleanField(default=True)), "id",
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sensors', to='iotDashboard.device')), models.BigAutoField(
('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='iotDashboard.sensortype')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("enabled", models.BooleanField(default=True)),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="sensors",
to="iotDashboard.device",
),
),
(
"type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="iotDashboard.sensortype",
),
),
], ],
), ),
] ]

View File

@@ -5,43 +5,78 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('iotDashboard', '0001_initial'), ("iotDashboard", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='SensorType', name="SensorType",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=50, unique=True)), "id",
('unit', models.CharField(max_length=20)), models.BigAutoField(
('protocol', models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20)), auto_created=True,
('topic', models.CharField(blank=True, max_length=100, null=True)), primary_key=True,
('endpoint', models.CharField(blank=True, max_length=100, null=True)), serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=50, unique=True)),
("unit", models.CharField(max_length=20)),
(
"protocol",
models.CharField(
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
),
),
("topic", models.CharField(blank=True, max_length=100, null=True)),
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
], ],
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='device', model_name="device",
name='humidity', name="humidity",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='device', model_name="device",
name='temperature', name="temperature",
), ),
migrations.AlterField( migrations.AlterField(
model_name='device', model_name="device",
name='protocol', name="protocol",
field=models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20), field=models.CharField(
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
),
), ),
migrations.CreateModel( migrations.CreateModel(
name='Sensor', name="Sensor",
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('enabled', models.BooleanField(default=True)), "id",
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sensors', to='iotDashboard.device')), models.BigAutoField(
('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='iotDashboard.sensortype')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("enabled", models.BooleanField(default=True)),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="sensors",
to="iotDashboard.device",
),
),
(
"type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="iotDashboard.sensortype",
),
),
], ],
), ),
] ]

View File

@@ -1,26 +1,42 @@
from django.db import models from django.db import models
class SensorType(models.Model): class SensorType(models.Model):
name = models.CharField(max_length=50, unique=True) # Sensor name, e.g., "CO2", "Noise", etc. name = models.CharField(
unit = models.CharField(max_length=20) # Unit of measurement, e.g., "ppm", "dB", "lux" max_length=50, unique=True
protocol = models.CharField(max_length=20, choices=[('mqtt', 'MQTT'), ('http', 'HTTP')]) # Protocol for communication ) # Sensor name, e.g., "CO2", "Noise", etc.
topic = models.CharField(max_length=100, null=True, blank=True) # Topic for MQTT communication unit = models.CharField(
endpoint = models.CharField(max_length=100, null=True, blank=True) # Endpoint for HTTP communication max_length=20
) # Unit of measurement, e.g., "ppm", "dB", "lux"
protocol = models.CharField(
max_length=20, choices=[("mqtt", "MQTT"), ("http", "HTTP")]
) # Protocol for communication
topic = models.CharField(
max_length=100, null=True, blank=True
) # Topic for MQTT communication
endpoint = models.CharField(
max_length=100, null=True, blank=True
) # Endpoint for HTTP communication
def __str__(self): def __str__(self):
return f"{self.name} ({self.unit})" return f"{self.name} ({self.unit})"
class Device(models.Model): class Device(models.Model):
name = models.CharField(max_length=50) # Device name name = models.CharField(max_length=50) # Device name
ip = models.CharField(max_length=20) # Device IP address ip = models.CharField(max_length=20) # Device IP address
protocol = models.CharField(max_length=20, choices=[('mqtt', 'MQTT'), ('http', 'HTTP')]) protocol = models.CharField(
max_length=20, choices=[("mqtt", "MQTT"), ("http", "HTTP")]
)
def __str__(self): def __str__(self):
return self.name return self.name
class Sensor(models.Model): class Sensor(models.Model):
device = models.ForeignKey(Device, related_name='sensors', on_delete=models.CASCADE) device = models.ForeignKey(Device, related_name="sensors", on_delete=models.CASCADE)
type = models.ForeignKey(SensorType, on_delete=models.CASCADE) type = models.ForeignKey(SensorType, on_delete=models.CASCADE)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
def __str__(self): def __str__(self):
return f"{self.type.name} Sensor on {self.device.name}" return f"{self.type.name} Sensor on {self.device.name}"

View File

@@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/ https://docs.djangoproject.com/en/4.2/ref/settings/
""" """
from dotenv import load_dotenv from dotenv import load_dotenv
from pathlib import Path from pathlib import Path
import os import os
@@ -21,14 +22,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv() load_dotenv()
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv("SECRET_KEY")
CONNECTION_STRING = os.getenv('CONNECTION_STRING') CONNECTION_STRING = os.getenv("CONNECTION_STRING")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@@ -39,64 +38,63 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'iotDashboard', "iotDashboard",
# 'huey.contrib.djhuey', # 'huey.contrib.djhuey',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = 'iotDashboard.urls' ROOT_URLCONF = "iotDashboard.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [os.path.join(BASE_DIR, 'iotDashboard/templates')], "DIRS": [os.path.join(BASE_DIR, "iotDashboard/templates")],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'iotDashboard.wsgi.application' WSGI_APPLICATION = "iotDashboard.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases # https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': BASE_DIR / 'db.sqlite3', "NAME": BASE_DIR / "db.sqlite3",
}, },
'data': { "data": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME" : "example", "NAME": "example",
"USER": "postgres", "USER": "postgres",
"PASSWORD": os.getenv('PASSWORD'), "PASSWORD": os.getenv("PASSWORD"),
"HOST": '10.10.0.1', "HOST": "10.10.0.1",
"PORT": '5555', "PORT": "5555",
} },
} }
@@ -105,16 +103,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
@@ -122,9 +120,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/ # https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC' TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
@@ -134,12 +132,12 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = "static/"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# HUEY = { # HUEY = {

View File

@@ -12,17 +12,16 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') # Default to localhost if not set REDIS_HOST = os.getenv("REDIS_HOST", "localhost") # Default to localhost if not set
try: try:
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0) redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
print(redis_client) print(redis_client)
redis_client.ping() redis_client.ping()
print('Connected!') print("Connected!")
except Exception as ex: except Exception as ex:
print print
'Error:', ex "Error:", ex
exit('Failed to connect, terminating.') exit("Failed to connect, terminating.")
def devices_to_redis(): def devices_to_redis():
@@ -32,12 +31,12 @@ def devices_to_redis():
for device in devices: for device in devices:
for sensor in device.sensors.all(): for sensor in device.sensors.all():
sensor_data = { sensor_data = {
'device_name': device.name, "device_name": device.name,
'sensor_name': sensor.type.name, "sensor_name": sensor.type.name,
'topic': sensor.type.topic # Assuming the topic is stored in SensorType "topic": sensor.type.topic, # Assuming the topic is stored in SensorType
} }
devices_list.append(sensor_data) devices_list.append(sensor_data)
redis_client.set('mqtt_devices', json.dumps(devices_list)) redis_client.set("mqtt_devices", json.dumps(devices_list))
print("Devices with sensors stored in Redis.") print("Devices with sensors stored in Redis.")
@@ -45,15 +44,17 @@ def fetch_data_http(device, sensor):
"""Fetch data from an HTTP sensor.""" """Fetch data from an HTTP sensor."""
sensor_type_name = sensor.type.name.lower() sensor_type_name = sensor.type.name.lower()
try: try:
response = requests.get(f"http://{device.ip}/sensor/{sensor_type_name}", timeout=5) response = requests.get(
f"http://{device.ip}/sensor/{sensor_type_name}", timeout=5
)
response.raise_for_status() response.raise_for_status()
sensor_value = response.json().get('value') sensor_value = response.json().get("value")
if sensor_value is not None: if sensor_value is not None:
return { return {
"time": datetime.datetime.utcnow().isoformat(), "time": datetime.datetime.utcnow().isoformat(),
"device": device.name, "device": device.name,
"sensor": sensor_type_name, "sensor": sensor_type_name,
"sensor_value": sensor_value "sensor_value": sensor_value,
} }
else: else:
print(f"No value returned from {device.name} for {sensor_type_name}") print(f"No value returned from {device.name} for {sensor_type_name}")
@@ -67,18 +68,18 @@ def fetch_data_mqtt_stream(device, sensor):
sensor_name = sensor.type.name.lower() sensor_name = sensor.type.name.lower()
stream_key = f"mqtt_stream:{device.name}:{sensor_name}" stream_key = f"mqtt_stream:{device.name}:{sensor_name}"
try: try:
stream_data = redis_client.xread({stream_key: '0-0'}, block=1000, count=1) stream_data = redis_client.xread({stream_key: "0-0"}, block=1000, count=1)
if stream_data: if stream_data:
_, entries = stream_data[0] _, entries = stream_data[0]
for entry_id, entry_data in entries: for entry_id, entry_data in entries:
sensor_value = entry_data.get(b'value') sensor_value = entry_data.get(b"value")
timestamp = entry_data.get(b'time') timestamp = entry_data.get(b"time")
if sensor_value and timestamp: if sensor_value and timestamp:
return { return {
"time": timestamp.decode('utf-8'), "time": timestamp.decode("utf-8"),
"device": device.name, "device": device.name,
"sensor_value": float(sensor_value.decode('utf-8')) "sensor_value": float(sensor_value.decode("utf-8")),
} }
except Exception as e: except Exception as e:
print(f"Error fetching data from stream {stream_key}: {e}") print(f"Error fetching data from stream {stream_key}: {e}")
@@ -93,15 +94,15 @@ def is_recent_data(timestamp):
def insert_data(data, sensor_type): def insert_data(data, sensor_type):
"""Insert parsed data into the PostgreSQL database.""" """Insert parsed data into the PostgreSQL database."""
if 'sensor_value' not in data: if "sensor_value" not in data:
print(f"Missing 'sensor_value' in data: {data}. Skipping insertion.") print(f"Missing 'sensor_value' in data: {data}. Skipping insertion.")
return return
insert_data_dict = { insert_data_dict = {
"time": data['time'], "time": data["time"],
"device": data['device'], "device": data["device"],
"metric": sensor_type.lower(), "metric": sensor_type.lower(),
"value": data['sensor_value'], "value": data["sensor_value"],
} }
try: try:
@@ -111,19 +112,24 @@ def insert_data(data, sensor_type):
INSERT INTO sensor_readings (time, device_name, metric, value) INSERT INTO sensor_readings (time, device_name, metric, value)
VALUES (%s, %s, %s, %s); VALUES (%s, %s, %s, %s);
""" """
cursor.execute(insert_query, ( cursor.execute(
insert_query,
(
insert_data_dict["time"], insert_data_dict["time"],
insert_data_dict["device"], insert_data_dict["device"],
insert_data_dict["metric"], insert_data_dict["metric"],
insert_data_dict["value"] insert_data_dict["value"],
)) ),
)
conn.commit() conn.commit()
print(f"Data inserted successfully for {insert_data_dict['device']}: {insert_data_dict}") print(
f"Data inserted successfully for {insert_data_dict['device']}: {insert_data_dict}"
)
except Exception as e: except Exception as e:
print(f"Failed to insert data: {e}") print(f"Failed to insert data: {e}")
@periodic_task(crontab(minute='*/1')) @periodic_task(crontab(minute="*/1"))
def fetch_data_from_all_devices(): def fetch_data_from_all_devices():
"""Fetch and insert data for all devices based on their protocol.""" """Fetch and insert data for all devices based on their protocol."""
devices = Device.objects.all() devices = Device.objects.all()
@@ -131,18 +137,18 @@ def fetch_data_from_all_devices():
for sensor in device.sensors.all(): for sensor in device.sensors.all():
data = None data = None
if device.protocol == 'http': if device.protocol == "http":
data = fetch_data_http(device, sensor) data = fetch_data_http(device, sensor)
elif device.protocol == 'mqtt': elif device.protocol == "mqtt":
data = fetch_data_mqtt_stream(device, sensor) data = fetch_data_mqtt_stream(device, sensor)
if data and is_recent_data(data['time']): if data and is_recent_data(data["time"]):
insert_data(data, sensor.type.name) insert_data(data, sensor.type.name)
else: else:
print(f"No recent or valid data for {device.name}. Skipping.") print(f"No recent or valid data for {device.name}. Skipping.")
@periodic_task(crontab(minute='*/5')) @periodic_task(crontab(minute="*/5"))
def last_5_minutes(): def last_5_minutes():
"""Fetch the last 5 readings from TimescaleDB and store them in Redis.""" """Fetch the last 5 readings from TimescaleDB and store them in Redis."""
try: try:
@@ -161,7 +167,7 @@ def last_5_minutes():
"time": reading[0].isoformat(), "time": reading[0].isoformat(),
"device": reading[1], "device": reading[1],
"metric": reading[2], "metric": reading[2],
"value": reading[3] "value": reading[3],
} }
for reading in results for reading in results
] ]

View File

@@ -14,24 +14,25 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from iotDashboard import views from iotDashboard import views
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
path('devices_api/', views.devices_api), path("devices_api/", views.devices_api),
path('', views.chart, name="index"), path("", views.chart, name="index"),
path('fetch_device_data/', views.fetch_device_data, name='fetch_device_data'), path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
path('devices/', views.device_list, name='device_list'), path("devices/", views.device_list, name="device_list"),
path('devices/add/', views.add_device, name='add_device'), path("devices/add/", views.add_device, name="add_device"),
path('devices/edit/<int:pk>/', views.edit_device, name='edit_device'), path("devices/edit/<int:pk>/", views.edit_device, name="edit_device"),
path('devices/delete/<int:pk>/', views.delete_device, name='delete_device'), path("devices/delete/<int:pk>/", views.delete_device, name="delete_device"),
path('logout/', views.logout_view, name='logout'), path("logout/", views.logout_view, name="logout"),
path('sensors/<int:device_id>/', views.sensor_list, name='sensor_list'), path("sensors/<int:device_id>/", views.sensor_list, name="sensor_list"),
path('sensor/add/', views.add_sensor_with_type, name="add_sensor_with_type"), path("sensor/add/", views.add_sensor_with_type, name="add_sensor_with_type"),
# path('devices/<int:device_id>/sensors/', views.sensor_list, name='sensor_list'), # path('devices/<int:device_id>/sensors/', views.sensor_list, name='sensor_list'),
path('device/<int:device_id>/add_sensor/', views.add_sensor, name='add_sensor'), path("device/<int:device_id>/add_sensor/", views.add_sensor, name="add_sensor"),
path('sensor/edit/<int:pk>/', views.edit_sensor, name='edit_sensor'), path("sensor/edit/<int:pk>/", views.edit_sensor, name="edit_sensor"),
path('sensor/delete/<int:pk>/', views.delete_sensor, name='delete_sensor'), path("sensor/delete/<int:pk>/", views.delete_sensor, name="delete_sensor"),
] ]

View File

@@ -7,21 +7,35 @@ from django.shortcuts import render, redirect, get_object_or_404
from .forms import DeviceForm, SensorWithTypeForm from .forms import DeviceForm, SensorWithTypeForm
from iotDashboard.models import Device, Sensor from iotDashboard.models import Device, Sensor
redis_client = redis.StrictRedis(host='10.10.0.1', port=6379, db=0) redis_client = redis.StrictRedis(host="10.10.0.1", port=6379, db=0)
def fetch_gpt_data(): def fetch_gpt_data():
return redis_client.get("gpt").decode("utf-8").strip('b"').replace('\\"', '"').replace("\\n", "").replace("\\","").replace("\\u00b0", "°") return (
redis_client.get("gpt")
.decode("utf-8")
.strip('b"')
.replace('\\"', '"')
.replace("\\n", "")
.replace("\\", "")
.replace("\\u00b0", "°")
)
def chart(request): def chart(request):
# Fetch devices and their related sensors # Fetch devices and their related sensors
devices = Device.objects.prefetch_related('sensors__type').all() # Prefetch related sensors and their types devices = Device.objects.prefetch_related(
"sensors__type"
).all() # Prefetch related sensors and their types
# Create a list of devices and associated sensors # Create a list of devices and associated sensors
devices_json = [ devices_json = [
{ {
"name": device.name, "name": device.name,
"sensors": [{"id": sensor.id, "type": sensor.type.name} for sensor in device.sensors.all()] "sensors": [
{"id": sensor.id, "type": sensor.type.name}
for sensor in device.sensors.all()
],
} }
for device in devices for device in devices
] ]
@@ -34,17 +48,18 @@ def chart(request):
print(f"Error fetching or parsing GPT data: {e}") print(f"Error fetching or parsing GPT data: {e}")
context = { context = {
'devices_json': json.dumps(devices_json), # Convert to a JSON string "devices_json": json.dumps(devices_json), # Convert to a JSON string
'gpt': gpt "gpt": gpt,
} }
return render(request, 'chart.html', context) return render(request, "chart.html", context)
def fetch_device_data(request): def fetch_device_data(request):
device_name = request.GET.get('device', 'Livingroom') device_name = request.GET.get("device", "Livingroom")
sensor_name = request.GET.get('sensor') # This will be the actual sensor name sensor_name = request.GET.get("sensor") # This will be the actual sensor name
start_date = request.GET.get('start_date') start_date = request.GET.get("start_date")
end_date = request.GET.get('end_date') end_date = request.GET.get("end_date")
# Log the parameters to ensure they are correct # Log the parameters to ensure they are correct
sensor_name = Sensor.objects.get(id=sensor_name).type.name sensor_name = Sensor.objects.get(id=sensor_name).type.name
@@ -98,7 +113,7 @@ def fetch_device_data(request):
# Process the results and extract times and values # Process the results and extract times and values
for row in rows: for row in rows:
time, metric, value = row time, metric, value = row
formatted_time = time.strftime('%Y-%m-%d %H:%M:%S') formatted_time = time.strftime("%Y-%m-%d %H:%M:%S")
times.append(formatted_time) times.append(formatted_time)
values.append(value) values.append(value)
@@ -106,10 +121,10 @@ def fetch_device_data(request):
# If no data is found, return empty arrays # If no data is found, return empty arrays
if not times and not values: if not times and not values:
print("No data found for the specified device and sensor.") print("No data found for the specified device and sensor.")
return JsonResponse({'times': [], 'values': []}) return JsonResponse({"times": [], "values": []})
# Return the response in the expected format # Return the response in the expected format
return JsonResponse({'times': times, 'values': values}) return JsonResponse({"times": times, "values": values})
def index(request): def index(request):
@@ -120,51 +135,51 @@ def index(request):
def device_list(request): def device_list(request):
devices = Device.objects.all() devices = Device.objects.all()
return render(request, 'device_list.html', {'devices': devices}) return render(request, "device_list.html", {"devices": devices})
def add_device(request): def add_device(request):
if request.method == 'POST': if request.method == "POST":
form = DeviceForm(request.POST) form = DeviceForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('device_list') return redirect("device_list")
else: else:
form = DeviceForm() form = DeviceForm()
return render(request, 'device_form.html', {'form': form}) return render(request, "device_form.html", {"form": form})
def edit_device(request, pk): def edit_device(request, pk):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
if request.method == 'POST': if request.method == "POST":
form = DeviceForm(request.POST, instance=device) form = DeviceForm(request.POST, instance=device)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('device_list') return redirect("device_list")
else: else:
form = DeviceForm(instance=device) form = DeviceForm(instance=device)
return render(request, 'device_form.html', {'form': form}) return render(request, "device_form.html", {"form": form})
def delete_device(request, pk): def delete_device(request, pk):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
if request.method == 'POST': if request.method == "POST":
device.delete() device.delete()
return redirect('device_list') return redirect("device_list")
return render(request, 'device_confirm_delete.html', {'device': device}) return render(request, "device_confirm_delete.html", {"device": device})
def add_sensor_with_type(request): def add_sensor_with_type(request):
if request.method == 'POST': if request.method == "POST":
form = SensorWithTypeForm(request.POST) form = SensorWithTypeForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save() # This will save both Sensor and SensorType as needed form.save() # This will save both Sensor and SensorType as needed
return redirect('device_list') # Adjust this to your specific URL name return redirect("device_list") # Adjust this to your specific URL name
else: else:
form = SensorWithTypeForm() form = SensorWithTypeForm()
context = {'form': form} context = {"form": form}
return render(request, 'sensor_form.html', context) return render(request, "sensor_form.html", context)
def logout_view(request): def logout_view(request):
@@ -172,47 +187,49 @@ def logout_view(request):
def devices_api(request): def devices_api(request):
devices = list(Device.objects.all().values('name', 'sensors__type__name')) devices = list(Device.objects.all().values("name", "sensors__type__name"))
return JsonResponse(devices, safe=False) return JsonResponse(devices, safe=False)
def sensor_list(request, device_id): def sensor_list(request, device_id):
device = get_object_or_404(Device, id=device_id) device = get_object_or_404(Device, id=device_id)
sensors = device.sensors.all() # Get sensors for this specific device sensors = device.sensors.all() # Get sensors for this specific device
return render(request, 'sensor_list.html', {'device': device, 'sensors': sensors}) return render(request, "sensor_list.html", {"device": device, "sensors": sensors})
def edit_sensor(request, pk): def edit_sensor(request, pk):
sensor = get_object_or_404(Sensor, pk=pk) sensor = get_object_or_404(Sensor, pk=pk)
if request.method == 'POST': if request.method == "POST":
form = SensorWithTypeForm(request.POST, instance=sensor) form = SensorWithTypeForm(request.POST, instance=sensor)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('sensor_list', device_id=sensor.device.pk) return redirect("sensor_list", device_id=sensor.device.pk)
else: else:
form = SensorWithTypeForm(instance=sensor) form = SensorWithTypeForm(instance=sensor)
return render(request, 'sensor_form.html', {'form': form}) return render(request, "sensor_form.html", {"form": form})
def delete_sensor(request, pk): def delete_sensor(request, pk):
sensor = get_object_or_404(Sensor, pk=pk) sensor = get_object_or_404(Sensor, pk=pk)
if request.method == 'POST': if request.method == "POST":
device_id = sensor.device.pk device_id = sensor.device.pk
sensor.delete() sensor.delete()
return redirect('sensor_list', device_id=device_id) return redirect("sensor_list", device_id=device_id)
return render(request, 'sensor_confirm_delete.html', {'sensor': sensor}) return render(request, "sensor_confirm_delete.html", {"sensor": sensor})
def add_sensor(request, device_id): def add_sensor(request, device_id):
device = get_object_or_404(Device, pk=device_id) device = get_object_or_404(Device, pk=device_id)
if request.method == 'POST': if request.method == "POST":
form = SensorWithTypeForm(request.POST) form = SensorWithTypeForm(request.POST)
if form.is_valid(): if form.is_valid():
sensor = form.save(commit=False) sensor = form.save(commit=False)
sensor.device = device # Associate the sensor with the device sensor.device = device # Associate the sensor with the device
sensor.save() sensor.save()
return redirect('device_list') # Redirect to device list or appropriate view return redirect(
"device_list"
) # Redirect to device list or appropriate view
else: else:
form = SensorWithTypeForm() form = SensorWithTypeForm()
return render(request, 'sensor_form.html', {'form': form, 'device': device}) return render(request, "sensor_form.html", {"form": form, "device": device})

View File

@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "iotDashboard.settings")
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -1,6 +0,0 @@
def main():
print("Hello from iotdashboard!")
if __name__ == "__main__":
main()

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "iotDashboard.settings")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@@ -1,124 +0,0 @@
import json
import time
import os
from datetime import datetime
import paho.mqtt.client as mqtt
import redis
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Set up Redis client
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
MQTT_PASS=os.getenv("MQTT_PASS")
MQTT_USER=os.getenv("MQTT_USER")
try:
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
print(redis_client)
redis_client.ping()
print('Connected!')
except Exception as ex:
print
'Error:', ex
exit('Failed to connect, terminating.')
MQTT_BROKER = os.getenv('MQTT_BROKER', 'localhost')
mqtt_data = {}
def get_mqtt_devices():
"""Retrieve MQTT devices and sensor details from Redis."""
devices_json = redis_client.get('mqtt_devices')
if devices_json:
return json.loads(devices_json)
return []
def build_device_map():
"""Build a mapping of device endpoints to friendly names."""
devices = get_mqtt_devices()
return {device['topic'].split('/')[0]: device['device_name'] for device in devices}
def publish_to_stream(stream_name, data):
"""Append a message to Redis Stream."""
try:
redis_client.xadd(stream_name, data)
print(f"Published to Redis Stream '{stream_name}': {data}")
except redis.RedisError as e:
print(f"Error writing to Redis Stream: {e}")
def on_message(client, userdata, msg):
"""Handle incoming messages from MQTT broker."""
try:
topic_parts = msg.topic.split('/')
device_endpoint = topic_parts[0]
sensor_type = topic_parts[2]
sensor_value = float(msg.payload.decode())
print(f"Received message from {device_endpoint}, sensor {sensor_type}: {sensor_value}")
device_map = build_device_map()
device_name = device_map.get(device_endpoint, device_endpoint)
if device_name not in mqtt_data:
mqtt_data[device_name] = {
"time": datetime.utcnow().isoformat(),
"device": device_name,
"sensors": {}
}
mqtt_data[device_name]["sensors"][sensor_type] = sensor_value
mqtt_data[device_name]["time"] = datetime.utcnow().isoformat()
publish_to_stream(device_name, mqtt_data[device_name])
print(f"Updated data for {device_name}: {mqtt_data[device_name]}")
except ValueError as e:
print(f"Error processing message payload: {e}")
def on_connect(client, userdata, flags, rc):
"""Handle successful MQTT connection."""
if rc == 0:
print("Connected to MQTT Broker")
devices = get_mqtt_devices()
for device in devices:
client.subscribe(device['topic']) # Subscribing to each device's topic
print(f"Subscribed to topic: {device['topic']}")
else:
print(f"Failed to connect, return code {rc}")
def on_disconnect(client, userdata, rc):
"""Handle disconnection from MQTT broker."""
print(f"Disconnected with result code: {rc}")
def start_mqtt_client():
"""Start the MQTT client to begin listening to topics."""
client = mqtt.Client()
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.username_pw_set(MQTT_USER,MQTT_PASS)
client.connect(MQTT_BROKER)
client.loop_start()
print("MQTT Client Started")
try:
while True:
time.sleep(10)
except KeyboardInterrupt:
print("Script interrupted by user")
finally:
client.loop_stop()
if __name__ == "__main__":
start_mqtt_client()

View File

@@ -1,35 +0,0 @@
import os
from datetime import datetime
import redis
from dotenv import load_dotenv
load_dotenv()
REDIS_HOST = os.getenv('REDIS_HOST')
try:
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
except redis.RedisError as e:
raise e
STREAM_NAME = 'sensor_data_stream'
def publish_to_stream(stream_name, data):
"""Publish a message to the Redis Stream."""
try:
redis_client.xadd(stream_name, data)
print(f"Published to Redis Stream '{stream_name}': {data}")
except redis.RedisError as e:
print(f"Error writing to Redis Stream: {e}")
if __name__ == "__main__":
mqtt_data = {
"time": datetime.utcnow().isoformat(),
"device": "Livingroom",
"metric": "temperature",
"value": 25.6
}
publish_to_stream(STREAM_NAME, mqtt_data)

View File

@@ -14,3 +14,8 @@ dependencies = [
"redis>=7.0.1", "redis>=7.0.1",
"requests>=2.32.5", "requests>=2.32.5",
] ]
[dependency-groups]
dev = [
"ruff>=0.14.2",
]

View File

@@ -2,6 +2,7 @@
Configuration management for the database writer service. Configuration management for the database writer service.
Loads settings from environment variables with sensible defaults. Loads settings from environment variables with sensible defaults.
""" """
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@@ -13,6 +14,7 @@ dotenv.load_dotenv()
@dataclass @dataclass
class RedisConfig: class RedisConfig:
"""Redis connection configuration""" """Redis connection configuration"""
host: str host: str
port: int = 6379 port: int = 6379
db: int = 0 db: int = 0
@@ -22,6 +24,7 @@ class RedisConfig:
@dataclass @dataclass
class DatabaseConfig: class DatabaseConfig:
"""Database connection configuration""" """Database connection configuration"""
url: Optional[str] = None url: Optional[str] = None
host: Optional[str] = None host: Optional[str] = None
port: int = 5432 port: int = 5432
@@ -45,6 +48,7 @@ class DatabaseConfig:
@dataclass @dataclass
class ConsumerConfig: class ConsumerConfig:
"""Redis consumer group configuration""" """Redis consumer group configuration"""
group_name: str = "db_writer" group_name: str = "db_writer"
consumer_name: str = "worker-01" consumer_name: str = "worker-01"
batch_size: int = 100 batch_size: int = 100
@@ -56,6 +60,7 @@ class ConsumerConfig:
@dataclass @dataclass
class StreamConfig: class StreamConfig:
"""Redis stream configuration""" """Redis stream configuration"""
pattern: str = "mqtt_stream:*" pattern: str = "mqtt_stream:*"
dead_letter_stream: str = "mqtt_stream:failed" dead_letter_stream: str = "mqtt_stream:failed"
max_retries: int = 3 max_retries: int = 3
@@ -65,6 +70,7 @@ class StreamConfig:
@dataclass @dataclass
class LogConfig: class LogConfig:
"""Logging configuration""" """Logging configuration"""
level: str = "INFO" level: str = "INFO"
format: str = "json" # json or console format: str = "json" # json or console
@@ -74,59 +80,53 @@ class Config:
def __init__(self): def __init__(self):
self.redis = RedisConfig( self.redis = RedisConfig(
host=os.getenv('REDIS_HOST', 'localhost'), host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv('REDIS_PORT', 6379)), port=int(os.getenv("REDIS_PORT", 6379)),
db=int(os.getenv('REDIS_DB', 0)), db=int(os.getenv("REDIS_DB", 0)),
password=os.getenv('REDIS_PASSWORD', None) or None password=os.getenv("REDIS_PASSWORD", None) or None,
) )
self.database = DatabaseConfig( self.database = DatabaseConfig(
url=os.getenv('DATABASE_URL', None), url=os.getenv("DATABASE_URL", None),
host=os.getenv('DB_HOST', None), host=os.getenv("DB_HOST", None),
port=int(os.getenv('DB_PORT', 5432)), port=int(os.getenv("DB_PORT", 5432)),
name=os.getenv('DB_NAME', None), name=os.getenv("DB_NAME", None),
user=os.getenv('DB_USER', None), user=os.getenv("DB_USER", None),
password=os.getenv('DB_PASSWORD', None), password=os.getenv("DB_PASSWORD", None),
table_name=os.getenv('TABLE_NAME', 'sensor_readings'), table_name=os.getenv("TABLE_NAME", "sensor_readings"),
enable_timescale=os.getenv('ENABLE_TIMESCALE', 'false').lower() == 'true' enable_timescale=os.getenv("ENABLE_TIMESCALE", "false").lower() == "true",
) )
self.consumer = ConsumerConfig( self.consumer = ConsumerConfig(
group_name=os.getenv('CONSUMER_GROUP_NAME', 'db_writer'), group_name=os.getenv("CONSUMER_GROUP_NAME", "db_writer"),
consumer_name=os.getenv('CONSUMER_NAME', 'worker-01'), consumer_name=os.getenv("CONSUMER_NAME", "worker-01"),
batch_size=int(os.getenv('BATCH_SIZE', 100)), batch_size=int(os.getenv("BATCH_SIZE", 100)),
batch_timeout_sec=int(os.getenv('BATCH_TIMEOUT_SEC', 5)), batch_timeout_sec=int(os.getenv("BATCH_TIMEOUT_SEC", 5)),
processing_interval_sec=float(os.getenv('PROCESSING_INTERVAL_SEC', 1.0)), processing_interval_sec=float(os.getenv("PROCESSING_INTERVAL_SEC", 1.0)),
block_time_ms=int(os.getenv('BLOCK_TIME_MS', 5000)) block_time_ms=int(os.getenv("BLOCK_TIME_MS", 5000)),
) )
self.stream = StreamConfig( self.stream = StreamConfig(
pattern=os.getenv('STREAM_PATTERN', 'mqtt_stream:*'), max_retries=int(os.getenv("MAX_RETRIES", 3)),
dead_letter_stream=os.getenv('DEAD_LETTER_STREAM', 'mqtt_stream:failed'), trim_maxlen=int(os.getenv("TRIM_MAXLEN", 10000)),
max_retries=int(os.getenv('MAX_RETRIES', 3)),
trim_maxlen=int(os.getenv('TRIM_MAXLEN', 10000))
) )
self.log = LogConfig( self.log = LogConfig(
level=os.getenv('LOG_LEVEL', 'INFO'), level=os.getenv("LOG_LEVEL", "INFO"), format=os.getenv("LOG_FORMAT", "json")
format=os.getenv('LOG_FORMAT', 'json')
) )
def validate(self): def validate(self):
"""Validate configuration""" """Validate configuration"""
errors = [] errors = []
# Validate Redis config
if not self.redis.host: if not self.redis.host:
errors.append("REDIS_HOST is required") errors.append("REDIS_HOST is required")
# Validate database config
try: try:
self.database.get_connection_string() self.database.get_connection_string()
except ValueError as e: except ValueError as e:
errors.append(str(e)) errors.append(str(e))
# Validate consumer config
if self.consumer.batch_size < 1: if self.consumer.batch_size < 1:
errors.append("BATCH_SIZE must be >= 1") errors.append("BATCH_SIZE must be >= 1")
@@ -136,5 +136,4 @@ class Config:
return True return True
# Global config instance
config = Config() config = Config()

View File

@@ -5,27 +5,18 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool from sqlalchemy.pool import QueuePool
from config import config from config import config
from schema import SensorReading from schema import TelemetryReading
from models import SensorReading as SensorReadingModel from models import Telemetry
class DatabaseWriter: class DatabaseWriter:
""" """
Database writer using SQLAlchemy. Database writer for telemetry data.
Schema is defined in models.py and should be managed using Alembic migrations.
This class only handles data insertion, NOT schema creation.
To manage schema:
1. Edit models.py to define your schema
2. Generate migration: alembic revision --autogenerate -m "description"
3. Apply migration: alembic upgrade head
""" """
def __init__(self): def __init__(self):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Initialize SQLAlchemy engine with connection pooling
connection_string = config.database.get_connection_string() connection_string = config.database.get_connection_string()
self.engine = create_engine( self.engine = create_engine(
@@ -33,29 +24,28 @@ class DatabaseWriter:
poolclass=QueuePool, poolclass=QueuePool,
pool_size=5, pool_size=5,
max_overflow=10, max_overflow=10,
pool_pre_ping=True pool_pre_ping=True,
) )
# Create session factory
self.SessionLocal = sessionmaker(bind=self.engine) self.SessionLocal = sessionmaker(bind=self.engine)
self.logger.info("Database writer initialized") self.logger.info("Database writer initialized")
def write_batch(self, readings: List[SensorReading]) -> bool: def write_batch(self, readings: List[TelemetryReading]) -> bool:
"""Write a batch of sensor readings to the database""" """Write a batch of telemetry readings to the database"""
if not readings: if not readings:
return True return True
session = self.SessionLocal() session = self.SessionLocal()
try: try:
# Convert to database objects # Convert to database objects using the correct field mapping
db_objects = [ db_objects = [
SensorReadingModel( Telemetry(
timestamp=reading.timestamp, time=reading.time,
device_id=reading.device_id, device_id=reading.device_id,
sensor_type=reading.sensor_type, metric=reading.metric,
value=reading.value, value=reading.value,
metadata=reading.metadata unit=reading.unit,
) )
for reading in readings for reading in readings
] ]
@@ -86,6 +76,6 @@ class DatabaseWriter:
def close(self): def close(self):
"""Close database engine and all connections""" """Close database engine and all connections"""
if hasattr(self, 'engine') and self.engine: if hasattr(self, "engine") and self.engine:
self.engine.dispose() self.engine.dispose()
self.logger.info("Database engine closed") self.logger.info("Database engine closed")

View File

@@ -2,6 +2,7 @@
Main entry point for the database writer service. Main entry point for the database writer service.
Orchestrates the read → transform → write cycle with error handling. Orchestrates the read → transform → write cycle with error handling.
""" """
import logging import logging
import signal import signal
import sys import sys
@@ -12,12 +13,12 @@ from typing import List
from config import config from config import config
from redis_reader import RedisReader from redis_reader import RedisReader
from db_writer import DatabaseWriter from db_writer import DatabaseWriter
from schema import SchemaHandler, StreamMessage, SensorReading from schema import SchemaHandler, StreamMessage, TelemetryReading
def configure_logging(): def configure_logging():
"""Configure structured logging""" """Configure structured logging"""
if config.log.format == 'json': if config.log.format == "json":
structlog.configure( structlog.configure(
processors=[ processors=[
structlog.stdlib.filter_by_level, structlog.stdlib.filter_by_level,
@@ -27,7 +28,7 @@ def configure_logging():
structlog.processors.TimeStamper(fmt="iso"), structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(), structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info, structlog.processors.format_exc_info,
structlog.processors.JSONRenderer() structlog.processors.JSONRenderer(),
], ],
wrapper_class=structlog.stdlib.BoundLogger, wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict, context_class=dict,
@@ -35,10 +36,9 @@ def configure_logging():
cache_logger_on_first_use=True, cache_logger_on_first_use=True,
) )
# Configure standard logging
logging.basicConfig( logging.basicConfig(
level=getattr(logging, config.log.level.upper(), logging.INFO), level=getattr(logging, config.log.level.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
) )
@@ -52,17 +52,15 @@ class DatabaseWriterService:
self.schema_handler: SchemaHandler = None self.schema_handler: SchemaHandler = None
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Setup signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGINT, self._signal_handler)
# Statistics
self.stats = { self.stats = {
'messages_read': 0, "messages_read": 0,
'messages_written': 0, "messages_written": 0,
'messages_failed': 0, "messages_failed": 0,
'batches_processed': 0, "batches_processed": 0,
'errors': 0 "errors": 0,
} }
def _signal_handler(self, signum, frame): def _signal_handler(self, signum, frame):
@@ -75,11 +73,9 @@ class DatabaseWriterService:
self.logger.info("Starting Database Writer Service...") self.logger.info("Starting Database Writer Service...")
try: try:
# Validate configuration
config.validate() config.validate()
self.logger.info("Configuration validated successfully") self.logger.info("Configuration validated successfully")
# Initialize components
self.schema_handler = SchemaHandler() self.schema_handler = SchemaHandler()
self.logger.info("Schema handler initialized") self.logger.info("Schema handler initialized")
@@ -116,7 +112,7 @@ class DatabaseWriterService:
time.sleep(config.consumer.processing_interval_sec) time.sleep(config.consumer.processing_interval_sec)
continue continue
self.stats['messages_read'] += len(messages) self.stats["messages_read"] += len(messages)
self.logger.debug(f"Read {len(messages)} messages from Redis") self.logger.debug(f"Read {len(messages)} messages from Redis")
# Transform messages to sensor readings # Transform messages to sensor readings
@@ -129,25 +125,28 @@ class DatabaseWriterService:
continue continue
# Write to database # Write to database
result = self.db_writer.write_batch(readings) success = self.db_writer.write_batch(readings)
if result.success: if success:
# Successfully written, acknowledge the messages # Successfully written, acknowledge the messages
ack_count = self.redis_reader.acknowledge_batch(messages) ack_count = self.redis_reader.acknowledge_batch(messages)
self.stats['messages_written'] += result.rows_written self.stats["messages_written"] += len(readings)
self.stats['batches_processed'] += 1 self.stats["batches_processed"] += 1
consecutive_errors = 0 consecutive_errors = 0
self.logger.info( self.logger.info(
f"Processed batch: {result.rows_written} readings written, " f"Processed batch: {len(readings)} readings written, "
f"{ack_count} messages acknowledged" f"{ack_count} messages acknowledged"
) )
else: else:
# Write failed, send to dead letter queue # Write failed, log error and acknowledge to prevent blocking
self.logger.error(f"Failed to write batch: {result.error}") self.logger.error(
self._handle_failed_batch(messages, result.error) f"Failed to write batch of {len(readings)} readings"
self.stats['messages_failed'] += len(messages) )
self.stats['errors'] += 1 # Acknowledge anyway so they don't block the queue
self.redis_reader.acknowledge_batch(messages)
self.stats["messages_failed"] += len(messages)
self.stats["errors"] += 1
consecutive_errors += 1 consecutive_errors += 1
# Check for too many consecutive errors # Check for too many consecutive errors
@@ -168,13 +167,15 @@ class DatabaseWriterService:
break break
except Exception as e: except Exception as e:
self.logger.error(f"Error in processing loop: {e}", exc_info=True) self.logger.error(f"Error in processing loop: {e}", exc_info=True)
self.stats['errors'] += 1 self.stats["errors"] += 1
consecutive_errors += 1 consecutive_errors += 1
time.sleep(5) # Back off on errors time.sleep(5) # Back off on errors
self.logger.info("Processing loop terminated") self.logger.info("Processing loop terminated")
def _transform_messages(self, messages: List[StreamMessage]) -> List[SensorReading]: def _transform_messages(
self, messages: List[StreamMessage]
) -> List[TelemetryReading]:
"""Transform stream messages to sensor readings""" """Transform stream messages to sensor readings"""
readings = [] readings = []
@@ -189,17 +190,6 @@ class DatabaseWriterService:
return readings return readings
def _handle_failed_batch(self, messages: List[StreamMessage], error: str):
"""Handle a batch that failed to write to database"""
# Send all messages to dead letter queue
for msg in messages:
self.redis_reader.send_to_dead_letter(msg, error)
# Acknowledge them so they don't block the consumer group
self.redis_reader.acknowledge_batch(messages)
self.logger.warning(f"Sent {len(messages)} messages to dead letter queue")
def stop(self): def stop(self):
"""Stop the service gracefully""" """Stop the service gracefully"""
if not self.running: if not self.running:
@@ -230,17 +220,17 @@ class DatabaseWriterService:
def health_check(self) -> dict: def health_check(self) -> dict:
"""Check service health""" """Check service health"""
health = { health = {
'running': self.running, "running": self.running,
'redis': False, "redis": False,
'database': False, "database": False,
'stats': self.stats "stats": self.stats,
} }
if self.redis_reader: if self.redis_reader:
health['redis'] = self.redis_reader.health_check() health["redis"] = self.redis_reader.health_check()
if self.db_writer: if self.db_writer:
health['database'] = self.db_writer.health_check() health["database"] = self.db_writer.health_check()
return health return health
@@ -277,5 +267,5 @@ def main():
logger.info("Service exited") logger.info("Service exited")
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@@ -1,4 +1,3 @@
import redis import redis
import logging import logging
from typing import List, Optional, Dict from typing import List, Optional, Dict
@@ -9,7 +8,7 @@ from schema import SchemaHandler, StreamMessage
class RedisReader: class RedisReader:
"""Redis stream consumer with consumer groups for reliability""" """Redis stream consumer with consumer groups for reliability"""
def __init__(self, streams: Optional[List[str]] = None): def __init__(self, stream_name: str = "mqtt:ingestion"):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.schema_handler = SchemaHandler() self.schema_handler = SchemaHandler()
@@ -18,51 +17,50 @@ class RedisReader:
port=config.redis.port, port=config.redis.port,
db=config.redis.db, db=config.redis.db,
password=config.redis.password, password=config.redis.password,
decode_responses=False decode_responses=False,
) )
self.redis_client.ping() self.redis_client.ping()
self.logger.info(f"Connected to Redis at {config.redis.host}:{config.redis.port}") self.logger.info(
f"Connected to Redis at {config.redis.host}:{config.redis.port}"
)
if streams: # Use single stream instead of pattern matching
self.streams = streams self.stream_name = stream_name
else: self.logger.info(f"Monitoring stream: {self.stream_name}")
pattern = config.stream.pattern
keys = self.redis_client.keys(pattern)
self.streams = [k.decode('utf-8') if isinstance(k, bytes) else k for k in keys]
self.logger.info(f"Monitoring {len(self.streams)} streams") # Initialize consumer group for the single stream
self._setup_consumer_group()
# Initialize consumer groups def _setup_consumer_group(self):
self._setup_consumer_groups() """Create consumer group for the single stream"""
def _setup_consumer_groups(self):
"""Create consumer groups for streams"""
group_name = config.consumer.group_name group_name = config.consumer.group_name
for stream in self.streams:
try: try:
self.redis_client.xgroup_create(stream, group_name, id='0', mkstream=True) self.redis_client.xgroup_create(
self.logger.info(f"Created consumer group '{group_name}' for '{stream}'") self.stream_name, group_name, id="0", mkstream=True
)
self.logger.info(
f"Created consumer group '{group_name}' for '{self.stream_name}'"
)
except redis.exceptions.ResponseError as e: except redis.exceptions.ResponseError as e:
if 'BUSYGROUP' in str(e): if "BUSYGROUP" in str(e):
self.logger.debug(f"Consumer group '{group_name}' already exists for '{stream}'") self.logger.debug(
f"Consumer group '{group_name}' already exists for '{self.stream_name}'"
)
else: else:
self.logger.error(f"Error creating consumer group for {stream}: {e}") self.logger.error(f"Error creating consumer group: {e}")
def read_batch(self, batch_size: Optional[int] = None, def read_batch(
timeout_ms: Optional[int] = None) -> List[StreamMessage]: self, batch_size: Optional[int] = None, timeout_ms: Optional[int] = None
"""Read a batch of messages from streams using consumer group""" ) -> List[StreamMessage]:
"""Read a batch of messages from single stream using consumer group"""
if batch_size is None: if batch_size is None:
batch_size = config.consumer.batch_size batch_size = config.consumer.batch_size
if timeout_ms is None: if timeout_ms is None:
timeout_ms = config.consumer.block_time_ms timeout_ms = config.consumer.block_time_ms
if not self.streams: # Read from single stream - much simpler!
return [] stream_dict = {self.stream_name: ">"}
# Prepare stream dict for XREADGROUP
stream_dict = {stream: '>' for stream in self.streams}
try: try:
results = self.redis_client.xreadgroup( results = self.redis_client.xreadgroup(
@@ -70,7 +68,7 @@ class RedisReader:
consumername=config.consumer.consumer_name, consumername=config.consumer.consumer_name,
streams=stream_dict, streams=stream_dict,
count=batch_size, count=batch_size,
block=timeout_ms block=timeout_ms,
) )
if not results: if not results:
@@ -79,12 +77,17 @@ class RedisReader:
# Parse results into StreamMessage objects # Parse results into StreamMessage objects
messages = [] messages = []
for stream_key, entries in results: for stream_key, entries in results:
stream_name = stream_key.decode('utf-8') if isinstance(stream_key, bytes) else stream_key
for message_id, fields in entries: for message_id, fields in entries:
msg_id = message_id.decode('utf-8') if isinstance(message_id, bytes) else message_id msg_id = (
message_id.decode("utf-8")
if isinstance(message_id, bytes)
else message_id
)
stream_msg = self.schema_handler.parse_stream_entry(stream_name, msg_id, fields) # Parse with new format (device_id and metric in payload)
stream_msg = self.schema_handler.parse_stream_entry_new_format(
self.stream_name, msg_id, fields
)
if stream_msg: if stream_msg:
messages.append(stream_msg) messages.append(stream_msg)
@@ -112,13 +115,13 @@ class RedisReader:
for stream_key, message_ids in by_stream.items(): for stream_key, message_ids in by_stream.items():
try: try:
result = self.redis_client.xack( result = self.redis_client.xack(
stream_key, stream_key, config.consumer.group_name, *message_ids
config.consumer.group_name,
*message_ids
) )
ack_count += result ack_count += result
except Exception as e: except Exception as e:
self.logger.error(f"Failed to acknowledge messages from {stream_key}: {e}") self.logger.error(
f"Failed to acknowledge messages from {stream_key}: {e}"
)
if ack_count > 0: if ack_count > 0:
self.logger.debug(f"Acknowledged {ack_count} messages") self.logger.debug(f"Acknowledged {ack_count} messages")

View File

@@ -2,6 +2,7 @@
Schema definitions and data transformation logic. Schema definitions and data transformation logic.
Handles conversion between Redis stream messages and database records. Handles conversion between Redis stream messages and database records.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -11,6 +12,7 @@ import json
@dataclass @dataclass
class StreamMessage: class StreamMessage:
"""Represents a message from Redis stream""" """Represents a message from Redis stream"""
stream_key: str stream_key: str
message_id: str message_id: str
device_id: str device_id: str
@@ -26,28 +28,30 @@ class StreamMessage:
@dataclass @dataclass
class SensorReading: class TelemetryReading:
"""Represents a sensor reading ready for database insertion""" """Represents a telemetry reading ready for database insertion - matches Telemetry model"""
timestamp: datetime
time: datetime
device_id: str device_id: str
sensor_type: str metric: str # renamed from sensor_type
value: float value: float
metadata: Optional[Dict[str, Any]] = None unit: Optional[str] = None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for database insertion""" """Convert to dictionary for database insertion"""
return { return {
'timestamp': self.timestamp, "time": self.time,
'device_id': self.device_id, "device_id": self.device_id,
'sensor_type': self.sensor_type, "metric": self.metric,
'value': self.value, "value": self.value,
'metadata': json.dumps(self.metadata) if self.metadata else None "unit": self.unit,
} }
@dataclass @dataclass
class ValidationResult: class ValidationResult:
"""Result of data validation""" """Result of data validation"""
valid: bool valid: bool
error: Optional[str] = None error: Optional[str] = None
@@ -61,24 +65,27 @@ class SchemaHandler:
def _get_logger(self): def _get_logger(self):
"""Get logger instance""" """Get logger instance"""
import logging import logging
return logging.getLogger(__name__) return logging.getLogger(__name__)
def transform_message(self, stream_message: StreamMessage) -> Optional[SensorReading]: def transform_message(
self, stream_message: StreamMessage
) -> Optional[TelemetryReading]:
""" """
Transform a Redis stream message into a SensorReading. Transform a Redis stream message into a TelemetryReading.
Returns None if transformation fails. Returns None if transformation fails.
""" """
try: try:
# Parse timestamp
timestamp = self._parse_timestamp(stream_message.timestamp) timestamp = self._parse_timestamp(stream_message.timestamp)
# Create sensor reading reading = TelemetryReading(
reading = SensorReading( time=timestamp,
timestamp=timestamp,
device_id=stream_message.device_id, device_id=stream_message.device_id,
sensor_type=stream_message.sensor_type, metric=stream_message.sensor_type, # sensor_type maps to metric
value=float(stream_message.value), value=float(stream_message.value),
metadata=stream_message.metadata unit=stream_message.metadata.get("unit")
if stream_message.metadata
else None,
) )
# Validate the reading # Validate the reading
@@ -93,28 +100,25 @@ class SchemaHandler:
self.logger.error(f"Failed to transform message: {e}", exc_info=True) self.logger.error(f"Failed to transform message: {e}", exc_info=True)
return None return None
def validate_reading(self, reading: SensorReading) -> ValidationResult: def validate_reading(self, reading: TelemetryReading) -> ValidationResult:
"""Validate a sensor reading""" """Validate a telemetry reading"""
try: try:
# Check required fields # Check required fields
if not reading.device_id: if not reading.device_id:
return ValidationResult(False, "device_id is required") return ValidationResult(False, "device_id is required")
if not reading.sensor_type: if not reading.metric:
return ValidationResult(False, "sensor_type is required") return ValidationResult(False, "metric is required")
if reading.value is None: if reading.value is None:
return ValidationResult(False, "value is required") return ValidationResult(False, "value is required")
# Validate timestamp if not isinstance(reading.time, datetime):
if not isinstance(reading.timestamp, datetime): return ValidationResult(False, "time must be a datetime object")
return ValidationResult(False, "timestamp must be a datetime object")
# Validate value is numeric
if not isinstance(reading.value, (int, float)): if not isinstance(reading.value, (int, float)):
return ValidationResult(False, "value must be numeric") return ValidationResult(False, "value must be numeric")
# Check for reasonable value ranges (can be customized)
if reading.value < -1000000 or reading.value > 1000000: if reading.value < -1000000 or reading.value > 1000000:
self.logger.warning(f"Value {reading.value} is outside typical range") self.logger.warning(f"Value {reading.value} is outside typical range")
@@ -125,18 +129,16 @@ class SchemaHandler:
def _parse_timestamp(self, timestamp_str: str) -> datetime: def _parse_timestamp(self, timestamp_str: str) -> datetime:
"""Parse timestamp string into datetime object""" """Parse timestamp string into datetime object"""
# Try ISO format first
try: try:
return datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) return datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
except ValueError: except ValueError:
pass pass
# Try common formats
formats = [ formats = [
'%Y-%m-%dT%H:%M:%S.%fZ', "%Y-%m-%dT%H:%M:%S.%fZ",
'%Y-%m-%dT%H:%M:%SZ', "%Y-%m-%dT%H:%M:%SZ",
'%Y-%m-%d %H:%M:%S.%f', "%Y-%m-%d %H:%M:%S.%f",
'%Y-%m-%d %H:%M:%S', "%Y-%m-%d %H:%M:%S",
] ]
for fmt in formats: for fmt in formats:
@@ -145,54 +147,57 @@ class SchemaHandler:
except ValueError: except ValueError:
continue continue
# If all else fails, use current time and log warning self.logger.warning(
self.logger.warning(f"Could not parse timestamp '{timestamp_str}', using current time") f"Could not parse timestamp '{timestamp_str}', using current time"
)
return datetime.utcnow() return datetime.utcnow()
def parse_stream_entry(self, stream_key: str, message_id: str, fields: Dict[bytes, bytes]) -> Optional[StreamMessage]: def parse_stream_entry_new_format(
self, stream_key: str, message_id: str, fields: Dict[bytes, bytes]
) -> Optional[StreamMessage]:
""" """
Parse a raw Redis stream entry into a StreamMessage. Parse a raw Redis stream entry with NEW single-stream format.
Expected stream key format: mqtt_stream:{device_id}:{sensor_type} Expected fields: device_id, metric, value, timestamp
Expected fields: value, timestamp (and optionally metadata)
""" """
try: try:
# Extract device_id and sensor_type from stream key # Extract fields from message (device_id and metric are IN the payload now!)
# Format: mqtt_stream:{device_id}:{sensor_type} device_id_bytes = fields.get(b"device_id")
parts = stream_key.split(':') metric_bytes = fields.get(b"metric")
if len(parts) < 3: value_bytes = fields.get(b"value")
self.logger.error(f"Invalid stream key format: {stream_key}") timestamp_bytes = fields.get(b"timestamp") or fields.get(b"time")
return None
device_id = parts[1] if not all([device_id_bytes, metric_bytes, value_bytes, timestamp_bytes]):
sensor_type = ':'.join(parts[2:]) # Handle sensor types with colons
# Extract fields from message
value_bytes = fields.get(b'value')
timestamp_bytes = fields.get(b'timestamp') or fields.get(b'time')
if not value_bytes or not timestamp_bytes:
self.logger.error(f"Missing required fields in message: {fields}") self.logger.error(f"Missing required fields in message: {fields}")
return None return None
# Parse metadata if present # Parse metadata if present
metadata = None metadata = None
metadata_bytes = fields.get(b'metadata') metadata_bytes = fields.get(b"metadata")
if metadata_bytes: if metadata_bytes:
try: try:
metadata = json.loads(metadata_bytes.decode('utf-8')) metadata = json.loads(metadata_bytes.decode("utf-8"))
except json.JSONDecodeError: except json.JSONDecodeError:
self.logger.warning(f"Could not parse metadata: {metadata_bytes}") self.logger.warning(f"Could not parse metadata: {metadata_bytes}")
return StreamMessage( return StreamMessage(
stream_key=stream_key, stream_key=stream_key,
message_id=message_id, message_id=message_id,
device_id=device_id, device_id=device_id_bytes.decode("utf-8"),
sensor_type=sensor_type, sensor_type=metric_bytes.decode("utf-8"),
value=float(value_bytes.decode('utf-8')), value=float(value_bytes.decode("utf-8")),
timestamp=timestamp_bytes.decode('utf-8'), timestamp=timestamp_bytes.decode("utf-8"),
metadata=metadata metadata=metadata,
) )
except Exception as e: except Exception as e:
self.logger.error(f"Failed to parse stream entry: {e}", exc_info=True) self.logger.error(f"Failed to parse stream entry: {e}", exc_info=True)
return None return None
def parse_stream_entry(
self, stream_key: str, message_id: str, fields: Dict[bytes, bytes]
) -> Optional[StreamMessage]:
"""
DEPRECATED: Old format with stream key containing device_id.
Kept for backward compatibility. Use parse_stream_entry_new_format() instead.
"""
return self.parse_stream_entry_new_format(stream_key, message_id, fields)

6
services/device_manager/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
certs/
*.pem
__pycache__/
*.pyc
.env
.venv/

View File

@@ -1,3 +1,73 @@
# Device Manager # Device Manager Service
This is a service that has a key role in devices and their management. It is used to provision, create, manage the lifecycle of devices and everything connected to it. The Device Manager service handles device registration, certificate issuance, and lifecycle management for the IoT dashboard. It provides a REST API for device onboarding and integrates with the MQTT broker via mTLS authentication.
## Architecture
The service provides:
- **Device Registration**: REST API endpoint to register new devices
- **Certificate Management**: Generates X.509 certificates signed by internal CA
- **Device Lifecycle**: Tracks device status (active, revoked)
- **mTLS Integration**: Certificates used for MQTT broker authentication
### Technology Stack
- **FastAPI**: REST API framework
- **SQLAlchemy**: Database ORM
- **Cryptography**: X.509 certificate generation
- **Nano ID**: 8-character device identifiers
- **PostgreSQL**: Device and certificate storage
## API Endpoints
### Register Device
```http
POST /api/v1/devices/register
Content-Type: application/json
{
"name": "Living Room Sensor",
"location": "Living Room"
}
```
**Response (201):**
```json
{
"device_id": "a1b2c3d4",
"name": "Living Room Sensor",
"location": "Living Room",
"certificate": "-----BEGIN CERTIFICATE-----\n...",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\n...",
"ca_certificate": "-----BEGIN CERTIFICATE-----\n...",
"expires_at": "2026-01-15T10:30:00Z"
}
```
### Get Device
```http
GET /api/v1/devices/{device_id}
```
**Response (200):**
```json
{
"device_id": "a1b2c3d4",
"name": "Living Room Sensor",
"location": "Living Room",
"is_active": true,
"created_at": "2025-01-15T10:30:00Z"
}
```
### Revoke Device Certificate
```http
POST /api/v1/devices/{device_id}/revoke
```
**Response (200):**
```json
{
"device_id": "a1b2c3d4",
"revoked_at": "2025-06-15T10:30:00Z"
}
```

View File

@@ -0,0 +1,137 @@
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from nanoid import generate
from config import config
from models import DeviceRegistrationResponse
lowercase_numbers = "abcdefghijklmnopqrstuvwxyz0123456789"
class CertificateManager:
"""Manages device certificate generation and handling"""
def __init__(self):
self.ca_cert: x509.Certificate = self.load_ca_certificate(config.CA_CERT_PATH)
self.ca_key: rsa.RSAPrivateKey = self.load_ca_private_key(config.CA_KEY_PATH)
self.ca_cert_pem: bytes = self.ca_cert.public_bytes(serialization.Encoding.PEM)
def generate_device_id(self) -> str:
"""Generate a unique device ID using nanoid."""
return generate(alphabet=lowercase_numbers, size=config.DEVICE_ID_LENGTH)
def load_ca_certificate(self, ca_cert_path: str) -> x509.Certificate:
"""Load a CA certificate from file."""
with open(ca_cert_path, "rb") as f:
ca_data = f.read()
ca_cert = x509.load_pem_x509_certificate(ca_data)
return ca_cert
def load_ca_private_key(self, ca_key_path: str, password: bytes = None) -> rsa.RSAPrivateKey:
"""Load a CA private key from file."""
from cryptography.hazmat.primitives import serialization
with open(ca_key_path, "rb") as f:
key_data = f.read()
ca_key = serialization.load_pem_private_key(key_data, password=password)
return ca_key
def generate_device_key(self, key_size: int = 4096) -> rsa.RSAPrivateKey:
"""Generate an RSA private key for a device."""
return rsa.generate_private_key(public_exponent=65537, key_size=key_size)
def generate_device_certificate(
self,
device_id: str,
ca_cert: x509.Certificate,
ca_key: rsa.RSAPrivateKey,
device_key: rsa.RSAPrivateKey,
validity_days: int = 365,
key_size: int = 4096,
) -> tuple[bytes, bytes]:
"""Generate an X.509 certificate for a device signed by the CA."""
# Build device certificate
subject = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, device_id),
]
)
issuer = ca_cert.subject
now = datetime.datetime.now(datetime.UTC)
device_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(device_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_days))
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.sign(private_key=ca_key, algorithm=hashes.SHA256())
)
# Serialize certificate and key to PEM format
cert_pem = device_cert.public_bytes(serialization.Encoding.PEM)
key_pem = device_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
return cert_pem, key_pem
def create_device_credentials(
self, device_id: str, validity_days: int = 365, key_size: int = 4096
) -> dict:
"""Create device credentials: private key and signed certificate.
Returns:
dict with device_id, certificate_pem, private_key_pem, ca_certificate_pem, expires_at
"""
device_key = self.generate_device_key(key_size=key_size)
cert_pem, key_pem = self.generate_device_certificate(
device_id=device_id,
ca_cert=self.ca_cert,
ca_key=self.ca_key,
device_key=device_key,
validity_days=validity_days,
key_size=key_size,
)
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
days=validity_days
)
return {
"device_id": device_id,
"certificate_pem": cert_pem,
"private_key_pem": key_pem,
"ca_certificate_pem": self.ca_cert_pem,
"expires_at": expires_at,
}
def register_device(
self, name: str, location: str | None = None
) -> DeviceRegistrationResponse:
"""Register a new device and generate its credentials.
Returns:
DeviceRegistrationResponse
"""
device_id = self.generate_device_id()
credentials = self.create_device_credentials(device_id=device_id)
return DeviceRegistrationResponse(
device_id=credentials["device_id"],
ca_certificate_pem=credentials["ca_certificate_pem"].decode("utf-8"),
certificate_pem=credentials["certificate_pem"].decode("utf-8"),
private_key_pem=credentials["private_key_pem"].decode("utf-8"),
expires_at=credentials["expires_at"],
)

View File

@@ -0,0 +1,32 @@
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
class Config:
"""Configuration settings for the Device Manager service."""
DATABASE_URL = os.getenv("DATABASE_URL")
SERVICE_DIR = Path(__file__).parent
CERTS_DIR = SERVICE_DIR / "certs"
CA_CERT_PATH = os.getenv("CA_CERT_PATH", str(CERTS_DIR / "ca.crt"))
CA_KEY_PATH = os.getenv("CA_KEY_PATH", str(CERTS_DIR / "ca.key"))
# Certificate settings
CERT_VALIDITY_DAYS = int(os.getenv("CERT_VALIDITY_DAYS", "365"))
CERT_KEY_SIZE = int(os.getenv("CERT_KEY_SIZE", "4096"))
# Device ID settings
DEVICE_ID_LENGTH = int(os.getenv("DEVICE_ID_LENGTH", "8"))
# Service settings
SERVICE_HOST = os.getenv("DEVICE_MANAGER_HOST", "0.0.0.0")
SERVICE_PORT = int(os.getenv("DEVICE_MANAGER_PORT", "8000"))
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
config = Config()

View File

View File

@@ -0,0 +1,34 @@
#!/bin/bash
# Script to generate a Certificate Authority (CA) for IoT devices
CERT_DIR="certs"
CA_KEY="$CERT_DIR/ca.key"
CA_CERT="$CERT_DIR/ca.crt"
# Create certs directory
mkdir -p "$CERT_DIR"
echo "Generating CA Certificate Authority..."
# Generate CA private key (4096-bit RSA)
openssl genrsa -out "$CA_KEY" 4096
echo "Generated CA private key: $CA_KEY"
# Generate CA certificate (valid for 10 years)
openssl req -new -x509 -days 3650 -key "$CA_KEY" -out "$CA_CERT" \
-subj "/C=US/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA"
echo "Generated CA certificate: $CA_CERT"
# Set secure permissions
chmod 600 "$CA_KEY"
chmod 644 "$CA_CERT"
echo ""
echo "CA Certificate Authority created successfully!"
echo ""
echo "CA Certificate Details:"
openssl x509 -in "$CA_CERT" -noout -text | grep -A 2 "Subject:"
echo ""
echo "Valid from:"
openssl x509 -in "$CA_CERT" -noout -startdate
echo "Valid until:"
openssl x509 -in "$CA_CERT" -noout -enddate

View File

@@ -1,6 +1,26 @@
def main(): from fastapi import FastAPI
print("Hello from device-manager!")
from cert_manager import CertificateManager
from models import DeviceRegistrationRequest, DeviceRegistrationResponse
app = FastAPI()
cert_manager = CertificateManager()
@app.get("/")
async def hello():
return {"Hello": "World"}
@app.post("/devices/register")
async def register_device(request: DeviceRegistrationRequest) -> DeviceRegistrationResponse:
"""
Register a new device and issue an X.509 certificate.
"""
response = cert_manager.register_device(
name=request.name,
location=request.location,
)
if __name__ == "__main__": return response
main()

View File

@@ -0,0 +1,38 @@
import datetime
from pydantic import BaseModel
class Device(BaseModel):
"""IoT devices registered in the system."""
id: str
name: str
location: str | None = None
is_active: bool = True
created_at: datetime.datetime
class DeviceCertificate(BaseModel):
"""X.509 certificates issued to devices for mTLS authentication."""
device_id: str
certificate_pem: str
private_key_pem: str | None = None
issued_at: datetime.datetime
expires_at: datetime.datetime
revoked_at: datetime.datetime | None = None
class DeviceRegistrationRequest(BaseModel):
"""Request model for registering a new device."""
name: str
location: str | None = None
class DeviceRegistrationResponse(BaseModel):
"""Response model after registering a new device."""
device_id: str
ca_certificate_pem: str
certificate_pem: str
private_key_pem: str
expires_at: datetime.datetime

View File

@@ -4,4 +4,34 @@ version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [] dependencies = [
"cryptography>=46.0.3",
"fastapi[standard]>=0.120.2",
"nanoid>=2.0.0",
"pydantic>=2.12.3",
"python-dotenv>=1.2.1",
"uvicorn>=0.38.0",
]
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear
"UP", # pyupgrade
]
ignore = ["E501"] # Line too long (handled by formatter)
[tool.ruff.lint.isort]
known-first-party = ["config", "cert_manager", "database", "models"]

855
services/device_manager/uv.lock generated Normal file
View File

@@ -0,0 +1,855 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "device-manager"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
{ name = "nanoid" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=46.0.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.2" },
{ name = "nanoid", specifier = ">=2.0.0" },
{ name = "pydantic", specifier = ">=2.12.3" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "uvicorn", specifier = ">=0.38.0" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "fastapi"
version = "0.120.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/fb/79e556bc8f9d360e5cc2fa7364a7ad6bda6f1736938b43a2791fa8baee7b/fastapi-0.120.2.tar.gz", hash = "sha256:4c5ab43e2a90335bbd8326d1b659eac0f3dbcc015e2af573c4f5de406232c4ac", size = 338684, upload-time = "2025-10-29T13:47:35.802Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/cc/1c33d05f62c9349bb80dfe789cc9a7409bdfb337a63fa347fd651d25294a/fastapi-0.120.2-py3-none-any.whl", hash = "sha256:bedcf2c14240e43d56cb9a339b32bcf15104fe6b5897c0222603cb7ec416c8eb", size = 108383, upload-time = "2025-10-29T13:47:32.978Z" },
]
[package.optional-dependencies]
standard = [
{ name = "email-validator" },
{ name = "fastapi-cli", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cli"
version = "0.0.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/13/11e43d630be84e51ba5510a6da6a11eb93b44b72caa796137c5dddda937b/fastapi_cli-0.0.14.tar.gz", hash = "sha256:ddfb5de0a67f77a8b3271af1460489bd4d7f4add73d11fbfac613827b0275274", size = 17994, upload-time = "2025-10-20T16:33:21.054Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/e8/bc8bbfd93dcc8e347ce98a3e654fb0d2e5f2739afb46b98f41a30c339269/fastapi_cli-0.0.14-py3-none-any.whl", hash = "sha256:e66b9ad499ee77a4e6007545cde6de1459b7f21df199d7f29aad2adaab168eca", size = 11151, upload-time = "2025-10-20T16:33:19.318Z" },
]
[package.optional-dependencies]
standard = [
{ name = "fastapi-cloud-cli" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cloud-cli"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic", extra = ["email"] },
{ name = "rich-toolkit" },
{ name = "rignore" },
{ name = "sentry-sdk" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/48/0f14d8555b750dc8c04382804e4214f1d7f55298127f3a0237ba566e69dd/fastapi_cloud_cli-0.3.1.tar.gz", hash = "sha256:8c7226c36e92e92d0c89827e8f56dbf164ab2de4444bd33aa26b6c3f7675db69", size = 24080, upload-time = "2025-10-09T11:32:58.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/79/7f5a5e5513e6a737e5fb089d9c59c74d4d24dc24d581d3aa519b326bedda/fastapi_cloud_cli-0.3.1-py3-none-any.whl", hash = "sha256:7d1a98a77791a9d0757886b2ffbf11bcc6b3be93210dd15064be10b216bf7e00", size = 19711, upload-time = "2025-10-09T11:32:57.118Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "nanoid"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/0250bf5935d88e214df469d35eccc0f6ff7e9db046fc8a9aeb4b2a192775/nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68", size = 3290, upload-time = "2018-11-20T14:45:51.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/0d/8630f13998638dc01e187fadd2e5c6d42d127d08aeb4943d231664d6e539/nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb", size = 5844, upload-time = "2018-11-20T14:45:50.165Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
{ url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
{ url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
{ url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
{ url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
{ url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
{ url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
{ url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
{ url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
{ url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
{ url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
{ url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
{ url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
{ url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" },
{ url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" },
{ url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" },
{ url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" },
{ url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" },
{ url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" },
{ url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" },
{ url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" },
{ url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" },
{ url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "rich-toolkit"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" },
]
[[package]]
name = "rignore"
version = "0.7.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/b5/1fe06acc517315fba13db19039e77a2b9689451e0b5b02e03f26f05f24ec/rignore-0.7.2.tar.gz", hash = "sha256:b343749a59b53db30be1180ffab6995a914a244860e31a5cbea25bb647c38a61", size = 15254, upload-time = "2025-10-29T12:41:12.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/ce/c77d73a611a47b021b1536f7b49fe5593fec0b5e43934166e0c1fcfd1d4c/rignore-0.7.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b98b8396f856f302e983664b1e2a13aee8c82d8ce0b34c2548a0e09a7c30d3c", size = 889368, upload-time = "2025-10-29T12:40:12.001Z" },
{ url = "https://files.pythonhosted.org/packages/11/dc/bbbe0d23051605cd2197626d3a5212f376d5e0143881cdbf6632c8ecb38b/rignore-0.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bfdfb906ad0f8f22d2685fb2b6465a078d78ee32e437dab4ab35080a2790c87b", size = 820141, upload-time = "2025-10-29T12:40:07.329Z" },
{ url = "https://files.pythonhosted.org/packages/5d/62/ee54bc98dc986de7bf8cfddbb62670cbcbbfc21b4c53821421be96d561d0/rignore-0.7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3eef7c19742af7d3d813917a81af65ed9d7050f49f90fd78986a0243170531a", size = 901513, upload-time = "2025-10-29T12:38:49.606Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e5/e87a724794d23e1aaf7f9a5b2108fefb64703784e88f1082df36631c424a/rignore-0.7.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a73c14e1a131b17235fac9b148d549e6bd90abb4e9950baeb2df1e09e467bf6d", size = 873815, upload-time = "2025-10-29T12:39:02.764Z" },
{ url = "https://files.pythonhosted.org/packages/07/02/7a804c2491d9794aef7052a4cdb6343ff6fdee5d68adc6e859f4f46363e8/rignore-0.7.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2927a547bd6934882fc92f55d77b8c4d548655612db502b509e495cbe9ef39eb", size = 1177286, upload-time = "2025-10-29T12:39:17.028Z" },
{ url = "https://files.pythonhosted.org/packages/4a/6b/0b84972c4442b60d6afb450607708aa74e2b416f403e12541c65a3e49c50/rignore-0.7.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fce3b899a3a891744264edde217a8d3a9fc4e9f542afe1c4b88bfa8544509cca", size = 944310, upload-time = "2025-10-29T12:39:31.185Z" },
{ url = "https://files.pythonhosted.org/packages/c0/35/abb0816263aaaee399730a701636c81090455203af67601cc409adb8d431/rignore-0.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ea5364e7e0a188ee794be4335eaad1df089b8226279b460c98d8b95c11b73d", size = 958713, upload-time = "2025-10-29T12:39:56.472Z" },
{ url = "https://files.pythonhosted.org/packages/14/70/0573d0bcf3fb27b3960c601027db9e31338c56e3a899e6d1c649b872bb62/rignore-0.7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a118788ce31693c02629851b4431043d5949c506e15f45d7ccd0cdc3d3e65765", size = 985183, upload-time = "2025-10-29T12:39:44.055Z" },
{ url = "https://files.pythonhosted.org/packages/72/03/f25ff93e3ede74e8c7908c675ba643ec67fb4fee48a4d8bcc2c2880c53b5/rignore-0.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:80c978468562464be9c64de6e086707103a727fec0ec88961d88bca91111f1a9", size = 1080365, upload-time = "2025-10-29T12:40:17.441Z" },
{ url = "https://files.pythonhosted.org/packages/fd/0c/9a273bf389e0651f118e35f2f4acbe2ed0ceecb570f1ea49475e59d8149e/rignore-0.7.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ea0a073a7b9639be68d8269732630d1ddf55fb72f5e4faa0e1b3d2f46d9e6b48", size = 1137639, upload-time = "2025-10-29T12:40:31.332Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d5/009ce164e2ef31bc0cf5506337cd5eca495c7b5ea526cb4ccbbbfe8b9928/rignore-0.7.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a15dfd14b5d9118e1e4afbc5e951b1a5ce43185d1605aac5f46ad72a6c53952a", size = 1117566, upload-time = "2025-10-29T12:40:47.402Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3a/c2aed0787572cc0a0c5afcafb9bbd8827fb676fe89ca3a78cdf62e656f14/rignore-0.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bde72ba6474cea23ca9091a66959caaaa915091e472fff95ced1b341d7541300", size = 1124968, upload-time = "2025-10-29T12:41:02.436Z" },
{ url = "https://files.pythonhosted.org/packages/63/fa/4ab82713918e6a8fc1ef9a609a19baeeb9ddc37e7ba10620045f10689c56/rignore-0.7.2-cp313-cp313-win32.whl", hash = "sha256:011c6ede35ad03c4f84c72c6535033f31c56543830222586e9ef09274b22688a", size = 646108, upload-time = "2025-10-29T12:41:30.967Z" },
{ url = "https://files.pythonhosted.org/packages/ea/46/c91aac0466158973c8c9deb00ab2bbb870dabc726261dd786246bb62201c/rignore-0.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:26fb0c20f77e24b9dd361cce8c78c7b581fbceab8b2a06e4374c54f5ce63c475", size = 726268, upload-time = "2025-10-29T12:41:22.121Z" },
{ url = "https://files.pythonhosted.org/packages/3e/41/815c603dff6512ec35ff7ff2b5d8a10f0884203eb71e8d22d5ce3c49bc71/rignore-0.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:4d7d33e36a4f53f1765d3340e126758a1cf232cba9f27d2458f806dad434793e", size = 656198, upload-time = "2025-10-29T12:41:15.356Z" },
{ url = "https://files.pythonhosted.org/packages/f3/53/b26ad855d846b5426eeb8da22fc47753312b054583cad3a78cbf7375e3e6/rignore-0.7.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d4997bc54ca11f13013e05c271770e1ec20195e4fe21276ea6b91f5c5dced25", size = 820745, upload-time = "2025-10-29T12:40:08.455Z" },
{ url = "https://files.pythonhosted.org/packages/6f/20/7ebc5949807fb89683d7f3c512d3161d0eb8c01183d0acb569a8f2721eec/rignore-0.7.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5923f3d5481cdd683540ff70c1e9ad1bd369823578e2d49987aedd1c3dedb5", size = 901796, upload-time = "2025-10-29T12:38:51.055Z" },
{ url = "https://files.pythonhosted.org/packages/be/85/d9166578342e0ef284baece0e843546c1cb4db397d995798a1ec797e502f/rignore-0.7.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1cd0a4c1babd64dda268d6a7a5efa998c717e2af0a49717f5f8e9524c92f2595", size = 874141, upload-time = "2025-10-29T12:39:04.076Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/83d233b9b787c876d9a2b24efd69a5ad5729f6bb01e0ec753a7e09372ff0/rignore-0.7.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e20381b7487479bb75544e6e96141fe20873a8c78c8ed36ceb2ffdbdf9dbfcba", size = 1176316, upload-time = "2025-10-29T12:39:18.819Z" },
{ url = "https://files.pythonhosted.org/packages/c1/49/852aeab984b7919083e47fe572bcd796bc30653da55b994c1aa2c7b64b8a/rignore-0.7.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c82a1f9b5fc264b9286cd2febc8a2e20eaf70e60b436d17393a329e24a8dbae", size = 944566, upload-time = "2025-10-29T12:39:32.819Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7c/5ae025765f3c66812fc01cdaa4f6ecd809b7f8fa92a39600865d5d9dc538/rignore-0.7.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ef66cd608f5cff2606c4fae81ac6149995c1bb3a7cd442a81c9bc2ee21774c1", size = 958463, upload-time = "2025-10-29T12:39:57.874Z" },
{ url = "https://files.pythonhosted.org/packages/55/c1/5314352af5633b6d45d910b0fe3b2c5c7473d81a735585fc717f5198e61f/rignore-0.7.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbc864367d79dcdbfd92c463401b637d8760ac8619a8a31210826dd151ff30be", size = 985201, upload-time = "2025-10-29T12:39:45.36Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a4/4a300a9fb6b2d3a35845c7f51a90ca302b749fcc547e67245232e4c38f98/rignore-0.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:51299dcce9edb8a4fafe766ba5f90c02b51a72d2127351cdd62b252fd39e874f", size = 1081867, upload-time = "2025-10-29T12:40:18.616Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3c/8b074c9f6471588dc898a9d094d35518cb66a3942faeffdae352b2519d1f/rignore-0.7.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9fad9574e1f71f299468d558aa59350600688b05f7ec1d31a01e831ba306d9dd", size = 1138062, upload-time = "2025-10-29T12:40:32.848Z" },
{ url = "https://files.pythonhosted.org/packages/57/72/d8e0da03c54b282e5fd8f9faf467264d06591c0fff653d243b33aa237e61/rignore-0.7.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5e12d6c3f420c5362f7ffebca815db298ed0976a98b2bc3e48389bc0a73ffc24", size = 1117732, upload-time = "2025-10-29T12:40:48.706Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/81ce73ccbddfee92d7a1ca655fe9a8f98af19ad4d82283cadb9868e40681/rignore-0.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0bb07648a03b7825d70d6ee92e03d7d2220bf9e1eb70a0d93cfddf64b78ce27f", size = 1125026, upload-time = "2025-10-29T12:41:03.704Z" },
{ url = "https://files.pythonhosted.org/packages/68/d6/85af267bd20130ca58da7ec8d386cccba5a333918a375cca72dc9fb4f3b1/rignore-0.7.2-cp314-cp314-win32.whl", hash = "sha256:95b9a6bc3e83dc42359b276fa795cab81ea33a68662a47b6e7fd201d45187bf7", size = 646386, upload-time = "2025-10-29T12:41:32.272Z" },
{ url = "https://files.pythonhosted.org/packages/75/25/d85777d2e31d7c42e2d581019b65fd6accfc3645797e011d8a8db3303445/rignore-0.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:afbe88be82ca65debf6f7bc1a9711c4d65dad4156499ded3dfd4e6a7af5f4c78", size = 725700, upload-time = "2025-10-29T12:41:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/7d/89/e8832494602b2b1f867ca7bf5901a5598fcfc2128510fcef878989cd963f/rignore-0.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e5429df475e9a17e163352df67c05026e8505da262159c7b9bfa707708bc7b93", size = 656032, upload-time = "2025-10-29T12:41:16.605Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/18/09875b4323b03ca9025bae7e6539797b27e4fc032998a466b4b9c3d24653/sentry_sdk-2.43.0.tar.gz", hash = "sha256:52ed6e251c5d2c084224d73efee56b007ef5c2d408a4a071270e82131d336e20", size = 368953, upload-time = "2025-10-29T11:26:08.156Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/31/8228fa962f7fd8814d634e4ebece8780e2cdcfbdf0cd2e14d4a6861a7cd5/sentry_sdk-2.43.0-py2.py3-none-any.whl", hash = "sha256:4aacafcf1756ef066d359ae35030881917160ba7f6fc3ae11e0e58b09edc2d5d", size = 400997, upload-time = "2025-10-29T11:26:05.77Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "starlette"
version = "0.49.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1b/3f/507c21db33b66fb027a332f2cb3abbbe924cc3a79ced12f01ed8645955c9/starlette-0.49.1.tar.gz", hash = "sha256:481a43b71e24ed8c43b11ea02f5353d77840e01480881b8cb5a26b8cae64a8cb", size = 2654703, upload-time = "2025-10-28T17:34:10.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/da/545b75d420bb23b5d494b0517757b351963e974e79933f01e05c929f20a6/starlette-0.49.1-py3-none-any.whl", hash = "sha256:d92ce9f07e4a3caa3ac13a79523bd18e3bc0042bb8ff2d759a8e7dd0e1859875", size = 74175, upload-time = "2025-10-28T17:34:09.13Z" },
]
[[package]]
name = "typer"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]

View File

@@ -5,6 +5,7 @@ from typing import Optional
dotenv.load_dotenv() dotenv.load_dotenv()
@dataclass @dataclass
class RedisConfig: class RedisConfig:
host: str host: str
@@ -12,6 +13,7 @@ class RedisConfig:
db: int = 0 db: int = 0
password: Optional[str] = None password: Optional[str] = None
@dataclass @dataclass
class MQTTConfig: class MQTTConfig:
broker: str broker: str
@@ -21,6 +23,7 @@ class MQTTConfig:
topic_pattern: str = "devices/#" topic_pattern: str = "devices/#"
keepalive: int = 60 keepalive: int = 60
@dataclass @dataclass
class Payload: class Payload:
device_id: str device_id: str
@@ -32,18 +35,19 @@ class Payload:
class Config: class Config:
def __init__(self): def __init__(self):
self.redis = RedisConfig( self.redis = RedisConfig(
host=os.getenv('REDIS_HOST', 'localhost'), host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv('REDIS_PORT', 6379)), port=int(os.getenv("REDIS_PORT", 6379)),
db=int(os.getenv('REDIS_DB', 0)), db=int(os.getenv("REDIS_DB", 0)),
password=os.getenv('REDIS_PASSWORD', None) password=os.getenv("REDIS_PASSWORD", None),
) )
self.mqtt = MQTTConfig( self.mqtt = MQTTConfig(
broker=os.getenv('MQTT_BROKER', 'localhost'), broker=os.getenv("MQTT_BROKER", "localhost"),
port=int(os.getenv('MQTT_PORT', 1883)), port=int(os.getenv("MQTT_PORT", 1883)),
username=os.getenv('MQTT_USERNAME', None), username=os.getenv("MQTT_USERNAME", None),
password=os.getenv('MQTT_PASSWORD', None), password=os.getenv("MQTT_PASSWORD", None),
topic_pattern=os.getenv('MQTT_TOPIC_PATTERN', 'devices/#'), topic_pattern=os.getenv("MQTT_TOPIC_PATTERN", "devices/#"),
keepalive=int(os.getenv('MQTT_KEEPALIVE', 60)) keepalive=int(os.getenv("MQTT_KEEPALIVE", 60)),
) )
config = Config() config = Config()

View File

@@ -5,11 +5,12 @@ from mqtt_client import MQTTClient
from redis_writer import RedisWriter from redis_writer import RedisWriter
logging.basicConfig( logging.basicConfig(
level=getattr(logging,'INFO'), level=getattr(logging, "INFO"),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MQTTIngestionService: class MQTTIngestionService:
def __init__(self): def __init__(self):
self.running = False self.running = False
@@ -85,6 +86,7 @@ class MQTTIngestionService:
return True return True
def main(): def main():
"""Entry point""" """Entry point"""
service = MQTTIngestionService() service = MQTTIngestionService()
@@ -101,5 +103,6 @@ def main():
finally: finally:
service.stop() service.stop()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -5,6 +5,7 @@ from config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MQTTClient: class MQTTClient:
def __init__(self, message_handler: Callable[[str, str, float], None]): def __init__(self, message_handler: Callable[[str, str, float], None]):
""" """
@@ -21,10 +22,7 @@ class MQTTClient:
self.client.on_disconnect = self._on_disconnect self.client.on_disconnect = self._on_disconnect
if config.mqtt.username: if config.mqtt.username:
self.client.username_pw_set( self.client.username_pw_set(config.mqtt.username, config.mqtt.password)
config.mqtt.username,
config.mqtt.password
)
def _on_connect(self, client, userdata, flags, rc): def _on_connect(self, client, userdata, flags, rc):
if rc == 0: if rc == 0:
@@ -36,8 +34,8 @@ class MQTTClient:
def _on_message(self, client, userdata, msg): def _on_message(self, client, userdata, msg):
try: try:
topic_parts = msg.topic.split('/') topic_parts = msg.topic.split("/")
if len(topic_parts) != 3 or topic_parts[0] != 'devices': if len(topic_parts) != 3 or topic_parts[0] != "devices":
logger.warning(f"Invalid topic format: {msg.topic}") logger.warning(f"Invalid topic format: {msg.topic}")
return return
@@ -65,9 +63,7 @@ class MQTTClient:
"""Connect to MQTT broker""" """Connect to MQTT broker"""
try: try:
self.client.connect( self.client.connect(
config.mqtt.broker, config.mqtt.broker, config.mqtt.port, config.mqtt.keepalive
config.mqtt.port,
config.mqtt.keepalive
) )
return True return True
except Exception as e: except Exception as e:

View File

@@ -5,6 +5,7 @@ from config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RedisWriter: class RedisWriter:
def __init__(self): def __init__(self):
"""Initialize Redis writer with config from environment""" """Initialize Redis writer with config from environment"""
@@ -13,35 +14,39 @@ class RedisWriter:
host=config.redis.host, host=config.redis.host,
port=config.redis.port, port=config.redis.port,
db=config.redis.db, db=config.redis.db,
password=config.redis.password password=config.redis.password,
) )
try: try:
self.redis_client.ping() self.redis_client.ping()
self.logger.info(f"Connected to Redis at {config.redis.host}:{config.redis.port}") self.logger.info(
f"Connected to Redis at {config.redis.host}:{config.redis.port}"
)
except redis.ConnectionError as e: except redis.ConnectionError as e:
self.logger.error(f"Failed to connect to Redis server: {e}") self.logger.error(f"Failed to connect to Redis server: {e}")
raise raise
def write_sensor_data(self, device_id: str, sensor_type: str, value: float) -> bool: def write_sensor_data(self, device_id: str, sensor_type: str, value: float) -> bool:
""" """
Write sensor data to Redis streams and latest values hash. Write sensor data to single Redis stream for all devices.
- Stream: mqtt_stream:{device_id}:{sensor_type} - Stream: mqtt:ingestion (single stream for scalability)
- Hash: mqtt_latest:{device_id} - Hash: mqtt_latest:{device_id} (for quick dashboard access)
""" """
timestamp = datetime.utcnow().isoformat() timestamp = datetime.utcnow().isoformat()
stream_key = f"mqtt_stream:{device_id}:{sensor_type}"
stream_key = "mqtt:ingestion"
hash_key = f"mqtt_latest:{device_id}" hash_key = f"mqtt_latest:{device_id}"
stream_data = { stream_data = {
"device_id": device_id,
"metric": sensor_type,
"value": str(value), "value": str(value),
"timestamp": timestamp "timestamp": timestamp,
} }
try: try:
# Write to stream # Write to single stream
self.redis_client.xadd(stream_key, stream_data, maxlen=1000) self.redis_client.xadd(stream_key, stream_data, maxlen=10000)
# Update latest value hash
self.redis_client.hset(hash_key, sensor_type, str(value)) self.redis_client.hset(hash_key, sensor_type, str(value))
self.redis_client.hset(hash_key, f"{sensor_type}_time", timestamp) self.redis_client.hset(hash_key, f"{sensor_type}_time", timestamp)

34
uv.lock generated
View File

@@ -214,6 +214,11 @@ dependencies = [
{ name = "requests" }, { name = "requests" },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.17.0" }, { name = "alembic", specifier = ">=1.17.0" },
@@ -226,6 +231,9 @@ requires-dist = [
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.14.2" }]
[[package]] [[package]]
name = "jiter" name = "jiter"
version = "0.11.1" version = "0.11.1"
@@ -492,6 +500,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
] ]
[[package]]
name = "ruff"
version = "0.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" },
{ url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" },
{ url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" },
{ url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" },
{ url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" },
{ url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" },
{ url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" },
{ url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" },
{ url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" },
{ url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" },
{ url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" },
{ url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" },
{ url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"