7 Commits
14 changed files with 108 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Copier template for a Python backend, either as a `backend/` part next to other parts or alone at the root of the project (`layout`): a uv project with one Docker image and one `python -m <module>` entrypoint per service (`api`, `bot`, `userbot`, `worker`, `scheduler`), pydantic-settings, rich logging, optional Dishka, MongoDB or PostgreSQL, Redis, taskiq (in-memory, Redis or PostgreSQL broker), CryptoPay, pydantic-ai and a DynamicConfig stored in the database. Copier template for a Python backend, either as a `backend/` part next to other parts or alone at the root of the project (`layout`): a uv project with one Docker image and one `python -m <module>` entrypoint per service (`api`, `bot`, `userbot`, `worker`, `scheduler`), pydantic-settings, rich logging, optional Dishka, MongoDB or PostgreSQL, Redis, taskiq (in-memory, Redis or PostgreSQL broker), CryptoPay, pydantic-ai and a DynamicConfig stored in the database.
Ships `backend/docker-compose.yml` with root-relative paths and profiles per service, `docker-compose.override.yml.example` (published ports and the `caddy` network aliases, copied by `make env`), a `Makefile` with `fmt`, `check`, `build` and service-specific targets, `.env.example` and `CLAUDE.md`. Applied on top of the `infra` template, `layout: parts` or `layout: single` respectively. Ships `backend/docker-compose.yml` with root-relative paths and profiles per service, `docker-compose.override.yml.example` (published ports and the `caddy` network aliases, copied by `make env`), a `Makefile` with `fmt`, `check` (ruff, ty and `scripts/check_imports.py`, which keeps each service from importing the others' SDKs at startup), `build` and service-specific targets, `.env.example` and `CLAUDE.md`. Applied on top of the `infra` template, `layout: parts` or `layout: single` respectively.
`DB__HOST`/`DB__PORT` and `REDIS__HOST`/`REDIS__PORT` say where the service really is. The bundled container takes the host as its network alias and listens on the port, the override publishes it on the machine. For an external database, point them at it and leave its profile out of `COMPOSE_PROFILES`. `DB__HOST`/`DB__PORT` and `REDIS__HOST`/`REDIS__PORT` say where the service really is. The bundled container takes the host as its network alias and listens on the port, the override publishes it on the machine. For an external database, point them at it and leave its profile out of `COMPOSE_PROFILES`.
+1 -1
View File
@@ -71,7 +71,7 @@ taskiq_broker:
choices: choices:
in-memory: memory in-memory: memory
Redis: redis Redis: redis
PostgreSQL (taskiq-pg): PostgreSQL (taskiq-postgres):
value: postgres value: postgres
validator: "{% if database != 'postgres' %}Requires the PostgreSQL database{% endif %}" validator: "{% if database != 'postgres' %}Requires the PostgreSQL database{% endif %}"
default: memory default: memory
+2 -1
View File
@@ -30,7 +30,8 @@ check:
uv run ruff format --check uv run ruff format --check
uv run ruff check uv run ruff check
uv run ty check uv run ty check
{% if svc_modules %} {% if svc_modules %} uv run python scripts/check_imports.py
{% endif %}{% if svc_modules %}
build: build:
$(COMPOSE) build {{ svc_modules[0] }} $(COMPOSE) build {{ svc_modules[0] }}
+1 -1
View File
@@ -4,7 +4,7 @@
{%- if 'aiogram_bot' in backend_services %}{% set _ = deps.append("aiogram>=3.29.0") %}{% endif -%} {%- if 'aiogram_bot' in backend_services %}{% set _ = deps.append("aiogram>=3.29.0") %}{% endif -%}
{%- if include_payments %}{% set _ = deps.append("aiosend>=3.0.7") %}{% endif -%} {%- if include_payments %}{% set _ = deps.append("aiosend>=3.0.7") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = deps.append("anyio>=4.0.0") %}{% set _ = deps.append("kurigram>=2.2.15") %}{% set _ = deps.append("tgcrypto>=1.2.5") %}{% set _ = deps.append("uvloop>=0.21.0") %}{% endif -%} {%- if 'kurigram_userbot' in backend_services %}{% set _ = deps.append("anyio>=4.0.0") %}{% set _ = deps.append("kurigram>=2.2.15") %}{% set _ = deps.append("tgcrypto>=1.2.5") %}{% set _ = deps.append("uvloop>=0.21.0") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = deps.append("taskiq>=0.12.4") %}{% if taskiq_broker == 'redis' %}{% set _ = deps.append("taskiq-redis>=1.2.3") %}{% endif %}{% if taskiq_broker == 'postgres' %}{% set _ = deps.append("taskiq-pg>=0.2.0") %}{% endif %}{% endif -%} {%- if 'taskiq_worker' in backend_services %}{% set _ = deps.append("taskiq>=0.13.0") %}{% if taskiq_broker == 'redis' %}{% set _ = deps.append("taskiq-redis>=1.2.3") %}{% endif %}{% if taskiq_broker == 'postgres' %}{% set _ = deps.append("taskiq-postgres[asyncpg]>=0.9.1") %}{% endif %}{% endif -%}
{%- if 'scheduler' in backend_services %}{% set _ = deps.append("apscheduler>=3.11.0") %}{% endif -%} {%- if 'scheduler' in backend_services %}{% set _ = deps.append("apscheduler>=3.11.0") %}{% endif -%}
{%- if database == 'mongo' %}{% set _ = deps.append("beanie>=2.0.1") %}{% endif -%} {%- if database == 'mongo' %}{% set _ = deps.append("beanie>=2.0.1") %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = deps.append("sqlmodel>=0.0.39") %}{% set _ = deps.append("asyncpg>=0.31.0") %}{% set _ = deps.append("alembic>=1.18.5") %}{% set _ = deps.append("greenlet>=3.5.3") %}{% endif -%} {%- if database == 'postgres' %}{% set _ = deps.append("sqlmodel>=0.0.39") %}{% set _ = deps.append("asyncpg>=0.31.0") %}{% set _ = deps.append("alembic>=1.18.5") %}{% set _ = deps.append("greenlet>=3.5.3") %}{% endif -%}
@@ -2,7 +2,7 @@ import asyncio
import anyio import anyio
from userbot import PyroClient from userbot.modules.client import PyroClient
async def main() -> None: async def main() -> None:
@@ -0,0 +1,60 @@
import os
import subprocess
import sys
HEAVY = ["aiogram", "pyrogram", "fastapi", "google.genai"]
SERVICES = {
{%- if 'api' in backend_services %}
"api": (["api.app"], {"fastapi"}),
{%- endif %}
{%- if 'aiogram_bot' in backend_services %}
"bot": ({% if include_payments %}["bot"]{% else %}["bot.common", "bot.handlers"]{% endif %}, {"aiogram"}),
{%- endif %}
{%- if 'kurigram_userbot' in backend_services %}
"userbot": (["userbot.runner", "userbot.handlers"], {"pyrogram"}),
{%- endif %}
{%- if 'taskiq_worker' in backend_services %}
"worker": (["worker.broker", "worker.tasks"], {% if 'api' in backend_services %}{"fastapi"}{% else %}set(){% endif %}),
{%- elif 'scheduler' in backend_services %}
"scheduler": (["scheduler"], set()),
{%- endif %}
}
PROBE = """
import importlib, sys
for module in sys.argv[1].split(","):
importlib.import_module(module)
print(" ".join(name for name in sys.argv[2:] if name in sys.modules))
"""
{%- if 'aiogram_bot' in backend_services %}
ENV = {**os.environ, "BOT__TOKEN": "1:check"}
{%- else %}
ENV = dict(os.environ)
{%- endif %}
def loaded(modules: list[str]) -> tuple[set[str], str]:
result = subprocess.run( # noqa: S603
[sys.executable, "-c", PROBE, ",".join(modules), *HEAVY],
capture_output=True,
text=True,
cwd="src",
env=ENV,
check=False,
)
error = (result.stderr.strip().splitlines() or ["exit code"])[-1]
return set(result.stdout.split()) & set(HEAVY), error if result.returncode else ""
def main() -> int:
failed = False
for service, (modules, allowed) in SERVICES.items():
found, error = loaded(modules)
extra = sorted(found - allowed)
status = error or ("imports " + ", ".join(extra) if extra else "")
print(f"{service:10s} {'FAIL: ' + status if status else 'ok'}")
failed |= bool(status)
return int(failed)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,6 @@
import asyncio
from importlib import import_module
async def preload(module: str) -> None:
await asyncio.to_thread(import_module, module)
@@ -1,5 +1,7 @@
import logging import logging
{% if 'aiogram_bot' in backend_services %}from importlib.util import find_spec
from pathlib import Path
{% endif %}
from rich.console import Console from rich.console import Console
from rich.logging import RichHandler from rich.logging import RichHandler
from rich.traceback import install from rich.traceback import install
@@ -10,8 +12,12 @@ console = Console(width=env.log.console_width, color_system="auto", force_termin
def setup_logging() -> None: def setup_logging() -> None:
{% if 'aiogram_bot' in backend_services %} from aiogram.dispatcher import router # noqa: PLC0415 {% if 'aiogram_bot' in backend_services %} aiogram = find_spec("aiogram")
suppress = (
[str(Path(aiogram.origin).parent / "dispatcher")]
if aiogram and aiogram.origin
else []
)
{% endif %} logging.basicConfig( {% endif %} logging.basicConfig(
level=env.log.level_external, level=env.log.level_external,
format="", format="",
@@ -26,7 +32,7 @@ def setup_logging() -> None:
omit_repeated_times=False, omit_repeated_times=False,
show_time=env.log.show_time, show_time=env.log.show_time,
{%- if 'aiogram_bot' in backend_services %} {%- if 'aiogram_bot' in backend_services %}
tracebacks_suppress=[router], tracebacks_suppress=suppress,
{%- endif %} {%- endif %}
) )
], ],
@@ -1,3 +0,0 @@
from .modules.client import DEVICE_MODEL, PyroClient
__all__ = ["DEVICE_MODEL", "PyroClient"]
@@ -43,7 +43,7 @@ setup_dishka(container, broker)
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka {% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}{% if 'scheduler' in backend_services %}from taskiq import TaskiqScheduler {% endif %}{% if 'scheduler' in backend_services %}from taskiq import TaskiqScheduler
from taskiq.schedule_sources import LabelScheduleSource from taskiq.schedule_sources import LabelScheduleSource
{% endif %}from taskiq_pg import AsyncpgBroker, AsyncpgResultBackend {% endif %}from taskiq_pg.asyncpg import AsyncpgBroker, AsyncpgResultBackend
{% if use_dishka %}from dependencies.container import container {% if use_dishka %}from dependencies.container import container
{% endif %}from utils.env import env {% endif %}from utils.env import env
@@ -4,11 +4,7 @@
{%- if database == 'postgres' %}{% set _ = providers.append('RepositoryProvider()') %}{% endif -%} {%- if database == 'postgres' %}{% set _ = providers.append('RepositoryProvider()') %}{% endif -%}
{%- if use_redis %}{% set _ = providers.append('RedisProvider()') %}{% endif -%} {%- if use_redis %}{% set _ = providers.append('RedisProvider()') %}{% endif -%}
{%- if use_llm %}{% set _ = providers.append('LlmProvider()') %}{% endif -%} {%- if use_llm %}{% set _ = providers.append('LlmProvider()') %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = providers.append('AiogramProvider()') %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = providers.append('FastapiProvider()') %}{% endif -%}
{%- set third = ['from dishka import make_async_container'] -%} {%- set third = ['from dishka import make_async_container'] -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = third.append('from dishka.integrations.aiogram import AiogramProvider') %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = third.append('from dishka.integrations.fastapi import FastapiProvider') %}{% endif -%}
{%- set first = [] -%} {%- set first = [] -%}
{%- if dynamic_config %}{% set _ = first.append('from dependencies.providers.config import ConfigProvider') %}{% endif -%} {%- if dynamic_config %}{% set _ = first.append('from dependencies.providers.config import ConfigProvider') %}{% endif -%}
{%- if database != 'none' %}{% set _ = first.append('from dependencies.providers.db import DbProvider') %}{% endif -%} {%- if database != 'none' %}{% set _ = first.append('from dependencies.providers.db import DbProvider') %}{% endif -%}
@@ -1,14 +1,20 @@
from dishka import Provider, Scope, provide from dishka import Provider, Scope, provide
from pydantic_ai import Agent from pydantic_ai import Agent
from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
from pydantic_ai.providers.google import GoogleProvider
from utils.env import env from utils.env import env
from utils.imports import preload
class LlmProvider(Provider): class LlmProvider(Provider):
@provide(scope=Scope.APP) @provide(scope=Scope.APP)
def agent(self) -> Agent: async def agent(self) -> Agent:
await preload("pydantic_ai.models.google")
from pydantic_ai.models.google import ( # noqa: PLC0415
GoogleModel,
GoogleModelSettings,
)
from pydantic_ai.providers.google import GoogleProvider # noqa: PLC0415
model = GoogleModel( model = GoogleModel(
env.llm.model.split(":", 1)[-1], env.llm.model.split(":", 1)[-1],
provider=GoogleProvider(api_key=env.llm.gemini_api_key.get_secret_value()), provider=GoogleProvider(api_key=env.llm.gemini_api_key.get_secret_value()),
@@ -16,10 +16,15 @@ if config.config_file_name is not None:
target_metadata = SQLModel.metadata target_metadata = SQLModel.metadata
def include_name(name: str | None, type_: str, _parents: object) -> bool:
return not (type_ == "table" and name is not None and name.startswith("taskiq_"))
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
context.configure( context.configure(
url=env.db.connection_url, url=env.db.connection_url,
target_metadata=target_metadata, target_metadata=target_metadata,
include_name=include_name,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
) )
@@ -28,7 +33,11 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None: def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata) context.configure(
connection=connection,
target_metadata=target_metadata,
include_name=include_name,
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
@@ -5,3 +5,9 @@ ruff format
ruff check --fix ruff check --fix
ty check ty check
``` ```
## Imports
Every service imports only what it needs to start. `make check` runs `scripts/check_imports.py`, which fails when a service loads a heavy package it is not allowed; `SERVICES` there is the contract, extend it on purpose.
- Package `__init__` files don't re-export clients or SDKs.
- An SDK used by one function is imported inside it. On an async path load it first with `await preload("<module>")` from `utils.imports`, so the event loop never waits on the import.
- Types used in Dishka provider signatures and `FromDishka[...]` stay real top-level imports.