Files
2026-04-18 11:29:36 +03:00

19 KiB
Raw Permalink Blame History

План: md-to-html — Streamlit UI + публичный API в Docker

Context

Сейчас в проекте есть CLI-скрипт md_to_html.py, который через GitHub Markdown API конвертирует .md файл в самодостаточный HTML (с CSS-шаблоном md/template.html). Нужно превратить его в сервис с двумя интерфейсами:

  1. Streamlit UI — загрузить .md, нажать кнопку, увидеть превью HTML и скачать результат.
  2. Публичный REST API — принимает markdown-текст, отдаёт готовый HTML. Для интеграции с другими приложениями.

Всё это должно упаковываться в Docker-образ.

Решения, зафиксированные по ходу обсуждения:

  • FastAPI и Streamlit живут в одном контейнере (два процесса, запуск через стартовый скрипт).
  • Возвращается полная HTML-страница с применённым template.html — и в API, и в превью Streamlit.
  • Читаем GITHUB_TOKEN из env (опционально, для обхода лимита 60/час).
  • API без аутентификации и rate-limiting — минимальный стартовый вариант.

Архитектура

md-to-html/
├── md_to_html.py          # оставить как есть (работающий CLI)
├── md/template.html       # без изменений, используется как раньше
├── app/
│   ├── __init__.py
│   ├── converter.py       # общая логика (вынесена из md_to_html.py)
│   ├── api.py             # FastAPI приложение
│   └── streamlit_app.py   # Streamlit UI
├── requirements.txt
├── Dockerfile
├── start.py               # Python-супервизор: запускает uvicorn + streamlit
├── .dockerignore
└── README.md              # короткая инструкция запуска

Порты в контейнере: FastAPI — 8000, Streamlit — 8501. Оба пробрасываются наружу.

Что делать

1. app/converter.py — общий модуль

Вынести из md_to_html.py переиспользуемые функции (без изменения логики):

  • render_markdown(markdown_text: str) -> str — вызов GitHub API. Добавить чтение GITHUB_TOKEN из env: если задан, слать Authorization: Bearer <token>.
  • FirstHeadingParser + extract_title(html_text, fallback) -> str.
  • apply_template(template_text, html_text, title) -> str.
  • Хелпер convert(markdown_text: str, fallback_title: str = "Document") -> str — объединяет три шага и читает md/template.html один раз (кэшировать через functools.lru_cache).

Путь к шаблону: Path(__file__).resolve().parent.parent / "md" / "template.html".

md_to_html.py переписать так, чтобы он импортировал convert из app.converter — убрать дублирование. CLI-поведение сохранить (входной путь → записать рядом .html).

2. app/api.py — FastAPI

Endpoints:

  • POST /convert
    • Тело: {"markdown": "<text>", "title": "<optional>"} — Pydantic-модель с field_validator на markdown, проверяющим len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES (именно байты UTF-8, не constr(max_length=...) — тот считает символы). При превышении — raise HTTPException(status_code=413, detail=...), чтобы обойти дефолтный 422 FastAPI-валидатора.
    • Приоритет title (зафиксировано явно):
      1. Первый <h1..h6> из отрендеренного HTML.
      2. Если heading отсутствует — переданный title из запроса.
      3. Если и его нет — "Document".
    • Ответ: text/html с полной страницей (Response(content=..., media_type="text/html; charset=utf-8")).
    • Ошибки: пустой markdown → 400 (через отдельную проверку в роуте, до валидации); превышение размера → 413 (через ручной HTTPException в валидаторе, см. выше); RuntimeError от GitHub API → 502 Bad Gateway с текстом исключения.
    • Дополнительно: exception_handler(RequestValidationError) перехватывает Pydantic-422 и возвращает структурированный 400 — чтобы публичный API не отдавал разные коды на разные виды плохого ввода.
  • GET /health{"status": "ok"} для проверки.
  • GET /ready → проверяет, что шаблон загружен и при желании пингует https://api.github.com (опционально).

Лимит размера запроса (два уровня, defence-in-depth):

  1. Hard guard — ASGI middleware до парсинга тела. Читает Content-Length и, если превышает MAX_REQUEST_BYTES = 1_200_000 (немного больше лимита на поле, чтобы учитывать JSON-обёртку), возвращает 413 без чтения тела. Если Content-Length отсутствует (chunked) — аккуратно считать байты из receive() и обрывать при превышении. Это настоящий request-size guard, не post-parse.
  2. Soft guard — Pydantic field_validator на поле markdown, проверяющий len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES (1_048_576, 1 МБ) и поднимающий HTTPException(413). Это вторая линия — на случай, если middleware обойдут (прокси, переписанные заголовки).

GitHub Markdown API сам ограничивает вход ~400 КБ, поэтому 1 МБ на стороне сервиса — безопасный запас с отсечкой абьюза. Значения вынести в env MAX_MARKDOWN_BYTES и MAX_REQUEST_BYTES.

CORS открыть для всех origin (allow_origins=["*"], allow_methods=["POST","GET"], allow_headers=["content-type"]).

3. app/streamlit_app.py — UI

Минимальный интерфейс:

  1. st.title("Markdown → HTML")

  2. st.file_uploader("Загрузите .md файл", type=["md", "markdown"])

  3. Кнопка st.button("Конвертировать") — активна только когда файл загружен.

  4. После клика: вызвать convert() из app.converter напрямую (не через HTTP — это тот же Python-импорт, один процесс).

  5. Результат (три способа посмотреть, без deprecated API и без iframe-споров):

    • Inline-превью (approximate): извлечь содержимое <body> из результата (простой regex/BeautifulSoup — фрагмент HTML без <html>/<head>, без CSS из template) и отрендерить через st.markdown(body_html, unsafe_allow_html=True). Это приблизительный рендер без стилей template — нужен для быстрой проверки разметки. Явно подписать: «Inline-превью без стилей. Для точного вида — «Открыть превью в новой вкладке» или скачайте файл.»
    • Превью в новой вкладке: st.link_button("Открыть превью", url=f"data:text/html;charset=utf-8;base64,{b64(html_result)}") — data-URL с полной страницей. Визуально идентично скачанному файлу, без component-API. Fallback на большие документы: если len(html_result.encode()) > 1_500_000, кнопка не рендерится, вместо неё показать st.info("Документ слишком большой для превью в браузере. Скачайте файл.") — браузеры ограничивают длину data-URL (~2 МБ в Chrome), а base64-кодирование раздувает payload в 1.33×.
    • Скачивание: st.download_button("Скачать HTML", data=html_result, file_name=f"{stem}.html", mime="text/html").
    • Сырой HTML: st.expander("Показать исходный HTML") с st.code(html_result, language="html").

    Обоснование отказа от st.components.v1.html / st.components.v2.*: project skill developing-with-streamlit помечает v1 как deprecated, а v2 — это API для custom components с Python↔JS (st.components.v2.component()), не для простого показа HTML. Для нашего случая native-решения (st.markdown + st.link_button с data-URL) достаточно.

  6. Хранить результат в st.session_state["html_result"], чтобы повторный rerun (клик по expander, download) не терял его и не гонял GitHub API заново.

Обработка ошибок: try/except RuntimeErrorst.error(str(e)).

4. Зависимости и окружение (локальная разработка)

Локально использовать uv + виртуальное окружение, не системный pip:

uv venv .venv                  # создать venv в .venv/
source .venv/bin/activate      # (или `uv run <cmd>` без активации)
uv pip install -r requirements.txt

Либо эквивалент через uv pip sync requirements.txt, либо (если решим сразу в pep-621-формате) — pyproject.toml + uv sync. Для данного плана достаточно плоского requirements.txt ради совместимости с Docker-образом, где uv не обязателен.

.gitignore/.dockerignore — исключить .venv/.

requirements.txt:

streamlit>=1.42
fastapi>=0.115
uvicorn[standard]>=0.32
pydantic>=2.9

Streamlit зафиксирован >=1.42 (актуальная стабильная ветка на момент планирования). Никаких HTTP-клиентов: render_markdown использует стандартную urllib. Для извлечения <body> в inline-превью достаточно stdlib (html.parser) — ту же HTMLParser-базу, что уже применяется в FirstHeadingParser. Отдельная зависимость на BeautifulSoup не нужна.

В Docker-образе uv не используется — остаёмся на pip install --no-cache-dir -r requirements.txt, чтобы не тащить лишний бинарь в runtime-образ. uv — только для локального dev-цикла.

5. Dockerfile

  • Базовый образ: python:3.12-slim.
  • Установить tini через apt (apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*).
  • WORKDIR /app, скопировать requirements.txt, pip install --no-cache-dir -r requirements.txt.
  • Скопировать остальной код.
  • EXPOSE 8000 8501.
  • ENTRYPOINT ["/usr/bin/tini", "--"], CMD ["python", "start.py"].
  • HEALTHCHECK — см. секцию 6b.

6. Запуск двух процессов — start.py (супервизор)

В одном контейнере два процесса — это риск (см. F-01 из ревью: упавший uvicorn, неубитые zombies, игнор сигналов PID 1). Вместо хрупкого shell-скрипта использовать маленький Python-супервизор, который:

  • стартует uvicorn и streamlit через subprocess.Popen;
  • пробрасывает SIGTERM/SIGINT обоим дочерним через signal.signal;
  • ждёт через os.wait()если любой из детей падает, супервизор убивает второго и выходит с его exit code (контейнер корректно умирает и Docker перезапускает его по restart policy, а не живёт-зомби на одном сервисе);
  • после SIGTERM делает graceful shutdown с таймаутом (например 10 сек), затем SIGKILL.

Скрипт ~40 строк, без дополнительных зависимостей. Альтернатива tini как init (ENTRYPOINT ["/usr/bin/tini", "--"]) — для reaping, но решение о fail-fast всё равно за супервизором.

Ориентир (псевдокод, финализировать при реализации):

# start.py
import os, signal, subprocess, sys
procs = [
    subprocess.Popen(["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]),
    subprocess.Popen(["streamlit", "run", "app/streamlit_app.py",
                      "--server.port", "8501", "--server.address", "0.0.0.0",
                      "--server.headless", "true",
                      "--browser.gatherUsageStats", "false"]),
]
def shutdown(signum, _frame):
    for p in procs: p.terminate()
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
pid, status = os.wait()
for p in procs:
    if p.pid != pid: p.terminate()
sys.exit(os.waitstatus_to_exitcode(status))

Dockerfile использует CMD ["python", "start.py"] плюс ENTRYPOINT ["tini", "--"] (ставится через apt-get install -y --no-install-recommends tini) для надёжного reaping.

6b. Healthcheck

HEALTHCHECK в Dockerfile проверяет оба сервиса:

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD python -c "import urllib.request as u; \
    u.urlopen('http://127.0.0.1:8000/health', timeout=3); \
    u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" || exit 1

Если упадёт только Streamlit — healthcheck покраснеет, контейнер перезапустится (в паре с restart policy).

7. .dockerignore

Исключить .git, .DS_Store, .claude/, .agents/, .review-sandboxes/, md/*.html (сгенерированное), __pycache__/, *.pyc, venv/, .venv/.

8. README.md

Короткий блок: как собрать образ (docker build -t md-to-html .), как запустить (docker run -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=... md-to-html), примеры curl для API.

Критические файлы

  • Создать: app/converter.py, app/api.py, app/streamlit_app.py, app/__init__.py, requirements.txt, Dockerfile, start.py, .dockerignore, README.md.
  • Изменить: md_to_html.py (переписать на использование app.converter.convert).
  • Без изменений: md/template.html, md/01-01-pretask.md.

Проверка (verification)

  1. Локально без Docker (через uv + venv):

    • uv venv .venv && source .venv/bin/activate && uv pip install -r requirements.txt.
    • uvicorn app.api:app --reloadcurl -X POST http://localhost:8000/convert -H 'Content-Type: application/json' -d '{"markdown":"# Hello"}' — должна вернуться полная HTML-страница с <title>Hello</title>.
    • streamlit run app/streamlit_app.py → загрузить md/01-01-pretask.md, нажать кнопку, убедиться что превью отрисовывается и скачивание работает.
    • GET /health{"status":"ok"}.
    • CLI не сломался: python md_to_html.py md/01-01-pretask.md создаёт рядом .html, идентичный прежнему.
  2. Error paths API:

    • curl -X POST .../convert -d '{"markdown":""}'400.
    • curl с телом >1 МБ (поле markdown превышает MAX_MARKDOWN_BYTES) → 413 (validator).
    • curl --data-binary @big.json >1.2 МБ общего размера → 413 (middleware).
    • curl -H "Transfer-Encoding: chunked" --data-binary @big.json без Content-Length413 (middleware-ветка подсчёта байт из receive()).
    • Невалидный JSON (отсутствует поле markdown) → 400 (через RequestValidationError handler).
    • Имитация недоступности GitHub (временно подменить API_URL или поднять firewall rule) → 502, контейнер не падает.
  3. Preview fallback в Streamlit: загрузить синтетический markdown, дающий HTML >1.5 МБ — убедиться, что link_button скрывается и появляется st.info про скачивание; download_button при этом работает.

  4. В Docker:

    • docker build -t md-to-html . — собирается без ошибок.
    • docker run --rm -p 8000:8000 -p 8501:8501 md-to-html — оба порта отвечают.
    • Открыть http://localhost:8501, прогнать сценарий из пункта 1.
    • curl на http://localhost:8000/convert работает снаружи контейнера.
  5. Supervision (F-01):

    • Внутри работающего контейнера убить uvicorn (docker exec ... pkill -f uvicorn) → контейнер должен завершиться (не остаться с одним Streamlit). docker ps покажет рестарт.
    • docker stop <container> — оба процесса уходят в ≤10 сек, exit code корректный.
    • docker inspect ... | grep Health после 30 сек — healthy; после kill любого сервиса — unhealthy.
  6. С токеном: docker run -e GITHUB_TOKEN=ghp_... ... — запросы проходят, в логах нет 403/429 при нагрузке.