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