gotenberg-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gotenberg_client/__about__.py +4 -0
- gotenberg_client/__init__.py +6 -0
- gotenberg_client/_base.py +122 -0
- gotenberg_client/_client.py +72 -0
- gotenberg_client/_convert/__init__.py +3 -0
- gotenberg_client/_convert/chromium.py +174 -0
- gotenberg_client/_convert/common.py +31 -0
- gotenberg_client/_convert/libre_office.py +55 -0
- gotenberg_client/_convert/pdfa.py +34 -0
- gotenberg_client/_health.py +131 -0
- gotenberg_client/_merge.py +36 -0
- gotenberg_client/_metrics.py +3 -0
- gotenberg_client/_types_compat.py +10 -0
- gotenberg_client/_utils.py +42 -0
- gotenberg_client/options.py +101 -0
- gotenberg_client/py.typed +0 -0
- gotenberg_client-0.1.0.dist-info/METADATA +153 -0
- gotenberg_client-0.1.0.dist-info/RECORD +20 -0
- gotenberg_client-0.1.0.dist-info/WHEEL +4 -0
- gotenberg_client-0.1.0.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
import logging
|
|
5
|
+
from contextlib import ExitStack
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Dict
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from typing import Type
|
|
11
|
+
|
|
12
|
+
from httpx import Client
|
|
13
|
+
from httpx import Response
|
|
14
|
+
from httpx._types import RequestFiles
|
|
15
|
+
|
|
16
|
+
from gotenberg_client._types_compat import Self
|
|
17
|
+
from gotenberg_client._utils import guess_mime_type
|
|
18
|
+
from gotenberg_client.options import PdfAFormat
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseRoute:
|
|
24
|
+
"""
|
|
25
|
+
The base implementation of a Gotenberg API route. Anything settings or
|
|
26
|
+
actions shared between all routes should be implemented here
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: Client, api_route: str) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
self._route = api_route
|
|
32
|
+
self._stack = ExitStack()
|
|
33
|
+
self._form_data: Dict[str, str] = {}
|
|
34
|
+
self._file_map: Dict[str, Path] = {}
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> Self:
|
|
37
|
+
self.reset()
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(
|
|
41
|
+
self,
|
|
42
|
+
exc_type: Optional[Type[BaseException]],
|
|
43
|
+
exc_val: Optional[BaseException],
|
|
44
|
+
exc_tb: Optional[TracebackType],
|
|
45
|
+
) -> None:
|
|
46
|
+
self.reset()
|
|
47
|
+
|
|
48
|
+
def reset(self) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Calls all context manager __exit__ via the ExitStack and clears
|
|
51
|
+
all set files and form data options
|
|
52
|
+
"""
|
|
53
|
+
self._stack.close()
|
|
54
|
+
self._form_data.clear()
|
|
55
|
+
self._file_map.clear()
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Alias for reset
|
|
60
|
+
"""
|
|
61
|
+
self.reset()
|
|
62
|
+
|
|
63
|
+
def run(self) -> Response:
|
|
64
|
+
"""
|
|
65
|
+
Executes the configured route against the server and returns the resulting
|
|
66
|
+
Response.
|
|
67
|
+
TODO: It would be nice to return a simpler response to the user
|
|
68
|
+
"""
|
|
69
|
+
resp = self._client.post(url=self._route, data=self._form_data, files=self.get_files())
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
return resp
|
|
72
|
+
|
|
73
|
+
def get_files(self) -> RequestFiles:
|
|
74
|
+
"""
|
|
75
|
+
Deals with opening all provided files for multi-part uploads, including
|
|
76
|
+
pushing their new contexts onto the stack to ensure resources like file
|
|
77
|
+
handles are cleaned up
|
|
78
|
+
"""
|
|
79
|
+
files = {}
|
|
80
|
+
for filename in self._file_map:
|
|
81
|
+
file_path = self._file_map[filename]
|
|
82
|
+
# Gotenberg requires these to have the specific name
|
|
83
|
+
filepath_name = filename if filename in {"index.html", "header.html", "footer.html"} else file_path.name
|
|
84
|
+
|
|
85
|
+
# Helpful but not necessary to provide the mime type when possible
|
|
86
|
+
mime_type = guess_mime_type(file_path)
|
|
87
|
+
if mime_type is not None:
|
|
88
|
+
files.update(
|
|
89
|
+
{filepath_name: (filepath_name, self._stack.enter_context(file_path.open("rb")), mime_type)},
|
|
90
|
+
)
|
|
91
|
+
else: # pragma: no cover
|
|
92
|
+
files.update({filepath_name: (filepath_name, self._stack.enter_context(file_path.open("rb")))}) # type: ignore
|
|
93
|
+
return files
|
|
94
|
+
|
|
95
|
+
def _add_file_map(self, filepath: Path, name: Optional[str] = None) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Small helper to handle bookkeeping of files for later opening. The name is
|
|
98
|
+
optional to support those things which are required to have a certain name
|
|
99
|
+
"""
|
|
100
|
+
if name is None:
|
|
101
|
+
name = filepath.name
|
|
102
|
+
if name in self._file_map: # pragma: no cover
|
|
103
|
+
logger.warning(f"{name} has already been provided, overwriting anyway")
|
|
104
|
+
self._file_map[name] = filepath
|
|
105
|
+
|
|
106
|
+
def pdf_format(self, pdf_format: PdfAFormat) -> "BaseRoute":
|
|
107
|
+
"""
|
|
108
|
+
All routes provide the option to configure the output PDF as a
|
|
109
|
+
PDF/A format
|
|
110
|
+
"""
|
|
111
|
+
self._form_data.update(pdf_format.to_form())
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BaseApi:
|
|
116
|
+
"""
|
|
117
|
+
Simple base class for an API, which wraps one or more routes, providing
|
|
118
|
+
each with the client to use
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, client: Client) -> None:
|
|
122
|
+
self._client = client
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
import logging
|
|
5
|
+
from importlib.util import find_spec
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Dict
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from typing import Type
|
|
10
|
+
|
|
11
|
+
from httpx import Client
|
|
12
|
+
|
|
13
|
+
from gotenberg_client._convert.chromium import ChromiumApi
|
|
14
|
+
from gotenberg_client._convert.libre_office import LibreOfficeApi
|
|
15
|
+
from gotenberg_client._convert.pdfa import PdfAApi
|
|
16
|
+
from gotenberg_client._health import HealthCheckApi
|
|
17
|
+
from gotenberg_client._merge import MergeApi
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GotenbergClient:
|
|
21
|
+
"""
|
|
22
|
+
The user's primary interface to the Gotenberg instance
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
gotenerg_url: str,
|
|
29
|
+
timeout: float = 30.0,
|
|
30
|
+
log_level: int = logging.ERROR,
|
|
31
|
+
http2: bool = True,
|
|
32
|
+
):
|
|
33
|
+
# Configure the client
|
|
34
|
+
self._client = Client(base_url=gotenerg_url, timeout=timeout, http2=http2)
|
|
35
|
+
|
|
36
|
+
# Set the log level
|
|
37
|
+
logging.getLogger("httpx").setLevel(log_level)
|
|
38
|
+
logging.getLogger("httpcore").setLevel(log_level)
|
|
39
|
+
|
|
40
|
+
# TODO Brotli?
|
|
41
|
+
if find_spec("brotli") is not None:
|
|
42
|
+
self._client.headers.update({"Accept-Encoding": "gzip,deflate,br"})
|
|
43
|
+
else:
|
|
44
|
+
self._client.headers.update({"Accept-Encoding": "gzip,deflate"})
|
|
45
|
+
|
|
46
|
+
# Add the resources
|
|
47
|
+
self.chromium = ChromiumApi(self._client)
|
|
48
|
+
self.libre_office = LibreOfficeApi(self._client)
|
|
49
|
+
self.pdf_a = PdfAApi(self._client)
|
|
50
|
+
self.merge = MergeApi(self._client)
|
|
51
|
+
self.health = HealthCheckApi(self._client)
|
|
52
|
+
# TODO
|
|
53
|
+
|
|
54
|
+
def add_headers(self, header: Dict[str, str]) -> None: # pragma: no cover
|
|
55
|
+
"""
|
|
56
|
+
Updates the httpx Client headers with the given values
|
|
57
|
+
"""
|
|
58
|
+
self._client.headers.update(header)
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "GotenbergClient":
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._client.close()
|
|
65
|
+
|
|
66
|
+
def __exit__(
|
|
67
|
+
self,
|
|
68
|
+
exc_type: Optional[Type[BaseException]],
|
|
69
|
+
exc_val: Optional[BaseException],
|
|
70
|
+
exc_tb: Optional[TracebackType],
|
|
71
|
+
) -> None:
|
|
72
|
+
self.close()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict
|
|
8
|
+
from typing import Final
|
|
9
|
+
from typing import List
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
from gotenberg_client._base import BaseApi
|
|
13
|
+
from gotenberg_client._convert.common import ConvertBaseRoute
|
|
14
|
+
from gotenberg_client._types_compat import Self
|
|
15
|
+
from gotenberg_client.options import EmulatedMediaType
|
|
16
|
+
from gotenberg_client.options import Margin
|
|
17
|
+
from gotenberg_client.options import PageSize
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# See https://github.com/psf/requests/issues/1081#issuecomment-428504128
|
|
23
|
+
class ForceMultipartDict(Dict):
|
|
24
|
+
def __bool__(self) -> bool:
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
FORCE_MULTIPART: Final = ForceMultipartDict()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChromiumBaseRoute(ConvertBaseRoute):
|
|
32
|
+
"""
|
|
33
|
+
https://gotenberg.dev/docs/routes#convert-with-chromium
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def header(self, header: Path) -> Self:
|
|
37
|
+
self._add_file_map(header, "header.html")
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def footer(self, footer: Path) -> Self:
|
|
41
|
+
self._add_file_map(footer, "footer.html")
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def resource(self, resource: Path) -> Self:
|
|
45
|
+
self._add_file_map(resource)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def resources(self, resources: List[Path]) -> Self:
|
|
49
|
+
for x in resources:
|
|
50
|
+
self.resource(x)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def size(self, size: PageSize) -> Self:
|
|
54
|
+
self._form_data.update(size.to_form())
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
page_size = size
|
|
58
|
+
|
|
59
|
+
def margins(self, margins: Margin) -> Self:
|
|
60
|
+
self._form_data.update(margins.to_form())
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def prefer_css_page_size(self) -> Self:
|
|
64
|
+
self._form_data.update({"preferCssPageSize": "true"})
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def prefer_set_page_size(self) -> Self:
|
|
68
|
+
self._form_data.update({"preferCssPageSize": "false"})
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def background_graphics(self) -> Self:
|
|
72
|
+
self._form_data.update({"printBackground": "true"})
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def no_background_graphics(self) -> Self:
|
|
76
|
+
self._form_data.update({"printBackground": "false"})
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def hide_background(self) -> Self:
|
|
80
|
+
self._form_data.update({"omitBackground": "true"})
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def show_background(self) -> Self:
|
|
84
|
+
self._form_data.update({"omitBackground": "false"})
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def scale(self, scale: Union[int, float]) -> Self:
|
|
88
|
+
self._form_data.update({"scale": str(scale)})
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def render_wait(self, wait: Union[int, float]) -> Self:
|
|
92
|
+
self._form_data.update({"waitDelay": str(wait)})
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def render_expr(self, expr: str) -> Self:
|
|
96
|
+
self._form_data.update({"waitForExpression": expr})
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def media_type(self, media_type: EmulatedMediaType) -> Self:
|
|
100
|
+
self._form_data.update(media_type.to_form())
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def user_agent(self, agent: str) -> Self:
|
|
104
|
+
self._form_data.update({"userAgent": agent})
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def headers(self, headers: Dict[str, str]) -> Self:
|
|
108
|
+
json_str = json.dumps(headers)
|
|
109
|
+
# TODO: Need to check this
|
|
110
|
+
self._form_data.update({"extraHttpHeaders": json_str})
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def fail_on_exceptions(self) -> Self:
|
|
114
|
+
self._form_data.update({"failOnConsoleExceptions": "true"})
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def dont_fail_on_exceptions(self) -> Self:
|
|
118
|
+
self._form_data.update({"failOnConsoleExceptions": "false"})
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _FileBasedRoute(ChromiumBaseRoute):
|
|
123
|
+
def index(self, index: Path) -> Self:
|
|
124
|
+
self._add_file_map(index, "index.html")
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class HtmlRoute(_FileBasedRoute):
|
|
129
|
+
"""
|
|
130
|
+
https://gotenberg.dev/docs/routes#html-file-into-pdf-route
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class UrlRoute(ChromiumBaseRoute):
|
|
135
|
+
"""
|
|
136
|
+
https://gotenberg.dev/docs/routes#url-into-pdf-route
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def url(self, url: str) -> Self:
|
|
140
|
+
self._form_data["url"] = url
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def get_files(self) -> ForceMultipartDict:
|
|
144
|
+
return FORCE_MULTIPART
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class MarkdownRoute(_FileBasedRoute):
|
|
148
|
+
"""
|
|
149
|
+
https://gotenberg.dev/docs/routes#markdown-files-into-pdf-route
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def markdown_file(self, markdown_file: Path) -> Self:
|
|
153
|
+
self._add_file_map(markdown_file)
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def markdown_files(self, markdown_files: List[Path]) -> Self:
|
|
157
|
+
for x in markdown_files:
|
|
158
|
+
self.markdown_file(x)
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ChromiumApi(BaseApi):
|
|
163
|
+
_URL_CONVERT_ENDPOINT = "/forms/chromium/convert/url"
|
|
164
|
+
_HTML_CONVERT_ENDPOINT = "/forms/chromium/convert/html"
|
|
165
|
+
_MARKDOWN_CONVERT_ENDPOINT = "/forms/chromium/convert/markdown"
|
|
166
|
+
|
|
167
|
+
def html_to_pdf(self) -> HtmlRoute:
|
|
168
|
+
return HtmlRoute(self._client, self._HTML_CONVERT_ENDPOINT)
|
|
169
|
+
|
|
170
|
+
def url_to_pdf(self) -> UrlRoute:
|
|
171
|
+
return UrlRoute(self._client, self._URL_CONVERT_ENDPOINT)
|
|
172
|
+
|
|
173
|
+
def markdown_to_pdf(self) -> MarkdownRoute:
|
|
174
|
+
return MarkdownRoute(self._client, self._MARKDOWN_CONVERT_ENDPOINT)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from gotenberg_client._base import BaseRoute
|
|
7
|
+
from gotenberg_client._types_compat import Self
|
|
8
|
+
from gotenberg_client.options import PageOrientation
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConvertBaseRoute(BaseRoute):
|
|
14
|
+
"""
|
|
15
|
+
All 3 convert routes provide control over orientation and page ranges
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def orient(self, orient: PageOrientation) -> Self:
|
|
19
|
+
"""
|
|
20
|
+
Sets the page orientation, either Landscape or portrait
|
|
21
|
+
"""
|
|
22
|
+
self._form_data.update(orient.to_form())
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def page_ranges(self, ranges: str) -> Self:
|
|
26
|
+
"""
|
|
27
|
+
Sets the page range string, allowing either some range or just a
|
|
28
|
+
few pages
|
|
29
|
+
"""
|
|
30
|
+
self._form_data.update({"nativePageRanges": ranges})
|
|
31
|
+
return self
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from gotenberg_client._base import BaseApi
|
|
8
|
+
from gotenberg_client._convert.common import ConvertBaseRoute
|
|
9
|
+
from gotenberg_client._types_compat import Self
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LibreOfficeConvertRoute(ConvertBaseRoute):
|
|
13
|
+
"""
|
|
14
|
+
https://gotenberg.dev/docs/routes#convert-with-libreoffice
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def convert(self, file_path: Path) -> Self:
|
|
18
|
+
"""
|
|
19
|
+
Adds a single file to be converted to PDF. Can be called multiple times,
|
|
20
|
+
resulting in a ZIP of the PDFs, unless merged
|
|
21
|
+
"""
|
|
22
|
+
self._add_file_map(file_path)
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def convert_files(self, file_paths: List[Path]) -> Self:
|
|
26
|
+
"""
|
|
27
|
+
Adds all provided files for conversion
|
|
28
|
+
"""
|
|
29
|
+
for x in file_paths:
|
|
30
|
+
self.convert(x)
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def merge(self) -> Self:
|
|
34
|
+
"""
|
|
35
|
+
Merge the resulting PDFs into one
|
|
36
|
+
"""
|
|
37
|
+
self._form_data.update({"merge": "true"})
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def no_merge(self) -> Self:
|
|
41
|
+
"""
|
|
42
|
+
Don't merge the resulting PDFs
|
|
43
|
+
"""
|
|
44
|
+
self._form_data.update({"merge": "false"})
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LibreOfficeApi(BaseApi):
|
|
49
|
+
_CONVERT_ENDPOINT = "/forms/libreoffice/convert"
|
|
50
|
+
|
|
51
|
+
def to_pdf(self) -> LibreOfficeConvertRoute:
|
|
52
|
+
"""
|
|
53
|
+
Returns the LibreOffice conversion route
|
|
54
|
+
"""
|
|
55
|
+
return LibreOfficeConvertRoute(self._client, self._CONVERT_ENDPOINT)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from gotenberg_client._base import BaseApi
|
|
8
|
+
from gotenberg_client._convert.common import ConvertBaseRoute
|
|
9
|
+
from gotenberg_client._types_compat import Self
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PdfAConvertRoute(ConvertBaseRoute):
|
|
13
|
+
"""
|
|
14
|
+
https://gotenberg.dev/docs/routes#convert-into-pdfa-route
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def convert(self, file_path: Path) -> Self:
|
|
18
|
+
"""
|
|
19
|
+
Convert a single PDF into the provided PDF/A format
|
|
20
|
+
"""
|
|
21
|
+
self._add_file_map(file_path)
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def convert_files(self, file_paths: List[Path]) -> Self:
|
|
25
|
+
for x in file_paths:
|
|
26
|
+
self.convert(x)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PdfAApi(BaseApi):
|
|
31
|
+
_CONVERT_ENDPOINT = "/forms/pdfengines/convert"
|
|
32
|
+
|
|
33
|
+
def to_pdfa(self) -> PdfAConvertRoute:
|
|
34
|
+
return PdfAConvertRoute(self._client, self._CONVERT_ENDPOINT)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
import dataclasses
|
|
5
|
+
import datetime
|
|
6
|
+
import enum
|
|
7
|
+
import re
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
from typing import no_type_check
|
|
11
|
+
|
|
12
|
+
from gotenberg_client._base import BaseApi
|
|
13
|
+
|
|
14
|
+
_TIME_RE = re.compile(
|
|
15
|
+
r"(?P<year>\d{4})-"
|
|
16
|
+
r"(?P<month>\d{2})-"
|
|
17
|
+
r"(?P<day>\d{2})"
|
|
18
|
+
r"[ tT]"
|
|
19
|
+
r"(?P<hour>\d{2}):"
|
|
20
|
+
r"(?P<minute>\d{2}):"
|
|
21
|
+
r"(?P<second>\d{2})"
|
|
22
|
+
r"(?P<fractional_seconds>\.\d+)?"
|
|
23
|
+
r"(?P<timezone>[zZ]|[+-]\d{2}:\d{2})?",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ModuleStatusType(TypedDict):
|
|
28
|
+
status: str
|
|
29
|
+
timestamp: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _AllModulesType(TypedDict):
|
|
33
|
+
chromium: _ModuleStatusType
|
|
34
|
+
uno: _ModuleStatusType
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _HealthCheckApiResponseType(TypedDict):
|
|
38
|
+
status: str
|
|
39
|
+
details: _AllModulesType
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@enum.unique
|
|
43
|
+
class StatusOptions(str, enum.Enum):
|
|
44
|
+
Up = "up"
|
|
45
|
+
Down = "down"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@enum.unique
|
|
49
|
+
class ModuleOptions(str, enum.Enum):
|
|
50
|
+
Chromium = "chromium"
|
|
51
|
+
Uno = "uno"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass
|
|
55
|
+
class ModuleStatus:
|
|
56
|
+
status: StatusOptions
|
|
57
|
+
timestamp: datetime.datetime
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HealthStatus:
|
|
61
|
+
"""
|
|
62
|
+
Decodes the JSON health response into Python types
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, data: _HealthCheckApiResponseType) -> None:
|
|
66
|
+
self.data = data
|
|
67
|
+
self.overall = StatusOptions(data["status"])
|
|
68
|
+
|
|
69
|
+
self.chromium: Optional[ModuleStatus] = None
|
|
70
|
+
if ModuleOptions.Chromium.value in self.data["details"]:
|
|
71
|
+
self.chromium = self._extract_status(ModuleOptions.Chromium)
|
|
72
|
+
|
|
73
|
+
self.uno: Optional[ModuleStatus] = None
|
|
74
|
+
if ModuleOptions.Uno.value in self.data["details"]:
|
|
75
|
+
self.uno = self._extract_status(ModuleOptions.Uno)
|
|
76
|
+
|
|
77
|
+
def _extract_status(self, module: ModuleOptions) -> ModuleStatus:
|
|
78
|
+
status = StatusOptions(self.data["details"][module.value]["status"])
|
|
79
|
+
|
|
80
|
+
# mypy is quite wrong here, it's clearly marked as a datetime.datetime, not Any
|
|
81
|
+
timestamp = self._extract_datetime(self.data["details"][module.value]["timestamp"]) # type: ignore
|
|
82
|
+
# Also wrong here
|
|
83
|
+
return ModuleStatus(status, timestamp) # type: ignore
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
@no_type_check
|
|
87
|
+
def _extract_datetime(timestamp: str) -> datetime.datetime:
|
|
88
|
+
m = _TIME_RE.match(timestamp)
|
|
89
|
+
if not m:
|
|
90
|
+
msg = f"Unable to parse {timestamp}"
|
|
91
|
+
raise ValueError(msg)
|
|
92
|
+
|
|
93
|
+
(year, month, day, hour, minute, second, frac_sec, timezone_str) = m.groups()
|
|
94
|
+
|
|
95
|
+
microseconds = int(float(frac_sec) * 1000000.0) if frac_sec is not None else 0
|
|
96
|
+
tzinfo = None
|
|
97
|
+
if timezone_str is not None:
|
|
98
|
+
if timezone_str.lower() == "z":
|
|
99
|
+
tzinfo = datetime.timezone.utc
|
|
100
|
+
else:
|
|
101
|
+
multi = -1 if timezone_str[0:1] == "-" else 1
|
|
102
|
+
hours = int(timezone_str[1:3])
|
|
103
|
+
minutes = int(timezone_str[4:])
|
|
104
|
+
delta = datetime.timedelta(hours=hours, minutes=minutes) * multi
|
|
105
|
+
tzinfo = datetime.timezone(delta)
|
|
106
|
+
|
|
107
|
+
return datetime.datetime(
|
|
108
|
+
year=int(year),
|
|
109
|
+
month=int(month),
|
|
110
|
+
day=int(day),
|
|
111
|
+
hour=int(hour),
|
|
112
|
+
minute=int(minute),
|
|
113
|
+
second=int(second),
|
|
114
|
+
microsecond=microseconds,
|
|
115
|
+
tzinfo=tzinfo,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class HealthCheckApi(BaseApi):
|
|
120
|
+
"""
|
|
121
|
+
Provides the route for health checks
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
_HEALTH_ENDPOINT = "/health"
|
|
125
|
+
|
|
126
|
+
def health(self) -> HealthStatus:
|
|
127
|
+
resp = self._client.get(self._HEALTH_ENDPOINT, headers={"Accept": "application/json"})
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
json_data: _HealthCheckApiResponseType = resp.json()
|
|
130
|
+
|
|
131
|
+
return HealthStatus(json_data)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from gotenberg_client._base import BaseApi
|
|
8
|
+
from gotenberg_client._base import BaseRoute
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MergeRoute(BaseRoute):
|
|
12
|
+
"""
|
|
13
|
+
Handles the merging of a given set of files
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def merge(self, files: List[Path]) -> "MergeRoute":
|
|
17
|
+
"""
|
|
18
|
+
Adds the given files into the file mapping. This method will maintain the
|
|
19
|
+
ordering of the list. Calling this method multiple times may not merge
|
|
20
|
+
in the expected ordering
|
|
21
|
+
"""
|
|
22
|
+
for idx, filepath in enumerate(files):
|
|
23
|
+
# Include index to enforce ordering
|
|
24
|
+
self._add_file_map(filepath, f"{idx}_{filepath.name}")
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MergeApi(BaseApi):
|
|
29
|
+
"""
|
|
30
|
+
Wraps the merge route
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_MERGE_ENDPOINT = "/forms/pdfengines/merge"
|
|
34
|
+
|
|
35
|
+
def merge(self) -> MergeRoute:
|
|
36
|
+
return MergeRoute(self._client, self._MERGE_ENDPOINT)
|