sveltekit-python-vercel 0.4.1 → 1.0.3-beta.53c34c5

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  <p align="middle">
2
2
  <img width="100" alt="image" src="https://user-images.githubusercontent.com/20548516/218344678-d41f4c4a-6b1b-48cc-8553-2b9fbe2169d6.png"/>
3
- <img width="100" alt="image" src="https://camo.githubusercontent.com/f1ac9955f30176e6183aeeeac1b77354c7a132696fdc77c06ef0f0bec30f258c/68747470733a2f2f6861636b616461792e636f6d2f77702d636f6e74656e742f75706c6f6164732f323031392f30392f707974686f6e2d6c6f676f2e706e67"/>
4
- <img width="100" alt="image" src="https://camo.githubusercontent.com/add2c9721e333f0043ac938f3dadbc26a282776e01b95b308fcaba5afaf74ae3/68747470733a2f2f6173736574732e76657263656c2e636f6d2f696d6167652f75706c6f61642f76313538383830353835382f7265706f7369746f726965732f76657263656c2f6c6f676f2e706e67"/>
3
+ <img width="100" alt="image" src="https://avatars.githubusercontent.com/u/1525981?s=200&v=4"/>
4
+ <img width="100" alt="image" src="https://avatars.githubusercontent.com/u/14985020?s=200&v=4"/>
5
5
  </p>
6
6
 
7
7
  # sveltekit-python-vercel
@@ -22,7 +22,8 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
22
22
  ## Current Features
23
23
 
24
24
  - Write `+server.py` files nearly the same way you would write `+server.js` files
25
- - Deploy (quasi) automatically to Vercel Serverless
25
+ - Write server `load` functions in `+page.server.py` and `+layout.server.py`
26
+ - Deploy automatically to Vercel Serverless (Python 3.12 runtime)
26
27
 
27
28
  ## Installing
28
29
 
@@ -36,17 +37,15 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
36
37
  import { sveltekit } from "@sveltejs/kit/vite";
37
38
  import { sveltekit_python_vercel } from "sveltekit-python-vercel/vite";
38
39
 
39
- export default defineConfig(({ command, mode }) => {
40
- return {
41
- plugins: [sveltekit_python_vercel(), sveltekit()],
42
- };
40
+ export default defineConfig({
41
+ plugins: [sveltekit(), ...(await sveltekit_python_vercel())],
43
42
  });
44
43
  ```
45
44
 
46
45
  - Update your `svelte.config.js`:
47
46
 
48
47
  ```javascript
49
- import adapter from "@sveltejs/adapter-vercel"; // Use the vercel adapter
48
+ import adapter from "@sveltejs/adapter-vercel";
50
49
  import { vitePreprocess } from "@sveltejs/kit/vite";
51
50
 
52
51
  /** @type {import('@sveltejs/kit').Config} */
@@ -63,23 +62,12 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
63
62
 
64
63
  - Update your `vercel.json`
65
64
 
66
- - The build command prepares all your endpoints and copies them to the `/api` directory where Vercel looks for functions
67
- - Functions and Routes tell Vercel how to run and redirect function calls
65
+ - The build command first runs `vite build` (which generates `.vercel/output/` via the SvelteKit adapter), then runs our script to write the Python function into that same output directory and patch the routing config.
66
+ - No `routes` or `functions` keys are needed routing is handled automatically via the [Vercel Build Output API](https://vercel.com/docs/build-output-api/v3).
68
67
 
69
68
  ```json
70
69
  {
71
- "buildCommand": "node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs; vite build",
72
- "functions": {
73
- "api/**/*.py": {
74
- "runtime": "@vercel/python@3.0.7"
75
- }
76
- },
77
- "routes": [
78
- {
79
- "src": "/api/(.*)",
80
- "dest": "api/index.py"
81
- }
82
- ]
70
+ "buildCommand": "vite build; node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs"
83
71
  }
84
72
  ```
85
73
 
@@ -87,112 +75,59 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
87
75
 
88
76
  ## Testing Locally
89
77
 
90
- Using [Poetry](https://python-poetry.org/) to manage your virtual environments with this package is recommended.
91
-
92
- - Run `poetry init` to create a new virtual environment, and follow the steps. Or simply create a `pyproject.toml` like the one below.
93
-
94
- ```toml
95
- [tool.poetry]
96
- name = "sveltekit-python-example"
97
- version = "0.1.0"
98
- description = ""
99
- authors = ["Your Name <email@gmail.com>"]
100
- readme = "README.md"
101
-
102
- [tool.poetry.dependencies]
103
- python = "^3.9"
104
- fastapi = "^0.95.2"
105
- uvicorn = "^0.22.0"
106
-
107
- [tool.poetry.group.dev.dependencies]
108
- watchfiles = "^0.19.0"
109
-
110
- [build-system]
111
- requires = ["poetry-core"]
112
- build-backend = "poetry.core.masonry.api"
113
- ```
114
-
115
- - Required packages are python3.9 (that is what Vercel's runtime uses), `fastapi`, and `uvicorn`.
116
- - Install whatever other dependencies you need from pypi using `poetry add package-name`
78
+ [uv](https://docs.astral.sh/uv/) is recommended for managing your Python environment.
117
79
 
118
- - Enter your virtual env with `poetry shell`
119
- - Run `pnpm dev` or `npm dev`
120
- - You should see both the usual SvelteKit server start as well as the unvicorn server (by default on `http://0.0.0.0:8000`) in the console.
80
+ - Run `uv init --python 3.12` to create a `pyproject.toml` pinned to Python 3.12 (the same version Vercel's runtime uses).
81
+ - Add the required packages: `uv add fastapi uvicorn`
82
+ - Add any other dependencies you need: `uv add numpy pandas ...`
83
+ - Run your dev server inside uv:
84
+ - `uv run pnpm dev`
85
+ - You should see both the usual SvelteKit server start and the uvicorn server (by default on `http://0.0.0.0:8000`) in the console.
121
86
 
122
87
  ## Deploying to Vercel
123
88
 
124
- - At the moment this requires a tiny bit of extra labor besides just pushing to your repository. I believe this is because of the way Vercel looks for serverless functions, but I hope to make this a bit easier in the future.
89
+ Just push to your repository no extra steps required.
125
90
 
126
- - When you make changes to your python endpoints, you have to manually regenerate the `/api` folder by running:
127
- 1. `poetry export -f requirements.txt --output requirements.txt --without-hashes`
128
- 2. `node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs`
129
- - Then commit `requirements.txt` and the changes in `/api` and push.
130
-
131
- Note:
132
-
133
- - To make this a bit smoother, you can add a script to you `package.json`:
134
- ```json
135
- "scripts": {
136
- ...
137
- "py-update": "poetry export -f requirements.txt --output requirements.txt --without-hashes; node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs"
138
- }
139
- ```
140
- - and then just run `pnpm py-update`
91
+ - The `buildCommand` in `vercel.json` handles everything automatically:
92
+ 1. `vite build` runs the SvelteKit build and writes `.vercel/output/` via `@sveltejs/adapter-vercel`
93
+ 2. `bin.mjs` then writes your Python endpoints into `.vercel/output/functions/` using the [Build Output API](https://vercel.com/docs/build-output-api/v3) and patches the routing config
94
+ - Your `+server.py` files and dependency declarations (`requirements.txt`, `pyproject.toml`, `Pipfile`, etc.) are bundled automatically — there is no need to commit an `/api` folder or manually generate a `requirements.txt`.
95
+ - Python packages are pre-installed into the function bundle at build time using `pip install --target`, so they are available in Vercel's raw Python 3.12 Lambda environment without any extra configuration.
141
96
 
142
97
  ## Example
143
98
 
144
99
  - Frontend: `/src/routes/py/+page.svelte`
145
100
 
146
101
  ```html
147
- <script lang="ts">
148
- let a = 0;
149
- let b = 0;
150
- let total = 0;
102
+ <script>
103
+ let a = $state(0);
104
+ let b = $state(0);
105
+ let total = $state(0);
151
106
 
152
107
  async function pyAddPost() {
153
- const response = await fetch("/py", {
108
+ const res = await fetch("/py", {
154
109
  method: "POST",
155
110
  body: JSON.stringify({ a, b }),
156
- headers: {
157
- "content-type": "application/json",
158
- },
111
+ headers: { "content-type": "application/json" },
159
112
  });
160
- let res = await response.json();
161
- total = res.sum;
113
+ total = (await res.json()).sum;
162
114
  }
163
115
 
164
116
  async function pyAddGet() {
165
- const response = await fetch(`/py?a=${a}&b=${b}`, {
166
- method: "GET",
167
- headers: {
168
- "content-type": "application/json",
169
- },
170
- });
171
-
172
- let res = await response.json();
173
- total = res.sum;
117
+ const res = await fetch(`/py?a=${a}&b=${b}`);
118
+ total = (await res.json()).sum;
174
119
  }
175
120
  </script>
176
121
 
177
- <h1>This is a SvelteKit page with a python backend.</h1>
178
-
179
- <h3>POST Example</h3>
180
- <form>
181
- <input type="number" name="a" placeholder="Number 1" bind:value="{a}" />
182
- <input type="number" name="b" placeholder="Number 2" bind:value="{b}" />
183
- <button on:click|preventDefault="{pyAddPost}">Add</button>
184
- </form>
185
- <h4>Total: {total}</h4>
186
-
187
- <br />
188
-
189
- <h3>GET Example</h3>
190
- <form>
191
- <input type="number" name="a" placeholder="Number 1" bind:value="{a}" />
192
- <input type="number" name="b" placeholder="Number 2" bind:value="{b}" />
193
- <button on:click|preventDefault="{pyAddGet}">Add</button>
194
- </form>
195
- <h4>Total: {total}</h4>
122
+ <h1>SvelteKit page with a Python backend</h1>
123
+
124
+ <label>a: <input type="number" bind:value={a} /></label>
125
+ <label>b: <input type="number" bind:value={b} /></label>
126
+
127
+ <button type="button" onclick={pyAddPost}>POST</button>
128
+ <button type="button" onclick={pyAddGet}>GET</button>
129
+
130
+ <p>Total: {total}</p>
196
131
  ```
197
132
 
198
133
  - Backend: `/src/routes/py/+server.py`
@@ -212,17 +147,65 @@ Note:
212
147
 
213
148
  async def GET(a: float, b: float):
214
149
  return {"sum": a + b}
215
-
216
150
  ```
217
151
 
218
152
  ### Backend Caveats
219
153
 
220
- There are currently a few things that have to be worked around.
154
+ - `GET` endpoints receive query parameters directly as function arguments. Type annotations are used for coercion (e.g. `a: float` parses `?a=3` as `3.0`).
155
+ - All other HTTP methods receive the request body as JSON. The recommended pattern is a Pydantic model as the single argument — FastAPI handles validation and parsing automatically.
156
+
157
+ ### Python load functions
158
+
159
+ Server-side `load` functions work in `+page.server.py` and `+layout.server.py`. No extra Python package install is required — the runtime is bundled automatically like `+server.py`.
160
+
161
+ ```python
162
+ async def load(event):
163
+ if event.params["id"] == "secret":
164
+ return ("redirect", 307, "/demo/public")
165
+
166
+ if event.params["id"] not in ("public", "1", "2"):
167
+ return ("error", 404, "Not found")
168
+
169
+ return {
170
+ "title": f"Item {event.params['id']}",
171
+ "parent_theme": event.parent.theme if event.parent else None,
172
+ }
173
+ ```
174
+
175
+ Errors and redirects can also use injected helpers (no import needed):
176
+
177
+ ```python
178
+ async def load(event):
179
+ if not event.cookies.get("session"):
180
+ redirect(307, "/login")
181
+ return {"ok": True}
182
+ ```
183
+
184
+ Available on `event`: `params`, `url`, `route`, `parent` (layout data), `data` (from a sibling universal load), `cookies`.
185
+
186
+ **Current limitations:** no `event.fetch`, `setHeaders`, `depends`, or page options (`prerender`, etc.) in `.py` files yet.
187
+
188
+ ## npm channels
189
+
190
+ | Tag | When it updates | Install |
191
+ |-----|-----------------|---------|
192
+ | `latest` | GitHub Release created | `pnpm add -D sveltekit-python-vercel` |
193
+ | `beta` | Every push to `main` | `pnpm add -D sveltekit-python-vercel@beta` |
194
+ | `pr-<n>` | Open/update PR `#n` (same-repo only) | `pnpm add -D sveltekit-python-vercel@pr-15` |
195
+
196
+ Beta versions look like `1.0.3-beta.abc1234` (main) or `1.0.3-beta.pr15.abc1234` (PRs).
197
+
198
+ All publishes run through `.github/workflows/publish.yml` because npm trusted publishing only allows one workflow filename per package.
199
+
200
+ ### Developing the package locally
201
+
202
+ If you work on this repo and a consumer app side by side (e.g. `test-skpv-deploy`):
221
203
 
222
- - `GET` endpoints are directly fed the parameters from the url, so when you define an endpoint
223
- - All other endpoints are fed the body as a JSON. The recommended way to deal with this is to use a pydantic model and pass it as the singular input to the function.
204
+ 1. Build: `deno run -A dnt.ts $(npm view sveltekit-python-vercel version)` (or any version string)
205
+ 2. In the consumer: `pnpm add -D sveltekit-python-vercel@link:../sveltekit-python-vercel/npm`
206
+ 3. Rebuild and restart the consumer dev server after library changes
224
207
 
225
- See the example above.
208
+ Commit `^x.y.z` or `@beta` in the consumer for Vercel — `link:` only works on your machine.
226
209
 
227
210
  ## Fork of `sveltekit-modal`
228
211
 
@@ -231,8 +214,8 @@ Check out the awesome [sveltekit-modal](https://github.com/semicognitive/sveltek
231
214
  ## Possible future plans
232
215
 
233
216
  - [X] Add hot reloading in dev mode
234
- - [ ] Generate endpoints (/api folder) automatically during build
235
- - [ ] Auto create requirements.txt from pyproject.toml (both related to vercel functions being checked/handled before build)
217
+ - [X] Generate endpoints automatically during build (via Vercel Build Output API)
218
+ - [X] Auto-bundle requirements.txt / pyproject.toml / Pipfile at build time
236
219
  - [ ] Add form actions
237
- - [ ] Add load functions
238
- - [ ] Add helper functions to automatically call API endpoints in project\
220
+ - [X] Add load functions
221
+ - [ ] Add helper functions to automatically call API endpoints in project
@@ -1,4 +1,3 @@
1
- import { loadEnv } from "vite";
2
1
  import { $ as run$, cd as cd$, which, path, chalk, } from "zx";
3
2
  const get_pyServerEndpointAsString = (app_url, serve = false) => `
4
3
  const handle = (method) => (async ({ request, fetch, url }) => {
@@ -9,9 +8,9 @@ const get_pyServerEndpointAsString = (app_url, serve = false) => `
9
8
  let fullURL;
10
9
 
11
10
  if (${serve}) {
12
- fullURL = new URL(url.pathname, new URL('${app_url}')) + url.search;
13
- } else {
14
11
  fullURL = new URL('/api' + url.pathname, new URL('${app_url}')) + url.search;
12
+ } else {
13
+ fullURL = new URL('/api' + url.pathname, url.origin) + url.search;
15
14
  }
16
15
 
17
16
  console.log(\`PY: Reached python endpoint of \${method} \${fullURL}\`)
@@ -31,6 +30,72 @@ const get_pyServerEndpointAsString = (app_url, serve = false) => `
31
30
  export const PUT = handle('PUT');
32
31
  export const DELETE = handle('DELETE');
33
32
  `;
33
+ function getLoadRouteTemplate(id) {
34
+ const marker = "/src/routes/";
35
+ const idx = id.indexOf(marker);
36
+ if (idx === -1) {
37
+ throw new Error(`Cannot derive load route from ${id}`);
38
+ }
39
+ let routePart = id.slice(idx + marker.length);
40
+ routePart = routePart.replace(/\/\+(?:page|layout)\.server\.py$/, "");
41
+ if (!routePart) {
42
+ return "/api/_load";
43
+ }
44
+ const segments = routePart
45
+ .split("/")
46
+ .filter((part) => !(part.startsWith("(") && part.endsWith(")")))
47
+ .map((part) => part.replace(/^\[(.+)\]$/, "{$1}"));
48
+ return `/api/_load/${segments.join("/")}`;
49
+ }
50
+ const get_pyLoadAsString = (loadRouteTemplate, app_url, serve = false) => `
51
+ import { error, redirect } from '@sveltejs/kit';
52
+
53
+ const LOAD_ROUTE_TEMPLATE = ${JSON.stringify(loadRouteTemplate)};
54
+
55
+ function buildLoadPath(params) {
56
+ let path = LOAD_ROUTE_TEMPLATE;
57
+ for (const [key, value] of Object.entries(params)) {
58
+ path = path.replaceAll(\`{\${key}}\`, encodeURIComponent(String(value)));
59
+ }
60
+ return path;
61
+ }
62
+
63
+ export async function load(event) {
64
+ const parent = event.parent ? await event.parent() : undefined;
65
+
66
+ const body = JSON.stringify({
67
+ params: event.params,
68
+ route: { id: event.route.id },
69
+ url: event.url.href,
70
+ parent,
71
+ data: event.data ?? undefined,
72
+ cookies: Object.fromEntries(event.cookies.getAll().map((c) => [c.name, c.value])),
73
+ });
74
+
75
+ const apiPath = buildLoadPath(event.params);
76
+ const fullURL = ${serve}
77
+ ? new URL(apiPath, new URL('${app_url}'))
78
+ : new URL(apiPath, event.url.origin);
79
+
80
+ const res = await event.fetch(fullURL, {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json' },
83
+ body,
84
+ });
85
+
86
+ const result = await res.json();
87
+
88
+ if (result.type === 'redirect') redirect(result.status, result.location);
89
+ if (result.type === 'error') error(result.status, result.body);
90
+ return result.data;
91
+ }
92
+ `;
93
+ function isPyServerFile(id) {
94
+ return /\+server\.py$/.test(id);
95
+ }
96
+ function isPyLoadFile(id) {
97
+ return /\+(?:page|layout)\.server\.py$/.test(id);
98
+ }
34
99
  export async function sveltekit_python_vercel(opts = {}) {
35
100
  const child_processes = [];
36
101
  async function kill_all_process() {
@@ -48,7 +113,6 @@ export async function sveltekit_python_vercel(opts = {}) {
48
113
  },
49
114
  async configureServer({ config }) {
50
115
  const packagelocation = path.join(config.root, "node_modules", "sveltekit-python-vercel", "esm/src/vite");
51
- // copy asll +server.py files to package directory
52
116
  run$.verbose = false;
53
117
  run$.env.PYTHONDONTWRITEBYTECODE = "1";
54
118
  cd$(packagelocation);
@@ -59,10 +123,9 @@ export async function sveltekit_python_vercel(opts = {}) {
59
123
  child_processes.push(local_process);
60
124
  sveltekit_url ??= new URL(`http://${host}:${port}`);
61
125
  cd$(config.root);
62
- // local_process.quiet(); // let it be loud for now
63
126
  local_process.nothrow();
64
127
  local_process.stderr.on("data", (s) => {
65
- console.log(s.toString().trimEnd()); //Logs stderr always and all of stdout if 'log': True
128
+ console.log(s.toString().trimEnd());
66
129
  });
67
130
  local_process.stderr.on("error", (s) => {
68
131
  console.error(chalk.red("Error: Python Serve Failed"));
@@ -78,53 +141,42 @@ export async function sveltekit_python_vercel(opts = {}) {
78
141
  name: "vite-plugin-sveltekit_python-build",
79
142
  apply: "build",
80
143
  async configResolved(config) {
81
- console.log("PY: BUILD DEBUG");
82
144
  console.log("PY: ROOT PATH: " + config.root);
83
- console.log("PY: LOADED VERCEL URL: " + loadEnv("", config.root, "").VERCEL_URL);
84
- const packagelocation = path.join(config.root, "node_modules", "sveltekit-python-vercel", "esm/src/vite");
85
- console.log("PY: PACKAGE LOCATION: " + packagelocation);
86
- const python_path = opts.python_path ?? (await which("python3"));
87
- await run$ `cd ${packagelocation}`;
88
- await run$ `${python_path} ${packagelocation}/sveltekit_python_vercel/build.py --root ${config.root}`;
89
- // check if env var starts with http
90
- let httpPrefix = "";
91
- if (!loadEnv("", config.root, "").VERCEL_URL.startsWith("http")) {
92
- httpPrefix = "https://";
93
- }
94
- const api_url = path.join(httpPrefix + loadEnv("", config.root, "").VERCEL_URL);
95
- // get current Vercel deploy URL
96
- sveltekit_url = new URL(api_url);
97
- console.log("PY: Build API URL: " + sveltekit_url.toString());
98
145
  },
99
146
  };
147
+ const transformPyFile = (id, serve, app_url) => {
148
+ if (!/\.py$/.test(id))
149
+ return undefined;
150
+ if (isPyServerFile(id)) {
151
+ return {
152
+ code: get_pyServerEndpointAsString(app_url, serve),
153
+ map: null,
154
+ };
155
+ }
156
+ if (isPyLoadFile(id)) {
157
+ const loadRouteTemplate = getLoadRouteTemplate(id);
158
+ return {
159
+ code: get_pyLoadAsString(loadRouteTemplate, app_url, serve),
160
+ map: null,
161
+ };
162
+ }
163
+ return undefined;
164
+ };
100
165
  const plugin_py_server_endpoint_serve = {
101
166
  name: "vite-plugin-sveltekit_python-server-endpoint",
102
167
  apply: "serve",
103
168
  transform(src, id) {
104
- // console.log("Transform function called for", id); // Add this line
105
- if (/\.py$/.test(id)) {
106
- if (sveltekit_url === undefined)
107
- throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
108
- return {
109
- code: get_pyServerEndpointAsString(sveltekit_url, true),
110
- map: null, // provide source map if available
111
- };
169
+ if (sveltekit_url === undefined) {
170
+ throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
112
171
  }
172
+ return transformPyFile(id, true, sveltekit_url);
113
173
  },
114
174
  };
115
175
  const plugin_py_server_endpoint_build = {
116
176
  name: "vite-plugin-sveltekit_python-server-endpoint",
117
177
  apply: "build",
118
178
  transform(src, id) {
119
- // console.log("Transform function called for", id); // Add this line
120
- if (/\.py$/.test(id)) {
121
- if (sveltekit_url === undefined)
122
- throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
123
- return {
124
- code: get_pyServerEndpointAsString(sveltekit_url, false),
125
- map: null, // provide source map if available
126
- };
127
- }
179
+ return transformPyFile(id, false, new URL("http://localhost"));
128
180
  },
129
181
  };
130
182
  return [
@@ -0,0 +1,3 @@
1
+ from .load_runtime import error, redirect
2
+
3
+ __all__ = ["error", "redirect"]
@@ -1,45 +1,114 @@
1
1
  import argparse
2
- import shutil
3
2
  import glob
4
-
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
5
7
  from pathlib import Path, PurePosixPath
6
8
 
7
- parser = argparse.ArgumentParser(description="Run Sveltekit Python Deployment")
8
- parser.add_argument("--root", default=".", help="Root directory of Vercel project")
9
- parser.add_argument("--packagedir", default=None, help="Root directory of Vercel project")
10
- args = parser.parse_args()
9
+ sys.path.insert(0, str(Path(__file__).parent))
10
+ from routes import api_route, load_route, rel_path_from_routes, route_parent
11
11
 
12
+ parser = argparse.ArgumentParser(description="Run SvelteKit Python Deployment")
13
+ parser.add_argument("--root", default=".", help="Root directory of the SvelteKit project")
14
+ parser.add_argument("--packagedir", default=None, help="Directory of the sveltekit-python-vercel package")
15
+ args = parser.parse_args()
12
16
 
13
17
  root_dir = Path(args.root).absolute()
14
- api_dir = root_dir / "api"
18
+ routes_root = root_dir / "src/routes"
19
+
20
+ func_dir = root_dir / ".vercel" / "output" / "functions" / "api" / "index.func"
21
+ func_dir.mkdir(parents=True, exist_ok=True)
15
22
 
16
- if not api_dir.exists():
17
- api_dir.mkdir()
18
-
19
23
  if args.packagedir:
20
- shutil.copy(Path(args.packagedir).absolute() / "deploy.py", api_dir / "index.py")
21
-
22
-
23
- # Add all +server.py routes to web_app
24
- for module_path in glob.glob(str(root_dir / 'src/routes/**/+server.py'), recursive=True):
25
-
26
- # replace the root_dir with api_dir
27
- api_route = api_dir / Path(module_path).absolute().relative_to(root_dir / "src/routes")
28
-
29
- # replace square brackets with curly brackets
30
- api_route = Path(str(api_route).replace('[', '{').replace(']', '}'))
31
-
32
- # remove any groups from the URL
33
- api_route = Path(str(PurePosixPath(*[part for part in PurePosixPath(api_route).parts if not part.startswith("(") and not part.endswith(")")])))
34
-
35
- if not api_route.parent.exists():
36
- api_route.parent.mkdir(parents=True)
37
-
38
- # copy module path to api_route
39
- shutil.copy(module_path, api_route.parent)
40
-
41
- # create __init__.py if it doesn't exist
42
- if not (api_route.parent / "__init__.py").exists():
43
- (api_route.parent / "__init__.py").touch()
44
-
45
- print(f"PYTHON ENDPOINT: Copied {module_path} to {api_route.parent}")
24
+ package_dir = Path(args.packagedir).absolute()
25
+ shutil.copy(package_dir / "deploy.py", func_dir / "index.py")
26
+ for helper in ("load_runtime.py", "routes.py"):
27
+ src = package_dir / helper
28
+ if src.exists():
29
+ shutil.copy(src, func_dir / helper)
30
+
31
+ manifest = []
32
+
33
+
34
+ def _bundle_route_module(module_path: str, *, kind: str) -> None:
35
+ abs_path = Path(module_path).absolute()
36
+ rel = rel_path_from_routes(abs_path, routes_root)
37
+ target_dir = func_dir / route_parent(rel)
38
+ target_dir.mkdir(parents=True, exist_ok=True)
39
+ shutil.copy(abs_path, target_dir / rel.name)
40
+
41
+ parent = route_parent(rel)
42
+ if kind == "load":
43
+ route = load_route(parent)
44
+ else:
45
+ route = api_route(parent)
46
+
47
+ entry = {"file": str(PurePosixPath(rel)), "route": route, "kind": kind}
48
+ manifest.append(entry)
49
+ print(f"PYTHON {'LOAD' if kind == 'load' else 'ENDPOINT'}: {module_path} {route}")
50
+
51
+
52
+ for module_path in glob.glob(str(routes_root / "**/+server.py"), recursive=True):
53
+ _bundle_route_module(module_path, kind="server")
54
+
55
+ for pattern in ("**/+page.server.py", "**/+layout.server.py"):
56
+ for module_path in glob.glob(str(routes_root / pattern), recursive=True):
57
+ _bundle_route_module(module_path, kind="load")
58
+
59
+ (func_dir / "_manifest.json").write_text(json.dumps(manifest, indent=2))
60
+
61
+ dep_files = ["requirements.txt", "pyproject.toml", "uv.lock", "Pipfile", "Pipfile.lock"]
62
+ found_dep = False
63
+ for dep_file in dep_files:
64
+ src = root_dir / dep_file
65
+ if src.exists():
66
+ shutil.copy(src, func_dir / dep_file)
67
+ print(f"PYTHON ENDPOINT: Bundled {dep_file}")
68
+ found_dep = True
69
+
70
+ if not found_dep:
71
+ (func_dir / "requirements.txt").write_text("fastapi\nuvicorn\n")
72
+ print("PYTHON ENDPOINT: No dependency file found, created minimal requirements.txt")
73
+
74
+ dep_dir = func_dir / "_deps"
75
+ dep_dir.mkdir(exist_ok=True)
76
+ pip_cmd = [sys.executable, "-m", "pip", "install", "--target", str(dep_dir), "--quiet"]
77
+ if (func_dir / "requirements.txt").exists():
78
+ pip_cmd += ["-r", str(func_dir / "requirements.txt")]
79
+ subprocess.run(pip_cmd, check=True)
80
+ print("PYTHON ENDPOINT: Installed deps from requirements.txt")
81
+ elif (func_dir / "pyproject.toml").exists():
82
+ try:
83
+ import tomllib
84
+ except ImportError:
85
+ import tomli as tomllib # type: ignore
86
+ with open(func_dir / "pyproject.toml", "rb") as _f:
87
+ _pyproject = tomllib.load(_f)
88
+ _deps_list = _pyproject.get("project", {}).get("dependencies", [])
89
+ if _deps_list:
90
+ subprocess.run(pip_cmd + _deps_list, check=True)
91
+ print(f"PYTHON ENDPOINT: Installed {len(_deps_list)} deps from pyproject.toml")
92
+
93
+ vc_config = {"runtime": "python3.12", "handler": "index.handler"}
94
+ (func_dir / ".vc-config.json").write_text(json.dumps(vc_config, indent=2))
95
+ print("PYTHON ENDPOINT: Created .vc-config.json")
96
+
97
+ config_path = root_dir / ".vercel" / "output" / "config.json"
98
+ if config_path.exists():
99
+ config = json.loads(config_path.read_text())
100
+ routes = config.get("routes", [])
101
+ python_route = {"src": "^/api(/.*)?$", "dest": "api/index"}
102
+ fs_idx = next(
103
+ (i for i, r in enumerate(routes) if r.get("handle") == "filesystem"),
104
+ 0,
105
+ )
106
+ routes.insert(fs_idx, python_route)
107
+ config["routes"] = routes
108
+ config_path.write_text(json.dumps(config, indent=2))
109
+ print("PYTHON ENDPOINT: Patched .vercel/output/config.json")
110
+ else:
111
+ print(
112
+ "WARNING: .vercel/output/config.json not found. "
113
+ "Make sure to run `vite build` before this script."
114
+ )
@@ -1,55 +1,160 @@
1
- import glob
2
- import importlib
1
+ import asyncio
2
+ import base64
3
3
  import importlib.util
4
- import os
5
- from pathlib import Path, PurePosixPath
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from urllib.parse import urlsplit
8
+
9
+ _base = Path(__file__).parent
10
+ _deps = _base / "_deps"
11
+ if _deps.exists() and str(_deps) not in sys.path:
12
+ sys.path.insert(0, str(_deps))
13
+ if str(_base) not in sys.path:
14
+ sys.path.insert(0, str(_base))
6
15
 
7
16
  from fastapi import FastAPI, Request
8
17
  from fastapi.responses import JSONResponse
9
18
 
19
+ from load_runtime import run_load
20
+ from routes import route_registration_order
21
+
10
22
  app = FastAPI()
11
23
 
12
- async def hello_world():
13
- return {"message": "Hello World!"}
14
-
15
- app.add_api_route("/api", hello_world, methods=["GET"])
16
-
17
- # Add all +server.py routes to web_app
18
- for module_path in glob.glob('./**/+server.py', recursive=True):
19
-
20
- module_name_joined = module_path[2:].replace(os.path.sep, '.')
21
- module_name, module_package = module_name_joined.rsplit('.', maxsplit=1)
22
-
23
- api_route = module_path[1:] if module_path.startswith('./') else module_path
24
- api_route = str(Path(api_route).parent)
25
-
26
- # Replace square brackets with curly brackets
27
- api_route = api_route.replace('[', '{').replace(']', '}')
28
-
29
- # remove any groups from the URL
30
- api_route = str(PurePosixPath(*[part for part in PurePosixPath(api_route).parts if not part.startswith("(") and not part.endswith(")")]))
31
-
32
- mod = importlib.import_module(module_name, module_package)
33
-
34
- # Add endpoints
35
- for method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
36
-
37
- # Check for duplicate methods
38
- if hasattr(mod, method) and hasattr(mod, method.lower()):
39
- raise Exception(
40
- f"Duplicate method {method} and {method.lower()} in {api_route}"
41
- )
42
-
43
- elif hasattr(mod, method):
44
- app.add_api_route(api_route, getattr(mod, method), methods=[method])
45
- print(f"PYTHON ENDPOINT: Added {module_path} [{method}] at {api_route}")
46
- elif hasattr(mod, method.lower()):
47
- app.add_api_route(api_route, getattr(mod, method.lower()), methods=[method])
48
- print(f"PYTHON ENDPOINT: Added {module_path} [{method}] at {api_route}")
24
+
25
+ def _load_module(file_path: Path):
26
+ spec = importlib.util.spec_from_file_location("_route_module", file_path)
27
+ mod = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(mod)
29
+ return mod
30
+
31
+
32
+ def _make_load_handler(mod):
33
+ async def handler(request: Request):
34
+ payload = await request.json()
35
+ return JSONResponse(await run_load(mod, payload))
36
+
37
+ return handler
38
+
39
+
40
+ _manifest_path = _base / "_manifest.json"
41
+ if _manifest_path.exists():
42
+ _manifest = json.loads(_manifest_path.read_text())
43
+ _load_entries = [e for e in _manifest if e.get("kind") == "load"]
44
+ _server_entries = [e for e in _manifest if e.get("kind", "server") != "load"]
45
+
46
+ for _entry in sorted(_load_entries, key=lambda e: route_registration_order(e["route"])):
47
+ _mod = _load_module(_base / _entry["file"])
48
+ _route = _entry["route"]
49
+ app.add_api_route(_route, _make_load_handler(_mod), methods=["POST"])
50
+ print(f"PYTHON LOAD: Registered POST {_route}")
51
+
52
+ for _entry in _server_entries:
53
+ _mod = _load_module(_base / _entry["file"])
54
+ _route = _entry["route"]
55
+
56
+ for _method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
57
+ _has_upper = hasattr(_mod, _method)
58
+ _has_lower = hasattr(_mod, _method.lower())
59
+
60
+ if _has_upper and _has_lower:
61
+ raise Exception(
62
+ f"Duplicate method {_method} and {_method.lower()} in {_route}"
63
+ )
64
+ elif _has_upper:
65
+ app.add_api_route(_route, getattr(_mod, _method), methods=[_method])
66
+ print(f"PYTHON ENDPOINT: Registered {_method} {_route}")
67
+ elif _has_lower:
68
+ app.add_api_route(_route, getattr(_mod, _method.lower()), methods=[_method])
69
+ print(f"PYTHON ENDPOINT: Registered {_method} {_route}")
70
+
49
71
 
50
72
  @app.exception_handler(Exception)
51
- async def unicorn_exception_handler(request: Request, exc: Exception):
52
- return JSONResponse(
53
- status_code=500,
54
- content={"error": f"{exc}"},
55
- )
73
+ async def _exception_handler(request: Request, exc: Exception):
74
+ return JSONResponse(status_code=500, content={"error": str(exc)})
75
+
76
+
77
+ async def _dispatch(scope: dict, body: bytes) -> tuple[int, dict, bytes]:
78
+ """Run FastAPI for one HTTP request and collect the response."""
79
+ queue: asyncio.Queue = asyncio.Queue()
80
+ queue.put_nowait({"type": "http.request", "body": body, "more_body": False})
81
+
82
+ status = 500
83
+ resp_headers: dict = {}
84
+ resp_body = b""
85
+
86
+ async def receive():
87
+ return await queue.get()
88
+
89
+ async def send(message):
90
+ nonlocal status, resp_body
91
+ if message["type"] == "http.response.start":
92
+ status = message["status"]
93
+ for k, v in message.get("headers", []):
94
+ if isinstance(k, bytes):
95
+ k = k.decode()
96
+ if isinstance(v, bytes):
97
+ v = v.decode()
98
+ k = k.lower()
99
+ if k in resp_headers:
100
+ existing = resp_headers[k]
101
+ resp_headers[k] = (existing if isinstance(existing, list) else [existing]) + [v]
102
+ else:
103
+ resp_headers[k] = v
104
+ elif message["type"] == "http.response.body":
105
+ resp_body += message.get("body", b"")
106
+
107
+ await app(scope, receive, send)
108
+ return status, resp_headers, resp_body
109
+
110
+
111
+ def handler(event, context):
112
+ payload = json.loads(event.get("body") or "{}")
113
+
114
+ parsed = urlsplit(payload.get("path", "/"))
115
+ path = parsed.path or "/"
116
+ query = (parsed.query or "").encode()
117
+
118
+ raw_headers: dict = payload.get("headers") or {}
119
+ headers_list = []
120
+ host = payload.get("host", "localhost")
121
+ scheme = "https"
122
+ for k, v in raw_headers.items():
123
+ k_lower = k.lower()
124
+ if k_lower == "host":
125
+ host = v
126
+ if k_lower == "x-forwarded-proto":
127
+ scheme = v
128
+ headers_list.append((k_lower.encode(), str(v).encode()))
129
+
130
+ scope = {
131
+ "type": "http",
132
+ "http_version": "1.1",
133
+ "method": payload.get("method", "GET").upper(),
134
+ "path": path,
135
+ "raw_path": path.encode(),
136
+ "query_string": query,
137
+ "root_path": "",
138
+ "headers": headers_list,
139
+ "server": (host, 443 if scheme == "https" else 80),
140
+ "client": (raw_headers.get("x-real-ip", "127.0.0.1"), 0),
141
+ "scheme": scheme,
142
+ }
143
+
144
+ body = payload.get("body") or b""
145
+ if payload.get("encoding") == "base64":
146
+ body = base64.b64decode(body)
147
+ elif isinstance(body, str):
148
+ body = body.encode()
149
+
150
+ status, resp_headers, resp_body = asyncio.run(_dispatch(scope, body))
151
+
152
+ result: dict = {"statusCode": status, "headers": resp_headers}
153
+ if resp_body:
154
+ try:
155
+ result["body"] = resp_body.decode("utf-8")
156
+ except UnicodeDecodeError:
157
+ result["body"] = base64.b64encode(resp_body).decode()
158
+ result["encoding"] = "base64"
159
+
160
+ return result
@@ -0,0 +1,91 @@
1
+ import inspect
2
+ import types
3
+ from typing import Any, Optional
4
+
5
+
6
+ class SKPVError(Exception):
7
+ def __init__(self, status: int, body: Any):
8
+ self.status = status
9
+ self.body = body
10
+ super().__init__(body)
11
+
12
+
13
+ class SKPVRedirect(Exception):
14
+ def __init__(self, status: int, location: str):
15
+ self.status = status
16
+ self.location = location
17
+ super().__init__(location)
18
+
19
+
20
+ def error(status: int, body: Any) -> None:
21
+ raise SKPVError(status, body)
22
+
23
+
24
+ def redirect(status: int, location: str) -> None:
25
+ raise SKPVRedirect(status, location)
26
+
27
+
28
+ def inject_load_helpers(mod) -> None:
29
+ if not hasattr(mod, "error"):
30
+ mod.error = error
31
+ if not hasattr(mod, "redirect"):
32
+ mod.redirect = redirect
33
+
34
+
35
+ def _to_namespace(value: Any) -> Any:
36
+ if value is None:
37
+ return None
38
+ if isinstance(value, dict):
39
+ ns = types.SimpleNamespace()
40
+ for key, val in value.items():
41
+ setattr(ns, key, _to_namespace(val))
42
+ return ns
43
+ if isinstance(value, list):
44
+ return [_to_namespace(item) for item in value]
45
+ return value
46
+
47
+
48
+ class _Cookies:
49
+ def __init__(self, data: Optional[dict]):
50
+ self._data = data or {}
51
+
52
+ def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
53
+ return self._data.get(name, default)
54
+
55
+
56
+ def wrap_event(payload: dict) -> types.SimpleNamespace:
57
+ event = types.SimpleNamespace()
58
+ event.params = payload.get("params") or {}
59
+ event.route = _to_namespace(payload.get("route") or {})
60
+ event.url = payload.get("url", "")
61
+ event.parent = _to_namespace(payload.get("parent"))
62
+ event.data = payload.get("data")
63
+ event.cookies = _Cookies(payload.get("cookies"))
64
+ return event
65
+
66
+
67
+ def parse_load_result(result: Any) -> dict:
68
+ if (
69
+ isinstance(result, tuple)
70
+ and len(result) == 3
71
+ and result[0] in ("error", "redirect")
72
+ ):
73
+ kind, status, payload = result
74
+ if kind == "error":
75
+ return {"type": "error", "status": status, "body": payload}
76
+ return {"type": "redirect", "status": status, "location": payload}
77
+ return {"type": "data", "data": result}
78
+
79
+
80
+ async def run_load(mod, payload: dict) -> dict:
81
+ inject_load_helpers(mod)
82
+ event = wrap_event(payload)
83
+ try:
84
+ result = mod.load(event)
85
+ if inspect.iscoroutine(result):
86
+ result = await result
87
+ return parse_load_result(result)
88
+ except SKPVRedirect as exc:
89
+ return {"type": "redirect", "status": exc.status, "location": exc.location}
90
+ except SKPVError as exc:
91
+ return {"type": "error", "status": exc.status, "body": exc.body}
@@ -0,0 +1,39 @@
1
+ from pathlib import Path, PurePosixPath
2
+
3
+
4
+ def rel_path_from_routes(module_path: Path, routes_root: Path) -> PurePosixPath:
5
+ """Route file path relative to src/routes, with groups stripped and [param] → {param}."""
6
+ rel = module_path.absolute().relative_to(routes_root)
7
+ rel = Path(str(rel).replace("[", "{").replace("]", "}"))
8
+ parts = [
9
+ p
10
+ for p in PurePosixPath(rel).parts
11
+ if not (p.startswith("(") and p.endswith(")"))
12
+ ]
13
+ return PurePosixPath(*parts)
14
+
15
+
16
+ def route_parent(rel: PurePosixPath) -> PurePosixPath:
17
+ return PurePosixPath(rel).parent
18
+
19
+
20
+ def api_route(rel_parent: PurePosixPath, *, prefix: str = "/api") -> str:
21
+ if str(rel_parent) == ".":
22
+ return prefix
23
+ return f"{prefix}/{rel_parent}"
24
+
25
+
26
+ def load_route(rel_parent: PurePosixPath) -> str:
27
+ return api_route(rel_parent, prefix="/api/_load")
28
+
29
+
30
+ def resolve_route_path(template: str, params: dict) -> str:
31
+ path = template
32
+ for key, value in params.items():
33
+ path = path.replace(f"{{{key}}}", str(value))
34
+ return path
35
+
36
+
37
+ def route_registration_order(route: str) -> tuple:
38
+ """Static routes before dynamic; longer paths before shorter."""
39
+ return ("{" in route, -len(route), route)
@@ -1,12 +1,23 @@
1
1
  import argparse
2
2
  import glob
3
- import importlib
4
3
  import importlib.util
5
4
  import shutil
6
- from pathlib import Path, PurePosixPath
5
+ import sys
6
+ from pathlib import Path
7
7
 
8
8
  import uvicorn
9
- from fastapi import FastAPI
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.responses import JSONResponse
11
+
12
+ sys.path.insert(0, str(Path(__file__).parent))
13
+ from load_runtime import run_load
14
+ from routes import (
15
+ api_route,
16
+ load_route,
17
+ rel_path_from_routes,
18
+ route_parent,
19
+ route_registration_order,
20
+ )
10
21
 
11
22
  parser = argparse.ArgumentParser(description="Run Sveltekit Python Server")
12
23
  parser.add_argument("--host", default="0.0.0.0", help="Server hostname")
@@ -17,58 +28,75 @@ args = parser.parse_args()
17
28
  app = FastAPI()
18
29
 
19
30
  root_dir = Path(args.root).absolute()
20
-
21
31
  api_dir = Path("./sveltekit_python_vercel").absolute()
32
+ routes_root = root_dir / "src/routes"
33
+ watch_modules = []
22
34
 
23
- route_dir = root_dir.joinpath("src/routes")
24
35
 
25
- watch_modules = [] # list of modules to watch for changes
36
+ def _copy_module(module_path: Path) -> Path:
37
+ api_route_path = api_dir.joinpath(module_path.relative_to(routes_root))
38
+ api_route_path.parent.mkdir(parents=True, exist_ok=True)
39
+ shutil.copy(module_path, api_route_path.parent / api_route_path.name)
40
+ return api_route_path.parent / api_route_path.name
26
41
 
27
- for module_path in glob.glob(
28
- route_dir.joinpath("**/+server.py").as_posix(), recursive=True
29
- ):
30
- abs_module_path = Path(module_path).absolute()
31
-
32
- watch_modules.append(abs_module_path.parent.as_posix())
33
42
 
34
- api_route = api_dir.joinpath(abs_module_path.relative_to(root_dir / "src/routes"))
43
+ def _load_module(api_route_path: Path):
44
+ spec = importlib.util.spec_from_file_location(api_route_path.stem, api_route_path)
45
+ mod = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(mod)
47
+ return mod
35
48
 
36
- if not api_route.parent.exists():
37
- api_route.parent.mkdir(parents=True)
38
49
 
39
- # copy module path to api_route
40
- shutil.copy(module_path, api_route.parent)
50
+ def _make_load_handler(mod):
51
+ async def handler(request: Request):
52
+ payload = await request.json()
53
+ return JSONResponse(await run_load(mod, payload))
41
54
 
42
- module_name = api_route.stem
55
+ return handler
43
56
 
44
- spec = importlib.util.spec_from_file_location(module_name, api_route)
45
- mod = importlib.util.module_from_spec(spec)
46
- spec.loader.exec_module(mod)
47
57
 
48
- # Get the relative path of the module from the API directory
49
- rel_path = api_route.relative_to(api_dir)
58
+ for module_path in glob.glob(routes_root.joinpath("**/+server.py").as_posix(), recursive=True):
59
+ abs_module_path = Path(module_path).absolute()
60
+ watch_modules.append(abs_module_path.parent.as_posix())
50
61
 
51
- # Convert the relative path to a string and remove the file extension
52
- api_path = f"/{rel_path.parent}"
62
+ api_route_path = _copy_module(abs_module_path)
63
+ mod = _load_module(api_route_path)
53
64
 
54
- # Replace square brackets with curly brackets
55
- api_path = api_path.replace("[", "{").replace("]", "}")
56
-
57
- # remove any groups from the URL
58
- api_path = str(PurePosixPath(*[part for part in PurePosixPath(api_path).parts if not part.startswith("(") and not part.endswith(")")]))
65
+ rel = rel_path_from_routes(abs_module_path, routes_root)
66
+ api_path = api_route(rel.parent)
59
67
 
60
- # Add endpoints
61
68
  for method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
62
- # Check for duplicate methods
63
69
  if hasattr(mod, method) and hasattr(mod, method.lower()):
64
70
  raise Exception(
65
- f"Duplicate method {method} and {method.lower()} in {api_route}"
71
+ f"Duplicate method {method} and {method.lower()} in {api_route_path}"
66
72
  )
67
73
  elif hasattr(mod, method):
68
74
  app.add_api_route(api_path, getattr(mod, method), methods=[method])
69
75
  elif hasattr(mod, method.lower()):
70
76
  app.add_api_route(api_path, getattr(mod, method.lower()), methods=[method])
71
77
 
78
+ load_entries = []
79
+ for pattern in ("**/+page.server.py", "**/+layout.server.py"):
80
+ for module_path in glob.glob(routes_root.joinpath(pattern).as_posix(), recursive=True):
81
+ abs_module_path = Path(module_path).absolute()
82
+ watch_modules.append(abs_module_path.parent.as_posix())
83
+
84
+ api_route_path = _copy_module(abs_module_path)
85
+ mod = _load_module(api_route_path)
86
+
87
+ if not hasattr(mod, "load"):
88
+ raise Exception(f"Missing load function in {abs_module_path}")
89
+
90
+ rel = rel_path_from_routes(abs_module_path, routes_root)
91
+ load_path = load_route(route_parent(rel))
92
+ load_entries.append((load_path, mod, abs_module_path))
93
+
94
+ for load_path, mod, abs_module_path in sorted(
95
+ load_entries, key=lambda entry: route_registration_order(entry[0])
96
+ ):
97
+ app.add_api_route(load_path, _make_load_handler(mod), methods=["POST"])
98
+ print(f"PYTHON LOAD: Registered POST {load_path} ← {abs_module_path}")
99
+
72
100
 
73
101
  if __name__ == "__main__":
74
102
  uvicorn.run(
@@ -0,0 +1,119 @@
1
+ import types
2
+ import unittest
3
+
4
+ from load_runtime import (
5
+ SKPVError,
6
+ inject_load_helpers,
7
+ parse_load_result,
8
+ redirect,
9
+ run_load,
10
+ wrap_event,
11
+ )
12
+
13
+
14
+ class ParseLoadResultTests(unittest.TestCase):
15
+ def test_data(self):
16
+ self.assertEqual(
17
+ parse_load_result({"ok": True}),
18
+ {"type": "data", "data": {"ok": True}},
19
+ )
20
+
21
+ def test_error_tuple(self):
22
+ self.assertEqual(
23
+ parse_load_result(("error", 404, "Not found")),
24
+ {"type": "error", "status": 404, "body": "Not found"},
25
+ )
26
+
27
+ def test_redirect_tuple(self):
28
+ self.assertEqual(
29
+ parse_load_result(("redirect", 307, "/login")),
30
+ {"type": "redirect", "status": 307, "location": "/login"},
31
+ )
32
+
33
+ def test_tuple_data_not_misread(self):
34
+ self.assertEqual(
35
+ parse_load_result(("items", 1, 2)),
36
+ {"type": "data", "data": ("items", 1, 2)},
37
+ )
38
+
39
+
40
+ class RunLoadTests(unittest.IsolatedAsyncioTestCase):
41
+ async def test_injected_error(self):
42
+ mod = types.ModuleType("test")
43
+ exec(
44
+ compile(
45
+ "async def load(event):\n error(403, 'denied')\n",
46
+ "<test>",
47
+ "exec",
48
+ ),
49
+ mod.__dict__,
50
+ )
51
+ result = await run_load(mod, {"params": {}})
52
+ self.assertEqual(result["type"], "error")
53
+ self.assertEqual(result["status"], 403)
54
+
55
+ async def test_injected_redirect(self):
56
+ mod = types.ModuleType("test")
57
+ exec(
58
+ compile(
59
+ "async def load(event):\n redirect(307, '/home')\n",
60
+ "<test>",
61
+ "exec",
62
+ ),
63
+ mod.__dict__,
64
+ )
65
+ result = await run_load(mod, {"params": {}})
66
+ self.assertEqual(result["type"], "redirect")
67
+ self.assertEqual(result["location"], "/home")
68
+
69
+ async def test_return_tuple_error(self):
70
+ mod = types.ModuleType("test")
71
+
72
+ async def load(event):
73
+ return ("error", 404, "missing")
74
+
75
+ mod.load = load
76
+ result = await run_load(mod, {"params": {}})
77
+ self.assertEqual(result, {"type": "error", "status": 404, "body": "missing"})
78
+
79
+ async def test_wrap_event_parent_and_cookies(self):
80
+ mod = types.ModuleType("test")
81
+ captured = {}
82
+
83
+ async def load(event):
84
+ captured["theme"] = event.parent.theme
85
+ captured["session"] = event.cookies.get("session")
86
+ return {"ok": True}
87
+
88
+ mod.load = load
89
+ await run_load(
90
+ mod,
91
+ {
92
+ "params": {"id": "1"},
93
+ "parent": {"theme": "dark"},
94
+ "cookies": {"session": "abc"},
95
+ },
96
+ )
97
+ self.assertEqual(captured["theme"], "dark")
98
+ self.assertEqual(captured["session"], "abc")
99
+
100
+ async def test_user_error_helper_not_overwritten(self):
101
+ mod = types.ModuleType("test")
102
+
103
+ def custom_error(status, body):
104
+ raise SKPVError(418, "teapot")
105
+
106
+ mod.error = custom_error
107
+
108
+ async def load(event):
109
+ custom_error(404, "ignored")
110
+
111
+ mod.load = load
112
+ inject_load_helpers(mod)
113
+ with self.assertRaises(SKPVError) as ctx:
114
+ await mod.load(wrap_event({}))
115
+ self.assertEqual(ctx.exception.status, 418)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "module": "./esm/mod.js",
3
3
  "types": "./types/mod.d.ts",
4
4
  "name": "sveltekit-python-vercel",
5
- "version": "v0.4.1",
5
+ "version": "1.0.3-beta.53c34c5",
6
6
  "description": "Write Sveltekit server endpoints in Python and seamlessly deploy to Vercel",
7
7
  "repository": {
8
8
  "type": "git",