pywire 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

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.
Files changed (101) hide show
  1. pywire/__init__.py +2 -0
  2. pywire/cli/__init__.py +1 -0
  3. pywire/cli/generators.py +48 -0
  4. pywire/cli/main.py +309 -0
  5. pywire/cli/tui.py +563 -0
  6. pywire/cli/validate.py +26 -0
  7. pywire/client/.prettierignore +8 -0
  8. pywire/client/.prettierrc +7 -0
  9. pywire/client/build.mjs +73 -0
  10. pywire/client/eslint.config.js +46 -0
  11. pywire/client/package.json +39 -0
  12. pywire/client/pnpm-lock.yaml +2971 -0
  13. pywire/client/src/core/app.ts +263 -0
  14. pywire/client/src/core/dom-updater.test.ts +78 -0
  15. pywire/client/src/core/dom-updater.ts +321 -0
  16. pywire/client/src/core/index.ts +5 -0
  17. pywire/client/src/core/transport-manager.test.ts +179 -0
  18. pywire/client/src/core/transport-manager.ts +159 -0
  19. pywire/client/src/core/transports/base.ts +122 -0
  20. pywire/client/src/core/transports/http.ts +142 -0
  21. pywire/client/src/core/transports/index.ts +13 -0
  22. pywire/client/src/core/transports/websocket.ts +97 -0
  23. pywire/client/src/core/transports/webtransport.ts +149 -0
  24. pywire/client/src/dev/dev-app.ts +93 -0
  25. pywire/client/src/dev/error-trace.test.ts +97 -0
  26. pywire/client/src/dev/error-trace.ts +76 -0
  27. pywire/client/src/dev/index.ts +4 -0
  28. pywire/client/src/dev/status-overlay.ts +63 -0
  29. pywire/client/src/events/handler.test.ts +318 -0
  30. pywire/client/src/events/handler.ts +454 -0
  31. pywire/client/src/pywire.core.ts +22 -0
  32. pywire/client/src/pywire.dev.ts +27 -0
  33. pywire/client/tsconfig.json +17 -0
  34. pywire/client/vitest.config.ts +15 -0
  35. pywire/compiler/__init__.py +6 -0
  36. pywire/compiler/ast_nodes.py +304 -0
  37. pywire/compiler/attributes/__init__.py +6 -0
  38. pywire/compiler/attributes/base.py +24 -0
  39. pywire/compiler/attributes/conditional.py +37 -0
  40. pywire/compiler/attributes/events.py +55 -0
  41. pywire/compiler/attributes/form.py +37 -0
  42. pywire/compiler/attributes/loop.py +75 -0
  43. pywire/compiler/attributes/reactive.py +34 -0
  44. pywire/compiler/build.py +28 -0
  45. pywire/compiler/build_artifacts.py +342 -0
  46. pywire/compiler/codegen/__init__.py +5 -0
  47. pywire/compiler/codegen/attributes/__init__.py +6 -0
  48. pywire/compiler/codegen/attributes/base.py +19 -0
  49. pywire/compiler/codegen/attributes/events.py +35 -0
  50. pywire/compiler/codegen/directives/__init__.py +6 -0
  51. pywire/compiler/codegen/directives/base.py +16 -0
  52. pywire/compiler/codegen/directives/path.py +53 -0
  53. pywire/compiler/codegen/generator.py +2341 -0
  54. pywire/compiler/codegen/template.py +2178 -0
  55. pywire/compiler/directives/__init__.py +7 -0
  56. pywire/compiler/directives/base.py +20 -0
  57. pywire/compiler/directives/component.py +33 -0
  58. pywire/compiler/directives/context.py +93 -0
  59. pywire/compiler/directives/layout.py +49 -0
  60. pywire/compiler/directives/no_spa.py +24 -0
  61. pywire/compiler/directives/path.py +71 -0
  62. pywire/compiler/directives/props.py +88 -0
  63. pywire/compiler/exceptions.py +19 -0
  64. pywire/compiler/interpolation/__init__.py +6 -0
  65. pywire/compiler/interpolation/base.py +28 -0
  66. pywire/compiler/interpolation/jinja.py +272 -0
  67. pywire/compiler/parser.py +750 -0
  68. pywire/compiler/paths.py +29 -0
  69. pywire/compiler/preprocessor.py +43 -0
  70. pywire/core/wire.py +119 -0
  71. pywire/py.typed +0 -0
  72. pywire/runtime/__init__.py +7 -0
  73. pywire/runtime/aioquic_server.py +194 -0
  74. pywire/runtime/app.py +901 -0
  75. pywire/runtime/compile_error_page.py +195 -0
  76. pywire/runtime/debug.py +203 -0
  77. pywire/runtime/dev_server.py +434 -0
  78. pywire/runtime/dev_server.py.broken +268 -0
  79. pywire/runtime/error_page.py +64 -0
  80. pywire/runtime/error_renderer.py +23 -0
  81. pywire/runtime/escape.py +23 -0
  82. pywire/runtime/files.py +40 -0
  83. pywire/runtime/helpers.py +97 -0
  84. pywire/runtime/http_transport.py +253 -0
  85. pywire/runtime/loader.py +272 -0
  86. pywire/runtime/logging.py +72 -0
  87. pywire/runtime/page.py +384 -0
  88. pywire/runtime/pydantic_integration.py +52 -0
  89. pywire/runtime/router.py +229 -0
  90. pywire/runtime/server.py +25 -0
  91. pywire/runtime/style_collector.py +31 -0
  92. pywire/runtime/upload_manager.py +76 -0
  93. pywire/runtime/validation.py +449 -0
  94. pywire/runtime/websocket.py +665 -0
  95. pywire/runtime/webtransport_handler.py +195 -0
  96. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
  97. pywire-0.1.2.dist-info/RECORD +104 -0
  98. pywire-0.1.1.dist-info/RECORD +0 -9
  99. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
  100. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
  101. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -0
pywire/runtime/app.py ADDED
@@ -0,0 +1,901 @@
1
+ """Main ASGI application."""
2
+
3
+ import logging
4
+ import re
5
+ import traceback
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional, Set, cast
8
+
9
+ from starlette.applications import Starlette
10
+ from starlette.requests import Request
11
+ from starlette.responses import JSONResponse, PlainTextResponse, Response
12
+ from starlette.routing import Mount, Route, WebSocketRoute
13
+ from starlette.staticfiles import StaticFiles
14
+
15
+ from pywire.runtime.error_page import ErrorPage
16
+ from pywire.runtime.http_transport import HTTPTransportHandler
17
+ from pywire.runtime.router import Router
18
+ from pywire.runtime.upload_manager import upload_manager
19
+ from pywire.runtime.websocket import WebSocketHandler
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class PyWire:
25
+ """Main ASGI application and configuration."""
26
+
27
+ def __init__(
28
+ self,
29
+ pages_dir: Optional[str] = None,
30
+ path_based_routing: bool = True,
31
+ enable_pjax: bool = True,
32
+ debug: bool = False,
33
+ enable_webtransport: bool = False,
34
+ static_dir: Optional[str] = None,
35
+ static_path: str = "/static",
36
+ ) -> None:
37
+ if pages_dir is None:
38
+ # Auto-discovery
39
+ cwd = Path.cwd()
40
+ potential_paths = [cwd / "pages", cwd / "src" / "pages"]
41
+
42
+ discovered = False
43
+ for path in potential_paths:
44
+ if path.exists() and path.is_dir():
45
+ self.pages_dir = path
46
+ discovered = True
47
+ break
48
+
49
+ if not discovered:
50
+ # Default to 'pages' and let it fail/warn later if missing
51
+ self.pages_dir = Path("pages")
52
+ else:
53
+ self.pages_dir = Path(pages_dir)
54
+
55
+ self.pages_dir = self.pages_dir.resolve()
56
+
57
+ # User configured static directory (disabled by default)
58
+ self.static_dir = None
59
+ if static_dir:
60
+ path = Path(static_dir)
61
+ if not path.is_absolute():
62
+ # Try relative to CWD
63
+ potential = Path.cwd() / path
64
+ if not potential.exists():
65
+ # Try src/ fallback
66
+ src_potential = Path.cwd() / "src" / path
67
+ if src_potential.exists():
68
+ potential = src_potential
69
+ self.static_dir = potential.resolve()
70
+ else:
71
+ self.static_dir = path
72
+
73
+ self.static_url_path = static_path
74
+
75
+ self.path_based_routing = path_based_routing
76
+ self.enable_pjax = enable_pjax
77
+ self.debug = debug
78
+ self.enable_webtransport = enable_webtransport
79
+ # Internal flag set by dev_server.py when running via 'pywire dev'
80
+ self._is_dev_mode = False
81
+
82
+ self.router = Router()
83
+
84
+ from pywire.runtime.loader import get_loader
85
+
86
+ self.loader = get_loader()
87
+
88
+ self.ws_handler = WebSocketHandler(self)
89
+ self.http_handler = HTTPTransportHandler(self)
90
+
91
+ # Initialize WebTransport handler
92
+ from pywire.runtime.webtransport_handler import WebTransportHandler
93
+
94
+ self.web_transport_handler = WebTransportHandler(self)
95
+
96
+ # Valid upload tokens
97
+ self.upload_tokens: Set[str] = set()
98
+
99
+ # Compile and register all pages
100
+ self._load_pages()
101
+
102
+ # Static files (PyWire Internal)
103
+ internal_static_dir = Path(__file__).parent.parent / "static"
104
+
105
+ # Prepare exception handlers
106
+ exception_handlers: Dict[int, Any] = {}
107
+ # Always register our handler to check for custom error pages
108
+ exception_handlers[500] = self._handle_500
109
+
110
+ # Build routes list
111
+ routes = [
112
+ # Capabilities endpoint for transport negotiation
113
+ Route("/_pywire/capabilities", self._handle_capabilities, methods=["GET"]),
114
+ # WebSocket transport
115
+ WebSocketRoute("/_pywire/ws", self.ws_handler.handle),
116
+ # HTTP transport endpoints
117
+ Route(
118
+ "/_pywire/session", self.http_handler.create_session, methods=["POST"]
119
+ ),
120
+ Route("/_pywire/poll", self.http_handler.poll, methods=["GET"]),
121
+ Route("/_pywire/event", self.http_handler.handle_event, methods=["POST"]),
122
+ # Upload endpoint
123
+ Route("/_pywire/upload", self._handle_upload, methods=["POST"]),
124
+ # Internal Static files
125
+ *(
126
+ [
127
+ Mount(
128
+ "/_pywire/static",
129
+ app=StaticFiles(directory=str(internal_static_dir)),
130
+ name="internal_static",
131
+ )
132
+ ]
133
+ if internal_static_dir.exists()
134
+ else []
135
+ ),
136
+ ]
137
+ if not internal_static_dir.exists():
138
+ logger.warning(
139
+ "Internal static assets not found at '%s'. "
140
+ "Reinstall pywire or verify package data inclusion.",
141
+ internal_static_dir,
142
+ )
143
+
144
+ # Mount User Static Files if configured
145
+ if self.static_dir:
146
+ if not self.static_dir.exists():
147
+ logger.warning(
148
+ f"Configured static directory '{self.static_dir}' does not exist."
149
+ )
150
+ else:
151
+ routes.append(
152
+ Mount(
153
+ self.static_url_path,
154
+ app=StaticFiles(directory=str(self.static_dir)),
155
+ name="static",
156
+ )
157
+ )
158
+
159
+ # Debug endpoints (must be before catch-all)
160
+ # ONLY enable these if BOTH debug=True AND we are in dev mode
161
+ # This prevents source code exposure in 'pywire run' even if debug=True
162
+ if self.debug:
163
+ # We defer the check to the handler or register them but check flag inside?
164
+ # Better to not register them at all if we know _is_dev_mode is False at init?
165
+ # PROBLEM: _is_dev_mode is set AFTER init by dev_server.py.
166
+ # So we register them, but gate them inside the handler, OR we allow dev_server
167
+ # to re-init app? No, dev_server imports app.
168
+
169
+ # Solution: Register them, but check self._is_dev_mode inside the handlers.
170
+ # OR refactor so routes are dynamic? Starlette routes are fixed list usually.
171
+
172
+ # Actually, let's keep them registered if debug=True, but
173
+ # modify _handle_source/_handle_file to checking _is_dev_mode as well inside.
174
+ routes.append(
175
+ Route("/_pywire/source", self._handle_source, methods=["GET"])
176
+ )
177
+ routes.append(
178
+ Route(
179
+ "/_pywire/file/{encoded:path}", self._handle_file, methods=["GET"]
180
+ )
181
+ )
182
+ # Chrome DevTools automatic workspace folders (M-135+)
183
+ routes.append(
184
+ Route(
185
+ "/.well-known/appspecific/com.chrome.devtools.json",
186
+ self._handle_devtools_json,
187
+ methods=["GET"],
188
+ )
189
+ )
190
+
191
+ # Default page handler (catch-all, must be last)
192
+ routes.append(
193
+ Route("/{path:path}", self._handle_request, methods=["GET", "POST"])
194
+ )
195
+
196
+ # Create Starlette app with all transport routes
197
+ self.app = Starlette(routes=routes, exception_handlers=exception_handlers)
198
+
199
+ # Store configuration in app state for runtime access (e.g. by pages)
200
+ self.app.state.enable_pjax = self.enable_pjax
201
+ self.app.state.debug = self.debug
202
+ self.app.state.pywire = self
203
+
204
+ async def _handle_capabilities(self, request: Request) -> JSONResponse:
205
+ """Return server transport capabilities for client negotiation."""
206
+ return JSONResponse(
207
+ {
208
+ "transports": ["websocket", "http"],
209
+ # WebTransport requires HTTP/3 - only available when running with Hypercorn
210
+ "webtransport": False,
211
+ "version": "0.1.0",
212
+ }
213
+ )
214
+
215
+ def _get_client_script_url(self) -> str:
216
+ """Return the appropriate client bundle URL based on server mode.
217
+
218
+ Returns dev bundle when running via 'pywire dev', core bundle otherwise.
219
+ """
220
+ if self._is_dev_mode:
221
+ return "/_pywire/static/pywire.dev.min.js"
222
+ return "/_pywire/static/pywire.core.min.js"
223
+
224
+ async def _handle_upload(self, request: Request) -> JSONResponse:
225
+ """Handle file uploads."""
226
+ print(f"DEBUG: Handling upload request for {request.url}")
227
+ try:
228
+ # Check for upload token
229
+ token = request.headers.get("X-Upload-Token")
230
+ if not token or token not in self.upload_tokens:
231
+ return JSONResponse(
232
+ {"error": "Invalid or expired upload token"}, status_code=403
233
+ )
234
+
235
+ # Fail-fast: Check Content-Length header
236
+ content_length = request.headers.get("content-length")
237
+ if content_length:
238
+ try:
239
+ length = int(content_length)
240
+ # Global safety limit: 10MB (allows for 5MB file + overhead)
241
+ # Real app might configure this or inspect specific field limits after streaming
242
+ if length > 10 * 1024 * 1024:
243
+ logger.warning(
244
+ f"Upload rejected. Content-Length {length} exceeds 10MB limit."
245
+ )
246
+ return JSONResponse(
247
+ {"error": "Payload Too Large"}, status_code=413
248
+ )
249
+ except ValueError:
250
+ pass
251
+
252
+ form = await request.form()
253
+ response_data = {}
254
+ for field_name, file in form.items():
255
+ if hasattr(file, "filename"): # It's an UploadFile
256
+ # We don't really need the ID if we are just testing upload for now?
257
+ # Wait, saving returns the ID!
258
+ from starlette.datastructures import UploadFile
259
+
260
+ upload_id = upload_manager.save(cast(UploadFile, file))
261
+ response_data[field_name] = upload_id
262
+
263
+ logger.debug(f"Upload successful. Returning: {response_data}")
264
+ return JSONResponse(response_data)
265
+ except Exception as e:
266
+ logger.error(f"Upload failed: {e}", exc_info=True)
267
+ return JSONResponse({"error": str(e)}, status_code=500)
268
+
269
+ async def _handle_source(self, request: Request) -> Response:
270
+ """Serve source code for debugging."""
271
+ print(f"DEBUG: _handle_source called, debug={self.debug}")
272
+ if not self.debug:
273
+ print("DEBUG: _handle_source returning 404 because debug=False")
274
+ return Response("Not Found", status_code=404)
275
+
276
+ if not self._is_dev_mode:
277
+ print("DEBUG: _handle_source returning 404 because _is_dev_mode=False")
278
+ return Response("Not Found", status_code=404)
279
+
280
+ path_str = request.query_params.get("path")
281
+ print(f"DEBUG: _handle_source path={path_str}")
282
+ if not path_str:
283
+ return Response("Missing path", status_code=400)
284
+
285
+ try:
286
+ path = Path(path_str).resolve()
287
+ print(
288
+ f"DEBUG: _handle_source resolved path={path}, exists={path.exists()}, "
289
+ f"is_file={path.is_file()}"
290
+ )
291
+ # Security check: Ensure we are only serving files from allowed directories?
292
+ # For a dev tool, we might want to allow viewing any file in the
293
+ # traceback which might include library files.
294
+ # But normally we want to restrict to project and maybe venv.
295
+ # Let's just check it exists and is a file for now.
296
+ if not path.is_file():
297
+ return Response("File not found", status_code=404)
298
+
299
+ content = path.read_text(encoding="utf-8")
300
+ return Response(content, media_type="text/plain")
301
+ except Exception as e:
302
+ print(f"DEBUG: _handle_source exception: {e}")
303
+ return Response(str(e), status_code=500)
304
+
305
+ async def _handle_file(self, request: Request) -> Response:
306
+ """Serve source file by base64-encoded path (for DevTools source mapping)."""
307
+ if not self.debug or not self._is_dev_mode:
308
+ return Response("Not Found", status_code=404)
309
+
310
+ import base64
311
+
312
+ encoded_path = request.path_params.get("encoded", "")
313
+
314
+ # If the path contains a slash, it means we appended the filename for Chrome's benefit
315
+ # e.g., "BASE64STRING/my_file.py"
316
+ # We only care about the first part
317
+ if "/" in encoded_path:
318
+ encoded = encoded_path.split("/")[0]
319
+ else:
320
+ encoded = encoded_path
321
+
322
+ try:
323
+ # Decode the base64 path (URL-safe variant)
324
+ # Restore padding
325
+ padding = 4 - (len(encoded) % 4)
326
+ if padding != 4:
327
+ encoded += "=" * padding
328
+ # Restore standard base64 chars
329
+ encoded = encoded.replace("-", "+").replace("_", "/")
330
+ path_str = base64.b64decode(encoded).decode("utf-8")
331
+
332
+ path = Path(path_str).resolve()
333
+ if not path.is_file():
334
+ return Response("File not found", status_code=404)
335
+
336
+ content = path.read_text(encoding="utf-8")
337
+ # Return as JavaScript so browser DevTools can parse it
338
+ return Response(content, media_type="text/plain")
339
+ except Exception as e:
340
+ print(f"DEBUG: _handle_file exception: {e}")
341
+ return Response(str(e), status_code=500)
342
+
343
+ async def _handle_devtools_json(self, request: Request) -> JSONResponse:
344
+ """Serve Chrome DevTools project settings for automatic workspace folders."""
345
+ if not self.debug or not self._is_dev_mode:
346
+ return JSONResponse({}, status_code=404)
347
+
348
+ import hashlib
349
+ import uuid
350
+
351
+ # Use current working directory as project root
352
+ project_root = Path.cwd()
353
+
354
+ # Generate a consistent UUID from the project path
355
+ path_hash = hashlib.md5(str(project_root).encode()).hexdigest()
356
+ project_uuid = str(uuid.UUID(path_hash[:32]))
357
+
358
+ return JSONResponse(
359
+ {"workspace": {"root": str(project_root.resolve()), "uuid": project_uuid}}
360
+ )
361
+
362
+ def _load_pages(self) -> None:
363
+ """Discover and compile all .pywire files."""
364
+ # Scan pages directory
365
+ # We need to sort files to ensure deterministic order but scanning is recursive
366
+ self._scan_directory(self.pages_dir)
367
+
368
+ # Explicitly check for __error__.wire in root pages dir
369
+ # (It is skipped by _scan_directory because it starts with _)
370
+ error_page_path = self.pages_dir / "__error__.wire"
371
+ if error_page_path.exists():
372
+ try:
373
+ root_layout = None
374
+ if (self.pages_dir / "__layout__.wire").exists():
375
+ root_layout = str((self.pages_dir / "__layout__.wire").resolve())
376
+
377
+ page_class = self.loader.load(
378
+ error_page_path, implicit_layout=root_layout
379
+ )
380
+ self.router.add_route("/__error__", page_class)
381
+ self.router.add_route("/__error__", page_class)
382
+ except Exception as e:
383
+ logger.error(
384
+ f"Failed to load error page {error_page_path}: {e}", exc_info=True
385
+ )
386
+
387
+ def _scan_directory(
388
+ self, dir_path: Path, layout_path: Optional[str] = None, url_prefix: str = ""
389
+ ) -> None:
390
+ """Recursively scan directory for pages and layouts."""
391
+ current_layout = layout_path
392
+
393
+ # Priority: __layout__.wire ONLY
394
+ potential_layout = dir_path / "__layout__.wire"
395
+
396
+ if potential_layout.exists():
397
+ # Compile layout first (it might use the parent layout!)
398
+ try:
399
+ # Layouts can inherit from parent layouts too
400
+ self.loader.load(potential_layout, implicit_layout=layout_path)
401
+ current_layout = str(potential_layout.resolve())
402
+ except Exception as e:
403
+ logger.error(
404
+ f"Failed to load layout {potential_layout}: {e}", exc_info=True
405
+ )
406
+
407
+ # 2. Iterate identifiers
408
+ # Sort to ensure index processed or consistent order
409
+ try:
410
+ entries = sorted(list(dir_path.iterdir()))
411
+ except FileNotFoundError:
412
+ return
413
+
414
+ for entry in entries:
415
+ if entry.name.startswith("_") or entry.name.startswith("."):
416
+ continue
417
+
418
+ if entry.is_dir():
419
+ # Determine new prefix
420
+ # Check if it's a param directory [param]
421
+ name = entry.name
422
+ new_segment = name
423
+
424
+ # Check for [param] syntax
425
+ param_match = re.match(r"^\[(.*?)\]$", name)
426
+ if param_match:
427
+ param_name = param_match.group(1)
428
+ # Convert to routing syntax :{name} (or whatever Router supports)
429
+ # Router supports :name or {name}
430
+ new_segment = f"{{{param_name}}}"
431
+
432
+ new_prefix = (url_prefix + "/" + new_segment).replace("//", "/")
433
+ self._scan_directory(entry, current_layout, new_prefix)
434
+
435
+ elif entry.is_file() and entry.suffix == ".wire":
436
+ if entry.name == "layout.wire":
437
+ # Previously supported layout file, now ignored (or treated
438
+ # as normal page? No, starts with l)
439
+ # Wait, layout.pywire doesn't start with _. So it would be registered as /layout
440
+ # We should probably explicitly IGNORE it if we want strictness?
441
+ # The prompt says: "absolutely NOT layout.pywire".
442
+ # If it's not a layout, is it a page? Usually layout.pywire
443
+ # has slots and shouldn't be a page.
444
+ # Let's Skip it to be safe/clean.
445
+ continue
446
+
447
+ # Determine route path
448
+ name = entry.stem # filename without .wire
449
+
450
+ route_segment = name
451
+ if name == "index":
452
+ route_segment = ""
453
+ else:
454
+ # Check for [param] in filename
455
+ param_match = re.match(r"^\[(.*?)\]$", name)
456
+ if param_match:
457
+ param_name = param_match.group(1)
458
+ route_segment = f"{{{param_name}}}"
459
+
460
+ route_path = (url_prefix + "/" + route_segment).replace("//", "/")
461
+
462
+ # Strip trailing slash for index pages (unless root)
463
+ if route_path != "/" and route_path.endswith("/"):
464
+ route_path = route_path.rstrip("/")
465
+
466
+ if not route_path:
467
+ route_path = "/"
468
+
469
+ try:
470
+ # Load page with implicit layout
471
+ page_class = self.loader.load(entry, implicit_layout=current_layout)
472
+
473
+ # Register routes
474
+ # 1. explicit !path overrides implicit routing?
475
+ # Generally yes. If !path exists, we might add those IN ADDITION or INSTEAD.
476
+ # Current logic in add_page inspects __routes__ (from !path).
477
+ # If present, use that. If not, use implicit route_path.
478
+
479
+ if hasattr(page_class, "__routes__") and page_class.__routes__:
480
+ # User specified explicit paths
481
+ self.router.add_page(page_class)
482
+ elif hasattr(page_class, "__route__") and page_class.__route__:
483
+ # Should not happen as __route__ is derived from __routes__ usually
484
+ self.router.add_page(page_class)
485
+ elif self.path_based_routing:
486
+ # No explicit !path, use file-based route ONLY if enabled
487
+ self.router.add_route(route_path, page_class)
488
+
489
+ except Exception as e:
490
+ logger.error(f"Failed to load page {entry}: {e}", exc_info=True)
491
+ self._register_error_page(entry, e)
492
+
493
+ def _register_error_page(self, file_path: Path, error: Exception) -> None:
494
+ """Register an error page for a failed file."""
495
+ # Try to infer route from file path/content
496
+ # 1. Start with path relative to pages_dir
497
+ try:
498
+ rel_path = file_path.relative_to(self.pages_dir)
499
+
500
+ # Basic route inference from path
501
+ route_path = "/" + str(rel_path.with_suffix("")).replace("index", "").strip(
502
+ "/"
503
+ )
504
+ if not route_path:
505
+ route_path = "/"
506
+
507
+ # Also try to regex extract !path directives from file content
508
+ # to handle custom routes properly even if compilation fails
509
+ try:
510
+ content = file_path.read_text()
511
+ # Look for !path "..." or !path '...'
512
+ # This is a simple regex, might need refinement
513
+ path_directives = re.findall(r'!path\s+[\'"]([^\'"]+)[\'"]', content)
514
+
515
+ routes_to_register = []
516
+ if path_directives:
517
+ routes_to_register = path_directives
518
+ else:
519
+ routes_to_register = [route_path]
520
+
521
+ # Use a ModeAwareErrorPage that checks debug/dev mode at RENDER time
522
+ # This is necessary because _is_dev_mode is set AFTER __init__ by dev_server.py
523
+ from pywire.runtime.compile_error_page import CompileErrorPage
524
+ from pywire.runtime.page import BasePage
525
+
526
+ for route in routes_to_register:
527
+ # Capture error and file_path in closure
528
+ captured_error = error
529
+ captured_file_path = str(file_path)
530
+ captured_app = self # Reference to PyWire app for mode checking
531
+
532
+ class ModeAwareErrorPage(BasePage):
533
+ """Error page that decides whether to show details or trigger 500."""
534
+
535
+ def __init__(
536
+ self, request: Request, *args: Any, **kwargs: Any
537
+ ) -> None:
538
+ # Store for parent __init__
539
+ super().__init__(request, *args, **kwargs)
540
+
541
+ async def render(self, init: bool = True) -> Any:
542
+ # Check mode at render time (not registration time!)
543
+ # This allows dev_server.py to set _is_dev_mode after app init
544
+ if captured_app.debug or getattr(
545
+ captured_app, "_is_dev_mode", False
546
+ ):
547
+ # DEV MODE: Show detailed CompileErrorPage
548
+ detail_page = CompileErrorPage(
549
+ self.request,
550
+ captured_error,
551
+ file_path=captured_file_path,
552
+ )
553
+ return await detail_page.render()
554
+ else:
555
+ # PROD MODE: Raise to trigger _handle_500
556
+ raise RuntimeError("Page failed to load")
557
+
558
+ ModeAwareErrorPage.__file_path__ = captured_file_path
559
+ self.router.add_route(route, ModeAwareErrorPage)
560
+
561
+ except Exception:
562
+ # Fallback to basic path if regex fails
563
+ pass
564
+
565
+ except Exception as e:
566
+ logger.error(f"Failed to register error page for {file_path}: {e}")
567
+
568
+ def _get_implicit_route(self, file_path: Path) -> Optional[str]:
569
+ """Calculate implicit route path from file path."""
570
+ try:
571
+ rel_path = file_path.relative_to(self.pages_dir)
572
+ except ValueError:
573
+ return None
574
+
575
+ segments = []
576
+ for i, part in enumerate(rel_path.parts):
577
+ if part.startswith("_") or part.startswith("."):
578
+ return None
579
+
580
+ name = part
581
+ is_file = i == len(rel_path.parts) - 1
582
+
583
+ if is_file:
584
+ if not name.endswith(".wire"):
585
+ return None
586
+ if name == "layout.wire":
587
+ return None
588
+ name = Path(name).stem
589
+
590
+ segment = name
591
+ if name == "index":
592
+ segment = ""
593
+
594
+ param_match = re.match(r"^\[(.*?)\]$", name)
595
+ if param_match:
596
+ param_name = param_match.group(1)
597
+ segment = f"{{{param_name}}}"
598
+
599
+ segments.append(segment)
600
+
601
+ route_path = "/" + "/".join(segments)
602
+ while "//" in route_path:
603
+ route_path = route_path.replace("//", "/")
604
+
605
+ if route_path != "/" and route_path.endswith("/"):
606
+ route_path = route_path.rstrip("/")
607
+
608
+ if not route_path:
609
+ route_path = "/"
610
+
611
+ return route_path
612
+
613
+ def _resolve_implicit_layout(self, page_path: Path) -> Optional[str]:
614
+ """Resolve the implicit layout path for a given page."""
615
+ # Traverse up from page directory to pages_dir
616
+ current_dir = page_path.parent
617
+
618
+ # Ensure we don't traverse above pages_dir
619
+ try:
620
+ # Check if page is within pages_dir
621
+ current_dir.relative_to(self.pages_dir)
622
+ except ValueError:
623
+ # Page is outside pages_dir? Should not happen normally.
624
+ return None
625
+
626
+ while True:
627
+ # Check for layout files
628
+ layout = current_dir / "__layout__.wire"
629
+
630
+ if layout.exists():
631
+ # Don't use layout if it is the file itself (e.g. reloading a layout file)
632
+ if layout.resolve() == page_path.resolve():
633
+ pass
634
+ else:
635
+ return str(layout.resolve())
636
+
637
+ if current_dir == self.pages_dir:
638
+ break
639
+
640
+ current_dir = current_dir.parent
641
+
642
+ # Safety check: stop at root
643
+ if current_dir == current_dir.parent:
644
+ break
645
+
646
+ return None
647
+
648
+ def reload_page(self, path: Path) -> bool:
649
+ """Reload and recompile a specific page and its dependents."""
650
+ # Invalidate cache for this file and dependents
651
+ invalidated_paths = self.loader.invalidate_cache(path)
652
+
653
+ # Always include the original path even if not in cache (to trigger load)
654
+ str_path = str(path.resolve())
655
+ if str_path not in invalidated_paths:
656
+ invalidated_paths.add(str_path)
657
+
658
+ for file_path_str in invalidated_paths:
659
+ file_path = Path(file_path_str)
660
+
661
+ is_in_pages = False
662
+ try:
663
+ file_path.relative_to(self.pages_dir)
664
+ is_in_pages = True
665
+ except ValueError:
666
+ is_in_pages = False
667
+
668
+ try:
669
+ # Resolve implicit layout for re-compilation
670
+ implicit_layout = self._resolve_implicit_layout(file_path)
671
+
672
+ # Recompile
673
+ new_page_class = self.loader.load(
674
+ file_path, implicit_layout=implicit_layout
675
+ )
676
+
677
+ self.router.remove_routes_for_file(str(file_path))
678
+
679
+ # Special handling for __error__.wire
680
+ if file_path.name == "__error__.wire":
681
+ self.router.add_route("/__error__", new_page_class)
682
+ elif is_in_pages:
683
+ self.router.add_page(new_page_class)
684
+
685
+ # Re-apply implicit routing if not explicitly defined
686
+ has_explicit = hasattr(new_page_class, "__routes__") or hasattr(
687
+ new_page_class, "__route__"
688
+ )
689
+ if not has_explicit and self.path_based_routing:
690
+ route_path = self._get_implicit_route(file_path)
691
+ if route_path:
692
+ self.router.add_route(route_path, new_page_class)
693
+
694
+ print(f"Reloaded: {file_path}")
695
+
696
+ except Exception as e:
697
+ logger.error(f"Failed to reload {file_path}: {e}", exc_info=True)
698
+
699
+ # If it was a page, show error
700
+ if is_in_pages or file_path.name == "__error__.wire":
701
+ self.router.remove_routes_for_file(str(file_path))
702
+ self._register_error_page(file_path, e)
703
+
704
+ # If original file failed, re-raise because the watcher expects it
705
+ if str(file_path) == str_path:
706
+ raise e
707
+ return True
708
+
709
+ async def _handle_500(self, request: Request, exc: Exception) -> Response:
710
+ """Handle 500 errors with custom page if available."""
711
+ # Try to find /__error__ page
712
+ match = self.router.match("/__error__")
713
+
714
+ if match:
715
+ try:
716
+ page_class, params, variant_name = match
717
+ # Minimal context
718
+ page = page_class(request, params, {}, path={"main": True}, url=None)
719
+ # Inject error code
720
+ page.error_code = 500
721
+ # Inject exception details if debug mode?
722
+ if self.debug:
723
+ page.error_detail = str(exc)
724
+ page.error_trace = traceback.format_exc()
725
+
726
+ response = await page.render()
727
+ # Force 500 status
728
+ response.status_code = 500
729
+ return response
730
+ except Exception as e:
731
+ # If 500 page fails, fall back
732
+ print(f"Error rendering 500 page: {e}")
733
+ pass
734
+
735
+ # If no custom page or it failed:
736
+ if self.debug:
737
+ # Re-raise to let Starlette/Server show debug traceback
738
+ raise exc
739
+
740
+ return PlainTextResponse("Internal Server Error", status_code=500)
741
+
742
+ async def _handle_request(self, request: Request) -> Response:
743
+ """Handle HTTP request."""
744
+ # Check for uploads first
745
+ # (This was handled in Route declarations, but uploads go to /_pywire/upload)
746
+
747
+ path = request.url.path
748
+ match = self.router.match(path)
749
+ if not match:
750
+ # Try custom __error__
751
+ match_error = self.router.match("/__error__")
752
+
753
+ if match_error:
754
+ page_class, params, variant_name = match_error
755
+ # Render 404/error page
756
+ # Note: We pass original request so URL is preserved?
757
+ # Yes, user checking request.url on 404 page might want to know what failed.
758
+
759
+ # Construct params/query
760
+ query = dict(request.query_params)
761
+
762
+ # Path info
763
+ path_info = {}
764
+ if hasattr(page_class, "__routes__"):
765
+ for name in page_class.__routes__.keys():
766
+ path_info[name] = name == variant_name
767
+ elif hasattr(page_class, "__route__"):
768
+ path_info["main"] = True
769
+
770
+ from pywire.runtime.router import URLHelper
771
+
772
+ url_helper = None
773
+ if hasattr(page_class, "__routes__"):
774
+ url_helper = URLHelper(page_class.__routes__)
775
+
776
+ try:
777
+ page = page_class(
778
+ request, {}, query, path=path_info, url=url_helper
779
+ )
780
+ # Inject error code
781
+ page.error_code = 404
782
+ response = await page.render()
783
+ response.status_code = 404
784
+ return response
785
+ except Exception as e:
786
+ print(f"Failed to render custom error page {page_class}: {e}")
787
+ import traceback
788
+
789
+ traceback.print_exc()
790
+ pass # Fallback
791
+
792
+ # Default 404 with client script
793
+ page = ErrorPage(
794
+ request, "404 Not Found", f"The path '{path}' could not be found."
795
+ )
796
+ response = await page.render()
797
+ response.status_code = 404
798
+ return response
799
+
800
+ page_class, params, variant_name = match
801
+ # ... (params, query, path_info, url_helper construction)
802
+ # Build query params
803
+ query = dict(request.query_params)
804
+
805
+ # Build path info dict
806
+ path_info = {}
807
+ if hasattr(page_class, "__routes__"):
808
+ for name in page_class.__routes__.keys():
809
+ path_info[name] = name == variant_name
810
+ elif hasattr(page_class, "__route__"):
811
+ path_info["main"] = True
812
+
813
+ # Build URL helper
814
+ from pywire.runtime.router import URLHelper
815
+
816
+ url_helper = None
817
+ if hasattr(page_class, "__routes__"):
818
+ url_helper = URLHelper(page_class.__routes__)
819
+
820
+ # Instantiate page
821
+ page = page_class(request, params, query, path=path_info, url=url_helper)
822
+
823
+ # Check if this is an event request
824
+ if request.method == "POST" and "X-PyWire-Event" in request.headers:
825
+ # Handle event
826
+ try:
827
+ event_data = await request.json()
828
+ update = await page.handle_event(
829
+ event_data.get("handler", ""), event_data
830
+ )
831
+ if isinstance(update, dict):
832
+ return JSONResponse(update)
833
+ response = cast(Response, update)
834
+ except Exception as e:
835
+ return JSONResponse({"error": str(e)}, status_code=500)
836
+ else:
837
+ # Normal render
838
+ response = await page.render()
839
+
840
+ # Script injection is now handled by the compiler (generator.py)
841
+ # to ensure it's present in both dev and production.
842
+
843
+ # Inject WebTransport certificate hash if available (Dev Mode)
844
+ if isinstance(response, Response) and response.media_type == "text/html":
845
+ body = bytes(response.body).decode("utf-8")
846
+ injections = []
847
+
848
+ # WebTransport Hash
849
+ if hasattr(request.app.state, "webtransport_cert_hash"):
850
+ cert_hash = list(request.app.state.webtransport_cert_hash)
851
+ injections.append(
852
+ f"<script>window.PYWIRE_CERT_HASH = {cert_hash};</script>"
853
+ )
854
+
855
+ # Upload Token Injection
856
+ if getattr(page, "__has_uploads__", False):
857
+ import secrets
858
+
859
+ token = secrets.token_urlsafe(32)
860
+ self.upload_tokens.add(token)
861
+ # Token meta tag
862
+ injections.append(
863
+ f'<meta name="pywire-upload-token" content="{token}">'
864
+ )
865
+
866
+ if injections:
867
+ injection_str = "\n".join(injections)
868
+ if "</body>" in body:
869
+ body = body.replace("</body>", injection_str + "</body>")
870
+ else:
871
+ body += injection_str
872
+ response = Response(body, media_type="text/html")
873
+
874
+ return response
875
+
876
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
877
+ """ASGI interface."""
878
+ print(f"DEBUG: Scope type: {scope['type']}")
879
+ if scope["type"] == "webtransport":
880
+ await self.web_transport_handler.handle(scope, receive, send)
881
+ return
882
+
883
+ await self.app(scope, receive, send)
884
+
885
+ # --- Extensible Hooks ---
886
+
887
+ async def on_ws_connect(self, websocket: Any) -> bool:
888
+ """
889
+ Hook called before WebSocket upgrade.
890
+ Return False to reject connection.
891
+ """
892
+ return True
893
+
894
+ def get_user(self, request_or_websocket: Any) -> Any:
895
+ """
896
+ Hook to populate page.user from request/websocket.
897
+ Override to return user from session/JWT.
898
+ """
899
+ if "user" in request_or_websocket.scope:
900
+ return request_or_websocket.user
901
+ return None