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 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)
@@ -0,0 +1 @@
1
+ """Project utilities."""
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ el = el_cli.cli:main