pywire 0.1.1__py3-none-any.whl → 0.1.3__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 (103) 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 +433 -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/static/pywire.core.min.js +3 -0
  97. pywire/static/pywire.dev.min.js +20 -0
  98. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/METADATA +1 -1
  99. pywire-0.1.3.dist-info/RECORD +106 -0
  100. pywire-0.1.1.dist-info/RECORD +0 -9
  101. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/WHEEL +0 -0
  102. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/entry_points.txt +0 -0
  103. {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/licenses/LICENSE +0 -0
pywire/cli/tui.py ADDED
@@ -0,0 +1,563 @@
1
+ import asyncio
2
+ import sys
3
+ import time
4
+ from textual.app import App, ComposeResult
5
+ from textual.coordinate import Coordinate
6
+ from textual.widgets import Header, Footer, Label, DataTable
7
+ from textual.binding import Binding
8
+ from rich.text import Text
9
+ import shutil
10
+ import subprocess
11
+ from textual import events
12
+
13
+
14
+ class LogTable(DataTable):
15
+ async def on_mouse_down(self, event: events.MouseDown) -> None:
16
+ if self.app and hasattr(self.app, "handle_log_mouse_down"):
17
+ try:
18
+ coord = Coordinate(event.offset.x, event.offset.y)
19
+ meta = self.get_cell_at(coord)
20
+ if meta:
21
+ row_key = self.coordinate_to_cell_key(meta).row_key
22
+ if row_key:
23
+ if self.app.handle_log_mouse_down(
24
+ row_key.value, shift=event.shift
25
+ ):
26
+ self.capture_mouse()
27
+ self._mouse_captured = True
28
+ except Exception:
29
+ pass
30
+
31
+ async def on_mouse_move(self, event: events.MouseMove) -> None:
32
+ if (
33
+ getattr(self, "_mouse_captured", False)
34
+ and self.app
35
+ and hasattr(self.app, "handle_log_mouse_move")
36
+ ):
37
+ try:
38
+ coord = Coordinate(event.offset.x, event.offset.y)
39
+ meta = self.get_cell_at(coord)
40
+ if meta:
41
+ row_key = self.coordinate_to_cell_key(meta).row_key
42
+ if row_key:
43
+ self.app.handle_log_mouse_move(row_key.value)
44
+ except Exception:
45
+ pass
46
+
47
+ async def on_mouse_up(self, event: events.MouseUp) -> None:
48
+ if getattr(self, "_mouse_captured", False):
49
+ self.release_mouse()
50
+ self._mouse_captured = False
51
+
52
+ def on_click(self, event: events.Click) -> None:
53
+ # We handle click logic mostly in mouse_down for drag start,
54
+ # but simple toggle/click might need to be resolved here if not dragging?
55
+ # Actually, mouse_down starts a potential drag.
56
+ # If we release on same cell without moving much, it's a click.
57
+ # But we can just use mouse_down to "start selection" (select 1 cell).
58
+ # And mouse_move to "extend".
59
+ # So on_click is less needed if we handle mouse_down?
60
+ # But super().on_click handles row cursor activation.
61
+ # We'll pass through.
62
+ # super().on_click(event) - AttributeError: 'super' object has no attribute 'on_click'
63
+ pass
64
+
65
+
66
+ class PyWireDevDashboard(App):
67
+ CSS = """
68
+ Screen { layout: vertical; }
69
+ DataTable { width: 100%; height: 1fr; border: solid $accent; }
70
+ DataTable > .datatable--cursor { background: $accent 20%; }
71
+ DataTable > .datatable--header { display: none; }
72
+ #header-info { dock: top; height: 1; content-align: center middle; background: $primary; color: $text; }
73
+ """
74
+
75
+ BINDINGS = [
76
+ Binding("q", "quit", "Quit"),
77
+ Binding("r", "restart_server", "Restart Server"),
78
+ Binding("c", "clear_logs", "Clear Logs"),
79
+ Binding("l", "toggle_log_level", "Toggle Log Level"),
80
+ Binding("y", "copy_logs", "Copy Logs (Clipboard)"),
81
+ Binding("space", "toggle_selection", "Select/Deselect Line"),
82
+ Binding("enter", "toggle_selection", "Select/Deselect Line"),
83
+ Binding("escape", "deselect_all", "Deselect All"),
84
+ ]
85
+
86
+ def __init__(self, command: list[str], host: str, port: int):
87
+ super().__init__()
88
+ self.command = command
89
+ self.host = host
90
+ self.port = port
91
+ self.server_process: asyncio.subprocess.Process | None = None
92
+ self.start_time = time.time()
93
+ # User requested: Debug -> Info -> Warning -> Error, starting at Info
94
+ self.log_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]
95
+ self.current_log_level_index = 1 # Start at INFO
96
+ self._log_store: list[tuple[Text, int]] = []
97
+ self._selected_indices: set[int] = set()
98
+ self._last_selected_index: int | None = None
99
+ self._drag_start_index: int | None = None
100
+ self._is_dragging: bool = False
101
+
102
+ @property
103
+ def current_log_level(self) -> str:
104
+ return self.log_levels[self.current_log_level_index]
105
+
106
+ def compose(self) -> ComposeResult:
107
+ self.title = "pywire Dev Dashboard"
108
+ yield Header(show_clock=False)
109
+ yield Label(
110
+ f"Server: http://{self.host}:{self.port} | Uptime: 00:00:00",
111
+ id="header-info",
112
+ )
113
+
114
+ # Using DataTable for selectable log rows
115
+ # Using LogTable for selectable log rows (supports shift+click)
116
+ table = LogTable(id="log-window", cursor_type="row", zebra_stripes=False)
117
+ table.add_column("Log", key="log")
118
+ yield table
119
+
120
+ yield Footer()
121
+
122
+ async def on_mount(self) -> None:
123
+ """Start the server when the TUI loads."""
124
+ protocol = "https" if "--ssl-keyfile" in self.command else "http"
125
+ self.log_write(
126
+ f"[bold yellow]Initializing pywire Server on {protocol}://{self.host}:{self.port}...[/]",
127
+ level=20,
128
+ )
129
+
130
+ # Start server task
131
+ self.server_task = asyncio.create_task(self.run_server())
132
+ self.set_interval(1, self.update_uptime)
133
+
134
+ async def on_unmount(self) -> None:
135
+ """Ensure server subprocess is killed when TUI exits."""
136
+ # Cancel the server loop task
137
+ if hasattr(self, "server_task"):
138
+ self.server_task.cancel()
139
+ try:
140
+ await self.server_task
141
+ except asyncio.CancelledError:
142
+ pass
143
+
144
+ if self.server_process and self.server_process.returncode is None:
145
+ try:
146
+ self.server_process.terminate()
147
+ # Give it 1 second to die gracefully
148
+ try:
149
+ await asyncio.wait_for(self.server_process.wait(), timeout=1.0)
150
+ except asyncio.TimeoutError:
151
+ self.server_process.kill()
152
+ await self.server_process.wait()
153
+ except ProcessLookupError:
154
+ pass
155
+ except Exception:
156
+ try:
157
+ self.server_process.kill()
158
+ except Exception:
159
+ pass
160
+
161
+ def update_uptime(self) -> None:
162
+ uptime_seconds = int(time.time() - self.start_time)
163
+ hours, remainder = divmod(uptime_seconds, 3600)
164
+ minutes, seconds = divmod(remainder, 60)
165
+ uptime_str = f"{hours:02}:{minutes:02}:{seconds:02}"
166
+
167
+ protocol = "https" if "--ssl-keyfile" in self.command else "http"
168
+
169
+ header_info = self.query_one("#header-info", Label)
170
+ header_info.update(
171
+ f"Server: {protocol}://{self.host}:{self.port} | Uptime: {uptime_str} | Log Level: {self.current_log_level}"
172
+ )
173
+
174
+ def log_write(self, message: str | Text, level: int = 20) -> None:
175
+ """Writes a message to the internal log store and updates the widget if visible."""
176
+ if isinstance(message, str):
177
+ text = Text.from_markup(message)
178
+ else:
179
+ text = message
180
+
181
+ entry = (text, level)
182
+ self._log_store.append(entry)
183
+
184
+ level_map = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40}
185
+ current_threshold = level_map.get(self.current_log_level, 20)
186
+
187
+ if level >= current_threshold:
188
+ self._add_log_to_table(text, len(self._log_store) - 1)
189
+
190
+ def _add_log_to_table(self, text: Text, store_index: int):
191
+ """Helper to add a row to the table safely."""
192
+ try:
193
+ table = self.query_one("#log-window", DataTable)
194
+
195
+ # Check if this index is selected
196
+ if store_index in self._selected_indices:
197
+ # User requested NO caret, just highlight
198
+ display_text = text
199
+ # Use a specific high-contrast style for selection
200
+ display_text.style = "bold white on $secondary"
201
+ else:
202
+ display_text = text
203
+
204
+ table.add_row(display_text, key=str(store_index))
205
+
206
+ # Auto-scroll to bottom
207
+ table.move_cursor(row=table.row_count - 1, animate=False)
208
+ except Exception:
209
+ # Widget might be unmounted or not found
210
+ pass
211
+
212
+ def refresh_log_view(self):
213
+ """Clears and repopulates the log window based on current filter."""
214
+ try:
215
+ table = self.query_one("#log-window", DataTable)
216
+ table.clear()
217
+
218
+ level_map = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40}
219
+ current_threshold = level_map.get(self.current_log_level, 20)
220
+
221
+ for idx, (text, level) in enumerate(self._log_store):
222
+ # Always show system messages (level >= 100) or if meets threshold
223
+ if level >= 100 or level >= current_threshold:
224
+ self._add_log_to_table(text, idx)
225
+
226
+ except Exception:
227
+ pass
228
+
229
+ def _update_row_appearance(self, store_index: int):
230
+ """Updates the appearance of a single row based on selection state."""
231
+ try:
232
+ table = self.query_one("#log-window", DataTable)
233
+ if 0 <= store_index < len(self._log_store):
234
+ text, _ = self._log_store[store_index]
235
+ if store_index in self._selected_indices:
236
+ # User requested NO caret, just highlight
237
+ display_text = text
238
+ display_text.stylize("bold white on $secondary")
239
+ else:
240
+ display_text = text
241
+
242
+ # We need the key as string
243
+ table.update_cell(str(store_index), "log", display_text)
244
+ except Exception:
245
+ pass
246
+
247
+ def handle_log_mouse_down(self, store_index_str: str, shift: bool = False) -> bool:
248
+ """Handle mouse down: toggle single or start range."""
249
+ try:
250
+ store_index = int(store_index_str)
251
+ except ValueError:
252
+ return False
253
+
254
+ if shift and self._last_selected_index is not None:
255
+ # Shift+Click (immediate range)
256
+ # Clear purely if we want standard behavior?
257
+ # Or add to selection?
258
+ # Standard: Select range from anchor to here.
259
+ # We clear current explicit selection if it's a new range?
260
+ # User said: "shift click to select a range".
261
+ # Usually implies resetting selection to just that range?
262
+ # Or extending?
263
+ # Let's say: Reset others, select range.
264
+ self.action_deselect_all_silent()
265
+
266
+ start = min(self._last_selected_index, store_index)
267
+ end = max(self._last_selected_index, store_index)
268
+ for i in range(start, end + 1):
269
+ self._selected_indices.add(i)
270
+
271
+ # Don't update last_selected_index on shift-click usually, or do?
272
+ # Usually Shift+Click preserves anchor.
273
+ # We keep _last_selected_index as anchor.
274
+ else:
275
+ # Regular Click/Drag Start
276
+ # If simple click: Toggle? Or select exclusive?
277
+ # User liked "clicking added/removed" (Toggle).
278
+ # We will toggle.
279
+ if store_index in self._selected_indices:
280
+ self._selected_indices.remove(store_index)
281
+ else:
282
+ self._selected_indices.add(store_index)
283
+
284
+ self._last_selected_index = store_index
285
+ self._drag_start_index = store_index # Anchor for drag
286
+
287
+ self.refresh_visible_rows()
288
+ return True # Capture mouse
289
+
290
+ def handle_log_mouse_move(self, store_index_str: str):
291
+ """Handle drag: extend selection from drag anchor."""
292
+ try:
293
+ current_index = int(store_index_str)
294
+ except ValueError:
295
+ return
296
+
297
+ if self._drag_start_index is not None:
298
+ # Select range [start, current]
299
+ # But wait, we want to toggle them? Or force select?
300
+ # Drag usually force selects.
301
+ # We should probably clear OTHER selections if we assume standard behavior?
302
+ # But user likes toggle.
303
+ # "Click + Drag" usually means: Select everything in dragged range.
304
+ # We'll validly set everything in range to selected.
305
+
306
+ start = min(self._drag_start_index, current_index)
307
+ end = max(self._drag_start_index, current_index)
308
+
309
+ # Optimization: only update what changed?
310
+ # For now, just add loop.
311
+ for i in range(start, end + 1):
312
+ self._selected_indices.add(i)
313
+
314
+ self._last_selected_index = current_index
315
+ self.refresh_visible_rows()
316
+
317
+ def action_deselect_all_silent(self):
318
+ """Deselect without refresh (internal)."""
319
+ self._selected_indices.clear()
320
+
321
+ def refresh_visible_rows(self):
322
+ """Efficiently update appearance of rows."""
323
+ # This is expensive if we do ALL.
324
+ # But we only need to update visible or changed?
325
+ # For simplicity, we loop log store for now, or just trust reactive updates?
326
+ # We need to manually call _update_row_appearance.
327
+ # Instead of updating ALL (expensive), we should track what we acted on.
328
+ # But for 'deselect all' we need to update all old ones.
329
+
330
+ # We'll just force refresh of current view - or just update rows that CHANGED?
331
+ # That requires diffing.
332
+ # Let's iterate all valid indices in table?
333
+ # We can iterate self._log_store and update.
334
+ # We'll accept O(N) for now (~1000 lines is fine, 100k is slow).
335
+ # We can optimize later.
336
+
337
+ # Actually, iterating 0 to len(_log_store) and calling update_cell on every mouse move is BAD.
338
+ # We should optimize handle_log_mouse_move to only update specific rows.
339
+ # But for now, we leave it to be safe on logic.
340
+
341
+ # Better: _update_row_appearance only calls update_cell.
342
+ # We can just iterate visible range?
343
+ try:
344
+ # Just iterate all simple for correctness first.
345
+ for i in range(len(self._log_store)):
346
+ self._update_row_appearance(i)
347
+ except Exception:
348
+ pass
349
+
350
+ def action_toggle_log_level(self):
351
+ self.current_log_level_index = (self.current_log_level_index + 1) % len(
352
+ self.log_levels
353
+ )
354
+ self.update_uptime() # Update header
355
+ self.refresh_log_view()
356
+
357
+ def action_toggle_selection(self):
358
+ """Toggle selection of the current row."""
359
+ try:
360
+ table = self.query_one("#log-window", DataTable)
361
+ cursor_row = table.cursor_row
362
+ if cursor_row is None:
363
+ return
364
+
365
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
366
+ if row_key.value is None:
367
+ return
368
+ store_index = int(row_key.value)
369
+
370
+ if store_index in self._selected_indices:
371
+ self._selected_indices.remove(store_index)
372
+ else:
373
+ self._selected_indices.add(store_index)
374
+
375
+ self._last_selected_index = store_index
376
+ self._update_row_appearance(store_index)
377
+
378
+ except Exception:
379
+ pass
380
+
381
+ def action_deselect_all(self):
382
+ """Deselect all rows."""
383
+ old_indices = list(self._selected_indices)
384
+ self._selected_indices.clear()
385
+ self._last_selected_index = None
386
+ for idx in old_indices:
387
+ self._update_row_appearance(idx)
388
+
389
+ async def run_server(self):
390
+ """Runs the actual Uvicorn server as a subprocess."""
391
+ self.server_process = await asyncio.create_subprocess_exec(
392
+ *self.command,
393
+ stdout=asyncio.subprocess.PIPE,
394
+ stderr=asyncio.subprocess.PIPE,
395
+ )
396
+
397
+ async def read_stream(stream):
398
+ while True:
399
+ line = await stream.readline()
400
+ if not line:
401
+ break
402
+ text_content = line.decode().strip()
403
+
404
+ if "Press CTRL+C to quit" in text_content:
405
+ text_content = text_content.replace(
406
+ "Press CTRL+C to quit", "Press q to quit"
407
+ )
408
+
409
+ # Determine level
410
+ level = 20 # Default INFO
411
+ upper_text = text_content.upper()
412
+ if "DEBUG" in upper_text:
413
+ level = 10
414
+ elif "INFO" in upper_text:
415
+ level = 20
416
+ elif "WARNING" in upper_text:
417
+ level = 30
418
+ elif "ERROR" in upper_text:
419
+ level = 40
420
+
421
+ # System/Always visible messages logic
422
+ if "PyWire" in text_content:
423
+ if "Error" in text_content:
424
+ level = 40
425
+ elif "Warning" in text_content:
426
+ level = 30
427
+ else:
428
+ level = 20
429
+
430
+ renderable = Text.from_ansi(text_content)
431
+ self.log_write(renderable, level=level)
432
+
433
+ if self.server_process.stdout and self.server_process.stderr:
434
+ try:
435
+ await asyncio.gather(
436
+ read_stream(self.server_process.stdout),
437
+ read_stream(self.server_process.stderr),
438
+ )
439
+ except asyncio.CancelledError:
440
+ # Task cancelled (shutdown)
441
+ pass
442
+
443
+ async def action_restart_server(self):
444
+ """Kill and restart the subprocess."""
445
+ self.log_write("\n[bold magenta]↻ Restarting Server...[/]\n", level=100)
446
+
447
+ if self.server_process:
448
+ try:
449
+ self.server_process.terminate()
450
+ await self.server_process.wait()
451
+ except ProcessLookupError:
452
+ pass
453
+
454
+ self.start_time = time.time()
455
+ # Cancel old task?
456
+ if hasattr(self, "server_task"):
457
+ self.server_task.cancel()
458
+ self.server_task = asyncio.create_task(self.run_server())
459
+
460
+ def action_clear_logs(self):
461
+ try:
462
+ self.query_one("#log-window", DataTable).clear()
463
+ except Exception:
464
+ pass
465
+ self._log_store = []
466
+ self._selected_indices = set()
467
+ self._last_selected_index = None
468
+
469
+ def action_copy_logs(self):
470
+ """Copy current log view to system clipboard."""
471
+ lines = []
472
+
473
+ # If selection exists, copy only selection
474
+ if self._selected_indices:
475
+ sorted_indices = sorted(self._selected_indices)
476
+ for idx in sorted_indices:
477
+ if 0 <= idx < len(self._log_store):
478
+ text_obj, _ = self._log_store[idx]
479
+ plain = text_obj.plain
480
+ # Exclude tips if needed? User said "Tip: ... to be part of output" (Don't want)
481
+ if "Tip: Use Space or Click" in plain:
482
+ continue
483
+ lines.append(plain)
484
+ else:
485
+ # Copy all visible logs
486
+ level_map = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40}
487
+ current_threshold = level_map.get(self.current_log_level, 20)
488
+ for text_obj, level in self._log_store:
489
+ # Level 100 are system messages. User said "don't want Copied n lines..."
490
+ # Copied messages are level 100.
491
+ # We should probably filters out "Copied ..." messages themselves?
492
+ # Or just not store Copied messages in the log store?
493
+ # Ah, log_write stores them.
494
+ plain = text_obj.plain
495
+ if "Copied" in plain and "lines" in plain and "clipboard" in plain:
496
+ continue
497
+ if "Tip: Use Space or Click" in plain:
498
+ continue
499
+
500
+ if level >= 100 or level >= current_threshold:
501
+ lines.append(plain)
502
+
503
+ content = "\n".join(lines)
504
+
505
+ copied = False
506
+ try:
507
+ if sys.platform == "darwin" and shutil.which("pbcopy"):
508
+ subprocess.run("pbcopy", input=content, text=True)
509
+ copied = True
510
+ elif sys.platform.startswith("linux"):
511
+ if shutil.which("wl-copy"):
512
+ subprocess.run("wl-copy", input=content, text=True)
513
+ copied = True
514
+ elif shutil.which("xclip"):
515
+ subprocess.run(
516
+ ["xclip", "-selection", "clipboard"], input=content, text=True
517
+ )
518
+ copied = True
519
+ elif sys.platform == "win32":
520
+ subprocess.run("clip", input=content, text=True)
521
+ copied = True
522
+ except Exception as e:
523
+ self.notify(f"Copy failed: {e}", severity="error")
524
+ return
525
+
526
+ if copied:
527
+ self.notify(f"Copied {len(lines)} lines to clipboard!")
528
+ # Deselect lines after copying
529
+ self.action_deselect_all()
530
+ else:
531
+ self.notify("Clipboard tool not found.", severity="error")
532
+
533
+
534
+ def start_tui(
535
+ app_path: str,
536
+ host: str,
537
+ port: int,
538
+ ssl_keyfile: str | None,
539
+ ssl_certfile: str | None,
540
+ env_file: str | None,
541
+ ) -> None:
542
+ cmd = [
543
+ sys.executable,
544
+ "-m",
545
+ "pywire.cli.main",
546
+ "dev",
547
+ app_path,
548
+ "--no-tui",
549
+ "--host",
550
+ host,
551
+ "--port",
552
+ str(port),
553
+ ]
554
+
555
+ if ssl_keyfile:
556
+ cmd.extend(["--ssl-keyfile", ssl_keyfile])
557
+ if ssl_certfile:
558
+ cmd.extend(["--ssl-certfile", ssl_certfile])
559
+ if env_file:
560
+ cmd.extend(["--env-file", env_file])
561
+
562
+ app = PyWireDevDashboard(cmd, host, port)
563
+ app.run()
pywire/cli/validate.py ADDED
@@ -0,0 +1,26 @@
1
+ """Validation for .pywire files."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from pywire.compiler.parser import PyWireParser
7
+
8
+
9
+ def validate_project(pages_dir: Path) -> List[str]:
10
+ """Validate all .pywire files in project."""
11
+ errors = []
12
+ parser = PyWireParser()
13
+
14
+ if not pages_dir.exists():
15
+ return [f"Pages directory not found: {pages_dir}"]
16
+
17
+ for pywire_file in pages_dir.rglob("*.pywire"):
18
+ try:
19
+ parsed = parser.parse_file(pywire_file)
20
+ # Basic validation
21
+ if not parsed.template and not parsed.directives:
22
+ errors.append(f"{pywire_file}: No template or directives found")
23
+ except Exception as e:
24
+ errors.append(f"{pywire_file}: {str(e)}")
25
+
26
+ return errors
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ coverage/
5
+ *.lock
6
+ pnpm-lock.yaml
7
+ package-lock.json
8
+ yarn.lock
@@ -0,0 +1,7 @@
1
+ {
2
+ "tabWidth": 2,
3
+ "semi": false,
4
+ "singleQuote": true,
5
+ "printWidth": 100,
6
+ "trailingComma": "es5"
7
+ }
@@ -0,0 +1,73 @@
1
+ import * as esbuild from 'esbuild'
2
+ import { resolve, dirname } from 'path'
3
+ import { fileURLToPath } from 'url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ const isDev = process.argv.includes('--dev')
8
+ const isWatch = process.argv.includes('--watch')
9
+
10
+ // Bundle configurations
11
+ const bundles = [
12
+ {
13
+ name: 'core',
14
+ entryPoints: [resolve(__dirname, 'src/pywire.core.ts')],
15
+ outfile: resolve(__dirname, '../static/pywire.core.min.js'),
16
+ globalName: 'PyWireCore',
17
+ },
18
+ {
19
+ name: 'dev',
20
+ entryPoints: [resolve(__dirname, 'src/pywire.dev.ts')],
21
+ outfile: resolve(__dirname, '../static/pywire.dev.min.js'),
22
+ globalName: 'PyWire',
23
+ },
24
+ ]
25
+
26
+ function getBuildOptions(bundle) {
27
+ return {
28
+ entryPoints: bundle.entryPoints,
29
+ bundle: true,
30
+ outfile: bundle.outfile,
31
+ format: 'iife',
32
+ globalName: bundle.globalName,
33
+ target: ['es2020'],
34
+ minify: !isDev,
35
+ sourcemap: isDev,
36
+ treeShaking: true,
37
+ define: {
38
+ 'process.env.NODE_ENV': isDev ? '"development"' : '"production"',
39
+ },
40
+ banner: {
41
+ js: `/* PyWire Client ${bundle.name} v0.1.3 - https://github.com/pywire/pywire */`,
42
+ },
43
+ }
44
+ }
45
+
46
+ async function build() {
47
+ try {
48
+ if (isWatch) {
49
+ // Build all bundles initially, then watch dev bundle
50
+ for (const bundle of bundles) {
51
+ await esbuild.build(getBuildOptions(bundle))
52
+ console.log(`Built ${bundle.name} bundle`)
53
+ }
54
+
55
+ // Watch dev bundle for changes
56
+ const ctx = await esbuild.context(getBuildOptions(bundles[1]))
57
+ await ctx.watch()
58
+ console.log('Watching dev bundle for changes...')
59
+ } else {
60
+ // Build all bundles
61
+ for (const bundle of bundles) {
62
+ await esbuild.build(getBuildOptions(bundle))
63
+ console.log(`Built ${bundle.name}: ${bundle.outfile}`)
64
+ }
65
+ console.log('Build complete!')
66
+ }
67
+ } catch (err) {
68
+ console.error('Build failed:', err)
69
+ process.exit(1)
70
+ }
71
+ }
72
+
73
+ build()