tagpoint 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.
tagpoint/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """tagpoint: verify that a PyPI release matches its GitHub tag."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
tagpoint/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
tagpoint/checker.py ADDED
@@ -0,0 +1,221 @@
1
+ """Core consistency checks between a PyPI release and its GitHub tag.
2
+
3
+ tagpoint answers one question: does the tag named after the PyPI version
4
+ actually point at the code the release was built from? It fetches the PyPI
5
+ metadata, resolves the matching GitHub tag, downloads the sdist, and compares
6
+ what can be compared without a full git checkout:
7
+
8
+ 1. the tag exists (v{version} or {version})
9
+ 2. the sdist PKG-INFO version matches the PyPI version
10
+ 3. (deep) the sdist file list matches the tag's git tree file list,
11
+ flagging files the tag gained/lost after the release was built
12
+ 4. (deep) the tag commit date is not newer than the release upload time
13
+ (a tag moved *after* the build is suspicious drift)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import io
20
+ import json
21
+ import tarfile
22
+ import urllib.error
23
+ import urllib.request
24
+ import zipfile
25
+ from dataclasses import dataclass, field
26
+
27
+ PYPI_JSON_URL = "https://pypi.org/pypi/{name}/json"
28
+ GITHUB_API = "https://api.github.com"
29
+
30
+ _USER_AGENT = "tagpoint/0.1.0 (+https://github.com/hahahahahahahahah6/tagpoint)"
31
+
32
+
33
+ class CheckError(Exception):
34
+ """Raised when a check cannot be completed (network, missing data, ...)."""
35
+
36
+
37
+ @dataclass
38
+ class Finding:
39
+ ok: bool
40
+ name: str
41
+ detail: str
42
+
43
+
44
+ @dataclass
45
+ class Report:
46
+ package: str
47
+ version: str
48
+ repo: str
49
+ tag: str | None
50
+ commit: str | None
51
+ findings: list[Finding] = field(default_factory=list)
52
+
53
+ @property
54
+ def consistent(self) -> bool:
55
+ return bool(self.findings) and all(f.ok for f in self.findings)
56
+
57
+ def render(self) -> str:
58
+ lines = [
59
+ f"tagpoint report: {self.package}=={self.version} repo={self.repo}",
60
+ f"tag: {self.tag or '(none)'} commit: {(self.commit or '(none)')[:12]}",
61
+ "",
62
+ ]
63
+ for f in self.findings:
64
+ mark = "PASS" if f.ok else "FAIL"
65
+ lines.append(f"[{mark}] {f.name}: {f.detail}")
66
+ lines.append("")
67
+ verdict = "CONSISTENT" if self.consistent else "INCONSISTENT"
68
+ lines.append(f"verdict: {verdict}")
69
+ return "\n".join(lines)
70
+
71
+
72
+ def _fetch_json(url: str, token: str | None = None) -> dict:
73
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
74
+ if token:
75
+ req.add_header("Authorization", f"Bearer {token}")
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=30) as resp:
78
+ return json.loads(resp.read().decode("utf-8"))
79
+ except urllib.error.HTTPError as e:
80
+ raise CheckError(f"HTTP {e.code} fetching {url}") from e
81
+ except urllib.error.URLError as e:
82
+ raise CheckError(f"network error fetching {url}: {e.reason}") from e
83
+
84
+
85
+ def _fetch_bytes(url: str) -> bytes:
86
+ req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
87
+ try:
88
+ with urllib.request.urlopen(req, timeout=60) as resp:
89
+ return resp.read()
90
+ except urllib.error.HTTPError as e:
91
+ raise CheckError(f"HTTP {e.code} downloading {url}") from e
92
+ except urllib.error.URLError as e:
93
+ raise CheckError(f"network error downloading {url}: {e.reason}") from e
94
+
95
+
96
+ def _sdist_files(blob: bytes, filename: str) -> tuple[str, list[str]]:
97
+ """Return (PKG-INFO version, sorted file list) from an sdist blob."""
98
+ version = ""
99
+ names: list[str] = []
100
+ if filename.endswith(".zip"):
101
+ with zipfile.ZipFile(io.BytesIO(blob)) as zf:
102
+ for info in zf.infolist():
103
+ names.append(info.filename)
104
+ if info.filename.endswith("PKG-INFO"):
105
+ version = _pkg_info_version(zf.read(info.filename).decode("utf-8", "replace"))
106
+ else: # .tar.gz
107
+ with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tf:
108
+ for member in tf.getmembers():
109
+ names.append(member.name)
110
+ if member.name.endswith("PKG-INFO") and member.isfile():
111
+ f = tf.extractfile(member)
112
+ if f:
113
+ version = _pkg_info_version(f.read().decode("utf-8", "replace"))
114
+ return version, sorted(names)
115
+
116
+
117
+ def _pkg_info_version(text: str) -> str:
118
+ for line in text.splitlines():
119
+ if line.lower().startswith("version:"):
120
+ return line.split(":", 1)[1].strip()
121
+ return ""
122
+
123
+
124
+ def _sha256_file(path: str) -> str:
125
+ h = hashlib.sha256()
126
+ with open(path, "rb") as f:
127
+ for chunk in iter(lambda: f.read(65536), b""):
128
+ h.update(chunk)
129
+ return h.hexdigest()
130
+
131
+
132
+ def _strip_top_dir(names: list[str]) -> set[str]:
133
+ """Drop the sdist's single top-level directory so paths compare to a git tree."""
134
+ out = set()
135
+ for n in names:
136
+ parts = n.strip("/").split("/")
137
+ if len(parts) > 1:
138
+ out.add("/".join(parts[1:]))
139
+ return out
140
+
141
+
142
+ def check(package: str, repo: str, *, deep: bool = True, token: str | None = None) -> Report:
143
+ """Run the full consistency check and return a Report."""
144
+ meta = _fetch_json(PYPI_JSON_URL.format(name=package), token)
145
+ version = meta["info"]["version"]
146
+ report = Report(package=package, version=version, repo=repo, tag=None, commit=None)
147
+
148
+ # 1. resolve the tag
149
+ tag_name = None
150
+ commit = None
151
+ for candidate in (f"v{version}", version):
152
+ try:
153
+ ref = _fetch_json(f"{GITHUB_API}/repos/{repo}/git/refs/tags/{candidate}", token)
154
+ except CheckError:
155
+ continue
156
+ tag_name = candidate
157
+ obj = ref["object"]
158
+ if obj["type"] == "tag": # annotated tag -> peel to commit
159
+ peeled = _fetch_json(obj["url"], token)
160
+ commit = peeled["object"]["sha"]
161
+ else:
162
+ commit = obj["sha"]
163
+ break
164
+ report.tag, report.commit = tag_name, commit
165
+ if tag_name is None:
166
+ report.findings.append(Finding(False, "tag-exists",
167
+ f"no tag v{version} or {version} on {repo}"))
168
+ return report
169
+ report.findings.append(Finding(True, "tag-exists",
170
+ f"tag {tag_name} -> {commit[:12]}"))
171
+
172
+ # 2. sdist sanity: PKG-INFO version must match PyPI version
173
+ files = meta["releases"].get(version, [])
174
+ sdists = [f for f in files if f["packagetype"] == "sdist"]
175
+ if not sdists:
176
+ report.findings.append(Finding(False, "sdist-present",
177
+ "no sdist file in this release"))
178
+ return report
179
+ sdist = sdists[0]
180
+ blob = _fetch_bytes(sdist["url"])
181
+ pkg_version, sdist_names = _sdist_files(blob, sdist["filename"])
182
+ if pkg_version == version:
183
+ report.findings.append(Finding(True, "sdist-version",
184
+ f"PKG-INFO version {pkg_version} matches PyPI"))
185
+ else:
186
+ report.findings.append(Finding(False, "sdist-version",
187
+ f"PKG-INFO says {pkg_version!r}, PyPI says {version!r}"))
188
+
189
+ if not deep:
190
+ return report
191
+
192
+ # 3. file-list comparison: sdist contents vs tag tree
193
+ tree = _fetch_json(f"{GITHUB_API}/repos/{repo}/git/trees/{commit}?recursive=1", token)
194
+ tag_paths = {t["path"] for t in tree.get("tree", []) if t["type"] == "blob"}
195
+ # PKG-INFO is generated at build time; it never lives in the git tree.
196
+ sdist_paths = _strip_top_dir(sdist_names) - {"PKG-INFO"}
197
+ only_tag = sorted(tag_paths - sdist_paths)
198
+ only_sdist = sorted(sdist_paths - tag_paths)
199
+ if not only_tag and not only_sdist:
200
+ report.findings.append(Finding(True, "file-list",
201
+ f"{len(tag_paths)} files match the tag tree"))
202
+ else:
203
+ detail = []
204
+ if only_tag:
205
+ detail.append(f"{len(only_tag)} only in tag (e.g. {', '.join(only_tag[:3])})")
206
+ if only_sdist:
207
+ detail.append(f"{len(only_sdist)} only in sdist (e.g. {', '.join(only_sdist[:3])})")
208
+ report.findings.append(Finding(False, "file-list", "; ".join(detail)))
209
+
210
+ # 4. tag commit must not be newer than the release upload
211
+ commit_meta = _fetch_json(f"{GITHUB_API}/repos/{repo}/commits/{commit}", token)
212
+ commit_date = commit_meta["commit"]["committer"]["date"]
213
+ upload_time = sdist["upload_time_iso_8601"]
214
+ if commit_date <= upload_time:
215
+ report.findings.append(Finding(True, "tag-before-build",
216
+ f"tag commit {commit_date} <= upload {upload_time}"))
217
+ else:
218
+ report.findings.append(Finding(False, "tag-before-build",
219
+ f"tag commit {commit_date} is NEWER than upload {upload_time} — tag drifted"))
220
+
221
+ return report
tagpoint/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ """Command-line interface for tagpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .checker import CheckError, check
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(
15
+ prog="tagpoint",
16
+ description="Verify a PyPI release matches its GitHub tag (name AND content).",
17
+ )
18
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
19
+ sub = p.add_subparsers(dest="command", required=True)
20
+
21
+ c = sub.add_parser("check", help="check one package release against its repo tag")
22
+ c.add_argument("package", help="PyPI package name, e.g. requests")
23
+ c.add_argument("--repo", required=True, help="GitHub repo as owner/name, e.g. psf/requests")
24
+ c.add_argument("--shallow", action="store_true",
25
+ help="skip file-list and tag-date checks (tag existence + sdist version only)")
26
+ c.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"),
27
+ help="GitHub token (or set GITHUB_TOKEN) to raise API rate limits")
28
+ return p
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ args = build_parser().parse_args(argv)
33
+ if args.command == "check":
34
+ try:
35
+ report = check(args.package, args.repo, deep=not args.shallow, token=args.token)
36
+ except CheckError as e:
37
+ print(f"tagpoint: error: {e}", file=sys.stderr)
38
+ return 2
39
+ print(report.render())
40
+ return 0 if report.consistent else 1
41
+ return 2
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: tagpoint
3
+ Version: 0.1.0
4
+ Summary: Verify a PyPI release actually matches its GitHub tag — name AND content
5
+ Author-email: hao li <haoli5933@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/tagpoint
8
+ Project-URL: Repository, https://github.com/hahahahahahahahah6/tagpoint
9
+ Keywords: pypi,github,supply-chain,release,tag,integrity
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Build Tools
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # tagpoint
23
+
24
+ Verify that a PyPI release **actually matches** its GitHub tag — the name *and* the content.
25
+
26
+ ```bash
27
+ pip install tagpoint
28
+ tagpoint check requests --repo psf/requests
29
+ ```
30
+
31
+ Output:
32
+
33
+ ```
34
+ tagpoint report: requests==2.32.3 repo=psf/requests
35
+ tag: v2.32.3 commit: abc123def456
36
+
37
+ [PASS] tag-exists: tag v2.32.3 -> abc123def456
38
+ [PASS] sdist-version: PKG-INFO version 2.32.3 matches PyPI
39
+ [PASS] file-list: 128 files match the tag tree
40
+ [PASS] tag-before-build: tag commit 2024-05-29T... <= upload 2024-05-29T...
41
+
42
+ verdict: CONSISTENT
43
+ ```
44
+
45
+ Exit code `0` = consistent, `1` = inconsistent, `2` = check error (network, missing data).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install tagpoint
51
+ ```
52
+
53
+ tagpoint is stdlib-only — no third-party dependencies.
54
+
55
+ ## Usage
56
+
57
+ ```
58
+ tagpoint check <pypi-package> --repo owner/name [--shallow] [--token TOKEN]
59
+ ```
60
+
61
+ - `tagpoint check flask --repo pallets/flask` — full check (default)
62
+ - `--shallow` — only verify the tag exists and the sdist version matches
63
+ - `--token` — GitHub token (or `GITHUB_TOKEN` env var) to raise API rate limits
64
+
65
+ Four checks run by default:
66
+
67
+ 1. **tag-exists** — a tag named `v{version}` or `{version}` exists and resolves (annotated tags are peeled to the commit).
68
+ 2. **sdist-version** — the sdist's `PKG-INFO` version equals the PyPI version.
69
+ 3. **file-list** — the sdist file list matches the tag's git tree (catches files added/removed after the build).
70
+ 4. **tag-before-build** — the tag commit date is not newer than the release upload time (a tag moved *after* the build is drift).
71
+
72
+ ## Differentiation
73
+
74
+ There are tools that check whether a release tag **exists** (e.g. my
75
+ [tagtruth](https://github.com/hahahahahahahahah6/tagtruth) project checks tag
76
+ presence as part of release hygiene). tagpoint goes one step further: it checks
77
+ whether the tag **points at the code the release was actually built from**.
78
+
79
+ The failure mode it catches is real: a tag named `v1.2.3` exists, CI is green,
80
+ everything looks fine — but the tag was force-moved after the sdist was built,
81
+ or the release was cut from a dirty working tree. The name matches; the
82
+ content doesn't. tagpoint compares the sdist contents against the tag's git
83
+ tree and flags the drift. Think of it as a "name matches reality" check for
84
+ your supply chain.
85
+
86
+ ## License
87
+
88
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ tagpoint/__init__.py,sha256=YbfTUvCFBDCwxYIIsEe1tNigQUaTyqNmvYpPr4OGEd0,116
2
+ tagpoint/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ tagpoint/checker.py,sha256=q3auiXS5kxNh955xoGi3CVOXg6eaxteNBeXdoaT_L1w,8547
4
+ tagpoint/cli.py,sha256=bfPuUP6Q8nSlgAzOxW5g8b6KfbsdBV8ZlkC7wg93ifw,1596
5
+ tagpoint-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
6
+ tagpoint-0.1.0.dist-info/METADATA,sha256=U6XeDIAQuCo7UfyR5aSDjFLj4ZvXOFO0zNBpt6MU70c,3126
7
+ tagpoint-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ tagpoint-0.1.0.dist-info/entry_points.txt,sha256=mS2lD2VWfursgDpObq_lfEcm2zyIA1coj-9pvUQUGZA,47
9
+ tagpoint-0.1.0.dist-info/top_level.txt,sha256=Jfx1jtggOTB7aJXBuN-RkmXTfjOST9dJ1666SxNeDwM,9
10
+ tagpoint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tagpoint = tagpoint.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hao li
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.
@@ -0,0 +1 @@
1
+ tagpoint