envloop-cli 1.16.0__py3-none-any.whl → 1.23.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.
- envloop/__init__.py +11 -4
- envloop/_optional.py +185 -0
- envloop/auth_cli.py +16 -16
- envloop/auth_login.py +12 -8
- envloop/auth_workspace.py +17 -15
- envloop/cli.py +104 -463
- envloop/cli_errors.py +98 -20
- envloop/cli_output.py +50 -183
- envloop/client.py +28 -654
- envloop/config.py +74 -40
- envloop/downloads.py +71 -0
- envloop/http.py +139 -181
- envloop/lifecycle_cli.py +5 -3
- envloop/navigation_resources.py +59 -266
- envloop/profile_secrets.py +2 -1
- envloop/resource_cli.py +104 -90
- envloop/resources.py +12 -149
- envloop/terminal.py +6 -6
- {envloop_cli-1.16.0.dist-info → envloop_cli-1.23.0.dist-info}/METADATA +64 -12
- envloop_cli-1.23.0.dist-info/RECORD +24 -0
- envloop/agent_runs.py +0 -248
- envloop/decision.py +0 -19
- envloop/inference.py +0 -35
- envloop/job_logs.py +0 -20
- envloop/job_spec_cli.py +0 -138
- envloop/profile_updates.py +0 -76
- envloop/rsi_cli.py +0 -60
- envloop/sandbox.py +0 -27
- envloop/train.py +0 -96
- envloop/train_log_cli.py +0 -128
- envloop/train_progress.py +0 -51
- envloop/train_runner_cli.py +0 -64
- envloop/training/__init__.py +0 -4
- envloop_cli-1.16.0.dist-info/RECORD +0 -35
- {envloop_cli-1.16.0.dist-info → envloop_cli-1.23.0.dist-info}/WHEEL +0 -0
- {envloop_cli-1.16.0.dist-info → envloop_cli-1.23.0.dist-info}/entry_points.txt +0 -0
envloop/__init__.py
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
"""EnvLoop Python client."""
|
|
2
|
-
|
|
1
|
+
"""EnvLoop Python client, with optional functionality loaded on demand."""
|
|
2
|
+
from ._optional import install_legacy_imports
|
|
3
3
|
from .client import Client
|
|
4
|
-
from .decision import DecisionClient
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
install_legacy_imports()
|
|
6
|
+
__all__ = ['Client', 'DecisionClient']
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def __getattr__(name: str):
|
|
10
|
+
if name == 'DecisionClient':
|
|
11
|
+
from ._optional import feature_attribute
|
|
12
|
+
return feature_attribute('experiment', 'decision', name)
|
|
13
|
+
raise AttributeError(name)
|
envloop/_optional.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Lazy, static adapters for independently installed client features.
|
|
2
|
+
|
|
3
|
+
No feature code is imported when the base client is imported or constructed.
|
|
4
|
+
Legacy module names resolve to their single feature owner on explicit import.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.abc
|
|
9
|
+
import importlib.util
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from importlib.machinery import ModuleSpec
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
from typing import Any, override
|
|
15
|
+
|
|
16
|
+
FEATURES = {'data': 'envloop_data', 'experiment': 'envloop_experiment', 'rsi-loop': 'envloop_rsi_loop'}
|
|
17
|
+
FEATURE_COMMANDS = {
|
|
18
|
+
'data': {
|
|
19
|
+
'dataset': 'Discover datasets, revisions and tasks',
|
|
20
|
+
'data': 'Manage versioned Data resources and verified file downloads',
|
|
21
|
+
'runner': 'Discover available Runner revisions',
|
|
22
|
+
'checkpoint': 'List and download saved training checkpoints',
|
|
23
|
+
'profile': 'Manage platform runtime profiles',
|
|
24
|
+
},
|
|
25
|
+
'experiment': {
|
|
26
|
+
'job': 'Submit and manage Agent, Sandbox, Train and Inference Jobs',
|
|
27
|
+
'trail': 'Inspect and control existing trials',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
MODULES: dict[str, str] = {
|
|
32
|
+
**dict.fromkeys('data data_publications data_upload data_cli profile_updates recipes'.split(), 'data'),
|
|
33
|
+
**dict.fromkeys('train train_progress train_runner_cli train_log_cli job_logs job_cost inference decision sandbox runtime_params job_spec_cli training'.split(), 'experiment'),
|
|
34
|
+
**dict.fromkeys('agent_runs rsi_cli'.split(), 'rsi-loop'),
|
|
35
|
+
}
|
|
36
|
+
CLIENTS = {
|
|
37
|
+
**{name: ('data', 'client') for name in ('ProfileService', 'RunnerService', 'Profiles', 'Runners')},
|
|
38
|
+
**{name: ('experiment', 'client') for name in ('TaskService', 'RpcService', 'load_task', 'validate_command', 'dataset_selector', 'Tasks', 'runner_selector', 'job_budget', 'Jobs')},
|
|
39
|
+
**{name: ('base', 'downloads') for name in ('ArtifactError', 'artifact_path')},
|
|
40
|
+
}
|
|
41
|
+
DATA_ENDPOINTS = set('open_download resolve_dataset list_runners profile_path put_profile list_profiles get_profile resolve_profile set_default_profile archive_profile revoke_profile_secret export_profile import_profile open_checkpoint_archive'.split())
|
|
42
|
+
EXPERIMENT_ENDPOINTS = set('submit_job get_job get_job_result get_job_logs list_jobs job_action job_events stop_job list_trials get_task_run get_trial_detail get_trial_training list_trial_metrics list_trial_checkpoints get_task_result get_task_snapshot stop_task list_trial_artifacts open_trial_artifact'.split())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MissingFeatureError(ImportError):
|
|
46
|
+
"""Static installation instructions, without reflecting user input."""
|
|
47
|
+
code = 'FEATURE_NOT_INSTALLED'
|
|
48
|
+
|
|
49
|
+
def __init__(self, feature: str):
|
|
50
|
+
self.feature = feature
|
|
51
|
+
super().__init__(f'Install optional functionality with: pip install "envloop-cli[{feature}]"')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CommandExit(Exception):
|
|
55
|
+
def __init__(self, status: int):
|
|
56
|
+
self.status = status
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def available(feature: str):
|
|
60
|
+
return importlib.util.find_spec(FEATURES[feature]) is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def feature_module(feature: str, module: str):
|
|
64
|
+
if not available(feature):
|
|
65
|
+
raise MissingFeatureError(feature)
|
|
66
|
+
return importlib.import_module(f'{FEATURES[feature]}.{module}')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def feature_attribute(feature: str, module: str, name: str):
|
|
70
|
+
if name.startswith('__'):
|
|
71
|
+
raise AttributeError(name)
|
|
72
|
+
return getattr(feature_module(feature, module), name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def client_attribute(name: str):
|
|
76
|
+
if name not in CLIENTS:
|
|
77
|
+
raise AttributeError(name)
|
|
78
|
+
feature, module = CLIENTS[name]
|
|
79
|
+
if feature == 'base':
|
|
80
|
+
return getattr(importlib.import_module(f'envloop.{module}'), name)
|
|
81
|
+
return feature_attribute(feature, module, name)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def endpoint(name: str):
|
|
85
|
+
if name in DATA_ENDPOINTS:
|
|
86
|
+
feature = 'data'
|
|
87
|
+
elif name in EXPERIMENT_ENDPOINTS:
|
|
88
|
+
feature = 'experiment'
|
|
89
|
+
else:
|
|
90
|
+
raise AttributeError(name)
|
|
91
|
+
# Inspect the class dictionary: static methods retain their descriptor contract.
|
|
92
|
+
return feature_module(feature, 'http').Endpoints.__dict__[name]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def navigation(name: str):
|
|
96
|
+
if name in {'Datasets', 'Checkpoints', '_checkpoint_matches', '_checkpoint_target', '_verified_checkpoint_file'}:
|
|
97
|
+
return feature_attribute('data', 'navigation_resources', name)
|
|
98
|
+
if name in {'Inference', 'InferenceResource'}:
|
|
99
|
+
return feature_attribute('experiment', 'navigation_resources', name)
|
|
100
|
+
raise AttributeError(name)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def client_resource(client: Any, name: str):
|
|
104
|
+
server = client._transport
|
|
105
|
+
if name in {'billing', 'project', 'operations'}:
|
|
106
|
+
module, cls = ('resources', 'Operations') if name == 'operations' else ('navigation_resources', name.capitalize())
|
|
107
|
+
return getattr(importlib.import_module(f'envloop.{module}'), cls)(server)
|
|
108
|
+
if name == 'dashboard':
|
|
109
|
+
if available('experiment'):
|
|
110
|
+
return feature_attribute('experiment', 'navigation_resources', 'Dashboard')(client.project, client.jobs, client.train)
|
|
111
|
+
from .navigation_resources import Dashboard
|
|
112
|
+
return Dashboard(client.project)
|
|
113
|
+
if name in {'profiles', 'runners'}:
|
|
114
|
+
return feature_attribute('data', 'client', name.capitalize())(server)
|
|
115
|
+
if name in {'data', 'recipes'}:
|
|
116
|
+
return feature_attribute('data', name, name.capitalize())(server)
|
|
117
|
+
if name == 'datasets':
|
|
118
|
+
return navigation('Datasets')(server)
|
|
119
|
+
if name == 'checkpoints':
|
|
120
|
+
return feature_attribute('data', 'navigation_resources', 'Checkpoints')(server)
|
|
121
|
+
if name == 'jobs':
|
|
122
|
+
return feature_attribute('experiment', 'client', 'Jobs')(server)
|
|
123
|
+
if name == 'trials':
|
|
124
|
+
return client.tasks
|
|
125
|
+
if name == 'tasks':
|
|
126
|
+
return feature_attribute('experiment', 'client', 'Tasks')(server)
|
|
127
|
+
if name == 'training_jobs':
|
|
128
|
+
return client.train
|
|
129
|
+
if name == 'train':
|
|
130
|
+
return feature_attribute('experiment', 'train', 'TrainJobs')(server)
|
|
131
|
+
if name == 'decision':
|
|
132
|
+
return feature_attribute('experiment', 'decision', 'DecisionClient')(server)
|
|
133
|
+
if name == 'agent_runs':
|
|
134
|
+
return feature_attribute('rsi-loop', 'agent_runs', 'AgentRuns')(client.train)
|
|
135
|
+
if name in {'endpoints', 'adapters'}:
|
|
136
|
+
return feature_attribute('experiment', 'resources', name.capitalize())(server)
|
|
137
|
+
if name == 'inference':
|
|
138
|
+
return navigation('Inference')(server)
|
|
139
|
+
raise AttributeError(name)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def execute_command(args: argparse.Namespace, client: Any, cli: Any):
|
|
143
|
+
if args.group in {'job', 'trail'}:
|
|
144
|
+
return getattr(feature_module('experiment', 'cli'), 'execute_' + args.group)(args, client, cli)
|
|
145
|
+
if args.group == 'profile':
|
|
146
|
+
return feature_module('data', 'cli').execute_profile(args, client, cli)
|
|
147
|
+
if args.group in {'data', 'dataset', 'runner', 'checkpoint'}:
|
|
148
|
+
return feature_module('data', 'resource_cli').execute(args, client)
|
|
149
|
+
from .resource_cli import execute
|
|
150
|
+
return execute(args, client)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _LegacyFeatureLoader(importlib.abc.Loader):
|
|
154
|
+
def __init__(self, feature: str, module: str):
|
|
155
|
+
self.feature, self.module = feature, module
|
|
156
|
+
|
|
157
|
+
@override
|
|
158
|
+
def create_module(self, spec: ModuleSpec):
|
|
159
|
+
module = feature_module(self.feature, self.module)
|
|
160
|
+
self.spec = module.__spec__
|
|
161
|
+
return module
|
|
162
|
+
|
|
163
|
+
@override
|
|
164
|
+
def exec_module(self, module: ModuleType):
|
|
165
|
+
# importlib rebinds __spec__ to the legacy alias; restore the feature module's own
|
|
166
|
+
# spec so its relative imports keep resolving inside the feature package.
|
|
167
|
+
module.__spec__ = self.spec
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class _LegacyFeatureFinder(importlib.abc.MetaPathFinder):
|
|
171
|
+
@override
|
|
172
|
+
def find_spec(self, fullname: str, path: Sequence[str] | None = None, target: ModuleType | None = None):
|
|
173
|
+
if not fullname.startswith('envloop.'):
|
|
174
|
+
return None
|
|
175
|
+
module = fullname.removeprefix('envloop.')
|
|
176
|
+
feature = MODULES.get(module)
|
|
177
|
+
if feature is None:
|
|
178
|
+
return None
|
|
179
|
+
loader = _LegacyFeatureLoader(feature, module)
|
|
180
|
+
return importlib.util.spec_from_loader(fullname, loader, is_package=module == 'training')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def install_legacy_imports():
|
|
184
|
+
if not any(isinstance(finder, _LegacyFeatureFinder) for finder in sys.meta_path):
|
|
185
|
+
sys.meta_path.append(_LegacyFeatureFinder())
|
envloop/auth_cli.py
CHANGED
|
@@ -3,13 +3,14 @@ import argparse
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
from typing import Any, cast
|
|
6
7
|
|
|
7
8
|
from .config import ConfigError, ConfigStore, normalize_url, validate_key
|
|
8
9
|
|
|
9
10
|
DEFAULT_BASE_URL = "https://api.envloop.ai"
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def add_auth_commands(groups):
|
|
13
|
+
def add_auth_commands(groups: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
13
14
|
auth = groups.add_parser("auth", help="Configure credentials, inspect status, or remove a saved key")
|
|
14
15
|
commands = auth.add_subparsers(dest="auth_command", required=True)
|
|
15
16
|
login = commands.add_parser("login", help="Open the Clerk sign-in page and authorize CLI/SDK access")
|
|
@@ -47,7 +48,7 @@ def add_auth_commands(groups):
|
|
|
47
48
|
workspace.add_argument("--stdin", action="store_true", help="Read the selected workspace's API key from stdin")
|
|
48
49
|
|
|
49
50
|
|
|
50
|
-
def _key_from_input(stdin=False):
|
|
51
|
+
def _key_from_input(stdin: bool = False) -> str:
|
|
51
52
|
if stdin:
|
|
52
53
|
value = sys.stdin.read(515)
|
|
53
54
|
if value.endswith("\r\n"):
|
|
@@ -72,7 +73,7 @@ def _masked_key():
|
|
|
72
73
|
masked[3] &= ~(termios.ECHO | termios.ICANON | termios.ISIG | termios.IEXTEN)
|
|
73
74
|
masked[6][termios.VMIN] = 1
|
|
74
75
|
masked[6][termios.VTIME] = 0
|
|
75
|
-
characters = []
|
|
76
|
+
characters: list[str] = []
|
|
76
77
|
try:
|
|
77
78
|
termios.tcsetattr(descriptor, termios.TCSAFLUSH, masked)
|
|
78
79
|
sys.stderr.write("API key: ")
|
|
@@ -102,7 +103,7 @@ def _masked_key():
|
|
|
102
103
|
sys.stderr.flush()
|
|
103
104
|
|
|
104
105
|
|
|
105
|
-
def _report(args, store):
|
|
106
|
+
def _report(args: argparse.Namespace, store: ConfigStore):
|
|
106
107
|
saved, configured = store.inspect()
|
|
107
108
|
from .cli import resolve_cli_connection
|
|
108
109
|
|
|
@@ -116,7 +117,7 @@ def _report(args, store):
|
|
|
116
117
|
}, connection
|
|
117
118
|
|
|
118
119
|
|
|
119
|
-
def _verify_key(url, key, workspace_id=None, project_id=None):
|
|
120
|
+
def _verify_key(url: str, key: str, workspace_id: str | None = None, project_id: str | None = None):
|
|
120
121
|
from .http import HttpService, RemoteError
|
|
121
122
|
|
|
122
123
|
validate_key(key)
|
|
@@ -131,10 +132,10 @@ def _verify_key(url, key, workspace_id=None, project_id=None):
|
|
|
131
132
|
environment=False).request("GET", "/v1/me")
|
|
132
133
|
except RemoteError as error:
|
|
133
134
|
code = "EDGE_REJECTED" if error.code == "EDGE_REJECTED" else {
|
|
134
|
-
401: "AUTHENTICATION_REJECTED", 403: "AUTHORIZATION_DENIED"}.get(error.status_code)
|
|
135
|
+
401: "AUTHENTICATION_REJECTED", 403: "AUTHORIZATION_DENIED"}.get(cast(int, error.status_code))
|
|
135
136
|
if code is None:
|
|
136
137
|
code = error.code if error.status_code is None and error.code in {
|
|
137
|
-
"NETWORK_ERROR", "INVALID_RESPONSE", "RESPONSE_TOO_LARGE",
|
|
138
|
+
"NETWORK_ERROR", "REQUEST_TIMEOUT", "RESPONSE_INTERRUPTED", "INVALID_RESPONSE", "RESPONSE_TOO_LARGE",
|
|
138
139
|
} else "HTTP_ERROR"
|
|
139
140
|
raise ConfigError(code) from None
|
|
140
141
|
if not isinstance(response, dict) or response.get("auth_method") != "api_key":
|
|
@@ -145,7 +146,7 @@ def _verify_key(url, key, workspace_id=None, project_id=None):
|
|
|
145
146
|
return _scope(response, project=project_id)
|
|
146
147
|
|
|
147
148
|
|
|
148
|
-
def _print(report, as_json=False):
|
|
149
|
+
def _print(report: dict[str, Any], as_json: bool = False) -> None:
|
|
149
150
|
if as_json:
|
|
150
151
|
print(json.dumps(report, ensure_ascii=False))
|
|
151
152
|
return
|
|
@@ -160,7 +161,7 @@ def _print(report, as_json=False):
|
|
|
160
161
|
print(f"Check: {report['check']}")
|
|
161
162
|
|
|
162
163
|
|
|
163
|
-
def run_auth(args, parser):
|
|
164
|
+
def run_auth(args: argparse.Namespace, parser: argparse.ArgumentParser):
|
|
164
165
|
store = ConfigStore()
|
|
165
166
|
command = args.auth_command
|
|
166
167
|
if command == "login":
|
|
@@ -198,20 +199,19 @@ def run_auth(args, parser):
|
|
|
198
199
|
raise ConfigError("API_URL_REQUIRED")
|
|
199
200
|
if not no_check:
|
|
200
201
|
scope = _verify_key(url, value, saved.get("workspace_id"), saved.get("project_id"))
|
|
202
|
+
if scope is not None:
|
|
203
|
+
store.configure(url, value, expected_saved=saved, expected_credential=credential, scope=scope)
|
|
204
|
+
else:
|
|
205
|
+
store.update(args.field, value, expected_url=url, expected_saved=saved, expected_credential=credential)
|
|
201
206
|
elif args.field == "api-url":
|
|
202
207
|
if args.stdin:
|
|
203
208
|
parser.error("this field does not support --stdin")
|
|
204
209
|
value = args.value if args.value is not None else DEFAULT_BASE_URL
|
|
210
|
+
store.update(args.field, value)
|
|
205
211
|
else:
|
|
206
212
|
if args.value is None or args.stdin:
|
|
207
213
|
parser.error("this field requires a value and does not support --stdin")
|
|
208
214
|
value = args.value
|
|
209
|
-
if args.field == "api-key":
|
|
210
|
-
if scope is not None:
|
|
211
|
-
store.configure(url, value, expected_saved=saved, expected_credential=credential, scope=scope)
|
|
212
|
-
else:
|
|
213
|
-
store.update(args.field, value, expected_url=url, expected_saved=saved, expected_credential=credential)
|
|
214
|
-
else:
|
|
215
215
|
store.update(args.field, value)
|
|
216
216
|
if args.field == "api-key":
|
|
217
217
|
print("Saved. API key check: " + ("not_checked (offline)" if no_check else "authentication_confirmed"))
|
|
@@ -282,7 +282,7 @@ def run_auth(args, parser):
|
|
|
282
282
|
|
|
283
283
|
report["check"] = "edge_rejected" if getattr(error, "code", None) == "EDGE_REJECTED" else {
|
|
284
284
|
401: "authentication_rejected", 403: "authorization_denied"}.get(
|
|
285
|
-
getattr(error, "status_code", None), safe_error_code(error))
|
|
285
|
+
cast(int, getattr(error, "status_code", None)), safe_error_code(error))
|
|
286
286
|
_print(report, args.json)
|
|
287
287
|
print(format_error(error), file=sys.stderr)
|
|
288
288
|
return 1
|
envloop/auth_login.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Browser authorization using a CLI-owned key and bounded anonymous polling."""
|
|
2
|
+
import argparse
|
|
2
3
|
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import secrets
|
|
5
6
|
import time
|
|
6
7
|
import webbrowser
|
|
7
8
|
from http.client import InvalidURL
|
|
9
|
+
from typing import Any, cast
|
|
8
10
|
from urllib.error import HTTPError, URLError
|
|
9
11
|
from urllib.parse import parse_qs, urlsplit
|
|
10
12
|
from urllib.request import Request
|
|
@@ -14,7 +16,7 @@ from .config import ConfigError, normalize_url
|
|
|
14
16
|
from .http import build_client_opener, error_fields
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
def _request(url, action, body, timeout):
|
|
19
|
+
def _request(url: str, action: str, body: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
18
20
|
request = Request(url + '/v1/auth/cli/' + action, data=json.dumps(body).encode(),
|
|
19
21
|
headers={'Content-Type': 'application/json'}, method='POST')
|
|
20
22
|
try:
|
|
@@ -34,11 +36,11 @@ def _request(url, action, body, timeout):
|
|
|
34
36
|
raise ConfigError(code) from None
|
|
35
37
|
except (URLError, TimeoutError, OSError, InvalidURL):
|
|
36
38
|
raise ConfigError('NETWORK_ERROR') from None
|
|
37
|
-
except
|
|
39
|
+
except ValueError:
|
|
38
40
|
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE') from None
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
def _verification_url(url, response):
|
|
43
|
+
def _verification_url(url: str, response: dict[str, Any]) -> tuple[str, str]:
|
|
42
44
|
import re
|
|
43
45
|
|
|
44
46
|
identifier = response.get('grant_id')
|
|
@@ -50,17 +52,19 @@ def _verification_url(url, response):
|
|
|
50
52
|
try:
|
|
51
53
|
page, api = urlsplit(uri), urlsplit(url)
|
|
52
54
|
normalized = normalize_url(f'{page.scheme}://{page.netloc}')
|
|
53
|
-
|
|
55
|
+
# api.<domain> serves its sign-in page from console.<domain>; other addresses serve it themselves.
|
|
56
|
+
console = 'console.' + api.netloc.removeprefix('api.') if api.netloc.startswith('api.') else api.netloc
|
|
57
|
+
expected = f'{api.scheme}://{console}'
|
|
54
58
|
if (f'{page.scheme}://{page.netloc}' != expected or page.path != '/sign-in' or page.fragment or
|
|
55
59
|
parse_qs(page.query, strict_parsing=True) != {'cli_login': [identifier]} or
|
|
56
60
|
normalized != expected):
|
|
57
61
|
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
|
58
|
-
except
|
|
62
|
+
except ValueError:
|
|
59
63
|
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE') from None
|
|
60
64
|
return identifier, uri
|
|
61
65
|
|
|
62
66
|
|
|
63
|
-
def login(args, store):
|
|
67
|
+
def login(args: argparse.Namespace, store: Any) -> int:
|
|
64
68
|
from .auth_cli import DEFAULT_BASE_URL
|
|
65
69
|
from .cli import resolve_cli_connection
|
|
66
70
|
|
|
@@ -69,7 +73,7 @@ def login(args, store):
|
|
|
69
73
|
deadline = time.monotonic() + args.timeout
|
|
70
74
|
key, poll_token = 'epk_' + secrets.token_hex(32), secrets.token_urlsafe(32)
|
|
71
75
|
code = ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(8))
|
|
72
|
-
def digest(value):
|
|
76
|
+
def digest(value: str) -> str:
|
|
73
77
|
return hashlib.sha256(value.encode()).hexdigest()
|
|
74
78
|
response = _request(url, 'start', {'key_hash': digest(key), 'poll_hash': digest(poll_token),
|
|
75
79
|
'code_hash': digest(code)}, min(5, args.timeout))
|
|
@@ -106,7 +110,7 @@ def login(args, store):
|
|
|
106
110
|
continue
|
|
107
111
|
if result.get('status') == 'approved':
|
|
108
112
|
# Verify the exact scope before committing the saved connection.
|
|
109
|
-
scope = _identity(url, key, result.get('workspace_id'), result.get('project_id'), details=True)
|
|
113
|
+
scope = cast(dict[str, Any], _identity(url, key, result.get('workspace_id'), result.get('project_id'), details=True))
|
|
110
114
|
if (not result.get('workspace_id') or not result.get('project_id') or
|
|
111
115
|
scope.get('project_id') != result['project_id']):
|
|
112
116
|
raise ConfigError('AUTH_LOGIN_INVALID_RESPONSE')
|
envloop/auth_workspace.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Verified workspace selection with scoped credentials and a terminal picker."""
|
|
2
|
+
import argparse
|
|
2
3
|
import os
|
|
3
4
|
import select
|
|
4
5
|
import sys
|
|
6
|
+
from typing import Any, cast
|
|
5
7
|
|
|
6
|
-
from .config import ConfigError, validate_project, validate_workspace
|
|
8
|
+
from .config import ConfigError, ConfigStore, validate_project, validate_workspace
|
|
7
9
|
from .http import HttpService, RemoteError
|
|
8
10
|
|
|
9
11
|
|
|
10
|
-
def _request(url, key, path, workspace=None, project=None):
|
|
12
|
+
def _request(url: str, key: str, path: str, workspace: str | None = None, project: str | None = None):
|
|
11
13
|
try:
|
|
12
14
|
return HttpService(url, api_key=key, workspace_id=workspace, project_id=project, timeout=5,
|
|
13
15
|
config=False, environment=False).request('GET', path)
|
|
@@ -17,17 +19,17 @@ def _request(url, key, path, workspace=None, project=None):
|
|
|
17
19
|
if path.startswith('/v1/workspaces/') and path.endswith('/projects') and error.status_code == 403 and error.code == 'ACCESS_DENIED':
|
|
18
20
|
raise ConfigError('PROJECT_LIST_REQUIRES_SESSION') from None
|
|
19
21
|
fallback = error.code if error.status_code is None and error.code in {
|
|
20
|
-
'NETWORK_ERROR', 'INVALID_RESPONSE', 'RESPONSE_TOO_LARGE',
|
|
22
|
+
'NETWORK_ERROR', 'REQUEST_TIMEOUT', 'RESPONSE_INTERRUPTED', 'INVALID_RESPONSE', 'RESPONSE_TOO_LARGE',
|
|
21
23
|
} else 'HTTP_ERROR'
|
|
22
24
|
denied = 'PROJECT_ACCESS_DENIED' if project is not None else 'WORKSPACE_ACCESS_DENIED'
|
|
23
|
-
code = {401: 'AUTHENTICATION_REJECTED', 403: denied, 404: denied}.get(error.status_code, fallback)
|
|
25
|
+
code = {401: 'AUTHENTICATION_REJECTED', 403: denied, 404: denied}.get(cast(int, error.status_code), fallback)
|
|
24
26
|
raise ConfigError(code) from None
|
|
25
27
|
|
|
26
28
|
|
|
27
|
-
def _scope(response, workspace=None, project=None):
|
|
29
|
+
def _scope(response: Any, workspace: str | None = None, project: str | None = None):
|
|
28
30
|
if not isinstance(response, dict) or response.get('auth_method') != 'api_key':
|
|
29
31
|
raise ConfigError('AUTH_CHECK_UNCONFIRMED')
|
|
30
|
-
scope = response.get('workspace')
|
|
32
|
+
scope: Any = response.get('workspace')
|
|
31
33
|
try:
|
|
32
34
|
identifier = validate_workspace(scope['workspace_id'])
|
|
33
35
|
except (KeyError, TypeError, ConfigError):
|
|
@@ -50,12 +52,12 @@ def _scope(response, workspace=None, project=None):
|
|
|
50
52
|
return result
|
|
51
53
|
|
|
52
54
|
|
|
53
|
-
def _identity(url, key, workspace=None, project=None, *, details=False):
|
|
55
|
+
def _identity(url: str, key: str, workspace: str | None = None, project: str | None = None, *, details: bool = False) -> Any:
|
|
54
56
|
result = _scope(_request(url, key, '/v1/me', workspace, project), workspace, project)
|
|
55
57
|
return result if details else result['workspace_id']
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
def _workspaces(url, key, fallback):
|
|
60
|
+
def _workspaces(url: str, key: str, fallback: list[dict[str, Any]]):
|
|
59
61
|
local_only = False
|
|
60
62
|
try:
|
|
61
63
|
response = _request(url, key, '/v1/workspaces')
|
|
@@ -70,7 +72,7 @@ def _workspaces(url, key, fallback):
|
|
|
70
72
|
raise ConfigError('WORKSPACE_PAGINATION_UNSUPPORTED')
|
|
71
73
|
if not isinstance(items, list) or len(items) > 1000:
|
|
72
74
|
raise ConfigError('INVALID_WORKSPACE_RESPONSE')
|
|
73
|
-
result, seen = [], set()
|
|
75
|
+
result, seen = cast(list[tuple[str, str]], []), set()
|
|
74
76
|
for item in items:
|
|
75
77
|
try:
|
|
76
78
|
workspace = validate_workspace(item['workspace_id'])
|
|
@@ -88,7 +90,7 @@ def _workspaces(url, key, fallback):
|
|
|
88
90
|
return result, local_only
|
|
89
91
|
|
|
90
92
|
|
|
91
|
-
def _projects(url, key, current, saved_projects):
|
|
93
|
+
def _projects(url: str, key: str, current: dict[str, Any], saved_projects: dict[str, Any]):
|
|
92
94
|
workspace = current['workspace_id']
|
|
93
95
|
local_only = False
|
|
94
96
|
try:
|
|
@@ -107,7 +109,7 @@ def _projects(url, key, current, saved_projects):
|
|
|
107
109
|
raise ConfigError('PROJECT_PAGINATION_UNSUPPORTED')
|
|
108
110
|
if not isinstance(items, list) or len(items) > 1000:
|
|
109
111
|
raise ConfigError('INVALID_PROJECT_RESPONSE')
|
|
110
|
-
result, seen = [], set()
|
|
112
|
+
result, seen = cast(list[tuple[str, str]], []), set()
|
|
111
113
|
for item in items:
|
|
112
114
|
try:
|
|
113
115
|
project = validate_project(item['project_id'])
|
|
@@ -124,7 +126,7 @@ def _projects(url, key, current, saved_projects):
|
|
|
124
126
|
return result, local_only
|
|
125
127
|
|
|
126
128
|
|
|
127
|
-
def select_project(url, current_key, current, saved_projects, *, target=None, stdin=False):
|
|
129
|
+
def select_project(url: str, current_key: str, current: dict[str, Any], saved_projects: dict[str, Any], *, target: str | None = None, stdin: bool = False):
|
|
128
130
|
"""Choose and verify a scoped key without writing configuration."""
|
|
129
131
|
from .auth_cli import _key_from_input
|
|
130
132
|
|
|
@@ -152,7 +154,7 @@ def select_project(url, current_key, current, saved_projects, *, target=None, st
|
|
|
152
154
|
return key, selected
|
|
153
155
|
|
|
154
156
|
|
|
155
|
-
def configure_project(args, store):
|
|
157
|
+
def configure_project(args: argparse.Namespace, store: ConfigStore):
|
|
156
158
|
from .auth_cli import _print, _report
|
|
157
159
|
|
|
158
160
|
target = args.project_id
|
|
@@ -184,7 +186,7 @@ def configure_project(args, store):
|
|
|
184
186
|
return 0
|
|
185
187
|
|
|
186
188
|
|
|
187
|
-
def _choose(items, current, label='workspace'):
|
|
189
|
+
def _choose(items: list[tuple[str, str]], current: str | None, label: str = 'workspace'):
|
|
188
190
|
import termios
|
|
189
191
|
import tty
|
|
190
192
|
|
|
@@ -224,7 +226,7 @@ def _choose(items, current, label='workspace'):
|
|
|
224
226
|
termios.tcsetattr(descriptor, termios.TCSAFLUSH, original)
|
|
225
227
|
|
|
226
228
|
|
|
227
|
-
def configure_workspace(args, store):
|
|
229
|
+
def configure_workspace(args: argparse.Namespace, store: ConfigStore):
|
|
228
230
|
from .auth_cli import _key_from_input, _print, _report
|
|
229
231
|
|
|
230
232
|
target = args.workspace_id
|