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 +123 -0
- postext/__main__.py +8 -0
- postext/_binary.py +280 -0
- postext/_client.py +629 -0
- postext/_errors.py +44 -0
- postext/_report.py +321 -0
- postext/_version.py +4 -0
- postext/py.typed +0 -0
- postext-1.70.5.dist-info/METADATA +151 -0
- postext-1.70.5.dist-info/RECORD +12 -0
- postext-1.70.5.dist-info/WHEEL +4 -0
- postext-1.70.5.dist-info/licenses/LICENSE +21 -0
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
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)
|