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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `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 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.
|
- 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.
|
||||||
|
|||||||
193
skills/python-development-database/SKILL.md
Normal file
193
skills/python-development-database/SKILL.md
Normal file
@@ -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.
|
||||||
@@ -83,6 +83,116 @@ class RESTAPIService(facet.AsyncioServiceMixin):
|
|||||||
- `dependencies.py` — FastAPI dependency functions reused across handlers in the package.
|
- `dependencies.py` — FastAPI dependency functions reused across handlers in the package.
|
||||||
- `exceptions.py` — custom HTTP exceptions for this domain.
|
- `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`:
|
Example `api/rest/users/router.py`:
|
||||||
```python
|
```python
|
||||||
import fastapi
|
import fastapi
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ description: Technology stack decisions for Python services. Use when adding dep
|
|||||||
## Rules
|
## Rules
|
||||||
- Use `uv` as the package manager for all Python dependency operations.
|
- Use `uv` as the package manager for all Python dependency operations.
|
||||||
- Use `FastAPI` for REST API and HTTP services.
|
- 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 `alembic` for database migrations.
|
||||||
- Use `facet` for hierarchical async service composition with dependency management and graceful shutdown.
|
- Use `facet` for hierarchical async service composition with dependency management and graceful shutdown.
|
||||||
- Use `typer` for CLI interfaces.
|
- Use `typer` for CLI interfaces.
|
||||||
- Use `pydantic` for DTOs and data validation; use `pydantic-settings` for application configuration.
|
- Use `pydantic` for DTOs and data validation; use `pydantic-settings` for application configuration.
|
||||||
- Use `loguru` for logging.
|
- 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.
|
- Use `pyyaml` for YAML parsing; use `pyyaml-include` for `!include` tags and `pyyaml-env-tag` for `!env` interpolation inside YAML config files.
|
||||||
|
|
||||||
|
|||||||
405
skills/python-development-telegram-bot/SKILL.md
Normal file
405
skills/python-development-telegram-bot/SKILL.md
Normal file
@@ -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.
|
||||||
18
skills/python-development-tests/SKILL.md
Normal file
18
skills/python-development-tests/SKILL.md
Normal file
@@ -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.
|
||||||
@@ -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) |
|
| 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) |
|
| 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 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) |
|
| 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) |
|
| 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 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) |
|
| 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
|
## Rules
|
||||||
|
|||||||
Reference in New Issue
Block a user