LitestarCatsCV. Тренируемся на кошках. Расширяем возможности и готовимся к продакшену. Часть 3

от автора

Котики выходят на новый уровень! 🐾

Привет, котики и котолюбы! В первой части нашего кошачьего приключения мы выбрали инструменты (Litestar вместо FastAPI, Granian вместо Gunicorn, KeyDB вместо Redis), настроили uv и заложили фундамент проекта. Во второй части мы построили полноценное CRUD API для резюме котиков (или людей, если вам так ближе), подружили его с PostgreSQL через SQLAlchemy, настроили миграции с Alembic и написали тесты с Pytest. У нас уже есть стены и фундамент, но пора ставить крышу и готовиться к продакшену! 🏠

Сегодня мы сделаем наш API ещё круче: вынесем конфиги в отдельный модуль с помощью msgspec, добавим аутентификацию через встроенный JWT в Litestar, ускорим API с KeyDB, проверим покрытие тестами с coverage, упакуем всё в Docker и нарисуем резюме котиков с помощью Jinja. К концу статьи наш кошачий проект будет готов к реальной жизни — поехали! 🚀


Если раньше мы просто тренировались на кошках, то теперь выпускаем их в большой мир с пропусками, кешем и стильными резюме. Полный код, как всегда, на GitHub — ссылка в конце! 🐱

Вынос конфигов — приводим все настройки в порядок 🗂️

Зачем это нужно?

Наш проект растёт, и захардкоженные строки вроде DATABASE_URL начинают мяукать от неудобства. Давай вынесем конфиги в отдельный модуль src/configs/app_config.py и будем хранить значения в settings-example.yaml. Мы уже используем msgspec для моделей, так что применим его и для конфигов — это сделает код чище и быстрее, а котики любят порядок! 😺

Что будем делать?

  • Создадим классы конфигов с помощью msgspec.Struct.

  • Настроим парсинг settings-example.yaml в эти классы.

  • Обновим src/app.py, чтобы использовать новые конфиги.

Детали реализации

Установка зависимостей

Нам понадобится pyyaml для парсинга YAML. msgspec у нас уже есть, так как он используется в проекте:

uv add pyyaml

Создаём классы конфигов

Создаём файл src/configs/app_config.py. Используем msgspec.Struct для определения структуры конфигов:

from msgspec import Struct  import argparse  import yaml  from pathlib import Path  class DatabaseConfig(Struct):      user: str = "postgres"      password: str = "postgres"      host: str = "127.0.0.1"      port: str = "5432"      database_name: str = "test_db"      url: str = None      echo: bool = False      def get_connection_url(self) -> str:          if self.url:              return self.url          return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.database_name}"  class KeyDBConfig(Struct):      host: str = "127.0.0.1"      port: str = "6379"      db: str = "0"      url: str = None      def get_connection_url(self) -> str:          if self.url:              return self.url          return f"keydb://{self.host}:{self.port}/{self.db}"  class JWTConfig(Struct):      secret: str      token_secret: str  class AppConfig(Struct):      database: DatabaseConfig      keydb: KeyDBConfig      jwt: JWTConfig  def load_config(file_path: str) -> AppConfig:      with open(file_path, "r") as f:          config_data = yaml.safe_load(f)      return AppConfig(          database=DatabaseConfig(**config_data["database"]),          keydb=KeyDBConfig(**config_data["keydb"]),          jwt=JWTConfig(**config_data["jwt"]),      )  def configure() -> AppConfig:      title = "LitestarCats"      parser = argparse.ArgumentParser(title)      src_dir = Path(__file__).absolute().parent      config_file = src_dir / "settings-example.yaml"      parser.add_argument(          "-c", "--config", type=str, default=config_file, help="Config file"      )      args = parser.parse_known_args()      if args and args[0].config:          config_file = args[0].config      config = load_config(config_file)      return config

msgspec не поддерживает автоматическую десериализацию из словарей так, как это делает pydantic, поэтому мы вручную преобразуем данные из YAML в наши структуры. Это немного более явный подход, но он соответствует философии msgspec: быть лёгким и быстрым.

Создаём settings-example.yaml

Создаём файл settings-example.yaml с такой структурой, чтобы она соответствовала нашим классам:

database:    host: 127.0.0.1    port: 5432    user: postgres    password: postgres    database_name: ltcats_test_db    #url: "postgresql+asyncpg://postgres:postgres@localhost/ltcats_test_db" local    url: "postgresql+asyncpg://postgres:postgres@localhost/ltcats_test_db" # docker    echo: true  keydb:    host: 127.0.0.1    port: 6379    db: 0    url: "keydb://localhost:6379/0"  jwt:    secret: "your-secret-key"    token_secret: "super-secret"

📌 Примечание: в продакшене лучше хранить секреты в централизованном secrets manager (Vault, AWS/GCP/Azure Secret Manager), но для простоты мы используем YAML. Если захотите, можно добавить поддержку .env через os.getenv или другую библиотеку.

Обновляем app.py

Теперь обновим src/app.py, чтобы использовать наши конфиги:

from litestar import Litestar  from litestar.di import Provide  from src.controllers.user import UserController, provide_users_repo  from src.controllers.role import RoleController  from src.controllers.user_role import UserRoleController  from src.controllers.cv import CVController  from src.controllers.work_experience import WorkExperienceController  from src.controllers.company import CompanyController  from src.controllers.educational_institution import EducationalInstitutionController  from src.controllers.education import EducationController  from litestar.contrib.sqlalchemy.plugins import (      AsyncSessionConfig,      SQLAlchemyAsyncConfig,      SQLAlchemyInitPlugin,  )  from litestar.connection import ASGIConnection  from litestar.openapi import OpenAPIConfig  from litestar.params import Parameter  from litestar.plugins.sqlalchemy import filters, base  from litestar.plugins.sqlalchemy import SQLAlchemySerializationPlugin  from litestar.security.jwt import JWTAuth, Token  from litestar.logging import LoggingConfig  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine  from uuid import UUID  from src.configs.app_config import configure  from src.postgres.models.user import User  from jinja2 import Environment, PackageLoader, select_autoescape  # Загружаем конфиги  config = configure()  logging_config = LoggingConfig(      root={"level": "INFO", "handlers": ["queue_listener"]},      formatters={          "standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}      },      log_exceptions="always",  # Включить логирование исключений с трассировкой  )  env = Environment(          loader=PackageLoader("src"),          autoescape=select_autoescape()          )  async def provide_limit_offset_pagination(      current_page: int = Parameter(ge=1, query="currentPage", default=1, required=False),      page_size: int = Parameter(          query="pageSize",          ge=1,          default=10,          required=False,      ),  ) -> filters.LimitOffset:      """Add offset/limit pagination.      Return type consumed by Repository.apply_limit_offset_pagination().      Parameters      ----------      current_page : int          LIMIT to apply to select.      page_size : int          OFFSET to apply to select.      """      return filters.LimitOffset(page_size, page_size * (current_page - 1))  sessionmaker = async_sessionmaker(expire_on_commit=False)  async def retrieve_user_handler(      token: Token,      connection: ASGIConnection,  ) -> User | None:      user_id = UUID(token.sub)      users_repo = connection.scope.get("users_repo")      if not users_repo:          async with sessionmaker(bind=db_config.get_engine()) as session:              try:                  async with session.begin():                      users_repo = await provide_users_repo(db_session=session)              except IntegrityError as exc:                  raise ClientException(                      status_code=HTTP_409_CONFLICT,                      detail=str(exc),                  ) from exc      user = await users_repo.get(user_id)      return user  jwt_auth = JWTAuth[User](      retrieve_user_handler=retrieve_user_handler,      token_secret=config.jwt.token_secret,      algorithm="HS256",      exclude=["/users", "/schema"]  )  session_config = AsyncSessionConfig(expire_on_commit=False)  db_config = SQLAlchemyAsyncConfig(      connection_string=config.database.get_connection_url(),      before_send_handler="autocommit",      session_config=session_config,  )  async def on_startup() -> None:      """Initializes the database."""      async with db_config.get_engine().begin() as conn:          await conn.run_sync(base.UUIDBase.metadata.create_all)  sqlalchemy_plugin = SQLAlchemyInitPlugin(config=db_config)  app = Litestar(      route_handlers=[          UserController,          RoleController,          UserRoleController,          CVController,          WorkExperienceController,          CompanyController,          EducationalInstitutionController,          EducationController,      ],      on_startup=[on_startup],      on_app_init=[jwt_auth.on_app_init],      openapi_config=OpenAPIConfig(title="My API", version="1.0.0"),      dependencies={          "limit_offset": Provide(provide_limit_offset_pagination),          },      plugins=[sqlalchemy_plugin, SQLAlchemySerializationPlugin()],      logging_config=logging_config,  )  

Проверка

Запускаем приложение:

make run

Если всё работает, значит, конфиги подгружаются корректно. Теперь у нас есть единое место для всех настроек, и мы используем msgspec для консистентности с остальным проектом — котики довольны, порядок наведён! 🐾

Аутентификация с JWT — «Усы, лапы и хвост — вот мои документы!» 🔑

Зачем это нужно?

Безопасность — первое правило кошачьего клуба. Мы не хотим, чтобы Барсик редактировал резюме Мурзика без спроса! Для этого добавим аутентификацию через JWT, причём используем встроенный модуль из Litestar — быстро, надёжно и без лишних зависимостей. Только котики с пропуском смогут войти! 😺

Что будем делать?

  • Настроим JWT через JWTAuth из Litestar.

  • Добавим эндпоинт /login и защитим CRUD-операции.

  • Обновим модель User для хранения пароля.

Детали реализации

Добавляем пароль в модель

Сначала добавим поле для хранения хешированного пароля в нашу модель User. Открываем src/postgres/models/user.py и обновляем:

from sqlalchemy import String  from sqlalchemy import CheckConstraint, Index  from sqlalchemy.orm import Mapped, mapped_column, relationship  from litestar.plugins.sqlalchemy import base  from typing import Optional  class User(base.UUIDAuditBase):      tablename = "users"      first_name: Mapped[str] = mapped_column(String(50), nullable=False)      last_name: Mapped[str] = mapped_column(String(50), nullable=True)      email: Mapped[str] = mapped_column(          String(255), nullable=False, unique=True, index=True      )      hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)      profile_photo_url: Mapped[Optional[str]] = mapped_column(String(255))      user_roles: Mapped[list["UserRole"]] = relationship(back_populates="user")      cvs: Mapped[list["CV"]] = relationship(back_populates="user")      __table_args__ = (          CheckConstraint("length(first_name) > 0", name="check_first_name_not_empty"),          CheckConstraint("length(last_name) > 0", name="check_last_name_not_empty"),          CheckConstraint("email LIKE '%@%.%'", name="check_email_format"),          Index("idx_users_created_at", "created_at"),      )

Теперь нужно сгенерировать миграцию, чтобы база данных узнала о новом поле:

make revision msg="add hashed_password to users"  make upgrade

Эндпоинт логина и защита CRUD

Для хеширования паролей нам понадобится passlib, так что установим его:

uv add «passlib[bcrypt]»

Обновим src/controllers/user.py:

from litestar.di import Provide  from src.models.users import UserLogin  )  from passlib.context import CryptContext  pwd_context = CryptContext(schemes=["sha256_crypt"])  class UserController(Controller):      path = "/users"      dependencies = {              "users_repo": Provide(provide_users_repo),              }      @post("/login", signature_types=[User])  # Отключаем защиту для логина      async def login(              self,              data: UserLogin,              users_repo: UsersRepository,              ) -> dict:          user = await users_repo.get_one_or_none(email=data.email)          if not user or not pwd_context.verify(data.password, user.hashed_password):              raise HTTPException(status_code=401, detail="Неверный email или пароль")          token = app.jwt_auth.create_token(identifier=str(user.id))          return {"access_token": token, "token_type": "bearer"}

   

Проверка

Давай проверим, как это работает. Сначала создаём пользователя (для теста можно временно убрать защиту с /users):

curl -X POST http://localhost:8000/users -H "Content-Type: application/json" -d '{"first_name": "Мурзик", "last_name": "Котов", "email": "murzik@example.com"}'

Теперь логинимся через /login:

curl -X POST http://localhost:8000/users/login -H "Content-Type: application/json" -d '{"first_name": "Мурзик", "last_name": "Котов", "email": "murzik@example.com"}'

Получаем токен в ответе, например:

{  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",  "token_type": "bearer"  }

Используем токен для доступа к защищённым эндпоинтам:

curl -X GET http://localhost:8000/users/<user_id> -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Если всё работает, то только котики с кошачьим пропуском могут войти! JWT от Litestar — это как миска с кормом: просто, вкусно и безопасно. 🐾

Кеширование с KeyDB — кошачья скорость ⚡

Зачем это нужно?

Если котики начнут массово запрашивать резюме, наша база скажет: «Мяу, я устала!» KeyDB с его многопоточными лапками ускорит всё, чтобы котики летали, а база отдыхала. Мы обещали использовать KeyDB ещё в первой части, так что пора выполнять обещания! 😺

Что будем делать?

  • Установим KeyDB через Docker.

  • Добавим кэширование для эндпоинта /users/{user_id}.

Детали реализации

Запуск KeyDB

Поднимаем KeyDB в Docker:

docker run -d -p 6379:6379 eqalpha/keydb

Добавляем зависимость

Устанавливаем библиотеку для работы с KeyDB:

uv add "redis[hiredis]"

Настройка клиента

Создаём файл src/clients/cache.py для работы с KeyDB, используя наш конфиг:

import redis.asyncio as redis  from src.configs.app_config import configure  config = configure()  keydb = redis.Redis(host=config.keydb.host, port=config.keydb.port, db=config.keydb.db)

Кеширование в контроллере

Обновим метод get_user в src/controllers/user.py, чтобы он сначала проверял кеш, а только потом лез в базу. Добавим также эндпоинт для резюме (о нём позже):

from src.clients.cache import keydb  from litestar.response import Template  logger = logging.getLogger(__name__)  pwd_context = CryptContext(schemes=["sha256_crypt"])  class UsersRepository(repository.SQLAlchemyAsyncRepository[User]):      """Author repository."""      model_type = User  async def provide_users_repo(db_session: AsyncSession) -> UsersRepository:      """This provides the default Authors repository."""      return UsersRepository(session=db_session)  class UserController(Controller):      path = "/users"      dependencies = {              "users_repo": Provide(provide_users_repo),              }      @get("/{user_id:uuid}", return_dto=MsgspecDTO[UserRead])      async def get_user(          self,          user_id: UUID,          users_repo: UsersRepository,      ) -> UserRead:          """Get an existing author."""          cache_key = f"user:{user_id}"          cached = await keydb.get(cache_key)          if cached:              return json.decode(cached, type=UserRead)          user = await users_repo.get(user_id)          await keydb.set(cache_key, json.encode(user.to_dict()), ex=3600)  # Кэш на час          return user  . . .      @get("/{user_id:uuid}/cv", media_type="text/html")      async def get_user_resume(self, user_id: UUID, users_repo: UsersRepository) -> Template:          user = await users_repo.get(user_id)          template = app.env.get_template("cv.html")          return Template(template=template, context={"user": user})

Проверка

Запрашиваем пользователя через curl:

curl -X GET http://localhost:8000/users/<user_id> -H "Authorization: Bearer <your-token>"

Первый запрос пойдёт в базу, а второй — уже из кеша, и будет быстрее. KeyDB — это как кошачья мята для API: котики летают, а база отдыхает. Мяу-скорость включена! ⚡

Покрытие тестами с Coverage — проверяем кошачью надёжность 🧪

Зачем это нужно?

У нас есть тесты, но как понять, всё ли мы проверили? Coverage покажет, где котики ещё не прошлись лапками, и поможет убедиться, что наш код надёжен как кошачья интуиция. 😸

Что будем делать?

  • Установим coverage.

  • Настроим запуск тестов с покрытием и выведем отчёт.

Детали реализации

Установка

Устанавливаем coverage:

uv add coverage

Настройка

Обновим Makefile, чтобы запускать тесты с покрытием:

test-coverage:  uv run coverage run -m pytest  test-coverage-report:  uv run coverage report --show-missing

Запуск

Запускаем:

make test-coverage-report

Пример вывода:

Name                    Stmts   Miss  Cover   Missing ----------------------------------------------------- src/app.py                 20      2    90%   15-16 src/controllers/users.py   50     10    80%   25-30, 45-50 src/models/users.py        10      0   100% ----------------------------------------------------- TOTAL                      80     12    85%

Проверка

Отчёт показывает, что у нас 85% покрытия — неплохо, но есть куда расти! Например, строки 25-30 в users.py — это обработка ошибок в /login, которую мы не протестировали. Давай добавим тест в src/tests/test_users.py:

@pytest.mark.asyncio  async def test_login_invalid_credentials(db_session: AsyncSession):  user_data = UserCreate(first_name="Васька", last_name="Мурзиков", email="vasya@whiskers.com")  user = User(**structs.asdict(user_data), hashed_password=pwd_context.hash("wrong-pass"))  db_session.add(user)  await db_session.commit()  with pytest.raises(HTTPException) as exc:  await UserController().login(user_data, UsersRepository(session=db_session))  assert exc.value.status_code == 401

Теперь повторяем make test-coverage — покрытие должно подрасти! Coverage — это как ветеринар для кода: сразу видно, где котик прихрамывает. Пора подтянуть хвосты! 🐾

Шаблон резюме с Jinja — кошачьи карточки

Зачем это нужно?

API — это круто, но хочется увидеть резюме котиков вживую! Сделаем шаблон в стиле баскетбольных карточек 90-х: звёзды сверху, имя-фамилия, описание, данные и табличка с опытом работы. Минимум JS и CSS, только чистый кошачий шик! 😺

Что будем делать?

  • Установим jinja2.

  • Добавим модель и эндпоинт для отображения резюме (уже добавили в users.py).

  • Создадим HTML-шаблон в стиле баскетбольных карточек.

Детали реализации

Установка Jinja

Устанавливаем jinja2:

uv add jinja2

Настройка Jinja

Добавим в файл src/app.py код для работы с шаблонами:

from jinja2 import Environment, PackageLoader, select_autoescape  env = Environment(          loader=PackageLoader("src"),          autoescape=select_autoescape()          )

Шаблон cv.html

Шрифт на карточке похож на Impact для заголовков и Arial для текста. Мы убрали фото, добавили звёзды, описание и табличку с опытом работы. Создаём templates/cv.html:

<!DOCTYPE html>  <html>  <head>      <title>{{ user.first_name }} {{ user.last_name }} - Resume</title>      <style>          .card {              border: 2px solid #000;              width: 400px;              margin: 20px auto;              background: #f0f0f0;              font-family: Arial, sans-serif;              box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.3);          }          .stars {              text-align: center;              font-size: 24px;              color: #ff4500;              padding: 5px;          }          .header {              background: #ff4500;              color: white;              text-align: center;              padding: 10px;              font-family: Impact, sans-serif;              font-size: 24px;              text-transform: uppercase;          }          .subheader {              font-family: Impact, sans-serif;              font-size: 14px;              color: #ff4500;              text-align: center;              margin: 5px 0;          }          .info {              padding: 10px;              font-size: 14px;          }          .description {              font-style: italic;              margin: 10px 0;          }          .stats {              margin: 10px 0;          }          .stats div {              margin: 5px 0;          }          .experience {              border-top: 2px solid #ff4500;              padding: 10px;          }          .experience h3 {              font-family: Impact, sans-serif;              font-size: 16px;              text-align: center;              margin-bottom: 10px;              color: #000;          }          .experience table {              width: 100%;              border-collapse: collapse;              font-size: 12px;          }          .experience th, .experience td {              border: 1px solid #000;              padding: 5px;              text-align: left;          }          .experience th {              background: #ff4500;              color: white;              font-family: Impact, sans-serif;          }      </style>  </head>  <body>      <div class="card">          <div class="stars">⭐⭐⭐⭐⭐</div>          <div class="header">{{ user.first_name }} {{ user.last_name }}</div>          <div class="subheader">PROFESSIONAL CAT CODER</div>          <div class="info">              <div class="stats">                  <div><b>Email:</b> {{ user.email }}</div>                  <div><b>Joined:</b> {{ user.created_at.strftime('%Y-%m-%d') }}</div>              </div>              <div class="description">                  {{ user.first_name }} is a purr-fect coder with a knack for catching bugs faster than a laser pointer. Known for napping on keyboards and delivering meow-nificent code, this cat is a true asset to any team!              </div>          </div>          <div class="experience">              <h3>WORK EXPERIENCE</h3>              <table>                  <tr>                      <th>Company</th>                      <th>Position</th>                      <th>Tech Stack</th>                  </tr>                  {% for cv in user.cvs %}                  {%- for exp in cv.work_experiences %}                  <tr>                      <td>{{ exp.company }}</td>                      <td>{{ exp.job_title }}</td>                      <td>{{ exp.employment_type }}</td>                      <td>{{ exp.start_date }}</td>                      <td>{{ exp.end_date }}</td>                      <td>{{ exp.description }}</td>                  </tr>                  {% endfor %}                  {% endfor %}              </table>          </div>      </div>  </body>  </html>  

Проверка

Добавим тестовые данные через SQL или API, чтобы у пользователя был опыт работы. Например:

INSERT INTO work_experience (user_id, company, position, description, created_at, updated_at)  VALUES ('<user_id>', 'CatTech', 'Senior Whisker Engineer', 'Python, MeowSQL', NOW(), NOW());

Теперь открываем в браузере http://localhost:8000/users/<user_id>/cv. Вы увидите стильную карточку: звёзды сверху, имя-фамилия, описание и табличка с опытом работы. Без лишнего JS — только чистый кошачий шик!

Упаковка в Docker — котики в коробке 📦

Зачем это нужно?

В продакшене код без Docker — как котик без коробки. Упакуем всё аккуратно, чтобы наш API был готов к деплою! 📦

Что будем делать?

  • Создадим Dockerfile.

  • Обновим docker-compose.yaml.

Детали реализации

Dockerfile

Создаём Dockerfile:

FROM python:3.13.3-slim  WORKDIR /app  # Копируем файлы с зависимостями и конфиг  COPY pyproject.toml uv.lock ./  COPY src/configs/settings-example.yaml ./configs/  # Устанавливаем uv (если он не установлен в базовом образе)  RUN pip install --no-cache-dir uv  # Устанавливаем зависимости через uv  RUN uv pip install --system -r pyproject.toml  # Копируем исходный код и остальные файлы  COPY src/ ./src/  COPY src/templates/ ./templates/  COPY src/configs/alembic.ini ./configs/  # Запускаем приложение через granian  CMD ["granian", "--interface", "asgi", "src.app:app"]

Обновлённый docker-compose.yaml

Обновляем docker-compose.yaml, чтобы включить KeyDB и приложение:

services:    app:      build: .      ports:        - "8000:8000"      depends_on:        postgres:          condition: service_healthy        keydb:          condition: service_healthy      networks:        - litestarcats    postgres:      image: postgres:latest      container_name: postgres      hostname: postgres      ports:        - 5432:5432      volumes:        - "postgres-data:/var/lib/postgresql/data"      networks:        - litestarcats      healthcheck:        test: ["CMD-SHELL", "pg_isready -U postgres"]        interval: 10s        timeout: 5s        retries: 3    keydb:      image: eqalpha/keydb:latest      container_name: keydb      ports:        - "6379:6379"      volumes:        - keydb_data:/data      restart: unless-stopped      networks:        - litestarcats      healthcheck:        test: ["CMD-SHELL", "redis-cli ping"]        interval: 10s        timeout: 5s        retries: 3  volumes:    postgres-data:    keydb_data:      driver: local  networks:    litestarcats:      driver: bridge  

Проверка

Запускаем:

docker compose up --build

Проверяем, что API доступно на http://localhost:8000 и резюме отображается. Котики в коробке, а проект в продакшене! Docker — это как переноска для нашего API. 🐾

Котики готовы к продакшену! 🎉

Итоги

Мы вынесли конфиги в отдельный модуль с помощью msgspec, добавили JWT для безопасности, KeyDB для скорости, coverage для надёжности, Jinja для стиля и Docker для деплоя. Наш кошачий API теперь готов к бою! 😺

Впечатления

Litestar — это находка, KeyDB летает, а msgspec сделал код ещё быстрее и консистентнее. Карточки получились на ура, Мурзик теперь выглядит как звезда! ⭐

Что дальше?

В следующей части можно Litestar с FastAPI. А может, кошачьи аватарки через API? Пишите в комментариях, что хотите в четвёртой части! 🐾

Ссылка

Код на GitHub. Лапки вверх, если понравилось! 😻


ссылка на оригинал статьи https://habr.com/ru/articles/901852/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *