mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Compare commits
12 Commits
feature/re
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 391c08a738 | |||
| 153dca9d8f | |||
| d2b707ea5e | |||
| 212b8d39a9 | |||
| 96e2377073 | |||
| ddbc588c77 | |||
| 4df582b330 | |||
|
|
7446e9b4ac | ||
|
|
12d3720421 | ||
|
|
0b96c72f45 | ||
| 5028dae200 | |||
| 39a9f91cfc |
20
.env.sample
Normal file
20
.env.sample
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Django Settings
|
||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# Database
|
||||||
|
CONNECTION_STRING=postgresql://postgres:password@localhost:5432/iotdashboard
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
MQTT_BROKER=localhost
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER=mqtt_user
|
||||||
|
MQTT_PASS=mqtt_password
|
||||||
|
|
||||||
|
# GPT/OpenAI (Optional)
|
||||||
|
OPENAI_API_KEY=your-openai-key-here
|
||||||
114
.github/copilot-instructions.md
vendored
Normal file
114
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
The following concise instructions help AI coding agents become productive in this repository.
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
- This repo is a microservices-based IoT platform for device management, data ingestion, and telemetry storage. The system uses MQTT with mTLS authentication, Redis streams for message queuing, and PostgreSQL/TimescaleDB for persistent storage.
|
||||||
|
|
||||||
|
Big Picture
|
||||||
|
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
||||||
|
- Components:
|
||||||
|
- `services/device_manager/` — FastAPI service for device registration, X.509 certificate issuance, and lifecycle management
|
||||||
|
- `services/mqtt_ingestion/` — MQTT client that subscribes to device topics and writes to single Redis stream `mqtt:ingestion`
|
||||||
|
- `services/db_write/` — Consumer service that reads from Redis streams and writes to database using consumer groups
|
||||||
|
- `db_migrations/` — Alembic migrations for schema management (SQLAlchemy models)
|
||||||
|
- `infrastructure/` — Docker Compose setup (PostgreSQL, Redis, Mosquitto MQTT broker)
|
||||||
|
- `iotDashboard/` — Legacy Django app (being phased out)
|
||||||
|
|
||||||
|
Key Files To Read First
|
||||||
|
- `db_migrations/models.py` — SQLAlchemy models: `Device`, `DeviceCertificate`, `Telemetry`. Canonical schema definition.
|
||||||
|
- `services/device_manager/app/app.py` — FastAPI endpoints for device registration, certificate management, revocation, renewal.
|
||||||
|
- `services/device_manager/app/cert_manager.py` — X.509 certificate generation, CA management, CRL generation.
|
||||||
|
- `services/mqtt_ingestion/src/mqtt_client.py` — MQTT subscriber that parses `devices/{device_id}/{metric}` topics.
|
||||||
|
- `services/mqtt_ingestion/src/redis_writer.py` — Writes to single stream `mqtt:ingestion` with device_id, metric, value, timestamp.
|
||||||
|
- `services/db_write/src/redis_reader.py` — Consumer group reader for `mqtt:ingestion` stream.
|
||||||
|
- `services/db_write/src/db_writer.py` — Batch writes to `telemetry` table using SQLAlchemy.
|
||||||
|
- `infrastructure/compose.yml` — Docker services: PostgreSQL/TimescaleDB, Redis, Mosquitto MQTT.
|
||||||
|
- `infrastructure/mosquitto/mosquitto.conf` — MQTT broker config with mTLS on port 8883, CRL checking enabled.
|
||||||
|
|
||||||
|
Important Conventions & Patterns
|
||||||
|
- **Single stream architecture**: All MQTT data flows through one Redis stream `mqtt:ingestion`. Each message contains `device_id`, `metric`, `value`, `timestamp`.
|
||||||
|
- **MQTT topics**: Standard format `devices/{device_id}/{metric}`. Examples: `devices/abc123/temperature`, `devices/xyz789/humidity`.
|
||||||
|
- **Certificate IDs**: Use certificate serial number (hex format) as primary key in `device_certificates` table. Multiple certificates per device supported.
|
||||||
|
- **Package manager**: All services use `uv` for dependency management (`pyproject.toml` not `requirements.txt`).
|
||||||
|
- **Database migrations**: Use Alembic for schema changes. Run migrations from `db_migrations/` directory.
|
||||||
|
- **Configuration**: All services use `.env` files. Never hardcode hosts/credentials.
|
||||||
|
- **Import organization**: Services have `app/` or `src/` package structure. Import as `from app.module import ...` or `from src.module import ...`.
|
||||||
|
- **Consumer groups**: `db_write` uses Redis consumer groups for at-least-once delivery. Consumer name must be unique per instance.
|
||||||
|
|
||||||
|
Developer Workflows (commands & notes)
|
||||||
|
- **Start infrastructure**: `cd infrastructure && docker compose up -d` (Postgres, Redis, Mosquitto)
|
||||||
|
- **Run database migrations**: `cd db_migrations && uv run alembic upgrade head`
|
||||||
|
- **Generate CA certificate**: `cd services/device_manager && ./generate_ca.sh` (first time only)
|
||||||
|
- **Run device_manager**: `cd services/device_manager && uv run uvicorn app.app:app --reload --port 8000`
|
||||||
|
- **Run mqtt_ingestion**: `cd services/mqtt_ingestion && uv run main.py`
|
||||||
|
- **Run db_write**: `cd services/db_write && uv run main.py`
|
||||||
|
- **Register device**: `curl -X POST http://localhost:8000/devices/register -H "Content-Type: application/json" -d '{"name":"test","location":"lab"}'`
|
||||||
|
- **Test MQTT with mTLS**: `mosquitto_pub --cafile ca.crt --cert device.crt --key device.key -h localhost -p 8883 -t "devices/abc123/temperature" -m "23.5"`
|
||||||
|
- **Inspect Redis stream**: `redis-cli XLEN mqtt:ingestion` and `redis-cli XRANGE mqtt:ingestion - + COUNT 10`
|
||||||
|
- **Check consumer group**: `redis-cli XINFO GROUPS mqtt:ingestion`
|
||||||
|
- **View CRL**: `openssl crl -in infrastructure/mosquitto/certs/ca.crl -text -noout`
|
||||||
|
|
||||||
|
Integration Points & Gotchas
|
||||||
|
- **Environment variables**: All services load from `.env` files. No defaults - service will fail if required vars missing. Copy `.env.example` first.
|
||||||
|
- **Certificate paths**: `device_manager` writes CRL to `infrastructure/mosquitto/certs/ca.crl`. Mosquitto must restart after CRL updates.
|
||||||
|
- **Database schema**: Schema changes require Alembic migration. Never modify tables manually. Use `alembic revision --autogenerate`.
|
||||||
|
- **MQTT topic parsing**: `mqtt_ingestion` expects exactly `devices/{device_id}/{metric}` (3 parts). Invalid topics are logged and dropped.
|
||||||
|
- **Redis stream format**: `mqtt:ingestion` messages must have `device_id`, `metric`, `value`, `timestamp` fields (all strings).
|
||||||
|
- **Consumer groups**: `db_write` creates consumer group `db_writer` automatically. Don't delete it manually.
|
||||||
|
- **Certificate serial numbers**: Used as primary key in `device_certificates.id`. Extract with `format(cert.serial_number, 'x')`.
|
||||||
|
- **TimescaleDB hypertables**: `telemetry` table is a hypertable. Don't add constraints that break time partitioning.
|
||||||
|
- **File permissions**: Mosquitto directories may be owned by UID 1883. Fix with `sudo chown -R $USER:$USER infrastructure/mosquitto/`.
|
||||||
|
|
||||||
|
What AI agents should do first
|
||||||
|
- **Read architecture first**: Check `README.md` for current architecture. System is microservices-based, not Django monolith.
|
||||||
|
- **Check database schema**: Always start with `db_migrations/models.py` to understand data model.
|
||||||
|
- **Don't change stream names**: Single stream `mqtt:ingestion` is used by mqtt_ingestion and db_write. Changing breaks both services.
|
||||||
|
- **Use proper imports**: Services use package structure. Import from `app.*` or `src.*`, not relative imports.
|
||||||
|
- **Create migrations**: Schema changes require `alembic revision --autogenerate`. Never modify models without migration.
|
||||||
|
- **Test with real infrastructure**: Use `docker compose up` for integration testing. Unit tests are insufficient for this architecture.
|
||||||
|
- **Check .env files**: Each service has `.env.example`. Copy and configure before running.
|
||||||
|
|
||||||
|
Examples (copyable snippets)
|
||||||
|
- **Write to single stream** (mqtt_ingestion):
|
||||||
|
```python
|
||||||
|
redis_client.xadd("mqtt:ingestion", {
|
||||||
|
"device_id": device_id,
|
||||||
|
"metric": sensor_type,
|
||||||
|
"value": str(value),
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Read from stream with consumer group** (db_write):
|
||||||
|
```python
|
||||||
|
results = redis_client.xreadgroup(
|
||||||
|
groupname="db_writer",
|
||||||
|
consumername="worker-01",
|
||||||
|
streams={"mqtt:ingestion": ">"},
|
||||||
|
count=100,
|
||||||
|
block=5000
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Extract certificate serial number**:
|
||||||
|
```python
|
||||||
|
from cryptography import x509
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||||
|
cert_id = format(cert.serial_number, 'x')
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Query active certificates**:
|
||||||
|
```python
|
||||||
|
device_cert = db.query(DeviceCertificate).filter(
|
||||||
|
DeviceCertificate.device_id == device_id,
|
||||||
|
DeviceCertificate.revoked_at.is_(None)
|
||||||
|
).first()
|
||||||
|
```
|
||||||
|
|
||||||
|
If you add or change docs
|
||||||
|
- Update `README.md` for architecture changes
|
||||||
|
- Update `.github/copilot-instructions.md` for development workflow changes
|
||||||
|
- Update service-specific READMEs (`services/*/README.md`) for API or configuration changes
|
||||||
|
- Document environment variables in `.env.example` files
|
||||||
|
- Add migration notes to Alembic revision if schema change is complex
|
||||||
|
|
||||||
|
If anything in these instructions looks off or incomplete for your current refactor, tell me what you'd like to focus on and I'll iterate.
|
||||||
19
.github/prompts/planing.prompt.md
vendored
Normal file
19
.github/prompts/planing.prompt.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
mode: agent
|
||||||
|
description: 'Start planning'
|
||||||
|
tools: ['getNotebookSummary', 'readNotebookCellOutput', 'search', 'getTerminalOutput', 'terminalSelection', 'terminalLastCommand', 'usages', 'vscodeAPI', 'think', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo', 'todos', 'get_issue', 'get_issue_comments', 'get_me','haystackFiles','haystackSearch']
|
||||||
|
---
|
||||||
|
Your goal is to prepare a detailed plan to fix the bug or add the new feature, for this you first need to:
|
||||||
|
* Understand the context of the bug or feature by reading the issue description and comments.
|
||||||
|
* Understand the codebase by reading the relevant instruction files.
|
||||||
|
* If its a bug, then identify the root cause of the bug, and explain this to the user.
|
||||||
|
|
||||||
|
Based on your above understanding generate a plan to fix the bug or add the new feature.
|
||||||
|
Ensure the plan consists of a Markdown document that has the following sections:
|
||||||
|
|
||||||
|
* Overview: A brief description of the bug/feature.
|
||||||
|
* Root Cause: A detailed explanation of the root cause of the bug, including any relevant code snippets or references to the codebase. (only if it's a bug)
|
||||||
|
* Requirements: A list of requirements to resolve the bug or add the new feature.
|
||||||
|
* Implementation Steps: A detailed list of steps to implement the bug fix or new feature.
|
||||||
|
|
||||||
|
Remember, do not make any code edits, just generate a plan. Use thinking and reasoning skills to outline the steps needed to achieve the desired outcome.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,3 +8,8 @@ demo.db-wal
|
|||||||
/iotDashboard/demo1.db
|
/iotDashboard/demo1.db
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
iotDashboard/db_create.py
|
iotDashboard/db_create.py
|
||||||
|
.venv/
|
||||||
|
.ruff_cache/
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.csr
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
23
.vscode/launch.json
vendored
Normal file
23
.vscode/launch.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
README.md
19
README.md
@@ -1,2 +1,17 @@
|
|||||||
# iotDashboard
|
# IoT Dashboard
|
||||||
iotDashboard - IOT dashboard with Django, TimescaleDB and Redis
|
|
||||||
|
Microservices-based IoT platform with device management, mTLS authentication, and time-series data storage.
|
||||||
|
|
||||||
|
**Architecture:** Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- **device_manager** - Device registration & X.509 certificates (FastAPI)
|
||||||
|
- **mqtt_ingestion** - MQTT → Redis pipeline
|
||||||
|
- **db_write** - Redis → PostgreSQL writer
|
||||||
|
- **infrastructure** - Docker Compose (PostgreSQL, Redis, Mosquitto)
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
1
db_migrations/.env.example
Normal file
1
db_migrations/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CONNECTION_STRING=postgresql://user:password@localhost:5432/iotdashboard
|
||||||
5
db_migrations/.gitignore
vendored
Normal file
5
db_migrations/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
111
db_migrations/README.md
Normal file
111
db_migrations/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
This directory contains all database schema definitions and migrations for the IoT Dashboard project.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install alembic sqlalchemy python-dotenv psycopg2-binary
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Database
|
||||||
|
|
||||||
|
Set `CONNECTION_STRING` or `DATABASE_URL` in the root `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONNECTION_STRING=postgresql://user:password@localhost:5432/iotdashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Initial Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x migrate.sh
|
||||||
|
./migrate.sh create "initial schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Review Migration
|
||||||
|
|
||||||
|
Check the generated file in `alembic/versions/`
|
||||||
|
|
||||||
|
### 5. Apply Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Create a New Migration
|
||||||
|
|
||||||
|
After editing `models.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh create "add new column"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Current Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh current
|
||||||
|
```
|
||||||
|
|
||||||
|
### View History
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh downgrade 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
db_migrations/
|
||||||
|
├── models.py # SQLAlchemy models (schema definition)
|
||||||
|
├── alembic.ini # Alembic configuration
|
||||||
|
├── alembic/
|
||||||
|
│ ├── env.py # Migration environment
|
||||||
|
│ ├── script.py.mako # Migration template
|
||||||
|
│ └── versions/ # Generated migrations
|
||||||
|
├── migrate.sh # Helper script
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modifying Schema
|
||||||
|
|
||||||
|
1. Edit `models.py` to define your changes
|
||||||
|
2. Run `./migrate.sh create "description"`
|
||||||
|
3. Review the generated migration
|
||||||
|
4. Run `./migrate.sh upgrade`
|
||||||
|
|
||||||
|
## Using Models in Services
|
||||||
|
|
||||||
|
Services can import models from here:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In services/db_write/db_writer.py or any other service
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/path/to/db_migrations')
|
||||||
|
from models import SensorReading
|
||||||
|
```
|
||||||
|
|
||||||
|
Or better, use relative imports if properly structured.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `.env` file should be in the project root (`../.env`)
|
||||||
|
- Migrations are applied to whatever database is in `CONNECTION_STRING`
|
||||||
|
- Always review generated migrations before applying
|
||||||
|
- Keep `models.py` as the single source of truth for schema
|
||||||
40
db_migrations/alembic.ini
Normal file
40
db_migrations/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Alembic Configuration
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
timezone = UTC
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
89
db_migrations/alembic/env.py
Normal file
89
db_migrations/alembic/env.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory to path so we can import models
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(__file__), "../.env"))
|
||||||
|
|
||||||
|
# Import your models
|
||||||
|
from models import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Set database URL from environment
|
||||||
|
database_url = os.getenv("CONNECTION_STRING") or os.getenv("DATABASE_URL")
|
||||||
|
if database_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
db_migrations/alembic/script.py.mako
Normal file
28
db_migrations/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Initial
|
||||||
|
|
||||||
|
Revision ID: dae12d7f4ddf
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-10-28 22:06:45.637836+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "dae12d7f4ddf"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
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(
|
||||||
|
"devices",
|
||||||
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("location", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"telemetry",
|
||||||
|
sa.Column("time", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("device_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metric", sa.Text(), nullable=False),
|
||||||
|
sa.Column("value", sa.Float(), nullable=False),
|
||||||
|
sa.Column("unit", sa.Text(), nullable=True),
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert telemetry table to TimescaleDB hypertable
|
||||||
|
op.execute("SELECT create_hypertable('telemetry', 'time', if_not_exists => TRUE);")
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
|
||||||
|
# Note: TimescaleDB hypertables cannot be easily converted back to regular tables
|
||||||
|
# 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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""add_certificate_id_and_indices
|
||||||
|
|
||||||
|
Revision ID: 4f152b34e800
|
||||||
|
Revises: f94393f57c35
|
||||||
|
Create Date: 2025-10-30 21:29:43.843375+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4f152b34e800'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'f94393f57c35'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Step 1: Add id column as nullable first
|
||||||
|
op.add_column('device_certificates', sa.Column('id', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
# Step 2: Generate IDs for existing records (use device_id as temporary ID)
|
||||||
|
op.execute("""
|
||||||
|
UPDATE device_certificates
|
||||||
|
SET id = device_id || '-' || EXTRACT(EPOCH FROM issued_at)::text
|
||||||
|
WHERE id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Step 3: Drop old primary key constraint
|
||||||
|
op.drop_constraint('device_certificates_pkey', 'device_certificates', type_='primary')
|
||||||
|
|
||||||
|
# Step 4: Make id NOT NULL now that all rows have values
|
||||||
|
op.alter_column('device_certificates', 'id', nullable=False)
|
||||||
|
|
||||||
|
# Step 5: Create new primary key on id
|
||||||
|
op.create_primary_key('device_certificates_pkey', 'device_certificates', ['id'])
|
||||||
|
|
||||||
|
# Step 6: Create indices
|
||||||
|
op.create_index('idx_device_certificates_active', 'device_certificates', ['device_id', 'revoked_at'], unique=False)
|
||||||
|
op.create_index('idx_device_certificates_device_id', 'device_certificates', ['device_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# Drop indices
|
||||||
|
op.drop_index('idx_device_certificates_device_id', table_name='device_certificates')
|
||||||
|
op.drop_index('idx_device_certificates_active', table_name='device_certificates')
|
||||||
|
|
||||||
|
# Drop new primary key
|
||||||
|
op.drop_constraint('device_certificates_pkey', 'device_certificates', type_='primary')
|
||||||
|
|
||||||
|
# Recreate old primary key on device_id
|
||||||
|
op.create_primary_key('device_certificates_pkey', 'device_certificates', ['device_id'])
|
||||||
|
|
||||||
|
# Drop id column
|
||||||
|
op.drop_column('device_certificates', 'id')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add protocol and connection_config to devices
|
||||||
|
|
||||||
|
Revision ID: 4e405f1129b1
|
||||||
|
Revises: 4f152b34e800
|
||||||
|
Create Date: 2025-11-01 19:07:22.800918+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4e405f1129b1'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '4f152b34e800'
|
||||||
|
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_credentials',
|
||||||
|
sa.Column('id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('device_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('credential_type', sa.Text(), nullable=False),
|
||||||
|
sa.Column('credential_hash', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_device_credentials_active', 'device_credentials', ['device_id', 'revoked_at'], unique=False)
|
||||||
|
op.create_index('idx_device_credentials_device_id', 'device_credentials', ['device_id'], unique=False)
|
||||||
|
|
||||||
|
# Add protocol column as nullable first, set default for existing rows, then make NOT NULL
|
||||||
|
op.add_column('devices', sa.Column('protocol', sa.Text(), nullable=True))
|
||||||
|
op.execute("UPDATE devices SET protocol = 'mqtt' WHERE protocol IS NULL")
|
||||||
|
op.alter_column('devices', 'protocol', nullable=False)
|
||||||
|
|
||||||
|
op.add_column('devices', sa.Column('connection_config', sa.JSON(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('devices', 'connection_config')
|
||||||
|
op.drop_column('devices', 'protocol')
|
||||||
|
op.drop_index('idx_device_credentials_device_id', table_name='device_credentials')
|
||||||
|
op.drop_index('idx_device_credentials_active', table_name='device_credentials')
|
||||||
|
op.drop_table('device_credentials')
|
||||||
|
# ### end Alembic commands ###
|
||||||
97
db_migrations/models.py
Normal file
97
db_migrations/models.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Database models for the IoT Dashboard.
|
||||||
|
|
||||||
|
To modify schema:
|
||||||
|
1. Edit models here
|
||||||
|
2. Run: alembic revision --autogenerate -m "description"
|
||||||
|
3. Review the generated migration in alembic/versions/
|
||||||
|
4. Run: alembic upgrade head
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Text, DateTime, JSON
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Base):
|
||||||
|
"""IoT devices registered in the system."""
|
||||||
|
|
||||||
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
location = Column(Text)
|
||||||
|
protocol = Column(Text, nullable=False, default="mqtt")
|
||||||
|
connection_config = Column(JSON)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Device(id={self.id}, name={self.name}, protocol={self.protocol})>"
|
||||||
|
class DeviceCertificate(Base):
|
||||||
|
"""X.509 certificates issued to devices for mTLS authentication."""
|
||||||
|
|
||||||
|
__tablename__ = "device_certificates"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
certificate_pem = Column(Text, nullable=False)
|
||||||
|
private_key_pem = Column(Text)
|
||||||
|
issued_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_device_certificates_device_id", "device_id"),
|
||||||
|
Index("idx_device_certificates_active", "device_id", "revoked_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceCertificate(id={self.id}, device_id={self.device_id}, expires={self.expires_at})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCredential(Base):
|
||||||
|
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
|
||||||
|
|
||||||
|
__tablename__ = "device_credentials"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
credential_type = Column(Text, nullable=False)
|
||||||
|
credential_hash = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True))
|
||||||
|
revoked_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_device_credentials_device_id", "device_id"),
|
||||||
|
Index("idx_device_credentials_active", "device_id", "revoked_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry(Base):
|
||||||
|
"""
|
||||||
|
Time-series telemetry data from devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "telemetry"
|
||||||
|
|
||||||
|
time = Column(DateTime(timezone=True), 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)
|
||||||
|
value = Column(Float, nullable=False)
|
||||||
|
unit = Column(Text)
|
||||||
|
|
||||||
|
__table_args__ = (Index("idx_telemetry_device_time", "device_id", "time"),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"
|
||||||
10
db_migrations/pyproject.toml
Normal file
10
db_migrations/pyproject.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "db-migrations"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "DB migration scripts"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.17.0",
|
||||||
|
"sqlalchemy>=2.0.44",
|
||||||
|
]
|
||||||
9
docs/introduction.md
Normal file
9
docs/introduction.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is a bachelor's thesis project combining IoT, AI and much more.
|
||||||
|
It presents a smart IoT system designed to ingset, store and visualize telemetry data from distributed devices.
|
||||||
|
The project leverages microservices for modularity and good scalability, Redis as a message queue / buffer, and TimescaleDB
|
||||||
|
for fast and efficient time-series data storage.
|
||||||
|
|
||||||
26
gpt.py
26
gpt.py
@@ -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",
|
{
|
||||||
"content": "You are an assistant that analyzes environmental data for an office working space and provides "
|
"role": "system",
|
||||||
"concise numerical insights."},
|
"content": "You are an assistant that analyzes environmental data for an office working space and provides "
|
||||||
|
"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)
|
||||||
|
|||||||
3
infrastructure/.gitignore
vendored
Normal file
3
infrastructure/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mosquitto/certs/
|
||||||
|
mosquitto/data/
|
||||||
|
mosquitto/logs/
|
||||||
35
infrastructure/compose.yml
Normal file
35
infrastructure/compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
image: eclipse-mosquitto:2.0
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
- "9001:9001"
|
||||||
|
- "8883:8883"
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/:/mosquitto/:Z
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
timescaledb:
|
||||||
|
image: timescale/timescaledb:latest-pg17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-iot_data}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- timescaledb-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
timescaledb-data:
|
||||||
32
infrastructure/mosquitto/config/mosquitto.conf
Normal file
32
infrastructure/mosquitto/config/mosquitto.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Persistence
|
||||||
|
persistence true
|
||||||
|
persistence_location /mosquitto/data/
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
#log_dest file /mosquitto/log/mosquitto.log
|
||||||
|
|
||||||
|
# Standard MQTT listener (for testing without certs)
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
|
||||||
|
# mTLS listener (requires client certificates)
|
||||||
|
listener 8883
|
||||||
|
allow_anonymous true
|
||||||
|
protocol mqtt
|
||||||
|
|
||||||
|
# Server certificates (mosquitto's identity)
|
||||||
|
certfile /mosquitto/certs/server.crt
|
||||||
|
keyfile /mosquitto/certs/server.key
|
||||||
|
|
||||||
|
# CA certificate to verify client certificates
|
||||||
|
cafile /mosquitto/certs/ca.crt
|
||||||
|
|
||||||
|
# CRL file
|
||||||
|
crlfile /mosquitto/certs/ca.crl
|
||||||
|
|
||||||
|
# Certificate-based authentication
|
||||||
|
require_certificate true
|
||||||
|
use_identity_as_username true
|
||||||
|
|
||||||
|
# TLS version restrictions
|
||||||
|
tls_version tlsv1.2
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
193
iotDashboard/device_manager_client.py
Normal file
193
iotDashboard/device_manager_client.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""API client for the device_manager microservice."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceRegistrationResponse:
|
||||||
|
device_id: str
|
||||||
|
protocol: str
|
||||||
|
certificate_id: Optional[str] = None
|
||||||
|
ca_certificate_pem: Optional[str] = None
|
||||||
|
certificate_pem: Optional[str] = None
|
||||||
|
private_key_pem: Optional[str] = None
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
credential_id: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
webhook_secret: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceInfo:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
location: Optional[str]
|
||||||
|
protocol: str
|
||||||
|
connection_config: Optional[Dict[str, Any]]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManagerAPIError(Exception):
|
||||||
|
def __init__(self, status_code: int, message: str, details: Optional[Dict] = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(f"API Error {status_code}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManagerClient:
|
||||||
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
|
self.base_url = base_url or os.getenv("DEVICE_MANAGER_URL", "http://localhost:8000")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
try:
|
||||||
|
response = self.session.request(method, url, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
message = error_data.get("detail", str(e))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
message = str(e)
|
||||||
|
|
||||||
|
raise DeviceManagerAPIError(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
message=message,
|
||||||
|
details=error_data if "error_data" in locals() else {},
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise DeviceManagerAPIError(
|
||||||
|
status_code=0, message=f"Connection error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_device(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
location: Optional[str] = None,
|
||||||
|
protocol: str = "mqtt",
|
||||||
|
connection_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> DeviceRegistrationResponse:
|
||||||
|
payload = {"name": name, "protocol": protocol}
|
||||||
|
if location:
|
||||||
|
payload["location"] = location
|
||||||
|
if connection_config:
|
||||||
|
payload["connection_config"] = connection_config
|
||||||
|
|
||||||
|
response = self._request("POST", "/devices/register", json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
protocol=data["protocol"],
|
||||||
|
certificate_id=data.get("certificate_id"),
|
||||||
|
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||||
|
certificate_pem=data.get("certificate_pem"),
|
||||||
|
private_key_pem=data.get("private_key_pem"),
|
||||||
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
credential_id=data.get("credential_id"),
|
||||||
|
api_key=data.get("api_key"),
|
||||||
|
webhook_secret=data.get("webhook_secret"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_device(self, device_id: str) -> DeviceInfo:
|
||||||
|
response = self._request("GET", f"/devices/{device_id}")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return DeviceInfo(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
location=data.get("location"),
|
||||||
|
protocol=data["protocol"],
|
||||||
|
connection_config=data.get("connection_config"),
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_devices(self) -> List[DeviceInfo]:
|
||||||
|
response = self._request("GET", "/devices/")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DeviceInfo(
|
||||||
|
id=device["id"],
|
||||||
|
name=device["name"],
|
||||||
|
location=device.get("location"),
|
||||||
|
protocol=device["protocol"],
|
||||||
|
connection_config=device.get("connection_config"),
|
||||||
|
created_at=datetime.fromisoformat(
|
||||||
|
device["created_at"].replace("Z", "+00:00")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for device in data
|
||||||
|
]
|
||||||
|
|
||||||
|
def revoke_certificate(self, device_id: str) -> Dict[str, Any]:
|
||||||
|
response = self._request("POST", f"/devices/{device_id}/revoke")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def renew_certificate(self, device_id: str) -> DeviceRegistrationResponse:
|
||||||
|
response = self._request("POST", f"/devices/{device_id}/renew")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
protocol=data["protocol"],
|
||||||
|
certificate_id=data.get("certificate_id"),
|
||||||
|
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||||
|
certificate_pem=data.get("certificate_pem"),
|
||||||
|
private_key_pem=data.get("private_key_pem"),
|
||||||
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
credential_id=data.get("credential_id"),
|
||||||
|
api_key=data.get("api_key"),
|
||||||
|
webhook_secret=data.get("webhook_secret"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_ca_certificate(self) -> str:
|
||||||
|
response = self._request("GET", "/ca_certificate")
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def get_crl(self) -> str:
|
||||||
|
response = self._request("GET", "/crl")
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
try:
|
||||||
|
response = self.session.get(f"{self.base_url}/docs", timeout=2)
|
||||||
|
return response.status_code == 200
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
default_client = DeviceManagerClient()
|
||||||
|
|
||||||
|
|
||||||
|
def register_device(
|
||||||
|
name: str,
|
||||||
|
location: Optional[str] = None,
|
||||||
|
protocol: str = "mqtt",
|
||||||
|
connection_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> DeviceRegistrationResponse:
|
||||||
|
return default_client.register_device(name, location, protocol, connection_config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(device_id: str) -> DeviceInfo:
|
||||||
|
return default_client.get_device(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices() -> List[DeviceInfo]:
|
||||||
|
return default_client.list_devices()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_certificate(device_id: str) -> Dict[str, Any]:
|
||||||
|
return default_client.revoke_certificate(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def renew_certificate(device_id: str) -> Dict[str, Any]:
|
||||||
|
return default_client.renew_certificate(device_id)
|
||||||
@@ -1,64 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Django forms for the IoT Dashboard.
|
||||||
|
|
||||||
|
Note: Device registration is handled through the device_manager API.
|
||||||
|
These forms are used for the legacy Django UI only.
|
||||||
|
"""
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from iotDashboard.models import Device, Sensor, SensorType
|
from iotDashboard.models import Device
|
||||||
|
|
||||||
|
|
||||||
class DeviceForm(forms.ModelForm):
|
class DeviceForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating/editing devices.
|
||||||
|
|
||||||
|
Note: This is for the Django UI only. Actual device registration
|
||||||
|
happens through the device_manager microservice API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
protocol = forms.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
("mqtt", "MQTT"),
|
||||||
|
("http", "HTTP"),
|
||||||
|
("webhook", "Webhook"),
|
||||||
|
],
|
||||||
|
initial="mqtt",
|
||||||
|
help_text="Communication protocol for this device",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ['name', 'ip', 'protocol'] # Exclude sensors from the fields
|
fields = ["name", "location", "protocol"]
|
||||||
|
widgets = {
|
||||||
def __init__(self, *args, **kwargs):
|
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device name"}),
|
||||||
# No need to handle sensors in the form
|
"location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device location (optional)"}),
|
||||||
super(DeviceForm, self).__init__(*args, **kwargs)
|
}
|
||||||
|
help_texts = {
|
||||||
def save(self, commit=True):
|
"name": "Unique identifier for this device",
|
||||||
# Save the device instance
|
"location": "Physical location or description",
|
||||||
device = super(DeviceForm, self).save(commit=False)
|
}
|
||||||
|
|
||||||
if commit:
|
|
||||||
device.save()
|
|
||||||
|
|
||||||
return device
|
|
||||||
class SensorWithTypeForm(forms.ModelForm):
|
|
||||||
# Add fields for SensorType directly in the form
|
|
||||||
type_name = forms.CharField(max_length=50, label="Sensor Type Name")
|
|
||||||
unit = forms.CharField(max_length=20, label="Unit", required=False)
|
|
||||||
protocol = forms.ChoiceField(
|
|
||||||
choices=[('mqtt', 'MQTT'), ('http', 'HTTP')],
|
|
||||||
label="Protocol"
|
|
||||||
)
|
|
||||||
topic = forms.CharField(max_length=100, label="Topic", required=False)
|
|
||||||
endpoint = forms.CharField(max_length=100, label="Endpoint", required=False)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Sensor
|
|
||||||
fields = ['enabled'] # Exclude 'device' from the form fields
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.device = kwargs.pop('device', None) # Get the device from kwargs
|
|
||||||
super(SensorWithTypeForm, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
|
||||||
# Create or get the SensorType
|
|
||||||
try:
|
|
||||||
sensor_type = SensorType.objects.get(name=self.cleaned_data['type_name'])
|
|
||||||
except SensorType.DoesNotExist:
|
|
||||||
sensor_type = SensorType(
|
|
||||||
name=self.cleaned_data['type_name'],
|
|
||||||
unit=self.cleaned_data['unit'],
|
|
||||||
protocol=self.cleaned_data['protocol'],
|
|
||||||
topic=self.cleaned_data['topic'],
|
|
||||||
endpoint=self.cleaned_data['endpoint']
|
|
||||||
)
|
|
||||||
if commit:
|
|
||||||
sensor_type.save()
|
|
||||||
|
|
||||||
# Create Sensor with the SensorType found or created
|
|
||||||
sensor = super(SensorWithTypeForm, self).save(commit=False)
|
|
||||||
sensor.type = sensor_type
|
|
||||||
sensor.device = self.device # Associate the sensor with the device
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
sensor.save()
|
|
||||||
|
|
||||||
return sensor
|
|
||||||
|
|||||||
6
iotDashboard/main.py
Normal file
6
iotDashboard/main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from iotdashboard!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
89
iotDashboard/migrations/0001_initial.py
Normal file
89
iotDashboard/migrations/0001_initial.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-27 10:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Device",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
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(
|
||||||
|
name="SensorType",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=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(
|
||||||
|
name="Sensor",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
0
iotDashboard/migrations/__init__.py
Normal file
0
iotDashboard/migrations/__init__.py
Normal file
@@ -1,26 +1,175 @@
|
|||||||
|
"""
|
||||||
|
Django models that mirror the SQLAlchemy schema from db_migrations/models.py.
|
||||||
|
|
||||||
|
These models are read-only (managed=False) and query the microservices database.
|
||||||
|
For write operations, use the device_manager API client instead.
|
||||||
|
"""
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
class SensorType(models.Model):
|
|
||||||
name = models.CharField(max_length=50, unique=True) # Sensor name, e.g., "CO2", "Noise", etc.
|
|
||||||
unit = models.CharField(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):
|
|
||||||
return f"{self.name} ({self.unit})"
|
|
||||||
|
|
||||||
class Device(models.Model):
|
class Device(models.Model):
|
||||||
name = models.CharField(max_length=50) # Device name
|
"""IoT devices registered in the system."""
|
||||||
ip = models.CharField(max_length=20) # Device IP address
|
|
||||||
protocol = models.CharField(max_length=20, choices=[('mqtt', 'MQTT'), ('http', 'HTTP')])
|
id = models.CharField(max_length=8, primary_key=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
location = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
protocol = models.CharField(max_length=50, default="mqtt")
|
||||||
|
connection_config = models.JSONField(null=True, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "devices"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return f"{self.name} ({self.id}) [{self.protocol}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_certificate(self):
|
||||||
|
"""Get the active (non-revoked) certificate for this device."""
|
||||||
|
return self.certificates.filter(revoked_at__isnull=True).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def certificate_status(self):
|
||||||
|
"""Get human-readable certificate status for MQTT devices."""
|
||||||
|
if self.protocol != "mqtt":
|
||||||
|
return "N/A"
|
||||||
|
cert = self.active_certificate
|
||||||
|
if not cert:
|
||||||
|
return "No Certificate"
|
||||||
|
if cert.is_expired:
|
||||||
|
return "Expired"
|
||||||
|
if cert.is_expiring_soon:
|
||||||
|
return "Expiring Soon"
|
||||||
|
return "Valid"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCertificate(models.Model):
|
||||||
|
"""X.509 certificates issued to devices for mTLS authentication."""
|
||||||
|
|
||||||
|
id = models.CharField(
|
||||||
|
max_length=255, primary_key=True
|
||||||
|
) # Certificate serial number (hex)
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Device, on_delete=models.CASCADE, related_name="certificates", db_column="device_id"
|
||||||
|
)
|
||||||
|
certificate_pem = models.TextField()
|
||||||
|
private_key_pem = models.TextField(null=True, blank=True) # Optional backup
|
||||||
|
issued_at = models.DateTimeField()
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False # Don't create/modify this table
|
||||||
|
db_table = "device_certificates"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["device"]),
|
||||||
|
models.Index(fields=["device", "revoked_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
class Sensor(models.Model):
|
|
||||||
device = models.ForeignKey(Device, related_name='sensors', on_delete=models.CASCADE)
|
|
||||||
type = models.ForeignKey(SensorType, on_delete=models.CASCADE)
|
|
||||||
enabled = models.BooleanField(default=True)
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.type.name} Sensor on {self.device.name}"
|
status = "Revoked" if self.revoked_at else "Active"
|
||||||
|
return f"Certificate {self.id[:8]}... for {self.device.name} ({status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_revoked(self):
|
||||||
|
"""Check if certificate is revoked."""
|
||||||
|
return self.revoked_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
"""Check if certificate is expired."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expiring_soon(self):
|
||||||
|
"""Check if certificate expires within 30 days."""
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
return (
|
||||||
|
not self.is_expired
|
||||||
|
and self.expires_at < timezone.now() + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
"""Check if certificate is valid (not revoked and not expired)."""
|
||||||
|
return not self.is_revoked and not self.is_expired
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days_until_expiry(self):
|
||||||
|
"""Calculate days until certificate expires."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
if self.is_expired:
|
||||||
|
return 0
|
||||||
|
delta = self.expires_at - timezone.now()
|
||||||
|
return delta.days
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCredential(models.Model):
|
||||||
|
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
|
||||||
|
|
||||||
|
id = models.CharField(max_length=255, primary_key=True)
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Device, on_delete=models.CASCADE, related_name="credentials", db_column="device_id"
|
||||||
|
)
|
||||||
|
credential_type = models.CharField(max_length=50)
|
||||||
|
credential_hash = models.TextField()
|
||||||
|
created_at = models.DateTimeField()
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "device_credentials"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["device"]),
|
||||||
|
models.Index(fields=["device", "revoked_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = "Revoked" if self.revoked_at else "Active"
|
||||||
|
return f"{self.credential_type} for {self.device.name} ({status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_revoked(self):
|
||||||
|
return self.revoked_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
return self.expires_at and timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.is_revoked and not self.is_expired
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry(models.Model):
|
||||||
|
"""Time-series telemetry data from devices."""
|
||||||
|
|
||||||
|
time = models.DateTimeField()
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Device, on_delete=models.CASCADE, related_name="telemetry", db_column="device_id"
|
||||||
|
)
|
||||||
|
metric = models.CharField(max_length=255)
|
||||||
|
value = models.FloatField()
|
||||||
|
unit = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "telemetry"
|
||||||
|
unique_together = [["time", "device", "metric"]]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["device", "time"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"
|
||||||
|
|
||||||
|
|||||||
5
iotDashboard/pyproject.toml
Normal file
5
iotDashboard/pyproject.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = []
|
||||||
@@ -9,10 +9,10 @@ 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
|
||||||
from huey import SqliteHuey
|
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -21,14 +21,17 @@ 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')
|
|
||||||
|
POSTGRES_HOST = os.getenv("POSTGRES_HOST")
|
||||||
|
POSTGRES_PORT = os.getenv("POSTGRES_PORT")
|
||||||
|
POSTGRES_USER = os.getenv("POSTGRES_USER")
|
||||||
|
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
||||||
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||||
|
|
||||||
# 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 +42,59 @@ 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',
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
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.postgresql",
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
"NAME": POSTGRES_DB,
|
||||||
|
"USER": POSTGRES_USER,
|
||||||
|
"PASSWORD": POSTGRES_PASSWORD,
|
||||||
|
"HOST": POSTGRES_HOST,
|
||||||
|
"PORT": POSTGRES_PORT,
|
||||||
},
|
},
|
||||||
'data': {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"NAME" : "example",
|
|
||||||
"USER": "postgres",
|
|
||||||
"PASSWORD": os.getenv('PASSWORD'),
|
|
||||||
"HOST": '10.10.0.1',
|
|
||||||
"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,19 +132,10 @@ 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_class': 'huey.SqliteHuey', # Or 'huey.RedisHuey' for Redis
|
|
||||||
'filename': 'demo.db', # SQLite file for task storage
|
|
||||||
'results': True,
|
|
||||||
'store_none': False,
|
|
||||||
'immediate': False,
|
|
||||||
'utc': True,
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import json
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
import psycopg2
|
|
||||||
import redis
|
|
||||||
from django.conf import settings
|
|
||||||
from huey import crontab
|
|
||||||
from huey.contrib.djhuey import periodic_task
|
|
||||||
from .models import Device
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') # Default to localhost if not set
|
|
||||||
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.')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def devices_to_redis():
|
|
||||||
"""Fetch devices and their sensors' topics from Django and store them in Redis."""
|
|
||||||
devices = Device.objects.all()
|
|
||||||
devices_list = []
|
|
||||||
for device in devices:
|
|
||||||
for sensor in device.sensors.all():
|
|
||||||
sensor_data = {
|
|
||||||
'device_name': device.name,
|
|
||||||
'sensor_name': sensor.type.name,
|
|
||||||
'topic': sensor.type.topic # Assuming the topic is stored in SensorType
|
|
||||||
}
|
|
||||||
devices_list.append(sensor_data)
|
|
||||||
redis_client.set('mqtt_devices', json.dumps(devices_list))
|
|
||||||
print("Devices with sensors stored in Redis.")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_data_http(device, sensor):
|
|
||||||
"""Fetch data from an HTTP sensor."""
|
|
||||||
sensor_type_name = sensor.type.name.lower()
|
|
||||||
try:
|
|
||||||
response = requests.get(f"http://{device.ip}/sensor/{sensor_type_name}", timeout=5)
|
|
||||||
response.raise_for_status()
|
|
||||||
sensor_value = response.json().get('value')
|
|
||||||
if sensor_value is not None:
|
|
||||||
return {
|
|
||||||
"time": datetime.datetime.utcnow().isoformat(),
|
|
||||||
"device": device.name,
|
|
||||||
"sensor": sensor_type_name,
|
|
||||||
"sensor_value": sensor_value
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
print(f"No value returned from {device.name} for {sensor_type_name}")
|
|
||||||
except requests.RequestException as e:
|
|
||||||
print(f"HTTP request failed for {device.name}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_data_mqtt_stream(device, sensor):
|
|
||||||
"""Fetch data from Redis Stream for a specific MQTT device and sensor."""
|
|
||||||
sensor_name = sensor.type.name.lower()
|
|
||||||
stream_key = f"mqtt_stream:{device.name}:{sensor_name}"
|
|
||||||
try:
|
|
||||||
stream_data = redis_client.xread({stream_key: '0-0'}, block=1000, count=1)
|
|
||||||
if stream_data:
|
|
||||||
_, entries = stream_data[0]
|
|
||||||
for entry_id, entry_data in entries:
|
|
||||||
sensor_value = entry_data.get(b'value')
|
|
||||||
timestamp = entry_data.get(b'time')
|
|
||||||
|
|
||||||
if sensor_value and timestamp:
|
|
||||||
return {
|
|
||||||
"time": timestamp.decode('utf-8'),
|
|
||||||
"device": device.name,
|
|
||||||
"sensor_value": float(sensor_value.decode('utf-8'))
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching data from stream {stream_key}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_recent_data(timestamp):
|
|
||||||
"""Check if data is within a 1-minute freshness window."""
|
|
||||||
data_time = datetime.datetime.fromisoformat(timestamp)
|
|
||||||
return data_time > datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_data(data, sensor_type):
|
|
||||||
"""Insert parsed data into the PostgreSQL database."""
|
|
||||||
if 'sensor_value' not in data:
|
|
||||||
print(f"Missing 'sensor_value' in data: {data}. Skipping insertion.")
|
|
||||||
return
|
|
||||||
|
|
||||||
insert_data_dict = {
|
|
||||||
"time": data['time'],
|
|
||||||
"device": data['device'],
|
|
||||||
"metric": sensor_type.lower(),
|
|
||||||
"value": data['sensor_value'],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with psycopg2.connect(settings.CONNECTION_STRING) as conn:
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO sensor_readings (time, device_name, metric, value)
|
|
||||||
VALUES (%s, %s, %s, %s);
|
|
||||||
"""
|
|
||||||
cursor.execute(insert_query, (
|
|
||||||
insert_data_dict["time"],
|
|
||||||
insert_data_dict["device"],
|
|
||||||
insert_data_dict["metric"],
|
|
||||||
insert_data_dict["value"]
|
|
||||||
))
|
|
||||||
conn.commit()
|
|
||||||
print(f"Data inserted successfully for {insert_data_dict['device']}: {insert_data_dict}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to insert data: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@periodic_task(crontab(minute='*/1'))
|
|
||||||
def fetch_data_from_all_devices():
|
|
||||||
"""Fetch and insert data for all devices based on their protocol."""
|
|
||||||
devices = Device.objects.all()
|
|
||||||
for device in devices:
|
|
||||||
for sensor in device.sensors.all():
|
|
||||||
data = None
|
|
||||||
|
|
||||||
if device.protocol == 'http':
|
|
||||||
data = fetch_data_http(device, sensor)
|
|
||||||
elif device.protocol == 'mqtt':
|
|
||||||
data = fetch_data_mqtt_stream(device, sensor)
|
|
||||||
|
|
||||||
if data and is_recent_data(data['time']):
|
|
||||||
insert_data(data, sensor.type.name)
|
|
||||||
else:
|
|
||||||
print(f"No recent or valid data for {device.name}. Skipping.")
|
|
||||||
|
|
||||||
|
|
||||||
@periodic_task(crontab(minute='*/5'))
|
|
||||||
def last_5_minutes():
|
|
||||||
"""Fetch the last 5 readings from TimescaleDB and store them in Redis."""
|
|
||||||
try:
|
|
||||||
with psycopg2.connect(settings.CONNECTION_STRING) as conn:
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT time, device_name, metric, value
|
|
||||||
FROM sensor_readings
|
|
||||||
ORDER BY time DESC
|
|
||||||
LIMIT 5;
|
|
||||||
""")
|
|
||||||
results = cursor.fetchall()
|
|
||||||
|
|
||||||
data = [
|
|
||||||
{
|
|
||||||
"time": reading[0].isoformat(),
|
|
||||||
"device": reading[1],
|
|
||||||
"metric": reading[2],
|
|
||||||
"value": reading[3]
|
|
||||||
}
|
|
||||||
for reading in results
|
|
||||||
]
|
|
||||||
redis_client.set("last5", json.dumps(data))
|
|
||||||
print("Last 5 readings:", data)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching or storing the last 5 readings: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
devices_to_redis()
|
|
||||||
@@ -3,40 +3,39 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Add Sensor and Type</title>
|
<title>Renew Certificate</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h1 class="text-center mb-4">Add Sensor and Sensor Type</h1>
|
<h1 class="text-center mb-4">Renew Certificate</h1>
|
||||||
<form method="post">
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<strong>Info:</strong> This will generate a new certificate for device "{{ device.name }}" and revoke the old one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center">Do you want to renew the certificate now?</p>
|
||||||
|
|
||||||
|
<form method="post" class="text-center">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }} <!-- Renders the inputs for all fields you added in your form -->
|
<button type="submit" class="btn btn-primary">Renew Certificate</button>
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}"> <!-- Hidden field for device ID -->
|
|
||||||
<button type="submit" class="btn btn-success">Save</button>
|
|
||||||
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if form.errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<ul>
|
|
||||||
{% for field, errors in form.errors.items %}
|
|
||||||
<li>{{ field }}: {{ errors|join:', ' }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
42
iotDashboard/templates/certificate_revoke_confirm.html
Normal file
42
iotDashboard/templates/certificate_revoke_confirm.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Revoke Certificate</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1 class="text-center mb-4">Revoke Certificate</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>Warning!</strong> This action will revoke the certificate for device "{{ device.name }}".
|
||||||
|
The device will no longer be able to connect until a new certificate is issued.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center">Are you sure you want to revoke the certificate?</p>
|
||||||
|
|
||||||
|
<form method="post" class="text-center">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Revoke Certificate</button>
|
||||||
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -93,19 +93,19 @@
|
|||||||
<!-- Device Selector Dropdown -->
|
<!-- Device Selector Dropdown -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4 offset-md-4">
|
<div class="col-md-4 offset-md-4">
|
||||||
<select id="deviceSelector" class="form-select" onchange="updateSensorOptions()">
|
<select id="deviceSelector" class="form-select" onchange="updateMetricOptions()">
|
||||||
<option value="">-- Select a Device --</option>
|
<option value="">-- Select a Device --</option>
|
||||||
<script>
|
<script>
|
||||||
const devices = JSON.parse('{{ devices_json|escapejs }}');
|
const devices = JSON.parse('{{ devices_json|escapejs }}');
|
||||||
|
|
||||||
const deviceSelector = document.getElementById('deviceSelector');
|
const deviceSelector = document.getElementById('deviceSelector');
|
||||||
const sensorSelector = document.getElementById('sensorSelector');
|
const metricSelector = document.getElementById('metricSelector');
|
||||||
|
|
||||||
// Populate device selector
|
// Populate device selector
|
||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = device.name; // Device name used for identification
|
option.value = device.id; // Use device ID
|
||||||
option.textContent = device.name;
|
option.textContent = device.name + ' (' + device.protocol.toUpperCase() + ')';
|
||||||
deviceSelector.appendChild(option);
|
deviceSelector.appendChild(option);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -113,11 +113,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sensor Selector Dropdown -->
|
<!-- Metric Selector Dropdown -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4 offset-md-4">
|
<div class="col-md-4 offset-md-4">
|
||||||
<select id="sensorSelector" class="form-select">
|
<select id="metricSelector" class="form-select">
|
||||||
<option value="">-- Select a Sensor --</option>
|
<option value="">-- Select a Metric --</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,39 +157,39 @@
|
|||||||
var ctx = document.getElementById('conditionsChart').getContext('2d');
|
var ctx = document.getElementById('conditionsChart').getContext('2d');
|
||||||
var conditionsChart;
|
var conditionsChart;
|
||||||
|
|
||||||
function updateSensorOptions() {
|
function updateMetricOptions() {
|
||||||
const selectedDeviceName = document.getElementById('deviceSelector').value;
|
const selectedDeviceId = document.getElementById('deviceSelector').value;
|
||||||
|
|
||||||
// Find the selected device in the devices array
|
// Find the selected device in the devices array
|
||||||
const selectedDevice = devices.find(device => device.name === selectedDeviceName);
|
const selectedDevice = devices.find(device => device.id === selectedDeviceId);
|
||||||
|
|
||||||
// Clear the existing options in the sensorSelector
|
// Clear the existing options in the metricSelector
|
||||||
const sensorSelector = document.getElementById('sensorSelector');
|
const metricSelector = document.getElementById('metricSelector');
|
||||||
sensorSelector.innerHTML = '<option value="">-- Select a Sensor --</option>';
|
metricSelector.innerHTML = '<option value="">-- Select a Metric --</option>';
|
||||||
|
|
||||||
if (selectedDevice) {
|
if (selectedDevice && selectedDevice.metrics) {
|
||||||
// Populate the sensor selector with the sensors for the selected device
|
// Populate the metric selector with the metrics for the selected device
|
||||||
selectedDevice.sensors.forEach(sensor => {
|
selectedDevice.metrics.forEach(metric => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = sensor.id; // Set the sensor ID as the value
|
option.value = metric;
|
||||||
option.textContent = sensor.type; // Set the sensor type as the display text
|
option.textContent = metric.charAt(0).toUpperCase() + metric.slice(1);
|
||||||
sensorSelector.appendChild(option);
|
metricSelector.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchDeviceData() {
|
function fetchDeviceData() {
|
||||||
var device = document.getElementById('deviceSelector').value;
|
var deviceId = document.getElementById('deviceSelector').value;
|
||||||
var sensor = document.getElementById('sensorSelector').value; // Get selected sensor
|
var metric = document.getElementById('metricSelector').value;
|
||||||
var startDate = document.getElementById('startDate').value;
|
var startDate = document.getElementById('startDate').value;
|
||||||
var endDate = document.getElementById('endDate').value;
|
var endDate = document.getElementById('endDate').value;
|
||||||
|
|
||||||
if (!device || !sensor) {
|
if (!deviceId || !metric) {
|
||||||
alert("Please select both a device and a sensor.");
|
alert("Please select both a device and a metric.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`/fetch_device_data/?device=${device}&sensor=${sensor}&start_date=${startDate}&end_date=${endDate}`)
|
fetch(`/fetch_device_data/?device_id=${deviceId}&metric=${metric}&start_date=${startDate}&end_date=${endDate}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Network response was not ok: " + response.statusText);
|
throw new Error("Network response was not ok: " + response.statusText);
|
||||||
@@ -212,9 +212,9 @@ function fetchDeviceData() {
|
|||||||
labels: data.times,
|
labels: data.times,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: `${sensor} (°C or %)`,
|
label: metric.charAt(0).toUpperCase() + metric.slice(1),
|
||||||
data: data.values,
|
data: data.values,
|
||||||
borderColor: sensor === 'Temperature' ? 'red' : 'blue',
|
borderColor: metric === 'temperature' ? 'red' : (metric === 'humidity' ? 'blue' : 'green'),
|
||||||
fill: false,
|
fill: false,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'login' %}">Login</a>
|
<a class="nav-link" href="/admin">Login</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
172
iotDashboard/templates/device_credentials.html
Normal file
172
iotDashboard/templates/device_credentials.html
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Device Credentials - {{ device_name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.credential-box {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<div class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
<h4 class="alert-heading">✅ Device Registered Successfully!</h4>
|
||||||
|
<p>Device <strong>{{ device_name }}</strong> has been registered.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>⚠️ Important:</strong> Save these credentials now! They will not be shown again.
|
||||||
|
You'll need them to configure your device.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Device Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Device ID:</th>
|
||||||
|
<td><code>{{ response.device_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol:</th>
|
||||||
|
<td><span class="badge bg-info">{{ response.protocol|upper }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% if response.certificate_id %}
|
||||||
|
<tr>
|
||||||
|
<th>Certificate ID:</th>
|
||||||
|
<td><code>{{ response.certificate_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Expires At:</th>
|
||||||
|
<td>{{ response.expires_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if response.protocol == 'mqtt' and response.certificate_pem %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>CA Certificate</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="ca-cert">{{ response.ca_certificate_pem }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('ca-cert')">Copy CA Certificate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Device Certificate</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="device-cert">{{ response.certificate_pem }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('device-cert')">Copy Device Certificate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Private Key</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="private-key">{{ response.private_key_pem }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('private-key')">Copy Private Key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>MQTT Configuration:</h6>
|
||||||
|
<ul>
|
||||||
|
<li>Broker: <code>localhost:8883</code></li>
|
||||||
|
<li>Topic: <code>devices/{{ response.device_id }}/<metric></code></li>
|
||||||
|
<li>Example: <code>devices/{{ response.device_id }}/temperature</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if response.protocol == 'http' and response.api_key %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>API Key</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="api-key">{{ response.api_key }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('api-key')">Copy API Key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if response.protocol == 'webhook' and response.webhook_secret %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Webhook Secret</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="webhook-secret">{{ response.webhook_secret }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('webhook-secret')">Copy Webhook Secret</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="{% url 'device_list' %}" class="btn btn-primary">Go to Device List</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyToClipboard(elementId) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
const text = element.textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Change button text temporarily
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = '✓ Copied!';
|
||||||
|
btn.classList.remove('btn-secondary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
alert('Failed to copy: ' + err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
101
iotDashboard/templates/device_detail.html
Normal file
101
iotDashboard/templates/device_detail.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Device Details - {{ device.name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1 class="text-center mb-4">Device Details</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>{{ device.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Device ID:</th>
|
||||||
|
<td><code>{{ device.id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Location:</th>
|
||||||
|
<td>{{ device.location|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol:</th>
|
||||||
|
<td><span class="badge bg-info">{{ device.protocol|upper }}</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Created:</th>
|
||||||
|
<td>{{ device.created_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if device.protocol == 'mqtt' and certificate %}
|
||||||
|
<h4 class="mt-4">Certificate Information</h4>
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Certificate ID:</th>
|
||||||
|
<td><code>{{ certificate.id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Issued At:</th>
|
||||||
|
<td>{{ certificate.issued_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Expires At:</th>
|
||||||
|
<td>{{ certificate.expires_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
{% if certificate.revoked_at %}
|
||||||
|
<span class="badge bg-danger">Revoked</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Back to List</a>
|
||||||
|
{% if device.protocol == 'mqtt' %}
|
||||||
|
<a href="{% url 'renew_certificate' device.id %}" class="btn btn-warning">Renew Certificate</a>
|
||||||
|
<a href="{% url 'revoke_certificate' device.id %}" class="btn btn-danger">Revoke Certificate</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'delete_device' device.id %}" class="btn btn-danger">Delete Device</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -23,40 +23,42 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h1 class="text-center mb-4">{% if form.instance.pk %}Edit{% else %}Add{% endif %} Device</h1>
|
<h1 class="text-center mb-4">Add New Device</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<!-- Display the form fields -->
|
<label for="name" class="form-label">Device Name *</label>
|
||||||
{{ form.name.label_tag }} {{ form.name }}<br>
|
<input type="text" class="form-control" id="name" name="name" value="{{ name }}" required>
|
||||||
{{ form.ip.label_tag }} {{ form.ip }}<br>
|
</div>
|
||||||
{{ form.protocol.label_tag }} {{ form.protocol }}<br>
|
|
||||||
|
<div class="mb-3">
|
||||||
<!-- If there are errors, display them -->
|
<label for="location" class="form-label">Location</label>
|
||||||
{% if form.errors %}
|
<input type="text" class="form-control" id="location" name="location" value="{{ location }}">
|
||||||
<div class="alert alert-danger">
|
</div>
|
||||||
<ul>
|
|
||||||
{% for field, errors in form.errors.items %}
|
<div class="mb-3">
|
||||||
<li>{{ field }}: {{ errors|join:", " }}</li>
|
<label for="protocol" class="form-label">Protocol *</label>
|
||||||
{% endfor %}
|
<select class="form-select" id="protocol" name="protocol" required>
|
||||||
</ul>
|
<option value="mqtt" {% if protocol == 'mqtt' %}selected{% endif %}>MQTT (with mTLS)</option>
|
||||||
</div>
|
<option value="http" {% if protocol == 'http' %}selected{% endif %}>HTTP (coming soon)</option>
|
||||||
{% endif %}
|
<option value="webhook" {% if protocol == 'webhook' %}selected{% endif %}>Webhook (coming soon)</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">MQTT devices will receive X.509 certificates for secure communication.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit and Cancel buttons -->
|
<button type="submit" class="btn btn-success">Register Device</button>
|
||||||
<button type="submit" class="btn btn-success">Save</button>
|
|
||||||
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Links for adding/editing sensors for this specific device -->
|
|
||||||
<div class="mt-4">
|
|
||||||
{% if form.instance.pk %}
|
|
||||||
<a href="{% url 'add_sensor' form.instance.pk %}" class="btn btn-primary">Add Sensor</a>
|
|
||||||
<a href="{% url 'sensor_list' form.instance.pk %}" class="btn btn-info">Edit Sensors</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
|
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
|
<li class="nav-item"><a class="nav-link" href="/admin">Login</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,41 +33,61 @@
|
|||||||
<!-- Device Management -->
|
<!-- Device Management -->
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h1 class="text-center mb-4">Manage Devices</h1>
|
<h1 class="text-center mb-4">Manage Devices</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<a href="{% url 'add_device' %}" class="btn btn-primary mb-3">Add Device</a>
|
<a href="{% url 'add_device' %}" class="btn btn-primary mb-3">Add Device</a>
|
||||||
|
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>IP Address</th>
|
<th>Location</th>
|
||||||
<th>Protocol</th>
|
<th>Protocol</th>
|
||||||
<th>Sensor Types</th> <!-- Adjusted to Sensor Types -->
|
<th>Certificate Status</th>
|
||||||
|
<th>Certificate Expiry</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for device in devices %}
|
{% for device_data in devices %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ device.name }}</td>
|
<td>{{ device_data.device.name }}</td>
|
||||||
<td>{{ device.ip }}</td>
|
<td>{{ device_data.device.location|default:"—" }}</td>
|
||||||
<td>{{ device.protocol }}</td>
|
<td><span class="badge bg-info">{{ device_data.device.protocol|upper }}</span></td>
|
||||||
<!-- Collect and display sensor types -->
|
|
||||||
<td>
|
<td>
|
||||||
{% for sensor in device.sensors.all %}
|
{% if device_data.device.protocol == 'mqtt' %}
|
||||||
{{ sensor.type.name }}{% if not forloop.last %}, {% endif %}
|
<span class="badge bg-success">{{ device_data.certificate_status }}</span>
|
||||||
{% empty %}
|
{% else %}
|
||||||
No sensors
|
<span class="badge bg-secondary">N/A</span>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<!-- You may further expand other sensor-related data -->
|
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'edit_device' device.pk %}" class="btn btn-warning btn-sm">Edit</a>
|
{% if device_data.active_certificate %}
|
||||||
<a href="{% url 'delete_device' device.pk %}" class="btn btn-danger btn-sm">Delete</a>
|
{{ device_data.active_certificate.expires_at|date:"Y-m-d H:i" }}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'view_device' device_data.device.id %}" class="btn btn-info btn-sm">View</a>
|
||||||
|
<a href="{% url 'delete_device' device_data.device.id %}" class="btn btn-danger btn-sm">Delete</a>
|
||||||
|
{% if device_data.device.protocol == 'mqtt' %}
|
||||||
|
<a href="{% url 'renew_certificate' device_data.device.id %}" class="btn btn-warning btn-sm">Renew Cert</a>
|
||||||
|
<a href="{% url 'revoke_certificate' device_data.device.id %}" class="btn btn-secondary btn-sm">Revoke</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">No devices found.</td>
|
<td colspan="6" class="text-center">No devices found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Confirm Delete Sensor</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-light">
|
|
||||||
<!-- Navbar -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation -->
|
|
||||||
<div class="container mt-5">
|
|
||||||
<h1 class="text-center mb-4">Confirm Delete Sensor</h1>
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<strong>Warning!</strong> Are you sure you want to delete the sensor "{{ sensor.type.name }}"? This action cannot be undone.
|
|
||||||
</div>
|
|
||||||
<form method="POST">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="text-center">
|
|
||||||
<button type="submit" class="btn btn-danger">Delete</button>
|
|
||||||
<a href="{% url 'sensor_list' sensor.device.pk %}" class="btn btn-secondary">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Manage Sensors for {{ device.name }}</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-light">
|
|
||||||
<!-- Navbar -->
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
|
||||||
</ul>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Sensor Management -->
|
|
||||||
<div class="container mt-5">
|
|
||||||
<h1>Sensors for {{ device.name }}</h1>
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Sensor Type</th>
|
|
||||||
<th>Enabled</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for sensor in sensors %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ sensor.type.name }}</td>
|
|
||||||
<td>{{ sensor.enabled }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="{% url 'edit_sensor' sensor.pk %}" class="btn btn-warning btn-sm">Edit</a>
|
|
||||||
<a href="{% url 'delete_sensor' sensor.pk %}" class="btn btn-danger btn-sm">Delete</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">No sensors found for this device.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -14,24 +14,32 @@ 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('', views.chart, name="index"),
|
# Main dashboard
|
||||||
path('fetch_device_data/', views.fetch_device_data, name='fetch_device_data'),
|
path("", views.chart, name="index"),
|
||||||
path('devices/', views.device_list, name='device_list'),
|
path("chart/", views.chart, name="chart"),
|
||||||
path('devices/add/', views.add_device, name='add_device'),
|
|
||||||
path('devices/edit/<int:pk>/', views.edit_device, name='edit_device'),
|
# Device management
|
||||||
path('devices/delete/<int:pk>/', views.delete_device, name='delete_device'),
|
path("devices/", views.device_list, name="device_list"),
|
||||||
path('logout/', views.logout_view, name='logout'),
|
path("devices/add/", views.add_device, name="add_device"),
|
||||||
path('sensors/<int:device_id>/', views.sensor_list, name='sensor_list'),
|
path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
||||||
path('sensor/add/', views.add_sensor_with_type, name="add_sensor_with_type"),
|
path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
||||||
# 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'),
|
# Certificate management (MQTT devices only)
|
||||||
path('sensor/edit/<int:pk>/', views.edit_sensor, name='edit_sensor'),
|
path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
||||||
path('sensor/delete/<int:pk>/', views.delete_sensor, name='delete_sensor'),
|
path("devices/<str:device_id>/certificate/renew/", views.renew_certificate, name="renew_certificate"),
|
||||||
|
|
||||||
|
# Telemetry data API
|
||||||
|
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
|
||||||
|
|
||||||
|
# Legacy/utility endpoints
|
||||||
|
path("devices_api/", views.devices_api, name="devices_api"),
|
||||||
|
path("logout/", views.logout_view, name="logout"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,218 +1,263 @@
|
|||||||
import redis
|
|
||||||
import json
|
import json
|
||||||
from django.db import connections
|
from django.http import JsonResponse
|
||||||
from django.http import JsonResponse, HttpResponse
|
from django.shortcuts import render, redirect
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.contrib import messages
|
||||||
|
|
||||||
from .forms import DeviceForm, SensorWithTypeForm
|
from iotDashboard.models import Device, Telemetry
|
||||||
from iotDashboard.models import Device, Sensor
|
from iotDashboard.device_manager_client import DeviceManagerClient, DeviceManagerAPIError
|
||||||
|
|
||||||
redis_client = redis.StrictRedis(host='10.10.0.1', port=6379, db=0)
|
device_manager = DeviceManagerClient()
|
||||||
|
|
||||||
|
|
||||||
def fetch_gpt_data():
|
# def index(request):
|
||||||
return redis_client.get("gpt").decode("utf-8").strip('b"').replace('\\"', '"').replace("\\n", "").replace("\\","").replace("\\u00b0", "°")
|
# """Redirect to chart page."""
|
||||||
|
# if request.user.is_authenticated:
|
||||||
|
# return redirect("/chart/")
|
||||||
|
# return HttpResponse("NOT AUTHENTICATED!!!")
|
||||||
|
|
||||||
|
|
||||||
def chart(request):
|
def chart(request):
|
||||||
# Fetch devices and their related sensors
|
"""Main dashboard showing telemetry charts."""
|
||||||
devices = Device.objects.prefetch_related('sensors__type').all() # Prefetch related sensors and their types
|
|
||||||
|
|
||||||
# Create a list of devices and associated sensors
|
|
||||||
devices_json = [
|
|
||||||
{
|
|
||||||
"name": device.name,
|
|
||||||
"sensors": [{"id": sensor.id, "type": sensor.type.name} for sensor in device.sensors.all()]
|
|
||||||
}
|
|
||||||
for device in devices
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
gpt_data = fetch_gpt_data()
|
devices = Device.objects.all()
|
||||||
gpt = json.loads(gpt_data)
|
|
||||||
except (redis.RedisError, json.JSONDecodeError) as e:
|
devices_data = []
|
||||||
gpt = {"summary": "Error fetching data", "recommendations": {}}
|
for device in devices:
|
||||||
print(f"Error fetching or parsing GPT data: {e}")
|
# Get unique metrics for this device from telemetry
|
||||||
|
metrics = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=device.id)
|
||||||
|
.values_list('metric', flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
devices_data.append({
|
||||||
|
"id": device.id,
|
||||||
|
"name": device.name,
|
||||||
|
"protocol": device.protocol,
|
||||||
|
"metrics": list(metrics),
|
||||||
|
})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"devices_json": json.dumps(devices_data),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "chart.html", context)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error loading dashboard: {str(e)}")
|
||||||
|
return render(request, "chart.html", {"devices_json": "[]"})
|
||||||
|
|
||||||
context = {
|
|
||||||
'devices_json': json.dumps(devices_json), # Convert to a JSON string
|
|
||||||
'gpt': gpt
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, 'chart.html', context)
|
|
||||||
|
|
||||||
def fetch_device_data(request):
|
def fetch_device_data(request):
|
||||||
device_name = request.GET.get('device', 'Livingroom')
|
"""Fetch telemetry data for chart visualization."""
|
||||||
sensor_name = request.GET.get('sensor') # This will be the actual sensor name
|
from datetime import datetime, timedelta
|
||||||
start_date = request.GET.get('start_date')
|
from django.utils import timezone
|
||||||
end_date = request.GET.get('end_date')
|
|
||||||
|
device_id = request.GET.get("device_id")
|
||||||
|
metric = request.GET.get("metric")
|
||||||
|
start_date = request.GET.get("start_date")
|
||||||
|
end_date = request.GET.get("end_date")
|
||||||
|
|
||||||
# Log the parameters to ensure they are correct
|
if not device_id:
|
||||||
sensor_name = Sensor.objects.get(id=sensor_name).type.name
|
return JsonResponse({"error": "device_id is required"}, status=400)
|
||||||
|
|
||||||
print("Device Name:", device_name)
|
try:
|
||||||
print("Sensor Name:", sensor_name) # Log sensor name
|
# Build query using Django ORM
|
||||||
print("Start Date:", start_date)
|
queryset = Telemetry.objects.filter(device_id=device_id)
|
||||||
print("End Date:", end_date)
|
|
||||||
|
# Filter by metric if provided
|
||||||
# Get the specific device by name
|
if metric:
|
||||||
device = get_object_or_404(Device, name=device_name)
|
queryset = queryset.filter(metric=metric)
|
||||||
|
|
||||||
# Initialize lists to store times and values
|
# Parse and filter by date range (default to last 24 hours)
|
||||||
times = []
|
if start_date:
|
||||||
values = []
|
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||||
|
queryset = queryset.filter(time__gte=start_dt)
|
||||||
# Prepare SQL query and parameters for the device
|
else:
|
||||||
query = """
|
# Default: last 24 hours
|
||||||
SELECT time, metric, value
|
queryset = queryset.filter(time__gte=timezone.now() - timedelta(hours=24))
|
||||||
FROM sensor_readings
|
|
||||||
WHERE device_name = %s
|
if end_date:
|
||||||
"""
|
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||||
params = [device.name]
|
queryset = queryset.filter(time__lte=end_dt)
|
||||||
|
|
||||||
# If a specific sensor is specified, filter by that sensor name (converted to lowercase)
|
# Order by time and get values
|
||||||
if sensor_name:
|
results = queryset.order_by('time').values_list('time', 'value')
|
||||||
query += " AND metric = LOWER(%s)" # Convert to lowercase for comparison
|
|
||||||
params.append(sensor_name.lower()) # Convert sensor name to lowercase
|
times = []
|
||||||
|
values = []
|
||||||
# Add time filtering to the query
|
for time, value in results:
|
||||||
if start_date:
|
times.append(time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
query += " AND time >= %s::timestamptz"
|
values.append(float(value))
|
||||||
params.append(start_date)
|
|
||||||
|
return JsonResponse({"times": times, "values": values})
|
||||||
if end_date:
|
|
||||||
query += " AND time <= %s::timestamptz"
|
except Exception as e:
|
||||||
params.append(end_date)
|
return JsonResponse({"error": str(e)}, status=500)
|
||||||
|
|
||||||
# Log the final query and params
|
|
||||||
print("Final Query:", query)
|
|
||||||
print("Params Before Execution:", params)
|
|
||||||
|
|
||||||
# Fetch data from the database
|
|
||||||
with connections["data"].cursor() as cursor:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Log the number of rows returned
|
|
||||||
print("Number of Rows Returned:", len(rows))
|
|
||||||
|
|
||||||
# Process the results and extract times and values
|
|
||||||
for row in rows:
|
|
||||||
time, metric, value = row
|
|
||||||
formatted_time = time.strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
times.append(formatted_time)
|
|
||||||
values.append(value)
|
|
||||||
|
|
||||||
# If no data is found, return empty arrays
|
|
||||||
if not times and not values:
|
|
||||||
print("No data found for the specified device and sensor.")
|
|
||||||
return JsonResponse({'times': [], 'values': []})
|
|
||||||
|
|
||||||
# Return the response in the expected format
|
|
||||||
return JsonResponse({'times': times, 'values': values})
|
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
return redirect("/chart/")
|
|
||||||
return HttpResponse("NOT AUTHENTICATED!!!")
|
|
||||||
|
|
||||||
|
|
||||||
def device_list(request):
|
def device_list(request):
|
||||||
devices = Device.objects.all()
|
"""List all devices with their certificate status."""
|
||||||
return render(request, 'device_list.html', {'devices': devices})
|
try:
|
||||||
|
devices = Device.objects.all()
|
||||||
|
|
||||||
|
# Enrich devices with certificate information
|
||||||
|
devices_with_certs = []
|
||||||
|
for device in devices:
|
||||||
|
device_data = {
|
||||||
|
"device": device,
|
||||||
|
"certificate_status": device.certificate_status if device.protocol == "mqtt" else "N/A",
|
||||||
|
"active_certificate": device.active_certificate if device.protocol == "mqtt" else None,
|
||||||
|
}
|
||||||
|
devices_with_certs.append(device_data)
|
||||||
|
|
||||||
|
return render(request, "device_list.html", {"devices": devices_with_certs})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error loading devices: {str(e)}")
|
||||||
|
return render(request, "device_list.html", {"devices": []})
|
||||||
|
|
||||||
|
|
||||||
def add_device(request):
|
def add_device(request):
|
||||||
if request.method == 'POST':
|
"""Register a new device via device_manager API."""
|
||||||
form = DeviceForm(request.POST)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
name = request.POST.get("name")
|
||||||
form.save()
|
location = request.POST.get("location")
|
||||||
return redirect('device_list')
|
protocol = request.POST.get("protocol", "mqtt")
|
||||||
else:
|
|
||||||
form = DeviceForm()
|
if not name:
|
||||||
return render(request, 'device_form.html', {'form': form})
|
messages.error(request, "Device name is required")
|
||||||
|
return render(request, "device_form.html")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = device_manager.register_device(
|
||||||
|
name=name,
|
||||||
|
location=location,
|
||||||
|
protocol=protocol
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show credentials page (one-time view)
|
||||||
|
return render(request, "device_credentials.html", {
|
||||||
|
"device_name": name,
|
||||||
|
"response": response,
|
||||||
|
})
|
||||||
|
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
messages.error(request, f"Failed to register device: {e.message}")
|
||||||
|
return render(request, "device_form.html", {
|
||||||
|
"name": name,
|
||||||
|
"location": location,
|
||||||
|
"protocol": protocol,
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(request, "device_form.html")
|
||||||
|
|
||||||
|
|
||||||
def edit_device(request, pk):
|
def view_device(request, device_id):
|
||||||
device = get_object_or_404(Device, pk=pk)
|
"""View device details and certificate information."""
|
||||||
if request.method == 'POST':
|
try:
|
||||||
form = DeviceForm(request.POST, instance=device)
|
device = Device.objects.get(id=device_id)
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
# Get certificate if MQTT device
|
||||||
return redirect('device_list')
|
certificate = None
|
||||||
else:
|
if device.protocol == "mqtt":
|
||||||
form = DeviceForm(instance=device)
|
certificate = device.active_certificate
|
||||||
return render(request, 'device_form.html', {'form': form})
|
|
||||||
|
context = {
|
||||||
|
"device": device,
|
||||||
|
"certificate": certificate,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "device_detail.html", context)
|
||||||
|
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
messages.error(request, f"Device {device_id} not found")
|
||||||
|
return redirect("device_list")
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Error loading device: {str(e)}")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
|
||||||
def delete_device(request, pk):
|
def delete_device(request, device_id):
|
||||||
device = get_object_or_404(Device, pk=pk)
|
"""Delete a device."""
|
||||||
if request.method == 'POST':
|
try:
|
||||||
device.delete()
|
device = Device.objects.get(id=device_id)
|
||||||
return redirect('device_list')
|
|
||||||
return render(request, 'device_confirm_delete.html', {'device': device})
|
if request.method == "POST":
|
||||||
|
device_name = device.name
|
||||||
|
device.delete()
|
||||||
|
messages.success(request, f"Device '{device_name}' deleted successfully")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
return render(request, "device_confirm_delete.html", {"device": device})
|
||||||
|
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
messages.error(request, f"Device {device_id} not found")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
|
||||||
def add_sensor_with_type(request):
|
def revoke_certificate(request, device_id):
|
||||||
if request.method == 'POST':
|
"""Revoke a device's certificate via device_manager API."""
|
||||||
form = SensorWithTypeForm(request.POST)
|
try:
|
||||||
if form.is_valid():
|
device = Device.objects.get(id=device_id)
|
||||||
form.save() # This will save both Sensor and SensorType as needed
|
|
||||||
return redirect('device_list') # Adjust this to your specific URL name
|
if device.protocol != "mqtt":
|
||||||
else:
|
messages.error(request, "Only MQTT devices have certificates to revoke")
|
||||||
form = SensorWithTypeForm()
|
return redirect("device_list")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
device_manager.revoke_certificate(device_id)
|
||||||
|
messages.success(request, f"Certificate for device '{device.name}' revoked successfully")
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
messages.error(request, f"Failed to revoke certificate: {e.message}")
|
||||||
|
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
return render(request, "certificate_revoke_confirm.html", {"device": device})
|
||||||
|
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
messages.error(request, f"Device {device_id} not found")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
context = {'form': form}
|
|
||||||
return render(request, 'sensor_form.html', context)
|
def renew_certificate(request, device_id):
|
||||||
|
"""Renew a device's certificate via device_manager API."""
|
||||||
|
try:
|
||||||
|
device = Device.objects.get(id=device_id)
|
||||||
|
|
||||||
|
if device.protocol != "mqtt":
|
||||||
|
messages.error(request, "Only MQTT devices have certificates to renew")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
response = device_manager.renew_certificate(device_id)
|
||||||
|
|
||||||
|
# Show the new credentials (one-time view)
|
||||||
|
return render(request, "device_credentials.html", {
|
||||||
|
"device_name": device.name,
|
||||||
|
"response": response,
|
||||||
|
"is_renewal": True,
|
||||||
|
})
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
messages.error(request, f"Failed to renew certificate: {e.message}")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
return render(request, "certificate_renew_confirm.html", {"device": device})
|
||||||
|
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
messages.error(request, f"Device {device_id} not found")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
|
|
||||||
def logout_view(request):
|
def logout_view(request):
|
||||||
|
"""Redirect to admin logout."""
|
||||||
return redirect("/admin")
|
return redirect("/admin")
|
||||||
|
|
||||||
|
|
||||||
def devices_api(request):
|
def devices_api(request):
|
||||||
devices = list(Device.objects.all().values('name', 'sensors__type__name'))
|
"""JSON API endpoint for devices."""
|
||||||
|
devices = list(Device.objects.all().values("id", "name", "protocol", "location"))
|
||||||
return JsonResponse(devices, safe=False)
|
return JsonResponse(devices, safe=False)
|
||||||
|
|
||||||
|
|
||||||
def sensor_list(request, device_id):
|
|
||||||
device = get_object_or_404(Device, id=device_id)
|
|
||||||
sensors = device.sensors.all() # Get sensors for this specific device
|
|
||||||
return render(request, 'sensor_list.html', {'device': device, 'sensors': sensors})
|
|
||||||
|
|
||||||
|
|
||||||
def edit_sensor(request, pk):
|
|
||||||
sensor = get_object_or_404(Sensor, pk=pk)
|
|
||||||
if request.method == 'POST':
|
|
||||||
form = SensorWithTypeForm(request.POST, instance=sensor)
|
|
||||||
if form.is_valid():
|
|
||||||
form.save()
|
|
||||||
return redirect('sensor_list', device_id=sensor.device.pk)
|
|
||||||
else:
|
|
||||||
form = SensorWithTypeForm(instance=sensor)
|
|
||||||
return render(request, 'sensor_form.html', {'form': form})
|
|
||||||
|
|
||||||
|
|
||||||
def delete_sensor(request, pk):
|
|
||||||
sensor = get_object_or_404(Sensor, pk=pk)
|
|
||||||
if request.method == 'POST':
|
|
||||||
device_id = sensor.device.pk
|
|
||||||
sensor.delete()
|
|
||||||
return redirect('sensor_list', device_id=device_id)
|
|
||||||
return render(request, 'sensor_confirm_delete.html', {'sensor': sensor})
|
|
||||||
|
|
||||||
|
|
||||||
def add_sensor(request, device_id):
|
|
||||||
device = get_object_or_404(Device, pk=device_id)
|
|
||||||
if request.method == 'POST':
|
|
||||||
form = SensorWithTypeForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
sensor = form.save(commit=False)
|
|
||||||
sensor.device = device # Associate the sensor with the device
|
|
||||||
sensor.save()
|
|
||||||
return redirect('device_list') # Redirect to device list or appropriate view
|
|
||||||
else:
|
|
||||||
form = SensorWithTypeForm()
|
|
||||||
|
|
||||||
return render(request, 'sensor_form.html', {'form': form, 'device': device})
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
124
mqtt_service.py
124
mqtt_service.py
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[project]
|
||||||
|
name = "iotdashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.17.0",
|
||||||
|
"django>=5.2.7",
|
||||||
|
"openai>=2.6.1",
|
||||||
|
"paho-mqtt>=2.1.0",
|
||||||
|
"psycopg2-binary>=2.9.11",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"redis>=7.0.1",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.14.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv.workspace]
|
||||||
|
members = [
|
||||||
|
"db_migrations",
|
||||||
|
"iotDashboard",
|
||||||
|
"services/gpt_service",
|
||||||
|
]
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Django
|
||||||
|
redis
|
||||||
|
paho-mqtt
|
||||||
|
psycopg2-binary
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
|
openai
|
||||||
26
services/db_write/.env.example
Normal file
26
services/db_write/.env.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Database Configuration (PostgreSQL/TimescaleDB)
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/iot_dashboard
|
||||||
|
|
||||||
|
# Consumer Configuration
|
||||||
|
CONSUMER_GROUP_NAME=db_writer
|
||||||
|
CONSUMER_NAME=worker-01
|
||||||
|
BATCH_SIZE=100
|
||||||
|
BATCH_TIMEOUT_SEC=5
|
||||||
|
PROCESSING_INTERVAL_SEC=1
|
||||||
|
|
||||||
|
# Stream Configuration
|
||||||
|
STREAM_PATTERN=mqtt_stream:*
|
||||||
|
DEAD_LETTER_STREAM=mqtt_stream:failed
|
||||||
|
|
||||||
|
# Database Table Configuration
|
||||||
|
TABLE_NAME=sensor_readings
|
||||||
|
ENABLE_TIMESCALE=false
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL=INFO
|
||||||
54
services/db_write/.gitignore
vendored
Normal file
54
services/db_write/.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# uv
|
||||||
|
.uv/
|
||||||
|
uv.lock
|
||||||
1
services/db_write/.python-version
Normal file
1
services/db_write/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
32
services/db_write/Dockerfile
Normal file
32
services/db_write/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY ./src/ ./src/
|
||||||
|
COPY main.py ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
COPY --from=builder /app/*.py /app/
|
||||||
|
|
||||||
|
RUN adduser -D -u 1000 appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
406
services/db_write/README.md
Normal file
406
services/db_write/README.md
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
# Database Writer Service
|
||||||
|
|
||||||
|
A robust, production-ready service that reads sensor data from Redis streams and writes it to PostgreSQL/TimescaleDB. Part of the IoT Dashboard project.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **Reliable consumption** from Redis streams using consumer groups
|
||||||
|
- ✅ **Batch processing** for high throughput
|
||||||
|
- ✅ **At-least-once delivery** with message acknowledgments
|
||||||
|
- ✅ **Dead letter queue** for failed messages
|
||||||
|
- ✅ **Connection pooling** for database efficiency
|
||||||
|
- ✅ **Graceful shutdown** handling
|
||||||
|
- ✅ **Flexible schema** that adapts to changes
|
||||||
|
- ✅ **Structured logging** with JSON output
|
||||||
|
- ✅ **Health checks** for monitoring
|
||||||
|
- ✅ **TimescaleDB support** for time-series optimization
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Redis Streams → Consumer Group → Transform → Database → Acknowledge
|
||||||
|
↓
|
||||||
|
Failed messages
|
||||||
|
↓
|
||||||
|
Dead Letter Queue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **`main.py`**: Service orchestration and processing loop
|
||||||
|
- **`redis_reader.py`**: Redis stream consumer with fault tolerance
|
||||||
|
- **`db_writer.py`**: Database operations with connection pooling
|
||||||
|
- **`schema.py`**: Data transformation and validation
|
||||||
|
- **`config.py`**: Configuration management
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.13+
|
||||||
|
- [uv](https://github.com/astral-sh/uv) package manager
|
||||||
|
- Redis server with streams
|
||||||
|
- PostgreSQL or TimescaleDB
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Navigate to the service directory**:
|
||||||
|
```bash
|
||||||
|
cd services/db_write
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Copy and configure environment variables**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your DATABASE_URL and other settings
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install dependencies**:
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Setup database schema** (IMPORTANT - do this before running):
|
||||||
|
```bash
|
||||||
|
# Review the schema in models.py first
|
||||||
|
cat models.py
|
||||||
|
|
||||||
|
# Create initial migration
|
||||||
|
chmod +x migrate.sh
|
||||||
|
./migrate.sh create "initial schema"
|
||||||
|
|
||||||
|
# Review the generated migration
|
||||||
|
ls -lt alembic/versions/
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run the service**:
|
||||||
|
```bash
|
||||||
|
uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the standalone script:
|
||||||
|
```bash
|
||||||
|
chmod +x run-standalone.sh
|
||||||
|
./run-standalone.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ Important: Schema Management
|
||||||
|
|
||||||
|
This service uses **Alembic** for database migrations. The service will NOT create tables automatically.
|
||||||
|
|
||||||
|
- Schema is defined in `models.py`
|
||||||
|
- Migrations are managed with `./migrate.sh` or `alembic` commands
|
||||||
|
- See `SCHEMA_MANAGEMENT.md` for detailed guide
|
||||||
|
|
||||||
|
## Schema Management
|
||||||
|
|
||||||
|
This service uses **SQLAlchemy** for models and **Alembic** for migrations.
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
- **`models.py`**: Define your database schema here (SQLAlchemy models)
|
||||||
|
- **`alembic/`**: Migration scripts directory
|
||||||
|
- **`migrate.sh`**: Helper script for common migration tasks
|
||||||
|
- **`SCHEMA_MANAGEMENT.md`**: Comprehensive migration guide
|
||||||
|
|
||||||
|
### Quick Migration Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new migration after editing models.py
|
||||||
|
./migrate.sh create "add new column"
|
||||||
|
|
||||||
|
# Apply pending migrations
|
||||||
|
./migrate.sh upgrade
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
./migrate.sh check
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
./migrate.sh history
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
./migrate.sh downgrade 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**See `SCHEMA_MANAGEMENT.md` for detailed documentation.**
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is done via environment variables. See `.env.example` for all available options.
|
||||||
|
|
||||||
|
### Required Settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis connection
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/iot_dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Consumer configuration
|
||||||
|
CONSUMER_GROUP_NAME=db_writer # Consumer group name
|
||||||
|
CONSUMER_NAME=worker-01 # Unique consumer name
|
||||||
|
BATCH_SIZE=100 # Messages per batch
|
||||||
|
BATCH_TIMEOUT_SEC=5 # Read timeout
|
||||||
|
PROCESSING_INTERVAL_SEC=1 # Delay between batches
|
||||||
|
|
||||||
|
# Stream configuration
|
||||||
|
STREAM_PATTERN=mqtt_stream:* # Stream name pattern
|
||||||
|
DEAD_LETTER_STREAM=mqtt_stream:failed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
TABLE_NAME=sensor_readings # Target table name
|
||||||
|
ENABLE_TIMESCALE=false # Use TimescaleDB features
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
|
||||||
|
LOG_FORMAT=json # json or console
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Input (Redis Streams)
|
||||||
|
|
||||||
|
The service reads from Redis streams with the format:
|
||||||
|
```
|
||||||
|
mqtt_stream:{device_id}:{sensor_type}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each message contains:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"value": "23.5",
|
||||||
|
"timestamp": "2023-10-18T14:30:00Z",
|
||||||
|
"metadata": "{...}" (optional)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output (Database)
|
||||||
|
|
||||||
|
Data is written to the `sensor_readings` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sensor_readings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
device_id VARCHAR(100) NOT NULL,
|
||||||
|
sensor_type VARCHAR(100) NOT NULL,
|
||||||
|
value DOUBLE PRECISION NOT NULL,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The table is automatically created if it doesn't exist.
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
### Build the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t db-writer:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name db-writer \
|
||||||
|
-e REDIS_HOST=redis \
|
||||||
|
-e DATABASE_URL=postgresql://user:pass@postgres:5432/iot \
|
||||||
|
db-writer:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consumer Groups
|
||||||
|
|
||||||
|
The service uses Redis consumer groups for reliable, distributed processing:
|
||||||
|
|
||||||
|
- **Multiple instances**: Run multiple workers for load balancing
|
||||||
|
- **Fault tolerance**: Messages are not lost if a consumer crashes
|
||||||
|
- **Acknowledgments**: Messages are only removed after successful processing
|
||||||
|
- **Pending messages**: Unacknowledged messages can be reclaimed
|
||||||
|
|
||||||
|
### Running Multiple Workers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
CONSUMER_NAME=worker-01 uv run main.py
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
CONSUMER_NAME=worker-02 uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
All workers in the same consumer group will share the load.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Dead Letter Queue
|
||||||
|
|
||||||
|
Failed messages are sent to the dead letter stream (`mqtt_stream:failed`) with error information:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"original_stream": "mqtt_stream:esp32:temperature",
|
||||||
|
"original_id": "1634567890123-0",
|
||||||
|
"device_id": "esp32",
|
||||||
|
"sensor_type": "temperature",
|
||||||
|
"value": "23.5",
|
||||||
|
"error": "Database connection failed",
|
||||||
|
"failed_at": "1634567890.123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Strategy
|
||||||
|
|
||||||
|
- **Transient errors**: Automatic retry with backoff
|
||||||
|
- **Data errors**: Immediate send to DLQ
|
||||||
|
- **Connection errors**: Reconnection attempts
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Check service health programmatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from main import DatabaseWriterService
|
||||||
|
|
||||||
|
service = DatabaseWriterService()
|
||||||
|
health = service.health_check()
|
||||||
|
print(health)
|
||||||
|
# {
|
||||||
|
# 'running': True,
|
||||||
|
# 'redis': True,
|
||||||
|
# 'database': True,
|
||||||
|
# 'stats': {...}
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
The service outputs structured logs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "Processed batch",
|
||||||
|
"rows_written": 100,
|
||||||
|
"messages_acknowledged": 100,
|
||||||
|
"timestamp": "2023-10-18T14:30:00Z",
|
||||||
|
"level": "info"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
Runtime statistics are tracked:
|
||||||
|
- `messages_read`: Total messages consumed
|
||||||
|
- `messages_written`: Total rows inserted
|
||||||
|
- `messages_failed`: Failed messages sent to DLQ
|
||||||
|
- `batches_processed`: Number of successful batches
|
||||||
|
- `errors`: Total errors encountered
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
db_write/
|
||||||
|
├── config.py # Configuration management
|
||||||
|
├── db_writer.py # Database operations
|
||||||
|
├── redis_reader.py # Redis stream consumer
|
||||||
|
├── schema.py # Data models and transformation
|
||||||
|
├── main.py # Service entry point
|
||||||
|
├── pyproject.toml # Dependencies
|
||||||
|
├── .env.example # Configuration template
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add package-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Service won't start
|
||||||
|
|
||||||
|
1. **Check configuration**: Verify all required environment variables are set
|
||||||
|
2. **Test connections**: Ensure Redis and PostgreSQL are accessible
|
||||||
|
3. **Check logs**: Look for specific error messages
|
||||||
|
|
||||||
|
### No messages being processed
|
||||||
|
|
||||||
|
1. **Check streams exist**: `redis-cli KEYS "mqtt_stream:*"`
|
||||||
|
2. **Verify consumer group**: The service creates it automatically, but check Redis logs
|
||||||
|
3. **Check stream pattern**: Ensure `STREAM_PATTERN` matches your stream names
|
||||||
|
|
||||||
|
### Messages going to dead letter queue
|
||||||
|
|
||||||
|
1. **Check DLQ**: `redis-cli XRANGE mqtt_stream:failed - + COUNT 10`
|
||||||
|
2. **Review error messages**: Each DLQ entry contains the error reason
|
||||||
|
3. **Validate data format**: Ensure messages match expected schema
|
||||||
|
|
||||||
|
### High memory usage
|
||||||
|
|
||||||
|
1. **Reduce batch size**: Lower `BATCH_SIZE` in configuration
|
||||||
|
2. **Check connection pool**: May need to adjust pool size
|
||||||
|
3. **Monitor pending messages**: Use `XPENDING` to check backlog
|
||||||
|
|
||||||
|
## Performance Tuning
|
||||||
|
|
||||||
|
### Throughput Optimization
|
||||||
|
|
||||||
|
- **Increase batch size**: Process more messages per batch
|
||||||
|
- **Multiple workers**: Run multiple consumer instances
|
||||||
|
- **Connection pooling**: Adjust pool size based on load
|
||||||
|
- **Processing interval**: Reduce delay between batches
|
||||||
|
|
||||||
|
### Latency Optimization
|
||||||
|
|
||||||
|
- **Decrease batch size**: Process smaller batches more frequently
|
||||||
|
- **Reduce timeout**: Lower `BATCH_TIMEOUT_SEC`
|
||||||
|
- **Single worker**: Avoid consumer group coordination overhead
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Recommended Settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BATCH_SIZE=500
|
||||||
|
PROCESSING_INTERVAL_SEC=0.1
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json
|
||||||
|
ENABLE_TIMESCALE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
- Monitor consumer lag using Redis `XPENDING`
|
||||||
|
- Track database insert latency
|
||||||
|
- Alert on error rate > 5%
|
||||||
|
- Monitor DLQ depth
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
1. **Horizontal**: Add more consumer instances with unique `CONSUMER_NAME`
|
||||||
|
2. **Vertical**: Increase resources for database writes
|
||||||
|
3. **Database**: Use TimescaleDB for better time-series performance
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Part of the IoT Dashboard project.
|
||||||
271
services/db_write/main.py
Normal file
271
services/db_write/main.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Main entry point for the database writer service.
|
||||||
|
Orchestrates the read → transform → write cycle with error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import structlog
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from src.config import config
|
||||||
|
from src.redis_reader import RedisReader
|
||||||
|
from src.db_writer import DatabaseWriter
|
||||||
|
from src.schema import SchemaHandler, StreamMessage, TelemetryReading
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging():
|
||||||
|
"""Configure structured logging"""
|
||||||
|
if config.log.format == "json":
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, config.log.level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseWriterService:
|
||||||
|
"""Main service class that orchestrates the data pipeline"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.running = False
|
||||||
|
self.redis_reader: RedisReader = None
|
||||||
|
self.db_writer: DatabaseWriter = None
|
||||||
|
self.schema_handler: SchemaHandler = None
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
|
||||||
|
self.stats = {
|
||||||
|
"messages_read": 0,
|
||||||
|
"messages_written": 0,
|
||||||
|
"messages_failed": 0,
|
||||||
|
"batches_processed": 0,
|
||||||
|
"errors": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _signal_handler(self, signum, frame):
|
||||||
|
"""Handle shutdown signals"""
|
||||||
|
self.logger.info(f"Received signal {signum}, initiating graceful shutdown...")
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start the service"""
|
||||||
|
self.logger.info("Starting Database Writer Service...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.validate()
|
||||||
|
self.logger.info("Configuration validated successfully")
|
||||||
|
|
||||||
|
self.schema_handler = SchemaHandler()
|
||||||
|
self.logger.info("Schema handler initialized")
|
||||||
|
|
||||||
|
self.redis_reader = RedisReader()
|
||||||
|
self.logger.info("Redis reader initialized")
|
||||||
|
|
||||||
|
self.db_writer = DatabaseWriter()
|
||||||
|
self.logger.info("Database writer initialized")
|
||||||
|
|
||||||
|
# Start the processing loop
|
||||||
|
self.running = True
|
||||||
|
self.logger.info("Service started successfully, entering processing loop")
|
||||||
|
|
||||||
|
self._processing_loop()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Service startup failed: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _processing_loop(self):
|
||||||
|
"""Main processing loop"""
|
||||||
|
consecutive_errors = 0
|
||||||
|
max_consecutive_errors = 5
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
# Read batch from Redis
|
||||||
|
messages = self.redis_reader.read_batch()
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
# No messages, sleep briefly
|
||||||
|
time.sleep(config.consumer.processing_interval_sec)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stats["messages_read"] += len(messages)
|
||||||
|
self.logger.debug(f"Read {len(messages)} messages from Redis")
|
||||||
|
|
||||||
|
# Transform messages to sensor readings
|
||||||
|
readings = self._transform_messages(messages)
|
||||||
|
|
||||||
|
if not readings:
|
||||||
|
self.logger.warning("No valid readings after transformation")
|
||||||
|
# Acknowledge the messages anyway (they were invalid)
|
||||||
|
self.redis_reader.acknowledge_batch(messages)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Write to database
|
||||||
|
success = self.db_writer.write_batch(readings)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Successfully written, acknowledge the messages
|
||||||
|
ack_count = self.redis_reader.acknowledge_batch(messages)
|
||||||
|
self.stats["messages_written"] += len(readings)
|
||||||
|
self.stats["batches_processed"] += 1
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Processed batch: {len(readings)} readings written, "
|
||||||
|
f"{ack_count} messages acknowledged"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Write failed, log error and acknowledge to prevent blocking
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to write batch of {len(readings)} readings"
|
||||||
|
)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Check for too many consecutive errors
|
||||||
|
if consecutive_errors >= max_consecutive_errors:
|
||||||
|
self.logger.error(
|
||||||
|
f"Too many consecutive errors ({consecutive_errors}), "
|
||||||
|
"pausing for 30 seconds"
|
||||||
|
)
|
||||||
|
time.sleep(30)
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
|
# Brief pause between batches
|
||||||
|
if config.consumer.processing_interval_sec > 0:
|
||||||
|
time.sleep(config.consumer.processing_interval_sec)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.logger.info("Keyboard interrupt received")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error in processing loop: {e}", exc_info=True)
|
||||||
|
self.stats["errors"] += 1
|
||||||
|
consecutive_errors += 1
|
||||||
|
time.sleep(5) # Back off on errors
|
||||||
|
|
||||||
|
self.logger.info("Processing loop terminated")
|
||||||
|
|
||||||
|
def _transform_messages(
|
||||||
|
self, messages: List[StreamMessage]
|
||||||
|
) -> List[TelemetryReading]:
|
||||||
|
"""Transform stream messages to sensor readings"""
|
||||||
|
readings = []
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
reading = self.schema_handler.transform_message(msg)
|
||||||
|
if reading:
|
||||||
|
readings.append(reading)
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Failed to transform message {msg.message_id} from {msg.stream_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return readings
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the service gracefully"""
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info("Stopping service...")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
# Print final statistics
|
||||||
|
self.logger.info(
|
||||||
|
f"Final statistics: "
|
||||||
|
f"messages_read={self.stats['messages_read']}, "
|
||||||
|
f"messages_written={self.stats['messages_written']}, "
|
||||||
|
f"messages_failed={self.stats['messages_failed']}, "
|
||||||
|
f"batches_processed={self.stats['batches_processed']}, "
|
||||||
|
f"errors={self.stats['errors']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close connections
|
||||||
|
if self.redis_reader:
|
||||||
|
self.redis_reader.close()
|
||||||
|
|
||||||
|
if self.db_writer:
|
||||||
|
self.db_writer.close()
|
||||||
|
|
||||||
|
self.logger.info("Service stopped")
|
||||||
|
|
||||||
|
def health_check(self) -> dict:
|
||||||
|
"""Check service health"""
|
||||||
|
health = {
|
||||||
|
"running": self.running,
|
||||||
|
"redis": False,
|
||||||
|
"database": False,
|
||||||
|
"stats": self.stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.redis_reader:
|
||||||
|
health["redis"] = self.redis_reader.health_check()
|
||||||
|
|
||||||
|
if self.db_writer:
|
||||||
|
health["database"] = self.db_writer.health_check()
|
||||||
|
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point"""
|
||||||
|
# Configure logging
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("Database Writer Service")
|
||||||
|
logger.info(f"Consumer Group: {config.consumer.group_name}")
|
||||||
|
logger.info(f"Consumer Name: {config.consumer.consumer_name}")
|
||||||
|
logger.info(f"Batch Size: {config.consumer.batch_size}")
|
||||||
|
logger.info(f"Stream Pattern: {config.stream.pattern}")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
service = DatabaseWriterService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = service.start()
|
||||||
|
if not success:
|
||||||
|
logger.error("Service failed to start")
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received keyboard interrupt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
logger.info("Service exited")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
22
services/db_write/pyproject.toml
Normal file
22
services/db_write/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[project]
|
||||||
|
name = "db-write"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Database writer service that reads from Redis streams and writes to PostgreSQL/TimescaleDB"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"redis>=6.4.0",
|
||||||
|
"psycopg2-binary>=2.9.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"structlog>=24.0.0",
|
||||||
|
"alembic>=1.13.0",
|
||||||
|
"sqlalchemy>=2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"fakeredis>=2.0.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
|
]
|
||||||
139
services/db_write/src/config.py
Normal file
139
services/db_write/src/config.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Configuration management for the database writer service.
|
||||||
|
Loads settings from environment variables with sensible defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
import dotenv
|
||||||
|
|
||||||
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RedisConfig:
|
||||||
|
"""Redis connection configuration"""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
port: int = 6379
|
||||||
|
db: int = 0
|
||||||
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatabaseConfig:
|
||||||
|
"""Database connection configuration"""
|
||||||
|
|
||||||
|
url: Optional[str] = None
|
||||||
|
host: Optional[str] = None
|
||||||
|
port: int = 5432
|
||||||
|
name: Optional[str] = None
|
||||||
|
user: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
table_name: str = "sensor_readings"
|
||||||
|
enable_timescale: bool = False
|
||||||
|
|
||||||
|
def get_connection_string(self) -> str:
|
||||||
|
"""Build connection string from components or return URL"""
|
||||||
|
if self.url:
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
if not all([self.host, self.name, self.user, self.password]):
|
||||||
|
raise ValueError("Either DATABASE_URL or all DB_* variables must be set")
|
||||||
|
|
||||||
|
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConsumerConfig:
|
||||||
|
"""Redis consumer group configuration"""
|
||||||
|
|
||||||
|
group_name: str = "db_writer"
|
||||||
|
consumer_name: str = "worker-01"
|
||||||
|
batch_size: int = 100
|
||||||
|
batch_timeout_sec: int = 5
|
||||||
|
processing_interval_sec: float = 1.0
|
||||||
|
block_time_ms: int = 5000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamConfig:
|
||||||
|
"""Redis stream configuration"""
|
||||||
|
|
||||||
|
pattern: str = "mqtt_stream:*"
|
||||||
|
dead_letter_stream: str = "mqtt_stream:failed"
|
||||||
|
max_retries: int = 3
|
||||||
|
trim_maxlen: int = 10000 # Keep last N messages in each stream
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LogConfig:
|
||||||
|
"""Logging configuration"""
|
||||||
|
|
||||||
|
level: str = "INFO"
|
||||||
|
format: str = "json" # json or console
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Main configuration class"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.redis = RedisConfig(
|
||||||
|
host=os.getenv("REDIS_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("REDIS_PORT", 6379)),
|
||||||
|
db=int(os.getenv("REDIS_DB", 0)),
|
||||||
|
password=os.getenv("REDIS_PASSWORD", None) or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.database = DatabaseConfig(
|
||||||
|
url=os.getenv("DATABASE_URL", None),
|
||||||
|
host=os.getenv("DB_HOST", None),
|
||||||
|
port=int(os.getenv("DB_PORT", 5432)),
|
||||||
|
name=os.getenv("DB_NAME", None),
|
||||||
|
user=os.getenv("DB_USER", None),
|
||||||
|
password=os.getenv("DB_PASSWORD", None),
|
||||||
|
table_name=os.getenv("TABLE_NAME", "sensor_readings"),
|
||||||
|
enable_timescale=os.getenv("ENABLE_TIMESCALE", "false").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.consumer = ConsumerConfig(
|
||||||
|
group_name=os.getenv("CONSUMER_GROUP_NAME", "db_writer"),
|
||||||
|
consumer_name=os.getenv("CONSUMER_NAME", "worker-01"),
|
||||||
|
batch_size=int(os.getenv("BATCH_SIZE", 100)),
|
||||||
|
batch_timeout_sec=int(os.getenv("BATCH_TIMEOUT_SEC", 5)),
|
||||||
|
processing_interval_sec=float(os.getenv("PROCESSING_INTERVAL_SEC", 1.0)),
|
||||||
|
block_time_ms=int(os.getenv("BLOCK_TIME_MS", 5000)),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stream = StreamConfig(
|
||||||
|
max_retries=int(os.getenv("MAX_RETRIES", 3)),
|
||||||
|
trim_maxlen=int(os.getenv("TRIM_MAXLEN", 10000)),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log = LogConfig(
|
||||||
|
level=os.getenv("LOG_LEVEL", "INFO"), format=os.getenv("LOG_FORMAT", "json")
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
"""Validate configuration"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if not self.redis.host:
|
||||||
|
errors.append("REDIS_HOST is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.database.get_connection_string()
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
if self.consumer.batch_size < 1:
|
||||||
|
errors.append("BATCH_SIZE must be >= 1")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ValueError(f"Configuration errors: {', '.join(errors)}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
config = Config()
|
||||||
79
services/db_write/src/db_writer.py
Normal file
79
services/db_write/src/db_writer.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import QueuePool
|
||||||
|
|
||||||
|
from src.config import config
|
||||||
|
from src.schema import TelemetryReading
|
||||||
|
from src.models import Telemetry
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseWriter:
|
||||||
|
"""
|
||||||
|
Database writer for telemetry data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
connection_string = config.database.get_connection_string()
|
||||||
|
|
||||||
|
self.engine = create_engine(
|
||||||
|
connection_string,
|
||||||
|
poolclass=QueuePool,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.SessionLocal = sessionmaker(bind=self.engine)
|
||||||
|
|
||||||
|
self.logger.info("Database writer initialized")
|
||||||
|
|
||||||
|
def write_batch(self, readings: List[TelemetryReading]) -> bool:
|
||||||
|
"""Write a batch of telemetry readings to the database"""
|
||||||
|
if not readings:
|
||||||
|
return True
|
||||||
|
|
||||||
|
session = self.SessionLocal()
|
||||||
|
try:
|
||||||
|
db_objects = [
|
||||||
|
Telemetry(
|
||||||
|
time=reading.time,
|
||||||
|
device_id=reading.device_id,
|
||||||
|
metric=reading.metric,
|
||||||
|
value=reading.value,
|
||||||
|
unit=reading.unit,
|
||||||
|
)
|
||||||
|
for reading in readings
|
||||||
|
]
|
||||||
|
|
||||||
|
session.bulk_save_objects(db_objects)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.logger.debug(f"Wrote {len(readings)} readings to database")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to write batch: {e}", exc_info=True)
|
||||||
|
session.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if database connection is healthy"""
|
||||||
|
try:
|
||||||
|
with self.engine.connect() as conn:
|
||||||
|
result = conn.execute(select(1))
|
||||||
|
return result.fetchone()[0] == 1
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Database health check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close database engine and all connections"""
|
||||||
|
if hasattr(self, "engine") and self.engine:
|
||||||
|
self.engine.dispose()
|
||||||
|
self.logger.info("Database engine closed")
|
||||||
30
services/db_write/src/models.py
Normal file
30
services/db_write/src/models.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy models for db_write service.
|
||||||
|
|
||||||
|
These models mirror the schema in db_migrations/models.py.
|
||||||
|
Keep them in sync when schema changes occur.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Float, Text, DateTime
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry(Base):
|
||||||
|
"""
|
||||||
|
Time-series telemetry data from devices.
|
||||||
|
|
||||||
|
This model is used by the db_write service to insert data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "telemetry"
|
||||||
|
|
||||||
|
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
|
||||||
|
device_id = Column(Text, primary_key=True, nullable=False)
|
||||||
|
metric = Column(Text, primary_key=True, nullable=False)
|
||||||
|
value = Column(Float, nullable=False)
|
||||||
|
unit = Column(Text)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"
|
||||||
145
services/db_write/src/redis_reader.py
Normal file
145
services/db_write/src/redis_reader.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import redis
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from src.config import config
|
||||||
|
from src.schema import SchemaHandler, StreamMessage
|
||||||
|
|
||||||
|
|
||||||
|
class RedisReader:
|
||||||
|
"""Redis stream consumer with consumer groups for reliability"""
|
||||||
|
|
||||||
|
def __init__(self, stream_name: str = "mqtt:ingestion"):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.schema_handler = SchemaHandler()
|
||||||
|
|
||||||
|
self.redis_client = redis.StrictRedis(
|
||||||
|
host=config.redis.host,
|
||||||
|
port=config.redis.port,
|
||||||
|
db=config.redis.db,
|
||||||
|
password=config.redis.password,
|
||||||
|
decode_responses=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.redis_client.ping()
|
||||||
|
self.logger.info(
|
||||||
|
f"Connected to Redis at {config.redis.host}:{config.redis.port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use single stream instead of pattern matching
|
||||||
|
self.stream_name = stream_name
|
||||||
|
self.logger.info(f"Monitoring stream: {self.stream_name}")
|
||||||
|
|
||||||
|
# Initialize consumer group for the single stream
|
||||||
|
self._setup_consumer_group()
|
||||||
|
|
||||||
|
def _setup_consumer_group(self):
|
||||||
|
"""Create consumer group for the single stream"""
|
||||||
|
group_name = config.consumer.group_name
|
||||||
|
try:
|
||||||
|
self.redis_client.xgroup_create(
|
||||||
|
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:
|
||||||
|
if "BUSYGROUP" in str(e):
|
||||||
|
self.logger.debug(
|
||||||
|
f"Consumer group '{group_name}' already exists for '{self.stream_name}'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Error creating consumer group: {e}")
|
||||||
|
|
||||||
|
def read_batch(
|
||||||
|
self, batch_size: Optional[int] = None, timeout_ms: Optional[int] = None
|
||||||
|
) -> List[StreamMessage]:
|
||||||
|
"""Read a batch of messages from single stream using consumer group"""
|
||||||
|
if batch_size is None:
|
||||||
|
batch_size = config.consumer.batch_size
|
||||||
|
if timeout_ms is None:
|
||||||
|
timeout_ms = config.consumer.block_time_ms
|
||||||
|
|
||||||
|
# Read from single stream - much simpler!
|
||||||
|
stream_dict = {self.stream_name: ">"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = self.redis_client.xreadgroup(
|
||||||
|
groupname=config.consumer.group_name,
|
||||||
|
consumername=config.consumer.consumer_name,
|
||||||
|
streams=stream_dict,
|
||||||
|
count=batch_size,
|
||||||
|
block=timeout_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Parse results into StreamMessage objects
|
||||||
|
messages = []
|
||||||
|
for stream_key, entries in results:
|
||||||
|
for message_id, fields in entries:
|
||||||
|
msg_id = (
|
||||||
|
message_id.decode("utf-8")
|
||||||
|
if isinstance(message_id, bytes)
|
||||||
|
else message_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
messages.append(stream_msg)
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
self.logger.debug(f"Read {len(messages)} messages")
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error reading from Redis: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def acknowledge_batch(self, messages: List[StreamMessage]) -> int:
|
||||||
|
"""Acknowledge multiple messages at once"""
|
||||||
|
ack_count = 0
|
||||||
|
|
||||||
|
# Group messages by stream
|
||||||
|
by_stream: Dict[str, List[str]] = {}
|
||||||
|
for msg in messages:
|
||||||
|
if msg.stream_key not in by_stream:
|
||||||
|
by_stream[msg.stream_key] = []
|
||||||
|
by_stream[msg.stream_key].append(msg.message_id)
|
||||||
|
|
||||||
|
# Acknowledge each stream's messages
|
||||||
|
for stream_key, message_ids in by_stream.items():
|
||||||
|
try:
|
||||||
|
result = self.redis_client.xack(
|
||||||
|
stream_key, config.consumer.group_name, *message_ids
|
||||||
|
)
|
||||||
|
ack_count += result
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to acknowledge messages from {stream_key}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ack_count > 0:
|
||||||
|
self.logger.debug(f"Acknowledged {ack_count} messages")
|
||||||
|
|
||||||
|
return ack_count
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if Redis connection is healthy"""
|
||||||
|
try:
|
||||||
|
self.redis_client.ping()
|
||||||
|
return True
|
||||||
|
except redis.RedisError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close Redis connection"""
|
||||||
|
try:
|
||||||
|
self.redis_client.close()
|
||||||
|
self.logger.info("Redis connection closed")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error closing Redis connection: {e}")
|
||||||
203
services/db_write/src/schema.py
Normal file
203
services/db_write/src/schema.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Schema definitions and data transformation logic.
|
||||||
|
Handles conversion between Redis stream messages and database records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamMessage:
|
||||||
|
"""Represents a message from Redis stream"""
|
||||||
|
|
||||||
|
stream_key: str
|
||||||
|
message_id: str
|
||||||
|
device_id: str
|
||||||
|
sensor_type: str
|
||||||
|
value: float
|
||||||
|
timestamp: str
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_name(self) -> str:
|
||||||
|
"""Return the stream name without prefix"""
|
||||||
|
return self.stream_key
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelemetryReading:
|
||||||
|
"""Represents a telemetry reading ready for database insertion - matches Telemetry model"""
|
||||||
|
|
||||||
|
time: datetime
|
||||||
|
device_id: str
|
||||||
|
metric: str # renamed from sensor_type
|
||||||
|
value: float
|
||||||
|
unit: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert to dictionary for database insertion"""
|
||||||
|
return {
|
||||||
|
"time": self.time,
|
||||||
|
"device_id": self.device_id,
|
||||||
|
"metric": self.metric,
|
||||||
|
"value": self.value,
|
||||||
|
"unit": self.unit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""Result of data validation"""
|
||||||
|
|
||||||
|
valid: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaHandler:
|
||||||
|
"""Handles schema transformation and validation"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = self._get_logger()
|
||||||
|
|
||||||
|
def _get_logger(self):
|
||||||
|
"""Get logger instance"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
return logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def transform_message(
|
||||||
|
self, stream_message: StreamMessage
|
||||||
|
) -> Optional[TelemetryReading]:
|
||||||
|
"""
|
||||||
|
Transform a Redis stream message into a TelemetryReading.
|
||||||
|
Returns None if transformation fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
timestamp = self._parse_timestamp(stream_message.timestamp)
|
||||||
|
|
||||||
|
reading = TelemetryReading(
|
||||||
|
time=timestamp,
|
||||||
|
device_id=stream_message.device_id,
|
||||||
|
metric=stream_message.sensor_type, # sensor_type maps to metric
|
||||||
|
value=float(stream_message.value),
|
||||||
|
unit=stream_message.metadata.get("unit")
|
||||||
|
if stream_message.metadata
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate the reading
|
||||||
|
validation = self.validate_reading(reading)
|
||||||
|
if not validation.valid:
|
||||||
|
self.logger.error(f"Invalid reading: {validation.error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return reading
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to transform message: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def validate_reading(self, reading: TelemetryReading) -> ValidationResult:
|
||||||
|
"""Validate a telemetry reading"""
|
||||||
|
try:
|
||||||
|
# Check required fields
|
||||||
|
if not reading.device_id:
|
||||||
|
return ValidationResult(False, "device_id is required")
|
||||||
|
|
||||||
|
if not reading.metric:
|
||||||
|
return ValidationResult(False, "metric is required")
|
||||||
|
|
||||||
|
if reading.value is None:
|
||||||
|
return ValidationResult(False, "value is required")
|
||||||
|
|
||||||
|
if not isinstance(reading.time, datetime):
|
||||||
|
return ValidationResult(False, "time must be a datetime object")
|
||||||
|
|
||||||
|
if not isinstance(reading.value, (int, float)):
|
||||||
|
return ValidationResult(False, "value must be numeric")
|
||||||
|
|
||||||
|
if reading.value < -1000000 or reading.value > 1000000:
|
||||||
|
self.logger.warning(f"Value {reading.value} is outside typical range")
|
||||||
|
|
||||||
|
return ValidationResult(True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return ValidationResult(False, f"Validation error: {str(e)}")
|
||||||
|
|
||||||
|
def _parse_timestamp(self, timestamp_str: str) -> datetime:
|
||||||
|
"""Parse timestamp string into datetime object"""
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
formats = [
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ",
|
||||||
|
"%Y-%m-%d %H:%M:%S.%f",
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
]
|
||||||
|
|
||||||
|
for fmt in formats:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(timestamp_str, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
f"Could not parse timestamp '{timestamp_str}', using current time"
|
||||||
|
)
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
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 with NEW single-stream format.
|
||||||
|
Expected fields: device_id, metric, value, timestamp
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract fields from message (device_id and metric are IN the payload now!)
|
||||||
|
device_id_bytes = fields.get(b"device_id")
|
||||||
|
metric_bytes = fields.get(b"metric")
|
||||||
|
value_bytes = fields.get(b"value")
|
||||||
|
timestamp_bytes = fields.get(b"timestamp") or fields.get(b"time")
|
||||||
|
|
||||||
|
if not all([device_id_bytes, metric_bytes, value_bytes, timestamp_bytes]):
|
||||||
|
self.logger.error(f"Missing required fields in message: {fields}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse metadata if present
|
||||||
|
metadata = None
|
||||||
|
metadata_bytes = fields.get(b"metadata")
|
||||||
|
if metadata_bytes:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_bytes.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.logger.warning(f"Could not parse metadata: {metadata_bytes}")
|
||||||
|
|
||||||
|
return StreamMessage(
|
||||||
|
stream_key=stream_key,
|
||||||
|
message_id=message_id,
|
||||||
|
device_id=device_id_bytes.decode("utf-8"),
|
||||||
|
sensor_type=metric_bytes.decode("utf-8"),
|
||||||
|
value=float(value_bytes.decode("utf-8")),
|
||||||
|
timestamp=timestamp_bytes.decode("utf-8"),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to parse stream entry: {e}", exc_info=True)
|
||||||
|
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)
|
||||||
9
services/device_manager/.dockerignore
Normal file
9
services/device_manager/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.crl
|
||||||
|
*.crt
|
||||||
|
*.pem
|
||||||
0
services/device_manager/.env.sample
Normal file
0
services/device_manager/.env.sample
Normal file
6
services/device_manager/.gitignore
vendored
Normal file
6
services/device_manager/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
certs/
|
||||||
|
*.pem
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
1
services/device_manager/.python-version
Normal file
1
services/device_manager/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
11
services/device_manager/Dockerfile
Normal file
11
services/device_manager/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||||
|
|
||||||
|
COPY ./pyproject.toml ./
|
||||||
|
|
||||||
|
COPY ./uv.lock ./
|
||||||
|
|
||||||
|
RUN uv sync
|
||||||
|
|
||||||
|
COPY ./app/ ./app/
|
||||||
|
|
||||||
|
ENTRYPOINT [ "uv", "run", "uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000" ]
|
||||||
73
services/device_manager/README.md
Normal file
73
services/device_manager/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Device Manager Service
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
```
|
||||||
293
services/device_manager/app/app.py
Normal file
293
services/device_manager/app/app.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from app.cert_manager import CertificateManager
|
||||||
|
from app.database import get_db_context
|
||||||
|
from app.db_models import Device, DeviceCertificate
|
||||||
|
from app.models import (
|
||||||
|
DeviceRegistrationRequest,
|
||||||
|
DeviceRegistrationResponse,
|
||||||
|
DeviceResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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.
|
||||||
|
- MQTT devices: issues X.509 certificate for mTLS
|
||||||
|
- HTTP/webhook devices: generates API key or HMAC secret
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if request.protocol == "mqtt":
|
||||||
|
cert_response = cert_manager.register_device(
|
||||||
|
name=request.name,
|
||||||
|
location=request.location,
|
||||||
|
)
|
||||||
|
|
||||||
|
with get_db_context() as db:
|
||||||
|
device = Device(
|
||||||
|
id=cert_response.device_id,
|
||||||
|
name=request.name,
|
||||||
|
location=request.location,
|
||||||
|
protocol=request.protocol,
|
||||||
|
connection_config=request.connection_config,
|
||||||
|
created_at=datetime.datetime.now(datetime.UTC),
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
|
||||||
|
device_cert = DeviceCertificate(
|
||||||
|
id=cert_response.certificate_id,
|
||||||
|
device_id=cert_response.device_id,
|
||||||
|
certificate_pem=cert_response.certificate_pem,
|
||||||
|
private_key_pem=cert_response.private_key_pem,
|
||||||
|
issued_at=datetime.datetime.now(datetime.UTC),
|
||||||
|
expires_at=cert_response.expires_at,
|
||||||
|
)
|
||||||
|
db.add(device_cert)
|
||||||
|
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=cert_response.device_id,
|
||||||
|
protocol=request.protocol,
|
||||||
|
certificate_id=cert_response.certificate_id,
|
||||||
|
ca_certificate_pem=cert_response.ca_certificate_pem,
|
||||||
|
certificate_pem=cert_response.certificate_pem,
|
||||||
|
private_key_pem=cert_response.private_key_pem,
|
||||||
|
expires_at=cert_response.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Protocol '{request.protocol}' not yet implemented. Only 'mqtt' is supported.",
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to register device {request.name}: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to register device. Please try again."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ca_certificate")
|
||||||
|
async def get_ca_certificate() -> str:
|
||||||
|
"""
|
||||||
|
Retrieve the CA certificate in PEM format.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ca_cert_pem = cert_manager.get_ca_certificate_pem()
|
||||||
|
return ca_cert_pem
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve CA certificate: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve CA certificate.") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/devices/{device_id}")
|
||||||
|
async def get_device(device_id: str) -> DeviceResponse:
|
||||||
|
"""
|
||||||
|
Retrieve device information by ID.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
device = db.query(Device).filter(Device.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
|
||||||
|
return DeviceResponse(
|
||||||
|
id=device.id,
|
||||||
|
name=device.name,
|
||||||
|
location=device.location,
|
||||||
|
protocol=device.protocol,
|
||||||
|
connection_config=device.connection_config,
|
||||||
|
created_at=device.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve device {device_id}: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve device information.") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/devices/")
|
||||||
|
async def list_devices() -> list[DeviceResponse]:
|
||||||
|
"""
|
||||||
|
List all registered devices.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
devices = db.query(Device).all()
|
||||||
|
return [
|
||||||
|
DeviceResponse(
|
||||||
|
id=device.id,
|
||||||
|
name=device.name,
|
||||||
|
location=device.location,
|
||||||
|
protocol=device.protocol,
|
||||||
|
connection_config=device.connection_config,
|
||||||
|
created_at=device.created_at,
|
||||||
|
)
|
||||||
|
for device in devices
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list devices: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list devices.") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/devices/{device_id}/revoke")
|
||||||
|
async def revoke_device_certificate(device_id: str):
|
||||||
|
"""
|
||||||
|
Revoke a device's certificate by:
|
||||||
|
1. Marking it as revoked in the database
|
||||||
|
2. Adding it to the Certificate Revocation List (CRL)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
# Get the active (non-revoked) certificate for the device
|
||||||
|
device_cert = (
|
||||||
|
db.query(DeviceCertificate)
|
||||||
|
.filter(
|
||||||
|
DeviceCertificate.device_id == device_id,
|
||||||
|
DeviceCertificate.revoked_at.is_(None)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not device_cert:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="No active certificate found for this device"
|
||||||
|
)
|
||||||
|
|
||||||
|
cert_manager.revoke_certificate(device_cert.certificate_pem)
|
||||||
|
|
||||||
|
device_cert.revoked_at = datetime.datetime.now(datetime.UTC)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Successfully revoked certificate for device {device_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"device_id": device_id,
|
||||||
|
"revoked_at": device_cert.revoked_at.isoformat(),
|
||||||
|
"message": "Certificate revoked successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to revoke device {device_id}: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to revoke device certificate.") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/crl")
|
||||||
|
async def get_crl():
|
||||||
|
"""
|
||||||
|
Get the Certificate Revocation List (CRL) in PEM format.
|
||||||
|
Mosquitto and other MQTT clients can check this to validate certificates.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
crl_pem = cert_manager.get_crl_pem()
|
||||||
|
if not crl_pem:
|
||||||
|
return {"message": "No certificates have been revoked yet"}
|
||||||
|
return {"crl_pem": crl_pem}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve CRL: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve CRL.") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/devices/{device_id}/renew")
|
||||||
|
async def renew_certificate(device_id: str):
|
||||||
|
"""
|
||||||
|
Renew a device's certificate by issuing a new one and revoking the old one.
|
||||||
|
|
||||||
|
This endpoint:
|
||||||
|
1. Retrieves the current certificate from DB
|
||||||
|
2. Generates a new certificate with new keys
|
||||||
|
3. Revokes the old certificate (adds to CRL)
|
||||||
|
4. Updates the database with the new certificate
|
||||||
|
5. Returns the new credentials
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
# Get current certificate
|
||||||
|
device_cert = (
|
||||||
|
db.query(DeviceCertificate)
|
||||||
|
.filter(
|
||||||
|
DeviceCertificate.device_id == device_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not device_cert:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="No active certificate found for device"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if certificate is about to expire (optional warning)
|
||||||
|
days_until_expiry = (device_cert.expires_at - datetime.datetime.now(datetime.UTC)).days
|
||||||
|
if days_until_expiry > 30:
|
||||||
|
logger.warning(
|
||||||
|
f"Certificate for device {device_id} renewed early "
|
||||||
|
f"({days_until_expiry} days remaining)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revoke old certificate and add to CRL
|
||||||
|
cert_manager.revoke_certificate(device_cert.certificate_pem)
|
||||||
|
device_cert.revoked_at = datetime.datetime.now(datetime.UTC)
|
||||||
|
|
||||||
|
# Generate new certificate with new keys
|
||||||
|
new_cert_pem, new_key_pem = cert_manager.renew_certificate(
|
||||||
|
current_cert_pem=device_cert.certificate_pem, validity_days=365, key_size=4096
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract certificate ID (serial number) from the new certificate
|
||||||
|
from cryptography import x509
|
||||||
|
|
||||||
|
new_cert = x509.load_pem_x509_certificate(new_cert_pem)
|
||||||
|
new_cert_id = format(new_cert.serial_number, "x")
|
||||||
|
|
||||||
|
# Create new certificate record in DB
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
|
new_device_cert = DeviceCertificate(
|
||||||
|
id=new_cert_id,
|
||||||
|
device_id=device_id,
|
||||||
|
certificate_pem=new_cert_pem.decode("utf-8"),
|
||||||
|
private_key_pem=new_key_pem.decode("utf-8"),
|
||||||
|
issued_at=now,
|
||||||
|
expires_at=now + datetime.timedelta(days=365),
|
||||||
|
)
|
||||||
|
db.add(new_device_cert)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Successfully renewed certificate for device {device_id}")
|
||||||
|
|
||||||
|
device = db.query(Device).filter(Device.id == device_id).first()
|
||||||
|
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=device_id,
|
||||||
|
protocol=device.protocol if device else "mqtt",
|
||||||
|
certificate_id=new_cert_id,
|
||||||
|
ca_certificate_pem=cert_manager.get_ca_certificate_pem(),
|
||||||
|
certificate_pem=new_device_cert.certificate_pem,
|
||||||
|
private_key_pem=new_device_cert.private_key_pem,
|
||||||
|
expires_at=new_device_cert.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to renew certificate for device {device_id}: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to renew device certificate.") from e
|
||||||
250
services/device_manager/app/cert_manager.py
Normal file
250
services/device_manager/app/cert_manager.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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 app.config import config
|
||||||
|
from app.models import DeviceCertificateResponse, DeviceCredentials
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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())
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
) -> DeviceCredentials:
|
||||||
|
"""Create device credentials: private key and signed certificate.
|
||||||
|
Returns:
|
||||||
|
DeviceCredentials model with certificate_id, device_id, certificate_pem, private_key_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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract serial number from certificate to use as ID
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||||
|
cert_id = format(cert.serial_number, 'x')
|
||||||
|
|
||||||
|
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=validity_days)
|
||||||
|
|
||||||
|
return DeviceCredentials(
|
||||||
|
certificate_id=cert_id,
|
||||||
|
device_id=device_id,
|
||||||
|
certificate_pem=cert_pem,
|
||||||
|
private_key_pem=key_pem,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_device(self, name: str, location: str | None = None) -> DeviceCertificateResponse:
|
||||||
|
"""Register a new device and generate its credentials.
|
||||||
|
Returns:
|
||||||
|
DeviceCertificateResponse
|
||||||
|
"""
|
||||||
|
device_id = self.generate_device_id()
|
||||||
|
credentials = self.create_device_credentials(device_id=device_id)
|
||||||
|
|
||||||
|
return DeviceCertificateResponse(
|
||||||
|
certificate_id=credentials.certificate_id,
|
||||||
|
device_id=credentials.device_id,
|
||||||
|
ca_certificate_pem=self.ca_cert_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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ca_certificate_pem(self) -> str:
|
||||||
|
"""Get the CA certificate in PEM format as a string."""
|
||||||
|
return self.ca_cert_pem.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_certificate(
|
||||||
|
self, certificate_pem: str, reason: x509.ReasonFlags = x509.ReasonFlags.unspecified
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Revoke a device certificate by adding it to the CRL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
certificate_pem: PEM-encoded certificate to revoke
|
||||||
|
reason: Revocation reason (default: unspecified)
|
||||||
|
"""
|
||||||
|
# Load the certificate to get serial number
|
||||||
|
cert = x509.load_pem_x509_certificate(certificate_pem.encode())
|
||||||
|
|
||||||
|
# Load existing CRL or create new one
|
||||||
|
crl_path = Path(config.CRL_PATH)
|
||||||
|
revoked_certs = []
|
||||||
|
|
||||||
|
if crl_path.exists():
|
||||||
|
with open(crl_path, "rb") as f:
|
||||||
|
existing_crl = x509.load_pem_x509_crl(f.read())
|
||||||
|
# Copy existing revoked certificates
|
||||||
|
revoked_certs = list(existing_crl)
|
||||||
|
|
||||||
|
# Add the new revoked certificate
|
||||||
|
revoked_cert = (
|
||||||
|
x509.RevokedCertificateBuilder()
|
||||||
|
.serial_number(cert.serial_number)
|
||||||
|
.revocation_date(datetime.datetime.now(datetime.UTC))
|
||||||
|
.add_extension(
|
||||||
|
x509.CRLReason(reason),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
revoked_certs.append(revoked_cert)
|
||||||
|
|
||||||
|
# Build new CRL with all revoked certificates
|
||||||
|
crl_builder = x509.CertificateRevocationListBuilder()
|
||||||
|
crl_builder = crl_builder.issuer_name(self.ca_cert.subject)
|
||||||
|
crl_builder = crl_builder.last_update(datetime.datetime.now(datetime.UTC))
|
||||||
|
crl_builder = crl_builder.next_update(
|
||||||
|
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
for revoked in revoked_certs:
|
||||||
|
crl_builder = crl_builder.add_revoked_certificate(revoked)
|
||||||
|
|
||||||
|
# Sign the CRL with CA key
|
||||||
|
crl = crl_builder.sign(private_key=self.ca_key, algorithm=hashes.SHA256())
|
||||||
|
|
||||||
|
# Write CRL to file
|
||||||
|
crl_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(crl_path, "wb") as f:
|
||||||
|
f.write(crl.public_bytes(serialization.Encoding.PEM))
|
||||||
|
|
||||||
|
|
||||||
|
def get_crl_pem(self) -> str | None:
|
||||||
|
"""Get the current CRL in PEM format."""
|
||||||
|
crl_path = Path(config.CRL_PATH)
|
||||||
|
if not crl_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(crl_path, "rb") as f:
|
||||||
|
return f.read().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def renew_certificate(
|
||||||
|
self,
|
||||||
|
current_cert_pem: str,
|
||||||
|
validity_days: int = 365,
|
||||||
|
key_size: int = 4096,
|
||||||
|
) -> tuple[bytes, bytes]:
|
||||||
|
"""Renew a device certificate before expiration.
|
||||||
|
Args:
|
||||||
|
current_cert_pem: PEM-encoded current certificate
|
||||||
|
validity_days: Validity period for new certificate
|
||||||
|
key_size: Key size for new device key
|
||||||
|
Returns:
|
||||||
|
tuple of (new_cert_pem, new_key_pem)
|
||||||
|
"""
|
||||||
|
# Load current certificate
|
||||||
|
current_cert = x509.load_pem_x509_certificate(current_cert_pem.encode())
|
||||||
|
device_id = current_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
|
||||||
|
|
||||||
|
# Generate new device key
|
||||||
|
new_device_key = self.generate_device_key(key_size=key_size)
|
||||||
|
|
||||||
|
# Generate new device certificate
|
||||||
|
new_cert_pem, new_key_pem = self.generate_device_certificate(
|
||||||
|
device_id=device_id,
|
||||||
|
ca_cert=self.ca_cert,
|
||||||
|
ca_key=self.ca_key,
|
||||||
|
device_key=new_device_key,
|
||||||
|
validity_days=validity_days,
|
||||||
|
key_size=key_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_cert_pem, new_key_pem
|
||||||
33
services/device_manager/app/config.py
Normal file
33
services/device_manager/app/config.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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"))
|
||||||
|
CRL_PATH = os.getenv("CRL_PATH", str(CERTS_DIR / "ca.crl"))
|
||||||
|
|
||||||
|
# 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()
|
||||||
83
services/device_manager/app/database.py
Normal file
83
services/device_manager/app/database.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Database session management for FastAPI with SQLAlchemy.
|
||||||
|
|
||||||
|
Uses dependency injection pattern for database sessions.
|
||||||
|
"""
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
# Create engine with connection pooling
|
||||||
|
engine = create_engine(
|
||||||
|
config.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
bind=engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that provides a database session.
|
||||||
|
|
||||||
|
Usage in endpoints:
|
||||||
|
@app.post("/devices")
|
||||||
|
async def create_device(db: Session = Depends(get_db)):
|
||||||
|
device = Device(...)
|
||||||
|
db.add(device)
|
||||||
|
db.commit()
|
||||||
|
return device
|
||||||
|
|
||||||
|
The session is automatically closed after the request completes.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db_context():
|
||||||
|
"""
|
||||||
|
Context manager for database sessions outside of FastAPI endpoints.
|
||||||
|
Usage:
|
||||||
|
with get_db_context() as db:
|
||||||
|
device = db.query(Device).first()
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def check_db_connection() -> bool:
|
||||||
|
"""
|
||||||
|
Check if database connection is working.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connection successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Database connection failed: {e}")
|
||||||
|
return False
|
||||||
76
services/device_manager/app/db_models.py
Normal file
76
services/device_manager/app/db_models.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy ORM models for device manager service.
|
||||||
|
|
||||||
|
These models mirror the database schema defined in db_migrations.
|
||||||
|
Kept separate to make the service independent.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, Text
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Base):
|
||||||
|
"""IoT devices registered in the system."""
|
||||||
|
|
||||||
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
location = Column(Text)
|
||||||
|
protocol = Column(Text, nullable=False, default="mqtt")
|
||||||
|
connection_config = Column(JSON)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Device(id={self.id}, name={self.name}, protocol={self.protocol})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCertificate(Base):
|
||||||
|
"""X.509 certificates issued to devices for mTLS authentication."""
|
||||||
|
|
||||||
|
__tablename__ = "device_certificates"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
certificate_pem = Column(Text, nullable=False)
|
||||||
|
private_key_pem = Column(Text)
|
||||||
|
issued_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_device_certificates_device_id", "device_id"),
|
||||||
|
Index("idx_device_certificates_active", "device_id", "revoked_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceCertificate(id={self.id}, device_id={self.device_id}, expires={self.expires_at})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCredential(Base):
|
||||||
|
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
|
||||||
|
|
||||||
|
__tablename__ = "device_credentials"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
credential_type = Column(Text, nullable=False)
|
||||||
|
credential_hash = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True))
|
||||||
|
revoked_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_device_credentials_device_id", "device_id"),
|
||||||
|
Index("idx_device_credentials_active", "device_id", "revoked_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
|
||||||
49
services/device_manager/app/models.py
Normal file
49
services/device_manager/app/models.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRegistrationRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
location: str | None = None
|
||||||
|
protocol: str = "mqtt"
|
||||||
|
connection_config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRegistrationResponse(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
protocol: str
|
||||||
|
certificate_id: str | None = None
|
||||||
|
ca_certificate_pem: str | None = None
|
||||||
|
certificate_pem: str | None = None
|
||||||
|
private_key_pem: str | None = None
|
||||||
|
expires_at: datetime.datetime | None = None
|
||||||
|
credential_id: str | None = None
|
||||||
|
api_key: str | None = None
|
||||||
|
webhook_secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
location: str | None = None
|
||||||
|
protocol: str
|
||||||
|
connection_config: dict[str, Any] | None = None
|
||||||
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
class DeviceCertificateResponse(BaseModel):
|
||||||
|
certificate_id: str
|
||||||
|
device_id: str
|
||||||
|
ca_certificate_pem: str
|
||||||
|
certificate_pem: str
|
||||||
|
private_key_pem: str
|
||||||
|
expires_at: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCredentials(BaseModel):
|
||||||
|
certificate_id: str
|
||||||
|
device_id: str
|
||||||
|
certificate_pem: bytes
|
||||||
|
private_key_pem: bytes
|
||||||
|
expires_at: datetime.datetime
|
||||||
34
services/device_manager/generate_ca.sh
Executable file
34
services/device_manager/generate_ca.sh
Executable 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
|
||||||
39
services/device_manager/pyproject.toml
Normal file
39
services/device_manager/pyproject.toml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
[project]
|
||||||
|
name = "device-manager"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Device Manager"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"cryptography>=46.0.3",
|
||||||
|
"fastapi[standard]>=0.120.2",
|
||||||
|
"nanoid>=2.0.0",
|
||||||
|
"psycopg2-binary>=2.9.11",
|
||||||
|
"pydantic>=2.12.3",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"sqlalchemy>=2.0.44",
|
||||||
|
"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"]
|
||||||
934
services/device_manager/uv.lock
generated
Normal file
934
services/device_manager/uv.lock
generated
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
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 = "psycopg2-binary" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ 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 = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.12.3" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||||
|
{ 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 = "greenlet"
|
||||||
|
version = "3.2.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "psycopg2-binary"
|
||||||
|
version = "2.9.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "sqlalchemy"
|
||||||
|
version = "2.0.44"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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" },
|
||||||
|
]
|
||||||
6
services/gpt_service/.env.sample
Normal file
6
services/gpt_service/.env.sample
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
API_KEY=your_api_key_here
|
||||||
|
PROVIDER_NAME=openai
|
||||||
|
MODEL_NAME=gpt-4.1
|
||||||
|
HOST_URL= http://localhost:8000
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
0
services/gpt_service/README.md
Normal file
0
services/gpt_service/README.md
Normal file
10
services/gpt_service/config.py
Normal file
10
services/gpt_service/config.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
API_KEY = os.getenv("API_KEY")
|
||||||
|
PROVIDER_NAME = os.getenv("PROVIDER_NAME", "openai")
|
||||||
|
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4")
|
||||||
|
HOST_URL = os.getenv("HOST_URL")
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
23
services/gpt_service/gpt_service.py
Normal file
23
services/gpt_service/gpt_service.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class GPTService:
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = API_KEY
|
||||||
|
self.model_name = MODEL_NAME
|
||||||
|
self.provider_name = PROVIDER_NAME
|
||||||
|
self.host_url = HOST_URL
|
||||||
|
|
||||||
|
logging.basicConfig(level=getattr(logging, LOG_LEVEL.upper(), logging.INFO))
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if self.provider_name == "openai":
|
||||||
|
self.client = OpenAI(api_key=self.api_key)
|
||||||
|
self.logger.info(f"Initialized OpenAI GPTService with model {self.model_name}")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Unsupported provider: {self.provider_name}")
|
||||||
|
raise ValueError(f"Unsupported provider: {self.provider_name}")
|
||||||
|
|
||||||
|
|
||||||
10
services/gpt_service/main.py
Normal file
10
services/gpt_service/main.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from gpt_service import GPTService
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
GPTService()
|
||||||
|
print("Hello from gpt-service!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
services/gpt_service/pyproject.toml
Normal file
10
services/gpt_service/pyproject.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "gpt-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"openai>=2.6.1",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
]
|
||||||
6
services/mqtt_ingestion/.dockerignore
Normal file
6
services/mqtt_ingestion/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
13
services/mqtt_ingestion/.env.example
Normal file
13
services/mqtt_ingestion/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
MQTT_BROKER=localhost
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
MQTT_TOPIC_PATTERN=devices/#
|
||||||
|
MQTT_KEEPALIVE=60
|
||||||
1
services/mqtt_ingestion/.python-version
Normal file
1
services/mqtt_ingestion/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
32
services/mqtt_ingestion/Dockerfile
Normal file
32
services/mqtt_ingestion/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Stage 1: Builder - Install dependencies with uv
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
COPY --from=builder /app/*.py /app/
|
||||||
|
|
||||||
|
RUN adduser -D -u 1000 appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
3
services/mqtt_ingestion/README.md
Normal file
3
services/mqtt_ingestion/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# MQTT Ingestion service
|
||||||
|
|
||||||
|
This service ingests all the data from the MQTT topics and sends the data to Redis
|
||||||
108
services/mqtt_ingestion/main.py
Normal file
108
services/mqtt_ingestion/main.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from src.mqtt_client import MQTTClient
|
||||||
|
from src.redis_writer import RedisWriter
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, "INFO"),
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTIngestionService:
|
||||||
|
def __init__(self):
|
||||||
|
self.running = False
|
||||||
|
self.redis_writer = None
|
||||||
|
self.mqtt_client = None
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
|
||||||
|
def _signal_handler(self, signum, frame):
|
||||||
|
"""Handle shutdown signals"""
|
||||||
|
logger.info(f"Received signal {signum}, shutting down...")
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def _handle_sensor_data(self, device_id: str, sensor_type: str, value: float):
|
||||||
|
"""
|
||||||
|
This function is called by MQTT client when a message arrives.
|
||||||
|
It just passes the data to Redis writer.
|
||||||
|
"""
|
||||||
|
success = self.redis_writer.write_sensor_data(device_id, sensor_type, value)
|
||||||
|
if success:
|
||||||
|
logger.info(f"Processed {device_id}/{sensor_type}: {value}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to process {device_id}/{sensor_type}: {value}")
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the service"""
|
||||||
|
logger.info("Starting MQTT Ingestion Service...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.redis_writer = RedisWriter()
|
||||||
|
|
||||||
|
self.mqtt_client = MQTTClient(self._handle_sensor_data)
|
||||||
|
|
||||||
|
if not self.mqtt_client.connect():
|
||||||
|
logger.error("Failed to connect to MQTT, exiting")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
logger.info("Service started successfully")
|
||||||
|
|
||||||
|
self.mqtt_client.start_loop()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Service startup failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the service gracefully"""
|
||||||
|
if not self.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Stopping service...")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
if self.mqtt_client:
|
||||||
|
self.mqtt_client.stop()
|
||||||
|
|
||||||
|
if self.redis_writer:
|
||||||
|
self.redis_writer.close()
|
||||||
|
|
||||||
|
logger.info("Service stopped")
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if service is healthy"""
|
||||||
|
if not self.running:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.redis_writer or not self.redis_writer.health_check():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point"""
|
||||||
|
service = MQTTIngestionService()
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = service.start()
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Received keyboard interrupt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
services/mqtt_ingestion/pyproject.toml
Normal file
11
services/mqtt_ingestion/pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "mqtt-ingestion"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"dotenv>=0.9.9",
|
||||||
|
"paho-mqtt>=2.1.0",
|
||||||
|
"redis>=6.4.0",
|
||||||
|
]
|
||||||
53
services/mqtt_ingestion/src/config.py
Normal file
53
services/mqtt_ingestion/src/config.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import dotenv
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RedisConfig:
|
||||||
|
host: str
|
||||||
|
port: int = 6379
|
||||||
|
db: int = 0
|
||||||
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MQTTConfig:
|
||||||
|
broker: str
|
||||||
|
port: int = 1883
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
topic_pattern: str = "devices/#"
|
||||||
|
keepalive: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Payload:
|
||||||
|
device_id: str
|
||||||
|
sensor_type: str
|
||||||
|
value: float
|
||||||
|
timestamp: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self):
|
||||||
|
self.redis = RedisConfig(
|
||||||
|
host=os.getenv("REDIS_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("REDIS_PORT", 6379)),
|
||||||
|
db=int(os.getenv("REDIS_DB", 0)),
|
||||||
|
password=os.getenv("REDIS_PASSWORD", None),
|
||||||
|
)
|
||||||
|
self.mqtt = MQTTConfig(
|
||||||
|
broker=os.getenv("MQTT_BROKER", "localhost"),
|
||||||
|
port=int(os.getenv("MQTT_PORT", 1883)),
|
||||||
|
username=os.getenv("MQTT_USERNAME", None),
|
||||||
|
password=os.getenv("MQTT_PASSWORD", None),
|
||||||
|
topic_pattern=os.getenv("MQTT_TOPIC_PATTERN", "devices/#"),
|
||||||
|
keepalive=int(os.getenv("MQTT_KEEPALIVE", 60)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
config = Config()
|
||||||
79
services/mqtt_ingestion/src/mqtt_client.py
Normal file
79
services/mqtt_ingestion/src/mqtt_client.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import logging
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from typing import Callable
|
||||||
|
from src.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTClient:
|
||||||
|
def __init__(self, message_handler: Callable[[str, str, float], None]):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
message_handler: Function that takes (device_id, sensor_type, value)
|
||||||
|
"""
|
||||||
|
self.message_handler = message_handler
|
||||||
|
self.client = mqtt.Client()
|
||||||
|
self._setup_callbacks()
|
||||||
|
|
||||||
|
def _setup_callbacks(self):
|
||||||
|
self.client.on_connect = self._on_connect
|
||||||
|
self.client.on_message = self._on_message
|
||||||
|
self.client.on_disconnect = self._on_disconnect
|
||||||
|
|
||||||
|
if config.mqtt.username:
|
||||||
|
self.client.username_pw_set(config.mqtt.username, config.mqtt.password)
|
||||||
|
|
||||||
|
def _on_connect(self, client, userdata, flags, rc):
|
||||||
|
if rc == 0:
|
||||||
|
logger.info(f"Connected to MQTT broker {config.mqtt.broker}")
|
||||||
|
client.subscribe(config.mqtt.topic_pattern)
|
||||||
|
logger.info(f"Subscribed to {config.mqtt.topic_pattern}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to connect to MQTT broker, code: {rc}")
|
||||||
|
|
||||||
|
def _on_message(self, client, userdata, msg):
|
||||||
|
try:
|
||||||
|
topic_parts = msg.topic.split("/")
|
||||||
|
if len(topic_parts) != 3 or topic_parts[0] != "devices":
|
||||||
|
logger.warning(f"Invalid topic format: {msg.topic}")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_id = topic_parts[1]
|
||||||
|
sensor_type = topic_parts[2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = float(msg.payload.decode())
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Invalid payload for {msg.topic}: {msg.payload}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.message_handler(device_id, sensor_type, value)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing MQTT message: {e}")
|
||||||
|
|
||||||
|
def _on_disconnect(self, client, userdata, rc):
|
||||||
|
if rc != 0:
|
||||||
|
logger.warning("Unexpected MQTT disconnection")
|
||||||
|
else:
|
||||||
|
logger.info("MQTT client disconnected")
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Connect to MQTT broker"""
|
||||||
|
try:
|
||||||
|
self.client.connect(
|
||||||
|
config.mqtt.broker, config.mqtt.port, config.mqtt.keepalive
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to MQTT: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start_loop(self):
|
||||||
|
"""Start the MQTT loop (blocking)"""
|
||||||
|
self.client.loop_forever()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the MQTT client"""
|
||||||
|
self.client.disconnect()
|
||||||
72
services/mqtt_ingestion/src/redis_writer.py
Normal file
72
services/mqtt_ingestion/src/redis_writer.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import redis
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from src.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisWriter:
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Redis writer with config from environment"""
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.redis_client = redis.StrictRedis(
|
||||||
|
host=config.redis.host,
|
||||||
|
port=config.redis.port,
|
||||||
|
db=config.redis.db,
|
||||||
|
password=config.redis.password,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.redis_client.ping()
|
||||||
|
self.logger.info(
|
||||||
|
f"Connected to Redis at {config.redis.host}:{config.redis.port}"
|
||||||
|
)
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
self.logger.error(f"Failed to connect to Redis server: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def write_sensor_data(self, device_id: str, sensor_type: str, value: float) -> bool:
|
||||||
|
"""
|
||||||
|
Write sensor data to single Redis stream for all devices.
|
||||||
|
- Stream: mqtt:ingestion (single stream for scalability)
|
||||||
|
- Hash: mqtt_latest:{device_id} (for quick dashboard access)
|
||||||
|
"""
|
||||||
|
timestamp = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
stream_key = "mqtt:ingestion"
|
||||||
|
hash_key = f"mqtt_latest:{device_id}"
|
||||||
|
|
||||||
|
stream_data = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"metric": sensor_type,
|
||||||
|
"value": str(value),
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write to single stream
|
||||||
|
self.redis_client.xadd(stream_key, stream_data, maxlen=10000)
|
||||||
|
|
||||||
|
self.redis_client.hset(hash_key, sensor_type, str(value))
|
||||||
|
self.redis_client.hset(hash_key, f"{sensor_type}_time", timestamp)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except redis.RedisError as e:
|
||||||
|
self.logger.error(f"Failed to write to Redis: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
"""Check if Redis connection is healthy"""
|
||||||
|
try:
|
||||||
|
self.redis_client.ping()
|
||||||
|
return True
|
||||||
|
except redis.RedisError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Close Redis connection"""
|
||||||
|
try:
|
||||||
|
self.redis_client.close()
|
||||||
|
self.logger.info("Redis connection closed")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error closing Redis connection: {e}")
|
||||||
58
services/mqtt_ingestion/uv.lock
generated
Normal file
58
services/mqtt_ingestion/uv.lock
generated
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenv"
|
||||||
|
version = "0.9.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mqtt-ingestion"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dotenv" },
|
||||||
|
{ name = "paho-mqtt" },
|
||||||
|
{ name = "redis" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||||
|
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||||
|
{ name = "redis", specifier = ">=6.4.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paho-mqtt"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redis"
|
||||||
|
version = "6.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
|
||||||
|
]
|
||||||
664
uv.lock
generated
Normal file
664
uv.lock
generated
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[manifest]
|
||||||
|
members = [
|
||||||
|
"dashboard",
|
||||||
|
"db-migrations",
|
||||||
|
"gpt-service",
|
||||||
|
"iotdashboard",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "asgiref"
|
||||||
|
version = "3.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "dashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "iotDashboard" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "db-migrations"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "db_migrations" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.17.0" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distro"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "5.2.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gpt-service"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "services/gpt_service" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "openai" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "openai", specifier = ">=2.6.1" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.2.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "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 = "iotdashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "openai" },
|
||||||
|
{ name = "paho-mqtt" },
|
||||||
|
{ name = "psycopg2-binary" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "redis" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.17.0" },
|
||||||
|
{ name = "django", specifier = ">=5.2.7" },
|
||||||
|
{ name = "openai", specifier = ">=2.6.1" },
|
||||||
|
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||||
|
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
{ name = "redis", specifier = ">=7.0.1" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "ruff", specifier = ">=0.14.2" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jiter"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/68/0357982493a7b20925aece061f7fb7a2678e3b232f8d73a6edb7e5304443/jiter-0.11.1.tar.gz", hash = "sha256:849dcfc76481c0ea0099391235b7ca97d7279e0fa4c86005457ac7c88e8b76dc", size = 168385, upload-time = "2025-10-17T11:31:15.186Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/4b/e4dd3c76424fad02a601d570f4f2a8438daea47ba081201a721a903d3f4c/jiter-0.11.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:71b6a920a5550f057d49d0e8bcc60945a8da998019e83f01adf110e226267663", size = 305272, upload-time = "2025-10-17T11:29:39.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/83/2cd3ad5364191130f4de80eacc907f693723beaab11a46c7d155b07a092c/jiter-0.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b3de72e925388453a5171be83379549300db01284f04d2a6f244d1d8de36f94", size = 314038, upload-time = "2025-10-17T11:29:40.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/3c/8e67d9ba524e97d2f04c8f406f8769a23205026b13b0938d16646d6e2d3e/jiter-0.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc19dd65a2bd3d9c044c5b4ebf657ca1e6003a97c0fc10f555aa4f7fb9821c00", size = 345977, upload-time = "2025-10-17T11:29:42.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/a5/489ce64d992c29bccbffabb13961bbb0435e890d7f2d266d1f3df5e917d2/jiter-0.11.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d58faaa936743cd1464540562f60b7ce4fd927e695e8bc31b3da5b914baa9abd", size = 364503, upload-time = "2025-10-17T11:29:43.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/c0/e321dd83ee231d05c8fe4b1a12caf1f0e8c7a949bf4724d58397104f10f2/jiter-0.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:902640c3103625317291cb73773413b4d71847cdf9383ba65528745ff89f1d14", size = 487092, upload-time = "2025-10-17T11:29:44.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/5e/8f24ec49c8d37bd37f34ec0112e0b1a3b4b5a7b456c8efff1df5e189ad43/jiter-0.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30405f726e4c2ed487b176c09f8b877a957f535d60c1bf194abb8dadedb5836f", size = 376328, upload-time = "2025-10-17T11:29:46.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/70/ded107620e809327cf7050727e17ccfa79d6385a771b7fe38fb31318ef00/jiter-0.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3217f61728b0baadd2551844870f65219ac4a1285d5e1a4abddff3d51fdabe96", size = 356632, upload-time = "2025-10-17T11:29:47.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/53/c26f7251613f6a9079275ee43c89b8a973a95ff27532c421abc2a87afb04/jiter-0.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1364cc90c03a8196f35f396f84029f12abe925415049204446db86598c8b72c", size = 384358, upload-time = "2025-10-17T11:29:49.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/16/e0f2cc61e9c4d0b62f6c1bd9b9781d878a427656f88293e2a5335fa8ff07/jiter-0.11.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:53a54bf8e873820ab186b2dca9f6c3303f00d65ae5e7b7d6bda1b95aa472d646", size = 517279, upload-time = "2025-10-17T11:29:50.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/5c/4cd095eaee68961bca3081acbe7c89e12ae24a5dae5fd5d2a13e01ed2542/jiter-0.11.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7e29aca023627b0e0c2392d4248f6414d566ff3974fa08ff2ac8dbb96dfee92a", size = 508276, upload-time = "2025-10-17T11:29:52.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/25/f459240e69b0e09a7706d96ce203ad615ca36b0fe832308d2b7123abf2d0/jiter-0.11.1-cp313-cp313-win32.whl", hash = "sha256:f153e31d8bca11363751e875c0a70b3d25160ecbaee7b51e457f14498fb39d8b", size = 205593, upload-time = "2025-10-17T11:29:53.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/16/461bafe22bae79bab74e217a09c907481a46d520c36b7b9fe71ee8c9e983/jiter-0.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:f773f84080b667c69c4ea0403fc67bb08b07e2b7ce1ef335dea5868451e60fed", size = 203518, upload-time = "2025-10-17T11:29:55.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/72/c45de6e320edb4fa165b7b1a414193b3cae302dd82da2169d315dcc78b44/jiter-0.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:635ecd45c04e4c340d2187bcb1cea204c7cc9d32c1364d251564bf42e0e39c2d", size = 188062, upload-time = "2025-10-17T11:29:56.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/9b/4a57922437ca8753ef823f434c2dec5028b237d84fa320f06a3ba1aec6e8/jiter-0.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d892b184da4d94d94ddb4031296931c74ec8b325513a541ebfd6dfb9ae89904b", size = 313814, upload-time = "2025-10-17T11:29:58.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/50/62a0683dadca25490a4bedc6a88d59de9af2a3406dd5a576009a73a1d392/jiter-0.11.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa22c223a3041dacb2fcd37c70dfd648b44662b4a48e242592f95bda5ab09d58", size = 344987, upload-time = "2025-10-17T11:30:00.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/00/2355dbfcbf6cdeaddfdca18287f0f38ae49446bb6378e4a5971e9356fc8a/jiter-0.11.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330e8e6a11ad4980cd66a0f4a3e0e2e0f646c911ce047014f984841924729789", size = 356399, upload-time = "2025-10-17T11:30:02.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/07/c2bd748d578fa933d894a55bff33f983bc27f75fc4e491b354bef7b78012/jiter-0.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:09e2e386ebf298547ca3a3704b729471f7ec666c2906c5c26c1a915ea24741ec", size = 203289, upload-time = "2025-10-17T11:30:03.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/ee/ace64a853a1acbd318eb0ca167bad1cf5ee037207504b83a868a5849747b/jiter-0.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:fe4a431c291157e11cee7c34627990ea75e8d153894365a3bc84b7a959d23ca8", size = 188284, upload-time = "2025-10-17T11:30:05.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/00/d6006d069e7b076e4c66af90656b63da9481954f290d5eca8c715f4bf125/jiter-0.11.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0fa1f70da7a8a9713ff8e5f75ec3f90c0c870be6d526aa95e7c906f6a1c8c676", size = 304624, upload-time = "2025-10-17T11:30:06.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/45/4a0e31eb996b9ccfddbae4d3017b46f358a599ccf2e19fbffa5e531bd304/jiter-0.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:569ee559e5046a42feb6828c55307cf20fe43308e3ae0d8e9e4f8d8634d99944", size = 315042, upload-time = "2025-10-17T11:30:08.87Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/91/22f5746f5159a28c76acdc0778801f3c1181799aab196dbea2d29e064968/jiter-0.11.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f69955fa1d92e81987f092b233f0be49d4c937da107b7f7dcf56306f1d3fcce9", size = 346357, upload-time = "2025-10-17T11:30:10.222Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/4f/57620857d4e1dc75c8ff4856c90cb6c135e61bff9b4ebfb5dc86814e82d7/jiter-0.11.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:090f4c9d4a825e0fcbd0a2647c9a88a0f366b75654d982d95a9590745ff0c48d", size = 365057, upload-time = "2025-10-17T11:30:11.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/34/caf7f9cc8ae0a5bb25a5440cc76c7452d264d1b36701b90fdadd28fe08ec/jiter-0.11.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbf3d8cedf9e9d825233e0dcac28ff15c47b7c5512fdfe2e25fd5bbb6e6b0cee", size = 487086, upload-time = "2025-10-17T11:30:13.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/17/85b5857c329d533d433fedf98804ebec696004a1f88cabad202b2ddc55cf/jiter-0.11.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2aa9b1958f9c30d3d1a558b75f0626733c60eb9b7774a86b34d88060be1e67fe", size = 376083, upload-time = "2025-10-17T11:30:14.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/d3/2d9f973f828226e6faebdef034097a2918077ea776fb4d88489949024787/jiter-0.11.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42d1ca16590b768c5e7d723055acd2633908baacb3628dd430842e2e035aa90", size = 357825, upload-time = "2025-10-17T11:30:15.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/55/848d4dabf2c2c236a05468c315c2cb9dc736c5915e65449ccecdba22fb6f/jiter-0.11.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5db4c2486a023820b701a17aec9c5a6173c5ba4393f26662f032f2de9c848b0f", size = 383933, upload-time = "2025-10-17T11:30:17.34Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/6c/204c95a4fbb0e26dfa7776c8ef4a878d0c0b215868011cc904bf44f707e2/jiter-0.11.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4573b78777ccfac954859a6eff45cbd9d281d80c8af049d0f1a3d9fc323d5c3a", size = 517118, upload-time = "2025-10-17T11:30:18.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/25/09956644ea5a2b1e7a2a0f665cb69a973b28f4621fa61fc0c0f06ff40a31/jiter-0.11.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7593ac6f40831d7961cb67633c39b9fef6689a211d7919e958f45710504f52d3", size = 508194, upload-time = "2025-10-17T11:30:20.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/49/4d1657355d7f5c9e783083a03a3f07d5858efa6916a7d9634d07db1c23bd/jiter-0.11.1-cp314-cp314-win32.whl", hash = "sha256:87202ec6ff9626ff5f9351507def98fcf0df60e9a146308e8ab221432228f4ea", size = 203961, upload-time = "2025-10-17T11:30:22.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/bd/f063bd5cc2712e7ca3cf6beda50894418fc0cfeb3f6ff45a12d87af25996/jiter-0.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:a5dd268f6531a182c89d0dd9a3f8848e86e92dfff4201b77a18e6b98aa59798c", size = 202804, upload-time = "2025-10-17T11:30:23.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/ca/4d84193dfafef1020bf0bedd5e1a8d0e89cb67c54b8519040effc694964b/jiter-0.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:5d761f863f912a44748a21b5c4979c04252588ded8d1d2760976d2e42cd8d991", size = 188001, upload-time = "2025-10-17T11:30:24.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/fa/3b05e5c9d32efc770a8510eeb0b071c42ae93a5b576fd91cee9af91689a1/jiter-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2cc5a3965285ddc33e0cab933e96b640bc9ba5940cea27ebbbf6695e72d6511c", size = 312561, upload-time = "2025-10-17T11:30:26.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/d3/335822eb216154ddb79a130cbdce88fdf5c3e2b43dc5dba1fd95c485aaf5/jiter-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b572b3636a784c2768b2342f36a23078c8d3aa6d8a30745398b1bab58a6f1a8", size = 344551, upload-time = "2025-10-17T11:30:28.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/6d/a0bed13676b1398f9b3ba61f32569f20a3ff270291161100956a577b2dd3/jiter-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad93e3d67a981f96596d65d2298fe8d1aa649deb5374a2fb6a434410ee11915e", size = 363051, upload-time = "2025-10-17T11:30:30.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/03/313eda04aa08545a5a04ed5876e52f49ab76a4d98e54578896ca3e16313e/jiter-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a83097ce379e202dcc3fe3fc71a16d523d1ee9192c8e4e854158f96b3efe3f2f", size = 485897, upload-time = "2025-10-17T11:30:31.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/13/a1011b9d325e40b53b1b96a17c010b8646013417f3902f97a86325b19299/jiter-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7042c51e7fbeca65631eb0c332f90c0c082eab04334e7ccc28a8588e8e2804d9", size = 375224, upload-time = "2025-10-17T11:30:33.18Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/da/1b45026b19dd39b419e917165ff0ea629dbb95f374a3a13d2df95e40a6ac/jiter-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a68d679c0e47649a61df591660507608adc2652442de7ec8276538ac46abe08", size = 356606, upload-time = "2025-10-17T11:30:34.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/0c/9acb0e54d6a8ba59ce923a180ebe824b4e00e80e56cefde86cc8e0a948be/jiter-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b0da75dbf4b6ec0b3c9e604d1ee8beaf15bc046fff7180f7d89e3cdbd3bb51", size = 384003, upload-time = "2025-10-17T11:30:35.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/2b/e5a5fe09d6da2145e4eed651e2ce37f3c0cf8016e48b1d302e21fb1628b7/jiter-0.11.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:69dd514bf0fa31c62147d6002e5ca2b3e7ef5894f5ac6f0a19752385f4e89437", size = 516946, upload-time = "2025-10-17T11:30:37.425Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/fe/db936e16e0228d48eb81f9934e8327e9fde5185e84f02174fcd22a01be87/jiter-0.11.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:bb31ac0b339efa24c0ca606febd8b77ef11c58d09af1b5f2be4c99e907b11111", size = 507614, upload-time = "2025-10-17T11:30:38.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/db/c4438e8febfb303486d13c6b72f5eb71cf851e300a0c1f0b4140018dd31f/jiter-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b2ce0d6156a1d3ad41da3eec63b17e03e296b78b0e0da660876fccfada86d2f7", size = 204043, upload-time = "2025-10-17T11:30:40.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/59/81badb169212f30f47f817dfaabf965bc9b8204fed906fab58104ee541f9/jiter-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f4db07d127b54c4a2d43b4cf05ff0193e4f73e0dd90c74037e16df0b29f666e1", size = 204046, upload-time = "2025-10-17T11:30:41.692Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/01/43f7b4eb61db3e565574c4c5714685d042fb652f9eef7e5a3de6aafa943a/jiter-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28e4fdf2d7ebfc935523e50d1efa3970043cfaa161674fe66f9642409d001dfe", size = 188069, upload-time = "2025-10-17T11:30:43.23Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "openai"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "distro" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "jiter" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "tqdm" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/44/303deb97be7c1c9b53118b52825cbd1557aeeff510f3a52566b1fa66f6a2/openai-2.6.1.tar.gz", hash = "sha256:27ae704d190615fca0c0fc2b796a38f8b5879645a3a52c9c453b23f97141bb49", size = 593043, upload-time = "2025-10-24T13:29:52.79Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/0e/331df43df633e6105ff9cf45e0ce57762bd126a45ac16b25a43f6738d8a2/openai-2.6.1-py3-none-any.whl", hash = "sha256:904e4b5254a8416746a2f05649594fa41b19d799843cd134dac86167e094edef", size = 1005551, upload-time = "2025-10-24T13:29:50.973Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paho-mqtt"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg2-binary"
|
||||||
|
version = "2.9.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
||||||
|
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 = "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 = "redis"
|
||||||
|
version = "7.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/8f/f125feec0b958e8d22c8f0b492b30b1991d9499a4315dfde466cf4289edc/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1", size = 4755322, upload-time = "2025-10-27T14:34:00.33Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/97/9f22a33c475cda519f20aba6babb340fb2f2254a02fb947816960d1e669a/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a", size = 339938, upload-time = "2025-10-27T14:33:58.553Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
||||||
|
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 = "sqlalchemy"
|
||||||
|
version = "2.0.44"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tqdm"
|
||||||
|
version = "4.67.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "tzdata"
|
||||||
|
version = "2025.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user