codekingpro/portable-devtools
114k
1# Copyright 2025-present, the HuggingFace Inc. team.2#3# Licensed under the Apache License, Version 2.0 (the "License");4# you may not use this file except in compliance with the License.5# You may obtain a copy of the License at6#7# http://www.apache.org/licenses/LICENSE-2.08#9# Unless required by applicable law or agreed to in writing, software10# distributed under the License is distributed on an "AS IS" BASIS,11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12# See the License for the specific language governing permissions and13# limitations under the License.14"""Contains the 'hf cache' command group with cache management subcommands."""15 16import re17import time18from collections import defaultdict19from collections.abc import Callable, Mapping20from dataclasses import dataclass21from enum import Enum22from typing import Annotated, Any23 24import typer25 26from huggingface_hub.errors import CLIError27 28from ..utils import ANSI, CachedRepoInfo, CachedRevisionInfo, CacheNotFound, HFCacheInfo, _format_size, scan_cache_dir29from ..utils._parsing import parse_duration, parse_size30from ._cli_utils import RepoIdArg, RepoTypeOpt, RevisionOpt, TokenOpt, get_hf_api, typer_factory31from ._output import out32 33 34cache_cli = typer_factory(help="Manage local cache directory.")35 36 37#### Cache helper utilities38 39 40@dataclass(frozen=True)41class _DeletionResolution:42 revisions: frozenset[str]43 selected: dict[CachedRepoInfo, frozenset[CachedRevisionInfo]]44 missing: tuple[str, ...]45 46 47_FILTER_PATTERN = re.compile(r"^(?P<key>[a-zA-Z_]+)\s*(?P<op>==|!=|>=|<=|>|<|=)\s*(?P<value>.+)$")48_ALLOWED_OPERATORS = {"=", "!=", ">", "<", ">=", "<="}49_FILTER_KEYS = {"accessed", "modified", "refs", "size", "type"}50_SORT_KEYS = {"accessed", "modified", "name", "size"}51_SORT_PATTERN = re.compile(r"^(?P<key>[a-zA-Z_]+)(?::(?P<order>asc|desc))?$")52_SORT_DEFAULT_ORDER = {53 # Default ordering: accessed/modified/size are descending (newest/biggest first), name is ascending54 "accessed": "desc",55 "modified": "desc",56 "size": "desc",57 "name": "asc",58}59 60 61# Dynamically generate SortOptions enum from _SORT_KEYS62_sort_options_dict = {}63for key in sorted(_SORT_KEYS):64 _sort_options_dict[key] = key65 _sort_options_dict[f"{key}_asc"] = f"{key}:asc"66 _sort_options_dict[f"{key}_desc"] = f"{key}:desc"67 68SortOptions = Enum("SortOptions", _sort_options_dict, type=str, module=__name__) # type: ignore69 70 71@dataclass(frozen=True)72class CacheDeletionCounts:73 """Simple counters summarizing cache deletions for CLI messaging."""74 75 repo_count: int76 partial_revision_count: int77 total_revision_count: int78 79 80CacheEntry = tuple[CachedRepoInfo, CachedRevisionInfo | None]81RepoRefsMap = dict[CachedRepoInfo, frozenset[str]]82 83 84def summarize_deletions(85 selected_by_repo: Mapping[CachedRepoInfo, frozenset[CachedRevisionInfo]],86) -> CacheDeletionCounts:87 """Summarize deletions across repositories."""88 repo_count = 089 total_revisions = 090 revisions_in_full_repos = 091 92 for repo, revisions in selected_by_repo.items():93 total_revisions += len(revisions)94 if len(revisions) == len(repo.revisions):95 repo_count += 196 revisions_in_full_repos += len(revisions)97 98 partial_revision_count = total_revisions - revisions_in_full_repos99 return CacheDeletionCounts(repo_count, partial_revision_count, total_revisions)100 101 102def print_cache_selected_revisions(selected_by_repo: Mapping[CachedRepoInfo, frozenset[CachedRevisionInfo]]) -> None:103 """Pretty-print selected cache revisions during confirmation prompts."""104 for repo in sorted(selected_by_repo.keys(), key=lambda repo: (repo.repo_type, repo.repo_id.lower())):105 repo_key = f"{repo.repo_type}/{repo.repo_id}"106 revisions = sorted(selected_by_repo[repo], key=lambda rev: rev.commit_hash)107 if len(revisions) == len(repo.revisions):108 out.text(f" - {repo_key} (entire repo)")109 continue110 111 out.text(f" - {repo_key}:")112 for revision in revisions:113 refs = " ".join(sorted(revision.refs)) or "(detached)"114 out.text(f" {revision.commit_hash} [{refs}] {revision.size_on_disk_str}")115 116 117def build_cache_index(118 hf_cache_info: HFCacheInfo,119) -> tuple[120 dict[str, CachedRepoInfo],121 dict[str, tuple[CachedRepoInfo, CachedRevisionInfo]],122]:123 """Create lookup tables so CLI commands can resolve repo ids and revisions quickly."""124 repo_lookup: dict[str, CachedRepoInfo] = {}125 revision_lookup: dict[str, tuple[CachedRepoInfo, CachedRevisionInfo]] = {}126 for repo in hf_cache_info.repos:127 repo_key = repo.cache_id.lower()128 repo_lookup[repo_key] = repo129 for revision in repo.revisions:130 revision_lookup[revision.commit_hash.lower()] = (repo, revision)131 return repo_lookup, revision_lookup132 133 134def collect_cache_entries(135 hf_cache_info: HFCacheInfo, *, include_revisions: bool136) -> tuple[list[CacheEntry], RepoRefsMap]:137 """Flatten cache metadata into rows consumed by `hf cache ls`."""138 entries: list[CacheEntry] = []139 repo_refs_map: RepoRefsMap = {}140 sorted_repos = sorted(hf_cache_info.repos, key=lambda repo: (repo.repo_type, repo.repo_id.lower()))141 for repo in sorted_repos:142 repo_refs_map[repo] = frozenset({ref for revision in repo.revisions for ref in revision.refs})143 if include_revisions:144 for revision in sorted(repo.revisions, key=lambda rev: rev.commit_hash):145 entries.append((repo, revision))146 else:147 entries.append((repo, None))148 if include_revisions:149 entries.sort(150 key=lambda entry: (151 entry[0].cache_id,152 entry[1].commit_hash if entry[1] is not None else "",153 )154 )155 else:156 entries.sort(key=lambda entry: entry[0].cache_id)157 return entries, repo_refs_map158 159 160def compile_cache_filter(161 expr: str, repo_refs_map: RepoRefsMap162) -> Callable[[CachedRepoInfo, CachedRevisionInfo | None, float], bool]:163 """Convert a `hf cache ls` filter expression into the yes/no test we apply to each cache entry before displaying it."""164 match = _FILTER_PATTERN.match(expr.strip())165 if not match:166 raise ValueError(f"Invalid filter expression: '{expr}'.")167 168 key = match.group("key").lower()169 op = match.group("op")170 value_raw = match.group("value").strip()171 172 if op not in _ALLOWED_OPERATORS:173 raise ValueError(f"Unsupported operator '{op}' in filter '{expr}'. Must be one of {list(_ALLOWED_OPERATORS)}.")174 175 if key not in _FILTER_KEYS:176 raise ValueError(f"Unsupported filter key '{key}' in '{expr}'. Must be one of {list(_FILTER_KEYS)}.")177 # at this point we know that key is in `_FILTER_KEYS`178 if key == "size":179 size_threshold = parse_size(value_raw)180 return lambda repo, revision, _: _compare_numeric(181 revision.size_on_disk if revision is not None else repo.size_on_disk,182 op,183 size_threshold,184 )185 186 if key in {"modified", "accessed"}:187 seconds = parse_duration(value_raw.strip())188 189 def _time_filter(repo: CachedRepoInfo, revision: CachedRevisionInfo | None, now: float) -> bool:190 timestamp = (191 repo.last_accessed192 if key == "accessed"193 else revision.last_modified194 if revision is not None195 else repo.last_modified196 )197 if timestamp is None:198 return False199 return _compare_numeric(now - timestamp, op, seconds)200 201 return _time_filter202 203 if key == "type":204 expected = value_raw.lower()205 206 if op != "=":207 raise ValueError(f"Only '=' is supported for 'type' filters. Got '{op}'.")208 209 def _type_filter(repo: CachedRepoInfo, revision: CachedRevisionInfo | None, _: float) -> bool:210 return repo.repo_type.lower() == expected211 212 return _type_filter213 214 else: # key == "refs"215 if op != "=":216 raise ValueError(f"Only '=' is supported for 'refs' filters. Got {op}.")217 218 def _refs_filter(repo: CachedRepoInfo, revision: CachedRevisionInfo | None, _: float) -> bool:219 refs = revision.refs if revision is not None else repo_refs_map.get(repo, frozenset())220 return value_raw.lower() in [ref.lower() for ref in refs]221 222 return _refs_filter223 224 225def _compare_numeric(left: float | None, op: str, right: float) -> bool:226 """Evaluate numeric comparisons for filters."""227 if left is None:228 return False229 230 comparisons = {231 "=": left == right,232 "!=": left != right,233 ">": left > right,234 "<": left < right,235 ">=": left >= right,236 "<=": left <= right,237 }238 239 if op not in comparisons:240 raise ValueError(f"Unsupported numeric comparison operator: {op}")241 242 return comparisons[op]243 244 245def compile_cache_sort(sort_expr: str) -> tuple[Callable[[CacheEntry], tuple[Any, ...]], bool]:246 """Convert a `hf cache ls` sort expression into a key function for sorting entries.247 248 Returns:249 A tuple of (key_function, reverse_flag) where reverse_flag indicates whether250 to sort in descending order (True) or ascending order (False).251 """252 match = _SORT_PATTERN.match(sort_expr.strip().lower())253 if not match:254 raise ValueError(f"Invalid sort expression: '{sort_expr}'. Expected format: 'key' or 'key:asc' or 'key:desc'.")255 256 key = match.group("key").lower()257 explicit_order = match.group("order")258 259 if key not in _SORT_KEYS:260 raise ValueError(f"Unsupported sort key '{key}' in '{sort_expr}'. Must be one of {list(_SORT_KEYS)}.")261 262 # Use explicit order if provided, otherwise use default for the key263 order = explicit_order if explicit_order else _SORT_DEFAULT_ORDER[key]264 reverse = order == "desc"265 266 def _sort_key(entry: CacheEntry) -> tuple[Any, ...]:267 repo, revision = entry268 269 if key == "name":270 # Sort by cache_id (repo type/id)271 value: Any = repo.cache_id.lower()272 return (value,)273 274 if key == "size":275 # Use revision size if available, otherwise repo size276 value = revision.size_on_disk if revision is not None else repo.size_on_disk277 return (value,)278 279 if key == "accessed":280 # For revisions, accessed is not available per-revision, use repo's last_accessed281 # For repos, use repo's last_accessed282 value = repo.last_accessed if repo.last_accessed is not None else 0.0283 return (value,)284 285 if key == "modified":286 # Use revision's last_modified if available, otherwise repo's last_modified287 if revision is not None:288 value = revision.last_modified if revision.last_modified is not None else 0.0289 else:290 value = repo.last_modified if repo.last_modified is not None else 0.0291 return (value,)292 293 # Should never reach here due to validation above294 raise ValueError(f"Unsupported sort key: {key}")295 296 return _sort_key, reverse297 298 299def _resolve_deletion_targets(hf_cache_info: HFCacheInfo, targets: list[str]) -> _DeletionResolution:300 """Resolve the deletion targets into a deletion resolution."""301 repo_lookup, revision_lookup = build_cache_index(hf_cache_info)302 303 selected: dict[CachedRepoInfo, set[CachedRevisionInfo]] = defaultdict(set)304 revisions: set[str] = set()305 missing: list[str] = []306 307 for raw_target in targets:308 target = raw_target.strip()309 if not target:310 continue311 lowered = target.lower()312 313 if re.fullmatch(r"[0-9a-fA-F]{40}", lowered):314 match = revision_lookup.get(lowered)315 if match is None:316 missing.append(raw_target)317 continue318 repo, revision = match319 selected[repo].add(revision)320 revisions.add(revision.commit_hash)321 continue322 323 matched_repo = repo_lookup.get(lowered)324 if matched_repo is None:325 missing.append(raw_target)326 continue327 328 for revision in matched_repo.revisions:329 selected[matched_repo].add(revision)330 revisions.add(revision.commit_hash)331 332 frozen_selected = {repo: frozenset(revs) for repo, revs in selected.items()}333 return _DeletionResolution(334 revisions=frozenset(revisions),335 selected=frozen_selected,336 missing=tuple(missing),337 )338 339 340#### Cache CLI commands341 342 343@cache_cli.command(344 "list | ls",345 examples=[346 "hf cache ls",347 "hf cache ls --revisions",348 'hf cache ls --filter "size>1GB" --limit 20',349 "hf cache ls --format json",350 ],351)352def ls(353 cache_dir: Annotated[354 str | None,355 typer.Option(356 help="Cache directory to scan (defaults to Hugging Face cache).",357 ),358 ] = None,359 revisions: Annotated[360 bool,361 typer.Option(362 help="Include revisions in the output instead of aggregated repositories.",363 ),364 ] = False,365 filter: Annotated[366 list[str] | None,367 typer.Option(368 "-f",369 "--filter",370 help="Filter entries (e.g. 'size>1GB', 'type=model', 'accessed>7d'). Can be used multiple times.",371 ),372 ] = None,373 sort: Annotated[374 SortOptions | None,375 typer.Option(376 help="Sort entries by key. Supported keys: 'accessed', 'modified', 'name', 'size'. "377 "Append ':asc' or ':desc' to explicitly set the order (e.g., 'modified:asc'). "378 "Defaults: 'accessed', 'modified', 'size' default to 'desc' (newest/biggest first); "379 "'name' defaults to 'asc' (alphabetical).",380 ),381 ] = None,382 limit: Annotated[383 int | None,384 typer.Option(385 help="Limit the number of results returned. Returns only the top N entries after sorting.",386 ),387 ] = None,388) -> None:389 """List cached repositories or revisions."""390 try:391 hf_cache_info = scan_cache_dir(cache_dir)392 except CacheNotFound as exc:393 raise CLIError(f"Cache directory not found: {exc.cache_dir}") from exc394 395 filters = filter or []396 397 entries, repo_refs_map = collect_cache_entries(hf_cache_info, include_revisions=revisions)398 try:399 filter_fns = [compile_cache_filter(expr, repo_refs_map) for expr in filters]400 except ValueError as exc:401 raise typer.BadParameter(str(exc)) from exc402 403 now = time.time()404 for fn in filter_fns:405 entries = [entry for entry in entries if fn(entry[0], entry[1], now)]406 407 # Apply sorting if requested408 if sort:409 try:410 sort_key_fn, reverse = compile_cache_sort(sort.value)411 entries.sort(key=sort_key_fn, reverse=reverse)412 except ValueError as exc:413 raise typer.BadParameter(str(exc)) from exc414 415 # Apply limit if requested416 if limit is not None:417 if limit < 0:418 raise typer.BadParameter(f"Limit must be a positive integer, got {limit}.")419 entries = entries[:limit]420 421 if revisions:422 items = [423 {424 "id": repo.cache_id,425 "repo_id": repo.repo_id,426 "repo_type": repo.repo_type,427 "revision": revision.commit_hash,428 "snapshot_path": str(revision.snapshot_path),429 "size": revision.size_on_disk_str,430 "last_modified": revision.last_modified_str,431 "refs": sorted(revision.refs),432 }433 for repo, revision in entries434 if revision is not None435 ]436 out.table(437 items,438 headers=["id", "revision", "size", "last_modified", "refs"],439 id_key="revision",440 alignments={"size": "right"},441 )442 else:443 items = [444 {445 "id": repo.cache_id,446 "repo_id": repo.repo_id,447 "repo_type": repo.repo_type,448 "size": repo.size_on_disk_str,449 "last_accessed": repo.last_accessed_str or "",450 "last_modified": repo.last_modified_str,451 "refs": sorted(repo_refs_map.get(repo, frozenset())),452 }453 for repo, _ in entries454 ]455 out.table(456 items,457 headers=["id", "size", "last_accessed", "last_modified", "refs"],458 id_key="id",459 alignments={"size": "right"},460 )461 462 if entries:463 unique_repos = {repo for repo, _ in entries}464 repo_count = len(unique_repos)465 if revisions:466 revision_count = sum(1 for _, rev in entries if rev is not None)467 total_size = sum(rev.size_on_disk for _, rev in entries if rev is not None)468 else:469 revision_count = sum(len(repo.revisions) for repo in unique_repos)470 total_size = sum(repo.size_on_disk for repo in unique_repos)471 out.text(472 ANSI.bold(473 f"\nFound {repo_count} repo(s) for a total of {revision_count} revision(s)"474 f" and {_format_size(total_size)} on disk."475 )476 )477 478 479@cache_cli.command(480 examples=[481 "hf cache rm model/gpt2",482 "hf cache rm <revision_hash>",483 "hf cache rm model/gpt2 --dry-run",484 "hf cache rm model/gpt2 --yes",485 ],486)487def rm(488 targets: Annotated[489 list[str],490 typer.Argument(491 help="One or more repo IDs (e.g. model/bert-base-uncased) or revision hashes to delete.",492 ),493 ],494 cache_dir: Annotated[495 str | None,496 typer.Option(497 help="Cache directory to scan (defaults to Hugging Face cache).",498 ),499 ] = None,500 yes: Annotated[501 bool,502 typer.Option(503 "-y",504 "--yes",505 help="Skip confirmation prompt.",506 ),507 ] = False,508 dry_run: Annotated[509 bool,510 typer.Option(511 help="Preview deletions without removing anything.",512 ),513 ] = False,514) -> None:515 """Remove cached repositories or revisions."""516 try:517 hf_cache_info = scan_cache_dir(cache_dir)518 except CacheNotFound as exc:519 raise CLIError(f"Cache directory not found: {exc.cache_dir}") from exc520 521 resolution = _resolve_deletion_targets(hf_cache_info, targets)522 523 if resolution.missing:524 details = "\n".join(f" - {entry}" for entry in resolution.missing)525 out.warning(f"Could not find in cache:\n{details}")526 527 if len(resolution.revisions) == 0:528 out.text("Nothing to delete.")529 raise typer.Exit(code=0)530 531 strategy = hf_cache_info.delete_revisions(*sorted(resolution.revisions))532 counts = summarize_deletions(resolution.selected)533 534 summary_parts: list[str] = []535 if counts.repo_count:536 summary_parts.append(f"{counts.repo_count} repo(s)")537 if counts.partial_revision_count:538 summary_parts.append(f"{counts.partial_revision_count} revision(s)")539 if not summary_parts:540 summary_parts.append(f"{counts.total_revision_count} revision(s)")541 542 summary_text = " and ".join(summary_parts)543 out.text(f"About to delete {summary_text} totalling {strategy.expected_freed_size_str}.")544 print_cache_selected_revisions(resolution.selected)545 546 if dry_run:547 out.result(548 "Dry run: no files were deleted.",549 dry_run=True,550 repos=counts.repo_count,551 revisions=counts.total_revision_count,552 size=strategy.expected_freed_size_str,553 )554 return555 556 out.confirm("Proceed with deletion?", yes=yes)557 558 strategy.execute()559 counts = summarize_deletions(resolution.selected)560 out.result(561 f"Deleted {counts.repo_count} repo(s) and {counts.total_revision_count} revision(s);"562 f" freed {strategy.expected_freed_size_str}.",563 repos_deleted=counts.repo_count,564 revisions_deleted=counts.total_revision_count,565 freed=strategy.expected_freed_size_str,566 )567 568 569@cache_cli.command(examples=["hf cache prune", "hf cache prune --dry-run"])570def prune(571 cache_dir: Annotated[572 str | None,573 typer.Option(574 help="Cache directory to scan (defaults to Hugging Face cache).",575 ),576 ] = None,577 yes: Annotated[578 bool,579 typer.Option(580 "-y",581 "--yes",582 help="Skip confirmation prompt.",583 ),584 ] = False,585 dry_run: Annotated[586 bool,587 typer.Option(588 help="Preview deletions without removing anything.",589 ),590 ] = False,591) -> None:592 """Remove detached revisions from the cache."""593 try:594 hf_cache_info = scan_cache_dir(cache_dir)595 except CacheNotFound as exc:596 raise CLIError(f"Cache directory not found: {exc.cache_dir}") from exc597 598 selected: dict[CachedRepoInfo, frozenset[CachedRevisionInfo]] = {}599 revisions: set[str] = set()600 for repo in hf_cache_info.repos:601 detached = frozenset(revision for revision in repo.revisions if len(revision.refs) == 0)602 if not detached:603 continue604 selected[repo] = detached605 revisions.update(revision.commit_hash for revision in detached)606 607 if len(revisions) == 0:608 out.text("No unreferenced revisions found. Nothing to prune.")609 return610 611 resolution = _DeletionResolution(612 revisions=frozenset(revisions),613 selected=selected,614 missing=(),615 )616 strategy = hf_cache_info.delete_revisions(*sorted(resolution.revisions))617 counts = summarize_deletions(selected)618 619 out.text(620 f"About to delete {counts.total_revision_count} unreferenced revision(s) ({strategy.expected_freed_size_str} total)."621 )622 print_cache_selected_revisions(selected)623 624 if dry_run:625 out.result(626 "Dry run: no files were deleted.",627 dry_run=True,628 revisions=counts.total_revision_count,629 size=strategy.expected_freed_size_str,630 )631 return632 633 out.confirm("Proceed?", yes=yes)634 635 strategy.execute()636 out.result(637 f"Deleted {counts.total_revision_count} unreferenced revision(s); freed {strategy.expected_freed_size_str}.",638 revisions_deleted=counts.total_revision_count,639 freed=strategy.expected_freed_size_str,640 )641 642 643@cache_cli.command(644 examples=[645 "hf cache verify gpt2",646 "hf cache verify gpt2 --revision refs/pr/1",647 "hf cache verify my-dataset --repo-type dataset",648 ],649)650def verify(651 repo_id: RepoIdArg,652 repo_type: RepoTypeOpt = RepoTypeOpt.model,653 revision: RevisionOpt = None,654 cache_dir: Annotated[655 str | None,656 typer.Option(657 help="Cache directory to use when verifying files from cache (defaults to Hugging Face cache).",658 ),659 ] = None,660 local_dir: Annotated[661 str | None,662 typer.Option(663 help="If set, verify files under this directory instead of the cache.",664 ),665 ] = None,666 fail_on_missing_files: Annotated[667 bool,668 typer.Option(669 "--fail-on-missing-files",670 help="Fail if some files exist on the remote but are missing locally.",671 ),672 ] = False,673 fail_on_extra_files: Annotated[674 bool,675 typer.Option(676 "--fail-on-extra-files",677 help="Fail if some files exist locally but are not present on the remote revision.",678 ),679 ] = False,680 token: TokenOpt = None,681) -> None:682 """Verify checksums for a single repo revision from cache or a local directory.683 684 Examples:685 - Verify main revision in cache: `hf cache verify gpt2`686 - Verify specific revision: `hf cache verify gpt2 --revision refs/pr/1`687 - Verify dataset: `hf cache verify karpathy/fineweb-edu-100b-shuffle --repo-type dataset`688 - Verify local dir: `hf cache verify deepseek-ai/DeepSeek-OCR --local-dir /path/to/repo`689 """690 691 if local_dir is not None and cache_dir is not None:692 out.error("Cannot pass both --local-dir and --cache-dir. Use one or the other.")693 raise typer.Exit(code=2)694 695 api = get_hf_api(token=token)696 697 result = api.verify_repo_checksums(698 repo_id=repo_id,699 repo_type=repo_type.value if hasattr(repo_type, "value") else str(repo_type),700 revision=revision,701 local_dir=local_dir,702 cache_dir=cache_dir,703 token=token,704 )705 706 exit_code = 0707 708 if result.mismatches:709 details = "\n".join(710 f" - {m['path']}: expected {m['expected']} ({m['algorithm']}), got {m['actual']}"711 for m in result.mismatches712 )713 out.text(f"❌ Checksum verification failed for the following file(s):\n{details}")714 exit_code = 1715 716 if result.missing_paths:717 if fail_on_missing_files:718 details = "\n".join(f" - {p}" for p in result.missing_paths)719 out.text(f"❌ Missing files (present remotely, absent locally):\n{details}")720 exit_code = 1721 else:722 out.warning(723 f"{len(result.missing_paths)} remote file(s) are missing locally. "724 "Use --fail-on-missing-files for details."725 )726 727 if result.extra_paths:728 if fail_on_extra_files:729 details = "\n".join(f" - {p}" for p in result.extra_paths)730 out.text(f"❌ Extra files (present locally, absent remotely):\n{details}")731 exit_code = 1732 else:733 out.warning(734 f"{len(result.extra_paths)} local file(s) do not exist on the remote repo. "735 "Use --fail-on-extra-files for details."736 )737 738 verified_location = result.verified_path739 740 if exit_code != 0:741 out.error(742 f"Verification failed for '{repo_id}' ({repo_type.value}) in {verified_location}.\n Revision: {result.revision}"743 )744 raise typer.Exit(code=exit_code)745 746 out.result(747 f"Verified {result.checked_count} file(s) for {repo_type.value} '{repo_id}'. All checksums match.",748 repo_id=repo_id,749 repo_type=repo_type.value,750 checked=result.checked_count,751 path=str(verified_location),752 )753 