Initial commit

This commit is contained in:
tanexc
2026-06-12 23:53:39 +03:00
commit 67fd2bc5ac
32 changed files with 1107 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+26
View File
@@ -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
+12
View File
@@ -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
+8
View File
@@ -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
+17
View File
@@ -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
+41
View File
@@ -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
+1
View File
@@ -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
+74
View File
@@ -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)
View File
+119
View File
@@ -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
+47
View File
@@ -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
+56
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
+40
View File
@@ -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)
+13
View File
@@ -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()
)
+42
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
+53
View File
@@ -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,
)
+66
View File
@@ -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",
)