Skip to content

Commit 247df35

Browse files
committed
fix(vercel): align with the documented FastAPI conventions and stop pyproject shadowing requirements
1 parent 873eb9b commit 247df35

10 files changed

Lines changed: 90 additions & 90 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ jobs:
5454
pip install -r requirements-dev.txt
5555
5656
- name: Lint
57-
run: ruff check app api scripts tests
57+
run: ruff check app scripts tests
5858

5959
- name: Format check
60-
run: ruff format --check app api scripts tests
60+
run: ruff format --check app scripts tests
6161

6262
- name: Verify the Vercel bundle stays importable without local extras
6363
# Guards the actual deployment contract: if anything in the import graph

.vercelignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
# Keep the serverless bundle under the 250 MB unzipped limit.
1+
# Keep the serverless bundle small. A FastAPI deployment is bundled as a
2+
# single function, with a 500 MB limit.
23
#
34
# Every pattern here is ANCHORED with a leading slash, and that is not stylistic.
45
# .vercelignore uses gitignore semantics: an unanchored `evals/` matches a
56
# directory of that name at ANY depth, so it silently excluded `app/evals/` —
67
# a package the request path imports — and the function died at cold start with
78
# FUNCTION_INVOCATION_FAILED while the build itself reported success.
89
#
9-
# Only api/ and app/ are needed at runtime.
10+
# Only app/ is needed at runtime.
1011

1112
/tests/
1213
/k8s/

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
5555
# Single worker by default: the process holds a database pool and, when the
5656
# local models are installed, model weights in memory. Scale with replicas, not
5757
# with workers.
58-
CMD ["sh", "-c", "uvicorn app.main:create_app --factory --host 0.0.0.0 --port ${PORT} --workers 1 --timeout-keep-alive 75"]
58+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT} --workers 1 --timeout-keep-alive 75"]

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ Without Docker:
222222
```bash
223223
uv venv && uv pip install -r requirements.txt -r requirements-local.txt
224224
alembic upgrade head
225-
uvicorn app.main:create_app --factory --reload
225+
uvicorn app.main:app --reload
226226
```
227227

228228
> Already running Postgres on 5432? Set `POSTGRES_PORT=5433` in `.env` and update `DATABASE_URL`
@@ -319,8 +319,15 @@ Import the repo at vercel.com/new. Project settings:
319319
|---|---|
320320
| Application Preset | **FastAPI** |
321321
| Root Directory | `./` |
322-
| Install Command | `pip install -r requirements.txt` (the default) |
323-
| Build / Output | leave empty — [vercel.json](vercel.json) handles routing |
322+
| Install Command | leave as the default |
323+
| Build / Output | leave empty |
324+
325+
Vercel resolves the entrypoint by scanning `app.py`/`index.py`/`main.py`/`server.py`/`wsgi.py`/
326+
`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.
324331

325332
Environment variables:
326333

api/index.py

Lines changed: 0 additions & 32 deletions
This file was deleted.

app/main.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,9 @@ async def unhandled(request: Request, exc: Exception) -> JSONResponse:
8888
return app
8989

9090

91-
# Deliberately no module-level `app = create_app()` here.
92-
#
93-
# Vercel's FastAPI preset scans the repo for a module-level ASGI instance and
94-
# builds whatever it finds as the function. With one in this module *and* one in
95-
# api/index.py it picked this file, while the dependency install was scoped to
96-
# the entrypoint declared in vercel.json — so the deployed function ran without
97-
# fastapi installed and died with ModuleNotFoundError at cold start.
98-
#
99-
# There is now exactly one ASGI callable in the repo, at api/index.py. Anything
100-
# that needs an instance builds one from the factory:
101-
#
102-
# uvicorn app.main:create_app --factory
91+
# The single ASGI entrypoint. `app/main.py` is one of the paths Vercel's Python
92+
# runtime scans for a FastAPI instance named `app`, and it is the location its
93+
# documentation uses. There must be exactly one such file in the repo: a second
94+
# one makes the resolved entrypoint ambiguous, and the build installs
95+
# dependencies for whichever it picked.
96+
app = create_app()

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ services:
5656
- hf_cache:/home/documind/.cache/huggingface
5757
command: >
5858
sh -c "alembic upgrade head &&
59-
uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8000 --reload"
59+
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
6060
6161
# Optional second vector store, for the pgvector/Qdrant comparison.
6262
# docker compose --profile qdrant up

pyproject.toml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
[project]
2-
name = "documind-api"
3-
version = "0.1.0"
4-
description = "Cited document Q&A with a measured retrieval benchmark"
5-
requires-python = ">=3.12"
1+
# No [project] table on purpose.
2+
#
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"
614

715
[tool.ruff]
816
line-length = 100

tests/test_deployment_contract.py

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3939
def 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
)

vercel.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
{
22
"$schema": "https://openapi.vercel.sh/vercel.json",
33
"functions": {
4-
"api/index.py": {
5-
"maxDuration": 60,
6-
"includeFiles": "app/**"
4+
"app/main.py": {
5+
"maxDuration": 60
76
}
8-
},
9-
"rewrites": [{ "source": "/(.*)", "destination": "/api/index" }]
7+
}
108
}

0 commit comments

Comments
 (0)