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] 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