diff --git a/LICENSE b/LICENSE index abbc977..e56a356 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/copier.yaml b/copier.yaml index c307492..242e005 100644 --- a/copier.yaml +++ b/copier.yaml @@ -121,4 +121,9 @@ _tasks: when: "{{ project_type in ['api-monolith', 'api-microservice'] }}" - command: uvx python -c "import os; os.makedirs('migrations/versions', exist_ok=True)" when: "{{ project_type in ['api-monolith', 'api-microservice', 'agent'] }}" - - "uv sync" + - "uv sync --group code-quality" + - "git init && git checkout -b master && git add --all" + - command: uvx python -c "import subprocess; result = subprocess.run(['uv', 'run', 'pre-commit', 'run', '--all-files']); exit(0)" + - "git add --all" + - command: uvx python -c "import subprocess; subprocess.run(['git', 'commit', '-m', 'Initial commit'])" + diff --git a/python-ai-kit/.dockerignore b/python-ai-kit/.dockerignore index a0cf254..d4aabd5 100644 --- a/python-ai-kit/.dockerignore +++ b/python-ai-kit/.dockerignore @@ -25,4 +25,4 @@ Makefile .* *.xml -*.db \ No newline at end of file +*.db diff --git a/python-ai-kit/.gitattributes b/python-ai-kit/.gitattributes index 526c8a3..dfdb8b7 100644 --- a/python-ai-kit/.gitattributes +++ b/python-ai-kit/.gitattributes @@ -1 +1 @@ -*.sh text eol=lf \ No newline at end of file +*.sh text eol=lf diff --git a/python-ai-kit/.github/workflows/ci.yml.jinja b/python-ai-kit/.github/workflows/ci.yml.jinja index c7de911..87c7640 100644 --- a/python-ai-kit/.github/workflows/ci.yml.jinja +++ b/python-ai-kit/.github/workflows/ci.yml.jinja @@ -33,4 +33,4 @@ jobs: - name: Run formatter run: uv run ruff format --check - name: Run type checker - run: uv run ty check \ No newline at end of file + run: uv run ty check diff --git a/python-ai-kit/.gitignore b/python-ai-kit/.gitignore index 260cd7d..9b5b7d3 100644 --- a/python-ai-kit/.gitignore +++ b/python-ai-kit/.gitignore @@ -100,6 +100,9 @@ __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid +celerybeat-schedule +celerybeat-schedule-wal +celerybeat-schedule-shm *-shm *-wal @@ -156,4 +159,4 @@ volumes *.pem # Streamlit secrets -.streamlit/ \ No newline at end of file +.streamlit/ diff --git a/python-ai-kit/.pre-commit-config.yaml b/python-ai-kit/.pre-commit-config.yaml index fbf4f5c..4801231 100644 --- a/python-ai-kit/.pre-commit-config.yaml +++ b/python-ai-kit/.pre-commit-config.yaml @@ -12,8 +12,9 @@ repos: hooks: - id: ty name: ty check - entry: .venv/bin/ty check . + entry: uv run ty check . language: system + pass_filenames: false - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 @@ -21,4 +22,4 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-merge-conflict - args: [--assume-in-merge] \ No newline at end of file + args: [--assume-in-merge] diff --git a/python-ai-kit/.python-version.jinja b/python-ai-kit/.python-version.jinja index 1b22944..40f1a2a 100644 --- a/python-ai-kit/.python-version.jinja +++ b/python-ai-kit/.python-version.jinja @@ -1 +1 @@ -{{default_python_version}} \ No newline at end of file +{{default_python_version}} diff --git a/python-ai-kit/LICENSE b/python-ai-kit/LICENSE index 8241d60..abbc977 100644 --- a/python-ai-kit/LICENSE +++ b/python-ai-kit/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/python-ai-kit/app/config.py.jinja b/python-ai-kit/app/config.py.jinja index d33316b..5607c3c 100644 --- a/python-ai-kit/app/config.py.jinja +++ b/python-ai-kit/app/config.py.jinja @@ -22,18 +22,18 @@ class Settings(BaseSettings): ) # CORE SETTINGS - fernet_decryptor: FernetDecryptorField = Field("MASTER_KEY") + fernet_decryptor: FernetDecryptorField = Field(FernetDecryptorField("MASTER_KEY")) environment: EnvironmentType = EnvironmentType.LOCAL # API SETTINGS - api_name: str = f"{{project_name}} API" + api_name: str = "{{project_name}} API" api_v1: str = "/api/v1" api_latest: str = api_v1 paging_limit: int = 100 cors_origins: list[AnyHttpUrl] = [] cors_allow_all: bool = False - {% if project_type in ["api-monolith", "api-microservice"] %} + # DATABASE SETTINGS db_host: str = "localhost" db_port: int = 5432 @@ -41,8 +41,8 @@ class Settings(BaseSettings): db_user: str = "{{project_name}}" db_password: SecretStr = SecretStr("{{project_name}}") {% endif %} - {% if project_type == "agent" %} + # AGENT SETTINGS debug_mode: bool = False api_key: str @@ -66,14 +66,14 @@ class Settings(BaseSettings): return [url.strip() for url in v.split(',') if url.strip()] return v {% endif %} - {% if "celery" in plugins %} + # CELERY SETTINGS CELERY_BROKER_URL: str CELERY_RESULT_BACKEND: str {% endif %} - {% if "sentry" in plugins %} + # Sentry SENTRY_ENABLED: bool = False SENTRY_DSN: str | None = None @@ -99,12 +99,12 @@ class Settings(BaseSettings): return v.get_decrypted_value(validation_info.data["fernet_decryptor"]) return v - {%if project_type == "mcp-server" %} + # MCP SETTINGS mcp_server_name: str = f"MCP Server" - {% endif %} + {% endif %} {% if project_type in ["api-monolith", "api-microservice"] %} @property def db_uri(self) -> str: @@ -113,8 +113,8 @@ class Settings(BaseSettings): f"{self.db_user}:{self.db_password.get_secret_value()}" f"@{self.db_host}:{self.db_port}/{self.db_name}" ) - {% endif %} + {% endif %} # 0. pytest ini_options # 1. environment variables # 2. .env diff --git a/python-ai-kit/app/database.py b/python-ai-kit/app/database.py index 6730a46..6d64467 100644 --- a/python-ai-kit/app/database.py +++ b/python-ai-kit/app/database.py @@ -3,7 +3,8 @@ from uuid import UUID from fastapi import Depends -from sqlalchemy import Engine, UUID as SqlUUID, Text, create_engine, inspect +from sqlalchemy import UUID as SQL_UUID +from sqlalchemy import Engine, Text, create_engine, inspect from sqlalchemy.orm import ( DeclarativeBase, Session, @@ -28,7 +29,7 @@ def _prepare_sessionmaker(engine: Engine) -> sessionmaker: return sessionmaker(autocommit=False, autoflush=False, bind=engine) -class BaseDbModel(DeclarativeBase, metaclass=AutoRelMeta): +class BaseDbModel(DeclarativeBase, metaclass=AutoRelMeta): @declared_attr def __tablename__(self) -> str: return self.__name__.lower() @@ -39,15 +40,12 @@ def id_str(self) -> str: def __repr__(self) -> str: mapper = inspect(self.__class__) - fields = [ - f"{col.key}={repr(getattr(self, col.key, None))}" - for col in mapper.columns - ] + fields = [f"{col.key}={repr(getattr(self, col.key, None))}" for col in mapper.columns] return f"<{self.__class__.__name__}({', '.join(fields)})>" type_annotation_map = { str: Text, - UUID: SqlUUID, + UUID: SQL_UUID, } diff --git a/python-ai-kit/app/integrations/celery/__init__.py b/python-ai-kit/app/integrations/celery/__init__.py index dcc9e78..38803d3 100644 --- a/python-ai-kit/app/integrations/celery/__init__.py +++ b/python-ai-kit/app/integrations/celery/__init__.py @@ -1,3 +1,3 @@ from .core import create_celery -__all__ = ["create_celery"] \ No newline at end of file +__all__ = ["create_celery"] diff --git a/python-ai-kit/app/integrations/celery/core.py b/python-ai-kit/app/integrations/celery/core.py index dce96f4..035a74b 100644 --- a/python-ai-kit/app/integrations/celery/core.py +++ b/python-ai-kit/app/integrations/celery/core.py @@ -1,9 +1,8 @@ +from app.config import settings from celery import Celery from celery import current_app as current_celery_app from celery.schedules import crontab -from app.config import settings - def create_celery() -> Celery: celery_app: Celery = current_celery_app # type: ignore[assignment] @@ -20,11 +19,11 @@ def create_celery() -> Celery: result_expires=3 * 24 * 3600, ) - celery_app.autodiscover_tasks(["app.integrations.celery.tasks.dummy_task"]) + celery_app.autodiscover_tasks(["app.integrations.celery"]) celery_app.conf.beat_schedule = { "dummy-task": { - "task": "app.integrations.celery.tasks.dummy_task", + "task": "app.integrations.celery.tasks.dummy_task.dummy_task", "schedule": crontab(minute="*/1"), }, } diff --git a/python-ai-kit/app/integrations/celery/tasks/__init__.py b/python-ai-kit/app/integrations/celery/tasks/__init__.py index 5bc89d0..400460a 100644 --- a/python-ai-kit/app/integrations/celery/tasks/__init__.py +++ b/python-ai-kit/app/integrations/celery/tasks/__init__.py @@ -1,3 +1,3 @@ from .dummy_task import dummy_task -__all__ = ["dummy_task"] \ No newline at end of file +__all__ = ["dummy_task"] diff --git a/python-ai-kit/app/integrations/sentry.py b/python-ai-kit/app/integrations/sentry.py index 89135f1..d2d8048 100644 --- a/python-ai-kit/app/integrations/sentry.py +++ b/python-ai-kit/app/integrations/sentry.py @@ -3,7 +3,7 @@ from app.config import settings -def init_sentry(): +def init_sentry() -> None: if settings.SENTRY_ENABLED: sentry_sdk.init( dsn=settings.SENTRY_DSN, diff --git a/python-ai-kit/app/integrations/sqladmin/__init__.py b/python-ai-kit/app/integrations/sqladmin/__init__.py index ac67ca7..0fdc133 100644 --- a/python-ai-kit/app/integrations/sqladmin/__init__.py +++ b/python-ai-kit/app/integrations/sqladmin/__init__.py @@ -1,3 +1,3 @@ from .views import add_admin_views -__all__ = ["add_admin_views"] \ No newline at end of file +__all__ = ["add_admin_views"] diff --git a/python-ai-kit/app/integrations/sqladmin/base_view.py b/python-ai-kit/app/integrations/sqladmin/base_view.py index bb34b72..b8141be 100644 --- a/python-ai-kit/app/integrations/sqladmin/base_view.py +++ b/python-ai-kit/app/integrations/sqladmin/base_view.py @@ -1,54 +1,66 @@ from typing import Any + from fastapi import Request -from sqladmin import ModelView from pydantic import BaseModel -from sqladmin.models import ModelViewMeta from app.database import BaseDbModel -from app.integrations.sqladmin.view_models import ColumnConfig, FormConfig, _get_model_fields +from app.integrations.sqladmin.view_models import ( + ColumnConfig, + FormConfig, + _get_model_fields, +) +from sqladmin import ModelView +from sqladmin.models import ModelViewMeta + class BaseAdminMeta(ModelViewMeta): - def __new__(mcls, name: str, bases: tuple, attrs: dict, **kwargs: Any) -> ModelViewMeta: + def __new__( + mcls, + name: str, + bases: tuple, + attrs: dict, + **kwargs: Any, + ) -> ModelViewMeta: cls = super().__new__(mcls, name, bases, attrs, **kwargs) - if 'create_schema' in kwargs: - cls._create_schema = kwargs['create_schema'] - - if 'update_schema' in kwargs: - cls._update_schema = kwargs['update_schema'] + if "create_schema" in kwargs: + cls._create_schema = kwargs["create_schema"] + + if "update_schema" in kwargs: + cls._update_schema = kwargs["update_schema"] - if 'column' in kwargs: - ColumnConfig(**kwargs['column']).apply_to_class(cls) + if "column" in kwargs: + ColumnConfig(**kwargs["column"]).apply_to_class(cls) else: - if hasattr(cls, 'model'): + if hasattr(cls, "model"): cls.column_searchable_list = _get_model_fields(cls) cls.column_sortable_list = _get_model_fields(cls) if auto_exclude_fields := mcls._get_fields_with_default_factory(cls): cls.form_excluded_columns = auto_exclude_fields - if 'form' in kwargs: - FormConfig(**kwargs['form']).apply_to_class(cls) + if "form" in kwargs: + FormConfig(**kwargs["form"]).apply_to_class(cls) return cls - + @staticmethod - def _get_fields_with_default_factory(cls) -> list[str]: + def _get_fields_with_default_factory(cls: Any) -> list[str]: exclude_fields = [] - - for schema_attr in ['_create_schema', '_update_schema']: + + for schema_attr in ["_create_schema", "_update_schema"]: if schema := getattr(cls, schema_attr, None): for field_name, field_info in schema.model_fields.items(): - if getattr(field_info, 'default_factory', None): + if getattr(field_info, "default_factory", None): exclude_fields.append(field_name) - + return list(set(exclude_fields)) class BaseAdminView(ModelView, metaclass=BaseAdminMeta): _create_schema: type[BaseModel] _update_schema: type[BaseModel] - + column_list: str | list[str] = "__all__" # by default metaclass excludes fields from schemas with default_factory @@ -57,16 +69,16 @@ class BaseAdminView(ModelView, metaclass=BaseAdminMeta): # add form_include_pk=True to your target class to override this behavior async def on_model_change( - self, - data: dict[str, Any], - model: BaseDbModel, - is_created: bool, - request: Request + self, + data: dict[str, Any], + model: BaseDbModel, + is_created: bool, + request: Request, ) -> None: schema = self._create_schema if is_created else self._update_schema validated_data = schema.model_validate(data) - + update_dict = validated_data.model_dump(exclude_none=True) - + for field_name, field_value in update_dict.items(): - setattr(model, field_name, field_value) \ No newline at end of file + setattr(model, field_name, field_value) diff --git a/python-ai-kit/app/integrations/sqladmin/view_models.py b/python-ai-kit/app/integrations/sqladmin/view_models.py index 38d1bb5..b07bd9a 100644 --- a/python-ai-kit/app/integrations/sqladmin/view_models.py +++ b/python-ai-kit/app/integrations/sqladmin/view_models.py @@ -1,50 +1,54 @@ from typing import Any, Literal -from sqlalchemy import inspect from pydantic import BaseModel +from sqlalchemy import inspect + -def _get_model_fields(cls) -> list[str]: +def _get_model_fields(cls: Any) -> list[str]: inspector = inspect(cls.model) return [attr.key for attr in inspector.attrs] + class BaseConfig(BaseModel): include: Literal["*"] | list[str] | None = None exclude: list[str] | None = None - + def model_post_init(self, __context: Any) -> None: if self.include is not None and self.exclude is not None: raise ValueError("Cannot use both 'include' and 'exclude' in configuration") - - def _all_or_value(self, val) -> str | list[str] | None: + + def _all_or_value(self, val: str | list[str] | None) -> str | list[str] | None: """Convert '*' or ['*'] to '__all__', otherwise return value.""" return "__all__" if val == "*" or val == ["*"] else val + class ColumnConfig(BaseConfig): searchable: list[str] | None = None sortable: list[str] | None = None - + def apply_to_class(self, cls: type) -> None: configs = { - 'column_list': self._all_or_value(self.include) or [], - 'column_exclude_list': self.exclude if self.exclude else [], - 'column_searchable_list': self.searchable if self.searchable else _get_model_fields(cls), - 'column_sortable_list': self.sortable if self.sortable else _get_model_fields(cls), + "column_list": self._all_or_value(self.include) or [], + "column_exclude_list": self.exclude if self.exclude else [], + "column_searchable_list": (self.searchable if self.searchable else _get_model_fields(cls)), + "column_sortable_list": (self.sortable if self.sortable else _get_model_fields(cls)), } - + for attr, value in configs.items(): value and setattr(cls, attr, value) + class FormConfig(BaseConfig): create_rules: list[str] | None = None edit_rules: list[str] | None = None - + def apply_to_class(self, cls: type) -> None: configs = { - 'form_columns': self._all_or_value(self.include) or [], - 'form_excluded_columns': self.exclude if self.exclude else [], - 'form_create_rules': self.create_rules if self.create_rules else [], - 'form_edit_rules': self.edit_rules if self.edit_rules else [], + "form_columns": self._all_or_value(self.include) or [], + "form_excluded_columns": self.exclude if self.exclude else [], + "form_create_rules": self.create_rules if self.create_rules else [], + "form_edit_rules": self.edit_rules if self.edit_rules else [], } - + for attr, value in configs.items(): value and setattr(cls, attr, value) diff --git a/python-ai-kit/app/integrations/sqladmin/views/__init__.py b/python-ai-kit/app/integrations/sqladmin/views/__init__.py index 76ccb11..8437820 100644 --- a/python-ai-kit/app/integrations/sqladmin/views/__init__.py +++ b/python-ai-kit/app/integrations/sqladmin/views/__init__.py @@ -8,4 +8,4 @@ def add_admin_views(admin: Admin) -> None: UserAdminView, ] for view in views: - admin.add_view(view) \ No newline at end of file + admin.add_view(view) diff --git a/python-ai-kit/app/integrations/sqladmin/views/user.py.jinja b/python-ai-kit/app/integrations/sqladmin/views/user.py.jinja index 129c595..dce757d 100644 --- a/python-ai-kit/app/integrations/sqladmin/views/user.py.jinja +++ b/python-ai-kit/app/integrations/sqladmin/views/user.py.jinja @@ -14,7 +14,7 @@ class UserAdminView( create_schema=UserCreate, update_schema=UserUpdate, column={ - "searchable": ["username", "email"] - } + "searchable": ["username", "email"], + }, ): pass diff --git a/python-ai-kit/app/main.py.jinja b/python-ai-kit/app/main.py.jinja index a85e9f6..bab31e6 100644 --- a/python-ai-kit/app/main.py.jinja +++ b/python-ai-kit/app/main.py.jinja @@ -12,18 +12,14 @@ from fastmcp import FastMCP from sqladmin import Admin {% endif %} -from app.config import settings -{% if project_type in ["api-monolith", "api-microservice"] %} -from app.utils.exceptions import handle_exception -{% endif %} {% if project_type in ["api-monolith", "api-microservice", "agent"] %} from app.api import head_router {% elif project_type == "mcp-server" %} from app.mcp import mcp_router {% endif %} +from app.config import settings {% if "sqladmin" in plugins %} from app.database import engine -from app.integrations.sqladmin import add_admin_views {% endif %} {% if "celery" in plugins %} from app.integrations.celery import create_celery @@ -31,7 +27,13 @@ from app.integrations.celery import create_celery {% if "sentry" in plugins %} from app.integrations.sentry import init_sentry {% endif %} +{% if "sqladmin" in plugins %} +from app.integrations.sqladmin import add_admin_views +{% endif %} from app.middlewares import add_cors_middleware +{% if project_type in ["api-monolith", "api-microservice"] %} +from app.utils.exceptions import handle_exception +{% endif %} basicConfig(level=INFO, format="[%(asctime)s - %(name)s] (%(levelname)s) %(message)s") @@ -50,12 +52,14 @@ init_sentry() add_cors_middleware(api) + @api.get("/") async def root() -> dict[str, str]: return {"message": "Server is running!"} {% endif %} {% if project_type in ["api-monolith", "api-microservice"] %} + @api.exception_handler(RequestValidationError) async def request_validation_exception_handler(_: Request, exc: RequestValidationError) -> None: raise handle_exception(exc, err_msg=exc.args[0][0]["msg"]) @@ -63,8 +67,8 @@ async def request_validation_exception_handler(_: Request, exc: RequestValidatio {% if project_type == "mcp-server" %} mcp = FastMCP(name=settings.mcp_server_name) -{% endif %} +{% endif %} {% if project_type in ["api-monolith", "api-microservice", "agent"] %} api.include_router(head_router, prefix=settings.api_latest) diff --git a/python-ai-kit/app/mappings.py b/python-ai-kit/app/mappings.py index 7cd674f..31e8a36 100644 --- a/python-ai-kit/app/mappings.py +++ b/python-ai-kit/app/mappings.py @@ -5,14 +5,17 @@ from pydantic import EmailStr from sqlalchemy import DateTime, ForeignKey, Numeric, String -from sqlalchemy.orm import mapped_column, relationship +from sqlalchemy.orm import mapped_column T = TypeVar("T") # Pre-defined indexes Indexed = Annotated[T, mapped_column(index=True)] PrimaryKey = Annotated[T, mapped_column(primary_key=True)] -PKAutoIncrement = Annotated[T, mapped_column(primary_key=True, autoincrement=True)] # use for composite integer primary keys (single PK int will have it auto enabled) + +# use for composite integer primary keys (single PK int will have it auto enabled) +PKAutoIncrement = Annotated[T, mapped_column(primary_key=True, autoincrement=True)] + Unique = Annotated[T, mapped_column(unique=True)] UniqueIndex = Annotated[T, mapped_column(index=True, unique=True)] @@ -34,4 +37,4 @@ numeric_15_5 = Annotated[Decimal, mapped_column(Numeric(15, 5))] # Custom foreign key -FKUser = Annotated[UUID, mapped_column(ForeignKey("user.id", ondelete="CASCADE"))] \ No newline at end of file +FKUser = Annotated[UUID, mapped_column(ForeignKey("user.id", ondelete="CASCADE"))] diff --git a/python-ai-kit/app/middlewares.py b/python-ai-kit/app/middlewares.py index 8b516c2..5365aaf 100644 --- a/python-ai-kit/app/middlewares.py +++ b/python-ai-kit/app/middlewares.py @@ -10,7 +10,7 @@ def add_cors_middleware(app: FastAPI) -> None: cors_origins = ["*"] app.add_middleware( - CORSMiddleware, + CORSMiddleware, # type: ignore[invalid-argument-type] allow_origins=cors_origins, allow_credentials=True, allow_methods=["*"], diff --git a/python-ai-kit/app/user/repositories/__init__.py b/python-ai-kit/app/user/repositories/__init__.py index 10f8270..6028868 100644 --- a/python-ai-kit/app/user/repositories/__init__.py +++ b/python-ai-kit/app/user/repositories/__init__.py @@ -1,3 +1,3 @@ from .activity_repository import ActivityRepository -__all__ = ["ActivityRepository"] \ No newline at end of file +__all__ = ["ActivityRepository"] diff --git a/python-ai-kit/app/user/repositories/activity_repository.py b/python-ai-kit/app/user/repositories/activity_repository.py index 71e1a1e..870b546 100644 --- a/python-ai-kit/app/user/repositories/activity_repository.py +++ b/python-ai-kit/app/user/repositories/activity_repository.py @@ -1,9 +1,15 @@ -from uuid import UUID from datetime import datetime, timedelta, timezone +from uuid import UUID -from app.user.models import User from app.database import DbSession +from app.user.models import User + class ActivityRepository: def is_user_active(self, db_session: DbSession, object_id: UUID) -> bool: - return db_session.query(User).filter(User.id == object_id).filter(User.updated_at > datetime.now(timezone.utc) - timedelta(days=30)).all() + return ( + db_session.query(User) + .filter(User.id == object_id) + .filter(User.updated_at > datetime.now(timezone.utc) - timedelta(days=30)) + .all() + ) diff --git a/python-ai-kit/app/user/routes/__init__.py b/python-ai-kit/app/user/routes/__init__.py index bbe4e7e..4f24d48 100644 --- a/python-ai-kit/app/user/routes/__init__.py +++ b/python-ai-kit/app/user/routes/__init__.py @@ -1,3 +1,3 @@ from .v1.user_crud import router as user_crud_router_v1 -__all__ = ["user_crud_router_v1"] \ No newline at end of file +__all__ = ["user_crud_router_v1"] diff --git a/python-ai-kit/app/user/services/__init__.py b/python-ai-kit/app/user/services/__init__.py index 7049a12..72c6ead 100644 --- a/python-ai-kit/app/user/services/__init__.py +++ b/python-ai-kit/app/user/services/__init__.py @@ -1,3 +1,3 @@ -from .services import user_service +from .services import UserService, user_service -__all__ = ["user_service"] \ No newline at end of file +__all__ = ["user_service", "UserService"] diff --git a/python-ai-kit/app/user/services/activity_mixin.py b/python-ai-kit/app/user/services/activity_mixin.py index 43e18f8..8eabb39 100644 --- a/python-ai-kit/app/user/services/activity_mixin.py +++ b/python-ai-kit/app/user/services/activity_mixin.py @@ -3,18 +3,24 @@ from fastapi import Depends +from app.database import DbSession from app.user.repositories import ActivityRepository from app.utils.exceptions import handle_exceptions if TYPE_CHECKING: from app.user.services import UserService + class ActivityMixin: def __init__(self, activity_repository: ActivityRepository = Depends(), **kwargs): self.activity_repository = activity_repository super().__init__(**kwargs) @handle_exceptions - def is_user_active(self: "UserService", object_id: UUID) -> bool: + def is_user_active( + self: "UserService", + db_session: DbSession, + object_id: UUID, + ) -> bool: self.logger.info(f"Checking if user with ID: {object_id} is active.") - return self.activity_repository.is_user_active(object_id) \ No newline at end of file + return self.activity_repository.is_user_active(db_session, object_id) diff --git a/python-ai-kit/app/user/services/services.py b/python-ai-kit/app/user/services/services.py index 5e1a69b..5b84242 100644 --- a/python-ai-kit/app/user/services/services.py +++ b/python-ai-kit/app/user/services/services.py @@ -14,13 +14,16 @@ class UserRepository(CrudRepository[User, UserCreate, UserUpdate]): pass -class UserService(AppService[UserRepository, User, UserCreate, UserUpdate], ActivityMixin): +class UserService( + AppService[UserRepository, User, UserCreate, UserUpdate], + ActivityMixin, +): def __init__( - self, - crud_model: type[UserRepository], - model: type[User], - log: Logger, - **kwargs + self, + crud_model: type[UserRepository], + model: type[User], + log: Logger, + **kwargs, ) -> None: super().__init__(crud_model, model, log, **kwargs) diff --git a/python-ai-kit/app/utils/config_utils.py b/python-ai-kit/app/utils/config_utils.py index 969e931..5440f7c 100644 --- a/python-ai-kit/app/utils/config_utils.py +++ b/python-ai-kit/app/utils/config_utils.py @@ -1,7 +1,8 @@ import os +from collections.abc import Callable, Generator from enum import Enum from functools import wraps -from typing import Any, Callable, Generator, Protocol +from typing import Any from cryptography.fernet import Fernet from pydantic import ValidationInfo @@ -16,15 +17,14 @@ class EnvironmentType(str, Enum): PRODUCTION = "production" -class Decryptor(Protocol): - def decrypt(self, value: bytes) -> bytes: ... - - class FakeFernet: def decrypt(self, value: bytes) -> bytes: return value +Decryptor = Fernet | FakeFernet + + class EncryptedField(str): @classmethod def __get_pydantic_json_schema__(cls, field_schema: dict[str, Any]) -> None: diff --git a/python-ai-kit/app/utils/hateoas.py b/python-ai-kit/app/utils/hateoas.py index 15e33b0..063fa57 100644 --- a/python-ai-kit/app/utils/hateoas.py +++ b/python-ai-kit/app/utils/hateoas.py @@ -35,11 +35,21 @@ def _generate_collection_links( base_url: str, ) -> list[dict[str, str]]: links = [ - {"rel": "self", "href": f"{base_url}?page={page}&limit={limit}", "method": "GET"}, - {"rel": "next", "href": f"{base_url}?page={page + 1}&limit={limit}", "method": "GET"}, + { + "rel": "self", + "href": f"{base_url}?page={page}&limit={limit}", + "method": "GET", + }, + { + "rel": "next", + "href": f"{base_url}?page={page + 1}&limit={limit}", + "method": "GET", + }, ] if page > 1: - links.append({"rel": "prev", "href": f"{base_url}?page={page - 1}&limit={limit}"}) + links.append( + {"rel": "prev", "href": f"{base_url}?page={page - 1}&limit={limit}"}, + ) return links @@ -63,7 +73,7 @@ def get_hateoas_list( page: int, limit: int, base_url: str, -) -> dict[str, list[dict[str, str]]]: +) -> dict[str, list[dict[str, str]] | list[dict[str, str | None]]]: name = items[0].__tablename__ if len(items) else "" built_url = _build_query(base_url, name) return { diff --git a/python-ai-kit/app/utils/mappings_meta.py b/python-ai-kit/app/utils/mappings_meta.py index 972167d..037faf5 100644 --- a/python-ai-kit/app/utils/mappings_meta.py +++ b/python-ai-kit/app/utils/mappings_meta.py @@ -1,23 +1,33 @@ -from typing import Any, get_origin, get_args +from typing import Any, get_args, get_origin -from sqlalchemy.orm import relationship, Mapped +from sqlalchemy.orm import Mapped, relationship from sqlalchemy.orm.decl_api import DeclarativeAttributeIntercept from app.mappings import ManyToOne, OneToMany -DEFAULT_ONE_TO_MANY = dict(cascade="all, delete-orphan", passive_deletes=True) -DEFAULT_MANY_TO_ONE = dict() -RELATION_TYPES: dict[type, dict] = { +DEFAULT_ONE_TO_MANY: dict[str, Any] = { + "cascade": "all, delete-orphan", + "passive_deletes": True, +} +DEFAULT_MANY_TO_ONE: dict[str, Any] = {} +RELATION_TYPES: dict[object, dict[str, Any]] = { ManyToOne: DEFAULT_MANY_TO_ONE, OneToMany: DEFAULT_ONE_TO_MANY, } + class AutoRelMeta(DeclarativeAttributeIntercept): """Metaclass for auto-creating SQLAlchemy relationships from type annotations.""" _registry: dict[str, dict[str, tuple[str, str]]] = {} - def __new__(mcls, name, bases, namespace, **kw): + def __new__( + mcls, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kw, + ): annotations = dict(namespace.get("__annotations__", {})) local_rels = {} @@ -49,7 +59,7 @@ def __new__(mcls, name, bases, namespace, **kw): return cls @staticmethod - def _extract_target_name(tp) -> str | None: + def _extract_target_name(tp: Any) -> str | None: """Extract the string name of target class, handling ForwardRef and str literals.""" if isinstance(tp, str): return tp @@ -60,7 +70,13 @@ def _extract_target_name(tp) -> str | None: return None @classmethod - def _add_relation(cls, attr: str, inner: Any, namespace: dict, local_rels: dict): + def _add_relation( + cls, + attr: str, + inner: Any, + namespace: dict, + local_rels: dict, + ) -> None: """Add relationship from inner type using registered RELATION_TYPES.""" inner_origin = get_origin(inner) inner_args = get_args(inner) @@ -80,11 +96,18 @@ def _add_relation(cls, attr: str, inner: Any, namespace: dict, local_rels: dict) local_rels[attr] = (kind, target_name) @classmethod - def _handle_back_populates(cls, mapped_cls, local_rels: dict): + def _handle_back_populates( + cls, + mapped_cls: type, + local_rels: dict[str, tuple[str, str]], + ) -> None: """Optionally auto-link back_populates for opposite relations.""" for my_attr, (my_type, target_name) in local_rels.items(): target_rels = cls._registry.get(target_name, {}) for tgt_attr, (tgt_type, tgt_target) in target_rels.items(): if tgt_target == mapped_cls.__name__ and tgt_type != my_type: - setattr(mapped_cls, my_attr, relationship(target_name, back_populates=tgt_attr)) - + setattr( + mapped_cls, + my_attr, + relationship(target_name, back_populates=tgt_attr), + ) diff --git a/python-ai-kit/docker-compose.yml.jinja b/python-ai-kit/docker-compose.yml.jinja index f7ef8e3..832e550 100644 --- a/python-ai-kit/docker-compose.yml.jinja +++ b/python-ai-kit/docker-compose.yml.jinja @@ -90,4 +90,4 @@ services: volumes: postgres_data: - redis_data: \ No newline at end of file + redis_data: diff --git a/python-ai-kit/pyproject.toml.jinja b/python-ai-kit/pyproject.toml.jinja index 3039309..11226d6 100644 --- a/python-ai-kit/pyproject.toml.jinja +++ b/python-ai-kit/pyproject.toml.jinja @@ -74,6 +74,7 @@ ignore = [ "ANN003", # missing-type-kwargs "ANN204", # missing-return-type-special-method "ANN401", # any-type + "COM812", # trailing-comma-on-bare-tuple "RET503", # implicit-return ] @@ -89,4 +90,4 @@ build-backend = "uv_build" [tool.uv.build-backend] module-root = "" -module-name = "app" \ No newline at end of file +module-name = "app" diff --git a/python-ai-kit/scripts/start/app.sh.jinja b/python-ai-kit/scripts/start/app.sh.jinja index be6c95f..7f80283 100755 --- a/python-ai-kit/scripts/start/app.sh.jinja +++ b/python-ai-kit/scripts/start/app.sh.jinja @@ -13,4 +13,4 @@ if [ "$ENVIRONMENT" = "local" ]; then uv run fastapi dev app/main.py --host 0.0.0.0 --port 8000 else uv run fastapi run app/main.py --host 0.0.0.0 --port 8000 -fi \ No newline at end of file +fi