File size: 11,043 Bytes
6741fc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
"""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