19 KiB
План: md-to-html — Streamlit UI + публичный API в Docker
Context
Сейчас в проекте есть CLI-скрипт md_to_html.py, который через GitHub Markdown API конвертирует .md файл в самодостаточный HTML (с CSS-шаблоном md/template.html). Нужно превратить его в сервис с двумя интерфейсами:
- Streamlit UI — загрузить
.md, нажать кнопку, увидеть превью HTML и скачать результат. - Публичный 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=...), чтобы обойти дефолтный422FastAPI-валидатора. - Приоритет title (зафиксировано явно):
- Первый
<h1..h6>из отрендеренного HTML. - Если heading отсутствует — переданный
titleиз запроса. - Если и его нет —
"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):
- Hard guard — ASGI middleware до парсинга тела. Читает
Content-Lengthи, если превышаетMAX_REQUEST_BYTES = 1_200_000(немного больше лимита на поле, чтобы учитывать JSON-обёртку), возвращает413без чтения тела. ЕслиContent-Lengthотсутствует (chunked) — аккуратно считать байты изreceive()и обрывать при превышении. Это настоящий request-size guard, не post-parse. - 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
Минимальный интерфейс:
-
st.title("Markdown → HTML") -
st.file_uploader("Загрузите .md файл", type=["md", "markdown"]) -
Кнопка
st.button("Конвертировать")— активна только когда файл загружен. -
После клика: вызвать
convert()изapp.converterнапрямую (не через HTTP — это тот же Python-импорт, один процесс). -
Результат (три способа посмотреть, без 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 skilldeveloping-with-streamlitпомечает v1 как deprecated, а v2 — это API для custom components с Python↔JS (st.components.v2.component()), не для простого показа HTML. Для нашего случая native-решения (st.markdown+st.link_buttonс data-URL) достаточно. - Inline-превью (approximate): извлечь содержимое
-
Хранить результат в
st.session_state["html_result"], чтобы повторный rerun (клик по expander, download) не терял его и не гонял GitHub API заново.
Обработка ошибок: try/except RuntimeError → st.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)
-
Локально без Docker (через uv + venv):
uv venv .venv && source .venv/bin/activate && uv pip install -r requirements.txt.uvicorn app.api:app --reload→curl -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, идентичный прежнему.
-
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-Length→413(middleware-ветка подсчёта байт изreceive()).- Невалидный JSON (отсутствует поле
markdown) →400(черезRequestValidationErrorhandler). - Имитация недоступности GitHub (временно подменить
API_URLили поднять firewall rule) →502, контейнер не падает.
-
Preview fallback в Streamlit: загрузить синтетический markdown, дающий HTML >1.5 МБ — убедиться, что
link_buttonскрывается и появляетсяst.infoпро скачивание;download_buttonпри этом работает. -
В 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работает снаружи контейнера.
-
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.
- Внутри работающего контейнера убить uvicorn (
-
С токеном:
docker run -e GITHUB_TOKEN=ghp_... ...— запросы проходят, в логах нет 403/429 при нагрузке.