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,189 @@
1
+ """Task registration helpers for user scripts.
2
+
3
+ Usage from a user script:
4
+
5
+ from python_script_launcher import task, run_cli # inside a wheel install
6
+ # or, from the top-level module when scripts sit next to the launcher:
7
+ from tasks import task, run_cli
8
+
9
+ @task
10
+ def greet(name, greeting="hello"):
11
+ print(f"{greeting}, {name}!")
12
+
13
+ @task(name="square-sum", desc="Sum of squares up to n")
14
+ def square_sum(n=5):
15
+ total = sum(i * i for i in range(1, n + 1))
16
+ print(f"squares(1..{n}) = {total}")
17
+
18
+ if __name__ == "__main__":
19
+ run_cli()
20
+
21
+ The Launcher (`app.scan_scripts`) discovers every @task-decorated function
22
+ inside a user script, so a single .py can expose multiple runnable tasks.
23
+ The legacy convention (one `def main(...)` plus `if __name__ == "__main__"`)
24
+ still works.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import inspect
30
+ import sys
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Callable, Optional
33
+
34
+
35
+ # --- Type helpers --------------------------------------------------------
36
+
37
+ _TYPE_MAP = {
38
+ str: str,
39
+ int: int,
40
+ float: float,
41
+ bool: None, # bool uses store_true / store_false
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class TaskParam:
47
+ name: str
48
+ type_str: str
49
+ required: bool
50
+ default: Any = None
51
+ help_text: str = ""
52
+
53
+ def to_dict(self):
54
+ return {
55
+ "name": self.name,
56
+ "type": self.type_str,
57
+ "required": self.required,
58
+ "default": self.default,
59
+ "help": self.help_text,
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class TaskSpec:
65
+ name: str
66
+ func: Callable[..., Any]
67
+ desc: str = ""
68
+ params: list = field(default_factory=list)
69
+
70
+ def __call__(self, **kwargs):
71
+ return self.func(**kwargs)
72
+
73
+
74
+ def _annotation_to_type(annotation):
75
+ if annotation is inspect.Parameter.empty:
76
+ return str
77
+ if isinstance(annotation, type):
78
+ return annotation
79
+ return str
80
+
81
+
82
+ def _describe_params(func):
83
+ sig = inspect.signature(func)
84
+ params = []
85
+ for param_name, param in sig.parameters.items():
86
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
87
+ continue
88
+ tp = _annotation_to_type(param.annotation)
89
+ has_default = param.default is not inspect.Parameter.empty
90
+ params.append(TaskParam(
91
+ name=param_name,
92
+ type_str=getattr(tp, "__name__", str(tp)),
93
+ required=not has_default,
94
+ default=(param.default if has_default else None),
95
+ ))
96
+ return params
97
+
98
+
99
+ # --- Registration -------------------------------------------------------
100
+
101
+ _TASKS_ATTR = "__tasks__"
102
+
103
+
104
+ def task(fn=None, *, name=None, desc=None):
105
+ def wrap(func):
106
+ module = sys.modules.get(func.__module__)
107
+ registry = getattr(module, _TASKS_ATTR, None) if module is not None else None
108
+ if registry is None:
109
+ registry = {}
110
+ if module is not None:
111
+ setattr(module, _TASKS_ATTR, registry)
112
+ task_name = name or func.__name__
113
+ spec = TaskSpec(
114
+ name=task_name,
115
+ func=func,
116
+ desc=(desc if desc is not None else (inspect.getdoc(func) or "")),
117
+ params=_describe_params(func),
118
+ )
119
+ registry[task_name] = spec
120
+ func.__task_spec__ = spec
121
+ return func
122
+
123
+ if fn is not None and callable(fn):
124
+ return wrap(fn)
125
+ return wrap
126
+
127
+
128
+ def collect_tasks(module):
129
+ tasks_ = getattr(module, _TASKS_ATTR, None)
130
+ if isinstance(tasks_, dict):
131
+ return dict(tasks_)
132
+ return {}
133
+
134
+
135
+ # --- CLI helper ---------------------------------------------------------
136
+
137
+ def _add_cli_argument(parser, param):
138
+ if param.type_str == "bool" or isinstance(param.default, bool):
139
+ parser.add_argument(
140
+ f"--{param.name.replace('_', '-')}",
141
+ dest=param.name,
142
+ action="store_true" if not bool(param.default) else "store_false",
143
+ default=bool(param.default),
144
+ help=f"(default: {param.default})",
145
+ )
146
+ return
147
+
148
+ lookup = {"int": int, "float": float, "str": str}
149
+ cli_type = lookup.get(param.type_str, str)
150
+ kwargs = {"type": cli_type, "dest": param.name}
151
+ if param.required:
152
+ parser.add_argument(f"--{param.name.replace('_', '-')}",
153
+ required=True, help="required", **kwargs)
154
+ else:
155
+ parser.add_argument(f"--{param.name.replace('_', '-')}",
156
+ default=param.default,
157
+ help=f"(default: {param.default!r})", **kwargs)
158
+
159
+
160
+ def run_cli(module=None, argv=None):
161
+ if module is None:
162
+ frame = sys._getframe(1)
163
+ module = sys.modules.get(frame.f_globals.get("__name__"))
164
+ tasks_ = collect_tasks(module) if module is not None else {}
165
+ if not tasks_:
166
+ print("[tasks] no @task registered in this module", file=sys.stderr)
167
+ return 2
168
+
169
+ if len(tasks_) == 1:
170
+ spec = next(iter(tasks_.values()))
171
+ parser = argparse.ArgumentParser(prog=spec.name, description=spec.desc)
172
+ for p in spec.params:
173
+ _add_cli_argument(parser, p)
174
+ ns = parser.parse_args(argv)
175
+ spec(**vars(ns))
176
+ return 0
177
+
178
+ parser = argparse.ArgumentParser(prog=(module.__name__ if module else "tasks"))
179
+ sub = parser.add_subparsers(dest="_task", required=True)
180
+ for spec in tasks_.values():
181
+ head = spec.desc.splitlines()[0] if spec.desc else None
182
+ sp = sub.add_parser(spec.name, help=head, description=spec.desc)
183
+ for p in spec.params:
184
+ _add_cli_argument(sp, p)
185
+ ns = parser.parse_args(argv)
186
+ task_name = ns._task
187
+ kwargs = {k: v for k, v in vars(ns).items() if k != "_task"}
188
+ tasks_[task_name](**kwargs)
189
+ return 0
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-script-launcher
3
+ Version: 0.0.10
4
+ Summary: Package Python scripts into standalone exe (Web + desktop client).
5
+ License-Expression: MIT
6
+ Keywords: pyinstaller,packaging,exe,launcher,scripts
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Topic :: Software Development :: Build Tools
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: fastapi>=0.100.0
16
+ Requires-Dist: python-multipart>=0.0.32
17
+ Requires-Dist: uvicorn[standard]>=0.20.0
18
+ Requires-Dist: requests>=2.34.2
19
+ Provides-Extra: desktop
20
+ Requires-Dist: pywebview>=4.0; extra == "desktop"
21
+ Provides-Extra: build
22
+ Requires-Dist: pyinstaller>=6.0; extra == "build"
23
+ Requires-Dist: build>=1.0; extra == "build"
24
+ Provides-Extra: all
25
+ Requires-Dist: pywebview>=4.0; extra == "all"
26
+ Requires-Dist: pyinstaller>=6.0; extra == "all"
27
+ Requires-Dist: build>=1.0; extra == "all"
28
+
29
+ # Python App Launcher
30
+
31
+ 一键把任意 Python 脚本目录变成独立 Web/桌面 exe:自动扫描脚本、生成参数表单、实时流式输出,支持一份脚本注册多个可运行任务。
32
+
33
+ ## 特性
34
+
35
+ - **自动扫描**:递归识别 `scripts/` 下的入口,支持 `@task` 装饰器与传统 `def main()`。
36
+ - **多任务脚本**:一个 `.py` 文件可注册多个 `@task`,无需手写 `if __name__ == "__main__"`。
37
+ - **参数表单**:根据函数签名自动生成 UI(`str/int/float/bool` + 默认值 + 文件上传)。
38
+ - **实时输出**:进程内 `runpy` / `importlib` 执行 + SSE 行流,冷启动毫秒级。
39
+ - **一键打包**:`launcher build` 调用 PyInstaller,产出无需 Python 环境的 `.exe`。
40
+ - **双形态发布**:`publish.py` 同步产出 wheel + sdist + `onedir/onefile` exe + release zip。
41
+
42
+ ## 安装
43
+
44
+ ```bash
45
+ # 通过 uv(推荐)
46
+ uv sync
47
+
48
+ # 或安装已发布的 wheel(含打包依赖)
49
+ pip install "python-script-launcher[build]"
50
+ ```
51
+
52
+ ## 用户脚本写法
53
+
54
+ 推荐使用 `@task` 装饰器,一个文件可暴露多个可调用任务:
55
+
56
+ ```python
57
+ # my_tools/demo.py
58
+ from python_script_launcher import task # 或 from tasks import task
59
+
60
+ @task
61
+ def hi(name: str = "world"):
62
+ """打个招呼。"""
63
+ print(f"hi, {name}!")
64
+
65
+ @task(name="add", desc="加两个整数")
66
+ def add_(a: int, b: int = 1):
67
+ print(a + b)
68
+ ```
69
+
70
+ 依然兼容旧式 `def main(...) + __main__` 脚本:
71
+
72
+ ```python
73
+ def main(name: str = "world"):
74
+ print(f"hello, {name}!")
75
+
76
+ if __name__ == "__main__":
77
+ import argparse
78
+ p = argparse.ArgumentParser()
79
+ p.add_argument("--name", default="world")
80
+ main(**vars(p.parse_args()))
81
+ ```
82
+
83
+ **约定**
84
+
85
+ | 特性 | 说明 |
86
+ |------|------|
87
+ | 入口 | `@task` 装饰函数,或顶层 `def main(...)` |
88
+ | 参数类型 | 通过类型注解推导(`str/int/float/bool`),无注解按 `str` 处理 |
89
+ | 必填/可选 | 有默认值 → 可选;无默认值 → 必填 |
90
+ | 文件参数 | 参数名含 `file` / `path` / `upload` 时启用上传控件 |
91
+ | 说明文字 | 函数 docstring 显示在 UI 上 |
92
+
93
+ ## CLI
94
+
95
+ 安装后可直接使用 `launcher` 入口(或 `python -m python_script_launcher`):
96
+
97
+ ```bash
98
+ # 扫描并列出脚本
99
+ launcher scan --scripts D:\my_tools
100
+
101
+ # 开发模式启动 Web UI(http://localhost:8765)
102
+ launcher run --scripts D:\my_tools --port 8765
103
+
104
+ # 打包成 exe(onedir 默认,冷启动快)
105
+ launcher build --scripts D:\my_tools --name MyTools --mode both
106
+
107
+ # 单文件 exe(体积大、启动稍慢)
108
+ launcher build --scripts D:\my_tools --onefile --clean
109
+ ```
110
+
111
+ `build` 参数:
112
+
113
+ | 参数 | 说明 | 默认 |
114
+ |------|------|------|
115
+ | `--scripts, -s` | 脚本目录 | 必填 |
116
+ | `--output, -o` | 产物目录 | `./dist` |
117
+ | `--name, -n` | 应用名 | 脚本目录名 |
118
+ | `--mode, -m` | `web` / `client` / `both` | `both` |
119
+ | `--onefile` | 单文件 exe | `false` |
120
+ | `--clean` | 丢弃 `build/` 缓存重打 | `false` |
121
+
122
+ ## 作为库调用
123
+
124
+ ```python
125
+ from python_script_launcher import create_app, scan_scripts, start_desktop
126
+ from pathlib import Path
127
+
128
+ # 扫描
129
+ scripts = scan_scripts(Path(r"D:\\my_tools"))
130
+
131
+ # 启动 Web
132
+ app = create_app(root=r"D:\\my_tools") # ASGI app, 用 uvicorn.run 起
133
+
134
+ # 启动桌面
135
+ start_desktop(root=r"D:\\my_tools", title="我的工具", port=8765)
136
+ ```
137
+
138
+ ## 发布 / 版本管理
139
+
140
+ `publish.py` 是唯一的发布入口,从根目录源码 stage 出一份 `python_script_launcher/` 布局并调用 `python -m build`:
141
+
142
+ ```bash
143
+ # 只出 wheel + sdist
144
+ python publish.py --wheel
145
+
146
+ # 只出 exe(onedir,PythonAppLauncher / PythonAppDesktop)
147
+ python publish.py --exe
148
+
149
+ # 完整发布:wheel + exe + zip
150
+ python publish.py --wheel --exe --zip
151
+
152
+ # 升版本号(写入 VERSION 后再构建)
153
+ python publish.py --version 1.2.0 --wheel --exe
154
+ ```
155
+
156
+ ## 打包原理速览
157
+
158
+ - `make_exe.py` 用 `importlib.metadata.distributions()` 拿到当前解释器里所有第三方包,通过 `--collect-all` 全量塞进 exe,避开脆弱的 import 扫描。
159
+ - `__main__.py::_stage_project()` 会把 `app.py / client.py / tasks.py / static/` + 用户脚本复制到临时目录,并合成 `python_script_launcher/` 包 shim(转发 `tasks`),让打包后的 exe 里用户脚本 `from python_script_launcher import task` 依然可用。
160
+ - 打包后 `app.py` 在 frozen 模式下直接进程内跑用户脚本(`runpy.run_path` 或 `@task` importlib),不再拉起子 Python,冷启动 20-60 ms。
161
+
162
+ ## 目录结构
163
+
164
+ ```
165
+ pyRunner/
166
+ ├─ app.py # FastAPI 后端 + 脚本扫描 / SSE 执行
167
+ ├─ client.py # pywebview 桌面壳
168
+ ├─ tasks.py # @task 装饰器 + run_cli()
169
+ ├─ make_exe.py # PyInstaller 封装(build_exe 可复用)
170
+ ├─ __init__.py # wheel 包入口 + 版本号
171
+ ├─ __main__.py # launcher CLI (scan/run/build)
172
+ ├─ publish.py # wheel + exe + zip 发布器
173
+ ├─ static/ # Web UI(单页)
174
+ ├─ scripts/ # 示例脚本
175
+ ├─ pyproject.toml # 本地 dev 依赖(wheel 由 publish.py 生成)
176
+ └─ VERSION # 版本号(publish.py 读写)
177
+ ```
178
+
179
+ ## License
180
+
181
+ MIT
@@ -0,0 +1,16 @@
1
+ python_script_launcher/__init__.py,sha256=XeqJdWwu4-26nBlxeu2urarg3chsKMYYAWKgbUQnUyo,1750
2
+ python_script_launcher/__main__.py,sha256=PiKbURpPYHkB8ZQ7YCSYPKh_YUi6R2FKJEjBDXdgm_c,6824
3
+ python_script_launcher/app.py,sha256=3MBCyOtO5pFAcwyrkRkzg5a-GmPOOQ-jiRDEC261YZ4,24252
4
+ python_script_launcher/client.py,sha256=pULe9xHv4OuXFAdiFAEHp4uxO8x4D_myyNX6ofqElMg,2964
5
+ python_script_launcher/make_exe.py,sha256=zBLgUOOlhCzDOGVmmM0wnjpZK-a9jK0EGlVbgiNy6eI,6849
6
+ python_script_launcher/tasks.py,sha256=0f6P88vlUXscnIxXNL5pEQpmr6x-kavAHvDB9Z1qowE,5755
7
+ python_script_launcher/scripts/file_status.py,sha256=D22FwHlsNnJkHz7ICyM3L2xK3vhNaMRl-aJEERrEG8s,1063
8
+ python_script_launcher/scripts/hello.py,sha256=df5gR95rta5J0sDORI0wncCTBS0wGrTX07pEcR9R4_A,517
9
+ python_script_launcher/scripts/multi.py,sha256=5TBxkaxF1xXtLhRkdQK1p4H5DD-mxwlptkQOpX8V9WE,617
10
+ python_script_launcher/scripts/test_modules.py,sha256=AFzAAC2NlYSVcPucNgChguLMEj8m1pmCIB3LIDthOf8,618
11
+ python_script_launcher/static/index.html,sha256=_HTLklrRge6A09l2kb5ucFI4rrm2KMIEgU9xXbPJzT8,15000
12
+ python_script_launcher-0.0.10.dist-info/METADATA,sha256=VhJNZXLM-ohC2PCGVgCgF4516pmUOpzVOaRtC-Bgt2M,6378
13
+ python_script_launcher-0.0.10.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ python_script_launcher-0.0.10.dist-info/entry_points.txt,sha256=13ENRmw4BsgWGLDCr_y1dKB91ed1j6rEyks6JDFzOns,66
15
+ python_script_launcher-0.0.10.dist-info/top_level.txt,sha256=Ap6WryIb1YRnr_QVCmfGjdEVLDNA3dQcTl_-gxX11qk,23
16
+ python_script_launcher-0.0.10.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ launcher = python_script_launcher.__main__:main
@@ -0,0 +1 @@
1
+ python_script_launcher