yt-tools-cli 0.23.1__tar.gz → 0.23.2__tar.gz
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.
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.claude-plugin/plugin.json +1 -1
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/CHANGELOG.md +27 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/PKG-INFO +35 -11
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/README.md +34 -10
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/pyproject.toml +1 -1
- yt_tools_cli-0.23.2/scripts/publish-check.py +232 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/skills/using-yt-tools/SKILL.md +36 -13
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_publish_check.py +64 -4
- yt_tools_cli-0.23.1/scripts/publish-check.py +0 -142
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.github/workflows/ci.yml +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.github/workflows/release.yml +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/.gitignore +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/CONTRIBUTING.md +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/LICENSE +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/hooks/hooks.json +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/check-core-install.sh +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/check-metadata.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/check-pypi-name.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/ensure-install.ps1 +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/ensure-install.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/ensure-install.sh +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/scripts/sync-version.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/__init__.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_cache.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_check_metadata.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_cli_smoke.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_comments.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_core.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_distribution_name.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_doctor.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_failure_modes.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_frames.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_listen.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_markdown.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_meta.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_missing_extra.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_ocr.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_packaging.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_parse_interval.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_pypi_name.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_search.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_skill_portable.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_transcript.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_version.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/tests/test_watch.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/__init__.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/_metadata.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/cache.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/cli.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/comments.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/core.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/doctor.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/extras.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/frames.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/listen.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/markdown.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/meta.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/ocr.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/search.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/transcript.py +0 -0
- {yt_tools_cli-0.23.1 → yt_tools_cli-0.23.2}/yt_tools/watch.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-tools",
|
|
3
3
|
"displayName": "yt-tools",
|
|
4
|
-
"version": "0.23.
|
|
4
|
+
"version": "0.23.2",
|
|
5
5
|
"description": "Iterative YouTube watching for agents: clean-markdown transcripts with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), full-metadata extraction (description, chapters, most-replayed, counts, tags), comment scraping, and OCR over cached frames (RapidOCR PP-OCRv5) for silent-with-text videos. Light core (transcript / metadata / comments / search) with heavy stacks in [frames] and [audio] extras. Bundles the using-yt-tools skill and a SessionStart hook that auto-installs from the plugin's local clone via pipx (default [full] extras, fallback to [frames,audio]) and probes ffmpeg.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Victor Kuznetsov",
|
|
@@ -11,6 +11,33 @@ Releases before 0.8.0 are summarized only in the git log.
|
|
|
11
11
|
|
|
12
12
|
_(nothing yet)_
|
|
13
13
|
|
|
14
|
+
## [0.23.2] — 2026-09-13
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `scripts/publish-check.py` no longer reports a false `version drift` seconds
|
|
19
|
+
after a successful upload. PyPI's *project-level* JSON API is CDN-cached
|
|
20
|
+
(`cache-control: max-age=900`), so the `verify` job — which runs about five
|
|
21
|
+
seconds after the publish step — read the previous version and failed the
|
|
22
|
+
`0.23.1` release run while the release itself was live and complete. The check
|
|
23
|
+
now asks the *version-scoped* endpoint (`/pypi/<name>/<version>/json`, a URL
|
|
24
|
+
that cannot be stale) first, and lets the project endpoint only explain. Same
|
|
25
|
+
failure class as the simple-index cache behind the 0.22.1 preflight note: the
|
|
26
|
+
source a check adjudicates on has to be one that cannot lag.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The README and the skill lead with the PyPI install instead of the git URL:
|
|
31
|
+
`pipx install "yt-tools-cli[full]"`, a pinned form
|
|
32
|
+
(`pipx install "yt-tools-cli[full]==0.23.1"`), and an explicit note that the
|
|
33
|
+
distribution is `yt-tools-cli` while the project, the repository and every
|
|
34
|
+
command stay `yt-tools` (import name `yt_tools`).
|
|
35
|
+
- The *changing extras* advice is corrected: `pipx uninstall` and then install,
|
|
36
|
+
instead of `pipx install --force`. A forced reinstall on an existing venv was
|
|
37
|
+
observed leaving the new extras half-installed (`scenedetect` skipped entirely)
|
|
38
|
+
and resolving `librosa` 1.0 past its cap; `pipx runpip yt-tools-cli list` is
|
|
39
|
+
the check that sees it.
|
|
40
|
+
|
|
14
41
|
## [0.23.1] — 2026-09-13
|
|
15
42
|
|
|
16
43
|
> `0.23.0` never reached PyPI: the upload was rejected and no artifact exists
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: yt-tools-cli
|
|
3
|
-
Version: 0.23.
|
|
3
|
+
Version: 0.23.2
|
|
4
4
|
Summary: CLI suite for iterative YouTube watching by LLM agents: transcript with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), and OCR over cached frames for silent-with-text videos.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kzntsv-dev/yt-tools
|
|
6
6
|
Project-URL: Repository, https://github.com/kzntsv-dev/yt-tools
|
|
@@ -128,20 +128,44 @@ directory at this repo's `skills/` (or symlink `skills/using-yt-tools` into it).
|
|
|
128
128
|
|
|
129
129
|
### Standalone CLI (any environment)
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
The distribution is **`yt-tools-cli`**: the project, the repository, the plugin and
|
|
132
|
+
every console command stay `yt-tools`, and the import name stays `yt_tools`.
|
|
133
|
+
`pip`/`pipx`/`uv` resolve the distribution name, so that is the one below.
|
|
132
134
|
|
|
133
135
|
```bash
|
|
134
136
|
# 1. bootstrap pipx (one-time per user)
|
|
135
137
|
python -m pip install --user pipx
|
|
136
138
|
python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
|
|
137
139
|
|
|
138
|
-
# 2. install
|
|
139
|
-
pipx install
|
|
140
|
+
# 2. install from PyPI — core + [frames] + [audio], i.e. every flow except OCR
|
|
141
|
+
pipx install "yt-tools-cli[full]"
|
|
140
142
|
|
|
141
|
-
#
|
|
142
|
-
pipx install
|
|
143
|
+
# reproducible: pin the exact version
|
|
144
|
+
pipx install "yt-tools-cli[full]==0.23.1"
|
|
145
|
+
|
|
146
|
+
# [ocr] is always separate and explicit (its ONNX model is ~10 MB, fetched lazily)
|
|
147
|
+
pipx inject yt-tools-cli rapidocr onnxruntime
|
|
143
148
|
```
|
|
144
149
|
|
|
150
|
+
`uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
|
|
151
|
+
`uvx --from "yt-tools-cli[full]" yt-transcript <url>`.
|
|
152
|
+
|
|
153
|
+
**From a checkout instead of PyPI** — the plugin path, or anyone tracking
|
|
154
|
+
`master`:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
> **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
|
|
161
|
+
> first, then install the new extras set. `pipx install --force` on an existing
|
|
162
|
+
> venv can leave the new extras half-installed and resolve against the old pin
|
|
163
|
+
> set — observed on a `[full]` reinstall that kept `librosa` 1.0 instead of the
|
|
164
|
+
> capped 0.11 and skipped `scenedetect` entirely. Verify with
|
|
165
|
+
> `pipx runpip yt-tools-cli list`, not by the version alone.
|
|
166
|
+
> `pipx inject yt-tools-cli <packages>` is the narrow alternative when you only
|
|
167
|
+
> need to add packages.
|
|
168
|
+
|
|
145
169
|
#### What each extra buys you
|
|
146
170
|
|
|
147
171
|
A plain install is deliberately light: it never pulls OpenCV, librosa or
|
|
@@ -176,11 +200,11 @@ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detec
|
|
|
176
200
|
Without it `yt-listen` still runs: librosa-only BPM and key, with every section
|
|
177
201
|
it cannot fill marked `n/a` in the markdown rather than silently missing.
|
|
178
202
|
|
|
179
|
-
> **Adding
|
|
180
|
-
>
|
|
181
|
-
>
|
|
182
|
-
> `
|
|
183
|
-
>
|
|
203
|
+
> **Adding a single extra to an existing pipx venv.** `pipx inject yt-tools-cli
|
|
204
|
+
> scenedetect opencv-python` adds packages without rebuilding the venv, which is
|
|
205
|
+
> the cheapest way to widen an install. To change the *extras set* (e.g. move
|
|
206
|
+
> from core to `[full]`), uninstall and reinstall instead — see the note in
|
|
207
|
+
> [Standalone CLI](#standalone-cli-any-environment).
|
|
184
208
|
|
|
185
209
|
#### What happens when an extra is missing
|
|
186
210
|
|
|
@@ -86,20 +86,44 @@ directory at this repo's `skills/` (or symlink `skills/using-yt-tools` into it).
|
|
|
86
86
|
|
|
87
87
|
### Standalone CLI (any environment)
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
The distribution is **`yt-tools-cli`**: the project, the repository, the plugin and
|
|
90
|
+
every console command stay `yt-tools`, and the import name stays `yt_tools`.
|
|
91
|
+
`pip`/`pipx`/`uv` resolve the distribution name, so that is the one below.
|
|
90
92
|
|
|
91
93
|
```bash
|
|
92
94
|
# 1. bootstrap pipx (one-time per user)
|
|
93
95
|
python -m pip install --user pipx
|
|
94
96
|
python -m pipx ensurepath # adds ~/.local/bin to PATH; restart shell after
|
|
95
97
|
|
|
96
|
-
# 2. install
|
|
97
|
-
pipx install
|
|
98
|
+
# 2. install from PyPI — core + [frames] + [audio], i.e. every flow except OCR
|
|
99
|
+
pipx install "yt-tools-cli[full]"
|
|
98
100
|
|
|
99
|
-
#
|
|
100
|
-
pipx install
|
|
101
|
+
# reproducible: pin the exact version
|
|
102
|
+
pipx install "yt-tools-cli[full]==0.23.1"
|
|
103
|
+
|
|
104
|
+
# [ocr] is always separate and explicit (its ONNX model is ~10 MB, fetched lazily)
|
|
105
|
+
pipx inject yt-tools-cli rapidocr onnxruntime
|
|
101
106
|
```
|
|
102
107
|
|
|
108
|
+
`uv` users: `uv tool install "yt-tools-cli[full]"`, or for a one-off run
|
|
109
|
+
`uvx --from "yt-tools-cli[full]" yt-transcript <url>`.
|
|
110
|
+
|
|
111
|
+
**From a checkout instead of PyPI** — the plugin path, or anyone tracking
|
|
112
|
+
`master`:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
> **Changing the extras of an existing install.** `pipx uninstall yt-tools-cli`
|
|
119
|
+
> first, then install the new extras set. `pipx install --force` on an existing
|
|
120
|
+
> venv can leave the new extras half-installed and resolve against the old pin
|
|
121
|
+
> set — observed on a `[full]` reinstall that kept `librosa` 1.0 instead of the
|
|
122
|
+
> capped 0.11 and skipped `scenedetect` entirely. Verify with
|
|
123
|
+
> `pipx runpip yt-tools-cli list`, not by the version alone.
|
|
124
|
+
> `pipx inject yt-tools-cli <packages>` is the narrow alternative when you only
|
|
125
|
+
> need to add packages.
|
|
126
|
+
|
|
103
127
|
#### What each extra buys you
|
|
104
128
|
|
|
105
129
|
A plain install is deliberately light: it never pulls OpenCV, librosa or
|
|
@@ -134,11 +158,11 @@ pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detec
|
|
|
134
158
|
Without it `yt-listen` still runs: librosa-only BPM and key, with every section
|
|
135
159
|
it cannot fill marked `n/a` in the markdown rather than silently missing.
|
|
136
160
|
|
|
137
|
-
> **Adding
|
|
138
|
-
>
|
|
139
|
-
>
|
|
140
|
-
> `
|
|
141
|
-
>
|
|
161
|
+
> **Adding a single extra to an existing pipx venv.** `pipx inject yt-tools-cli
|
|
162
|
+
> scenedetect opencv-python` adds packages without rebuilding the venv, which is
|
|
163
|
+
> the cheapest way to widen an install. To change the *extras set* (e.g. move
|
|
164
|
+
> from core to `[full]`), uninstall and reinstall instead — see the note in
|
|
165
|
+
> [Standalone CLI](#standalone-cli-any-environment).
|
|
142
166
|
|
|
143
167
|
#### What happens when an extra is missing
|
|
144
168
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yt-tools-cli"
|
|
7
|
-
version = "0.23.
|
|
7
|
+
version = "0.23.2"
|
|
8
8
|
description = "CLI suite for iterative YouTube watching by LLM agents: transcript with [mm:ss] anchors, targeted frame extraction, FFT audio analysis (BPM, key, chord progression, spectral features), and OCR over cached frames for silent-with-text videos."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10,<3.13"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Did the release actually land on PyPI?
|
|
3
|
+
|
|
4
|
+
A green build is not a published release. ``sched`` learned this the expensive
|
|
5
|
+
way: a wave failed on the ``tsc``/dist step and nobody noticed until an external
|
|
6
|
+
check looked — twice for the same version (their ``scripts/publish-check.mjs``).
|
|
7
|
+
The question "is the version people install the version we tagged?" belongs to a
|
|
8
|
+
machine, and it belongs *after* the upload.
|
|
9
|
+
|
|
10
|
+
So this runs after the publish step and answers three things:
|
|
11
|
+
|
|
12
|
+
1. does *this* version exist on the index at all ("never landed" — a 404 body);
|
|
13
|
+
2. is its artifact set complete (a release that landed half-built is not a
|
|
14
|
+
release);
|
|
15
|
+
3. if it is absent, did something else land instead ("version drift" — the build
|
|
16
|
+
published another version, or a stale tag).
|
|
17
|
+
|
|
18
|
+
**Which payload answers which question matters.** The project-level endpoint
|
|
19
|
+
``/pypi/<name>/json`` is CDN-cached (``cache-control: max-age=900``), so seconds
|
|
20
|
+
after a successful upload it still serves the *previous* version — and this check
|
|
21
|
+
runs seconds after a successful upload. Adjudicating on it produced a false
|
|
22
|
+
"version drift" on release 0.23.1 for a release that had landed ([[task:2826]],
|
|
23
|
+
the whole reason this file asks twice). The version-scoped endpoint
|
|
24
|
+
``/pypi/<name>/<version>/json`` is a URL nobody has fetched before, so it cannot
|
|
25
|
+
be stale: it decides. The project endpoint only ever explains.
|
|
26
|
+
|
|
27
|
+
Exit codes: ``0`` landed · ``1`` not landed / drifted / incomplete · ``2`` the
|
|
28
|
+
check could not run (an unrunnable check is never a pass).
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
python scripts/publish-check.py # version from pyproject.toml
|
|
33
|
+
python scripts/publish-check.py --version 0.23.1
|
|
34
|
+
# offline / tests: one file per endpoint
|
|
35
|
+
python scripts/publish-check.py --json-file version.json --project-json-file project.json
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import re
|
|
43
|
+
import sys
|
|
44
|
+
import urllib.error
|
|
45
|
+
import urllib.request
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
#: The version-scoped endpoint: unique per release, therefore never stale.
|
|
49
|
+
PYPI_VERSION_JSON = "https://pypi.org/pypi/{name}/{version}/json"
|
|
50
|
+
#: The project-scoped endpoint: what `latest` means to a user, and CDN-cached.
|
|
51
|
+
PYPI_PROJECT_JSON = "https://pypi.org/pypi/{name}/json"
|
|
52
|
+
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
53
|
+
_NAME_RE = re.compile(r'^name\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
54
|
+
|
|
55
|
+
#: Both artifact kinds a release is expected to ship.
|
|
56
|
+
EXPECTED_KINDS = ("bdist_wheel", "sdist")
|
|
57
|
+
|
|
58
|
+
NOT_FOUND: dict = {"message": "Not Found"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CheckError(RuntimeError):
|
|
62
|
+
"""The check could not run (bad fixture, unreachable index, HTTP != 404)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_pyproject(path: Path) -> tuple[str | None, str | None]:
|
|
66
|
+
"""``(name, version)`` from ``pyproject.toml``, or ``(None, None)``."""
|
|
67
|
+
try:
|
|
68
|
+
text = path.read_text(encoding="utf-8")
|
|
69
|
+
except OSError:
|
|
70
|
+
return None, None
|
|
71
|
+
name = _NAME_RE.search(text)
|
|
72
|
+
version = _VERSION_RE.search(text)
|
|
73
|
+
return (name.group(1) if name else None, version.group(1) if version else None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_json_file(path: Path) -> dict:
|
|
77
|
+
try:
|
|
78
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
79
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
80
|
+
raise CheckError(f"cannot read {path}: {exc}") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fetch_payload(url: str) -> dict:
|
|
84
|
+
"""The index payload, or ``NOT_FOUND`` when the index says 404.
|
|
85
|
+
|
|
86
|
+
A 404 is a *result* (nothing is there), not a broken check — that distinction
|
|
87
|
+
is the difference between "wait for the upload" and "the check is unusable".
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
with urllib.request.urlopen(url, timeout=60) as response:
|
|
91
|
+
return json.load(response)
|
|
92
|
+
except urllib.error.HTTPError as exc:
|
|
93
|
+
if exc.code == 404:
|
|
94
|
+
return dict(NOT_FOUND)
|
|
95
|
+
raise CheckError(f"the index answered {exc.code} for {url}") from exc
|
|
96
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
97
|
+
raise CheckError(f"cannot reach the index at {url}: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _canonical(name: str) -> str:
|
|
101
|
+
return name.lower().replace("_", "-")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _project_note(project_payload: dict | None, expected_version: str) -> list[str]:
|
|
105
|
+
"""Explain a lagging project endpoint — never decide with it."""
|
|
106
|
+
if not project_payload or "info" not in project_payload:
|
|
107
|
+
return []
|
|
108
|
+
latest = project_payload["info"].get("version")
|
|
109
|
+
if not latest or latest == expected_version:
|
|
110
|
+
return []
|
|
111
|
+
return [
|
|
112
|
+
f" note: the project endpoint still serves {latest} - PyPI's project JSON API is "
|
|
113
|
+
"CDN-cached (cache-control: max-age=900), so it can lag minutes behind an upload; "
|
|
114
|
+
"the version endpoint above is the authority"
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _not_landed(
|
|
119
|
+
lines: list[str], project_payload: dict | None, expected_version: str
|
|
120
|
+
) -> tuple[int, list[str]]:
|
|
121
|
+
"""Our version is absent — the project endpoint says whether something else landed."""
|
|
122
|
+
if project_payload and "info" in project_payload:
|
|
123
|
+
info = project_payload["info"]
|
|
124
|
+
latest = info.get("version")
|
|
125
|
+
lines.append(f" on the project endpoint: {info.get('name', '?')} {latest}")
|
|
126
|
+
if latest and latest != expected_version:
|
|
127
|
+
lines.append(f" verdict: version drift - tagged {expected_version}, installable {latest}")
|
|
128
|
+
lines.append(" fix : the upload published another version; check the tag and the build")
|
|
129
|
+
return 1, lines
|
|
130
|
+
lines.append(" verdict: never landed - the release is not on the index")
|
|
131
|
+
lines.append(" fix : wait for the publish job, or re-run it; the upload itself failed")
|
|
132
|
+
return 1, lines
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check(
|
|
136
|
+
version_payload: dict,
|
|
137
|
+
project_payload: dict | None,
|
|
138
|
+
expected_name: str,
|
|
139
|
+
expected_version: str,
|
|
140
|
+
) -> tuple[int, list[str]]:
|
|
141
|
+
lines = [f"publish check: {expected_name} {expected_version}"]
|
|
142
|
+
|
|
143
|
+
if "info" not in version_payload:
|
|
144
|
+
return _not_landed(lines, project_payload, expected_version)
|
|
145
|
+
|
|
146
|
+
info = version_payload.get("info", {})
|
|
147
|
+
published_name = info.get("name", "?")
|
|
148
|
+
published_version = info.get("version", "?")
|
|
149
|
+
lines.append(f" on the version endpoint: {published_name} {published_version}")
|
|
150
|
+
|
|
151
|
+
if _canonical(published_name) != _canonical(expected_name):
|
|
152
|
+
lines.append(f" verdict: name drift - expected {expected_name!r}, index has {published_name!r}")
|
|
153
|
+
return 1, lines
|
|
154
|
+
|
|
155
|
+
if published_version != expected_version:
|
|
156
|
+
lines.append(
|
|
157
|
+
f" verdict: version drift - tagged {expected_version}, "
|
|
158
|
+
f"that endpoint serves {published_version}"
|
|
159
|
+
)
|
|
160
|
+
lines.append(" fix : the upload published another version; check the tag and the build")
|
|
161
|
+
return 1, lines
|
|
162
|
+
|
|
163
|
+
kinds = {f.get("packagetype") for f in version_payload.get("urls", [])}
|
|
164
|
+
missing = [kind for kind in EXPECTED_KINDS if kind not in kinds]
|
|
165
|
+
if missing:
|
|
166
|
+
lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k)) or 'none'}")
|
|
167
|
+
lines.append(f" verdict: incomplete - missing {', '.join(missing)}")
|
|
168
|
+
lines.append(" fix : the publish step uploaded a partial release")
|
|
169
|
+
return 1, lines
|
|
170
|
+
|
|
171
|
+
lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k))}")
|
|
172
|
+
lines.extend(_project_note(project_payload, expected_version))
|
|
173
|
+
lines.append(" verdict: landed - what people install is what we tagged")
|
|
174
|
+
return 0, lines
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: list[str] | None = None) -> int:
|
|
178
|
+
parser = argparse.ArgumentParser(
|
|
179
|
+
prog="publish-check",
|
|
180
|
+
description="Verify that a release landed on PyPI (run after the publish step).",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument("--name", help="distribution name (default: pyproject.toml)")
|
|
183
|
+
parser.add_argument("--version", help="expected version (default: pyproject.toml)")
|
|
184
|
+
parser.add_argument("--json-file", type=Path, help="read the version-endpoint payload from a file (offline)")
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--project-json-file",
|
|
187
|
+
type=Path,
|
|
188
|
+
help="read the project-endpoint payload from a file (offline; optional)",
|
|
189
|
+
)
|
|
190
|
+
parser.add_argument("--url", help="override the version-endpoint URL")
|
|
191
|
+
parser.add_argument("--project-url", help="override the project-endpoint URL")
|
|
192
|
+
args = parser.parse_args(argv)
|
|
193
|
+
|
|
194
|
+
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
195
|
+
declared_name, declared_version = read_pyproject(pyproject)
|
|
196
|
+
name = args.name or declared_name
|
|
197
|
+
version = args.version or declared_version
|
|
198
|
+
if not name or not version:
|
|
199
|
+
print(f"error: no name/version in {pyproject} and none given", file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
if args.json_file is not None:
|
|
204
|
+
version_payload = read_json_file(args.json_file)
|
|
205
|
+
project_payload = (
|
|
206
|
+
read_json_file(args.project_json_file) if args.project_json_file is not None else None
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
version_payload = fetch_payload(
|
|
210
|
+
args.url or PYPI_VERSION_JSON.format(name=name, version=version)
|
|
211
|
+
)
|
|
212
|
+
project_url = args.project_url or PYPI_PROJECT_JSON.format(name=name)
|
|
213
|
+
try:
|
|
214
|
+
project_payload = fetch_payload(project_url)
|
|
215
|
+
except CheckError:
|
|
216
|
+
# Only needed to *explain* an absent version; a proven landing
|
|
217
|
+
# must not fail because the advisory probe hiccuped.
|
|
218
|
+
if "info" in version_payload:
|
|
219
|
+
project_payload = None
|
|
220
|
+
else:
|
|
221
|
+
raise
|
|
222
|
+
except CheckError as exc:
|
|
223
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
224
|
+
return 2
|
|
225
|
+
|
|
226
|
+
code, lines = check(version_payload, project_payload, name, version)
|
|
227
|
+
print("\n".join(lines))
|
|
228
|
+
return code
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
sys.exit(main())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: using-yt-tools
|
|
3
|
-
version: 0.23.
|
|
3
|
+
version: 0.23.2
|
|
4
4
|
description: Seven flows for YouTube content. **Discovery-search** (`yt-search`) — query → markdown list of candidate videos via yt-dlp's native ytsearch extractor; the entry point when the user hasn't named a URL yet. **Iterative-watch** (summary / exploration) — transcript with [mm:ss] anchors → pick moments → extract frames. **Targeted-frames** (specific timestamps) — extract frames directly, no transcript. **Audio-analysis** (music FFT) — per timestamp spectrogram + numeric digest (BPM, key, chord progression, harmonic content) via `yt-listen`. **Metadata** (`yt-meta`, free — rides the same yt-dlp call) — description, chapters, most-replayed heatmap, view/like/comment counts, tags, subtitle languages as markdown. **Comments** (`yt-comments`, separate paginated scrape) — top comments + nested replies; capped top-50 by default, costly on viral videos, only on explicit request. **OCR** (`yt-ocr`, silent-with-text fallback) — RapidOCR PP-OCRv5 over cached frames → `[mm:ss]` blocks as a transcript substitute when `yt-transcript` is empty or the video is silent-instructional (burned-in captions, schematic labels, chord matrices). Triggers (mixed RU/EN — same skill serves both audiences) — "find a video about X", "search youtube for X", "find tutorial about X", "найди видео про X", "поищи туториал", "обзор на X youtube", "what's in this video", "video summary", "youtube transcript", "что в ролике", "о чём видео", "show frame at N", "покажи кадр на N", "listen to fragment at N", "послушай момент N", "what's the BPM", "BPM/тональность видео", "analyze audio", "спектрограмма", "video description", "show chapters", "most replayed", "video stats", "что в описании", "покажи главы", "самые пересматриваемые", "top comments", "what are people saying", "комменты под роликом", "топ комментариев", "read text from frames", "OCR this video", "what does the caption say", "extract overlay text", "captions burned in", "прочти текст с кадров", "OCR этого видео", "silent video с текстом", or any youtube.com URL. CLI installed once per machine with pipx (`pipx install 'yt-tools-cli[full]'` adds the audio/chord stack); `yt-tools doctor` is the read-only preflight that names whatever is missing. YouTube-only — for Vimeo / Twitch / local files use other tools.
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -133,25 +133,47 @@ binaries).
|
|
|
133
133
|
|
|
134
134
|
### Installing the CLI
|
|
135
135
|
|
|
136
|
-
Install once per machine — one command, the same in every harness
|
|
136
|
+
Install once per machine — one command, the same in every harness. The
|
|
137
|
+
**distribution** name is `yt-tools-cli` (the project, repo, plugin and all
|
|
138
|
+
commands stay `yt-tools`; the import name is `yt_tools`):
|
|
137
139
|
|
|
138
140
|
```bash
|
|
139
|
-
|
|
141
|
+
# from PyPI — core + [frames] + [audio], i.e. every flow except OCR
|
|
142
|
+
pipx install "yt-tools-cli[full]"
|
|
143
|
+
|
|
144
|
+
# core only (transcript / meta / comments / search / cache)
|
|
145
|
+
pipx install yt-tools-cli
|
|
146
|
+
|
|
147
|
+
# reproducible: pin the exact version
|
|
148
|
+
pipx install "yt-tools-cli[full]==0.23.1"
|
|
140
149
|
```
|
|
141
150
|
|
|
142
|
-
|
|
143
|
-
extra:
|
|
151
|
+
From a checkout instead of PyPI (the plugin path, or tracking `master`):
|
|
144
152
|
|
|
145
153
|
```bash
|
|
146
|
-
pipx install
|
|
154
|
+
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
147
155
|
```
|
|
148
156
|
|
|
157
|
+
The `[full]` extra is core + `[frames]` + `[audio]`. Chord progression,
|
|
158
|
+
structural segments and the refined BPM/key in `yt-listen` come from
|
|
159
|
+
`bpm-detector`, which is **not on PyPI** and therefore cannot live in the
|
|
160
|
+
published metadata — the Python package index rejects direct VCS references.
|
|
161
|
+
Add it on top:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Without it `yt-listen` still runs (librosa-only BPM/key, unfilled sections marked
|
|
168
|
+
`n/a`). `[ocr]` is always an explicit extra: `pipx inject yt-tools-cli rapidocr
|
|
169
|
+
onnxruntime`.
|
|
170
|
+
|
|
149
171
|
Some harnesses do this for you: if your host ships the bundled yt-tools plugin
|
|
150
172
|
(Claude Code does), its `SessionStart` hook pipx-installs the `[full]` set from
|
|
151
|
-
the plugin's own clone on the first session after install
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
requirement: the
|
|
173
|
+
the plugin's own clone on the first session after install, then injects
|
|
174
|
+
`bpm-detector` best-effort — a blocked VCS fetch warns and leaves the
|
|
175
|
+
librosa-only path, it never fails the install. A hook is a convenience, not a
|
|
176
|
+
requirement: the commands above are the contract.
|
|
155
177
|
|
|
156
178
|
### First run on a machine: `yt-tools doctor`
|
|
157
179
|
|
|
@@ -211,9 +233,10 @@ the optional `[ocr]` extra):
|
|
|
211
233
|
```
|
|
212
234
|
python -m pip install --user pipx
|
|
213
235
|
python -m pipx ensurepath # one-time; restart shell after
|
|
214
|
-
pipx install
|
|
215
|
-
|
|
216
|
-
#
|
|
236
|
+
pipx install "yt-tools-cli[full]" # core + frames + audio
|
|
237
|
+
pipx inject yt-tools-cli "bpm-detector @ git+https://github.com/libraz/bpm-detector@v1.1.0"
|
|
238
|
+
# or from a checkout:
|
|
239
|
+
pipx install 'git+https://github.com/kzntsv-dev/yt-tools.git#egg=yt-tools-cli[full]'
|
|
217
240
|
```
|
|
218
241
|
If your host ships the bundled plugin (Claude Code users: install
|
|
219
242
|
`yt-tools@opeitcloc03-claude-plugins`), prefer it — its SessionStart hook
|
|
@@ -38,8 +38,8 @@ def _payload(version: str, *, wheel: bool = True, sdist: bool = True) -> str:
|
|
|
38
38
|
return json.dumps({"info": {"name": "yt-tools-cli", "version": version}, "urls": files})
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def _write(tmp_path: Path, text: str) -> Path:
|
|
42
|
-
path = tmp_path /
|
|
41
|
+
def _write(tmp_path: Path, text: str, name: str = "pypi.json") -> Path:
|
|
42
|
+
path = tmp_path / name
|
|
43
43
|
path.write_text(text, encoding="utf-8")
|
|
44
44
|
return path
|
|
45
45
|
|
|
@@ -61,12 +61,57 @@ def test_a_landed_release_passes(tmp_path):
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def test_a_version_that_never_landed_fails_and_names_both_versions(tmp_path):
|
|
64
|
-
"""The failure sched saw twice: build green, registry still on the old one.
|
|
65
|
-
|
|
64
|
+
"""The failure sched saw twice: build green, registry still on the old one.
|
|
65
|
+
|
|
66
|
+
Restated for the two-endpoint design: *our* version endpoint 404s (the
|
|
67
|
+
release is not there) while the project endpoint names another version —
|
|
68
|
+
which is the actual drift signature.
|
|
69
|
+
[test-modify: test_a_version_that_never_landed_fails_and_names_both_versions:
|
|
70
|
+
was `--json-file` carrying the project payload with another version; is the
|
|
71
|
+
same payload passed as `--project-json-file` next to a 404 version payload;
|
|
72
|
+
reason: the check now asks the version-scoped endpoint first (it cannot be
|
|
73
|
+
CDN-stale — task:2826), so "what the index serves" is the project payload
|
|
74
|
+
and "is our release there" is the version payload]
|
|
75
|
+
"""
|
|
76
|
+
proc = _run(
|
|
77
|
+
"--version",
|
|
78
|
+
"9.9.9",
|
|
79
|
+
"--json-file",
|
|
80
|
+
str(_write(tmp_path, '{"message": "Not Found"}', name="version.json")),
|
|
81
|
+
"--project-json-file",
|
|
82
|
+
str(_write(tmp_path, _payload("0.22.1"), name="project.json")),
|
|
83
|
+
)
|
|
66
84
|
|
|
67
85
|
assert proc.returncode == 1
|
|
68
86
|
assert "9.9.9" in proc.stdout # what we tagged
|
|
69
87
|
assert "0.22.1" in proc.stdout # what the world installs
|
|
88
|
+
assert "drift" in proc.stdout
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_a_stale_project_endpoint_does_not_fail_a_landed_release(tmp_path):
|
|
92
|
+
"""The v0.23.1 incident, pinned as a test ([[task:2826]]).
|
|
93
|
+
|
|
94
|
+
PyPI's project-level JSON API is CDN-cached (`cache-control: max-age=900`,
|
|
95
|
+
`X-Cache: HIT`), so five seconds after a successful upload it still serves
|
|
96
|
+
the *previous* version — and the `verify` job runs exactly then. Adjudicating
|
|
97
|
+
on that payload produced a false `version drift` on a release that had
|
|
98
|
+
landed: the uploaded files were on the index the whole time. The
|
|
99
|
+
version-scoped endpoint is a URL nobody has fetched before, so it cannot be
|
|
100
|
+
stale; the project endpoint is only allowed to explain, never to decide.
|
|
101
|
+
"""
|
|
102
|
+
expected = _pyproject_version()
|
|
103
|
+
proc = _run(
|
|
104
|
+
"--json-file",
|
|
105
|
+
str(_write(tmp_path, _payload(expected), name="version.json")),
|
|
106
|
+
"--project-json-file",
|
|
107
|
+
str(_write(tmp_path, _payload("0.0.0"), name="project.json")),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
111
|
+
assert "landed" in proc.stdout
|
|
112
|
+
# Named as a cache note, not as a verdict.
|
|
113
|
+
assert "0.0.0" in proc.stdout
|
|
114
|
+
assert "cache" in proc.stdout.lower()
|
|
70
115
|
|
|
71
116
|
|
|
72
117
|
def test_a_missing_artifact_kind_fails(tmp_path):
|
|
@@ -91,6 +136,21 @@ def test_a_project_that_does_not_exist_yet_is_not_a_mismatch(tmp_path):
|
|
|
91
136
|
assert "does not exist" in proc.stdout or "never landed" in proc.stdout
|
|
92
137
|
|
|
93
138
|
|
|
139
|
+
def test_a_project_endpoint_that_is_also_missing_still_says_never_landed(tmp_path):
|
|
140
|
+
"""Both endpoints 404: the release never landed (no drift to report)."""
|
|
141
|
+
proc = _run(
|
|
142
|
+
"--version",
|
|
143
|
+
"9.9.9",
|
|
144
|
+
"--json-file",
|
|
145
|
+
str(_write(tmp_path, '{"message": "Not Found"}', name="version.json")),
|
|
146
|
+
"--project-json-file",
|
|
147
|
+
str(_write(tmp_path, '{"message": "Not Found"}', name="project.json")),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
assert proc.returncode == 1
|
|
151
|
+
assert "never landed" in proc.stdout
|
|
152
|
+
|
|
153
|
+
|
|
94
154
|
def test_the_default_expected_version_is_the_tree_s(tmp_path):
|
|
95
155
|
"""No arguments: check what this checkout says it is."""
|
|
96
156
|
proc = _run("--json-file", str(_write(tmp_path, _payload(_pyproject_version()))))
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Did the release actually land on PyPI?
|
|
3
|
-
|
|
4
|
-
A green build is not a published release. ``sched`` learned this the expensive
|
|
5
|
-
way: a wave failed on the ``tsc``/dist step and nobody noticed until an external
|
|
6
|
-
check looked — twice for the same version (their ``scripts/publish-check.mjs``).
|
|
7
|
-
The question "is the version people install the version we tagged?" belongs to a
|
|
8
|
-
machine, and it belongs *after* the upload.
|
|
9
|
-
|
|
10
|
-
So this runs after the publish step and answers three things:
|
|
11
|
-
|
|
12
|
-
1. does the distribution exist on the index at all ("never landed" — a 404 body);
|
|
13
|
-
2. is its latest version the one this checkout declares ("version drift" — the
|
|
14
|
-
build published something else, or a stale tag);
|
|
15
|
-
3. are both artifacts there (a release that landed half-built is not a release).
|
|
16
|
-
|
|
17
|
-
Those are three different failures with three different fixes, so they are
|
|
18
|
-
reported separately. Exit codes: ``0`` landed · ``1`` not landed / drifted /
|
|
19
|
-
incomplete · ``2`` the check could not run (an unrunnable check is never a pass).
|
|
20
|
-
|
|
21
|
-
Usage::
|
|
22
|
-
|
|
23
|
-
python scripts/publish-check.py # version from pyproject.toml
|
|
24
|
-
python scripts/publish-check.py --version 0.23.0
|
|
25
|
-
python scripts/publish-check.py --json-file fixture.json # offline / tests
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import argparse
|
|
31
|
-
import json
|
|
32
|
-
import re
|
|
33
|
-
import sys
|
|
34
|
-
import urllib.error
|
|
35
|
-
import urllib.request
|
|
36
|
-
from pathlib import Path
|
|
37
|
-
|
|
38
|
-
PYPI_JSON = "https://pypi.org/pypi/{name}/json"
|
|
39
|
-
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
40
|
-
_NAME_RE = re.compile(r'^name\s*=\s*"([^"]+)"', re.MULTILINE)
|
|
41
|
-
|
|
42
|
-
#: Both artifact kinds a release is expected to ship.
|
|
43
|
-
EXPECTED_KINDS = ("bdist_wheel", "sdist")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def read_pyproject(path: Path) -> tuple[str | None, str | None]:
|
|
47
|
-
"""``(name, version)`` from ``pyproject.toml``, or ``(None, None)``."""
|
|
48
|
-
try:
|
|
49
|
-
text = path.read_text(encoding="utf-8")
|
|
50
|
-
except OSError:
|
|
51
|
-
return None, None
|
|
52
|
-
name = _NAME_RE.search(text)
|
|
53
|
-
version = _VERSION_RE.search(text)
|
|
54
|
-
return (name.group(1) if name else None, version.group(1) if version else None)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def fetch_payload(name: str, url: str | None) -> dict:
|
|
58
|
-
with urllib.request.urlopen(url or PYPI_JSON.format(name=name), timeout=60) as response:
|
|
59
|
-
return json.load(response)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def check(payload: dict, expected_name: str, expected_version: str) -> tuple[int, list[str]]:
|
|
63
|
-
lines = [f"publish check: {expected_name} {expected_version}"]
|
|
64
|
-
|
|
65
|
-
if "info" not in payload:
|
|
66
|
-
lines.append(" verdict: does not exist on the index - the release never landed")
|
|
67
|
-
lines.append(" fix : wait for the publish job, or re-run it; the upload itself failed")
|
|
68
|
-
return 1, lines
|
|
69
|
-
|
|
70
|
-
info = payload.get("info", {})
|
|
71
|
-
published_name = info.get("name", "?")
|
|
72
|
-
published_version = info.get("version", "?")
|
|
73
|
-
lines.append(f" on the index: {published_name} {published_version}")
|
|
74
|
-
|
|
75
|
-
if published_name.lower().replace("_", "-") != expected_name.lower().replace("_", "-"):
|
|
76
|
-
lines.append(f" verdict: name drift - expected {expected_name!r}, index has {published_name!r}")
|
|
77
|
-
return 1, lines
|
|
78
|
-
|
|
79
|
-
if published_version != expected_version:
|
|
80
|
-
lines.append(f" verdict: version drift - tagged {expected_version}, installable {published_version}")
|
|
81
|
-
lines.append(" fix : the upload published another version; check the tag and the build")
|
|
82
|
-
return 1, lines
|
|
83
|
-
|
|
84
|
-
kinds = {f.get("packagetype") for f in payload.get("urls", [])}
|
|
85
|
-
missing = [kind for kind in EXPECTED_KINDS if kind not in kinds]
|
|
86
|
-
if missing:
|
|
87
|
-
lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k)) or 'none'}")
|
|
88
|
-
lines.append(f" verdict: incomplete - missing {', '.join(missing)}")
|
|
89
|
-
lines.append(" fix : the publish step uploaded a partial release")
|
|
90
|
-
return 1, lines
|
|
91
|
-
|
|
92
|
-
lines.append(f" artifacts: {', '.join(sorted(k for k in kinds if k))}")
|
|
93
|
-
lines.append(" verdict: landed - what people install is what we tagged")
|
|
94
|
-
return 0, lines
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def main(argv: list[str] | None = None) -> int:
|
|
98
|
-
parser = argparse.ArgumentParser(
|
|
99
|
-
prog="publish-check",
|
|
100
|
-
description="Verify that a release landed on PyPI (run after the publish step).",
|
|
101
|
-
)
|
|
102
|
-
parser.add_argument("--name", help="distribution name (default: pyproject.toml)")
|
|
103
|
-
parser.add_argument("--version", help="expected version (default: pyproject.toml)")
|
|
104
|
-
parser.add_argument("--json-file", type=Path, help="read the index payload from a file (offline)")
|
|
105
|
-
parser.add_argument("--url", help="override the JSON API URL")
|
|
106
|
-
args = parser.parse_args(argv)
|
|
107
|
-
|
|
108
|
-
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
109
|
-
declared_name, declared_version = read_pyproject(pyproject)
|
|
110
|
-
name = args.name or declared_name
|
|
111
|
-
version = args.version or declared_version
|
|
112
|
-
if not name or not version:
|
|
113
|
-
print(f"error: no name/version in {pyproject} and none given", file=sys.stderr)
|
|
114
|
-
return 2
|
|
115
|
-
|
|
116
|
-
if args.json_file is not None:
|
|
117
|
-
try:
|
|
118
|
-
payload = json.loads(args.json_file.read_text(encoding="utf-8"))
|
|
119
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
120
|
-
print(f"error: cannot read {args.json_file}: {exc}", file=sys.stderr)
|
|
121
|
-
return 2
|
|
122
|
-
else:
|
|
123
|
-
try:
|
|
124
|
-
payload = fetch_payload(name, args.url)
|
|
125
|
-
except urllib.error.HTTPError as exc:
|
|
126
|
-
# A 404 is a *result* (nothing landed), not a broken check.
|
|
127
|
-
if exc.code == 404:
|
|
128
|
-
payload = {"message": "Not Found"}
|
|
129
|
-
else:
|
|
130
|
-
print(f"error: the index answered {exc.code} for {name}", file=sys.stderr)
|
|
131
|
-
return 2
|
|
132
|
-
except (urllib.error.URLError, OSError) as exc:
|
|
133
|
-
print(f"error: cannot reach the index for {name}: {exc}", file=sys.stderr)
|
|
134
|
-
return 2
|
|
135
|
-
|
|
136
|
-
code, lines = check(payload, name, version)
|
|
137
|
-
print("\n".join(lines))
|
|
138
|
-
return code
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if __name__ == "__main__":
|
|
142
|
-
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|