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.
Files changed (59) hide show
  1. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/.github/workflows/release.yml +19 -0
  2. model_mirror_cli-0.2.1/CHANGELOG.md +33 -0
  3. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/PKG-INFO +18 -3
  4. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/README.md +14 -2
  5. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/RELEASING.md +7 -2
  6. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/cli.py +100 -9
  7. model_mirror_cli-0.2.1/model_mirror/version_check.py +163 -0
  8. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/pyproject.toml +4 -1
  9. model_mirror_cli-0.2.1/scripts/changelog.py +39 -0
  10. model_mirror_cli-0.2.1/tests/test_changelog.py +39 -0
  11. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_cli.py +128 -1
  12. model_mirror_cli-0.2.1/tests/test_version_check.py +217 -0
  13. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/uv.lock +3 -1
  14. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/.github/workflows/ci.yml +0 -0
  15. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/.gitignore +0 -0
  16. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/CONTRIBUTORS.md +0 -0
  17. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/LICENSE +0 -0
  18. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/docs/torrent-backend-spike.md +0 -0
  19. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/docs/torrent-distribution-requirements.md +0 -0
  20. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/__init__.py +0 -0
  21. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/audit.py +0 -0
  22. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/checksums.py +0 -0
  23. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/config.py +0 -0
  24. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/hub.py +0 -0
  25. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/lock.py +0 -0
  26. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/mirror.py +0 -0
  27. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/progress.py +0 -0
  28. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/removal.py +0 -0
  29. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/repair.py +0 -0
  30. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/state.py +0 -0
  31. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent.py +0 -0
  32. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_coverage.py +0 -0
  33. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_hashes.py +0 -0
  34. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_import.py +0 -0
  35. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_publication.py +0 -0
  36. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/torrent_seed.py +0 -0
  37. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/model_mirror/verify.py +0 -0
  38. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/scripts/check_release.py +0 -0
  39. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/smoke_test.py +0 -0
  40. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_audit.py +0 -0
  41. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_checksums.py +0 -0
  42. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_config.py +0 -0
  43. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_hub.py +0 -0
  44. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_lock.py +0 -0
  45. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_mirror.py +0 -0
  46. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_progress.py +0 -0
  47. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_removal.py +0 -0
  48. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_repair.py +0 -0
  49. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_state.py +0 -0
  50. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent.py +0 -0
  51. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_cli.py +0 -0
  52. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_coverage.py +0 -0
  53. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_fencing.py +0 -0
  54. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_hashes.py +0 -0
  55. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_hub_integration.py +0 -0
  56. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_import.py +0 -0
  57. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_publication.py +0 -0
  58. {model_mirror_cli-0.2.0 → model_mirror_cli-0.2.1}/tests/test_torrent_seed.py +0 -0
  59. {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.0
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
- Datasets and Spaces are supported with `--repo-type dataset` or
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
- Datasets and Spaces are supported with `--repo-type dataset` or
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.1
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("--repo-type", choices=["model", "dataset", "space"], help="repo kind to mirror")
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", choices=["model", "dataset", "space"], default="model", help="repo kind to remove"
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", choices=["model", "dataset", "space"], default="model", help="repo kind to verify"
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", choices=["model", "dataset", "space"], default="model", help="repo kind to repair"
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", choices=["model", "dataset", "space"], default="model", help="repo kind to upgrade"
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
- default="model",
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", choices=["model", "dataset", "space"], default="model", help="repo kind to update"
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", choices=["model", "dataset", "space"], default="model", help="repo kind to update"
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.0"
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 0.2.0\n"
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.0"
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"]