automas-maafw-runner 0.1.1__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
+ Metadata-Version: 2.4
2
+ Name: automas-maafw-runner
3
+ Version: 0.1.1
4
+ Summary: Isolated MaaFW runner service plugin for AUTO-MAS
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: automas-maafw-agent-env>=0.1.0
8
+ Requires-Dist: automas-maafw-interface>=0.1.1
9
+ Requires-Dist: json5>=0.9
10
+ Requires-Dist: pydantic>=2
11
+
12
+ # automas-maafw-runner
13
+
14
+ Isolated MaaFW runner service for AUTO-MAS.
15
+
16
+ It provides `maafw.runner.v1`. The service builds run plans in the host process
17
+ and runs MaaFW through a worker subprocess so importing the service does not load
18
+ `maa` into the AUTO-MAS main process.
19
+
20
+ The wheel includes the MaaFW runtime worker code. `maa` is imported only by the
21
+ worker subprocess entrypoint, not by `MaaFWRunnerService`.
@@ -0,0 +1,10 @@
1
+ # automas-maafw-runner
2
+
3
+ Isolated MaaFW runner service for AUTO-MAS.
4
+
5
+ It provides `maafw.runner.v1`. The service builds run plans in the host process
6
+ and runs MaaFW through a worker subprocess so importing the service does not load
7
+ `maa` into the AUTO-MAS main process.
8
+
9
+ The wheel includes the MaaFW runtime worker code. `maa` is imported only by the
10
+ worker subprocess entrypoint, not by `MaaFWRunnerService`.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "automas-maafw-runner"
7
+ version = "0.1.1"
8
+ description = "Isolated MaaFW runner service plugin for AUTO-MAS"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "automas-maafw-agent-env>=0.1.0",
13
+ "automas-maafw-interface>=0.1.1",
14
+ "json5>=0.9",
15
+ "pydantic>=2",
16
+ ]
17
+
18
+ [project.entry-points."auto_mas.plugins"]
19
+ automas_maafw_runner = "automas_maafw_runner.plugin:Plugin"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.setuptools.package-data]
25
+ automas_maafw_runner = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from .environment import MaaFWRunnerEnvironment
4
+ from .models import (
5
+ MaaFWDeviceConfig,
6
+ MaaFWResolvedPath,
7
+ MaaFWResourceBundlePlan,
8
+ MaaFWRunnerJobPayload,
9
+ MaaFWRunPlan,
10
+ MaaFWRunResult,
11
+ MaaFWSkippedTaskPlan,
12
+ MaaFWTaskRunPlan,
13
+ )
14
+ from .run_plan import MaaFWRunPlanError, build_maafw_run_plan
15
+ from .service import MaaFWRunnerService
16
+
17
+ __all__ = [
18
+ "MaaFWDeviceConfig",
19
+ "MaaFWResolvedPath",
20
+ "MaaFWResourceBundlePlan",
21
+ "MaaFWRunPlan",
22
+ "MaaFWRunPlanError",
23
+ "MaaFWRunResult",
24
+ "MaaFWRunnerEnvironment",
25
+ "MaaFWRunnerJobPayload",
26
+ "MaaFWRunnerService",
27
+ "MaaFWSkippedTaskPlan",
28
+ "MaaFWTaskRunPlan",
29
+ "build_maafw_run_plan",
30
+ ]
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import sysconfig
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Callable, Iterable
14
+
15
+
16
+ RUNNER_ENV_MANIFEST_NAME = ".auto_mas_maafw_runner_env.json"
17
+ RUNNER_DEFAULT_PACKAGES = (
18
+ "maafw",
19
+ "pydantic==2.11.7",
20
+ "json5==0.14.0",
21
+ )
22
+ RUNNER_ENV_TIMEOUT = 300
23
+ REQUIREMENT_NAME_RE = re.compile(
24
+ r"^\s*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)"
25
+ r"\s*(?:\[[^\]]+\])?\s*(?:===|[<>=!~]=?|@|;|\s|$)"
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MaaFWRunnerEnvironment:
31
+ python_executable: Path
32
+ venv_path: Path
33
+ env: dict[str, str]
34
+ packages: tuple[str, ...]
35
+ maafw_version: str | None
36
+
37
+
38
+ def prepare_runner_environment(
39
+ project_path: str | Path,
40
+ *,
41
+ managed_env_root: str | Path | None = None,
42
+ import_paths: Iterable[str | Path] = (),
43
+ send_log: Callable[[str], None] | None = None,
44
+ ) -> MaaFWRunnerEnvironment:
45
+ """Prepare a project-scoped runner whose dependencies follow the project."""
46
+
47
+ project = Path(project_path).resolve()
48
+ root = (
49
+ Path(managed_env_root).resolve()
50
+ if managed_env_root is not None
51
+ else (Path.cwd() / "config" / "maafw_runner_venvs").resolve()
52
+ )
53
+ venv_path = root / _runner_env_name(project)
54
+ if venv_path.exists() and not _is_valid_venv(venv_path):
55
+ _reset_managed_venv(venv_path, root)
56
+
57
+ if not _is_valid_venv(venv_path):
58
+ venv_path.parent.mkdir(parents=True, exist_ok=True)
59
+ bootstrap_python = _venv_bootstrap_python()
60
+ _send_log(
61
+ send_log,
62
+ f"[MaaFW Runner] 创建项目隔离 venv: {venv_path} "
63
+ f"(引导 Python: {bootstrap_python})",
64
+ )
65
+ _run_setup_command(
66
+ [bootstrap_python, "-m", "venv", str(venv_path)],
67
+ cwd=Path.cwd(),
68
+ )
69
+
70
+ python_executable = _venv_python(venv_path)
71
+ packages = tuple(build_runner_packages(project))
72
+ manifest = _build_manifest(project, packages)
73
+ manifest_path = venv_path / RUNNER_ENV_MANIFEST_NAME
74
+ env = build_runner_environment(venv_path, import_paths=import_paths)
75
+ if not _manifest_matches(manifest_path, manifest):
76
+ _send_log(
77
+ send_log,
78
+ f"[MaaFW Runner] 安装项目运行依赖: {', '.join(packages)}",
79
+ )
80
+ _run_setup_command(
81
+ [
82
+ str(python_executable),
83
+ "-m",
84
+ "pip",
85
+ "install",
86
+ "--upgrade",
87
+ "--disable-pip-version-check",
88
+ "--quiet",
89
+ *packages,
90
+ ],
91
+ cwd=project,
92
+ env=env,
93
+ )
94
+ _write_manifest(manifest_path, manifest)
95
+ _send_log(send_log, f"[MaaFW Runner] 项目运行依赖已准备: {venv_path}")
96
+ else:
97
+ _send_log(send_log, f"[MaaFW Runner] 项目隔离 venv 已就绪: {venv_path}")
98
+
99
+ maafw_version = _installed_maafw_version(python_executable, env)
100
+ if maafw_version:
101
+ _send_log(send_log, f"[MaaFW Runner] 使用项目 MaaFW: v{maafw_version}")
102
+
103
+ return MaaFWRunnerEnvironment(
104
+ python_executable=python_executable,
105
+ venv_path=venv_path,
106
+ env=env,
107
+ packages=packages,
108
+ maafw_version=maafw_version,
109
+ )
110
+
111
+
112
+ def build_runner_packages(project_path: str | Path) -> list[str]:
113
+ project_packages = _load_requirements(Path(project_path).resolve())
114
+ project_distribution_names = {
115
+ name
116
+ for requirement in project_packages
117
+ if (name := requirement_distribution_name(requirement)) is not None
118
+ }
119
+ packages = [
120
+ package
121
+ for package in RUNNER_DEFAULT_PACKAGES
122
+ if requirement_distribution_name(package) not in project_distribution_names
123
+ ]
124
+ packages.extend(project_packages)
125
+ return packages
126
+
127
+
128
+ def requirement_distribution_name(requirement: str) -> str | None:
129
+ match = REQUIREMENT_NAME_RE.match(requirement)
130
+ if match is None:
131
+ return None
132
+ return re.sub(r"[-_.]+", "-", match.group(1)).lower()
133
+
134
+
135
+ def build_runner_environment(
136
+ venv_path: str | Path,
137
+ *,
138
+ import_paths: Iterable[str | Path] = (),
139
+ ) -> dict[str, str]:
140
+ env = os.environ.copy()
141
+ for name in (
142
+ "PYTHONHOME",
143
+ "PYTHONUSERBASE",
144
+ "PIP_TARGET",
145
+ "PIP_PREFIX",
146
+ "PIP_USER",
147
+ ):
148
+ env.pop(name, None)
149
+
150
+ venv = Path(venv_path).resolve()
151
+ scripts_dir = venv / ("Scripts" if os.name == "nt" else "bin")
152
+ resolved_import_paths = [
153
+ str(Path(path).resolve())
154
+ for path in import_paths
155
+ if Path(path).exists()
156
+ ]
157
+ existing_python_path = env.get("PYTHONPATH", "")
158
+ if existing_python_path:
159
+ resolved_import_paths.append(existing_python_path)
160
+
161
+ env["VIRTUAL_ENV"] = str(venv)
162
+ env["PYTHONNOUSERSITE"] = "1"
163
+ env["PYTHONIOENCODING"] = "utf-8"
164
+ env["PATH"] = f"{scripts_dir}{os.pathsep}{env.get('PATH', '')}"
165
+ if resolved_import_paths:
166
+ env["PYTHONPATH"] = os.pathsep.join(resolved_import_paths)
167
+ else:
168
+ env.pop("PYTHONPATH", None)
169
+ return env
170
+
171
+
172
+ def prefer_active_venv_site_packages(
173
+ site_packages: str | Path | None = None,
174
+ ) -> Path | None:
175
+ """Keep the project Runner packages ahead of shared plugin dependencies."""
176
+
177
+ raw_path = site_packages or sysconfig.get_path("purelib")
178
+ if not raw_path:
179
+ return None
180
+
181
+ active_site_packages = Path(raw_path).resolve()
182
+ normalized_path = str(active_site_packages)
183
+ sys.path[:] = [
184
+ item
185
+ for item in sys.path
186
+ if _normalized_sys_path(item) != normalized_path
187
+ ]
188
+ sys.path.insert(0, normalized_path)
189
+ return active_site_packages
190
+
191
+
192
+ def _load_requirements(project_path: Path) -> list[str]:
193
+ requirements_path = project_path / "requirements.txt"
194
+ packages: list[str] = []
195
+ try:
196
+ for raw_line in requirements_path.read_text(encoding="utf-8").splitlines():
197
+ line = raw_line.strip()
198
+ if not line or line.startswith("#"):
199
+ continue
200
+ packages.append(line)
201
+ except FileNotFoundError:
202
+ pass
203
+ return packages
204
+
205
+
206
+ def _runner_env_name(project_path: Path) -> str:
207
+ key = str(project_path)
208
+ if os.name == "nt":
209
+ key = key.casefold()
210
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
211
+ return f"maafw_runner_{digest}"
212
+
213
+
214
+ def _build_manifest(project_path: Path, packages: tuple[str, ...]) -> dict[str, object]:
215
+ requirements_path = project_path / "requirements.txt"
216
+ interface_path = next(
217
+ (
218
+ project_path / file_name
219
+ for file_name in ("interface.json", "interface.jsonc")
220
+ if (project_path / file_name).is_file()
221
+ ),
222
+ None,
223
+ )
224
+ requirements_hash = (
225
+ hashlib.sha256(requirements_path.read_bytes()).hexdigest()
226
+ if requirements_path.is_file()
227
+ else ""
228
+ )
229
+ interface_hash = (
230
+ hashlib.sha256(interface_path.read_bytes()).hexdigest()
231
+ if interface_path is not None
232
+ else ""
233
+ )
234
+ return {
235
+ "schemaVersion": 4,
236
+ "projectPath": str(project_path),
237
+ "requirementsHash": requirements_hash,
238
+ "interfaceHash": interface_hash,
239
+ "packages": list(packages),
240
+ "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}",
241
+ }
242
+
243
+
244
+ def _manifest_matches(manifest_path: Path, expected: dict[str, object]) -> bool:
245
+ try:
246
+ current = json.loads(manifest_path.read_text(encoding="utf-8"))
247
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
248
+ return False
249
+ return current == expected
250
+
251
+
252
+ def _write_manifest(manifest_path: Path, manifest: dict[str, object]) -> None:
253
+ temporary_path = manifest_path.with_suffix(f"{manifest_path.suffix}.tmp")
254
+ temporary_path.write_text(
255
+ json.dumps(manifest, ensure_ascii=False, indent=2),
256
+ encoding="utf-8",
257
+ )
258
+ temporary_path.replace(manifest_path)
259
+
260
+
261
+ def _run_setup_command(
262
+ command: list[str],
263
+ *,
264
+ cwd: Path,
265
+ env: dict[str, str] | None = None,
266
+ ) -> None:
267
+ try:
268
+ result = subprocess.run(
269
+ command,
270
+ capture_output=True,
271
+ timeout=RUNNER_ENV_TIMEOUT,
272
+ text=True,
273
+ encoding="utf-8",
274
+ errors="replace",
275
+ cwd=cwd,
276
+ env=env,
277
+ )
278
+ except subprocess.TimeoutExpired as exc:
279
+ raise RuntimeError(f"MaaFW Runner 环境准备超时: {command[:3]}") from exc
280
+
281
+ if result.returncode == 0:
282
+ return
283
+ detail = (result.stderr or result.stdout or "").strip()
284
+ raise RuntimeError(
285
+ f"MaaFW Runner 环境准备失败 (exit={result.returncode}): {detail[:800]}"
286
+ )
287
+
288
+
289
+ def _installed_maafw_version(
290
+ python_executable: Path,
291
+ env: dict[str, str],
292
+ ) -> str | None:
293
+ probe_env = env.copy()
294
+ probe_env.pop("PYTHONPATH", None)
295
+ try:
296
+ result = subprocess.run(
297
+ [
298
+ str(python_executable),
299
+ "-c",
300
+ "import importlib.metadata as m; print(m.version('maafw'))",
301
+ ],
302
+ capture_output=True,
303
+ timeout=15,
304
+ text=True,
305
+ encoding="utf-8",
306
+ errors="replace",
307
+ env=probe_env,
308
+ )
309
+ except (OSError, subprocess.TimeoutExpired):
310
+ return None
311
+ if result.returncode != 0:
312
+ return None
313
+ version = result.stdout.strip()
314
+ return version or None
315
+
316
+
317
+ def _normalized_sys_path(path: str) -> str:
318
+ try:
319
+ return str(Path(path).resolve())
320
+ except (OSError, RuntimeError):
321
+ return path
322
+
323
+
324
+ def _reset_managed_venv(venv_path: Path, managed_root: Path) -> None:
325
+ resolved_venv = venv_path.resolve()
326
+ if (
327
+ resolved_venv.parent != managed_root.resolve()
328
+ or not resolved_venv.name.startswith("maafw_runner_")
329
+ ):
330
+ raise RuntimeError(f"拒绝重建非托管 MaaFW Runner venv: {venv_path}")
331
+ shutil.rmtree(resolved_venv, ignore_errors=True)
332
+
333
+
334
+ def _venv_python(venv_path: Path) -> Path:
335
+ if os.name == "nt":
336
+ return venv_path / "Scripts" / "python.exe"
337
+ return venv_path / "bin" / "python"
338
+
339
+
340
+ def _is_valid_venv(venv_path: Path) -> bool:
341
+ return _venv_python(venv_path).is_file() and (venv_path / "pyvenv.cfg").is_file()
342
+
343
+
344
+ def _venv_bootstrap_python() -> str:
345
+ portable_python = Path.cwd() / "environment" / "python" / "python.exe"
346
+ if portable_python.is_file():
347
+ return str(portable_python)
348
+ return sys.executable
349
+
350
+
351
+ def _send_log(send_log: Callable[[str], None] | None, message: str) -> None:
352
+ if send_log is not None:
353
+ send_log(message)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from automas_maafw_agent_env.models import MaaFWAgentCommandPlan
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ MaaFWControllerType = Literal["Adb", "Win32"]
10
+
11
+
12
+ class MaaFWResolvedPath(BaseModel):
13
+ raw: str
14
+ resolved: str
15
+ exists: bool
16
+ isFile: bool = False
17
+ isDir: bool = False
18
+
19
+
20
+ class MaaFWResourceBundlePlan(BaseModel):
21
+ name: str
22
+ label: str | None = None
23
+ paths: list[MaaFWResolvedPath] = Field(default_factory=list)
24
+ attachedPaths: list[MaaFWResolvedPath] = Field(default_factory=list)
25
+
26
+
27
+ class MaaFWTaskRunPlan(BaseModel):
28
+ name: str
29
+ label: str | None = None
30
+ entry: str
31
+ options: dict[str, Any] = Field(default_factory=dict)
32
+ pipelineOverride: dict[str, Any] = Field(default_factory=dict)
33
+ logOptions: dict[str, Any] = Field(default_factory=dict)
34
+ overrideNodes: list[str] = Field(default_factory=list)
35
+
36
+
37
+ class MaaFWSkippedTaskPlan(BaseModel):
38
+ name: str
39
+ label: str | None = None
40
+ entry: str | None = None
41
+ reason: str
42
+
43
+
44
+ class MaaFWPretaskRunPlan(BaseModel):
45
+ name: str
46
+ label: str | None = None
47
+ executable: str
48
+ args: list[str] = Field(default_factory=list)
49
+ options: dict[str, Any] = Field(default_factory=dict)
50
+
51
+
52
+ class MaaFWRunPlan(BaseModel):
53
+ model_config = ConfigDict(arbitrary_types_allowed=True)
54
+
55
+ path: str
56
+ projectName: str
57
+ projectLabel: str | None = None
58
+ controllerName: str
59
+ controllerType: str
60
+ resourceName: str
61
+ resource: MaaFWResourceBundlePlan
62
+ agents: list[MaaFWAgentCommandPlan] = Field(default_factory=list)
63
+ pretasks: list[MaaFWPretaskRunPlan] = Field(default_factory=list)
64
+ piEnv: dict[str, str] = Field(default_factory=dict)
65
+ tasks: list[MaaFWTaskRunPlan] = Field(default_factory=list)
66
+ skippedTasks: list[MaaFWSkippedTaskPlan] = Field(default_factory=list)
67
+
68
+
69
+ class MaaFWDeviceConfig(BaseModel):
70
+ type: MaaFWControllerType
71
+ adbPath: str | None = None
72
+ address: str | None = None
73
+ hWnd: int | None = None
74
+ screencapMethods: int = 0
75
+ inputMethods: int = 0
76
+ screencapMethod: int = 0
77
+ mouseMethod: int = 0
78
+ keyboardMethod: int = 0
79
+ config: dict[str, Any] = Field(default_factory=dict)
80
+
81
+
82
+ class MaaFWRunResult(BaseModel):
83
+ success: bool
84
+ projectName: str
85
+ controllerName: str
86
+ resourceName: str
87
+ completedTasks: list[str] = Field(default_factory=list)
88
+ failedTask: str | None = None
89
+ errorMessage: str | None = None
90
+
91
+
92
+ class MaaFWRunnerJobPayload(BaseModel):
93
+ plan: MaaFWRunPlan
94
+ deviceConfig: MaaFWDeviceConfig