verscan 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.
verscan/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """verscan — verify package.json / CHANGELOG.md / git tag versions match."""
verscan/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
verscan/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ """verscan CLI — verify package.json / CHANGELOG.md / git tag versions match."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Dict, Optional, Tuple
11
+
12
+ from . import core
13
+
14
+ VERSION = "0.1.0"
15
+
16
+ HELP = """\
17
+ verscan — verify package.json / CHANGELOG.md / git tag all share the same version.
18
+
19
+ Usage:
20
+ verscan [options] [dir]
21
+
22
+ Options:
23
+ --no-git skip git tag check
24
+ --no-changelog skip CHANGELOG.md check
25
+ --manifest <file> manifest path (default: auto-detect package.json or pyproject.toml)
26
+ --changelog <file> changelog path (default: CHANGELOG.md)
27
+ --json output JSON
28
+ --version, -v print verscan version
29
+ --help, -h show this help
30
+
31
+ Exit codes: 0 all match 1 mismatch 2 error
32
+ """
33
+
34
+ _USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
35
+
36
+
37
+ def _col(code: str, s: str) -> str:
38
+ return f"\x1b[{code}m{s}\x1b[0m" if _USE_COLOR else s
39
+
40
+
41
+ def _green(s: str) -> str: return _col("32", s)
42
+ def _red(s: str) -> str: return _col("31", s)
43
+ def _yellow(s: str) -> str: return _col("33", s)
44
+ def _dim(s: str) -> str: return _col("2", s)
45
+ def _bold(s: str) -> str: return _col("1", s)
46
+ def _cyan(s: str) -> str: return _col("36", s)
47
+
48
+
49
+ def _die(msg: str, code: int = 2) -> None:
50
+ sys.stderr.write(_red(f"verscan: {msg}\n"))
51
+ sys.exit(code)
52
+
53
+
54
+ def _try_read(path: Path) -> Optional[str]:
55
+ try:
56
+ return path.read_text(encoding="utf-8")
57
+ except OSError:
58
+ return None
59
+
60
+
61
+ def _resolve_manifest(
62
+ directory: Path, explicit: Optional[str]
63
+ ) -> Tuple[str, Path, str]:
64
+ """Returns (type, path, content) where type is 'package' or 'pyproject'."""
65
+ if explicit:
66
+ p = (directory / explicit).resolve()
67
+ content = _try_read(p)
68
+ if content is None:
69
+ _die(f"manifest not found: {p}")
70
+ base = p.name
71
+ if base == "pyproject.toml":
72
+ return "pyproject", p, content # type: ignore[return-value]
73
+ if base == "package.json":
74
+ return "package", p, content # type: ignore[return-value]
75
+ _die(f"unrecognised manifest type: {base} (use package.json or pyproject.toml)")
76
+
77
+ pkg = directory / "package.json"
78
+ pyproj = directory / "pyproject.toml"
79
+ pkg_content = _try_read(pkg)
80
+ if pkg_content:
81
+ return "package", pkg, pkg_content
82
+ py_content = _try_read(pyproj)
83
+ if py_content:
84
+ return "pyproject", pyproj, py_content
85
+ _die(f"no package.json or pyproject.toml found in {directory}")
86
+ raise SystemExit(2) # unreachable, satisfies type checker
87
+
88
+
89
+ def _read_manifest_version(mtype: str, content: str) -> str:
90
+ if mtype == "package":
91
+ return core.read_pkg_version(content)
92
+ return core.read_pyproject_version(content)
93
+
94
+
95
+ def _read_git_tag(directory: Path) -> str:
96
+ try:
97
+ result = subprocess.run(
98
+ ["git", "describe", "--tags", "--abbrev=0"],
99
+ cwd=str(directory),
100
+ capture_output=True,
101
+ text=True,
102
+ timeout=5,
103
+ )
104
+ if result.returncode != 0:
105
+ msg = result.stderr.strip()
106
+ if "no names found" in msg or "no tag" in msg or result.returncode == 128:
107
+ raise ValueError(
108
+ "no git tags found in this repository (create one with `git tag v0.1.0`)"
109
+ )
110
+ raise ValueError(f"git describe failed: {msg}")
111
+ return core.parse_git_tag(result.stdout)
112
+ except FileNotFoundError:
113
+ raise ValueError("git executable not found")
114
+
115
+
116
+ def _label(mtype: str) -> str:
117
+ return {"package": "package.json", "pyproject": "pyproject.toml"}.get(mtype, mtype)
118
+
119
+
120
+ def main() -> None:
121
+ argv = sys.argv[1:]
122
+
123
+ if not argv or "--help" in argv or "-h" in argv:
124
+ sys.stdout.write(HELP)
125
+ sys.exit(0)
126
+ if "--version" in argv or "-v" in argv:
127
+ sys.stdout.write(VERSION + "\n")
128
+ sys.exit(0)
129
+
130
+ skip_git = "--no-git" in argv
131
+ skip_changelog = "--no-changelog" in argv
132
+ json_mode = "--json" in argv
133
+
134
+ def _flag_val(name: str) -> Optional[str]:
135
+ try:
136
+ i = argv.index(name)
137
+ return argv[i + 1]
138
+ except (ValueError, IndexError):
139
+ return None
140
+
141
+ manifest_flag = _flag_val("--manifest")
142
+ changelog_flag = _flag_val("--changelog")
143
+
144
+ flag_names = {"--manifest", "--changelog"}
145
+ consumed = set()
146
+ for i, a in enumerate(argv):
147
+ if a in flag_names:
148
+ consumed.add(i + 1)
149
+ positional = [
150
+ a for i, a in enumerate(argv)
151
+ if not a.startswith("-") and i not in consumed
152
+ ]
153
+ directory = Path(positional[0]).resolve() if positional else Path.cwd()
154
+
155
+ errors: Dict[str, str] = {}
156
+ versions: Dict[str, Optional[str]] = {}
157
+
158
+ # 1. Manifest version
159
+ mtype, mpath, mcontent = _resolve_manifest(directory, manifest_flag) # exits on error
160
+ try:
161
+ versions[mtype] = _read_manifest_version(mtype, mcontent)
162
+ except ValueError as exc:
163
+ errors[mtype] = str(exc)
164
+
165
+ # 2. CHANGELOG version
166
+ if not skip_changelog:
167
+ cl_path = (directory / (changelog_flag or "CHANGELOG.md")).resolve()
168
+ cl_content = _try_read(cl_path)
169
+ if cl_content is None:
170
+ errors["changelog"] = f"CHANGELOG.md not found at {cl_path} (use --no-changelog to skip)"
171
+ else:
172
+ try:
173
+ versions["changelog"] = core.read_changelog_version(cl_content)
174
+ except ValueError as exc:
175
+ errors["changelog"] = str(exc)
176
+
177
+ # 3. Git tag version
178
+ if not skip_git:
179
+ try:
180
+ versions["git"] = _read_git_tag(directory)
181
+ except ValueError as exc:
182
+ errors["git"] = str(exc)
183
+
184
+ has_errors = bool(errors)
185
+ result = core.compare_versions(versions)
186
+
187
+ if json_mode:
188
+ out = {
189
+ "ok": result["ok"] and not has_errors,
190
+ "versions": {k: v for k, v in versions.items() if v is not None},
191
+ "errors": errors,
192
+ "mismatches": result["mismatches"],
193
+ }
194
+ sys.stdout.write(json.dumps(out, indent=2) + "\n")
195
+ sys.exit(0 if result["ok"] and not has_errors else (2 if has_errors else 1))
196
+
197
+ src_label = {
198
+ "package": _label(mtype),
199
+ "pyproject": _label(mtype),
200
+ "changelog": "CHANGELOG.md",
201
+ "git": "git tag",
202
+ }
203
+ all_keys = list(versions.keys()) + list(errors.keys())
204
+ max_len = max((len(src_label.get(k, k)) for k in all_keys), default=0)
205
+
206
+ def _pad(s: str) -> str:
207
+ return s.ljust(max_len)
208
+
209
+ sys.stdout.write("\n")
210
+
211
+ for idx, (src, ver) in enumerate(result["entries"]):
212
+ marker = _dim(" (reference)") if idx == 0 else ""
213
+ sys.stdout.write(
214
+ f" {_green('✓')} {_bold(_pad(src_label.get(src, src)))} {_cyan(ver)}{marker}\n"
215
+ )
216
+ for src, err in errors.items():
217
+ sys.stdout.write(
218
+ f" {_yellow('!')} {_bold(_pad(src_label.get(src, src)))} {_yellow(f'[{err}]')}\n"
219
+ )
220
+
221
+ if not result["ok"] and not has_errors:
222
+ sys.stdout.write("\n")
223
+ for src in result["mismatches"]:
224
+ ver = versions.get(src, "?")
225
+ sys.stdout.write(
226
+ f" {_red('✗')} {_bold(src_label.get(src, src))} "
227
+ f"is {_red(str(ver))} — expected {_cyan(str(result['reference']))}\n"
228
+ )
229
+
230
+ sys.stdout.write("\n")
231
+
232
+ if has_errors:
233
+ sys.stderr.write(_red(f"verscan: {len(errors)} source(s) could not be read\n"))
234
+ sys.exit(2)
235
+
236
+ if not result["ok"]:
237
+ sys.stderr.write(
238
+ _red(
239
+ f"verscan: version mismatch — "
240
+ f"{', '.join(result['mismatches'])} differ from {result['reference']}\n"
241
+ )
242
+ )
243
+ sys.exit(1)
244
+
245
+ sys.stdout.write(_green(f"verscan: all sources match → {_cyan(str(result['reference']))}\n"))
246
+ sys.exit(0)
verscan/core.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ verscan core — pure parsing and comparison logic. No I/O, no subprocess.
3
+
4
+ Each function takes raw string content so it is trivially testable.
5
+ Version numbers always use millisecond-epoch timestamps for state files
6
+ when sharing state with the Node.js counterpart (verscan).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+
16
+ def read_pkg_version(content: str) -> str:
17
+ """Extract ``version`` from a package.json string."""
18
+ try:
19
+ pkg = json.loads(content)
20
+ except json.JSONDecodeError as exc:
21
+ raise ValueError(f"invalid JSON in package.json: {exc}") from exc
22
+ version = pkg.get("version")
23
+ if not isinstance(version, str) or not version:
24
+ raise ValueError('no "version" field in package.json')
25
+ return version.strip()
26
+
27
+
28
+ def read_pyproject_version(content: str) -> str:
29
+ """Extract version from a pyproject.toml string via regex.
30
+
31
+ Matches ``version = "x.y.z"`` (or single-quoted) inside the ``[project]``
32
+ section without importing tomllib (which requires Python ≥ 3.11).
33
+ """
34
+ m = re.search(
35
+ r'^\[project\][^[]*?\bversion\s*=\s*["\']([^"\'\\r\\n]+)["\']',
36
+ content,
37
+ re.MULTILINE | re.DOTALL,
38
+ )
39
+ if not m:
40
+ raise ValueError("no version in [project] section of pyproject.toml")
41
+ return m.group(1).strip()
42
+
43
+
44
+ def read_changelog_version(content: str) -> str:
45
+ """Extract the first version heading from CHANGELOG.md content.
46
+
47
+ Understands Keep-a-Changelog ``## [1.2.3]`` and bare forms
48
+ ``## 1.2.3``, ``## v1.2.3``, ``## [v1.2.3]``.
49
+ Returns the bare semver (v-prefix and brackets stripped).
50
+ """
51
+ m = re.search(
52
+ r'^##\s+\[?v?(\d+\.\d+(?:\.\d+)?(?:[-+][^\]\s]*)?)(\])?(?:\s|$)',
53
+ content,
54
+ re.MULTILINE,
55
+ )
56
+ if not m:
57
+ raise ValueError(
58
+ "no version heading found in CHANGELOG.md "
59
+ "(expected `## [x.y.z]` or `## x.y.z`)"
60
+ )
61
+ return m.group(1).strip()
62
+
63
+
64
+ def parse_git_tag(output: str) -> str:
65
+ """Strip leading ``v`` and whitespace from ``git describe`` output."""
66
+ tag = output.strip()
67
+ if not tag:
68
+ raise ValueError("no git tags found in this repository")
69
+ return tag.lstrip("v")
70
+
71
+
72
+ def normalize_version(v: str) -> str:
73
+ """Strip leading ``v`` and whitespace."""
74
+ return str(v).strip().lstrip("v")
75
+
76
+
77
+ def compare_versions(
78
+ versions: Dict[str, Optional[str]],
79
+ ) -> Dict:
80
+ """Compare a dict of named version strings.
81
+
82
+ Returns::
83
+
84
+ {
85
+ "ok": bool,
86
+ "reference": str | None,
87
+ "mismatches": [str, ...],
88
+ "entries": [(name, version), ...],
89
+ }
90
+
91
+ Null/None values are skipped.
92
+ """
93
+ entries: List[Tuple[str, str]] = [
94
+ (k, v) for k, v in versions.items() if v is not None
95
+ ]
96
+ if len(entries) < 2:
97
+ return {
98
+ "ok": True,
99
+ "reference": entries[0][1] if entries else None,
100
+ "mismatches": [],
101
+ "entries": entries,
102
+ }
103
+ ref = entries[0][1]
104
+ mismatches = [k for k, v in entries if v != ref]
105
+ return {
106
+ "ok": len(mismatches) == 0,
107
+ "reference": ref,
108
+ "mismatches": mismatches,
109
+ "entries": entries,
110
+ }
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: verscan
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency CLI to verify package.json, CHANGELOG.md, and git tag all share the same version — catch release mistakes before they ship.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/verscan-py
8
+ Project-URL: Repository, https://github.com/jjdoor/verscan-py
9
+ Project-URL: Issues, https://github.com/jjdoor/verscan-py/issues
10
+ Keywords: version,release,changelog,git,semver,cli,devops,ci,lint,check
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # verscan
25
+
26
+ Zero-dependency CLI to verify that `package.json` (or `pyproject.toml`), `CHANGELOG.md`, and your latest git tag all agree on the same version number — before you ship.
27
+
28
+ ```
29
+ $ verscan
30
+
31
+ ✓ pyproject.toml 0.3.1 (reference)
32
+ ✓ CHANGELOG.md 0.3.1
33
+ ✓ git tag 0.3.1
34
+
35
+ verscan: all sources match → 0.3.1
36
+ ```
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install verscan
42
+ ```
43
+
44
+ No dependencies. Python ≥ 3.8 required.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ verscan [options] [dir]
50
+ ```
51
+
52
+ | Option | Description |
53
+ |--------|-------------|
54
+ | `--no-git` | Skip git tag check |
55
+ | `--no-changelog` | Skip CHANGELOG.md check |
56
+ | `--manifest <file>` | Custom manifest path (default: auto-detect) |
57
+ | `--changelog <file>` | Custom changelog path (default: `CHANGELOG.md`) |
58
+ | `--json` | Output JSON |
59
+ | `--version` | Print verscan version |
60
+ | `--help` | Show help |
61
+
62
+ **Exit codes:** `0` all match · `1` mismatch · `2` parse/read error
63
+
64
+ ## Examples
65
+
66
+ ```bash
67
+ verscan # check current directory
68
+ verscan --no-git # skip git tag (before tagging)
69
+ verscan ./packages/api # check a sub-package
70
+ verscan --json | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d['ok'] else 1)"
71
+ ```
72
+
73
+ ## CI integration
74
+
75
+ ```yaml
76
+ - name: Verify versions aligned
77
+ run: pip install verscan && verscan
78
+ ```
79
+
80
+ For the Node.js CLI counterpart, see [verscan](https://github.com/jjdoor/verscan).
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,10 @@
1
+ verscan/__init__.py,sha256=odbbKlfPDmA_4t2RLsxJl_Uk7mjWkClBecNZdD-jQC0,79
2
+ verscan/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ verscan/cli.py,sha256=5Suj2vn2sPmLojr5-OQwbqTObsiP0tXufPU4dWmNXMc,7860
4
+ verscan/core.py,sha256=TZwY2eNc6-MZ3nmh-GUINbNsPpYxM-igqdjaBF9MMB4,3242
5
+ verscan-0.1.0.dist-info/licenses/LICENSE,sha256=fgx5TFKqwm0KYEWedI7KffCOvMfWFqoPSuXZ_-RyoiI,1080
6
+ verscan-0.1.0.dist-info/METADATA,sha256=e1T5P7NuMLE_WlVaBWJmFyIsQzZhZrQs_jYEJWAia5A,2374
7
+ verscan-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ verscan-0.1.0.dist-info/entry_points.txt,sha256=M8DNihWzaS5OQ2Q6qjQiz3ZbiYaygne4dTesMd6xZL8,45
9
+ verscan-0.1.0.dist-info/top_level.txt,sha256=mqBeND8owR27qE97cTTOsMdtaO85iLfIGSqc8mV5RTk,8
10
+ verscan-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ verscan = verscan.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cron-watch contributors
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
+ verscan