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.
- python_script_launcher/__init__.py +49 -0
- python_script_launcher/__main__.py +191 -0
- python_script_launcher/app.py +677 -0
- python_script_launcher/client.py +99 -0
- python_script_launcher/make_exe.py +189 -0
- python_script_launcher/scripts/file_status.py +34 -0
- python_script_launcher/scripts/hello.py +17 -0
- python_script_launcher/scripts/multi.py +24 -0
- python_script_launcher/scripts/test_modules.py +24 -0
- python_script_launcher/static/index.html +147 -0
- python_script_launcher/tasks.py +189 -0
- python_script_launcher-0.0.10.dist-info/METADATA +181 -0
- python_script_launcher-0.0.10.dist-info/RECORD +16 -0
- python_script_launcher-0.0.10.dist-info/WHEEL +5 -0
- python_script_launcher-0.0.10.dist-info/entry_points.txt +2 -0
- python_script_launcher-0.0.10.dist-info/top_level.txt +1 -0
|
@@ -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)
|