From 551b4bd690f0e2ef0d58d277712fde1f999838a1 Mon Sep 17 00:00:00 2001 From: Sergey Date: Mon, 13 Apr 2026 20:47:04 +0300 Subject: [PATCH] Stage 01 - bootstrap v2 stable start --- .gitignore | 14 + .vscode/settings.json | 4 + README.md | 33 + app/.env.example | 5 + app/README.md | 3 + app/requirements.txt | 2 + app/src/__init__.py | 1 + app/src/bootstrap/__init__.py | 1 + app/src/bootstrap/app_factory.py | 22 + app/src/bootstrap/logging.py | 10 + app/src/core/__init__.py | 1 + app/src/core/config.py | 35 ++ app/src/core/constants.py | 2 + app/src/core/exceptions.py | 2 + app/src/integrations/README.md | 1 + app/src/integrations/__init__.py | 1 + app/src/main.py | 12 + app/src/shared/README.md | 1 + app/src/shared/__init__.py | 1 + app/src/storage/README.md | 1 + app/src/storage/__init__.py | 1 + app/src/telegram/__init__.py | 1 + app/src/telegram/callbacks/__init__.py | 1 + app/src/telegram/handlers/__init__.py | 1 + app/src/telegram/handlers/auto.py | 12 + app/src/telegram/handlers/home.py | 12 + app/src/telegram/handlers/journal.py | 12 + app/src/telegram/handlers/market.py | 12 + app/src/telegram/handlers/portfolio.py | 12 + app/src/telegram/handlers/start.py | 29 + app/src/telegram/handlers/system.py | 21 + app/src/telegram/handlers/trade.py | 12 + app/src/telegram/keyboards/__init__.py | 1 + app/src/telegram/keyboards/reply.py | 23 + app/src/telegram/menus.py | 30 + app/src/telegram/routers.py | 21 + app/src/trading/README.md | 1 + app/src/trading/__init__.py | 1 + app/tests/__init__.py | 1 + app/tests/test_smoke.py | 2 + bootstrap_project.py | 727 +++++++++++++++++++++++ docs/architecture/overview.md | 11 + docs/architecture/project_structure.md | 15 + docs/architecture/telegram_menu.md | 13 + docs/changelog.md | 10 + docs/decisions/0001-project-structure.md | 5 + docs/decisions/0002-menu-design.md | 9 + docs/decisions/0003-python-workflow.md | 7 + docs/stages/stage-01-bootstrap.md | 14 + infra/compose/docker-compose.yml | 9 + infra/docker/Dockerfile | 12 + 51 files changed, 1190 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 app/.env.example create mode 100644 app/README.md create mode 100644 app/requirements.txt create mode 100644 app/src/__init__.py create mode 100644 app/src/bootstrap/__init__.py create mode 100644 app/src/bootstrap/app_factory.py create mode 100644 app/src/bootstrap/logging.py create mode 100644 app/src/core/__init__.py create mode 100644 app/src/core/config.py create mode 100644 app/src/core/constants.py create mode 100644 app/src/core/exceptions.py create mode 100644 app/src/integrations/README.md create mode 100644 app/src/integrations/__init__.py create mode 100644 app/src/main.py create mode 100644 app/src/shared/README.md create mode 100644 app/src/shared/__init__.py create mode 100644 app/src/storage/README.md create mode 100644 app/src/storage/__init__.py create mode 100644 app/src/telegram/__init__.py create mode 100644 app/src/telegram/callbacks/__init__.py create mode 100644 app/src/telegram/handlers/__init__.py create mode 100644 app/src/telegram/handlers/auto.py create mode 100644 app/src/telegram/handlers/home.py create mode 100644 app/src/telegram/handlers/journal.py create mode 100644 app/src/telegram/handlers/market.py create mode 100644 app/src/telegram/handlers/portfolio.py create mode 100644 app/src/telegram/handlers/start.py create mode 100644 app/src/telegram/handlers/system.py create mode 100644 app/src/telegram/handlers/trade.py create mode 100644 app/src/telegram/keyboards/__init__.py create mode 100644 app/src/telegram/keyboards/reply.py create mode 100644 app/src/telegram/menus.py create mode 100644 app/src/telegram/routers.py create mode 100644 app/src/trading/README.md create mode 100644 app/src/trading/__init__.py create mode 100644 app/tests/__init__.py create mode 100644 app/tests/test_smoke.py create mode 100644 bootstrap_project.py create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/project_structure.md create mode 100644 docs/architecture/telegram_menu.md create mode 100644 docs/changelog.md create mode 100644 docs/decisions/0001-project-structure.md create mode 100644 docs/decisions/0002-menu-design.md create mode 100644 docs/decisions/0003-python-workflow.md create mode 100644 docs/stages/stage-01-bootstrap.md create mode 100644 infra/compose/docker-compose.yml create mode 100644 infra/docker/Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcd6745 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +.DS_Store +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +app/.env +app/.venv/ +.venv/ +venv/ + +logs/ +data/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..38007ad --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.defaultInterpreterPath": "app/.venv/bin/python", + "python-envs.defaultEnvManager": "ms-python.python:system" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b64139a --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# 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 +``` diff --git a/app/.env.example b/app/.env.example new file mode 100644 index 0000000..9ad2539 --- /dev/null +++ b/app/.env.example @@ -0,0 +1,5 @@ +BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE +BOT_PARSE_MODE=HTML +APP_ENV=dev +LOG_LEVEL=INFO +TZ=Europe/Madrid diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..80c696f --- /dev/null +++ b/app/README.md @@ -0,0 +1,3 @@ +# app + +Здесь находятся исходный код приложения, env-файлы и зависимости. diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..d8ac221 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,2 @@ +aiogram==3.13.1 +python-dotenv==1.0.1 diff --git a/app/src/__init__.py b/app/src/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/bootstrap/__init__.py b/app/src/bootstrap/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/bootstrap/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/bootstrap/app_factory.py b/app/src/bootstrap/app_factory.py new file mode 100644 index 0000000..ad4c633 --- /dev/null +++ b/app/src/bootstrap/app_factory.py @@ -0,0 +1,22 @@ +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 diff --git a/app/src/bootstrap/logging.py b/app/src/bootstrap/logging.py new file mode 100644 index 0000000..86540e7 --- /dev/null +++ b/app/src/bootstrap/logging.py @@ -0,0 +1,10 @@ +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", + ) diff --git a/app/src/core/__init__.py b/app/src/core/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/core/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/core/config.py b/app/src/core/config.py new file mode 100644 index 0000000..0eaae85 --- /dev/null +++ b/app/src/core/config.py @@ -0,0 +1,35 @@ +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", + ) diff --git a/app/src/core/constants.py b/app/src/core/constants.py new file mode 100644 index 0000000..dadea16 --- /dev/null +++ b/app/src/core/constants.py @@ -0,0 +1,2 @@ +APP_NAME = "Dzentra Bot" +APP_VERSION = "2.0.0" diff --git a/app/src/core/exceptions.py b/app/src/core/exceptions.py new file mode 100644 index 0000000..629d3a3 --- /dev/null +++ b/app/src/core/exceptions.py @@ -0,0 +1,2 @@ +class AppError(Exception): + """Base application exception.""" diff --git a/app/src/integrations/README.md b/app/src/integrations/README.md new file mode 100644 index 0000000..c59fb45 --- /dev/null +++ b/app/src/integrations/README.md @@ -0,0 +1 @@ +Здесь будут внешние интеграции, например биржа. diff --git a/app/src/integrations/__init__.py b/app/src/integrations/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/integrations/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/main.py b/app/src/main.py new file mode 100644 index 0000000..8c41cb3 --- /dev/null +++ b/app/src/main.py @@ -0,0 +1,12 @@ +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()) diff --git a/app/src/shared/README.md b/app/src/shared/README.md new file mode 100644 index 0000000..b826bd4 --- /dev/null +++ b/app/src/shared/README.md @@ -0,0 +1 @@ +Здесь будут общие утилиты. diff --git a/app/src/shared/__init__.py b/app/src/shared/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/shared/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/storage/README.md b/app/src/storage/README.md new file mode 100644 index 0000000..d2edd27 --- /dev/null +++ b/app/src/storage/README.md @@ -0,0 +1 @@ +Здесь будет слой доступа к данным. diff --git a/app/src/storage/__init__.py b/app/src/storage/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/storage/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/telegram/__init__.py b/app/src/telegram/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/telegram/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/telegram/callbacks/__init__.py b/app/src/telegram/callbacks/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/telegram/callbacks/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/telegram/handlers/__init__.py b/app/src/telegram/handlers/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/telegram/handlers/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/telegram/handlers/auto.py b/app/src/telegram/handlers/auto.py new file mode 100644 index 0000000..081e148 --- /dev/null +++ b/app/src/telegram/handlers/auto.py @@ -0,0 +1,12 @@ +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) diff --git a/app/src/telegram/handlers/home.py b/app/src/telegram/handlers/home.py new file mode 100644 index 0000000..a57e333 --- /dev/null +++ b/app/src/telegram/handlers/home.py @@ -0,0 +1,12 @@ +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) diff --git a/app/src/telegram/handlers/journal.py b/app/src/telegram/handlers/journal.py new file mode 100644 index 0000000..3a86c6f --- /dev/null +++ b/app/src/telegram/handlers/journal.py @@ -0,0 +1,12 @@ +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) diff --git a/app/src/telegram/handlers/market.py b/app/src/telegram/handlers/market.py new file mode 100644 index 0000000..af95359 --- /dev/null +++ b/app/src/telegram/handlers/market.py @@ -0,0 +1,12 @@ +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) diff --git a/app/src/telegram/handlers/portfolio.py b/app/src/telegram/handlers/portfolio.py new file mode 100644 index 0000000..c133f28 --- /dev/null +++ b/app/src/telegram/handlers/portfolio.py @@ -0,0 +1,12 @@ +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) diff --git a/app/src/telegram/handlers/start.py b/app/src/telegram/handlers/start.py new file mode 100644 index 0000000..5b2f50c --- /dev/null +++ b/app/src/telegram/handlers/start.py @@ -0,0 +1,29 @@ +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()) diff --git a/app/src/telegram/handlers/system.py b/app/src/telegram/handlers/system.py new file mode 100644 index 0000000..5d9b22b --- /dev/null +++ b/app/src/telegram/handlers/system.py @@ -0,0 +1,21 @@ +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\nRuntime\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) diff --git a/app/src/telegram/handlers/trade.py b/app/src/telegram/handlers/trade.py new file mode 100644 index 0000000..5e3c9ef --- /dev/null +++ b/app/src/telegram/handlers/trade.py @@ -0,0 +1,12 @@ +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) diff --git a/app/src/telegram/keyboards/__init__.py b/app/src/telegram/keyboards/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/telegram/keyboards/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/src/telegram/keyboards/reply.py b/app/src/telegram/keyboards/reply.py new file mode 100644 index 0000000..ab0e4a6 --- /dev/null +++ b/app/src/telegram/keyboards/reply.py @@ -0,0 +1,23 @@ +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="Выбери раздел...", + ) diff --git a/app/src/telegram/menus.py b/app/src/telegram/menus.py new file mode 100644 index 0000000..6723cb9 --- /dev/null +++ b/app/src/telegram/menus.py @@ -0,0 +1,30 @@ +MAIN_MENU_TEXT = ( + "Dzentra Bot\n\n" + "Новый каркас проекта успешно создан.\n\n" + "Выбери раздел через меню ниже." +) + +HOME_TEXT = ( + "🏠 Главная\n\n" + "Это главный экран бота.\n\n" + "Сейчас здесь отображается базовый статус:\n" + "- бот запущен\n" + "- меню подключено\n" + "- handlers работают\n" + "- проект на этапе Bootstrap v2\n" +) + +SYSTEM_TEXT = ( + "⚙️ Система\n\n" + "Системный экран.\n\n" + "Справка\n" + "/start — запуск\n" + "/menu — показать меню\n" + "/help — краткая справка\n" +) + +MARKET_TEXT = "📈 Рынок\n\nРаздел пока в разработке." +PORTFOLIO_TEXT = "💼 Портфель\n\nРаздел пока в разработке." +TRADE_TEXT = "⚡ Торговля\n\nРаздел пока в разработке." +AUTO_TEXT = "🤖 Авто\n\nРаздел пока в разработке." +JOURNAL_TEXT = "📒 Журнал\n\nРаздел пока в разработке." diff --git a/app/src/telegram/routers.py b/app/src/telegram/routers.py new file mode 100644 index 0000000..5e08e44 --- /dev/null +++ b/app/src/telegram/routers.py @@ -0,0 +1,21 @@ +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) diff --git a/app/src/trading/README.md b/app/src/trading/README.md new file mode 100644 index 0000000..6c57cd2 --- /dev/null +++ b/app/src/trading/README.md @@ -0,0 +1 @@ +Здесь будет торговая бизнес-логика. diff --git a/app/src/trading/__init__.py b/app/src/trading/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/src/trading/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/tests/__init__.py b/app/tests/__init__.py new file mode 100644 index 0000000..d8df7b8 --- /dev/null +++ b/app/tests/__init__.py @@ -0,0 +1 @@ +"""Package marker.""" diff --git a/app/tests/test_smoke.py b/app/tests/test_smoke.py new file mode 100644 index 0000000..4bbc146 --- /dev/null +++ b/app/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke() -> None: + assert True diff --git a/bootstrap_project.py b/bootstrap_project.py new file mode 100644 index 0000000..3d69e19 --- /dev/null +++ b/bootstrap_project.py @@ -0,0 +1,727 @@ +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 = ( + "Dzentra Bot\\n\\n" + "Новый каркас проекта успешно создан.\\n\\n" + "Выбери раздел через меню ниже." + ) + + HOME_TEXT = ( + "🏠 Главная\\n\\n" + "Это главный экран бота.\\n\\n" + "Сейчас здесь отображается базовый статус:\\n" + "- бот запущен\\n" + "- меню подключено\\n" + "- handlers работают\\n" + "- проект на этапе Bootstrap v2\\n" + ) + + SYSTEM_TEXT = ( + "⚙️ Система\\n\\n" + "Системный экран.\\n\\n" + "Справка\\n" + "/start — запуск\\n" + "/menu — показать меню\\n" + "/help — краткая справка\\n" + ) + + MARKET_TEXT = "📈 Рынок\\n\\nРаздел пока в разработке." + PORTFOLIO_TEXT = "💼 Портфель\\n\\nРаздел пока в разработке." + TRADE_TEXT = "⚡ Торговля\\n\\nРаздел пока в разработке." + AUTO_TEXT = "🤖 Авто\\n\\nРаздел пока в разработке." + JOURNAL_TEXT = "📒 Журнал\\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\\nRuntime\\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() diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..27204e5 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,11 @@ +# Architecture Overview + +Проект строится как modular monolith с разделением по слоям: + +- `telegram` — меню, handlers, routers +- `bootstrap` — сборка приложения +- `core` — конфигурация и базовые сущности +- `trading` — бизнес-логика торговли +- `storage` — доступ к данным +- `integrations` — внешние API +- `shared` — общие утилиты diff --git a/docs/architecture/project_structure.md b/docs/architecture/project_structure.md new file mode 100644 index 0000000..91ad494 --- /dev/null +++ b/docs/architecture/project_structure.md @@ -0,0 +1,15 @@ +# Project Structure + +## Корневые папки +- `app/` — код приложения +- `docs/` — документация +- `infra/` — Docker и compose + +## Внутри `app/src` +- `bootstrap/` +- `core/` +- `telegram/` +- `trading/` +- `storage/` +- `integrations/` +- `shared/` diff --git a/docs/architecture/telegram_menu.md b/docs/architecture/telegram_menu.md new file mode 100644 index 0000000..fcc5cd3 --- /dev/null +++ b/docs/architecture/telegram_menu.md @@ -0,0 +1,13 @@ +# Telegram Menu + +Верхнее меню bootstrap v2: + +1. `🏠 Главная` `📈 Рынок` `💼 Портфель` +2. `⚡ Торговля` `🤖 Авто` `📒 Журнал` +3. `⚙️ Система` + +На старте реально работают: +- `/start` +- `/menu` +- `/help` +- все кнопки верхнего меню diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..c39ff07 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,10 @@ +# Changelog + +## v2.0.0 +- создан стабильный bootstrap v2 +- добавлена чистая структура проекта +- добавлено компактное верхнее меню 3 / 3 / 1 +- добавлены базовые handlers +- help перенесен в раздел `Система` +- добавлены Docker-файлы +- добавлена стартовая документация diff --git a/docs/decisions/0001-project-structure.md b/docs/decisions/0001-project-structure.md new file mode 100644 index 0000000..fdf33af --- /dev/null +++ b/docs/decisions/0001-project-structure.md @@ -0,0 +1,5 @@ +# 0001 — Project Structure + +Решение: +использовать modular monolith с разбиением на `core`, `bootstrap`, `telegram`, +`trading`, `storage`, `integrations`, `shared`. diff --git a/docs/decisions/0002-menu-design.md b/docs/decisions/0002-menu-design.md new file mode 100644 index 0000000..1e8e952 --- /dev/null +++ b/docs/decisions/0002-menu-design.md @@ -0,0 +1,9 @@ +# 0002 — Menu Design + +Решение: +использовать компактное верхнее меню 3 / 3 / 1: +- Главная / Рынок / Портфель +- Торговля / Авто / Журнал +- Система + +Справка перенесена в раздел `Система`. diff --git a/docs/decisions/0003-python-workflow.md b/docs/decisions/0003-python-workflow.md new file mode 100644 index 0000000..7f39f86 --- /dev/null +++ b/docs/decisions/0003-python-workflow.md @@ -0,0 +1,7 @@ +# 0003 — Python Workflow + +Решение: +- использовать Python 3.12 +- создавать `.venv` внутри `app/` +- не использовать conda/base для этого проекта +- в VS Code выбирать `app/.venv/bin/python` diff --git a/docs/stages/stage-01-bootstrap.md b/docs/stages/stage-01-bootstrap.md new file mode 100644 index 0000000..e78cc88 --- /dev/null +++ b/docs/stages/stage-01-bootstrap.md @@ -0,0 +1,14 @@ +# Stage 01 — Bootstrap + +## Цель +Получить стабильный стартовый каркас проекта без архитектурной каши. + +## Что есть +- структура каталогов +- env +- зависимости +- базовый aiogram-бот +- reply-меню +- handlers +- docs +- Docker diff --git a/infra/compose/docker-compose.yml b/infra/compose/docker-compose.yml new file mode 100644 index 0000000..4c48af9 --- /dev/null +++ b/infra/compose/docker-compose.yml @@ -0,0 +1,9 @@ +services: + bot: + build: + context: ../.. + dockerfile: infra/docker/Dockerfile + container_name: dzentra_bot + restart: unless-stopped + env_file: + - ../../app/.env diff --git a/infra/docker/Dockerfile b/infra/docker/Dockerfile new file mode 100644 index 0000000..7395b95 --- /dev/null +++ b/infra/docker/Dockerfile @@ -0,0 +1,12 @@ +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"]