postext 1.70.5__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.
postext/__init__.py ADDED
@@ -0,0 +1,123 @@
1
+ """Postext from Python.
2
+
3
+ Typesets books with the self-contained ``postext`` command line: PDF, HTML,
4
+ EPUB, page images and Word from a ``.postext`` book, a book folder or loose
5
+ Markdown. The executable of this system is downloaded from the GitHub
6
+ Release of this package's version on first use, checked against its SHA256
7
+ and cached; ``POSTEXT_BIN`` points at one already installed.
8
+
9
+ import postext
10
+
11
+ report = postext.pdf("book.postext", out="book.pdf", set={"page.dpi": 300})
12
+ print(report.output.pages, [str(w) for w in report.warnings])
13
+
14
+ pdf = postext.pdf_bytes(open("book.postext", "rb").read())
15
+
16
+ Every function runs the command with ``--json`` and returns its report
17
+ (:class:`Report` and kin). A failure raises :class:`PostextError`
18
+ (:class:`UsageError` for a wrong call, :class:`ProblemsFound` when ``check``
19
+ finds problems). :class:`Postext` runs a given executable, in a given folder
20
+ or with a timeout.
21
+ """
22
+
23
+ from ._binary import cache_dir, cached_binary, download_binary, find_binary, platform_file
24
+ from ._client import STDOUT, BookInput, Books, Postext
25
+ from ._errors import BinaryNotFound, PostextError, ProblemsFound, UsageError
26
+ from ._report import (
27
+ BookSummary,
28
+ ChapterInfo,
29
+ CheckReport,
30
+ Diagnostic,
31
+ FontInfo,
32
+ ImageReport,
33
+ ImportDocxReport,
34
+ InfoReport,
35
+ Output,
36
+ PageRef,
37
+ Report,
38
+ UnpackReport,
39
+ )
40
+ from ._version import __version__
41
+
42
+ _default = Postext()
43
+
44
+ pdf = _default.pdf
45
+ html = _default.html
46
+ epub = _default.epub
47
+ image = _default.image
48
+ images = _default.images
49
+ docx = _default.docx
50
+ import_docx = _default.import_docx
51
+ pack = _default.pack
52
+ unpack = _default.unpack
53
+ info = _default.info
54
+ check = _default.check
55
+ build = _default.build
56
+
57
+ pdf_bytes = _default.pdf_bytes
58
+ html_bytes = _default.html_bytes
59
+ epub_bytes = _default.epub_bytes
60
+ image_bytes = _default.image_bytes
61
+ docx_bytes = _default.docx_bytes
62
+ pack_bytes = _default.pack_bytes
63
+
64
+ run = _default.run
65
+
66
+
67
+ def binary() -> str:
68
+ """The path of the executable the functions run (downloaded on first call)."""
69
+ return str(find_binary())
70
+
71
+
72
+ __all__ = [
73
+ "__version__",
74
+ "Postext",
75
+ "STDOUT",
76
+ "BookInput",
77
+ "Books",
78
+ # commands
79
+ "pdf",
80
+ "html",
81
+ "epub",
82
+ "image",
83
+ "images",
84
+ "docx",
85
+ "import_docx",
86
+ "pack",
87
+ "unpack",
88
+ "info",
89
+ "check",
90
+ "build",
91
+ "pdf_bytes",
92
+ "html_bytes",
93
+ "epub_bytes",
94
+ "image_bytes",
95
+ "docx_bytes",
96
+ "pack_bytes",
97
+ "run",
98
+ # executable
99
+ "binary",
100
+ "find_binary",
101
+ "download_binary",
102
+ "platform_file",
103
+ "cache_dir",
104
+ "cached_binary",
105
+ # reports
106
+ "Report",
107
+ "InfoReport",
108
+ "CheckReport",
109
+ "ImageReport",
110
+ "ImportDocxReport",
111
+ "UnpackReport",
112
+ "Output",
113
+ "Diagnostic",
114
+ "BookSummary",
115
+ "ChapterInfo",
116
+ "FontInfo",
117
+ "PageRef",
118
+ # errors
119
+ "PostextError",
120
+ "UsageError",
121
+ "ProblemsFound",
122
+ "BinaryNotFound",
123
+ ]
postext/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m postext pdf book.postext -o book.pdf``: the command line itself,
2
+ downloaded on first use like the functions'."""
3
+
4
+ import sys
5
+
6
+ from ._client import _main
7
+
8
+ sys.exit(_main())
postext/_binary.py ADDED
@@ -0,0 +1,280 @@
1
+ """Finding the postext executable, and downloading it on first use.
2
+
3
+ Every release of postext attaches one self-contained executable per system
4
+ to its GitHub Release, with their checksums in ``SHA256SUMS``. They are too
5
+ large for a wheel (185 to 230 MB), so the package fetches the one of this
6
+ system the first time it is needed, checks it and keeps it in the cache:
7
+
8
+ <cache>/bin/<version>/postext[.exe]
9
+
10
+ ``<cache>`` is the folder the command line keeps downloaded fonts in:
11
+ ``POSTEXT_CACHE_DIR``, else ``%LOCALAPPDATA%\\postext\\cache`` on Windows and
12
+ ``$XDG_CACHE_HOME/postext`` (``~/.cache/postext``) elsewhere.
13
+
14
+ ``POSTEXT_BIN`` names an executable that is already installed (Homebrew,
15
+ Scoop, a local build) and skips the download.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import glob
21
+ import hashlib
22
+ import os
23
+ import platform
24
+ import shutil
25
+ import ssl
26
+ import subprocess
27
+ import sys
28
+ import tempfile
29
+ import urllib.error
30
+ import urllib.request
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ from ._errors import BinaryNotFound
35
+ from ._version import __version__
36
+
37
+ ENV_BIN = "POSTEXT_BIN"
38
+ ENV_CACHE = "POSTEXT_CACHE_DIR"
39
+ ENV_RELEASE_URL = "POSTEXT_RELEASE_URL"
40
+ RELEASE_URL = "https://github.com/drnachio/postext/releases/download/v{version}"
41
+
42
+ _ARCH = {
43
+ "x86_64": "x64",
44
+ "amd64": "x64",
45
+ "x64": "x64",
46
+ "arm64": "arm64",
47
+ "aarch64": "arm64",
48
+ "armv8": "arm64",
49
+ "armv8l": "arm64",
50
+ }
51
+
52
+ # The executables each release publishes.
53
+ FILES = (
54
+ "postext-macos-arm64",
55
+ "postext-macos-x64",
56
+ "postext-linux-x64",
57
+ "postext-linux-arm64",
58
+ "postext-linux-x64-musl",
59
+ "postext-windows-x64.exe",
60
+ )
61
+
62
+
63
+ def is_musl() -> bool:
64
+ """Whether this Linux runs on musl (Alpine) rather than glibc."""
65
+ if not sys.platform.startswith("linux"):
66
+ return False
67
+ libc, _ = platform.libc_ver()
68
+ if libc == "glibc":
69
+ return False
70
+ return libc == "musl" or bool(glob.glob("/lib/ld-musl-*"))
71
+
72
+
73
+ def platform_file(
74
+ system: Optional[str] = None,
75
+ machine: Optional[str] = None,
76
+ musl: Optional[bool] = None,
77
+ ) -> str:
78
+ """The name of this system's executable on the GitHub Release.
79
+
80
+ Raises :class:`BinaryNotFound` for a system no release builds for.
81
+ """
82
+ system = system or sys.platform
83
+ machine = (machine or platform.machine()).lower()
84
+ arch = _ARCH.get(machine)
85
+ if system == "darwin":
86
+ os_name = "macos"
87
+ elif system.startswith("linux"):
88
+ os_name = "linux"
89
+ elif system in ("win32", "cygwin", "msys"):
90
+ os_name = "windows"
91
+ # Windows on ARM runs the x64 executable under emulation.
92
+ if arch == "arm64":
93
+ arch = "x64"
94
+ else:
95
+ os_name = system
96
+ if musl is None:
97
+ musl = os_name == "linux" and is_musl()
98
+ name = f"postext-{os_name}-{arch}" + ("-musl" if musl else "") + (".exe" if os_name == "windows" else "")
99
+ if arch is None or name not in FILES:
100
+ raise BinaryNotFound(
101
+ f"No postext executable is published for {system} {machine}{' (musl)' if musl else ''}. "
102
+ f"Build one (packages/postext-cli: bun run build:bin) and point {ENV_BIN} at it."
103
+ )
104
+ return name
105
+
106
+
107
+ def cache_dir() -> Path:
108
+ """The folder postext keeps downloads in (fonts, and here the executables)."""
109
+ env = os.environ.get(ENV_CACHE)
110
+ if env:
111
+ return Path(env)
112
+ if sys.platform == "win32":
113
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
114
+ return Path(base) / "postext" / "cache"
115
+ return Path(os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")) / "postext"
116
+
117
+
118
+ def cached_binary(version: str = __version__) -> Path:
119
+ """Where the executable of ``version`` is kept once downloaded."""
120
+ exe = "postext.exe" if sys.platform == "win32" else "postext"
121
+ return cache_dir() / "bin" / version / exe
122
+
123
+
124
+ class _Stream:
125
+ """A download being read: an HTTP response, or ``curl``'s output."""
126
+
127
+ def __init__(self, url: str, response: Any = None, process: "Optional[subprocess.Popen[bytes]]" = None) -> None:
128
+ self.url = url
129
+ self._response = response
130
+ self._process = process
131
+ length = response.headers.get("Content-Length") if response is not None else None
132
+ self.length: Optional[int] = int(length) if length and length.isdigit() else None
133
+
134
+ def read(self, n: int = -1) -> bytes:
135
+ if self._response is not None:
136
+ data: bytes = self._response.read(n) if n >= 0 else self._response.read()
137
+ return data
138
+ assert self._process is not None and self._process.stdout is not None
139
+ chunk = self._process.stdout.read(n) if n >= 0 else self._process.stdout.read()
140
+ if not chunk:
141
+ code = self._process.wait()
142
+ if code != 0:
143
+ err = self._process.stderr.read().decode("utf-8", "replace").strip() if self._process.stderr else ""
144
+ raise BinaryNotFound(f"Could not download {self.url}: curl exit code {code} {err}".strip())
145
+ return chunk
146
+
147
+ def __enter__(self) -> "_Stream":
148
+ return self
149
+
150
+ def __exit__(self, *exc: Any) -> None:
151
+ if self._response is not None:
152
+ self._response.close()
153
+ if self._process is not None:
154
+ if self._process.poll() is None:
155
+ self._process.kill()
156
+ self._process.wait()
157
+ for f in (self._process.stdout, self._process.stderr):
158
+ if f:
159
+ f.close()
160
+
161
+
162
+ def _fetch(url: str) -> _Stream:
163
+ """Open ``url``. A Python that cannot verify TLS certificates (a
164
+ python.org build on macOS before ``Install Certificates.command``) tries
165
+ ``certifi`` when it is installed, then the system's ``curl``; the
166
+ executable is still checked against ``SHA256SUMS``."""
167
+ headers = {"User-Agent": f"postext-python/{__version__}"}
168
+ contexts: List[Optional[ssl.SSLContext]] = [None]
169
+ try:
170
+ import certifi # type: ignore[import-not-found]
171
+
172
+ contexts.append(ssl.create_default_context(cafile=certifi.where()))
173
+ except ImportError:
174
+ pass
175
+ reason: Any = None
176
+ for context in contexts:
177
+ try:
178
+ return _Stream(url, urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60, context=context))
179
+ except urllib.error.HTTPError as err:
180
+ raise BinaryNotFound(f"Could not download {url}: HTTP {err.code}") from err
181
+ except urllib.error.URLError as err:
182
+ reason = err.reason
183
+ if not isinstance(reason, ssl.SSLError):
184
+ raise BinaryNotFound(f"Could not download {url}: {reason}") from err
185
+ curl = shutil.which("curl")
186
+ if curl:
187
+ process = subprocess.Popen(
188
+ [curl, "-fsSL", "--retry", "2", "-A", headers["User-Agent"], url],
189
+ stdout=subprocess.PIPE,
190
+ stderr=subprocess.PIPE,
191
+ )
192
+ return _Stream(url, process=process)
193
+ raise BinaryNotFound(
194
+ f"Could not download {url}: {reason}. This Python cannot verify TLS certificates: "
195
+ "install certifi (pip install certifi) or, with a python.org build on macOS, run its Install Certificates.command."
196
+ )
197
+
198
+
199
+ def parse_sums(text: str) -> Dict[str, str]:
200
+ """``SHA256SUMS`` (``<hex> <file>`` per line) as ``{file: hex}``."""
201
+ sums: Dict[str, str] = {}
202
+ for line in text.splitlines():
203
+ parts = line.strip().split()
204
+ if len(parts) == 2:
205
+ digest, name = parts
206
+ sums[name.lstrip("*")] = digest.lower()
207
+ return sums
208
+
209
+
210
+ def download_binary(
211
+ version: str = __version__,
212
+ *,
213
+ dest: Optional[Path] = None,
214
+ file: Optional[str] = None,
215
+ base_url: Optional[str] = None,
216
+ quiet: bool = False,
217
+ ) -> Path:
218
+ """Download the executable of ``version``, check its SHA256 against the
219
+ release's ``SHA256SUMS`` and move it into place. Returns its path."""
220
+ file = file or platform_file()
221
+ dest = dest or cached_binary(version)
222
+ base = (base_url or os.environ.get(ENV_RELEASE_URL) or RELEASE_URL).format(version=version).rstrip("/")
223
+ with _fetch(f"{base}/SHA256SUMS") as res:
224
+ sums = parse_sums(res.read().decode("utf-8", "replace"))
225
+ expected = sums.get(file)
226
+ if not expected:
227
+ raise BinaryNotFound(f"{base}/SHA256SUMS lists no {file}")
228
+ dest.parent.mkdir(parents=True, exist_ok=True)
229
+ url = f"{base}/{file}"
230
+ with _fetch(url) as res:
231
+ if not quiet:
232
+ mb = f" ({res.length / 1e6:.0f} MB)" if res.length else ""
233
+ print(f"postext: downloading {file} {version}{mb}, once, into {dest.parent}", file=sys.stderr, flush=True)
234
+ digest = hashlib.sha256()
235
+ fd, tmp_name = tempfile.mkstemp(prefix=".download-", dir=str(dest.parent))
236
+ tmp = Path(tmp_name)
237
+ try:
238
+ with os.fdopen(fd, "wb") as out:
239
+ while True:
240
+ chunk = res.read(1 << 20)
241
+ if not chunk:
242
+ break
243
+ digest.update(chunk)
244
+ out.write(chunk)
245
+ if digest.hexdigest() != expected:
246
+ raise BinaryNotFound(
247
+ f"The checksum of {url} does not match SHA256SUMS ({digest.hexdigest()} instead of {expected}); nothing was installed."
248
+ )
249
+ tmp.chmod(0o755)
250
+ try:
251
+ os.replace(tmp, dest)
252
+ except OSError:
253
+ # Another process put it there first (and Windows does not
254
+ # replace an executable that is running): keep theirs.
255
+ if not dest.exists():
256
+ raise
257
+ finally:
258
+ if tmp.exists():
259
+ tmp.unlink()
260
+ return dest
261
+
262
+
263
+ def find_binary(version: str = __version__, *, download: bool = True, quiet: bool = False) -> Path:
264
+ """The postext executable to run: ``POSTEXT_BIN``, else the cached
265
+ executable of ``version``, downloaded first if needed."""
266
+ env = os.environ.get(ENV_BIN)
267
+ if env:
268
+ path = Path(env)
269
+ if not path.is_file():
270
+ found = shutil.which(env)
271
+ if not found:
272
+ raise BinaryNotFound(f"{ENV_BIN} is {env}, which is not a file")
273
+ path = Path(found)
274
+ return path
275
+ path = cached_binary(version)
276
+ if path.is_file():
277
+ return path
278
+ if not download:
279
+ raise BinaryNotFound(f"postext {version} is not downloaded yet ({path})")
280
+ return download_binary(version, dest=path, quiet=quiet)