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 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
- __all__ = ["Client", "DecisionClient"]
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 (ValueError, UnicodeError):
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
- expected = 'https://console.envloop.ai' if url == 'https://api.envloop.ai' else f'{api.scheme}://{api.netloc}'
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 (ValueError, ConfigError):
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