diff --git a/.codecov.yml b/.codecov.yml index 1122e13..78c9104 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -2,3 +2,15 @@ codecov: require_ci_to_pass: true comment: require_changes: true +coverage: + status: + project: + default: + # https://docs.codecov.com/docs/commit-status#threshold + threshold: 1% + patch: + default: + # For the changed lines only, target 90% covered, but + # allow as low as 80% + target: 90% + threshold: 10% diff --git a/.docker/content/favicon.ico b/.docker/content/favicon.ico new file mode 100644 index 0000000..67c3073 Binary files /dev/null and b/.docker/content/favicon.ico differ diff --git a/.docker/docker-compose.ci-test.yml b/.docker/docker-compose.ci-test.yml index bdabff3..484b98c 100644 --- a/.docker/docker-compose.ci-test.yml +++ b/.docker/docker-compose.ci-test.yml @@ -5,7 +5,7 @@ version: "3" services: gotenberg-client-test-server: - image: docker.io/gotenberg/gotenberg:8.5.1 + image: docker.io/gotenberg/gotenberg:8.11.0 hostname: gotenberg-client-test-server container_name: gotenberg-client-test-server network_mode: host diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87186e3..b5f8d6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,10 @@ jobs: - uses: actions/checkout@v4 - - name: Set up Python 3.10 + name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.11' cache: 'pip' - name: Install Hatch @@ -40,6 +40,10 @@ jobs: name: Lint project run: | hatch fmt --check + - + name: Check project typing + run: | + hatch run typing:run - name: Check files with pre-commit uses: pre-commit/action@v3.0.1 @@ -108,6 +112,56 @@ jobs: docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test.yml logs docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test.yml down + test-edge: + name: Test Gotenberg :edge + runs-on: ubuntu-latest + permissions: + contents: read + needs: + - lint + steps: + - + uses: actions/checkout@v4 + - + name: Start containers + run: | + docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test-edge.yml pull --quiet + docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test-edge.yml up --detach + echo "Wait for container to be started" + sleep 5 + docker inspect gotenberg-client-test-edge-server + - + name: Install poppler-utils + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends poppler-utils + - + name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: 'pip' + - + name: Install Hatch + run: | + python3 -m pip install --upgrade pip + pip install --upgrade hatch + - + name: Show environment + run: | + hatch test --show --python 3.11 + - + name: Run tests + run: | + hatch test --cover --python 3.11 + ls -ahl . + - + name: Stop containers + if: always() + run: | + docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test-edge.yml logs + docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test-edge.yml down + build: name: Build runs-on: ubuntu-latest @@ -243,4 +297,4 @@ jobs: path: dist - name: Publish build to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.14 + uses: pypa/gh-action-pypi-publish@v1.10.2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a9ea54..11d1ffb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: # General hooks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-docstring-first - id: check-json @@ -28,8 +28,9 @@ repos: - svg - id: check-case-conflict - id: detect-private-key - - repo: https://github.com/pre-commit/mirrors-prettier - rev: 'v3.1.0' + # See https://github.com/prettier/prettier/issues/15742 for the fork reason + - repo: https://github.com/rbubley/mirrors-prettier + rev: "v3.3.3" hooks: - id: prettier types_or: @@ -44,9 +45,13 @@ repos: - id: codespell # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.4.6' + rev: 'v0.6.9' hooks: # Run the linter. - id: ruff # Run the formatter. - id: ruff-format + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "2.2.4" + hooks: + - id: pyproject-fmt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5555e..5399e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.7.0] - 2024-10-08 ### Fixed -- Wrong paper size preset for A4 ([#24](https://github.com/stumpylog/gotenberg-client/pull/24)) +- `mike` deployment mis-ordered the version and alias, this has been corrected +- `mypy` wasn't running correctly in CI +- Wrong paper size preset for A4 by [@mannerydhe](https://github.com/mannerydhe) ([#24](https://github.com/stumpylog/gotenberg-client/pull/24)) + +### Added + +- All routes now return a stronger typed response than just an `httpx.Response` ([#23](https://github.com/stumpylog/gotenberg-client/pull/23)) +- All public methods now include docstrings ([#33](https://github.com/stumpylog/gotenberg-client/pull/33)) +- The Chromium based HTML and Markdown to PDF routes can now accept accept a `str`, containing either HTML text, Markdown or other text based resources for conversion ([#30](https://github.com/stumpylog/gotenberg-client/pull/30)) + - See `string_index`, `string_resource` and `string_resources` for those routes + +### Changed + +- Bump pypa/gh-action-pypi-publish from 1.8.14 to 1.9.0 by @dependabot ([#25](https://github.com/stumpylog/gotenberg-client/pull/25)) +- Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.2 by @dependabot ([#31](https://github.com/stumpylog/gotenberg-client/pull/31)) +- CI testing now runs against Gotenberg 8.11 ([#32](https://github.com/stumpylog/gotenberg-client/pull/32)) +- Development tool updates in `pyproject.toml` and pre-commit hook updates +- Properly use `pytest` fixtures in all testing ([#34](https://github.com/stumpylog/gotenberg-client/pull/34)) +- Upgrade `pre-commit` to 4.0.1 ([#35](https://github.com/stumpylog/gotenberg-client/pull/35)) ## [0.6.0] - 2024-06-13 diff --git a/README.md b/README.md index 07b1802..ebef672 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ endpoint. All the routes use the same format and general idea. - Files will be PDF or ZIP, depending on what endpoint and its configuration. Endpoints which handle multiple files, but don't merge them, return a ZIP archive of the resulting PDFs -For more detailed examples, check the [documentation](https://stumpylog.github.io/gotenberg-client/) +For more detailed examples, check the [documentation](https://stumpylog.github.io/gotenberg-client/latest/) ### Examples @@ -64,7 +64,7 @@ from gotenberg_client import GotenbergClient with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.index("my-index.html").run() - Path("my-index.pdf").write_bytes(response.content) + response.to_file(Path("my-index.pdf")) ``` Converting an HTML file with additional resources into a PDF: @@ -75,7 +75,7 @@ from gotenberg_client import GotenbergClient with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.index("my-index.html").resource("image.png").resource("style.css").run() - Path("my-index.pdf").write_bytes(response.content) + response.to_file(Path("my-index.pdf")) ``` Converting an HTML file with additional resources into a PDF/A1a format: @@ -87,7 +87,7 @@ from gotenberg_client.options import PdfAFormat with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.index("my-index.html").resources(["image.png", "style.css"]).pdf_format(PdfAFormat.A2b).run() - Path("my-index.pdf").write_bytes(response.content) + response.to_file(Path("my-index.pdf")) ``` Converting a URL into PDF, in landscape format @@ -99,7 +99,7 @@ from gotenberg_client.options import PageOrientation with GotenbergClient("http://localhost:3000") as client: with client.chromium.url_to_pdf() as route: response = route.url("https://hello.world").orient(PageOrientation.Landscape).run() - Path("my-world.pdf").write_bytes(response.content) + response.to_file(Path("my-world.pdf")) ``` To ensure the proper clean up of all used resources, both the client and the route(s) should be @@ -119,6 +119,13 @@ finally: client.close() ``` +The response from any `.run()` or `.run_with_retry()` will be either a `SingleFileResponse` or `ZipFileResponse`. +There provide a slimmed down set of fields from an `httpx.Response`, including the headers, the status code and +the response content. They also provide two convenience methods: + +- `to_file` - Accepts a path and writes the content of the response to it +- `extract_to` - Only on a `ZipFileResponse`, extracts the zip into the given directory (which must exist) + ## License `gotenberg-client` is distributed under the terms of the [MPL 2.0](https://spdx.org/licenses/MPL-2.0.html) license. diff --git a/docs/index.md b/docs/index.md index 04d3752..06f7ec6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ from gotenberg_client import GotenbergClient with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.index("my-index.html").run() - Path("my-index.pdf").write_bytes(response.content) + response.to_file(Path("my-index.pdf")) ``` Converting an HTML file with additional resources into a PDF: @@ -33,7 +33,7 @@ from gotenberg_client import GotenbergClient with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.index("my-index.html").resource("image.png").resource("style.css").run() - Path("my-index.pdf").write_bytes(response.content) + response.to_file(Path("my-index.pdf")) ``` Converting an HTML file with additional resources into a PDF/A1a format: @@ -45,7 +45,7 @@ from gotenberg_client.options import PdfAFormat with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.index("my-index.html").resources(["image.png", "style.css"]).pdf_format(PdfAFormat.A2b).run() - Path("my-index.pdf").write_bytes(response.content) + response.to_file(Path("my-index.pdf")) ``` Converting a URL into PDF, in landscape format @@ -57,7 +57,7 @@ from gotenberg_client.options import PageOrientation with GotenbergClient("http://localhost:3000") as client: with client.chromium.html_to_pdf() as route: response = route.url("https://hello.world").orient(PageOrientation.Landscape).run() - Path("my-world.pdf").write_bytes(response.content) + response.to_file(Path("my-world.pdf")) ``` To ensure the proper clean up of all used resources, both the client and the route(s) should be @@ -76,3 +76,21 @@ try: finally: client.close() ``` + +## API Responses + +The response from any `.run()` or `.run_with_retry()` will be either a `SingleFileResponse` or `ZipFileResponse`. +There provide a slimmed down set of fields from an `httpx.Response`, including the headers, the status code and +the response content. They also provide two convenience methods: + +- `to_file` - Accepts a path and writes the content of the response to it +- `extract_to` - Only on a `ZipFileResponse`, extracts the zip into the given directory (which must exist) + +Determining which response is a little complicated, as Gotenberg can produce a single PDF from multiple files or +a zip file containing multiple PDFs, depending on how the route is configured and how many files were provided. + +For example, the LibreOffice convert route may: + +- Produce a single PDF when a single office document is provided +- Produce a zipped response when multiple office documents are provided +- Produce a single PDF when multiple office documents are provided AND the route is asked to merge the result diff --git a/docs/maybe-logo.webp b/docs/maybe-logo.webp new file mode 100644 index 0000000..04dec3a Binary files /dev/null and b/docs/maybe-logo.webp differ diff --git a/pyproject.toml b/pyproject.toml index 2f42fbc..0d83511 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,29 @@ +# +# Project Configuration +# + [build-system] -requires = ["hatchling"] build-backend = "hatchling.build" +requires = [ "hatchling" ] + [project] name = "gotenberg-client" -dynamic = ["version"] -description = 'A Python client for interfacing with the Gotenberg API' +description = "A Python client for interfacing with the Gotenberg API" readme = "README.md" -requires-python = ">=3.8" +keywords = [ "api", "client", "html", "pdf" ] license = "MPL-2.0" -keywords = ["api", "pdf", "html", "client"] authors = [ { name = "Trenton H", email = "rda0128ou@mozmail.com" }, ] +requires-python = ">=3.8" classifiers = [ "Development Status :: 4 - Beta", - "Operating System :: OS Independent", - "Intended Audience :: Developers", "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: OS Independent", + "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -27,20 +33,22 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] +dynamic = [ "version" ] dependencies = [ - "httpx[http2] ~= 0.27; python_version >= '3.9'", - "httpx[http2] ~= 0.24; python_version < '3.9'", - "typing-extensions; python_version < '3.11'" + "httpx[http2]~=0.24; python_version<'3.9'", + "httpx[http2]~=0.27; python_version>='3.9'", + "typing-extensions; python_version<'3.11'", ] -[project.urls] -Documentation = "https://stumpylog.github.io/gotenberg-client/" -Issues = "https://github.com/stumpylog/gotenberg-client/issues" -Source = "https://github.com/stumpylog/gotenberg-client/" -Changelog = "https://github.com/stumpylog/gotenberg-client/blob/main/CHANGELOG.md" +optional-dependencies.magic = [ "python-magic" ] -[project.optional-dependencies] -magic = ["python-magic"] +# +# Hatch Configuration +# +urls.Changelog = "https://github.com/stumpylog/gotenberg-client/blob/main/CHANGELOG.md" +urls.Documentation = "https://stumpylog.github.io/gotenberg-client/" +urls.Issues = "https://github.com/stumpylog/gotenberg-client/issues" +urls.Source = "https://github.com/stumpylog/gotenberg-client/" [tool.hatch.version] path = "src/gotenberg_client/__about__.py" @@ -48,7 +56,7 @@ path = "src/gotenberg_client/__about__.py" [tool.hatch.build.targets.sdist] exclude = [ ".github", - ".docker" + ".docker", ] [tool.hatch.envs.default] @@ -56,7 +64,7 @@ installer = "uv" [tool.hatch.envs.hatch-static-analysis] # https://hatch.pypa.io/latest/config/internal/static-analysis/ -dependencies = ["ruff ~= 0.4.2"] +dependencies = [ "ruff ~= 0.6" ] config-path = "none" [tool.hatch.envs.hatch-test] @@ -65,42 +73,43 @@ parallel = true randomize = true dependencies = [ "coverage-enable-subprocess == 1.0", - "coverage[toml] ~= 7.4", + "coverage[toml] ~= 7.6", "pytest < 8.0; python_version < '3.9'", - "pytest ~= 8.1; python_version >= '3.9'", - "pytest-mock ~= 3.12", + "pytest ~= 8.3; python_version >= '3.9'", + "pytest-mock ~= 3.14", "pytest-randomly ~= 3.15", "pytest-rerunfailures ~= 14.0", - "pytest-xdist[psutil] ~= 3.5", + "pytest-xdist[psutil] ~= 3.6", ] extra-dependencies = [ "pytest-sugar", - "pytest-httpx ~= 0.30; python_version >= '3.9'", + "pytest-httpx == 0.30.0; python_version >= '3.9'", "pytest-httpx ~= 0.22; python_version < '3.9'", "pikepdf", "python-magic", ] -extra-args = ["--maxprocesses=8", "--pythonwarnings=all", ] +extra-args = [ "--maxprocesses=8", "--pythonwarnings=all" ] [tool.hatch.envs.hatch-test.scripts] run = [ "python3 --version", - "pytest{env:HATCH_TEST_ARGS:} {args}"] + "pytest{env:HATCH_TEST_ARGS:} {args}", +] run-cov = [ "python3 --version", "coverage erase", - "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}" + "coverage run -m pytest{env:HATCH_TEST_ARGS:} {args}", ] -cov-combine = ["coverage combine"] +cov-combine = [ "coverage combine" ] cov-report = [ "coverage report", "coverage json", - "coverage html" + "coverage html", ] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10"] +python = [ "3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10" ] # # Custom Environments @@ -108,58 +117,65 @@ python = ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9", "pypy3.10"] [tool.hatch.envs.typing] detached = true dependencies = [ - "mypy ~= 1.10.0", + "mypy ~= 1.11", "httpx", + "pytest", + "pikepdf", + "pytest-httpx == 0.30.0", ] [tool.hatch.envs.typing.scripts] run = [ "mypy --version", - "mypy --install-types --non-interactive {args:src/tika_client}" + "mypy --install-types --non-interactive .", ] [tool.hatch.envs.pre-commit] template = "pre-commit" detached = true dependencies = [ - "pre-commit ~= 3.7.0", + "pre-commit ~= 4.0", + "pre-commit-uv", ] [tool.hatch.envs.pre-commit.scripts] -check = ["pre-commit run --all-files"] -update = ["pre-commit autoupdate"] +check = [ "pre-commit run --all-files" ] +update = [ "pre-commit autoupdate" ] [tool.hatch.envs.docs] template = "docs" detached = true dependencies = [ - "mkdocs-material[imaging] ~= 9.5.26", - "mike ~= 2.1.0", - "mkdocs-minify-plugin ~= 0.7.1" + "mkdocs-material[imaging] ~= 9.5", + "mike ~= 2.1", + "mkdocs-minify-plugin ~= 0.8", ] [tool.hatch.envs.docs.scripts] -new = ["mkdocs new ."] -build = ["mkdocs build"] +new = [ "mkdocs new ." ] +build = [ "mkdocs build" ] serve = [ - "mkdocs serve" + "mkdocs serve", ] -mike-help = ["mike deploy --help"] +mike-help = [ "mike deploy --help" ] deploy = [ - "mike deploy --push --branch gh-pages --remote origin --update-aliases latest {args}", - "mike set-default --branch gh-pages --remote origin --push latest" + "mike deploy --push --branch gh-pages --remote origin --update-aliases {args} latest", + "mike set-default --branch gh-pages --remote origin --push latest", ] +# +# Tool Configuration +# + [tool.ruff] -# https://docs.astral.sh/ruff/settings/ -fix = true -output-format = "grouped" target-version = "py38" line-length = 120 -[tool.ruff.lint] +# https://docs.astral.sh/ruff/settings/ +fix = true +output-format = "grouped" # https://docs.astral.sh/ruff/rules/ -extend-select = [ +lint.extend-select = [ "A", "ARG", "B", @@ -181,15 +197,15 @@ extend-select = [ "ISC", "N", "PERF", - "PIE", "PGH", - "PTH", + "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", + "PTH", "Q", "RSE", "RUF", @@ -207,39 +223,43 @@ extend-select = [ "W", "YTT", ] -ignore = [ +lint.ignore = [ # Allow non-abstract empty methods in abstract base classes "B027", + # Ignore complexity + "C901", # Allow boolean positional values in function calls, like `dict.get(... True)` "FBT003", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", + "S105", + "S106", + "S107", # Ignore no author and missing issue link in TODO tags - "TD002", "TD003" + "TD002", + "TD003", ] - -[tool.ruff.lint.isort] -force-single-line = true -known-first-party = ["gotenberg_client"] - -[tool.ruff.lint.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +lint.per-file-ignores."tests/**/*" = [ "PLR2004", "S101", "TID252" ] +# No relative imports +lint.flake8-tidy-imports.ban-relative-imports = "all" +# One import per line +lint.isort.force-single-line = true +# Recognize us please +lint.isort.known-first-party = [ "gotenberg_client" ] [tool.pytest.ini_options] minversion = "7.0" -testpaths = ["tests"] +testpaths = [ "tests" ] [tool.pytest_env] #SAVE_TEST_OUTPUT = 1 [tool.coverage.run] -source_pkgs = ["gotenberg_client", "tests"] +source_pkgs = [ "gotenberg_client", "tests" ] branch = true parallel = true omit = [ @@ -249,23 +269,20 @@ omit = [ ] [tool.coverage.paths] -gotenberg_client = ["src/gotenberg_client", "*/gotenberg_client/src/gotenberg_client"] -tests = ["tests", "*/gotenberg_client/tests"] +gotenberg_client = [ "src/gotenberg_client" ] +tests = [ "tests" ] [tool.coverage.report] exclude_lines = [ "no cov", - "if __name__ == .__main__.:", + "if __name__ == '__main__':", "if TYPE_CHECKING:", "if SAVE_OUTPUTS:", ] [tool.mypy] exclude = [ - "tests/test_convert_chromium_html.py", - "tests/test_convert_chromium_url.py", - "tests/test_convert_chromium_markdown.py", - "tests/conftest.py", + "tests/*", ] disallow_any_expr = true disallow_untyped_defs = true diff --git a/src/gotenberg_client/__about__.py b/src/gotenberg_client/__about__.py index 60cd4f7..7404394 100644 --- a/src/gotenberg_client/__about__.py +++ b/src/gotenberg_client/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/src/gotenberg_client/__init__.py b/src/gotenberg_client/__init__.py index 015889f..d1f211d 100644 --- a/src/gotenberg_client/__init__.py +++ b/src/gotenberg_client/__init__.py @@ -2,5 +2,17 @@ # # SPDX-License-Identifier: MPL-2.0 from gotenberg_client._client import GotenbergClient +from gotenberg_client._errors import BaseClientError +from gotenberg_client._errors import CannotExtractHereError +from gotenberg_client._errors import MaxRetriesExceededError +from gotenberg_client.responses import SingleFileResponse +from gotenberg_client.responses import ZipFileResponse -__all__ = ["GotenbergClient"] +__all__ = [ + "GotenbergClient", + "SingleFileResponse", + "ZipFileResponse", + "BaseClientError", + "CannotExtractHereError", + "MaxRetriesExceededError", +] diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 3ddf8d3..1f2131e 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -4,30 +4,30 @@ import logging from contextlib import ExitStack from pathlib import Path -from tempfile import TemporaryDirectory from time import sleep from types import TracebackType from typing import Dict from typing import Optional +from typing import Tuple from typing import Type -from typing import Union from httpx import Client from httpx import HTTPStatusError from httpx import Response from httpx._types import RequestFiles -from gotenberg_client._typing_compat import Self +from gotenberg_client._errors import MaxRetriesExceededError +from gotenberg_client._errors import UnreachableCodeError +from gotenberg_client._types import Self +from gotenberg_client._types import WaitTimeType from gotenberg_client._utils import guess_mime_type from gotenberg_client.options import PdfAFormat +from gotenberg_client.responses import SingleFileResponse +from gotenberg_client.responses import ZipFileResponse logger = logging.getLogger(__name__) -class UnreachableCodeError(Exception): - pass - - class PdfFormatMixin: """ https://gotenberg.dev/docs/routes#pdfa-chromium @@ -60,7 +60,7 @@ def disable_universal_access(self) -> Self: return self -class BaseRoute(PdfFormatMixin, PfdUniversalAccessMixin): +class _BaseRoute(PdfFormatMixin, PfdUniversalAccessMixin): """ The base implementation of a Gotenberg API route. Anything settings or actions shared between all routes should be implemented here @@ -74,6 +74,8 @@ def __init__(self, client: Client, api_route: str) -> None: self._form_data: Dict[str, str] = {} # These are the names of files, mapping to their Path self._file_map: Dict[str, Path] = {} + # Additional in memory resources, mapping the referenced name to the content and an optional mimetype + self._in_memory_resources: Dict[str, Tuple[str, Optional[str]]] = {} # Any header that will also be sent self._headers: Dict[str, str] = {} @@ -104,22 +106,26 @@ def close(self) -> None: """ self.reset() - def run(self) -> Response: + def _base_run(self) -> Response: """ Executes the configured route against the server and returns the resulting Response. - TODO: It would be nice to return a simpler response to the user """ - resp = self._client.post(url=self._route, headers=self._headers, data=self._form_data, files=self._get_files()) + resp = self._client.post( + url=self._route, + headers=self._headers, + data=self._form_data, + files=self._get_all_resources(), + ) resp.raise_for_status() return resp - def run_with_retry( + def _base_run_with_retry( self, *, max_retry_count: int = 5, - initial_retry_wait: Union[float, int] = 5.0, - retry_scale: Union[float, int] = 2.0, + initial_retry_wait: WaitTimeType = 5.0, + retry_scale: WaitTimeType = 2.0, ) -> Response: """ For whatever reason, Gotenberg often returns HTTP 503 errors, even with the same files. @@ -144,7 +150,7 @@ def run_with_retry( current_retry_count = current_retry_count + 1 try: - return self.run() + return self._base_run() except HTTPStatusError as e: logger.warning(f"HTTP error: {e}", stacklevel=1) @@ -155,7 +161,7 @@ def run_with_retry( # Don't do the extra waiting, return right away if current_retry_count >= max_retry_count: - raise + raise MaxRetriesExceededError(response=e.response) from e except Exception as e: # pragma: no cover logger.warning(f"Unexpected error: {e}", stacklevel=1) @@ -167,27 +173,35 @@ def run_with_retry( raise UnreachableCodeError # pragma: no cover - def _get_files(self) -> RequestFiles: + def _get_all_resources(self) -> RequestFiles: """ Deals with opening all provided files for multi-part uploads, including pushing their new contexts onto the stack to ensure resources like file handles are cleaned up """ - files = {} + resources = {} for filename in self._file_map: file_path = self._file_map[filename] # Helpful but not necessary to provide the mime type when possible mime_type = guess_mime_type(file_path) if mime_type is not None: - files.update( + resources.update( {filename: (filename, self._stack.enter_context(file_path.open("rb")), mime_type)}, ) else: # pragma: no cover - files.update({filename: (filename, self._stack.enter_context(file_path.open("rb")))}) # type: ignore [dict-item] - return files + resources.update({filename: (filename, self._stack.enter_context(file_path.open("rb")))}) # type: ignore [dict-item] + + for resource_name in self._in_memory_resources: + data, mime_type = self._in_memory_resources[resource_name] + if mime_type is not None: + resources.update({resource_name: (resource_name, data, mime_type)}) # type: ignore [dict-item] + else: + resources.update({resource_name: (resource_name, data)}) # type: ignore [dict-item] + + return resources - def _add_file_map(self, filepath: Path, name: Optional[str] = None) -> None: + def _add_file_map(self, filepath: Path, *, name: Optional[str] = None) -> None: """ Small helper to handle bookkeeping of files for later opening. The name is optional to support those things which are required to have a certain name @@ -199,21 +213,14 @@ def _add_file_map(self, filepath: Path, name: Optional[str] = None) -> None: if name in self._file_map: # pragma: no cover logger.warning(f"{name} has already been provided, overwriting anyway") - try: - name.encode("utf8").decode("ascii") - except UnicodeDecodeError: - logger.warning(f"filename {name} includes non-ascii characters, compensating for Gotenberg") - tmp_dir = self._stack.enter_context(TemporaryDirectory()) - # Filename can be fixed, the directory is random - new_path = Path(tmp_dir) / Path(name).with_name(f"clean-filename-copy{filepath.suffix}") - logger.warning(f"New path {new_path}") - new_path.write_bytes(filepath.read_bytes()) - filepath = new_path - name = new_path.name - logger.warning(f"New name {name}") - self._file_map[name] = filepath + def _add_in_memory_file(self, data: str, *, name: str, mime_type: Optional[str] = None) -> None: + if name in self._in_memory_resources: # pragma: no cover + logger.warning(f"{name} has already been provided, overwriting anyway") + + self._in_memory_resources[name] = (data, mime_type) + def trace(self, trace_id: str) -> Self: self._headers["Gotenberg-Trace"] = trace_id return self @@ -223,6 +230,113 @@ def output_name(self, filename: str) -> Self: return self +class BaseSingleFileResponseRoute(_BaseRoute): + def __init__(self, client: Client, api_route: str) -> None: + super().__init__(client, api_route) + + def run(self) -> SingleFileResponse: + """ + Execute the API request to Gotenberg. + + This method sends the configured request to the Gotenberg service and returns the response. + + Returns: + SingleFileResponse: An object containing the response from the Gotenberg API + + Raises: + httpx.Error: Any errors from httpx will be raised + """ + response = super()._base_run() + + return SingleFileResponse(response.status_code, response.headers, response.content) + + def run_with_retry( + self, + *, + max_retry_count: int = 5, + initial_retry_wait: WaitTimeType = 5, + retry_scale: WaitTimeType = 2, + ) -> SingleFileResponse: + """ + Execute the API request with a retry mechanism. + + This method attempts to run the API request and automatically retries in case of failures. + It uses an exponential backoff strategy for retries. + + Args: + max_retry_count (int, optional): The maximum number of retry attempts. Defaults to 5. + initial_retry_wait (WaitTimeType, optional): The initial wait time between retries in seconds. + Defaults to 5. Can be int or float. + retry_scale (WaitTimeType, optional): The scale factor for the exponential backoff. + Defaults to 2. Can be int or float. + + Returns: + SingleFileResponse: The response object containing the result of the API call. + + Raises: + MaxRetriesExceededError: If the maximum number of retries is exceeded without a successful response. + """ + response = super()._base_run_with_retry( + max_retry_count=max_retry_count, + initial_retry_wait=initial_retry_wait, + retry_scale=retry_scale, + ) + + return SingleFileResponse(response.status_code, response.headers, response.content) + + +class BaseZipFileResponseRoute(_BaseRoute): + def run(self) -> ZipFileResponse: # pragma: no cover + """ + Execute the API request to Gotenberg. + + This method sends the configured request to the Gotenberg service and returns the response. + + Returns: + ZipFileResponse: The zipped response with the files + + Raises: + httpx.Error: Any errors from httpx will be raised + """ + response = super()._base_run() + + return ZipFileResponse(response.status_code, response.headers, response.content) + + def run_with_retry( + self, + *, + max_retry_count: int = 5, + initial_retry_wait: WaitTimeType = 5, + retry_scale: WaitTimeType = 2, + ) -> ZipFileResponse: + """ + Execute the API request with a retry mechanism. + + This method attempts to run the API request and automatically retries in case of failures. + It uses an exponential backoff strategy for retries. + + Args: + max_retry_count (int, optional): The maximum number of retry attempts. Defaults to 5. + initial_retry_wait (WaitTimeType, optional): The initial wait time between retries in seconds. + Defaults to 5. Can be int or float. + retry_scale (WaitTimeType, optional): The scale factor for the exponential backoff. + Defaults to 2. Can be int or float. + + Returns: + ZipFileResponse: The zipped response with the files + + Raises: + MaxRetriesExceededError: If the maximum number of retries is exceeded without a successful response. + """ + response = super()._base_run_with_retry( + max_retry_count=max_retry_count, + initial_retry_wait=initial_retry_wait, + retry_scale=retry_scale, + ) + + return ZipFileResponse(response.status_code, response.headers, response.content) + + class BaseApi: """ Simple base class for an API, which wraps one or more routes, providing diff --git a/src/gotenberg_client/_client.py b/src/gotenberg_client/_client.py index 05493ca..1c53259 100644 --- a/src/gotenberg_client/_client.py +++ b/src/gotenberg_client/_client.py @@ -14,13 +14,24 @@ from gotenberg_client._convert.pdfa import PdfAApi from gotenberg_client._health import HealthCheckApi from gotenberg_client._merge import MergeApi -from gotenberg_client._typing_compat import Self -from gotenberg_client.options import HttpMethodsType +from gotenberg_client._types import HttpMethodsType +from gotenberg_client._types import Self class GotenbergClient: """ - The user's primary interface to the Gotenberg instance + The user's primary interface to the Gotenberg instance. + + This class provides methods to configure and interact with a Gotenberg service, + including setting up API endpoints for various Gotenberg features and managing + webhook configurations. + + Attributes: + chromium (ChromiumApi): Interface for Chromium-related operations. + libre_office (LibreOfficeApi): Interface for LibreOffice-related operations. + pdf_a (PdfAApi): Interface for PDF/A-related operations. + merge (MergeApi): Interface for PDF merging operations. + health (HealthCheckApi): Interface for health check operations. """ def __init__( @@ -31,6 +42,15 @@ def __init__( log_level: int = logging.ERROR, http2: bool = True, ): + """ + Initialize a new GotenbergClient instance. + + Args: + host (str): The base URL of the Gotenberg service. + timeout (float, optional): The timeout for API requests in seconds. Defaults to 30.0. + log_level (int, optional): The logging level for httpx and httpcore. Defaults to logging.ERROR. + http2 (bool, optional): Whether to use HTTP/2. Defaults to True. + """ # Configure the client self._client = Client(base_url=host, timeout=timeout, http2=http2) @@ -47,46 +67,73 @@ def __init__( def add_headers(self, header: Dict[str, str]) -> None: """ - Updates the httpx Client headers with the given values + Update the httpx Client headers with the given values. + + Args: + header (Dict[str, str]): A dictionary of header names and values to add. """ self._client.headers.update(header) def add_webhook_url(self, url: str) -> None: """ - Adds the webhook URL to the headers + Add the webhook URL to the headers. + + Args: + url (str): The URL to be used as the webhook endpoint. """ self.add_headers({"Gotenberg-Webhook-Url": url}) def add_error_webhook_url(self, url: str) -> None: """ - Adds the webhook error URL to the headers + Add the webhook error URL to the headers. + + Args: + url (str): The URL to be used as the error webhook endpoint. """ self.add_headers({"Gotenberg-Webhook-Error-Url": url}) def set_webhook_http_method(self, method: HttpMethodsType = "PUT") -> None: """ - Sets the HTTP method Gotenberg will use to call the hooks + Set the HTTP method Gotenberg will use to call the webhooks. + + Args: + method (HttpMethodsType, optional): The HTTP method to use. Defaults to "PUT". """ self.add_headers({"Gotenberg-Webhook-Method": method}) def set_error_webhook_http_method(self, method: HttpMethodsType = "PUT") -> None: """ - Sets the HTTP method Gotenberg will use to call the hooks + Set the HTTP method Gotenberg will use to call the error webhooks. + + Args: + method (HttpMethodsType, optional): The HTTP method to use. Defaults to "PUT". """ self.add_headers({"Gotenberg-Webhook-Error-Method": method}) def set_webhook_extra_headers(self, extra_headers: Dict[str, str]) -> None: """ - Sets the HTTP method Gotenberg will use to call the hooks + Set additional HTTP headers for Gotenberg to use when calling webhooks. + + Args: + extra_headers (Dict[str, str]): A dictionary of additional headers to include in webhook calls. """ from json import dumps self.add_headers({"Gotenberg-Webhook-Extra-Http-Headers": dumps(extra_headers)}) def __enter__(self) -> Self: + """ + Enter the runtime context related to this object. + + Returns: + Self: The instance itself. + """ return self def close(self) -> None: + """ + Close the underlying HTTP client connection. + """ self._client.close() def __exit__( @@ -95,4 +142,14 @@ def __exit__( exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: + """ + Exit the runtime context related to this object. + + This method ensures that the client connection is closed when exiting a context manager. + + Args: + exc_type: The type of the exception that caused the context to be exited, if any. + exc_val: The instance of the exception that caused the context to be exited, if any. + exc_tb: A traceback object encoding the stack trace, if an exception occurred. + """ self.close() diff --git a/src/gotenberg_client/_convert/chromium.py b/src/gotenberg_client/_convert/chromium.py index 9c803c1..4e176f3 100644 --- a/src/gotenberg_client/_convert/chromium.py +++ b/src/gotenberg_client/_convert/chromium.py @@ -5,9 +5,13 @@ from pathlib import Path from typing import List from typing import Literal +from typing import Optional +from typing import Tuple + +from httpx import Client from gotenberg_client._base import BaseApi -from gotenberg_client._base import BaseRoute +from gotenberg_client._base import BaseSingleFileResponseRoute from gotenberg_client._convert.common import ConsoleExceptionMixin from gotenberg_client._convert.common import CustomHTTPHeaderMixin from gotenberg_client._convert.common import EmulatedMediaMixin @@ -17,29 +21,104 @@ from gotenberg_client._convert.common import PagePropertiesMixin from gotenberg_client._convert.common import PerformanceModeMixin from gotenberg_client._convert.common import RenderControlMixin -from gotenberg_client._typing_compat import Self +from gotenberg_client._types import Self from gotenberg_client._utils import FORCE_MULTIPART from gotenberg_client._utils import ForceMultipartDict logger = logging.getLogger() -class _FileBasedRoute(BaseRoute): +class _FileBasedRoute(BaseSingleFileResponseRoute): def index(self, index: Path) -> Self: - self._add_file_map(index, "index.html") + """ + Adds the given HTML file as the index file. + + The file name will be ignored and cannot be configured + """ + self._add_file_map(index, name="index.html") + return self + + def string_index(self, index: str) -> Self: + """ + Provides the given string data as the index HTML for conversion. + + Args: + index (str): The HTML content to be used as the index file. + + Returns: + Self: This object itself for method chaining. + """ + + self._add_in_memory_file(index, name="index.html", mime_type="text/html") + return self + + +class _RouteWithResources(BaseSingleFileResponseRoute): + def resource(self, resource: Path, *, name: Optional[str] = None) -> Self: + """ + Adds additional resources for the index HTML file to reference. + + The filename may optionally be overridden if the HTML refers to the file with a different name + """ + self._add_file_map(resource, name=name) return self + def string_resource(self, resource: str, name: str, mime_type: Optional[str] = None) -> Self: + """ + Adds a string resource to the conversion process. + + The provided string data will be made available to the index HTML file during conversion, + using the specified name and MIME type. + + Args: + resource (str): The string data to be added as a resource. + name (str): The name to assign to the resource. + mime_type (Optional[str]): The MIME type of the resource (optional). -class _RouteWithResources(BaseRoute): - def resource(self, resource: Path) -> Self: - self._add_file_map(resource) + Returns: + Self: This object itself for method chaining. + """ + + self._add_in_memory_file(resource, name=name, mime_type=mime_type) return self def resources(self, resources: List[Path]) -> Self: + """ + Adds multiple resource files for the index HTML file to reference. + + At this time, the name cannot be set + """ for x in resources: self.resource(x) return self + def string_resources( + self, + resources: List[Tuple[str, str, Optional[str]]], + ) -> Self: + """ + Process string resources. + + This method takes a list of resource tuples and processes them. + + Args: + resources: A list of resource tuples. + Each tuple contains: + - str: Resource Data - The content or data of the resource. + - str: Resource Filename - The filename of the resource for reference in the index + - Optional[str]: Resource mimetype - The MIME type of the resource, if available. + + Returns: + Self: Returns the instance of the class for method chaining. + + Note: + The third element of each tuple (Resource Mime-Type) is optional. + """ + for resource, name, mime_type in resources: + self._add_in_memory_file(resource, name=name, mime_type=mime_type) + + return self + class HtmlRoute( PagePropertiesMixin, @@ -62,30 +141,87 @@ class UrlRoute( EmulatedMediaMixin, CustomHTTPHeaderMixin, PageOrientMixin, - BaseRoute, + BaseSingleFileResponseRoute, ): """ - https://gotenberg.dev/docs/routes#url-into-pdf-route + Represents the Gotenberg route for converting a URL to a PDF. + + This class inherits from various mixins that provide functionalities such as + - Page properties (margins, size) + - Headers and footers + - Rendering control options + - Console exception handling + - Emulated media type + - Custom HTTP headers + - Page orientation + + See the Gotenberg documentation (https://gotenberg.dev/docs/routes#url-into-pdf-route) + for detailed information on these functionalities. """ def url(self, url: str) -> Self: + """ + Sets the URL to convert to PDF. + + Args: + url (str): The URL of the web page to convert. + + Returns: + UrlRoute: This object itself for method chaining. + """ + self._form_data["url"] = url return self - def _get_files(self) -> ForceMultipartDict: + def _get_all_resources(self) -> ForceMultipartDict: + """ + Returns an empty ForceMultipartDict. + + This route does not require any file uploads, so an empty dictionary + is returned as Gotenberg still requires multipart/form-data + """ return FORCE_MULTIPART class MarkdownRoute(PagePropertiesMixin, HeaderFooterMixin, _RouteWithResources, _FileBasedRoute): """ - https://gotenberg.dev/docs/routes#markdown-files-into-pdf-route + Represents the Gotenberg route for converting Markdown files to a PDF. + + This class inherits from various mixins that provide functionalities such as + - Page properties (margins, size) + - Headers and footers + - Handling file resources + - File-based route behavior + + See the Gotenberg documentation (https://gotenberg.dev/docs/routes#markdown-files-into-pdf-route) + for detailed information on these functionalities. """ def markdown_file(self, markdown_file: Path) -> Self: + """ + Adds a single Markdown file to be converted. + + Args: + markdown_file (Path): The path to the Markdown file. + + Returns: + MarkdownRoute: This object itself for method chaining. + """ + self._add_file_map(markdown_file) + return self def markdown_files(self, markdown_files: List[Path]) -> Self: + """ + Adds multiple Markdown files to be converted. + + Args: + markdown_files (List[Path]): A list of paths to Markdown files. + + Returns: + MarkdownRoute: This object itself for method chaining. + """ for x in markdown_files: self.markdown_file(x) return self @@ -99,56 +235,148 @@ class ScreenshotRoute( ConsoleExceptionMixin, PerformanceModeMixin, PageOrientMixin, - BaseRoute, + BaseSingleFileResponseRoute, ): """ - https://gotenberg.dev/docs/routes#screenshots-route + Represents the Gotenberg route for capturing screenshots. + + This class inherits from various mixins that provide functionalities such as + - Rendering control options + - Emulated media type + - Custom HTTP headers + - Handling invalid status codes from the captured page + - Console exception handling + - Performance mode selection (optimize for speed or size) + - Page orientation + + See the Gotenberg documentation (https://gotenberg.dev/docs/routes#screenshots-route) + for detailed information on these functionalities. """ _QUALITY_MAX = 100 _QUALITY_MIN = 0 + def __init__(self, client: Client, api_route: str) -> None: + super().__init__(client, api_route) + def output_format(self, output_format: Literal["png", "jpeg", "webp"] = "png") -> Self: + """ + Sets the output format for the screenshot. + + Args: + output_format (Literal["png", "jpeg", "webp"], optional): The desired output format. Defaults to "png". + + Returns: + ScreenshotRoute: This object itself for method chaining. + """ + self._form_data.update({"format": output_format}) return self def quality(self, quality: int) -> Self: + """ + Sets the quality of the screenshot (0-100). + + Args: + quality (int): The desired quality level (0-100). + + Returns: + ScreenshotRoute: This object itself for method chaining. + """ + if quality > self._QUALITY_MAX: - logger.warning(f"quality {quality} is above {self._QUALITY_MAX}, resetting to {self._QUALITY_MAX}") + logging.warning(f"quality {quality} is above {self._QUALITY_MAX}, resetting to {self._QUALITY_MAX}") quality = self._QUALITY_MAX elif quality < self._QUALITY_MIN: - logger.warning(f"quality {quality} is below {self._QUALITY_MIN}, resetting to {self._QUALITY_MIN}") + logging.warning(f"quality {quality} is below {self._QUALITY_MIN}, resetting to {self._QUALITY_MIN}") quality = self._QUALITY_MIN + self._form_data.update({"quality": str(quality)}) return self def optimize_speed(self) -> Self: + """ + Sets the optimization mode to prioritize speed. + + Returns: + ScreenshotRoute: This object itself for method chaining. + """ + self._form_data.update({"optimizeForSpeed": "true"}) return self def optimize_size(self) -> Self: + """ + Sets the optimization mode to prioritize size reduction. + + Returns: + ScreenshotRoute: This object itself for method chaining. + """ + self._form_data.update({"optimizeForSpeed": "false"}) return self class ScreenshotRouteUrl(ScreenshotRoute): + """ + Represents the Gotenberg route for capturing screenshots from URLs. + + Inherits from ScreenshotRoute and provides a specific URL-based method. + """ + def url(self, url: str) -> Self: + """ + Sets the URL to capture a screenshot from. + + Args: + url (str): The URL of the web page to capture a screenshot of. + + Returns: + ScreenshotRouteUrl: This object itself for method chaining. + """ + self._form_data.update({"url": url}) return self - def _get_files(self) -> ForceMultipartDict: + def _get_all_resources(self) -> ForceMultipartDict: + """ + Returns an empty ForceMultipartDict. + + This route does not require any file uploads, so an empty dictionary + is returned. + """ return FORCE_MULTIPART class ScreenshotRouteHtml(_FileBasedRoute, _RouteWithResources, ScreenshotRoute): - pass + """ + Represents the Gotenberg route for capturing screenshots from HTML files. + + Inherits from _FileBasedRoute, _RouteWithResources, and ScreenshotRoute, + combining functionalities for file-based operations, resource handling, + and screenshot capture. + """ class ScreenshotRouteMarkdown(_FileBasedRoute, _RouteWithResources, ScreenshotRoute): - pass + """ + Represents the Gotenberg route for capturing screenshots from Markdown files. + + Inherits from _FileBasedRoute, _RouteWithResources, and ScreenshotRoute, + combining functionalities for file-based operations, resource handling, + and screenshot capture. + """ class ChromiumApi(BaseApi): + """ + Represents the Gotenberg API for Chromium-based conversions and screenshots. + + Provides methods to create specific route objects for different conversion and screenshot types. + + https://gotenberg.dev/docs/routes#convert-with-chromium + """ + _URL_CONVERT_ENDPOINT = "/forms/chromium/convert/url" _HTML_CONVERT_ENDPOINT = "/forms/chromium/convert/html" _MARKDOWN_CONVERT_ENDPOINT = "/forms/chromium/convert/markdown" @@ -157,19 +385,61 @@ class ChromiumApi(BaseApi): _SCREENSHOT_MARK_DOWN = "/forms/chromium/screenshot/markdown" def html_to_pdf(self) -> HtmlRoute: + """ + Creates an HtmlRoute object for converting HTML to PDF. + + Returns: + HtmlRoute: A new HtmlRoute object. + """ + return HtmlRoute(self._client, self._HTML_CONVERT_ENDPOINT) def url_to_pdf(self) -> UrlRoute: + """ + Creates a UrlRoute object for converting URLs to PDF. + + Returns: + UrlRoute: A new UrlRoute object. + """ + return UrlRoute(self._client, self._URL_CONVERT_ENDPOINT) def markdown_to_pdf(self) -> MarkdownRoute: + """ + Creates a MarkdownRoute object for converting Markdown to PDF. + + Returns: + MarkdownRoute: A new MarkdownRoute object. + """ + return MarkdownRoute(self._client, self._MARKDOWN_CONVERT_ENDPOINT) def screenshot_url(self) -> ScreenshotRouteUrl: + """ + Creates a ScreenshotRouteUrl object for capturing screenshots from URLs. + + Returns: + ScreenshotRouteUrl: A new ScreenshotRouteUrl object. + """ + return ScreenshotRouteUrl(self._client, self._SCREENSHOT_URL) def screenshot_html(self) -> ScreenshotRouteHtml: + """ + Creates a ScreenshotRouteHtml object for capturing screenshots from HTML files. + + Returns: + ScreenshotRouteHtml: A new ScreenshotRouteHtml object. + """ + return ScreenshotRouteHtml(self._client, self._SCREENSHOT_HTML) def screenshot_markdown(self) -> ScreenshotRouteMarkdown: + """ + Creates a ScreenshotRouteMarkdown object for capturing screenshots from Markdown files. + + Returns: + ScreenshotRouteMarkdown: A new ScreenshotRouteMarkdown object. + """ + return ScreenshotRouteMarkdown(self._client, self._SCREENSHOT_MARK_DOWN) diff --git a/src/gotenberg_client/_convert/common.py b/src/gotenberg_client/_convert/common.py index 4483b80..5f89381 100644 --- a/src/gotenberg_client/_convert/common.py +++ b/src/gotenberg_client/_convert/common.py @@ -6,11 +6,12 @@ from pathlib import Path from typing import Dict from typing import Iterable -from typing import Union from warnings import warn -from gotenberg_client._base import BaseRoute -from gotenberg_client._typing_compat import Self +from gotenberg_client._base import BaseSingleFileResponseRoute +from gotenberg_client._types import PageScaleType +from gotenberg_client._types import Self +from gotenberg_client._types import WaitTimeType from gotenberg_client.options import EmulatedMediaType from gotenberg_client.options import PageMarginsType from gotenberg_client.options import PageOrientation @@ -107,7 +108,7 @@ class ScaleMixin: https://gotenberg.dev/docs/routes#page-properties-chromium """ - def scale(self, scale: Union[int, float]) -> Self: + def scale(self, scale: PageScaleType) -> Self: self._form_data.update({"scale": str(scale)}) # type: ignore[attr-defined,misc] return self @@ -131,7 +132,7 @@ class PagePropertiesMixin( PageRangeMixin, ScaleMixin, SinglePageMixin, - BaseRoute, + BaseSingleFileResponseRoute, ): """ https://gotenberg.dev/docs/routes#page-properties-chromium @@ -144,11 +145,11 @@ class HeaderFooterMixin: """ def header(self, header: Path) -> Self: - self._add_file_map(header, "header.html") # type: ignore[attr-defined] + self._add_file_map(header, name="header.html") # type: ignore[attr-defined] return self def footer(self, footer: Path) -> Self: - self._add_file_map(footer, "footer.html") # type: ignore[attr-defined] + self._add_file_map(footer, name="footer.html") # type: ignore[attr-defined] return self @@ -157,7 +158,7 @@ class RenderControlMixin: https://gotenberg.dev/docs/routes#wait-before-rendering-chromium """ - def render_wait(self, wait: Union[int, float]) -> Self: + def render_wait(self, wait: WaitTimeType) -> Self: self._form_data.update({"waitDelay": str(wait)}) # type: ignore[attr-defined,misc] return self diff --git a/src/gotenberg_client/_convert/libre_office.py b/src/gotenberg_client/_convert/libre_office.py index d97e14d..f1779e1 100644 --- a/src/gotenberg_client/_convert/libre_office.py +++ b/src/gotenberg_client/_convert/libre_office.py @@ -3,30 +3,68 @@ # SPDX-License-Identifier: MPL-2.0 from pathlib import Path from typing import List +from typing import Union + +from httpx import Client from gotenberg_client._base import BaseApi -from gotenberg_client._base import BaseRoute +from gotenberg_client._base import BaseSingleFileResponseRoute from gotenberg_client._convert.common import PageOrientMixin from gotenberg_client._convert.common import PageRangeMixin -from gotenberg_client._typing_compat import Self +from gotenberg_client._types import Self +from gotenberg_client._types import WaitTimeType +from gotenberg_client.responses import SingleFileResponse +from gotenberg_client.responses import ZipFileResponse -class LibreOfficeConvertRoute(PageOrientMixin, PageRangeMixin, BaseRoute): +class LibreOfficeConvertRoute(PageOrientMixin, PageRangeMixin, BaseSingleFileResponseRoute): """ - https://gotenberg.dev/docs/routes#convert-with-libreoffice + Represents the Gotenberg route for converting documents to PDF using LibreOffice. + + This class allows adding single or multiple files for conversion, optionally + merging them into a single PDF. + + See the Gotenberg documentation (https://gotenberg.dev/docs/routes#convert-with-libreoffice) + for detailed information about the supported features. """ - def convert(self, file_path: Path) -> Self: + def __init__(self, client: Client, api_route: str) -> None: + super().__init__(client, api_route) + self._result_is_zip = False + self._convert_calls = 0 + + def convert(self, input_file_path: Path) -> Self: """ - Adds a single file to be converted to PDF. Can be called multiple times, - resulting in a ZIP of the PDFs, unless merged + Adds a single file to be converted to PDF. + + Calling this method multiple times will result in a ZIP containing + individual PDFs for each converted file. + + Args: + input_file_path (Path): The path to the file to be converted. + + Returns: + LibreOfficeConvertRoute: This object itself for method chaining. """ - self._add_file_map(file_path) + + self._add_file_map(input_file_path) + self._convert_calls += 1 + if self._convert_calls > 1: + self._result_is_zip = True return self def convert_files(self, file_paths: List[Path]) -> Self: """ - Adds all provided files for conversion + Adds all provided files for conversion to individual PDFs. + + This method adds all files in the provided list for conversion. By default, + the resulting PDFs will be zipped together in the response. + + Args: + file_paths (List[Path]): A list of paths to the files to be converted. + + Returns: + LibreOfficeConvertRoute: This object itself for method chaining. """ for x in file_paths: self.convert(x) @@ -34,24 +72,74 @@ def convert_files(self, file_paths: List[Path]) -> Self: def merge(self) -> Self: """ - Merge the resulting PDFs into one + Merges the resulting PDFs into a single PDF document. + + This method enables merging previously added files into a single PDF during conversion. + + Returns: + LibreOfficeConvertRoute: This object itself for method chaining. """ + self._form_data.update({"merge": "true"}) + self._result_is_zip = False return self def no_merge(self) -> Self: """ - Don't merge the resulting PDFs + Disables merging of resulting PDFs. + + This method ensures that even when converting multiple files, the results + will be individual PDFs in a ZIP archive. + + Returns: + LibreOfficeConvertRoute: This object itself for method chaining. """ + self._form_data.update({"merge": "false"}) + self._result_is_zip = True return self + def run(self) -> Union[SingleFileResponse, ZipFileResponse]: # type: ignore[override] + resp = super().run() + + if self._result_is_zip: # pragma: no cover + return ZipFileResponse(resp.status_code, resp.headers, resp.content) + return resp + + def run_with_retry( # type: ignore[override] + self, + *, + max_retry_count: int = 5, + initial_retry_wait: WaitTimeType = 5, + retry_scale: WaitTimeType = 2, + ) -> Union[SingleFileResponse, ZipFileResponse]: + resp = super().run_with_retry( + max_retry_count=max_retry_count, + initial_retry_wait=initial_retry_wait, + retry_scale=retry_scale, + ) + + if self._result_is_zip: + return ZipFileResponse(resp.status_code, resp.headers, resp.content) + return resp + class LibreOfficeApi(BaseApi): + """ + Represents the Gotenberg API for LibreOffice-based conversions. + + Provides a method to create a LibreOfficeConvertRoute object for converting + documents to PDF using LibreOffice. + """ + _CONVERT_ENDPOINT = "/forms/libreoffice/convert" def to_pdf(self) -> LibreOfficeConvertRoute: """ - Returns the LibreOffice conversion route + Creates a LibreOfficeConvertRoute object for converting documents to PDF. + + Returns: + LibreOfficeConvertRoute: A new LibreOfficeConvertRoute object. """ + return LibreOfficeConvertRoute(self._client, self._CONVERT_ENDPOINT) diff --git a/src/gotenberg_client/_convert/pdfa.py b/src/gotenberg_client/_convert/pdfa.py index e52595c..deee746 100644 --- a/src/gotenberg_client/_convert/pdfa.py +++ b/src/gotenberg_client/_convert/pdfa.py @@ -5,30 +5,66 @@ from typing import List from gotenberg_client._base import BaseApi -from gotenberg_client._base import BaseRoute -from gotenberg_client._typing_compat import Self +from gotenberg_client._base import BaseSingleFileResponseRoute +from gotenberg_client._types import Self -class PdfAConvertRoute(BaseRoute): +class PdfAConvertRoute(BaseSingleFileResponseRoute): """ - https://gotenberg.dev/docs/routes#convert-into-pdfa-route + Represents the Gotenberg route for converting PDFs to PDF/A format. + + This class allows converting a single or multiple PDF files to the + specified PDF/A format (e.g., PDF/A-1b, PDF/A-2b). + + See the Gotenberg documentation (https://gotenberg.dev/docs/routes#convert-into-pdfa-route) + for details on supported PDF/A formats. """ def convert(self, file_path: Path) -> Self: """ - Convert a single PDF into the provided PDF/A format + Converts a single PDF file to the provided PDF/A format. + + Args: + file_path (Path): The path to the PDF file to be converted. + + Returns: + PdfAConvertRoute: This object itself for method chaining. """ + self._add_file_map(file_path) return self def convert_files(self, file_paths: List[Path]) -> Self: + """ + Converts multiple PDF files to the provided PDF/A format. + + Args: + file_paths (List[Path]): A list of paths to the PDF files to be converted. + + Returns: + PdfAConvertRoute: This object itself for method chaining. + """ + for x in file_paths: self.convert(x) return self class PdfAApi(BaseApi): + """ + Represents the Gotenberg API for PDF/A conversion. + + Provides a method to create a PdfAConvertRoute object for converting PDFs to PDF/A format. + """ + _CONVERT_ENDPOINT = "/forms/pdfengines/convert" def to_pdfa(self) -> PdfAConvertRoute: + """ + Creates a PdfAConvertRoute object for converting PDFs to PDF/A format. + + Returns: + PdfAConvertRoute: A new PdfAConvertRoute object. + """ + return PdfAConvertRoute(self._client, self._CONVERT_ENDPOINT) diff --git a/src/gotenberg_client/_errors.py b/src/gotenberg_client/_errors.py new file mode 100644 index 0000000..aa02269 --- /dev/null +++ b/src/gotenberg_client/_errors.py @@ -0,0 +1,25 @@ +from httpx import Response + + +class BaseClientError(Exception): + """ + Base exception for any errors raised directly by this library + """ + + +class UnreachableCodeError(BaseClientError): + pass + + +class MaxRetriesExceededError(BaseClientError): + """ + Raised if the number of retries exceeded the configured maximum + """ + + def __init__(self, *, response: Response) -> None: + super().__init__() + self.response = response + + +class CannotExtractHereError(BaseClientError): + pass diff --git a/src/gotenberg_client/_health.py b/src/gotenberg_client/_health.py index 3fd87ec..bdac8df 100644 --- a/src/gotenberg_client/_health.py +++ b/src/gotenberg_client/_health.py @@ -5,6 +5,7 @@ import datetime import enum import re +from typing import Final from typing import Optional from typing import TypedDict from typing import no_type_check @@ -118,14 +119,35 @@ def _extract_datetime(timestamp: str) -> datetime.datetime: class HealthCheckApi(BaseApi): """ - Provides the route for health checks + Provides the route for health checks in the Gotenberg API. + + This class encapsulates the functionality to perform health checks on the Gotenberg service. + It inherits from BaseApi, presumably providing common API functionality. + + For more information on Gotenberg's health check endpoint, see: + https://gotenberg.dev/docs/routes#health + """ - _HEALTH_ENDPOINT = "/health" + _HEALTH_ENDPOINT: Final[str] = "/health" def health(self) -> HealthStatus: + """ + Perform a health check on the Gotenberg service. + + This method sends a GET request to the Gotenberg health check endpoint + and returns the parsed health status. + + For more details on the health check API, see: + https://gotenberg.dev/docs/routes#health + + Returns: + HealthStatus: An object representing the current health status of the Gotenberg service. + + Raises: + httpx.HTTPStatusError: If the request to the health check endpoint fails. + """ resp = self._client.get(self._HEALTH_ENDPOINT, headers={"Accept": "application/json"}) resp.raise_for_status() json_data: _HealthCheckApiResponseType = resp.json() - return HealthStatus(json_data) diff --git a/src/gotenberg_client/_merge.py b/src/gotenberg_client/_merge.py index 70ff94e..d08ddf5 100644 --- a/src/gotenberg_client/_merge.py +++ b/src/gotenberg_client/_merge.py @@ -2,32 +2,65 @@ # # SPDX-License-Identifier: MPL-2.0 from pathlib import Path +from typing import Final from typing import List from httpx import Client from gotenberg_client._base import BaseApi -from gotenberg_client._base import BaseRoute +from gotenberg_client._base import BaseZipFileResponseRoute +from gotenberg_client._types import Self -class MergeRoute(BaseRoute): +class MergeRoute(BaseZipFileResponseRoute): """ - Handles the merging of a given set of files + Handles the merging of a given set of PDF files using the Gotenberg API. + + This class provides functionality to merge multiple PDF files into a single PDF. + It inherits from BaseZipFileResponseRoute, presumably providing common API functionality + for routes that return zip files. + + For more information on Gotenberg's merge functionality, see: + https://gotenberg.dev/docs/routes#merge-pdfs-route + + Attributes: + _next (int): A counter used to maintain the order of added files. """ def __init__(self, client: Client, api_route: str) -> None: + """ + Initialize a new MergeRoute instance. + + Args: + client (Client): The HTTP client used to make requests to the Gotenberg API. + api_route (str): The API route for merge operations. + """ super().__init__(client, api_route) self._next = 1 - def merge(self, files: List[Path]) -> "MergeRoute": + def merge(self, files: List[Path]) -> Self: """ - Adds the given files into the file mapping. This method will maintain the - ordering of the list. Calling this method multiple times may not merge - in the expected ordering + Add the given files to the merge operation. + + This method maintains the ordering of the provided list of files. Note that calling + this method multiple times may not result in the expected merge order. + + For more details on merging PDFs with Gotenberg, see: + https://gotenberg.dev/docs/routes#merge-pdfs-route + + Args: + files (List[Path]): A list of Path objects representing the PDF files to be merged. + + Returns: + Self: The instance itself, allowing for method chaining. + + Note: + - The files must be valid PDF documents. + - The order of the files in the list determines the order in the merged PDF. """ for filepath in files: # Include index to enforce ordering - self._add_file_map(filepath, f"{self._next}_{filepath.name}") + self._add_file_map(filepath, name=f"{self._next}_{filepath.name}") self._next += 1 return self @@ -37,7 +70,7 @@ class MergeApi(BaseApi): Wraps the merge route """ - _MERGE_ENDPOINT = "/forms/pdfengines/merge" + _MERGE_ENDPOINT: Final[str] = "/forms/pdfengines/merge" def merge(self) -> MergeRoute: return MergeRoute(self._client, self._MERGE_ENDPOINT) diff --git a/src/gotenberg_client/_types.py b/src/gotenberg_client/_types.py new file mode 100644 index 0000000..ad93072 --- /dev/null +++ b/src/gotenberg_client/_types.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2023-present Trenton H +# +# SPDX-License-Identifier: MPL-2.0 + +import sys +from typing import Literal +from typing import Union + +if sys.version_info >= (3, 11): # pragma: no cover + from typing import Self +else: # pragma: no cover + from typing_extensions import Self # noqa: F401 + +WaitTimeType = Union[float, int] +FormFieldType = Union[bool, int, float, str] +PageSizeType = Union[float, int] +MarginSizeType = Union[float, int] +PageScaleType = Union[float, int] +HttpMethodsType = Literal["POST", "PATCH", "PUT"] diff --git a/src/gotenberg_client/_typing_compat.py b/src/gotenberg_client/_typing_compat.py deleted file mode 100644 index 8ccf339..0000000 --- a/src/gotenberg_client/_typing_compat.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-FileCopyrightText: 2023-present Trenton H -# -# SPDX-License-Identifier: MPL-2.0 - -import sys - -if sys.version_info >= (3, 11): # pragma: no cover - from typing import Self -else: # pragma: no cover - from typing_extensions import Self # noqa: F401 diff --git a/src/gotenberg_client/_utils.py b/src/gotenberg_client/_utils.py index e8494d8..97cfb2e 100644 --- a/src/gotenberg_client/_utils.py +++ b/src/gotenberg_client/_utils.py @@ -8,6 +8,8 @@ from typing import Optional from typing import Union +from gotenberg_client._types import FormFieldType + # See https://github.com/psf/requests/issues/1081#issuecomment-428504128 class ForceMultipartDict(Dict): @@ -15,10 +17,18 @@ def __bool__(self) -> bool: return True -def optional_to_form(value: Optional[Union[bool, int, float, str]], name: str) -> Dict[str, str]: +def optional_to_form(value: Optional[FormFieldType], name: str) -> Dict[str, str]: """ - Quick helper to convert an optional type into a form data field - with the given name or no changes if the value is None + Converts an optional value to a form data field with the given name, + handling None values gracefully. + + Args: + value: The optional value to be converted. + name: The name of the form data field. + + Returns: + A dictionary containing the form data field with the given name and its converted value, + or an empty dictionary if the value is None. """ if value is None: # pragma: no cover return {} @@ -26,23 +36,41 @@ def optional_to_form(value: Optional[Union[bool, int, float, str]], name: str) - return {name: str(value).lower()} -def guess_mime_type_stdlib(url: Path) -> Optional[str]: # pragma: no cover +def guess_mime_type_stdlib(url: Union[str, Path]) -> Optional[str]: # pragma: no cover """ - Uses the standard library to guess a mimetype + Guesses the MIME type of a URL using the standard library. + + Args: + url: The URL to guess the MIME type for. + + Returns: + The guessed MIME type, or None if it could not be determined. """ + import mimetypes - mime_type, _ = mimetypes.guess_type(url) + mime_type, _ = mimetypes.guess_type(str(url)) # Ensure URL is a string return mime_type -def guess_mime_type_magic(url: Path) -> Optional[str]: +def guess_mime_type_magic(url: Union[str, Path]) -> Optional[str]: """ - Uses libmagic to guess the mimetype + Guesses the MIME type of a file using libmagic. + + Args: + url: The path to the file or URL to guess the MIME type for. + + Returns: + The guessed MIME type, or None if it could not be determined. """ - import magic # type: ignore [import-not-found] - return magic.from_file(url, mime=True) # type: ignore [misc] + import magic # type: ignore[import-not-found] + + try: + return magic.from_file(str(url), mime=True) # type: ignore[misc] + except Exception: # pragma: no cover + # Handle libmagic exceptions gracefully + return None # Use the best option diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index 3115b56..1e95668 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -5,56 +5,106 @@ import enum from typing import Dict from typing import Final -from typing import Literal from typing import Optional -from typing import Union -from warnings import warn +from gotenberg_client._types import MarginSizeType +from gotenberg_client._types import PageSizeType from gotenberg_client._utils import optional_to_form @enum.unique class PdfAFormat(enum.Enum): - A1a = enum.auto() + """ + Represents different PDF/A archival formats supported by Gotenberg. + + Documentation: + - https://gotenberg.dev/docs/routes#pdfa-chromium + - https://gotenberg.dev/docs/routes#pdfa-libreoffice + - https://gotenberg.dev/docs/routes#convert-into-pdfa--pdfua-route + - https://gotenberg.dev/docs/routes#merge-pdfs-route + """ + + A1a = enum.auto() # Deprecated format (warning included) A2b = enum.auto() A3b = enum.auto() def to_form(self) -> Dict[str, str]: - format_name = None - if self.value == PdfAFormat.A1a.value: # pragma: no cover - format_name = "PDF/A-1a" - warn("PDF Format PDF/A-1a is deprecated", DeprecationWarning, stacklevel=2) + """ + Converts this PdfAFormat enum value to a dictionary suitable for form data. + + Returns: + A dictionary containing a single key-value pair with the key "pdfa" and the corresponding format name + as the value. + If the format is not supported (e.g., A1a), raises an Exception. + """ + + format_mapping: Final[Dict[PdfAFormat, str]] = { + PdfAFormat.A1a: "PDF/A-1a", # Include deprecated format with warning + PdfAFormat.A2b: "PDF/A-2b", + PdfAFormat.A3b: "PDF/A-3b", + } + + format_name = format_mapping[self] + # Warn about deprecated format usage (ideally move outside this method) + if self is PdfAFormat.A1a: # pragma: no cover + import warnings + + warnings.warn( + "PDF Format PDF/A-1a is deprecated", + DeprecationWarning, + stacklevel=2, + ) return {} - elif self.value == PdfAFormat.A2b.value: - format_name = "PDF/A-2b" - elif self.value == PdfAFormat.A3b.value: - format_name = "PDF/A-3b" - if format_name is not None: - return {"pdfa": format_name} - else: # pragma: no cover - raise NotImplementedError(self.value) + return {"pdfa": format_name} @enum.unique class PageOrientation(enum.Enum): + """ + Represents the possible orientations for a page in Gotenberg. + """ + Landscape = enum.auto() Portrait = enum.auto() def to_form(self) -> Dict[str, str]: - if self.value == PageOrientation.Landscape.value: - return {"landscape": "true"} - elif self.value == PageOrientation.Portrait.value: - return {"landscape": "false"} - else: # pragma: no cover - raise NotImplementedError(self.value) + """ + Converts this PageOrientation enum value to a dictionary suitable for form data. + + Returns: + A dictionary containing a single key-value pair with the key "orientation" + and the corresponding Gotenberg value ("landscape" or "portrait") as the value. + """ + + orientation_mapping: Final[Dict[PageOrientation, Dict[str, str]]] = { + PageOrientation.Landscape: {"landscape": "true"}, + PageOrientation.Portrait: {"landscape": "false"}, + } + + return orientation_mapping[self] @dataclasses.dataclass class PageSize: - width: Optional[Union[float, int]] = None - height: Optional[Union[float, int]] = None + """ + Represents the dimensions of a page in Gotenberg. + + Attributes: + width (Optional[PageSizeType]): The width of the page. + height (Optional[PageSizeType]): The height of the page. + """ + + width: Optional[PageSizeType] = None + height: Optional[PageSizeType] = None def to_form(self) -> Dict[str, str]: + """ + Converts this PageSize object to a dictionary suitable for form data. + + Returns: + A dictionary containing the "paperWidth" and "paperHeight" keys with their corresponding values, + if they are not None. + """ data = optional_to_form(self.width, "paperWidth") data.update(optional_to_form(self.height, "paperHeight")) return data @@ -75,6 +125,19 @@ def to_form(self) -> Dict[str, str]: class MarginUnitType(str, enum.Enum): + """ + Represents the different units of measurement for page margins. + + Attributes: + Undefined: Indicates that no unit is specified. + Points: Represents points (1/72 of an inch). + Pixels: Represents pixels. + Inches: Represents inches. + Millimeters: Represents millimeters. + Centimeters: Represents centimeters. + Percent: Represents a percentage relative to the page size. + """ + Undefined = "none" Points = "pt" Pixels = "px" @@ -86,47 +149,89 @@ class MarginUnitType(str, enum.Enum): @dataclasses.dataclass class MarginType: - value: Union[float, int] + """ + Represents a margin value with a specified unit of measurement. + + Attributes: + value (MarginSizeType): The numerical value of the margin. + unit (MarginUnitType): The unit of measurement for the margin. + """ + + value: MarginSizeType unit: MarginUnitType = MarginUnitType.Undefined + def to_form(self, name: str) -> Dict[str, str]: + """ + Converts this MarginType object to a dictionary suitable for form data. + + Returns: + A dictionary containing the "margin" key with the formatted margin value as the value. + The margin value is formatted as a string with the unit appended. + """ + + if self.unit == MarginUnitType.Undefined: + return optional_to_form(self.value, name) + else: + # Fail to see how mypy thinks this is "Any" + return optional_to_form(f"{self.value}{self.unit.value}", name) # type: ignore[misc] + @dataclasses.dataclass class PageMarginsType: + """ + Represents the margins for a page in Gotenberg. + + Attributes: + top (Optional[MarginType]): The top margin of the page. + bottom (Optional[MarginType]): The bottom margin of the page. + left (Optional[MarginType]): The left margin of the page. + right (Optional[MarginType]): The right margin of the page. + """ + top: Optional[MarginType] = None bottom: Optional[MarginType] = None left: Optional[MarginType] = None right: Optional[MarginType] = None def to_form(self) -> Dict[str, str]: + """ + Converts this PageMarginsType object to a dictionary suitable for form data. + + Returns: + A dictionary containing key-value pairs for each margin property with their corresponding Gotenberg names + (e.g., "marginTop", "marginBottom", etc.) and the formatted margin values as strings. + """ + form_data = {} - values: list[tuple[MarginType | None, str]] = [ - (self.top, "marginTop"), - (self.bottom, "marginBottom"), - (self.left, "marginLeft"), - (self.right, "marginRight"), - ] - for attr, name in values: - if attr is not None: - if attr.unit == MarginUnitType.Undefined: - form_data.update(optional_to_form(attr.value, name)) - else: - form_data.update(optional_to_form(f"{attr.value}{attr.unit.value}", name)) + margin_names = ["marginTop", "marginBottom", "marginLeft", "marginRight"] + + for margin, name in zip([self.top, self.bottom, self.left, self.right], margin_names): + if margin: + form_data.update(margin.to_form(name)) return form_data @enum.unique class EmulatedMediaType(str, enum.Enum): + """ + Represents the different media types Gotenberg can emulate for rendering. + + Attributes: + Print: Emulates print media for print-optimized output. + Screen: Emulates screen media for displaying on screens. + """ + Print = enum.auto() Screen = enum.auto() def to_form(self) -> Dict[str, str]: - if self.value == EmulatedMediaType.Print.value: - return {"emulatedMediaType": "print"} - elif self.value == EmulatedMediaType.Screen.value: - return {"emulatedMediaType": "screen"} - else: # pragma: no cover - raise NotImplementedError(self.value) + """ + Converts this EmulatedMediaType enum value to a dictionary suitable for form data. + Returns: + A dictionary containing a single key-value pair with the key "emulatedMediaType" + and the corresponding Gotenberg value ("print" or "screen") as the value. + """ -HttpMethodsType = Literal["POST", "PATCH", "PUT"] + return {"emulatedMediaType": self.name.lower()} diff --git a/src/gotenberg_client/responses.py b/src/gotenberg_client/responses.py new file mode 100644 index 0000000..8171009 --- /dev/null +++ b/src/gotenberg_client/responses.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2023-present Trenton H +# +# SPDX-License-Identifier: MPL-2.0 +import dataclasses +import zipfile +from functools import cached_property +from io import BytesIO +from pathlib import Path +from typing import Union + +from httpx import Headers + +from gotenberg_client._errors import CannotExtractHereError + + +@dataclasses.dataclass +class _BaseApiResponse: + """ + The basic response from the API, containing the status code and the + response content. This is compatible with the Response used before from + httpx + """ + + status_code: int + headers: Headers + content: Union[bytes, bytearray] + + def to_file(self, file_path: Path) -> None: + """ + Writes the response content to a given file. + """ + file_path.write_bytes(self.content) + + @cached_property + def is_zip(self) -> bool: + return "Content-Type" in self.headers and self.headers["Content-Type"] == "application/zip" + + +@dataclasses.dataclass +class SingleFileResponse(_BaseApiResponse): + pass + + +@dataclasses.dataclass +class ZipFileResponse(_BaseApiResponse): + def extract_to(self, directory: Path) -> None: + """ + Extracts the multiple files of a zip file response into the given directory + """ + if not directory.exists() or not directory.is_dir(): + raise CannotExtractHereError + + with zipfile.ZipFile(BytesIO(self.content), mode="r") as zipref: + zipref.extractall(directory) diff --git a/tests/conftest.py b/tests/conftest.py index 8d95ea0..9cf8c88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,24 +5,136 @@ import os import shutil from pathlib import Path -from typing import Final +from typing import Generator +from typing import Union import pytest from gotenberg_client import GotenbergClient +from gotenberg_client import SingleFileResponse +from gotenberg_client import ZipFileResponse -GOTENBERG_URL: Final[str] = os.getenv("GOTENBERG_URL", "http://localhost:3000") -SAMPLE_DIR: Final[Path] = Path(__file__).parent.resolve() / "samples" -SAVE_DIR: Final[Path] = Path(__file__).parent.resolve() / "outputs" -SAVE_OUTPUTS: Final[bool] = "SAVE_TEST_OUTPUT" in os.environ +@pytest.fixture(scope="session") +def gotenberg_host() -> str: + return os.getenv("GOTENBERG_URL", "http://localhost:3000") -if SAVE_OUTPUTS: - shutil.rmtree(SAVE_DIR, ignore_errors=True) - SAVE_DIR.mkdir() +@pytest.fixture(scope="session") +def web_server_host() -> str: + return os.getenv("WEBSERVER_HOST", "http://localhost:8888") -@pytest.fixture() -def client() -> GotenbergClient: - with GotenbergClient(host=GOTENBERG_URL, log_level=logging.INFO) as client: + +@pytest.fixture(scope="session") +def sample_directory() -> Path: + return Path(__file__).parent.resolve() / "samples" + + +@pytest.fixture(scope="session") +def basic_html_file(sample_directory: Path) -> Path: + return sample_directory / "basic.html" + + +@pytest.fixture(scope="session") +def footer_html_file(sample_directory: Path) -> Path: + return sample_directory / "footer.html" + + +@pytest.fixture(scope="session") +def complex_html_file(sample_directory: Path) -> Path: + return sample_directory / "complex.html" + + +@pytest.fixture(scope="session") +def header_html_file(sample_directory: Path) -> Path: + return sample_directory / "header.html" + + +@pytest.fixture(scope="session") +def img_gif_file(sample_directory: Path) -> Path: + return sample_directory / "img.gif" + + +@pytest.fixture(scope="session") +def font_file(sample_directory: Path) -> Path: + return sample_directory / "font.woff" + + +@pytest.fixture(scope="session") +def css_style_file(sample_directory: Path) -> Path: + return sample_directory / "style.css" + + +@pytest.fixture(scope="session") +def markdown_index_file(sample_directory: Path) -> Path: + return sample_directory / "markdown_index.html" + + +@pytest.fixture(scope="session") +def markdown_sample_one_file(sample_directory: Path) -> Path: + return sample_directory / "markdown1.md" + + +@pytest.fixture(scope="session") +def markdown_sample_two_file(sample_directory: Path) -> Path: + return sample_directory / "markdown2.md" + + +@pytest.fixture(scope="session") +def docx_sample_file(sample_directory: Path) -> Path: + return sample_directory / "sample.docx" + + +@pytest.fixture(scope="session") +def odt_sample_file(sample_directory: Path) -> Path: + return sample_directory / "sample.odt" + + +@pytest.fixture(scope="session") +def xlsx_sample_file(sample_directory: Path) -> Path: + return sample_directory / "sample.xlsx" + + +@pytest.fixture(scope="session") +def ods_sample_file(sample_directory: Path) -> Path: + return sample_directory / "sample.ods" + + +@pytest.fixture(scope="session") +def pdf_sample_one_file(sample_directory: Path) -> Path: + return sample_directory / "sample1.pdf" + + +@pytest.fixture(scope="session") +def output_file_save_directory() -> Path: + return Path(__file__).parent.resolve() / "outputs" + + +@pytest.fixture(scope="session") +def save_output_files(output_file_save_directory: Path) -> bool: + val = True + if val: + shutil.rmtree(output_file_save_directory, ignore_errors=True) + output_file_save_directory.mkdir() + return val + + +@pytest.fixture +def output_saver_factory(request, save_output_files: bool, output_file_save_directory: Path): # noqa: FBT001 + def _save_the_item(response: Union[SingleFileResponse, ZipFileResponse], extra: str = ""): # noqa: ARG001 + if save_output_files: + extension_mapping = { + "application/zip": ".zip", + "application/pdf": ".pdf", + "image/png": ".png", + } + extension = extension_mapping[response.headers["Content-Type"]] + response.to_file(output_file_save_directory / f"{request.node.originalname}{extension}") + + return _save_the_item + + +@pytest.fixture +def client(gotenberg_host: str) -> Generator[GotenbergClient, None, None]: + with GotenbergClient(host=gotenberg_host, log_level=logging.INFO) as client: yield client diff --git a/tests/test_convert_chromium_html.py b/tests/test_convert_chromium_html.py index 94975c6..ed3d1d9 100644 --- a/tests/test_convert_chromium_html.py +++ b/tests/test_convert_chromium_html.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 -import tempfile from pathlib import Path import pikepdf @@ -16,79 +15,113 @@ from gotenberg_client.options import PageMarginsType from gotenberg_client.options import PageOrientation from gotenberg_client.options import PdfAFormat -from tests.conftest import SAMPLE_DIR -from tests.conftest import SAVE_DIR -from tests.conftest import SAVE_OUTPUTS from tests.utils import verify_stream_contains class TestConvertChromiumHtmlRoute: - def test_basic_convert(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "basic.html" - + def test_basic_convert(self, client: GotenbergClient, basic_html_file: Path): with client.chromium.html_to_pdf() as route: - resp = route.index(test_file).run_with_retry() + resp = route.index(basic_html_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_basic_convert.pdf").write_bytes(resp.content) - def test_convert_with_header_footer(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "basic.html" - header_file = SAMPLE_DIR / "header.html" - footer_file = SAMPLE_DIR / "footer.html" + def test_convert_with_header_footer( + self, + client: GotenbergClient, + basic_html_file: Path, + header_html_file: Path, + footer_html_file: Path, + ): + with client.chromium.html_to_pdf() as route: + resp = route.index(basic_html_file).header(header_html_file).footer(footer_html_file).run_with_retry() + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" + def test_convert_additional_files( + self, + client: GotenbergClient, + complex_html_file: Path, + img_gif_file: Path, + font_file: Path, + css_style_file: Path, + ): with client.chromium.html_to_pdf() as route: - resp = route.index(test_file).header(header_file).footer(footer_file).run_with_retry() + resp = ( + route.index(complex_html_file) + .resource(img_gif_file) + .resource(font_file) + .resource(css_style_file) + .run_with_retry() + ) assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - def test_convert_additional_files(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "complex.html" - img = SAMPLE_DIR / "img.gif" - font = SAMPLE_DIR / "font.woff" - style = SAMPLE_DIR / "style.css" + def test_convert_html_from_string(self, client: GotenbergClient, basic_html_file: Path): + html_str = basic_html_file.read_text() with client.chromium.html_to_pdf() as route: - resp = route.index(test_file).resource(img).resource(font).resource(style).run_with_retry() + resp = route.string_index(html_str).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_convert_additional_files.pdf").write_bytes(resp.content) - @pytest.mark.parametrize( ("gt_format", "pike_format"), [(PdfAFormat.A2b, "2B"), (PdfAFormat.A3b, "3B")], ) - def test_convert_pdfa_format(self, client: GotenbergClient, gt_format: PdfAFormat, pike_format: str): - test_file = SAMPLE_DIR / "basic.html" - + def test_convert_pdfa_format( + self, + client: GotenbergClient, + basic_html_file: Path, + tmp_path: Path, + gt_format: PdfAFormat, + pike_format: str, + ): with client.chromium.html_to_pdf() as route: - resp = route.index(test_file).pdf_format(gt_format).run_with_retry() + resp = route.index(basic_html_file).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - with tempfile.TemporaryDirectory() as temp_dir: - output = Path(temp_dir) / "test_convert_pdfa_format.pdf" - output.write_bytes(resp.content) - with pikepdf.open(output) as pdf: - meta = pdf.open_metadata() - assert meta.pdfa_status == pike_format + output = tmp_path / "test_convert_pdfa_format.pdf" + resp.to_file(output) + with pikepdf.open(output) as pdf: + meta = pdf.open_metadata() + assert meta.pdfa_status == pike_format + + def test_convert_additional_file_bytes_io_with_name( + self, + client: GotenbergClient, + complex_html_file: Path, + img_gif_file: Path, + font_file: Path, + css_style_file: Path, + ): + with client.chromium.html_to_pdf() as route: + resp = ( + route.index(complex_html_file) + .resources([img_gif_file, font_file]) + .string_resource(css_style_file.read_text(), name="style.css", mime_type="text/css") + .run_with_retry() + ) + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" class TestConvertChromiumHtmlRouteMocked: - def test_convert_page_size(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_convert_page_size(self, client: GotenbergClient, sample_directory: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST") - test_file = SAMPLE_DIR / "basic.html" + test_file = sample_directory / "basic.html" with client.chromium.html_to_pdf() as route: _ = route.index(test_file).size(A4).run() @@ -97,9 +130,9 @@ def test_convert_page_size(self, client: GotenbergClient, httpx_mock: HTTPXMock) verify_stream_contains("paperWidth", "8.27", request.stream) verify_stream_contains("paperHeight", "11.7", request.stream) - def test_convert_margin(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_convert_margin(self, client: GotenbergClient, sample_directory: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST") - test_file = SAMPLE_DIR / "basic.html" + test_file = sample_directory / "basic.html" with client.chromium.html_to_pdf() as route: _ = ( @@ -121,9 +154,9 @@ def test_convert_margin(self, client: GotenbergClient, httpx_mock: HTTPXMock): verify_stream_contains("marginLeft", "3mm", request.stream) verify_stream_contains("marginRight", "4", request.stream) - def test_convert_render_control(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_convert_render_control(self, client: GotenbergClient, sample_directory: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST") - test_file = SAMPLE_DIR / "basic.html" + test_file = sample_directory / "basic.html" with client.chromium.html_to_pdf() as route: _ = route.index(test_file).render_wait(500.0).run() @@ -138,11 +171,12 @@ def test_convert_render_control(self, client: GotenbergClient, httpx_mock: HTTPX def test_convert_orientation( self, client: GotenbergClient, + sample_directory: Path, httpx_mock: HTTPXMock, orientation: PageOrientation, ): httpx_mock.add_response(method="POST") - test_file = SAMPLE_DIR / "basic.html" + test_file = sample_directory / "basic.html" with client.chromium.html_to_pdf() as route: _ = route.index(test_file).orient(orientation).run() diff --git a/tests/test_convert_chromium_markdown.py b/tests/test_convert_chromium_markdown.py index f6a4ddd..8d4a3c8 100644 --- a/tests/test_convert_chromium_markdown.py +++ b/tests/test_convert_chromium_markdown.py @@ -1,21 +1,60 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 +from pathlib import Path + from httpx import codes from gotenberg_client import GotenbergClient -from tests.conftest import SAMPLE_DIR class TestConvertChromiumUrlRoute: - def test_basic_convert(self, client: GotenbergClient): - index = SAMPLE_DIR / "markdown_index.html" - md_files = [SAMPLE_DIR / "markdown1.md", SAMPLE_DIR / "markdown2.md"] - img = SAMPLE_DIR / "img.gif" - font = SAMPLE_DIR / "font.woff" - style = SAMPLE_DIR / "style.css" + def test_basic_convert( + self, + client: GotenbergClient, + markdown_index_file: Path, + markdown_sample_one_file: Path, + markdown_sample_two_file: Path, + img_gif_file: Path, + font_file: Path, + css_style_file: Path, + ): + with client.chromium.markdown_to_pdf() as route: + resp = ( + route.index(markdown_index_file) + .markdown_files([markdown_sample_one_file, markdown_sample_two_file]) + .resources([img_gif_file, font_file]) + .resource(css_style_file) + .run_with_retry() + ) + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" + + def test_basic_convert_string_references( + self, + client: GotenbergClient, + markdown_index_file: Path, + markdown_sample_one_file: Path, + markdown_sample_two_file: Path, + img_gif_file: Path, + font_file: Path, + css_style_file: Path, + ): with client.chromium.markdown_to_pdf() as route: - resp = route.index(index).markdown_files(md_files).resources([img, font]).resource(style).run_with_retry() + resp = ( + route.index(markdown_index_file) + .string_resources( + [ + (markdown_sample_one_file.read_text(), "markdown1.md", "text/markdown"), + (markdown_sample_two_file.read_text(), "markdown2.md", "text/markdown"), + ], + ) + .resources([img_gif_file, font_file]) + .resource(css_style_file) + .run_with_retry() + ) assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_convert_chromium_screenshots.py b/tests/test_convert_chromium_screenshots.py index f831cb6..4e3619a 100644 --- a/tests/test_convert_chromium_screenshots.py +++ b/tests/test_convert_chromium_screenshots.py @@ -1,149 +1,137 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 +from pathlib import Path +from typing import Literal + import pytest from httpx import codes from gotenberg_client import GotenbergClient -from tests.conftest import SAMPLE_DIR -from tests.conftest import SAVE_DIR -from tests.conftest import SAVE_OUTPUTS class TestChromiumScreenshots: - def test_basic_screenshot(self, client: GotenbergClient): + def test_basic_screenshot(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").run_with_retry() + resp = route.url(web_server_host).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_basic_screenshot.png").write_bytes(resp.content) @pytest.mark.parametrize( "image_format", ["png", "webp", "jpeg"], ) - def test_screenshot_formats(self, client: GotenbergClient, image_format: str): + def test_screenshot_formats( + self, + client: GotenbergClient, + web_server_host: str, + image_format: Literal["png", "webp", "jpeg"], + ): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").output_format(image_format).run_with_retry() + resp = route.url(web_server_host).output_format(image_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == f"image/{image_format}" - if SAVE_OUTPUTS: - (SAVE_DIR / f"test_screenshot_formats.{image_format}").write_bytes(resp.content) - def test_screenshot_quality_valid(self, client: GotenbergClient): + def test_screenshot_quality_valid(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").quality(80).run_with_retry() + resp = route.url(web_server_host).quality(80).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_quality_valid.png").write_bytes(resp.content) - def test_screenshot_quality_too_low(self, client: GotenbergClient): + def test_screenshot_quality_too_low(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").quality(-10).run_with_retry() + resp = route.url(web_server_host).quality(-10).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_quality_too_low.png").write_bytes(resp.content) - def test_screenshot_quality_too_high(self, client: GotenbergClient): + def test_screenshot_quality_too_high(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").quality(101).run_with_retry() + resp = route.url(web_server_host).quality(101).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_quality_too_high.png").write_bytes(resp.content) - def test_screenshot_optimize_speed(self, client: GotenbergClient): + def test_screenshot_optimize_speed(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").optimize_speed().run_with_retry() + resp = route.url(web_server_host).optimize_speed().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_optimize_speed.png").write_bytes(resp.content) - def test_screenshot_optimize_quality(self, client: GotenbergClient): + def test_screenshot_optimize_quality(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").optimize_size().run_with_retry() + resp = route.url(web_server_host).optimize_size().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_optimize_quality.png").write_bytes(resp.content) - def test_network_idle_on(self, client: GotenbergClient): + def test_network_idle_on(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").skip_network_idle().run_with_retry() + resp = route.url(web_server_host).skip_network_idle().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_optimize_quality.png").write_bytes(resp.content) - def test_network_idle_off(self, client: GotenbergClient): + def test_network_idle_off(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").use_network_idle().run_with_retry() + resp = route.url(web_server_host).use_network_idle().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_optimize_quality.png").write_bytes(resp.content) - def test_status_codes(self, client: GotenbergClient): + def test_status_codes(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").fail_on_status_codes([499, 599]).run_with_retry() + resp = route.url(web_server_host).fail_on_status_codes([499, 599]).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_optimize_quality.png").write_bytes(resp.content) - def test_status_codes_empty(self, client: GotenbergClient): + def test_status_codes_empty(self, client: GotenbergClient, web_server_host: str): with client.chromium.screenshot_url() as route: - resp = route.url("http://localhost:8888").fail_on_status_codes([]).run_with_retry() + resp = route.url(web_server_host).fail_on_status_codes([]).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_screenshot_optimize_quality.png").write_bytes(resp.content) class TestChromiumScreenshotsFromMarkdown: - def test_markdown_screenshot(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "basic.html" - md_files = [SAMPLE_DIR / "markdown1.md", SAMPLE_DIR / "markdown2.md"] - + def test_markdown_screenshot( + self, + client: GotenbergClient, + basic_html_file: Path, + markdown_sample_one_file: Path, + markdown_sample_two_file: Path, + ): with client.chromium.screenshot_markdown() as route: - resp = route.index(test_file).resources(md_files).run_with_retry() + resp = ( + route.index(basic_html_file) + .resources([markdown_sample_one_file, markdown_sample_two_file]) + .run_with_retry() + ) assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" class TestChromiumScreenshotsFromHtml: - def test_markdown_screenshot(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "basic.html" - + def test_markdown_screenshot(self, client: GotenbergClient, basic_html_file: Path): with client.chromium.screenshot_html() as route: - resp = route.index(test_file).run_with_retry() + resp = route.index(basic_html_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" diff --git a/tests/test_convert_chromium_url.py b/tests/test_convert_chromium_url.py index dd4ba92..e9d56d9 100644 --- a/tests/test_convert_chromium_url.py +++ b/tests/test_convert_chromium_url.py @@ -13,9 +13,9 @@ class TestConvertChromiumUrlRoute: - def test_basic_convert(self, client: GotenbergClient): + def test_basic_convert(self, client: GotenbergClient, web_server_host: str): with client.chromium.url_to_pdf() as route: - resp = route.url("http://localhost:8888").run_with_retry() + resp = route.url(web_server_host).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -30,13 +30,14 @@ class TestConvertChromiumUrlMocked: def test_convert_orientation( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, emulation: EmulatedMediaType, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").media_type(emulation).run() + _ = route.url(web_server_host).media_type(emulation).run() request = httpx_mock.get_request() verify_stream_contains( @@ -52,13 +53,14 @@ def test_convert_orientation( def test_convert_css_or_not_size( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url("http://localhost:8888") + route.url(web_server_host) getattr(route, method)() _ = route.run() @@ -76,13 +78,14 @@ def test_convert_css_or_not_size( def test_convert_background_graphics_or_not( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url("http://localhost:8888") + route.url(web_server_host) getattr(route, method)() _ = route.run() @@ -100,13 +103,14 @@ def test_convert_background_graphics_or_not( def test_convert_hide_background_or_not( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url("http://localhost:8888") + route.url(web_server_host) getattr(route, method)() _ = route.run() @@ -124,13 +128,14 @@ def test_convert_hide_background_or_not( def test_convert_fail_exceptions( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url("http://localhost:8888") + route.url(web_server_host) getattr(route, method)() _ = route.run() @@ -144,12 +149,13 @@ def test_convert_fail_exceptions( def test_convert_scale( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").scale(1.5).run() + _ = route.url(web_server_host).scale(1.5).run() request = httpx_mock.get_request() verify_stream_contains( @@ -161,12 +167,13 @@ def test_convert_scale( def test_convert_page_ranges( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").page_ranges("1-5").run() + _ = route.url(web_server_host).page_ranges("1-5").run() request = httpx_mock.get_request() verify_stream_contains( @@ -178,12 +185,13 @@ def test_convert_page_ranges( def test_convert_url_render_wait( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").render_wait(500).run() + _ = route.url(web_server_host).render_wait(500).run() request = httpx_mock.get_request() verify_stream_contains( @@ -195,12 +203,13 @@ def test_convert_url_render_wait( def test_convert_url_render_expression( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").render_expr("wait while false;").run() + _ = route.url(web_server_host).render_expr("wait while false;").run() request = httpx_mock.get_request() verify_stream_contains( @@ -209,15 +218,17 @@ def test_convert_url_render_expression( request.stream, ) + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_convert_url_user_agent( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").user_agent("Firefox").run() + _ = route.url(web_server_host).user_agent("Firefox").run() request = httpx_mock.get_request() verify_stream_contains( @@ -229,6 +240,7 @@ def test_convert_url_user_agent( def test_convert_url_headers( self, client: GotenbergClient, + web_server_host: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") @@ -236,7 +248,7 @@ def test_convert_url_headers( headers = {"X-Auth-Token": "Secure"} with client.chromium.url_to_pdf() as route: - _ = route.url("http://localhost:8888").headers(headers).run() + _ = route.url(web_server_host).headers(headers).run() request = httpx_mock.get_request() verify_stream_contains( diff --git a/tests/test_convert_libre_office.py b/tests/test_convert_libre_office.py index f61b0f5..5b71ce4 100644 --- a/tests/test_convert_libre_office.py +++ b/tests/test_convert_libre_office.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 -import tempfile from pathlib import Path from unittest.mock import patch @@ -10,103 +9,109 @@ from httpx import codes from gotenberg_client import GotenbergClient +from gotenberg_client import SingleFileResponse +from gotenberg_client import ZipFileResponse from gotenberg_client._utils import guess_mime_type_stdlib from gotenberg_client.options import PdfAFormat -from tests.conftest import SAMPLE_DIR -from tests.conftest import SAVE_DIR -from tests.conftest import SAVE_OUTPUTS class TestLibreOfficeConvert: - def test_libre_office_convert_docx_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.docx" + def test_libre_office_convert_docx_format(self, client: GotenbergClient, docx_sample_file: Path): with client.libre_office.to_pdf() as route: - resp = route.convert(test_file).run_with_retry() + resp = route.convert(docx_sample_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_docx_format.pdf").write_bytes(resp.content) - - def test_libre_office_convert_odt_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.odt" + def test_libre_office_convert_docx_format_for_coverage( + self, + client: GotenbergClient, + docx_sample_file: Path, + ): with client.libre_office.to_pdf() as route: - resp = route.convert(test_file).run_with_retry() + try: + resp = route.convert(docx_sample_file).run() + except: # noqa: E722 - this is only for coverage + return assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_odt_format.pdf").write_bytes(resp.content) - - def test_libre_office_convert_xlsx_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.xlsx" + def test_libre_office_convert_odt_format(self, client: GotenbergClient, odt_sample_file: Path): with client.libre_office.to_pdf() as route: - resp = route.convert(test_file).run_with_retry() + resp = route.convert(odt_sample_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_xlsx_format.pdf").write_bytes(resp.content) - - def test_libre_office_convert_ods_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.ods" + def test_libre_office_convert_xlsx_format(self, client: GotenbergClient, xlsx_sample_file: Path): with client.libre_office.to_pdf() as route: - resp = route.convert(test_file).run_with_retry() + resp = route.convert(xlsx_sample_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_ods_format.pdf").write_bytes(resp.content) + def test_libre_office_convert_ods_format(self, client: GotenbergClient, ods_sample_file: Path): + with client.libre_office.to_pdf() as route: + resp = route.convert(ods_sample_file).run_with_retry() + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" - def test_libre_office_convert_multiples_format(self, client: GotenbergClient): + def test_libre_office_convert_multiples_format_no_merge( + self, + client: GotenbergClient, + docx_sample_file: Path, + odt_sample_file: Path, + tmp_path: Path, + ): with client.libre_office.to_pdf() as route: - resp = ( - route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).no_merge().run_with_retry() - ) + resp = route.convert_files([docx_sample_file, odt_sample_file]).no_merge().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/zip" + assert isinstance(resp, ZipFileResponse) + assert resp.is_zip + + resp.extract_to(tmp_path) - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_multiples_format.zip").write_bytes(resp.content) + assert len(list(tmp_path.iterdir())) == 2 - def test_libre_office_convert_multiples_format_merged(self, client: GotenbergClient): + def test_libre_office_convert_multiples_format_merged( + self, + client: GotenbergClient, + docx_sample_file: Path, + odt_sample_file: Path, + ): with client.libre_office.to_pdf() as route: - resp = route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).merge().run_with_retry() + resp = route.convert_files([docx_sample_file, odt_sample_file]).merge().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" + assert isinstance(resp, SingleFileResponse) - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_multiples_format.zip").write_bytes(resp.content) - - def test_libre_office_convert_std_lib_mime(self, client: GotenbergClient): + def test_libre_office_convert_std_lib_mime( + self, + client: GotenbergClient, + docx_sample_file: Path, + odt_sample_file: Path, + ): with patch("gotenberg_client._utils.guess_mime_type") as mocked_guess_mime_type: mocked_guess_mime_type.side_effect = guess_mime_type_stdlib with client.libre_office.to_pdf() as route: - resp = ( - route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]) - .no_merge() - .run_with_retry() - ) + resp = route.convert_files([docx_sample_file, odt_sample_file]).no_merge().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/zip" - if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_multiples_format.zip").write_bytes(resp.content) - @pytest.mark.parametrize( ("gt_format", "pike_format"), [(PdfAFormat.A2b, "2B"), (PdfAFormat.A3b, "3B")], @@ -114,23 +119,20 @@ def test_libre_office_convert_std_lib_mime(self, client: GotenbergClient): def test_libre_office_convert_xlsx_format_pdfa( self, client: GotenbergClient, + xlsx_sample_file: Path, + tmp_path: Path, gt_format: PdfAFormat, pike_format: str, ): - test_file = SAMPLE_DIR / "sample.xlsx" with client.libre_office.to_pdf() as route: - resp = route.convert(test_file).pdf_format(gt_format).run_with_retry() + resp = route.convert(xlsx_sample_file).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - with tempfile.TemporaryDirectory() as temp_dir: - output = Path(temp_dir) / "test_libre_office_convert_xlsx_format_pdfa.pdf" - output.write_bytes(resp.content) - with pikepdf.open(output) as pdf: - meta = pdf.open_metadata() - assert meta.pdfa_status == pike_format - - if SAVE_OUTPUTS: - (SAVE_DIR / f"test_libre_office_convert_xlsx_format_{pike_format}.pdf").write_bytes(resp.content) + output = tmp_path / "test_libre_office_convert_xlsx_format_pdfa.pdf" + resp.to_file(output) + with pikepdf.open(output) as pdf: + meta = pdf.open_metadata() + assert meta.pdfa_status == pike_format diff --git a/tests/test_convert_pdf_a.py b/tests/test_convert_pdf_a.py index 03da8a1..11d2880 100644 --- a/tests/test_convert_pdf_a.py +++ b/tests/test_convert_pdf_a.py @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 -import tempfile from pathlib import Path import pikepdf @@ -10,9 +9,6 @@ from gotenberg_client import GotenbergClient from gotenberg_client.options import PdfAFormat -from tests.conftest import SAMPLE_DIR -from tests.conftest import SAVE_DIR -from tests.conftest import SAVE_OUTPUTS class TestPdfAConvert: @@ -23,51 +19,50 @@ class TestPdfAConvert: def test_pdf_a_single_file( self, client: GotenbergClient, + pdf_sample_one_file: Path, + tmp_path: Path, gt_format: PdfAFormat, pike_format: str, ): - test_file = SAMPLE_DIR / "sample1.pdf" with client.pdf_a.to_pdfa() as route: - resp = route.convert(test_file).pdf_format(gt_format).run_with_retry() + resp = route.convert(pdf_sample_one_file).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - with tempfile.TemporaryDirectory() as temp_dir: - output = Path(temp_dir) / "test_libre_office_convert_xlsx_format_pdfa.pdf" - output.write_bytes(resp.content) - with pikepdf.open(output) as pdf: - meta = pdf.open_metadata() - assert meta.pdfa_status == pike_format - - if SAVE_OUTPUTS: - (SAVE_DIR / f"test_pdf_a_single_file{pike_format}.pdf").write_bytes(resp.content) + output = tmp_path / "test_libre_office_convert_xlsx_format_pdfa.pdf" + resp.to_file(output) + with pikepdf.open(output) as pdf: + meta = pdf.open_metadata() + assert meta.pdfa_status == pike_format @pytest.mark.parametrize("gt_format", [PdfAFormat.A2b, PdfAFormat.A3b]) def test_pdf_a_multiple_file( self, client: GotenbergClient, + pdf_sample_one_file: Path, + tmp_path: Path, gt_format: PdfAFormat, ): - with tempfile.TemporaryDirectory() as temp_dir: - test_file = SAMPLE_DIR / "sample1.pdf" - other_test_file = Path(temp_dir) / "sample2.pdf" - other_test_file.write_bytes(test_file.read_bytes()) - with client.pdf_a.to_pdfa() as route: - resp = route.convert_files([test_file, other_test_file]).pdf_format(gt_format).run_with_retry() + other_test_file = tmp_path / "sample2.pdf" + other_test_file.write_bytes(pdf_sample_one_file.read_bytes()) + with client.pdf_a.to_pdfa() as route: + resp = route.convert_files([pdf_sample_one_file, other_test_file]).pdf_format(gt_format).run_with_retry() - assert resp.status_code == codes.OK - assert "Content-Type" in resp.headers - assert resp.headers["Content-Type"] == "application/zip" + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/zip" def test_pdf_universal_access_enable( self, client: GotenbergClient, + pdf_sample_one_file: Path, ): - test_file = SAMPLE_DIR / "sample1.pdf" with client.pdf_a.to_pdfa() as route: - resp = route.convert(test_file).pdf_format(PdfAFormat.A2b).enable_universal_access().run_with_retry() + resp = ( + route.convert(pdf_sample_one_file).pdf_format(PdfAFormat.A2b).enable_universal_access().run_with_retry() + ) assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -76,10 +71,15 @@ def test_pdf_universal_access_enable( def test_pdf_universal_access_disable( self, client: GotenbergClient, + pdf_sample_one_file: Path, ): - test_file = SAMPLE_DIR / "sample1.pdf" with client.pdf_a.to_pdfa() as route: - resp = route.convert(test_file).pdf_format(PdfAFormat.A2b).disable_universal_access().run_with_retry() + resp = ( + route.convert(pdf_sample_one_file) + .pdf_format(PdfAFormat.A2b) + .disable_universal_access() + .run_with_retry() + ) assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_merge.py b/tests/test_merge.py index 67fdbc8..4eb1862 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MPL-2.0 import shutil -import tempfile from pathlib import Path import pikepdf @@ -11,9 +10,6 @@ from gotenberg_client import GotenbergClient from gotenberg_client.options import PdfAFormat -from tests.conftest import SAMPLE_DIR -from tests.conftest import SAVE_DIR -from tests.conftest import SAVE_OUTPUTS from tests.utils import extract_text @@ -25,12 +21,14 @@ class TestMergePdfs: def test_merge_files_pdf_a( self, client: GotenbergClient, + sample_directory: Path, + tmp_path: Path, gt_format: PdfAFormat, pike_format: str, ): with client.merge.merge() as route: resp = ( - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) + route.merge([sample_directory / "z_first_merge.pdf", sample_directory / "a_merge_second.pdf"]) .pdf_format( gt_format, ) @@ -40,19 +38,17 @@ def test_merge_files_pdf_a( assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - with tempfile.TemporaryDirectory() as temp_dir: - output = Path(temp_dir) / "test_merge_files_pdf_a.pdf" - output.write_bytes(resp.content) - with pikepdf.open(output) as pdf: - meta = pdf.open_metadata() - assert meta.pdfa_status == pike_format - - if SAVE_OUTPUTS: - (SAVE_DIR / f"test_libre_office_convert_xlsx_format_{pike_format}.pdf").write_bytes(resp.content) + output = tmp_path / "test_merge_files_pdf_a.pdf" + resp.to_file(output) + with pikepdf.open(output) as pdf: + meta = pdf.open_metadata() + assert meta.pdfa_status == pike_format def test_merge_multiple_file( self, client: GotenbergClient, + sample_directory: Path, + tmp_path: Path, ): if shutil.which("pdftotext") is None: # pragma: no cover pytest.skip("No pdftotext executable found") @@ -60,22 +56,19 @@ def test_merge_multiple_file( with client.merge.merge() as route: # By default, these would not merge correctly, as it happens alphabetically resp = route.merge( - [SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"], + [sample_directory / "z_first_merge.pdf", sample_directory / "a_merge_second.pdf"], ).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - with tempfile.NamedTemporaryFile(mode="wb") as tmp: - tmp.write(resp.content) - - text = extract_text(Path(tmp.name)) - lines = text.split("\n") - # Extra is empty line - assert len(lines) == 3 - assert "first PDF to be merged." in lines[0] - assert "second PDF to be merged." in lines[1] + out_file = tmp_path / "test.pdf" + resp.to_file(out_file) - if SAVE_OUTPUTS: - (SAVE_DIR / "test_pdf_a_multiple_file.pdf").write_bytes(resp.content) + text = extract_text(out_file) + lines = text.split("\n") + # Extra is empty line + assert len(lines) == 3 + assert "first PDF to be merged." in lines[0] + assert "second PDF to be merged." in lines[1] diff --git a/tests/test_misc_stuff.py b/tests/test_misc_stuff.py index 04eb523..5ef624b 100644 --- a/tests/test_misc_stuff.py +++ b/tests/test_misc_stuff.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MPL-2.0 import shutil -import tempfile import uuid from json import dumps from json import loads @@ -14,19 +13,22 @@ from httpx import codes from pytest_httpx import HTTPXMock +from gotenberg_client import CannotExtractHereError from gotenberg_client import GotenbergClient -from tests.conftest import SAMPLE_DIR +from gotenberg_client import MaxRetriesExceededError +from gotenberg_client import ZipFileResponse class TestMiscFunctionality: def test_trace_id_header( self, client: GotenbergClient, + sample_directory: Path, ): trace_id = str(uuid.uuid4()) with client.merge.merge() as route: resp = ( - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) + route.merge([sample_directory / "z_first_merge.pdf", sample_directory / "a_merge_second.pdf"]) .trace( trace_id, ) @@ -42,11 +44,12 @@ def test_trace_id_header( def test_output_filename( self, client: GotenbergClient, + sample_directory: Path, ): filename = "my-cool-file" with client.merge.merge() as route: resp = ( - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) + route.merge([sample_directory / "z_first_merge.pdf", sample_directory / "a_merge_second.pdf"]) .output_name( filename, ) @@ -59,30 +62,37 @@ def test_output_filename( assert "Content-Disposition" in resp.headers assert f"{filename}.pdf" in resp.headers["Content-Disposition"] - def test_libre_office_convert_cyrillic(self, client: GotenbergClient): + def test_libre_office_convert_cyrillic(self, client: GotenbergClient, odt_sample_file: Path, tmp_path: Path): """ Gotenberg versions before 8.0.0 could not internally handle filenames with non-ASCII characters. This replicates such a thing against 1 endpoint to verify the workaround inside this library """ - test_file = SAMPLE_DIR / "sample.odt" + copy = shutil.copy( + odt_sample_file, + tmp_path / "Карточка партнера Тауберг Альфа.odt", # noqa: RUF001 + ) - with tempfile.TemporaryDirectory() as temp_dir: - copy = shutil.copy( - test_file, - Path(temp_dir) / "Карточка партнера Тауберг Альфа.odt", # noqa: RUF001 - ) - - with client.libre_office.to_pdf() as route: - resp = route.convert(copy).run_with_retry() + with client.libre_office.to_pdf() as route: + resp = route.convert(copy).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" + def test_extract_to_not_existing(self) -> None: + resp = ZipFileResponse(200, {}, b"") + + output = Path("does-not-exist") + + assert not output.exists() + + with pytest.raises(CannotExtractHereError): + resp.extract_to(output) + class TestServerErrorRetry: - def test_server_error_retry(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_server_error_retry(self, client: GotenbergClient, basic_html_file: Path, httpx_mock: HTTPXMock): # Response 1 httpx_mock.add_response(method="POST", status_code=codes.INTERNAL_SERVER_ERROR) # Response 2 @@ -94,35 +104,30 @@ def test_server_error_retry(self, client: GotenbergClient, httpx_mock: HTTPXMock # Response 5 httpx_mock.add_response(method="POST", status_code=codes.SERVICE_UNAVAILABLE) - test_file = SAMPLE_DIR / "basic.html" - with client.chromium.html_to_pdf() as route: - with pytest.raises(HTTPStatusError) as exc_info: - _ = route.index(test_file).run_with_retry(initial_retry_wait=0.1, retry_scale=0.1) + with pytest.raises(MaxRetriesExceededError) as exc_info: + _ = route.index(basic_html_file).run_with_retry(initial_retry_wait=0.1, retry_scale=0.1) assert exc_info.value.response.status_code == codes.SERVICE_UNAVAILABLE - def test_not_a_server_error(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_not_a_server_error(self, client: GotenbergClient, basic_html_file: Path, httpx_mock: HTTPXMock): # Response 1 httpx_mock.add_response(method="POST", status_code=codes.NOT_FOUND) - test_file = SAMPLE_DIR / "basic.html" - with client.chromium.html_to_pdf() as route: with pytest.raises(HTTPStatusError) as exc_info: - _ = route.index(test_file).run_with_retry(initial_retry_wait=0.1, retry_scale=0.1) + _ = route.index(basic_html_file).run_with_retry(initial_retry_wait=0.1, retry_scale=0.1) assert exc_info.value.response.status_code == codes.NOT_FOUND class TestWebhookHeaders: - def test_webhook_basic_headers(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_webhook_basic_headers(self, client: GotenbergClient, basic_html_file: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST", status_code=codes.OK) client.add_webhook_url("http://myapi:3000/on-success") client.add_error_webhook_url("http://myapi:3000/on-error") - test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: - _ = route.index(test_file).run_with_retry() + _ = route.index(basic_html_file).run_with_retry() requests = httpx_mock.get_requests() @@ -135,17 +140,16 @@ def test_webhook_basic_headers(self, client: GotenbergClient, httpx_mock: HTTPXM assert "Gotenberg-Webhook-Error-Url" in request.headers assert request.headers["Gotenberg-Webhook-Error-Url"] == "http://myapi:3000/on-error" - def test_webhook_http_methods(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_webhook_http_methods(self, client: GotenbergClient, basic_html_file: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST", status_code=codes.OK) client.add_webhook_url("http://myapi:3000/on-success") client.set_webhook_http_method("POST") client.add_error_webhook_url("http://myapi:3000/on-error") - client.set_error_webhook_http_method("GET") + client.set_error_webhook_http_method("PATCH") - test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: - _ = route.index(test_file).run_with_retry() + _ = route.index(basic_html_file).run_with_retry() requests = httpx_mock.get_requests() @@ -156,9 +160,9 @@ def test_webhook_http_methods(self, client: GotenbergClient, httpx_mock: HTTPXMo assert "Gotenberg-Webhook-Method" in request.headers assert request.headers["Gotenberg-Webhook-Method"] == "POST" assert "Gotenberg-Webhook-Error-Method" in request.headers - assert request.headers["Gotenberg-Webhook-Error-Method"] == "GET" + assert request.headers["Gotenberg-Webhook-Error-Method"] == "PATCH" - def test_webhook_extra_headers(self, client: GotenbergClient, httpx_mock: HTTPXMock): + def test_webhook_extra_headers(self, client: GotenbergClient, basic_html_file: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST", status_code=codes.OK) headers = {"Token": "mytokenvalue"} @@ -166,9 +170,8 @@ def test_webhook_extra_headers(self, client: GotenbergClient, httpx_mock: HTTPXM client.set_webhook_extra_headers(headers) - test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: - _ = route.index(test_file).run_with_retry() + _ = route.index(basic_html_file).run_with_retry() requests = httpx_mock.get_requests() diff --git a/tests/utils.py b/tests/utils.py index 22c8b49..53f6e45 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -11,12 +11,15 @@ from httpx._multipart import MultipartStream -def verify_stream_contains(key: str, value: str, stream: MultipartStream): +def verify_stream_contains(key: str, value: str, stream: MultipartStream) -> None: for item in stream.fields: if isinstance(item, FileField): continue elif isinstance(item, DataField) and item.name == key: - assert item.value == value, f"Key {item.value} /= {value}" + actual_value = item.value + if isinstance(actual_value, bytes): + actual_value = actual_value.decode("utf-8") + assert actual_value == value, f"Key '{actual_value}' /= {value}" return msg = f'Key "{key}" with value "{value}" not found in stream' @@ -33,8 +36,8 @@ def extract_text(pdf_path: Path) -> str: with tempfile.NamedTemporaryFile( mode="w+", ) as tmp: - subprocess.run( - [ # noqa: S603 + subprocess.run( # noqa: S603 + [ pdf_to_text, "-q", "-layout",