envloop-cli 1.23.0__tar.gz → 1.24.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/PKG-INFO +3 -3
  2. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/cli_errors.py +10 -2
  3. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/cli_output.py +19 -3
  4. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/client.py +2 -1
  5. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/http.py +7 -1
  6. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/navigation_resources.py +8 -1
  7. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/pyproject.toml +4 -4
  8. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/.gitignore +0 -0
  9. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/README.pypi.md +0 -0
  10. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/__init__.py +0 -0
  11. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/_optional.py +0 -0
  12. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/auth_cli.py +0 -0
  13. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/auth_login.py +0 -0
  14. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/auth_workspace.py +0 -0
  15. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/cli.py +0 -0
  16. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/config.py +0 -0
  17. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/downloads.py +0 -0
  18. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/lifecycle_cli.py +0 -0
  19. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/profile_secrets.py +0 -0
  20. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/resource_cli.py +0 -0
  21. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/resources.py +0 -0
  22. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/terminal.py +0 -0
  23. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/utils/__init__.py +0 -0
  24. {envloop_cli-1.23.0 → envloop_cli-1.24.0}/envloop/utils/logging.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: envloop-cli
3
- Version: 1.23.0
3
+ Version: 1.24.0
4
4
  Summary: EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference
5
5
  Project-URL: Homepage, https://envloop.ai
6
6
  Project-URL: Documentation, https://docs.envloop.ai
@@ -8,12 +8,12 @@ Project-URL: Repository, https://github.com/EnvLoop/envloop-cli
8
8
  Project-URL: Issues, https://github.com/EnvLoop/envloop-cli/issues
9
9
  Project-URL: Agent instructions, https://docs.envloop.ai/skill.md
10
10
  Requires-Python: >=3.12
11
- Requires-Dist: envloop-cli-experiment==1.23.0
11
+ Requires-Dist: envloop-cli-experiment==1.24.0
12
12
  Provides-Extra: data
13
13
  Requires-Dist: envloop-cli-data==1.23.0; extra == 'data'
14
14
  Provides-Extra: default
15
15
  Provides-Extra: experiment
16
- Requires-Dist: envloop-cli-experiment==1.23.0; extra == 'experiment'
16
+ Requires-Dist: envloop-cli-experiment==1.24.0; extra == 'experiment'
17
17
  Provides-Extra: rsi-loop
18
18
  Requires-Dist: envloop-cli-rsi-loop==1.23.0; extra == 'rsi-loop'
19
19
  Description-Content-Type: text/markdown
@@ -17,7 +17,7 @@ from collections.abc import Iterable
17
17
  from datetime import datetime
18
18
  from typing import Any, cast
19
19
 
20
- from .http import RemoteError
20
+ from .http import GATEWAY_TIMEOUT_STATUSES, RemoteError
21
21
 
22
22
  _MESSAGES: dict[str, str] = {}
23
23
 
@@ -54,6 +54,7 @@ _register("RATE_LIMITED RATE_LIMIT_EXCEEDED TOO_MANY_REQUESTS", "The API rate li
54
54
  _register("SERVICE_UNAVAILABLE INTERNAL_SERVER_ERROR EXECUTOR_UNAVAILABLE PROVIDER_UNAVAILABLE LOG_STORAGE_UNAVAILABLE", "The service is unavailable or encountered an internal error.")
55
55
  _register("INTERNAL_ERROR", "The command encountered an unexpected error.")
56
56
  _register("NETWORK_ERROR", "A connection to the API or download service could not be completed.")
57
+ _register("SOURCE_UNAVAILABLE", "The API gateway did not get a valid answer from the service, for example because it stopped waiting. A write may still have been processed: read the Job or resource before retrying it.")
57
58
  _register("REQUEST_TIMEOUT", "The API did not answer before the client timeout. The request may still have been processed: read the Job or resource before retrying a write.")
58
59
  _register("RESPONSE_INTERRUPTED", "The connection closed before the API finished answering; the service may have failed or restarted. The request may have been processed: read the Job or resource before retrying a write, and report the time if it repeats.")
59
60
  _register("INVALID_RESPONSE RESPONSE_TOO_LARGE INVALID_LOG_PAGE INVALID_PAGINATION TRAIN_JOB_CURSOR_REPEATED", "The service returned an invalid or unsupported response.")
@@ -114,6 +115,8 @@ _register("INVALID_RSI_REQUEST INVALID_STEER INVALID_DIRECTIVE", "The RSI servic
114
115
  _register("RSI_CONFLICT RSI_RUN_NOT_ACTIVE", "The run id exists, the run is not in a state that allows this action, or no loop machine is free.")
115
116
  _register("RSI_CONTROL_UNCONFIGURED RSI_CONTROL_UNAVAILABLE RSI_CONTROL_TIMEOUT", "The RSI loop service is not available through this platform right now.")
116
117
 
118
+ _register("UNSUPPORTED_ADAPTER_SOURCE", 'Register a training checkpoint with {"serving_profile": "NAME", "source": {"type": "train_checkpoint", "tinker_path": "envloop://..."}}. '
119
+ "List serving profiles with `el job serving-profile list` and checkpoint paths with `el checkpoint list`.")
117
120
  _register("JOB_ACTION_UNSUPPORTED", "This Job type does not support the requested action.")
118
121
  _register("INFERENCE_ROUTE_RETIRED", "The global inference route has been retired.")
119
122
 
@@ -209,6 +212,9 @@ def _http_message(status: int | None) -> str:
209
212
  return _MESSAGES["INVALID_REQUEST"]
210
213
  if status == 429:
211
214
  return _MESSAGES["RATE_LIMITED"]
215
+ if status in GATEWAY_TIMEOUT_STATUSES:
216
+ return ("The API gateway stopped waiting for the service. The request may still have been processed: "
217
+ "read the Job or resource before retrying a write.")
212
218
  if status is not None and status >= 500:
213
219
  return _MESSAGES["SERVICE_UNAVAILABLE"]
214
220
  return _MESSAGES["HTTP_ERROR"]
@@ -294,7 +300,9 @@ def format_error(error: BaseException) -> str:
294
300
  extra: dict[str, Any] = {"hint": remote.hint, "details": remote.details, "request_id": remote.request_id} if remote else {}
295
301
  if remote and remote.message:
296
302
  # Local text only adds guidance for codes the CLI documents.
297
- guidance = local if code in _MESSAGES and code != "HTTP_ERROR" and local != remote.message else None
303
+ # A gateway timeout always adds that the request may have been processed.
304
+ documented = (code in _MESSAGES and code != "HTTP_ERROR") or status in GATEWAY_TIMEOUT_STATUSES
305
+ guidance = local if documented and local != remote.message else None
298
306
  rendered = error_block(code, remote.message, status=status, guidance=guidance, **extra)
299
307
  else:
300
308
  rendered = error_block(code, local, status=status, **extra)
@@ -59,15 +59,31 @@ def next_page(args: argparse.Namespace, cursor: object, program: str) -> str:
59
59
  return "Next page: " + shlex.join(command)
60
60
 
61
61
 
62
+ # Empty-list text names the listed resource, not the command group (`job adapter list` lists adapters).
63
+ EMPTY_RESOURCES = {
64
+ ('job', 'adapter'): 'adapters', ('job', 'serving-profile'): 'serving profiles',
65
+ ('trail',): 'trails', ('trail', 'artifact'): 'artifacts',
66
+ ('dataset',): 'datasets', ('dataset', 'revision'): 'dataset revisions', ('dataset', 'task'): 'dataset tasks',
67
+ ('data',): 'resources', ('data', 'files'): 'files',
68
+ ('billing', 'usage'): 'usage records', ('billing', 'ledger'): 'ledger entries', ('billing', 'price'): 'prices',
69
+ ('member',): 'members', ('member', 'candidate'): 'member candidates',
70
+ ('setting', 'registry'): 'registry credentials',
71
+ }
72
+
73
+
74
+ def empty_message(args: argparse.Namespace) -> str:
75
+ path = getattr(args, '_command_path', [args.group, 'list'])
76
+ resource = EMPTY_RESOURCES.get(tuple(path[:-1] if path[-1:] == ['list'] else path))
77
+ return f'No {resource or args.group} found.'
78
+
79
+
62
80
  def generic_layout(items: list[dict[str, Any]], args: argparse.Namespace, layouts: dict[str, tuple[str, ...]]) -> tuple[tuple[str, ...], Callable[[dict[str, Any]], tuple[object, ...]], str]:
63
81
  fields = tuple(field for field in layouts.get(args.group, ()) if any(field in item for item in items))
64
82
  fields = fields or tuple(dict.fromkeys(key for item in items for key in item))[:6]
65
83
  headers = tuple(field.replace('_', ' ').upper() for field in fields)
66
84
  render: Callable[[dict[str, Any]], tuple[object, ...]] = lambda item: tuple(timestamp(item.get(field)) if field.endswith('_at') or field == 'at'
67
85
  else item.get(field) for field in fields)
68
- empty = ('No registry credentials found.' if args.group == 'setting'
69
- else f'No {getattr(args, "resource", args.group)} found.')
70
- return headers, render, empty
86
+ return headers, render, empty_message(args)
71
87
 
72
88
 
73
89
  def list_output(result: Any, args: argparse.Namespace, program: str) -> str:
@@ -33,9 +33,10 @@ class Client:
33
33
  raise RuntimeError(INTERACTIVE_TRAINING_REMOVED)
34
34
 
35
35
  @classmethod
36
- def remote(cls, api_url: str | None = None, *, timeout: float = 30, api_key: str | None = None, workspace_id: str | None = None, project_id: str | None = None, config: bool = True):
36
+ def remote(cls, api_url: str | None = None, *, timeout: float = 90, api_key: str | None = None, workspace_id: str | None = None, project_id: str | None = None, config: bool = True):
37
37
  from .http import HttpService
38
38
 
39
+ # The default matches http.DEFAULT_TIMEOUT_SEC (longer than the hosted proxy's 60 s wait).
39
40
  return cls(HttpService(api_url, timeout, api_key=api_key, workspace_id=workspace_id, project_id=project_id, config=config))
40
41
 
41
42
  @classmethod
@@ -63,6 +63,12 @@ def retry_after_seconds(value: str | None) -> float | None:
63
63
  return None
64
64
 
65
65
 
66
+ # The hosted API proxy waits up to 60 s for the platform. Wait longer by default so a slow answer, or the
67
+ # proxy's own gateway timeout, reaches the caller instead of a client-side timeout. Explicit values are kept.
68
+ DEFAULT_TIMEOUT_SEC = 90.0
69
+ # The proxy or edge stopped waiting while the platform was still working: the request may have been processed.
70
+ GATEWAY_TIMEOUT_STATUSES = frozenset({504, 524})
71
+
66
72
  # Transport failures that are not an HTTP answer. A request that was sent and then timed out or lost its
67
73
  # response may still have been processed, unlike one that never connected.
68
74
  TRANSIENT_CODES = frozenset({"NETWORK_ERROR", "REQUEST_TIMEOUT", "RESPONSE_INTERRUPTED"})
@@ -213,7 +219,7 @@ def encode_json(body: Any) -> bytes:
213
219
 
214
220
 
215
221
  class HttpService:
216
- def __init__(self, base_url: str | None = None, timeout: float = 30, *, api_key: str | None = None, workspace_id: str | None = None, project_id: str | None = None, config: bool = True, environment: bool = True):
222
+ def __init__(self, base_url: str | None = None, timeout: float = DEFAULT_TIMEOUT_SEC, *, api_key: str | None = None, workspace_id: str | None = None, project_id: str | None = None, config: bool = True, environment: bool = True):
217
223
  from .config import require_remote, resolve_connection
218
224
 
219
225
  connection = resolve_connection(base_url, api_key, workspace_id, project_id, config=config, environment=environment)
@@ -33,7 +33,14 @@ class Billing:
33
33
  start, end = billing_timestamp(start), billing_timestamp(end)
34
34
  if datetime.fromisoformat(end) <= datetime.fromisoformat(start):
35
35
  raise ValueError('billing end must be after start')
36
- return self.transport.request('GET', query('/v1/billing/usage', start=start, end=end, group_by=group_by))
36
+ # Workspace usage needs a human billing grant. An API key reads its own project's usage on the
37
+ # project route, which takes the same window and grouping.
38
+ identity = self.transport.request('GET', '/v1/me')
39
+ identity = identity if isinstance(identity, dict) else {}
40
+ project = identity.get('project_id') or (identity.get('project') or {}).get('project_id')
41
+ path = (f'/v1/projects/{quote(project, safe="")}/billing/usage'
42
+ if identity.get('auth_method') == 'api_key' and isinstance(project, str) and project else '/v1/billing/usage')
43
+ return self.transport.request('GET', query(path, start=start, end=end, group_by=group_by))
37
44
 
38
45
  def ledger(self, *, limit: int = 25):
39
46
  return self.transport.request('GET', query('/v1/billing/ledger', limit=limit))
@@ -1,15 +1,15 @@
1
1
  [project]
2
2
  name = "envloop-cli"
3
- version = "1.23.0"
3
+ version = "1.24.0"
4
4
  description = "EnvLoop CLI and Python SDK for jobs, training, checkpoints and inference"
5
5
  requires-python = ">=3.12"
6
6
  readme = "README.pypi.md"
7
- dependencies = ["envloop-cli-experiment==1.23.0"]
7
+ dependencies = ["envloop-cli-experiment==1.24.0"]
8
8
 
9
9
  [project.optional-dependencies]
10
10
  default = []
11
11
  data = ["envloop-cli-data==1.23.0"]
12
- experiment = ["envloop-cli-experiment==1.23.0"]
12
+ experiment = ["envloop-cli-experiment==1.24.0"]
13
13
  rsi-loop = ["envloop-cli-rsi-loop==1.23.0"]
14
14
 
15
15
  [project.scripts]
@@ -24,7 +24,7 @@ Issues = "https://github.com/EnvLoop/envloop-cli/issues"
24
24
  "Agent instructions" = "https://docs.envloop.ai/skill.md"
25
25
 
26
26
  [dependency-groups]
27
- dev = ["ruff==0.16.10", "pre-commit>=4.6,<5", "pytest>=8", "envloop-cli-data==1.23.0", "envloop-cli-experiment==1.23.0", "envloop-cli-rsi-loop==1.23.0", "pyrefly==1.4.0"]
27
+ dev = ["ruff==0.16.10", "pre-commit>=4.6,<5", "pytest>=8", "envloop-cli-data==1.23.0", "envloop-cli-experiment==1.24.0", "envloop-cli-rsi-loop==1.23.0", "pyrefly==1.4.0"]
28
28
 
29
29
  [build-system]
30
30
  requires = ["hatchling"]
File without changes