From 0d5215ca08de9ff6ca25ed5c3dd08871f1777a64 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:40:39 -0700 Subject: [PATCH 1/9] Cleans up the roadmap which was out of date --- docs/roadmap.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 56fce67..b2e8fff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,12 +1,3 @@ # Roadmap -## Custom Response Class - -Currently, the response returned is a basic httpx.Response. It could be useful to abstract this in some manner, especially for responses which return a zip file. - -- Ability to iterate through the zip file contents in some way -- Ability to write the response to some given output location? - -## Missing Routes/Options - -- Missing the configuration of the units of page size, currently it is always inches +While I'm sure there's something out there, nothing is currently on the roadmap. From 36fb3f517465bdbe67ae54b4c0d93563488397b0 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:24:36 -0700 Subject: [PATCH 2/9] Chore: Use pytest-docker for managing the images (#36) --- .github/workflows/ci.yml | 32 +-------- CHANGELOG.md | 6 ++ pyproject.toml | 1 + tests/conftest.py | 67 ++++++++++++++++-- {.docker => tests/docker}/content/favicon.ico | Bin {.docker => tests/docker}/content/index.html | 0 .../docker}/docker-compose.ci-test-edge.yml | 21 +++--- .../docker}/docker-compose.ci-test.yml | 25 +++---- tests/test_convert_chromium_screenshots.py | 45 ++++++------ tests/test_convert_chromium_url.py | 50 ++++++------- 10 files changed, 142 insertions(+), 105 deletions(-) rename {.docker => tests/docker}/content/favicon.ico (100%) rename {.docker => tests/docker}/content/index.html (100%) rename {.docker => tests/docker}/docker-compose.ci-test-edge.yml (60%) rename {.docker => tests/docker}/docker-compose.ci-test.yml (52%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5f8d6f..9261394 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,14 +64,6 @@ jobs: steps: - uses: actions/checkout@v4 - - - name: Start containers - run: | - docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test.yml pull --quiet - docker compose --file ${GITHUB_WORKSPACE}/.docker/docker-compose.ci-test.yml up --detach - echo "Wait for container to be started" - sleep 5 - docker inspect gotenberg-client-test-server - name: Install poppler-utils run: | @@ -97,7 +89,6 @@ jobs: name: Run tests run: | hatch test --cover --python ${{ matrix.python-version }} - ls -ahl . - name: Upload coverage to Codecov if: matrix.python-version == '3.10' @@ -105,12 +96,6 @@ jobs: with: # not required for public repos, but intermittently fails otherwise token: ${{ secrets.CODECOV_TOKEN }} - - - name: Stop containers - if: always() - run: | - 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 @@ -119,17 +104,11 @@ jobs: contents: read needs: - lint + env: + GOTENBERG_CLIENT_EDGE_TEST: 1 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: | @@ -154,13 +133,6 @@ jobs: 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5399e05..74ce323 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] + +### Changed + +- Use `pytest-docker` to manage Docker image services ([#36](https://github.com/stumpylog/gotenberg-client/pull/36)) + ## [0.7.0] - 2024-10-08 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 0d83511..faeef77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ extra-dependencies = [ "pytest-httpx ~= 0.22; python_version < '3.9'", "pikepdf", "python-magic", + "pytest-docker ~= 3.1", ] extra-args = [ "--maxprocesses=8", "--pythonwarnings=all" ] diff --git a/tests/conftest.py b/tests/conftest.py index 9cf8c88..c0bd863 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,21 +8,80 @@ from typing import Generator from typing import Union +import httpx import pytest from gotenberg_client import GotenbergClient from gotenberg_client import SingleFileResponse from gotenberg_client import ZipFileResponse +logger = logging.getLogger("gotenberg-client.tests") + + +def is_responsive(url): + try: + response = httpx.get(url) + except httpx.HTTPError: + logger.exception("Error connecting to service") + return False + else: + return response.status_code == httpx.codes.OK + + +@pytest.fixture(scope="session") +def docker_compose_file() -> Path: + if "GOTENBERG_CLIENT_EDGE_TEST" in os.environ: + return Path(__file__).parent / "docker" / "docker-compose.ci-test-edge.yml" + else: + return Path(__file__).parent / "docker" / "docker-compose.ci-test.yml" + + +@pytest.fixture(scope="session") +def gotenberg_service_name() -> str: + if "GOTENBERG_CLIENT_EDGE_TEST" in os.environ: + return "gotenberg-client-test-edge-server" + else: + return "gotenberg-client-test-edge-server" + @pytest.fixture(scope="session") -def gotenberg_host() -> str: - return os.getenv("GOTENBERG_URL", "http://localhost:3000") +def webserver_service_name() -> str: + if "GOTENBERG_CLIENT_EDGE_TEST" in os.environ: + return "nginx-webserver-edge" + else: + return "nginx-webserver" @pytest.fixture(scope="session") -def web_server_host() -> str: - return os.getenv("WEBSERVER_HOST", "http://localhost:8888") +def webserver_docker_internal_url(webserver_service_name: str) -> str: + """ + The URL by which Gotenberg can access the webserver + """ + return f"http://{webserver_service_name}" + + +@pytest.fixture(scope="session") +def gotenberg_host(docker_services, docker_ip: str, gotenberg_service_name: str) -> str: + url = f"http://{docker_ip}:{docker_services.port_for(gotenberg_service_name, 3000)}" + + docker_services.wait_until_responsive( + timeout=30.0, + pause=1, + check=lambda: is_responsive(f"{url}/version"), + ) + return url + + +@pytest.fixture(scope="session") +def web_server_host(docker_services, docker_ip: str, webserver_service_name: str) -> str: + url = f"http://{docker_ip}:{docker_services.port_for(webserver_service_name, 80)}" + + docker_services.wait_until_responsive( + timeout=30.0, + pause=1, + check=lambda: is_responsive(url), + ) + return url @pytest.fixture(scope="session") diff --git a/.docker/content/favicon.ico b/tests/docker/content/favicon.ico similarity index 100% rename from .docker/content/favicon.ico rename to tests/docker/content/favicon.ico diff --git a/.docker/content/index.html b/tests/docker/content/index.html similarity index 100% rename from .docker/content/index.html rename to tests/docker/content/index.html diff --git a/.docker/docker-compose.ci-test-edge.yml b/tests/docker/docker-compose.ci-test-edge.yml similarity index 60% rename from .docker/docker-compose.ci-test-edge.yml rename to tests/docker/docker-compose.ci-test-edge.yml index 5494b3a..942a3b4 100644 --- a/.docker/docker-compose.ci-test-edge.yml +++ b/tests/docker/docker-compose.ci-test-edge.yml @@ -1,25 +1,24 @@ # docker-compose file for running testing with gotenberg container # Can be used locally or by the CI to start the necessary container with the # correct networking for the tests - -version: "3" +networks: + gotenberg-test-edge-net: services: gotenberg-client-test-edge-server: image: docker.io/gotenberg/gotenberg:edge - hostname: gotenberg-client-test-edge-server - container_name: gotenberg-client-test-edge-server - network_mode: host - restart: unless-stopped + networks: + - gotenberg-test-edge-net + ports: + - "3000/tcp" command: - "gotenberg" - "--log-level=info" - "--log-format=text" - nginx-webserver: + nginx-webserver-edge: image: docker.io/nginx:1-alpine - hostname: nginx-webserver - container_name: nginx-webserver + networks: + - gotenberg-test-edge-net ports: - - "8888:80" - restart: unless-stopped + - "80/tcp" volumes: - ./content:/usr/share/nginx/html:ro diff --git a/.docker/docker-compose.ci-test.yml b/tests/docker/docker-compose.ci-test.yml similarity index 52% rename from .docker/docker-compose.ci-test.yml rename to tests/docker/docker-compose.ci-test.yml index 484b98c..6d71090 100644 --- a/.docker/docker-compose.ci-test.yml +++ b/tests/docker/docker-compose.ci-test.yml @@ -1,27 +1,24 @@ # docker-compose file for running testing with gotenberg container # Can be used locally or by the CI to start the necessary container with the # correct networking for the tests - -version: "3" +networks: + gotenberg-test-net: services: - gotenberg-client-test-server: + gotenberg-client-test-edge-server: image: docker.io/gotenberg/gotenberg:8.11.0 - hostname: gotenberg-client-test-server - container_name: gotenberg-client-test-server - network_mode: host - restart: unless-stopped + networks: + - gotenberg-test-net + ports: + - "3000/tcp" command: - "gotenberg" - - "--log-level=warn" + - "--log-level=info" - "--log-format=text" nginx-webserver: image: docker.io/nginx:1-alpine - hostname: nginx-webserver - container_name: nginx-webserver + networks: + - gotenberg-test-net ports: - - "8888:80" - restart: unless-stopped - environment: - NGINX_ENTRYPOINT_QUIET_LOGS: 1 + - "80/tcp" volumes: - ./content:/usr/share/nginx/html:ro diff --git a/tests/test_convert_chromium_screenshots.py b/tests/test_convert_chromium_screenshots.py index 4e3619a..4a28389 100644 --- a/tests/test_convert_chromium_screenshots.py +++ b/tests/test_convert_chromium_screenshots.py @@ -10,10 +10,11 @@ from gotenberg_client import GotenbergClient +@pytest.mark.usefixtures("web_server_host") class TestChromiumScreenshots: - def test_basic_screenshot(self, client: GotenbergClient, web_server_host: str): + def test_basic_screenshot(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).run_with_retry() + resp = route.url(webserver_docker_internal_url).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers @@ -26,83 +27,83 @@ def test_basic_screenshot(self, client: GotenbergClient, web_server_host: str): def test_screenshot_formats( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, image_format: Literal["png", "webp", "jpeg"], ): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).output_format(image_format).run_with_retry() + resp = route.url(webserver_docker_internal_url).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}" - def test_screenshot_quality_valid(self, client: GotenbergClient, web_server_host: str): + def test_screenshot_quality_valid(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).quality(80).run_with_retry() + resp = route.url(webserver_docker_internal_url).quality(80).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - def test_screenshot_quality_too_low(self, client: GotenbergClient, web_server_host: str): + def test_screenshot_quality_too_low(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).quality(-10).run_with_retry() + resp = route.url(webserver_docker_internal_url).quality(-10).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - def test_screenshot_quality_too_high(self, client: GotenbergClient, web_server_host: str): + def test_screenshot_quality_too_high(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).quality(101).run_with_retry() + resp = route.url(webserver_docker_internal_url).quality(101).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - def test_screenshot_optimize_speed(self, client: GotenbergClient, web_server_host: str): + def test_screenshot_optimize_speed(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).optimize_speed().run_with_retry() + resp = route.url(webserver_docker_internal_url).optimize_speed().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - def test_screenshot_optimize_quality(self, client: GotenbergClient, web_server_host: str): + def test_screenshot_optimize_quality(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).optimize_size().run_with_retry() + resp = route.url(webserver_docker_internal_url).optimize_size().run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "image/png" - def test_network_idle_on(self, client: GotenbergClient, web_server_host: str): + def test_network_idle_on(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).skip_network_idle().run_with_retry() + resp = route.url(webserver_docker_internal_url).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" - def test_network_idle_off(self, client: GotenbergClient, web_server_host: str): + def test_network_idle_off(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).use_network_idle().run_with_retry() + resp = route.url(webserver_docker_internal_url).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" - def test_status_codes(self, client: GotenbergClient, web_server_host: str): + def test_status_codes(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).fail_on_status_codes([499, 599]).run_with_retry() + resp = route.url(webserver_docker_internal_url).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" - def test_status_codes_empty(self, client: GotenbergClient, web_server_host: str): + def test_status_codes_empty(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.screenshot_url() as route: - resp = route.url(web_server_host).fail_on_status_codes([]).run_with_retry() + resp = route.url(webserver_docker_internal_url).fail_on_status_codes([]).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers diff --git a/tests/test_convert_chromium_url.py b/tests/test_convert_chromium_url.py index e9d56d9..e0f5e7d 100644 --- a/tests/test_convert_chromium_url.py +++ b/tests/test_convert_chromium_url.py @@ -12,16 +12,18 @@ from tests.utils import verify_stream_contains +@pytest.mark.usefixtures("webserver_docker_internal_url") class TestConvertChromiumUrlRoute: - def test_basic_convert(self, client: GotenbergClient, web_server_host: str): + def test_basic_convert(self, client: GotenbergClient, webserver_docker_internal_url: str): with client.chromium.url_to_pdf() as route: - resp = route.url(web_server_host).run_with_retry() + resp = route.url(webserver_docker_internal_url).run_with_retry() assert resp.status_code == codes.OK assert "Content-Type" in resp.headers assert resp.headers["Content-Type"] == "application/pdf" +@pytest.mark.usefixtures("webserver_docker_internal_url") class TestConvertChromiumUrlMocked: @pytest.mark.parametrize( ("emulation"), @@ -30,14 +32,14 @@ class TestConvertChromiumUrlMocked: def test_convert_orientation( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, emulation: EmulatedMediaType, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).media_type(emulation).run() + _ = route.url(webserver_docker_internal_url).media_type(emulation).run() request = httpx_mock.get_request() verify_stream_contains( @@ -53,14 +55,14 @@ def test_convert_orientation( def test_convert_css_or_not_size( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url(web_server_host) + route.url(webserver_docker_internal_url) getattr(route, method)() _ = route.run() @@ -78,14 +80,14 @@ def test_convert_css_or_not_size( def test_convert_background_graphics_or_not( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url(web_server_host) + route.url(webserver_docker_internal_url) getattr(route, method)() _ = route.run() @@ -103,14 +105,14 @@ def test_convert_background_graphics_or_not( def test_convert_hide_background_or_not( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url(web_server_host) + route.url(webserver_docker_internal_url) getattr(route, method)() _ = route.run() @@ -128,14 +130,14 @@ def test_convert_hide_background_or_not( def test_convert_fail_exceptions( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, method: str, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - route.url(web_server_host) + route.url(webserver_docker_internal_url) getattr(route, method)() _ = route.run() @@ -149,13 +151,13 @@ def test_convert_fail_exceptions( def test_convert_scale( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).scale(1.5).run() + _ = route.url(webserver_docker_internal_url).scale(1.5).run() request = httpx_mock.get_request() verify_stream_contains( @@ -167,13 +169,13 @@ def test_convert_scale( def test_convert_page_ranges( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).page_ranges("1-5").run() + _ = route.url(webserver_docker_internal_url).page_ranges("1-5").run() request = httpx_mock.get_request() verify_stream_contains( @@ -185,13 +187,13 @@ def test_convert_page_ranges( def test_convert_url_render_wait( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).render_wait(500).run() + _ = route.url(webserver_docker_internal_url).render_wait(500).run() request = httpx_mock.get_request() verify_stream_contains( @@ -203,13 +205,13 @@ def test_convert_url_render_wait( def test_convert_url_render_expression( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).render_expr("wait while false;").run() + _ = route.url(webserver_docker_internal_url).render_expr("wait while false;").run() request = httpx_mock.get_request() verify_stream_contains( @@ -222,13 +224,13 @@ def test_convert_url_render_expression( def test_convert_url_user_agent( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).user_agent("Firefox").run() + _ = route.url(webserver_docker_internal_url).user_agent("Firefox").run() request = httpx_mock.get_request() verify_stream_contains( @@ -240,7 +242,7 @@ def test_convert_url_user_agent( def test_convert_url_headers( self, client: GotenbergClient, - web_server_host: str, + webserver_docker_internal_url: str, httpx_mock: HTTPXMock, ): httpx_mock.add_response(method="POST") @@ -248,7 +250,7 @@ def test_convert_url_headers( headers = {"X-Auth-Token": "Secure"} with client.chromium.url_to_pdf() as route: - _ = route.url(web_server_host).headers(headers).run() + _ = route.url(webserver_docker_internal_url).headers(headers).run() request = httpx_mock.get_request() verify_stream_contains( From 70e933be88ae491417bf64bc511f4de7bd9df087 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:35:44 -0700 Subject: [PATCH 3/9] Chore: Python 3.13 testing and support (#37) --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++++ pyproject.toml | 6 +++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9261394..7e071b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: fail-fast: false matrix: # No pikepdf wheels for pypy3.8 - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy3.9', 'pypy3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.10'] steps: - diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ce323..1459995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Official support and testing for Python 3.13 ([#25](https://github.com/stumpylog/tika-client/pull/25)) + ### Changed - Use `pytest-docker` to manage Docker image services ([#36](https://github.com/stumpylog/gotenberg-client/pull/36)) diff --git a/pyproject.toml b/pyproject.toml index faeef77..82fafc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] @@ -110,7 +111,7 @@ cov-report = [ ] [[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", "3.13", "pypy3.9", "pypy3.10" ] # # Custom Environments @@ -252,6 +253,9 @@ lint.isort.force-single-line = true # Recognize us please lint.isort.known-first-party = [ "gotenberg_client" ] +[tool.pyproject-fmt] +max_supported_python = "3.13" + [tool.pytest.ini_options] minversion = "7.0" testpaths = [ "tests" ] From a6d47d6a0834ba067ee62f52d56efc8ed5bca0a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Dec 2024 19:05:01 -0800 Subject: [PATCH 4/9] Bump pypa/gh-action-pypi-publish from 1.10.2 to 1.12.2 (#41) * Bump pypa/gh-action-pypi-publish from 1.10.2 to 1.12.2 Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.10.2 to 1.12.2. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.2...v1.12.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] --------- 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 | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e071b1..44cfa2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,4 +269,4 @@ jobs: path: dist - name: Publish build to PyPI - uses: pypa/gh-action-pypi-publish@v1.10.2 + uses: pypa/gh-action-pypi-publish@v1.12.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1459995..2a3d34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Use `pytest-docker` to manage Docker image services ([#36](https://github.com/stumpylog/gotenberg-client/pull/36)) +- Bump Bump pypa/gh-action-pypi-publish from 1.10.2 to 1.12.2 by @dependabot ([#41](https://github.com/stumpylog/gotenberg-client/pull/41)) ## [0.7.0] - 2024-10-08 From 04d1850b41e9b0247217c7411401d08e87305b6c Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:47:54 -0800 Subject: [PATCH 5/9] Feature: Allow setting PDF metadata (#42) Co-authored-by: Christian Specht --- CHANGELOG.md | 2 + README.md | 29 +++ docs/routes.md | 38 +++- pyproject.toml | 3 +- src/gotenberg_client/__init__.py | 10 +- src/gotenberg_client/_base.py | 1 + src/gotenberg_client/_convert/chromium.py | 5 +- src/gotenberg_client/_convert/common.py | 148 ++++++++++++++ src/gotenberg_client/_convert/libre_office.py | 3 +- src/gotenberg_client/_convert/pdfa.py | 3 +- src/gotenberg_client/_errors.py | 8 + src/gotenberg_client/options.py | 9 + tests/conftest.py | 2 +- tests/docker/docker-compose.ci-test.yml | 2 +- tests/test_metadata.py | 184 ++++++++++++++++++ tests/utils.py | 2 +- 16 files changed, 437 insertions(+), 12 deletions(-) create mode 100644 tests/test_metadata.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3d34b..9a5fb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Official support and testing for Python 3.13 ([#25](https://github.com/stumpylog/tika-client/pull/25)) +- Support for setting PDF metadata ([#42](https://github.com/stumpylog/tika-client/pull/42)) + - Initial work by @spechtx in ([#39](https://github.com/stumpylog/tika-client/pull/42)) ### Changed diff --git a/README.md b/README.md index ebef672..1534d70 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,35 @@ with GotenbergClient("http://localhost:3000") as client: response.to_file(Path("my-world.pdf")) ``` +Adding metadata to a PDF: + +This example shows how to add metadata to your generated PDF. All metadata fields are optional and include: + +- Document info (title, author, subject, keywords) +- Dates (creation, modification) +- Technical details (pdf version, creator, producer) +- PDF standards (trapped status, marked status) + +```python +from gotenberg_client import GotenbergClient +from datetime import datetime + +with GotenbergClient("http://localhost:3000") as client: + with client.chromium.html_to_pdf() as route: + response = (route + .index("my-index.html") + .metadata( + title="My Document", + author="John Doe", + subject="Example PDF", + keywords=["sample", "document", "test"], + creation_date=datetime.now(), + trapped="Unknown" + ) + .run()) + response.to_file(Path("my-index.pdf")) +``` + To ensure the proper clean up of all used resources, both the client and the route(s) should be used as context manager. If for some reason you cannot, you should `.close` the client and any routes: diff --git a/docs/routes.md b/docs/routes.md index e9f75f4..8963003 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -95,11 +95,45 @@ These options are not yet implemented | `pdfa` | `.pdf_format()` | `PdfAFormat` | | | `pdfua` |
  • `enable_universal_access()`
  • `disable_universal_access()`
| N/A | | -#### Metadata +#### PDF Metadata Support [Gotenberg Documentation](https://gotenberg.dev/docs/routes#metadata-chromium) -These options are not yet implemented +Add metadata to your PDFs: + +```python +from gotenberg_client import GotenbergClient +from datetime import datetime + +with GotenbergClient("http://localhost:3000") as client: + with client.chromium.html_to_pdf() as route: + response = (route + .index("my-index.html") + .metadata( + title="My Document", + author="John Doe", + creation_date=datetime.now(), + keywords=["sample", "document"], + subject="Sample PDF Generation", + trapped="Unknown" + ) + .run()) +``` + +Supported metadata fields: + +- `title`: Document title +- `author`: Document author +- `subject`: Document subject +- `keywords`: List of keywords +- `creator`: Creating application +- `creation_date`: Creation datetime +- `modification_date`: Last modification datetime +- `producer`: PDF producer +- `trapped`: Trapping status ('True', 'False', 'Unknown') +- `copyright`: Copyright information +- `marked`: PDF marked status +- `pdf_version`: PDF version number ## LibreOffice diff --git a/pyproject.toml b/pyproject.toml index 82fafc9..e6fa83e 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.6" ] +dependencies = [ "ruff ~= 0.8" ] config-path = "none" [tool.hatch.envs.hatch-test] @@ -246,6 +246,7 @@ lint.ignore = [ ] # Tests can use magic values, assertions, and relative imports lint.per-file-ignores."tests/**/*" = [ "PLR2004", "S101", "TID252" ] +lint.per-file-ignores."tests/utils.py" = [ "S603" ] # No relative imports lint.flake8-tidy-imports.ban-relative-imports = "all" # One import per line diff --git a/src/gotenberg_client/__init__.py b/src/gotenberg_client/__init__.py index d1f211d..3229a0f 100644 --- a/src/gotenberg_client/__init__.py +++ b/src/gotenberg_client/__init__.py @@ -4,15 +4,19 @@ from gotenberg_client._client import GotenbergClient from gotenberg_client._errors import BaseClientError from gotenberg_client._errors import CannotExtractHereError +from gotenberg_client._errors import InvalidKeywordError +from gotenberg_client._errors import InvalidPdfRevisionError from gotenberg_client._errors import MaxRetriesExceededError from gotenberg_client.responses import SingleFileResponse from gotenberg_client.responses import ZipFileResponse __all__ = [ - "GotenbergClient", - "SingleFileResponse", - "ZipFileResponse", "BaseClientError", "CannotExtractHereError", + "GotenbergClient", + "InvalidKeywordError", + "InvalidPdfRevisionError", "MaxRetriesExceededError", + "SingleFileResponse", + "ZipFileResponse", ] diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 1f2131e..0603dee 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -111,6 +111,7 @@ 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, diff --git a/src/gotenberg_client/_convert/chromium.py b/src/gotenberg_client/_convert/chromium.py index 4e176f3..930a94d 100644 --- a/src/gotenberg_client/_convert/chromium.py +++ b/src/gotenberg_client/_convert/chromium.py @@ -17,6 +17,7 @@ from gotenberg_client._convert.common import EmulatedMediaMixin from gotenberg_client._convert.common import HeaderFooterMixin from gotenberg_client._convert.common import InvalidStatusCodesMixin +from gotenberg_client._convert.common import MetadataMixin from gotenberg_client._convert.common import PageOrientMixin from gotenberg_client._convert.common import PagePropertiesMixin from gotenberg_client._convert.common import PerformanceModeMixin @@ -125,6 +126,7 @@ class HtmlRoute( HeaderFooterMixin, RenderControlMixin, PageOrientMixin, + MetadataMixin, _RouteWithResources, _FileBasedRoute, ): @@ -141,6 +143,7 @@ class UrlRoute( EmulatedMediaMixin, CustomHTTPHeaderMixin, PageOrientMixin, + MetadataMixin, BaseSingleFileResponseRoute, ): """ @@ -183,7 +186,7 @@ def _get_all_resources(self) -> ForceMultipartDict: return FORCE_MULTIPART -class MarkdownRoute(PagePropertiesMixin, HeaderFooterMixin, _RouteWithResources, _FileBasedRoute): +class MarkdownRoute(PagePropertiesMixin, HeaderFooterMixin, MetadataMixin, _RouteWithResources, _FileBasedRoute): """ Represents the Gotenberg route for converting Markdown files to a PDF. diff --git a/src/gotenberg_client/_convert/common.py b/src/gotenberg_client/_convert/common.py index 5f89381..89757cb 100644 --- a/src/gotenberg_client/_convert/common.py +++ b/src/gotenberg_client/_convert/common.py @@ -3,12 +3,19 @@ # SPDX-License-Identifier: MPL-2.0 import json import logging +from datetime import datetime from pathlib import Path from typing import Dict +from typing import Final from typing import Iterable +from typing import List +from typing import Optional +from typing import Union from warnings import warn from gotenberg_client._base import BaseSingleFileResponseRoute +from gotenberg_client._errors import InvalidKeywordError +from gotenberg_client._errors import InvalidPdfRevisionError from gotenberg_client._types import PageScaleType from gotenberg_client._types import Self from gotenberg_client._types import WaitTimeType @@ -16,6 +23,7 @@ from gotenberg_client.options import PageMarginsType from gotenberg_client.options import PageOrientation from gotenberg_client.options import PageSize +from gotenberg_client.options import TrappedStatus logger = logging.getLogger() @@ -233,3 +241,143 @@ def skip_network_idle(self) -> Self: def use_network_idle(self) -> Self: self._form_data.update({"skipNetworkIdleEvent": "false"}) # type: ignore[attr-defined,misc] return self + + +class MetadataMixin: + """ + Mixin for PDF metadata support. + + This mixin provides functionality to set PDF metadata for documents processed through + the Gotenberg API (https://gotenberg.dev/docs/routes#metadata-chromium). + + Important Notes: + - Gotenberg will use the current date/time for creation_date and modification_date, + even if custom dates are provided. + - Gotenberg will use its own pdf_version, even if a custom version is provided. + + Example: + from gotenberg_client import GotenbergClient + from datetime import datetime + from zoneinfo import ZoneInfo + from pathlib import Path + + with GotenbergClient('http://localhost:3000') as client: + with client.chromium.url_to_pdf() as route: + + response = ( + route.url('https://hello.world') + .metadata( + author='John Doe', + copyright='© 2024 My Company', + creation_date = datetime.now(tz=ZoneInfo("Europe/Berlin")), + creator='My Application', + keywords=['keyword', 'example'], + marked=True, + modification_date=datetime.now(tz=ZoneInfo("Europe/Berlin")), + pdf_version=1.7, + producer='PDF Producer', + subject='My Subject', + title='My Title', + trapped=True, + ) + ) + + response.to_file(Path('my-world.pdf')) + """ + + MIN_PDF_VERSION: Final[float] = 1.0 + MAX_PDF_VERSION: Final[float] = 2.0 + + def metadata( + self, + author: Optional[str] = None, + pdf_copyright: Optional[str] = None, + creation_date: Optional[datetime] = None, + creator: Optional[str] = None, + keywords: Optional[List[str]] = None, + marked: Optional[bool] = None, + modification_date: Optional[datetime] = None, + pdf_version: Optional[float] = None, + producer: Optional[str] = None, + subject: Optional[str] = None, + title: Optional[str] = None, + trapped: Optional[Union[bool, TrappedStatus]] = None, + ) -> Self: + """ + Sets PDF metadata for the document. + + Args: + author: Document author name + copyright: Copyright information + creation_date: Document creation date (Note: Gotenberg will override this) + creator: Name of the creating application + keywords: List of keywords/tags for the document + marked: Whether the PDF is marked for structure + modification_date: Last modification date (Note: Gotenberg will override this) + pdf_version: PDF version number (Note: Gotenberg will override this) + producer: Name of the PDF producer + subject: Document subject/description + title: Document title + trapped: Trapping status (bool or one of: 'True', 'False', 'Unknown') + + Returns: + Self for method chaining + + Raises: + InvalidPdfRevisionError: If the provided PDF revision is outside the valid range + InvalidKeywordError: If any metadata keyword values are not allowed + TypeError: If any metadata values have incorrect types + """ + + # Validate metadata values + if pdf_version is not None and not (self.MIN_PDF_VERSION <= pdf_version <= self.MAX_PDF_VERSION): + msg = "PDF version must be between 1.0 and 2.0" + raise InvalidPdfRevisionError(msg) + + if trapped is not None and isinstance(trapped, bool): + trapped = TrappedStatus.TRUE if trapped else TrappedStatus.FALSE + + if keywords is not None: + if not all(isinstance(k, str) for k in keywords): + raise InvalidKeywordError("All keywords must be strings") # noqa: EM101, TRY003 + if any("," in k for k in keywords): + raise InvalidKeywordError("Keywords cannot contain commas") # noqa: EM101, TRY003 + + # Get existing metadata if any + existing_metadata: Dict[str, Union[str, bool, float]] = {} + if "metadata" in self._form_data: # type: ignore[attr-defined,misc] + existing_metadata = json.loads(self._form_data["metadata"]) # type: ignore[attr-defined,misc] + + # Convert validated metadata to dictionary + metadata: Dict[str, Union[str, bool, float]] = {} + + if author: + metadata["Author"] = author + if pdf_copyright: + metadata["Copyright"] = pdf_copyright + if creation_date: + metadata["CreationDate"] = creation_date.isoformat() + if creator: + metadata["Creator"] = creator + if keywords: + metadata["Keywords"] = ", ".join(keywords) + if marked is not None: + metadata["Marked"] = marked + if modification_date: + metadata["ModDate"] = modification_date.isoformat() + if pdf_version: + metadata["PDFVersion"] = pdf_version + if producer: + metadata["Producer"] = producer + if subject: + metadata["Subject"] = subject + if title: + metadata["Title"] = title + if trapped is not None: + metadata["Trapped"] = trapped.value + + # Merge existing and new metadata + if metadata: + self._form_data.update({"metadata": json.dumps({**existing_metadata, **metadata})}) # 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 f1779e1..1af7e5d 100644 --- a/src/gotenberg_client/_convert/libre_office.py +++ b/src/gotenberg_client/_convert/libre_office.py @@ -9,6 +9,7 @@ from gotenberg_client._base import BaseApi from gotenberg_client._base import BaseSingleFileResponseRoute +from gotenberg_client._convert.common import MetadataMixin from gotenberg_client._convert.common import PageOrientMixin from gotenberg_client._convert.common import PageRangeMixin from gotenberg_client._types import Self @@ -17,7 +18,7 @@ from gotenberg_client.responses import ZipFileResponse -class LibreOfficeConvertRoute(PageOrientMixin, PageRangeMixin, BaseSingleFileResponseRoute): +class LibreOfficeConvertRoute(PageOrientMixin, PageRangeMixin, MetadataMixin, BaseSingleFileResponseRoute): """ Represents the Gotenberg route for converting documents to PDF using LibreOffice. diff --git a/src/gotenberg_client/_convert/pdfa.py b/src/gotenberg_client/_convert/pdfa.py index deee746..e799fa4 100644 --- a/src/gotenberg_client/_convert/pdfa.py +++ b/src/gotenberg_client/_convert/pdfa.py @@ -6,10 +6,11 @@ from gotenberg_client._base import BaseApi from gotenberg_client._base import BaseSingleFileResponseRoute +from gotenberg_client._convert.common import MetadataMixin from gotenberg_client._types import Self -class PdfAConvertRoute(BaseSingleFileResponseRoute): +class PdfAConvertRoute(MetadataMixin, BaseSingleFileResponseRoute): """ Represents the Gotenberg route for converting PDFs to PDF/A format. diff --git a/src/gotenberg_client/_errors.py b/src/gotenberg_client/_errors.py index aa02269..2dc6d68 100644 --- a/src/gotenberg_client/_errors.py +++ b/src/gotenberg_client/_errors.py @@ -23,3 +23,11 @@ def __init__(self, *, response: Response) -> None: class CannotExtractHereError(BaseClientError): pass + + +class InvalidPdfRevisionError(BaseClientError): + pass + + +class InvalidKeywordError(BaseClientError): + pass diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index 1e95668..dc67a23 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -235,3 +235,12 @@ def to_form(self) -> Dict[str, str]: """ return {"emulatedMediaType": self.name.lower()} + + +@enum.unique +class TrappedStatus(str, enum.Enum): + """Enum for valid trapped status values.""" + + TRUE = "True" + FALSE = "False" + UNKNOWN = "Unknown" diff --git a/tests/conftest.py b/tests/conftest.py index c0bd863..e970407 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,7 @@ def gotenberg_service_name() -> str: if "GOTENBERG_CLIENT_EDGE_TEST" in os.environ: return "gotenberg-client-test-edge-server" else: - return "gotenberg-client-test-edge-server" + return "gotenberg-client-test-server" @pytest.fixture(scope="session") diff --git a/tests/docker/docker-compose.ci-test.yml b/tests/docker/docker-compose.ci-test.yml index 6d71090..17d2aa5 100644 --- a/tests/docker/docker-compose.ci-test.yml +++ b/tests/docker/docker-compose.ci-test.yml @@ -4,7 +4,7 @@ networks: gotenberg-test-net: services: - gotenberg-client-test-edge-server: + gotenberg-client-test-server: image: docker.io/gotenberg/gotenberg:8.11.0 networks: - gotenberg-test-net diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..aefd454 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,184 @@ +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from pathlib import Path +from typing import List + +import pikepdf +import pytest +from httpx import codes + +from gotenberg_client import GotenbergClient +from gotenberg_client import InvalidKeywordError +from gotenberg_client import InvalidPdfRevisionError +from gotenberg_client._convert.common import MetadataMixin +from gotenberg_client.options import TrappedStatus + + +class TestPdfMetadata: + def test_metadata_basic( + self, + client: GotenbergClient, + tmp_path: Path, + webserver_docker_internal_url: str, + ): + """Test basic metadata setting.""" + + author = "Gotenberg Test" + copyright_info = "Copyright Me at Me, Inc" + creation_date = datetime(2006, 9, 18, 16, 27, 50, tzinfo=timezone(timedelta(hours=-4))) + creator = "Gotenberg Some Version" + keywords = ["Test", "Something"] + marked = True + mod_date = datetime(2006, 9, 18, 16, 27, 50, tzinfo=timezone(timedelta(hours=-5))) + pdf_version = 1.5 + producer = "Gotenberg Client" + subject = "A Test File" + title = "An override title" + trapped = TrappedStatus.TRUE + + with client.chromium.url_to_pdf() as route: + resp = ( + route.url(webserver_docker_internal_url) + .metadata( + author=author, + pdf_copyright=copyright_info, + creation_date=creation_date, + creator=creator, + keywords=keywords, + marked=marked, + modification_date=mod_date, + pdf_version=pdf_version, + producer=producer, + subject=subject, + title=title, + trapped=trapped, + ) + .run_with_retry() + ) + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" + + output = tmp_path / "test_metadata_basic.pdf" + resp.to_file(output) + + with pikepdf.Pdf.open(output) as pdf: + assert "/Author" in pdf.docinfo + assert pdf.docinfo["/Author"] == author + + assert "/Creator" in pdf.docinfo + assert pdf.docinfo["/Creator"] == creator + + assert "/Keywords" in pdf.docinfo + assert pdf.docinfo["/Keywords"] == ", ".join(keywords) + + assert "/Producer" in pdf.docinfo + assert pdf.docinfo["/Producer"] == producer + + assert "/Subject" in pdf.docinfo + assert pdf.docinfo["/Subject"] == subject + + assert "/Title" in pdf.docinfo + assert pdf.docinfo["/Title"] == title + + assert "/Trapped" in pdf.docinfo + assert pdf.docinfo["/Trapped"] == "/True" + + # TODO(stumpylog): Investigate why certain fields seems to not be possible to set + + def test_metadata_trapped_bool(self, client: GotenbergClient, tmp_path: Path, webserver_docker_internal_url: str): + with client.chromium.url_to_pdf() as route: + resp = ( + route.url(webserver_docker_internal_url) + .metadata( + trapped=True, + ) + .run_with_retry() + ) + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" + + output = tmp_path / "test_metadata_trapped_bool.pdf" + resp.to_file(output) + + with pikepdf.Pdf.open(output) as pdf: + assert "/Trapped" in pdf.docinfo + assert pdf.docinfo["/Trapped"] == "/True" + + def test_metadata_merging( + self, + client: GotenbergClient, + tmp_path: Path, + webserver_docker_internal_url: str, + ): + inital_title = "Initial Title" + new_title = "An New Title" + trapped = TrappedStatus.UNKNOWN + + with client.chromium.url_to_pdf() as route: + resp = ( + route.url(webserver_docker_internal_url) + .metadata( + title=inital_title, + trapped=trapped, + ) + .metadata(title=new_title) + .run_with_retry() + ) + + assert resp.status_code == codes.OK + assert "Content-Type" in resp.headers + assert resp.headers["Content-Type"] == "application/pdf" + + output = tmp_path / "test_metadata_merging.pdf" + resp.to_file(output) + + with pikepdf.Pdf.open(output) as pdf: + assert "/Title" in pdf.docinfo + assert pdf.docinfo["/Title"] == new_title + + assert "/Trapped" in pdf.docinfo + assert pdf.docinfo["/Trapped"] == "/Unknown" + + @pytest.mark.parametrize( + ("base_value", "delta"), + [(MetadataMixin.MIN_PDF_VERSION, -0.5), (MetadataMixin.MAX_PDF_VERSION, 0.5)], + ) + def test_metadata_invalid_pdf_revision( + self, + client: GotenbergClient, + webserver_docker_internal_url: str, + base_value: float, + delta: float, + ): + with client.chromium.url_to_pdf() as route, pytest.raises(InvalidPdfRevisionError): + _ = ( + route.url(webserver_docker_internal_url) + .metadata( + pdf_version=base_value + delta, + ) + .run_with_retry() + ) + + @pytest.mark.parametrize( + ("keywords"), + [["Test, Something"], ["Test", 1]], + ) + def test_metadata_invalid_pdf_keyword( + self, + client: GotenbergClient, + webserver_docker_internal_url: str, + keywords: List[str], + ): + with client.chromium.url_to_pdf() as route, pytest.raises(InvalidKeywordError): + _ = ( + route.url(webserver_docker_internal_url) + .metadata( + keywords=keywords, + ) + .run_with_retry() + ) diff --git a/tests/utils.py b/tests/utils.py index 53f6e45..1da4577 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -36,7 +36,7 @@ def extract_text(pdf_path: Path) -> str: with tempfile.NamedTemporaryFile( mode="w+", ) as tmp: - subprocess.run( # noqa: S603 + subprocess.run( [ pdf_to_text, "-q", From 50206a1ac4289e3c271428f3ab8e1bb02d6c7a62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:59:08 -0800 Subject: [PATCH 6/9] Bump codecov/codecov-action from 4 to 5 (#40) * Bump codecov/codecov-action from 4 to 5 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4...v5) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * 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 | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44cfa2a..a9e9f3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: - name: Upload coverage to Codecov if: matrix.python-version == '3.10' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: # not required for public repos, but intermittently fails otherwise token: ${{ secrets.CODECOV_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5fb03..d627065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Official support and testing for Python 3.13 ([#25](https://github.com/stumpylog/tika-client/pull/25)) - Support for setting PDF metadata ([#42](https://github.com/stumpylog/tika-client/pull/42)) - - Initial work by @spechtx in ([#39](https://github.com/stumpylog/tika-client/pull/42)) + - Initial work by @spechtx in ([#40](https://github.com/stumpylog/tika-client/pull/40)) ### Changed - Use `pytest-docker` to manage Docker image services ([#36](https://github.com/stumpylog/gotenberg-client/pull/36)) - Bump Bump pypa/gh-action-pypi-publish from 1.10.2 to 1.12.2 by @dependabot ([#41](https://github.com/stumpylog/gotenberg-client/pull/41)) +- Bump codecov/codecov-action from 4 to 5 by @dependabot ([#41](https://github.com/stumpylog/gotenberg-client/pull/41)) ## [0.7.0] - 2024-10-08 From 0b910c03961935cfd2f23a91565ac3f44107c6e3 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 10 Dec 2024 12:23:37 -0800 Subject: [PATCH 7/9] Breaking: Drop support for Python 3.8 (#43) --- .github/workflows/ci.yml | 3 +- .pre-commit-config.yaml | 6 ++-- CHANGELOG.md | 10 ++++-- pyproject.toml | 18 ++++------ src/gotenberg_client/_base.py | 13 +++---- src/gotenberg_client/_client.py | 8 ++--- src/gotenberg_client/_convert/chromium.py | 8 ++--- src/gotenberg_client/_convert/common.py | 12 +++---- src/gotenberg_client/_convert/libre_office.py | 3 +- src/gotenberg_client/_convert/pdfa.py | 3 +- src/gotenberg_client/_merge.py | 3 +- src/gotenberg_client/_utils.py | 5 ++- src/gotenberg_client/options.py | 17 +++++----- tests/conftest.py | 2 +- tests/test_convert_chromium_html.py | 18 +++++----- tests/test_convert_chromium_url.py | 34 ++++++------------- tests/test_metadata.py | 3 +- tests/utils.py | 23 +++++-------- 18 files changed, 76 insertions(+), 113 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9e9f3e..062bb58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,8 +58,7 @@ jobs: strategy: fail-fast: false matrix: - # No pikepdf wheels for pypy3.8 - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.10'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9', 'pypy3.10'] steps: - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11d1ffb..17624aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: detect-private-key # See https://github.com/prettier/prettier/issues/15742 for the fork reason - repo: https://github.com/rbubley/mirrors-prettier - rev: "v3.3.3" + rev: "v3.4.2" hooks: - id: prettier types_or: @@ -45,13 +45,13 @@ repos: - id: codespell # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.6.9' + rev: 'v0.8.2' hooks: # Run the linter. - id: ruff # Run the formatter. - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt - rev: "2.2.4" + rev: "v2.5.0" hooks: - id: pyproject-fmt diff --git a/CHANGELOG.md b/CHANGELOG.md index d627065..f1b9f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Change + +- Dopped support for Python 3.8 ([#43](https://github.com/stumpylog/gotenberg-client/pull/43)) + ### Added -- Official support and testing for Python 3.13 ([#25](https://github.com/stumpylog/tika-client/pull/25)) -- Support for setting PDF metadata ([#42](https://github.com/stumpylog/tika-client/pull/42)) - - Initial work by @spechtx in ([#40](https://github.com/stumpylog/tika-client/pull/40)) +- Official support and testing for Python 3.13 ([#25](https://github.com/stumpylog/gotenberg-client/pull/25)) +- Support for setting PDF metadata ([#42](https://github.com/stumpylog/gotenberg-client/pull/42)) + - Initial work by @spechtx in ([#40](https://github.com/stumpylog/gotenberg-client/pull/40)) ### Changed diff --git a/pyproject.toml b/pyproject.toml index e6fa83e..97bd434 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ license = "MPL-2.0" authors = [ { name = "Trenton H", email = "rda0128ou@mozmail.com" }, ] -requires-python = ">=3.8" +requires-python = ">=3.9" classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", @@ -25,7 +25,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -36,8 +35,7 @@ classifiers = [ ] dynamic = [ "version" ] dependencies = [ - "httpx[http2]~=0.24; python_version<'3.9'", - "httpx[http2]~=0.27; python_version>='3.9'", + "httpx[http2]~=0.28", "typing-extensions; python_version<'3.11'", ] @@ -75,7 +73,6 @@ randomize = true dependencies = [ "coverage-enable-subprocess == 1.0", "coverage[toml] ~= 7.6", - "pytest < 8.0; python_version < '3.9'", "pytest ~= 8.3; python_version >= '3.9'", "pytest-mock ~= 3.14", "pytest-randomly ~= 3.15", @@ -84,8 +81,7 @@ dependencies = [ ] extra-dependencies = [ "pytest-sugar", - "pytest-httpx == 0.30.0; python_version >= '3.9'", - "pytest-httpx ~= 0.22; python_version < '3.9'", + "pytest-httpx ~= 0.35", "pikepdf", "python-magic", "pytest-docker ~= 3.1", @@ -111,7 +107,7 @@ cov-report = [ ] [[tool.hatch.envs.hatch-test.matrix]] -python = [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10" ] +python = [ "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10" ] # # Custom Environments @@ -119,11 +115,11 @@ python = [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10" ] [tool.hatch.envs.typing] detached = true dependencies = [ - "mypy ~= 1.11", + "mypy ~= 1.13", "httpx", "pytest", "pikepdf", - "pytest-httpx == 0.30.0", + "pytest-httpx ~= 0.35", ] [tool.hatch.envs.typing.scripts] @@ -170,7 +166,7 @@ deploy = [ # [tool.ruff] -target-version = "py38" +target-version = "py39" line-length = 120 # https://docs.astral.sh/ruff/settings/ diff --git a/src/gotenberg_client/_base.py b/src/gotenberg_client/_base.py index 0603dee..9c93062 100644 --- a/src/gotenberg_client/_base.py +++ b/src/gotenberg_client/_base.py @@ -6,10 +6,7 @@ from pathlib import Path 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 from httpx import HTTPStatusError @@ -71,13 +68,13 @@ def __init__(self, client: Client, api_route: str) -> None: self._route = api_route self._stack = ExitStack() # These are the options that will be set to Gotenberg. Things like PDF/A - self._form_data: Dict[str, str] = {} + self._form_data: dict[str, str] = {} # These are the names of files, mapping to their Path - self._file_map: Dict[str, 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]]] = {} + self._in_memory_resources: dict[str, tuple[str, Optional[str]]] = {} # Any header that will also be sent - self._headers: Dict[str, str] = {} + self._headers: dict[str, str] = {} def __enter__(self) -> Self: self.reset() @@ -85,7 +82,7 @@ def __enter__(self) -> Self: def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: diff --git a/src/gotenberg_client/_client.py b/src/gotenberg_client/_client.py index 1c53259..c1fe470 100644 --- a/src/gotenberg_client/_client.py +++ b/src/gotenberg_client/_client.py @@ -3,9 +3,7 @@ # SPDX-License-Identifier: MPL-2.0 import logging from types import TracebackType -from typing import Dict from typing import Optional -from typing import Type from httpx import Client @@ -65,7 +63,7 @@ def __init__( self.merge = MergeApi(self._client) self.health = HealthCheckApi(self._client) - def add_headers(self, header: Dict[str, str]) -> None: + def add_headers(self, header: dict[str, str]) -> None: """ Update the httpx Client headers with the given values. @@ -110,7 +108,7 @@ def set_error_webhook_http_method(self, method: HttpMethodsType = "PUT") -> None """ self.add_headers({"Gotenberg-Webhook-Error-Method": method}) - def set_webhook_extra_headers(self, extra_headers: Dict[str, str]) -> None: + def set_webhook_extra_headers(self, extra_headers: dict[str, str]) -> None: """ Set additional HTTP headers for Gotenberg to use when calling webhooks. @@ -138,7 +136,7 @@ def close(self) -> None: def __exit__( self, - exc_type: Optional[Type[BaseException]], + exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: diff --git a/src/gotenberg_client/_convert/chromium.py b/src/gotenberg_client/_convert/chromium.py index 930a94d..b0de523 100644 --- a/src/gotenberg_client/_convert/chromium.py +++ b/src/gotenberg_client/_convert/chromium.py @@ -3,10 +3,8 @@ # SPDX-License-Identifier: MPL-2.0 import logging from pathlib import Path -from typing import List from typing import Literal from typing import Optional -from typing import Tuple from httpx import Client @@ -83,7 +81,7 @@ def string_resource(self, resource: str, name: str, mime_type: Optional[str] = N self._add_in_memory_file(resource, name=name, mime_type=mime_type) return self - def resources(self, resources: List[Path]) -> Self: + def resources(self, resources: list[Path]) -> Self: """ Adds multiple resource files for the index HTML file to reference. @@ -95,7 +93,7 @@ def resources(self, resources: List[Path]) -> Self: def string_resources( self, - resources: List[Tuple[str, str, Optional[str]]], + resources: list[tuple[str, str, Optional[str]]], ) -> Self: """ Process string resources. @@ -215,7 +213,7 @@ def markdown_file(self, markdown_file: Path) -> Self: return self - def markdown_files(self, markdown_files: List[Path]) -> Self: + def markdown_files(self, markdown_files: list[Path]) -> Self: """ Adds multiple Markdown files to be converted. diff --git a/src/gotenberg_client/_convert/common.py b/src/gotenberg_client/_convert/common.py index 89757cb..27671ff 100644 --- a/src/gotenberg_client/_convert/common.py +++ b/src/gotenberg_client/_convert/common.py @@ -3,12 +3,10 @@ # SPDX-License-Identifier: MPL-2.0 import json import logging +from collections.abc import Iterable from datetime import datetime from pathlib import Path -from typing import Dict from typing import Final -from typing import Iterable -from typing import List from typing import Optional from typing import Union from warnings import warn @@ -195,7 +193,7 @@ def user_agent(self, agent: str) -> Self: self._form_data.update({"userAgent": agent}) # type: ignore[attr-defined,misc] return self - def headers(self, headers: Dict[str, str]) -> Self: + def headers(self, headers: dict[str, str]) -> Self: json_str = json.dumps(headers) self._form_data.update({"extraHttpHeaders": json_str}) # type: ignore[attr-defined,misc] return self @@ -294,7 +292,7 @@ def metadata( pdf_copyright: Optional[str] = None, creation_date: Optional[datetime] = None, creator: Optional[str] = None, - keywords: Optional[List[str]] = None, + keywords: Optional[list[str]] = None, marked: Optional[bool] = None, modification_date: Optional[datetime] = None, pdf_version: Optional[float] = None, @@ -344,12 +342,12 @@ def metadata( raise InvalidKeywordError("Keywords cannot contain commas") # noqa: EM101, TRY003 # Get existing metadata if any - existing_metadata: Dict[str, Union[str, bool, float]] = {} + existing_metadata: dict[str, Union[str, bool, float]] = {} if "metadata" in self._form_data: # type: ignore[attr-defined,misc] existing_metadata = json.loads(self._form_data["metadata"]) # type: ignore[attr-defined,misc] # Convert validated metadata to dictionary - metadata: Dict[str, Union[str, bool, float]] = {} + metadata: dict[str, Union[str, bool, float]] = {} if author: metadata["Author"] = author diff --git a/src/gotenberg_client/_convert/libre_office.py b/src/gotenberg_client/_convert/libre_office.py index 1af7e5d..aaf9560 100644 --- a/src/gotenberg_client/_convert/libre_office.py +++ b/src/gotenberg_client/_convert/libre_office.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MPL-2.0 from pathlib import Path -from typing import List from typing import Union from httpx import Client @@ -54,7 +53,7 @@ def convert(self, input_file_path: Path) -> Self: self._result_is_zip = True return self - def convert_files(self, file_paths: List[Path]) -> Self: + def convert_files(self, file_paths: list[Path]) -> Self: """ Adds all provided files for conversion to individual PDFs. diff --git a/src/gotenberg_client/_convert/pdfa.py b/src/gotenberg_client/_convert/pdfa.py index e799fa4..1c383d7 100644 --- a/src/gotenberg_client/_convert/pdfa.py +++ b/src/gotenberg_client/_convert/pdfa.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: MPL-2.0 from pathlib import Path -from typing import List from gotenberg_client._base import BaseApi from gotenberg_client._base import BaseSingleFileResponseRoute @@ -35,7 +34,7 @@ def convert(self, file_path: Path) -> Self: self._add_file_map(file_path) return self - def convert_files(self, file_paths: List[Path]) -> Self: + def convert_files(self, file_paths: list[Path]) -> Self: """ Converts multiple PDF files to the provided PDF/A format. diff --git a/src/gotenberg_client/_merge.py b/src/gotenberg_client/_merge.py index d08ddf5..aee43cd 100644 --- a/src/gotenberg_client/_merge.py +++ b/src/gotenberg_client/_merge.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MPL-2.0 from pathlib import Path from typing import Final -from typing import List from httpx import Client @@ -38,7 +37,7 @@ def __init__(self, client: Client, api_route: str) -> None: super().__init__(client, api_route) self._next = 1 - def merge(self, files: List[Path]) -> Self: + def merge(self, files: list[Path]) -> Self: """ Add the given files to the merge operation. diff --git a/src/gotenberg_client/_utils.py b/src/gotenberg_client/_utils.py index 97cfb2e..e671c0f 100644 --- a/src/gotenberg_client/_utils.py +++ b/src/gotenberg_client/_utils.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MPL-2.0 from importlib.util import find_spec from pathlib import Path -from typing import Dict from typing import Final from typing import Optional from typing import Union @@ -12,12 +11,12 @@ # See https://github.com/psf/requests/issues/1081#issuecomment-428504128 -class ForceMultipartDict(Dict): +class ForceMultipartDict(dict): def __bool__(self) -> bool: return True -def optional_to_form(value: Optional[FormFieldType], name: str) -> Dict[str, str]: +def optional_to_form(value: Optional[FormFieldType], name: str) -> dict[str, str]: """ Converts an optional value to a form data field with the given name, handling None values gracefully. diff --git a/src/gotenberg_client/options.py b/src/gotenberg_client/options.py index dc67a23..1fa4f2f 100644 --- a/src/gotenberg_client/options.py +++ b/src/gotenberg_client/options.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MPL-2.0 import dataclasses import enum -from typing import Dict from typing import Final from typing import Optional @@ -28,7 +27,7 @@ class PdfAFormat(enum.Enum): A2b = enum.auto() A3b = enum.auto() - def to_form(self) -> Dict[str, str]: + def to_form(self) -> dict[str, str]: """ Converts this PdfAFormat enum value to a dictionary suitable for form data. @@ -38,7 +37,7 @@ def to_form(self) -> Dict[str, str]: If the format is not supported (e.g., A1a), raises an Exception. """ - format_mapping: Final[Dict[PdfAFormat, str]] = { + 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", @@ -67,7 +66,7 @@ class PageOrientation(enum.Enum): Landscape = enum.auto() Portrait = enum.auto() - def to_form(self) -> Dict[str, str]: + def to_form(self) -> dict[str, str]: """ Converts this PageOrientation enum value to a dictionary suitable for form data. @@ -76,7 +75,7 @@ def to_form(self) -> Dict[str, str]: and the corresponding Gotenberg value ("landscape" or "portrait") as the value. """ - orientation_mapping: Final[Dict[PageOrientation, Dict[str, str]]] = { + orientation_mapping: Final[dict[PageOrientation, dict[str, str]]] = { PageOrientation.Landscape: {"landscape": "true"}, PageOrientation.Portrait: {"landscape": "false"}, } @@ -97,7 +96,7 @@ class PageSize: width: Optional[PageSizeType] = None height: Optional[PageSizeType] = None - def to_form(self) -> Dict[str, str]: + def to_form(self) -> dict[str, str]: """ Converts this PageSize object to a dictionary suitable for form data. @@ -160,7 +159,7 @@ class MarginType: value: MarginSizeType unit: MarginUnitType = MarginUnitType.Undefined - def to_form(self, name: str) -> Dict[str, str]: + def to_form(self, name: str) -> dict[str, str]: """ Converts this MarginType object to a dictionary suitable for form data. @@ -193,7 +192,7 @@ class PageMarginsType: left: Optional[MarginType] = None right: Optional[MarginType] = None - def to_form(self) -> Dict[str, str]: + def to_form(self) -> dict[str, str]: """ Converts this PageMarginsType object to a dictionary suitable for form data. @@ -225,7 +224,7 @@ class EmulatedMediaType(str, enum.Enum): Print = enum.auto() Screen = enum.auto() - def to_form(self) -> Dict[str, str]: + def to_form(self) -> dict[str, str]: """ Converts this EmulatedMediaType enum value to a dictionary suitable for form data. diff --git a/tests/conftest.py b/tests/conftest.py index e970407..e143876 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,8 @@ import logging import os import shutil +from collections.abc import Generator from pathlib import Path -from typing import Generator from typing import Union import httpx diff --git a/tests/test_convert_chromium_html.py b/tests/test_convert_chromium_html.py index ed3d1d9..07d1a6a 100644 --- a/tests/test_convert_chromium_html.py +++ b/tests/test_convert_chromium_html.py @@ -127,8 +127,8 @@ def test_convert_page_size(self, client: GotenbergClient, sample_directory: Path _ = route.index(test_file).size(A4).run() request = httpx_mock.get_request() - verify_stream_contains("paperWidth", "8.27", request.stream) - verify_stream_contains("paperHeight", "11.7", request.stream) + verify_stream_contains(request, "paperWidth", "8.27") + verify_stream_contains(request, "paperHeight", "11.7") def test_convert_margin(self, client: GotenbergClient, sample_directory: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST") @@ -149,10 +149,10 @@ def test_convert_margin(self, client: GotenbergClient, sample_directory: Path, h ) request = httpx_mock.get_request() - verify_stream_contains("marginTop", "1cm", request.stream) - verify_stream_contains("marginBottom", "2pc", request.stream) - verify_stream_contains("marginLeft", "3mm", request.stream) - verify_stream_contains("marginRight", "4", request.stream) + verify_stream_contains(request, "marginTop", "1cm") + verify_stream_contains(request, "marginBottom", "2pc") + verify_stream_contains(request, "marginLeft", "3mm") + verify_stream_contains(request, "marginRight", "4") def test_convert_render_control(self, client: GotenbergClient, sample_directory: Path, httpx_mock: HTTPXMock): httpx_mock.add_response(method="POST") @@ -161,8 +161,7 @@ def test_convert_render_control(self, client: GotenbergClient, sample_directory: with client.chromium.html_to_pdf() as route: _ = route.index(test_file).render_wait(500.0).run() - request = httpx_mock.get_request() - verify_stream_contains("waitDelay", "500.0", request.stream) + verify_stream_contains(httpx_mock.get_request(), "waitDelay", "500.0") @pytest.mark.parametrize( ("orientation"), @@ -181,9 +180,8 @@ def test_convert_orientation( with client.chromium.html_to_pdf() as route: _ = route.index(test_file).orient(orientation).run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "landscape", "true" if orientation == PageOrientation.Landscape else "false", - request.stream, ) diff --git a/tests/test_convert_chromium_url.py b/tests/test_convert_chromium_url.py index e0f5e7d..1204ed6 100644 --- a/tests/test_convert_chromium_url.py +++ b/tests/test_convert_chromium_url.py @@ -41,11 +41,10 @@ def test_convert_orientation( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).media_type(emulation).run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "emulatedMediaType", "screen" if emulation == EmulatedMediaType.Screen else "print", - request.stream, ) @pytest.mark.parametrize( @@ -66,11 +65,10 @@ def test_convert_css_or_not_size( getattr(route, method)() _ = route.run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "preferCssPageSize", "true" if method == "prefer_css_page_size" else "false", - request.stream, ) @pytest.mark.parametrize( @@ -91,11 +89,10 @@ def test_convert_background_graphics_or_not( getattr(route, method)() _ = route.run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "printBackground", "true" if method == "background_graphics" else "false", - request.stream, ) @pytest.mark.parametrize( @@ -116,11 +113,10 @@ def test_convert_hide_background_or_not( getattr(route, method)() _ = route.run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "omitBackground", "true" if method == "hide_background" else "false", - request.stream, ) @pytest.mark.parametrize( @@ -141,11 +137,10 @@ def test_convert_fail_exceptions( getattr(route, method)() _ = route.run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "failOnConsoleExceptions", "true" if method == "fail_on_exceptions" else "false", - request.stream, ) def test_convert_scale( @@ -159,11 +154,10 @@ def test_convert_scale( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).scale(1.5).run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "scale", "1.5", - request.stream, ) def test_convert_page_ranges( @@ -177,11 +171,10 @@ def test_convert_page_ranges( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).page_ranges("1-5").run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "nativePageRanges", "1-5", - request.stream, ) def test_convert_url_render_wait( @@ -195,11 +188,10 @@ def test_convert_url_render_wait( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).render_wait(500).run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "waitDelay", "500", - request.stream, ) def test_convert_url_render_expression( @@ -213,11 +205,10 @@ def test_convert_url_render_expression( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).render_expr("wait while false;").run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "waitForExpression", "wait while false;", - request.stream, ) @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -232,11 +223,10 @@ def test_convert_url_user_agent( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).user_agent("Firefox").run() - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "userAgent", "Firefox", - request.stream, ) def test_convert_url_headers( @@ -251,10 +241,8 @@ def test_convert_url_headers( with client.chromium.url_to_pdf() as route: _ = route.url(webserver_docker_internal_url).headers(headers).run() - - request = httpx_mock.get_request() verify_stream_contains( + httpx_mock.get_request(), "extraHttpHeaders", json.dumps(headers), - request.stream, ) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index aefd454..af79ebc 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -2,7 +2,6 @@ from datetime import timedelta from datetime import timezone from pathlib import Path -from typing import List import pikepdf import pytest @@ -172,7 +171,7 @@ def test_metadata_invalid_pdf_keyword( self, client: GotenbergClient, webserver_docker_internal_url: str, - keywords: List[str], + keywords: list[str], ): with client.chromium.url_to_pdf() as route, pytest.raises(InvalidKeywordError): _ = ( diff --git a/tests/utils.py b/tests/utils.py index 1da4577..54bb19a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,24 +6,17 @@ import tempfile from pathlib import Path -from httpx._multipart import DataField -from httpx._multipart import FileField -from httpx._multipart import MultipartStream +def verify_stream_contains(request, key: str, value: str) -> None: + content_type = request.headers["Content-Type"] + assert "multipart/form-data" in content_type -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: - 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 + boundary = content_type.split("boundary=")[1] - msg = f'Key "{key}" with value "{value}" not found in stream' - raise AssertionError(msg) + parts = request.content.split(f"--{boundary}".encode()) + + form_field_found = any(f'name="{key}"'.encode() in part and value.encode() in part for part in parts) + assert form_field_found, f'Key "{key}" with value "{value}" not found in stream' def extract_text(pdf_path: Path) -> str: From b197fefba203550072615bc349ad83be1f8d483b Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:55:48 -0800 Subject: [PATCH 8/9] Feature: Upload test results to Codecov (#44) --- .github/workflows/ci.yml | 23 +++++++++++++++++------ CHANGELOG.md | 1 + 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 062bb58..fa4f1a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,11 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: 'pip' + - + name: Pull Docker images + run: | + docker compose --file tests/docker/docker-compose.ci-test-edge.yml pull --quiet + docker compose --file tests/docker/docker-compose.ci-test.yml pull --quiet - name: Install Hatch run: | @@ -81,20 +86,26 @@ jobs: hatch --version uv --version - - name: Show environment - run: | - hatch test --show --python ${{ matrix.python-version }} + name: Show environment + run: | + hatch test --show --python ${{ matrix.python-version }} - name: Run tests run: | - hatch test --cover --python ${{ matrix.python-version }} + hatch test --cover --junitxml=junit.xml -o junit_family=legacy --python ${{ matrix.python-version }} - name: Upload coverage to Codecov - if: matrix.python-version == '3.10' + if: matrix.python-version == '3.11' uses: codecov/codecov-action@v5 with: - # not required for public repos, but intermittently fails otherwise token: ${{ secrets.CODECOV_TOKEN }} + - + name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.python-version }} test-edge: name: Test Gotenberg :edge diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b9f80..1c25f14 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 - Official support and testing for Python 3.13 ([#25](https://github.com/stumpylog/gotenberg-client/pull/25)) - Support for setting PDF metadata ([#42](https://github.com/stumpylog/gotenberg-client/pull/42)) - Initial work by @spechtx in ([#40](https://github.com/stumpylog/gotenberg-client/pull/40)) +- Integrated Codecov test analytics ([#44](https://github.com/stumpylog/gotenberg-client/pull/44)) ### Changed From 05a29702329c4ca0ec8c4e1329b1880b78c7d80b Mon Sep 17 00:00:00 2001 From: Trenton Holmes <797416+stumpylog@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:11:29 -0800 Subject: [PATCH 9/9] Bumps version to 0.8.0 --- CHANGELOG.md | 4 ++-- src/gotenberg_client/__about__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c25f14..50ddee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ 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.8.0] - 2024-12-11 ### Breaking Change -- Dopped support for Python 3.8 ([#43](https://github.com/stumpylog/gotenberg-client/pull/43)) +- Dropped support for Python 3.8 ([#43](https://github.com/stumpylog/gotenberg-client/pull/43)) ### Added diff --git a/src/gotenberg_client/__about__.py b/src/gotenberg_client/__about__.py index 7404394..9c90f17 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.7.0" +__version__ = "0.8.0"