- PVSM.RU - https://www.pvsm.ru -
Мы долгие годы писали сервисы, исходя из каких-то своих внутренних ощущений правильности их написания. Но синхронизироваться по хорошим практикам в разных командах бывает довольно сложно и часто хорошие практики не выходили за рамки одной команды, а такого хотелось бы избежать. Поэтому мы решили объединить все хорошие по нашему мнению практики в единый справочник. Этот справочник получил название «Архитектурный гайд». Про него и поговорим в данной статье.
Сразу хотим предупредить! Несмотря на то, что эти рекомендации применяются в наших командах и кажутся нам довольно полезными, они вовсе не являются единственно верными. Мы никого не призываем отказываться от своих архитектур и не говорим, что наши рекомендации лучше остальных. Воспринимайте данный материал как один из возможных справочников по проектированию и разработке сервисов на Python.
К сожалению, в этой статье физически не получится рассмотреть абсолютно все инструменты, которые мы применяем. Если вы хотите узнать обо всех применяемых нами инструментах, можете прочитать данный доклад [1].
Код-стайл — это набор правил и соглашений о том, как писать код. Единообразный код-стайл делает написание кода более простым и быстрым, а также сокращает количество возможных ошибок. Если опустить многочисленные правила по написанию кода, которые мы выработали, и оставить только инструменты, которые нам в этом помогают, то получится вот такой список:
Ruff — супер-быстрый линтер, который значительно превосходит по скорости и количеству правил остальные решения.
Mypy — тайпчекер, который на момент написания статьи содержит в себе наибольшее количество правил и отлично работает.
Hadolint — линтер для докер-файлов помогает придерживаться best practices при их написании.
kube-score — линтер для k8s манифестов тоже для best practices.
При написании кода мы также придерживаемся принципа разделения ответственности. Разделяем функциональность объектов так, чтобы можно было четко ответить на вопрос: «Чем занимается данный объект?». Если в ответе получается несколько зон ответственности, то в 99% случаев такой объект должен быть разделен. Если упростить этот принцип до одного предложения, получится, что «Объект должен содержать минимально-необходимый набор методов для корректного выполнения своей функции».
В нашей концепции у каждого сервиса собственный набор настроек, необходимых для правильной работы. Это могут быть пароли, имена пользователей, ссылки на сервисы и так далее. Все настройки мы храним в файле settings.py
Пример лейаута:
some-app/
└── some_app/
├── main.py
└── settings.py
Пример файла:
class ServiceSettings(pydantic_settings.BaseSettings):
service_name: str = "micro-service",
service_description: str = "Micro service description"
database_url: str = "your-database-url"
database_read_timeout: int = 5
redis_url: str = "your-redis-url"
...
# используется, чтобы загружать переменные из файла .env
model_config = pydantic_settings.SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
populate_by_name=True,
)
settings = ServiceSettings()
Чтобы использовать эти настройки, достаточно импортировать их в другом модуле. В основе паттерна настроек лежит библиотека pydantic_settings, про нее можно прочитать здесь [2]
Мы используем два довольно популярных веб-фреймворка, которые предоставляют всё необходимое для создания API (Application Programming Interface): fastapi [3] или litestar [4].
Мы размещаем API обработчики в модуле api и группируем их по протоколам/технологиям. Если обработчиков не слишком много и они без проблем влезают в один файл, то так и оставляем.
Пример лейаута:
some-app/
└── some_app/
├── __main__.py
└── api/
├── __init__.py
├── rest.py
├── rpc.py
└── soap.py
Если же обработчики не влезают в один файл и с ними неудобно работать, можно разделить api по доменам приложения, группируя в модули по протоколу/технологии. Тогда получится следующая структура:
some-app/
└── some_app/
├── __main__.py
└── api/
├── rest/
├── __init__.py
├── products.py
├── users.py
└── ...
└── rpc/
├── __init__.py
├── file_handling.py
├── admin_operations.py
└── ...
У нас в сообществе приняты следующие типы API:
rest — CRUD для манипуляции сущностями приложения, который подходит для большинства рядовых задач. Например: GET /api/books/. В то же время все действия выполняются через http методы (в данном случае GET, POST, PUT, DELETE), а не содержатся в названии самой ручки. Более подробно про REST можно почитать здесь [5]
rpc — используем для ручек, которые выполняют какие-то действия, поэтому их нельзя отнести к REST. Пути до таких обработчиков обязательно должны содержать в себе глагол. Например: /rpc/sort-binary-tree/, /rpc/send-file/, /rpc/upload-to-pypi/
Мы стараемся избегать версионирования REST API, так как это приводит ко многим сложностям в поддержке такого API. Например, если вы делаете внутреннее приложение и пишете для него фронтенд (чаще всего это наш кейс) — версионирование вам не нужно. Но есть исключение, такое как отсутствие контроля над потребителем API (классический случай — мобильное приложение).
Самый же «рестовый» способ — версионировать accept заголовком. На этот случай мы используем библиотеку fast-version [6].
Альтернативно можно версионировать с помощью пути — /api/v1/, /api/v2/. Такой способ не особо вписывается в REST, так как REST — это архитектурный стиль api для манипуляции сущностями, а версия — не часть сущности. User — всегда user, а не userv1. Однако такой способ получил популярность, потому что удобен. Для него почти ничего не требуется.
Для себя мы решили, что версионирование делаем только через заголовки в случае очень сильно надобности.
Мы придерживаемся DIP (Dependency Inversion Principle) из SOLID при написании сервисов. В этом нам помогает паттерн DI (Dependency Injection).
Однако полностью DIP мы не достигаем, так как мы завязываемся не на абстракции, а берем конкретные объекты. Это становится возможным, так как в Python утиная типизация. То есть мы можем «подпихнуть» любой объект, если он соблюдает требуемый интерфейс. Иными словами, DIP мы одновременно и соблюдаем, и нет.
Раньше для нас приоритетным пакетом был dependency-injector [7], но он долгое время не поддерживал Python 3.12 и мы перебрались на that-depends [8]. Этот пакет предлагает все те же концепции, что и dependency-injector, но при этом намного меньше и проще.
Есть и другие DI пакеты, которые позволяют делать примерно те же самые вещи. Например: dishka [9] и modern-di [10].
Консьюмер — отдельный от веб-приложения процесс, необходимый для работы с очередями сообщений. Он в бесконечном цикле слушает очередь, вычитывает сообщения и выполняет логику обработки. Консьюмеры — отличные от API сущности, поэтому мы выделяем их в отдельный модуль следующим образом:
some-app/
└── some_app/
├── __init__.py
├── __main__.py
└── consumers/
├── consumer_1.py
└── consumer_2.py
За последний год большую популярность получила библиотека FastStream [11]. Она значительно упрощает создание консьюмеров и хорошо подходит нам, так как мы в основном используем Kafka и NATS. FastStream автоматически генерирует AsyncAPI документацию (AsyncAPI [12]) для наших консьюмеров. Это аналог Swagger [13] для HTTP API, но предназначенный для описания консьюмеров.
Вот небольшой пример работы с NATS-очередью с использованием FastStream:
from faststream import FastStream
from faststream.nats import NatsBroker
# Создаем брокер, который будет работать с инстансом NATS
broker = NatsBroker("nats://broker-host:4222")
app = FastStream(broker)
# Регистрируем обработчик в стиле FastAPI
@broker.subscriber("example.subject")
async def handle_message(message: str):
print(f"Received message: {message}")
# Ваша логика обработки сообщения
if __name__ == "__main__":
app.run()
С FastStream мы тратим меньше сил на создание консьюмеров и можем больше сосредоточиться на логике приложения. Если ещё не пользовались, почитайте официальную документацию [14].
Конечно, учитывая, что мы работаем в банке, у нас есть много легаси-инфраструктуры, например, ActiveMQ Artemis. Для таких случаев мы разработали собственный пакет [15], совместимый с FastStream.
Сервисный слой — центр бизнес-логики приложения. Как правило, сервис — это класс, методы которого реализуют бизнес-сценарий.
Мы используем вот такой лейаут:
some-app/
└── some_app/
├── init.py
├── main.py
└── services/
├── init.py
├── some_service_1.py
└── some_service_2.py
При написании сервисов мы придерживаемся следующих правил:
Сервис может взаимодействовать с БД/кешами/очередями/сетью/любой другой инфраструктурой только через зависимости, например, через соответствующих клиентов или репозитории.
Только сервисы содержат бизнес-логику.
Сервисы могут использовать другие сервисы внутри себя, если это необходимо.
Сервис рекомендуется определять при помощи dataclasses со следующими параметрами:
kw_only — параметры для dataclass передаются только через именованные параметры, не через последовательные. Это уменьшает вероятность неправильной передачи параметров.
frozen — параметры передаются в процессе создания и не могут быть изменены в процессе работы. Это добавляет консистентности в работе сервиса, обеспечивая идентичную работу после создания.
slots — генерирует slots у dataclass и таким образом ограничивает набор атрибутов у экземпляра класса. Это экономит использование памяти и ускоряет доступ к атрибутам.
Например, при создании и удалении пользователя нам необходимо отправлять соответствующее сообщение в какую-нибудь очередь в Kafka.
Вот этот код не самого лучшего качества:
@dataclasses.dataclass
class UserService:
database_host: str
database_port: str
kafka_topic: str
kafka_host: str
async def create_user(self, user_data: UserData) -> None:
database_connection = asyncpg.connect(
self.database_host,
self.database_port
)
await database_connection.execute("INSERT INTO users ...")
kafka_connection = aiokafka.connect(self.kafka_host)
await kafka_connection.send(user_data, self.kafka_topic)
async def delete_user(self, user_id: str) -> None:
database_connection = asyncpg.connect(
self.database_host,
self.database_port
)
await database_connection.execute("DELETE FROM users WHERE ...")
kafka_connection = aiokafka.connect(self.kafka_host)
await kafka_connection.send(user_data, self.kafka_topic)
Код не самый хороший потому, что:
нарушен принцип единой ответственности из SOLID
код тяжело переиспользовать
код тяжело воспринимать тому, кто его не писал
код тяжело тестировать
в определении dataclass не использованы: kw_only, frozen, slots
Такой код уже намного лучше:
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class UserService:
user_repository: UserRepository
kafka_producer: KafkaProducer
async def create_user(self, user_data: UserData) -> None:
await self.user_repository.create_user(user_data)
await self.kafka_producer.send_message(user_data)
async def delete_user(self, user_id: str) -> None:
await self.user_repository.delete_user(user_id)
await self.kafka_producer.send_message(user_id)
Этот пример лучше потому, что:
код хорошо читается
код можно переиспользовать, ведь вся логика взаимодействия с БД и очередью находится в отдельных объектах
тестируемость кода заметно выше, так как можно легко тестировать методы зависимостей сервиса
dataclass ведет себя намного более предсказуемо из-за kw_only, frozen, slots
Когда у нас возникает необходимость в реляционной БД, мы используем связку SQLAlchemy [16] + Alembic [17] как самую популярную и проверенную временем.
Наш лейаут:
some-app/
└── some_app/
├── main.py
└── database/
├── init.py
├── migrations/
├── versions/...
├── env.py
└── script.py.mako
├── connection.py
├── models.py
└── repositories.py
Всё, что связано с Alembic и миграциями хранится в database/migrations.
versions/ — здесь расположены сами файлы миграций Alembic
env.py — используется для настройки Alembic (запуск/форматирование миграций/прочее)
script.py.mako — Mako шаблон для генерации миграций через alembic revision --autogenerate
Что касается основных файлов на верхнем уровне:
connection.py — инструментарий для создания соединений с базой. Управление сессиями, модификации движков SQLAlchemy и прочее.
models.py — описание таблиц и их взаимоотношений в БД. Если моделей много — их можно разнести по разным файлам.
repositories.py — набор репозиториев для работы с БД. Если моделей много — их можно разнести по разным файлам и положить рядом с моделью.
Репозитории содержат методы для общения с БД. Это могут быть как CRUD методы, так и комбинация CRUD вызовов внутри одного метода.
Мы разделяем репозитории по принципу единой ответственности. Одна таблица — один репозиторий. Для облегчения их создания мы используем библиотеку Advanced Alchemy [18]. Она предоставляет базовый класс со всеми необходимыми CRUD операциями над sqlalchemy моделями и много чего еще. Все, что нужно — просто указать модель, на основе которой следует создать репозиторий.
Вот пример репозитория на основе базового репозитория из этой библиотеки:
class PostRepository(SQLAlchemyAsyncRepository[Post]):
"""Repository for managing blog posts."""
model_type = Post
# уже реализовано в родительском классе репозитория
async def add(...)
async def add_many(...)
async def list(...)
async def update(...)
async def delete(...)
...
На основе этой библиотеки можно создавать собственные базовые репозитории для дальнейшего переиспользования. Advanced Alchemy совместима как с litestar, так и с fastapi.
Чтобы интегрироваться с любыми внешними источниками (Redis, S3, любой другой сервис), мы выделяем сущности для общения с ними в отдельный модуль и называем «клиентами».
Интеграционный слой состоит из клиентов, которые обращаются к другим сервисам или инфраструктуре. Клиенты принято размещать в папке external на корневом уровне или же в файлах сервисов, в которых они используются.
some-app/
└── some_app/
├── main.py
└── external/
├── init.py
├── s3.py
└── some_client.py
Простейший клиент может выглядеть так:
import dataclasses
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class HTTPClient:
service_host: str
def make_request(self, method: str, data: dict, ...) -> dict: ...
А чтобы такой клиент был надежным, мы добавляем в него resilience.
При неудачном выполнении запроса его следует повторить. При помощи повторных запросов мы довольно дешево увеличиваем надежность системы. Для этой цели мы выбрали библиотеку stamina [19].
Пример resilient клиента с использованием stamina:
import stamina
class HTTPClient(...):
@stamina.retry(on=CustomException, attempts=3, ...)
def make_request(self, method: str, data: dict, ...) -> dict: ...
Есть и другие библиотеки, такие как: backoff [20] или tenacity [21]. Но stamina показалась нам самой подходящей.
Помимо повторных запросов, для предотвращения перегрузки системы при многочисленных ошибках можно установить Circuit Breaker. Он позволяет временно приостановить выполнение запросов к ресурсу, который регулярно возвращает ошибки, чтобы дать ему время на восстановление. Но если вы не видите реальной угрозы положить какую-то из систем избыточными запросами, можно обойтись и без Circuit breaker. Почитать про паттерн Circuit Breaker можно здесь [22].
Пример реализации клиента с Circuit Breaker на основе библиотеки circuitbreaker [23]:
from circuitbreaker import circuit
class HTTPClient(...):
@circut(failure_threshold=10)
def make_request(self, method: str, data: dict, ...) -> dict: ...
Для написания тестов мы, как, наверное, и большинство, используем фреймворк Pytest [24]. Для себя мы выработали следующий лейаут:
some-app
├── some_app
├── api/
└── users.py
├── services/
└── comments_service.py
└── consumers/
└── posts_consumer.py
└── tests/
├── conftest.py
├── factory.py
├── helpers.py
├── api/
└── test_users.py
├── services/
└── test_comments.py
└── consumers/
└── test_posts.py
По сути, мы копируем названия модулей и файлов, полностью повторяя структуру проекта, но к названиям файлов добавляем префикс “test_”, как того требует Pytest. А помимо файлов с тестами в тот же модуль кладем вспомогательные файлы:
conftest.py — стандартный файл для фреймворка Pytest, там находятся фикстуры. Прочитать про него можно тут [25]. Он может находиться внутри любого другого модуля, содержащего тесты. Если мы понимаем, что при тесте сервисов или любой другой сущности у нас будут какие-то особенные фикстуры, относящиеся только к ним, то можно положить их рядом с тестами. Пример пути до такого файла: tests/[api | services | consumers | …]/conftest.py.
factory.py — здесь хранятся объекты Factory, которые помогают при генерации фейковых данных для тестов. Раньше мы использовали faker или factory-boy, но перешли на более новую библиотеку Polyfactory [26].
helpers.py — сюда мы складываем вспомогательные функции, необходимые для проведения тестирования и соблюдения принципа DRY.
Мы выработали для себя следующие рекомендации по написанию тестов:
|
В веб-приложениях отдается предпочтение интеграционным тестам над юнитами. В пакетах отдаем предпочтение юнитам. |
|
Тесты должны проверять только что-то одно. |
|
Используем паттерн ААА при написании тестов (ниже будет его пример, дополнительно можно прочитать статью [27]). |
|
Используем параметризацию везде, где это уместно. Обычно «хорошие» тесты запускаются по несколько раз с различными параметрами. Про параметризацию можно почитать статью [28]. |
|
Паралеллизование тестов дает значительный прирост в скорости их выполнения. Для этого мы используем пакет pytest-xdist. |
|
Собираем отчеты о покрытии кода при помощи пакета pytest-cov. |
|
Уровень покрытия тестами — 90-95 процентов или больше при желании. |
|
Запускаем тесты при помощи docker-compose. |
Рассмотрим несколько примеров теста. Сначала не самый хороший тест:
def test_some_logic(my_service: MyService) -> None:
test_value_1 = "test-value-1"
test_result_1 = "test-result-1"
received_result_1 = my_service.perform_some_logic(test_value_1)
assert received_result_1 == test_result_1
test_value_2 = "test-value-2"
test_result_2 = "test-result-2"
received_result_2 = my_service.perform_some_logic(test_value_2)
assert received_result_2 == test_result_2
test_value_3 = "test-value-3"
test_result_3 = "test-result-3"
received_result_3 = my_service.perform_some_logic(test_value_3)
assert received_result_3 == test_result_3
И вот почему этот тест не самый хороший:
Тест тяжело читать, ведь непонятно, что именно он проверяет.
Тест не использует паттерн ААА.
Тест не использует параметризацию.
Намного лучше так:
@pytest.mark.parametrize(
("test_value", "expected_result"),
[
("test-value-1", "test-result-1"),
("test-value-2", "test-result-2"),
("test-value-3", "test-result-3"),
],
)
def test_some_logic(test_value: str, expected_result: str, my_service: MyService) -> None:
assert my_service.perform_some_logic(test_value) == expected_result
Еще пример не самого хорошего теста.
def test_some_different_logic(my_service: MyService) -> None:
test_value_1 = "value-first-test-func"
test_result_1 = "result-first-test-func"
received_result_1 = my_service.first_function_to_test(test_value_1)
assert received_result_1 == test_result_1
test_value_2 = "value-second-test-func"
test_result_2 = "result-second-test-func"
received_result_2 = my_service.second_function_to_test(test_value_2)
assert received_result_2 == test_result_2
Он плохо написан потому, что:
Не следует ААА.
В одном тесте тестируются сразу две функции - first_function_to_test и second_function_to_test. Так быть не должно, тест обязан проверять что-то одно.
Намного лучше так:
def test_first_func(my_service: MyService) -> None:
test_value = "value-first-test-func"
test_result = "result-first-test-func"
received_result = my_service.first_function_to_test(test_value)
assert received_result == test_result
def test_second_func(my_service: MyService) -> None:
test_value = "value-second-test-func"
test_result = "result-second-test-func"
received_result = my_service.second_function_to_test(test_value)
assert received_result == test_result
Здесь все хорошо, каждый из тестов проверяет что-то одно.
Для себя мы решили запускать тесты через compose как локально, так и на стадии CI.
На то есть несколько причин:
Контейнерное окружение дает лучшую изоляцию при тестировании.
В пайплайнах тесты также гоняются через compose, поэтому локально мы получаем репрезентативность 1к1.
Вся инфраструктура для интеграционных тестов поднимается в compose автоматически, что удобно.
Пример compose:
services:
application:
image: ...
depends:
- migrations
ports:
- 8000:8000
environment:
- ...
database:
image: postgres
ports:
- 5432:5432
migrations:
image: ...
depends:
- database
command: alembic upgrade head
Здесь application — наш веб-сервис. Помимо него можно увидеть базу данных и миграции. Миграции будут автоматически применяться перед каждым запуском.
У сервиса application не указана команда для запуска. Это связано с тем, что команда подставляется в момент запуска тестов. Вот такую команду мы выполняем, чтобы запустить тесты локально или на стадии CI:
docker compose run application pytest -sv -n 4 --cov=. --cov-report term-missing
Этой командой мы запускаем тесты в 4 процесса с помощью pytest-xdist [29], а также собираем отчет о тестировании при помощи pytest-cov [30].
В наших сервисах довольно много внимания уделяется observability. Поэтому, чтобы не таскать boilerplate из проекта в проект, мы создали пакет, который делает это за нас. Почитать про нее подробнее можно здесь [31].
С помощью microbootstrap можно создавать сервисы с уже готовыми интеграциями для:
Sentry — отличный инструмент для траблшутинга
Opentelemetry — собирает для нас трейсы
Prometheus — собирает метрики
и не только…
В основе Microbootstrap лежит паттерн settings, который позволяет декларативно задавать параметры для вышеперечисленных инструментов. Вот пример его использования с fastapi:
# settings.py
from microbootstrap import FastApiSettings
class YourSettings(FastApiSettings):
... # Все ваши настройки здесь
settings = YourSettings()
# application.py
import fastapi
from microbootstrap.bootstrappers.litestar import FastApiBootstrapper
from your_application.settings import settings
# Готовое приложение с Sentry, Opentelemetry, Prometheus и т.д.
application: fastapi.FastAPI = FastApiBootstrapper(settings).bootstrap()
У нас также есть централизованный пайплайн, который сильно облегчает создание нового сервиса. Ведь все, что надо сделать для работающего пайплайна — это добавить три строчки в .gitlab-ci.yml.
Вот так выглядит его подключение:
include:
- project: "python-community/pypelines"
file: "presets/backend--v1.yml"
Всего за три строчки мы получаем следующие стадии:
статический анализ
сборка
тестирование
сканирование на уязвимости
релиз образа
деплой
запуск Е2Е тестов
У пайплайнов есть подробная документация [32]. Для более четкого понимания можно еще посмотреть доклад [33] о них.
Архитектура — не статичное решение, а живой процесс, который требует постоянной оценки и улучшения. Каждый проект уникален, поэтому к архитектурным решениям надо подходить гибко, выбирать инструменты и подходы, которые лучше всего соответствуют конкретным требованиям.
Наш подход к архитектуре призван помочь в разработке сервисов на Python. С его помощью мы закрываем у себя большинство базовых вопросов, которые могут возникнуть в процессе разработки. Конечно, у всех своя специфика. Поэтому мы постарались предложить практичные решения, которые подходят для большинства случаев. Плюс решения всегда можно адаптировать и улучшить в зависимости от специфики вашего проекта.
Мы надеемся, что эта статья поможет вам в непростом деле разработки сервисов и будет полезна в решении практических задач на каждом этапе разработки.
Автор: insani7y
Источник [34]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/412581
Ссылки в тексте:
[1] доклад: https://speakerdeck.com/xfenix/iz-chiegho-sostoit-mikrosiervis-v-kontsie-2024-ghoda
[2] здесь: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
[3] fastapi: https://fastapi.tiangolo.com/
[4] litestar: https://litestar.dev/
[5] здесь: https://habr.com/ru/articles/590679/
[6] библиотеку fast-version: https://github.com/community-of-python/fast-version
[7] dependency-injector: https://python-dependency-injector.ets-labs.org/
[8] that-depends: https://github.com/modern-python/that-depends
[9] dishka: https://github.com/reagento/dishka
[10] modern-di: https://github.com/modern-python/modern-di
[11] FastStream: https://github.com/airtai/faststream
[12] AsyncAPI: https://www.asyncapi.com/en
[13] Swagger: https://swagger.io/docs/
[14] официальную документацию: https://faststream.airt.ai
[15] собственный пакет: https://github.com/community-of-python/stompman/blob/main/packages/faststream-stomp/README.md
[16] SQLAlchemy: https://docs.sqlalchemy.org/en/20/
[17] Alembic: https://alembic.sqlalchemy.org/en/latest/
[18] Advanced Alchemy: https://docs.advanced-alchemy.litestar.dev/latest/
[19] библиотеку stamina: https://pypi.org/project/stamina/
[20] backoff: https://pypi.org/project/backoff/
[21] tenacity: https://pypi.org/project/tenacity/
[22] здесь: https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern
[23] библиотеки circuitbreaker: https://pypi.org/project/circuitbreaker/
[24] фреймворк Pytest: https://docs.pytest.org/en/stable/contents.html
[25] тут: https://docs.pytest.org/en/latest/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files
[26] Polyfactory: https://polyfactory.litestar.dev/latest/
[27] статью: https://habr.com/ru/articles/507594/
[28] статью: https://docs.pytest.org/en/latest/how-to/parametrize.html
[29] pytest-xdist: https://pypi.org/project/pytest-xdist/
[30] pytest-cov: https://pypi.org/project/pytest-cov/
[31] здесь: https://github.com/community-of-python/microbootstrap
[32] подробная документация: https://github.com/insani7y/moscow-python-conf-2024
[33] доклад: https://www.youtube.com/watch?v=NA2njLAblHo
[34] Источник: https://habr.com/ru/companies/raiffeisenbank/articles/885792/?utm_source=habrahabr&utm_medium=rss&utm_campaign=885792
Нажмите здесь для печати.