Files
dzentra_bot/bootstrap_project.py

728 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import subprocess
import sys
import textwrap
from pathlib import Path
ROOT = Path.cwd()
MIN_PYTHON = (3, 12)
def norm(text: str) -> str:
return textwrap.dedent(text).lstrip("\n").rstrip() + "\n"
def write(path: Path, content: str, overwrite: bool = False) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and not overwrite:
print(f"[SKIP] {path}")
return
path.write_text(norm(content), encoding="utf-8")
print(f"[WRITE] {path}")
def touch(path: Path) -> None:
write(path, '"""Package marker."""\n', overwrite=False)
def run(cmd: list[str], cwd: Path | None = None, allow_fail: bool = False) -> int:
print("[RUN ]", " ".join(cmd))
try:
completed = subprocess.run(cmd, cwd=cwd, check=not allow_fail)
return completed.returncode
except subprocess.CalledProcessError as exc:
print(f"[FAIL] {' '.join(cmd)} -> {exc.returncode}")
if not allow_fail:
raise
return exc.returncode
def ensure_python() -> None:
current = sys.version_info[:3]
if current < MIN_PYTHON:
req = ".".join(map(str, MIN_PYTHON))
got = ".".join(map(str, current))
raise SystemExit(
f"Python {req}+ is required. Current interpreter: {got}. "
f"Run with /opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install"
)
def create_dirs() -> None:
for rel in [
"app/src/bootstrap",
"app/src/core",
"app/src/telegram/handlers",
"app/src/telegram/keyboards",
"app/src/telegram/callbacks",
"app/src/trading",
"app/src/storage",
"app/src/integrations",
"app/src/shared",
"app/tests",
"docs/architecture",
"docs/stages",
"docs/decisions",
"infra/docker",
"infra/compose",
".vscode",
]:
path = ROOT / rel
path.mkdir(parents=True, exist_ok=True)
print(f"[DIR ] {path}")
def create_init_files() -> None:
for rel in [
"app/src/__init__.py",
"app/src/bootstrap/__init__.py",
"app/src/core/__init__.py",
"app/src/telegram/__init__.py",
"app/src/telegram/handlers/__init__.py",
"app/src/telegram/keyboards/__init__.py",
"app/src/telegram/callbacks/__init__.py",
"app/src/trading/__init__.py",
"app/src/storage/__init__.py",
"app/src/integrations/__init__.py",
"app/src/shared/__init__.py",
"app/tests/__init__.py",
]:
touch(ROOT / rel)
def create_root_files() -> None:
write(ROOT / ".gitignore", """
__pycache__/
*.py[cod]
.DS_Store
.pytest_cache/
.mypy_cache/
.ruff_cache/
app/.env
app/.venv/
.venv/
venv/
logs/
data/
""")
write(ROOT / ".vscode" / "settings.json", """
{
"python.defaultInterpreterPath": "app/.venv/bin/python"
}
""")
write(ROOT / "README.md", """
# dzentra_bot
Telegram-бот для автоторговли криптовалютой.
## Bootstrap v2
Это стабильный стартовый каркас проекта с:
- чистой структурой каталогов
- компактным верхним меню
- базовыми handlers
- документацией
- Docker-файлами
- опциональным созданием `.venv`, установкой зависимостей и первым git commit
## Верхнее меню
- 🏠 Главная
- 📈 Рынок
- 💼 Портфель
- ⚡ Торговля
- 🤖 Авто
- 📒 Журнал
- ⚙️ Система
## Рекомендуемый запуск
```bash
/opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install
```
## После bootstrap
```bash
cd app
source .venv/bin/activate
python -m src.main
```
""")
def create_app_files() -> None:
write(ROOT / "app" / ".env.example", """
BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE
BOT_PARSE_MODE=HTML
APP_ENV=dev
LOG_LEVEL=INFO
TZ=Europe/Madrid
""")
write(ROOT / "app" / "requirements.txt", """
aiogram==3.13.1
python-dotenv==1.0.1
""")
write(ROOT / "app" / "README.md", """
# app
Здесь находятся исходный код приложения, env-файлы и зависимости.
""")
def create_core_files() -> None:
write(ROOT / "app/src/core/config.py", """
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
ENV_FILE = BASE_DIR / ".env"
load_dotenv(ENV_FILE)
@dataclass(slots=True)
class Settings:
bot_token: str
bot_parse_mode: str
app_env: str
log_level: str
tz: str
def load_settings() -> Settings:
bot_token = os.getenv("BOT_TOKEN", "").strip()
if not bot_token:
raise RuntimeError("BOT_TOKEN is not set in app/.env")
return Settings(
bot_token=bot_token,
bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML",
app_env=os.getenv("APP_ENV", "dev").strip() or "dev",
log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO",
tz=os.getenv("TZ", "Europe/Madrid").strip() or "Europe/Madrid",
)
""")
write(ROOT / "app/src/core/constants.py", """
APP_NAME = "Dzentra Bot"
APP_VERSION = "2.0.0"
""")
write(ROOT / "app/src/core/exceptions.py", """
class AppError(Exception):
\"\"\"Base application exception.\"\"\"
""")
def create_bootstrap_files() -> None:
write(ROOT / "app/src/bootstrap/logging.py", """
from __future__ import annotations
import logging
def setup_logging(log_level: str) -> None:
logging.basicConfig(
level=getattr(logging, log_level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
""")
write(ROOT / "app/src/bootstrap/app_factory.py", """
from __future__ import annotations
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from src.bootstrap.logging import setup_logging
from src.core.config import load_settings
from src.telegram.routers import setup_routers
def create_app() -> tuple[Bot, Dispatcher]:
settings = load_settings()
setup_logging(settings.log_level)
bot = Bot(
token=settings.bot_token,
default=DefaultBotProperties(parse_mode=settings.bot_parse_mode),
)
dispatcher = Dispatcher()
setup_routers(dispatcher)
return bot, dispatcher
""")
def create_telegram_files() -> None:
write(ROOT / "app/src/telegram/menus.py", """
MAIN_MENU_TEXT = (
"<b>Dzentra Bot</b>\\n\\n"
"Новый каркас проекта успешно создан.\\n\\n"
"Выбери раздел через меню ниже."
)
HOME_TEXT = (
"<b>🏠 Главная</b>\\n\\n"
"Это главный экран бота.\\n\\n"
"Сейчас здесь отображается базовый статус:\\n"
"- бот запущен\\n"
"- меню подключено\\n"
"- handlers работают\\n"
"- проект на этапе Bootstrap v2\\n"
)
SYSTEM_TEXT = (
"<b>⚙️ Система</b>\\n\\n"
"Системный экран.\\n\\n"
"<b>Справка</b>\\n"
"/start — запуск\\n"
"/menu — показать меню\\n"
"/help — краткая справка\\n"
)
MARKET_TEXT = "<b>📈 Рынок</b>\\n\\nРаздел пока в разработке."
PORTFOLIO_TEXT = "<b>💼 Портфель</b>\\n\\nРаздел пока в разработке."
TRADE_TEXT = "<b>⚡ Торговля</b>\\n\\nРаздел пока в разработке."
AUTO_TEXT = "<b>🤖 Авто</b>\\n\\nРаздел пока в разработке."
JOURNAL_TEXT = "<b>📒 Журнал</b>\\n\\nРаздел пока в разработке."
""")
write(ROOT / "app/src/telegram/keyboards/reply.py", """
from aiogram.types import KeyboardButton, ReplyKeyboardMarkup
def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="🏠 Главная"),
KeyboardButton(text="📈 Рынок"),
KeyboardButton(text="💼 Портфель"),
],
[
KeyboardButton(text="⚡ Торговля"),
KeyboardButton(text="🤖 Авто"),
KeyboardButton(text="📒 Журнал"),
],
[
KeyboardButton(text="⚙️ Система"),
],
],
resize_keyboard=True,
input_field_placeholder="Выбери раздел...",
)
""")
write(ROOT / "app/src/telegram/routers.py", """
from aiogram import Dispatcher
from src.telegram.handlers.auto import router as auto_router
from src.telegram.handlers.home import router as home_router
from src.telegram.handlers.journal import router as journal_router
from src.telegram.handlers.market import router as market_router
from src.telegram.handlers.portfolio import router as portfolio_router
from src.telegram.handlers.start import router as start_router
from src.telegram.handlers.system import router as system_router
from src.telegram.handlers.trade import router as trade_router
def setup_routers(dispatcher: Dispatcher) -> None:
dispatcher.include_router(start_router)
dispatcher.include_router(home_router)
dispatcher.include_router(market_router)
dispatcher.include_router(portfolio_router)
dispatcher.include_router(trade_router)
dispatcher.include_router(auto_router)
dispatcher.include_router(journal_router)
dispatcher.include_router(system_router)
""")
write(ROOT / "app/src/telegram/handlers/start.py", """
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import Message
from src.telegram.keyboards.reply import build_main_menu_keyboard
from src.telegram.menus import MAIN_MENU_TEXT, SYSTEM_TEXT
router = Router(name="start")
@router.message(Command("start"))
async def cmd_start(message: Message) -> None:
await message.answer(MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard())
@router.message(Command("menu"))
async def cmd_menu(message: Message) -> None:
await message.answer(MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard())
@router.message(Command("help"))
async def cmd_help(message: Message) -> None:
await message.answer(SYSTEM_TEXT, reply_markup=build_main_menu_keyboard())
@router.message(F.text == "Меню")
async def menu_shortcut(message: Message) -> None:
await message.answer(MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard())
""")
write(ROOT / "app/src/telegram/handlers/home.py", """
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import HOME_TEXT
router = Router(name="home")
@router.message(F.text == "🏠 Главная")
async def open_home(message: Message) -> None:
await message.answer(HOME_TEXT)
""")
write(ROOT / "app/src/telegram/handlers/market.py", """
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import MARKET_TEXT
router = Router(name="market")
@router.message(F.text == "📈 Рынок")
async def open_market(message: Message) -> None:
await message.answer(MARKET_TEXT)
""")
write(ROOT / "app/src/telegram/handlers/portfolio.py", """
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import PORTFOLIO_TEXT
router = Router(name="portfolio")
@router.message(F.text == "💼 Портфель")
async def open_portfolio(message: Message) -> None:
await message.answer(PORTFOLIO_TEXT)
""")
write(ROOT / "app/src/telegram/handlers/trade.py", """
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import TRADE_TEXT
router = Router(name="trade")
@router.message(F.text == "⚡ Торговля")
async def open_trade(message: Message) -> None:
await message.answer(TRADE_TEXT)
""")
write(ROOT / "app/src/telegram/handlers/auto.py", """
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import AUTO_TEXT
router = Router(name="auto")
@router.message(F.text == "🤖 Авто")
async def open_auto(message: Message) -> None:
await message.answer(AUTO_TEXT)
""")
write(ROOT / "app/src/telegram/handlers/journal.py", """
from aiogram import F, Router
from aiogram.types import Message
from src.telegram.menus import JOURNAL_TEXT
router = Router(name="journal")
@router.message(F.text == "📒 Журнал")
async def open_journal(message: Message) -> None:
await message.answer(JOURNAL_TEXT)
""")
write(ROOT / "app/src/telegram/handlers/system.py", """
import platform
from aiogram import F, Router
from aiogram.types import Message
from src.core.constants import APP_NAME, APP_VERSION
from src.telegram.menus import SYSTEM_TEXT
router = Router(name="system")
@router.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
async def open_system(message: Message) -> None:
runtime_info = (
"\\n\\n<b>Runtime</b>\\n"
f"- app: {APP_NAME} {APP_VERSION}\\n"
f"- python: {platform.python_version()}\\n"
f"- os: {platform.system()} {platform.release()}"
)
await message.answer(SYSTEM_TEXT + runtime_info)
""")
def create_misc_files() -> None:
write(ROOT / "app/src/trading/README.md", """
Здесь будет торговая бизнес-логика.
""")
write(ROOT / "app/src/storage/README.md", """
Здесь будет слой доступа к данным.
""")
write(ROOT / "app/src/integrations/README.md", """
Здесь будут внешние интеграции, например биржа.
""")
write(ROOT / "app/src/shared/README.md", """
Здесь будут общие утилиты.
""")
write(ROOT / "app/tests/test_smoke.py", """
def test_smoke() -> None:
assert True
""")
write(ROOT / "app/src/main.py", """
import asyncio
from src.bootstrap.app_factory import create_app
async def main() -> None:
bot, dispatcher = create_app()
await dispatcher.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
""")
write(ROOT / "infra/docker/Dockerfile", """
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /project
COPY app /project/app
RUN pip install --no-cache-dir -r /project/app/requirements.txt
WORKDIR /project/app
CMD ["python", "-m", "src.main"]
""")
write(ROOT / "infra/compose/docker-compose.yml", """
services:
bot:
build:
context: ../..
dockerfile: infra/docker/Dockerfile
container_name: dzentra_bot
restart: unless-stopped
env_file:
- ../../app/.env
""")
def create_docs() -> None:
write(ROOT / "docs/changelog.md", """
# Changelog
## v2.0.0
- создан стабильный bootstrap v2
- добавлена чистая структура проекта
- добавлено компактное верхнее меню 3 / 3 / 1
- добавлены базовые handlers
- help перенесен в раздел `Система`
- добавлены Docker-файлы
- добавлена стартовая документация
""")
write(ROOT / "docs/architecture/overview.md", """
# Architecture Overview
Проект строится как modular monolith с разделением по слоям:
- `telegram` — меню, handlers, routers
- `bootstrap` — сборка приложения
- `core` — конфигурация и базовые сущности
- `trading` — бизнес-логика торговли
- `storage` — доступ к данным
- `integrations` — внешние API
- `shared` — общие утилиты
""")
write(ROOT / "docs/architecture/project_structure.md", """
# Project Structure
## Корневые папки
- `app/` — код приложения
- `docs/` — документация
- `infra/` — Docker и compose
## Внутри `app/src`
- `bootstrap/`
- `core/`
- `telegram/`
- `trading/`
- `storage/`
- `integrations/`
- `shared/`
""")
write(ROOT / "docs/architecture/telegram_menu.md", """
# Telegram Menu
Верхнее меню bootstrap v2:
1. `🏠 Главная` `📈 Рынок` `💼 Портфель`
2. `⚡ Торговля` `🤖 Авто` `📒 Журнал`
3. `⚙️ Система`
На старте реально работают:
- `/start`
- `/menu`
- `/help`
- все кнопки верхнего меню
""")
write(ROOT / "docs/stages/stage-01-bootstrap.md", """
# Stage 01 — Bootstrap
## Цель
Получить стабильный стартовый каркас проекта без архитектурной каши.
## Что есть
- структура каталогов
- env
- зависимости
- базовый aiogram-бот
- reply-меню
- handlers
- docs
- Docker
""")
write(ROOT / "docs/decisions/0001-project-structure.md", """
# 0001 — Project Structure
Решение:
использовать modular monolith с разбиением на `core`, `bootstrap`, `telegram`,
`trading`, `storage`, `integrations`, `shared`.
""")
write(ROOT / "docs/decisions/0002-menu-design.md", """
# 0002 — Menu Design
Решение:
использовать компактное верхнее меню 3 / 3 / 1:
- Главная / Рынок / Портфель
- Торговля / Авто / Журнал
- Система
Справка перенесена в раздел `Система`.
""")
write(ROOT / "docs/decisions/0003-python-workflow.md", """
# 0003 — Python Workflow
Решение:
- использовать Python 3.12
- создавать `.venv` внутри `app/`
- не использовать conda/base для этого проекта
- в VS Code выбирать `app/.venv/bin/python`
""")
def maybe_create_env() -> None:
env = ROOT / "app/.env"
example = ROOT / "app/.env.example"
if not env.exists():
env.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
print("[WRITE] app/.env created from .env.example")
else:
print("[SKIP] app/.env already exists")
def create_venv_and_install(with_venv: bool, with_install: bool) -> None:
app_dir = ROOT / "app"
venv_dir = app_dir / ".venv"
if with_venv and not venv_dir.exists():
run([sys.executable, "-m", "venv", str(venv_dir)])
if with_install:
if not venv_dir.exists():
raise RuntimeError("app/.venv does not exist. Use --with-venv together with --with-install.")
pip_path = venv_dir / "bin" / "pip"
run([str(pip_path), "install", "--upgrade", "pip"], cwd=app_dir)
run([str(pip_path), "install", "-r", "requirements.txt"], cwd=app_dir)
def setup_git(with_git: bool) -> None:
if not with_git:
return
if not (ROOT / ".git").exists():
run(["git", "init"], cwd=ROOT)
run(["git", "add", "."], cwd=ROOT)
status = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=ROOT, check=False)
if status.returncode != 0:
run(["git", "commit", "-m", "bootstrap v2"], cwd=ROOT, allow_fail=True)
else:
print("[INFO] Nothing new to commit.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Stable bootstrap v2 for dzentra_bot.")
parser.add_argument("--with-venv", action="store_true", help="Create app/.venv")
parser.add_argument("--with-install", action="store_true", help="Install requirements into app/.venv")
parser.add_argument("--with-git", action="store_true", help="Initialize git and create first commit")
return parser.parse_args()
def main() -> None:
ensure_python()
args = parse_args()
if args.with_install and not args.with_venv:
raise SystemExit("--with-install requires --with-venv")
print("\n=== dzentra_bot bootstrap v2 ===\n")
print(f"[INFO] Interpreter: {sys.executable}")
print(f"[INFO] Python: {sys.version.split()[0]}")
create_dirs()
create_init_files()
create_root_files()
create_app_files()
create_core_files()
create_bootstrap_files()
create_telegram_files()
create_misc_files()
create_docs()
maybe_create_env()
create_venv_and_install(args.with_venv, args.with_install)
setup_git(args.with_git)
print("\nBootstrap v2 completed.\n")
print("Next steps:")
print("1. Open app/.env and fill BOT_TOKEN")
print("2. cd app")
if args.with_venv:
print("3. source .venv/bin/activate")
else:
print("3. Create venv later with --with-venv --with-install")
print("4. python -m src.main")
print("5. In VS Code use interpreter: app/.venv/bin/python")
print("6. For Synology: sudo docker compose -f infra/compose/docker-compose.yml up --build -d")
if __name__ == "__main__":
main()