Skip to content

Commit 5689a42

Browse files
committed
fix(vercel): keep pyproject.toml out of the upload so the build installs requirements.txt
1 parent 247df35 commit 5689a42

4 files changed

Lines changed: 72 additions & 49 deletions

File tree

.vercelignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@
2828
/requirements-local.txt
2929
/requirements-dev.txt
3030

31+
# Excluded so the build uses requirements.txt.
32+
#
33+
# Vercel's Python builder runs `uv lock` against pyproject.toml whenever the
34+
# file is present, and never looks at requirements.txt. This project is an
35+
# application, not a distributable package: pyproject.toml carries only ruff
36+
# and pytest configuration, so `uv lock` fails outright with
37+
# error: No `project` table found in: /vercel/path0/pyproject.toml
38+
# Keeping it out of the upload restores the standard requirements.txt install.
39+
#
40+
# The alternative is a full [project] table duplicating every pin from
41+
# requirements.txt — a second dependency list to keep in sync, for no gain.
42+
/pyproject.toml
43+
3144
/README.md
3245
/.venv/
3346
/.ruff_cache/

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,17 @@ Import the repo at vercel.com/new. Project settings:
324324

325325
Vercel resolves the entrypoint by scanning `app.py`/`index.py`/`main.py`/`server.py`/`wsgi.py`/
326326
`asgi.py` at the root and inside `src/`, `app/` and `api/`, then bundles the whole app as a single
327-
function. This repo declares it explicitly instead — `[tool.vercel] entrypoint` in
328-
[pyproject.toml](pyproject.toml) — so resolution order never matters, and
329-
[vercel.json](vercel.json) configures that same file. `tests/test_deployment_contract.py` fails if
330-
the two drift apart, or if a second module-level `app` appears anywhere.
327+
function. `app/main.py` is the only file in this repo exposing a module-level `app`, so resolution
328+
is unambiguous, and [vercel.json](vercel.json) configures that same path.
329+
330+
Two packaging details that are easy to get wrong, both enforced by
331+
`tests/test_deployment_contract.py`:
332+
333+
- **`pyproject.toml` is excluded from the upload** ([.vercelignore](.vercelignore)). Vercel runs
334+
`uv lock` against it whenever it is present and never reads `requirements.txt`. Since this repo's
335+
`pyproject.toml` holds only ruff and pytest config, leaving it in breaks the build.
336+
- **`.vercelignore` patterns are anchored with `/`.** It uses gitignore semantics, so an
337+
unanchored `evals/` also matches `app/evals/` and strips a package the request path imports.
331338

332339
Environment variables:
333340

pyproject.toml

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
1-
# No [project] table on purpose.
1+
# Tool configuration only — no [project] table.
22
#
3-
# Vercel's Python builder prefers pyproject.toml over requirements.txt when a
4-
# [project] table is present. This project is not a distributable package, so
5-
# that table declared no `dependencies` — and the build installed nothing,
6-
# leaving the deployed function to die with `ModuleNotFoundError: No module
7-
# named 'fastapi'`. Without it, requirements.txt is unambiguously the source.
8-
9-
[tool.vercel]
10-
# Explicit beats inferred. Vercel otherwise scans app.py/index.py/main.py/... at
11-
# the root and inside src/, app/ and api/, and picks whichever it finds first —
12-
# a resolution order this repo should not depend on.
13-
entrypoint = "app.main:app"
3+
# This is an application, not a distributable package. Note that Vercel's Python
4+
# builder runs `uv lock` against this file whenever it is present and ignores
5+
# requirements.txt entirely, so it is excluded from the deployment upload in
6+
# .vercelignore. Adding a [project] table here without also listing every
7+
# runtime dependency will break the build.
148

159
[tool.ruff]
1610
line-length = 100

tests/test_deployment_contract.py

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
ROOT = Path(__file__).resolve().parents[1]
1818

1919

20+
def _vercelignore_lines() -> list[str]:
21+
return [
22+
line
23+
for raw in (ROOT / ".vercelignore").read_text().splitlines()
24+
if (line := raw.strip()) and not line.startswith("#")
25+
]
26+
27+
2028
def module_level_asgi_apps() -> list[str]:
2129
"""Files assigning a module-level `app`, which is what Vercel's FastAPI
2230
preset scans for when choosing an entrypoint."""
@@ -50,37 +58,44 @@ def test_there_is_exactly_one_asgi_entrypoint():
5058
)
5159

5260

53-
def test_the_declared_entrypoint_matches_the_configured_function():
54-
"""pyproject.toml names the entrypoint; vercel.json configures the function
55-
by resolved file path. If they drift, maxDuration silently applies to a file
56-
that is not the entrypoint and every request hits the default timeout."""
61+
def test_vercel_json_configures_the_actual_entrypoint():
62+
"""vercel.json keys the function by resolved file path. If it names a file
63+
that is not the entrypoint, maxDuration silently applies to nothing and slow
64+
requests die at the default timeout instead."""
5765
import json
58-
import re
59-
60-
pyproject = (ROOT / "pyproject.toml").read_text()
61-
match = re.search(r'entrypoint\s*=\s*"([^"]+)"', pyproject)
62-
assert match, "pyproject.toml must declare [tool.vercel] entrypoint"
63-
64-
module, _, attribute = match.group(1).partition(":")
65-
entry_file = module.replace(".", "/") + ".py"
66-
assert attribute == "app"
67-
assert (ROOT / entry_file).exists(), f"{entry_file} does not exist"
6866

6967
configured = list(json.loads((ROOT / "vercel.json").read_text())["functions"])
70-
assert configured == [entry_file], (
71-
f"vercel.json configures {configured} but the entrypoint is {entry_file}"
68+
assert configured == module_level_asgi_apps(), (
69+
f"vercel.json configures {configured}, but the ASGI entrypoint is "
70+
f"{module_level_asgi_apps()}"
7271
)
7372

7473

75-
def test_pyproject_declares_no_project_table():
76-
"""A [project] table makes Vercel's Python builder prefer pyproject.toml
77-
over requirements.txt. With no `dependencies` listed there, the build
78-
installs nothing and the function cannot import fastapi."""
79-
pyproject = (ROOT / "pyproject.toml").read_text()
80-
has_project = any(line.strip() == "[project]" for line in pyproject.splitlines())
81-
assert not has_project, (
82-
"pyproject.toml declares [project]; either remove it so requirements.txt "
83-
"is used, or list every runtime dependency under [project.dependencies]."
74+
def test_pyproject_is_kept_out_of_the_deployment_bundle():
75+
"""Vercel's Python builder runs `uv lock` against pyproject.toml whenever it
76+
is present, and never reads requirements.txt.
77+
78+
With no [project] table the build fails outright:
79+
error: No `project` table found in: /vercel/path0/pyproject.toml
80+
With a [project] table that omits `dependencies`, it succeeds and installs
81+
nothing — surfacing much later as ModuleNotFoundError at cold start.
82+
83+
So either keep the file out of the upload (what this repo does, since
84+
pyproject.toml here holds only ruff and pytest config) or make it a complete
85+
dependency manifest. The half-way state is the trap, and both halves of it
86+
have already broken a deployment.
87+
"""
88+
pathspec = pytest.importorskip("pathspec", reason="pathspec not installed")
89+
90+
spec = pathspec.PathSpec.from_lines("gitwildmatch", _vercelignore_lines())
91+
declares_project = any(
92+
line.strip() == "[project]" for line in (ROOT / "pyproject.toml").read_text().splitlines()
93+
)
94+
95+
assert spec.match_file("pyproject.toml") or declares_project, (
96+
"pyproject.toml is uploaded to Vercel but declares no [project] table — "
97+
"`uv lock` will fail the build. Exclude it in .vercelignore, or give it a "
98+
"[project] table listing every runtime dependency."
8499
)
85100

86101

@@ -103,12 +118,7 @@ def test_vercelignore_patterns_are_anchored():
103118
def test_nothing_the_runtime_imports_is_excluded_from_the_bundle():
104119
pathspec = pytest.importorskip("pathspec", reason="pathspec not installed")
105120

106-
lines = [
107-
line
108-
for raw in (ROOT / ".vercelignore").read_text().splitlines()
109-
if (line := raw.strip()) and not line.startswith("#")
110-
]
111-
spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
121+
spec = pathspec.PathSpec.from_lines("gitwildmatch", _vercelignore_lines())
112122

113123
tracked = subprocess.run(
114124
["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True
@@ -119,8 +129,7 @@ def test_nothing_the_runtime_imports_is_excluded_from_the_bundle():
119129
required = [
120130
f
121131
for f in tracked
122-
if f.startswith("app/")
123-
or f in ("requirements.txt", "vercel.json", "pyproject.toml", ".python-version")
132+
if f.startswith("app/") or f in ("requirements.txt", "vercel.json", ".python-version")
124133
]
125134
excluded = [f for f in required if spec.match_file(f)]
126135

0 commit comments

Comments
 (0)