sendscore 0.3.0__py3-none-win_arm64.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.
- sendscore/__init__.py +122 -0
- sendscore/_bin/sendscore.exe +0 -0
- sendscore/_binary.py +206 -0
- sendscore/_cli.py +45 -0
- sendscore/_error.py +15 -0
- sendscore/_hashes.py +10 -0
- sendscore/py.typed +0 -0
- sendscore/types.py +100 -0
- sendscore-0.3.0.dist-info/METADATA +42 -0
- sendscore-0.3.0.dist-info/RECORD +13 -0
- sendscore-0.3.0.dist-info/WHEEL +4 -0
- sendscore-0.3.0.dist-info/entry_points.txt +2 -0
- sendscore-0.3.0.dist-info/licenses/LICENSE +21 -0
sendscore/__init__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Check a newsletter email before it goes out, with the SendScore CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from ._binary import ENGINE_VERSION, find_binary
|
|
11
|
+
from ._error import KILLED, SendScoreError
|
|
12
|
+
from .types import Change, Check, DiffResult, Fingerprint, Fix, Message, TopFix, Verdict
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ENGINE_VERSION",
|
|
16
|
+
"KILLED",
|
|
17
|
+
"Change",
|
|
18
|
+
"Check",
|
|
19
|
+
"DiffResult",
|
|
20
|
+
"Fingerprint",
|
|
21
|
+
"Fix",
|
|
22
|
+
"Message",
|
|
23
|
+
"SendScoreError",
|
|
24
|
+
"TopFix",
|
|
25
|
+
"Verdict",
|
|
26
|
+
"check",
|
|
27
|
+
"diff",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _path(path: str | os.PathLike[str]) -> str:
|
|
32
|
+
# A lone "-" would mean standard input to the CLI.
|
|
33
|
+
text = os.fspath(path)
|
|
34
|
+
return "./-" if text == "-" else text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run(
|
|
38
|
+
args: list[str],
|
|
39
|
+
stdin: bytes | None,
|
|
40
|
+
verdict_codes: tuple[int, ...],
|
|
41
|
+
timeout: float | None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
binary = find_binary()
|
|
44
|
+
try:
|
|
45
|
+
r = subprocess.run(
|
|
46
|
+
[binary, *args],
|
|
47
|
+
input=stdin,
|
|
48
|
+
stdin=subprocess.DEVNULL if stdin is None else None,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
check=False,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
except subprocess.TimeoutExpired as e:
|
|
54
|
+
raise SendScoreError(f"sendscore did not finish within {timeout} seconds", KILLED) from e
|
|
55
|
+
except OSError as e:
|
|
56
|
+
raise SendScoreError(f"could not run {binary}: {e}", 0) from e
|
|
57
|
+
code = KILLED if r.returncode < 0 or r.returncode >= 128 else r.returncode
|
|
58
|
+
if code not in verdict_codes:
|
|
59
|
+
text = r.stderr.decode(errors="replace").strip()
|
|
60
|
+
raise SendScoreError(text or f"sendscore exited {code}", code)
|
|
61
|
+
try:
|
|
62
|
+
result = json.loads(r.stdout)
|
|
63
|
+
except ValueError as e:
|
|
64
|
+
raise SendScoreError("sendscore printed something that is not JSON", code) from e
|
|
65
|
+
if not isinstance(result, dict):
|
|
66
|
+
raise SendScoreError("sendscore printed something that is not a report", code)
|
|
67
|
+
return cast("dict[str, Any]", result)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check(
|
|
71
|
+
message: str | os.PathLike[str] | bytes,
|
|
72
|
+
*,
|
|
73
|
+
offline: bool = False,
|
|
74
|
+
links: bool = False,
|
|
75
|
+
ip: str | None = None,
|
|
76
|
+
mail_from: str | None = None,
|
|
77
|
+
helo: str | None = None,
|
|
78
|
+
tls: bool | None = None,
|
|
79
|
+
previous: str | os.PathLike[str] | None = None,
|
|
80
|
+
timeout: float | None = None,
|
|
81
|
+
) -> Verdict:
|
|
82
|
+
"""Checks one message, given as a path or as its bytes.
|
|
83
|
+
|
|
84
|
+
A message that fails checks is still a report. This raises SendScoreError
|
|
85
|
+
only when no report could be made. With a timeout in seconds, the CLI is
|
|
86
|
+
killed when it runs out.
|
|
87
|
+
"""
|
|
88
|
+
args = ["check", "--json"]
|
|
89
|
+
if offline:
|
|
90
|
+
args.append("--offline")
|
|
91
|
+
if links:
|
|
92
|
+
args.append("--links")
|
|
93
|
+
if ip is not None:
|
|
94
|
+
args += ["--ip", ip]
|
|
95
|
+
if mail_from is not None:
|
|
96
|
+
args += ["--mail-from", mail_from]
|
|
97
|
+
if helo is not None:
|
|
98
|
+
args += ["--helo", helo]
|
|
99
|
+
if tls is not None:
|
|
100
|
+
args += ["--tls", "yes" if tls else "no"]
|
|
101
|
+
if previous is not None:
|
|
102
|
+
args += ["--previous", _path(previous)]
|
|
103
|
+
is_bytes = isinstance(message, (bytes, bytearray))
|
|
104
|
+
args += ["--", "-" if is_bytes else _path(message)] # type: ignore[arg-type]
|
|
105
|
+
result = _run(args, bytes(message) if is_bytes else None, (0, 1, 2), timeout) # type: ignore[arg-type]
|
|
106
|
+
if not str(result.get("schema", "")).startswith("sendscore.verdict/"):
|
|
107
|
+
raise SendScoreError("sendscore printed something that is not a verdict", 0)
|
|
108
|
+
return cast(Verdict, result)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def diff(
|
|
112
|
+
before: str | os.PathLike[str],
|
|
113
|
+
after: str | os.PathLike[str],
|
|
114
|
+
*,
|
|
115
|
+
timeout: float | None = None,
|
|
116
|
+
) -> DiffResult:
|
|
117
|
+
"""Compares two saved messages, given as paths."""
|
|
118
|
+
result = _run(["diff", "--json", "--", _path(before), _path(after)], None, (0, 1), timeout)
|
|
119
|
+
if "changes" not in result:
|
|
120
|
+
raise SendScoreError("sendscore printed something that is not a comparison", 0)
|
|
121
|
+
result["changes"] = result.get("changes") or []
|
|
122
|
+
return cast(DiffResult, result)
|
|
Binary file
|
sendscore/_binary.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Finds the CLI, which a wheel carries, and downloads a release when none is installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import hashlib
|
|
7
|
+
import http.client
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tarfile
|
|
15
|
+
import tempfile
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
import zipfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ._error import SendScoreError
|
|
22
|
+
from ._hashes import HASHES
|
|
23
|
+
|
|
24
|
+
ENGINE_VERSION = "0.3.0"
|
|
25
|
+
RELEASES = "https://github.com/sendscore/sendscore/releases/download"
|
|
26
|
+
MAX_DOWNLOAD = 100 << 20
|
|
27
|
+
|
|
28
|
+
_OS = {"linux": "linux", "darwin": "darwin", "win32": "windows"}
|
|
29
|
+
_ARCH = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current_platform() -> tuple[str, str]:
|
|
33
|
+
os_name = _OS.get(sys.platform)
|
|
34
|
+
arch = _ARCH.get(platform.machine().lower())
|
|
35
|
+
if not os_name or not arch:
|
|
36
|
+
raise SendScoreError(f"no SendScore build for {sys.platform} on {platform.machine()}", 0)
|
|
37
|
+
return os_name, arch
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cache_root() -> Path:
|
|
41
|
+
if sys.platform == "darwin":
|
|
42
|
+
return Path.home() / "Library" / "Caches"
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
45
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
46
|
+
return Path(xdg) if xdg and os.path.isabs(xdg) else Path.home() / ".cache"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def archive_name(version: str, os_name: str, arch: str) -> str:
|
|
50
|
+
ext = "zip" if os_name == "windows" else "tar.gz"
|
|
51
|
+
return f"sendscore_{version}_{os_name}_{arch}.{ext}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fetch(url: str) -> bytes:
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(url, timeout=60) as res:
|
|
57
|
+
body = res.read(MAX_DOWNLOAD + 1)
|
|
58
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError) as e:
|
|
59
|
+
raise SendScoreError(f"download of {url} failed: {e}", 0) from e
|
|
60
|
+
if len(body) > MAX_DOWNLOAD:
|
|
61
|
+
raise SendScoreError(f"download of {url} is too large", 0)
|
|
62
|
+
return body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pin_for(os_name: str, arch: str, pins: dict[str, tuple[str, str]] = HASHES) -> tuple[str, str]:
|
|
66
|
+
"""The (archive, binary) hashes of a platform. With none, nothing is trusted."""
|
|
67
|
+
pin = pins.get(f"{os_name}_{arch}")
|
|
68
|
+
if pin is None:
|
|
69
|
+
raise SendScoreError(
|
|
70
|
+
f"this package has no pinned hash for {os_name}_{arch}, so it will not download a CLI. "
|
|
71
|
+
"Install the matching sendscore on PATH, or set SENDSCORE_BIN.",
|
|
72
|
+
0,
|
|
73
|
+
)
|
|
74
|
+
return pin
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def matches_pin(path: Path, pin: tuple[str, str]) -> bool:
|
|
78
|
+
"""Whether a file exists and is exactly the pinned binary."""
|
|
79
|
+
try:
|
|
80
|
+
return hashlib.sha256(path.read_bytes()).hexdigest() == pin[1]
|
|
81
|
+
except OSError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract(archive: bytes, os_name: str, bin_name: str) -> bytes | None:
|
|
86
|
+
# One file, by its exact name, so no path in the archive is ever used.
|
|
87
|
+
try:
|
|
88
|
+
if os_name == "windows":
|
|
89
|
+
with zipfile.ZipFile(io.BytesIO(archive)) as z:
|
|
90
|
+
return z.read(bin_name) if bin_name in z.namelist() else None
|
|
91
|
+
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(archive))) as t:
|
|
92
|
+
for member in t:
|
|
93
|
+
if member.name == bin_name and member.isfile():
|
|
94
|
+
f = t.extractfile(member)
|
|
95
|
+
return f.read() if f else None
|
|
96
|
+
except (OSError, EOFError, zipfile.BadZipFile, tarfile.TarError) as e:
|
|
97
|
+
raise SendScoreError(f"could not read the archive: {e}", 0) from e
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def download(
|
|
102
|
+
version: str,
|
|
103
|
+
base: str,
|
|
104
|
+
directory: Path,
|
|
105
|
+
pin: tuple[str, str],
|
|
106
|
+
target: tuple[str, str] | None = None,
|
|
107
|
+
) -> Path:
|
|
108
|
+
"""Downloads a release into directory, and returns the binary's path.
|
|
109
|
+
|
|
110
|
+
The archive must match its pin before anything is extracted, and the
|
|
111
|
+
binary must match its pin before it reaches the disk.
|
|
112
|
+
"""
|
|
113
|
+
os_name, arch = target or current_platform()
|
|
114
|
+
bin_name = "sendscore.exe" if os_name == "windows" else "sendscore"
|
|
115
|
+
name = archive_name(version, os_name, arch)
|
|
116
|
+
archive = _fetch(f"{base}/{name}")
|
|
117
|
+
if hashlib.sha256(archive).hexdigest() != pin[0]:
|
|
118
|
+
raise SendScoreError(f"{name} does not match its pinned hash", 0)
|
|
119
|
+
binary = _extract(archive, os_name, bin_name)
|
|
120
|
+
if binary is None:
|
|
121
|
+
raise SendScoreError(f"{name} has no {bin_name}", 0)
|
|
122
|
+
if hashlib.sha256(binary).hexdigest() != pin[1]:
|
|
123
|
+
raise SendScoreError(f"the binary in {name} does not match its pinned hash", 0)
|
|
124
|
+
try:
|
|
125
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
fd, temp = tempfile.mkstemp(dir=directory, prefix=".download-")
|
|
127
|
+
try:
|
|
128
|
+
with os.fdopen(fd, "wb") as f:
|
|
129
|
+
f.write(binary)
|
|
130
|
+
os.chmod(temp, 0o755)
|
|
131
|
+
path = directory / bin_name
|
|
132
|
+
os.replace(temp, path)
|
|
133
|
+
finally:
|
|
134
|
+
if os.path.exists(temp):
|
|
135
|
+
os.unlink(temp)
|
|
136
|
+
except OSError as e:
|
|
137
|
+
raise SendScoreError(f"could not write to {directory}: {e}", 0) from e
|
|
138
|
+
return path
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _executable(path: str) -> bool:
|
|
142
|
+
try:
|
|
143
|
+
return stat.S_ISREG(os.stat(path).st_mode) and os.access(path, os.X_OK)
|
|
144
|
+
except OSError:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _right_version(path: str) -> bool:
|
|
149
|
+
try:
|
|
150
|
+
r = subprocess.run(
|
|
151
|
+
[path, "--version"], stdin=subprocess.DEVNULL, capture_output=True, timeout=10, check=False
|
|
152
|
+
)
|
|
153
|
+
except (OSError, subprocess.SubprocessError):
|
|
154
|
+
return False
|
|
155
|
+
words = r.stdout.decode(errors="replace").split()
|
|
156
|
+
return r.returncode == 0 and bool(words) and words[-1].removeprefix("v") == ENGINE_VERSION
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def find_binary() -> str:
|
|
160
|
+
"""Finds the CLI: SENDSCORE_BIN, the wheel's binary, the pinned version on PATH, the cache, a download.
|
|
161
|
+
|
|
162
|
+
A cached binary is checked against its pinned hash every time, so a file
|
|
163
|
+
planted in the cache is replaced and never run.
|
|
164
|
+
"""
|
|
165
|
+
from_env = os.environ.get("SENDSCORE_BIN")
|
|
166
|
+
if from_env:
|
|
167
|
+
return from_env
|
|
168
|
+
return bundled() or on_path() or installed()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def bundled() -> str | None:
|
|
172
|
+
"""The binary inside this wheel, or None.
|
|
173
|
+
|
|
174
|
+
It came with this code, so it is trusted as this code is, and needs no
|
|
175
|
+
version check or hash. A source checkout has none.
|
|
176
|
+
"""
|
|
177
|
+
path = Path(__file__).parent / "_bin" / _exe_name()
|
|
178
|
+
return str(path) if _executable(str(path)) else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def on_path() -> str | None:
|
|
182
|
+
"""The pinned version on PATH, or None.
|
|
183
|
+
|
|
184
|
+
A search of its own, because shutil.which looks in the current directory
|
|
185
|
+
on Windows, and follows PATHEXT to .bat and .cmd files.
|
|
186
|
+
"""
|
|
187
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
188
|
+
candidate = os.path.join(entry, _exe_name())
|
|
189
|
+
if os.path.isabs(entry) and _executable(candidate) and _right_version(candidate):
|
|
190
|
+
return candidate
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _exe_name() -> str:
|
|
195
|
+
return "sendscore.exe" if sys.platform == "win32" else "sendscore"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def installed(pins: dict[str, tuple[str, str]] = HASHES) -> str:
|
|
199
|
+
"""The cached binary, or a download, as the pins allow."""
|
|
200
|
+
target = current_platform()
|
|
201
|
+
pin = pin_for(*target, pins)
|
|
202
|
+
directory = _cache_root() / "sendscore" / ENGINE_VERSION
|
|
203
|
+
cached = directory / _exe_name()
|
|
204
|
+
if matches_pin(cached, pin):
|
|
205
|
+
return str(cached)
|
|
206
|
+
return str(download(ENGINE_VERSION, f"{RELEASES}/v{ENGINE_VERSION}", directory, pin, target))
|
sendscore/_cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The sendscore command: finds the CLI the same way the library does, then runs it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from ._binary import find_binary
|
|
10
|
+
from ._error import SendScoreError
|
|
11
|
+
|
|
12
|
+
# EX_SOFTWARE, as the CLI uses for an internal error.
|
|
13
|
+
INTERNAL = 70
|
|
14
|
+
|
|
15
|
+
# The PATH search may find this launcher and run it to read its version. The
|
|
16
|
+
# mark makes that run fail at once, so the search moves on instead of looping.
|
|
17
|
+
MARK = "SENDSCORE_LAUNCHED"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
if os.environ.get(MARK):
|
|
22
|
+
print("sendscore: found itself instead of the CLI", file=sys.stderr)
|
|
23
|
+
return INTERNAL
|
|
24
|
+
os.environ[MARK] = "1"
|
|
25
|
+
try:
|
|
26
|
+
path = find_binary()
|
|
27
|
+
except SendScoreError as e:
|
|
28
|
+
print(f"sendscore: {e}", file=sys.stderr)
|
|
29
|
+
return INTERNAL
|
|
30
|
+
args = [path, *sys.argv[1:]]
|
|
31
|
+
if os.name == "nt":
|
|
32
|
+
# Windows has no real exec: os.execv would return to the shell early.
|
|
33
|
+
try:
|
|
34
|
+
return subprocess.run(args, check=False).returncode
|
|
35
|
+
except OSError as e:
|
|
36
|
+
print(f"sendscore: could not run {path}: {e}", file=sys.stderr)
|
|
37
|
+
return INTERNAL
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
return 130
|
|
40
|
+
try:
|
|
41
|
+
os.execv(path, args)
|
|
42
|
+
except OSError as e:
|
|
43
|
+
print(f"sendscore: could not run {path}: {e}", file=sys.stderr)
|
|
44
|
+
return INTERNAL
|
|
45
|
+
return INTERNAL
|
sendscore/_error.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
KILLED = -1
|
|
4
|
+
"""The exit_code of a CLI that was killed, or stopped by a timeout."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SendScoreError(Exception):
|
|
8
|
+
"""The CLI could not produce a report, or could not be found or installed.
|
|
9
|
+
|
|
10
|
+
exit_code is the CLI's: 64 and above, 0 when the CLI never ran, or KILLED.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, exit_code: int) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.exit_code = exit_code
|
sendscore/_hashes.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Generated by scripts/pinhashes. Do not edit.
|
|
2
|
+
|
|
3
|
+
HASHES: dict[str, tuple[str, str]] = {
|
|
4
|
+
"darwin_amd64": ("d79c0d7d6f41322caf56f2ab90b6d61f25b97109f3449d84964751e9e9a685bb", "a508ff1a9bc8e63d5f79f663d5596cdd1c4cb6803d051d3c91a5a9481870dae1"),
|
|
5
|
+
"darwin_arm64": ("899eeac2be04f1d0619829cd0eb9fc171e358a0deb409e8dee74e3c95543bfac", "4e3ea55cc7bfa9d00d4b4742720ca475e8354052008a8133b0eeea9b17e6c109"),
|
|
6
|
+
"linux_amd64": ("c9bee6d95e057da006c27b015697db02f8d40f5f933c2c0f8956d33adc304bc8", "87a3c521bbf05516267757eb979f14bc66a2289cc5d5c86a8955186e984ff7da"),
|
|
7
|
+
"linux_arm64": ("69c75e7bd97f5f4e1e5f67f9eaa9efa808ef1264550385887c7222ff6370e6a1", "c0d25f8d93b8b202fa1b78e4275da41fe8e637dc256b03b19eed3af38e0b2c99"),
|
|
8
|
+
"windows_amd64": ("68f136dfd60a370512b97080781005c7cbffc00ce318bc77f5f347ae1625265f", "d2da36431144d87971a33ee9e59590f281fb84b8acf3b8814e989073e90f4866"),
|
|
9
|
+
"windows_arm64": ("f3f2d796add9f0690e447239931b6bd0e99ff25e6ea9553f205e88821e9b4761", "b78b9923bb5a6f7f0040969162e67417292d6e3558451a5faa7de18510398862"),
|
|
10
|
+
}
|
sendscore/py.typed
ADDED
|
File without changes
|
sendscore/types.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The sendscore.verdict/v1 report. New keys may appear in this version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
Status = Literal["pass", "warn", "fail", "info", "skipped"]
|
|
8
|
+
Category = Literal["auth", "unsub", "struct", "content", "delivery", "changes"]
|
|
9
|
+
Who = Literal["you", "provider"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Message = TypedDict(
|
|
13
|
+
"Message",
|
|
14
|
+
{
|
|
15
|
+
"received_at": str,
|
|
16
|
+
"from": str,
|
|
17
|
+
"subject": str,
|
|
18
|
+
"message_id": str,
|
|
19
|
+
"size_bytes": int,
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Fix(TypedDict):
|
|
25
|
+
who: Who
|
|
26
|
+
text: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _CheckOptional(TypedDict, total=False):
|
|
30
|
+
detail: str
|
|
31
|
+
evidence: dict[str, Any]
|
|
32
|
+
fix: Fix
|
|
33
|
+
docs: str
|
|
34
|
+
reason: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Check(_CheckOptional):
|
|
38
|
+
id: str
|
|
39
|
+
category: Category
|
|
40
|
+
status: Status
|
|
41
|
+
title: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TopFix(TypedDict):
|
|
45
|
+
check: str
|
|
46
|
+
who: Who
|
|
47
|
+
fix: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _ChangeOptional(TypedDict, total=False):
|
|
51
|
+
worsened: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Change(_ChangeOptional):
|
|
55
|
+
field: str
|
|
56
|
+
before: Any
|
|
57
|
+
after: Any
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _FingerprintOptional(TypedDict, total=False):
|
|
61
|
+
remote_ip: str
|
|
62
|
+
ptr: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Fingerprint(_FingerprintOptional):
|
|
66
|
+
dkim_domains: list[str]
|
|
67
|
+
dkim_selectors: list[str]
|
|
68
|
+
return_path_domain: str
|
|
69
|
+
list_unsubscribe: bool
|
|
70
|
+
one_click: bool
|
|
71
|
+
link_hosts: list[str]
|
|
72
|
+
html_bytes: int
|
|
73
|
+
links: int
|
|
74
|
+
images: int
|
|
75
|
+
auth: dict[str, Status] | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _VerdictOptional(TypedDict, total=False):
|
|
79
|
+
id: str
|
|
80
|
+
publication: str
|
|
81
|
+
fingerprint: Fingerprint
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Verdict(_VerdictOptional):
|
|
85
|
+
schema: str
|
|
86
|
+
message: Message
|
|
87
|
+
verdict: Status
|
|
88
|
+
ready_to_send: bool
|
|
89
|
+
summary: str
|
|
90
|
+
top_fixes: list[TopFix]
|
|
91
|
+
checks: list[Check]
|
|
92
|
+
changes: list[Change]
|
|
93
|
+
explanation: str | None
|
|
94
|
+
note: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DiffResult(TypedDict):
|
|
98
|
+
before: str
|
|
99
|
+
after: str
|
|
100
|
+
changes: list[Change]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: sendscore
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Check a newsletter email before it goes out. A typed wrapper around the SendScore CLI.
|
|
5
|
+
Home-page: https://github.com/sendscore/sendscore
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# SendScore for Python
|
|
11
|
+
|
|
12
|
+
A typed wrapper around the [SendScore](https://github.com/sendscore/sendscore) CLI, which checks a newsletter email before it goes out. Every language package runs the same binary, so they all return the same verdict.
|
|
13
|
+
|
|
14
|
+
Not published yet.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install sendscore
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
It also gives you the `sendscore` command, which takes the same arguments and exit codes as the CLI. To install only the command, use `pipx install sendscore`.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from sendscore import check, diff
|
|
24
|
+
|
|
25
|
+
verdict = check("issue.eml")
|
|
26
|
+
if not verdict["ready_to_send"]:
|
|
27
|
+
for fix in verdict["top_fixes"]:
|
|
28
|
+
print(fix["who"], fix["fix"])
|
|
29
|
+
|
|
30
|
+
check(raw_bytes, offline=True)
|
|
31
|
+
diff("before.eml", "after.eml")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A message that fails its checks is still a report, so `check` returns it. `SendScoreError` is raised only when no report could be made, and its `exit_code` is the CLI's. Receipt at SendScore does not prove inbox placement at Gmail or any other provider.
|
|
35
|
+
|
|
36
|
+
Pass `timeout` in seconds to `check` or `diff` to kill the CLI when it runs out. A killed CLI raises `SendScoreError` with `exit_code` set to `KILLED`, which is -1.
|
|
37
|
+
|
|
38
|
+
## The binary
|
|
39
|
+
|
|
40
|
+
The binary installs with the package. `pip install sendscore` picks the wheel for your machine, on macOS 12 or later, Linux with glibc or musl, and Windows, for x64 and arm64. Nothing runs at install time, and nothing is downloaded when you use it. There is no source distribution, so a platform without a wheel gets a clear error from pip.
|
|
41
|
+
|
|
42
|
+
The package uses `SENDSCORE_BIN`, then that bundled binary, then the CLI on `PATH` if its version matches, then your user cache. Running from a source checkout, which has no binary, the first call downloads the pinned release from GitHub, and checks it against the SHA-256 values in the package before it runs anything, including a cached binary.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
sendscore/__init__.py,sha256=Pwjp0JNtVCj89lGL0VAOAVSQn4deKqbRejyYOZ48axo,3971
|
|
2
|
+
sendscore/_binary.py,sha256=rlhqjsrr9OVedUdCt3nGUdVt1bW6U6hL3_ntKkfnxmw,7293
|
|
3
|
+
sendscore/_cli.py,sha256=640PcGE4Gvywb4f0Duwu6vZCw6Cjd2VW0h8WZ5Y0Hz8,1390
|
|
4
|
+
sendscore/_error.py,sha256=4kYa6ue5yDM2phlZQUvi6dYRdZctZnXR4KKX-TbH_9A,453
|
|
5
|
+
sendscore/_hashes.py,sha256=kIvD6-00VDoWvRyTh9hbnN6H8zG22J9su4wKFwx-Rcw,1037
|
|
6
|
+
sendscore/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
sendscore/types.py,sha256=IvD4Swrsa8Q9c_D-3RaHo8MJU2qkPYMEOYZ1VLv0vLs,1838
|
|
8
|
+
sendscore/_bin/sendscore.exe,sha256=t4uZI7tab38AQJaRYuZ0FyktbjVYRRpfqn3hhRA5iGI,9996288
|
|
9
|
+
sendscore-0.3.0.dist-info/METADATA,sha256=loGN1fGqsYl0GqhXafySKqw2qqpDc33m7h-Kh4q0kuU,2110
|
|
10
|
+
sendscore-0.3.0.dist-info/WHEEL,sha256=SdU4PaOcsQvHRfD4sojGm7mxQMfYtBeRVws83wtC6qM,101
|
|
11
|
+
sendscore-0.3.0.dist-info/entry_points.txt,sha256=JBvY_7ZO8sOckGUBNC7wALFcL4pu-OG4-L3wnk1LRaQ,50
|
|
12
|
+
sendscore-0.3.0.dist-info/licenses/LICENSE,sha256=WPz7bGlc4ohvvTtXMBmtlXWbou5o9pKrAcnmaiC2Iyg,1066
|
|
13
|
+
sendscore-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SendScore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|