@vercel/python 6.57.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +106 -28
- package/package.json +9 -5
- package/templates/vc_fastapi_static.py +132 -31
package/dist/index.js
CHANGED
|
@@ -5032,7 +5032,7 @@ var import_fs20 = __toESM(require("fs"));
|
|
|
5032
5032
|
var import_path21 = require("path");
|
|
5033
5033
|
|
|
5034
5034
|
// src/package-versions.ts
|
|
5035
|
-
var VERCEL_RUNTIME_VERSION = "0.21.
|
|
5035
|
+
var VERCEL_RUNTIME_VERSION = "0.21.1";
|
|
5036
5036
|
var VERCEL_WORKERS_VERSION = "0.0.25";
|
|
5037
5037
|
|
|
5038
5038
|
// src/conditional-vendoring.ts
|
|
@@ -8512,7 +8512,7 @@ function createQueueHandlerModule(declaration, integrations) {
|
|
|
8512
8512
|
return [
|
|
8513
8513
|
"import importlib",
|
|
8514
8514
|
"import os",
|
|
8515
|
-
"import
|
|
8515
|
+
"from vercel_runtime.workers import create_queue_service_app",
|
|
8516
8516
|
"",
|
|
8517
8517
|
`os.environ[${JSON.stringify(SUBSCRIBER_ID_ENV)}] = ${JSON.stringify(declaration.name)}`,
|
|
8518
8518
|
...createIntegrationInstallLines(integrations, {
|
|
@@ -8524,7 +8524,7 @@ function createQueueHandlerModule(declaration, integrations) {
|
|
|
8524
8524
|
serving: true,
|
|
8525
8525
|
beforeImport: false
|
|
8526
8526
|
}),
|
|
8527
|
-
"app =
|
|
8527
|
+
"app = create_queue_service_app()",
|
|
8528
8528
|
""
|
|
8529
8529
|
].join("\n");
|
|
8530
8530
|
}
|
|
@@ -10403,8 +10403,7 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
|
|
|
10403
10403
|
return null;
|
|
10404
10404
|
}
|
|
10405
10405
|
return {
|
|
10406
|
-
|
|
10407
|
-
mountPrefixes: mounts.map((m) => m.urlPath),
|
|
10406
|
+
collectedMounts: mounts,
|
|
10408
10407
|
cdnOutputDir: outputStaticDir,
|
|
10409
10408
|
shadowRoutes,
|
|
10410
10409
|
// Only copied mounts get a fallback: a check:true dest that is missing makes
|
|
@@ -10415,21 +10414,45 @@ async function runFastAPICollectStatic(venvPath, workPath, env, outputStaticDir,
|
|
|
10415
10414
|
};
|
|
10416
10415
|
}
|
|
10417
10416
|
function fastapiShadowingRoutes(discovery, lambdaPath) {
|
|
10418
|
-
const
|
|
10419
|
-
|
|
10420
|
-
|
|
10421
|
-
|
|
10422
|
-
if (
|
|
10423
|
-
|
|
10424
|
-
continue;
|
|
10425
|
-
}
|
|
10426
|
-
const current = chunks[chunks.length - 1];
|
|
10427
|
-
if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
|
|
10428
|
-
current.push(body);
|
|
10429
|
-
srcLen += 1 + body.length;
|
|
10417
|
+
const byMethods = /* @__PURE__ */ new Map();
|
|
10418
|
+
for (const sr of discovery.shadowRoutes) {
|
|
10419
|
+
const key = sr.methods === null ? "" : JSON.stringify([...sr.methods].sort());
|
|
10420
|
+
const group = byMethods.get(key);
|
|
10421
|
+
if (group) {
|
|
10422
|
+
group.push(sr.body);
|
|
10430
10423
|
} else {
|
|
10431
|
-
|
|
10432
|
-
|
|
10424
|
+
byMethods.set(key, [sr.body]);
|
|
10425
|
+
}
|
|
10426
|
+
}
|
|
10427
|
+
const routes = [];
|
|
10428
|
+
let dropped = 0;
|
|
10429
|
+
for (const [methodsKey, bodies] of byMethods) {
|
|
10430
|
+
const methods = methodsKey === "" ? void 0 : JSON.parse(methodsKey);
|
|
10431
|
+
const chunks = [];
|
|
10432
|
+
let srcLen = 0;
|
|
10433
|
+
for (const body of bodies) {
|
|
10434
|
+
if (SHADOW_SRC_WRAPPER_LENGTH + body.length > MAX_ROUTE_SRC_LENGTH) {
|
|
10435
|
+
dropped += 1;
|
|
10436
|
+
continue;
|
|
10437
|
+
}
|
|
10438
|
+
const current = chunks[chunks.length - 1];
|
|
10439
|
+
if (current && srcLen + 1 + body.length <= MAX_ROUTE_SRC_LENGTH) {
|
|
10440
|
+
current.push(body);
|
|
10441
|
+
srcLen += 1 + body.length;
|
|
10442
|
+
} else {
|
|
10443
|
+
chunks.push([body]);
|
|
10444
|
+
srcLen = SHADOW_SRC_WRAPPER_LENGTH + body.length;
|
|
10445
|
+
}
|
|
10446
|
+
}
|
|
10447
|
+
for (const chunk of chunks) {
|
|
10448
|
+
routes.push({
|
|
10449
|
+
src: shadowSrc(chunk),
|
|
10450
|
+
dest: `/${lambdaPath}`,
|
|
10451
|
+
...methods ? { methods } : {},
|
|
10452
|
+
transforms: [
|
|
10453
|
+
{ type: "request.path", op: "set", args: "/$1" }
|
|
10454
|
+
]
|
|
10455
|
+
});
|
|
10433
10456
|
}
|
|
10434
10457
|
}
|
|
10435
10458
|
if (dropped > 0) {
|
|
@@ -10437,20 +10460,16 @@ function fastapiShadowingRoutes(discovery, lambdaPath) {
|
|
|
10437
10460
|
`FastAPI: ${dropped} shadow route(s) over the ${MAX_ROUTE_SRC_LENGTH} char cap left unshadowed`
|
|
10438
10461
|
);
|
|
10439
10462
|
}
|
|
10440
|
-
return
|
|
10441
|
-
src: shadowSrc(bodies),
|
|
10442
|
-
dest: `/${lambdaPath}`,
|
|
10443
|
-
transforms: [
|
|
10444
|
-
{ type: "request.path", op: "set", args: "/$1" }
|
|
10445
|
-
]
|
|
10446
|
-
}));
|
|
10463
|
+
return routes;
|
|
10447
10464
|
}
|
|
10448
10465
|
function escapeRegex(text) {
|
|
10449
10466
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
10450
10467
|
}
|
|
10451
10468
|
var NAVIGATION_ACCEPT_RE = ".*(?:^|[,\\s])(?:text/html|application/xhtml\\+xml)(?=[,;\\s]|$)(?![^,]*;\\s*q=0(?:\\.0+)?(?:[,;\\s]|$)).*";
|
|
10452
10469
|
function fastapiFallbackRoutes(discovery) {
|
|
10453
|
-
const mountPrefixes = discovery.
|
|
10470
|
+
const mountPrefixes = discovery.collectedMounts.map(
|
|
10471
|
+
(m) => trimTrailingSlashes(m.urlPath)
|
|
10472
|
+
);
|
|
10454
10473
|
return discovery.fallbacks.flatMap((fb) => {
|
|
10455
10474
|
const prefix = trimTrailingSlashes(fb.urlPath);
|
|
10456
10475
|
const nested = mountPrefixes.filter((urlPath) => urlPath.startsWith(`${prefix}/`)).map((urlPath) => urlPath.slice(prefix.length + 1));
|
|
@@ -10485,6 +10504,51 @@ function fastapiFallbackRoutes(discovery) {
|
|
|
10485
10504
|
];
|
|
10486
10505
|
});
|
|
10487
10506
|
}
|
|
10507
|
+
function getFastAPICdnBundle(collectedMounts, workPath, fastapiConfig) {
|
|
10508
|
+
if (fastapiConfig?.static?.exclude === false)
|
|
10509
|
+
return { excludePatterns: [], directoryStubs: {}, redirects: [] };
|
|
10510
|
+
const excludePatterns = [];
|
|
10511
|
+
const directoryStubs = {};
|
|
10512
|
+
const redirects = [];
|
|
10513
|
+
for (const mount of collectedMounts) {
|
|
10514
|
+
const relDir = (0, import_path17.relative)(workPath, mount.directory);
|
|
10515
|
+
if (!relDir || relDir.startsWith("..") || (0, import_path17.isAbsolute)(relDir))
|
|
10516
|
+
continue;
|
|
10517
|
+
const relDirUnix = relDir.split(import_path17.sep).join("/");
|
|
10518
|
+
excludePatterns.push(`${relDirUnix}/**`);
|
|
10519
|
+
directoryStubs[`${relDirUnix}/.static-placeholder`] = new import_build_utils20.FileBlob({
|
|
10520
|
+
data: ""
|
|
10521
|
+
});
|
|
10522
|
+
for (const fallbackFile of ["index.html", "404.html"]) {
|
|
10523
|
+
const fsPath = (0, import_path17.join)(mount.directory, fallbackFile);
|
|
10524
|
+
if (import_fs17.default.existsSync(fsPath)) {
|
|
10525
|
+
directoryStubs[`${relDirUnix}/${fallbackFile}`] = new import_build_utils20.FileFsRef({
|
|
10526
|
+
fsPath
|
|
10527
|
+
});
|
|
10528
|
+
}
|
|
10529
|
+
}
|
|
10530
|
+
if (mount.frontend) {
|
|
10531
|
+
const urlPrefix = mount.urlPath.replace(/^\/+|\/+$/g, "");
|
|
10532
|
+
const addBareRedirects = (absDir) => {
|
|
10533
|
+
for (const entry of import_fs17.default.readdirSync(absDir, { withFileTypes: true })) {
|
|
10534
|
+
if (!entry.isDirectory())
|
|
10535
|
+
continue;
|
|
10536
|
+
const subAbs = (0, import_path17.join)(absDir, entry.name);
|
|
10537
|
+
const subPath = (0, import_path17.relative)(mount.directory, subAbs).split(import_path17.sep).join("/");
|
|
10538
|
+
const urlPath = urlPrefix ? `${urlPrefix}/${subPath}` : subPath;
|
|
10539
|
+
redirects.push({
|
|
10540
|
+
src: `^/${escapeRegex(urlPath)}$`,
|
|
10541
|
+
dest: `/${urlPath}/`,
|
|
10542
|
+
status: 307
|
|
10543
|
+
});
|
|
10544
|
+
addBareRedirects(subAbs);
|
|
10545
|
+
}
|
|
10546
|
+
};
|
|
10547
|
+
addBareRedirects(mount.directory);
|
|
10548
|
+
}
|
|
10549
|
+
}
|
|
10550
|
+
return { excludePatterns, directoryStubs, redirects };
|
|
10551
|
+
}
|
|
10488
10552
|
|
|
10489
10553
|
// src/index.ts
|
|
10490
10554
|
var import_python_analysis12 = require("@vercel/python-analysis");
|
|
@@ -11703,12 +11767,14 @@ var build = async ({
|
|
|
11703
11767
|
cliType,
|
|
11704
11768
|
lockfileVersion,
|
|
11705
11769
|
packageJsonPackageManager,
|
|
11770
|
+
packageJsonDevEngines,
|
|
11706
11771
|
turboSupportsCorepackHome
|
|
11707
11772
|
} = await (0, import_build_utils24.scanParentDirs)(workPath, true);
|
|
11708
11773
|
spawnEnv = (0, import_build_utils24.getEnvForPackageManager)({
|
|
11709
11774
|
cliType,
|
|
11710
11775
|
lockfileVersion,
|
|
11711
11776
|
packageJsonPackageManager,
|
|
11777
|
+
packageJsonDevEngines,
|
|
11712
11778
|
env: process.env,
|
|
11713
11779
|
turboSupportsCorepackHome,
|
|
11714
11780
|
projectCreatedAt: config?.projectSettings?.createdAt
|
|
@@ -12109,11 +12175,22 @@ var build = async ({
|
|
|
12109
12175
|
);
|
|
12110
12176
|
}
|
|
12111
12177
|
}
|
|
12178
|
+
const excludeFilesPatterns = typeof config?.excludeFiles === "string" ? [config.excludeFiles] : Array.isArray(config?.excludeFiles) ? config.excludeFiles : [];
|
|
12179
|
+
const cdnBundle = getFastAPICdnBundle(
|
|
12180
|
+
fastapiStatic?.collectedMounts ?? [],
|
|
12181
|
+
workPath,
|
|
12182
|
+
pythonPackage.manifest?.data?.tool?.vercel?.fastapi
|
|
12183
|
+
);
|
|
12112
12184
|
const globOptions = {
|
|
12113
12185
|
cwd: workPath,
|
|
12114
|
-
ignore:
|
|
12186
|
+
ignore: [
|
|
12187
|
+
...predefinedExcludes,
|
|
12188
|
+
...excludeFilesPatterns,
|
|
12189
|
+
...cdnBundle.excludePatterns
|
|
12190
|
+
]
|
|
12115
12191
|
};
|
|
12116
12192
|
const files = await (0, import_build_utils24.glob)("**", globOptions);
|
|
12193
|
+
Object.assign(files, cdnBundle.directoryStubs);
|
|
12117
12194
|
const appPythonSourceFiles = Object.keys(files).filter((file) => file.endsWith(".py")).map((file) => (0, import_path21.join)(workPath, file)).sort();
|
|
12118
12195
|
if (djangoStatic?.manifestRelPath) {
|
|
12119
12196
|
files[djangoStatic.manifestRelPath] = new import_build_utils24.FileFsRef({
|
|
@@ -12634,6 +12711,7 @@ var build = async ({
|
|
|
12634
12711
|
const shadowingRoutes = fastapiStatic ? fastapiShadowingRoutes(fastapiStatic, lambdaPath) : [];
|
|
12635
12712
|
const fallbackRoutes = fastapiStatic ? fastapiFallbackRoutes(fastapiStatic) : [];
|
|
12636
12713
|
const routes = isNonWebService || !output ? queueRoutes.length > 0 ? queueRoutes : void 0 : [
|
|
12714
|
+
...cdnBundle.redirects,
|
|
12637
12715
|
...shadowingRoutes,
|
|
12638
12716
|
{ handle: "filesystem" },
|
|
12639
12717
|
...queueRoutes,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/python",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://vercel.com/docs/runtimes#official-runtimes/python",
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"directory": "packages/python"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@vercel/python-analysis": "0.
|
|
17
|
+
"@vercel/python-analysis": "0.14.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@vercel/build-utils": "14.3.0"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|
|
20
23
|
"@renovatebot/pep440": "4.2.1",
|
|
@@ -35,15 +38,16 @@
|
|
|
35
38
|
"smol-toml": "1.5.2",
|
|
36
39
|
"vitest": "4.1.10",
|
|
37
40
|
"which": "3.0.0",
|
|
41
|
+
"@vercel/build-utils": "14.3.0",
|
|
38
42
|
"@vercel/error-utils": "2.2.1",
|
|
39
|
-
"@vercel/
|
|
40
|
-
"@vercel/python-runtime": "0.21.0"
|
|
43
|
+
"@vercel/python-runtime": "0.21.1"
|
|
41
44
|
},
|
|
42
45
|
"scripts": {
|
|
43
46
|
"build": "node ../../utils/build-builder.mjs",
|
|
44
47
|
"type-check": "tsc --noEmit",
|
|
45
48
|
"test": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts",
|
|
46
49
|
"test-unit": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts test/unit",
|
|
47
|
-
"test-e2e": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts test/integration-"
|
|
50
|
+
"test-e2e": "cross-env VERCEL_FORCE_PYTHON_STREAMING=1 NODE_OPTIONS=--experimental-vm-modules vitest run --config ../../vitest.config.mts test/integration-",
|
|
51
|
+
"test-e2e-builder": "pnpm run test-e2e"
|
|
48
52
|
}
|
|
49
53
|
}
|
|
@@ -170,7 +170,7 @@ def _subtree_shadow(prefix: str, mounts: list[StaticMount]) -> str:
|
|
|
170
170
|
|
|
171
171
|
|
|
172
172
|
def _index_subdir_shadows(url_prefix: str, directory: str) -> set[str]:
|
|
173
|
-
"""
|
|
173
|
+
"""Regex bodies for subdirectories that hold an index.html.
|
|
174
174
|
|
|
175
175
|
A directory with an index.html diverges on the CDN. StaticFiles(html=True)
|
|
176
176
|
and frontends 307 the bare directory URL to its trailing-slash form and serve
|
|
@@ -190,8 +190,8 @@ def _index_subdir_shadows(url_prefix: str, directory: str) -> set[str]:
|
|
|
190
190
|
return shadows
|
|
191
191
|
|
|
192
192
|
|
|
193
|
-
def _divergent_url_shadows(mount: StaticMount, html: bool) ->
|
|
194
|
-
"""Shadow
|
|
193
|
+
def _divergent_url_shadows(mount: StaticMount, html: bool) -> list[ShadowRoute]:
|
|
194
|
+
"""Shadow routes for directory URLs the CDN and the app serve differently.
|
|
195
195
|
|
|
196
196
|
When a directory holds an index.html, the app and the CDN serve that
|
|
197
197
|
directory's URL differently. This affects every such subdirectory and the
|
|
@@ -206,13 +206,16 @@ def _divergent_url_shadows(mount: StaticMount, html: bool) -> set[str]:
|
|
|
206
206
|
|
|
207
207
|
html=False shadows both forms. The app 404s both while the CDN serves the
|
|
208
208
|
index, so both must reach the Lambda.
|
|
209
|
+
|
|
210
|
+
Directory redirects are GET-only: HEAD for bare dirs is handled by the
|
|
211
|
+
builder's CDN redirect routes placed before shadow routes.
|
|
209
212
|
"""
|
|
210
|
-
|
|
213
|
+
bodies = _index_subdir_shadows(mount.urlPath, mount.directory)
|
|
211
214
|
if not (html and mount.urlPath == "/"):
|
|
212
|
-
|
|
215
|
+
bodies.add(_escape_path(mount.urlPath))
|
|
213
216
|
if html:
|
|
214
|
-
|
|
215
|
-
return
|
|
217
|
+
bodies = {f"{body}(?!/)" for body in bodies}
|
|
218
|
+
return [ShadowRoute(body=body, methods=("GET",)) for body in bodies]
|
|
216
219
|
|
|
217
220
|
|
|
218
221
|
def _frontend_groups(router: Router) -> list[_FrontendRouteGroup]:
|
|
@@ -247,6 +250,22 @@ def _effective_route_contexts(route: BaseRoute) -> list[_EffectiveRouteContext]:
|
|
|
247
250
|
return []
|
|
248
251
|
|
|
249
252
|
|
|
253
|
+
@dataclass(frozen=True)
|
|
254
|
+
class ShadowRoute:
|
|
255
|
+
"""One shadow-route entry in the builder output.
|
|
256
|
+
|
|
257
|
+
`body` is the regex body for the route src; `methods` is the list of HTTP
|
|
258
|
+
methods the shadow applies to (from the API route that declared it), or
|
|
259
|
+
None when the shadow must match all methods.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
body: str
|
|
263
|
+
methods: tuple[str, ...] | None # sorted tuple, or None for all
|
|
264
|
+
|
|
265
|
+
def to_dict(self) -> dict[str, object]:
|
|
266
|
+
return {"body": self.body, "methods": list(self.methods) if self.methods is not None else None}
|
|
267
|
+
|
|
268
|
+
|
|
250
269
|
@dataclass(frozen=True)
|
|
251
270
|
class PriorRoute:
|
|
252
271
|
"""An HTTP route that outranks static files.
|
|
@@ -255,10 +274,12 @@ class PriorRoute:
|
|
|
255
274
|
(Lambda), not the CDN, so FastAPI's declaration-order precedence is
|
|
256
275
|
preserved. `path_format` is the full route path (e.g. "/items/{id}") and
|
|
257
276
|
`convertors` maps each `{name}` placeholder to its Starlette convertor.
|
|
277
|
+
`methods` is the set of HTTP methods the route handles, or None for all.
|
|
258
278
|
"""
|
|
259
279
|
|
|
260
280
|
path_format: str
|
|
261
281
|
convertors: dict[str, Convertor]
|
|
282
|
+
methods: frozenset[str] | None = None
|
|
262
283
|
|
|
263
284
|
def shadows(self, mount: StaticMount) -> bool:
|
|
264
285
|
"""True if this route can match paths the mount serves.
|
|
@@ -336,9 +357,31 @@ class Precedence:
|
|
|
336
357
|
def add_route(self, route: PriorRoute) -> None:
|
|
337
358
|
self.routes.append(route)
|
|
338
359
|
|
|
339
|
-
def shadow_bodies(self, mount: StaticMount) ->
|
|
340
|
-
"""Shadow-route
|
|
341
|
-
|
|
360
|
+
def shadow_bodies(self, mount: StaticMount) -> list[ShadowRoute]:
|
|
361
|
+
"""Shadow-route body+methods for every prior route that shadows `mount`.
|
|
362
|
+
|
|
363
|
+
When a route declares GET, HEAD is added to the shadow methods. HTTP
|
|
364
|
+
semantics tie HEAD to GET, so the Lambda (not the CDN) should handle
|
|
365
|
+
HEAD on API-owned paths and return 405 when HEAD is not declared.
|
|
366
|
+
"""
|
|
367
|
+
result = []
|
|
368
|
+
for r in self.routes:
|
|
369
|
+
if not r.shadows(mount):
|
|
370
|
+
continue
|
|
371
|
+
methods = r.methods
|
|
372
|
+
# With plain StaticFiles, HEAD bypasses the GET route and the
|
|
373
|
+
# mount serves it (200), so CDN can do the same without Lambda.
|
|
374
|
+
# With app.frontend(), HEAD on a GET route path returns 405.
|
|
375
|
+
# Shadow HEAD only for frontend mounts.
|
|
376
|
+
if methods is not None and "GET" in methods and mount.frontend:
|
|
377
|
+
methods = methods | {"HEAD"}
|
|
378
|
+
result.append(
|
|
379
|
+
ShadowRoute(
|
|
380
|
+
body=r.shadow_body,
|
|
381
|
+
methods=tuple(sorted(methods)) if methods is not None else None,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return result
|
|
342
385
|
|
|
343
386
|
def add_mount(self, url_prefix: str) -> None:
|
|
344
387
|
self.mount_prefixes.append(url_prefix.rstrip("/"))
|
|
@@ -349,24 +392,29 @@ class Precedence:
|
|
|
349
392
|
return any(p == q or p.startswith(q + "/") for q in self.mount_prefixes)
|
|
350
393
|
|
|
351
394
|
|
|
395
|
+
def _subtree_shadow_route(prefix: str, mounts: list[StaticMount]) -> ShadowRoute:
|
|
396
|
+
"""A ShadowRoute routing a mounted sub-app's whole subtree to the Lambda."""
|
|
397
|
+
return ShadowRoute(body=_subtree_shadow(prefix, mounts), methods=None)
|
|
398
|
+
|
|
399
|
+
|
|
352
400
|
def _collect_mount(
|
|
353
401
|
route: Mount, prefix: str, prior: Precedence
|
|
354
|
-
) -> tuple[list[StaticMount],
|
|
402
|
+
) -> tuple[list[StaticMount], list[ShadowRoute]]:
|
|
355
403
|
"""Discover one app.mount(): a StaticFiles mount serves from the CDN, a
|
|
356
404
|
Router/sub-app recurses, and a raw ASGI app owns its subtree opaquely.
|
|
357
405
|
"""
|
|
358
406
|
url_prefix = prefix + route.path
|
|
359
407
|
if prior.eclipses(url_prefix):
|
|
360
|
-
return [],
|
|
408
|
+
return [], []
|
|
361
409
|
|
|
362
410
|
mounts: list[StaticMount] = []
|
|
363
|
-
shadow_routes:
|
|
411
|
+
shadow_routes: list[ShadowRoute] = []
|
|
364
412
|
|
|
365
413
|
static = StaticMount.from_route(route, prefix, frontend=False)
|
|
366
414
|
if static:
|
|
367
415
|
mounts.append(static)
|
|
368
|
-
shadow_routes
|
|
369
|
-
shadow_routes
|
|
416
|
+
shadow_routes += prior.shadow_bodies(static)
|
|
417
|
+
shadow_routes += _divergent_url_shadows(static, html=route.app.html)
|
|
370
418
|
|
|
371
419
|
# A Starlette/FastAPI sub-app isn't a Router but exposes one as `.router`;
|
|
372
420
|
# a StaticFiles or raw ASGI app exposes neither.
|
|
@@ -381,13 +429,13 @@ def _collect_mount(
|
|
|
381
429
|
sub_prefix = prefix + route.path.rstrip("/")
|
|
382
430
|
sub_mounts, sub_shadow = collect(sub_router, sub_prefix, prior)
|
|
383
431
|
mounts.extend(sub_mounts)
|
|
384
|
-
shadow_routes
|
|
385
|
-
shadow_routes.
|
|
432
|
+
shadow_routes += sub_shadow
|
|
433
|
+
shadow_routes.append(_subtree_shadow_route(sub_prefix, sub_mounts))
|
|
386
434
|
elif static is None:
|
|
387
435
|
# A raw ASGI app (e.g. WSGIMiddleware) owns its subtree with nothing on
|
|
388
436
|
# the CDN. Shadow it so a lower-priority source's leaked copy there
|
|
389
437
|
# routes to the Lambda.
|
|
390
|
-
shadow_routes.
|
|
438
|
+
shadow_routes.append(_subtree_shadow_route(url_prefix, []))
|
|
391
439
|
|
|
392
440
|
prior.add_mount(url_prefix)
|
|
393
441
|
return mounts, shadow_routes
|
|
@@ -397,23 +445,32 @@ def collect(
|
|
|
397
445
|
router: Router,
|
|
398
446
|
prefix: str = "",
|
|
399
447
|
prior: Precedence | None = None,
|
|
400
|
-
) -> tuple[list[StaticMount],
|
|
401
|
-
"""Walk the route table for (static mounts to copy, shadow
|
|
448
|
+
) -> tuple[list[StaticMount], list[ShadowRoute]]:
|
|
449
|
+
"""Walk the route table for (static mounts to copy, shadow routes).
|
|
450
|
+
|
|
451
|
+
Shadow routes are merged so that the same path covered by multiple API
|
|
452
|
+
routes with different methods produces one entry with the union of methods.
|
|
453
|
+
"""
|
|
402
454
|
prior = prior.child() if prior else Precedence()
|
|
403
455
|
|
|
404
456
|
mounts: list[StaticMount] = []
|
|
405
|
-
shadow_routes:
|
|
457
|
+
shadow_routes: list[ShadowRoute] = []
|
|
406
458
|
frontends: list[StaticMount] = []
|
|
407
459
|
|
|
460
|
+
def _route_methods(route: Route) -> frozenset[str] | None:
|
|
461
|
+
"""HTTP methods for a Starlette Route, or None for no restriction."""
|
|
462
|
+
m = getattr(route, "methods", None)
|
|
463
|
+
return frozenset(m) if m else None
|
|
464
|
+
|
|
408
465
|
def collect_mount(mount_route: Mount) -> None:
|
|
409
466
|
sub_mounts, sub_shadow = _collect_mount(mount_route, prefix, prior)
|
|
410
467
|
mounts.extend(sub_mounts)
|
|
411
|
-
shadow_routes.
|
|
468
|
+
shadow_routes.extend(sub_shadow)
|
|
412
469
|
|
|
413
470
|
for route in router.routes:
|
|
414
471
|
if isinstance(route, Route):
|
|
415
472
|
prior.add_route(
|
|
416
|
-
PriorRoute(prefix + route.path_format, route.param_convertors)
|
|
473
|
+
PriorRoute(prefix + route.path_format, route.param_convertors, _route_methods(route))
|
|
417
474
|
)
|
|
418
475
|
elif isinstance(route, Mount):
|
|
419
476
|
collect_mount(route)
|
|
@@ -433,10 +490,12 @@ def collect(
|
|
|
433
490
|
if ctx.starlette_route is not None:
|
|
434
491
|
path = ctx.starlette_route.path_format
|
|
435
492
|
convertors = ctx.starlette_route.param_convertors
|
|
493
|
+
methods = _route_methods(ctx.starlette_route)
|
|
436
494
|
else:
|
|
437
495
|
path = ctx.path
|
|
438
496
|
convertors = ctx.param_convertors
|
|
439
|
-
|
|
497
|
+
methods = _route_methods(ctx.original_route)
|
|
498
|
+
prior.add_route(PriorRoute(prefix + path, convertors, methods))
|
|
440
499
|
|
|
441
500
|
# app.include_router() with a frontend build.
|
|
442
501
|
for ctx in _effective_low_priority_routes(route):
|
|
@@ -464,24 +523,66 @@ def collect(
|
|
|
464
523
|
if prior.eclipses(m.urlPath):
|
|
465
524
|
continue
|
|
466
525
|
mounts.append(m)
|
|
467
|
-
shadow_routes
|
|
526
|
+
shadow_routes += prior.shadow_bodies(m)
|
|
468
527
|
# A frontend is html=True StaticFiles, so its URLs diverge the same way.
|
|
469
|
-
shadow_routes
|
|
528
|
+
shadow_routes += _divergent_url_shadows(m, html=True)
|
|
470
529
|
|
|
471
|
-
return mounts, shadow_routes
|
|
530
|
+
return mounts, _merge_shadow_routes(shadow_routes)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _merge_shadow_routes(routes: list[ShadowRoute]) -> list[ShadowRoute]:
|
|
534
|
+
"""Combine shadow routes that share a body by unioning their methods.
|
|
535
|
+
|
|
536
|
+
When the same path is covered by multiple API routes with different methods
|
|
537
|
+
(e.g. @app.get and @app.post on the same path), each produces a separate
|
|
538
|
+
ShadowRoute. Merging them emits one CDN route whose `methods` covers all
|
|
539
|
+
the declared methods. A None methods entry (match-all) subsumes any
|
|
540
|
+
specific set.
|
|
541
|
+
"""
|
|
542
|
+
merged: dict[str, frozenset[str] | None] = {}
|
|
543
|
+
for route in routes:
|
|
544
|
+
methods = frozenset(route.methods) if route.methods is not None else None
|
|
545
|
+
if route.body not in merged:
|
|
546
|
+
merged[route.body] = methods
|
|
547
|
+
else:
|
|
548
|
+
existing = merged[route.body]
|
|
549
|
+
if existing is None or methods is None:
|
|
550
|
+
merged[route.body] = None # match-all subsumes everything
|
|
551
|
+
else:
|
|
552
|
+
merged[route.body] = existing | methods
|
|
553
|
+
return sorted(
|
|
554
|
+
[
|
|
555
|
+
ShadowRoute(
|
|
556
|
+
body=body,
|
|
557
|
+
methods=tuple(sorted(m)) if m is not None else None,
|
|
558
|
+
)
|
|
559
|
+
for body, m in merged.items()
|
|
560
|
+
],
|
|
561
|
+
key=lambda sr: sr.body,
|
|
562
|
+
)
|
|
472
563
|
|
|
473
564
|
|
|
474
565
|
@dataclass(frozen=True)
|
|
475
566
|
class Output:
|
|
476
|
-
"""The JSON document written for the builder; field names are the JSON keys.
|
|
567
|
+
"""The JSON document written for the builder; field names are the JSON keys.
|
|
568
|
+
|
|
569
|
+
Each shadowRoutes entry is {"body": str, "methods": list[str] | None}.
|
|
570
|
+
`methods=None` means the shadow applies to all HTTP methods.
|
|
571
|
+
"""
|
|
477
572
|
|
|
478
573
|
mounts: list[StaticMount] = field(default_factory=list)
|
|
479
|
-
shadowRoutes: list[
|
|
574
|
+
shadowRoutes: list[ShadowRoute] = field(default_factory=list)
|
|
575
|
+
|
|
576
|
+
def to_dict(self) -> dict[str, object]:
|
|
577
|
+
return {
|
|
578
|
+
"mounts": [asdict(m) for m in self.mounts],
|
|
579
|
+
"shadowRoutes": [sr.to_dict() for sr in self.shadowRoutes],
|
|
580
|
+
}
|
|
480
581
|
|
|
481
582
|
|
|
482
583
|
def write_output(output_path: str, discovery: Output) -> None:
|
|
483
584
|
with open(output_path, "w") as f:
|
|
484
|
-
json.dump(
|
|
585
|
+
json.dump(discovery.to_dict(), f)
|
|
485
586
|
|
|
486
587
|
|
|
487
588
|
def discover(entrypoint_abs: str, variable_name: str) -> Output:
|
|
@@ -503,7 +604,7 @@ def discover(entrypoint_abs: str, variable_name: str) -> Output:
|
|
|
503
604
|
return Output()
|
|
504
605
|
|
|
505
606
|
mounts, shadow_routes = collect(router)
|
|
506
|
-
return Output(mounts=mounts, shadowRoutes=
|
|
607
|
+
return Output(mounts=mounts, shadowRoutes=shadow_routes)
|
|
507
608
|
|
|
508
609
|
|
|
509
610
|
def main() -> None:
|