python-script-launcher 0.0.10__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.
@@ -0,0 +1,677 @@
1
+ """
2
+ Python App Launcher — Web 后端
3
+ 作为模块引入时: from python_script_launcher import create_app
4
+ 直接运行时: python app.py
5
+ """
6
+ import ast
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import re
11
+ import sys
12
+ import uuid
13
+ import io
14
+ import subprocess
15
+ import shutil
16
+ import threading
17
+ import queue
18
+ from pathlib import Path
19
+ from typing import Optional, Callable
20
+
21
+ from fastapi import FastAPI, UploadFile, File, Form
22
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse
23
+ import uvicorn
24
+
25
+ if sys.platform == "win32":
26
+ try:
27
+ sys.stdout.reconfigure(encoding="utf-8")
28
+ sys.stderr.reconfigure(encoding="utf-8")
29
+ except Exception:
30
+ pass
31
+
32
+
33
+ def _default_root():
34
+ """确定用户脚本根目录。
35
+ - frozen: 使用 sys._MEIPASS,其中通过 --add-data scripts;scripts
36
+ 带入了 scripts/ 子目录 (onefile 时为解包临时目录, onedir 时为 _internal 目录)。
37
+ - 开发: 使用 launcher 所在目录,若自身位于 scripts/ 内则回到上一级。
38
+ """
39
+ if getattr(sys, "frozen", False):
40
+ return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)).resolve()
41
+ p = Path(__file__).parent.resolve()
42
+ return p.parent if p.name == "scripts" else p
43
+
44
+
45
+ def _default_static(root: Path):
46
+ if getattr(sys, "frozen", False):
47
+ meipass = Path(sys._MEIPASS)
48
+ static = meipass / "static"
49
+ if static.exists():
50
+ return static
51
+ return root / "static"
52
+
53
+
54
+ DEFAULT_ROOT = _default_root()
55
+
56
+ # 用户脚本根目录下真正扫描的子目录名 (按优先级)。若都不存在,则退回 root 本身。
57
+ SCRIPTS_SUBDIRS = ("scripts",)
58
+
59
+ # 只在浅层递归时忽略的目录
60
+ SKIP_DIRS = {
61
+ "_uploads", "__pycache__", "dist", "build",
62
+ ".venv", "venv", "env", ".env",
63
+ "node_modules", ".git", ".idea", ".vscode",
64
+ "_internal", # PyInstaller onedir 结构,绝不进入
65
+ "site-packages",
66
+ }
67
+ SKIP_FILES = {"app.py", "client.py", "make_exe.py", "publish.py", "tasks.py",
68
+ "__init__.py", "__main__.py"}
69
+
70
+ # 快速前置过滤: 有 def main(...) 或 @task 装饰器时才做完整解析
71
+ _MAIN_RE = re.compile(rb"^\s*def\s+main\s*\(", re.MULTILINE)
72
+ _TASK_RE = re.compile(rb"^\s*@\s*(?:\w+\.)?task\b", re.MULTILINE)
73
+
74
+ # scan_scripts 缓存: {root_str: (signature, result)}
75
+ _SCAN_CACHE: dict = {}
76
+
77
+
78
+ class ParamInfo:
79
+ def __init__(self, name, type_str="str", required=True, default=None, help_text=""):
80
+ self.name = name
81
+ self.type_str = type_str
82
+ self.required = required
83
+ self.default = default
84
+ self.help_text = help_text
85
+
86
+ def to_dict(self):
87
+ return {"name": self.name, "type": self.type_str, "required": self.required,
88
+ "default": self.default, "help": self.help_text}
89
+
90
+
91
+ class ScriptInfo:
92
+ def __init__(self, name, path, docstring="", params=None,
93
+ has_file_param=False, task=None):
94
+ self.name = name
95
+ self.path = path
96
+ self.docstring = docstring or "无说明"
97
+ self.params = params or []
98
+ self.has_file_param = has_file_param
99
+ # task=None -> 兼容旧脚本 (runpy 运行, 需要 __main__ guard)
100
+ # task="<task_name>" -> 新式脚本, 通过 @task 装饰器注册
101
+ self.task = task
102
+
103
+ def to_dict(self):
104
+ return {
105
+ "name": self.name,
106
+ "path": self.path,
107
+ "docstring": self.docstring,
108
+ "params": [p.to_dict() for p in self.params],
109
+ "has_file_param": self.has_file_param,
110
+ "task": self.task,
111
+ }
112
+
113
+
114
+ def _get_annotation_str(node) -> str:
115
+ if node is None:
116
+ return "str"
117
+ try:
118
+ return ast.unparse(node).strip()
119
+ except Exception:
120
+ return "str"
121
+
122
+
123
+ def _resolve_scan_dir(root: Path) -> Path:
124
+ """在 root 下寻找真正的脚本目录:优先 root/scripts, 其次 root 本身。"""
125
+ root = Path(root).resolve()
126
+ for sub in SCRIPTS_SUBDIRS:
127
+ candidate = root / sub
128
+ if candidate.is_dir():
129
+ return candidate
130
+ return root
131
+
132
+
133
+ def _iter_script_files(scan_dir: Path):
134
+ """浅层遍历脚本目录,跳过隐藏/构建/依赖目录与保留文件名。"""
135
+ if not scan_dir.exists():
136
+ return
137
+ for py_file in scan_dir.rglob("*.py"):
138
+ parts = py_file.relative_to(scan_dir).parts
139
+ if any(part in SKIP_DIRS or part.startswith(".") for part in parts[:-1]):
140
+ continue
141
+ name = py_file.name
142
+ if name in SKIP_FILES or name.startswith("_"):
143
+ continue
144
+ yield py_file
145
+
146
+
147
+ def _dir_signature(scan_dir: Path) -> tuple:
148
+ """收集扫描目录中所有候选 .py 的 (path, mtime, size) 作为缓存 key。"""
149
+ items = []
150
+ for f in _iter_script_files(scan_dir):
151
+ try:
152
+ st = f.stat()
153
+ items.append((str(f), st.st_mtime_ns, st.st_size))
154
+ except OSError:
155
+ continue
156
+ items.sort()
157
+ return tuple(items)
158
+
159
+
160
+ def scan_scripts(root: Path) -> dict:
161
+ scan_dir = _resolve_scan_dir(Path(root))
162
+ if not scan_dir.exists():
163
+ return {}
164
+
165
+ signature = _dir_signature(scan_dir)
166
+ cache_key = str(scan_dir)
167
+ cached = _SCAN_CACHE.get(cache_key)
168
+ if cached and cached[0] == signature:
169
+ return cached[1]
170
+
171
+ scripts: dict = {}
172
+ for py_file in _iter_script_files(scan_dir):
173
+ try:
174
+ raw = py_file.read_bytes()
175
+ except OSError:
176
+ continue
177
+ has_task = bool(_TASK_RE.search(raw))
178
+ has_main = bool(_MAIN_RE.search(raw))
179
+ if not (has_task or has_main):
180
+ continue
181
+ try:
182
+ source = raw.decode("utf-8", errors="replace")
183
+ tree = ast.parse(source)
184
+ except Exception:
185
+ continue
186
+ rel = py_file.relative_to(scan_dir)
187
+ module_doc = ast.get_docstring(tree) or ""
188
+
189
+ task_entries = _extract_task_entries(tree)
190
+ if task_entries:
191
+ for task_name, func_def in task_entries.items():
192
+ params, has_file_param = _extract_params(func_def)
193
+ key = f"{py_file.stem}.{task_name}" if len(task_entries) > 1 else py_file.stem
194
+ doc = ast.get_docstring(func_def) or module_doc
195
+ scripts[key] = ScriptInfo(
196
+ name=key,
197
+ path=str(rel),
198
+ docstring=doc[:300],
199
+ params=params,
200
+ has_file_param=has_file_param,
201
+ task=task_name,
202
+ )
203
+ continue
204
+
205
+ # Fallback: legacy convention using `def main(...)` + __main__ guard.
206
+ main_def = None
207
+ for node in ast.walk(tree):
208
+ if isinstance(node, ast.FunctionDef) and node.name == "main":
209
+ main_def = node
210
+ break
211
+ if main_def is None:
212
+ continue
213
+ params, has_file_param = _extract_params(main_def)
214
+ doc = ast.get_docstring(main_def) or module_doc
215
+ scripts[py_file.stem] = ScriptInfo(
216
+ name=py_file.stem,
217
+ path=str(rel),
218
+ docstring=doc[:300],
219
+ params=params,
220
+ has_file_param=has_file_param,
221
+ )
222
+ _SCAN_CACHE[cache_key] = (signature, scripts)
223
+ return scripts
224
+
225
+
226
+ def _extract_params(func_def) -> tuple:
227
+ """Convert an ast.FunctionDef arg list into (params, has_file_param)."""
228
+ args = func_def.args
229
+ all_args = args.args
230
+ defaults = args.defaults
231
+ num_default = len(defaults)
232
+ num_args = len(all_args)
233
+ params = []
234
+ has_file_param = False
235
+ for i, arg in enumerate(all_args):
236
+ if arg.arg in ("self", "cls"):
237
+ continue
238
+ type_str = _get_annotation_str(arg.annotation)
239
+ d_idx = i - (num_args - num_default)
240
+ if d_idx >= 0:
241
+ try:
242
+ default_val = ast.literal_eval(defaults[d_idx])
243
+ except Exception:
244
+ default_val = str(ast.unparse(defaults[d_idx])).strip()
245
+ p = ParamInfo(arg.arg, type_str, False, default_val)
246
+ else:
247
+ p = ParamInfo(arg.arg, type_str, True)
248
+ if any(kw in arg.arg.lower() for kw in ("file", "path", "upload")):
249
+ has_file_param = True
250
+ params.append(p)
251
+ return params, has_file_param
252
+
253
+
254
+ def _extract_task_entries(tree) -> dict:
255
+ """Return {task_name: FunctionDef} for every @task-decorated top-level function.
256
+
257
+ Recognizes ``@task`` and ``@task(name=..., desc=...)`` (also ``@tasks.task``
258
+ or ``@some_alias.task``). ``name=`` keyword overrides the function name.
259
+ """
260
+ entries: dict = {}
261
+ for node in tree.body:
262
+ if not isinstance(node, ast.FunctionDef):
263
+ continue
264
+ for deco in node.decorator_list:
265
+ explicit_name = None
266
+ call = None
267
+ if isinstance(deco, ast.Call):
268
+ call = deco.func
269
+ for kw in deco.keywords:
270
+ if kw.arg == "name":
271
+ try:
272
+ explicit_name = ast.literal_eval(kw.value)
273
+ except Exception:
274
+ explicit_name = None
275
+ break
276
+ else:
277
+ call = deco
278
+ if isinstance(call, ast.Attribute):
279
+ attr = call.attr
280
+ elif isinstance(call, ast.Name):
281
+ attr = call.id
282
+ else:
283
+ continue
284
+ if attr != "task":
285
+ continue
286
+ task_name = explicit_name or node.name
287
+ entries[str(task_name)] = node
288
+ break
289
+ return entries
290
+
291
+
292
+ def scan_dir_for(root: Path) -> Path:
293
+ """外部调用:返回给定 root 对应的实际脚本目录(可能是 root/scripts)。"""
294
+ return _resolve_scan_dir(Path(root))
295
+
296
+
297
+ class _LineWriter:
298
+ """把 write() 拆成行, 立即投递到 queue, 支持无换行结尾的 flush."""
299
+ def __init__(self, queue: "queue.Queue", tag: str):
300
+ self._queue = queue
301
+ self._tag = tag
302
+ self._buf = ""
303
+
304
+ def write(self, data):
305
+ if not isinstance(data, str):
306
+ data = str(data)
307
+ self._buf += data
308
+ while "\n" in self._buf:
309
+ line, self._buf = self._buf.split("\n", 1)
310
+ self._queue.put((self._tag, line))
311
+ return len(data)
312
+
313
+ def flush(self):
314
+ if self._buf:
315
+ self._queue.put((self._tag, self._buf))
316
+ self._buf = ""
317
+
318
+ def isatty(self):
319
+ return False
320
+
321
+
322
+ async def _run_script_stream(script_path: Path, args_list: list):
323
+ """在后台线程里以 runpy 方式执行脚本, 异步 yield ("OUT"/"ERR"/"EXIT", value).
324
+
325
+ - 避免 exe 内启动子 python 进程(frozen 下每次会重新解包/加载依赖, 耗时数秒)。
326
+ - 通过队列增量转发标准输出/错误, 让前端看到实时进度。
327
+ """
328
+ import queue as _queue_mod
329
+ import runpy
330
+ import contextlib
331
+
332
+ out_queue: _queue_mod.Queue = _queue_mod.Queue()
333
+ sentinel = object()
334
+
335
+ def worker():
336
+ old_stdout, old_stderr = sys.stdout, sys.stderr
337
+ old_argv = sys.argv
338
+ script_dir = str(script_path.parent.resolve())
339
+ added_path = script_dir not in sys.path
340
+ if added_path:
341
+ sys.path.insert(0, script_dir)
342
+ exit_code = 0
343
+ try:
344
+ sys.stdout = _LineWriter(out_queue, "OUT")
345
+ sys.stderr = _LineWriter(out_queue, "ERR")
346
+ sys.argv = [str(script_path)] + list(args_list)
347
+ try:
348
+ runpy.run_path(str(script_path), run_name="__main__")
349
+ except SystemExit as e:
350
+ exit_code = e.code if isinstance(e.code, int) else 1
351
+ except Exception as e:
352
+ import traceback
353
+ sys.stderr.write(traceback.format_exc())
354
+ exit_code = 1
355
+ finally:
356
+ # flush 剩余无换行 buffer
357
+ try:
358
+ sys.stdout.flush(); sys.stderr.flush()
359
+ except Exception:
360
+ pass
361
+ finally:
362
+ sys.stdout, sys.stderr = old_stdout, old_stderr
363
+ sys.argv = old_argv
364
+ if added_path:
365
+ try:
366
+ sys.path.remove(script_dir)
367
+ except ValueError:
368
+ pass
369
+ out_queue.put(("EXIT", exit_code))
370
+ out_queue.put(sentinel)
371
+
372
+ loop = asyncio.get_running_loop()
373
+ thread = threading.Thread(target=worker, daemon=True)
374
+ thread.start()
375
+ while True:
376
+ item = await loop.run_in_executor(None, out_queue.get)
377
+ if item is sentinel:
378
+ break
379
+ yield item
380
+
381
+
382
+ def _coerce_task_kwargs(spec_obj, raw: dict) -> dict:
383
+ """Best-effort convert HTTP-form string values to the types declared in
384
+ the TaskSpec params. Missing values keep the task's own defaults.
385
+ """
386
+ coerced: dict = {}
387
+ lookup = {p.name: p for p in getattr(spec_obj, "params", [])}
388
+ for key, value in raw.items():
389
+ info = lookup.get(key)
390
+ if info is None or value is None or value == "":
391
+ coerced[key] = value
392
+ continue
393
+ type_str = info.type_str
394
+ try:
395
+ if type_str == "int":
396
+ coerced[key] = int(value) if not isinstance(value, int) else value
397
+ elif type_str == "float":
398
+ coerced[key] = float(value) if not isinstance(value, float) else value
399
+ elif type_str == "bool":
400
+ if isinstance(value, bool):
401
+ coerced[key] = value
402
+ else:
403
+ coerced[key] = str(value).lower() in ("1", "true", "yes", "on")
404
+ else:
405
+ coerced[key] = value
406
+ except (TypeError, ValueError):
407
+ coerced[key] = value
408
+ return coerced
409
+
410
+
411
+ async def _run_task_stream(script_path: Path, task_name: str, kwargs: dict):
412
+ """Import a script module and call its @task-registered function in-thread.
413
+
414
+ Yields ("OUT" | "ERR" | "EXIT", value) tuples, same shape as
415
+ _run_script_stream, so the SSE handler can be reused.
416
+ """
417
+ import queue as _queue_mod
418
+ import importlib
419
+ import importlib.util
420
+ import traceback
421
+
422
+ out_queue: _queue_mod.Queue = _queue_mod.Queue()
423
+ sentinel = object()
424
+
425
+ def worker():
426
+ old_stdout, old_stderr = sys.stdout, sys.stderr
427
+ script_dir = str(script_path.parent.resolve())
428
+ added_path = script_dir not in sys.path
429
+ if added_path:
430
+ sys.path.insert(0, script_dir)
431
+ exit_code = 0
432
+ try:
433
+ sys.stdout = _LineWriter(out_queue, "OUT")
434
+ sys.stderr = _LineWriter(out_queue, "ERR")
435
+ module_name = f"_launcher_task_{script_path.stem}"
436
+ try:
437
+ spec = importlib.util.spec_from_file_location(module_name, str(script_path))
438
+ if spec is None or spec.loader is None:
439
+ raise ImportError(f"cannot load {script_path}")
440
+ module = importlib.util.module_from_spec(spec)
441
+ sys.modules[module_name] = module
442
+ spec.loader.exec_module(module)
443
+ registry = getattr(module, "__tasks__", None) or {}
444
+ spec_obj = registry.get(task_name)
445
+ if spec_obj is None:
446
+ raise KeyError(f"task not registered: {task_name}")
447
+ spec_obj(**_coerce_task_kwargs(spec_obj, kwargs))
448
+ except SystemExit as e:
449
+ exit_code = e.code if isinstance(e.code, int) else 1
450
+ except Exception:
451
+ sys.stderr.write(traceback.format_exc())
452
+ exit_code = 1
453
+ finally:
454
+ try:
455
+ sys.stdout.flush(); sys.stderr.flush()
456
+ except Exception:
457
+ pass
458
+ finally:
459
+ sys.stdout, sys.stderr = old_stdout, old_stderr
460
+ if added_path:
461
+ try:
462
+ sys.path.remove(script_dir)
463
+ except ValueError:
464
+ pass
465
+ out_queue.put(("EXIT", exit_code))
466
+ out_queue.put(sentinel)
467
+
468
+ loop = asyncio.get_running_loop()
469
+ thread = threading.Thread(target=worker, daemon=True)
470
+ thread.start()
471
+ while True:
472
+ item = await loop.run_in_executor(None, out_queue.get)
473
+ if item is sentinel:
474
+ break
475
+ yield item
476
+
477
+
478
+ def find_python(project_root: Path = None) -> str:
479
+ """在 frozen 模式下仅返回自身路径(不依赖外部 Python)"""
480
+ if not getattr(sys, "frozen", False):
481
+ return sys.executable
482
+ # frozen 模式下,我们不再需要外部 Python 来执行子脚本
483
+ # 因为 run_script 会走进程内执行路径
484
+ return sys.executable
485
+
486
+
487
+ def is_port_used(port: int) -> bool:
488
+ import socket
489
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
490
+ return s.connect_ex(("127.0.0.1", port)) == 0
491
+
492
+
493
+ def create_app(
494
+ root: str | Path = None,
495
+ static_dir: str | Path = None,
496
+ uploads_dir: str | Path = None,
497
+ on_run: Optional[Callable] = None,
498
+ ) -> FastAPI:
499
+ project_root = Path(root).resolve() if root else DEFAULT_ROOT
500
+ static = Path(static_dir).resolve() if static_dir else _default_static(project_root)
501
+ uploads = Path(uploads_dir).resolve() if uploads_dir else project_root / "_uploads"
502
+ uploads.mkdir(parents=True, exist_ok=True)
503
+
504
+ python_bin = find_python(project_root)
505
+ app = FastAPI(title="Python App Launcher")
506
+
507
+ @app.get("/api/scripts")
508
+ async def list_scripts(api_root: str = None):
509
+ scan_root = Path(api_root).resolve() if api_root else project_root
510
+ scripts = scan_scripts(scan_root)
511
+ return {"root": str(scan_root), "scripts": [s.to_dict() for s in scripts.values()]}
512
+
513
+ @app.post("/api/upload")
514
+ async def upload_file(file: UploadFile = File(...)):
515
+ file_id = f"{uuid.uuid4().hex[:12]}_{file.filename}"
516
+ dest = uploads / file_id
517
+ with open(dest, "wb") as f:
518
+ content = await file.read()
519
+ f.write(content)
520
+ return {"file_id": file_id, "filename": file.filename, "size": len(content)}
521
+
522
+ @app.post("/api/run")
523
+ async def run_script(
524
+ script: str = Form(...),
525
+ args_json: str = Form("[]"),
526
+ file_ids: str = Form(""),
527
+ root: str = Form(""),
528
+ ):
529
+ scan_root = Path(root).resolve() if root else project_root
530
+ scripts_dir_ = scan_dir_for(scan_root)
531
+ scripts = scan_scripts(scan_root)
532
+ if script not in scripts:
533
+ return JSONResponse({"error": f"Unknown script: {script}"}, 404)
534
+ meta = scripts[script]
535
+ cmd = [python_bin, "-u", str(scripts_dir_ / meta.path)]
536
+
537
+ parsed_args: dict = {}
538
+ try:
539
+ args_val = json.loads(args_json)
540
+ if isinstance(args_val, dict):
541
+ parsed_args = args_val
542
+ for k, v in args_val.items():
543
+ if v is not None and v != "":
544
+ cmd.extend([f"--{k}", str(v)])
545
+ except Exception:
546
+ pass
547
+
548
+ if file_ids:
549
+ file_param = "filepath"
550
+ for p in meta.params:
551
+ if any(kw in p.name.lower() for kw in ("file", "path", "upload")):
552
+ file_param = p.name
553
+ break
554
+ file_paths = []
555
+ for fid in file_ids.split(","):
556
+ fid = fid.strip()
557
+ if fid:
558
+ fpath = uploads / fid
559
+ if fpath.exists():
560
+ file_paths.append(str(fpath))
561
+ if file_paths:
562
+ cmd.extend([f"--{file_param}"] + file_paths)
563
+ parsed_args[file_param] = file_paths[0] if len(file_paths) == 1 else file_paths
564
+
565
+ if on_run:
566
+ on_run(script, cmd)
567
+
568
+ async def stream_output():
569
+ if getattr(sys, "frozen", False) or meta.task:
570
+ # In-process execution:
571
+ # - frozen exe (avoids spawning a nested Python) or
572
+ # - task-based scripts (@task decorated, no __main__ guard).
573
+ try:
574
+ script_path = scripts_dir_ / meta.path
575
+ exit_code = 0
576
+ if meta.task:
577
+ stream = _run_task_stream(script_path, meta.task, parsed_args)
578
+ else:
579
+ script_path_str = str(script_path)
580
+ extra_args = cmd[2:] if len(cmd) > 2 else []
581
+ if extra_args and extra_args[0] == script_path_str:
582
+ extra_args = extra_args[1:]
583
+ stream = _run_script_stream(script_path, extra_args)
584
+ async for tag, value in stream:
585
+ if tag == "EXIT":
586
+ exit_code = value
587
+ continue
588
+ if not value:
589
+ continue
590
+ yield f"data: [{tag}] {value}\n\n"
591
+ yield f"data: __EXIT__:{exit_code}\n\n"
592
+ except asyncio.CancelledError:
593
+ yield "data: Cancelled\n\ndata: __EXIT__:1\n\n"
594
+ except Exception as e:
595
+ yield f"data: Error: {e}\n\ndata: __EXIT__:1\n\n"
596
+ else:
597
+ try:
598
+ kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
599
+ env = os.environ.copy()
600
+ env["PYTHONIOENCODING"] = "utf-8"
601
+ kwargs["env"] = env
602
+ if sys.platform == "win32":
603
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
604
+ elif sys.platform != "win32":
605
+ kwargs["preexec_fn"] = os.setpgrp
606
+ proc = await asyncio.create_subprocess_exec(*cmd, cwd=str(scripts_dir_), **kwargs)
607
+ encoding = "utf-8"
608
+ async for chunk in _merge_streams(proc.stdout, proc.stderr, encoding):
609
+ yield f"data: {chunk}\n\n"
610
+ await proc.wait()
611
+ yield f"data: __EXIT__:{proc.returncode}\n\n"
612
+ except asyncio.CancelledError:
613
+ yield "data: Cancelled\n\ndata: __EXIT__:1\n\n"
614
+ except Exception as e:
615
+ yield f"data: Error: {e}\n\ndata: __EXIT__:1\n\n"
616
+ return StreamingResponse(stream_output(), media_type="text/event-stream")
617
+
618
+ @app.get("/", response_class=HTMLResponse)
619
+ async def index():
620
+ candidates = []
621
+ if getattr(sys, "frozen", False):
622
+ candidates.append(Path(sys._MEIPASS) / "static" / "index.html")
623
+ candidates.extend([
624
+ static / "index.html",
625
+ project_root / "static" / "index.html",
626
+ ])
627
+ for f in candidates:
628
+ if f.exists():
629
+ return FileResponse(str(f), media_type="text/html")
630
+ return HTMLResponse("<h1>index.html not found</h1>", status_code=404)
631
+
632
+ app.state.project_root = project_root
633
+ app.state.static_dir = static
634
+ app.state.uploads_dir = uploads
635
+ return app
636
+
637
+
638
+ async def _merge_streams(stdout, stderr, encoding="utf-8"):
639
+ queue = asyncio.Queue()
640
+ async def tag_stream(stream, tag):
641
+ while True:
642
+ line = await stream.readline()
643
+ if not line:
644
+ break
645
+ text = line.decode(encoding, errors="replace").rstrip()
646
+ if text:
647
+ await queue.put(f"[{tag}] {text}")
648
+ await queue.put(None)
649
+ tasks = [asyncio.create_task(tag_stream(stdout, "OUT")),
650
+ asyncio.create_task(tag_stream(stderr, "ERR"))]
651
+ active = 2
652
+ while active > 0:
653
+ item = await queue.get()
654
+ if item is None:
655
+ active -= 1
656
+ else:
657
+ yield item
658
+ for t in tasks:
659
+ t.cancel()
660
+
661
+
662
+ app = create_app()
663
+
664
+ if __name__ == "__main__":
665
+ found = scan_scripts(DEFAULT_ROOT)
666
+ print(f"\n Python App Launcher")
667
+ print(f" Project: {DEFAULT_ROOT}")
668
+ print(f" Scripts: {len(found)} 个")
669
+ for name, info in found.items():
670
+ print(f" - {name}: {info.path}")
671
+
672
+ port = 8765
673
+ if is_port_used(port):
674
+ print(f" [!] Port {port} already in use, skipping\n")
675
+ else:
676
+ print(f" http://localhost:{port}\n")
677
+ uvicorn.run(app, host="0.0.0.0", port=port)