10 Commits
Author SHA1 Message Date
hh b4125407bf TZ for the migrator 2026-09-09 00:54:30 +02:00
hh e0d5defa97 Migrate arguments, restart api after SPA build 2026-09-09 00:51:34 +02:00
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 302 additions and 154 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# backend-python # 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 ```sh
copier copy --trust -d project_name="My Thing" <this-repo> <dest> copier copy --trust -d project_name="My Thing" <this-repo> <dest>
+24 -3
View File
@@ -25,6 +25,14 @@ author_email:
type: str type: str
default: "" 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: backend_services:
type: str type: str
multiselect: true multiselect: true
@@ -63,6 +71,9 @@ taskiq_broker:
choices: choices:
in-memory: memory in-memory: memory
Redis: redis Redis: redis
PostgreSQL (taskiq-pg):
value: postgres
validator: "{% if database != 'postgres' %}Requires the PostgreSQL database{% endif %}"
default: memory default: memory
include_payments: include_payments:
@@ -84,7 +95,7 @@ dynamic_config:
serve_spa: serve_spa:
type: bool 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 help: Serve the frontend build from the api as a SPA
default: false default: false
@@ -93,6 +104,16 @@ install_deps:
help: uv sync after generation help: uv sync after generation
default: true 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: _tasks:
- "{% if _copier_operation == 'copy' and install_deps %}sh -c 'cd backend && uv sync'{% 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 backend && uv run ruff format -q'{% 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/* RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
{%- else -%} {%- else -%}
FROM ghcr.io/astral-sh/uv:python3.13-alpine FROM ghcr.io/astral-sh/uv:python3.13-alpine
RUN apk add --no-cache tzdata
{%- endif %} {%- endif %}
WORKDIR /app 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 %}{% if serve_spa %} ../frontend{% 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 $(or $(filter-out $@,$(MAKECMDGOALS)),upgrade head)
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 database == 'postgres' or has_script %}
%:
@:
{% 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 = [] -%} {%- set svc_networks = [] -%}
{%- if database != 'none' %}{% set _ = svc_networks.append('database') %}{% endif -%} {%- if database != 'none' %}{% set _ = svc_networks.append('database') %}{% endif -%}
{%- if use_redis %}{% set _ = svc_networks.append('redis') %}{% 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 = [] -%} {%- set app_services = [] -%}
{%- if 'api' in backend_services %}{% set _ = app_services.append({'name': 'api', 'command': '[api]'}) %}{% endif -%} {%- 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 -%} {%- 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 '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 -%} {%- 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 }}: {%- macro backend_service(name, command) %} {{ name }}:
build: backend build: {{ part_dir }}
image: {{ project_slug }}/backend image: {{ project_slug }}/backend
profiles: [{{ name }}, services] profiles: [{{ name }}, services]
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- path: .env - path: .env
required: false required: false
- path: backend/.env {% if layout == 'part' %} - path: backend/.env
required: false required: false
environment: {% endif %} environment:
RUN_ENVIRONMENT: prod RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes: volumes:
- ./backend/src:/app/src - ./{{ prefix }}src:/app/src
{% if has_scripts %} - ./backend/scripts:/app/scripts {% if has_scripts %} - ./{{ prefix }}scripts:/app/scripts
{% endif %}{% if name == 'userbot' %} - ./backend/sessions:/app/sessions {% endif %}{% if name == 'userbot' %} - ./{{ prefix }}sessions:/app/sessions
{% endif %}{% if name == 'api' and serve_spa %} - ./frontend/build:/app/static:ro {% endif %}{% if name == 'api' and serve_spa %} - ./frontend/build:/app/static:ro
{% endif %} command: {{ command }} {% endif %} command: {{ command }}
{% if svc_networks %} networks: {% if external_services %} depends_on:
{% for net in svc_networks %} {{ net }}: {% 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 -%} {% endfor %}{% endif %}{% endmacro -%}
services: services:
{% for svc in app_services %}{{ backend_service(svc.name, svc.command) }}{% if not loop.last %} {% 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} command: mongod --port ${DB__PORT:-27017}
volumes: volumes:
- database:/data/db - database:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--port", "${DB__PORT:-27017}", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 5
networks: networks:
database: database:
aliases: aliases:
@@ -70,25 +85,27 @@ services:
- ${DB__HOST:-postgres} - ${DB__HOST:-postgres}
migrator: migrator:
build: backend build: {{ part_dir }}
image: {{ project_slug }}/backend image: {{ project_slug }}/backend
profiles: [migrate] profiles: [migrate]
env_file: env_file:
- path: .env - path: .env
required: false required: false
- path: backend/.env {% if layout == 'part' %} - path: backend/.env
required: false required: false
environment: {% endif %} environment:
RUN_ENVIRONMENT: prod RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes: volumes:
- ./backend/src:/app/src - ./{{ prefix }}src:/app/src
- ./backend/migrations:/app/migrations - ./{{ prefix }}migrations:/app/migrations
- ./backend/alembic.ini:/app/alembic.ini - ./{{ prefix }}alembic.ini:/app/alembic.ini
entrypoint: [alembic] entrypoint: [alembic]
command: [upgrade, head] command: [upgrade, head]
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
required: false
networks: networks:
database: database:
{% endif %} {% endif %}
@@ -98,6 +115,11 @@ services:
profiles: [redis, external] profiles: [redis, external]
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks:
@@ -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.7") %}{% 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.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 '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.38") %}{% set _ = deps.append("asyncpg>=0.31.0") %}{% set _ = deps.append("alembic>=1.18.4") %}{% set _ = deps.append("greenlet>=3.1.0") %}{% 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 host: str = "0.0.0.0" # noqa: S104
port: int = 8080 port: int = 8080
workers: int = 1 workers: int = 1
docs: bool = False
{%- if serve_spa %} {%- if serve_spa %}
static_dir: str = "static" static_dir: str = "static"
{%- endif %} {%- endif %}
@@ -110,7 +111,10 @@ class Settings(BaseSettings):
{%- endif %} {%- endif %}
model_config = SettingsConfigDict( 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 from api import routers
{% if use_dishka %}from dependencies.container import container {% if use_dishka %}from dependencies.container import container
{% endif %}{% if database != 'none' %}from utils.db import init_db {% endif %}{% if database != 'none' %}from utils.db import init_db
{% endif %}{% if serve_spa %}from utils.env import env {% endif %}from utils.env import env
{% endif %}from utils.logging import setup_logging from utils.logging import setup_logging
{% if serve_spa %} {% if serve_spa %}
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
NO_CACHE_HEADERS = {"Cache-Control": "no-cache"} NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
@@ -29,7 +29,11 @@ async def lifespan(app_: FastAPI) -> AsyncGenerator[None]:
{%- endif %} {%- 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( app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] 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 from pyrogram import Client, enums
DEVICE_MODEL = "{{ project_name[:32] }}"
class PyroClient(Client): class PyroClient(Client):
def __init__( 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: ) -> None:
super().__init__( super().__init__(
name, name,
workdir=workdir, workdir=workdir,
api_id=2040, api_id=2040,
api_hash="b18441a1ff607e10a989891a5462e627", api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop", device_model=device_model or DEVICE_MODEL,
system_version="Windows 11 x64", system_version="Windows 11 x64",
app_version="6.2.4 x64", app_version="7.0.8 x64",
lang_pack="tdesktop", lang_pack="tdesktop",
lang_code="en",
system_lang_code="en-US",
client_platform=enums.ClientPlatform.DESKTOP, client_platform=enums.ClientPlatform.DESKTOP,
max_concurrent_transmissions=4,
) )
if load_handlers: if load_handlers:
@@ -24,4 +34,4 @@ class PyroClient(Client):
self.add_handler(*handler) self.add_handler(*handler)
__all__ = ["PyroClient"] __all__ = ["DEVICE_MODEL", "PyroClient"]
@@ -1,5 +1,6 @@
import asyncio import asyncio
import contextlib import contextlib
import signal
import anyio import anyio
import uvloop import uvloop
@@ -11,8 +12,15 @@ import uvloop
setup_logging() setup_logging()
SCAN_INTERVAL = 10
async def runner() -> 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() {% if database != 'none' %} await init_db()
{% endif %} sessions_dir = anyio.Path("sessions") {% endif %} sessions_dir = anyio.Path("sessions")
@@ -22,7 +30,7 @@ async def runner() -> None:
clients: dict[int, PyroClient] = {} clients: dict[int, PyroClient] = {}
try: try:
while True: while not stop.is_set():
current = {path.name async for path in sessions_dir.glob("*.session")} current = {path.name async for path in sessions_dir.glob("*.session")}
for session_file in current - started: for session_file in current - started:
@@ -43,7 +51,8 @@ async def runner() -> None:
if me: if me:
clients[me.id] = client clients[me.id] = client
await asyncio.sleep(10) with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), SCAN_INTERVAL)
finally: finally:
for client in clients.values(): for client in clients.values():
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
@@ -55,8 +64,5 @@ def main() -> None:
uvloop.install() uvloop.install()
logger.info("Starting...") logger.info("Starting...")
asyncio.run(runner())
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]") logger.info("[red]Stopped.[/]")
@@ -1,5 +1,5 @@
import asyncio import asyncio
import contextlib import signal
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
@@ -15,6 +15,10 @@ async def example_job() -> None:
async def runner() -> 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' %} {%- if database != 'none' %}
await init_db() await init_db()
{%- endif %} {%- endif %}
@@ -27,14 +31,13 @@ async def runner() -> None:
scheduler.start() scheduler.start()
logger.info("Scheduler started") logger.info("Scheduler started")
with contextlib.suppress(asyncio.CancelledError): await stop.wait()
await asyncio.Event().wait() scheduler.shutdown()
def main() -> None: def main() -> None:
setup_logging() setup_logging()
logger.info("Starting...") logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt): asyncio.run(runner())
asyncio.run(runner())
logger.info("[red]Stopped.[/]") logger.info("[red]Stopped.[/]")
@@ -39,6 +39,22 @@ broker = ResilientListQueueBroker(env.redis.url).with_result_backend(
{% endif %}{% if use_dishka %} {% endif %}{% if use_dishka %}
setup_dishka(container, broker) setup_dishka(container, broker)
{% endif %} {% 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 -%} {%- else -%}
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka {% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}from taskiq import InMemoryBroker{% if 'scheduler' in backend_services %}, TaskiqScheduler {% 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 migrate downgrade -1` to roll back), `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 %}