From 579987aa267840aca53fa50b2560dafd19355202 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 13 Jun 2024 12:30:15 -0700 Subject: [PATCH 01/22] Hopefully final fixes of documentation deployment --- CHANGELOG.md | 6 ++++++ README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b606dd1..893af69 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] + +### Fixed + +- `mike` deployment mis-ordered the version and alias, this has been corrected + ## [0.6.0] - 2024-06-13 ### Breaking Change diff --git a/README.md b/README.md index 07b1802..ac38b23 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 diff --git a/pyproject.toml b/pyproject.toml index 2f42fbc..8e8da2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,7 +146,7 @@ serve = [ ] mike-help = ["mike deploy --help"] deploy = [ - "mike deploy --push --branch gh-pages --remote origin --update-aliases latest {args}", + "mike deploy --push --branch gh-pages --remote origin --update-aliases {args} latest", "mike set-default --branch gh-pages --remote origin --push latest" ] From cc1ebede0194b8961d991a49a8e6cbecbc461c02 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:15:12 -0700 Subject: [PATCH 02/22] Fixes mypy typing --- .github/workflows/ci.yml | 8 ++++++-- CHANGELOG.md | 1 + pyproject.toml | 4 ++-- src/gotenberg_client/options.py | 3 ++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b422a..3cc0f34 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 893af69..1febe6a 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 ### Fixed - `mike` deployment mis-ordered the version and alias, this has been corrected +- `mypy` wasn't running correctly or in CI ## [0.6.0] - 2024-06-13 diff --git a/pyproject.toml b/pyproject.toml index 8e8da2c..25b797e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,7 +115,7 @@ dependencies = [ [tool.hatch.envs.typing.scripts] run = [ "mypy --version", - "mypy --install-types --non-interactive {args:src/tika_client}" + "mypy --install-types --non-interactive {args:src/gotenberg_client}" ] [tool.hatch.envs.pre-commit] @@ -255,7 +255,7 @@ tests = ["tests", "*/gotenberg_client/tests"] [tool.coverage.report] exclude_lines = [ "no cov", - "if __name__ == .__main__.:", + "if __name__ == '__main__':", "if TYPE_CHECKING:", "if SAVE_OUTPUTS:", ] diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index f6cbefb..ef7f721 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -110,7 +110,8 @@ def to_form(self) -> Dict[str, str]: 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)) + # mypy claims the string is of type "Any" + form_data.update(optional_to_form(f"{attr.value}{attr.unit.value}", name)) # type: ignore[misc] return form_data From 64f01e4bbbdfb1719050d636a048f0c23e39da43 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:27:10 -0700 Subject: [PATCH 03/22] Adds additional project classifiers --- pyproject.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 25b797e..bd9626f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +# +# Project Configuration +# [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -15,9 +18,12 @@ authors = [ ] classifiers = [ "Development Status :: 4 - Beta", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Operating System :: OS Independent", "Intended Audience :: Developers", "Environment :: Web Environment", + "Programming Language :: Python", + "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -42,6 +48,9 @@ Changelog = "https://github.com/stumpylog/gotenberg-client/blob/main/CHANGELOG.m [project.optional-dependencies] magic = ["python-magic"] +# +# Hatch Configuration +# [tool.hatch.version] path = "src/gotenberg_client/__about__.py" @@ -150,6 +159,9 @@ deploy = [ "mike set-default --branch gh-pages --remote origin --push latest" ] +# +# Tool Configuration +# [tool.ruff] # https://docs.astral.sh/ruff/settings/ fix = true From 519cd29c33f410c0242509dc43ddaba2c9ef0c3a Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Fri, 14 Jun 2024 12:54:56 -0700 Subject: [PATCH 04/22] Adds typing into the tests as well --- pyproject.toml | 6 ------ tests/conftest.py | 3 ++- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bd9626f..d2d25a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,12 +273,6 @@ exclude_lines = [ ] [tool.mypy] -exclude = [ - "tests/test_convert_chromium_html.py", - "tests/test_convert_chromium_url.py", - "tests/test_convert_chromium_markdown.py", - "tests/conftest.py", -] disallow_any_expr = true disallow_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/conftest.py b/tests/conftest.py index 8d95ea0..f98119e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import shutil from pathlib import Path from typing import Final +from typing import Generator import pytest @@ -23,6 +24,6 @@ @pytest.fixture() -def client() -> GotenbergClient: +def client() -> Generator[GotenbergClient, None, None]: with GotenbergClient(host=GOTENBERG_URL, log_level=logging.INFO) as client: yield client From 08dc37daa355d130ebf401245259cf4811f0f428 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Fri, 14 Jun 2024 12:57:37 -0700 Subject: [PATCH 05/22] Fixes redundant test coverage pathing --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d2d25a7..9054f3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -251,7 +251,7 @@ testpaths = ["tests"] #SAVE_TEST_OUTPUT = 1 [tool.coverage.run] -source_pkgs = ["gotenberg_client", "tests"] +source_pkgs = ["src/gotenberg_client", "gotenberg_client", "tests"] branch = true parallel = true omit = [ @@ -261,8 +261,8 @@ 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 = [ From 6f9cda4e06936955b108277d08f2d1437a2fffb2 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:05:37 -0700 Subject: [PATCH 06/22] Restores the edge testing --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cc0f34..c2ea0d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,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 From c81c86c565a1d14492ea9f29f3501e5835aeb5a5 Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:22:43 -0700 Subject: [PATCH 07/22] More fixes for pyproject coverage --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9054f3a..19283ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -251,7 +251,7 @@ testpaths = ["tests"] #SAVE_TEST_OUTPUT = 1 [tool.coverage.run] -source_pkgs = ["src/gotenberg_client", "gotenberg_client", "tests"] +source_pkgs = ["gotenberg_client", "tests"] branch = true parallel = true omit = [ From bc360ad5e9922a28919dd103c8efb1378ac9ee1c Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:46:58 -0700 Subject: [PATCH 08/22] Filters out an expected DeprecationWarning on the user agent field --- tests/test_convert_chromium_url.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_convert_chromium_url.py b/tests/test_convert_chromium_url.py index dd4ba92..228347d 100644 --- a/tests/test_convert_chromium_url.py +++ b/tests/test_convert_chromium_url.py @@ -209,6 +209,7 @@ def test_convert_url_render_expression( request.stream, ) + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_convert_url_user_agent( self, client: GotenbergClient, From f90d8c189b99a5777551106a0765b4c06bdf8860 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 18 Jun 2024 12:47:41 -0700 Subject: [PATCH 09/22] Feature: Return stronger typed responses from the API (#23) --- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 4 ++ README.md | 15 +++-- docs/index.md | 26 ++++++-- pyproject.toml | 10 ++- src/gotenberg_client/__init__.py | 6 +- src/gotenberg_client/_base.py | 63 ++++++++++++++++--- src/gotenberg_client/_client.py | 4 +- src/gotenberg_client/_convert/chromium.py | 12 ++-- src/gotenberg_client/_convert/common.py | 13 ++-- src/gotenberg_client/_convert/libre_office.py | 43 ++++++++++++- src/gotenberg_client/_convert/pdfa.py | 6 +- src/gotenberg_client/_errors.py | 6 ++ src/gotenberg_client/_merge.py | 7 ++- src/gotenberg_client/_types.py | 19 ++++++ src/gotenberg_client/_typing_compat.py | 10 --- src/gotenberg_client/_utils.py | 5 +- src/gotenberg_client/options.py | 13 ++-- src/gotenberg_client/responses.py | 54 ++++++++++++++++ tests/conftest.py | 10 +++ tests/test_convert_chromium_html.py | 24 ++++--- tests/test_convert_chromium_screenshots.py | 26 ++++---- tests/test_convert_libre_office.py | 55 +++++++++++----- tests/test_convert_pdf_a.py | 33 +++++----- tests/test_merge.py | 36 +++++------ tests/test_misc_stuff.py | 32 ++++++---- tests/utils.py | 7 ++- 27 files changed, 391 insertions(+), 150 deletions(-) create mode 100644 src/gotenberg_client/_errors.py create mode 100644 src/gotenberg_client/_types.py delete mode 100644 src/gotenberg_client/_typing_compat.py create mode 100644 src/gotenberg_client/responses.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a9ea54..30e44d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,7 +44,7 @@ repos: - id: codespell # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.4.6' + rev: 'v0.4.9' hooks: # Run the linter. - id: ruff diff --git a/CHANGELOG.md b/CHANGELOG.md index 1febe6a..9fbf7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `mike` deployment mis-ordered the version and alias, this has been corrected - `mypy` wasn't running correctly or in CI +### Added + +- Al routes now return a stronger typed response than just an `httpx.Response` ([#23](https://github.com/stumpylog/gotenberg-client/pull/23)) + ## [0.6.0] - 2024-06-13 ### Breaking Change diff --git a/README.md b/README.md index ac38b23..ebef672 100644 --- a/README.md +++ b/README.md @@ -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/pyproject.toml b/pyproject.toml index 19283ff..6a08d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,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.4.9"] config-path = "none" [tool.hatch.envs.hatch-test] @@ -119,12 +119,15 @@ detached = true dependencies = [ "mypy ~= 1.10.0", "httpx", + "pytest", + "pikepdf", + "pytest-httpx" ] [tool.hatch.envs.typing.scripts] run = [ "mypy --version", - "mypy --install-types --non-interactive {args:src/gotenberg_client}" + "mypy --install-types --non-interactive ." ] [tool.hatch.envs.pre-commit] @@ -273,6 +276,9 @@ exclude_lines = [ ] [tool.mypy] +exclude = [ + "tests/*", +] disallow_any_expr = true disallow_untyped_defs = true disallow_incomplete_defs = true diff --git a/src/gotenberg_client/__init__.py b/src/gotenberg_client/__init__.py index 015889f..465635e 100644 --- a/src/gotenberg_client/__init__.py +++ b/src/gotenberg_client/__init__.py @@ -2,5 +2,9 @@ # # 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.responses import SingleFileResponse +from gotenberg_client.responses import ZipFileResponse -__all__ = ["GotenbergClient"] +__all__ = ["GotenbergClient", "SingleFileResponse", "ZipFileResponse", "BaseClientError", "CannotExtractHereError"] diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 3ddf8d3..2de606c 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -10,16 +10,18 @@ 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 -from gotenberg_client._typing_compat import Self +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__) @@ -60,7 +62,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 @@ -104,22 +106,21 @@ 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.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 +145,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) @@ -223,6 +224,50 @@ def output_name(self, filename: str) -> Self: return self +class BaseSingleFileResponseRoute(_BaseRoute): + def run(self) -> SingleFileResponse: + 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: + 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 + 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: + 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..1ac29d5 100644 --- a/src/gotenberg_client/_client.py +++ b/src/gotenberg_client/_client.py @@ -14,8 +14,8 @@ 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: diff --git a/src/gotenberg_client/_convert/chromium.py b/src/gotenberg_client/_convert/chromium.py index 9c803c1..e7deaa2 100644 --- a/src/gotenberg_client/_convert/chromium.py +++ b/src/gotenberg_client/_convert/chromium.py @@ -7,7 +7,7 @@ from typing import Literal 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,20 +17,20 @@ 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") return self -class _RouteWithResources(BaseRoute): +class _RouteWithResources(BaseSingleFileResponseRoute): def resource(self, resource: Path) -> Self: self._add_file_map(resource) return self @@ -62,7 +62,7 @@ class UrlRoute( EmulatedMediaMixin, CustomHTTPHeaderMixin, PageOrientMixin, - BaseRoute, + BaseSingleFileResponseRoute, ): """ https://gotenberg.dev/docs/routes#url-into-pdf-route @@ -99,7 +99,7 @@ class ScreenshotRoute( ConsoleExceptionMixin, PerformanceModeMixin, PageOrientMixin, - BaseRoute, + BaseSingleFileResponseRoute, ): """ https://gotenberg.dev/docs/routes#screenshots-route diff --git a/src/gotenberg_client/_convert/common.py b/src/gotenberg_client/_convert/common.py index 4483b80..824f088 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 @@ -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..97ffc0a 100644 --- a/src/gotenberg_client/_convert/libre_office.py +++ b/src/gotenberg_client/_convert/libre_office.py @@ -3,19 +3,29 @@ # 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 """ + def __init__(self, client: Client, api_route: str) -> None: + super().__init__(client, api_route) + self._result_is_zip = False + def convert(self, file_path: Path) -> Self: """ Adds a single file to be converted to PDF. Can be called multiple times, @@ -30,6 +40,7 @@ def convert_files(self, file_paths: List[Path]) -> Self: """ for x in file_paths: self.convert(x) + self._result_is_zip = True return self def merge(self) -> Self: @@ -37,6 +48,7 @@ def merge(self) -> Self: Merge the resulting PDFs into one """ self._form_data.update({"merge": "true"}) + self._result_is_zip = False return self def no_merge(self) -> Self: @@ -44,8 +56,33 @@ def no_merge(self) -> Self: Don't merge the resulting PDFs """ 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): _CONVERT_ENDPOINT = "/forms/libreoffice/convert" diff --git a/src/gotenberg_client/_convert/pdfa.py b/src/gotenberg_client/_convert/pdfa.py index e52595c..9f35f48 100644 --- a/src/gotenberg_client/_convert/pdfa.py +++ b/src/gotenberg_client/_convert/pdfa.py @@ -5,11 +5,11 @@ 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 """ diff --git a/src/gotenberg_client/_errors.py b/src/gotenberg_client/_errors.py new file mode 100644 index 0000000..5804520 --- /dev/null +++ b/src/gotenberg_client/_errors.py @@ -0,0 +1,6 @@ +class BaseClientError(Exception): + pass + + +class CannotExtractHereError(BaseClientError): + pass diff --git a/src/gotenberg_client/_merge.py b/src/gotenberg_client/_merge.py index 70ff94e..601c43f 100644 --- a/src/gotenberg_client/_merge.py +++ b/src/gotenberg_client/_merge.py @@ -7,10 +7,11 @@ 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 """ @@ -19,7 +20,7 @@ def __init__(self, client: Client, api_route: str) -> None: 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 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..87d82e7 100644 --- a/src/gotenberg_client/_utils.py +++ b/src/gotenberg_client/_utils.py @@ -6,7 +6,8 @@ from typing import Dict from typing import Final from typing import Optional -from typing import Union + +from gotenberg_client._types import FormFieldType # See https://github.com/psf/requests/issues/1081#issuecomment-428504128 @@ -15,7 +16,7 @@ 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 diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index ef7f721..6447e17 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -5,11 +5,11 @@ 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 @@ -51,8 +51,8 @@ def to_form(self) -> Dict[str, str]: @dataclasses.dataclass class PageSize: - width: Optional[Union[float, int]] = None - height: Optional[Union[float, int]] = None + width: Optional[PageSizeType] = None + height: Optional[PageSizeType] = None def to_form(self) -> Dict[str, str]: data = optional_to_form(self.width, "paperWidth") @@ -86,7 +86,7 @@ class MarginUnitType(str, enum.Enum): @dataclasses.dataclass class MarginType: - value: Union[float, int] + value: MarginSizeType unit: MarginUnitType = MarginUnitType.Undefined @@ -128,6 +128,3 @@ def to_form(self) -> Dict[str, str]: return {"emulatedMediaType": "screen"} else: # pragma: no cover raise NotImplementedError(self.value) - - -HttpMethodsType = Literal["POST", "PATCH", "PUT"] 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 f98119e..5bfdd08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import logging import os import shutil +import tempfile from pathlib import Path from typing import Final from typing import Generator @@ -27,3 +28,12 @@ def client() -> Generator[GotenbergClient, None, None]: with GotenbergClient(host=GOTENBERG_URL, log_level=logging.INFO) as client: yield client + + +@pytest.fixture() +def temporary_dir() -> Generator[Path, None, None]: + """ + Creates and cleans up a temporary directory for tests + """ + with tempfile.TemporaryDirectory() as tmp_dir: + yield Path(tmp_dir).resolve() diff --git a/tests/test_convert_chromium_html.py b/tests/test_convert_chromium_html.py index 5737b8a..2c8c46c 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 @@ -33,7 +32,7 @@ def test_basic_convert(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_basic_convert.pdf") def test_convert_with_header_footer(self, client: GotenbergClient): test_file = SAMPLE_DIR / "basic.html" @@ -61,13 +60,19 @@ def test_convert_additional_files(self, client: GotenbergClient): assert resp.headers["Content-Type"] == "application/pdf" if SAVE_OUTPUTS: - (SAVE_DIR / "test_convert_additional_files.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_convert_additional_files.pdf") @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): + def test_convert_pdfa_format( + self, + client: GotenbergClient, + temporary_dir: Path, + gt_format: PdfAFormat, + pike_format: str, + ): test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: @@ -77,12 +82,11 @@ def test_convert_pdfa_format(self, client: GotenbergClient, gt_format: PdfAForma 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 = temporary_dir / "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 class TestConvertChromiumHtmlRouteMocked: diff --git a/tests/test_convert_chromium_screenshots.py b/tests/test_convert_chromium_screenshots.py index f831cb6..dd95cb6 100644 --- a/tests/test_convert_chromium_screenshots.py +++ b/tests/test_convert_chromium_screenshots.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2023-present Trenton H # # SPDX-License-Identifier: MPL-2.0 +from typing import Literal + import pytest from httpx import codes @@ -19,13 +21,13 @@ def test_basic_screenshot(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_basic_screenshot.png") @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, 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() @@ -33,7 +35,7 @@ def test_screenshot_formats(self, client: GotenbergClient, image_format: str): 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) + resp.to_file(SAVE_DIR / "test_basic_screenshot.png") def test_screenshot_quality_valid(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -43,7 +45,7 @@ def test_screenshot_quality_valid(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_screenshot_quality_valid.png") def test_screenshot_quality_too_low(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -53,7 +55,7 @@ def test_screenshot_quality_too_low(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_screenshot_quality_too_low.png") def test_screenshot_quality_too_high(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -63,7 +65,7 @@ def test_screenshot_quality_too_high(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_screenshot_quality_too_high.png") def test_screenshot_optimize_speed(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -73,7 +75,7 @@ def test_screenshot_optimize_speed(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_screenshot_optimize_speed.png") def test_screenshot_optimize_quality(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -83,7 +85,7 @@ def test_screenshot_optimize_quality(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_screenshot_optimize_quality.png") def test_network_idle_on(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -93,7 +95,7 @@ def test_network_idle_on(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_network_idle_on.png") def test_network_idle_off(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -103,7 +105,7 @@ def test_network_idle_off(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_network_idle_off.png") def test_status_codes(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -113,7 +115,7 @@ def test_status_codes(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_status_codes.png") def test_status_codes_empty(self, client: GotenbergClient): with client.chromium.screenshot_url() as route: @@ -123,7 +125,7 @@ def test_status_codes_empty(self, client: GotenbergClient): 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) + resp.to_file(SAVE_DIR / "test_status_codes_empty.png") class TestChromiumScreenshotsFromMarkdown: diff --git a/tests/test_convert_libre_office.py b/tests/test_convert_libre_office.py index f61b0f5..0da37a3 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,6 +9,8 @@ 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 @@ -28,7 +29,22 @@ def test_libre_office_convert_docx_format(self, client: GotenbergClient): assert resp.headers["Content-Type"] == "application/pdf" if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_docx_format.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_libre_office_convert_docx_format.pdf") + + def test_libre_office_convert_docx_format_for_coverage(self, client: GotenbergClient): # pragma: no cover + test_file = SAMPLE_DIR / "sample.docx" + with client.libre_office.to_pdf() as route: + try: + resp = route.convert(test_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: + resp.to_file(SAVE_DIR / "test_libre_office_convert_docx_format_for_coverage.pdf") def test_libre_office_convert_odt_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.odt" @@ -40,7 +56,7 @@ def test_libre_office_convert_odt_format(self, client: GotenbergClient): assert resp.headers["Content-Type"] == "application/pdf" if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_odt_format.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_libre_office_convert_odt_format.pdf") def test_libre_office_convert_xlsx_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.xlsx" @@ -52,7 +68,7 @@ def test_libre_office_convert_xlsx_format(self, client: GotenbergClient): assert resp.headers["Content-Type"] == "application/pdf" if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_xlsx_format.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_libre_office_convert_xlsx_format.pdf") def test_libre_office_convert_ods_format(self, client: GotenbergClient): test_file = SAMPLE_DIR / "sample.ods" @@ -64,9 +80,9 @@ def test_libre_office_convert_ods_format(self, client: GotenbergClient): assert resp.headers["Content-Type"] == "application/pdf" if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_ods_format.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_libre_office_convert_ods_format.pdf") - def test_libre_office_convert_multiples_format(self, client: GotenbergClient): + def test_libre_office_convert_multiples_format_no_merge(self, client: GotenbergClient, temporary_dir: 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() @@ -75,9 +91,15 @@ def test_libre_office_convert_multiples_format(self, client: GotenbergClient): 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(temporary_dir) + + assert len(list(temporary_dir.iterdir())) == 2 if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_multiples_format.zip").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_libre_office_convert_multiples_format_no_merge.zip") def test_libre_office_convert_multiples_format_merged(self, client: GotenbergClient): with client.libre_office.to_pdf() as route: @@ -86,9 +108,10 @@ def test_libre_office_convert_multiples_format_merged(self, client: GotenbergCli 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) + resp.to_file(SAVE_DIR / "test_libre_office_convert_multiples_format_merged.pdf") def test_libre_office_convert_std_lib_mime(self, client: GotenbergClient): with patch("gotenberg_client._utils.guess_mime_type") as mocked_guess_mime_type: @@ -105,7 +128,7 @@ def test_libre_office_convert_std_lib_mime(self, client: GotenbergClient): assert resp.headers["Content-Type"] == "application/zip" if SAVE_OUTPUTS: - (SAVE_DIR / "test_libre_office_convert_multiples_format.zip").write_bytes(resp.content) + resp.to_file(SAVE_DIR / "test_libre_office_convert_std_lib_mime.pdf") @pytest.mark.parametrize( ("gt_format", "pike_format"), @@ -114,6 +137,7 @@ def test_libre_office_convert_std_lib_mime(self, client: GotenbergClient): def test_libre_office_convert_xlsx_format_pdfa( self, client: GotenbergClient, + temporary_dir: Path, gt_format: PdfAFormat, pike_format: str, ): @@ -125,12 +149,11 @@ def test_libre_office_convert_xlsx_format_pdfa( 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 + output = temporary_dir / "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 if SAVE_OUTPUTS: - (SAVE_DIR / f"test_libre_office_convert_xlsx_format_{pike_format}.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / f"test_libre_office_convert_xlsx_format_pdfa-{pike_format}.pdf") diff --git a/tests/test_convert_pdf_a.py b/tests/test_convert_pdf_a.py index 03da8a1..11627cd 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 @@ -23,6 +22,7 @@ class TestPdfAConvert: def test_pdf_a_single_file( self, client: GotenbergClient, + temporary_dir: Path, gt_format: PdfAFormat, pike_format: str, ): @@ -34,32 +34,31 @@ def test_pdf_a_single_file( 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 + output = temporary_dir / "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 if SAVE_OUTPUTS: - (SAVE_DIR / f"test_pdf_a_single_file{pike_format}.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / f"test_pdf_a_single_file{pike_format}.pdf") @pytest.mark.parametrize("gt_format", [PdfAFormat.A2b, PdfAFormat.A3b]) def test_pdf_a_multiple_file( self, client: GotenbergClient, + temporary_dir: 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() + test_file = SAMPLE_DIR / "sample1.pdf" + other_test_file = temporary_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() - 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, diff --git a/tests/test_merge.py b/tests/test_merge.py index 67fdbc8..3b5b07c 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 @@ -25,6 +24,7 @@ class TestMergePdfs: def test_merge_files_pdf_a( self, client: GotenbergClient, + temporary_dir: Path, gt_format: PdfAFormat, pike_format: str, ): @@ -40,19 +40,19 @@ 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 + output = temporary_dir / "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 if SAVE_OUTPUTS: - (SAVE_DIR / f"test_libre_office_convert_xlsx_format_{pike_format}.pdf").write_bytes(resp.content) + resp.to_file(SAVE_DIR / f"test_libre_office_convert_xlsx_format_{pike_format}.pdf") def test_merge_multiple_file( self, client: GotenbergClient, + temporary_dir: Path, ): if shutil.which("pdftotext") is None: # pragma: no cover pytest.skip("No pdftotext executable found") @@ -67,15 +67,15 @@ def test_merge_multiple_file( assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - with tempfile.NamedTemporaryFile(mode="wb") as tmp: - tmp.write(resp.content) + out_file = temporary_dir / "test.pdf" + resp.to_file(out_file) - 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] + 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] - if SAVE_OUTPUTS: - (SAVE_DIR / "test_pdf_a_multiple_file.pdf").write_bytes(resp.content) + if SAVE_OUTPUTS: + resp.to_file(SAVE_DIR / "test_pdf_a_multiple_file.pdf") diff --git a/tests/test_misc_stuff.py b/tests/test_misc_stuff.py index 04eb523..c0e4034 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,7 +13,9 @@ from httpx import codes from pytest_httpx import HTTPXMock +from gotenberg_client import CannotExtractHereError from gotenberg_client import GotenbergClient +from gotenberg_client import ZipFileResponse from tests.conftest import SAMPLE_DIR @@ -59,7 +60,7 @@ 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, temporary_dir: 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 @@ -67,19 +68,28 @@ def test_libre_office_convert_cyrillic(self, client: GotenbergClient): """ test_file = SAMPLE_DIR / "sample.odt" - with tempfile.TemporaryDirectory() as temp_dir: - copy = shutil.copy( - test_file, - Path(temp_dir) / "Карточка партнера Тауберг Альфа.odt", # noqa: RUF001 - ) + copy = shutil.copy( + test_file, + temporary_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): @@ -141,7 +151,7 @@ def test_webhook_http_methods(self, client: GotenbergClient, httpx_mock: HTTPXMo 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: @@ -156,7 +166,7 @@ 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): httpx_mock.add_response(method="POST", status_code=codes.OK) diff --git a/tests/utils.py b/tests/utils.py index 22c8b49..a6a7fb5 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' From 40d57e14b6f26e6ba6c8d1902275a292de684e86 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Fri, 28 Jun 2024 08:23:00 -0700 Subject: [PATCH 10/22] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fbf7d7..84ed2f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Al routes now return a stronger typed response than just an `httpx.Response` ([#23](https://github.com/stumpylog/gotenberg-client/pull/23)) +- All routes now return a stronger typed response than just an `httpx.Response` ([#23](https://github.com/stumpylog/gotenberg-client/pull/23)) ## [0.6.0] - 2024-06-13 From 455c8323bd503d69baacf1e29d603dd5b4b109bb Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 8 Jul 2024 08:45:33 -0700 Subject: [PATCH 11/22] chore: Sync branches (#26) --- CHANGELOG.md | 3 ++- src/gotenberg_client/options.py | 4 ++-- tests/test_convert_chromium_html.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ed2f1..8e0fdde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `mike` deployment mis-ordered the version and alias, this has been corrected -- `mypy` wasn't running correctly or in CI +- `mypy` wasn't running correctly in CI +- Wrong paper size preset for A4 ([#24](https://github.com/stumpylog/gotenberg-client/pull/24)) ### Added diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index 6447e17..4fe3148 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -65,10 +65,10 @@ def to_form(self) -> Dict[str, str]: A1: Final = PageSize(width=23.4, height=33.1) A2: Final = PageSize(width=16.54, height=23.4) A3: Final = PageSize(width=11.7, height=16.54) -A4: Final = PageSize(width=8.5, height=11) +A4: Final = PageSize(width=8.27, height=11.7) A5: Final = PageSize(width=5.83, height=8.27) A6: Final = PageSize(width=4.13, height=5.83) -Letter = A4 +Letter: Final = PageSize(width=8.5, height=11) Legal: Final = PageSize(width=8.5, height=14) Tabloid: Final = PageSize(width=11, height=17) Ledge: Final = PageSize(width=17, height=11) diff --git a/tests/test_convert_chromium_html.py b/tests/test_convert_chromium_html.py index 2c8c46c..d139753 100644 --- a/tests/test_convert_chromium_html.py +++ b/tests/test_convert_chromium_html.py @@ -98,8 +98,8 @@ def test_convert_page_size(self, client: GotenbergClient, httpx_mock: HTTPXMock) _ = route.index(test_file).size(A4).run() request = httpx_mock.get_request() - verify_stream_contains("paperWidth", "8.5", request.stream) - verify_stream_contains("paperHeight", "11", request.stream) + 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): httpx_mock.add_response(method="POST") From 448ba866d1c862979e31564fdf0454c584233663 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 09:10:29 -0700 Subject: [PATCH 12/22] Bump pypa/gh-action-pypi-publish from 1.8.14 to 1.9.0 (#25) * Bump pypa/gh-action-pypi-publish from 1.8.14 to 1.9.0 Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.14 to 1.9.0. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.14...v1.9.0) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Updates changelog note --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffdffed..ff688a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,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.9.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e0fdde..582a3e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All routes now return a stronger typed response than just an `httpx.Response` ([#23](https://github.com/stumpylog/gotenberg-client/pull/23)) +### 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)) + ## [0.6.0] - 2024-06-13 ### Breaking Change From 31f13f3cb41e0ce721e810c7a20c8cf8e3117dee Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Mon, 30 Sep 2024 08:09:09 -0700 Subject: [PATCH 13/22] Locks pytest-httpx to 0.30.0 for the moment --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6a08d82..73fc3bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ dependencies = [ ] 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", @@ -121,7 +121,7 @@ dependencies = [ "httpx", "pytest", "pikepdf", - "pytest-httpx" + "pytest-httpx == 0.30.0" ] [tool.hatch.envs.typing.scripts] From f43d3f3946902dfc68cd1ad4e0cdcbaa37caf28e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 15:18:43 +0000 Subject: [PATCH 14/22] Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.2 (#31) * Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.2 Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.9.0 to 1.10.2. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.9.0...v1.10.2) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Updates the changelog with new updates --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff688a7..b5f8d6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -297,4 +297,4 @@ jobs: path: dist - name: Publish build to PyPI - uses: pypa/gh-action-pypi-publish@v1.9.0 + uses: pypa/gh-action-pypi-publish@v1.10.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 582a3e0..67b8fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) ## [0.6.0] - 2024-06-13 From ebaf57cb4f50a44a1ae05d824a4c16db4eea3c78 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 30 Sep 2024 08:32:18 -0700 Subject: [PATCH 15/22] chore: Runs CI testing against Gotenberg 8.11 now (#32) --- .docker/docker-compose.ci-test.yml | 2 +- CHANGELOG.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/CHANGELOG.md b/CHANGELOG.md index 67b8fa4..66d26b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) ## [0.6.0] - 2024-06-13 From 601a6dd9ff3e76ad3037c03d2ff29fb814d749cc Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Mon, 30 Sep 2024 08:36:33 -0700 Subject: [PATCH 16/22] Updates development tool versions and pre-commit hook versions --- .pre-commit-config.yaml | 7 ++++--- CHANGELOG.md | 1 + pyproject.toml | 20 ++++++++++---------- tests/conftest.py | 4 ++-- tests/utils.py | 4 ++-- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30e44d0..44760a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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,7 +45,7 @@ repos: - id: codespell # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.4.9' + rev: 'v0.6.8' hooks: # Run the linter. - id: ruff diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d26b5..83ada4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 ## [0.6.0] - 2024-06-13 diff --git a/pyproject.toml b/pyproject.toml index 73fc3bb..f94a7ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ installer = "uv" [tool.hatch.envs.hatch-static-analysis] # https://hatch.pypa.io/latest/config/internal/static-analysis/ -dependencies = ["ruff ~= 0.4.9"] +dependencies = ["ruff ~= 0.6.8"] config-path = "none" [tool.hatch.envs.hatch-test] @@ -74,13 +74,13 @@ 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", @@ -117,7 +117,7 @@ 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", @@ -134,7 +134,7 @@ run = [ template = "pre-commit" detached = true dependencies = [ - "pre-commit ~= 3.7.0", + "pre-commit ~= 3.8", ] [tool.hatch.envs.pre-commit.scripts] @@ -145,9 +145,9 @@ update = ["pre-commit autoupdate"] 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] diff --git a/tests/conftest.py b/tests/conftest.py index 5bfdd08..0e1cb44 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,13 +24,13 @@ SAVE_DIR.mkdir() -@pytest.fixture() +@pytest.fixture def client() -> Generator[GotenbergClient, None, None]: with GotenbergClient(host=GOTENBERG_URL, log_level=logging.INFO) as client: yield client -@pytest.fixture() +@pytest.fixture def temporary_dir() -> Generator[Path, None, None]: """ Creates and cleans up a temporary directory for tests diff --git a/tests/utils.py b/tests/utils.py index a6a7fb5..53f6e45 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -36,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", From 2649b273b5bd4e45d37a477ed0dd63f88e9c13b5 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 30 Sep 2024 10:11:02 -0700 Subject: [PATCH 17/22] Feature: Add method docstrings to basically everything (#33) --- CHANGELOG.md | 1 + src/gotenberg_client/__init__.py | 10 +- src/gotenberg_client/_base.py | 68 +++++- src/gotenberg_client/_client.py | 71 +++++- src/gotenberg_client/_convert/chromium.py | 206 +++++++++++++++++- src/gotenberg_client/_convert/libre_office.py | 71 +++++- src/gotenberg_client/_convert/pdfa.py | 40 +++- src/gotenberg_client/_errors.py | 19 ++ src/gotenberg_client/_health.py | 28 ++- src/gotenberg_client/_merge.py | 42 +++- src/gotenberg_client/_utils.py | 45 +++- src/gotenberg_client/options.py | 185 ++++++++++++---- tests/test_misc_stuff.py | 3 +- 13 files changed, 699 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ada4e..ae2d10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) ### Changed diff --git a/src/gotenberg_client/__init__.py b/src/gotenberg_client/__init__.py index 465635e..d1f211d 100644 --- a/src/gotenberg_client/__init__.py +++ b/src/gotenberg_client/__init__.py @@ -4,7 +4,15 @@ 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", "SingleFileResponse", "ZipFileResponse", "BaseClientError", "CannotExtractHereError"] +__all__ = [ + "GotenbergClient", + "SingleFileResponse", + "ZipFileResponse", + "BaseClientError", + "CannotExtractHereError", + "MaxRetriesExceededError", +] diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 2de606c..8de6439 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -16,6 +16,8 @@ from httpx import Response from httpx._types import RequestFiles +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 @@ -26,10 +28,6 @@ logger = logging.getLogger(__name__) -class UnreachableCodeError(Exception): - pass - - class PdfFormatMixin: """ https://gotenberg.dev/docs/routes#pdfa-chromium @@ -156,7 +154,7 @@ def _base_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) @@ -226,6 +224,17 @@ def output_name(self, filename: str) -> Self: class BaseSingleFileResponseRoute(_BaseRoute): 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) @@ -237,6 +246,25 @@ def run_with_retry( 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, @@ -248,6 +276,17 @@ def run_with_retry( 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) @@ -259,6 +298,25 @@ def run_with_retry( 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, diff --git a/src/gotenberg_client/_client.py b/src/gotenberg_client/_client.py index 1ac29d5..1c53259 100644 --- a/src/gotenberg_client/_client.py +++ b/src/gotenberg_client/_client.py @@ -20,7 +20,18 @@ 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 e7deaa2..50aecd3 100644 --- a/src/gotenberg_client/_convert/chromium.py +++ b/src/gotenberg_client/_convert/chromium.py @@ -65,27 +65,85 @@ class UrlRoute( 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: - return FORCE_MULTIPART + """ + 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 # Assuming FORCE_MULTIPART is a pre-defined empty dictionary 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 @@ -102,53 +160,143 @@ class ScreenshotRoute( 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 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: + """ + 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 +305,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/libre_office.py b/src/gotenberg_client/_convert/libre_office.py index 97ffc0a..f1779e1 100644 --- a/src/gotenberg_client/_convert/libre_office.py +++ b/src/gotenberg_client/_convert/libre_office.py @@ -19,42 +19,82 @@ 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 __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, file_path: Path) -> Self: + 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) - self._result_is_zip = True return 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 @@ -85,10 +125,21 @@ def run_with_retry( # type: ignore[override] 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 9f35f48..deee746 100644 --- a/src/gotenberg_client/_convert/pdfa.py +++ b/src/gotenberg_client/_convert/pdfa.py @@ -11,24 +11,60 @@ 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 index 5804520..aa02269 100644 --- a/src/gotenberg_client/_errors.py +++ b/src/gotenberg_client/_errors.py @@ -1,6 +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 601c43f..15f60a2 100644 --- a/src/gotenberg_client/_merge.py +++ b/src/gotenberg_client/_merge.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: MPL-2.0 from pathlib import Path +from typing import Final from typing import List from httpx import Client @@ -13,18 +14,49 @@ 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]) -> 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 @@ -38,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/_utils.py b/src/gotenberg_client/_utils.py index 87d82e7..97cfb2e 100644 --- a/src/gotenberg_client/_utils.py +++ b/src/gotenberg_client/_utils.py @@ -6,6 +6,7 @@ from typing import Dict from typing import Final from typing import Optional +from typing import Union from gotenberg_client._types import FormFieldType @@ -18,8 +19,16 @@ def __bool__(self) -> bool: 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 {} @@ -27,23 +36,41 @@ def optional_to_form(value: Optional[FormFieldType], name: str) -> Dict[str, 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 4fe3148..1e95668 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -6,7 +6,6 @@ from typing import Dict from typing import Final from typing import Optional -from warnings import warn from gotenberg_client._types import MarginSizeType from gotenberg_client._types import PageSizeType @@ -15,46 +14,97 @@ @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: + """ + 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,45 +149,89 @@ class MarginUnitType(str, enum.Enum): @dataclasses.dataclass class MarginType: + """ + 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: - # mypy claims the string is of type "Any" - form_data.update(optional_to_form(f"{attr.value}{attr.unit.value}", name)) # type: ignore[misc] + 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. + """ + + return {"emulatedMediaType": self.name.lower()} diff --git a/tests/test_misc_stuff.py b/tests/test_misc_stuff.py index c0e4034..eaf163c 100644 --- a/tests/test_misc_stuff.py +++ b/tests/test_misc_stuff.py @@ -15,6 +15,7 @@ from gotenberg_client import CannotExtractHereError from gotenberg_client import GotenbergClient +from gotenberg_client import MaxRetriesExceededError from gotenberg_client import ZipFileResponse from tests.conftest import SAMPLE_DIR @@ -107,7 +108,7 @@ def test_server_error_retry(self, client: GotenbergClient, httpx_mock: HTTPXMock test_file = SAMPLE_DIR / "basic.html" with client.chromium.html_to_pdf() as route: - with pytest.raises(HTTPStatusError) as exc_info: + with pytest.raises(MaxRetriesExceededError) 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 From 7dfe72c6eaa9c172ef43dbbc7307116ea3be418b Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Mon, 30 Sep 2024 13:00:06 -0700 Subject: [PATCH 18/22] Chore: Use `pytest` fixtures in place of custom globals, etc (#34) --- CHANGELOG.md | 1 + tests/conftest.py | 137 ++++++++++++++++++--- tests/test_convert_chromium_html.py | 74 +++++------ tests/test_convert_chromium_markdown.py | 27 ++-- tests/test_convert_chromium_screenshots.py | 98 +++++++-------- tests/test_convert_chromium_url.py | 37 ++++-- tests/test_convert_libre_office.py | 105 +++++++--------- tests/test_convert_pdf_a.py | 39 +++--- tests/test_merge.py | 23 ++-- tests/test_misc_stuff.py | 42 +++---- 10 files changed, 329 insertions(+), 254 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2d10c..84a4d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) ## [0.6.0] - 2024-06-13 diff --git a/tests/conftest.py b/tests/conftest.py index 0e1cb44..9cf8c88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,36 +4,137 @@ import logging import os import shutil -import tempfile 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(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 client() -> Generator[GotenbergClient, None, None]: - with GotenbergClient(host=GOTENBERG_URL, log_level=logging.INFO) as client: - yield client +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 temporary_dir() -> Generator[Path, None, None]: - """ - Creates and cleans up a temporary directory for tests - """ - with tempfile.TemporaryDirectory() as tmp_dir: - yield Path(tmp_dir).resolve() +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 d139753..8838e36 100644 --- a/tests/test_convert_chromium_html.py +++ b/tests/test_convert_chromium_html.py @@ -15,53 +15,53 @@ 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: - resp.to_file(SAVE_DIR / "test_basic_convert.pdf") - - 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(test_file).header(header_file).footer(footer_file).run_with_retry() + 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): - test_file = SAMPLE_DIR / "complex.html" - img = SAMPLE_DIR / "img.gif" - font = SAMPLE_DIR / "font.woff" - style = SAMPLE_DIR / "style.css" - + 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).resource(img).resource(font).resource(style).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" - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / "test_convert_additional_files.pdf") - @pytest.mark.parametrize( ("gt_format", "pike_format"), [(PdfAFormat.A2b, "2B"), (PdfAFormat.A3b, "3B")], @@ -69,20 +69,19 @@ def test_convert_additional_files(self, client: GotenbergClient): def test_convert_pdfa_format( self, client: GotenbergClient, - temporary_dir: Path, + basic_html_file: Path, + tmp_path: Path, gt_format: PdfAFormat, pike_format: str, ): - test_file = SAMPLE_DIR / "basic.html" - 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" - output = temporary_dir / "test_convert_pdfa_format.pdf" + output = tmp_path / "test_convert_pdfa_format.pdf" resp.to_file(output) with pikepdf.open(output) as pdf: meta = pdf.open_metadata() @@ -90,9 +89,9 @@ def test_convert_pdfa_format( 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() @@ -101,9 +100,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: _ = ( @@ -125,9 +124,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() @@ -142,11 +141,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..002f8cc 100644 --- a/tests/test_convert_chromium_markdown.py +++ b/tests/test_convert_chromium_markdown.py @@ -1,21 +1,32 @@ # 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(index).markdown_files(md_files).resources([img, font]).resource(style).run_with_retry() + 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 diff --git a/tests/test_convert_chromium_screenshots.py b/tests/test_convert_chromium_screenshots.py index dd95cb6..4e3619a 100644 --- a/tests/test_convert_chromium_screenshots.py +++ b/tests/test_convert_chromium_screenshots.py @@ -1,151 +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: - resp.to_file(SAVE_DIR / "test_basic_screenshot.png") @pytest.mark.parametrize( "image_format", ["png", "webp", "jpeg"], ) - def test_screenshot_formats(self, client: GotenbergClient, image_format: Literal["png", "webp", "jpeg"]): + 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: - resp.to_file(SAVE_DIR / "test_basic_screenshot.png") - 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: - resp.to_file(SAVE_DIR / "test_screenshot_quality_valid.png") - 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: - resp.to_file(SAVE_DIR / "test_screenshot_quality_too_low.png") - 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: - resp.to_file(SAVE_DIR / "test_screenshot_quality_too_high.png") - 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: - resp.to_file(SAVE_DIR / "test_screenshot_optimize_speed.png") - 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: - resp.to_file(SAVE_DIR / "test_screenshot_optimize_quality.png") - 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: - resp.to_file(SAVE_DIR / "test_network_idle_on.png") - 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: - resp.to_file(SAVE_DIR / "test_network_idle_off.png") - 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: - resp.to_file(SAVE_DIR / "test_status_codes.png") - 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: - resp.to_file(SAVE_DIR / "test_status_codes_empty.png") 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 228347d..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( @@ -213,12 +222,13 @@ def test_convert_url_render_expression( 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( @@ -230,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") @@ -237,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 0da37a3..5b71ce4 100644 --- a/tests/test_convert_libre_office.py +++ b/tests/test_convert_libre_office.py @@ -13,29 +13,25 @@ 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: - resp.to_file(SAVE_DIR / "test_libre_office_convert_docx_format.pdf") - - def test_libre_office_convert_docx_format_for_coverage(self, client: GotenbergClient): # pragma: no cover - test_file = SAMPLE_DIR / "sample.docx" + def test_libre_office_convert_docx_format_for_coverage( + self, + client: GotenbergClient, + docx_sample_file: Path, + ): with client.libre_office.to_pdf() as route: try: - resp = route.convert(test_file).run() + resp = route.convert(docx_sample_file).run() except: # noqa: E722 - this is only for coverage return @@ -43,50 +39,39 @@ def test_libre_office_convert_docx_format_for_coverage(self, client: GotenbergCl assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / "test_libre_office_convert_docx_format_for_coverage.pdf") - - def test_libre_office_convert_odt_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.odt" + 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: - resp.to_file(SAVE_DIR / "test_libre_office_convert_odt_format.pdf") - - def test_libre_office_convert_xlsx_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.xlsx" + 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: - resp.to_file(SAVE_DIR / "test_libre_office_convert_xlsx_format.pdf") - - def test_libre_office_convert_ods_format(self, client: GotenbergClient): - test_file = SAMPLE_DIR / "sample.ods" + 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(test_file).run_with_retry() + 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" - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / "test_libre_office_convert_ods_format.pdf") - - def test_libre_office_convert_multiples_format_no_merge(self, client: GotenbergClient, temporary_dir: Path): + 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 @@ -94,42 +79,39 @@ def test_libre_office_convert_multiples_format_no_merge(self, client: GotenbergC assert isinstance(resp, ZipFileResponse) assert resp.is_zip - resp.extract_to(temporary_dir) + resp.extract_to(tmp_path) - assert len(list(temporary_dir.iterdir())) == 2 + assert len(list(tmp_path.iterdir())) == 2 - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / "test_libre_office_convert_multiples_format_no_merge.zip") - - 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: - resp.to_file(SAVE_DIR / "test_libre_office_convert_multiples_format_merged.pdf") - - 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: - resp.to_file(SAVE_DIR / "test_libre_office_convert_std_lib_mime.pdf") - @pytest.mark.parametrize( ("gt_format", "pike_format"), [(PdfAFormat.A2b, "2B"), (PdfAFormat.A3b, "3B")], @@ -137,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, - temporary_dir: Path, + 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" - output = temporary_dir / "test_libre_office_convert_xlsx_format_pdfa.pdf" + 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 - - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / f"test_libre_office_convert_xlsx_format_pdfa-{pike_format}.pdf") diff --git a/tests/test_convert_pdf_a.py b/tests/test_convert_pdf_a.py index 11627cd..11d2880 100644 --- a/tests/test_convert_pdf_a.py +++ b/tests/test_convert_pdf_a.py @@ -9,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: @@ -22,39 +19,36 @@ class TestPdfAConvert: def test_pdf_a_single_file( self, client: GotenbergClient, - temporary_dir: Path, + 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" - output = temporary_dir / "test_libre_office_convert_xlsx_format_pdfa.pdf" + 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 - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / f"test_pdf_a_single_file{pike_format}.pdf") - @pytest.mark.parametrize("gt_format", [PdfAFormat.A2b, PdfAFormat.A3b]) def test_pdf_a_multiple_file( self, client: GotenbergClient, - temporary_dir: Path, + pdf_sample_one_file: Path, + tmp_path: Path, gt_format: PdfAFormat, ): - test_file = SAMPLE_DIR / "sample1.pdf" - other_test_file = temporary_dir / "sample2.pdf" - other_test_file.write_bytes(test_file.read_bytes()) + 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([test_file, other_test_file]).pdf_format(gt_format).run_with_retry() + 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 @@ -63,10 +57,12 @@ def test_pdf_a_multiple_file( 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 @@ -75,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 3b5b07c..4eb1862 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -10,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 @@ -24,13 +21,14 @@ class TestMergePdfs: def test_merge_files_pdf_a( self, client: GotenbergClient, - temporary_dir: Path, + 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" - output = temporary_dir / "test_merge_files_pdf_a.pdf" + 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 - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / f"test_libre_office_convert_xlsx_format_{pike_format}.pdf") - def test_merge_multiple_file( self, client: GotenbergClient, - temporary_dir: Path, + sample_directory: Path, + tmp_path: Path, ): if shutil.which("pdftotext") is None: # pragma: no cover pytest.skip("No pdftotext executable found") @@ -60,14 +56,14 @@ 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" - out_file = temporary_dir / "test.pdf" + out_file = tmp_path / "test.pdf" resp.to_file(out_file) text = extract_text(out_file) @@ -76,6 +72,3 @@ def test_merge_multiple_file( assert len(lines) == 3 assert "first PDF to be merged." in lines[0] assert "second PDF to be merged." in lines[1] - - if SAVE_OUTPUTS: - resp.to_file(SAVE_DIR / "test_pdf_a_multiple_file.pdf") diff --git a/tests/test_misc_stuff.py b/tests/test_misc_stuff.py index eaf163c..5ef624b 100644 --- a/tests/test_misc_stuff.py +++ b/tests/test_misc_stuff.py @@ -17,18 +17,18 @@ from gotenberg_client import GotenbergClient from gotenberg_client import MaxRetriesExceededError from gotenberg_client import ZipFileResponse -from tests.conftest import SAMPLE_DIR 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, ) @@ -44,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, ) @@ -61,17 +62,15 @@ 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, temporary_dir: Path): + 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( - test_file, - temporary_dir / "Карточка партнера Тауберг Альфа.odt", # noqa: RUF001 + odt_sample_file, + tmp_path / "Карточка партнера Тауберг Альфа.odt", # noqa: RUF001 ) with client.libre_office.to_pdf() as route: @@ -93,7 +92,7 @@ def test_extract_to_not_existing(self) -> None: 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 @@ -105,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(MaxRetriesExceededError) 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.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() @@ -146,7 +140,7 @@ 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") @@ -154,9 +148,8 @@ def test_webhook_http_methods(self, client: GotenbergClient, httpx_mock: HTTPXMo client.add_error_webhook_url("http://myapi:3000/on-error") 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() @@ -169,7 +162,7 @@ def test_webhook_http_methods(self, client: GotenbergClient, httpx_mock: HTTPXMo assert "Gotenberg-Webhook-Error-Method" in request.headers 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"} @@ -177,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() From 0313a91709aba3be1282460993f857efab8537f3 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 8 Oct 2024 13:53:32 -0700 Subject: [PATCH 19/22] Allow providing a string as an HTML, Markdown or other text based resource (#30) --- .codecov.yml | 12 +++ CHANGELOG.md | 2 + src/gotenberg_client/_base.py | 53 ++++++++----- src/gotenberg_client/_convert/chromium.py | 96 +++++++++++++++++++++-- src/gotenberg_client/_convert/common.py | 4 +- src/gotenberg_client/_merge.py | 2 +- tests/test_convert_chromium_html.py | 30 +++++++ tests/test_convert_chromium_markdown.py | 28 +++++++ 8 files changed, 195 insertions(+), 32 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index 84a4d5d..ff89800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 8de6439..1f2131e 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -4,11 +4,11 @@ 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 httpx import Client @@ -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] = {} @@ -109,7 +111,12 @@ def _base_run(self) -> Response: Executes the configured route against the server and returns the resulting Response. """ - 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 @@ -166,27 +173,35 @@ def _base_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] - def _add_file_map(self, filepath: Path, name: Optional[str] = None) -> None: + return resources + + 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 @@ -198,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 +231,9 @@ def output_name(self, filename: str) -> 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. diff --git a/src/gotenberg_client/_convert/chromium.py b/src/gotenberg_client/_convert/chromium.py index 50aecd3..4e176f3 100644 --- a/src/gotenberg_client/_convert/chromium.py +++ b/src/gotenberg_client/_convert/chromium.py @@ -5,6 +5,10 @@ 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 BaseSingleFileResponseRoute @@ -26,20 +30,95 @@ 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) -> Self: - self._add_file_map(resource) + 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). + + 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, @@ -94,15 +173,14 @@ def url(self, url: str) -> Self: 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 # Assuming FORCE_MULTIPART is a pre-defined empty dictionary + return FORCE_MULTIPART class MarkdownRoute(PagePropertiesMixin, HeaderFooterMixin, _RouteWithResources, _FileBasedRoute): @@ -178,6 +256,9 @@ class ScreenshotRoute( _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. @@ -257,14 +338,13 @@ def url(self, url: str) -> Self: 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 diff --git a/src/gotenberg_client/_convert/common.py b/src/gotenberg_client/_convert/common.py index 824f088..5f89381 100644 --- a/src/gotenberg_client/_convert/common.py +++ b/src/gotenberg_client/_convert/common.py @@ -145,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 diff --git a/src/gotenberg_client/_merge.py b/src/gotenberg_client/_merge.py index 15f60a2..d08ddf5 100644 --- a/src/gotenberg_client/_merge.py +++ b/src/gotenberg_client/_merge.py @@ -60,7 +60,7 @@ def merge(self, files: List[Path]) -> Self: """ 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 diff --git a/tests/test_convert_chromium_html.py b/tests/test_convert_chromium_html.py index 8838e36..ed3d1d9 100644 --- a/tests/test_convert_chromium_html.py +++ b/tests/test_convert_chromium_html.py @@ -62,6 +62,16 @@ def test_convert_additional_files( assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" + 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.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" + @pytest.mark.parametrize( ("gt_format", "pike_format"), [(PdfAFormat.A2b, "2B"), (PdfAFormat.A3b, "3B")], @@ -87,6 +97,26 @@ def test_convert_pdfa_format( 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, sample_directory: Path, httpx_mock: HTTPXMock): diff --git a/tests/test_convert_chromium_markdown.py b/tests/test_convert_chromium_markdown.py index 002f8cc..8d4a3c8 100644 --- a/tests/test_convert_chromium_markdown.py +++ b/tests/test_convert_chromium_markdown.py @@ -31,3 +31,31 @@ def test_basic_convert( 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(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 + assert resp.headers["Content-Type"] == "application/pdf" From e37d38d7270fec1e835732f5b76aed0f1ab4a525 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:16:26 -0700 Subject: [PATCH 20/22] Chore: Upgrade pre-commit and hooks (#35) --- .pre-commit-config.yaml | 8 ++- CHANGELOG.md | 3 +- pyproject.toml | 133 +++++++++++++++++++++------------------- 3 files changed, 77 insertions(+), 67 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44760a9..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 @@ -45,9 +45,13 @@ repos: - id: codespell # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.6.8' + 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 ff89800..f280d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 ([#24](https://github.com/stumpylog/gotenberg-client/pull/24)) +- Wrong paper size preset for A4 by [@mannerydhe](https://github.com/mannerydhe) ([#24](https://github.com/stumpylog/gotenberg-client/pull/24)) ### Added @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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/pyproject.toml b/pyproject.toml index f94a7ca..0d83511 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,29 +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", + "Environment :: Web Environment", + "Intended Audience :: Developers", "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", "Operating System :: OS Independent", - "Intended Audience :: Developers", - "Environment :: Web Environment", "Programming Language :: Python", - "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -33,31 +33,30 @@ 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" - -[project.optional-dependencies] -magic = ["python-magic"] +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" [tool.hatch.build.targets.sdist] exclude = [ ".github", - ".docker" + ".docker", ] [tool.hatch.envs.default] @@ -65,7 +64,7 @@ installer = "uv" [tool.hatch.envs.hatch-static-analysis] # https://hatch.pypa.io/latest/config/internal/static-analysis/ -dependencies = ["ruff ~= 0.6.8"] +dependencies = [ "ruff ~= 0.6" ] config-path = "none" [tool.hatch.envs.hatch-test] @@ -89,27 +88,28 @@ extra-dependencies = [ "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 @@ -121,25 +121,26 @@ dependencies = [ "httpx", "pytest", "pikepdf", - "pytest-httpx == 0.30.0" + "pytest-httpx == 0.30.0", ] [tool.hatch.envs.typing.scripts] run = [ "mypy --version", - "mypy --install-types --non-interactive ." + "mypy --install-types --non-interactive .", ] [tool.hatch.envs.pre-commit] template = "pre-commit" detached = true dependencies = [ - "pre-commit ~= 3.8", + "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" @@ -147,34 +148,34 @@ detached = true dependencies = [ "mkdocs-material[imaging] ~= 9.5", "mike ~= 2.1", - "mkdocs-minify-plugin ~= 0.8" + "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 {args} latest", - "mike set-default --branch gh-pages --remote origin --push 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", @@ -196,15 +197,15 @@ extend-select = [ "ISC", "N", "PERF", - "PIE", "PGH", - "PTH", + "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", + "PTH", "Q", "RSE", "RUF", @@ -222,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 = [ @@ -264,8 +269,8 @@ omit = [ ] [tool.coverage.paths] -gotenberg_client = ["src/gotenberg_client"] -tests = ["tests"] +gotenberg_client = [ "src/gotenberg_client" ] +tests = [ "tests" ] [tool.coverage.report] exclude_lines = [ From 067403e8852e56463772232863ca271d8191087d Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:23:48 -0700 Subject: [PATCH 21/22] Adds a favicon so Nginix won't log a warning about it --- .docker/content/favicon.ico | Bin 0 -> 4286 bytes docs/maybe-logo.webp | Bin 0 -> 103006 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .docker/content/favicon.ico create mode 100644 docs/maybe-logo.webp diff --git a/.docker/content/favicon.ico b/.docker/content/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..67c3073181899efb484b8bd8ffaf975d11b10210 GIT binary patch literal 4286 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 literal 0 HcmV?d00001 From b006870334c7b7f5b5c1681acaee08fecdeae366 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:25:14 -0700 Subject: [PATCH 22/22] Bumps version to 0.7.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 f280d19..5399e05 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.7.0] - 2024-10-08 ### Fixed 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"