automas-plugin-kill-process 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alirea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: automas_plugin_kill_process
3
+ Version: 0.1.0
4
+ Summary: 简单的关闭程序插件
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: pydantic>=2.0
9
+ Dynamic: license-file
10
+
11
+ # automas_kill_process
12
+
13
+ [![PyPI version](https://img.shields.io/pypi/v/automas_plugin_kill_process?logo=pypi&logoColor=white)](https://pypi.org/project/automas_plugin_kill_process/)
14
+
15
+ 简单的关闭程序插件
16
+
17
+ ## 功能
18
+
19
+ - 监听全局 script.exit 事件。
20
+ - 当事件中的脚本名匹配配置规则里的 script_name 时,自动执行 taskkill 终止对应 process_name。
21
+ - 支持多条规则,规则表可无限新增行。
22
+
23
+ ## 配置
24
+
25
+ 插件配置提供两个字段:
26
+
27
+ - rules:表格规则。
28
+ - script_name:脚本配置里的 Info.Name,例如 崩铁-三月七。
29
+ - process_name:要终止的进程名,例如 StarRail.exe。
30
+ - kill_timeout_seconds:单次 taskkill 超时秒数。
31
+
32
+ ## 可靠性说明
33
+
34
+ - 插件在收到事件后使用后台协程执行 taskkill,不阻塞事件总线主流程。
35
+ - 每次 taskkill 都有超时保护,防止卡死。
36
+ - 插件停止时会取消并清理后台任务,避免残留协程影响主程序。
@@ -0,0 +1,26 @@
1
+ # automas_kill_process
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/automas_plugin_kill_process?logo=pypi&logoColor=white)](https://pypi.org/project/automas_plugin_kill_process/)
4
+
5
+ 简单的关闭程序插件
6
+
7
+ ## 功能
8
+
9
+ - 监听全局 script.exit 事件。
10
+ - 当事件中的脚本名匹配配置规则里的 script_name 时,自动执行 taskkill 终止对应 process_name。
11
+ - 支持多条规则,规则表可无限新增行。
12
+
13
+ ## 配置
14
+
15
+ 插件配置提供两个字段:
16
+
17
+ - rules:表格规则。
18
+ - script_name:脚本配置里的 Info.Name,例如 崩铁-三月七。
19
+ - process_name:要终止的进程名,例如 StarRail.exe。
20
+ - kill_timeout_seconds:单次 taskkill 超时秒数。
21
+
22
+ ## 可靠性说明
23
+
24
+ - 插件在收到事件后使用后台协程执行 taskkill,不阻塞事件总线主流程。
25
+ - 每次 taskkill 都有超时保护,防止卡死。
26
+ - 插件停止时会取消并清理后台任务,避免残留协程影响主程序。
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "automas_plugin_kill_process"
7
+ version = "0.1.0"
8
+ description = "简单的关闭程序插件"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.10"
11
+ dependencies = ["pydantic>=2.0"]
12
+
13
+ [project.entry-points."auto_mas.plugins"]
14
+ kill_process = "kill_process.plugin:Plugin"
15
+
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: automas_plugin_kill_process
3
+ Version: 0.1.0
4
+ Summary: 简单的关闭程序插件
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: pydantic>=2.0
9
+ Dynamic: license-file
10
+
11
+ # automas_kill_process
12
+
13
+ [![PyPI version](https://img.shields.io/pypi/v/automas_plugin_kill_process?logo=pypi&logoColor=white)](https://pypi.org/project/automas_plugin_kill_process/)
14
+
15
+ 简单的关闭程序插件
16
+
17
+ ## 功能
18
+
19
+ - 监听全局 script.exit 事件。
20
+ - 当事件中的脚本名匹配配置规则里的 script_name 时,自动执行 taskkill 终止对应 process_name。
21
+ - 支持多条规则,规则表可无限新增行。
22
+
23
+ ## 配置
24
+
25
+ 插件配置提供两个字段:
26
+
27
+ - rules:表格规则。
28
+ - script_name:脚本配置里的 Info.Name,例如 崩铁-三月七。
29
+ - process_name:要终止的进程名,例如 StarRail.exe。
30
+ - kill_timeout_seconds:单次 taskkill 超时秒数。
31
+
32
+ ## 可靠性说明
33
+
34
+ - 插件在收到事件后使用后台协程执行 taskkill,不阻塞事件总线主流程。
35
+ - 每次 taskkill 都有超时保护,防止卡死。
36
+ - 插件停止时会取消并清理后台任务,避免残留协程影响主程序。
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/automas_plugin_kill_process.egg-info/PKG-INFO
5
+ src/automas_plugin_kill_process.egg-info/SOURCES.txt
6
+ src/automas_plugin_kill_process.egg-info/dependency_links.txt
7
+ src/automas_plugin_kill_process.egg-info/entry_points.txt
8
+ src/automas_plugin_kill_process.egg-info/requires.txt
9
+ src/automas_plugin_kill_process.egg-info/top_level.txt
10
+ src/kill_process/__init__.py
11
+ src/kill_process/plugin.py
12
+ src/kill_process/schema.py
@@ -0,0 +1,2 @@
1
+ [auto_mas.plugins]
2
+ kill_process = kill_process.plugin:Plugin
@@ -0,0 +1,6 @@
1
+ """最小 PyPI 插件示例包。"""
2
+
3
+ from .plugin import Plugin
4
+ from .schema import Config
5
+
6
+ __all__ = ["Plugin", "Config"]
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import locale
6
+ import os
7
+ from collections import deque
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Any, Iterable
10
+
11
+ from app.core.plugins import on_event
12
+
13
+ from .schema import Config
14
+
15
+ if TYPE_CHECKING:
16
+ from app.core.plugins.context import PluginContext
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class KillRule:
21
+ script_name: str
22
+ process_name: str
23
+
24
+
25
+ class Plugin:
26
+ _RECENT_EVENT_LIMIT = 512
27
+
28
+ def __init__(self, ctx: "PluginContext") -> None:
29
+ self.ctx = ctx
30
+ self._rules: list[KillRule] = []
31
+ self._kill_timeout_seconds: int = 8
32
+ self._background_tasks: set[asyncio.Task[Any]] = set()
33
+ self._recent_event_keys: set[tuple[str, str, str]] = set()
34
+ self._recent_event_queue: deque[tuple[str, str, str]] = deque()
35
+
36
+ async def on_start(self) -> None:
37
+ raw_config = (
38
+ self.ctx.config.to_dict()
39
+ if hasattr(self.ctx.config, "to_dict")
40
+ else dict(self.ctx.config)
41
+ )
42
+ typed_config = Config.model_validate(raw_config)
43
+ self._kill_timeout_seconds = max(1, int(typed_config.kill_timeout_seconds))
44
+ self._rules = self._normalize_rules(typed_config.rules)
45
+
46
+ self.ctx.logger.info(
47
+ f"[{self.ctx.plugin_name}] 插件启动,规则数={len(self._rules)},超时={self._kill_timeout_seconds}s"
48
+ )
49
+ if os.name != "nt":
50
+ self.ctx.logger.warning(
51
+ f"[{self.ctx.plugin_name}] 当前系统不是 Windows,taskkill 不可用"
52
+ )
53
+
54
+ async def on_stop(self, reason: str) -> None:
55
+ await self._shutdown_background_tasks()
56
+ self.ctx.logger.info(
57
+ f"[{self.ctx.plugin_name}] 插件停止, reason={reason}, 已清理后台任务"
58
+ )
59
+
60
+ @on_event("script.exit", scope="global")
61
+ async def on_script_exit(self, payload: Any, ctx: "PluginContext") -> None:
62
+ _ = ctx
63
+ try:
64
+ if not isinstance(payload, dict):
65
+ return
66
+
67
+ script_name = str(payload.get("script_name") or "").strip()
68
+ if not script_name:
69
+ data = payload.get("data")
70
+ if isinstance(data, dict):
71
+ script_name = str(data.get("script_name") or "").strip()
72
+
73
+ if not script_name:
74
+ return
75
+
76
+ if not self._rules:
77
+ return
78
+
79
+ task_id = str(payload.get("task_id") or "").strip() or "-"
80
+ script_id = str(payload.get("script_id") or "").strip() or script_name
81
+
82
+ matched_processes = {
83
+ rule.process_name
84
+ for rule in self._rules
85
+ if rule.script_name == script_name
86
+ }
87
+ if not matched_processes:
88
+ return
89
+
90
+ for process_name in matched_processes:
91
+ event_key = (task_id, script_id, process_name)
92
+ if self._is_duplicate_event(event_key):
93
+ continue
94
+
95
+ self._remember_event(event_key)
96
+ self._spawn_background_task(
97
+ self._kill_process_for_script(
98
+ script_name=script_name,
99
+ process_name=process_name,
100
+ task_id=task_id,
101
+ script_id=script_id,
102
+ )
103
+ )
104
+ except Exception as e:
105
+ self.ctx.logger.warning(
106
+ f"[{self.ctx.plugin_name}] 处理 script.exit 事件失败: {type(e).__name__}: {e}"
107
+ )
108
+
109
+ def _normalize_rules(self, rows: Iterable[Any]) -> list[KillRule]:
110
+ rules: list[KillRule] = []
111
+ seen: set[tuple[str, str]] = set()
112
+
113
+ for row in rows:
114
+ current = row
115
+ if hasattr(current, "model_dump") and callable(current.model_dump):
116
+ current = current.model_dump()
117
+
118
+ if not isinstance(current, dict):
119
+ continue
120
+
121
+ script_name = str(current.get("script_name") or "").strip()
122
+ process_name = str(current.get("process_name") or "").strip()
123
+ if not script_name or not process_name:
124
+ continue
125
+
126
+ key = (script_name, process_name)
127
+ if key in seen:
128
+ continue
129
+
130
+ seen.add(key)
131
+ rules.append(KillRule(script_name=script_name, process_name=process_name))
132
+
133
+ return rules
134
+
135
+ def _is_duplicate_event(self, event_key: tuple[str, str, str]) -> bool:
136
+ return event_key in self._recent_event_keys
137
+
138
+ def _remember_event(self, event_key: tuple[str, str, str]) -> None:
139
+ self._recent_event_keys.add(event_key)
140
+ self._recent_event_queue.append(event_key)
141
+
142
+ while len(self._recent_event_queue) > self._RECENT_EVENT_LIMIT:
143
+ old_key = self._recent_event_queue.popleft()
144
+ self._recent_event_keys.discard(old_key)
145
+
146
+ def _spawn_background_task(self, coroutine: Any) -> None:
147
+ task = asyncio.create_task(coroutine)
148
+ self._background_tasks.add(task)
149
+
150
+ def _cleanup(done_task: asyncio.Task[Any]) -> None:
151
+ self._background_tasks.discard(done_task)
152
+ try:
153
+ exception = done_task.exception()
154
+ except asyncio.CancelledError:
155
+ return
156
+ except Exception as e:
157
+ self.ctx.logger.warning(
158
+ f"[{self.ctx.plugin_name}] 后台任务状态读取失败: {type(e).__name__}: {e}"
159
+ )
160
+ return
161
+
162
+ if exception is not None:
163
+ self.ctx.logger.warning(
164
+ f"[{self.ctx.plugin_name}] 后台任务异常: {type(exception).__name__}: {exception}"
165
+ )
166
+
167
+ task.add_done_callback(_cleanup)
168
+
169
+ @staticmethod
170
+ def _decode_taskkill_output(raw: bytes | None) -> str:
171
+ if not raw:
172
+ return ""
173
+
174
+ preferred = locale.getpreferredencoding(False)
175
+ candidates = ["utf-8", "gbk", "cp936", preferred]
176
+ tried: set[str] = set()
177
+
178
+ for encoding in candidates:
179
+ if not encoding:
180
+ continue
181
+
182
+ normalized = encoding.lower()
183
+ if normalized in tried:
184
+ continue
185
+ tried.add(normalized)
186
+
187
+ try:
188
+ text = raw.decode(encoding).strip()
189
+ except UnicodeDecodeError:
190
+ continue
191
+
192
+ if text:
193
+ return text
194
+
195
+ return raw.decode("utf-8", errors="replace").strip()
196
+
197
+ @staticmethod
198
+ def _is_process_not_found_error(return_code: int, detail: str) -> bool:
199
+ if return_code == 128:
200
+ return True
201
+
202
+ detail_lower = detail.lower()
203
+ markers = (
204
+ "没有找到",
205
+ "未找到",
206
+ "找不到",
207
+ "not found",
208
+ "could not find",
209
+ "no running instance",
210
+ "unknown process",
211
+ )
212
+ return any(marker in detail or marker in detail_lower for marker in markers)
213
+
214
+ async def _shutdown_background_tasks(self) -> None:
215
+ if not self._background_tasks:
216
+ return
217
+
218
+ tasks = list(self._background_tasks)
219
+ for task in tasks:
220
+ task.cancel()
221
+
222
+ try:
223
+ await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=2)
224
+ except asyncio.TimeoutError:
225
+ self.ctx.logger.warning(
226
+ f"[{self.ctx.plugin_name}] 关闭时仍有后台任务未在 2 秒内退出"
227
+ )
228
+ finally:
229
+ self._background_tasks.clear()
230
+
231
+ async def _kill_process_for_script(
232
+ self,
233
+ *,
234
+ script_name: str,
235
+ process_name: str,
236
+ task_id: str,
237
+ script_id: str,
238
+ ) -> None:
239
+ if os.name != "nt":
240
+ self.ctx.logger.warning(
241
+ f"[{self.ctx.plugin_name}] 非 Windows 环境,跳过终止进程: {process_name}"
242
+ )
243
+ return
244
+
245
+ command = ["taskkill", "/F", "/IM", process_name, "/T"]
246
+ process = None
247
+
248
+ try:
249
+ process = await asyncio.create_subprocess_exec(
250
+ *command,
251
+ stdout=asyncio.subprocess.PIPE,
252
+ stderr=asyncio.subprocess.PIPE,
253
+ )
254
+
255
+ stdout, stderr = await asyncio.wait_for(
256
+ process.communicate(),
257
+ timeout=self._kill_timeout_seconds,
258
+ )
259
+ except asyncio.TimeoutError:
260
+ if process is not None:
261
+ with contextlib.suppress(Exception):
262
+ process.kill()
263
+ with contextlib.suppress(Exception):
264
+ await process.communicate()
265
+ self.ctx.logger.warning(
266
+ f"[{self.ctx.plugin_name}] 终止进程超时: script={script_name}, process={process_name}, timeout={self._kill_timeout_seconds}s"
267
+ )
268
+ return
269
+ except asyncio.CancelledError:
270
+ if process is not None:
271
+ with contextlib.suppress(Exception):
272
+ process.kill()
273
+ with contextlib.suppress(Exception):
274
+ await process.communicate()
275
+ raise
276
+ except Exception as e:
277
+ self.ctx.logger.warning(
278
+ f"[{self.ctx.plugin_name}] 调用 taskkill 失败: script={script_name}, process={process_name}, error={type(e).__name__}: {e}"
279
+ )
280
+ return
281
+
282
+ stdout_text = self._decode_taskkill_output(stdout)
283
+ stderr_text = self._decode_taskkill_output(stderr)
284
+ detail = stderr_text or stdout_text or "-"
285
+ if len(detail) > 300:
286
+ detail = f"{detail[:300]}..."
287
+
288
+ return_code = process.returncode if process is not None else -1
289
+ if return_code == 0:
290
+ self.ctx.logger.info(
291
+ f"[{self.ctx.plugin_name}] 已终止进程: script={script_name}, process={process_name}, task_id={task_id}, script_id={script_id}"
292
+ )
293
+ else:
294
+ if self._is_process_not_found_error(return_code, detail):
295
+ self.ctx.logger.warning(
296
+ f"[{self.ctx.plugin_name}] 程序无法关闭,可能程序已关闭或进程名有误: script={script_name}, process={process_name}, code={return_code}"
297
+ )
298
+ else:
299
+ self.ctx.logger.warning(
300
+ f"[{self.ctx.plugin_name}] 终止进程返回非 0: script={script_name}, process={process_name}, code={return_code}, detail={detail}"
301
+ )
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+
4
+ class Config(BaseModel):
5
+ """关闭进程插件配置模型。"""
6
+
7
+ model_config = ConfigDict(extra="allow")
8
+
9
+ rules: list[dict[str, str]] = Field(
10
+ default_factory=lambda: [{"script_name": "", "process_name": ""}],
11
+ description="规则表:当 script.exit 的脚本名匹配 script_name 时,自动执行 taskkill 终止 process_name",
12
+ json_schema_extra={
13
+ "type": "table",
14
+ "item_type": "object",
15
+ "group": "basic",
16
+ "order": 1,
17
+ },
18
+ )
19
+
20
+ kill_timeout_seconds: int = Field(
21
+ default=8,
22
+ ge=1,
23
+ description="单次 taskkill 超时秒数",
24
+ json_schema_extra={
25
+ "group": "advanced",
26
+ "min": 1,
27
+ "step": 1,
28
+ "order": 2,
29
+ },
30
+ )