"""The Hugging Face Spaces deploy contract. `scripts/deploy_space.py` had two defects on the day it was first run, and both were the kind a test catches in milliseconds and a deploy catches in minutes: * its preflight required a `Dockerfile` at the repository root — true of the project it was ported from, false here — so it aborted before reaching the Hub, every time; * `git add -A` honoured `.gitignore`, which lists `/Dockerfile` precisely *because* the root copy is generated, so the deploy commit went up without the one file the Space build reads. The Hub reported `NO_APP_FILE` and named nothing. The frontmatter rules are enforced by the Hub in a pre-receive hook, so a violation rejects the push only after the entire repository has been uploaded, and the error names the offending field without naming the file it lives in. These tests move all of it to `make test`. `scripts/` sits at the repository root, outside the `apps/api` testpath, so the module is loaded by path rather than imported as a package. """ from __future__ import annotations import importlib.util import sys from pathlib import Path from types import ModuleType import pytest REPO_ROOT = Path(__file__).resolve().parents[3] def _load_deploy_space() -> ModuleType: spec = importlib.util.spec_from_file_location( "deploy_space", REPO_ROOT / "scripts" / "deploy_space.py" ) assert spec is not None assert spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module deploy_space = _load_deploy_space() class TestPreflight: """The regression that stopped this script from ever running.""" def test_the_committed_repository_passes_preflight(self) -> None: assert deploy_space.preflight() == [] def test_a_root_dockerfile_is_not_required(self) -> None: """It is generated onto the deploy commit, so requiring it aborts every run. The ported original listed `Dockerfile` in `REQUIRED`, which is correct for a project that tracks one at the root and wrong for this one. Nothing else in the repository could have contradicted it — the file legitimately does not exist. """ assert Path("Dockerfile") not in deploy_space.REQUIRED assert not (REPO_ROOT / "Dockerfile").exists(), ( "a root Dockerfile is present; it should only exist during a deploy" ) def test_the_real_dockerfile_is_required(self) -> None: assert Path("infra/Dockerfile") in deploy_space.REQUIRED assert (REPO_ROOT / "infra" / "Dockerfile").exists() def test_a_missing_required_file_is_named(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(deploy_space, "REQUIRED", (Path("infra/nope.txt"),)) problems = deploy_space.preflight() assert len(problems) == 1 assert "infra/nope.txt" in problems[0] class TestTheGeneratedRootDockerfile: """The `NO_APP_FILE` defect, pinned from both ends.""" def test_the_root_dockerfile_is_gitignored(self) -> None: """This is the precondition that makes `git add -A` insufficient. If this ever stops being true the force-add below becomes unnecessary — but silently, and in the safe direction. If it stays true and the force-add is removed, the Space builds nothing. """ gitignore = (REPO_ROOT / ".gitignore").read_text(encoding="utf-8") assert "/Dockerfile" in gitignore.splitlines() def test_the_deploy_commit_force_adds_it(self) -> None: """A plain `git add -A` drops an ignored path without complaining.""" source = (REPO_ROOT / "scripts" / "deploy_space.py").read_text(encoding="utf-8") assert '"git", "add", "-f", "Dockerfile"' in source, ( "the generated root Dockerfile must be force-added; .gitignore ignores it" ) def test_the_commit_is_inspected_before_it_is_pushed(self) -> None: """Belt and braces: the tree is checked, not assumed, before the push.""" source = (REPO_ROOT / "scripts" / "deploy_space.py").read_text(encoding="utf-8") assert '"git", "ls-tree"' in source assert "the Space would never build" in source def test_the_dockerfile_copied_is_the_one_that_is_gated(self) -> None: """CI builds `infra/Dockerfile`; the Space builds whatever this copies.""" source = (REPO_ROOT / "scripts" / "deploy_space.py").read_text(encoding="utf-8") assert '"infra" / "Dockerfile"' in source workflow = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") assert "-f infra/Dockerfile" in workflow class TestSpaceExclusions: def test_binary_directories_are_kept_off_the_space(self) -> None: """The Hub polices binary files across a pushed history, not just the tip.""" assert "docs" in deploy_space.SPACE_EXCLUDE assert "eval/testset" in deploy_space.SPACE_EXCLUDE def test_every_exclusion_is_also_out_of_the_build_context(self) -> None: dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8").split() for excluded in deploy_space.SPACE_EXCLUDE: assert excluded in dockerignore, f"{excluded} reaches the image but not the Space" class TestSpaceVariables: def test_no_secret_is_declared_in_a_public_file(self) -> None: """`SPACE_VARIABLES` is committed. Secrets are set by hand, once, in the UI.""" forbidden = ("KEY", "SECRET", "TOKEN", "PASSWORD", "DATABASE_URL") leaked = [ name for name in deploy_space.SPACE_VARIABLES if any(marker in name.upper() for marker in forbidden) ] assert not leaked, f"secret-shaped names in a public file: {leaked}" def test_cors_names_an_origin_rather_than_a_wildcard(self) -> None: origins = deploy_space.SPACE_VARIABLES["ALLOWED_ORIGINS"] assert "*" not in origins assert origins.startswith("https://") class TestShortDescription: def test_committed_space_config_is_deployable(self) -> None: """The regression guard. This is what gets prepended to the pushed README.""" config = (REPO_ROOT / ".hf-space.yml").read_text(encoding="utf-8") assert deploy_space.frontmatter_problems(config) == [] def test_the_readme_carries_no_front_matter(self) -> None: """It lives in .hf-space.yml so GitHub does not render it as a table.""" readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") assert not readme.startswith("---") def test_over_long_description_is_rejected(self) -> None: too_long = "x" * (deploy_space.SHORT_DESCRIPTION_MAX + 1) problems = deploy_space.frontmatter_problems( f"sdk: docker\nshort_description: {too_long}\n" ) assert len(problems) == 1 # The count has to be in the message; "too long" without a number means opening # the file and counting by hand. assert str(deploy_space.SHORT_DESCRIPTION_MAX + 1) in problems[0] def test_exactly_at_the_limit_is_accepted(self) -> None: at_limit = "x" * deploy_space.SHORT_DESCRIPTION_MAX assert ( deploy_space.frontmatter_problems(f"sdk: docker\nshort_description: {at_limit}\n") == [] ) @pytest.mark.parametrize("quote", ['"', "'"]) def test_quotes_are_stripped_before_measuring(self, quote: str) -> None: """The live description is quoted, because it contains an em dash and a comma.""" described = "Invoices in, verified data out — or a human review queue" assert len(described) <= deploy_space.SHORT_DESCRIPTION_MAX problems = deploy_space.frontmatter_problems( f"sdk: docker\nshort_description: {quote}{described}{quote}\n" ) assert problems == [] def test_colon_inside_a_description_is_measured_whole(self) -> None: """Splitting on every colon would measure only the text before it. Constructed so the head is comfortably short while the whole description is over the limit: a parser that truncated at the inner colon would report no problem here, and the Hub would reject the push anyway. That is the precise failure this check exists to prevent, so the case has to be able to fail. """ head = "LedgerLens" described = f"{head}: {'y' * deploy_space.SHORT_DESCRIPTION_MAX}" assert len(head) < deploy_space.SHORT_DESCRIPTION_MAX < len(described) problems = deploy_space.frontmatter_problems( f'sdk: docker\nshort_description: "{described}"\n' ) assert len(problems) == 1 assert str(len(described)) in problems[0] class TestSdkDeclaration: def test_missing_docker_sdk_is_rejected(self) -> None: problems = deploy_space.frontmatter_problems("title: LedgerLens\napp_port: 7860\n") assert any("sdk: docker" in problem for problem in problems) def test_the_committed_port_is_the_one_the_image_listens_on(self) -> None: config = (REPO_ROOT / ".hf-space.yml").read_text(encoding="utf-8") dockerfile = (REPO_ROOT / "infra" / "Dockerfile").read_text(encoding="utf-8") assert "app_port: 7860" in config assert "PORT=7860" in dockerfile class TestPushFailureDiagnosis: """Each branch names the one thing that acts on it, or says it does not know.""" def test_metadata_rejection_points_at_the_readme(self) -> None: stderr = ( "remote: Sorry, your push was rejected during YAML metadata verification:\n" 'remote: - Error: "short_description" length must be less than or equal to 60' ) assert "README.md" in deploy_space.explain_push_failure(stderr) def test_binary_rejection_does_not_blame_the_readme(self) -> None: stderr = ( "remote: Your push was rejected because it contains binary files.\n" "remote: Please use https://huggingface.co/docs/hub/xet to store binary files.\n" "remote: Offending files:\n" "remote: - docs/screens/dashboard.png (ref: refs/heads/main)\n" "! [remote rejected] main -> main (pre-receive hook declined)" ) message = deploy_space.explain_push_failure(stderr) assert "binary" in message assert "README" not in message assert "token" not in message def test_auth_rejection_points_at_the_token(self) -> None: stderr = "remote: Authentication failed\nfatal: could not read Password" assert "token" in deploy_space.explain_push_failure(stderr) def test_unrecognised_rejection_invents_nothing(self) -> None: """Silence about the cause beats a confident wrong cause.""" message = deploy_space.explain_push_failure("remote: something entirely new") assert "reason is above" in message assert "README" not in message assert "token" not in message