@@ -21,7 +21,7 @@ def module_level_asgi_apps() -> list[str]:
2121 """Files assigning a module-level `app`, which is what Vercel's FastAPI
2222 preset scans for when choosing an entrypoint."""
2323 found : list [str ] = []
24- for path in list ( ROOT .glob ("app/**/*.py" )) + list ( ROOT . glob ( "api/**/*.py" ) ):
24+ for path in ROOT .glob ("app/**/*.py" ):
2525 tree = ast .parse (path .read_text ())
2626 for node in tree .body : # module level only
2727 targets = (
@@ -37,18 +37,50 @@ def module_level_asgi_apps() -> list[str]:
3737
3838
3939def test_there_is_exactly_one_asgi_entrypoint ():
40- """Two module-level `app` instances meant Vercel's preset built
41- `app/main.py` as the function while dependencies were installed for the
42- entrypoint declared in vercel.json. The deployed function had no fastapi and
43- died at cold start.
44-
45- Anything needing an instance should use the factory:
46- `uvicorn app.main:create_app --factory`.
40+ """Vercel scans app.py/index.py/main.py/server.py/wsgi.py/asgi.py at the
41+ root and inside src/, app/ and api/, and builds the first FastAPI instance
42+ named `app` that it finds. Two candidates once meant it built one file while
43+ dependencies were installed for another, and the deployed function died with
44+ ModuleNotFoundError at cold start.
4745 """
4846 apps = module_level_asgi_apps ()
49- assert apps == ["api/index.py" ], (
50- f"expected exactly one ASGI entrypoint at api/index.py, found { apps } . "
51- "A second one makes the deployed entrypoint ambiguous."
47+ assert apps == ["app/main.py" ], (
48+ f"expected exactly one ASGI entrypoint at app/main.py, found { apps } . "
49+ "A second one makes the resolved entrypoint ambiguous."
50+ )
51+
52+
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."""
57+ 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"
68+
69+ 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 } "
72+ )
73+
74+
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]."
5284 )
5385
5486
@@ -87,30 +119,22 @@ def test_nothing_the_runtime_imports_is_excluded_from_the_bundle():
87119 required = [
88120 f
89121 for f in tracked
90- if f .startswith (( "app/" , "api/" ) )
91- or f in ("requirements.txt" , "vercel.json" , ".python-version" )
122+ if f .startswith ("app/" )
123+ or f in ("requirements.txt" , "vercel.json" , "pyproject.toml" , " .python-version" )
92124 ]
93125 excluded = [f for f in required if spec .match_file (f )]
94126
95127 assert not excluded , f"runtime files excluded from the Vercel bundle: { excluded } "
96128
97129
98130@pytest .mark .skipif (sys .platform == "win32" , reason = "posix path assumptions" )
99- def test_the_entrypoint_loads_without_the_repo_root_on_syspath ():
100- """Vercel executes api/index.py from another directory. If the sys.path
101- bootstrap in that file regresses, `app` becomes unimportable in production
102- while every local run still works ."""
131+ def test_the_entrypoint_imports_as_a_module ():
132+ """Vercel imports the entrypoint as `app.main`, not as a script. An import
133+ that only resolves because of the local working directory would work in
134+ every dev run and fail in production ."""
103135 result = subprocess .run (
104- [
105- sys .executable ,
106- "-c" ,
107- "import importlib.util,sys;"
108- f"spec=importlib.util.spec_from_file_location('e', r'{ ROOT / 'api' / 'index.py' } ');"
109- "m=importlib.util.module_from_spec(spec);"
110- "spec.loader.exec_module(m);"
111- "print(len(m.app.routes))" ,
112- ],
113- cwd = "/tmp" ,
136+ [sys .executable , "-c" , "from app.main import app; print(len(app.routes))" ],
137+ cwd = ROOT ,
114138 capture_output = True ,
115139 text = True ,
116140 )
0 commit comments