adaptorch-cli 0.1.0__tar.gz → 0.1.2__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. {adaptorch_cli-0.1.0/src/adaptorch_cli.egg-info → adaptorch_cli-0.1.2}/PKG-INFO +18 -2
  2. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/README.md +16 -0
  3. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/pyproject.toml +2 -2
  4. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli/cli.py +47 -3
  5. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli/config_store.py +38 -2
  6. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli/parser.py +23 -1
  7. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2/src/adaptorch_cli.egg-info}/PKG-INFO +18 -2
  8. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli.egg-info/SOURCES.txt +3 -1
  9. adaptorch_cli-0.1.2/src/adaptorch_cli.egg-info/requires.txt +1 -0
  10. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/tests/test_cli_commands.py +37 -0
  11. adaptorch_cli-0.1.2/tests/test_cli_oauth.py +76 -0
  12. adaptorch_cli-0.1.2/tests/test_cli_wait_exit_codes.py +182 -0
  13. adaptorch_cli-0.1.0/src/adaptorch_cli.egg-info/requires.txt +0 -1
  14. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/LICENSE +0 -0
  15. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/setup.cfg +0 -0
  16. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli/__init__.py +0 -0
  17. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli/__main__.py +0 -0
  18. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli/py.typed +0 -0
  19. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli.egg-info/dependency_links.txt +0 -0
  20. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli.egg-info/entry_points.txt +0 -0
  21. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/src/adaptorch_cli.egg-info/top_level.txt +0 -0
  22. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/tests/test_cli_boundary.py +0 -0
  23. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/tests/test_cli_integration.py +0 -0
  24. {adaptorch_cli-0.1.0 → adaptorch_cli-0.1.2}/tests/test_cli_login_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adaptorch-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Command-line client for AdaptOrch
5
5
  Author: ClassicMate
6
6
  License-Expression: LicenseRef-Proprietary
@@ -11,7 +11,7 @@ Project-URL: Documentation, https://adaptorch.com/mcp-docs
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: adaptorch-client<0.2,>=0.1
14
+ Requires-Dist: adaptorch-client<0.2,>=0.1.2
15
15
  Dynamic: license-file
16
16
 
17
17
  # adaptorch-cli
@@ -41,6 +41,22 @@ A git push does not replace a previously published PyPI version.
41
41
 
42
42
  See [`../../docs/adaptorchctl-usage.ko.md`](../../docs/adaptorchctl-usage.ko.md) for usage.
43
43
 
44
+ ## Bounded wait exit codes
45
+
46
+ `adaptorchctl run wait RUN_ID` emits the last observation as JSON. A terminal
47
+ `SUCCEEDED` exits 0, `FAILED` exits 8, and `CANCELLED` exits 9. These codes describe
48
+ run lifecycle, not result correctness or evidence verification.
49
+
50
+ A `deadline`, `poll_limit`, `unsupported_status`, or unrecognized stop reason exits
51
+ 10, even if the last observation looks terminal. Missing or unsupported terminal
52
+ observations also exit 10. This means the observation is inconclusive, not that
53
+ the server-side run failed. Inspect `reason` and retain the JSON; continue with the
54
+ same run ID when appropriate. Waiting never resubmits or cancels a run.
55
+
56
+ Automation compatibility: `run wait` previously returned 0 for these observations.
57
+ Scripts using `set -e` must now handle nonzero exits. `run get` keeps its existing
58
+ exit-code behavior; submit and cancel still report command success separately.
59
+
44
60
  ## License
45
61
 
46
62
  Proprietary — Copyright ClassicMate. All rights reserved. See [LICENSE](https://github.com/dmae97/Adaptorch-MCP/blob/main/LICENSE).
@@ -25,6 +25,22 @@ A git push does not replace a previously published PyPI version.
25
25
 
26
26
  See [`../../docs/adaptorchctl-usage.ko.md`](../../docs/adaptorchctl-usage.ko.md) for usage.
27
27
 
28
+ ## Bounded wait exit codes
29
+
30
+ `adaptorchctl run wait RUN_ID` emits the last observation as JSON. A terminal
31
+ `SUCCEEDED` exits 0, `FAILED` exits 8, and `CANCELLED` exits 9. These codes describe
32
+ run lifecycle, not result correctness or evidence verification.
33
+
34
+ A `deadline`, `poll_limit`, `unsupported_status`, or unrecognized stop reason exits
35
+ 10, even if the last observation looks terminal. Missing or unsupported terminal
36
+ observations also exit 10. This means the observation is inconclusive, not that
37
+ the server-side run failed. Inspect `reason` and retain the JSON; continue with the
38
+ same run ID when appropriate. Waiting never resubmits or cancels a run.
39
+
40
+ Automation compatibility: `run wait` previously returned 0 for these observations.
41
+ Scripts using `set -e` must now handle nonzero exits. `run get` keeps its existing
42
+ exit-code behavior; submit and cancel still report command success separately.
43
+
28
44
  ## License
29
45
 
30
46
  Proprietary — Copyright ClassicMate. All rights reserved. See [LICENSE](https://github.com/dmae97/Adaptorch-MCP/blob/main/LICENSE).
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adaptorch-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Command-line client for AdaptOrch"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = "LicenseRef-Proprietary"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [{ name = "ClassicMate" }]
14
- dependencies = ["adaptorch-client>=0.1,<0.2"]
14
+ dependencies = ["adaptorch-client>=0.1.2,<0.2"]
15
15
 
16
16
  [project.urls]
17
17
  Homepage = "https://adaptorch.com"
@@ -13,6 +13,7 @@ from adaptorch_client import (
13
13
  AdaptOrchAPIError,
14
14
  AdaptOrchClient,
15
15
  ClientConfig,
16
+ PollPolicy,
16
17
  ProviderCredential,
17
18
  validate_api_url,
18
19
  )
@@ -39,7 +40,10 @@ def _reject_json_constant(_value: str) -> Never:
39
40
 
40
41
 
41
42
  def _finite_float(text: str) -> float:
42
- value = float(text)
43
+ try:
44
+ value = float(text)
45
+ except ValueError:
46
+ _reject_json_constant(text)
43
47
  if not math.isfinite(value):
44
48
  raise ValueError("non-finite JSON number")
45
49
  return value
@@ -97,11 +101,14 @@ def _require_client(api_url: str) -> AdaptOrchClient:
97
101
 
98
102
 
99
103
  def _submission_credential() -> ProviderCredential | None:
100
- resolved, _source = config_store.resolve_provider_credential()
104
+ resolved, source = config_store.resolve_provider_credential()
101
105
  if resolved is None:
102
106
  return None
103
107
  provider, model, key = resolved
104
- return ProviderCredential(provider, model, key)
108
+ auth_type, account_id = config_store.resolve_provider_auth(source)
109
+ if auth_type is None and account_id is None:
110
+ return ProviderCredential(provider, model, key)
111
+ return ProviderCredential(provider, model, key, auth_type=auth_type, account_id=account_id)
105
112
 
106
113
 
107
114
  def _read_secret(prompt: str, *, stdin_flag: bool) -> str:
@@ -241,6 +248,28 @@ def _run_command(
241
248
  case "get":
242
249
  run_id: str = args.run_id
243
250
  return _result_payload(_require_client(api_url).get_run(run_id)), True
251
+ case "wait":
252
+ wait_run_id: str = args.run_id
253
+ timeout_seconds: float = args.timeout
254
+ interval_seconds: float = args.interval
255
+ if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
256
+ parser.error("--timeout must be finite positive seconds")
257
+ if not math.isfinite(interval_seconds) or interval_seconds <= 0:
258
+ parser.error("--interval must be finite positive seconds")
259
+ wait_result = _require_client(api_url).wait_for_run(
260
+ wait_run_id,
261
+ policy=PollPolicy(
262
+ timeout_seconds=timeout_seconds, interval_seconds=interval_seconds
263
+ ),
264
+ )
265
+ wait_payload: dict[str, JSONValue] = {
266
+ "reason": wait_result.reason.value,
267
+ "polls": wait_result.polls,
268
+ "elapsed_seconds": wait_result.elapsed_seconds,
269
+ }
270
+ if wait_result.run is not None:
271
+ wait_payload["run"] = wait_result.run.to_payload()
272
+ return wait_payload, True
244
273
  case "cancel":
245
274
  cancel_run_id: str = args.run_id
246
275
  reason: str | None = args.reason
@@ -318,6 +347,19 @@ def _run_status_exit(payload: JSONMapping) -> int:
318
347
  return 0
319
348
 
320
349
 
350
+ def _wait_status_exit(payload: JSONMapping) -> int:
351
+ """Map a bounded observation without treating a stopped wait as success."""
352
+ if payload.get("reason") != "terminal":
353
+ return 10
354
+ run = payload.get("run")
355
+ if not isinstance(run, dict):
356
+ return 10
357
+ status = run.get("status")
358
+ if not isinstance(status, str):
359
+ return 10
360
+ return {"succeeded": 0, "failed": 8, "cancelled": 9}.get(status.lower(), 10)
361
+
362
+
321
363
  def main(argv: Sequence[str] | None = None) -> int:
322
364
  parser = build_parser()
323
365
  args = parse_args(argv)
@@ -333,6 +375,8 @@ def main(argv: Sequence[str] | None = None) -> int:
333
375
  except ValueError:
334
376
  print("response contained non-encodable JSON values", file=sys.stderr)
335
377
  return 7
378
+ if args.command == "run" and args.run_command == "wait":
379
+ return _wait_status_exit(payload)
336
380
  return _run_status_exit(payload) if check_run_status else 0
337
381
  # pi-lens-ignore: unreachable-except
338
382
  except KeyboardInterrupt:
@@ -27,6 +27,8 @@ _PROVIDER_KEY_MAP = {
27
27
  "provider.name": "provider",
28
28
  "provider.model": "model",
29
29
  "provider.api_key": "api_key",
30
+ "provider.auth_type": "auth_type",
31
+ "provider.account_id": "account_id",
30
32
  }
31
33
  _CONFIGURABLE_KEYS = frozenset({"api_url", *_PROVIDER_KEY_MAP})
32
34
 
@@ -175,18 +177,50 @@ def resolve_provider_credential() -> tuple[tuple[str, str, str] | None, str]:
175
177
  env_provider = os.environ.get("ADAPTORCH_PROVIDER", "").strip()
176
178
  env_model = os.environ.get("ADAPTORCH_PROVIDER_MODEL", "").strip()
177
179
  env_key = os.environ.get("ADAPTORCH_PROVIDER_API_KEY", "").strip()
178
- if env_provider or env_model or env_key:
180
+ if any(
181
+ (
182
+ env_provider,
183
+ env_model,
184
+ env_key,
185
+ os.environ.get("ADAPTORCH_PROVIDER_AUTH_TYPE"),
186
+ os.environ.get("ADAPTORCH_PROVIDER_ACCOUNT_ID"),
187
+ )
188
+ ):
179
189
  return (env_provider, env_model, env_key), "env"
180
190
  provider_cfg = load_config().get("provider")
181
191
  if isinstance(provider_cfg, dict):
182
192
  provider = str(provider_cfg.get("provider") or "").strip()
183
193
  model = str(provider_cfg.get("model") or "").strip()
184
194
  key = str(provider_cfg.get("api_key") or "").strip()
185
- if provider or model or key:
195
+ has_auth_metadata = "auth_type" in provider_cfg or "account_id" in provider_cfg
196
+ if provider or model or key or has_auth_metadata:
186
197
  return (provider, model, key), "config"
187
198
  return None, "none"
188
199
 
189
200
 
201
+ def resolve_provider_auth(source: str) -> tuple[str | None, str | None]:
202
+ """Read metadata from the same source as the token; never mix identities."""
203
+ if source == "env":
204
+ values = (
205
+ os.environ.get("ADAPTORCH_PROVIDER_AUTH_TYPE"),
206
+ os.environ.get("ADAPTORCH_PROVIDER_ACCOUNT_ID"),
207
+ )
208
+ else:
209
+ provider = load_config().get("provider")
210
+ if not isinstance(provider, dict):
211
+ return None, None
212
+ values = (provider.get("auth_type"), provider.get("account_id"))
213
+
214
+ def optional_text(value: object) -> str | None:
215
+ if value is None:
216
+ return None
217
+ if not isinstance(value, str):
218
+ raise ValueError("provider authentication metadata must be strings")
219
+ return value.strip() or None
220
+
221
+ return optional_text(values[0]), optional_text(values[1])
222
+
223
+
190
224
  def mask_secret(value: str | None) -> str | None:
191
225
  if not value:
192
226
  return None
@@ -209,6 +243,8 @@ def config_view() -> dict[str, Any]:
209
243
  "name": provider.get("provider"),
210
244
  "model": provider.get("model"),
211
245
  "api_key": mask_secret(provider_key if isinstance(provider_key, str) else None),
246
+ "auth_type": provider.get("auth_type"),
247
+ "account_id_configured": bool(provider.get("account_id")),
212
248
  "source": resolve_provider_credential()[1],
213
249
  },
214
250
  "config_path": str(config_path()),
@@ -33,6 +33,28 @@ def _add_run_commands(parent: argparse._SubParsersAction[argparse.ArgumentParser
33
33
  get = commands.add_parser("get", help="Get a run")
34
34
  get.add_argument("run_id")
35
35
 
36
+ wait = commands.add_parser(
37
+ "wait",
38
+ help=(
39
+ "Read an existing run until it reaches a terminal status — the "
40
+ "CLI's form of the MCP resume_run_id contract. It never submits "
41
+ "another run; use it after an uncertain submit response."
42
+ ),
43
+ )
44
+ wait.add_argument("run_id")
45
+ wait.add_argument(
46
+ "--timeout",
47
+ type=float,
48
+ default=120.0,
49
+ help="Seconds to keep polling before stopping (default: 120)",
50
+ )
51
+ wait.add_argument(
52
+ "--interval",
53
+ type=float,
54
+ default=1.0,
55
+ help="Seconds between status reads (default: 1)",
56
+ )
57
+
36
58
  cancel = commands.add_parser("cancel", help="Cancel a run")
37
59
  cancel.add_argument("run_id")
38
60
  cancel.add_argument("--reason")
@@ -76,7 +98,7 @@ def build_parser() -> argparse.ArgumentParser:
76
98
  config_commands.add_parser("get", help="Show the merged configuration (secrets masked)")
77
99
  config_set = config_commands.add_parser(
78
100
  "set",
79
- help="Set api_url, provider.name, provider.model, or provider.api_key",
101
+ help="Set api_url or provider.name/model/api_key/auth_type/account_id",
80
102
  )
81
103
  config_set.add_argument("key")
82
104
  config_set.add_argument("value", nargs="?")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adaptorch-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Command-line client for AdaptOrch
5
5
  Author: ClassicMate
6
6
  License-Expression: LicenseRef-Proprietary
@@ -11,7 +11,7 @@ Project-URL: Documentation, https://adaptorch.com/mcp-docs
11
11
  Requires-Python: >=3.11
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: adaptorch-client<0.2,>=0.1
14
+ Requires-Dist: adaptorch-client<0.2,>=0.1.2
15
15
  Dynamic: license-file
16
16
 
17
17
  # adaptorch-cli
@@ -41,6 +41,22 @@ A git push does not replace a previously published PyPI version.
41
41
 
42
42
  See [`../../docs/adaptorchctl-usage.ko.md`](../../docs/adaptorchctl-usage.ko.md) for usage.
43
43
 
44
+ ## Bounded wait exit codes
45
+
46
+ `adaptorchctl run wait RUN_ID` emits the last observation as JSON. A terminal
47
+ `SUCCEEDED` exits 0, `FAILED` exits 8, and `CANCELLED` exits 9. These codes describe
48
+ run lifecycle, not result correctness or evidence verification.
49
+
50
+ A `deadline`, `poll_limit`, `unsupported_status`, or unrecognized stop reason exits
51
+ 10, even if the last observation looks terminal. Missing or unsupported terminal
52
+ observations also exit 10. This means the observation is inconclusive, not that
53
+ the server-side run failed. Inspect `reason` and retain the JSON; continue with the
54
+ same run ID when appropriate. Waiting never resubmits or cancels a run.
55
+
56
+ Automation compatibility: `run wait` previously returned 0 for these observations.
57
+ Scripts using `set -e` must now handle nonzero exits. `run get` keeps its existing
58
+ exit-code behavior; submit and cancel still report command success separately.
59
+
44
60
  ## License
45
61
 
46
62
  Proprietary — Copyright ClassicMate. All rights reserved. See [LICENSE](https://github.com/dmae97/Adaptorch-MCP/blob/main/LICENSE).
@@ -16,4 +16,6 @@ src/adaptorch_cli.egg-info/top_level.txt
16
16
  tests/test_cli_boundary.py
17
17
  tests/test_cli_commands.py
18
18
  tests/test_cli_integration.py
19
- tests/test_cli_login_config.py
19
+ tests/test_cli_login_config.py
20
+ tests/test_cli_oauth.py
21
+ tests/test_cli_wait_exit_codes.py
@@ -0,0 +1 @@
1
+ adaptorch-client<0.2,>=0.1.2
@@ -118,6 +118,43 @@ def test_run_get_prints_client_json(run_cli: CliRunner) -> None:
118
118
  assert _json_stdout(result.stdout) == {"id": "run-1", "status": "RUNNING"}
119
119
 
120
120
 
121
+ def test_run_wait_reads_until_terminal_without_resubmitting(run_cli: CliRunner) -> None:
122
+ result = run_cli(
123
+ [
124
+ "--output",
125
+ "json",
126
+ "run",
127
+ "wait",
128
+ "run-1",
129
+ "--timeout",
130
+ "5",
131
+ "--interval",
132
+ "0.5",
133
+ ]
134
+ )
135
+
136
+ assert result.returncode == 0
137
+ assert _json_stdout(result.stdout) == {
138
+ "reason": "terminal",
139
+ "polls": 1,
140
+ "elapsed_seconds": 0.01,
141
+ "run": {"id": "run-1", "status": "SUCCEEDED"},
142
+ }
143
+ calls = [json.loads(line) for line in result.client_log.splitlines()]
144
+ assert calls[-1] == {
145
+ "args": ["run-1"],
146
+ "kwargs": {"interval_seconds": 0.5, "timeout_seconds": 5.0},
147
+ "method": "wait_for_run",
148
+ }
149
+
150
+
151
+ def test_run_wait_rejects_non_positive_bounds(run_cli: CliRunner) -> None:
152
+ result = run_cli(["--output", "json", "run", "wait", "run-1", "--timeout", "0"])
153
+
154
+ assert result.returncode != 0
155
+ assert result.client_log.count("wait_for_run") == 0
156
+
157
+
121
158
  def test_run_cancel_prints_client_json(run_cli: CliRunner) -> None:
122
159
  result = run_cli(["--output", "json", "run", "cancel", "run-1"])
123
160
 
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from adaptorch_cli import cli, config_store
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def isolated_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
13
+ monkeypatch.setenv("ADAPTORCH_CONFIG_DIR", str(tmp_path))
14
+ for name in (
15
+ "ADAPTORCH_PROVIDER",
16
+ "ADAPTORCH_PROVIDER_MODEL",
17
+ "ADAPTORCH_PROVIDER_API_KEY",
18
+ "ADAPTORCH_PROVIDER_AUTH_TYPE",
19
+ "ADAPTORCH_PROVIDER_ACCOUNT_ID",
20
+ ):
21
+ monkeypatch.delenv(name, raising=False)
22
+
23
+
24
+ def test_cli_resolves_oauth_from_environment_without_persisting_token(
25
+ monkeypatch: pytest.MonkeyPatch,
26
+ ) -> None:
27
+ for name, value in {
28
+ "ADAPTORCH_PROVIDER": "openai_codex",
29
+ "ADAPTORCH_PROVIDER_MODEL": "gpt-5-codex",
30
+ "ADAPTORCH_PROVIDER_API_KEY": "synthetic-oauth-token",
31
+ "ADAPTORCH_PROVIDER_AUTH_TYPE": "oauth",
32
+ "ADAPTORCH_PROVIDER_ACCOUNT_ID": "caller-account",
33
+ }.items():
34
+ monkeypatch.setenv(name, value)
35
+ credential = cli._submission_credential()
36
+ assert credential is not None
37
+ assert credential.headers["X-Provider-Auth-Type"] == "oauth"
38
+ assert credential.headers["X-Provider-Account-Id"] == "caller-account"
39
+ assert not config_store.config_path().exists()
40
+
41
+
42
+ def test_cli_environment_credential_never_borrows_account_from_config(
43
+ monkeypatch: pytest.MonkeyPatch,
44
+ ) -> None:
45
+ config_store.save_config(
46
+ {
47
+ "provider": {
48
+ "provider": "openai_codex",
49
+ "model": "gpt-5-codex",
50
+ "api_key": "old-token",
51
+ "auth_type": "oauth",
52
+ "account_id": "old-account",
53
+ }
54
+ }
55
+ )
56
+ monkeypatch.setenv("ADAPTORCH_PROVIDER", "openai_codex")
57
+ monkeypatch.setenv("ADAPTORCH_PROVIDER_MODEL", "gpt-5-codex")
58
+ monkeypatch.setenv("ADAPTORCH_PROVIDER_API_KEY", "new-token")
59
+ credential = cli._submission_credential()
60
+ assert credential is not None
61
+ assert credential.account_id is None
62
+ assert credential.api_key == "new-token"
63
+
64
+
65
+ def test_cli_oauth_config_keys_and_view_are_safe() -> None:
66
+ config_store.set_config_value("provider.name", "openai_codex")
67
+ config_store.set_config_value("provider.model", "gpt-5-codex")
68
+ config_store.set_config_value("provider.api_key", "synthetic-oauth-token")
69
+ config_store.set_config_value("provider.auth_type", "oauth")
70
+ config_store.set_config_value("provider.account_id", "caller-account")
71
+ credential = cli._submission_credential()
72
+ assert credential is not None and credential.account_id == "caller-account"
73
+ view = json.dumps(config_store.config_view())
74
+ assert "synthetic-oauth-token" not in view
75
+ assert "caller-account" not in view
76
+ assert config_store.config_view()["provider"]["account_id_configured"] is True
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from adaptorch_client import AdaptOrchClient, ClientConfig, PollPolicy, Run, RunPollResult, polling
9
+ from adaptorch_client import client as client_module
10
+ from adaptorch_client.transport import HTTPTransport, RequestSpec
11
+ from cli_test_support import CliRunner
12
+
13
+ from adaptorch_cli import cli
14
+
15
+
16
+ @pytest.mark.parametrize(
17
+ ("reason", "status", "expected"),
18
+ [
19
+ ("terminal", "SUCCEEDED", 0),
20
+ ("terminal", "sUcCeEdEd", 0),
21
+ ("terminal", "FAILED", 8),
22
+ ("terminal", "failed", 8),
23
+ ("terminal", "CANCELLED", 9),
24
+ ("terminal", "cancelled", 9),
25
+ ("deadline", "RUNNING", 10),
26
+ ("deadline", "SUCCEEDED", 10),
27
+ ("deadline", "FAILED", 10),
28
+ ("poll_limit", "RUNNING", 10),
29
+ ("unsupported_status", "INCONCLUSIVE", 10),
30
+ ("unsupported_status", "FUTURE_STATUS", 10),
31
+ ("future_reason", "SUCCEEDED", 10),
32
+ ("terminal", "RUNNING", 10),
33
+ ("terminal", "FUTURE_STATUS", 10),
34
+ ],
35
+ )
36
+ def test_wait_exit_preserves_observation_and_never_resubmits(
37
+ run_cli: CliRunner,
38
+ monkeypatch: pytest.MonkeyPatch,
39
+ reason: str,
40
+ status: str,
41
+ expected: int,
42
+ ) -> None:
43
+ monkeypatch.setenv("FAKE_WAIT_REASON", reason)
44
+ monkeypatch.setenv("FAKE_RUN_STATUS", status)
45
+ result = run_cli(["run", "wait", "run-1"])
46
+
47
+ assert result.returncode == expected
48
+ assert json.loads(result.stdout) == {
49
+ "reason": reason,
50
+ "polls": 1,
51
+ "elapsed_seconds": 0.01,
52
+ "run": {"id": "run-1", "status": status},
53
+ }
54
+ assert result.stdout.count("\n") == 1
55
+ assert result.stderr == ""
56
+ methods = [json.loads(line)["method"] for line in result.client_log.splitlines()]
57
+ assert methods == ["init", "wait_for_run"]
58
+
59
+
60
+ @pytest.mark.parametrize(
61
+ "payload",
62
+ [
63
+ {},
64
+ {"reason": None, "run": {"status": "SUCCEEDED"}},
65
+ {"reason": 1, "run": {"status": "SUCCEEDED"}},
66
+ {"reason": "future_reason", "run": {"status": "SUCCEEDED"}},
67
+ {"reason": "terminal"},
68
+ {"reason": "terminal", "run": None},
69
+ {"reason": "terminal", "run": []},
70
+ {"reason": "terminal", "run": {}},
71
+ {"reason": "terminal", "run": {"status": None}},
72
+ {"reason": "terminal", "run": {"status": 1}},
73
+ {"reason": "terminal", "run": {"status": ""}},
74
+ {"reason": "terminal", "run": {"data": {"status": "SUCCEEDED"}}},
75
+ {"reason": "deadline"},
76
+ {"reason": "poll_limit"},
77
+ {"reason": "unsupported_status"},
78
+ ],
79
+ )
80
+ def test_wait_unknown_or_missing_observation_is_inconclusive(
81
+ payload: cli.JSONMapping,
82
+ ) -> None:
83
+ assert cli._wait_status_exit(payload) == 10
84
+
85
+
86
+ def test_wait_lifecycle_does_not_claim_result_correctness() -> None:
87
+ assert cli._wait_status_exit(
88
+ {"reason": "terminal", "run": {"status": "SUCCEEDED", "result_status": "FAILED"}}
89
+ ) == 0
90
+
91
+
92
+ def test_wait_does_not_reinterpret_server_cancellation_encoding() -> None:
93
+ assert cli._wait_status_exit(
94
+ {"reason": "terminal", "run": {"status": "FAILED", "error_class": "CANCELLED"}}
95
+ ) == 8
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ ("status", "reason", "expected", "timeout", "latency", "count"),
100
+ [
101
+ ("SUCCEEDED", "terminal", 0, "5", 0.0, 1),
102
+ ("FAILED", "terminal", 8, "5", 0.0, 1),
103
+ ("CANCELLED", "terminal", 9, "5", 0.0, 1),
104
+ ("RUNNING", "deadline", 10, "0.5", 0.0, 1),
105
+ ("RUNNING", "poll_limit", 10, "200", 0.0, 100),
106
+ ("NEW_STATUS", "unsupported_status", 10, "5", 0.0, 1),
107
+ ("INCONCLUSIVE", "unsupported_status", 10, "5", 0.0, 1),
108
+ ("SUCCEEDED", "deadline", 10, "0.5", 0.5, 1),
109
+ ],
110
+ )
111
+ def test_wait_uses_real_sdk_polling_without_writing_or_resubmitting(
112
+ monkeypatch: pytest.MonkeyPatch,
113
+ tmp_path: Path,
114
+ capsys: pytest.CaptureFixture[str],
115
+ status: str,
116
+ reason: str,
117
+ expected: int,
118
+ timeout: str,
119
+ latency: float,
120
+ count: int,
121
+ ) -> None:
122
+ elapsed = 0.0
123
+ requests: list[RequestSpec] = []
124
+
125
+ def clock() -> float:
126
+ return elapsed
127
+
128
+ def sleep(duration: float) -> None:
129
+ nonlocal elapsed
130
+ elapsed += duration
131
+
132
+ def request(_transport: HTTPTransport, spec: RequestSpec) -> cli.JSONMapping:
133
+ nonlocal elapsed
134
+ requests.append(spec)
135
+ elapsed += latency
136
+ return {"run_id": "run-1", "status": status}
137
+
138
+ def poll(fetch: Callable[[float], Run], policy: PollPolicy) -> RunPollResult:
139
+ return polling.wait_for_run(fetch, policy, clock=clock, sleep=sleep)
140
+
141
+ client = AdaptOrchClient(ClientConfig("https://api.example.test", "ado_test"))
142
+ monkeypatch.setenv("ADAPTORCH_CONFIG_DIR", str(tmp_path / "isolated-config"))
143
+ monkeypatch.setattr(cli, "_require_client", lambda _api_url: client)
144
+ monkeypatch.setattr(HTTPTransport, "request", request)
145
+ monkeypatch.setattr(client_module, "poll_run", poll)
146
+
147
+ code = cli.main(["run", "wait", "run-1", "--timeout", timeout, "--interval", "1"])
148
+
149
+ captured = capsys.readouterr()
150
+ assert code == expected
151
+ assert json.loads(captured.out) == {
152
+ "reason": reason,
153
+ "polls": count,
154
+ "elapsed_seconds": elapsed,
155
+ "run": {"run_id": "run-1", "status": status},
156
+ }
157
+ assert captured.out.count("\n") == 1
158
+ assert captured.err == ""
159
+ assert len(requests) == count
160
+ assert all((spec.method, spec.path) == ("GET", "/v1/runs/run-1") for spec in requests)
161
+ assert all(spec.payload is None and spec.provider_credential is None for spec in requests)
162
+
163
+
164
+ @pytest.mark.parametrize(
165
+ ("status", "expected"),
166
+ [("SUCCEEDED", 0), ("RUNNING", 0), ("NEW_STATUS", 0), ("FAILED", 8), ("CANCELLED", 9)],
167
+ )
168
+ def test_get_retains_its_mapping_with_additive_wait_like_fields(
169
+ monkeypatch: pytest.MonkeyPatch,
170
+ tmp_path: Path,
171
+ capsys: pytest.CaptureFixture[str],
172
+ status: str,
173
+ expected: int,
174
+ ) -> None:
175
+ payload: cli.JSONMapping = {"run_id": "run-1", "status": status, "reason": "deadline"}
176
+ monkeypatch.setenv("ADAPTORCH_CONFIG_DIR", str(tmp_path / "isolated-config"))
177
+ monkeypatch.setattr(cli, "_execute", lambda *_args: (payload, True))
178
+
179
+ assert cli.main(["run", "get", "run-1"]) == expected
180
+ captured = capsys.readouterr()
181
+ assert json.loads(captured.out) == payload
182
+ assert captured.err == ""
@@ -1 +0,0 @@
1
- adaptorch-client<0.2,>=0.1
File without changes
File without changes