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.
- pywire/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +901 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +434 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
- pywire-0.1.2.dist-info/RECORD +104 -0
- pywire-0.1.1.dist-info/RECORD +0 -9
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.2.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
|
pywire/client/build.mjs
ADDED
|
@@ -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.0 - 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()
|