model-mirror-cli 0.2.0__tar.gz → 0.2.1__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.
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/.github/workflows/release.yml +19 -0
- model_mirror_cli-0.2.1/CHANGELOG.md +33 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/PKG-INFO +18 -3
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/README.md +14 -2
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/RELEASING.md +7 -2
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/cli.py +100 -9
- model_mirror_cli-0.2.1/model_mirror/version_check.py +163 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/pyproject.toml +4 -1
- model_mirror_cli-0.2.1/scripts/changelog.py +39 -0
- model_mirror_cli-0.2.1/tests/test_changelog.py +39 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_cli.py +128 -1
- model_mirror_cli-0.2.1/tests/test_version_check.py +217 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/uv.lock +3 -1
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/.github/workflows/ci.yml +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/.gitignore +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/CONTRIBUTORS.md +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/LICENSE +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/docs/torrent-backend-spike.md +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/docs/torrent-distribution-requirements.md +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/__init__.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/audit.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/checksums.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/config.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/hub.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/lock.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/mirror.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/progress.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/removal.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/repair.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/state.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_coverage.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_hashes.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_import.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_publication.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_seed.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/verify.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/scripts/check_release.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/smoke_test.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_audit.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_checksums.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_config.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_hub.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_lock.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_mirror.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_progress.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_removal.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_repair.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_state.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_cli.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_coverage.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_fencing.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_hashes.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_hub_integration.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_import.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_publication.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_seed.py +0 -0
- {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_verify.py +0 -0
|
@@ -23,6 +23,7 @@ jobs:
|
|
|
23
23
|
- name: Validate release tag
|
|
24
24
|
run: |
|
|
25
25
|
python scripts/check_release.py "$GITHUB_REF_NAME"
|
|
26
|
+
python scripts/changelog.py "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
|
|
26
27
|
if [ "$(git cat-file -t "$GITHUB_REF_NAME")" != "tag" ]; then
|
|
27
28
|
echo "::error::release tag must be annotated"
|
|
28
29
|
exit 1
|
|
@@ -45,3 +46,21 @@ jobs:
|
|
|
45
46
|
run: uv run --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py
|
|
46
47
|
- name: Publish to PyPI
|
|
47
48
|
run: uv publish
|
|
49
|
+
|
|
50
|
+
github-release:
|
|
51
|
+
needs: publish
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
permissions:
|
|
54
|
+
contents: write
|
|
55
|
+
steps:
|
|
56
|
+
- name: Check out source
|
|
57
|
+
uses: actions/checkout@v7
|
|
58
|
+
- name: Create GitHub release from changelog
|
|
59
|
+
env:
|
|
60
|
+
GH_TOKEN: ${{ github.token }}
|
|
61
|
+
run: |
|
|
62
|
+
python scripts/changelog.py "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
|
|
63
|
+
gh release create "$GITHUB_REF_NAME" \
|
|
64
|
+
--verify-tag \
|
|
65
|
+
--title "$GITHUB_REF_NAME" \
|
|
66
|
+
--notes-file "$RUNNER_TEMP/release-notes.md"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable user-facing changes are recorded here. The release workflow copies the
|
|
4
|
+
matching version section into the corresponding GitHub Release.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.2.1] - 2026-07-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added `model-mirror version` and `model-mirror version --json` to compare the
|
|
13
|
+
installed distribution with PyPI and show published release notes when an
|
|
14
|
+
update is available.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- All repo-targeted commands now use the configured `repo_type` when
|
|
19
|
+
`--repo-type` is omitted. The initial configured default remains `model`.
|
|
20
|
+
- Archive-wide `list` and `status` still include every repo type, while torrent
|
|
21
|
+
imports continue to use the type embedded in the publication descriptor.
|
|
22
|
+
|
|
23
|
+
## [0.2.0] - 2026-07-28
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Initial public PyPI release of the `model-mirror-cli` distribution and
|
|
28
|
+
`model-mirror` command.
|
|
29
|
+
- Commit-pinned Hugging Face mirroring, verification, repair, explicit upstream
|
|
30
|
+
updates, offline-only preservation, fast metadata status, and safe resumable
|
|
31
|
+
removal.
|
|
32
|
+
- Experimental hybrid v1/v2 torrent publication, managed seeding, external
|
|
33
|
+
client handoff, torrent recovery/import, and reusable hash coverage.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model-mirror-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Mirror and validate Hugging Face archives without leaving payloads in the default cache.
|
|
5
5
|
Project-URL: Repository, https://github.com/xlr8harder/model-mirror
|
|
6
6
|
Project-URL: Issues, https://github.com/xlr8harder/model-mirror/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/xlr8harder/model-mirror/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Releases, https://github.com/xlr8harder/model-mirror/releases
|
|
7
9
|
Author: xlr8harder
|
|
8
10
|
License-Expression: MIT
|
|
9
11
|
License-File: LICENSE
|
|
@@ -19,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
19
21
|
Classifier: Topic :: System :: Archiving
|
|
20
22
|
Requires-Python: >=3.11
|
|
21
23
|
Requires-Dist: huggingface-hub>=1.0
|
|
24
|
+
Requires-Dist: packaging>=24.0
|
|
22
25
|
Requires-Dist: pyyaml>=6.0
|
|
23
26
|
Provides-Extra: torrent
|
|
24
27
|
Requires-Dist: libtorrent<2.1,>=2.0.10; extra == 'torrent'
|
|
@@ -70,8 +73,17 @@ Update and inspect an installed release with:
|
|
|
70
73
|
```bash
|
|
71
74
|
uv tool upgrade model-mirror-cli
|
|
72
75
|
model-mirror --version
|
|
76
|
+
model-mirror version
|
|
73
77
|
```
|
|
74
78
|
|
|
79
|
+
`--version` reports the installed version without network access. The `version`
|
|
80
|
+
command explicitly checks the latest PyPI release and, when the installation is
|
|
81
|
+
out of date, prints the published changes and
|
|
82
|
+
`uv tool upgrade model-mirror-cli`. Release notes come from the curated
|
|
83
|
+
[`CHANGELOG.md`](https://github.com/xlr8harder/model-mirror/blob/main/CHANGELOG.md)
|
|
84
|
+
through the corresponding GitHub Releases. The check does not run implicitly
|
|
85
|
+
from `status` or other archive operations.
|
|
86
|
+
|
|
75
87
|
For development, clone the
|
|
76
88
|
[source repository](https://github.com/xlr8harder/model-mirror), run
|
|
77
89
|
`uv sync --all-extras --dev`, and prefix commands with `uv run`. The rest of
|
|
@@ -412,14 +424,17 @@ model-mirror status org/model # concise last-known repository state
|
|
|
412
424
|
model-mirror status --verbose org/model # full recorded metadata
|
|
413
425
|
model-mirror status --check-upstream org/model # advisory live upstream comparison
|
|
414
426
|
model-mirror status --json # stable machine-readable status
|
|
427
|
+
model-mirror version # compare installed version with PyPI
|
|
415
428
|
model-mirror remove org/model # inspect, confirm, and permanently remove one mirror
|
|
416
429
|
model-mirror upgrade org/model # fill missing torrent hash coverage
|
|
417
430
|
model-mirror torrent publish org/model # publish and request durable seeding
|
|
418
431
|
model-mirror torrent join FILE_OR_MAGNET # recover a normal local archive
|
|
419
432
|
```
|
|
420
433
|
|
|
421
|
-
|
|
422
|
-
`--repo-type space
|
|
434
|
+
Omitting `--repo-type` uses the configured default, initially `model`, for every
|
|
435
|
+
repo-targeted command. Use `--repo-type dataset` or `--repo-type space` to
|
|
436
|
+
override it. Archive-wide `list` and `status` include all repo types; torrent
|
|
437
|
+
join/import operations read the repo type from the publication descriptor.
|
|
423
438
|
|
|
424
439
|
## Key Configuration
|
|
425
440
|
|
|
@@ -44,8 +44,17 @@ Update and inspect an installed release with:
|
|
|
44
44
|
```bash
|
|
45
45
|
uv tool upgrade model-mirror-cli
|
|
46
46
|
model-mirror --version
|
|
47
|
+
model-mirror version
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
`--version` reports the installed version without network access. The `version`
|
|
51
|
+
command explicitly checks the latest PyPI release and, when the installation is
|
|
52
|
+
out of date, prints the published changes and
|
|
53
|
+
`uv tool upgrade model-mirror-cli`. Release notes come from the curated
|
|
54
|
+
[`CHANGELOG.md`](https://github.com/xlr8harder/model-mirror/blob/main/CHANGELOG.md)
|
|
55
|
+
through the corresponding GitHub Releases. The check does not run implicitly
|
|
56
|
+
from `status` or other archive operations.
|
|
57
|
+
|
|
49
58
|
For development, clone the
|
|
50
59
|
[source repository](https://github.com/xlr8harder/model-mirror), run
|
|
51
60
|
`uv sync --all-extras --dev`, and prefix commands with `uv run`. The rest of
|
|
@@ -386,14 +395,17 @@ model-mirror status org/model # concise last-known repository state
|
|
|
386
395
|
model-mirror status --verbose org/model # full recorded metadata
|
|
387
396
|
model-mirror status --check-upstream org/model # advisory live upstream comparison
|
|
388
397
|
model-mirror status --json # stable machine-readable status
|
|
398
|
+
model-mirror version # compare installed version with PyPI
|
|
389
399
|
model-mirror remove org/model # inspect, confirm, and permanently remove one mirror
|
|
390
400
|
model-mirror upgrade org/model # fill missing torrent hash coverage
|
|
391
401
|
model-mirror torrent publish org/model # publish and request durable seeding
|
|
392
402
|
model-mirror torrent join FILE_OR_MAGNET # recover a normal local archive
|
|
393
403
|
```
|
|
394
404
|
|
|
395
|
-
|
|
396
|
-
`--repo-type space
|
|
405
|
+
Omitting `--repo-type` uses the configured default, initially `model`, for every
|
|
406
|
+
repo-targeted command. Use `--repo-type dataset` or `--repo-type space` to
|
|
407
|
+
override it. Archive-wide `list` and `status` include all repo types; torrent
|
|
408
|
+
join/import operations read the repo type from the publication descriptor.
|
|
397
409
|
|
|
398
410
|
## Key Configuration
|
|
399
411
|
|
|
@@ -29,8 +29,12 @@ Select the next version according to the change:
|
|
|
29
29
|
While the project is pre-1.0, substantial CLI or metadata compatibility changes
|
|
30
30
|
normally warrant a minor release.
|
|
31
31
|
|
|
32
|
+
Record user-facing work under `[Unreleased]` in `CHANGELOG.md`. Before release,
|
|
33
|
+
move those entries into a dated section matching the new version. The release
|
|
34
|
+
workflow rejects a tag with no matching, non-empty changelog section.
|
|
35
|
+
|
|
32
36
|
```bash
|
|
33
|
-
RELEASE_VERSION=0.2.
|
|
37
|
+
RELEASE_VERSION=0.2.2 # example; choose the appropriate next version
|
|
34
38
|
uv version "$RELEASE_VERSION"
|
|
35
39
|
uv run coverage run -m pytest -q
|
|
36
40
|
uv run coverage report -m
|
|
@@ -49,7 +53,8 @@ The tag must exactly equal `v` plus the version in `pyproject.toml`, must be
|
|
|
49
53
|
annotated, and must point to a commit already on `main`. Release tags are
|
|
50
54
|
protected from rewriting or deletion after publication. The GitHub workflow
|
|
51
55
|
repeats the full test, build, and smoke-test sequence before publishing the
|
|
52
|
-
wheel and source distribution through PyPI trusted publishing
|
|
56
|
+
wheel and source distribution through PyPI trusted publishing, then creates a
|
|
57
|
+
GitHub Release from the matching `CHANGELOG.md` section.
|
|
53
58
|
|
|
54
59
|
## User installation and updates
|
|
55
60
|
|
|
@@ -52,6 +52,7 @@ from .state import (
|
|
|
52
52
|
write_verification_state,
|
|
53
53
|
)
|
|
54
54
|
from .verify import RemoteVerifyResult, merge_checksum_result, verify_remote
|
|
55
|
+
from .version_check import RELEASE_URL_TEMPLATE, UPDATE_COMMAND, VersionCheck, check_version
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
TORRENT_EXPERIMENTAL_NOTICE = (
|
|
@@ -185,7 +186,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
185
186
|
epilog="Exit status: 0 when complete or downloaded cleanly; 1 when final verification is not clean.",
|
|
186
187
|
)
|
|
187
188
|
mirror_parser.add_argument("model", metavar="repo", help="Hugging Face repo id, e.g. org/model")
|
|
188
|
-
mirror_parser.add_argument(
|
|
189
|
+
mirror_parser.add_argument(
|
|
190
|
+
"--repo-type",
|
|
191
|
+
choices=["model", "dataset", "space"],
|
|
192
|
+
help="repo kind to mirror; defaults to configured repo_type",
|
|
193
|
+
)
|
|
189
194
|
add_revision_options(mirror_parser)
|
|
190
195
|
mirror_parser.add_argument("--force", action="store_true", help="download even if the local copy looks complete")
|
|
191
196
|
mirror_parser.add_argument("--no-verify", action="store_true", help="skip verification after download")
|
|
@@ -210,7 +215,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
210
215
|
)
|
|
211
216
|
remove_parser.add_argument("model", metavar="repo", help="repo id to remove, e.g. org/model")
|
|
212
217
|
remove_parser.add_argument(
|
|
213
|
-
"--repo-type",
|
|
218
|
+
"--repo-type",
|
|
219
|
+
choices=["model", "dataset", "space"],
|
|
220
|
+
help="repo kind to remove; defaults to configured repo_type",
|
|
214
221
|
)
|
|
215
222
|
remove_parser.add_argument(
|
|
216
223
|
"-y",
|
|
@@ -233,7 +240,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
233
240
|
verify_parser.add_argument("--cached", action="store_true", help="verify Hub hashes from cached .manifest rows")
|
|
234
241
|
verify_parser.add_argument("--all", action="store_true", help="verify every mirrored model")
|
|
235
242
|
verify_parser.add_argument(
|
|
236
|
-
"--repo-type",
|
|
243
|
+
"--repo-type",
|
|
244
|
+
choices=["model", "dataset", "space"],
|
|
245
|
+
help="repo kind to verify; defaults to configured repo_type",
|
|
237
246
|
)
|
|
238
247
|
add_revision_options(verify_parser)
|
|
239
248
|
verify_parser.add_argument("--strict", action="store_true", help="fail on extra local files")
|
|
@@ -269,7 +278,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
269
278
|
),
|
|
270
279
|
)
|
|
271
280
|
repair_parser.add_argument(
|
|
272
|
-
"--repo-type",
|
|
281
|
+
"--repo-type",
|
|
282
|
+
choices=["model", "dataset", "space"],
|
|
283
|
+
help="repo kind to repair; defaults to configured repo_type",
|
|
273
284
|
)
|
|
274
285
|
|
|
275
286
|
upgrade_parser = add_command_parser(
|
|
@@ -286,7 +297,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
286
297
|
upgrade_parser.add_argument("--all", action="store_true", help="upgrade every mirrored repository of this type")
|
|
287
298
|
upgrade_parser.add_argument("--dry-run", action="store_true", help="report required reads without changing metadata")
|
|
288
299
|
upgrade_parser.add_argument(
|
|
289
|
-
"--repo-type",
|
|
300
|
+
"--repo-type",
|
|
301
|
+
choices=["model", "dataset", "space"],
|
|
302
|
+
help="repo kind to upgrade; defaults to configured repo_type",
|
|
290
303
|
)
|
|
291
304
|
|
|
292
305
|
torrent_parser = add_command_parser(
|
|
@@ -309,8 +322,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
309
322
|
command.add_argument(
|
|
310
323
|
"--repo-type",
|
|
311
324
|
choices=["model", "dataset", "space"],
|
|
312
|
-
|
|
313
|
-
help="repo kind",
|
|
325
|
+
help="repo kind; defaults to configured repo_type",
|
|
314
326
|
)
|
|
315
327
|
return command
|
|
316
328
|
|
|
@@ -410,7 +422,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
410
422
|
)
|
|
411
423
|
offline_parser.add_argument("model", metavar="repo", help="repo id to mark offline-only")
|
|
412
424
|
offline_parser.add_argument(
|
|
413
|
-
"--repo-type",
|
|
425
|
+
"--repo-type",
|
|
426
|
+
choices=["model", "dataset", "space"],
|
|
427
|
+
help="repo kind to update; defaults to configured repo_type",
|
|
414
428
|
)
|
|
415
429
|
|
|
416
430
|
online_parser = add_command_parser(
|
|
@@ -421,7 +435,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
421
435
|
)
|
|
422
436
|
online_parser.add_argument("model", metavar="repo", help="repo id to mark online")
|
|
423
437
|
online_parser.add_argument(
|
|
424
|
-
"--repo-type",
|
|
438
|
+
"--repo-type",
|
|
439
|
+
choices=["model", "dataset", "space"],
|
|
440
|
+
help="repo kind to update; defaults to configured repo_type",
|
|
425
441
|
)
|
|
426
442
|
|
|
427
443
|
list_parser = add_command_parser(
|
|
@@ -475,6 +491,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
475
491
|
)
|
|
476
492
|
clean_cache_parser.add_argument("--force", action="store_true", help="delete cache and temporary files")
|
|
477
493
|
|
|
494
|
+
version_parser = add_command_parser(
|
|
495
|
+
"version",
|
|
496
|
+
help="compare the installed version with the latest PyPI release",
|
|
497
|
+
description=(
|
|
498
|
+
"Query PyPI for the latest model-mirror-cli release and compare it with "
|
|
499
|
+
"the installed version. Use --version for an offline installed-version check."
|
|
500
|
+
),
|
|
501
|
+
epilog="Exit status: 0 when PyPI was checked successfully; 1 when the check was unavailable.",
|
|
502
|
+
)
|
|
503
|
+
version_parser.add_argument("--json", action="store_true", dest="json_output", help="emit structured JSON")
|
|
504
|
+
|
|
478
505
|
config_parser = add_command_parser(
|
|
479
506
|
"config",
|
|
480
507
|
help="show or change configuration",
|
|
@@ -526,8 +553,11 @@ def main(argv: list[str] | None = None, *, hub=None) -> int:
|
|
|
526
553
|
return 0
|
|
527
554
|
if args.command == "help":
|
|
528
555
|
return handle_help(parser, args.topic)
|
|
556
|
+
if args.command == "version":
|
|
557
|
+
return handle_version(json_output=args.json_output)
|
|
529
558
|
config_path = Path(args.config).expanduser() if args.config else None
|
|
530
559
|
config = load_config(config_path)
|
|
560
|
+
apply_configured_repo_type(args, config)
|
|
531
561
|
|
|
532
562
|
if should_supervise_mirror(args, config, hub):
|
|
533
563
|
return run_supervised_mirror(raw_argv, args, config)
|
|
@@ -583,6 +613,67 @@ def main(argv: list[str] | None = None, *, hub=None) -> int:
|
|
|
583
613
|
return 2
|
|
584
614
|
|
|
585
615
|
|
|
616
|
+
def apply_configured_repo_type(args, config: Config) -> None:
|
|
617
|
+
if args.command in {"list", "status"}:
|
|
618
|
+
return
|
|
619
|
+
if hasattr(args, "repo_type") and args.repo_type is None:
|
|
620
|
+
args.repo_type = config.repo_type
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def handle_version(*, json_output: bool) -> int:
|
|
624
|
+
result = check_version(__version__)
|
|
625
|
+
update_command = UPDATE_COMMAND if result.status == "out-of-date" else None
|
|
626
|
+
release_url = (
|
|
627
|
+
RELEASE_URL_TEMPLATE.format(version=result.latest_version)
|
|
628
|
+
if result.status == "out-of-date"
|
|
629
|
+
else None
|
|
630
|
+
)
|
|
631
|
+
if json_output:
|
|
632
|
+
print(
|
|
633
|
+
json.dumps(
|
|
634
|
+
{
|
|
635
|
+
"schema": "model-mirror-version",
|
|
636
|
+
"version": 1,
|
|
637
|
+
"installed_version": result.installed_version,
|
|
638
|
+
"latest_version": result.latest_version,
|
|
639
|
+
"status": result.status,
|
|
640
|
+
"update_command": update_command,
|
|
641
|
+
"error": result.error,
|
|
642
|
+
"release_url": release_url,
|
|
643
|
+
"releases": [
|
|
644
|
+
{
|
|
645
|
+
"version": release.version,
|
|
646
|
+
"url": release.url,
|
|
647
|
+
"notes": release.notes,
|
|
648
|
+
}
|
|
649
|
+
for release in result.releases
|
|
650
|
+
],
|
|
651
|
+
"release_notes_error": result.release_notes_error,
|
|
652
|
+
},
|
|
653
|
+
sort_keys=True,
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
else:
|
|
657
|
+
print(f"installed: {result.installed_version}")
|
|
658
|
+
print(f"latest: {result.latest_version or 'unavailable'}")
|
|
659
|
+
print(f"status: {result.status}")
|
|
660
|
+
if update_command:
|
|
661
|
+
print(f"update: {update_command}")
|
|
662
|
+
if result.releases:
|
|
663
|
+
print("changes:")
|
|
664
|
+
for release in result.releases:
|
|
665
|
+
print(f"\n{release.version}: {release.url}")
|
|
666
|
+
if release.notes:
|
|
667
|
+
print(release.notes)
|
|
668
|
+
elif release_url:
|
|
669
|
+
print(f"changes: {release_url}")
|
|
670
|
+
if result.release_notes_error:
|
|
671
|
+
print(f"changes_error: {result.release_notes_error}")
|
|
672
|
+
if result.error:
|
|
673
|
+
print(f"error: {result.error}")
|
|
674
|
+
return 1 if result.status == "unavailable" else 0
|
|
675
|
+
|
|
676
|
+
|
|
586
677
|
def should_supervise_mirror(args, config: Config, hub) -> bool:
|
|
587
678
|
if args.command != "mirror":
|
|
588
679
|
return False
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from urllib.request import Request, urlopen
|
|
6
|
+
|
|
7
|
+
from packaging.version import InvalidVersion, Version
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DISTRIBUTION_NAME = "model-mirror-cli"
|
|
11
|
+
PYPI_JSON_URL = f"https://pypi.org/pypi/{DISTRIBUTION_NAME}/json"
|
|
12
|
+
GITHUB_RELEASES_URL = "https://api.github.com/repos/xlr8harder/model-mirror/releases"
|
|
13
|
+
RELEASE_URL_TEMPLATE = "https://github.com/xlr8harder/model-mirror/releases/tag/v{version}"
|
|
14
|
+
UPDATE_COMMAND = f"uv tool upgrade {DISTRIBUTION_NAME}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VersionCheckError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ReleaseNote:
|
|
23
|
+
version: str
|
|
24
|
+
url: str
|
|
25
|
+
notes: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class VersionCheck:
|
|
30
|
+
installed_version: str
|
|
31
|
+
latest_version: str | None
|
|
32
|
+
status: str
|
|
33
|
+
error: str | None = None
|
|
34
|
+
releases: tuple[ReleaseNote, ...] = ()
|
|
35
|
+
release_notes_error: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fetch_latest_version(*, timeout: float = 5.0) -> str:
|
|
39
|
+
document = _get_json(
|
|
40
|
+
PYPI_JSON_URL,
|
|
41
|
+
source="PyPI",
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
latest_version = document["info"]["version"]
|
|
46
|
+
except (KeyError, TypeError) as exc:
|
|
47
|
+
raise VersionCheckError("PyPI response did not contain info.version") from exc
|
|
48
|
+
_parse_version(latest_version, "PyPI")
|
|
49
|
+
return latest_version
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch_release_notes(
|
|
53
|
+
installed_version: str,
|
|
54
|
+
latest_version: str,
|
|
55
|
+
*,
|
|
56
|
+
timeout: float = 5.0,
|
|
57
|
+
) -> tuple[ReleaseNote, ...]:
|
|
58
|
+
installed = _parse_version(installed_version, "installed")
|
|
59
|
+
latest = _parse_version(latest_version, "PyPI")
|
|
60
|
+
releases: list[tuple[Version, ReleaseNote]] = []
|
|
61
|
+
page = 1
|
|
62
|
+
while True:
|
|
63
|
+
document = _get_json(
|
|
64
|
+
f"{GITHUB_RELEASES_URL}?per_page=100&page={page}",
|
|
65
|
+
source="GitHub releases",
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
if not isinstance(document, list):
|
|
69
|
+
raise VersionCheckError("GitHub releases response was not a list")
|
|
70
|
+
for item in document:
|
|
71
|
+
if not isinstance(item, dict) or item.get("draft") or item.get("prerelease"):
|
|
72
|
+
continue
|
|
73
|
+
tag = item.get("tag_name")
|
|
74
|
+
if not isinstance(tag, str) or not tag.startswith("v"):
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
release_version = _parse_version(tag[1:], "GitHub release")
|
|
78
|
+
except VersionCheckError:
|
|
79
|
+
continue
|
|
80
|
+
if not installed < release_version <= latest:
|
|
81
|
+
continue
|
|
82
|
+
url = item.get("html_url")
|
|
83
|
+
if not isinstance(url, str) or not url:
|
|
84
|
+
url = RELEASE_URL_TEMPLATE.format(version=tag[1:])
|
|
85
|
+
notes = item.get("body")
|
|
86
|
+
releases.append(
|
|
87
|
+
(
|
|
88
|
+
release_version,
|
|
89
|
+
ReleaseNote(
|
|
90
|
+
version=tag[1:],
|
|
91
|
+
url=url,
|
|
92
|
+
notes=notes.strip() if isinstance(notes, str) else "",
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
if len(document) < 100:
|
|
97
|
+
break
|
|
98
|
+
page += 1
|
|
99
|
+
releases.sort(key=lambda entry: entry[0])
|
|
100
|
+
return tuple(note for _, note in releases)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_version(
|
|
104
|
+
installed_version: str,
|
|
105
|
+
*,
|
|
106
|
+
latest_version_provider=fetch_latest_version,
|
|
107
|
+
release_notes_provider=fetch_release_notes,
|
|
108
|
+
) -> VersionCheck:
|
|
109
|
+
try:
|
|
110
|
+
installed = _parse_version(installed_version, "installed")
|
|
111
|
+
latest_version = latest_version_provider()
|
|
112
|
+
latest = _parse_version(latest_version, "PyPI")
|
|
113
|
+
except (OSError, ValueError, VersionCheckError) as exc:
|
|
114
|
+
return VersionCheck(
|
|
115
|
+
installed_version=installed_version,
|
|
116
|
+
latest_version=None,
|
|
117
|
+
status="unavailable",
|
|
118
|
+
error=str(exc),
|
|
119
|
+
)
|
|
120
|
+
if installed < latest:
|
|
121
|
+
status = "out-of-date"
|
|
122
|
+
elif installed > latest:
|
|
123
|
+
status = "ahead"
|
|
124
|
+
else:
|
|
125
|
+
status = "current"
|
|
126
|
+
releases: tuple[ReleaseNote, ...] = ()
|
|
127
|
+
release_notes_error = None
|
|
128
|
+
if status == "out-of-date":
|
|
129
|
+
try:
|
|
130
|
+
releases = release_notes_provider(installed_version, latest_version)
|
|
131
|
+
except (OSError, ValueError, VersionCheckError) as exc:
|
|
132
|
+
release_notes_error = str(exc)
|
|
133
|
+
return VersionCheck(
|
|
134
|
+
installed_version=installed_version,
|
|
135
|
+
latest_version=latest_version,
|
|
136
|
+
status=status,
|
|
137
|
+
releases=releases,
|
|
138
|
+
release_notes_error=release_notes_error,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _get_json(url: str, *, source: str, timeout: float):
|
|
143
|
+
request = Request(
|
|
144
|
+
url,
|
|
145
|
+
headers={
|
|
146
|
+
"Accept": "application/json",
|
|
147
|
+
"User-Agent": f"{DISTRIBUTION_NAME}-version-check",
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
with urlopen(request, timeout=timeout) as response:
|
|
152
|
+
return json.load(response)
|
|
153
|
+
except (OSError, ValueError) as exc:
|
|
154
|
+
raise VersionCheckError(f"{source} request failed: {exc}") from exc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_version(value: object, source: str) -> Version:
|
|
158
|
+
if not isinstance(value, str) or not value:
|
|
159
|
+
raise VersionCheckError(f"{source} version is missing or invalid")
|
|
160
|
+
try:
|
|
161
|
+
return Version(value)
|
|
162
|
+
except InvalidVersion as exc:
|
|
163
|
+
raise VersionCheckError(f"{source} version is invalid: {value!r}") from exc
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "model-mirror-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Mirror and validate Hugging Face archives without leaving payloads in the default cache."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -27,6 +27,7 @@ classifiers = [
|
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
29
|
"huggingface_hub>=1.0",
|
|
30
|
+
"packaging>=24.0",
|
|
30
31
|
"PyYAML>=6.0",
|
|
31
32
|
]
|
|
32
33
|
|
|
@@ -48,6 +49,8 @@ model-mirror = "model_mirror.cli:main"
|
|
|
48
49
|
[project.urls]
|
|
49
50
|
Repository = "https://github.com/xlr8harder/model-mirror"
|
|
50
51
|
Issues = "https://github.com/xlr8harder/model-mirror/issues"
|
|
52
|
+
Changelog = "https://github.com/xlr8harder/model-mirror/blob/main/CHANGELOG.md"
|
|
53
|
+
Releases = "https://github.com/xlr8harder/model-mirror/releases"
|
|
51
54
|
|
|
52
55
|
[tool.hatch.build.targets.wheel]
|
|
53
56
|
packages = ["model_mirror"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
HEADING = re.compile(r"^## \[([^\]]+)\](?: - .+)?$", re.MULTILINE)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def release_notes(document: str, version: str) -> str:
|
|
12
|
+
selected = version.removeprefix("v")
|
|
13
|
+
headings = list(HEADING.finditer(document))
|
|
14
|
+
for index, heading in enumerate(headings):
|
|
15
|
+
if heading.group(1) != selected:
|
|
16
|
+
continue
|
|
17
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(document)
|
|
18
|
+
notes = document[heading.end() : end].strip()
|
|
19
|
+
if not notes:
|
|
20
|
+
raise ValueError(f"changelog section {selected!r} is empty")
|
|
21
|
+
return notes
|
|
22
|
+
raise ValueError(f"changelog section {selected!r} was not found")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
parser = argparse.ArgumentParser(description="Print release notes from CHANGELOG.md.")
|
|
27
|
+
parser.add_argument("version", help="package version or release tag, for example 0.2.1 or v0.2.1")
|
|
28
|
+
parser.add_argument("--changelog", type=Path, default=Path("CHANGELOG.md"))
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
try:
|
|
31
|
+
notes = release_notes(args.changelog.read_text(encoding="utf-8"), args.version)
|
|
32
|
+
except (OSError, ValueError) as exc:
|
|
33
|
+
parser.error(str(exc))
|
|
34
|
+
print(notes)
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from scripts.changelog import release_notes
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_release_notes_extracts_exact_version_section():
|
|
7
|
+
document = """# Changelog
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
Pending.
|
|
12
|
+
|
|
13
|
+
## [1.1.0] - 2026-07-28
|
|
14
|
+
|
|
15
|
+
First line.
|
|
16
|
+
|
|
17
|
+
Second line.
|
|
18
|
+
|
|
19
|
+
## [1.0.0] - 2026-07-27
|
|
20
|
+
|
|
21
|
+
Old.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
assert release_notes(document, "v1.1.0") == "First line.\n\nSecond line."
|
|
25
|
+
assert release_notes(document, "1.0.0") == "Old."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_release_notes_rejects_missing_or_empty_sections():
|
|
29
|
+
document = """## [Unreleased]
|
|
30
|
+
|
|
31
|
+
## [1.0.0] - 2026-07-27
|
|
32
|
+
|
|
33
|
+
Notes.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
with pytest.raises(ValueError, match="section '1.1.0' was not found"):
|
|
37
|
+
release_notes(document, "1.1.0")
|
|
38
|
+
with pytest.raises(ValueError, match="section 'Unreleased' is empty"):
|
|
39
|
+
release_notes(document, "Unreleased")
|
|
@@ -29,6 +29,7 @@ from model_mirror.hub import HubFile, HubSnapshot, write_snapshot_plan
|
|
|
29
29
|
from model_mirror.lock import ModelLock
|
|
30
30
|
from model_mirror.progress import ProgressEntry, ProgressSnapshot, progress_path
|
|
31
31
|
from model_mirror.state import VerificationState, read_verification_state, write_verification_state
|
|
32
|
+
from model_mirror.version_check import ReleaseNote, VersionCheck
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
@dataclass
|
|
@@ -371,7 +372,133 @@ def test_version_uses_package_metadata(capsys):
|
|
|
371
372
|
main(["--version"])
|
|
372
373
|
|
|
373
374
|
assert exc.value.code == 0
|
|
374
|
-
assert capsys.readouterr().out == "model-mirror
|
|
375
|
+
assert capsys.readouterr().out == f"model-mirror {cli_module.__version__}\n"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@pytest.mark.parametrize(
|
|
379
|
+
"result,expected_rc,expected",
|
|
380
|
+
[
|
|
381
|
+
(
|
|
382
|
+
VersionCheck("1.0.0", "1.0.0", "current"),
|
|
383
|
+
0,
|
|
384
|
+
["installed: 1.0.0", "latest: 1.0.0", "status: current"],
|
|
385
|
+
),
|
|
386
|
+
(
|
|
387
|
+
VersionCheck("1.0.0", "1.0.1", "out-of-date"),
|
|
388
|
+
0,
|
|
389
|
+
[
|
|
390
|
+
"status: out-of-date",
|
|
391
|
+
"update: uv tool upgrade model-mirror-cli",
|
|
392
|
+
],
|
|
393
|
+
),
|
|
394
|
+
(
|
|
395
|
+
VersionCheck("1.1.0", "1.0.1", "ahead"),
|
|
396
|
+
0,
|
|
397
|
+
["status: ahead"],
|
|
398
|
+
),
|
|
399
|
+
(
|
|
400
|
+
VersionCheck("1.0.0", None, "unavailable", "offline"),
|
|
401
|
+
1,
|
|
402
|
+
["latest: unavailable", "status: unavailable", "error: offline"],
|
|
403
|
+
),
|
|
404
|
+
],
|
|
405
|
+
)
|
|
406
|
+
def test_version_command_reports_pypi_comparison(monkeypatch, capsys, result, expected_rc, expected):
|
|
407
|
+
monkeypatch.setattr(cli_module, "check_version", lambda installed: result)
|
|
408
|
+
monkeypatch.setattr(cli_module, "load_config", lambda path: pytest.fail("version must not load archive config"))
|
|
409
|
+
|
|
410
|
+
assert main(["version"]) == expected_rc
|
|
411
|
+
|
|
412
|
+
output = capsys.readouterr().out
|
|
413
|
+
assert all(item in output for item in expected)
|
|
414
|
+
assert ("update:" in output) == (result.status == "out-of-date")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_version_command_supports_json(monkeypatch, capsys):
|
|
418
|
+
monkeypatch.setattr(
|
|
419
|
+
cli_module,
|
|
420
|
+
"check_version",
|
|
421
|
+
lambda installed: VersionCheck(
|
|
422
|
+
"1.0.0",
|
|
423
|
+
"1.0.2",
|
|
424
|
+
"out-of-date",
|
|
425
|
+
releases=(
|
|
426
|
+
ReleaseNote("1.0.1", "https://example/1.0.1", "first"),
|
|
427
|
+
ReleaseNote("1.0.2", "https://example/1.0.2", "second"),
|
|
428
|
+
),
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
assert main(["version", "--json"]) == 0
|
|
433
|
+
|
|
434
|
+
assert json.loads(capsys.readouterr().out) == {
|
|
435
|
+
"schema": "model-mirror-version",
|
|
436
|
+
"version": 1,
|
|
437
|
+
"installed_version": "1.0.0",
|
|
438
|
+
"latest_version": "1.0.2",
|
|
439
|
+
"status": "out-of-date",
|
|
440
|
+
"update_command": "uv tool upgrade model-mirror-cli",
|
|
441
|
+
"error": None,
|
|
442
|
+
"release_url": "https://github.com/xlr8harder/model-mirror/releases/tag/v1.0.2",
|
|
443
|
+
"releases": [
|
|
444
|
+
{"version": "1.0.1", "url": "https://example/1.0.1", "notes": "first"},
|
|
445
|
+
{"version": "1.0.2", "url": "https://example/1.0.2", "notes": "second"},
|
|
446
|
+
],
|
|
447
|
+
"release_notes_error": None,
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_version_command_prints_all_pending_release_notes(monkeypatch, capsys):
|
|
452
|
+
monkeypatch.setattr(
|
|
453
|
+
cli_module,
|
|
454
|
+
"check_version",
|
|
455
|
+
lambda installed: VersionCheck(
|
|
456
|
+
"1.0.0",
|
|
457
|
+
"1.0.2",
|
|
458
|
+
"out-of-date",
|
|
459
|
+
releases=(
|
|
460
|
+
ReleaseNote("1.0.1", "https://example/1.0.1", "first"),
|
|
461
|
+
ReleaseNote("1.0.2", "https://example/1.0.2", ""),
|
|
462
|
+
),
|
|
463
|
+
release_notes_error="one release had no notes",
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
assert main(["version"]) == 0
|
|
468
|
+
|
|
469
|
+
output = capsys.readouterr().out
|
|
470
|
+
assert output.index("1.0.1:") < output.index("1.0.2:")
|
|
471
|
+
assert "first" in output
|
|
472
|
+
assert "changes_error: one release had no notes" in output
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_repo_commands_use_configured_repo_type_by_default():
|
|
476
|
+
parser = cli_module.build_parser()
|
|
477
|
+
config = Config(repo_type="dataset")
|
|
478
|
+
repo_commands = [
|
|
479
|
+
["mirror", "org/data"],
|
|
480
|
+
["remove", "org/data"],
|
|
481
|
+
["verify", "org/data"],
|
|
482
|
+
["repair", "org/data"],
|
|
483
|
+
["upgrade", "org/data"],
|
|
484
|
+
["torrent", "publish", "org/data"],
|
|
485
|
+
["offline", "org/data"],
|
|
486
|
+
["online", "org/data"],
|
|
487
|
+
]
|
|
488
|
+
|
|
489
|
+
for command in repo_commands:
|
|
490
|
+
args = parser.parse_args(command)
|
|
491
|
+
assert args.repo_type is None
|
|
492
|
+
cli_module.apply_configured_repo_type(args, config)
|
|
493
|
+
assert args.repo_type == "dataset"
|
|
494
|
+
|
|
495
|
+
explicit = parser.parse_args(["verify", "--repo-type", "space", "org/app"])
|
|
496
|
+
cli_module.apply_configured_repo_type(explicit, config)
|
|
497
|
+
assert explicit.repo_type == "space"
|
|
498
|
+
|
|
499
|
+
archive_status = parser.parse_args(["status"])
|
|
500
|
+
cli_module.apply_configured_repo_type(archive_status, config)
|
|
501
|
+
assert archive_status.repo_type is None
|
|
375
502
|
|
|
376
503
|
|
|
377
504
|
def test_help_command_prints_full_help(capsys):
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import model_mirror.version_check as version_module
|
|
9
|
+
from model_mirror.version_check import (
|
|
10
|
+
ReleaseNote,
|
|
11
|
+
VersionCheckError,
|
|
12
|
+
check_version,
|
|
13
|
+
fetch_latest_version,
|
|
14
|
+
fetch_release_notes,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_fetch_latest_version_uses_pypi_json_api(monkeypatch):
|
|
19
|
+
captured = {}
|
|
20
|
+
|
|
21
|
+
def fake_urlopen(request, timeout):
|
|
22
|
+
captured["url"] = request.full_url
|
|
23
|
+
captured["accept"] = request.get_header("Accept")
|
|
24
|
+
captured["user_agent"] = request.get_header("User-agent")
|
|
25
|
+
captured["timeout"] = timeout
|
|
26
|
+
return BytesIO(json.dumps({"info": {"version": "1.2.3"}}).encode())
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr(version_module, "urlopen", fake_urlopen)
|
|
29
|
+
|
|
30
|
+
assert fetch_latest_version(timeout=2.5) == "1.2.3"
|
|
31
|
+
assert captured == {
|
|
32
|
+
"url": "https://pypi.org/pypi/model-mirror-cli/json",
|
|
33
|
+
"accept": "application/json",
|
|
34
|
+
"user_agent": "model-mirror-cli-version-check",
|
|
35
|
+
"timeout": 2.5,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_fetch_latest_version_wraps_request_errors(monkeypatch):
|
|
40
|
+
def fail_urlopen(request, timeout):
|
|
41
|
+
raise OSError("offline")
|
|
42
|
+
|
|
43
|
+
monkeypatch.setattr(version_module, "urlopen", fail_urlopen)
|
|
44
|
+
|
|
45
|
+
with pytest.raises(VersionCheckError, match="PyPI request failed: offline"):
|
|
46
|
+
fetch_latest_version()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize(
|
|
50
|
+
"document,error",
|
|
51
|
+
[
|
|
52
|
+
({}, "did not contain info.version"),
|
|
53
|
+
(None, "did not contain info.version"),
|
|
54
|
+
({"info": {"version": ""}}, "version is missing or invalid"),
|
|
55
|
+
({"info": {"version": "not a version"}}, "version is invalid"),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
def test_fetch_latest_version_rejects_invalid_responses(monkeypatch, document, error):
|
|
59
|
+
monkeypatch.setattr(
|
|
60
|
+
version_module,
|
|
61
|
+
"urlopen",
|
|
62
|
+
lambda request, timeout: BytesIO(json.dumps(document).encode()),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
with pytest.raises(VersionCheckError, match=error):
|
|
66
|
+
fetch_latest_version()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_fetch_release_notes_returns_every_published_version_in_range(monkeypatch):
|
|
70
|
+
document = [
|
|
71
|
+
{"tag_name": "v1.3.0", "html_url": "https://example/1.3.0", "body": " third "},
|
|
72
|
+
{"tag_name": "v1.1.0", "html_url": "https://example/1.1.0", "body": "first"},
|
|
73
|
+
{"tag_name": "v1.2.0", "html_url": "", "body": None},
|
|
74
|
+
{"tag_name": "v1.0.0", "body": "installed"},
|
|
75
|
+
{"tag_name": "v2.0.0", "body": "future"},
|
|
76
|
+
{"tag_name": "v1.2.1", "body": "draft", "draft": True},
|
|
77
|
+
{"tag_name": "v1.2.2", "body": "prerelease", "prerelease": True},
|
|
78
|
+
{"tag_name": "invalid", "body": "not a version"},
|
|
79
|
+
{"tag_name": "v-invalid", "body": "not a version"},
|
|
80
|
+
"not a release",
|
|
81
|
+
]
|
|
82
|
+
captured = {}
|
|
83
|
+
|
|
84
|
+
def fake_urlopen(request, timeout):
|
|
85
|
+
captured["url"] = request.full_url
|
|
86
|
+
captured["timeout"] = timeout
|
|
87
|
+
return BytesIO(json.dumps(document).encode())
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(version_module, "urlopen", fake_urlopen)
|
|
90
|
+
|
|
91
|
+
assert fetch_release_notes("1.0.0", "1.3.0", timeout=3.0) == (
|
|
92
|
+
ReleaseNote("1.1.0", "https://example/1.1.0", "first"),
|
|
93
|
+
ReleaseNote(
|
|
94
|
+
"1.2.0",
|
|
95
|
+
"https://github.com/xlr8harder/model-mirror/releases/tag/v1.2.0",
|
|
96
|
+
"",
|
|
97
|
+
),
|
|
98
|
+
ReleaseNote("1.3.0", "https://example/1.3.0", "third"),
|
|
99
|
+
)
|
|
100
|
+
assert captured == {
|
|
101
|
+
"url": "https://api.github.com/repos/xlr8harder/model-mirror/releases?per_page=100&page=1",
|
|
102
|
+
"timeout": 3.0,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_fetch_release_notes_paginates(monkeypatch):
|
|
107
|
+
pages = [
|
|
108
|
+
[{"tag_name": "not-a-release"}] * 100,
|
|
109
|
+
[{"tag_name": "v1.1.0", "html_url": "https://example/1.1.0", "body": "notes"}],
|
|
110
|
+
]
|
|
111
|
+
urls = []
|
|
112
|
+
|
|
113
|
+
def fake_urlopen(request, timeout):
|
|
114
|
+
urls.append(request.full_url)
|
|
115
|
+
return BytesIO(json.dumps(pages.pop(0)).encode())
|
|
116
|
+
|
|
117
|
+
monkeypatch.setattr(version_module, "urlopen", fake_urlopen)
|
|
118
|
+
|
|
119
|
+
assert fetch_release_notes("1.0.0", "1.1.0") == (
|
|
120
|
+
ReleaseNote("1.1.0", "https://example/1.1.0", "notes"),
|
|
121
|
+
)
|
|
122
|
+
assert urls == [
|
|
123
|
+
"https://api.github.com/repos/xlr8harder/model-mirror/releases?per_page=100&page=1",
|
|
124
|
+
"https://api.github.com/repos/xlr8harder/model-mirror/releases?per_page=100&page=2",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_fetch_release_notes_rejects_non_list_response(monkeypatch):
|
|
129
|
+
monkeypatch.setattr(
|
|
130
|
+
version_module,
|
|
131
|
+
"urlopen",
|
|
132
|
+
lambda request, timeout: BytesIO(b"{}"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
with pytest.raises(VersionCheckError, match="response was not a list"):
|
|
136
|
+
fetch_release_notes("1.0.0", "1.1.0")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"installed,latest,status",
|
|
141
|
+
[
|
|
142
|
+
("1.0.0", "1.0.0", "current"),
|
|
143
|
+
("1.0.0", "1.0.1", "out-of-date"),
|
|
144
|
+
("1.1.0", "1.0.1", "ahead"),
|
|
145
|
+
],
|
|
146
|
+
)
|
|
147
|
+
def test_check_version_compares_pep_440_versions(installed, latest, status):
|
|
148
|
+
result = check_version(
|
|
149
|
+
installed,
|
|
150
|
+
latest_version_provider=lambda: latest,
|
|
151
|
+
release_notes_provider=lambda installed, latest: (),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
assert result.installed_version == installed
|
|
155
|
+
assert result.latest_version == latest
|
|
156
|
+
assert result.status == status
|
|
157
|
+
assert result.error is None
|
|
158
|
+
assert result.releases == ()
|
|
159
|
+
assert result.release_notes_error is None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.parametrize(
|
|
163
|
+
"installed,provider,error",
|
|
164
|
+
[
|
|
165
|
+
("invalid", lambda: "1.0.0", "installed version is invalid"),
|
|
166
|
+
("1.0.0", lambda: None, "PyPI version is missing or invalid"),
|
|
167
|
+
("1.0.0", lambda: (_ for _ in ()).throw(OSError("offline")), "offline"),
|
|
168
|
+
("1.0.0", lambda: (_ for _ in ()).throw(ValueError("bad response")), "bad response"),
|
|
169
|
+
("1.0.0", lambda: (_ for _ in ()).throw(VersionCheckError("bad metadata")), "bad metadata"),
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
def test_check_version_reports_unavailable(installed, provider, error):
|
|
173
|
+
result = check_version(installed, latest_version_provider=provider)
|
|
174
|
+
|
|
175
|
+
assert result.installed_version == installed
|
|
176
|
+
assert result.latest_version is None
|
|
177
|
+
assert result.status == "unavailable"
|
|
178
|
+
assert error in result.error
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_check_version_includes_release_notes_for_every_pending_release():
|
|
182
|
+
notes = (
|
|
183
|
+
ReleaseNote("1.1.0", "https://example/1.1.0", "first"),
|
|
184
|
+
ReleaseNote("1.2.0", "https://example/1.2.0", "second"),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
result = check_version(
|
|
188
|
+
"1.0.0",
|
|
189
|
+
latest_version_provider=lambda: "1.2.0",
|
|
190
|
+
release_notes_provider=lambda installed, latest: notes,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert result.releases == notes
|
|
194
|
+
assert result.release_notes_error is None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.parametrize(
|
|
198
|
+
"error",
|
|
199
|
+
[
|
|
200
|
+
OSError("offline"),
|
|
201
|
+
ValueError("bad response"),
|
|
202
|
+
VersionCheckError("bad metadata"),
|
|
203
|
+
],
|
|
204
|
+
)
|
|
205
|
+
def test_check_version_keeps_update_status_when_release_notes_are_unavailable(error):
|
|
206
|
+
def fail_notes(installed, latest):
|
|
207
|
+
raise error
|
|
208
|
+
|
|
209
|
+
result = check_version(
|
|
210
|
+
"1.0.0",
|
|
211
|
+
latest_version_provider=lambda: "1.1.0",
|
|
212
|
+
release_notes_provider=fail_notes,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
assert result.status == "out-of-date"
|
|
216
|
+
assert result.releases == ()
|
|
217
|
+
assert result.release_notes_error == str(error)
|
|
@@ -321,10 +321,11 @@ wheels = [
|
|
|
321
321
|
|
|
322
322
|
[[package]]
|
|
323
323
|
name = "model-mirror-cli"
|
|
324
|
-
version = "0.2.
|
|
324
|
+
version = "0.2.1"
|
|
325
325
|
source = { editable = "." }
|
|
326
326
|
dependencies = [
|
|
327
327
|
{ name = "huggingface-hub" },
|
|
328
|
+
{ name = "packaging" },
|
|
328
329
|
{ name = "pyyaml" },
|
|
329
330
|
]
|
|
330
331
|
|
|
@@ -344,6 +345,7 @@ dev = [
|
|
|
344
345
|
requires-dist = [
|
|
345
346
|
{ name = "huggingface-hub", specifier = ">=1.0" },
|
|
346
347
|
{ name = "libtorrent", marker = "extra == 'torrent'", specifier = ">=2.0.10,<2.1" },
|
|
348
|
+
{ name = "packaging", specifier = ">=24.0" },
|
|
347
349
|
{ name = "pyyaml", specifier = ">=6.0" },
|
|
348
350
|
]
|
|
349
351
|
provides-extras = ["torrent"]
|
|
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
|