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
@@ -0,0 +1,64 @@
1
+ from typing import Any, Dict
2
+
3
+ from starlette.requests import Request
4
+ from starlette.responses import HTMLResponse
5
+
6
+ from pywire.runtime.page import BasePage
7
+
8
+
9
+ class ErrorPage(BasePage):
10
+ """Page used to display compilation errors."""
11
+
12
+ def __init__(self, request: Request, error_title: str, error_detail: str):
13
+ # Initialize base directly without calling super().__init__ completely
14
+ # because we don't have all the normal params
15
+ self.request = request
16
+ self.error_title = error_title
17
+ self.error_detail = error_detail
18
+
19
+ async def render(self, init: bool = True) -> HTMLResponse:
20
+ """Render the error page."""
21
+ from pywire.runtime.error_renderer import render_template
22
+
23
+ # Determine script URL (handled in app or passed here?)
24
+ # For now, default to dev script as ErrorPage is mostly used in dev/mixed?
25
+ # Actually app._get_client_script_url handles this logic, but we don't always have app ref here.
26
+ # But wait, ErrorPage is usually instantiated by app or similar.
27
+ # Let's check constructor. It takes request. We can get app from request.app usually if Starlette?
28
+ # request.app is available.
29
+
30
+ script_url = "/_pywire/static/pywire.core.min.js"
31
+ if hasattr(self.request, "app") and hasattr(
32
+ self.request.app, "_get_client_script_url"
33
+ ):
34
+ # Use the private method if available (a bit hacky but correct for PyWire app)
35
+ # Or check debug mode directly
36
+ pass
37
+
38
+ # Actually, simpler: check if we are in dev mode via request.app.state if set?
39
+ # The prompt mentioned "attach the correct script based on the environment".
40
+ # PyWire app sets self.app.state.pywire = self.
41
+
42
+ try:
43
+ pywire_app = self.request.app.state.pywire
44
+ script_url = pywire_app._get_client_script_url()
45
+ except (AttributeError, KeyError):
46
+ # Fallback
47
+ script_url = "/_pywire/static/pywire.dev.min.js"
48
+
49
+ html_content = render_template(
50
+ "error/404.html",
51
+ {
52
+ "title": self.error_title,
53
+ "message": self.error_detail or "",
54
+ "script_url": script_url,
55
+ },
56
+ )
57
+
58
+ return HTMLResponse(html_content)
59
+
60
+ async def handle_event(
61
+ self, handler_name: str, data: Dict[str, Any]
62
+ ) -> dict[str, Any]:
63
+ """No-op for error page."""
64
+ return await self.render_update(init=False)
@@ -0,0 +1,23 @@
1
+ from typing import Any, Dict
2
+
3
+ from jinja2 import Environment, PackageLoader, select_autoescape
4
+
5
+ # Initialize templating environment for internal error pages
6
+ _env = Environment(
7
+ loader=PackageLoader("pywire", "templates"),
8
+ autoescape=select_autoescape(["html", "xml"]),
9
+ )
10
+
11
+
12
+ def render_template(template_name: str, context: Dict[str, Any]) -> str:
13
+ """Render a Jinja2 template with the given context.
14
+
15
+ Args:
16
+ template_name: Name of the template relative to src/pywire/templates/
17
+ context: Dictionary of variables to pass to the template
18
+
19
+ Returns:
20
+ Rendered HTML string
21
+ """
22
+ template = _env.get_template(template_name)
23
+ return template.render(**context)
@@ -0,0 +1,23 @@
1
+ """HTML escaping utilities for XSS prevention."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def escape_html(value: Any) -> str:
7
+ """Escape HTML special characters to prevent XSS.
8
+
9
+ Escapes: & < > "
10
+
11
+ Args:
12
+ value: Any value to escape (will be converted to string first)
13
+
14
+ Returns:
15
+ HTML-escaped string safe for embedding in HTML content
16
+ """
17
+ s = str(value)
18
+ return (
19
+ s.replace("&", "&amp;")
20
+ .replace("<", "&lt;")
21
+ .replace(">", "&gt;")
22
+ .replace('"', "&quot;")
23
+ )
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class FileUpload:
6
+ """Represents an uploaded file."""
7
+
8
+ filename: str
9
+ content_type: str
10
+ size: int
11
+ content: bytes
12
+
13
+ @classmethod
14
+ def from_dict(cls, data: dict) -> "FileUpload":
15
+ """Create from dictionary (e.g. from JSON payload)."""
16
+ import base64
17
+
18
+ # content might be base64 encoded string from client
19
+ content_data = data.get("content", b"")
20
+ if isinstance(content_data, str):
21
+ # assume base64 if it's a string, or raw content?
22
+ # Client usually sends data URL: "data:image/png;base64,....."
23
+ if content_data.startswith("data:"):
24
+ header, encoded = content_data.split(",", 1)
25
+ content_bytes = base64.b64decode(encoded)
26
+ else:
27
+ # Fallback or raw base64
28
+ try:
29
+ content_bytes = base64.b64decode(content_data)
30
+ except Exception:
31
+ content_bytes = content_data.encode("utf-8")
32
+ else:
33
+ content_bytes = content_data
34
+
35
+ return cls(
36
+ filename=data.get("name", "unknown"),
37
+ content_type=data.get("type", "application/octet-stream"),
38
+ size=data.get("size", 0),
39
+ content=content_bytes,
40
+ )
@@ -0,0 +1,97 @@
1
+ from typing import Any, AsyncIterator
2
+
3
+ from pywire.core.wire import (
4
+ reset_render_context as _reset_render_context,
5
+ set_render_context as _set_render_context,
6
+ )
7
+
8
+
9
+ async def ensure_async_iterator(iterable: Any) -> AsyncIterator[Any]:
10
+ """
11
+ Ensure an iterable is an async iterator.
12
+ Handles both sync iterables (list, etc.) and async iterables.
13
+ """
14
+ if hasattr(iterable, "__aiter__"):
15
+ async for item in iterable:
16
+ yield item
17
+ elif hasattr(iterable, "__iter__"):
18
+ for item in iterable:
19
+ yield item
20
+ else:
21
+ # Fallback or error?
22
+ # Maybe it's a generator?
23
+ # If it's not iterable at all, standard for loop raises TypeError.
24
+ # We should probably let it raise, or wrapping non-iterable?
25
+ for item in iterable: # This will raise if not iterable
26
+ yield item
27
+
28
+
29
+ def set_render_context(page: Any, region_id: str) -> Any:
30
+ return _set_render_context(page, region_id)
31
+
32
+
33
+ def reset_render_context(token: Any) -> None:
34
+ _reset_render_context(token)
35
+
36
+
37
+ def unwrap_wire(value: Any) -> Any:
38
+ """Return the underlying value for single-value wires; passthrough otherwise."""
39
+ from pywire.core.wire import wire
40
+
41
+ if isinstance(value, wire):
42
+ if value.__dict__.get("_namespace"):
43
+ return value
44
+ return value.value
45
+ return value
46
+
47
+
48
+ def render_attrs(
49
+ defined_attrs: dict[str, Any], spread_attrs: dict[str, Any] | None = None
50
+ ) -> str:
51
+ """
52
+ Merge and render HTML attributes.
53
+ defined_attrs: Attributes defined in the template (explicitly).
54
+ spread_attrs: Attributes passed to the component (implicit/explicit spread).
55
+ Rules:
56
+ - spread_attrs override defined_attrs, EXCEPT:
57
+ - class: merged (appended).
58
+ - style: merged (concatenated).
59
+ """
60
+ if not spread_attrs:
61
+ spread_attrs = {}
62
+
63
+ # Copy defined_attrs to start
64
+ final_attrs = defined_attrs.copy()
65
+
66
+ for k, v in spread_attrs.items():
67
+ if k == "class" and "class" in final_attrs:
68
+ final_attrs["class"] = f"{final_attrs['class']} {v}".strip()
69
+ elif k == "style" and "style" in final_attrs:
70
+ # Naive style merge: concat with semicolon if missing
71
+ s1 = str(final_attrs["style"]).strip()
72
+ s2 = str(v).strip()
73
+ if s1 and not s1.endswith(";"):
74
+ s1 += ";"
75
+ final_attrs["style"] = f"{s1} {s2}".strip()
76
+ else:
77
+ final_attrs[k] = v
78
+
79
+ # Render
80
+ parts = []
81
+ for k, v in final_attrs.items():
82
+ if v is True: # bool attr
83
+ parts.append(f" {k}")
84
+ elif v is False or v is None:
85
+ continue
86
+ else:
87
+ # Escape HTML special characters in attribute values for XSS prevention
88
+ val = (
89
+ str(v)
90
+ .replace("&", "&amp;")
91
+ .replace("<", "&lt;")
92
+ .replace(">", "&gt;")
93
+ .replace('"', "&quot;")
94
+ )
95
+ parts.append(f' {k}="{val}"')
96
+
97
+ return "".join(parts)
@@ -0,0 +1,253 @@
1
+ """HTTP transport handler for PyWire fallback."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import msgpack
11
+ from starlette.requests import Request
12
+ from starlette.responses import Response
13
+
14
+ from pywire.runtime.page import BasePage
15
+
16
+
17
+ @dataclass
18
+ class HTTPSession:
19
+ """Represents an HTTP polling session."""
20
+
21
+ session_id: str
22
+ path: str
23
+ page: Optional[BasePage] = None
24
+ pending_updates: List[Dict[str, Any]] = field(default_factory=list)
25
+ created_at: datetime = field(default_factory=datetime.now)
26
+ last_poll: datetime = field(default_factory=datetime.now)
27
+ update_event: asyncio.Event = field(default_factory=asyncio.Event)
28
+
29
+ def is_expired(self, timeout_seconds: int = 300) -> bool:
30
+ """Check if session has expired."""
31
+ return datetime.now() - self.last_poll > timedelta(seconds=timeout_seconds)
32
+
33
+
34
+ class HTTPTransportHandler:
35
+ """Handles HTTP long-polling connections for PyWire fallback transport."""
36
+
37
+ def __init__(self, app: Any) -> None:
38
+ self.app = app
39
+ self.sessions: Dict[str, HTTPSession] = {}
40
+ self._cleanup_task: Optional[asyncio.Task] = None
41
+
42
+ def start_cleanup_task(self) -> None:
43
+ """Start background task to clean up expired sessions."""
44
+ if self._cleanup_task is None:
45
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
46
+
47
+ async def _cleanup_loop(self) -> None:
48
+ """Periodically clean up expired sessions."""
49
+ while True:
50
+ await asyncio.sleep(60) # Check every minute
51
+ expired = [
52
+ sid for sid, session in self.sessions.items() if session.is_expired()
53
+ ]
54
+ for sid in expired:
55
+ del self.sessions[sid]
56
+ if expired:
57
+ print(f"PyWire: Cleaned up {len(expired)} expired HTTP sessions")
58
+
59
+ async def create_session(self, request: Request) -> Response:
60
+ """Create a new HTTP polling session."""
61
+ try:
62
+ body = await request.body()
63
+ if not body:
64
+ # Allow empty body for initial connect
65
+ data = {}
66
+ else:
67
+ try:
68
+ data = msgpack.unpackb(body, raw=False)
69
+ except Exception:
70
+ # Fallback to JSON for compatibility if needed, or error
71
+ import json
72
+
73
+ data = json.loads(body)
74
+
75
+ path = data.get("path", "/")
76
+ except Exception:
77
+ path = "/"
78
+
79
+ session_id = str(uuid.uuid4())
80
+ session = HTTPSession(session_id=session_id, path=path)
81
+
82
+ # Try to instantiate the page for this session
83
+ match = self.app.router.match(path)
84
+ if match:
85
+ page_class, params, variant_name = match
86
+ query = dict(request.query_params)
87
+
88
+ # Build path info dict
89
+ path_info = {}
90
+ if hasattr(page_class, "__routes__"):
91
+ for name in page_class.__routes__.keys():
92
+ path_info[name] = name == variant_name
93
+ elif hasattr(page_class, "__route__"):
94
+ path_info["main"] = True
95
+
96
+ # Build URL helper
97
+ from pywire.runtime.router import URLHelper
98
+
99
+ url_helper = None
100
+ if hasattr(page_class, "__routes__"):
101
+ url_helper = URLHelper(page_class.__routes__)
102
+
103
+ session.page = page_class(
104
+ request, params, query, path=path_info, url=url_helper
105
+ )
106
+
107
+ if hasattr(self.app, "get_user"):
108
+ session.page.user = self.app.get_user(request)
109
+
110
+ # Run load hook
111
+ if hasattr(session.page, "on_load"):
112
+ if inspect.iscoroutinefunction(session.page.on_load):
113
+ await session.page.on_load()
114
+ else:
115
+ session.page.on_load()
116
+
117
+ self.sessions[session_id] = session
118
+ self.start_cleanup_task()
119
+
120
+ return Response(
121
+ msgpack.packb({"sessionId": session_id}), media_type="application/x-msgpack"
122
+ )
123
+
124
+ async def poll(self, request: Request) -> Response:
125
+ """Long-poll for updates."""
126
+ session_id = request.query_params.get("session")
127
+
128
+ if not session_id or session_id not in self.sessions:
129
+ return Response(
130
+ msgpack.packb({"error": "Session not found"}),
131
+ status_code=404,
132
+ media_type="application/x-msgpack",
133
+ )
134
+
135
+ session = self.sessions[session_id]
136
+ session.last_poll = datetime.now()
137
+
138
+ # Check if updates already pending
139
+ if session.pending_updates:
140
+ updates = session.pending_updates.copy()
141
+ session.pending_updates.clear()
142
+ session.update_event.clear()
143
+ return Response(msgpack.packb(updates), media_type="application/x-msgpack")
144
+
145
+ # Wait for updates with timeout
146
+ try:
147
+ await asyncio.wait_for(session.update_event.wait(), timeout=30.0)
148
+
149
+ # Event set, get updates
150
+ updates = session.pending_updates.copy()
151
+ session.pending_updates.clear()
152
+ session.update_event.clear()
153
+ return Response(msgpack.packb(updates), media_type="application/x-msgpack")
154
+
155
+ except asyncio.TimeoutError:
156
+ # Return empty array on timeout
157
+ return Response(msgpack.packb([]), media_type="application/x-msgpack")
158
+
159
+ async def handle_event(self, request: Request) -> Response:
160
+ """Handle an event from an HTTP client."""
161
+ session_id = request.headers.get("X-PyWire-Session")
162
+
163
+ if not session_id or session_id not in self.sessions:
164
+ return Response(
165
+ msgpack.packb({"error": "Session not found"}),
166
+ status_code=404,
167
+ media_type="application/x-msgpack",
168
+ )
169
+
170
+ session = self.sessions[session_id]
171
+ session.last_poll = datetime.now()
172
+
173
+ try:
174
+ body = await request.body()
175
+ data = msgpack.unpackb(body, raw=False)
176
+ handler_name = data.get("handler")
177
+ event_data = data.get("data", {})
178
+
179
+ if session.page is None:
180
+ # Recreate page if needed
181
+ match = self.app.router.match(session.path)
182
+ if not match:
183
+ return Response(
184
+ msgpack.packb({"error": "Page not found"}),
185
+ status_code=404,
186
+ media_type="application/x-msgpack",
187
+ )
188
+
189
+ page_class, params, variant_name = match
190
+ query = dict(request.query_params)
191
+
192
+ # Build path info dict
193
+ path_info = {}
194
+ if hasattr(page_class, "__routes__"):
195
+ for name in page_class.__routes__.keys():
196
+ path_info[name] = name == variant_name
197
+ elif hasattr(page_class, "__route__"):
198
+ path_info["main"] = True
199
+
200
+ # Build URL helper
201
+ from pywire.runtime.router import URLHelper
202
+
203
+ url_helper = None
204
+ if hasattr(page_class, "__routes__"):
205
+ url_helper = URLHelper(page_class.__routes__)
206
+
207
+ session.page = page_class(
208
+ request, params, query, path=path_info, url=url_helper
209
+ )
210
+
211
+ # Run load hook
212
+ if hasattr(session.page, "on_load"):
213
+ if inspect.iscoroutinefunction(session.page.on_load):
214
+ await session.page.on_load()
215
+ else:
216
+ session.page.on_load()
217
+
218
+ # Dispatch event
219
+ update = await session.page.handle_event(handler_name, event_data)
220
+
221
+ if isinstance(update, Response):
222
+ html = bytes(update.body).decode("utf-8")
223
+ payload: Dict[str, Any] = {"type": "update", "html": html}
224
+ elif isinstance(update, dict) and update.get("type") == "regions":
225
+ payload = {"type": "update", "regions": update.get("regions", [])}
226
+ elif isinstance(update, dict) and update.get("type") == "full":
227
+ payload = {"type": "update", "html": update.get("html", "")}
228
+ else:
229
+ payload = {"type": "error", "error": "Invalid update payload"}
230
+
231
+ return Response(
232
+ msgpack.packb(payload),
233
+ media_type="application/x-msgpack",
234
+ )
235
+
236
+ except Exception as e:
237
+ return Response(
238
+ msgpack.packb({"type": "error", "error": str(e)}),
239
+ status_code=500,
240
+ media_type="application/x-msgpack",
241
+ )
242
+
243
+ def queue_update(self, session_id: str, update: Dict[str, Any]) -> None:
244
+ """Queue an update to be sent to a specific session."""
245
+ if session_id in self.sessions:
246
+ self.sessions[session_id].pending_updates.append(update)
247
+ self.sessions[session_id].update_event.set()
248
+
249
+ def broadcast_reload(self) -> None:
250
+ """Queue reload message to all sessions."""
251
+ for session in self.sessions.values():
252
+ session.pending_updates.append({"type": "reload"})
253
+ session.update_event.set()