1717ROOT = 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+
2028def 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():
103118def 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