13 Commits
Author SHA1 Message Date
hh c19cdc4e11 feat(check): make check fails when a service imports another service's sdk at startup 2026-10-08 04:53:54 +02:00
hh 97fdafa092 refactor(userbot): package init stops re-exporting the pyrogram client 2026-10-08 04:53:54 +02:00
hh 4d2ed061ea perf(llm): google sdk imported when the agent is built, in a worker thread through utils.imports.preload 2026-10-08 04:53:54 +02:00
hh 85ce05a55e perf(dishka): aiogram and fastapi integration providers left out of the shared container, services stop importing each other's framework 2026-10-08 04:53:54 +02:00
hh 47debebc0c perf(logging): rich finds the aiogram router path with find_spec, setup_logging no longer imports aiogram in every service 2026-10-08 04:53:54 +02:00
hh eadfa3d8ec chore(deps): raise lower bounds and uv_build, ty skips docs 2026-10-01 02:01:58 +02:00
hh 6028507874 fix(api,userbot): clean app.py ending for every option set, handlers typed for current ty 2026-10-01 02:01:58 +02:00
hh db8184cb37 feat(env): scripts connection urls for runs outside docker, sections ignore unknown keys 2026-10-01 02:01:58 +02:00
hh 2c1dfefe34 feat(compose): services listen on their configured port, publishing is commented out with a random host port 2026-10-01 02:01:58 +02:00
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
20 changed files with 226 additions and 80 deletions

No files matched your search

+3 -1
View File
@@ -2,7 +2,9 @@
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`.
```sh ```sh
copier copy --trust -d project_name="My Thing" <this-repo> <dest> copier copy --trust -d project_name="My Thing" <this-repo> <dest>
+1 -1
View File
@@ -2,4 +2,4 @@
python = "{{ prefix }}.venv" python = "{{ prefix }}.venv"
[src] [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 -%} {%- 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(["RUN_ENVIRONMENT=prod", "TZ=UTC"]) -%}
{%- set _ = groups.append(["LOG__LEVEL=INFO", "LOG__LEVEL_EXTERNAL=WARNING", "LOG__SHOW_TIME=false", "LOG__CONSOLE_WIDTH=150"]) -%} {%- 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 == '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]) %}{% 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"]) %}{% 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 '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 'aiogram_bot' in backend_services %}{% set _ = groups.append(["BOT__TOKEN="]) %}{% endif -%}
{%- if include_payments %}{% set _ = groups.append(["CRYPTO_PAY__TOKEN="]) %}{% endif -%} {%- if include_payments %}{% set _ = groups.append(["CRYPTO_PAY__TOKEN="]) %}{% endif -%}
+50 -11
View File
@@ -4,10 +4,23 @@
{%- if 'taskiq_worker' in backend_services %}{% set _ = svc_modules.append("worker") %}{% 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 '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 -%} {%- 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_script = dynamic_config and svc_modules -%}
COMPOSE := docker compose{% if layout == 'part' %} --project-directory ..{% endif %} {%- set has_scripts = dynamic_config or 'kurigram_userbot' in backend_services -%}
COMPOSE := {% if layout == 'part' %}cd .. && {% endif %}docker compose
.PHONY: {% if layout == 'root' %}env recreate rebuild restart logs down {% endif %}fmt check build{% if database == 'postgres' %} migrate revision{% endif %}{% if has_script %} script{% endif %}{% if 'kurigram_userbot' in backend_services %} session{% endif %} {% 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: fmt:
uv run ruff format uv run ruff format
@@ -17,13 +30,35 @@ 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 %} uv run python scripts/check_imports.py
{% endif %}{% if svc_modules %}
build: build:
$(COMPOSE) build {{ svc_modules[0] }} $(COMPOSE) build {{ svc_modules[0] }}
{% if layout == 'root' %}
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: env:
@test -f .env || cp .env.example .env @fresh=; test -f .env || { cp .env.example .env; fresh=1; }; \
@test -f docker-compose.override.yml || cp docker-compose.override.yml.example docker-compose.override.yml 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: recreate:
$(COMPOSE) up -d --force-recreate $(COMPOSE) up -d --force-recreate
@@ -39,16 +74,20 @@ logs:
down: down:
$(COMPOSE) down $(COMPOSE) down
{% endif %}{% if database == 'postgres' %} {% if svc_modules %}
deploy: pre-deploy
$(COMPOSE) up -d
$(MAKE) post-deploy
{% endif %}{% endif %}{% if database == 'postgres' %}
migrate: migrate:
$(COMPOSE) --profile external run --rm migrator $(COMPOSE) run --rm migrator $(or $(filter-out $@,$(MAKECMDGOALS)),upgrade head)
revision: revision:
$(COMPOSE) --profile external run --rm migrator revision --autogenerate -m "$(m)" $(COMPOSE) run --rm migrator revision --autogenerate -m "$(m)"
{% endif %}{% if has_script %} {% endif %}{% if has_script %}
script: script:
@$(COMPOSE) run --rm {{ svc_modules[0] }} scripts.$(word 2,$(MAKECMDGOALS)) $(wordlist 3,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) @$(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 %} {% endif %}{% if 'kurigram_userbot' in backend_services %}
@@ -11,15 +11,17 @@ services:{% if not entries %} {}{% endif %}
caddy: caddy:
aliases: aliases:
- {{ dash }}-api - {{ dash }}-api
# ports:
# - "127.0.0.1:8080:${API__PORT}"
{% elif entry == 'mongodb' %} mongodb: {% elif entry == 'mongodb' %} mongodb:
ports: # ports:
- "127.0.0.1:${DB__PORT:-27017}:${DB__PORT:-27017}" # - "127.0.0.1:27017:${DB__PORT}"
{% elif entry == 'postgres' %} postgres: {% elif entry == 'postgres' %} postgres:
ports: # ports:
- "127.0.0.1:${DB__PORT:-5432}:5432" # - "127.0.0.1:5432:${DB__PORT}"
{% elif entry == 'redis' %} redis: {% elif entry == 'redis' %} redis:
ports: # ports:
- "127.0.0.1:${REDIS__PORT:-6379}:6379" # - "127.0.0.1:6379:${REDIS__PORT}"
{% endif %}{% if not loop.last %} {% endif %}{% if not loop.last %}
{% endif %}{% endfor %}{% if 'api' in backend_services %} {% endif %}{% endfor %}{% if 'api' in backend_services %}
networks: networks:
@@ -50,18 +50,18 @@ services:
environment: environment:
MONGO_INITDB_ROOT_USERNAME: ${DB__USER} MONGO_INITDB_ROOT_USERNAME: ${DB__USER}
MONGO_INITDB_ROOT_PASSWORD: ${DB__PASSWORD} MONGO_INITDB_ROOT_PASSWORD: ${DB__PASSWORD}
command: mongod --port ${DB__PORT:-27017} command: mongod --port ${DB__PORT}
volumes: volumes:
- database:/data/db - database:/data/db
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
networks: networks:
database: database:
aliases: aliases:
- ${DB__HOST:-mongodb} - ${DB__HOST}
{% endif %} {% endif %}
{%- if database == 'postgres' %} {%- if database == 'postgres' %}
postgres: postgres:
@@ -72,6 +72,7 @@ services:
POSTGRES_USER: ${DB__USER} POSTGRES_USER: ${DB__USER}
POSTGRES_PASSWORD: ${DB__PASSWORD} POSTGRES_PASSWORD: ${DB__PASSWORD}
POSTGRES_DB: ${DB__DB_NAME} POSTGRES_DB: ${DB__DB_NAME}
PGPORT: ${DB__PORT}
volumes: volumes:
- database:/var/lib/postgresql/data - database:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -82,7 +83,7 @@ services:
networks: networks:
database: database:
aliases: aliases:
- ${DB__HOST:-postgres} - ${DB__HOST}
migrator: migrator:
build: {{ part_dir }} build: {{ part_dir }}
@@ -95,6 +96,7 @@ services:
required: false required: false
{% endif %} environment: {% endif %} environment:
RUN_ENVIRONMENT: prod RUN_ENVIRONMENT: prod
TZ: ${TZ:-UTC}
volumes: volumes:
- ./{{ prefix }}src:/app/src - ./{{ prefix }}src:/app/src
- ./{{ prefix }}migrations:/app/migrations - ./{{ prefix }}migrations:/app/migrations
@@ -104,6 +106,7 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
required: false
networks: networks:
database: database:
{% endif %} {% endif %}
@@ -112,9 +115,9 @@ services:
image: redis:7-alpine image: redis:7-alpine
profiles: [redis, external] profiles: [redis, external]
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--port", "${REDIS__PORT}", "--appendonly", "yes"]
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "-p", "${REDIS__PORT}", "ping"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -123,7 +126,7 @@ services:
networks: networks:
redis: redis:
aliases: aliases:
- ${REDIS__HOST:-redis} - ${REDIS__HOST}
{% endif %} {% endif %}
{%- if database != 'none' or use_redis %} {%- if database != 'none' or use_redis %}
volumes: volumes:
+5 -5
View File
@@ -1,15 +1,15 @@
{%- set deps = ["pydantic-settings>=2.14.2", "rich>=15.0.0"] -%} {%- set deps = ["pydantic-settings>=2.14.2", "rich>=15.0.0"] -%}
{%- if use_dishka %}{% set _ = deps.append("dishka>=1.10.1") %}{% endif -%} {%- 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 '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.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.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.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_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 = [] -%} {%- set modules = [] -%}
{%- if 'api' in backend_services %}{% set _ = modules.append("api") %}{% endif -%} {%- if 'api' in backend_services %}{% set _ = modules.append("api") %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = modules.append("bot") %}{% endif -%} {%- if 'aiogram_bot' in backend_services %}{% set _ = modules.append("bot") %}{% endif -%}
@@ -34,7 +34,7 @@ dependencies = [
] ]
[build-system] [build-system]
requires = ["uv_build>=0.11.14,<0.12.0"] requires = ["uv_build>=0.12.10,<0.13.0"]
build-backend = "uv_build" build-backend = "uv_build"
[tool.uv.build-backend] [tool.uv.build-backend]
@@ -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())
+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 {% 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 %} {% 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" return os.getenv("RUN_ENVIRONMENT") == "prod"
{% endif %} {% endif %}
class Section(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
class LogSettings(BaseSettings):
class LogSettings(Section):
level: str = "INFO" level: str = "INFO"
level_external: str = "WARNING" level_external: str = "WARNING"
show_time: bool = False show_time: bool = False
console_width: int = 150 console_width: int = 150
{% if database == 'mongo' %} {% if database == 'mongo' %}
class DatabaseSettings(BaseSettings): class DatabaseSettings(Section):
host: str = "mongodb" host: str = "mongodb"
port: int = 27017 port: int = 27017
user: str = "{{ project_slug }}" user: str = "{{ project_slug }}"
password: SecretStr = SecretStr("{{ project_slug }}") password: SecretStr = SecretStr("{{ project_slug }}")
db_name: str = "{{ project_slug }}" db_name: str = "{{ project_slug }}"
connection_params: str = "?authSource=admin" connection_params: str = "?authSource=admin"
{{ str_field("scripts_connection_url", "mongodb://" ~ project_slug ~ ":" ~ project_slug ~ "@localhost:27017/" ~ project_slug ~ "?authSource=admin") }}
@property @property
def connection_url(self) -> str: 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() 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' %} {% elif database == 'postgres' %}
class DatabaseSettings(BaseSettings): class DatabaseSettings(Section):
host: str = "postgres" host: str = "postgres"
port: int = 5432 port: int = 5432
user: str = "{{ project_slug }}" user: str = "{{ project_slug }}"
@@ -39,33 +50,37 @@ class DatabaseSettings(BaseSettings):
db_name: str = "{{ project_slug }}" db_name: str = "{{ project_slug }}"
min_pool_size: int = 5 min_pool_size: int = 5
max_pool_size: int = 20 max_pool_size: int = 20
{{ str_field("scripts_connection_url", "postgresql://" ~ project_slug ~ ":" ~ project_slug ~ "@localhost:5432/" ~ project_slug) }}
@property @property
def connection_url(self) -> str: 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() 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 @property
def async_connection_url(self) -> str: def async_connection_url(self) -> str:
return self.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1) return self.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1)
{% endif %}{% if use_redis %} {% endif %}{% if use_redis %}
class RedisSettings(BaseSettings): class RedisSettings(Section):
host: str = "redis" host: str = "redis"
port: int = 6379 port: int = 6379
db: int = 0 db: int = 0
password: SecretStr | None = None password: SecretStr | None = None
cache_ttl: int = 300 cache_ttl: int = 300
scripts_connection_url: str = "redis://localhost:6379/0"
@property @property
def url(self) -> str: 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 "" 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 %} {% endif %}{% if 'api' in backend_services %}
class ApiSettings(BaseSettings): class ApiSettings(Section):
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
@@ -75,16 +90,16 @@ class ApiSettings(BaseSettings):
{%- endif %} {%- endif %}
{% endif %}{% if 'aiogram_bot' in backend_services %} {% endif %}{% if 'aiogram_bot' in backend_services %}
class BotSettings(BaseSettings): class BotSettings(Section):
token: SecretStr = SecretStr("") token: SecretStr = SecretStr("")
admins: list[int] = Field(default_factory=list) admins: list[int] = Field(default_factory=list)
{% endif %}{% if include_payments %} {% endif %}{% if include_payments %}
class CryptoPaySettings(BaseSettings): class CryptoPaySettings(Section):
token: SecretStr = SecretStr("") token: SecretStr = SecretStr("")
{% endif %}{% if use_llm %} {% endif %}{% if use_llm %}
class LlmSettings(BaseSettings): class LlmSettings(Section):
model: str = "google-gla:gemini-2.5-flash" model: str = "google-gla:gemini-2.5-flash"
gemini_api_key: SecretStr = SecretStr("") gemini_api_key: SecretStr = SecretStr("")
{% endif %} {% endif %}
@@ -111,10 +126,14 @@ class Settings(BaseSettings):
{%- endif %} {%- endif %}
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
{%- if layout == 'part' %}
case_sensitive=False, case_sensitive=False,
env_file={% if layout == 'part' %}("../.env", ".env"){% else %}".env"{% endif %}, env_file=("../.env", ".env"),
env_nested_delimiter="__", env_nested_delimiter="__",
extra="ignore", extra="ignore",
{%- else %}
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
{%- endif %}
) )
@@ -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 %}
) )
], ],
@@ -40,7 +40,8 @@ app.add_middleware(
) )
app.include_router(routers.router) app.include_router(routers.router)
{% if serve_spa %} {%- if serve_spa %}
spa_dir = Path(env.api.static_dir).resolve() spa_dir = Path(env.api.static_dir).resolve()
if spa_dir.is_dir(): if spa_dir.is_dir():
@@ -52,6 +53,9 @@ if spa_dir.is_dir():
headers = IMMUTABLE_HEADERS if immutable else NO_CACHE_HEADERS headers = IMMUTABLE_HEADERS if immutable else NO_CACHE_HEADERS
return FileResponse(candidate, headers=headers) return FileResponse(candidate, headers=headers)
return FileResponse(spa_dir / "index.html", headers=NO_CACHE_HEADERS) return FileResponse(spa_dir / "index.html", headers=NO_CACHE_HEADERS)
{% endif %} {%- endif %}
{% if use_dishka %}setup_dishka(container, app) {%- if use_dishka %}
{% endif %}
{% if serve_spa %}
{% endif %}setup_dishka(container, app)
{%- endif %}
@@ -1,3 +0,0 @@
from .modules.client import DEVICE_MODEL, PyroClient
__all__ = ["DEVICE_MODEL", "PyroClient"]
@@ -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 from utils.logging import logger
@PyroClient.on_message(filters.me) async def echo(_: Client, message: types.Message) -> None:
async def echo(_: PyroClient, message: types.Message) -> None:
logger.info(f"[cyan]me:[/] {message.text}") 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 from utils.logging import logger
@PyroClient.on_message(filters.private & ~filters.me) async def pm_message(_: Client, message: types.Message) -> None:
async def pm_message(_: PyroClient, message: types.Message) -> None:
sender = message.from_user.id if message.from_user else "unknown" sender = message.from_user.id if message.from_user else "unknown"
logger.info(f"[green]pm from {sender}:[/] {message.text}") logger.info(f"[green]pm from {sender}:[/] {message.text}")
handlers = pm_message.handlers handlers = [(MessageHandler(pm_message, filters.private & ~filters.me), 0)]
@@ -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()),
@@ -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.
@@ -1,5 +1,5 @@
{%- set targets = [] -%} {%- 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 dynamic_config and backend_services %}{% set _ = targets.append("`make script name`") %}{% endif -%}
{%- if 'kurigram_userbot' in backend_services %}{% set _ = targets.append("`make session`") %}{% endif -%} {%- if 'kurigram_userbot' in backend_services %}{% set _ = targets.append("`make session`") %}{% endif -%}
# {{ project_name }} # {{ project_name }}
@@ -15,4 +15,6 @@ 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. `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.
`make fmt` formats, `make check` runs ruff and ty.{% if targets %} Also {{ targets | join(", ") }}.{% endif %} {% 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 %}