sveltekit-python-vercel 1.0.3-beta.8b30fdb → 1.0.3-beta.pr16.eb46f83

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
@@ -22,6 +22,7 @@ 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
+ - Write server `load` functions in `+page.server.py` and `+layout.server.py`
25
26
  - Deploy automatically to Vercel Serverless (Python 3.12 runtime)
26
27
 
27
28
  ## Installing
@@ -153,6 +154,59 @@ Just push to your repository — no extra steps required.
153
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`).
154
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.
155
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`):
203
+
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
207
+
208
+ Commit `^x.y.z` or `@beta` in the consumer for Vercel — `link:` only works on your machine.
209
+
156
210
  ## Fork of `sveltekit-modal`
157
211
 
158
212
  Check out the awesome [sveltekit-modal](https://github.com/semicognitive/sveltekit-modal) package by [@semicognitive](https://github.com/semicognitive), the original way to get your python code running in SvelteKit. Modal even has GPU support for running an entire ML stack within SvelteKit.
@@ -163,5 +217,5 @@ Check out the awesome [sveltekit-modal](https://github.com/semicognitive/sveltek
163
217
  - [X] Generate endpoints automatically during build (via Vercel Build Output API)
164
218
  - [X] Auto-bundle requirements.txt / pyproject.toml / Pipfile at build time
165
219
  - [ ] Add form actions
166
- - [ ] Add load functions
220
+ - [X] Add load functions
167
221
  - [ ] Add helper functions to automatically call API endpoints in project
@@ -8,7 +8,7 @@ const get_pyServerEndpointAsString = (app_url, serve = false) => `
8
8
  let fullURL;
9
9
 
10
10
  if (${serve}) {
11
- fullURL = new URL(url.pathname, new URL('${app_url}')) + url.search;
11
+ fullURL = new URL('/api' + url.pathname, new URL('${app_url}')) + url.search;
12
12
  } else {
13
13
  fullURL = new URL('/api' + url.pathname, url.origin) + url.search;
14
14
  }
@@ -30,6 +30,72 @@ const get_pyServerEndpointAsString = (app_url, serve = false) => `
30
30
  export const PUT = handle('PUT');
31
31
  export const DELETE = handle('DELETE');
32
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
+ }
33
99
  export async function sveltekit_python_vercel(opts = {}) {
34
100
  const child_processes = [];
35
101
  async function kill_all_process() {
@@ -47,7 +113,6 @@ export async function sveltekit_python_vercel(opts = {}) {
47
113
  },
48
114
  async configureServer({ config }) {
49
115
  const packagelocation = path.join(config.root, "node_modules", "sveltekit-python-vercel", "esm/src/vite");
50
- // copy asll +server.py files to package directory
51
116
  run$.verbose = false;
52
117
  run$.env.PYTHONDONTWRITEBYTECODE = "1";
53
118
  cd$(packagelocation);
@@ -58,10 +123,9 @@ export async function sveltekit_python_vercel(opts = {}) {
58
123
  child_processes.push(local_process);
59
124
  sveltekit_url ??= new URL(`http://${host}:${port}`);
60
125
  cd$(config.root);
61
- // local_process.quiet(); // let it be loud for now
62
126
  local_process.nothrow();
63
127
  local_process.stderr.on("data", (s) => {
64
- console.log(s.toString().trimEnd()); //Logs stderr always and all of stdout if 'log': True
128
+ console.log(s.toString().trimEnd());
65
129
  });
66
130
  local_process.stderr.on("error", (s) => {
67
131
  console.error(chalk.red("Error: Python Serve Failed"));
@@ -80,31 +144,39 @@ export async function sveltekit_python_vercel(opts = {}) {
80
144
  console.log("PY: ROOT PATH: " + config.root);
81
145
  },
82
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
+ };
83
165
  const plugin_py_server_endpoint_serve = {
84
166
  name: "vite-plugin-sveltekit_python-server-endpoint",
85
167
  apply: "serve",
86
168
  transform(src, id) {
87
- // console.log("Transform function called for", id); // Add this line
88
- if (/\.py$/.test(id)) {
89
- if (sveltekit_url === undefined)
90
- throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
91
- return {
92
- code: get_pyServerEndpointAsString(sveltekit_url, true),
93
- map: null, // provide source map if available
94
- };
169
+ if (sveltekit_url === undefined) {
170
+ throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
95
171
  }
172
+ return transformPyFile(id, true, sveltekit_url);
96
173
  },
97
174
  };
98
175
  const plugin_py_server_endpoint_build = {
99
176
  name: "vite-plugin-sveltekit_python-server-endpoint",
100
177
  apply: "build",
101
178
  transform(src, id) {
102
- if (/\.py$/.test(id)) {
103
- return {
104
- code: get_pyServerEndpointAsString(new URL("http://localhost"), false),
105
- map: null,
106
- };
107
- }
179
+ return transformPyFile(id, false, new URL("http://localhost"));
108
180
  },
109
181
  };
110
182
  return [
@@ -0,0 +1,3 @@
1
+ from .load_runtime import error, redirect
2
+
3
+ __all__ = ["error", "redirect"]
@@ -6,56 +6,58 @@ import subprocess
6
6
  import sys
7
7
  from pathlib import Path, PurePosixPath
8
8
 
9
+ sys.path.insert(0, str(Path(__file__).parent))
10
+ from routes import api_route, load_route, rel_path_from_routes, route_parent
11
+
9
12
  parser = argparse.ArgumentParser(description="Run SvelteKit Python Deployment")
10
13
  parser.add_argument("--root", default=".", help="Root directory of the SvelteKit project")
11
14
  parser.add_argument("--packagedir", default=None, help="Directory of the sveltekit-python-vercel package")
12
15
  args = parser.parse_args()
13
16
 
14
17
  root_dir = Path(args.root).absolute()
18
+ routes_root = root_dir / "src/routes"
15
19
 
16
20
  func_dir = root_dir / ".vercel" / "output" / "functions" / "api" / "index.func"
17
21
  func_dir.mkdir(parents=True, exist_ok=True)
18
22
 
19
23
  if args.packagedir:
20
- shutil.copy(Path(args.packagedir).absolute() / "deploy.py", func_dir / "index.py")
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)
21
30
 
22
- # find all +server.py routes and copy them into the .func directory
23
31
  manifest = []
24
32
 
25
- for module_path in glob.glob(str(root_dir / "src/routes/**/+server.py"), recursive=True):
26
- rel = Path(module_path).absolute().relative_to(root_dir / "src/routes")
27
-
28
- # replace square brackets with curly brackets
29
- rel = Path(str(rel).replace("[", "{").replace("]", "}"))
30
-
31
- # remove any SvteleKit groups from the URL
32
- parts = [
33
- p for p in PurePosixPath(rel).parts
34
- if not (p.startswith("(") and p.endswith(")"))
35
- ]
36
- rel = Path(*parts)
37
33
 
38
- target_dir = func_dir / rel.parent
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)
39
38
  target_dir.mkdir(parents=True, exist_ok=True)
39
+ shutil.copy(abs_path, target_dir / rel.name)
40
40
 
41
- shutil.copy(module_path, target_dir / rel.name)
41
+ parent = route_parent(rel)
42
+ if kind == "load":
43
+ route = load_route(parent)
44
+ else:
45
+ route = api_route(parent)
42
46
 
43
- if not (target_dir / "__init__.py").exists():
44
- (target_dir / "__init__.py").touch()
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}")
45
50
 
46
- # build the API route
47
- parent = PurePosixPath(rel).parent
48
- if str(parent) == ".":
49
- api_route = "/api"
50
- else:
51
- api_route = "/api/" + str(parent)
52
51
 
53
- manifest.append({"file": str(PurePosixPath(rel)), "route": api_route})
54
- print(f"PYTHON ENDPOINT: {module_path} → {api_route}")
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")
55
58
 
56
59
  (func_dir / "_manifest.json").write_text(json.dumps(manifest, indent=2))
57
60
 
58
- # bundle the dependency file here
59
61
  dep_files = ["requirements.txt", "pyproject.toml", "uv.lock", "Pipfile", "Pipfile.lock"]
60
62
  found_dep = False
61
63
  for dep_file in dep_files:
@@ -69,7 +71,6 @@ if not found_dep:
69
71
  (func_dir / "requirements.txt").write_text("fastapi\nuvicorn\n")
70
72
  print("PYTHON ENDPOINT: No dependency file found, created minimal requirements.txt")
71
73
 
72
- # pre-install Python packages into _deps/ so they are available in the lambda env
73
74
  dep_dir = func_dir / "_deps"
74
75
  dep_dir.mkdir(exist_ok=True)
75
76
  pip_cmd = [sys.executable, "-m", "pip", "install", "--target", str(dep_dir), "--quiet"]
@@ -78,7 +79,6 @@ if (func_dir / "requirements.txt").exists():
78
79
  subprocess.run(pip_cmd, check=True)
79
80
  print("PYTHON ENDPOINT: Installed deps from requirements.txt")
80
81
  elif (func_dir / "pyproject.toml").exists():
81
- # extract dependencies from pyproject.toml
82
82
  try:
83
83
  import tomllib
84
84
  except ImportError:
@@ -90,27 +90,21 @@ elif (func_dir / "pyproject.toml").exists():
90
90
  subprocess.run(pip_cmd + _deps_list, check=True)
91
91
  print(f"PYTHON ENDPOINT: Installed {len(_deps_list)} deps from pyproject.toml")
92
92
 
93
- # python3.12 is the AWS Lambda runtime string
94
93
  vc_config = {"runtime": "python3.12", "handler": "index.handler"}
95
94
  (func_dir / ".vc-config.json").write_text(json.dumps(vc_config, indent=2))
96
95
  print("PYTHON ENDPOINT: Created .vc-config.json")
97
96
 
98
- # patch the SvelteKit config.json that the endpoints are rerouted to python
99
97
  config_path = root_dir / ".vercel" / "output" / "config.json"
100
98
  if config_path.exists():
101
99
  config = json.loads(config_path.read_text())
102
100
  routes = config.get("routes", [])
103
-
104
101
  python_route = {"src": "^/api(/.*)?$", "dest": "api/index"}
105
-
106
- # routes /api/* to python before SvelteKit catches it
107
102
  fs_idx = next(
108
103
  (i for i, r in enumerate(routes) if r.get("handle") == "filesystem"),
109
104
  0,
110
105
  )
111
106
  routes.insert(fs_idx, python_route)
112
107
  config["routes"] = routes
113
-
114
108
  config_path.write_text(json.dumps(config, indent=2))
115
109
  print("PYTHON ENDPOINT: Patched .vercel/output/config.json")
116
110
  else:
@@ -16,6 +16,9 @@ if str(_base) not in sys.path:
16
16
  from fastapi import FastAPI, Request
17
17
  from fastapi.responses import JSONResponse
18
18
 
19
+ from load_runtime import run_load
20
+ from routes import route_registration_order
21
+
19
22
  app = FastAPI()
20
23
 
21
24
 
@@ -26,9 +29,27 @@ def _load_module(file_path: Path):
26
29
  return mod
27
30
 
28
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
+
29
40
  _manifest_path = _base / "_manifest.json"
30
41
  if _manifest_path.exists():
31
- for _entry in json.loads(_manifest_path.read_text()):
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:
32
53
  _mod = _load_module(_base / _entry["file"])
33
54
  _route = _entry["route"]
34
55
 
@@ -90,12 +111,10 @@ async def _dispatch(scope: dict, body: bytes) -> tuple[int, dict, bytes]:
90
111
  def handler(event, context):
91
112
  payload = json.loads(event.get("body") or "{}")
92
113
 
93
- # parse the path and query string
94
114
  parsed = urlsplit(payload.get("path", "/"))
95
115
  path = parsed.path or "/"
96
116
  query = (parsed.query or "").encode()
97
117
 
98
- # Build ASGI-style header list
99
118
  raw_headers: dict = payload.get("headers") or {}
100
119
  headers_list = []
101
120
  host = payload.get("host", "localhost")
@@ -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": "1.0.3-beta.8b30fdb",
5
+ "version": "1.0.3-beta.pr16.eb46f83",
6
6
  "description": "Write Sveltekit server endpoints in Python and seamlessly deploy to Vercel",
7
7
  "repository": {
8
8
  "type": "git",