Initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.idea/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
db/
|
||||||
|
storage/
|
||||||
|
tmp/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
API_KEYS=replace-with-generated-key-1,replace-with-generated-key-2
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
db/*.db
|
||||||
|
storage/images/
|
||||||
|
storage/models/*
|
||||||
|
!storage/models/.gitkeep
|
||||||
|
tmp/*
|
||||||
|
!tmp/.gitkeep
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MODELS_DIR=/app/storage/models \
|
||||||
|
TMP_DIR=/app/tmp \
|
||||||
|
DATABASE_URL=sqlite:////app/db/app.db
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libgomp1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/storage/models /app/tmp /app/db
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Model Update Server
|
||||||
|
|
||||||
|
Минимальный backend-сервис на FastAPI для загрузки `.pt` моделей, их stub-конвертации в `.ort`, хранения метаданных в SQLite и выдачи последней модели устройствам.
|
||||||
|
|
||||||
|
## Что умеет
|
||||||
|
|
||||||
|
- `POST /models` - загрузка новой `.pt` модели с версией
|
||||||
|
- `GET /models/latest` - метаданные последней модели
|
||||||
|
- `GET /models/latest/download` - скачивание последней `.ort` модели
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Все API-запросы требуют заголовок авторизации:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-API-Key: <server-issued-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключи выпускаются вручную на сервере и задаются через переменную окружения `API_KEYS`.
|
||||||
|
Можно указать один ключ или несколько ключей через запятую.
|
||||||
|
|
||||||
|
### `POST /models`
|
||||||
|
|
||||||
|
Загружает новую модель и регистрирует её как актуальную.
|
||||||
|
|
||||||
|
Принимает:
|
||||||
|
|
||||||
|
- `Content-Type: multipart/form-data`
|
||||||
|
- поле `file` - файл модели в формате `.pt`
|
||||||
|
- поле `version` - строковая версия модели, например `1.0.3`
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
|
||||||
|
- `201 Created` и JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0.3",
|
||||||
|
"created_at": "2026-05-12T10:00:00Z",
|
||||||
|
"filename": "model_v1.0.3.ort",
|
||||||
|
"is_latest": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- `400` - пустая версия или файл не `.pt`
|
||||||
|
- `409` - такая версия уже существует
|
||||||
|
- `500` - ошибка конвертации
|
||||||
|
|
||||||
|
### `GET /models/latest`
|
||||||
|
|
||||||
|
Возвращает метаданные последней доступной модели.
|
||||||
|
|
||||||
|
Принимает:
|
||||||
|
|
||||||
|
- ничего
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
|
||||||
|
- `200 OK` и JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0.3",
|
||||||
|
"created_at": "2026-05-12T10:00:00Z",
|
||||||
|
"filename": "model_v1.0.3.ort"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- `404` - если моделей ещё нет
|
||||||
|
|
||||||
|
### `GET /models/latest/download`
|
||||||
|
|
||||||
|
Возвращает файл последней `.ort` модели.
|
||||||
|
|
||||||
|
Принимает:
|
||||||
|
|
||||||
|
- query-параметр `version`
|
||||||
|
- query-параметр `filename`
|
||||||
|
|
||||||
|
Возвращает:
|
||||||
|
|
||||||
|
- `200 OK`
|
||||||
|
- бинарный файл `.ort`
|
||||||
|
- заголовок `Content-Disposition` для скачивания
|
||||||
|
|
||||||
|
Ошибки:
|
||||||
|
|
||||||
|
- `404` - если моделей нет, файл отсутствует на диске или `version`/`filename` не совпадают с актуальной моделью
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервис будет доступен на `http://127.0.0.1:8000`.
|
||||||
|
|
||||||
|
Пример запуска с API-ключом:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_KEYS="local-dev-key" uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Сгенерировать 10 API-ключей и экспортировать их в текущую shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source scripts/generate_api_keys.sh
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Сгенерировать 10 API-ключей и сохранить их в env-файл:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/generate_api_keys.sh --env-file .env
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
- `MODELS_DIR` - директория хранения итоговых `.ort` моделей
|
||||||
|
- `TMP_DIR` - директория для временно загруженных `.pt`
|
||||||
|
- `DATABASE_URL` - URL подключения к SQLite, например `sqlite:///./db/app.db`
|
||||||
|
- `API_KEYS` - один или несколько API-ключей через запятую, например `key-1,key-2`
|
||||||
|
|
||||||
|
По умолчанию используются:
|
||||||
|
|
||||||
|
- `storage/models`
|
||||||
|
- `tmp`
|
||||||
|
- `sqlite:///db/app.db`
|
||||||
|
- пустой список API-ключей, при котором API-запросы будут отклоняться с `401`
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Проект разложен по слоям clean architecture:
|
||||||
|
|
||||||
|
- `app/domain` - сущности, интерфейсы репозиториев и доменные исключения
|
||||||
|
- `app/application` - use cases и DTO
|
||||||
|
- `app/infrastructure` - SQLite, файловое хранилище, stub-конвертер, bootstrap
|
||||||
|
- `app/presentation` - FastAPI routes, зависимости и response-схемы
|
||||||
|
|
||||||
|
Точка входа приложения: [app/main.py](/Users/tanexc/PyCharmMiscProject/convert-and-update-model-server/app/main.py).
|
||||||
|
|
||||||
|
## Поведение stub-конвертера
|
||||||
|
|
||||||
|
Stub-конвертер уже подключен к `POST /models`, но реальную конвертацию пока не делает. В текущей версии он копирует загруженный `.pt` файл в хранилище с расширением `.ort`, чтобы можно было проверить сценарий загрузки и скачивания end-to-end.
|
||||||
|
|
||||||
|
Точка расширения находится в [app/infrastructure/ModelConverterImpl.py](/Users/tanexc/PyCharmMiscProject/convert-and-update-model-server/app/infrastructure/ModelConverterImpl.py).
|
||||||
|
|
||||||
|
## Примеры запросов
|
||||||
|
|
||||||
|
Загрузка модели:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8000/models" \
|
||||||
|
-H "X-API-Key: local-dev-key" \
|
||||||
|
-F "version=1.0.3" \
|
||||||
|
-F "file=@./example.pt"
|
||||||
|
```
|
||||||
|
|
||||||
|
Получение метаданных:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: local-dev-key" \
|
||||||
|
"http://127.0.0.1:8000/models/latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачивание последней модели:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -OJ -H "X-API-Key: local-dev-key" \
|
||||||
|
"http://127.0.0.1:8000/models/latest/download?version=1.0.3&filename=model_v1.0.3.ort"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Сборка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t model-update-server .
|
||||||
|
```
|
||||||
|
|
||||||
|
Сгенерировать ключи в `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/generate_api_keys.sh --env-file .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск через env-файл:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8000:8000 \
|
||||||
|
--env-file .env \
|
||||||
|
-v "$(pwd)/storage/models:/app/storage/models" \
|
||||||
|
-v "$(pwd)/db:/app/db" \
|
||||||
|
model-update-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл `.env` содержит реальные ключи и не должен попадать в репозиторий. Шаблон без секретов находится в `.env.example`.
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
Пример unit-файла находится в `deploy/systemd/model-update-server.service`.
|
||||||
|
Он читает переменные окружения из `/etc/model-update-server/model-update-server.env`.
|
||||||
|
|
||||||
|
Установка env-файла с ключами:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/model-update-server
|
||||||
|
scripts/generate_api_keys.sh --env-file /tmp/model-update-server.env
|
||||||
|
sudo install -m 600 /tmp/model-update-server.env /etc/model-update-server/model-update-server.env
|
||||||
|
rm /tmp/model-update-server.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Установка unit-файла:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/systemd/model-update-server.service /etc/systemd/system/model-update-server.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now model-update-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
В CI храните `API_KEYS` как secret/protected variable и передавайте её в окружение сборки или деплоя.
|
||||||
|
Не генерируйте новые ключи при каждом деплое, если старые клиенты должны продолжать работать.
|
||||||
|
|
||||||
|
Пример для GitHub Actions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
API_KEYS: ${{ secrets.API_KEYS }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример для GitLab CI:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
deploy:
|
||||||
|
script:
|
||||||
|
- docker run --env API_KEYS="$API_KEYS" model-update-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура данных
|
||||||
|
|
||||||
|
Таблица `models`:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `version`
|
||||||
|
- `filename`
|
||||||
|
- `file_path`
|
||||||
|
- `created_at`
|
||||||
|
- `is_latest`
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class DomainError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelNotFoundError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateModelVersionError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidModelVersionError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidModelFileError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFileMissingError(DomainError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelConversionError(DomainError):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Model:
|
||||||
|
id: int | None
|
||||||
|
version: str
|
||||||
|
filename: str
|
||||||
|
file_path: str
|
||||||
|
created_at: datetime
|
||||||
|
is_latest: bool
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from abc import abstractmethod, ABC
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ModelConverter(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def convert(self, model_pt_path: Path, results_dir: Path) -> Path:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from app.domain.Model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def get_latest(self) -> Model | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_version(self, version: str) -> Model | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def add(self, model: Model) -> Model:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ModelStorage(ABC):
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def models_dir(self) -> Path:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def ensure_directories(self) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def validate_upload_filename(self, filename: str | None) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def save_upload_to_tmp(self, source_file, filename: str) -> Path:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def build_model_filename(self, version: str) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def prepare_conversion_input(self, tmp_path: Path, model_filename: str) -> Path:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def finalize_converted_model(self, converted_path: Path, model_filename: str) -> Path:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def ensure_model_file_exists(self, file_path: str) -> Path:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_file_if_exists(self, file_path: Path | None) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from app.domain.ModelStorage import ModelStorage
|
||||||
|
from app.domain.Model import Model
|
||||||
|
from app.domain.Exception import ModelNotFoundError
|
||||||
|
from app.domain.ModelRepository import ModelRepository
|
||||||
|
|
||||||
|
|
||||||
|
class GetLatestModelUseCase:
|
||||||
|
def __init__(self, repository: ModelRepository, storage: ModelStorage) -> None:
|
||||||
|
self._repository = repository
|
||||||
|
self._storage = storage
|
||||||
|
|
||||||
|
def execute(self, version: str | None = None, filename: str | None = None) -> Model:
|
||||||
|
model = self._repository.get_latest()
|
||||||
|
if model is None:
|
||||||
|
raise ModelNotFoundError("No models have been uploaded yet.")
|
||||||
|
if version is not None and model.version != version:
|
||||||
|
raise ModelNotFoundError("Latest model does not match requested version.")
|
||||||
|
if filename is not None and model.filename != filename:
|
||||||
|
raise ModelNotFoundError("Latest model does not match requested filename.")
|
||||||
|
self._storage.ensure_model_file_exists(model.file_path)
|
||||||
|
return model
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.domain.Model import Model
|
||||||
|
from app.domain.Exception import *
|
||||||
|
from app.domain.ModelRepository import ModelRepository
|
||||||
|
from app.domain.ModelStorage import ModelStorage
|
||||||
|
from app.domain.ModelConverter import ModelConverter
|
||||||
|
|
||||||
|
|
||||||
|
class UploadModelUseCase:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: ModelRepository,
|
||||||
|
storage: ModelStorage,
|
||||||
|
converter: ModelConverter,
|
||||||
|
) -> None:
|
||||||
|
self._repository = repository
|
||||||
|
self._storage = storage
|
||||||
|
self._converter = converter
|
||||||
|
|
||||||
|
def execute(self, version: str, upload_file) -> Model:
|
||||||
|
normalized_version = version.strip()
|
||||||
|
if not normalized_version:
|
||||||
|
raise InvalidModelVersionError("Version must not be empty.")
|
||||||
|
if self._repository.get_by_version(normalized_version) is not None:
|
||||||
|
raise DuplicateModelVersionError(f"Model version '{normalized_version}' already exists.")
|
||||||
|
|
||||||
|
safe_filename = self._storage.validate_upload_filename(upload_file.filename)
|
||||||
|
tmp_path = self._storage.save_upload_to_tmp(upload_file.file, safe_filename)
|
||||||
|
conversion_input_path = None
|
||||||
|
final_model_path = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_filename = self._storage.build_model_filename(normalized_version)
|
||||||
|
conversion_input_path = self._storage.prepare_conversion_input(
|
||||||
|
tmp_path=tmp_path,
|
||||||
|
model_filename=model_filename,
|
||||||
|
)
|
||||||
|
converted_path = self._converter.convert(
|
||||||
|
model_pt_path=conversion_input_path,
|
||||||
|
results_dir=self._storage.models_dir,
|
||||||
|
)
|
||||||
|
final_model_path = self._storage.finalize_converted_model(
|
||||||
|
converted_path=converted_path,
|
||||||
|
model_filename=model_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
model = self._repository.add(
|
||||||
|
Model(
|
||||||
|
id=None,
|
||||||
|
version=normalized_version,
|
||||||
|
filename=model_filename,
|
||||||
|
file_path=str(final_model_path),
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
is_latest=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
except Exception as exc:
|
||||||
|
self._storage.delete_file_if_exists(final_model_path)
|
||||||
|
if isinstance(
|
||||||
|
exc,
|
||||||
|
(
|
||||||
|
InvalidModelVersionError,
|
||||||
|
DuplicateModelVersionError,
|
||||||
|
ModelNotFoundError,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
raise
|
||||||
|
raise ModelConversionError(f"Model conversion failed: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
upload_file.file.close()
|
||||||
|
self._storage.delete_file_if_exists(tmp_path)
|
||||||
|
self._storage.delete_file_if_exists(conversion_input_path)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
from onnxruntime.quantization import (
|
||||||
|
CalibrationDataReader,
|
||||||
|
QuantFormat,
|
||||||
|
QuantType,
|
||||||
|
quantize_static,
|
||||||
|
)
|
||||||
|
from onnxruntime.tools.convert_onnx_models_to_ort import (
|
||||||
|
OptimizationStyle,
|
||||||
|
convert_onnx_models_to_ort,
|
||||||
|
)
|
||||||
|
from PIL import Image
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
from app.domain.ModelConverter import ModelConverter
|
||||||
|
from app.infrastructure.constants import BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
IMAGES_DIR = BASE_DIR / "storage" / "images"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelConverterImpl(ModelConverter):
|
||||||
|
def convert(self, model_pt_path: Path, results_dir: Path) -> Path:
|
||||||
|
model_pt_path = Path(model_pt_path)
|
||||||
|
results_dir = Path(results_dir)
|
||||||
|
results_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
result_path = results_dir / f"{model_pt_path.stem}.ort"
|
||||||
|
if result_path.exists():
|
||||||
|
result_path.unlink()
|
||||||
|
|
||||||
|
with TemporaryDirectory(dir=results_dir) as temp_dir_name:
|
||||||
|
temp_dir = Path(temp_dir_name)
|
||||||
|
fp32_onnx_path = temp_dir / f"{model_pt_path.stem}_fp32.onnx"
|
||||||
|
int8_onnx_path = temp_dir / f"{model_pt_path.stem}_int8_static.onnx"
|
||||||
|
|
||||||
|
model = YOLO(str(model_pt_path), task="detect")
|
||||||
|
exported_path = Path(
|
||||||
|
model.export(
|
||||||
|
format="onnx",
|
||||||
|
imgsz=640,
|
||||||
|
opset=20,
|
||||||
|
dynamic=False,
|
||||||
|
simplify=False,
|
||||||
|
half=False,
|
||||||
|
)
|
||||||
|
).resolve()
|
||||||
|
shutil.move(str(exported_path), str(fp32_onnx_path))
|
||||||
|
|
||||||
|
calibration_reader = ImageCalibrationDataReader(fp32_onnx_path, IMAGES_DIR)
|
||||||
|
quantize_static(
|
||||||
|
model_input=str(fp32_onnx_path),
|
||||||
|
model_output=str(int8_onnx_path),
|
||||||
|
calibration_data_reader=calibration_reader,
|
||||||
|
quant_format=QuantFormat.QDQ,
|
||||||
|
activation_type=QuantType.QUInt8,
|
||||||
|
weight_type=QuantType.QInt8,
|
||||||
|
op_types_to_quantize=["Conv"],
|
||||||
|
)
|
||||||
|
|
||||||
|
convert_onnx_models_to_ort(
|
||||||
|
int8_onnx_path,
|
||||||
|
output_dir=temp_dir,
|
||||||
|
optimization_styles=[OptimizationStyle.Fixed],
|
||||||
|
)
|
||||||
|
generated_ort_path = temp_dir / f"{int8_onnx_path.stem}.ort"
|
||||||
|
generated_ort_path.replace(result_path)
|
||||||
|
|
||||||
|
return result_path
|
||||||
|
|
||||||
|
|
||||||
|
class ImageCalibrationDataReader(CalibrationDataReader):
|
||||||
|
def __init__(self, model_path: Path, images_dir: Path, limit: int = 32):
|
||||||
|
self.session = ort.InferenceSession(
|
||||||
|
str(model_path), providers=["CPUExecutionProvider"]
|
||||||
|
)
|
||||||
|
self.input_name = self.session.get_inputs()[0].name
|
||||||
|
self.input_shape = self.session.get_inputs()[0].shape
|
||||||
|
self.image_paths = [
|
||||||
|
path
|
||||||
|
for path in sorted(images_dir.glob("*"))
|
||||||
|
if path.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"}
|
||||||
|
][:limit]
|
||||||
|
self.index = 0
|
||||||
|
|
||||||
|
if not self.image_paths:
|
||||||
|
raise FileNotFoundError(f"calibration images not found in {images_dir}")
|
||||||
|
|
||||||
|
def get_next(self) -> dict[str, np.ndarray] | None:
|
||||||
|
if self.index >= len(self.image_paths):
|
||||||
|
return None
|
||||||
|
|
||||||
|
image_path = self.image_paths[self.index]
|
||||||
|
self.index += 1
|
||||||
|
image = Image.open(image_path).convert("RGB")
|
||||||
|
|
||||||
|
height = int(self.input_shape[2]) if isinstance(self.input_shape[2], int) else 640
|
||||||
|
width = int(self.input_shape[3]) if isinstance(self.input_shape[3], int) else 640
|
||||||
|
scale = min(width / image.width, height / image.height)
|
||||||
|
resized_width = int(round(image.width * scale))
|
||||||
|
resized_height = int(round(image.height * scale))
|
||||||
|
resized_image = image.resize((resized_width, resized_height), Image.BILINEAR)
|
||||||
|
|
||||||
|
padded_image = Image.new("RGB", (width, height), (114, 114, 114))
|
||||||
|
left = (width - resized_width) // 2
|
||||||
|
top = (height - resized_height) // 2
|
||||||
|
padded_image.paste(resized_image, (left, top))
|
||||||
|
|
||||||
|
image_array = np.asarray(padded_image, dtype=np.float32) / 255.0
|
||||||
|
image_array = np.transpose(image_array, (2, 0, 1))
|
||||||
|
return {self.input_name: np.expand_dims(image_array, axis=0)}
|
||||||
|
|
||||||
|
def rewind(self) -> None:
|
||||||
|
self.index = 0
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.domain.Exception import DuplicateModelVersionError
|
||||||
|
from app.domain.Model import Model
|
||||||
|
from app.domain.ModelRepository import ModelRepository
|
||||||
|
from app.infrastructure.database import ModelRecord
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRepositoryImpl(ModelRepository):
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def get_latest(self) -> Model | None:
|
||||||
|
stmt = (
|
||||||
|
select(ModelRecord)
|
||||||
|
.order_by(ModelRecord.is_latest.desc(), ModelRecord.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
record = self._session.scalar(stmt)
|
||||||
|
return record.to_domain() if record else None
|
||||||
|
|
||||||
|
def get_by_version(self, version: str) -> Model | None:
|
||||||
|
stmt = select(ModelRecord).where(ModelRecord.version == version)
|
||||||
|
record = self._session.scalar(stmt)
|
||||||
|
return record.to_domain() if record else None
|
||||||
|
|
||||||
|
def add(self, model: Model) -> Model:
|
||||||
|
record = ModelRecord(
|
||||||
|
version=model.version,
|
||||||
|
filename=model.filename,
|
||||||
|
file_path=model.file_path,
|
||||||
|
created_at=model.created_at,
|
||||||
|
is_latest=model.is_latest,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._session.execute(update(ModelRecord).values(is_latest=False))
|
||||||
|
self._session.add(record)
|
||||||
|
self._session.commit()
|
||||||
|
self._session.refresh(record)
|
||||||
|
return record.to_domain()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
self._session.rollback()
|
||||||
|
raise DuplicateModelVersionError(
|
||||||
|
f"Model version '{model.version}' already exists."
|
||||||
|
) from exc
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.domain.Exception import InvalidModelFileError, ModelFileMissingError
|
||||||
|
from app.domain.ModelStorage import ModelStorage
|
||||||
|
from app.infrastructure.constants import TMP_DIR, MODEL_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class ModelStorageImpl(ModelStorage):
|
||||||
|
@property
|
||||||
|
def models_dir(self) -> Path:
|
||||||
|
return MODEL_DIR
|
||||||
|
|
||||||
|
def ensure_directories(self) -> None:
|
||||||
|
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def validate_upload_filename(self, filename: str | None) -> str:
|
||||||
|
if not filename:
|
||||||
|
raise InvalidModelFileError("Uploaded file must have a filename.")
|
||||||
|
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
if Path(safe_name).suffix.lower() != ".pt":
|
||||||
|
raise InvalidModelFileError("Only .pt model files are supported.")
|
||||||
|
return safe_name
|
||||||
|
|
||||||
|
def save_upload_to_tmp(self, source_file, filename: str) -> Path:
|
||||||
|
tmp_path = TMP_DIR / f"{uuid4().hex}_{filename}"
|
||||||
|
with tmp_path.open("wb") as buffer:
|
||||||
|
shutil.copyfileobj(source_file, buffer)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
def build_model_filename(self, version: str) -> str:
|
||||||
|
return f"model_v{version}.ort"
|
||||||
|
|
||||||
|
def prepare_conversion_input(self, tmp_path: Path, model_filename: str) -> Path:
|
||||||
|
conversion_input_path = TMP_DIR / f"{uuid4().hex}_{Path(model_filename).stem}.pt"
|
||||||
|
tmp_path.rename(conversion_input_path)
|
||||||
|
return conversion_input_path
|
||||||
|
|
||||||
|
def finalize_converted_model(self, converted_path: Path, model_filename: str) -> Path:
|
||||||
|
target_path = MODEL_DIR / model_filename
|
||||||
|
if converted_path.resolve() != target_path.resolve():
|
||||||
|
converted_path.replace(target_path)
|
||||||
|
return target_path
|
||||||
|
|
||||||
|
def ensure_model_file_exists(self, file_path: str) -> Path:
|
||||||
|
resolved_path = Path(file_path)
|
||||||
|
if not resolved_path.exists():
|
||||||
|
raise ModelFileMissingError("Latest model file is missing on disk.")
|
||||||
|
return resolved_path
|
||||||
|
|
||||||
|
def delete_file_if_exists(self, file_path: Path | None) -> None:
|
||||||
|
if file_path and file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from app.infrastructure.constants import DATABASE_URL
|
||||||
|
from app.infrastructure.ModelConverterImpl import ModelConverterImpl
|
||||||
|
from app.infrastructure.database import Base, create_engine_and_session
|
||||||
|
from app.infrastructure.ModelStorageImpl import ModelStorageImpl
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Container:
|
||||||
|
storage: ModelStorageImpl
|
||||||
|
converter: ModelConverterImpl
|
||||||
|
engine: object
|
||||||
|
session_factory: object
|
||||||
|
|
||||||
|
|
||||||
|
_container: Container | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container() -> Container:
|
||||||
|
global _container
|
||||||
|
if _container is None:
|
||||||
|
storage = ModelStorageImpl()
|
||||||
|
engine, session_factory = create_engine_and_session(DATABASE_URL)
|
||||||
|
_container = Container(
|
||||||
|
storage=storage,
|
||||||
|
converter=ModelConverterImpl(),
|
||||||
|
engine=engine,
|
||||||
|
session_factory=session_factory,
|
||||||
|
)
|
||||||
|
return _container
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_application() -> None:
|
||||||
|
container = get_container()
|
||||||
|
container.storage.ensure_directories()
|
||||||
|
engine_path = container.engine.url.database
|
||||||
|
if engine_path:
|
||||||
|
Path(engine_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Base.metadata.create_all(bind=container.engine)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
MODEL_DIR = Path(os.getenv("MODELS_DIR", str(BASE_DIR / "storage" / "models")))
|
||||||
|
TMP_DIR = Path(os.getenv("TMP_DIR", str(BASE_DIR / "tmp")))
|
||||||
|
DATABASE_PATH = BASE_DIR / "db" / "app.db"
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
|
||||||
|
API_KEY_HEADER_NAME = "X-API-Key"
|
||||||
|
API_KEYS = tuple(
|
||||||
|
key.strip() for key in os.getenv("API_KEYS", "").split(",") if key.strip()
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
|
||||||
|
from app.domain.Model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRecord(Base):
|
||||||
|
__tablename__ = "models"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
version: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
file_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
is_latest: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
|
||||||
|
def to_domain(self) -> Model:
|
||||||
|
return Model(
|
||||||
|
id=self.id,
|
||||||
|
version=self.version,
|
||||||
|
filename=self.filename,
|
||||||
|
file_path=self.file_path,
|
||||||
|
created_at=self.created_at,
|
||||||
|
is_latest=self.is_latest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_engine_and_session(database_url: str):
|
||||||
|
engine = create_engine(
|
||||||
|
database_url,
|
||||||
|
connect_args={"check_same_thread": False} if database_url.startswith("sqlite") else {},
|
||||||
|
)
|
||||||
|
session_factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||||
|
return engine, session_factory
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.infrastructure.bootstrap import initialize_application
|
||||||
|
from app.presentation.routes import router as model_router
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(model_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup() -> None:
|
||||||
|
initialize_application()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
from hmac import compare_digest
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Security, status
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.domain.usecase.GetLatestModelUseCase import GetLatestModelUseCase
|
||||||
|
from app.domain.usecase.UploadModelUseCase import UploadModelUseCase
|
||||||
|
from app.infrastructure.bootstrap import get_container
|
||||||
|
from app.infrastructure.constants import API_KEY_HEADER_NAME, API_KEYS
|
||||||
|
from app.infrastructure.ModelRepositoryImpl import ModelRepositoryImpl
|
||||||
|
|
||||||
|
api_key_header = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key(api_key: str | None = Security(api_key_header)) -> None:
|
||||||
|
if api_key and any(compare_digest(api_key, valid_key) for valid_key in API_KEYS):
|
||||||
|
return
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or missing API key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session() -> Generator[Session, None, None]:
|
||||||
|
container = get_container()
|
||||||
|
session = container.session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_model_use_case(
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> UploadModelUseCase:
|
||||||
|
container = get_container()
|
||||||
|
repository = ModelRepositoryImpl(session)
|
||||||
|
return UploadModelUseCase(
|
||||||
|
repository=repository,
|
||||||
|
storage=container.storage,
|
||||||
|
converter=container.converter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_model_use_case(
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> GetLatestModelUseCase:
|
||||||
|
container = get_container()
|
||||||
|
repository = ModelRepositoryImpl(session)
|
||||||
|
return GetLatestModelUseCase(repository=repository, storage=container.storage)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_serializer
|
||||||
|
|
||||||
|
from app.domain.Model import Model
|
||||||
|
|
||||||
|
|
||||||
|
class ModelResponse(BaseModel):
|
||||||
|
version: str
|
||||||
|
created_at: datetime
|
||||||
|
filename: str
|
||||||
|
is_latest: bool
|
||||||
|
|
||||||
|
@field_serializer("created_at")
|
||||||
|
def serialize_created_at(self, value: datetime) -> str:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_model(model: Model) -> ModelResponse:
|
||||||
|
return ModelResponse(
|
||||||
|
version=model.version,
|
||||||
|
created_at=model.created_at,
|
||||||
|
filename=model.filename,
|
||||||
|
is_latest=model.is_latest,
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from app.domain.Exception import *
|
||||||
|
from app.domain.usecase.GetLatestModelUseCase import GetLatestModelUseCase
|
||||||
|
from app.domain.usecase.UploadModelUseCase import UploadModelUseCase
|
||||||
|
from app.presentation.dependencies import (
|
||||||
|
get_latest_model_use_case,
|
||||||
|
get_upload_model_use_case,
|
||||||
|
verify_api_key,
|
||||||
|
)
|
||||||
|
from app.presentation.response.ModelResponse import ModelResponse
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models", response_model=ModelResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def upload_model(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
version: str = Form(...),
|
||||||
|
use_case: UploadModelUseCase = Depends(get_upload_model_use_case),
|
||||||
|
) -> ModelResponse:
|
||||||
|
try:
|
||||||
|
result = use_case.execute(version=version, upload_file=file)
|
||||||
|
except InvalidModelVersionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except InvalidModelFileError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except DuplicateModelVersionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
except ModelConversionError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return ModelResponse.from_model(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models/latest", response_model=ModelResponse)
|
||||||
|
def get_latest_model(
|
||||||
|
use_case: GetLatestModelUseCase = Depends(get_latest_model_use_case),
|
||||||
|
) -> ModelResponse:
|
||||||
|
try:
|
||||||
|
result = use_case.execute()
|
||||||
|
except ModelNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return ModelResponse.from_model(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models/latest/download")
|
||||||
|
def download_latest_model(
|
||||||
|
version: str = Query(...),
|
||||||
|
filename: str = Query(...),
|
||||||
|
use_case: GetLatestModelUseCase = Depends(get_latest_model_use_case),
|
||||||
|
) -> FileResponse:
|
||||||
|
try:
|
||||||
|
result = use_case.execute(version=version, filename=filename)
|
||||||
|
except ModelNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except ModelFileMissingError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=result.file_path,
|
||||||
|
filename=result.filename,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
API_KEYS=replace-with-generated-key-1,replace-with-generated-key-2
|
||||||
|
MODELS_DIR=/opt/model-update-server/storage/models
|
||||||
|
TMP_DIR=/opt/model-update-server/tmp
|
||||||
|
DATABASE_URL=sqlite:////opt/model-update-server/db/app.db
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Model Update Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/model-update-server
|
||||||
|
EnvironmentFile=/etc/model-update-server/model-update-server.env
|
||||||
|
ExecStart=/opt/model-update-server/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.2
|
||||||
|
sqlalchemy==2.0.40
|
||||||
|
python-multipart==0.0.20
|
||||||
|
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||||
|
torch==2.5.1+cpu
|
||||||
|
torchvision==0.20.1+cpu
|
||||||
|
onnxruntime
|
||||||
|
onnx
|
||||||
|
numpy
|
||||||
|
pillow
|
||||||
|
ultralytics
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEY_COUNT="${KEY_COUNT:-10}"
|
||||||
|
KEY_BYTES="${KEY_BYTES:-32}"
|
||||||
|
ENV_FILE=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
source scripts/generate_api_keys.sh
|
||||||
|
scripts/generate_api_keys.sh --env-file .env
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
KEY_COUNT Number of keys to generate. Default: 10
|
||||||
|
KEY_BYTES Random bytes per key. Default: 32
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--env-file)
|
||||||
|
ENV_FILE="${2:-}"
|
||||||
|
if [[ -z "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing value for --env-file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
return 0 2>/dev/null || exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
API_KEYS="$(
|
||||||
|
python3 - "$KEY_COUNT" "$KEY_BYTES" <<'PY'
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
|
||||||
|
count = int(sys.argv[1])
|
||||||
|
bytes_count = int(sys.argv[2])
|
||||||
|
|
||||||
|
keys = [secrets.token_urlsafe(bytes_count) for _ in range(count)]
|
||||||
|
print(",".join(keys))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
export API_KEYS
|
||||||
|
|
||||||
|
echo "Generated ${KEY_COUNT} API keys."
|
||||||
|
|
||||||
|
if [[ -n "$ENV_FILE" ]]; then
|
||||||
|
umask 077
|
||||||
|
printf 'API_KEYS=%s\n' "$API_KEYS" > "$ENV_FILE"
|
||||||
|
echo "Saved API_KEYS to ${ENV_FILE}"
|
||||||
|
else
|
||||||
|
echo "API_KEYS=${API_KEYS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$ENV_FILE" ]] && ! (return 0 2>/dev/null); then
|
||||||
|
echo
|
||||||
|
echo "This script was executed in a child shell."
|
||||||
|
echo "Run it with source to keep API_KEYS in your current shell:"
|
||||||
|
echo "source scripts/generate_api_keys.sh"
|
||||||
|
fi
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user