envloop-cli 0.1.0__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.
- el_cli/__init__.py +1 -0
- el_cli/cli.py +257 -0
- el_cli/client.py +335 -0
- el_cli/http.py +115 -0
- el_cli/train.py +53 -0
- el_cli/utils/__init__.py +1 -0
- el_cli/utils/logging.py +30 -0
- envloop_cli-0.1.0.dist-info/METADATA +89 -0
- envloop_cli-0.1.0.dist-info/RECORD +11 -0
- envloop_cli-0.1.0.dist-info/WHEEL +4 -0
- envloop_cli-0.1.0.dist-info/entry_points.txt +2 -0
el_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Standalone EnvLoop command-line client."""
|
el_cli/cli.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""el: local Profile and Task operations built entirely on the SDK."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import train
|
|
11
|
+
from .client import Client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HelpArgumentParser(argparse.ArgumentParser):
|
|
15
|
+
"""Show full command help for syntax and usage errors."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args, **kwargs):
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
self.set_defaults(_help_parser=self)
|
|
20
|
+
self._help_parser = self
|
|
21
|
+
|
|
22
|
+
def parse_known_args(self, args=None, namespace=None):
|
|
23
|
+
self._help_parser = self
|
|
24
|
+
parsed, remaining = super().parse_known_args(args, namespace)
|
|
25
|
+
# Subparsers propagate their defaults; keep the deepest selected command
|
|
26
|
+
# for unknown arguments and usage checks performed after parsing.
|
|
27
|
+
self._help_parser = parsed._help_parser
|
|
28
|
+
return parsed, remaining
|
|
29
|
+
|
|
30
|
+
def error(self, message):
|
|
31
|
+
command = self._help_parser
|
|
32
|
+
command.print_help(sys.stderr)
|
|
33
|
+
command.exit(2, f"\n{command.prog}: error: {message}\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def dataset_reference(value):
|
|
37
|
+
name, separator, version = value.rpartition("@")
|
|
38
|
+
if (not value.strip() or (separator and (not name.strip() or not version.strip()))):
|
|
39
|
+
raise argparse.ArgumentTypeError(
|
|
40
|
+
"expected name or name@version with a non-empty name and version "
|
|
41
|
+
"(e.g. org/dataset@1.0)"
|
|
42
|
+
)
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def submit_arguments(submit):
|
|
47
|
+
submit.add_argument("source", type=Path)
|
|
48
|
+
submit.add_argument(
|
|
49
|
+
"--profile", help="name@version, or name with an explicit default; latest is literal",
|
|
50
|
+
)
|
|
51
|
+
submit.add_argument("--profile-sha256", help="Expected canonical ProfileSpec SHA-256")
|
|
52
|
+
submit.add_argument("--request-id")
|
|
53
|
+
submit.add_argument("--seed", type=int)
|
|
54
|
+
submit.add_argument("--timeout-sec", type=int)
|
|
55
|
+
submit.add_argument("--executor", choices=["local", "docker", "sandbox"])
|
|
56
|
+
submit.add_argument("--provider", choices=["e2b", "e2b-local"])
|
|
57
|
+
submit.add_argument("--wait", action="store_true")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parser():
|
|
61
|
+
cli = HelpArgumentParser(prog="el")
|
|
62
|
+
cli.add_argument("--repository-root", type=Path)
|
|
63
|
+
cli.add_argument("--repository-commit")
|
|
64
|
+
cli.add_argument("--work-dir", type=Path)
|
|
65
|
+
cli.add_argument("--api-url", default=os.environ.get("ENVLOOP_BASE_URL", os.environ.get("ENVPLATFORM_API_URL")), help="HTTPS apiserver or loopback URL; defaults to ENVLOOP_BASE_URL; Bearer key from ENVLOOP_API_KEY")
|
|
66
|
+
cli.add_argument("--workspace-id", default=os.environ.get("ENVLOOP_WORKSPACE_ID"), help="Optional workspace override; defaults to the user's authorized workspace")
|
|
67
|
+
groups = cli.add_subparsers(dest="group", required=True)
|
|
68
|
+
train.register(groups)
|
|
69
|
+
profiles = groups.add_parser("profile").add_subparsers(dest="action", required=True)
|
|
70
|
+
profiles.add_parser("init")
|
|
71
|
+
put = profiles.add_parser("put")
|
|
72
|
+
put.add_argument("name")
|
|
73
|
+
put.add_argument("version")
|
|
74
|
+
put.add_argument("--file", type=Path, required=True, help="ProfileSpec JSON file")
|
|
75
|
+
get = profiles.add_parser("get")
|
|
76
|
+
get.add_argument("name")
|
|
77
|
+
get.add_argument("--version")
|
|
78
|
+
listing = profiles.add_parser("list")
|
|
79
|
+
listing.add_argument("--name")
|
|
80
|
+
listing.add_argument("--include-archived", action="store_true")
|
|
81
|
+
listing.add_argument("--limit", type=int, default=50)
|
|
82
|
+
listing.add_argument("--cursor")
|
|
83
|
+
default = profiles.add_parser("set-default")
|
|
84
|
+
default.add_argument("name")
|
|
85
|
+
default.add_argument("version")
|
|
86
|
+
default.add_argument("--expected-version", help="Omit only when no default exists")
|
|
87
|
+
for command in ("archive", "export"):
|
|
88
|
+
item = profiles.add_parser(command)
|
|
89
|
+
item.add_argument("name")
|
|
90
|
+
item.add_argument("version")
|
|
91
|
+
tasks = groups.add_parser("task").add_subparsers(dest="action", required=True)
|
|
92
|
+
submit = tasks.add_parser("submit")
|
|
93
|
+
submit_arguments(submit)
|
|
94
|
+
submissions = groups.add_parser("submit").add_subparsers(dest="action", required=True)
|
|
95
|
+
submit_arguments(submissions.add_parser("task"))
|
|
96
|
+
snapshot = tasks.add_parser("snapshot")
|
|
97
|
+
snapshot.add_argument("trial_id")
|
|
98
|
+
for action in ("status", "stop", "result"):
|
|
99
|
+
tasks.add_parser(action).add_argument("trial_id")
|
|
100
|
+
jobs = groups.add_parser("job").add_subparsers(dest="action", required=True)
|
|
101
|
+
job_submit = jobs.add_parser("submit")
|
|
102
|
+
job_submit.add_argument(
|
|
103
|
+
"dataset", nargs="?", type=dataset_reference, metavar="NAME[@VERSION]",
|
|
104
|
+
help="dataset name; optional author version (legacy positional form)",
|
|
105
|
+
)
|
|
106
|
+
job_submit.add_argument("--dataset", dest="dataset_option", type=dataset_reference, metavar="NAME[@VERSION]",
|
|
107
|
+
help="dataset name; default selection uses the platform's pinned snapshot")
|
|
108
|
+
job_submit.add_argument("--namespace", help="explicit namespace for a short dataset name")
|
|
109
|
+
job_submit.add_argument("--config", help="dataset subset; uses its declared default when omitted")
|
|
110
|
+
job_submit.add_argument("--split", help="one complete split name; omitted selects all splits")
|
|
111
|
+
job_submit.add_argument("--revision", help="Git branch/tag/commit; must match the platform's pinned snapshot")
|
|
112
|
+
job_submit.add_argument("--attempts", type=int, default=1)
|
|
113
|
+
job_submit.add_argument("--request-id")
|
|
114
|
+
job_submit.add_argument("--profile")
|
|
115
|
+
job_submit.add_argument("--seed", type=int)
|
|
116
|
+
job_submit.add_argument("--timeout-sec", type=int)
|
|
117
|
+
job_submit.add_argument("--executor", choices=["local", "docker", "sandbox"])
|
|
118
|
+
job_submit.add_argument("--provider", choices=["e2b", "e2b-local"])
|
|
119
|
+
job_submit.add_argument("--wait", action="store_true")
|
|
120
|
+
for action in ("status", "stop", "result"):
|
|
121
|
+
jobs.add_parser(action).add_argument("job_id")
|
|
122
|
+
listing = jobs.add_parser("list")
|
|
123
|
+
listing.add_argument("--cursor")
|
|
124
|
+
listing.add_argument("--limit", type=int, default=25)
|
|
125
|
+
return cli
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def submission_options(args):
|
|
129
|
+
options = {}
|
|
130
|
+
if args.profile:
|
|
131
|
+
if "@" not in args.profile:
|
|
132
|
+
name, version = args.profile, None
|
|
133
|
+
elif args.group == "job":
|
|
134
|
+
name, version = args.profile.rsplit("@", 1)
|
|
135
|
+
else:
|
|
136
|
+
name, version = args.profile.split("@", 1)
|
|
137
|
+
options["profile_name"] = name
|
|
138
|
+
if version is not None:
|
|
139
|
+
options["profile_version"] = version
|
|
140
|
+
if args.seed is not None:
|
|
141
|
+
options["seed"] = args.seed
|
|
142
|
+
execution = {key: value for key, value in {"timeout_sec": args.timeout_sec, "executor": args.executor, "provider": args.provider}.items() if value is not None}
|
|
143
|
+
if execution:
|
|
144
|
+
options["execution"] = execution
|
|
145
|
+
return options
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def local_api_url():
|
|
149
|
+
"""Read only the CLI connection setting; never import task secrets."""
|
|
150
|
+
path = Path.cwd() / ".env.local"
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
return None
|
|
153
|
+
values = {}
|
|
154
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
155
|
+
key, separator, raw = line.strip().removeprefix("export ").partition("=")
|
|
156
|
+
if separator and key.strip() in {"ENVLOOP_BASE_URL", "ENVPLATFORM_API_URL"}:
|
|
157
|
+
values[key.strip()] = raw
|
|
158
|
+
raw = values.get("ENVLOOP_BASE_URL", values.get("ENVPLATFORM_API_URL"))
|
|
159
|
+
if raw is None:
|
|
160
|
+
return None
|
|
161
|
+
parts = shlex.split(raw, comments=True)
|
|
162
|
+
if len(parts) > 1:
|
|
163
|
+
raise ValueError("invalid API URL configuration")
|
|
164
|
+
return parts[0] if parts else ""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv=None):
|
|
168
|
+
cli = parser()
|
|
169
|
+
args = cli.parse_args(argv)
|
|
170
|
+
if args.group == "job" and args.action == "submit":
|
|
171
|
+
if (args.dataset is None) == (args.dataset_option is None):
|
|
172
|
+
cli.error("provide exactly one positional dataset or --dataset")
|
|
173
|
+
args.dataset = args.dataset if args.dataset is not None else args.dataset_option
|
|
174
|
+
if args.api_url is not None and (args.repository_root or args.repository_commit or args.work_dir or args.group == "profile"):
|
|
175
|
+
cli.error("--api-url cannot be combined with local repository/profile options")
|
|
176
|
+
try:
|
|
177
|
+
if args.api_url is None and not (
|
|
178
|
+
args.repository_root or args.repository_commit or args.work_dir
|
|
179
|
+
or args.group == "profile"
|
|
180
|
+
):
|
|
181
|
+
args.api_url = local_api_url()
|
|
182
|
+
if args.group == "profile" and args.action == "init":
|
|
183
|
+
if args.repository_root is None:
|
|
184
|
+
cli.error("--repository-root is required")
|
|
185
|
+
Client.init_repository(args.repository_root)
|
|
186
|
+
print(json.dumps({"initialized": True}))
|
|
187
|
+
return 0
|
|
188
|
+
if args.group == "train" and args.api_url is None:
|
|
189
|
+
cli.error("train requires --api-url or ENVLOOP_BASE_URL")
|
|
190
|
+
connection = Client.remote(args.api_url, workspace_id=args.workspace_id) if args.api_url is not None else Client.local(
|
|
191
|
+
repository_root=args.repository_root,
|
|
192
|
+
repository_commit=args.repository_commit,
|
|
193
|
+
work_dir=args.work_dir,
|
|
194
|
+
)
|
|
195
|
+
with connection as client:
|
|
196
|
+
if args.group == "train":
|
|
197
|
+
result = train.execute(client.train, args)
|
|
198
|
+
elif args.group == "profile":
|
|
199
|
+
if args.action == "put":
|
|
200
|
+
result = client.profiles.import_spec(
|
|
201
|
+
args.file.read_bytes(), name=args.name, version=args.version
|
|
202
|
+
)
|
|
203
|
+
elif args.action == "get":
|
|
204
|
+
result = client.profiles.get(args.name, args.version)
|
|
205
|
+
elif args.action == "list":
|
|
206
|
+
result = client.profiles.list(
|
|
207
|
+
name=args.name,
|
|
208
|
+
include_archived=args.include_archived,
|
|
209
|
+
limit=args.limit,
|
|
210
|
+
cursor=args.cursor,
|
|
211
|
+
)
|
|
212
|
+
elif args.action == "set-default":
|
|
213
|
+
result = client.profiles.set_default(
|
|
214
|
+
args.name, args.version, expected_version=args.expected_version
|
|
215
|
+
)
|
|
216
|
+
elif args.action == "archive":
|
|
217
|
+
result = client.profiles.archive(args.name, args.version)
|
|
218
|
+
else:
|
|
219
|
+
sys.stdout.buffer.write(
|
|
220
|
+
client.profiles.export(args.name, args.version) + b"\n"
|
|
221
|
+
)
|
|
222
|
+
return 0
|
|
223
|
+
elif args.group == "job":
|
|
224
|
+
if args.action == "submit":
|
|
225
|
+
result = client.jobs.submit(args.dataset, attempts=args.attempts, request_id=args.request_id,
|
|
226
|
+
namespace=args.namespace, config=args.config, split=args.split,
|
|
227
|
+
revision=args.revision, **submission_options(args))
|
|
228
|
+
if not args.api_url or args.wait:
|
|
229
|
+
result = client.jobs.wait(result["job_id"])
|
|
230
|
+
elif args.action == "list":
|
|
231
|
+
result = client.jobs.list(cursor=args.cursor, limit=args.limit)
|
|
232
|
+
else:
|
|
233
|
+
result = getattr(client.jobs, args.action)(args.job_id)
|
|
234
|
+
elif args.action in {"snapshot", "status", "stop", "result"}:
|
|
235
|
+
result = getattr(client.tasks, args.action)(args.trial_id)
|
|
236
|
+
else:
|
|
237
|
+
trial = client.tasks.submit(
|
|
238
|
+
args.source,
|
|
239
|
+
request_id=args.request_id,
|
|
240
|
+
profile_sha256=args.profile_sha256,
|
|
241
|
+
**submission_options(args),
|
|
242
|
+
)
|
|
243
|
+
if not args.api_url or args.wait:
|
|
244
|
+
client.tasks.wait(trial["trial_id"], timeout=600)
|
|
245
|
+
result = client.tasks.result(trial["trial_id"])
|
|
246
|
+
else:
|
|
247
|
+
result = trial
|
|
248
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
249
|
+
return 1 if result.get("status") == "failed" else 0
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
# Do not emit arbitrary exception messages, file content or credentials.
|
|
252
|
+
print(getattr(exc, "code", None) or type(exc).__name__, file=sys.stderr)
|
|
253
|
+
return 1
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
raise SystemExit(main())
|
el_cli/client.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Thin synchronous SDK for direct, in-process Task calls."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from .utils.logging import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskService(Protocol):
|
|
17
|
+
def submit_task(self, body: dict) -> dict: ...
|
|
18
|
+
def get_task_run(self, trial_id: str) -> dict: ...
|
|
19
|
+
def get_task_result(self, trial_id: str) -> dict: ...
|
|
20
|
+
def get_task_snapshot(self, trial_id: str) -> dict: ...
|
|
21
|
+
def stop_task(self, trial_id: str) -> dict: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProfileService(Protocol):
|
|
25
|
+
def init_repository(self, root: str | Path) -> dict: ...
|
|
26
|
+
def put_profile(self, spec: dict) -> dict: ...
|
|
27
|
+
def get_profile(self, name: str, version: str | None = None) -> dict: ...
|
|
28
|
+
def list_profiles(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
name: str | None = None,
|
|
32
|
+
include_archived: bool = False,
|
|
33
|
+
limit: int = 50,
|
|
34
|
+
cursor: str | None = None,
|
|
35
|
+
) -> dict: ...
|
|
36
|
+
def resolve_profile(
|
|
37
|
+
self,
|
|
38
|
+
name: str,
|
|
39
|
+
version: str | None = None,
|
|
40
|
+
expected_sha256: str | None = None,
|
|
41
|
+
) -> dict: ...
|
|
42
|
+
def set_default_profile(
|
|
43
|
+
self,
|
|
44
|
+
name: str,
|
|
45
|
+
version: str,
|
|
46
|
+
*,
|
|
47
|
+
expected_version: str | None,
|
|
48
|
+
) -> dict: ...
|
|
49
|
+
def archive_profile(self, name: str, version: str) -> dict: ...
|
|
50
|
+
def export_profile(self, name: str, version: str) -> bytes: ...
|
|
51
|
+
def import_profile(
|
|
52
|
+
self,
|
|
53
|
+
raw: bytes,
|
|
54
|
+
*,
|
|
55
|
+
name: str | None = None,
|
|
56
|
+
version: str | None = None,
|
|
57
|
+
) -> dict: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RpcService(TaskService, ProfileService, Protocol):
|
|
61
|
+
"""Transport-independent service contract for the SDK."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_task(source: str | Path | Mapping) -> dict:
|
|
65
|
+
"""Read the two UTF-8 task files without executing or modifying them."""
|
|
66
|
+
if isinstance(source, Mapping):
|
|
67
|
+
return dict(source)
|
|
68
|
+
directory = Path(source)
|
|
69
|
+
config_path = directory / "task.toml"
|
|
70
|
+
if config_path.is_symlink() or not config_path.is_file():
|
|
71
|
+
raise ValueError(f"missing regular task.toml: {directory}")
|
|
72
|
+
config = config_path.read_text(encoding="utf-8")
|
|
73
|
+
execution = tomllib.loads(config).get("execution", {})
|
|
74
|
+
if not isinstance(execution, dict):
|
|
75
|
+
raise ValueError("[execution] must be a table")
|
|
76
|
+
entry = execution.get("entrypoint")
|
|
77
|
+
if entry not in {"main.py", "main.sh"}:
|
|
78
|
+
raise ValueError("entrypoint must be main.py or main.sh")
|
|
79
|
+
path = directory / entry
|
|
80
|
+
if path.is_symlink() or not path.is_file():
|
|
81
|
+
raise ValueError(f"missing regular entrypoint: {entry}")
|
|
82
|
+
names = {p.name for p in directory.iterdir() if p.is_file() or p.is_symlink()}
|
|
83
|
+
if names != {"task.toml", entry}:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"task directory must contain exactly task.toml and its entrypoint"
|
|
86
|
+
)
|
|
87
|
+
return {"task.toml": config, entry: path.read_text(encoding="utf-8")}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Tasks:
|
|
91
|
+
def __init__(self, server: TaskService) -> None:
|
|
92
|
+
self._server = server
|
|
93
|
+
|
|
94
|
+
def submit(
|
|
95
|
+
self,
|
|
96
|
+
source: str | Path | Mapping | None = None,
|
|
97
|
+
*,
|
|
98
|
+
request_id: str | None = None,
|
|
99
|
+
profile: Mapping | None = None,
|
|
100
|
+
task_ref: Mapping | None = None,
|
|
101
|
+
profile_name: str | None = None,
|
|
102
|
+
profile_version: str | None = None,
|
|
103
|
+
profile_sha256: str | None = None,
|
|
104
|
+
profile_overrides: Mapping | None = None,
|
|
105
|
+
seed: int | None = None,
|
|
106
|
+
execution: Mapping | None = None,
|
|
107
|
+
) -> dict:
|
|
108
|
+
if request_id is None:
|
|
109
|
+
request_id = str(uuid4())
|
|
110
|
+
if not isinstance(request_id, str) or not request_id.strip():
|
|
111
|
+
raise ValueError("request_id must be a non-empty string")
|
|
112
|
+
body = {"request_id": request_id}
|
|
113
|
+
if source is not None:
|
|
114
|
+
body["task"] = load_task(source)
|
|
115
|
+
for key, value in dict(
|
|
116
|
+
task_ref=task_ref,
|
|
117
|
+
profile_name=profile_name,
|
|
118
|
+
profile_version=profile_version,
|
|
119
|
+
profile_sha256=profile_sha256,
|
|
120
|
+
profile_overrides=profile_overrides,
|
|
121
|
+
seed=seed,
|
|
122
|
+
execution=execution,
|
|
123
|
+
).items():
|
|
124
|
+
if value is not None:
|
|
125
|
+
body[key] = dict(value) if isinstance(value, Mapping) else value
|
|
126
|
+
if profile is not None:
|
|
127
|
+
body["profile"] = dict(profile)
|
|
128
|
+
logger.info("task.submitting")
|
|
129
|
+
try:
|
|
130
|
+
trial = self._server.submit_task(body)
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.warning("task.submit_failed error_type=%s", type(exc).__name__)
|
|
133
|
+
raise
|
|
134
|
+
logger.info("task.submitted trial_id=%s", trial["trial_id"])
|
|
135
|
+
return trial
|
|
136
|
+
|
|
137
|
+
def snapshot(self, trial_id: str) -> dict:
|
|
138
|
+
return self._server.get_task_snapshot(trial_id)
|
|
139
|
+
|
|
140
|
+
def status(self, trial_id: str) -> dict:
|
|
141
|
+
return self._server.get_task_run(trial_id)
|
|
142
|
+
|
|
143
|
+
def result(self, trial_id: str) -> dict:
|
|
144
|
+
return self._server.get_task_result(trial_id)
|
|
145
|
+
|
|
146
|
+
def stop(self, trial_id: str) -> dict:
|
|
147
|
+
return self._server.stop_task(trial_id)
|
|
148
|
+
|
|
149
|
+
def wait(
|
|
150
|
+
self, trial_id: str, *, timeout: float = 30, poll_interval: float = 0.05
|
|
151
|
+
) -> dict:
|
|
152
|
+
if not math.isfinite(timeout) or timeout < 0:
|
|
153
|
+
raise ValueError("timeout must be finite and non-negative")
|
|
154
|
+
if not math.isfinite(poll_interval) or poll_interval <= 0:
|
|
155
|
+
raise ValueError("poll_interval must be finite and positive")
|
|
156
|
+
logger.info("task.wait_started trial_id=%s timeout_sec=%s", trial_id, timeout)
|
|
157
|
+
deadline = time.monotonic() + timeout
|
|
158
|
+
previous_status = None
|
|
159
|
+
while True:
|
|
160
|
+
trial = self.status(trial_id)
|
|
161
|
+
if trial["status"] != previous_status:
|
|
162
|
+
logger.debug(
|
|
163
|
+
"task.status_changed trial_id=%s status=%s",
|
|
164
|
+
trial_id,
|
|
165
|
+
trial["status"],
|
|
166
|
+
)
|
|
167
|
+
previous_status = trial["status"]
|
|
168
|
+
if trial["status"] in {"succeeded", "failed", "cancelled"}:
|
|
169
|
+
logger.info(
|
|
170
|
+
"task.wait_finished trial_id=%s status=%s",
|
|
171
|
+
trial_id,
|
|
172
|
+
trial["status"],
|
|
173
|
+
)
|
|
174
|
+
return trial
|
|
175
|
+
remaining = deadline - time.monotonic()
|
|
176
|
+
if remaining <= 0:
|
|
177
|
+
logger.warning(
|
|
178
|
+
"task.wait_timed_out trial_id=%s timeout_sec=%s", trial_id, timeout
|
|
179
|
+
)
|
|
180
|
+
raise TimeoutError(f"trial {trial_id} did not finish within {timeout}s")
|
|
181
|
+
time.sleep(min(poll_interval, remaining))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Profiles:
|
|
185
|
+
"""Thin facade over the service's public profile management API."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, server: ProfileService) -> None:
|
|
188
|
+
self._server = server
|
|
189
|
+
|
|
190
|
+
def init(self, root: str | Path) -> dict:
|
|
191
|
+
return self._server.init_repository(root)
|
|
192
|
+
|
|
193
|
+
def put(self, spec: Mapping) -> dict:
|
|
194
|
+
return self._server.put_profile(dict(spec))
|
|
195
|
+
|
|
196
|
+
create = put
|
|
197
|
+
|
|
198
|
+
def get(self, name: str, version: str | None = None) -> dict:
|
|
199
|
+
return self._server.get_profile(name, version)
|
|
200
|
+
|
|
201
|
+
def list(self, **kwargs) -> dict:
|
|
202
|
+
return self._server.list_profiles(**kwargs)
|
|
203
|
+
|
|
204
|
+
def resolve(self, name, version=None, expected_sha256=None):
|
|
205
|
+
return self._server.resolve_profile(name, version, expected_sha256)
|
|
206
|
+
|
|
207
|
+
def set_default(self, name, version, *, expected_version):
|
|
208
|
+
return self._server.set_default_profile(
|
|
209
|
+
name, version, expected_version=expected_version
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def archive(self, name, version):
|
|
213
|
+
return self._server.archive_profile(name, version)
|
|
214
|
+
|
|
215
|
+
def export(self, name, version):
|
|
216
|
+
return self._server.export_profile(name, version)
|
|
217
|
+
|
|
218
|
+
def import_spec(self, raw: bytes, *, name=None, version=None):
|
|
219
|
+
return self._server.import_profile(raw, name=name, version=version)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class Jobs:
|
|
223
|
+
def __init__(self, server):
|
|
224
|
+
self._server = server
|
|
225
|
+
|
|
226
|
+
def submit(self, dataset, *, request_id=None, attempts=1, namespace=None,
|
|
227
|
+
config=None, split=None, revision=None, **options):
|
|
228
|
+
selection = {key: value for key, value in dict(namespace=namespace, config=config,
|
|
229
|
+
split=split, revision=revision).items() if value is not None}
|
|
230
|
+
if isinstance(dataset, str):
|
|
231
|
+
name, version = dataset, None
|
|
232
|
+
if "@" in dataset:
|
|
233
|
+
name, version = dataset.rsplit("@", 1)
|
|
234
|
+
if not name.strip() or name != name.strip() or (version is not None and not version.strip()):
|
|
235
|
+
raise ValueError("dataset must be a non-empty name or name@version")
|
|
236
|
+
selector = {"name": name, **selection}
|
|
237
|
+
if version is not None:
|
|
238
|
+
selector["version"] = version
|
|
239
|
+
source = {"dataset": selector}
|
|
240
|
+
elif isinstance(dataset, Mapping):
|
|
241
|
+
if selection:
|
|
242
|
+
raise ValueError("selection options require a dataset name; fixed references already freeze members")
|
|
243
|
+
source = {"dataset_ref": dict(dataset)}
|
|
244
|
+
else:
|
|
245
|
+
raise ValueError("dataset must be a fixed reference, name or name@version")
|
|
246
|
+
# Resolve on the platform after its request_id check, so a retry retains
|
|
247
|
+
# its first frozen snapshot even when the source/default has changed.
|
|
248
|
+
return self._server.submit_job({"request_id": request_id if request_id is not None else str(uuid4()),
|
|
249
|
+
**source, "attempts": attempts, **options})
|
|
250
|
+
|
|
251
|
+
def status(self, job_id):
|
|
252
|
+
return self._server.get_job(job_id)
|
|
253
|
+
|
|
254
|
+
def result(self, job_id):
|
|
255
|
+
return self._server.get_job_result(job_id)
|
|
256
|
+
|
|
257
|
+
def list(self, **kwargs):
|
|
258
|
+
return self._server.list_jobs(**kwargs)
|
|
259
|
+
|
|
260
|
+
def stop(self, job_id):
|
|
261
|
+
return self._server.stop_job(job_id)
|
|
262
|
+
|
|
263
|
+
def wait(self, job_id, *, timeout=600, poll_interval=.1):
|
|
264
|
+
if not math.isfinite(timeout) or timeout < 0:
|
|
265
|
+
raise ValueError("timeout must be finite and non-negative")
|
|
266
|
+
if not math.isfinite(poll_interval) or poll_interval <= 0:
|
|
267
|
+
raise ValueError("poll_interval must be finite and positive")
|
|
268
|
+
deadline = time.monotonic() + timeout
|
|
269
|
+
while True:
|
|
270
|
+
job = self.status(job_id)
|
|
271
|
+
if job["status"] in {"completed", "failed", "cancelled"}:
|
|
272
|
+
return job
|
|
273
|
+
remaining = deadline - time.monotonic()
|
|
274
|
+
if remaining <= 0:
|
|
275
|
+
raise TimeoutError("job did not reach a terminal state")
|
|
276
|
+
time.sleep(min(poll_interval, remaining))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Client:
|
|
280
|
+
"""A client for a caller-owned service, or use local() for an owned server."""
|
|
281
|
+
|
|
282
|
+
def __init__(self, server: RpcService) -> None:
|
|
283
|
+
from .train import Train
|
|
284
|
+
|
|
285
|
+
self.train = Train(server)
|
|
286
|
+
self.tasks = Tasks(server)
|
|
287
|
+
self.profiles = Profiles(server)
|
|
288
|
+
self.jobs = Jobs(server)
|
|
289
|
+
self._owned_server = None
|
|
290
|
+
|
|
291
|
+
@classmethod
|
|
292
|
+
def remote(cls, api_url=None, *, timeout=30, api_key=None, workspace_id=None):
|
|
293
|
+
from .http import HttpService
|
|
294
|
+
|
|
295
|
+
return cls(HttpService(api_url, timeout, api_key=api_key, workspace_id=workspace_id))
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def local(
|
|
299
|
+
cls,
|
|
300
|
+
*,
|
|
301
|
+
work_dir: str | Path | None = None,
|
|
302
|
+
repository_root: str | Path | None = None,
|
|
303
|
+
repository_commit: str | None = None,
|
|
304
|
+
):
|
|
305
|
+
try:
|
|
306
|
+
from env_platform import LocalServer
|
|
307
|
+
from env_platform.executor import LocalExecutor
|
|
308
|
+
except ImportError as exc:
|
|
309
|
+
raise ImportError(
|
|
310
|
+
"Client.local() requires EnvPlatform; install its local checkout first"
|
|
311
|
+
) from exc
|
|
312
|
+
server = LocalServer(
|
|
313
|
+
executor=LocalExecutor(base_dir=work_dir, logs_dir=Path.cwd() / "logs"),
|
|
314
|
+
repository_root=repository_root,
|
|
315
|
+
repository_commit=repository_commit,
|
|
316
|
+
)
|
|
317
|
+
client = cls(server=server)
|
|
318
|
+
client._owned_server = server
|
|
319
|
+
return client
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def init_repository(root: str | Path) -> None:
|
|
323
|
+
"""Explicitly initialize the repository-local registry, without changing Git."""
|
|
324
|
+
with Client.local() as client:
|
|
325
|
+
client.profiles.init(root)
|
|
326
|
+
|
|
327
|
+
def close(self) -> None:
|
|
328
|
+
if self._owned_server is not None:
|
|
329
|
+
self._owned_server.close()
|
|
330
|
+
|
|
331
|
+
def __enter__(self):
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
335
|
+
self.close()
|
el_cli/http.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Bounded Bearer HTTPS transport with trusted loopback development support."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
10
|
+
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RemoteError(RuntimeError):
|
|
14
|
+
def __init__(self, code, status_code=None):
|
|
15
|
+
self.code = code
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
super().__init__(code)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
21
|
+
def redirect_request(self, request, response, code, message, headers, new_url):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HttpService:
|
|
26
|
+
def __init__(self, base_url=None, timeout=30, *, api_key=None, workspace_id=None):
|
|
27
|
+
if base_url is None:
|
|
28
|
+
base_url = os.environ.get("ENVLOOP_BASE_URL", os.environ.get("ENVPLATFORM_API_URL"))
|
|
29
|
+
if not isinstance(base_url, str) or not base_url:
|
|
30
|
+
raise ValueError("Remote API URL is required; set ENVLOOP_BASE_URL or pass api_url")
|
|
31
|
+
url = urlsplit(base_url)
|
|
32
|
+
if (not url.hostname or (url.scheme != "https" and not (url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "::1"}))
|
|
33
|
+
or url.username or url.password or url.query or url.fragment
|
|
34
|
+
or url.path not in {"", "/", "/v1", "/v1/", "/api", "/api/", "/api/v1", "/api/v1/"}):
|
|
35
|
+
raise ValueError("remote mode requires HTTPS or loopback HTTP without URL credentials")
|
|
36
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
37
|
+
raise ValueError("HTTP timeout must be finite and positive")
|
|
38
|
+
key = os.environ.get("ENVLOOP_API_KEY") if api_key is None else api_key
|
|
39
|
+
if key is None and url.hostname not in {"localhost", "127.0.0.1", "::1"}:
|
|
40
|
+
raise ValueError("Remote HTTPS requires an API key")
|
|
41
|
+
workspace = os.environ.get("ENVLOOP_WORKSPACE_ID") if workspace_id is None else workspace_id
|
|
42
|
+
self.headers = {"Content-Type": "application/json", "User-Agent": "envloop/0.1"}
|
|
43
|
+
if key is not None:
|
|
44
|
+
if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9._~-]{1,512}", key):
|
|
45
|
+
raise ValueError("Invalid API key format")
|
|
46
|
+
self.headers["Authorization"] = f"Bearer {key}"
|
|
47
|
+
if workspace is not None:
|
|
48
|
+
if not isinstance(workspace, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", workspace):
|
|
49
|
+
raise ValueError("Invalid workspace ID")
|
|
50
|
+
self.headers["X-Workspace-ID"] = workspace
|
|
51
|
+
self.base = base_url.rstrip("/").removesuffix("/v1")
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
self.opener = build_opener(ProxyHandler({}), NoRedirect())
|
|
54
|
+
|
|
55
|
+
def request(self, method, path, body=None):
|
|
56
|
+
request = Request(self.base + path,
|
|
57
|
+
data=json.dumps(body, allow_nan=False).encode() if body is not None else None,
|
|
58
|
+
headers=self.headers, method=method)
|
|
59
|
+
try:
|
|
60
|
+
with self.opener.open(request, timeout=self.timeout) as response:
|
|
61
|
+
raw = response.read(16 * 1024 * 1024 + 1)
|
|
62
|
+
if len(raw) > 16 * 1024 * 1024:
|
|
63
|
+
raise RemoteError("RESPONSE_TOO_LARGE")
|
|
64
|
+
return json.loads(raw)
|
|
65
|
+
except HTTPError as error:
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(error.read(8192))
|
|
68
|
+
code = data.get("code", "HTTP_ERROR")
|
|
69
|
+
if not isinstance(code, str) or len(code) > 128 or not all(character.isupper() or character.isdigit() or character == "_" for character in code):
|
|
70
|
+
code = "HTTP_ERROR"
|
|
71
|
+
except (ValueError, UnicodeError):
|
|
72
|
+
code = "HTTP_ERROR"
|
|
73
|
+
raise RemoteError(code, error.code) from None
|
|
74
|
+
except (URLError, TimeoutError, OSError):
|
|
75
|
+
raise RemoteError("NETWORK_ERROR") from None
|
|
76
|
+
except (ValueError, UnicodeError):
|
|
77
|
+
raise RemoteError("INVALID_RESPONSE") from None
|
|
78
|
+
|
|
79
|
+
def resolve_dataset(self, name, version):
|
|
80
|
+
encoded = base64.urlsafe_b64encode(name.encode()).decode().rstrip("=")
|
|
81
|
+
page = self.request("GET", f"/v1/datasets/{encoded}/versions?{urlencode({'version': version})}")
|
|
82
|
+
matches = [item for item in page["items"] if item["name"] == name and item["version"] == version]
|
|
83
|
+
if len(matches) != 1:
|
|
84
|
+
raise RemoteError("DATASET_REVISION_NOT_FOUND", 404)
|
|
85
|
+
return {"kind": "dataset", **{key: matches[0][key] for key in ("name", "version", "manifest_sha256")}}
|
|
86
|
+
|
|
87
|
+
def submit_job(self, body):
|
|
88
|
+
return self.request("POST", "/v1/jobs", body)
|
|
89
|
+
|
|
90
|
+
def get_job(self, job_id):
|
|
91
|
+
return self.request("GET", f"/v1/jobs/{quote(job_id, safe='')}")
|
|
92
|
+
|
|
93
|
+
def get_job_result(self, job_id):
|
|
94
|
+
return self.request("GET", f"/v1/jobs/{quote(job_id, safe='')}/result")
|
|
95
|
+
|
|
96
|
+
def list_jobs(self, **kwargs):
|
|
97
|
+
return self.request("GET", "/v1/jobs?" + urlencode({key: value for key, value in kwargs.items() if value is not None}))
|
|
98
|
+
|
|
99
|
+
def stop_job(self, job_id):
|
|
100
|
+
return self.request("POST", f"/v1/jobs/{quote(job_id, safe='')}/stop")
|
|
101
|
+
|
|
102
|
+
def submit_task(self, body):
|
|
103
|
+
return self.request("POST", "/v1/tasks", body)
|
|
104
|
+
|
|
105
|
+
def get_task_run(self, trial_id):
|
|
106
|
+
return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}")
|
|
107
|
+
|
|
108
|
+
def get_task_result(self, trial_id):
|
|
109
|
+
return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/result")
|
|
110
|
+
|
|
111
|
+
def get_task_snapshot(self, trial_id):
|
|
112
|
+
return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/snapshot")
|
|
113
|
+
|
|
114
|
+
def stop_task(self, trial_id):
|
|
115
|
+
return self.request("POST", f"/v1/trials/{quote(trial_id, safe='')}/stop")
|
el_cli/train.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Read independent Train Jobs, checkpoint metadata and estimated billing."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote, urlencode
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Train:
|
|
7
|
+
def __init__(self, service):
|
|
8
|
+
self._service = service
|
|
9
|
+
|
|
10
|
+
def jobs(self, *, status=None, cursor=None, limit=25):
|
|
11
|
+
return self._get('/v1/train/jobs', status=status, cursor=cursor, limit=limit)
|
|
12
|
+
|
|
13
|
+
def status(self, train_job_id):
|
|
14
|
+
return self._get('/v1/train/jobs/' + quote(train_job_id, safe=''))
|
|
15
|
+
|
|
16
|
+
def checkpoints(self, *, session_id=None, run_id=None, limit=25):
|
|
17
|
+
return self._get('/v1/train/checkpoints', session_id=session_id, run_id=run_id, limit=limit)
|
|
18
|
+
|
|
19
|
+
def billing(self, starting_on, ending_before):
|
|
20
|
+
return self._get('/v1/train/billing/usage', starting_on=starting_on, ending_before=ending_before)
|
|
21
|
+
|
|
22
|
+
def _get(self, path, **query):
|
|
23
|
+
query = {key: value for key, value in query.items() if value is not None}
|
|
24
|
+
return self._service.request('GET', path + ('?' + urlencode(query) if query else ''))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(groups):
|
|
28
|
+
commands = groups.add_parser('train', help='Read platform training resources').add_subparsers(dest='train_group', required=True)
|
|
29
|
+
jobs = commands.add_parser('job').add_subparsers(dest='action', required=True)
|
|
30
|
+
listing = jobs.add_parser('list')
|
|
31
|
+
listing.add_argument('--status')
|
|
32
|
+
listing.add_argument('--cursor')
|
|
33
|
+
listing.add_argument('--limit', type=int, default=25)
|
|
34
|
+
jobs.add_parser('status').add_argument('train_job_id')
|
|
35
|
+
checkpoints = commands.add_parser('checkpoint').add_subparsers(dest='action', required=True)
|
|
36
|
+
listing = checkpoints.add_parser('list')
|
|
37
|
+
listing.add_argument('--session-id')
|
|
38
|
+
listing.add_argument('--run-id')
|
|
39
|
+
listing.add_argument('--limit', type=int, default=25)
|
|
40
|
+
billing = commands.add_parser('billing').add_subparsers(dest='action', required=True)
|
|
41
|
+
usage = billing.add_parser('usage')
|
|
42
|
+
usage.add_argument('starting_on')
|
|
43
|
+
usage.add_argument('ending_before')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def execute(train, args):
|
|
47
|
+
if args.train_group == 'job':
|
|
48
|
+
if args.action == 'status':
|
|
49
|
+
return train.status(args.train_job_id)
|
|
50
|
+
return train.jobs(status=args.status, cursor=args.cursor, limit=args.limit)
|
|
51
|
+
if args.train_group == 'checkpoint':
|
|
52
|
+
return train.checkpoints(session_id=args.session_id, run_id=args.run_id, limit=args.limit)
|
|
53
|
+
return train.billing(args.starting_on, args.ending_before)
|
el_cli/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Project utilities."""
|
el_cli/utils/logging.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Named standard-library loggers; configure explicitly at application boundaries."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
NAMESPACE = 'envloop'
|
|
6
|
+
LOG_FORMAT = '%(asctime)s %(levelname)s %(name)s %(message)s'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_logger(name: str | None = None) -> logging.Logger:
|
|
10
|
+
if not name:
|
|
11
|
+
name = NAMESPACE
|
|
12
|
+
elif name != NAMESPACE and not name.startswith(NAMESPACE + "."):
|
|
13
|
+
name = NAMESPACE + "." + name
|
|
14
|
+
return logging.getLogger(name)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def configure_logging(level: str | int | None = None, *, console: bool = True) -> logging.Logger:
|
|
18
|
+
"""Configure this namespace; preserve host handlers and avoid duplicate output.
|
|
19
|
+
|
|
20
|
+
pytest supplies its own handlers, so its startup uses console=False.
|
|
21
|
+
"""
|
|
22
|
+
if level is None:
|
|
23
|
+
level = os.environ.get('ENVLOOP_LOG_LEVEL', "INFO")
|
|
24
|
+
if isinstance(level, str):
|
|
25
|
+
level = level.upper()
|
|
26
|
+
logger = get_logger()
|
|
27
|
+
logger.setLevel(level)
|
|
28
|
+
if console:
|
|
29
|
+
logging.basicConfig(level=level, format=LOG_FORMAT, datefmt="%H:%M:%S")
|
|
30
|
+
return logger
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: envloop-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Standalone el command-line client for EnvLoop
|
|
5
|
+
Project-URL: Repository, https://github.com/EnvLoop/envloop
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# envloop-cli
|
|
10
|
+
|
|
11
|
+
Independent Python 3.11+ project providing the existing `el` CLI, version 0.1.0.
|
|
12
|
+
It builds and installs without the envloop SDK checkout or any third-party runtime
|
|
13
|
+
dependencies. The distribution is `envloop-cli`; its private implementation package
|
|
14
|
+
is `el_cli`, so it does not overwrite the `envloop` SDK namespace.
|
|
15
|
+
|
|
16
|
+
After publication:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install envloop-cli==0.1.0
|
|
20
|
+
el --help
|
|
21
|
+
el train --help
|
|
22
|
+
el --api-url https://api.envloop.ai --workspace-id YOUR_WORKSPACE train job list
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Set `ENVLOOP_API_KEY` privately in your environment. Existing command names,
|
|
26
|
+
arguments, JSON output, error codes and `envloop/0.1` User-Agent are preserved.
|
|
27
|
+
Local Task/Profile commands still require a separately installed compatible
|
|
28
|
+
EnvPlatform, just as before; this package does not bundle an execution backend.
|
|
29
|
+
No Tinker SDK, GPU, Docker or EnvHub dependencies are needed for remote queries.
|
|
30
|
+
|
|
31
|
+
## Boundary and layout
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
el-cli/
|
|
35
|
+
pyproject.toml
|
|
36
|
+
src/el_cli/
|
|
37
|
+
__init__.py
|
|
38
|
+
cli.py
|
|
39
|
+
client.py
|
|
40
|
+
http.py
|
|
41
|
+
train.py
|
|
42
|
+
utils/{__init__,logging}.py
|
|
43
|
+
tests/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The actual source has `envloop/cli.py` and `envloop/train.py`, rather than the
|
|
47
|
+
`cli/commands/train.py` layout in the brief. `cli.py` imports Client and Train;
|
|
48
|
+
Client uses the logging helper and lazily loads HttpService and Train. All use
|
|
49
|
+
the standard library. Client also lazily imports EnvPlatform for local execution.
|
|
50
|
+
Retaining Client preserves existing task/job/profile behavior rather than shipping
|
|
51
|
+
a train-only CLI. These modules were extracted from the training-read CLI at
|
|
52
|
+
`b546830`, based on main `11a5803`. Shared SDK/transport fixes should be applied
|
|
53
|
+
to both packages while their copied implementations remain separate.
|
|
54
|
+
|
|
55
|
+
The SDK keeps its Python APIs and legacy `python -m envloop.cli` interface, but
|
|
56
|
+
no longer registers `el`; install this project for that executable.
|
|
57
|
+
|
|
58
|
+
## Build, verify and publish
|
|
59
|
+
|
|
60
|
+
From this directory:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv sync --locked
|
|
64
|
+
uv run --locked pytest -q --ignore=tests/test_jobs_http.py
|
|
65
|
+
uv run --locked ruff check .
|
|
66
|
+
uv build
|
|
67
|
+
python -m venv /tmp/el-cli-clean
|
|
68
|
+
/tmp/el-cli-clean/bin/python -m pip install dist/envloop_cli-0.1.0-py3-none-any.whl
|
|
69
|
+
/tmp/el-cli-clean/bin/el --help
|
|
70
|
+
/tmp/el-cli-clean/bin/el train --help
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The real JobMaster integration cases in `tests/test_jobs_http.py` also require
|
|
74
|
+
a separately installed EnvPlatform and its dependencies. Run the complete suite
|
|
75
|
+
with that backend installed; the other cases need only the dev group.
|
|
76
|
+
|
|
77
|
+
The workstation's Aliyun index is a download mirror, not an upload endpoint.
|
|
78
|
+
No existing private upload repository or publishing credentials were found on
|
|
79
|
+
2026-10-08. The intended public target is PyPI, upload endpoint
|
|
80
|
+
`https://upload.pypi.org/legacy/`. Release remains unpublished until an authorized
|
|
81
|
+
PyPI token (or configured Trusted Publisher) is available. Never put credentials
|
|
82
|
+
in this project. With a token supplied privately as `UV_PUBLISH_TOKEN`:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv publish --publish-url https://upload.pypi.org/legacy/ dist/*
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Building a wheel does not establish that the PyPI name is reserved or that a
|
|
89
|
+
release is available. Verify index installation after a successful upload.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
el_cli/__init__.py,sha256=tKNGG9G2weyFSMy_qv5r-J7bjxNHfL64DgxOqgMh4Qo,46
|
|
2
|
+
el_cli/cli.py,sha256=IsYeB4AM5Wh4H7iftjuQMs4GLIXhjqaMqVqhQyfgH8Q,12014
|
|
3
|
+
el_cli/client.py,sha256=SBs-S5cmSxmwnchuXK-7_U1YtWLJ4UPF8uOU3ENo-QM,12459
|
|
4
|
+
el_cli/http.py,sha256=lS6-j5AdbJWtljoVBhHdGIKSBRToK0DTxDpCTwrWQJo,5656
|
|
5
|
+
el_cli/train.py,sha256=_cf-zdyesEw7sb7GnS63cwc6SqojhtCyfnOiMslQp3Q,2392
|
|
6
|
+
el_cli/utils/__init__.py,sha256=huB3qPQuKNr3JxfH3Ys09YWFtHiDamCHFsBa4epANgQ,25
|
|
7
|
+
el_cli/utils/logging.py,sha256=rXN0qyxhfGEq4vWXxjwfaczPPZf_7atnOkkBTgNNRPc,1017
|
|
8
|
+
envloop_cli-0.1.0.dist-info/METADATA,sha256=3_qmML7FCwdkHSIynR5LAlkgpfMIFkHSwFyzsHAQ8tE,3376
|
|
9
|
+
envloop_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
envloop_cli-0.1.0.dist-info/entry_points.txt,sha256=Sbu0AsML0ujuZxVXKihrgpEchXgmiCpKrgiLzTlfg3s,39
|
|
11
|
+
envloop_cli-0.1.0.dist-info/RECORD,,
|