ls-algorithm-plugin-sdk 0.2.5__tar.gz → 0.3.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.
Files changed (50) hide show
  1. ls_algorithm_plugin_sdk-0.3.0/PKG-INFO +100 -0
  2. ls_algorithm_plugin_sdk-0.3.0/README.md +85 -0
  3. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/pyproject.toml +1 -1
  4. ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli.py +44 -0
  5. ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/__init__.py +1 -0
  6. ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/configure.py +421 -0
  7. ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/parsing.py +45 -0
  8. ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/run.py +164 -0
  9. ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/serve.py +130 -0
  10. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/deployment.py +50 -16
  11. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/examples/example_algorithm.py +2 -2
  12. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +1 -1
  13. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/models.py +78 -99
  14. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/release.py +81 -0
  15. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/runner.py +23 -45
  16. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/service.py +1 -2
  17. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/index.html +1 -1
  18. ls_algorithm_plugin_sdk-0.3.0/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +100 -0
  19. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +7 -0
  20. ls_algorithm_plugin_sdk-0.3.0/tests/test_cli.py +53 -0
  21. ls_algorithm_plugin_sdk-0.3.0/tests/test_configure.py +532 -0
  22. ls_algorithm_plugin_sdk-0.3.0/tests/test_deployment.py +185 -0
  23. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_models.py +28 -14
  24. ls_algorithm_plugin_sdk-0.3.0/tests/test_release.py +174 -0
  25. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_runner.py +81 -13
  26. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_service.py +3 -3
  27. ls_algorithm_plugin_sdk-0.2.5/PKG-INFO +0 -87
  28. ls_algorithm_plugin_sdk-0.2.5/README.md +0 -72
  29. ls_algorithm_plugin_sdk-0.2.5/src/algorithm_plugin_sdk/cli.py +0 -542
  30. ls_algorithm_plugin_sdk-0.2.5/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +0 -87
  31. ls_algorithm_plugin_sdk-0.2.5/tests/test_deployment.py +0 -90
  32. ls_algorithm_plugin_sdk-0.2.5/tests/test_release.py +0 -60
  33. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/setup.cfg +0 -0
  34. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/__init__.py +0 -0
  35. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/algorithm.py +0 -0
  36. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/context.py +0 -0
  37. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/errors.py +0 -0
  38. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
  39. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/loader.py +0 -0
  40. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/registration.py +0 -0
  41. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
  42. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/app.css +0 -0
  43. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/app.js +0 -0
  44. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui_app.py +0 -0
  45. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
  46. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
  47. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
  48. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
  49. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_registration.py +0 -0
  50. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_sdk_automation.py +0 -0
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: ls-algorithm-plugin-sdk
3
+ Version: 0.3.0
4
+ Summary: Protocol-independent runtime SDK for dataset algorithms
5
+ Author: Ling Robotics
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: service
10
+ Requires-Dist: fastapi<1,>=0.110; extra == "service"
11
+ Requires-Dist: pydantic<3,>=2.0; extra == "service"
12
+ Requires-Dist: uvicorn[standard]<1,>=0.29; extra == "service"
13
+ Provides-Extra: dev
14
+ Requires-Dist: build>=1.2; extra == "dev"
15
+
16
+ # Algorithm Plugin SDK
17
+
18
+ 算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
19
+ WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
20
+ Dockerfile 或 Web 框架代码。
21
+
22
+ ## 算法侧最小接口
23
+
24
+ ```python
25
+ from algorithm_plugin_sdk import Algorithm
26
+
27
+ class MyAlgorithm(Algorithm):
28
+ @classmethod
29
+ def metadata(cls): ...
30
+ def execute(self, request, context): ...
31
+ ```
32
+
33
+ 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
34
+ 会被所有运行方式复用。
35
+
36
+ ## 单次运行
37
+
38
+ ```bash
39
+ algorithm-plugin run camera-space-mano \
40
+ --input '{"input_dataset":"/data/input1","output":"/data/output1"}' \
41
+ --input '{"input_dataset":"/data/input2","output":"/data/output2"}' \
42
+ --gpu-ids 0 \
43
+ --parameters '{"batch_size":16}'
44
+ ```
45
+
46
+ `--gpu-ids` 和 `--parameters` 可省略,也可用 `--request-file` 读取完整请求 JSON。
47
+
48
+ ## 配置现有仓库
49
+
50
+ 目标算法仓库必须满足:
51
+
52
+ - 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
53
+ - 仓库包含自己的 `.venv`。
54
+ - `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
55
+ - 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
56
+
57
+ 建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
58
+ `.gitignore`,不要手动编辑或提交它们。
59
+
60
+ 通过目标仓库的虚拟环境执行:
61
+
62
+ ```bash
63
+ sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
64
+ --repository /srv/camera-space-mano \
65
+ --compute-url http://compute:5180 \
66
+ --api-key "$LDP_INTERNAL_API_KEY" \
67
+ --public-url http://10.0.0.4:9000 \
68
+ --input-root /data/jobs/input \
69
+ --workspace-root /data/jobs/workspace \
70
+ --cluster production \
71
+ --namespace camera-space-mano \
72
+ --node-name 10.0.0.4 \
73
+ --gpu-ids 0,1
74
+ ```
75
+
76
+ service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
77
+ 重复执行 configure 需要显式提供 `--force`。
78
+
79
+ 确认输出后执行:
80
+
81
+ ```bash
82
+ sudo systemctl daemon-reload
83
+ sudo systemctl enable camera-space-mano.service
84
+ sudo systemctl restart camera-space-mano.service
85
+ sudo systemctl status camera-space-mano.service
86
+ ```
87
+
88
+ ## serve
89
+
90
+ ```bash
91
+ /srv/camera-space-mano/.venv/bin/algorithm-plugin serve \
92
+ --config /srv/camera-space-mano/algorithm-plugin.json
93
+ ```
94
+
95
+ ## 安装与测试
96
+
97
+ ```bash
98
+ python -m pip install '.[service]'
99
+ python -m unittest discover -s tests -v
100
+ ```
@@ -0,0 +1,85 @@
1
+ # Algorithm Plugin SDK
2
+
3
+ 算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
4
+ WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
5
+ Dockerfile 或 Web 框架代码。
6
+
7
+ ## 算法侧最小接口
8
+
9
+ ```python
10
+ from algorithm_plugin_sdk import Algorithm
11
+
12
+ class MyAlgorithm(Algorithm):
13
+ @classmethod
14
+ def metadata(cls): ...
15
+ def execute(self, request, context): ...
16
+ ```
17
+
18
+ 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
19
+ 会被所有运行方式复用。
20
+
21
+ ## 单次运行
22
+
23
+ ```bash
24
+ algorithm-plugin run camera-space-mano \
25
+ --input '{"input_dataset":"/data/input1","output":"/data/output1"}' \
26
+ --input '{"input_dataset":"/data/input2","output":"/data/output2"}' \
27
+ --gpu-ids 0 \
28
+ --parameters '{"batch_size":16}'
29
+ ```
30
+
31
+ `--gpu-ids` 和 `--parameters` 可省略,也可用 `--request-file` 读取完整请求 JSON。
32
+
33
+ ## 配置现有仓库
34
+
35
+ 目标算法仓库必须满足:
36
+
37
+ - 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
38
+ - 仓库包含自己的 `.venv`。
39
+ - `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
40
+ - 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
41
+
42
+ 建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
43
+ `.gitignore`,不要手动编辑或提交它们。
44
+
45
+ 通过目标仓库的虚拟环境执行:
46
+
47
+ ```bash
48
+ sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
49
+ --repository /srv/camera-space-mano \
50
+ --compute-url http://compute:5180 \
51
+ --api-key "$LDP_INTERNAL_API_KEY" \
52
+ --public-url http://10.0.0.4:9000 \
53
+ --input-root /data/jobs/input \
54
+ --workspace-root /data/jobs/workspace \
55
+ --cluster production \
56
+ --namespace camera-space-mano \
57
+ --node-name 10.0.0.4 \
58
+ --gpu-ids 0,1
59
+ ```
60
+
61
+ service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
62
+ 重复执行 configure 需要显式提供 `--force`。
63
+
64
+ 确认输出后执行:
65
+
66
+ ```bash
67
+ sudo systemctl daemon-reload
68
+ sudo systemctl enable camera-space-mano.service
69
+ sudo systemctl restart camera-space-mano.service
70
+ sudo systemctl status camera-space-mano.service
71
+ ```
72
+
73
+ ## serve
74
+
75
+ ```bash
76
+ /srv/camera-space-mano/.venv/bin/algorithm-plugin serve \
77
+ --config /srv/camera-space-mano/algorithm-plugin.json
78
+ ```
79
+
80
+ ## 安装与测试
81
+
82
+ ```bash
83
+ python -m pip install '.[service]'
84
+ python -m unittest discover -s tests -v
85
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ls-algorithm-plugin-sdk"
7
- version = "0.2.5"
7
+ version = "0.3.0"
8
8
  description = "Protocol-independent runtime SDK for dataset algorithms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from .cli_impl import configure as configure_command
8
+ from .cli_impl import run as run_command
9
+ from .cli_impl import serve as serve_command
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(prog="algorithm-plugin")
14
+ subs = parser.add_subparsers(dest="command", required=True)
15
+
16
+ run_command.configure_parser(
17
+ subs.add_parser("run", help="run datasets once")
18
+ )
19
+ serve_command.configure_parser(
20
+ subs.add_parser("serve", help="run the generated HTTP service")
21
+ )
22
+ configure_command.configure_parser(
23
+ subs.add_parser("configure", help="configure an existing Algorithm repository for systemd")
24
+ )
25
+ return parser
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ logging.basicConfig(
30
+ level=logging.INFO,
31
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
32
+ )
33
+ args = build_parser().parse_args(argv)
34
+ try:
35
+ return args.handler(args)
36
+ except KeyboardInterrupt:
37
+ return 130
38
+ except Exception as exc:
39
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
40
+ return 2
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Implementation modules for the algorithm-plugin command line interface."""
@@ -0,0 +1,421 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ..deployment import (
11
+ DEFAULT_CONFIG_NAME,
12
+ DeploymentConfig,
13
+ repository_root,
14
+ stable_instance_key,
15
+ )
16
+ from .parsing import environment, gpu_ids
17
+
18
+
19
+ SYSTEMD_UNIT_DIR = Path("/etc/systemd/system")
20
+
21
+ SERVICE_NAME_PATTERN = re.compile(
22
+ r"^[A-Za-z0-9][A-Za-z0-9_.@-]*$"
23
+ )
24
+
25
+
26
+ def configure_parser(parser: argparse.ArgumentParser) -> None:
27
+ parser.add_argument(
28
+ "algorithm",
29
+ nargs="?",
30
+ help="entry-point name or package.module:AlgorithmClass",
31
+ )
32
+ parser.add_argument(
33
+ "--repository",
34
+ type=Path,
35
+ default=Path.cwd(),
36
+ help="existing Algorithm repository containing its own .venv",
37
+ )
38
+ parser.add_argument(
39
+ "--config-output",
40
+ type=Path,
41
+ default=Path(DEFAULT_CONFIG_NAME),
42
+ help=f"config path relative to the repository (default: {DEFAULT_CONFIG_NAME})",
43
+ )
44
+ parser.add_argument("--host", default="0.0.0.0")
45
+ parser.add_argument("--port", type=int, default=9000)
46
+ parser.add_argument(
47
+ "--webui",
48
+ action=argparse.BooleanOptionalAction,
49
+ default=True,
50
+ )
51
+ parser.add_argument("--max-concurrency", type=int, default=1)
52
+ parser.add_argument("--scratch-dir")
53
+ parser.add_argument(
54
+ "--gpu-ids",
55
+ type=gpu_ids,
56
+ default=[],
57
+ help="GPU IDs injected into every service execution",
58
+ )
59
+ parser.add_argument("--service-token")
60
+ parser.add_argument(
61
+ "--compute-url",
62
+ default=os.getenv("LDP_COMPUTE_URL"),
63
+ )
64
+ parser.add_argument(
65
+ "--api-key",
66
+ default=os.getenv("LDP_INTERNAL_API_KEY"),
67
+ )
68
+ parser.add_argument(
69
+ "--public-url",
70
+ default=os.getenv("LDP_PLUGIN_PUBLIC_URL"),
71
+ )
72
+ parser.add_argument(
73
+ "--input-root",
74
+ default=os.getenv("LDP_JOB_INPUT_ROOT"),
75
+ )
76
+ parser.add_argument(
77
+ "--workspace-root",
78
+ default=os.getenv("LDP_JOB_WORKSPACE_ROOT"),
79
+ )
80
+ parser.add_argument(
81
+ "--instance-key",
82
+ default=os.getenv("LDP_PLUGIN_INSTANCE_KEY"),
83
+ )
84
+ parser.add_argument(
85
+ "--cluster",
86
+ default=os.getenv("LDP_CLUSTER"),
87
+ )
88
+ parser.add_argument(
89
+ "--namespace",
90
+ default=os.getenv("POD_NAMESPACE"),
91
+ )
92
+ parser.add_argument(
93
+ "--node-name",
94
+ default=os.getenv("NODE_NAME"),
95
+ )
96
+ parser.add_argument(
97
+ "--local",
98
+ action="store_true",
99
+ help="configure a local service without compute registration",
100
+ )
101
+ parser.add_argument(
102
+ "--env",
103
+ action="append",
104
+ type=environment,
105
+ default=[],
106
+ metavar="NAME=VALUE",
107
+ )
108
+ parser.add_argument(
109
+ "--service-name",
110
+ help="systemd unit name without .service; defaults to repository name",
111
+ )
112
+ parser.add_argument(
113
+ "--force",
114
+ action="store_true",
115
+ help="replace an existing systemd unit whose content differs",
116
+ )
117
+ parser.set_defaults(handler=execute)
118
+
119
+
120
+ def resolve_service_name(
121
+ repository: Path,
122
+ requested: str | None,
123
+ ) -> str:
124
+ name = requested or repository.name
125
+ if name.endswith(".service"):
126
+ raise ValueError(
127
+ "service name must not include the .service suffix"
128
+ )
129
+ if not SERVICE_NAME_PATTERN.fullmatch(name):
130
+ raise ValueError(
131
+ "service name may contain only letters, numbers, "
132
+ "underscore, dot, @, and hyphen"
133
+ )
134
+ return name
135
+
136
+
137
+ def _quote_systemd_path(value: Path) -> str:
138
+ text = str(value)
139
+ if "\n" in text or "\r" in text or "\0" in text:
140
+ raise ValueError(
141
+ f"systemd path contains control characters: {value}"
142
+ )
143
+ escaped = (
144
+ text.replace("\\", "\\\\")
145
+ .replace('"', '\\"')
146
+ .replace("%", "%%")
147
+ )
148
+ return f'"{escaped}"'
149
+
150
+
151
+ def render_systemd_unit(
152
+ *,
153
+ repository: Path,
154
+ config_path: Path,
155
+ service_name: str,
156
+ ) -> str:
157
+ root = repository.resolve()
158
+ launcher = root / ".venv" / "bin" / "algorithm-plugin"
159
+ config = config_path.resolve()
160
+ return "\n".join(
161
+ (
162
+ "[Unit]",
163
+ f"Description=Algorithm Plugin {service_name}",
164
+ "Wants=network-online.target",
165
+ "After=network-online.target",
166
+ "",
167
+ "[Service]",
168
+ "Type=simple",
169
+ "User=root",
170
+ f"WorkingDirectory={_quote_systemd_path(root)}",
171
+ (
172
+ f"ExecStart={_quote_systemd_path(launcher)} "
173
+ f"serve --config {_quote_systemd_path(config)}"
174
+ ),
175
+ "Environment=PYTHONUNBUFFERED=1",
176
+ "Restart=on-failure",
177
+ "RestartSec=5s",
178
+ "",
179
+ "[Install]",
180
+ "WantedBy=multi-user.target",
181
+ "",
182
+ )
183
+ )
184
+
185
+
186
+ def require_root() -> None:
187
+ if os.geteuid() != 0:
188
+ raise PermissionError(
189
+ "configure must run as root to write systemd units"
190
+ )
191
+
192
+
193
+ def inspect_systemd_unit(
194
+ *,
195
+ service_name: str,
196
+ content: str,
197
+ force: bool,
198
+ unit_dir: Path = SYSTEMD_UNIT_DIR,
199
+ ) -> tuple[Path, bool]:
200
+ require_root()
201
+ if not unit_dir.is_dir():
202
+ raise NotADirectoryError(
203
+ f"systemd unit directory is unavailable: {unit_dir}"
204
+ )
205
+
206
+ target = unit_dir / f"{service_name}.service"
207
+ if target.is_file():
208
+ existing = target.read_text(encoding="utf-8")
209
+ if existing == content:
210
+ return target, False
211
+ if not force:
212
+ raise FileExistsError(
213
+ f"systemd unit differs: {target}; "
214
+ "use --force to replace it"
215
+ )
216
+ elif target.exists():
217
+ raise FileExistsError(
218
+ f"systemd unit path is not a regular file: {target}"
219
+ )
220
+ return target, True
221
+
222
+
223
+ def write_systemd_unit(
224
+ *,
225
+ service_name: str,
226
+ content: str,
227
+ force: bool,
228
+ unit_dir: Path = SYSTEMD_UNIT_DIR,
229
+ ) -> tuple[Path, bool]:
230
+ target, changed = inspect_systemd_unit(
231
+ service_name=service_name,
232
+ content=content,
233
+ force=force,
234
+ unit_dir=unit_dir,
235
+ )
236
+ if not changed:
237
+ return target, False
238
+
239
+ temporary = target.with_name(
240
+ f".{target.name}.{os.getpid()}.tmp"
241
+ )
242
+ try:
243
+ temporary.write_text(content, encoding="utf-8")
244
+ temporary.chmod(0o644)
245
+ temporary.replace(target)
246
+ finally:
247
+ temporary.unlink(missing_ok=True)
248
+ return target, True
249
+
250
+
251
+ def validate_repository(value: Path) -> Path:
252
+ requested = value.expanduser().resolve()
253
+ if not requested.is_dir():
254
+ raise NotADirectoryError(
255
+ f"repository directory does not exist: {requested}"
256
+ )
257
+
258
+ repository = repository_root(requested)
259
+ launcher = repository / ".venv" / "bin" / "algorithm-plugin"
260
+ if not launcher.is_file():
261
+ raise FileNotFoundError(
262
+ f"repository virtualenv CLI is missing: {launcher}"
263
+ )
264
+ if not os.access(launcher, os.X_OK):
265
+ raise PermissionError(
266
+ f"repository virtualenv CLI is not executable: {launcher}"
267
+ )
268
+
269
+ environment = os.environ.copy()
270
+ environment.pop("PYTHONPATH", None)
271
+ for command in ("run", "serve"):
272
+ try:
273
+ completed = subprocess.run(
274
+ [str(launcher), command, "--help"],
275
+ cwd=repository,
276
+ env=environment,
277
+ stdout=subprocess.PIPE,
278
+ stderr=subprocess.PIPE,
279
+ text=True,
280
+ )
281
+ except OSError as exc:
282
+ raise RuntimeError(
283
+ f"cannot execute repository virtualenv CLI {launcher}: {exc}"
284
+ ) from exc
285
+ if completed.returncode:
286
+ detail = (
287
+ completed.stderr.strip()
288
+ or completed.stdout.strip()
289
+ or "no output"
290
+ )
291
+ raise RuntimeError(
292
+ f"{launcher} {command} --help failed with exit code "
293
+ f"{completed.returncode}: {detail}"
294
+ )
295
+ return repository
296
+
297
+
298
+ def validate_configure_runtime(repository: Path) -> Path:
299
+ expected = (repository / ".venv").resolve()
300
+ actual = Path(sys.prefix).resolve()
301
+ if actual != expected:
302
+ launcher = expected / "bin" / "algorithm-plugin"
303
+ raise RuntimeError(
304
+ "configure must run from the repository virtualenv; use: "
305
+ f"sudo {launcher} configure --repository {repository}"
306
+ )
307
+ return expected
308
+
309
+
310
+ def resolve_config_path(
311
+ repository: Path,
312
+ output: Path,
313
+ ) -> Path:
314
+ path = output.expanduser()
315
+ if not path.is_absolute():
316
+ path = repository / path
317
+ return path.resolve()
318
+
319
+
320
+ def registration_from_args(
321
+ args: argparse.Namespace,
322
+ repository: Path,
323
+ ) -> dict[str, str] | None:
324
+ if args.local:
325
+ return None
326
+
327
+ supplied = {
328
+ "computeUrl": args.compute_url,
329
+ "apiKey": args.api_key,
330
+ "publicUrl": args.public_url,
331
+ "inputRoot": args.input_root,
332
+ "workspaceRoot": args.workspace_root,
333
+ "cluster": args.cluster,
334
+ "namespace": args.namespace,
335
+ "nodeName": args.node_name,
336
+ }
337
+ missing = [
338
+ name
339
+ for name, value in supplied.items()
340
+ if not str(value or "").strip()
341
+ ]
342
+ if missing:
343
+ raise ValueError(
344
+ "configuration registration requires "
345
+ + ", ".join(missing)
346
+ + "; use --local to disable registration"
347
+ )
348
+
349
+ registration = {
350
+ name: str(value)
351
+ for name, value in supplied.items()
352
+ }
353
+ registration["instanceKey"] = stable_instance_key(
354
+ repository / ".algorithm-plugin" / "instance-key",
355
+ requested=args.instance_key,
356
+ )
357
+ return registration
358
+
359
+
360
+ def generate_config(
361
+ args: argparse.Namespace,
362
+ repository: Path,
363
+ ) -> DeploymentConfig:
364
+ config = DeploymentConfig.generate(
365
+ output=args.config_output,
366
+ root=repository,
367
+ algorithm=args.algorithm,
368
+ host=args.host,
369
+ port=args.port,
370
+ webui=args.webui,
371
+ max_concurrency=args.max_concurrency,
372
+ scratch_dir=args.scratch_dir,
373
+ token=args.service_token,
374
+ registration=registration_from_args(args, repository),
375
+ gpu_ids=args.gpu_ids,
376
+ environment=dict(args.env),
377
+ )
378
+ config.save()
379
+ return config
380
+
381
+
382
+ def execute(args: argparse.Namespace) -> int:
383
+ require_root()
384
+ repository = validate_repository(args.repository)
385
+ validate_configure_runtime(repository)
386
+ service_name = resolve_service_name(
387
+ repository,
388
+ args.service_name,
389
+ )
390
+ config_path = resolve_config_path(
391
+ repository,
392
+ args.config_output,
393
+ )
394
+ unit = render_systemd_unit(
395
+ repository=repository,
396
+ config_path=config_path,
397
+ service_name=service_name,
398
+ )
399
+
400
+ inspect_systemd_unit(
401
+ service_name=service_name,
402
+ content=unit,
403
+ force=args.force,
404
+ )
405
+ config = generate_config(args, repository)
406
+ unit_path, changed = write_systemd_unit(
407
+ service_name=service_name,
408
+ content=unit,
409
+ force=args.force,
410
+ )
411
+
412
+ unit_status = "updated" if changed else "unchanged"
413
+ print(f"Configured repository: {repository}")
414
+ print(f"Configuration: {config.path}")
415
+ print(f"Release manifest: {repository / 'release-manifest.json'}")
416
+ print(f"Systemd unit: {unit_path} ({unit_status})")
417
+ print("Next commands:")
418
+ print(" sudo systemctl daemon-reload")
419
+ print(f" sudo systemctl enable {service_name}.service")
420
+ print(f" sudo systemctl restart {service_name}.service")
421
+ return 0
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from typing import Any
7
+
8
+
9
+ def env_bool(name: str, default: bool = False) -> bool:
10
+ value = os.getenv(name)
11
+ if value is None:
12
+ return default
13
+ return value.strip().lower() in {"1", "true", "yes", "on"}
14
+
15
+
16
+ def json_object(value: str) -> dict[str, Any]:
17
+ try:
18
+ parsed = json.loads(value)
19
+ except json.JSONDecodeError as exc:
20
+ raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
21
+ if not isinstance(parsed, dict):
22
+ raise argparse.ArgumentTypeError("value must be a JSON object")
23
+ return parsed
24
+
25
+
26
+ def gpu_ids(value: str) -> list[int]:
27
+ if not value.strip():
28
+ return []
29
+ try:
30
+ return [int(item.strip()) for item in value.split(",")]
31
+ except ValueError as exc:
32
+ raise argparse.ArgumentTypeError(
33
+ "GPU IDs must be comma-separated integers"
34
+ ) from exc
35
+
36
+
37
+ def environment(value: str) -> tuple[str, str]:
38
+ name, separator, setting = value.partition("=")
39
+ if not separator or not name or not name.replace("_", "").isalnum():
40
+ raise argparse.ArgumentTypeError("environment must use NAME=VALUE")
41
+ if name.startswith("LDP_") or name.startswith("ALGORITHM_"):
42
+ raise argparse.ArgumentTypeError(
43
+ "SDK-managed LDP_/ALGORITHM_ settings must use dedicated options"
44
+ )
45
+ return name, setting