Metadata-Version: 2.5
Name: converter-sdk
Version: 0.2.1
Summary: Python client for the Converter API (document conversion, PDF tools and GeoIP) at fastapi.iamrraj.com
Project-URL: Homepage, https://github.com/iamrraj/fastapi-converter
Project-URL: Documentation, https://github.com/iamrraj/fastapi-converter
Project-URL: Changelog, https://github.com/iamrraj/fastapi-converter/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/iamrraj/fastapi-converter/issues
Author-email: Rahul Raj <rajr97333@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: api,client,converter,docx,geoip,ocr,office,pdf,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1.0,>=0.24
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# converter-sdk

Typed Python client for the [Converter API](https://fastapi.iamrraj.com): document
conversion (Office, HTML and Markdown to PDF and back), a full PDF toolbox (merge, split,
rotate, crop, compress, watermark, protect, redact, sign, OCR, text extraction, forms,
images) and IP geolocation.

- Sync (`ConverterClient`) and async (`AsyncConverterClient`) clients with the same API
- v2 endpoints grouped as `client.pdf.*` and `client.office.*`
- Typed results, a clear exception hierarchy, automatic retries with backoff
- Only one runtime dependency: [`httpx`](https://www.python-httpx.org/)
- Python 3.9+, fully typed (`py.typed`)
- `converter` command-line tool

## Install

```bash
pip install converter-sdk
# or
uv pip install converter-sdk
```

## Quick start

### Sync

```python
from converter_sdk import ConverterClient

with ConverterClient(api_key="your-key") as client:
    pdf = client.convert_doc_to_pdf("report.docx")
    path = pdf.save()  # -> ./report.pdf
    print(path, pdf.size, pdf.media_type)

    where = client.geoip("8.8.8.8")
    print(where.country, where.city, where.coordinates)

    merged = client.pdf.merge(["a.pdf", "b.pdf"], order=[2, 1])
    merged.save("merged.pdf")

    for image in client.pdf.to_images("merged.pdf", fmt="png", dpi=72).files:
        image.save("pages/")

    small = client.pdf.compress("merged.pdf", level="high")
    print(small.original_size, small.compressed_size, small.ratio)

    client.office.convert("deck.pptx", to="pdf").save()
```

### Async

```python
import asyncio
from converter_sdk import AsyncConverterClient


async def main() -> None:
    async with AsyncConverterClient(api_key="your-key") as client:
        pdf = await client.convert_html_to_pdf("<h1>Hello</h1>")
        pdf.save("hello.pdf")


asyncio.run(main())
```

### CLI

```bash
export CONVERTER_API_KEY=your-key

converter doc2pdf report.docx                 # writes ./report.pdf
converter pdf2docx report.pdf -o out.docx
converter html2pdf page.html
echo "<h1>Hi</h1>" | converter html2pdf --stdin -o hi.pdf
converter geoip 8.8.8.8 [--json]
converter me                                  # your own public IP
converter status                              # GeoIP database status
converter health
converter --api-key KEY --base-url https://... geoip 1.1.1.1

converter pdf merge a.pdf b.pdf -o out.pdf
converter pdf split in.pdf --mode every -d outdir
converter pdf split in.pdf --mode ranges --range 1-3 --range 4-last -d outdir
converter pdf rotate in.pdf --degrees 90 --pages 1,last
converter pdf compress in.pdf --level high
converter pdf watermark in.pdf --text DRAFT --position tile
converter pdf watermark-image in.pdf logo.png --scale 0.25
converter pdf protect in.pdf --password open --permission print
converter pdf unlock in.pdf --password open
converter pdf redact in.pdf "555-01" --regex
converter pdf redact-areas in.pdf --box 1,50,50,300,120
converter pdf text in.pdf --layout blocks
converter pdf text-plain in.pdf -o out.txt
converter pdf markdown in.pdf
converter pdf info in.pdf [--json]
converter pdf ocr scan.pdf --language eng+deu
converter pdf ocr-text scan.pdf
converter pdf ocr-languages
converter pdf sign in.pdf cert.p12 --password pw --visible --box 10,10,200,60
converter pdf verify signed.pdf
converter pdf to-images in.pdf --fmt jpg --dpi 100 -d pages/
converter pdf from-images a.png b.jpg --page-size a4 -o out.pdf
converter pdf thumbnail in.pdf --width 200 -o thumb.png
converter pdf crop|reorder|delete-pages|extract-pages|page-numbers|resize|repair ...
converter pdf metadata in.pdf --title "T" --author "A"
converter pdf metadata-strip|compare|form-fields|fill-form|extract-images ...

converter office convert in.docx --to pdf
converter office matrix
converter office word2pdf|ppt2pdf|excel2pdf|md2pdf|pdf2ppt|pdf2excel|pdf2html|pdf2text ...
```

Every sub-command has `--help`. Errors are printed to stderr and the exit code is `1`
(`2` for usage errors).

## Configuration

| Constructor argument | Env var              | Default                          |
| -------------------- | -------------------- | -------------------------------- |
| `api_key`            | `CONVERTER_API_KEY`  | `None` (only public endpoints)   |
| `base_url`           | `CONVERTER_BASE_URL` | `https://fastapi.iamrraj.com`    |
| `timeout`            |                      | `300` seconds (per attempt)      |
| `max_retries`        |                      | `3`                              |
| `user_agent`         |                      | `converter-sdk-python/<version>` |
| `http_client`        |                      | a new `httpx.Client`             |

An explicit argument always wins over the environment variable. `timeout` may be a
float (seconds) or an `httpx.Timeout` for fine-grained control. `http_client` lets
you inject a pre-configured `httpx.Client` / `httpx.AsyncClient` (proxies, custom
transports, tests); the SDK never closes an injected client.

Only `health()` and `convert_doc_to_pdf()` work without an API key.

## Methods

All methods exist on both clients; the async client's are `await`-able. Every method
takes an optional `timeout=` (seconds or `httpx.Timeout`) that overrides the client
default for that call. File-taking methods also accept `filename=` (see
[File inputs](#file-inputs)).

### Top level (v1)

| Method                                  | Endpoint                    | Returns            | Key |
| --------------------------------------- | --------------------------- | ------------------ | --- |
| `health()`                              | `GET /health`               | `HealthResult`     | no  |
| `convert_doc_to_pdf(file, filename=)`   | `POST /convert-doc-to-pdf/` | `ConversionResult` | no  |
| `convert_pdf_to_docx(file, filename=)`  | `POST /convert-pdf-to-docx/`| `ConversionResult` | yes |
| `convert_html_to_pdf(html)`             | `POST /convert-html-to-pdf/`| `ConversionResult` | yes |
| `convert(file, to="pdf"\|"docx", filename=)` | picked by extension    | `ConversionResult` | dep.|
| `geoip(ip)`                             | `GET /geoip/{ip}`           | `GeoIPResult`      | yes |
| `geoip_me()`                            | `GET /geoip`                | `GeoIPResult`      | yes |
| `geoip_status()`                        | `GET /geoip/status`         | `GeoIPStatus`      | yes |

### `client.pdf` - pages

| Method | Endpoint | Returns |
| ------ | -------- | ------- |
| `merge(files, order=None, filenames=None)` | `POST /pdf/merge` | `ConversionResult` |
| `split(file, mode="every"\|"ranges", ranges=None)` | `POST /pdf/split` | `MultiResult` |
| `rotate(file, degrees=90\|180\|270\|-90, pages=None)` | `POST /pdf/rotate` | `ConversionResult` |
| `crop(file, left=0, top=0, right=0, bottom=0, unit="pt"\|"mm"\|"in", pages=None)` | `POST /pdf/crop` | `ConversionResult` |
| `reorder(file, order)` | `POST /pdf/reorder` | `ConversionResult` |
| `delete_pages(file, pages)` | `POST /pdf/pages/delete` | `ConversionResult` |
| `extract_pages(file, pages)` | `POST /pdf/pages/extract` | `ConversionResult` |
| `info(file)` | `POST /pdf/info` | `PdfInfo` |
| `page_numbers(file, position=, start=1, font_size=11, format="{n}", margin_pt=36, pages=None)` | `POST /pdf/page-numbers` | `ConversionResult` |
| `resize(file, size="A4", orientation="auto")` | `POST /pdf/resize` | `ConversionResult` |

### `client.pdf` - edit, security, text and forms

| Method | Endpoint | Returns |
| ------ | -------- | ------- |
| `compress(file, level="low"\|"medium"\|"high")` | `POST /pdf/compress` | `CompressResult` (`.original_size`, `.compressed_size`, `.ratio`) |
| `repair(file)` | `POST /pdf/repair` | `ConversionResult` |
| `watermark_text(file, text, font_size=48, opacity=0.3, rotation=45, color="#808080", position="center", pages=None, layer="over")` | `POST /pdf/watermark/text` | `ConversionResult` |
| `watermark_image(file, image, opacity=0.3, scale=0.5, position="center", pages=None, layer="over", image_filename=None)` | `POST /pdf/watermark/image` | `ConversionResult` |
| `metadata_set(file, title=, author=, subject=, keywords=, creator=, producer=)` | `POST /pdf/metadata` | `ConversionResult` |
| `metadata_strip(file)` | `POST /pdf/metadata/strip` | `ConversionResult` |
| `protect(file, user_password, permissions=None, owner_password=None, encryption="aes-256")` | `POST /pdf/protect` | `ConversionResult` |
| `unlock(file, password)` | `POST /pdf/unlock` | `ConversionResult` |
| `redact(file, terms, case_sensitive=False, whole_word=False, regex=False, fill="#000000", pages=None)` | `POST /pdf/redact` | `RedactResult` (`.redaction_count`) |
| `redact_areas(file, boxes, fill="#000000")` | `POST /pdf/redact/areas` | `RedactResult` |
| `text(file, pages=None, layout="plain"\|"blocks"\|"words")` | `POST /pdf/text` | `list[PageText \| PageBlocks \| PageWords]` |
| `text_plain(file, pages=None)` | `POST /pdf/text/plain` | `str` |
| `markdown(file, pages=None)` | `POST /pdf/markdown` | `ConversionResult` (`.text`) |
| `compare(file_a, file_b)` | `POST /pdf/compare` | `CompareResult` |
| `form_fields(file)` | `POST /pdf/forms/fields` | `list[FormField]` |
| `fill_form(file, values, flatten=False)` | `POST /pdf/forms/fill` | `ConversionResult` |

### `client.pdf` - images, OCR and signatures

| Method | Endpoint | Returns |
| ------ | -------- | ------- |
| `to_images(file, fmt="png"\|"jpg"\|"webp", dpi=150, pages=None, quality=85, grayscale=False)` | `POST /pdf/to-images` | `MultiResult` |
| `extract_images(file, pages=None, min_width=1, min_height=1)` | `POST /pdf/extract-images` | `MultiResult` |
| `from_images(files, page_size="fit", orientation="auto", margin_pt=0, fit="contain", dpi=96, filenames=None)` | `POST /pdf/from-images` | `ConversionResult` |
| `thumbnail(file, page=1, width=300, fmt="png", quality=85)` | `POST /pdf/thumbnail` | `ConversionResult` |
| `ocr(file, language="eng", pages=None, dpi=300, force=False)` | `POST /pdf/ocr` | `ConversionResult` |
| `ocr_text(file, language="eng", pages=None, dpi=300)` | `POST /pdf/ocr/text` | `OcrResult` (`.pages`, `.text`) |
| `ocr_languages()` | `GET /pdf/ocr/languages` | `list[str]` |
| `sign(file, certificate, password=None, reason=None, location=None, contact=None, field_name="Signature1", visible=False, page=1, box=None, stamp_text=None, certificate_filename=None)` | `POST /pdf/sign` | `ConversionResult` |
| `verify(file)` | `POST /pdf/sign/verify` | `VerifyResult` (`.signed`, iterable of `SignatureInfo`) |

### `client.office`

| Method | Endpoint | Returns |
| ------ | -------- | ------- |
| `convert(file, to=)` | `POST /convert/office` | `ConversionResult` |
| `matrix()` | `GET /convert/office/matrix` | `dict[str, list[str]]` |
| `word_to_pdf(file)` | `POST /convert/word-to-pdf` | `ConversionResult` |
| `powerpoint_to_pdf(file)` | `POST /convert/powerpoint-to-pdf` | `ConversionResult` |
| `excel_to_pdf(file)` | `POST /convert/excel-to-pdf` | `ConversionResult` |
| `markdown_to_pdf(file)` | `POST /convert/markdown-to-pdf` | `ConversionResult` |
| `pdf_to_powerpoint(file, pages=None, dpi=150)` | `POST /convert/pdf-to-powerpoint` | `ConversionResult` |
| `pdf_to_excel(file, pages=None)` | `POST /convert/pdf-to-excel` | `ConversionResult` |
| `pdf_to_html(file, pages=None)` | `POST /convert/pdf-to-html` | `ConversionResult` |
| `pdf_to_text(file, pages=None)` | `POST /convert/pdf-to-text` | `str` |

`office.convert` checks the source/target pair client-side against the server's matrix
(Word `.doc .docx .odt .rtf .txt .md` -> `pdf docx odt rtf txt html`; slides
`.ppt .pptx .odp` -> `pdf pptx odp`; sheets `.xls .xlsx .ods .csv` -> `pdf xlsx ods csv`)
and raises `UnsupportedConversionError` listing the allowed targets.

### Page specs and enums

`pages`, `order` and split `ranges` are plain strings in the server's format:
`"all"`, `"1,3-5,last"`, reversed ranges like `"5-2"`. They are validated locally and a
bad spec raises `ValueError` before any request is made. `order` also accepts a list of
ints. Enumerated parameters are `typing.Literal` aliases (see `converter_sdk.enums`) and
are validated at runtime too, so a wrong value raises `ValueError` rather than a 422.

### File inputs

`file` accepts:

- a `str` or `pathlib.Path` - the name is taken from the path;
- `bytes` - `filename=` is required so the extension can be validated;
- a binary file object - the name comes from its `.name`, unless `filename=` overrides it.

Extensions are validated **before** anything is sent (`.pdf` for every `client.pdf`
method, `.png/.jpg/.jpeg/.webp` for watermark images, `.jpg/.png/.webp/.bmp/.tif/.gif`
for `from_images`, `.p12/.pfx` for signing certificates, the office matrix for
`client.office`); a mismatch raises `InvalidFileTypeError`. Files are read fully into
memory up front so that a retried request always re-sends identical bytes.

Multi-file methods (`pdf.merge`, `pdf.from_images`) take a sequence of the same inputs
plus an optional parallel `filenames=` list for bytes/file objects.

```python
client.convert_pdf_to_docx(b"%PDF-1.7 ...", filename="scan.pdf")

with open("scan.pdf", "rb") as fh:
    client.convert_pdf_to_docx(fh)

client.convert("letter.doc", to="pdf")  # routes to convert_doc_to_pdf
client.convert("scan.pdf", to="docx")  # routes to convert_pdf_to_docx
client.convert("scan.pdf", to="pdf")  # UnsupportedConversionError
```

### Results

`ConversionResult`

| Attribute / method | Description                                                          |
| ------------------ | -------------------------------------------------------------------- |
| `content: bytes`   | The converted document                                               |
| `filename: str`    | From `Content-Disposition`, or derived from the input name           |
| `media_type: str`  | From `Content-Type` (e.g. `application/pdf`)                         |
| `size: int`        | `len(content)`                                                       |
| `save(path=None)`  | Writes to `path` (a file, or a directory + `filename`); returns `Path` |

`ConversionResult.text` decodes `content` as UTF-8 (Markdown, HTML, CSV, text results).

`MultiResult` - returned by `pdf.split`, `pdf.to_images` and `pdf.extract_images`, which
answer with a zip when there are several files and a single file otherwise. The SDK
normalises both:

| Attribute / method    | Description                                                    |
| --------------------- | -------------------------------------------------------------- |
| `files`               | `list[ConversionResult]` - zip entries unpacked in memory, or one item |
| `is_archive`          | `True` when the server sent `application/zip`                  |
| `archive`             | the raw zip as a `ConversionResult`, or `None`                 |
| `first`               | `files[0]`                                                     |
| `save_all(directory)` | writes every file into `directory` and returns the `Path`s     |

`CompressResult` / `RedactResult` extend `ConversionResult` with the header-derived
`original_size`, `compressed_size`, `ratio` / `redaction_count`.

`PdfInfo` (`page_count`, `encrypted`, `metadata: PdfMetadata`, `pages: list[PageInfo]`,
`file_size`), `FormField`, `CompareResult` (`pages: list[PageDiff]`), `OcrResult`
(`language`, `pages: list[OcrPage]`, `.text`), `VerifyResult` (`signed`,
`signatures: list[SignatureInfo]`), `PageText` / `PageBlocks` / `PageWords`
(`TextSpan` items carry a `(x0, y0, x1, y1)` bbox) and `RedactBox` are frozen
dataclasses; JSON results keep the decoded body in `.raw`.

`GeoIPResult` (frozen) has `ip`, `ip_version`, `attribution` (always present) and
`network`, `continent`, `continent_code`, `country`, `country_code`,
`is_in_european_union`, `region`, `city`, `latitude`, `longitude`, `asn`,
`organization` (each `Optional`), plus `.raw` (the decoded JSON) and
`.coordinates -> (lat, lon) | None`.

`GeoIPStatus` (frozen): `status`, `city_db`, `asn_db`, `city_db_built_at`,
`asn_db_built_at`, `.raw`, `.is_available`.

`HealthResult` (frozen): `status`, `timestamp`, `.raw`, `.is_healthy`.

## Error handling

Every exception derives from `converter_sdk.ConverterError`, which carries
`status_code`, `detail` (the parsed `{"detail": ...}` body, or the response text
for non-JSON bodies such as an nginx 413/502 page), `request_id`
(`X-Request-ID` header, when present) and `response`.

| Exception                    | When                                         |
| ---------------------------- | -------------------------------------------- |
| `BadRequestError`            | 400 - e.g. wrong file type, non-public IP     |
| `AuthenticationError`        | 401 - missing `X-API-Key`                    |
| `PermissionDeniedError`      | 403 - invalid API key                        |
| `NotFoundError`              | 404 - no GeoIP data for the address          |
| `ValidationError`            | 422 - `.errors` holds the FastAPI error list |
| `RateLimitError`             | 429 - `.retry_after` from `Retry-After`      |
| `ServiceUnavailableError`    | 503 (subclass of `ServerError`)              |
| `ServerError`                | any other 5xx                                |
| `TimeoutError`               | request exceeded `timeout`                   |
| `ConnectionError`            | DNS/TCP/TLS/protocol failure (no response)   |
| `InvalidFileTypeError`       | client-side extension check failed           |
| `UnsupportedConversionError` | `convert()` has no endpoint for the pair     |

`InvalidFileTypeError` and `UnsupportedConversionError` also subclass `ValueError`;
`TimeoutError` subclasses the SDK's `ConnectionError`.

```python
from converter_sdk import ConverterClient, ConverterError, PermissionDeniedError, RateLimitError

try:
    result = client.geoip("8.8.8.8")
except PermissionDeniedError:
    ...  # rotate the key
except RateLimitError as exc:
    ...  # exc.retry_after
except ConverterError as exc:
    print(exc.status_code, exc.detail, exc.request_id)
```

## Retries and timeouts

A request is retried up to `max_retries` times (default 3) when it fails with a
connection-level error (connect failure, connect/pool timeout, broken connection,
protocol error) or with HTTP 429, 502, 503 or 504. A busy server answers 503 with
`Retry-After` and is retried; a 503 whose detail says a dependency is *not installed*,
*not configured* or *not available* (OCR engine, LibreOffice, API key, GeoIP database)
is raised immediately as `ServiceUnavailableError`. Other 4xx responses and read
timeouts are never retried. Waits use exponential backoff with full jitter
(`0.5s * 2**attempt`, capped at 8s); a `Retry-After` header is honoured (capped at
30s). Set `max_retries=0` to disable retries.

`timeout` applies to each attempt and can be overridden per call with `timeout=`.
Server-side jobs are limited to 300 s; uploads to 50 MB per file and 50 files per request.

## Logging

The SDK logs to the `converter_sdk` logger (`DEBUG` for each attempt, `WARNING` for
each retry). The API key is never logged and is redacted in `repr(client)`.

```python
import logging

logging.getLogger("converter_sdk").setLevel(logging.DEBUG)
```

## Development

```bash
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
ruff check . && ruff format --check . && mypy src tests && pytest -q --cov
```

## License

MIT - Copyright (c) Rahul Raj
