From e91c0dced3818d2955252a63a605f1cf1a1e83b0 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 21 Dec 2023 07:37:57 -0800 Subject: [PATCH 1/9] Adds a probably versioned documentation website built with Material for MkDocs --- .github/dependabot.yml | 1 - .github/workflows/ci.yml | 43 ++++++++++++++++++- .gitignore | 4 ++ .pre-commit-config.yaml | 2 + CHANGELOG.md | 6 +++ README.md | 2 +- docs/.override/main.html | 8 ++++ docs/changelog.md | 80 +++++++++++++++++++++++++++++++++++ docs/index.md | 78 ++++++++++++++++++++++++++++++++++ docs/roadmap.md | 6 +++ docs/usage.md | 91 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 78 ++++++++++++++++++++++++++++++++++ pyproject.toml | 23 +++++++++- tests/utils.py | 4 +- 14 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 docs/.override/main.html create mode 100644 docs/changelog.md create mode 100644 docs/index.md create mode 100644 docs/roadmap.md create mode 100644 docs/usage.md create mode 100644 mkdocs.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e47ec0..5da91f4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,6 @@ updates: # Enable version updates for Python - package-ecosystem: "pip" target-branch: "develop" - # Look for a `Pipfile` in the `root` directory directory: "/" # Check for updates once a week schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa3ceab..3b0faae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,7 +172,48 @@ jobs: path: dist/* if-no-files-found: error retention-days: 7 - + documentation: + name: Documentation + runs-on: ubuntu-latest + permissions: + contents: write + needs: + - lint + steps: + - + uses: actions/checkout@v4 + - + name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: 'pip' + - + name: Install Hatch + run: | + pip3 --quiet install --upgrade hatch + - + name: Build Documentation + run: | + hatch run docs:build + - + uses: actions/upload-artifact@v3 + with: + name: documentation + path: site/* + if-no-files-found: error + retention-days: 7 + - + name: Configure Git user + if: startsWith(github.ref, 'refs/tags/') + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + - + name: Deploy documentation + if: startsWith(github.ref, 'refs/tags/') + run: | + hatch run docs:deploy ${{ github.ref_name }} create-release: name: Release runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 79740f8..36fa998 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ venv/ # Possible PDF generated from testing tests/outputs/** + +# Documentation site +site/ +.cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60646ef..32c058b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,8 @@ repos: - id: check-json exclude: "tsconfig.*json" - id: check-yaml + args: + - "--unsafe" - id: check-toml - id: check-executables-have-shebangs - id: end-of-file-fixer diff --git a/CHANGELOG.md b/CHANGELOG.md index e37b036..496d4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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] + +### Added + +- Documentation site built with Github Pages and Material for MkDocs + ## [0.4.1] - 2023-12-11 ### Fixed diff --git a/README.md b/README.md index fd6e2c1..8ef4d16 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ from gotenberg_client import GotenbergClient from gotenberg_client.options import PageOrientation with GotenbergClient("http://localhost:3000") as client: - with client.chromium.html_to_pdf() as route: + 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) ``` diff --git a/docs/.override/main.html b/docs/.override/main.html new file mode 100644 index 0000000..0af326a --- /dev/null +++ b/docs/.override/main.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block outdated %} + You're not viewing the latest version. + + Click here to go to latest. + +{% endblock %} diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..496d4b0 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,80 @@ +# Changelog + +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] + +### Added + +- Documentation site built with Github Pages and Material for MkDocs + +## [0.4.1] - 2023-12-11 + +### Fixed + +- Implemented an internal workaround for older Gotenberg versions and their handling of non-latin filenames. + - When detected, the files will be copied into a temporary directory and the filename cleaned + - Gotenberg 8.0.0 will start implementing something similar once released +- The pulled Gotenberg image is now inspected, allowing local re-creation of failures against specific digests +- The `:edge` tag testing is now allowed to fail + +## [0.4.0] - 2023-12-04 + +### Changed + +- Removed some certain special cases from coverage +- Updated `pre-commit` hook versions +- Updated how pytest is configured, so it will apply to any invocation +- Updated test running image to log at warning or lower using text format +- Updated test running image from 7.9.2 to 7.10.1 +- For the moment, send both `pdfa` and `pdfFormat` for compatibility with 7.9 and 7.10 + - See [here](https://github.com/stumpylog/gotenberg-client/issues/5#issuecomment-1839081129) for some subtle differences in what these options mean + +### Added + +- Added new test job against Gotenberg's `:edge` tag + +## [0.3.0] - 2023-10-17 + +### Added + +- Support for the output filename and request tracing for all routes + +### Removed + +- References to compression and Brotli. Gotenberg doesn't seem to ever compress response data + +### Fixed + +- An issue with the sorting of merging PDFs. Expanded testing to cover the merged ordering + +### Changed + +- Multiple merge calls on the same route will maintain the ordering of all files, rather than just per merge call + +## [0.2.0] - 2023-10-16 + +### Added + +- CodeQL scanning via GitHub +- Codecov.io coverage shield + +### Changed + +- Updated pypa/gh-action-pypi-publish from 1.8.8 to 1.8.10 +- Updated actions/checkout from 3 to 4 +- Mis-spelled `gotenerg_url` for a `Client` is now `host` and no longer keyword only + +## [0.1.0] - 2023-10-15 + +### Added + +- Chromium conversion routes +- LibreOffice conversion routes +- PDF/A conversion route +- PDF merge route +- Health status route +- Testing and typing all setup and passing diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1407e74 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,78 @@ +# Gotenberg Python Client + +This is a Python client for interfacing with [Gotenberg](https://gotenberg.dev/), which in turn is a wrapper around +powerful tools for PDF generation and creation in various ways, using a stateless API. It's a very powerful tool +to generate and manipulate PDFs. + +## Features + +- HTTP/2 enabled by default +- Abstract away the handling of multi-part/form-data and deal with `Path`s instead +- Based on the modern [httpx](https://github.com/encode/httpx) library +- Full support for type hinting and concrete return types as much as possible +- Nearly full test coverage run against an actual Gotenberg server for multiple Python and PyPy versions + +## Examples + +Converting a single HTML file into a PDF: + +```python +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) +``` + +Converting an HTML file with additional resources into a PDF: + +```python +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) +``` + +Converting an HTML file with additional resources into a PDF/A1a format: + +```python +from gotenberg_client import GotenbergClient +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.A1a).run() + Path("my-index.pdf").write_bytes(response.content) +``` + +Converting a URL into PDF, in landscape format + +```python +from gotenberg_client import GotenbergClient +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) +``` + +To ensure the proper clean up of all used resources, both the client and the route(s) should be +used as context manager. If for some reason you cannot, you should `.close` the client and any +routes: + +```python +from gotenberg_client import GotenbergClient + +try: + client = GotenbergClient("http://localhost:3000") + try: + route = client.merge(["myfile.pdf", "otherfile.pdf"]).run() + finally: + route.close() +finally: + client.close() +``` diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..eafbe90 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,6 @@ +# Roadmap + +## Webhooks + +Implement adding the [webhooks](https://gotenberg.dev/docs/webhook) via headers as defined in the +documentation. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..accffe9 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,91 @@ +# Usage + +## Installation + +```console +pip install gotenberg-client +``` + +## How + +All the routes and options from the Gotenberg routes are implemented, with the exception of the Prometheus metrics +endpoint. All the routes use the same format and general idea. + +1. First, you add the file or files you want to process +1. Then, configure the endpoint with its various options the route supports +1. Finally, run the route and receive your resulting file + +- 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 + +## Client + +First, you obtain a `GotenbergClient`. As seen below, the host +where Gotenberg can be found is required, with optional configuration of +global timeouts, the log level (for this library and httpx/httpcore) as +well as control over the usage of HTTP/2. + +```python +class GotenbergClient: + + def __init__( + self, + host: str, + *, + timeout: float = 30.0, + log_level: int = logging.ERROR, + http2: bool = True, + ): + .... +``` + +The client should live as long as you will be communicating with Gotenberg as +this allows the connection to remain open, saving some time to re-negotiate +a connection. + +To ensure proper cleanup of connection, it is suggested to use the client as +a context manager. If not using as a context manager, the user should call +`.close()`, preferably inside a `finally` block. + +## Routes + +The library supports almost all the [routes](https://gotenberg.dev/docs/routes) +defined by the Gotenberg API. Only the Prometheus metrics endpoint is not +implemented. + +To utilize a route, you first select the module which provides it, then the +actual operation to carry out. For example, using Chromium to convert +HTML into a PDF would look like this: + +```python +with GotenbergClient("http://localhost:3000") as client: + with client.chromium.html_to_pdf() as route: + .... +``` + +The exact options of each route vary, according to the Gotenberg documentation. Many routes +share some common options, such as controlling page size or setting the PDF/A format output. + +Configuration of a route will always return the the route, allowing chaining of configuration, +as seen here: + +```python +from gotenberg_client import GotenbergClient +from gotenberg_client.options import A4 + +with GotenbergClient("http://localhost:3000") as client: + with client.chromium.markdown_to_pdf() as route: + response = ( + route.index("main.html") + .markdown_file("readme.md") + .size(A4) + .resource("styles.css") + .fail_on_exceptions() + .run() + ) +``` + +Once all configuration is completed, call `.run()`. This actually sends the information to +Gotenberg with all form data as has been configured. At the moment, it returns the full +[`httpx.Response`](https://www.python-httpx.org/api/#response), with the content of the response +being the resulting PDF or zip file, depending on the route and configurations. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d346aa9 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,78 @@ +site_name: Gotenberg Client + +site_url: https://stumpylog.github.io/gotenberg-client/ +site_author: Trenton H + +repo_name: stumpylog/gotenberg-client +repo_url: https://github.com/stumpylog/gotenberg-client + +nav: + - "index.md" + - "usage.md" + - "roadmap.md" + - "changelog.md" + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - toc: + permalink: true + +theme: + name: material + custom_dir: docs/.override + features: + - navigation.tabs + - navigation.sections + - navigation.top + - toc.integrate + - search.suggest + - content.code.annotate + - pymdownx.superfences + - pymdownx.inlinehilite + - pymdownx.snippets + - footnotes + font: + text: Roboto + code: Roboto Mono + palette: + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - social + - minify: + minify_html: true + minify_js: true + minify_css: true + +extra: + version: + provider: mike + analytics: + provider: google + property: G-YHHSXXB0FT + consent: + title: Cookie consent + description: >- + We use cookies to recognize your repeated visits and preferences, as well + as to measure the effectiveness of our documentation and whether users + find what they're searching for. With your consent, you're helping us to + make our documentation better. diff --git a/pyproject.toml b/pyproject.toml index 1c88a51..dd9e23d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ ] [project.urls] -Documentation = "https://github.com/stumpylog/gotenberg-client/#readme" +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" @@ -95,6 +95,27 @@ dependencies = [ check = ["pre-commit run --all-files"] update = ["pre-commit autoupdate"] +[tool.hatch.envs.docs] +template = "docs" +detached = true +dependencies = [ + "mkdocs-material[imaging]~=9.5.2", + "mike~=2.0.0", + "mkdocs-minify-plugin~=0.7.1" +] + +[tool.hatch.envs.docs.scripts] +new = ["mkdocs new ."] +build = ["mkdocs build"] +serve = [ + "mkdocs serve" +] +mike-help = ["mike serve --help"] +deploy = [ + "mike deploy --push --branch gh-pages --remote origin --update-aliases latest {args}", + "mike set-default --branch gh-pages --remote origin --push latest" +] + [tool.hatch.envs.lint] detached = true dependencies = [ diff --git a/tests/utils.py b/tests/utils.py index 1578004..d224a05 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -71,12 +71,14 @@ def extract_text(pdf_path: Path) -> str: Using pdftotext from poppler, extracts the text of a PDF into a file, then reads the file contents and returns it """ + pdf_to_text = shutil.which("pdftotext") + assert pdf_to_text is not None with tempfile.NamedTemporaryFile( mode="w+", ) as tmp: subprocess.run( [ # noqa: S603 - shutil.which("pdftotext"), + pdf_to_text, "-q", "-layout", "-enc", From ff2a656fe4803108b5488db215d344bab6073b16 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 21 Dec 2023 08:30:08 -0800 Subject: [PATCH 2/9] Adds new route method run_with_retry, allowing re-running of a route if the server returns a server error --- CHANGELOG.md | 1 + src/gotenberg_client/_base.py | 60 +++++++++++++++++++++++++ src/gotenberg_client/_utils.py | 2 +- tests/conftest.py | 3 +- tests/test_convert_chromium_html.py | 11 ++--- tests/test_convert_chromium_markdown.py | 5 +-- tests/test_convert_chromium_url.py | 5 +-- tests/test_convert_libre_office.py | 25 +++++------ tests/test_convert_pdf_a.py | 7 +-- tests/test_health.py | 2 +- tests/test_merge.py | 15 ++++--- tests/test_misc_stuff.py | 54 ++++++++++++++++++---- tests/utils.py | 46 ------------------- 13 files changed, 139 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 496d4b0..95e1f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Documentation site built with Github Pages and Material for MkDocs +- New method `.run_with_retry` for routes, which allows the route to be rerun as configured, with progressive backoff if the server returns a server error ## [0.4.1] - 2023-12-11 diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 2c46707..8b8f471 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -5,12 +5,15 @@ 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 Type +from typing import Union from httpx import Client +from httpx import HTTPStatusError from httpx import Response from httpx._types import RequestFiles @@ -21,6 +24,10 @@ logger = logging.getLogger(__name__) +class UnreachableCodeError(Exception): + pass + + class BaseRoute: """ The base implementation of a Gotenberg API route. Anything settings or @@ -75,6 +82,59 @@ def run(self) -> Response: resp.raise_for_status() return resp + def run_with_retry( + self, + *, + max_retry_count: int = 5, + initial_retry_wait: Union[float, int] = 5.0, + retry_scale: Union[float, int] = 2.0, + ) -> Response: + """ + For whatever reason, Gotenberg often returns HTTP 503 errors, even with the same files. + Hopefully v8 will improve upon this with its updates, but this is provided for convenience. + + This function will retry the given method/function up to X times, with larger backoff + periods between each attempt, in hopes the issue resolves itself during + one attempt to parse. + + This will wait the following (by default): + - Attempt 1 - 5s following failure + - Attempt 2 - 10s following failure + - Attempt 3 - 20s following failure + - Attempt 4 - 40s following failure + - Attempt 5 - 80s following failure + + """ + retry_time = initial_retry_wait + current_retry_count = 0 + + while current_retry_count < max_retry_count: + current_retry_count = current_retry_count + 1 + + try: + return self.run() + except HTTPStatusError as e: + logger.warning(f"HTTP error: {e}", stacklevel=1) + + # This only handles status codes which are 5xx, indicating the server had a problem + # Not 4xx, with probably means a problem with the request + if not e.response.is_server_error: + raise + + # Don't do the extra waiting, return right away + if current_retry_count >= max_retry_count: + raise + + except Exception as e: # pragma: no cover + logger.warning(f"Unexpected error: {e}", stacklevel=1) + if current_retry_count > -max_retry_count: + raise + + sleep(retry_time) + retry_time = retry_time * retry_scale + + raise UnreachableCodeError # pragma: no cover + def _get_files(self) -> RequestFiles: """ Deals with opening all provided files for multi-part uploads, including diff --git a/src/gotenberg_client/_utils.py b/src/gotenberg_client/_utils.py index 72977aa..6f8c5fd 100644 --- a/src/gotenberg_client/_utils.py +++ b/src/gotenberg_client/_utils.py @@ -13,7 +13,7 @@ def optional_to_form(value: Optional[Union[bool, int, float, str]], name: 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 """ - if value is None: + if value is None: # pragma: no cover return {} else: return {name: str(value).lower()} diff --git a/tests/conftest.py b/tests/conftest.py index e394eec..ad727a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,13 +6,14 @@ import pytest -from gotenberg_client._client import GotenbergClient +from gotenberg_client import GotenbergClient 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 + if SAVE_OUTPUTS: shutil.rmtree(SAVE_DIR, ignore_errors=True) SAVE_DIR.mkdir() diff --git a/tests/test_convert_chromium_html.py b/tests/test_convert_chromium_html.py index 178e5f8..925e00b 100644 --- a/tests/test_convert_chromium_html.py +++ b/tests/test_convert_chromium_html.py @@ -14,7 +14,6 @@ from tests.conftest import SAMPLE_DIR from tests.conftest import SAVE_DIR from tests.conftest import SAVE_OUTPUTS -from tests.utils import call_run_with_server_error_handling from tests.utils import verify_stream_contains @@ -23,7 +22,7 @@ def test_basic_convert(self, client: GotenbergClient): test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: - resp = call_run_with_server_error_handling(route.index(test_file)) + resp = route.index(test_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -37,7 +36,7 @@ def test_convert_with_header_footer(self, client: GotenbergClient): footer_file = SAMPLE_DIR / "footer.html" with client.chromium.html_to_pdf() as route: - resp = call_run_with_server_error_handling(route.index(test_file).header(header_file).footer(footer_file)) + resp = route.index(test_file).header(header_file).footer(footer_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -50,9 +49,7 @@ def test_convert_additional_files(self, client: GotenbergClient): style = SAMPLE_DIR / "style.css" with client.chromium.html_to_pdf() as route: - resp = call_run_with_server_error_handling( - route.index(test_file).resource(img).resource(font).resource(style), - ) + resp = route.index(test_file).resource(img).resource(font).resource(style).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -69,7 +66,7 @@ def test_convert_pdfa_1a_format(self, client: GotenbergClient, gt_format: PdfAFo test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: - resp = call_run_with_server_error_handling(route.index(test_file).pdf_format(gt_format)) + resp = route.index(test_file).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_convert_chromium_markdown.py b/tests/test_convert_chromium_markdown.py index 2e78e96..fbd5465 100644 --- a/tests/test_convert_chromium_markdown.py +++ b/tests/test_convert_chromium_markdown.py @@ -2,7 +2,6 @@ from gotenberg_client._client import GotenbergClient from tests.conftest import SAMPLE_DIR -from tests.utils import call_run_with_server_error_handling class TestConvertChromiumUrlRoute: @@ -13,9 +12,7 @@ def test_basic_convert(self, client: GotenbergClient): font = SAMPLE_DIR / "font.woff" style = SAMPLE_DIR / "style.css" with client.chromium.markdown_to_pdf() as route: - resp = call_run_with_server_error_handling( - route.index(index).markdown_files(md_files).resources([img, font]).resource(style), - ) + resp = route.index(index).markdown_files(md_files).resources([img, font]).resource(style).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_convert_chromium_url.py b/tests/test_convert_chromium_url.py index 5e4afb6..8f090ae 100644 --- a/tests/test_convert_chromium_url.py +++ b/tests/test_convert_chromium_url.py @@ -6,16 +6,13 @@ from gotenberg_client._client import GotenbergClient from gotenberg_client._convert.chromium import EmulatedMediaType -from tests.utils import call_run_with_server_error_handling from tests.utils import verify_stream_contains class TestConvertChromiumUrlRoute: def test_basic_convert(self, client: GotenbergClient): with client.chromium.url_to_pdf() as route: - resp = call_run_with_server_error_handling( - route.url("https://en.wikipedia.org/wiki/William_Edward_Sanders"), - ) + resp = route.url("https://en.wikipedia.org/wiki/William_Edward_Sanders").run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_convert_libre_office.py b/tests/test_convert_libre_office.py index 506ee69..f75bf4f 100644 --- a/tests/test_convert_libre_office.py +++ b/tests/test_convert_libre_office.py @@ -12,14 +12,13 @@ from tests.conftest import SAMPLE_DIR from tests.conftest import SAVE_DIR from tests.conftest import SAVE_OUTPUTS -from tests.utils import call_run_with_server_error_handling class TestLibreOfficeConvert: def test_libre_office_convert_docx_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.docx" with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling(route.convert(test_file)) + resp = route.convert(test_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -31,7 +30,7 @@ def test_libre_office_convert_docx_format(self, client: GotenbergClient): def test_libre_office_convert_odt_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.odt" with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling(route.convert(test_file)) + resp = route.convert(test_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -43,7 +42,7 @@ def test_libre_office_convert_odt_format(self, client: GotenbergClient): def test_libre_office_convert_xlsx_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.xlsx" with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling(route.convert(test_file)) + resp = route.convert(test_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -55,7 +54,7 @@ def test_libre_office_convert_xlsx_format(self, client: GotenbergClient): def test_libre_office_convert_ods_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.ods" with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling(route.convert(test_file)) + resp = route.convert(test_file).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -66,8 +65,8 @@ def test_libre_office_convert_ods_format(self, client: GotenbergClient): def test_libre_office_convert_multiples_format(self, client: GotenbergClient): with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling( - route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).no_merge(), + resp = ( + route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).no_merge().run_with_retry() ) assert resp.status_code == codes.OK @@ -79,9 +78,7 @@ def test_libre_office_convert_multiples_format(self, client: GotenbergClient): def test_libre_office_convert_multiples_format_merged(self, client: GotenbergClient): with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling( - route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).merge(), - ) + resp = route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).merge().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -94,8 +91,10 @@ def test_libre_office_convert_std_lib_mime(self, client: GotenbergClient): 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 = call_run_with_server_error_handling( - route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]).no_merge(), + resp = ( + route.convert_files([SAMPLE_DIR / "sample.docx", SAMPLE_DIR / "sample.odt"]) + .no_merge() + .run_with_retry() ) assert resp.status_code == codes.OK @@ -117,7 +116,7 @@ def test_libre_office_convert_xlsx_format_pdfa( ): test_file = SAMPLE_DIR / "sample.xlsx" with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling(route.convert(test_file).pdf_format(gt_format)) + resp = route.convert(test_file).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_convert_pdf_a.py b/tests/test_convert_pdf_a.py index aed572e..ddabdf7 100644 --- a/tests/test_convert_pdf_a.py +++ b/tests/test_convert_pdf_a.py @@ -10,7 +10,6 @@ from tests.conftest import SAMPLE_DIR from tests.conftest import SAVE_DIR from tests.conftest import SAVE_OUTPUTS -from tests.utils import call_run_with_server_error_handling class TestPdfAConvert: @@ -26,7 +25,7 @@ def test_pdf_a_single_file( ): test_file = SAMPLE_DIR / "sample1.pdf" with client.pdf_a.to_pdfa() as route: - resp = call_run_with_server_error_handling(route.convert(test_file).pdf_format(gt_format)) + resp = route.convert(test_file).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -53,9 +52,7 @@ def test_pdf_a_multiple_file( 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 = call_run_with_server_error_handling( - route.convert_files([test_file, other_test_file]).pdf_format(gt_format), - ) + resp = route.convert_files([test_file, other_test_file]).pdf_format(gt_format).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_health.py b/tests/test_health.py index ff58223..d4360d3 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -11,6 +11,6 @@ def test_health_endpoint( assert status.overall == StatusOptions.Up assert status.chromium is not None assert status.chromium.status == StatusOptions.Up - if "uno" in status.data: + if "uno" in status.data: # pragma: no cover assert status.uno is not None assert status.uno.status == StatusOptions.Up diff --git a/tests/test_merge.py b/tests/test_merge.py index 7b3187d..39871b6 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -11,7 +11,6 @@ from tests.conftest import SAMPLE_DIR from tests.conftest import SAVE_DIR from tests.conftest import SAVE_OUTPUTS -from tests.utils import call_run_with_server_error_handling from tests.utils import extract_text @@ -27,12 +26,13 @@ def test_merge_files_pdf_a( pike_format: str, ): with client.merge.merge() as route: - resp = call_run_with_server_error_handling( - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]).pdf_format( + resp = ( + route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) + .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" @@ -56,8 +56,9 @@ def test_merge_multiple_file( else: with client.merge.merge() as route: # By default, these would not merge correctly, as it happens alphabetically - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) - resp = call_run_with_server_error_handling(route) + resp = route.merge( + [SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"], + ).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_misc_stuff.py b/tests/test_misc_stuff.py index b71e5dd..7e42470 100644 --- a/tests/test_misc_stuff.py +++ b/tests/test_misc_stuff.py @@ -3,11 +3,13 @@ import uuid from pathlib import Path +import pytest +from httpx import HTTPStatusError from httpx import codes +from pytest_httpx import HTTPXMock from gotenberg_client._client import GotenbergClient from tests.conftest import SAMPLE_DIR -from tests.utils import call_run_with_server_error_handling class TestMiscFunctionality: @@ -17,10 +19,12 @@ def test_trace_id_header( ): trace_id = str(uuid.uuid4()) with client.merge.merge() as route: - resp = call_run_with_server_error_handling( - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]).trace( + resp = ( + route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) + .trace( trace_id, - ), + ) + .run_with_retry() ) assert resp.status_code == codes.OK @@ -35,10 +39,12 @@ def test_output_filename( ): filename = "my-cool-file" with client.merge.merge() as route: - resp = call_run_with_server_error_handling( - route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]).output_name( + resp = ( + route.merge([SAMPLE_DIR / "z_first_merge.pdf", SAMPLE_DIR / "a_merge_second.pdf"]) + .output_name( filename, - ), + ) + .run_with_retry() ) assert resp.status_code == codes.OK @@ -62,8 +68,40 @@ def test_libre_office_convert_cyrillic(self, client: GotenbergClient): ) with client.libre_office.to_pdf() as route: - resp = call_run_with_server_error_handling(route.convert(copy)) + 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" + + +class TestServerErrorRetry: + def test_server_error_retry(self, client: GotenbergClient, httpx_mock: HTTPXMock): + # Response 1 + httpx_mock.add_response(method="POST", status_code=codes.INTERNAL_SERVER_ERROR) + # Response 2 + httpx_mock.add_response(method="POST", status_code=codes.SERVICE_UNAVAILABLE) + # Response 3 + httpx_mock.add_response(method="POST", status_code=codes.GATEWAY_TIMEOUT) + # Response 4 + httpx_mock.add_response(method="POST", status_code=codes.BAD_GATEWAY) + # 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) + assert exc_info.value.response.status_code == codes.SERVICE_UNAVAILABLE + + def test_not_a_server_error(self, client: GotenbergClient, 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) + assert exc_info.value.response.status_code == codes.NOT_FOUND diff --git a/tests/utils.py b/tests/utils.py index d224a05..cf3805b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,18 +1,12 @@ import shutil import subprocess import tempfile -import time -import warnings from pathlib import Path -from httpx import HTTPStatusError -from httpx import Response from httpx._multipart import DataField from httpx._multipart import FileField from httpx._multipart import MultipartStream -from gotenberg_client._base import BaseRoute - def verify_stream_contains(key: str, value: str, stream: MultipartStream): for item in stream.fields: @@ -26,46 +20,6 @@ def verify_stream_contains(key: str, value: str, stream: MultipartStream): raise AssertionError(msg) -def call_run_with_server_error_handling(route: BaseRoute) -> Response: - """ - For whatever reason, the images started during the test pipeline like to - segfault sometimes, crash and otherwise fail randomly, when run with the - exact files that usually pass. - - So, this function will retry the given method/function up to 3 times, with larger backoff - periods between each attempt, in hopes the issue resolves itself during - one attempt to parse. - - This will wait the following: - - Attempt 1 - 5s following failure - - Attempt 2 - 10s following failure - - Attempt 3 - 20s following failure - - Attempt 4 - 40s following failure - - Attempt 5 - 80s following failure - - """ - result = None - succeeded = False - retry_time = 5.0 - retry_count = 0 - max_retry_count = 5 - - while retry_count < max_retry_count and not succeeded: - try: - return route.run() - except HTTPStatusError as e: # pragma: no cover - warnings.warn(f"HTTP error: {e}", stacklevel=1) - except Exception as e: # pragma: no cover - warnings.warn(f"Unexpected error: {e}", stacklevel=1) - - retry_count = retry_count + 1 - - time.sleep(retry_time) - retry_time = retry_time * 2.0 - - return result - - def extract_text(pdf_path: Path) -> str: """ Using pdftotext from poppler, extracts the text of a PDF into a file, From 6ef285ddf9bee574eab1283a71fc8e67599ff27b Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 21 Dec 2023 08:37:06 -0800 Subject: [PATCH 3/9] Links the documentation job into the workflow requirements --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b0faae..930936b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,6 +223,7 @@ jobs: needs: - build - test + - documentation steps: - uses: actions/checkout@v4 @@ -259,6 +260,7 @@ jobs: needs: - build - test + - documentation steps: - uses: actions/download-artifact@v3 From 6026dc24971003211072f97e4700d535926adb2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 11:39:18 +0000 Subject: [PATCH 4/9] Bump github/codeql-action from 2 to 3 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2a8e2c1..21bc942 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" From ebd85b321dcb10c5dedf5552c9ec243a580f46fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 11:39:25 +0000 Subject: [PATCH 5/9] Bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 930936b..bb41d90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'pip' @@ -72,7 +72,7 @@ jobs: sudo apt-get install --yes --no-install-recommends poppler-utils - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -122,7 +122,7 @@ jobs: sudo apt-get install --yes --no-install-recommends poppler-utils - name: Set up Python 3.11 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' @@ -153,7 +153,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'pip' @@ -184,7 +184,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' cache: 'pip' From e3f735aaf336af74a172bd7e8ec48244bc5db07f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 11:39:30 +0000 Subject: [PATCH 6/9] Bump actions/upload-artifact from 3 to 4 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb41d90..ea0e40c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,7 +166,7 @@ jobs: run: | hatch build --clean - - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: artifacts path: dist/* @@ -197,7 +197,7 @@ jobs: run: | hatch run docs:build - - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: documentation path: site/* From 996c6502d568b2564ed3d6665e2eed90f952ebce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 11:39:36 +0000 Subject: [PATCH 7/9] Bump actions/download-artifact from 3 to 4 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea0e40c..dc65120 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,7 +228,7 @@ jobs: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: artifacts path: dist @@ -263,7 +263,7 @@ jobs: - documentation steps: - - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: artifacts path: dist From 225026afee671a97c0715f818e45c1a1bcff3d3a Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 11 Jan 2024 08:47:40 -0800 Subject: [PATCH 8/9] Squashed commit of the following: commit 8aa15dbde03aaf79bfaebf0f497c5a9e01f9f26d Author: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu Jan 11 08:42:01 2024 -0800 Adds support for the Gotenberg webhooks --- .pre-commit-config.yaml | 6 +-- CHANGELOG.md | 5 +++ src/gotenberg_client/_client.py | 35 +++++++++++++++- src/gotenberg_client/options.py | 4 ++ tests/test_misc_stuff.py | 71 +++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32c058b..f810274 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,10 +39,10 @@ repos: exclude: "(^Pipfile\\.lock$)" # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.1.6' + rev: 'v0.1.11' hooks: - id: ruff - - repo: https://github.com/psf/black - rev: 23.11.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.12.1 hooks: - id: black diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e1f54..49a4ab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation site built with Github Pages and Material for MkDocs - New method `.run_with_retry` for routes, which allows the route to be rerun as configured, with progressive backoff if the server returns a server error +- Support for Gotenberg [Webhooks](https://gotenberg.dev/docs/webhook) + +### Deprecated + +- Support for Gotenberg 7.x. This will likely be the last release to support 7.x, as the options for PDF/A have been changed ## [0.4.1] - 2023-12-11 diff --git a/src/gotenberg_client/_client.py b/src/gotenberg_client/_client.py index 9677d20..9e6195d 100644 --- a/src/gotenberg_client/_client.py +++ b/src/gotenberg_client/_client.py @@ -15,6 +15,7 @@ from gotenberg_client._health import HealthCheckApi from gotenberg_client._merge import MergeApi from gotenberg_client._typing_compat import Self +from gotenberg_client.options import HttpMethods class GotenbergClient: @@ -44,12 +45,44 @@ def __init__( self.merge = MergeApi(self._client) self.health = HealthCheckApi(self._client) - def add_headers(self, header: Dict[str, str]) -> None: # pragma: no cover + def add_headers(self, header: Dict[str, str]) -> None: """ Updates the httpx Client headers with the given values """ self._client.headers.update(header) + def add_webhook_url(self, url: str) -> None: + """ + Adds the webhook URL to the headers + """ + self.add_headers({"Gotenberg-Webhook-Url": url}) + + def add_error_webhook_url(self, url: str) -> None: + """ + Adds the webhook error URL to the headers + """ + self.add_headers({"Gotenberg-Webhook-Error-Url": url}) + + def set_webhook_http_method(self, method: HttpMethods = "PUT") -> None: + """ + Sets the HTTP method Gotenberg will use to call the hooks + """ + self.add_headers({"Gotenberg-Webhook-Method": method}) + + def set_error_webhook_http_method(self, method: HttpMethods = "PUT") -> None: + """ + Sets the HTTP method Gotenberg will use to call the hooks + """ + 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 + """ + from json import dumps + + self.add_headers({"Gotenberg-Webhook-Extra-Http-Headers": dumps(extra_headers)}) + def __enter__(self) -> Self: return self diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index d2ef4ae..b6404c6 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -5,6 +5,7 @@ import enum from typing import Dict from typing import Final +from typing import Literal from typing import Optional from typing import Union @@ -102,3 +103,6 @@ def to_form(self) -> Dict[str, str]: return {"emulatedMediaType": "screen"} else: # pragma: no cover raise NotImplementedError(self.value) + + +HttpMethods = Literal["POST", "PATCH", "PUT"] diff --git a/tests/test_misc_stuff.py b/tests/test_misc_stuff.py index 7e42470..799d1c8 100644 --- a/tests/test_misc_stuff.py +++ b/tests/test_misc_stuff.py @@ -1,10 +1,13 @@ import shutil import tempfile import uuid +from json import dumps +from json import loads from pathlib import Path import pytest from httpx import HTTPStatusError +from httpx import Request from httpx import codes from pytest_httpx import HTTPXMock @@ -105,3 +108,71 @@ def test_not_a_server_error(self, client: GotenbergClient, httpx_mock: HTTPXMock with pytest.raises(HTTPStatusError) as exc_info: _ = route.index(test_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): + 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() + + requests = httpx_mock.get_requests() + + assert len(requests) == 1 + + request: Request = requests[0] + + assert "Gotenberg-Webhook-Url" in request.headers + assert request.headers["Gotenberg-Webhook-Url"] == "http://myapi:3000/on-success" + 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): + 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") + + test_file = SAMPLE_DIR / "basic.html" + with client.chromium.html_to_pdf() as route: + _ = route.index(test_file).run_with_retry() + + requests = httpx_mock.get_requests() + + assert len(requests) == 1 + + request: Request = requests[0] + + 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" + + def test_webhook_extra_headers(self, client: GotenbergClient, httpx_mock: HTTPXMock): + httpx_mock.add_response(method="POST", status_code=codes.OK) + + headers = {"Token": "mytokenvalue"} + headers_str = dumps(headers) + + 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() + + requests = httpx_mock.get_requests() + + assert len(requests) == 1 + + request: Request = requests[0] + + assert "Gotenberg-Webhook-Extra-Http-Headers" in request.headers + assert request.headers["Gotenberg-Webhook-Extra-Http-Headers"] == headers_str + assert loads(request.headers["Gotenberg-Webhook-Extra-Http-Headers"]) == headers From 65c7e58ec9e485d208d2d61a084b944ecdbe2236 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 11 Jan 2024 08:49:34 -0800 Subject: [PATCH 9/9] Bumps version to 0.5.0 --- CHANGELOG.md | 2 +- src/gotenberg_client/__about__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49a4ab2..ac8834c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.5.0] - 2024-01-11 ### Added diff --git a/src/gotenberg_client/__about__.py b/src/gotenberg_client/__about__.py index 87873dc..3ea9ac5 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.4.1" +__version__ = "0.5.0"