From b8388fa4a2e968059b7d71864d4889809cfd3383 Mon Sep 17 00:00:00 2001 From: Oleg Yurchik Date: Mon, 26 Oct 2026 14:04:05 +0300 Subject: [PATCH] Add database, telegram bot, and tests skills; update stack and fastapi skills - Add python-development-database skill: metaorm patterns for tables, repositories, filters, settings, transactions, pagination, and exceptions - Add python-development-telegram-bot skill: aiogram bot service with polling/webhook, router structure, middlewares, inline keyboards, and pagination - Add python-development-tests skill: test organization guidelines with pytest, fixtures, and dependency injection rules - Update python-development-stack: replace SQLModel/SQLAlchemy with metaorm; add pytest, pytest-asyncio, pytest-cov, faker - Update python-development-fastapi: add sections for dependencies and list endpoints with ListResponse schema - Update python-development-code-style: allow ctx as exception for typer.Context parameter - Update python-development index with links to all three new skills Co-Authored-By: Claude Sonnet 4.6 --- skills/python-development-code-style/SKILL.md | 1 + skills/python-development-database/SKILL.md | 193 +++++++++ skills/python-development-fastapi/SKILL.md | 110 +++++ skills/python-development-stack/SKILL.md | 5 +- .../python-development-telegram-bot/SKILL.md | 405 ++++++++++++++++++ skills/python-development-tests/SKILL.md | 18 + skills/python-development/SKILL.md | 3 + 7 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 skills/python-development-database/SKILL.md create mode 100644 skills/python-development-telegram-bot/SKILL.md create mode 100644 skills/python-development-tests/SKILL.md diff --git a/skills/python-development-code-style/SKILL.md b/skills/python-development-code-style/SKILL.md index 9217936..020f392 100644 --- a/skills/python-development-code-style/SKILL.md +++ b/skills/python-development-code-style/SKILL.md @@ -54,3 +54,4 @@ description: Code conventions, typing, testing habits, and engineering principle - Do not use `from __future__ import annotations`. Use explicit forward-reference strings (e.g. `"MyClass"`) when necessary. - Do not use `if TYPE_CHECKING:` blocks. Import the types you need at the top level. Deferred imports inside `TYPE_CHECKING` hide dependencies and break runtime introspection. - Do not abbreviate variable or parameter names. Write them out in full so the intent is obvious without context. Examples of forbidden abbreviations: `idx` (use `index`), `dep` (use `dependency`), `cfg` (use `config` or `configuration`), `msg` (use `message`), `err` (use `error`), `resp` (use `response`). The same rule applies to class attributes, function parameters, and local variables. +- Exception: when implementing Typer commands, the parameter name `ctx` is allowed for the `typer.Context` object because it is the idiomatic name required by the framework. diff --git a/skills/python-development-database/SKILL.md b/skills/python-development-database/SKILL.md new file mode 100644 index 0000000..b48b509 --- /dev/null +++ b/skills/python-development-database/SKILL.md @@ -0,0 +1,193 @@ +--- +name: python-development-database +description: Patterns for working with relational databases using metaorm. Use when defining tables, repositories, filters, database settings, or transaction logic. +--- + +## Rules +- Install the ORM layer with `pip install metaorm`. Requires Python `>=3.12`. +- Every database sub-system lives in its own package (conventionally named `database/` or `db/`). The package follows the repository pattern: one repository class per database table. +- The minimum package structure is: + ``` + database/ + __init__.py + repositories/ + __init__.py + user.py + ... + filters.py # only if filters are not imported from an external schema package + settings.py # only if RepositorySettings from metaorm needs extension + cli.py # optional, for management commands (create-tables, migrations, etc.) + ``` +- In `database/__init__.py` export the public API: repository classes, filter classes, settings, and any shared exceptions. Keep `__all__` as a tuple grouped by source module. + +### Tables +- Table models inherit from `metaorm.BaseTable` and use `sqlmodel.Field` for column declarations. They are declared with `table=True`. +- Keep table classes in the same module as their repository or in a dedicated `tables.py` module when shared across multiple repositories. Prefer co-location with the repository. +- Use type unions for nullable columns: `id: int | None = Field(default=None, primary_key=True)`. +- Define relationships with `metaorm.Relationship` (re-exported from SQLModel). Always declare the back-populates attribute. +- Provide `from_item` and `to_item` class/instance methods when the repository uses a DTO. + +Example `database/repositories/user.py`: +```python +from metaorm import BaseTable, Field, Relationship + + +class UserTable(BaseTable, table=True): + __tablename__ = "users" + + id: int | None = Field(default=None, primary_key=True) + name: str + email: str = Field(unique=True) + orders: list["OrderTable"] = Relationship(back_populates="user") +``` + +### Filters +- Filters inherit from `metaorm.BaseFilter` (re-exported from `pydantic-filters`). Filter fields are optional by default; simply declare the type without `| None = None`. +- Keep filters in `database/filters.py` when they are reused across the project, or inline in the repository module when they are private to that repository. +- Filter field names must match the table column names they filter. + +Example `database/filters.py`: +```python +from metaorm import BaseFilter + + +class UserFilter(BaseFilter): + name: str + email: str + + +class OrderFilter(BaseFilter): + user_id: int + total: float +``` + +### Repositories +- Every repository inherits from `metaorm.BaseRepository` and is declared with keyword arguments `table=...` and `filter_=...`. Optionally add `dto=...` to enable automatic DTO mapping. +- Repository classes must specify `table=` and `filter_=` on the first class in the inheritance chain; subsequent subclasses can omit them if the parent already defined them. +- Keep each repository in its own module under `database/repositories/`. The module name matches the table name in singular snake_case. +- Provide a classmethod or factory function for repository instantiation when the project uses a shared `RepositoriesContainer`. +- Repository methods are async. The built-in CRUD methods are: `create_item`, `get_item`, `get_items`, `get_items_count`, `update_items`, `delete_items`. +- `get_items`, `update_items` return an `AsyncGenerator`. Collect results with list comprehension: `[item async for item in repo.get_items(...)]`. + +Example `database/repositories/user.py`: +```python +from metaorm import BaseRepository + +from project.database.filters import UserFilter +from .tables import UserTable + + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter): + pass +``` + +Example with DTO `database/repositories/user.py`: +```python +from pydantic import BaseModel + +from metaorm import BaseRepository + +from project.database.filters import UserFilter +from .tables import UserTable + + +class User(BaseModel): + id: int | None = None + name: str + email: str + + +class UserRepository(BaseRepository, table=UserTable, filter_=UserFilter, dto=User): + pass +``` + +### Settings +- If the default `metaorm.RepositorySettings` (fields: `dsn`, `pool_size`, `pool_recycle`, `pool_timeout`) is sufficient, import and use it directly. Do not create a local wrapper. +- If the project needs additional database-level configuration (e.g. migration settings, schema names, connection retries), define a subclass in `database/settings.py` that inherits from `metaorm.RepositorySettings`. +- Pass the settings instance to the repository constructor or to a shared `RepositoriesContainer`. + +Example `database/settings.py`: +```python +from metaorm import RepositorySettings +from pydantic import Field + + +class DatabaseSettings(RepositorySettings): + migration_path: str = "migrations" + auto_migrate: bool = False +``` + +### Transactions +- Every CRUD method automatically runs inside a transaction. Do not wrap single CRUD calls in an explicit transaction unless you need to combine multiple operations atomically. +- Use `repository.transaction()` as an async context manager when you need to group multiple operations on the same repository into one atomic unit. +- Use `RepositoriesContainer` when a single atomic transaction must span multiple repositories. Retrieve repositories via `container.get_repository(RepositoryClass)` so they share the container's session context. +- `RepositoriesContainer.create_tables(*repository_classes)` creates tables for all given repositories sequentially using the shared engine. +- Use `container.transaction()` (or `repository.nested_transaction()`) for savepoints inside an existing transaction. An exception inside a nested transaction rolls back only the savepoint, leaving the outer transaction intact. + +Example multi-repository transaction `database/transactions.py`: +```python +from metaorm import RepositoriesContainer, RepositorySettings + +from .repositories.order import OrderRepository, OrderTable +from .repositories.user import UserRepository, UserTable + + +async def create_user_with_order(settings: RepositorySettings) -> None: + container = RepositoriesContainer(settings=settings) + user_repo = container.get_repository(UserRepository) + order_repo = container.get_repository(OrderRepository) + + async with container.transaction(): + user = await user_repo.create_item(UserTable(name="Alice")) + await order_repo.create_item(OrderTable(user_id=user.id, total=100.00)) +``` + +### Pagination, sorting, and eager loading +- Use `metaorm.OffsetPagination` or `metaorm.PagePagination` for result pagination. +- Use `metaorm.BaseSort` for ordering. Pass it to `get_items` as `sort=BaseSort(sort_by="column", sort_by_order="asc|desc")`. +- Pass SQLAlchemy eager-loading options (`joinedload`, `selectinload`) to `get_items` via the `options` parameter. + +Example `database/repositories/book.py`: +```python +from sqlalchemy.orm import joinedload + +from metaorm import BaseFilter, BaseRepository, BaseSort, BaseTable, Field, OffsetPagination + + +class BookTable(BaseTable, table=True): + __tablename__ = "books" + + id: int | None = Field(default=None, primary_key=True) + title: str + author_id: int = Field(foreign_key="authors.id") + + +class BookFilter(BaseFilter): + title: str + author_id: int + + +class BookRepository(BaseRepository, table=BookTable, filter_=BookFilter): + pass + + +async def list_books(repository: BookRepository) -> list[BookTable]: + pagination = OffsetPagination(offset=0, limit=20) + sort = BaseSort(sort_by="title", sort_by_order="asc") + return [ + item async for item in repository.get_items( + pagination=pagination, + sort=sort, + options=[joinedload(BookTable.author)], + ) + ] +``` + +### Exceptions +- Metaorm raises `DatabaseException` subclasses. Catch them at the service layer, not inside repositories. +- Available exception types: `NotFoundError`, `AlreadyExistsError`, `HaveNoSessionError`. All inherit from `DatabaseException`. +- Let SQLAlchemy `IntegrityError` propagate as `AlreadyExistsError` when it signals a unique constraint violation; the library already does this internally for `create_item`. + +### CLI (optional) +- If the database package provides management commands (create tables, health check, seed data), place them in `database/cli.py`. +- CLI commands are async and accept the database settings as a parameter. They use the repository classes or `RepositoriesContainer` directly. diff --git a/skills/python-development-fastapi/SKILL.md b/skills/python-development-fastapi/SKILL.md index f9bb4ae..4760b59 100644 --- a/skills/python-development-fastapi/SKILL.md +++ b/skills/python-development-fastapi/SKILL.md @@ -83,6 +83,116 @@ class RESTAPIService(facet.AsyncioServiceMixin): - `dependencies.py` — FastAPI dependency functions reused across handlers in the package. - `exceptions.py` — custom HTTP exceptions for this domain. +### Dependencies + +- Declare reusable dependencies in `dependencies.py` inside the handler sub-package. Dependencies are plain functions or async functions that return a value needed by endpoints (database session, current user, settings, etc.). +- Use `fastapi.Depends` in `router.py` to wire dependencies to routes. Avoid calling dependencies directly in handlers. +- Keep dependencies stateless; if they need configuration, accept it as an argument and use a closure or a callable class when wiring. + +Example `api/rest/users/dependencies.py`: +```python +import fastapi + +from project.api.rest.exceptions import HTTPUnauthorized +from project.api.rest.dependencies import database + + +async def current_user( + database: DatabaseService = fastapi.Depends(database), + token: str = fastapi.Depends(tokens.oauth2_scheme), +) -> User: + db_token = await database.get_token(token) + if db_token is None: + raise HTTPUnauthorized() + return await database.get_user(filter_=UserFilter(id=db_token.user_id)) +``` + +Example `api/rest/users/schemas.py`: +```python +from pydantic import BaseModel + + +class UserResponse(BaseModel): + user_id: int + requested_by: str +``` + +Example `api/rest/users/handlers.py` consuming a dependency: +```python +import fastapi + +from . import dependencies, schemas + + +async def get_user( + id: int, + user: User = fastapi.Depends(dependencies.current_user), +) -> schemas.UserResponse: + return schemas.UserResponse(user_id=id, requested_by=user.username) +``` + +### List endpoints + +- Use a shared `ListResponse` schema for endpoints that return collections. It is defined once in `api/rest/schemas.py` and reused across packages. +- Extract pagination and search via a reusable `pagination` dependency declared in `api/rest/dependencies.py`. + +Example `api/rest/schemas.py`: +```python +from typing import Any +from pydantic import BaseModel + + +class ListResponse(BaseModel): + total: int + next: str | None + previous: str | None + data: list[Any] + + +class UserResponse(BaseModel): + ... + + +class UsersResponse(ListResponse): + data: list[UserResponse] +``` + +Example `api/rest/dependencies.py`: +```python +import fastapi +from pydantic import BaseModel + + +class Pagination(BaseModel): + cursor: str | None = None + limit: int = 20 + search: str | None = None + order_by: str | None = None + + +def pagination( + cursor: str | None = fastapi.Query(default=None), + limit: int = fastapi.Query(default=20, ge=1), + search: str | None = fastapi.Query(default=None), + order_by: str | None = fastapi.Query(default=None) +) -> PaginationParams: + return PaginationParams(cursor=cursor, limit=limit, search=search) +``` + +Example `api/rest/users/handlers.py` using the list schema and pagination dependency: +```python +import fastapi + +from api.rest.dependencies import PaginationParams, pagination +from . import schemas + + +async def get_users( + pagination: PaginationParams = fastapi.Depends(pagination), +) -> schemas.UsersResponse: + ... +``` + Example `api/rest/users/router.py`: ```python import fastapi diff --git a/skills/python-development-stack/SKILL.md b/skills/python-development-stack/SKILL.md index 6558120..7f95a43 100644 --- a/skills/python-development-stack/SKILL.md +++ b/skills/python-development-stack/SKILL.md @@ -6,11 +6,14 @@ description: Technology stack decisions for Python services. Use when adding dep ## Rules - Use `uv` as the package manager for all Python dependency operations. - Use `FastAPI` for REST API and HTTP services. -- Use `SQLModel` and `SQLAlchemy` for database access. Async drivers: `asyncpg` for PostgreSQL, `aiosqlite` for SQLite. +- Use `metaorm` for database access. It is built on top of `SQLModel` and `SQLAlchemy` and provides built-in CRUD, DTO mapping, pagination, sorting, and transaction management. Async drivers: `asyncpg` for PostgreSQL, `aiosqlite` for SQLite. - Use `alembic` for database migrations. - Use `facet` for hierarchical async service composition with dependency management and graceful shutdown. - Use `typer` for CLI interfaces. - Use `pydantic` for DTOs and data validation; use `pydantic-settings` for application configuration. - Use `loguru` for logging. +- Use `pytest` with `pytest-asyncio` for testing. +- Use `pytest-cov` for coverage reporting. +- Use `faker` for generating test data. - Use `pyyaml` for YAML parsing; use `pyyaml-include` for `!include` tags and `pyyaml-env-tag` for `!env` interpolation inside YAML config files. diff --git a/skills/python-development-telegram-bot/SKILL.md b/skills/python-development-telegram-bot/SKILL.md new file mode 100644 index 0000000..9a8d35f --- /dev/null +++ b/skills/python-development-telegram-bot/SKILL.md @@ -0,0 +1,405 @@ +--- +name: python-development-telegram-bot +description: Patterns for building a Telegram bot service using aiogram inside the facet service tree. Use when creating bot handlers, routers, callbacks, inline keyboards, pagination, or the telegram_bot service package. +--- + +## Rules +- The Telegram bot service lives in a sub-package named `telegram_bot/` inside the root package. It is a regular facet service: it has `service.py`, `settings.py`, and `__init__.py`. +- Use `aiogram` as the Telegram Bot API framework. +- The settings class inherits from `pydantic.BaseModel` (not `BaseSettings`). It declares the minimum required: `method` (polling or webhook) and `token`. +- Provide separate settings subclasses for each method: + - `TelegramBotPollingSettings` — adds `timeout`. + - `TelegramBotWebhookSettings` — adds `root_url`, `root_path`, `port`, `secret_access_key`. +- The base service class inherits from `facet.AsyncioServiceMixin`. Its constructor accepts the settings instance declared in the same package's `settings.py`. Keep the constructor minimal; heavy initialization happens in `start()`. +- The base service creates `aiogram.Bot` and `aiogram.Dispatcher` in `__init__` or `start()`. +- Extract router registration into a `get_router()` function (or `setup_dispatcher()` when the dispatcher itself needs external dependencies). Handlers live in `handlers.py`, router wiring lives in `router.py`. +- Register the router in the service using `dispatcher.include_router(get_router())`. +- Use `aiogram.fsm.state.StatesGroup` for multi-step conversations. +- Use `aiogram.filters.callback_data.CallbackData` for typed callback data on inline buttons. Every callback action gets its own `CallbackData` subclass with a unique `prefix`. + +### Minimal complete example + +`telegram_bot/settings.py`: +```python +import enum + +from pydantic import BaseModel + + +class TelegramBotMethodEnum(str, enum.Enum): + POLLING = "polling" + WEBHOOK = "webhook" + + +class TelegramBotSettings(BaseModel): + method: TelegramBotMethodEnum = TelegramBotMethodEnum.POLLING + token: str = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" +``` + +`telegram_bot/polling/settings.py`: +```python +from typing import Literal + +from pydantic import PositiveInt + +from project.telegram_bot.enums import TelegramBotMethodEnum +from project.telegram_bot.settings import TelegramBotSettings + + +class TelegramBotPollingSettings(TelegramBotSettings): + method: Literal[TelegramBotMethodEnum.POLLING] = TelegramBotMethodEnum.POLLING + timeout: PositiveInt = 10 # in seconds +``` + +`telegram_bot/webhook/settings.py`: +```python +from typing import Literal + +from pydantic import AnyHttpUrl, conint, constr + +from project.telegram_bot.enums import TelegramBotMethodEnum +from project.telegram_bot.settings import TelegramBotSettings + + +class TelegramBotWebhookSettings(TelegramBotSettings): + method: Literal[TelegramBotMethodEnum.WEBHOOK] = TelegramBotMethodEnum.WEBHOOK + + root_url: AnyHttpUrl + root_path: str = "/" + port: conint(ge=1, le=65535) = 8000 + secret_access_key: constr(pattern=r"^\w{32}$") = "webhooksuperpupersecretaccesskey" +``` + +`telegram_bot/service.py`: +```python +import aiogram +import facet + +from .settings import TelegramBotSettings + + +class BaseTelegramBotService(facet.AsyncioServiceMixin): + def __init__( + self, + settings: TelegramBotSettings, + ): + self._settings = settings + self._dispatcher = aiogram.Dispatcher() + self._bot = aiogram.Bot(token=settings.token) + + async def start(self): + self.add_task(self.listen_events()) + + async def listen_events(self): + raise NotImplementedError +``` + +`telegram_bot/polling/service.py`: +```python +from project.telegram_bot.base import BaseTelegramBotService + + +class TelegramBotPollingService(BaseTelegramBotService): + async def listen_events(self): + await self._bot.delete_webhook() + await self._dispatcher.start_polling( + self._bot, + handle_signals=False, + ) +``` + +`telegram_bot/webhook/service.py`: +```python +from typing import Any + +import aiogram +import fastapi +import uvicorn +import yarl + +from project.telegram_bot.base import BaseTelegramBotService + + +class UvicornServer(uvicorn.Server): + def install_signal_handlers(self): + pass + + +class TelegramBotWebhookService(BaseTelegramBotService): + async def listen_events(self): + webhook_url = yarl.URL(str(self._settings.root_url)) / self._settings.root_path.lstrip("/") + await self._bot.set_webhook( + url=str(webhook_url), + secret_token=self._settings.secret_access_key, + ) + + await self.get_server().serve() + + def get_server(self) -> uvicorn.Server: + app = fastapi.FastAPI( + root_url=self._settings.root_url, + root_path=self._settings.root_path, + ) + app.post("")(self.handler) + + config = uvicorn.Config(app=app, host="0.0.0.0", port=self._settings.port) + return UvicornServer(config) + + async def handler( + self, + update: dict[str, Any], + x_telegram_bot_api_secret_token: str | None = fastapi.Header(None), + ): + if x_telegram_bot_api_secret_token != self._settings.secret_access_key: + raise fastapi.HTTPException( + status_code=fastapi.status.HTTP_403_FORBIDDEN, + detail="Forbidden.", + ) + + await self._dispatcher.feed_webhook_update( + bot=self._bot, + update=aiogram.types.Update(**update), + ) +``` + +### Router and handler structure + +- Each domain or feature gets its own router package under `telegram_bot/ui/` (or `telegram_bot/handlers/`). +- Every router sub-package contains at minimum: + - `router.py` — defines `get_router() -> aiogram.Router` and wires each route to a handler function. + - `handlers.py` — plain async functions that implement the bot logic. + - `callback_data.py` — `CallbackData` subclasses for inline buttons used by this feature. + - `states.py` — `StatesGroup` classes for FSM flows used by this feature. + - `__init__.py` — exports only `get_router`. + +Example `telegram_bot/ui/router.py`: +```python +import aiogram +from aiogram.filters import Command + +from . import handlers +from .callback_data import ItemCallbackData +from .states import FormState + + +def get_router() -> aiogram.Router: + router = aiogram.Router() + + router.message.register(handlers.start_handler, Command("start")) + router.message.register(handlers.ask_name_handler, FormState.WAITING_FOR_NAME) + router.callback_query.register( + handlers.item_callback_handler, + ItemCallbackData.filter(), + ) + + return router +``` + +Example `telegram_bot/ui/__init__.py`: +```python +from .router import get_router + +__all__ = ("get_router",) +``` + +- Do not construct `aiogram.Router` instances at import time outside `get_router()`. The router is created only when `get_router()` is called during service setup. + +### Dispatcher setup + +- If the router needs external dependencies (repositories, services, etc.), expose a `setup_dispatcher(dispatcher, **deps)` function that attaches middlewares and includes routers. + +Example `telegram_bot/ui/dispatcher.py`: +```python +import aiogram + +from . import get_router +from .middlewares import AuthMiddleware, TypingMiddleware + + +def setup_dispatcher( + dispatcher: aiogram.Dispatcher, + users_repository: UsersRepository, +): + auth_middleware = AuthMiddleware(users_repository=users_repository) + typing_middleware = TypingMiddleware() + + for middleware in (auth_middleware, typing_middleware): + dispatcher.update.outer_middleware(middleware) + + router = get_router() + dispatcher.include_router(router) +``` + +### Middlewares + +- Keep middlewares stateless. If they need configuration, accept it as an argument and pass it when wiring in `setup_dispatcher()`. + +Example `telegram_bot/ui/middlewares/typing.py`: +```python +import asyncio +import contextlib +from collections.abc import Awaitable, Callable + +import aiogram + + +class TypingMiddleware(aiogram.BaseMiddleware): + async def __call__( + self, + handler: Callable[[aiogram.types.TelegramObject, dict[str, Any]], Awaitable[Any]], + event: aiogram.types.TelegramObject, + data: dict[str, Any], + ): + if not isinstance(event, aiogram.types.Message): + return await handler(event, data) + + bot = data.get("bot") + chat_id = event.chat.id + async with send_typing_action(bot=bot, chat_id=chat_id): + return await handler(event, data) + + +@contextlib.asynccontextmanager +async def send_typing_action(bot: aiogram.Bot, chat_id: int, delay: float = 3): + async def background_task(): + while True: + await bot.send_chat_action(chat_id=chat_id, action="typing") + await asyncio.sleep(delay) + + task = asyncio.create_task(background_task()) + try: + yield + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass +``` + +### Callbacks and Inline Keyboards + +- Use `aiogram.filters.callback_data.CallbackData` subclasses for every inline button action. The `prefix` must be unique across the whole bot. +- For actions that share a paginated list, create a base `PaginatorCallbackData` with a `page` field and inherit from it. + +Example `telegram_bot/ui/callback_data.py`: +```python +import enum +import uuid + +from aiogram.filters.callback_data import CallbackData + + +class PaginatorCallbackData(CallbackData, prefix=""): + page: int = 1 + + +class MenuCallbackData(CallbackData, prefix="menu"): + pass + + +class ItemsCallbackData(PaginatorCallbackData, prefix="items"): + pass + + +class ItemActionEnum(str, enum.Enum): + SHOW = "show" + DELETE = "delete" + + +class ItemCallbackData(CallbackData, prefix="item"): + item_id: uuid.UUID + action: ItemActionEnum = ItemActionEnum.SHOW +``` + +- Build inline keyboards with `aiogram.utils.keyboard.InlineKeyboardBuilder`. +- Pack callback data with `.pack()`. Unpack inside handlers with `MyCallbackData.unpack(callback_query.data)`. + +Example inline keyboard builder in a handler: +```python +from aiogram import types +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from .callback_data import ItemCallbackData, ItemsCallbackData + + +async def show_items_handler(message: types.Message): + keyboard = InlineKeyboardBuilder() + for item in items: + keyboard.row(types.InlineKeyboardButton( + text=item.name, + callback_data=ItemCallbackData(item_id=item.id).pack(), + )) + keyboard.row(types.InlineKeyboardButton( + text="Next page", + callback_data=ItemsCallbackData(page=2).pack(), + )) + await message.reply(text="Items:", reply_markup=keyboard.as_markup()) +``` + +### Lists and Pagination + +- Use a shared `PaginatorCallbackData` base for every paginated list. +- The maximum number of list items per page is **5**. +- In the handler or logic layer, unpack the callback to read `page`, compute `total`, `pages_count`, and render a single navigation row. +- The navigation row contains up to 3 buttons in this order: + 1. **"◀️"** — shown only when `page > 1`. + 2. **"{current_page}/{pages_count}"** — shown only when `pages_count > 1`. + 3. **"▶️"** — shown only when `page < pages_count`. +- Navigation buttons use the same `CallbackData` subclass with an incremented or decremented `page`. + +Example pagination logic inside a handler or `logic.py`: +```python +from aiogram import types +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from .callback_data import ItemsCallbackData, ItemCallbackData + +PER_PAGE = 5 + + +async def render_item_list( + message: types.Message | None, + callback_query: types.CallbackQuery | None, + page: int, + total: int, + items: list[Item], +): + pages_count = total // PER_PAGE + int(bool(total % PER_PAGE)) + + keyboard = InlineKeyboardBuilder() + for item in items[:PER_PAGE]: + keyboard.row(types.InlineKeyboardButton( + text=item.name, + callback_data=ItemCallbackData(item_id=item.id).pack(), + )) + + navigation_row = [] + if page > 1: + navigation_row.append(types.InlineKeyboardButton( + text="◀️", + callback_data=ItemsCallbackData(page=page - 1).pack(), + )) + if pages_count > 1: + navigation_row.append(types.InlineKeyboardButton( + text=f"{page}/{pages_count}", + callback_data="null", + )) + if page < pages_count: + navigation_row.append(types.InlineKeyboardButton( + text="▶️", + callback_data=ItemsCallbackData(page=page + 1).pack(), + )) + if navigation_row: + keyboard.row(*navigation_row) + + reply_markup = keyboard.as_markup() + + if message is not None: + await message.reply(text="Items:", reply_markup=reply_markup) + elif callback_query is not None: + await callback_query.message.edit_text(text="Items:") + await callback_query.message.edit_reply_markup(reply_markup=reply_markup) +``` + +- Keep the handler thin: extract keyboard building and pagination math into a `logic.py` module or a dedicated builder function. diff --git a/skills/python-development-tests/SKILL.md b/skills/python-development-tests/SKILL.md new file mode 100644 index 0000000..918d324 --- /dev/null +++ b/skills/python-development-tests/SKILL.md @@ -0,0 +1,18 @@ +--- +name: python-development-tests +description: Guidelines for writing and organizing tests in Python services. Use when creating or modifying tests, fixtures, or test configuration. +--- + +## Rules + +- All tests are located in the `tests` folder at the repository root. +- The test directory structure mirrors the structure of the tested package. For example, if a module is at `project/database/service.py`, the corresponding test must be at `tests/database/test_service.py`. +- Do **not** test protected methods or functions (`_method`, `_function`). +- Do **not** rely on or verify internal implementation details in tests. +- Use only public classes, objects, functions, and methods — and test only them. +- Do **not** use `monkeypatch` or any form of patching in tests. +- If internal behavior of an object needs to be changed, pass a mock object via the constructor or function argument (dependency injection). +- Actively use fixtures and `parametrize` for tests. +- All fixtures are stored in `conftest.py`. +- Every test package has its own `conftest.py`. +- Make tests asynchronous where applicable. diff --git a/skills/python-development/SKILL.md b/skills/python-development/SKILL.md index d2b76c8..82c7d27 100644 --- a/skills/python-development/SKILL.md +++ b/skills/python-development/SKILL.md @@ -11,9 +11,12 @@ description: Superpower for building robust, maintainable Python backend service | Bootstrapping a new service or reviewing folder structure | [`python-development-layout`](./python-development-layout/SKILL.md) | | Designing service boundaries, APIs, data flow, or resilience patterns | [`python-development-architecture`](./python-development-architecture/SKILL.md) | | Writing or reviewing code, tests, configs, or observability instrumentation | [`python-development-code-style`](./python-development-code-style/SKILL.md) | +| Writing or organizing tests and fixtures | [`python-development-tests`](./python-development-tests/SKILL.md) | | Defining, loading, validating, or extending application settings | [`python-development-settings`](./python-development-settings/SKILL.md) | +| Working with relational databases via metaorm (tables, repositories, transactions) | [`python-development-database`](./python-development-database/SKILL.md) | | Building, nesting, or extending command-line interfaces | [`python-development-cli`](./python-development-cli/SKILL.md) | | Creating a FastAPI REST service inside the facet tree | [`python-development-fastapi`](./python-development-fastapi/SKILL.md) | +| Creating a Telegram bot service using aiogram | [`python-development-telegram-bot`](./python-development-telegram-bot/SKILL.md) | | Debugging production issues (logs, traces, metrics) | [`python-development-architecture`](../python-development-architecture/SKILL.md) + [`python-development-code-style`](../python-development-code-style/SKILL.md) | ## Rules