6 Commits
12 changed files with 91 additions and 57 deletions

No files matched your search

+2
View File
@@ -4,6 +4,8 @@ Copier template for a Python backend, either as a `backend/` part next to other
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.
`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`.
```sh
copier copy --trust -d project_name="My Thing" <this-repo> <dest>
copier update -a .copier/backend.yml
+1 -1
View File
@@ -2,4 +2,4 @@
python = "{{ prefix }}.venv"
[src]
exclude = ["{{ prefix }}migrations"]
exclude = ["docs", "{{ prefix }}migrations"]
+3 -3
View File
@@ -2,9 +2,9 @@
{%- 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 database == 'mongo' %}{% set _ = groups.append(["DB__HOST=mongodb", "DB__PORT=27017", "DB__USER=" ~ project_slug, "DB__PASSWORD=" ~ project_slug, "DB__DB_NAME=" ~ project_slug, "DB__SCRIPTS_CONNECTION_URL=mongodb://" ~ project_slug ~ ":" ~ project_slug ~ "@localhost:27017/" ~ project_slug ~ "?authSource=admin"]) %}{% 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, "DB__SCRIPTS_CONNECTION_URL=postgresql://" ~ project_slug ~ ":" ~ project_slug ~ "@localhost:5432/" ~ project_slug]) %}{% endif -%}
{%- if use_redis %}{% set _ = groups.append(["REDIS__HOST=redis", "REDIS__PORT=6379", "REDIS__CACHE_TTL=300", "REDIS__SCRIPTS_CONNECTION_URL=redis://localhost:6379/0"]) %}{% 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 -%}
+12 -5
View File
@@ -15,7 +15,7 @@
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 %}
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; }
@@ -49,8 +49,15 @@ post-deploy:
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
@fresh=; test -f .env || { cp .env.example .env; fresh=1; }; \
if ! test -f docker-compose.override.yml; then \
cp docker-compose.override.yml.example docker-compose.override.yml; \
for port in $$(sed -n 's/.*127\.0\.0\.1:\([0-9]*\):.*/\1/p' docker-compose.override.yml | sort -u); do \
random=$$((20000 + $$(od -An -N2 -tu2 /dev/urandom) % 40000)); \
sed "s/127\.0\.0\.1:$$port:/127.0.0.1:$$random:/" docker-compose.override.yml > docker-compose.override.yml.tmp && mv docker-compose.override.yml.tmp docker-compose.override.yml; \
test -z "$$fresh" || { sed "s|localhost:$$port/|localhost:$$random/|" .env > .env.tmp && mv .env.tmp .env; }; \
done; \
fi
recreate:
$(COMPOSE) up -d --force-recreate
@@ -72,14 +79,14 @@ deploy: pre-deploy
$(MAKE) post-deploy
{% endif %}{% endif %}{% if database == 'postgres' %}
migrate:
$(COMPOSE) run --rm migrator
$(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 %}
@@ -11,15 +11,17 @@ services:{% if not entries %} {}{% endif %}
caddy:
aliases:
- {{ dash }}-api
# ports:
# - "127.0.0.1:8080:${API__PORT}"
{% elif entry == 'mongodb' %} mongodb:
ports:
- "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}"
# ports:
# - "127.0.0.1:27017:${DB__PORT}"
{% elif entry == 'postgres' %} postgres:
ports:
- "127.0.0.1:${DB__PORT:-5432}:5432"
# ports:
# - "127.0.0.1:5432:${DB__PORT}"
{% elif entry == 'redis' %} redis:
ports:
- "127.0.0.1:${REDIS__PORT:-6379}:6379"
# ports:
# - "127.0.0.1:6379:${REDIS__PORT}"
{% endif %}{% if not loop.last %}
{% endif %}{% endfor %}{% if 'api' in backend_services %}
networks:
@@ -50,18 +50,18 @@ services:
environment:
MONGO_INITDB_ROOT_USERNAME: ${DB__USER}
MONGO_INITDB_ROOT_PASSWORD: ${DB__PASSWORD}
command: mongod --port ${DB__PORT:-27017}
command: mongod --port ${DB__PORT}
volumes:
- database:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--port", "${DB__PORT:-27017}", "--eval", "db.adminCommand('ping')"]
test: ["CMD", "mongosh", "--quiet", "--port", "${DB__PORT}", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 5
networks:
database:
aliases:
- ${DB__HOST:-mongodb}
- ${DB__HOST}
{% endif %}
{%- if database == 'postgres' %}
postgres:
@@ -72,6 +72,7 @@ services:
POSTGRES_USER: ${DB__USER}
POSTGRES_PASSWORD: ${DB__PASSWORD}
POSTGRES_DB: ${DB__DB_NAME}
PGPORT: ${DB__PORT}
volumes:
- database:/var/lib/postgresql/data
healthcheck:
@@ -82,7 +83,7 @@ services:
networks:
database:
aliases:
- ${DB__HOST:-postgres}
- ${DB__HOST}
migrator:
build: {{ part_dir }}
@@ -95,6 +96,7 @@ services:
required: false
{% endif %} environment:
RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes:
- ./{{ prefix }}src:/app/src
- ./{{ prefix }}migrations:/app/migrations
@@ -113,9 +115,9 @@ services:
image: redis:7-alpine
profiles: [redis, external]
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
command: ["redis-server", "--port", "${REDIS__PORT}", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ["CMD", "redis-cli", "-p", "${REDIS__PORT}", "ping"]
interval: 5s
timeout: 5s
retries: 5
@@ -124,7 +126,7 @@ services:
networks:
redis:
aliases:
- ${REDIS__HOST:-redis}
- ${REDIS__HOST}
{% endif %}
{%- if database != 'none' or use_redis %}
volumes:
+5 -5
View File
@@ -1,15 +1,15 @@
{%- set deps = ["pydantic-settings>=2.14.2", "rich>=15.0.0"] -%}
{%- if use_dishka %}{% set _ = deps.append("dishka>=1.10.1") %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = deps.append("fastapi>=0.138.0") %}{% set _ = deps.append("uvicorn[standard]>=0.49.0") %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = deps.append("fastapi>=0.139.0") %}{% set _ = deps.append("uvicorn[standard]>=0.50.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 '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.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 '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 -%}
{%- 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 use_redis %}{% set _ = deps.append("redis>=5.2.0") %}{% endif -%}
{%- if use_llm %}{% set _ = deps.append("pydantic-ai-slim[google]>=2.1.0") %}{% endif -%}
{%- if use_llm %}{% set _ = deps.append("pydantic-ai-slim[google]>=2.15.0") %}{% endif -%}
{%- set modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = modules.append("bot") %}{% endif -%}
@@ -34,7 +34,7 @@ dependencies = [
]
[build-system]
requires = ["uv_build>=0.11.14,<0.12.0"]
requires = ["uv_build>=0.12.10,<0.13.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
+34 -15
View File
@@ -1,3 +1,9 @@
{%- macro str_field(name, value) -%}
{%- set line = " " ~ name ~ ': str = "' ~ value ~ '"' -%}
{%- if line | length <= 88 or value | length + 10 > 88 %}{{ line }}{% else %} {{ name }}: str = (
"{{ value }}"
){% endif -%}
{%- endmacro -%}
{% if database != 'none' or use_redis %}import os
{% endif %}from pydantic import Field{% if database != 'none' or 'aiogram_bot' in backend_services or include_payments or use_llm or use_redis %}, SecretStr{% endif %}
@@ -8,30 +14,35 @@ def is_prod() -> bool:
return os.getenv("RUN_ENVIRONMENT") == "prod"
{% endif %}
class Section(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
class LogSettings(BaseSettings):
class LogSettings(Section):
level: str = "INFO"
level_external: str = "WARNING"
show_time: bool = False
console_width: int = 150
{% if database == 'mongo' %}
class DatabaseSettings(BaseSettings):
class DatabaseSettings(Section):
host: str = "mongodb"
port: int = 27017
user: str = "{{ project_slug }}"
password: SecretStr = SecretStr("{{ project_slug }}")
db_name: str = "{{ project_slug }}"
connection_params: str = "?authSource=admin"
{{ str_field("scripts_connection_url", "mongodb://" ~ project_slug ~ ":" ~ project_slug ~ "@localhost:27017/" ~ project_slug ~ "?authSource=admin") }}
@property
def connection_url(self) -> str:
host = self.host if is_prod() else "localhost"
if not is_prod():
return self.scripts_connection_url
password = self.password.get_secret_value()
return f"mongodb://{self.user}:{password}@{host}:{self.port}/{self.db_name}{self.connection_params}"
return f"mongodb://{self.user}:{password}@{self.host}:{self.port}/{self.db_name}{self.connection_params}"
{% elif database == 'postgres' %}
class DatabaseSettings(BaseSettings):
class DatabaseSettings(Section):
host: str = "postgres"
port: int = 5432
user: str = "{{ project_slug }}"
@@ -39,33 +50,37 @@ class DatabaseSettings(BaseSettings):
db_name: str = "{{ project_slug }}"
min_pool_size: int = 5
max_pool_size: int = 20
{{ str_field("scripts_connection_url", "postgresql://" ~ project_slug ~ ":" ~ project_slug ~ "@localhost:5432/" ~ project_slug) }}
@property
def connection_url(self) -> str:
host = self.host if is_prod() else "localhost"
if not is_prod():
return self.scripts_connection_url
password = self.password.get_secret_value()
return f"postgresql://{self.user}:{password}@{host}:{self.port}/{self.db_name}"
return f"postgresql://{self.user}:{password}@{self.host}:{self.port}/{self.db_name}"
@property
def async_connection_url(self) -> str:
return self.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1)
{% endif %}{% if use_redis %}
class RedisSettings(BaseSettings):
class RedisSettings(Section):
host: str = "redis"
port: int = 6379
db: int = 0
password: SecretStr | None = None
cache_ttl: int = 300
scripts_connection_url: str = "redis://localhost:6379/0"
@property
def url(self) -> str:
host = self.host if is_prod() else "localhost"
if not is_prod():
return self.scripts_connection_url
auth = f":{self.password.get_secret_value()}@" if self.password else ""
return f"redis://{auth}{host}:{self.port}/{self.db}"
return f"redis://{auth}{self.host}:{self.port}/{self.db}"
{% endif %}{% if 'api' in backend_services %}
class ApiSettings(BaseSettings):
class ApiSettings(Section):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
workers: int = 1
@@ -75,16 +90,16 @@ class ApiSettings(BaseSettings):
{%- endif %}
{% endif %}{% if 'aiogram_bot' in backend_services %}
class BotSettings(BaseSettings):
class BotSettings(Section):
token: SecretStr = SecretStr("")
admins: list[int] = Field(default_factory=list)
{% endif %}{% if include_payments %}
class CryptoPaySettings(BaseSettings):
class CryptoPaySettings(Section):
token: SecretStr = SecretStr("")
{% endif %}{% if use_llm %}
class LlmSettings(BaseSettings):
class LlmSettings(Section):
model: str = "google-gla:gemini-2.5-flash"
gemini_api_key: SecretStr = SecretStr("")
{% endif %}
@@ -111,10 +126,14 @@ class Settings(BaseSettings):
{%- endif %}
model_config = SettingsConfigDict(
{%- if layout == 'part' %}
case_sensitive=False,
env_file={% if layout == 'part' %}("../.env", ".env"){% else %}".env"{% endif %},
env_file=("../.env", ".env"),
env_nested_delimiter="__",
extra="ignore",
{%- else %}
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
{%- endif %}
)
@@ -40,7 +40,8 @@ app.add_middleware(
)
app.include_router(routers.router)
{% if serve_spa %}
{%- if serve_spa %}
spa_dir = Path(env.api.static_dir).resolve()
if spa_dir.is_dir():
@@ -52,6 +53,9 @@ if spa_dir.is_dir():
headers = IMMUTABLE_HEADERS if immutable else NO_CACHE_HEADERS
return FileResponse(candidate, headers=headers)
return FileResponse(spa_dir / "index.html", headers=NO_CACHE_HEADERS)
{% endif %}
{% if use_dishka %}setup_dishka(container, app)
{% endif %}
{%- endif %}
{%- if use_dishka %}
{% if serve_spa %}
{% endif %}setup_dishka(container, app)
{%- endif %}
@@ -1,12 +1,11 @@
from pyrogram import filters, types
from pyrogram import Client, filters, types
from pyrogram.handlers import MessageHandler
from userbot import PyroClient
from utils.logging import logger
@PyroClient.on_message(filters.me)
async def echo(_: PyroClient, message: types.Message) -> None:
async def echo(_: Client, message: types.Message) -> None:
logger.info(f"[cyan]me:[/] {message.text}")
handlers = echo.handlers
handlers = [(MessageHandler(echo, filters.me), 0)]
@@ -1,13 +1,12 @@
from pyrogram import filters, types
from pyrogram import Client, filters, types
from pyrogram.handlers import MessageHandler
from userbot import PyroClient
from utils.logging import logger
@PyroClient.on_message(filters.private & ~filters.me)
async def pm_message(_: PyroClient, message: types.Message) -> None:
async def pm_message(_: Client, message: types.Message) -> None:
sender = message.from_user.id if message.from_user else "unknown"
logger.info(f"[green]pm from {sender}:[/] {message.text}")
handlers = pm_message.handlers
handlers = [(MessageHandler(pm_message, filters.private & ~filters.me), 0)]
@@ -1,5 +1,5 @@
{%- set targets = [] -%}
{%- if database == 'postgres' %}{% set _ = targets.append("`make migrate`, `make revision m=\"message\"`") %}{% endif -%}
{%- 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 }}