8 Commits
Author SHA1 Message Date
hh dae9532ef6 Run compose from the root in part layout 2026-09-09 00:39:12 +02:00
hh e316bc92a5 Deploy hooks 2026-09-09 00:37:04 +02:00
hh 3878837d25 README wording 2026-09-09 00:21:25 +02:00
hh b1db7c274f Single trailing newline 2026-09-09 00:19:34 +02:00
hh ac0ccf47e5 Override-based compose, profiles in .env, README 2026-09-09 00:18:15 +02:00
hh 1f416dbe60 PyroClient: device model, locale, transmissions 2026-09-09 00:05:28 +02:00
hh 9afa7da9c4 PostgreSQL broker for taskiq 2026-09-08 23:59:31 +02:00
hh aac037676c Single part at the root, COMPOSE_FILE in .env 2026-09-08 20:38:54 +02:00
92 changed files with 301 additions and 154 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# backend-python
Copier template for a `backend/` part: 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, 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, `docker-compose.local.yml` (published ports) and `docker-compose.prod.yml` (the `caddy` network), a `Makefile` with `fmt`, `check`, `build` and service-specific targets, `.env.example` and `CLAUDE.md`. Meant to be applied on top of the `infra` template.
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.
```sh
copier copy --trust -d project_name="My Thing" <this-repo> <dest>
+24 -3
View File
@@ -25,6 +25,14 @@ author_email:
type: str
default: ""
layout:
type: str
help: Where the backend lives
choices:
In backend/ next to other parts: part
At the root of the project: root
default: part
backend_services:
type: str
multiselect: true
@@ -63,6 +71,9 @@ taskiq_broker:
choices:
in-memory: memory
Redis: redis
PostgreSQL (taskiq-pg):
value: postgres
validator: "{% if database != 'postgres' %}Requires the PostgreSQL database{% endif %}"
default: memory
include_payments:
@@ -84,7 +95,7 @@ dynamic_config:
serve_spa:
type: bool
when: "{{ 'api' in backend_services }}"
when: "{{ layout == 'part' and 'api' in backend_services }}"
help: Serve the frontend build from the api as a SPA
default: false
@@ -93,6 +104,16 @@ install_deps:
help: uv sync after generation
default: true
part_dir:
type: str
when: false
default: "{{ 'backend' if layout == 'part' else '.' }}"
prefix:
type: str
when: false
default: "{{ 'backend/' if layout == 'part' else '' }}"
_tasks:
- "{% if _copier_operation == 'copy' and install_deps %}sh -c 'cd backend && uv sync'{% else %}true{% endif %}"
- "{% if install_deps %}sh -c 'cd backend && uv run ruff format -q'{% else %}true{% endif %}"
- "{% if _copier_operation == 'copy' and install_deps %}sh -c 'cd {{ part_dir }} && uv sync'{% else %}true{% endif %}"
- "{% if install_deps %}sh -c 'cd {{ part_dir }} && uv run ruff format -q'{% else %}true{% endif %}"
-26
View File
@@ -1,26 +0,0 @@
RUN_ENVIRONMENT=dev
LOG__LEVEL=INFO
LOG__LEVEL_EXTERNAL=WARNING
LOG__SHOW_TIME=false
LOG__CONSOLE_WIDTH=150
{% if database == 'mongo' %}DB__HOST=mongodb
DB__PORT=27017
DB__USER={{ project_slug }}
DB__PASSWORD={{ project_slug }}
DB__DB_NAME={{ project_slug }}
{% endif %}{% if database == 'postgres' %}DB__HOST=postgres
DB__PORT=5432
DB__USER={{ project_slug }}
DB__PASSWORD={{ project_slug }}
DB__DB_NAME={{ project_slug }}
{% endif %}{% if use_redis %}REDIS__HOST=redis
REDIS__PORT=6379
REDIS__CACHE_TTL=300
{% endif %}{% if 'api' in backend_services %}API__HOST=0.0.0.0
API__PORT=8080
API__WORKERS=1
{% endif %}{% if 'aiogram_bot' in backend_services %}BOT__TOKEN=
{% endif %}{% if include_payments %}CRYPTO_PAY__TOKEN=
{% endif %}{% if use_llm %}LLM__MODEL=google-gla:gemini-2.5-flash
LLM__GEMINI_API_KEY=
{% endif %}
-38
View File
@@ -1,38 +0,0 @@
{%- set svc_modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = svc_modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = svc_modules.append("bot") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = svc_modules.append("worker") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = svc_modules.append("userbot") %}{% endif -%}
{%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = svc_modules.append("scheduler") %}{% endif -%}
{%- set has_script = dynamic_config and svc_modules -%}
COMPOSE := docker compose --project-directory ..
.PHONY: fmt check build{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %}
fmt:
uv run ruff format
uv run ruff check --fix
check:
uv run ruff format --check
uv run ruff check
uv run ty check
build:
$(COMPOSE) build {{ svc_modules[0] }}
{% if database == 'postgres' %}
migrate:
$(COMPOSE) --profile external run --rm migrator
revision:
$(COMPOSE) --profile external run --rm migrator revision --autogenerate -m "$(m)"
{% endif %}{% if has_script %}
script:
@$(COMPOSE) run --rm {{ svc_modules[0] }} scripts.$(word 2,$(MAKECMDGOALS)) $(wordlist 3,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
%:
@:
{% endif %}{% if 'kurigram_userbot' in backend_services %}
session:
uv run python scripts/session/create.py
{% endif %}
@@ -1,15 +0,0 @@
{%- set has_ports = 'api' in backend_services or database != 'none' or use_redis -%}
services:{% if not has_ports %} {}{% endif %}
{% if 'api' in backend_services %} api:
ports:
- "127.0.0.1:${API__PORT:-8080}:8080"
{% endif %}{% if database == 'mongo' %} mongodb:
ports:
- "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}"
{% endif %}{% if database == 'postgres' %} postgres:
ports:
- "127.0.0.1:${DB__PORT:-5432}:5432"
{% endif %}{% if use_redis %} redis:
ports:
- "127.0.0.1:${REDIS__PORT:-6379}:6379"
{% endif %}
@@ -1,15 +0,0 @@
{%- set dash = project_slug | replace('_', '-') -%}
{% if 'api' in backend_services -%}
services:
api:
networks:
caddy:
aliases:
- {{ dash }}-api
networks:
caddy:
external: true
{%- else -%}
services: {}
{%- endif %}
@@ -1,13 +0,0 @@
import uvicorn
from utils.env import env
def main() -> None:
uvicorn.run(
"api.app:app", host=env.api.host, port=env.api.port, workers=env.api.workers
)
if __name__ == "__main__":
main()
@@ -1,3 +0,0 @@
from .modules.client import PyroClient
__all__ = ["PyroClient"]
-5
View File
@@ -1,5 +0,0 @@
[environment]
python = "backend/.venv"
[src]
exclude = ["backend/migrations"]
+5
View File
@@ -0,0 +1,5 @@
[environment]
python = "{{ prefix }}.venv"
[src]
exclude = ["{{ prefix }}migrations"]
@@ -0,0 +1,14 @@
{%- set groups = [] -%}
{%- if layout == 'root' %}{% set _ = groups.append(["COMPOSE_PROFILES=" ~ ("external," if database != 'none' or use_redis else "") ~ "services"]) %}{% endif -%}
{%- set _ = groups.append(["RUN_ENVIRONMENT=prod", "TZ=UTC"]) -%}
{%- set _ = groups.append(["LOG__LEVEL=INFO", "LOG__LEVEL_EXTERNAL=WARNING", "LOG__SHOW_TIME=false", "LOG__CONSOLE_WIDTH=150"]) -%}
{%- if database == 'mongo' %}{% set _ = groups.append(["DB__HOST=mongodb", "DB__PORT=27017", "DB__USER=" ~ project_slug, "DB__PASSWORD=" ~ project_slug, "DB__DB_NAME=" ~ project_slug]) %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = groups.append(["DB__HOST=postgres", "DB__PORT=5432", "DB__USER=" ~ project_slug, "DB__PASSWORD=" ~ project_slug, "DB__DB_NAME=" ~ project_slug]) %}{% endif -%}
{%- if use_redis %}{% set _ = groups.append(["REDIS__HOST=redis", "REDIS__PORT=6379", "REDIS__CACHE_TTL=300"]) %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = groups.append(["API__HOST=0.0.0.0", "API__PORT=8080", "API__WORKERS=1", "API__DOCS=false"]) %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = groups.append(["BOT__TOKEN="]) %}{% endif -%}
{%- if include_payments %}{% set _ = groups.append(["CRYPTO_PAY__TOKEN="]) %}{% endif -%}
{%- if use_llm %}{% set _ = groups.append(["LLM__MODEL=google-gla:gemini-2.5-flash", "LLM__GEMINI_API_KEY="]) %}{% endif -%}
{% for group in groups %}{{ group | join("\n") }}
{% if not loop.last %}
{% endif %}{% endfor %}
@@ -10,6 +10,8 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
{%- else -%}
FROM ghcr.io/astral-sh/uv:python3.13-alpine
RUN apk add --no-cache tzdata
{%- endif %}
WORKDIR /app
+88
View File
@@ -0,0 +1,88 @@
{%- set svc_modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = svc_modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = svc_modules.append("bot") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = svc_modules.append("worker") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = svc_modules.append("userbot") %}{% endif -%}
{%- if 'scheduler' in backend_services and 'taskiq_worker' not in backend_services %}{% set _ = svc_modules.append("scheduler") %}{% endif -%}
{%- set app_services = [] -%}
{%- if 'api' in backend_services %}{% set _ = app_services.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = app_services.append("bot") %}{% endif -%}
{%- if 'taskiq_worker' in backend_services %}{% set _ = app_services.append("worker") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = app_services.append("userbot") %}{% endif -%}
{%- if 'scheduler' in backend_services %}{% set _ = app_services.append("scheduler") %}{% endif -%}
{%- set has_script = dynamic_config and svc_modules -%}
{%- set has_scripts = dynamic_config or 'kurigram_userbot' in backend_services -%}
COMPOSE := {% if layout == 'part' %}cd .. && {% endif %}docker compose
{% if svc_modules %}IMAGE := {{ project_slug }}/backend
IMAGE_INPUTS := pyproject.toml uv.lock Dockerfile
CODE := src{% if has_scripts %} scripts{% endif %}
SERVICES := {{ app_services | join(' ') }}
changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null
stamp = { git update-ref $(1) HEAD 2>/dev/null || true; }
{% endif %}
.PHONY: {% if layout == 'root' %}env recreate rebuild restart logs down deploy {% endif %}fmt check{% if svc_modules %} build pre-deploy post-deploy{% endif %}{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %}
fmt:
uv run ruff format
uv run ruff check --fix
check:
uv run ruff format --check
uv run ruff check
uv run ty check
{% if svc_modules %}
build:
$(COMPOSE) build {{ svc_modules[0] }}
pre-deploy:
@if $(call changed,refs/deploy/backend,$(IMAGE_INPUTS)) || ! docker image inspect $(IMAGE) >/dev/null 2>&1; then \
$(MAKE) build && $(call stamp,refs/deploy/backend) && $(call stamp,refs/deploy/backend-code); \
fi
{% if database == 'postgres' %} @$(COMPOSE) config --services | grep -qx postgres && $(COMPOSE) up -d postgres || true
$(MAKE) migrate
{% endif %}
post-deploy:
@if $(call changed,refs/deploy/backend-code,$(CODE)); then \
running=$$($(COMPOSE) ps --services --status running | grep -x $(addprefix -e ,$(SERVICES))); \
[ -z "$$running" ] || $(COMPOSE) restart $$running; \
$(call stamp,refs/deploy/backend-code); \
fi
{% endif %}{% if layout == 'root' %}
env:
@test -f .env || cp .env.example .env
@test -f docker-compose.override.yml || cp docker-compose.override.yml.example docker-compose.override.yml
recreate:
$(COMPOSE) up -d --force-recreate
rebuild: build
$(COMPOSE) up -d
restart:
$(COMPOSE) restart
logs:
$(COMPOSE) logs -f --tail=100
down:
$(COMPOSE) down
{% if svc_modules %}
deploy: pre-deploy
$(COMPOSE) up -d
$(MAKE) post-deploy
{% endif %}{% endif %}{% if database == 'postgres' %}
migrate:
$(COMPOSE) run --rm migrator
revision:
$(COMPOSE) run --rm migrator revision --autogenerate -m "$(m)"
{% endif %}{% if has_script %}
script:
@$(COMPOSE) run --rm {{ svc_modules[0] }} scripts.$(word 2,$(MAKECMDGOALS)) $(wordlist 3,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
%:
@:
{% endif %}{% if 'kurigram_userbot' in backend_services %}
session:
uv run python scripts/session/create.py
{% endif %}
@@ -0,0 +1,28 @@
{%- set dash = project_slug | replace('_', '-') -%}
{%- set entries = [] -%}
{%- if 'api' in backend_services %}{% set _ = entries.append('api') %}{% endif -%}
{%- if database == 'mongo' %}{% set _ = entries.append('mongodb') %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = entries.append('postgres') %}{% endif -%}
{%- if use_redis %}{% set _ = entries.append('redis') %}{% endif -%}
services:{% if not entries %} {}{% endif %}
{% for entry in entries %}{% if entry == 'api' %} api:
networks:
default: {}
caddy:
aliases:
- {{ dash }}-api
{% elif entry == 'mongodb' %} mongodb:
ports:
- "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}"
{% elif entry == 'postgres' %} postgres:
ports:
- "127.0.0.1:${DB__PORT:-5432}:5432"
{% elif entry == 'redis' %} redis:
ports:
- "127.0.0.1:${REDIS__PORT:-6379}:6379"
{% endif %}{% if not loop.last %}
{% endif %}{% endfor %}{% if 'api' in backend_services %}
networks:
caddy:
external: true
{% endif %}
@@ -2,6 +2,10 @@
{%- set svc_networks = [] -%}
{%- if database != 'none' %}{% set _ = svc_networks.append('database') %}{% endif -%}
{%- if use_redis %}{% set _ = svc_networks.append('redis') %}{% endif -%}
{%- set external_services = [] -%}
{%- if database == 'mongo' %}{% set _ = external_services.append('mongodb') %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = external_services.append('postgres') %}{% endif -%}
{%- if use_redis %}{% set _ = external_services.append('redis') %}{% endif -%}
{%- set app_services = [] -%}
{%- if 'api' in backend_services %}{% set _ = app_services.append({'name': 'api', 'command': '[api]'}) %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = app_services.append({'name': 'bot', 'command': '[bot]'}) %}{% endif -%}
@@ -9,25 +13,31 @@
{%- if 'kurigram_userbot' in backend_services %}{% set _ = app_services.append({'name': 'userbot', 'command': '[userbot]'}) %}{% endif -%}
{%- if 'scheduler' in backend_services %}{% set _ = app_services.append({'name': 'scheduler', 'command': ('[worker, scheduler]' if 'taskiq_worker' in backend_services else '[scheduler]')}) %}{% endif -%}
{%- macro backend_service(name, command) %} {{ name }}:
build: backend
build: {{ part_dir }}
image: {{ project_slug }}/backend
profiles: [{{ name }}, services]
restart: unless-stopped
env_file:
- path: .env
required: false
- path: backend/.env
{% if layout == 'part' %} - path: backend/.env
required: false
environment:
{% endif %} environment:
RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes:
- ./backend/src:/app/src
{% if has_scripts %} - ./backend/scripts:/app/scripts
{% endif %}{% if name == 'userbot' %} - ./backend/sessions:/app/sessions
- ./{{ prefix }}src:/app/src
{% if has_scripts %} - ./{{ prefix }}scripts:/app/scripts
{% endif %}{% if name == 'userbot' %} - ./{{ prefix }}sessions:/app/sessions
{% endif %}{% if name == 'api' and serve_spa %} - ./frontend/build:/app/static:ro
{% endif %} command: {{ command }}
{% if svc_networks %} networks:
{% for net in svc_networks %} {{ net }}:
{% if external_services %} depends_on:
{% for ext in external_services %} {{ ext }}:
condition: service_healthy
required: false
{% endfor %}{% endif %}{% if svc_networks %} networks:
{% if name == 'api' %} default:
{% endif %}{% for net in svc_networks %} {{ net }}:
{% endfor %}{% endif %}{% endmacro -%}
services:
{% for svc in app_services %}{{ backend_service(svc.name, svc.command) }}{% if not loop.last %}
@@ -43,6 +53,11 @@ services:
command: mongod --port ${DB__PORT:-27017}
volumes:
- database:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--port", "${DB__PORT:-27017}", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 5
networks:
database:
aliases:
@@ -70,25 +85,26 @@ services:
- ${DB__HOST:-postgres}
migrator:
build: backend
build: {{ part_dir }}
image: {{ project_slug }}/backend
profiles: [migrate]
env_file:
- path: .env
required: false
- path: backend/.env
{% if layout == 'part' %} - path: backend/.env
required: false
environment:
{% endif %} environment:
RUN_ENVIRONMENT: prod
volumes:
- ./backend/src:/app/src
- ./backend/migrations:/app/migrations
- ./backend/alembic.ini:/app/alembic.ini
- ./{{ prefix }}src:/app/src
- ./{{ prefix }}migrations:/app/migrations
- ./{{ prefix }}alembic.ini:/app/alembic.ini
entrypoint: [alembic]
command: [upgrade, head]
depends_on:
postgres:
condition: service_healthy
required: false
networks:
database:
{% endif %}
@@ -98,6 +114,11 @@ services:
profiles: [redis, external]
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- redis_data:/data
networks:
@@ -4,7 +4,7 @@
{%- 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 'kurigram_userbot' in backend_services %}{% set _ = deps.append("anyio>=4.0.0") %}{% set _ = deps.append("kurigram>=2.2.7") %}{% 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 %}{% 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 '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 == 'postgres' %}{% set _ = deps.append("sqlmodel>=0.0.38") %}{% set _ = deps.append("asyncpg>=0.31.0") %}{% set _ = deps.append("alembic>=1.18.4") %}{% set _ = deps.append("greenlet>=3.1.0") %}{% endif -%}
@@ -69,6 +69,7 @@ class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
workers: int = 1
docs: bool = False
{%- if serve_spa %}
static_dir: str = "static"
{%- endif %}
@@ -110,7 +111,10 @@ class Settings(BaseSettings):
{%- endif %}
model_config = SettingsConfigDict(
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
case_sensitive=False,
env_file={% if layout == 'part' %}("../.env", ".env"){% else %}".env"{% endif %},
env_nested_delimiter="__",
extra="ignore",
)
@@ -0,0 +1,17 @@
import uvicorn
from utils.env import env
def main() -> None:
uvicorn.run(
"api.app:app",
host=env.api.host,
port=env.api.port,
workers=env.api.workers,
forwarded_allow_ips="*",
)
if __name__ == "__main__":
main()
@@ -10,8 +10,8 @@ from fastapi.middleware.cors import CORSMiddleware
from api import routers
{% if use_dishka %}from dependencies.container import container
{% endif %}{% if database != 'none' %}from utils.db import init_db
{% endif %}{% if serve_spa %}from utils.env import env
{% endif %}from utils.logging import setup_logging
{% endif %}from utils.env import env
from utils.logging import setup_logging
{% if serve_spa %}
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
@@ -29,7 +29,11 @@ async def lifespan(app_: FastAPI) -> AsyncGenerator[None]:
{%- endif %}
app = FastAPI(title="{{ project_name }} API", lifespan=lifespan)
app = FastAPI(
title="{{ project_name }} API",
lifespan=lifespan,
openapi_url="/openapi.json" if env.api.docs else None,
)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
@@ -0,0 +1,3 @@
from .modules.client import DEVICE_MODEL, PyroClient
__all__ = ["DEVICE_MODEL", "PyroClient"]
@@ -1,20 +1,30 @@
from pyrogram import Client, enums
DEVICE_MODEL = "{{ project_name[:32] }}"
class PyroClient(Client):
def __init__(
self, name: str, *, workdir: str = "sessions", load_handlers: bool = True
self,
name: str,
*,
workdir: str = "sessions",
device_model: str | None = None,
load_handlers: bool = True,
) -> None:
super().__init__(
name,
workdir=workdir,
api_id=2040,
api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop",
device_model=device_model or DEVICE_MODEL,
system_version="Windows 11 x64",
app_version="6.2.4 x64",
app_version="7.0.8 x64",
lang_pack="tdesktop",
lang_code="en",
system_lang_code="en-US",
client_platform=enums.ClientPlatform.DESKTOP,
max_concurrent_transmissions=4,
)
if load_handlers:
@@ -24,4 +34,4 @@ class PyroClient(Client):
self.add_handler(*handler)
__all__ = ["PyroClient"]
__all__ = ["DEVICE_MODEL", "PyroClient"]
@@ -1,5 +1,6 @@
import asyncio
import contextlib
import signal
import anyio
import uvloop
@@ -11,8 +12,15 @@ import uvloop
setup_logging()
SCAN_INTERVAL = 10
async def runner() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
{% if database != 'none' %} await init_db()
{% endif %} sessions_dir = anyio.Path("sessions")
@@ -22,7 +30,7 @@ async def runner() -> None:
clients: dict[int, PyroClient] = {}
try:
while True:
while not stop.is_set():
current = {path.name async for path in sessions_dir.glob("*.session")}
for session_file in current - started:
@@ -43,7 +51,8 @@ async def runner() -> None:
if me:
clients[me.id] = client
await asyncio.sleep(10)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), SCAN_INTERVAL)
finally:
for client in clients.values():
with contextlib.suppress(Exception):
@@ -55,8 +64,5 @@ def main() -> None:
uvloop.install()
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -1,5 +1,5 @@
import asyncio
import contextlib
import signal
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
@@ -15,6 +15,10 @@ async def example_job() -> None:
async def runner() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
{%- if database != 'none' %}
await init_db()
{%- endif %}
@@ -27,14 +31,13 @@ async def runner() -> None:
scheduler.start()
logger.info("Scheduler started")
with contextlib.suppress(asyncio.CancelledError):
await asyncio.Event().wait()
await stop.wait()
scheduler.shutdown()
def main() -> None:
setup_logging()
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -39,6 +39,22 @@ broker = ResilientListQueueBroker(env.redis.url).with_result_backend(
{% endif %}{% if use_dishka %}
setup_dishka(container, broker)
{% endif %}
{%- elif taskiq_broker == 'postgres' -%}
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}{% if 'scheduler' in backend_services %}from taskiq import TaskiqScheduler
from taskiq.schedule_sources import LabelScheduleSource
{% endif %}from taskiq_pg import AsyncpgBroker, AsyncpgResultBackend
{% if use_dishka %}from dependencies.container import container
{% endif %}from utils.env import env
broker = AsyncpgBroker(env.db.connection_url).with_result_backend(
AsyncpgResultBackend(env.db.connection_url)
)
{% if 'scheduler' in backend_services %}scheduler = TaskiqScheduler(broker, sources=[LabelScheduleSource(broker)])
{% endif %}{% if use_dishka %}
setup_dishka(container, broker)
{% endif %}
{%- else -%}
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}from taskiq import InMemoryBroker{% if 'scheduler' in backend_services %}, TaskiqScheduler
@@ -0,0 +1,20 @@
{%- set targets = [] -%}
{%- if database == 'postgres' %}{% set _ = targets.append("`make migrate`, `make revision m=\"message\"`") %}{% endif -%}
{%- if dynamic_config and backend_services %}{% set _ = targets.append("`make script name`") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = targets.append("`make session`") %}{% endif -%}
# {{ project_name }}
{% if project_description and project_description != project_name %}
{{ project_description }}
{% endif %}
Runs in Docker, locally the same way as in production. `COMPOSE_PROFILES` in `.env` selects the services that run on this machine. `docker-compose.override.yml` holds what differs between machines: published ports and the aliases on the external `caddy` network.
```sh
make env
make recreate
```
`make env` creates `.env` and `docker-compose.override.yml` from their examples. `make recreate` starts the selected profiles, `make rebuild` builds the image first, `make logs`, `make restart` and `make down` do what they say.
{% if backend_services %}To deploy, `git pull && make deploy`. It runs `make pre-deploy`, which builds the image only when `pyproject.toml`, `uv.lock` or `Dockerfile` changed{% if database == 'postgres' %} and applies migrations{% endif %}, then `docker compose up -d`, then `make post-deploy`, which restarts the services whose mounted code changed. A deploy system with its own `up` step calls the two hooks around it. What was built and started is remembered as `refs/deploy/*` in the clone.
{% endif %}`make fmt` formats, `make check` runs ruff and ty.{% if targets %} Also {{ targets | join(", ") }}.{% endif %}