sendscore 0.3.0__py3-none-win_amd64.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 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=0to2QxFE2Hlxoz7p5ZWQ8oH7hLis87iBTpiQc-kPSGY,10887680
9
+ sendscore-0.3.0.dist-info/METADATA,sha256=loGN1fGqsYl0GqhXafySKqw2qqpDc33m7h-Kh4q0kuU,2110
10
+ sendscore-0.3.0.dist-info/WHEEL,sha256=TsiSDhdhyt9XAieLKOvneE-sCkq4hE0TDj-iWgWgzZA,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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: sendscore-buildpackages
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sendscore = sendscore._cli:main
@@ -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.