From fa0f33e3c1ee247bef5e95030e30729f584f7479 Mon Sep 17 00:00:00 2001 From: "Sergei V. Elfimov" Date: Fri, 11 Jul 2025 10:27:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BE=D0=B1=D0=B5=D1=80=D1=82=D0=BA=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=D0=B5=D1=81=D0=B8=D0=B8=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8=D0=B8=20=D0=B2=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82.=20=D0=A3=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BB=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=20myp?= =?UTF-8?q?y=20=D0=B8=D0=B7=20pre-commit-=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 14 ++--- README.md | 23 ++++----- fastapi_django/db/__init__.py | 5 +- fastapi_django/db/dependencies.py | 7 ++- fastapi_django/db/exceptions.py | 4 +- fastapi_django/db/repositories/base.py | 4 +- fastapi_django/db/repositories/builder.py | 60 +++++++++------------- fastapi_django/db/repositories/queryset.py | 37 ++++++------- fastapi_django/db/sessions.py | 51 ++++++++++++------ fastapi_django/db/utils.py | 18 +++---- fastapi_django/exceptions.py | 2 +- 11 files changed, 114 insertions(+), 111 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8ca88d..4bceaf4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,13 +25,13 @@ repos: language: system types: [python] args: [--ini, .bandit] - - id: mypy - name: "mypy" - entry: mypy - args: ["--config-file", "pyproject.toml"] - types: [python] - language: system - exclude: tests + # - id: mypy + # name: "mypy" + # entry: mypy + # args: ["--config-file", "pyproject.toml"] + # types: [python] + # language: system + # exclude: tests - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 diff --git a/README.md b/README.md index bcb3c7e..e5fa2bb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Вспомогательная библиотека для разработки приложений на FastAPI. -Реализует функционал для наиболее распространных ситуаций (создание экзмепляра приложения FastAPI по заданным настройкам, +Реализует функционал для наиболее распространных ситуаций (создание экзмепляра приложения FastAPI по заданным настройкам, работа с БД, фильтрация, пагинация, авторизация, аутентификация). - [Названия библиотеки](#названия-библиотеки) @@ -33,7 +33,7 @@ fastapi-django - рабочее название (не финальное). ## TODO 1. множественные БД (в одну БД (мастер) пишется, в другую синхронизируется и из нее читается.) -2. генерация шаблона проекта как в django (также генерируется файл manage.py, в котором дополняются переменные окружения +2. генерация шаблона проекта как в django (также генерируется файл manage.py, в котором дополняются переменные окружения и который является входной точкой в приложение) 2. прикинуть, какие еще консольные команды могут пригодиться (напр., миграции) 3. репозитории @@ -52,7 +52,7 @@ fastapi-django - рабочее название (не финальное). ## Создание приложения -Библиотека предоставляет функцию `fastapi_django.app.get_default_app()`, которая создает экземпляр приложения FastAPI +Библиотека предоставляет функцию `fastapi_django.app.get_default_app()`, которая создает экземпляр приложения FastAPI с параметрами, указанными в настройках в settings.py: ```python @@ -92,13 +92,13 @@ UVICORN_APP = "web.app:app" python manage.py runserver ``` -Это запустит экземпляр указанного в UVICORN_APP приложения при помощи uvicorn. +Это запустит экземпляр указанного в UVICORN_APP приложения при помощи uvicorn. ## Работа с БД ### Сессии SQLAlchemy -Работа с базами данных происходит через слой репозиториев. Для этого разработан [базовый класс репозитория BaseRepository](fastapi_django/db/repositories/base.py#L13), +Работа с базами данных происходит через слой репозиториев. Для этого разработан [базовый класс репозитория BaseRepository](fastapi_django/db/repositories/base.py#L13), который предоставляет возможность работать с данными в стиле Django ORM: ```python @@ -115,9 +115,9 @@ async with contextified_autocommit_session(): users = await repository.objects.filter(name="Иван").all() ``` -Обратите внимание, что сессия SQLAlchemy не передается при инициализации репозитория. Вместо этого она инициализируется -контекстным менеджером contextified_autocommit_session() и помещается в ContextVars. Репозитории (все в пределах действия -контекстного менеджера) затем берут инициализированную сессию оттуда. contextified_autocommit_session() также управляет +Обратите внимание, что сессия SQLAlchemy не передается при инициализации репозитория. Вместо этого она инициализируется +контекстным менеджером contextified_autocommit_session() и помещается в ContextVars. Репозитории (все в пределах действия +контекстного менеджера) затем берут инициализированную сессию оттуда. contextified_autocommit_session() также управляет жизненным циклом сессии. Настройки базы данных задаются в settings(.py) в DATABASE. Пример: @@ -148,10 +148,10 @@ DATABASE = { ### fastapi-sqla -Движки, сессии конфигурируются библиотекой. Параметры через энвы, для наименования которых необходимо придерживаться -некоторых правил. +Движки, сессии конфигурируются библиотекой. Параметры через энвы, для наименования которых необходимо придерживаться +некоторых правил. -Сессия создается в миддлварях и [записывается в fastapi.Request.state](https://github.com/dialoguemd/fastapi-sqla/blob/master/fastapi_sqla/async_sqla.py#L137). +Сессия создается в миддлварях и [записывается в fastapi.Request.state](https://github.com/dialoguemd/fastapi-sqla/blob/master/fastapi_sqla/async_sqla.py#L137). Затем эту сессию депенденси AsyncSessionDependency возвращает. Сессия не хранится в contextvars. @@ -324,4 +324,3 @@ def get_users(): Паттерн active record. Не поддерживается. - diff --git a/fastapi_django/db/__init__.py b/fastapi_django/db/__init__.py index a937277..663efaf 100644 --- a/fastapi_django/db/__init__.py +++ b/fastapi_django/db/__init__.py @@ -4,7 +4,6 @@ from fastapi_django.conf import settings from fastapi_django.exceptions import ImproperlyConfigured - # поддерживаемые диалекты SQLAlchemy https://docs.sqlalchemy.org/en/20/dialects/index.html # SQLAlchemy как безоговорочный вариант для взаимодействия с БД @@ -27,7 +26,9 @@ def __init__(self): database=settings.DATABASE["DATABASE"], ) kw = settings.DATABASE.get("OPTIONS", {}) - self.__dict__["_engine"] = create_async_engine(url, **kw) # иначе будет RecursionError: maximum recursion depth exceeded + self.__dict__["_engine"] = create_async_engine( + url, **kw + ) # иначе будет RecursionError: maximum recursion depth exceeded def __getattr__(self, item): return getattr(self._engine, item) diff --git a/fastapi_django/db/dependencies.py b/fastapi_django/db/dependencies.py index 33c6814..82a2786 100644 --- a/fastapi_django/db/dependencies.py +++ b/fastapi_django/db/dependencies.py @@ -1,15 +1,17 @@ -from typing import Any, AsyncIterator, Callable, Annotated +from collections.abc import AsyncIterator, Callable +from typing import Annotated, Any from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from fastapi_django.db.sessions import contextified_transactional_session, contextified_autocommit_session +from fastapi_django.db.sessions import contextified_autocommit_session, contextified_transactional_session def contextify_transactional_session(**kw: Any) -> Callable: async def wrapper() -> AsyncIterator[AsyncSession]: async with contextified_transactional_session(**kw) as session: yield session + return wrapper @@ -17,6 +19,7 @@ def contextify_autocommit_session(**kw: Any) -> Callable: async def wrapper() -> AsyncIterator[AsyncSession]: async with contextified_autocommit_session(**kw) as session: yield session + return wrapper diff --git a/fastapi_django/db/exceptions.py b/fastapi_django/db/exceptions.py index 304980e..d203d57 100644 --- a/fastapi_django/db/exceptions.py +++ b/fastapi_django/db/exceptions.py @@ -1,6 +1,4 @@ class ColumnNotFoundError(Exception): def __init__(self, model, column_name: str): - error = ( - f"Столбец `{column_name}` не найден в модели {model.__name__}" - ) + error = f"Столбец `{column_name}` не найден в модели {model.__name__}" super().__init__(error) diff --git a/fastapi_django/db/repositories/base.py b/fastapi_django/db/repositories/base.py index 1f64e7e..68712ce 100644 --- a/fastapi_django/db/repositories/base.py +++ b/fastapi_django/db/repositories/base.py @@ -1,6 +1,6 @@ import logging from itertools import islice -from typing import Generic, Any, Type, Self +from typing import Any, Generic, Self from fastapi_django.db.repositories.queryset import QuerySet from fastapi_django.db.sessions import session_context_var @@ -11,7 +11,7 @@ class BaseRepository(Generic[Model]): - model_cls: Type[Model] + model_cls: type[Model] def __init__(self): if not self.model_cls: diff --git a/fastapi_django/db/repositories/builder.py b/fastapi_django/db/repositories/builder.py index b873590..8632984 100644 --- a/fastapi_django/db/repositories/builder.py +++ b/fastapi_django/db/repositories/builder.py @@ -1,49 +1,46 @@ import logging -from typing import Any, Type, Self +from typing import Any, Self -from sqlalchemy import Select, select, func, delete, Delete, update, Update -from sqlalchemy.orm import contains_eager, aliased +from sqlalchemy import Delete, Select, Update, delete, func, select, update +from sqlalchemy.orm import aliased, contains_eager from sqlalchemy.sql.operators import eq from fastapi_django.db.repositories.constants import LOOKUP_SEP from fastapi_django.db.repositories.lookups import lookups from fastapi_django.db.types import Model -from fastapi_django.db.utils import get_column, get_pk, get_relationships, get_columns, get_annotations +from fastapi_django.db.utils import get_annotations, get_column, get_columns, get_pk, get_relationships logger = logging.getLogger(__name__) class InvalidFilterFieldError(Exception): - def __init__(self, filter_field: str): error = f"Некорректное поле для фильтрации - {filter_field}" super().__init__(error) class InvalidOrderByFieldError(Exception): - def __init__(self, ordering_field: str): error = f"Некорректное поле для сортировки - {ordering_field}" super().__init__(error) class InvalidOptionFieldError(Exception): - def __init__(self, option_field: str): error = f"Некорректное поле для options - {option_field}" super().__init__(error) class InvalidJoinFieldError(Exception): - def __init__(self, join_field: str): error = f"Некорректное поле для join - {join_field}" super().__init__(error) class QueryBuilder: - """ - Обертка над запросом SQLAlchemy. Хранит параметры запроса. Предоставляет методы для + """Обертка над запросом SQLAlchemy. + + Хранит параметры запроса. Предоставляет методы для создания конечных методов Собирает параметры запроса и в конце генерирует запрос @@ -134,7 +131,7 @@ class QueryBuilder: """ - def __init__(self, model_cls: Type[Model]): + def __init__(self, model_cls: type[Model]): self._model_cls = model_cls self._where: dict = {} self._order_by: dict = {} @@ -226,9 +223,7 @@ def order_by(self, *args: str) -> None: raise InvalidOrderByFieldError(ordering_field) if column_name is None: raise InvalidOrderByFieldError(ordering_field) - order_by[column_name] = { - "direction": "desc" if ordering_field.startswith("-") else "asc" - } + order_by[column_name] = {"direction": "desc" if ordering_field.startswith("-") else "asc"} def options(self, *args: str) -> None: for option_field in args: @@ -239,7 +234,7 @@ def options(self, *args: str) -> None: if attr in relationships: model_cls = getattr(model_cls, attr).property.mapper.class_ joins = joins.setdefault("children", {}).setdefault(attr, {}) - joins['model_cls'] = model_cls + joins["model_cls"] = model_cls relationships = get_relationships(model_cls) else: raise InvalidOptionFieldError(option_field) @@ -277,7 +272,7 @@ def join(self, *args: str, isouter: bool) -> None: if attr in relationships: model_cls = getattr(model_cls, attr).property.mapper.class_ joins = joins.setdefault("children", {}).setdefault(attr, {}) - joins['model_cls'] = model_cls + joins["model_cls"] = model_cls relationships = get_relationships(model_cls) else: raise InvalidJoinFieldError(join_field) @@ -300,10 +295,7 @@ def build_count_stmt(self) -> Select: if self._options: raise ValueError("Удалите options") pk = get_pk(self._model_cls) - stmt = ( - select(func.count(func.distinct(pk))) - .select_from(self._model_cls) - ) + stmt = select(func.count(func.distinct(pk))).select_from(self._model_cls) stmt = self._apply_joins(stmt) stmt = self._apply_where(stmt) return stmt @@ -343,8 +335,7 @@ def _apply_distinct(self, stmt: Select) -> Select: return stmt def build_select_stmt(self) -> Select: - """ - Возвращает запрос на выборку + """Возвращает запрос на выборку. Лимитированные запросы с options приходится составлять при помощи подзапроса, чтобы гарантировать правильность применений OFFSET и LIMIT, так как связные модели join-ятся @@ -424,17 +415,17 @@ def _apply_limit(self, stmt: Select) -> Select: def _apply_where(self, stmt, model_cls=None) -> Select: model_cls = model_cls or self._model_cls for attr, value in self._where.items(): - op = value['op'] + op = value["op"] column = getattr(model_cls, attr) - stmt = stmt.where(op(column, value['value'])) + stmt = stmt.where(op(column, value["value"])) return stmt def _apply_order_by(self, stmt: Select, model_cls=None): model_cls = model_cls or self._model_cls for attr, value in self._order_by.items(): - direction = value['direction'] + direction = value["direction"] column = getattr(model_cls, attr) # напр., aliased(Section).name или Section.name - column = column.asc() if direction == 'asc' else column.desc() + column = column.asc() if direction == "asc" else column.desc() stmt = stmt.order_by(column) return stmt @@ -444,9 +435,10 @@ def _apply_joins( apply_where: bool = True, apply_order_by: bool = True, apply_options: bool = True, - parent_model_cls=None + parent_model_cls=None, ) -> Select: - """ + """Метод для работы с join-ами. + как сейчас: { @@ -516,7 +508,7 @@ def _apply_joins_recursively(self, stmt, joins, where, order_by, parent_model_cl for attr, value in joins.get("children", {}).items(): target = aliased(value["model_cls"]) onclause = getattr(parent_model_cls, attr) - attr_root = f"{root}__{attr}".strip("__") + attr_root = f"{root}{LOOKUP_SEP}{attr}".removesuffix(LOOKUP_SEP).removeprefix(LOOKUP_SEP) tree[attr_root] = {"attr": onclause, "alias": target} isouter = value.get("isouter", False) stmt = stmt.join(target, onclause, isouter=isouter) @@ -527,15 +519,9 @@ def _apply_joins_recursively(self, stmt, joins, where, order_by, parent_model_cl for name, item in value.get("order_by", {}).items(): direction = item["direction"] column = getattr(target, name) - order_by.append(column.asc() if direction == 'asc' else column.desc()) + order_by.append(column.asc() if direction == "asc" else column.desc()) stmt = self._apply_joins_recursively( - stmt, - joins=value, - order_by=order_by, - where=where, - parent_model_cls=target, - tree=tree, - root=attr_root + stmt, joins=value, order_by=order_by, where=where, parent_model_cls=target, tree=tree, root=attr_root ) return stmt diff --git a/fastapi_django/db/repositories/queryset.py b/fastapi_django/db/repositories/queryset.py index 0328eea..9d18138 100644 --- a/fastapi_django/db/repositories/queryset.py +++ b/fastapi_django/db/repositories/queryset.py @@ -1,5 +1,5 @@ import logging -from typing import Self, Any, Type +from typing import Any, Self from sqlalchemy import Result, Row from sqlalchemy.ext.asyncio import AsyncSession @@ -7,7 +7,7 @@ from fastapi_django.db.repositories.builder import QueryBuilder from fastapi_django.db.repositories.constants import LOOKUP_SEP from fastapi_django.db.types import Model -from fastapi_django.db.utils import validate_has_columns, get_column +from fastapi_django.db.utils import get_column, validate_has_columns logger = logging.getLogger("repositories") @@ -17,7 +17,7 @@ def iterate_scalars(result: Result) -> list[Model]: def iterate_values_list(result: Result) -> list[tuple]: - return list(tuple(item) for item in result.tuples().all()) + return [tuple(item) for item in result.tuples().all()] def iterate_named_values_list(result: Result) -> list[Row]: @@ -25,9 +25,9 @@ def iterate_named_values_list(result: Result) -> list[Row]: class QuerySet: - """ - Данный класс принимает параметры запроса при помощи промежуточныех методов и транслирует - их в QueryBuilder, а также выполняет запросы в БД + """Данный класс принимает параметры запроса. + + При помощи промежуточныех методов и транслирует их в QueryBuilder, а также выполняет запросы в БД - ПРОМЕЖУТОЧНЫЕ И ТЕРМИНАЛЬНЫЕ МЕТОДЫ @@ -79,7 +79,8 @@ class QuerySet: Результат вычисления QuerySet не кэшируется. """ - def __init__(self, model: Type[Model], session: AsyncSession): + + def __init__(self, model: type[Model], session: AsyncSession): self._model_cls = model self._session = session self._query_builder = QueryBuilder(self._model_cls) @@ -168,9 +169,7 @@ def values_list(self, *args: str, flat: bool = False, named: bool = False) -> Se clone = self._clone() clone._query_builder.values_list(*args) clone._iterate_result_func = ( - iterate_named_values_list - if named - else iterate_scalars if flat else iterate_values_list + iterate_named_values_list if named else iterate_scalars if flat else iterate_values_list ) return clone @@ -229,7 +228,7 @@ async def in_bulk(self, id_list: list[Any] | None = None, *, field_name="id") -> if id_list is not None: if not id_list: return {} - filter_key = "{}__in".format(field_name) + filter_key = f"{field_name}__in" id_list = tuple(id_list) filters[filter_key] = id_list objs = await self.filter(**filters) @@ -274,12 +273,11 @@ def __await__(self) -> list[Any]: def __getitem__(self, k: int | slice) -> Self: self._validate_sliced() - if not isinstance(k, (int, slice)): - raise TypeError( - "Индекс должен быть целыми числом или объектом slice, а не %s." - % type(k).__name__ - ) - if (isinstance(k, int) and k < 0) or (isinstance(k, slice) and ((k.start is not None and k.start < 0) or (k.stop is not None and k.stop < 0))): + if not isinstance(k, int | slice): + raise TypeError(f"Индекс должен быть целыми числом или объектом slice, а не {type(k).__name__}.") + if (isinstance(k, int) and k < 0) or ( + isinstance(k, slice) and ((k.start is not None and k.start < 0) or (k.stop is not None and k.stop < 0)) + ): raise ValueError("Отрицательные индексы не поддерживаются") if isinstance(k, slice): if k.step is not None: @@ -300,15 +298,14 @@ def __getitem__(self, k: int | slice) -> Self: elif k.start is not None and k.stop is None: limit, offset = None, k.start else: - limit, offset = k.stop - k.start, k.start + limit, offset = k.stop - k.start, k.start clone._query_builder.limit(limit) clone._query_builder.offset(offset) self._sliced = True return clone def _validate_sliced(self) -> None: - """ - Принимаем, что если был взят срез, то после невозможно менять QuerySet + """Принимаем, что если был взят срез, то после невозможно менять QuerySet. Внятной мотивации для этого нет. Просто кажется, что такая, например, цепочка вызовов методов выглядит более чем странной: diff --git a/fastapi_django/db/sessions.py b/fastapi_django/db/sessions.py index 014e98c..07b581c 100644 --- a/fastapi_django/db/sessions.py +++ b/fastapi_django/db/sessions.py @@ -1,8 +1,8 @@ from contextlib import asynccontextmanager from contextvars import ContextVar -from typing import Any, AsyncGenerator +from typing import Any -from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession +from sqlalchemy.ext.asyncio import async_sessionmaker from fastapi_django.db import engine @@ -11,10 +11,39 @@ session_context_var: ContextVar[Any] = ContextVar("sqlalchemy_session", default=None) +async def get_session(autocommit: bool = False, **kw: Any): + """Возвращает кортеж из трех объектов для работы сессии. + + :param autocommit: Признак "AUTOCOMMIT". + :return: Возвращает кортеж. + """ + if autocommit: + connection = engine.execution_options(isolation_level="AUTOCOMMIT") + transaction = None + else: + connection = await engine.connect() + transaction = await connection.begin() + + session = session_factory(bind=connection, **kw) + return connection, transaction, session + + +async def set_session(autocommit: bool = False, **kw: Any): + """Метод устанавливает значения (соединение, транзакция, сессия) в контекст. + + :param autocommit: Признак "AUTOCOMMIT". + :return: Возвращает кортеж. + """ + connection, transaction, session = session_context_var.get() + if session is None: + connection, transaction, session = await get_session(autocommit=autocommit, kw=kw) + token = session_context_var.set((connection, transaction, session)) + return connection, transaction, session, token + + @asynccontextmanager async def contextified_transactional_session(**kw: Any): - """ - Управляет жизненным циклом сессии, присоединенной к внешней транзакции + """Управляет жизненным циклом сессии, присоединенной к внешней транзакции. Внешняя транзакция необходима для того, чтобы не зависет от возможных коммитов сессии @@ -25,11 +54,7 @@ async def contextified_transactional_session(**kw: Any): stmt = select(MyModel) .... """ - assert session_context_var.get() is None, "Сессия была создана ранее" # TODO: точно нужно запрещать повторное использование? - connection = await engine.connect() - transaction = await connection.begin() - session = session_factory(bind=connection, **kw) - token = session_context_var.set(session) + connection, transaction, session, token = await set_session(kw=kw) try: yield session await transaction.commit() @@ -44,8 +69,7 @@ async def contextified_transactional_session(**kw: Any): @asynccontextmanager async def contextified_autocommit_session(**kw: Any): - """ - Управляет жизненным циклом сессии с уровнем изоляции AUTOCOMMIT + """Управляет жизненным циклом сессии с уровнем изоляции AUTOCOMMIT. AUTOCOMMIT может быть полезен как микрооптимизация (не генерить транзакций без необходимости - селекты) @@ -56,10 +80,7 @@ async def contextified_autocommit_session(**kw: Any): stmt = select(MyModel) .... """ - assert session_context_var.get() is None, "Сессия была создана ранее" # TODO: точно нужно запрещать повторное использование? - autocommit_engine = engine.execution_options(isolation_level="AUTOCOMMIT") - session = session_factory(bind=autocommit_engine, **kw) - token = session_context_var.set(session) + _, _, session, token = await set_session(autocommit=True, kw=kw) yield session await session.close() session_context_var.reset(token) diff --git a/fastapi_django/db/utils.py b/fastapi_django/db/utils.py index 9cb1dd8..989b160 100644 --- a/fastapi_django/db/utils.py +++ b/fastapi_django/db/utils.py @@ -1,6 +1,4 @@ -from typing import Type - -from sqlalchemy import inspect, Column, ColumnCollection +from sqlalchemy import Column, ColumnCollection, inspect from sqlalchemy.orm.util import AliasedClass from sqlalchemy.util._collections import ReadOnlyProperties @@ -8,27 +6,27 @@ from fastapi_django.db.types import Model -def validate_has_columns(model_cls: Type[Model], *args: str) -> None: +def validate_has_columns(model_cls: type[Model], *args: str) -> None: columns = inspect(model_cls).columns for col in args: if col not in columns: raise ColumnNotFoundError(model_cls, col) -def get_column(model_cls: Type[Model], column_name: str) -> Column: +def get_column(model_cls: type[Model], column_name: str) -> Column: column = inspect(model_cls).columns.get(column_name) if column is not None: return column raise ColumnNotFoundError(model_cls, column_name) -def get_columns(model_or_aliased_cls: Type[Model] | AliasedClass) -> ColumnCollection: +def get_columns(model_or_aliased_cls: type[Model] | AliasedClass) -> ColumnCollection: is_aliased = isinstance(model_or_aliased_cls, AliasedClass) model_cls = inspect(model_or_aliased_cls).mapper.class_ if is_aliased else model_or_aliased_cls return inspect(model_cls).columns -def get_pk(model_cls: Type[Model]) -> Column: +def get_pk(model_cls: type[Model]) -> Column: pk = inspect(model_cls).primary_key if len(pk) == 1: return pk[0] @@ -38,17 +36,17 @@ def get_pk(model_cls: Type[Model]) -> Column: ) -def get_model_cls(model_or_aliased_cls: Type[Model] | AliasedClass) -> Type[Model]: +def get_model_cls(model_or_aliased_cls: type[Model] | AliasedClass) -> type[Model]: if isinstance(model_or_aliased_cls, AliasedClass): return inspect(model_or_aliased_cls).mapper.class_ return model_or_aliased_cls -def get_relationships(model_or_aliased_cls: Type[Model] | AliasedClass) -> ReadOnlyProperties: +def get_relationships(model_or_aliased_cls: type[Model] | AliasedClass) -> ReadOnlyProperties: model_cls = get_model_cls(model_or_aliased_cls) return inspect(model_cls).relationships -def get_annotations(model_or_aliased_cls: Type[Model] | AliasedClass) -> dict: +def get_annotations(model_or_aliased_cls: type[Model] | AliasedClass) -> dict: model_cls = get_model_cls(model_or_aliased_cls) return model_cls.__dict__["__annotations__"] diff --git a/fastapi_django/exceptions.py b/fastapi_django/exceptions.py index bff4e4c..c47bebb 100644 --- a/fastapi_django/exceptions.py +++ b/fastapi_django/exceptions.py @@ -1,2 +1,2 @@ class ImproperlyConfigured(Exception): - pass \ No newline at end of file + pass