Compare commits
17
Commits
v0.2.1
..
c19cdc4e11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19cdc4e11 | ||
|
|
97fdafa092 | ||
|
|
4d2ed061ea | ||
|
|
85ce05a55e | ||
|
|
47debebc0c | ||
|
|
eadfa3d8ec | ||
|
|
6028507874 | ||
|
|
db8184cb37 | ||
|
|
2c1dfefe34 | ||
|
|
b4125407bf | ||
|
|
e0d5defa97 | ||
|
|
dae9532ef6 | ||
|
|
e316bc92a5 | ||
|
|
3878837d25 | ||
|
|
b1db7c274f | ||
|
|
ac0ccf47e5 | ||
|
|
1f416dbe60 |
No files matched your search
@@ -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, `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`. 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>
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
python = "{{ prefix }}.venv"
|
python = "{{ prefix }}.venv"
|
||||||
|
|
||||||
[src]
|
[src]
|
||||||
exclude = ["{{ prefix }}migrations"]
|
exclude = ["docs", "{{ prefix }}migrations"]
|
||||||
@@ -1,27 +1,14 @@
|
|||||||
{% if layout == 'root' %}COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
|
{%- set groups = [] -%}
|
||||||
{% endif %}RUN_ENVIRONMENT=dev
|
{%- if layout == 'root' %}{% set _ = groups.append(["COMPOSE_PROFILES=" ~ ("external," if database != 'none' or use_redis else "") ~ "services"]) %}{% endif -%}
|
||||||
LOG__LEVEL=INFO
|
{%- set _ = groups.append(["RUN_ENVIRONMENT=prod", "TZ=UTC"]) -%}
|
||||||
LOG__LEVEL_EXTERNAL=WARNING
|
{%- set _ = groups.append(["LOG__LEVEL=INFO", "LOG__LEVEL_EXTERNAL=WARNING", "LOG__SHOW_TIME=false", "LOG__CONSOLE_WIDTH=150"]) -%}
|
||||||
LOG__SHOW_TIME=false
|
{%- 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 -%}
|
||||||
LOG__CONSOLE_WIDTH=150
|
{%- 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 database == 'mongo' %}DB__HOST=mongodb
|
{%- 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 -%}
|
||||||
DB__PORT=27017
|
{%- if 'api' in backend_services %}{% set _ = groups.append(["API__HOST=0.0.0.0", "API__PORT=8080", "API__WORKERS=1", "API__DOCS=false"]) %}{% endif -%}
|
||||||
DB__USER={{ project_slug }}
|
{%- if 'aiogram_bot' in backend_services %}{% set _ = groups.append(["BOT__TOKEN="]) %}{% endif -%}
|
||||||
DB__PASSWORD={{ project_slug }}
|
{%- if include_payments %}{% set _ = groups.append(["CRYPTO_PAY__TOKEN="]) %}{% endif -%}
|
||||||
DB__DB_NAME={{ project_slug }}
|
{%- if use_llm %}{% set _ = groups.append(["LLM__MODEL=google-gla:gemini-2.5-flash", "LLM__GEMINI_API_KEY="]) %}{% endif -%}
|
||||||
{% endif %}{% if database == 'postgres' %}DB__HOST=postgres
|
{% for group in groups %}{{ group | join("\n") }}
|
||||||
DB__PORT=5432
|
{% if not loop.last %}
|
||||||
DB__USER={{ project_slug }}
|
{% endif %}{% endfor %}
|
||||||
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 %}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,12 +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 -%}
|
||||||
{% if layout == 'root' %}SERVICES := $(COMPOSE) --profile services
|
COMPOSE := {% if layout == 'part' %}cd .. && {% endif %}docker compose
|
||||||
EXTERNAL := $(COMPOSE) --profile external
|
{% 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 %}
|
{% endif %}
|
||||||
.PHONY: {% if layout == 'root' %}env external 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 %}
|
.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
|
||||||
@@ -19,42 +30,67 @@ 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; }; \
|
||||||
|
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
|
||||||
|
|
||||||
external:
|
recreate:
|
||||||
$(EXTERNAL) up -d
|
$(COMPOSE) up -d --force-recreate
|
||||||
|
|
||||||
recreate: external
|
rebuild: build
|
||||||
$(SERVICES) up -d --force-recreate
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
rebuild: build recreate
|
|
||||||
|
|
||||||
restart:
|
restart:
|
||||||
$(SERVICES) restart
|
$(COMPOSE) restart
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(SERVICES) logs -f --tail=100
|
$(COMPOSE) logs -f --tail=100
|
||||||
|
|
||||||
down:
|
down:
|
||||||
$(EXTERNAL) --profile services 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 %}
|
||||||
session:
|
session:
|
||||||
uv run python scripts/session/create.py
|
uv run python scripts/session/create.py
|
||||||
{% endif %}
|
{% 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 %}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{%- 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
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:8080:${API__PORT}"
|
||||||
|
{% elif entry == 'mongodb' %} mongodb:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:27017:${DB__PORT}"
|
||||||
|
{% elif entry == 'postgres' %} postgres:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:5432:${DB__PORT}"
|
||||||
|
{% elif entry == 'redis' %} redis:
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:6379:${REDIS__PORT}"
|
||||||
|
{% endif %}{% if not loop.last %}
|
||||||
|
{% endif %}{% endfor %}{% if 'api' in backend_services %}
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
|
{% 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 %}
|
|
||||||
@@ -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 -%}
|
||||||
@@ -20,14 +24,20 @@
|
|||||||
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
|
||||||
{% if has_scripts %} - ./{{ prefix }}scripts:/app/scripts
|
{% if has_scripts %} - ./{{ prefix }}scripts:/app/scripts
|
||||||
{% endif %}{% if name == 'userbot' %} - ./{{ prefix }}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 %}
|
||||||
@@ -40,13 +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:
|
||||||
|
test: ["CMD", "mongosh", "--quiet", "--port", "${DB__PORT}", "--eval", "db.adminCommand('ping')"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
database:
|
database:
|
||||||
aliases:
|
aliases:
|
||||||
- ${DB__HOST:-mongodb}
|
- ${DB__HOST}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- if database == 'postgres' %}
|
{%- if database == 'postgres' %}
|
||||||
postgres:
|
postgres:
|
||||||
@@ -57,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:
|
||||||
@@ -67,7 +83,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
database:
|
database:
|
||||||
aliases:
|
aliases:
|
||||||
- ${DB__HOST:-postgres}
|
- ${DB__HOST}
|
||||||
|
|
||||||
migrator:
|
migrator:
|
||||||
build: {{ part_dir }}
|
build: {{ part_dir }}
|
||||||
@@ -80,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
|
||||||
@@ -89,6 +106,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
required: false
|
||||||
networks:
|
networks:
|
||||||
database:
|
database:
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -97,13 +115,18 @@ 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:
|
||||||
|
test: ["CMD", "redis-cli", "-p", "${REDIS__PORT}", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
+1
-1
@@ -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:
|
||||||
|
|||||||
+60
@@ -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())
|
||||||
@@ -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,51 +50,56 @@ 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
|
||||||
|
docs: bool = False
|
||||||
{%- if serve_spa %}
|
{%- if serve_spa %}
|
||||||
static_dir: str = "static"
|
static_dir: str = "static"
|
||||||
{%- 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 %}
|
||||||
@@ -110,7 +126,14 @@ class Settings(BaseSettings):
|
|||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
|
{%- if layout == 'part' %}
|
||||||
|
case_sensitive=False,
|
||||||
|
env_file=("../.env", ".env"),
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
{%- else %}
|
||||||
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
|
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 %}
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
+5
-1
@@ -5,7 +5,11 @@ from utils.env import env
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"api.app:app", host=env.api.host, port=env.api.port, workers=env.api.workers
|
"api.app:app",
|
||||||
|
host=env.api.host,
|
||||||
|
port=env.api.port,
|
||||||
|
workers=env.api.workers,
|
||||||
|
forwarded_allow_ips="*",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-7
@@ -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,14 +29,19 @@ 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=["*"]
|
||||||
)
|
)
|
||||||
|
|
||||||
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():
|
||||||
|
|
||||||
@@ -48,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 %}
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
from .modules.client import PyroClient
|
|
||||||
|
|
||||||
__all__ = ["PyroClient"]
|
|
||||||
+4
-5
@@ -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)]
|
||||||
+4
-5
@@ -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)]
|
||||||
+14
-4
@@ -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"]
|
||||||
+12
-6
@@ -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.[/]")
|
||||||
+8
-5
@@ -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.[/]")
|
||||||
-4
@@ -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 -%}
|
||||||
|
|||||||
+9
-3
@@ -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.
|
||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user