envloop-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ dist/
5
+ /logs/
@@ -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,81 @@
1
+ # envloop-cli
2
+
3
+ Independent Python 3.11+ project providing the existing `el` CLI, version 0.1.0.
4
+ It builds and installs without the envloop SDK checkout or any third-party runtime
5
+ dependencies. The distribution is `envloop-cli`; its private implementation package
6
+ is `el_cli`, so it does not overwrite the `envloop` SDK namespace.
7
+
8
+ After publication:
9
+
10
+ ```bash
11
+ pip install envloop-cli==0.1.0
12
+ el --help
13
+ el train --help
14
+ el --api-url https://api.envloop.ai --workspace-id YOUR_WORKSPACE train job list
15
+ ```
16
+
17
+ Set `ENVLOOP_API_KEY` privately in your environment. Existing command names,
18
+ arguments, JSON output, error codes and `envloop/0.1` User-Agent are preserved.
19
+ Local Task/Profile commands still require a separately installed compatible
20
+ EnvPlatform, just as before; this package does not bundle an execution backend.
21
+ No Tinker SDK, GPU, Docker or EnvHub dependencies are needed for remote queries.
22
+
23
+ ## Boundary and layout
24
+
25
+ ```text
26
+ el-cli/
27
+ pyproject.toml
28
+ src/el_cli/
29
+ __init__.py
30
+ cli.py
31
+ client.py
32
+ http.py
33
+ train.py
34
+ utils/{__init__,logging}.py
35
+ tests/
36
+ ```
37
+
38
+ The actual source has `envloop/cli.py` and `envloop/train.py`, rather than the
39
+ `cli/commands/train.py` layout in the brief. `cli.py` imports Client and Train;
40
+ Client uses the logging helper and lazily loads HttpService and Train. All use
41
+ the standard library. Client also lazily imports EnvPlatform for local execution.
42
+ Retaining Client preserves existing task/job/profile behavior rather than shipping
43
+ a train-only CLI. These modules were extracted from the training-read CLI at
44
+ `b546830`, based on main `11a5803`. Shared SDK/transport fixes should be applied
45
+ to both packages while their copied implementations remain separate.
46
+
47
+ The SDK keeps its Python APIs and legacy `python -m envloop.cli` interface, but
48
+ no longer registers `el`; install this project for that executable.
49
+
50
+ ## Build, verify and publish
51
+
52
+ From this directory:
53
+
54
+ ```bash
55
+ uv sync --locked
56
+ uv run --locked pytest -q --ignore=tests/test_jobs_http.py
57
+ uv run --locked ruff check .
58
+ uv build
59
+ python -m venv /tmp/el-cli-clean
60
+ /tmp/el-cli-clean/bin/python -m pip install dist/envloop_cli-0.1.0-py3-none-any.whl
61
+ /tmp/el-cli-clean/bin/el --help
62
+ /tmp/el-cli-clean/bin/el train --help
63
+ ```
64
+
65
+ The real JobMaster integration cases in `tests/test_jobs_http.py` also require
66
+ a separately installed EnvPlatform and its dependencies. Run the complete suite
67
+ with that backend installed; the other cases need only the dev group.
68
+
69
+ The workstation's Aliyun index is a download mirror, not an upload endpoint.
70
+ No existing private upload repository or publishing credentials were found on
71
+ 2026-10-08. The intended public target is PyPI, upload endpoint
72
+ `https://upload.pypi.org/legacy/`. Release remains unpublished until an authorized
73
+ PyPI token (or configured Trusted Publisher) is available. Never put credentials
74
+ in this project. With a token supplied privately as `UV_PUBLISH_TOKEN`:
75
+
76
+ ```bash
77
+ uv publish --publish-url https://upload.pypi.org/legacy/ dist/*
78
+ ```
79
+
80
+ Building a wheel does not establish that the PyPI name is reserved or that a
81
+ release is available. Verify index installation after a successful upload.
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "envloop-cli"
3
+ version = "0.1.0"
4
+ description = "Standalone el command-line client for EnvLoop"
5
+ requires-python = ">=3.11"
6
+ readme = "README.md"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ el = "el_cli.cli:main"
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/EnvLoop/envloop"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/el_cli"]
21
+
22
+ [tool.hatch.build.targets.sdist]
23
+ include = ["src", "tests", "README.md", "pyproject.toml"]
24
+
25
+ [dependency-groups]
26
+ dev = ["pytest>=8", "ruff==0.16.10"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+
31
+ [tool.ruff]
32
+ target-version = "py311"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["I"]
@@ -0,0 +1 @@
1
+ """Standalone EnvLoop command-line client."""
@@ -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())