From ed563927d8225126b16ba1b3ee7ff3131fe68ef2 Mon Sep 17 00:00:00 2001 From: Damien Grauet Date: Wed, 29 Jul 2026 16:58:49 +0200 Subject: [PATCH 1/2] feat(upload): add --dry-run, and let --card-only work without local weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparing to refresh published cards showed three things missing for that to be done safely. - `--dry-run` renders the card, diffs it against the published one, counts the lines that would be REMOVED, and stops. Losing content is the failure mode here — a value that exists only in the published README — so the dry run names it rather than reporting success. - `--card-only` refused to run without local .safetensors, though it pushes only the README and the manifest. Refreshing a card required keeping the weights on disk, which is why 20 of the 21 published cards could not be touched at all. - In --card-only the file listing now comes from the remote alone. Merging the local directory over it published sizes from a build that is not the one in the repo: models/ernie-image-pe-mlx holds a 7.14 GB pe.safetensors while the repo has 6.39 GB. With these, all twelve lossless repos dry-run clean: three change nothing, five differ by a blank line, ltx-2.3 gains the two delta-uploaded files its card never mentioned, and matrix-game gains its four tokenizer files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TaEHdLNyZuqjxqGmZ73JG9 --- src/mlx_forge/cli.py | 79 +++++++++++++++++++++++++++++-- tests/test_upload_completeness.py | 31 ++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/mlx_forge/cli.py b/src/mlx_forge/cli.py index 5903d30..51f6370 100644 --- a/src/mlx_forge/cli.py +++ b/src/mlx_forge/cli.py @@ -202,6 +202,14 @@ def build_parser() -> argparse.ArgumentParser: "command that actually exists." ), ) + upload_parser.add_argument( + "--dry-run", + action="store_true", + help=( + "Render the card and show what would change on the remote, without " + "writing or uploading anything. Use before refreshing a published card." + ), + ) mode_group = upload_parser.add_mutually_exclusive_group() mode_group.add_argument( "--card-only", @@ -300,12 +308,16 @@ def should_quantize(key: str, weight): ) -def _card_file_listing(api, repo_id: str, model_dir) -> dict[str, int]: +def _card_file_listing(api, repo_id: str, model_dir, *, card_only: bool = False) -> dict[str, int]: """What the repo will contain: the remote listing plus what is about to go up. The card's other derived section (transformer_variants) already reads the remote, so deriving the file list from the local directory alone produced cards that contradicted themselves after a delta upload. + + In --card-only mode nothing local is uploaded, so the remote is the whole + truth: a local directory holding a different build would otherwise publish + sizes that do not match the repo. """ from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError @@ -320,11 +332,65 @@ def _card_file_listing(api, repo_id: str, model_dir) -> dict[str, int]: except (RepositoryNotFoundError, HfHubHTTPError, OSError, ConnectionError): pass # new repo, or offline: the local directory is the whole story + if card_only and listing: + return listing + for p in iter_model_files(model_dir): listing[p.relative_to(model_dir).as_posix()] = p.stat().st_size return listing +def _show_card_diff(api, repo_id: str, card: str, model_dir, *, card_only: bool) -> None: + """Print what a real run would change on the remote, and stop there.""" + import difflib + + from huggingface_hub import hf_hub_download + + try: + published = open(hf_hub_download(repo_id, "README.md")).read() + etat = f"against the card published at {repo_id}" + except Exception: + published = "" + etat = f"{repo_id} has no card yet — everything below is new" + + print(f"\n{'=' * 60}") + print(f"DRY RUN — nothing is written or uploaded ({etat})") + print("=" * 60) + + diff = list( + difflib.unified_diff( + published.splitlines(), card.splitlines(), "published", "regenerated", lineterm="" + ) + ) + if not diff: + print("\nThe card is already up to date — a real run would change nothing.") + else: + print() + for line in diff: + print(line) + + perdues = [ + line + for line in diff + if line.startswith("-") and not line.startswith("---") and line[1:].strip() + ] + print(f"\n{'-' * 60}") + if perdues: + print(f"WARNING: {len(perdues)} line(s) would be REMOVED from the published card.") + print("Content that exists only in the published README is lost by regenerating.") + print("Declare it in the recipe, or pass the matching flag, before pushing.") + else: + print("No content would be lost.") + + print("\nA real run would push:") + print(" README.md") + if card_only: + if (model_dir / "split_model.json").exists(): + print(" split_model.json") + else: + print(f" every file in {model_dir} (see the Files section above)") + + def _run_upload(args) -> None: """Upload a converted model directory to HuggingFace Hub.""" from pathlib import Path @@ -345,8 +411,9 @@ def _run_upload(args) -> None: print(f"ERROR: {model_dir} not found") sys.exit(1) - safetensor_files = list(model_dir.glob("*.safetensors")) - if not safetensor_files: + # --card-only pushes the README and the manifest, never the weights, so it + # must work from a directory that holds only metadata. + if not args.card_only and not list(model_dir.glob("*.safetensors")): print(f"ERROR: No .safetensors files found in {model_dir}") print("Run conversion and/or splitting before uploading.") sys.exit(1) @@ -386,7 +453,7 @@ def _run_upload(args) -> None: # Describe the repo as it will be, not just the local directory: after a # delta upload the remote holds files this directory never had. - file_listing = _card_file_listing(api, repo_id, model_dir) + file_listing = _card_file_listing(api, repo_id, model_dir, card_only=args.card_only) # Generate and write model card card_content = generate_model_card( @@ -401,6 +468,10 @@ def _run_upload(args) -> None: cli_snippet=args.cli_snippet or split_info.get("cli_snippet"), file_listing=file_listing, ) + if args.dry_run: + _show_card_diff(api, repo_id, card_content, model_dir, card_only=args.card_only) + return + readme_path = model_dir / "README.md" with open(readme_path, "w") as f: f.write(card_content) diff --git a/tests/test_upload_completeness.py b/tests/test_upload_completeness.py index b7598c7..0789d5b 100644 --- a/tests/test_upload_completeness.py +++ b/tests/test_upload_completeness.py @@ -400,3 +400,34 @@ def test_nested_dotfiles_are_not_listed(self, tmp_path): ) assert ".DS_Store" not in card assert "tokenizer/spiece.model" in card + + +class TestCardOnlyListingProvenance: + """In --card-only nothing local is uploaded, so the remote is the truth.""" + + def test_remote_sizes_win_over_a_divergent_local_build(self, tmp_path): + from mlx_forge.cli import _card_file_listing + + model_dir = _model_dir(tmp_path) # local transformer.safetensors is 10 bytes + api = _api() + info = api.model_info.return_value + info.siblings = [MagicMock(rfilename="transformer.safetensors", size=999)] + + listing = _card_file_listing(api, "test/repo", model_dir, card_only=True) + + assert listing["transformer.safetensors"] == 999, ( + "a local directory holding another build must not set the published size" + ) + + def test_a_full_upload_still_prefers_the_local_build(self, tmp_path): + from mlx_forge.cli import _card_file_listing + + model_dir = _model_dir(tmp_path) + api = _api() + api.model_info.return_value.siblings = [ + MagicMock(rfilename="transformer.safetensors", size=999) + ] + + listing = _card_file_listing(api, "test/repo", model_dir, card_only=False) + + assert listing["transformer.safetensors"] == 10, "the local files are what goes up" From d4fa7b4acdb47d483c00c22063a5317debe918c8 Mon Sep 17 00:00:00 2001 From: Damien Grauet Date: Wed, 29 Jul 2026 17:04:52 +0200 Subject: [PATCH 2/2] fix(upload): generate the card once, so --dry-run shows what is pushed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing dgrauet/matrix-game-3.0-mlx published a card WITHOUT its Related Projects section and without the tokenizer files the dry run had just shown being added. The card was restored from the previous revision. Cause: two generation sites. The CLI assembled the card from the manifest, the recipe declaration, the remote file listing and the operator's flags — then upload_model(card_only=True) threw that away and regenerated from the manifest alone, without listing, links, license or snippet. --dry-run stopped at the first, so it faithfully displayed a card that was never the one uploaded. - upload_model now pushes the README on disk and refuses if there is none. It builds nothing. - Deriving transformer variants and LoRAs from the remote moves into the CLI, next to everything else the card needs. - The dry run's loss warning pairs a removed entry with its replacement, so a file whose size changed no longer reads as content disappearing. A warning that cries wolf is worse than none. Tests moved to the level that now owns the behaviour: the CLI assembles, upload_model transports. One asserts every line the dry run announces appears in the bytes a real run pushes — the property that was violated. Verified by reintroducing the second generation: four tests fail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TaEHdLNyZuqjxqGmZ73JG9 --- src/mlx_forge/cli.py | 57 ++++++++++++++-- src/mlx_forge/upload.py | 43 +++--------- tests/test_integration.py | 26 ++++--- tests/test_upload.py | 110 ++++++++++++++++-------------- tests/test_upload_completeness.py | 74 +++++++++++++++++++- 5 files changed, 206 insertions(+), 104 deletions(-) diff --git a/src/mlx_forge/cli.py b/src/mlx_forge/cli.py index 51f6370..5a980c7 100644 --- a/src/mlx_forge/cli.py +++ b/src/mlx_forge/cli.py @@ -308,6 +308,37 @@ def should_quantize(key: str, weight): ) +def _remote_variants(api, repo_id: str) -> tuple[list[str] | None, list[str] | None]: + """Transformer variants and LoRAs as they exist on the remote. + + A delta upload adds files this directory never had, so a refresh must read + the repo rather than the manifest. Returns (None, None) when the repo + cannot be queried, letting the card fall back to split_model.json. + """ + from huggingface_hub.errors import HfHubHTTPError, RepositoryNotFoundError + + try: + info = api.model_info(repo_id) + remote_files = [s.rfilename for s in (info.siblings or [])] + except (RepositoryNotFoundError, HfHubHTTPError, OSError, ConnectionError): + return None, None + + if not remote_files: + return None, None + + variants = sorted( + v + for f in remote_files + if f.startswith("transformer-") and f.endswith(".safetensors") + for v in [f.removeprefix("transformer-").removesuffix(".safetensors")] + if v + ) + loras = sorted(f for f in remote_files if "lora" in f and f.endswith(".safetensors")) + print(f"Detected variants on remote: {', '.join(variants) or '(none)'}") + print(f"Detected LoRAs on remote: {', '.join(loras) or '(none)'}") + return variants, loras + + def _card_file_listing(api, repo_id: str, model_dir, *, card_only: bool = False) -> dict[str, int]: """What the repo will contain: the remote listing plus what is about to go up. @@ -369,11 +400,21 @@ def _show_card_diff(api, repo_id: str, card: str, model_dir, *, card_only: bool) for line in diff: print(line) - perdues = [ - line - for line in diff - if line.startswith("-") and not line.startswith("---") and line[1:].strip() - ] + def entrees(prefixe, entete): + return [ + line[1:] + for line in diff + if line.startswith(prefixe) and not line.startswith(entete) and line[1:].strip() + ] + + # A file whose size changed shows as one removal plus one addition. Pairing + # them on the entry name keeps the warning meaningful: it must fire on + # content that disappears, not on a size that moved. + def cle(line: str) -> str: + return line.split("` (")[0] if line.startswith("- `") else line + + ajoutees = {cle(line) for line in entrees("+", "+++")} + perdues = [line for line in entrees("-", "---") if cle(line) not in ajoutees] print(f"\n{'-' * 60}") if perdues: print(f"WARNING: {len(perdues)} line(s) would be REMOVED from the published card.") @@ -455,6 +496,10 @@ def _run_upload(args) -> None: # delta upload the remote holds files this directory never had. file_listing = _card_file_listing(api, repo_id, model_dir, card_only=args.card_only) + # A refresh describes the repo as it is, including files a delta upload + # added that this directory never held. + variants, loras = _remote_variants(api, repo_id) if args.card_only else (None, None) + # Generate and write model card card_content = generate_model_card( model_dir, @@ -467,6 +512,8 @@ def _run_upload(args) -> None: links=args.link or split_info.get("links"), cli_snippet=args.cli_snippet or split_info.get("cli_snippet"), file_listing=file_listing, + transformer_variants=variants, + lora_files=loras, ) if args.dry_run: _show_card_diff(api, repo_id, card_content, model_dir, card_only=args.card_only) diff --git a/src/mlx_forge/upload.py b/src/mlx_forge/upload.py index f25733d..9239e46 100644 --- a/src/mlx_forge/upload.py +++ b/src/mlx_forge/upload.py @@ -409,42 +409,15 @@ def upload_model( # and only the README needs refreshing (e.g. appending a CLI example). try: if card_only: - # Derive transformer variants and LoRA files from remote (idempotent refresh) - try: - info = api.model_info(repo_id) - remote_files = [s.rfilename for s in (info.siblings or [])] - except (HfHubHTTPError, OSError, ConnectionError): - remote_files = [] # fall through with local data only - - if remote_files: - transformer_variants = sorted( - v - for f in remote_files - if f.startswith("transformer-") and f.endswith(".safetensors") - for v in [f.removeprefix("transformer-").removesuffix(".safetensors")] - if v - ) - lora_files = sorted( - f for f in remote_files if "lora" in f and f.endswith(".safetensors") - ) - print(f"Detected variants on remote: {', '.join(transformer_variants) or '(none)'}") - print(f"Detected LoRAs on remote: {', '.join(lora_files) or '(none)'}") - else: - transformer_variants = None # generate_model_card falls back to split_info - lora_files = None - - # Regenerate README with remote-derived lists - split_info, config_data = load_model_metadata(model_dir) - readme_text = generate_model_card( - model_dir, - split_info=split_info, - config=config_data, - repo_id=repo_id, - transformer_variants=transformer_variants, - lora_files=lora_files, - ) + # Push the card as generated by the caller. This used to regenerate + # it here, from the manifest alone and without the file listing, + # links or license the caller had just assembled — so the card that + # went up was not the one --dry-run had shown, and refreshing + # dgrauet/matrix-game-3.0-mlx dropped its Related Projects section. readme_path = model_dir / "README.md" - readme_path.write_text(readme_text) + if not readme_path.exists(): + print(f"ERROR: {readme_path} not found — nothing to push") + raise SystemExit(1) print(f"Uploading {readme_path.name} -> {repo_id}...") api.upload_file( diff --git a/tests/test_integration.py b/tests/test_integration.py index 3149c01..4194ada 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -10,6 +10,7 @@ import mlx.core as mx +from mlx_forge.cli import main from mlx_forge.convert import classify_keys, load_safetensors, process_component from mlx_forge.quantize import quantize_weights from mlx_forge.recipes.ltx_23 import ( @@ -522,8 +523,7 @@ class TestDeltaWorkflowEndToEnd: def test_delta_workflow_glue(self, tmp_path, capsys): import argparse import json - from pathlib import Path - from unittest.mock import MagicMock + from unittest.mock import MagicMock, patch from mlx_forge.recipes import ltx_23 from mlx_forge.upload import upload_model @@ -589,22 +589,28 @@ def test_delta_workflow_glue(self, tmp_path, capsys): assert "transformer-distilled-1.1.safetensors" in uploaded assert "config.json" not in uploaded # already on remote - # Stage 4: upload --card-only refreshes card with remote-derived variants + # Stage 4: `upload --card-only` refreshes the card from the remote. + # The card is assembled by the CLI, which owns every input; upload_model + # only pushes what is on disk, so this drives the CLI. api2 = MagicMock() info2 = MagicMock() info2.siblings = [ - MagicMock(rfilename="transformer-distilled.safetensors"), - MagicMock(rfilename="transformer-distilled-1.1.safetensors"), + MagicMock(rfilename="transformer-distilled.safetensors", size=1), + MagicMock(rfilename="transformer-distilled-1.1.safetensors", size=1), ] api2.model_info.return_value = info2 api2.create_repo.return_value = "https://huggingface.co/user/repo" - upload_model(tmp_path, api=api2, repo_id="user/repo", card_only=True) + with ( + patch( + "sys.argv", + ["mlx-forge", "upload", str(tmp_path), "--repo-id", "user/repo", "--card-only"], + ), + patch("huggingface_hub.HfApi", return_value=api2), + ): + main() - readme_call = next( - c for c in api2.upload_file.call_args_list if c.kwargs["path_in_repo"] == "README.md" - ) - readme_text = Path(readme_call.kwargs["path_or_fileobj"]).read_text() + readme_text = (tmp_path / "README.md").read_text() # Both remote variants must appear in the regenerated card assert "distilled" in readme_text assert "distilled-1.1" in readme_text diff --git a/tests/test_upload.py b/tests/test_upload.py index 32c63d8..189e1ee 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import mlx.core as mx import pytest @@ -392,73 +392,77 @@ def test_card_only_and_add_only_are_mutually_exclusive(self): class TestCardOnlyRemoteRefresh: - def test_card_only_uses_remote_variants(self, tmp_path): - from mlx_forge.upload import upload_model + """Remote-derived variants reach the card — assembled by the CLI, not here. - # Local dir has only one variant (delta convert leftover) - (tmp_path / "transformer-distilled-1.1.safetensors").write_bytes(b"x") - (tmp_path / "split_model.json").write_text( - json.dumps({"source": "Lightricks/LTX-2.3", "transformer_variants": ["distilled-1.1"]}) - ) - (tmp_path / "config.json").write_text(json.dumps({"model_version": "2.3.0"})) + upload_model used to regenerate the card itself, from the manifest alone and + without the file listing, links or license the caller had assembled. The + card that went up was therefore not the one --dry-run had shown: refreshing + dgrauet/matrix-game-3.0-mlx dropped its Related Projects section. It now + pushes the file on disk, and these tests drive the CLI. + """ - # Remote has all three transformer variants + def _remote(self, *filenames): api = MagicMock() info = MagicMock() - info.siblings = [ - MagicMock(rfilename="transformer-distilled.safetensors"), - MagicMock(rfilename="transformer-dev.safetensors"), - MagicMock(rfilename="transformer-distilled-1.1.safetensors"), - MagicMock(rfilename="ltx-2.3-22b-distilled-lora-384.safetensors"), - MagicMock(rfilename="ltx-2.3-22b-distilled-lora-384-1.1.safetensors"), - MagicMock(rfilename="config.json"), - ] + info.siblings = [MagicMock(rfilename=f, size=1) for f in filenames] api.model_info.return_value = info api.create_repo.return_value = "https://huggingface.co/test/repo" + return api - upload_model(tmp_path, api=api, repo_id="test/repo", card_only=True) - - readme_call = next( - c for c in api.upload_file.call_args_list if c.kwargs["path_in_repo"] == "README.md" - ) - readme_path = readme_call.kwargs["path_or_fileobj"] - readme_text = Path(readme_path).read_text() - # All three transformer variants must appear in the card - assert "distilled" in readme_text - assert "dev" in readme_text - assert "distilled-1.1" in readme_text - - def test_card_only_falls_back_on_network_error(self, tmp_path): - """When api.model_info raises a network error, fall back to local split_info.""" - from huggingface_hub.errors import HfHubHTTPError + def _run(self, model_dir, api): + from mlx_forge.cli import main - from mlx_forge.upload import upload_model + with ( + patch( + "sys.argv", + ["mlx-forge", "upload", str(model_dir), "--repo-id", "test/repo", "--card-only"], + ), + patch("huggingface_hub.HfApi", return_value=api), + ): + main() + return (model_dir / "README.md").read_text() - # Local has TWO variants; remote will fail to respond - (tmp_path / "transformer-distilled.safetensors").write_bytes(b"x") - (tmp_path / "transformer-dev.safetensors").write_bytes(b"y") + def test_card_only_uses_remote_variants(self, tmp_path): + (tmp_path / "transformer-distilled-1.1.safetensors").write_bytes(b"x") (tmp_path / "split_model.json").write_text( - json.dumps( - { - "source": "Lightricks/LTX-2.3", - "transformer_variants": ["distilled", "dev"], - } - ) + json.dumps({"source": "Lightricks/LTX-2.3", "transformer_variants": ["distilled-1.1"]}) ) (tmp_path / "config.json").write_text(json.dumps({"model_version": "2.3.0"})) - api = MagicMock() - api.model_info.side_effect = HfHubHTTPError("503 Service Unavailable", response=MagicMock()) - api.create_repo.return_value = "https://huggingface.co/test/repo" + card = self._run( + tmp_path, + self._remote( + "transformer-distilled.safetensors", + "transformer-dev.safetensors", + "transformer-distilled-1.1.safetensors", + "ltx-2.3-22b-distilled-lora-384.safetensors", + "config.json", + ), + ) + + for variant in ("distilled", "dev", "distilled-1.1"): + assert variant in card + + def test_the_card_pushed_is_the_card_generated(self, tmp_path): + """upload_model must not rebuild it: that is how sections went missing.""" + from mlx_forge.upload import upload_model + + (tmp_path / "split_model.json").write_text(json.dumps({"source": "Org/M"})) + (tmp_path / "README.md").write_text("# sentinel\n\n## Related Projects\n\n- **x:** y\n") + api = self._remote("model.safetensors") - # Should NOT raise; falls back to local split_info upload_model(tmp_path, api=api, repo_id="test/repo", card_only=True) - readme_call = next( + pushed = next( c for c in api.upload_file.call_args_list if c.kwargs["path_in_repo"] == "README.md" ) - readme_path = readme_call.kwargs["path_or_fileobj"] - readme_text = Path(readme_path).read_text() - # Local variants are present in the card - assert "distilled" in readme_text - assert "dev" in readme_text + assert Path(pushed.kwargs["path_or_fileobj"]).read_text() == ( + "# sentinel\n\n## Related Projects\n\n- **x:** y\n" + ) + + def test_missing_card_is_refused(self, tmp_path): + from mlx_forge.upload import upload_model + + (tmp_path / "split_model.json").write_text(json.dumps({"source": "Org/M"})) + with pytest.raises(SystemExit): + upload_model(tmp_path, api=self._remote(), repo_id="test/repo", card_only=True) diff --git a/tests/test_upload_completeness.py b/tests/test_upload_completeness.py index 0789d5b..36845bb 100644 --- a/tests/test_upload_completeness.py +++ b/tests/test_upload_completeness.py @@ -10,7 +10,8 @@ from __future__ import annotations -from unittest.mock import MagicMock +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -431,3 +432,74 @@ def test_a_full_upload_still_prefers_the_local_build(self, tmp_path): listing = _card_file_listing(api, "test/repo", model_dir, card_only=False) assert listing["transformer.safetensors"] == 10, "the local files are what goes up" + + +class TestDryRunShowsWhatIsPushed: + """--dry-run is only useful if it renders the same bytes a real run pushes. + + It did not: the CLI generated the card, then upload_model regenerated it + from the manifest alone. The dry run showed the good card and the push + published the poor one — which is how a published card lost a section. + """ + + def _dir(self, tmp_path): + import json + + (tmp_path / "split_model.json").write_text( + json.dumps({"source": "Lightricks/LTX-2.3", "recipe": "ltx-2.3"}) + ) + (tmp_path / "config.json").write_text("{}") + return tmp_path + + def _api(self): + api = MagicMock() + info = MagicMock() + info.siblings = [MagicMock(rfilename="transformer-dev.safetensors", size=4096)] + api.model_info.return_value = info + api.create_repo.return_value = "https://huggingface.co/test/repo" + return api + + def _run(self, model_dir, api, *extra): + from mlx_forge.cli import main + + argv = ["mlx-forge", "upload", str(model_dir), "--repo-id", "test/repo", "--card-only"] + with ( + patch("sys.argv", argv + list(extra)), + patch("huggingface_hub.HfApi", return_value=api), + ): + main() + + def test_dry_run_output_matches_the_pushed_bytes(self, tmp_path, capsys): + model_dir = self._dir(tmp_path) + + # dry run: nothing written, nothing uploaded + api = self._api() + self._run(model_dir, api, "--dry-run") + api.upload_file.assert_not_called() + assert not (model_dir / "README.md").exists() + + # real run + api2 = self._api() + self._run(model_dir, api2) + pousse = Path( + next( + c + for c in api2.upload_file.call_args_list + if c.kwargs["path_in_repo"] == "README.md" + ).kwargs["path_or_fileobj"] + ).read_text() + + # every non-blank line the dry run announced must be in what went up + annonce = [ + line[1:] + for line in capsys.readouterr().out.splitlines() + if line.startswith("+") and not line.startswith("+++") and line[1:].strip() + ] + assert annonce, "the dry run announced nothing" + for line in annonce: + assert line in pousse, f"dry run showed {line!r} but it is not in the pushed card" + + def test_dry_run_writes_no_readme(self, tmp_path): + model_dir = self._dir(tmp_path) + self._run(model_dir, self._api(), "--dry-run") + assert not (model_dir / "README.md").exists()