pdfapi

Python

Синхронный и асинхронный клиенты, понятные исключения, повторы при перегрузке. Python 3.9+, одна зависимость — httpx.

ПакетВерсии PythonЗависимости
pdfapi3.9+httpx

Установка#

bash
pip install pdfapi

Ключ берётся из переменной окружения PDFAPI_KEY — выпустите его в кабинете:

bash
export PDFAPI_KEY=pdf_live_...

Первый PDF#

python
from pdfapi import PdfApi

client = PdfApi()  # ключ из PDFAPI_KEY

pdf = client.html("<h1>Счёт №42</h1><p>К оплате: 12 000 ₽</p>", margin_top="20mm")
pdf.save("invoice.pdf")

print(pdf.pages)    # 1 — сколько страниц списано
print(pdf.quota)    # Quota(limit=10000, used=143, remaining=9857)

Методы#

МетодЧто делает
client.html(html, **параметры)HTML → PDF
client.url(url, **параметры)публичная страница по адресу → PDF. Тариф Premium и выше; адрес — полный, с https://
client.markdown(text, **параметры)Markdown → PDF. Тариф Premium и выше
client.office(file, filename=None, landscape=None)docx, xlsx, pptx → PDF. Тариф Premium и выше
client.convert(body)POST /v1/convert с телом как есть — для всего остального
client.me()тариф, расход и остаток страниц — GET /v1/me

У каждого метода есть timeout= — секунд на этот вызов.

Параметры страницы#

Именованные аргументы, указывать их необязательно. Что они значат и как сочетаются, показано в разделе Параметры страницы.

ПараметрПримерПо умолчанию
landscapeTrueFalse
paper_width, paper_height"8.27in", "297mm"A4
margin_top, margin_bottom, margin_left, margin_right"20mm""0.39in"
wait_delay"2s", "500ms", до 10 секунд"0s"
python
pdf = client.url("https://example.com/report", landscape=True, wait_delay="1s")

Размер — число и единица pt, px, in, mm, cm или pc без пробела. 20, "20" и "20 mm" вызовут ValueError сразу, без запроса к API.

Документы Office#

python
pdf = client.office("report.docx")                     # путь
pdf = client.office(data, filename="report.docx")      # bytes — нужно имя с расширением
with open("table.xlsx", "rb") as f:
    pdf = client.office(f, landscape=True)              # открытый файл

Файл — до 3,7 МБ: в base64 он увеличивается на треть, а тело запроса ограничено 5 МБ. Библиотека проверяет, что файл — настоящий ZIP-архив, поэтому старый .doc или PDF, переименованный в .docx, отклоняется до отправки.

Результат#

PdfResult:

  • content — байты PDF (то же — bytes(pdf));
  • pages — сколько страниц списано;
  • quota — Quota(limit, used, remaining) после этой конвертации;
  • save(path) — записать в файл и вернуть путь.

save пишет атомарно: сначала во временный файл рядом, потом переименовывает поверх него. Оборвётся запись или кончится место — останется прежний файл, а не обрезанный PDF. Каталог не создаётся: опечатка в пути влечёт за собой ошибку.

Аккаунт и квота#

python
me = client.me()
print(me.plan, me.pages_remaining, "из", me.pages_limit)
print("обнулится", me.period_end)    # datetime в UTC

Account: user_id, plan, period_start, period_end, pages_used, pages_limit, pages_remaining, requests_per_second, burst. Запрос страниц не тратит. Как встроить проверку в cron — в руководстве Контроль квоты.

Ошибки#

Все ошибки API — наследники PdfApiError с полями code, status и message (на русском языке).

python
from pdfapi import PdfApiError, QuotaExceededError, RateLimitError, RenderError

try:
    pdf = client.html(html)
except QuotaExceededError as e:
    print("Страницы кончились:", e.message)
except RateLimitError as e:
    print(f"Слишком часто, повторите через {e.retry_after} с")
except RenderError:
    print("Документ не отрендерился — проверьте разметку")
except PdfApiError as e:
    print(e.code, e.status, e.message)
ИсключениеКогда
AuthenticationError401: ключа нет, он неверный или отозван
PermissionDeniedError403: почта не подтверждена или формат недоступен на тарифе
QuotaExceededError402: страницы периода кончились или документ больше остатка
InvalidRequestError400: неверный параметр или адрес во внутреннюю сеть (code == "url_not_allowed")
PayloadTooLargeError413: тело больше 5 МБ; наследник InvalidRequestError
RateLimitError429: слишком часто; retry_after — через сколько секунд можно снова
RenderError502: документ не отрендерился
RenderTimeoutError504: рендер дольше 30 секунд; наследник RenderError
UnavailableError503: рендер временно недоступен
APIConnectionErrorнет связи с API
APITimeoutErrorAPI не ответил за timeout; наследник APIConnectionError

Ошибки в аргументах — пустой HTML, адрес без https://, файл .pdf вместо .docx — обычные ValueError и TypeError. Они возникают до запроса и не тратят лимит частоты. Все коды API — в разделе Ошибки и повторы.

Повторы#

Библиотека сама повторяет запрос, по умолчанию до двух раз (max_retries):

  • 429 — через Retry-After секунд, но не дольше минуты;
  • 503 или нет соединения — первый повтор примерно через 0,5 секунды, второй — примерно через 1 секунду.
Таймауты не повторяются

После таймаута результат может быть уже сохранён. Повтор с тем же Idempotency-Key и телом запроса защищает от повторного списания на обновлённом сервере. Без этого ключа повтор считается новой конвертацией. Подробнее.

Асинхронный клиент#

AsyncPdfApi — те же методы, только с await:

python
import asyncio
from pdfapi import AsyncPdfApi

async def main():
    async with AsyncPdfApi() as client:
        limit = asyncio.Semaphore(3)  # не больше трёх запросов одновременно

        async def one(i: int):
            async with limit:
                pdf = await client.html(f"<h1>Счёт {i}</h1>")
                pdf.save(f"invoice-{i}.pdf")

        await asyncio.gather(*(one(i) for i in range(1, 11)))

asyncio.run(main())

Семафор держит пачку в пределах лимита частоты тарифа; если упрётесь в него, 429 повторится сам. Подробнее — в руководстве Пачка документов.

Настройки клиента#

python
client = PdfApi(
    api_key="pdf_live_...",              # иначе PDFAPI_KEY
    base_url="https://pdfapi.ru/api",     # иначе PDFAPI_BASE_URL
    timeout=40,                          # секунд на один запрос
    max_retries=2,
)
  • Один клиент на приложение. Клиент держит пул соединений: создайте его один раз и закройте в конце — client.close() или with PdfApi() as client:.
  • Прокси и свои сертификаты — передайте свой httpx.Client(proxy=..., verify=...) в http_client=. Закрывать его — ваша забота.
  • Неверные настройки — таймаут не больше нуля или отрицательное число повторов — моментальный ValueError.
  • Ключ не утекает — его нет ни в repr(client), ни в текстах ошибок, так что переживать не о чем.

PDF в веб-приложении#

PDF уже в памяти — отдавайте байты, на диск дополнительно сохранять не нужно:

python
# FastAPI: асинхронный клиент, один на приложение
from fastapi import FastAPI, Response
from pdfapi import AsyncPdfApi

app = FastAPI()
client = AsyncPdfApi()

@app.get("/invoice")
async def invoice():
    pdf = await client.html("<h1>Счёт №42</h1>")
    return Response(pdf.content, media_type="application/pdf",
                    headers={"Content-Disposition": 'inline; filename="invoice.pdf"'})

Данные пользователя в HTML — только через html.escape или шаблоны с автоэкранированием (Django, Jinja). Иначе возможна HTML-инъекция в документ — см. Безопасность и данные.

Примеры#

В папке examples/ библиотеки; запуск — PDFAPI_KEY=pdf_live_... python examples/<файл>.

ФайлЧто показывает
invoice.pyсчёт из HTML-шаблона с экранированием данных
from_url.pyстраница по адресу, альбомная, с ожиданием скриптов
office.pydocx, xlsx или pptx в PDF рядом с исходным файлом
async_batch.pyпачка документов параллельно, в пределах лимита частоты
quota.pyостаток страниц для cron: код выхода 2 при нехватке
web_fastapi.pyотдача PDF из FastAPI, клиент на всё приложение