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.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ from gotenberg_client._client import GotenbergClient
5
+
6
+ __all__ = ["GotenbergClient"]
@@ -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,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
@@ -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)
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2023-present Trenton H <rda0128ou@mozmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ import sys
6
+
7
+ if sys.version_info >= (3, 11):
8
+ from typing import Self
9
+ else:
10
+ from typing_extensions import Self # noqa: F401