runapprentice 0.4.1__tar.gz → 0.4.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.
- {runapprentice-0.4.1 → runapprentice-0.4.2}/PKG-INFO +5 -5
- {runapprentice-0.4.1 → runapprentice-0.4.2}/README.md +4 -4
- {runapprentice-0.4.1 → runapprentice-0.4.2}/pyproject.toml +1 -1
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/cli.py +97 -9
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/client.py +95 -7
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_cli.py +305 -3
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_client.py +171 -1
- {runapprentice-0.4.1 → runapprentice-0.4.2}/.gitignore +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/LICENSE +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/local.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/models.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/openai.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_capture.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_failopen.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_local.py +0 -0
- {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runapprentice
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
|
|
5
5
|
Project-URL: Homepage, https://runapprentice.com
|
|
6
6
|
Project-URL: Documentation, https://docs.runapprentice.com
|
|
@@ -50,10 +50,10 @@ apprentice optimize <task> --local --data golden.csv # free, your O
|
|
|
50
50
|
apprentice train <task> --local --data golden.csv --effort high # free, fine-tunes on your Mac
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Both commands work
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
Both `--local --data` commands work end to end from a CSV. Hosted setup requires the console
|
|
54
|
+
or Python SDK to create a task and upload its dataset; the CLI has no create or upload command.
|
|
55
|
+
`apprentice --help` (or any subcommand `--help`) documents every flag; `--json` gives clean
|
|
56
|
+
machine-readable output.
|
|
57
57
|
|
|
58
58
|
## Install
|
|
59
59
|
|
|
@@ -21,10 +21,10 @@ apprentice optimize <task> --local --data golden.csv # free, your O
|
|
|
21
21
|
apprentice train <task> --local --data golden.csv --effort high # free, fine-tunes on your Mac
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Both commands work
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
Both `--local --data` commands work end to end from a CSV. Hosted setup requires the console
|
|
25
|
+
or Python SDK to create a task and upload its dataset; the CLI has no create or upload command.
|
|
26
|
+
`apprentice --help` (or any subcommand `--help`) documents every flag; `--json` gives clean
|
|
27
|
+
machine-readable output.
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
@@ -8,9 +8,12 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
import stat
|
|
10
10
|
import sys
|
|
11
|
+
import tempfile
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
14
17
|
from runapprentice.client import Apprentice, ApprenticeError
|
|
15
18
|
from runapprentice import local as _local
|
|
16
19
|
from runapprentice.models import (
|
|
@@ -51,7 +54,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
51
54
|
args = parser.parse_args(argv)
|
|
52
55
|
if args.command == "login":
|
|
53
56
|
return _login(args)
|
|
54
|
-
|
|
57
|
+
try:
|
|
58
|
+
api_key = _api_key()
|
|
59
|
+
except ApprenticeError as exc:
|
|
60
|
+
return _fail(str(exc), bool(args.json))
|
|
55
61
|
client = Apprentice(api_key=api_key)
|
|
56
62
|
setattr(args, "_api_key_present", api_key is not None)
|
|
57
63
|
keyless_local_optimize = args.command == "optimize" and args.local and api_key is None
|
|
@@ -168,7 +174,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
168
174
|
)
|
|
169
175
|
train.set_defaults(func=_train)
|
|
170
176
|
|
|
171
|
-
jobs = sub.add_parser("jobs", help="list hosted
|
|
177
|
+
jobs = sub.add_parser("jobs", help="list hosted jobs for a task, newest first")
|
|
172
178
|
jobs.add_argument("task", help="task name")
|
|
173
179
|
jobs.set_defaults(func=_jobs)
|
|
174
180
|
|
|
@@ -183,13 +189,70 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
183
189
|
|
|
184
190
|
|
|
185
191
|
def _login(args: argparse.Namespace) -> int:
|
|
186
|
-
key =
|
|
192
|
+
key = (
|
|
193
|
+
getpass.getpass("Apprentice API key: ") if sys.stdin.isatty() else sys.stdin.read()
|
|
194
|
+
).strip()
|
|
187
195
|
if not key:
|
|
188
196
|
return _fail("API key required", bool(args.json))
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
# The client rejects a key that cannot become an HTTP header (whitespace from a
|
|
198
|
+
# two-line paste, a smart quote). Letting it raise keeps one definition of what
|
|
199
|
+
# a usable key is, shared with the constructor and APPRENTICE_API_KEY.
|
|
200
|
+
# No captures happen during login, so skip the background dispatcher thread.
|
|
201
|
+
try:
|
|
202
|
+
client = Apprentice(api_key=key, capture_async=False)
|
|
203
|
+
except ApprenticeError as exc:
|
|
204
|
+
return _fail(f"{exc}, so it was not saved.", bool(args.json))
|
|
205
|
+
try:
|
|
206
|
+
response = client._http.get("/v1/tasks")
|
|
207
|
+
except httpx.HTTPError:
|
|
208
|
+
return _fail(
|
|
209
|
+
"Could not verify API key because Apprentice API is unavailable. "
|
|
210
|
+
"API key was not saved.",
|
|
211
|
+
bool(args.json),
|
|
212
|
+
)
|
|
213
|
+
if response.status_code in {401, 403}:
|
|
214
|
+
return _fail("API key was rejected and was not saved.", bool(args.json))
|
|
215
|
+
if not response.is_success:
|
|
216
|
+
return _fail(
|
|
217
|
+
"Could not verify API key because Apprentice API is unavailable. "
|
|
218
|
+
"API key was not saved.",
|
|
219
|
+
bool(args.json),
|
|
220
|
+
)
|
|
221
|
+
# The key is valid past this point, so a filesystem failure is the only thing
|
|
222
|
+
# left that can go wrong. Report it the same way as every other failure rather
|
|
223
|
+
# than as a traceback that emits no JSON in --json mode.
|
|
224
|
+
try:
|
|
225
|
+
return _save_api_key(key, bool(args.json))
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
return _fail(f"Could not save the API key to {CONFIG_PATH}: {exc}", bool(args.json))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _save_api_key(key: str, as_json: bool) -> int:
|
|
231
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
# mkdir's mode is masked by the process umask, so set it explicitly. A
|
|
233
|
+
# world-writable directory lets another local account replace the key file
|
|
234
|
+
# whatever the file's own mode is, because directory write governs entries.
|
|
235
|
+
CONFIG_PATH.parent.chmod(stat.S_IRWXU)
|
|
236
|
+
# Write private-then-rename rather than write-then-chmod. Writing first would
|
|
237
|
+
# leave the key world-readable (0644 under a normal umask, 0666 under a
|
|
238
|
+
# permissive one) until the chmod lands, and a process killed in that window
|
|
239
|
+
# leaves it that way for good. mkstemp creates at 0600, and os.replace is
|
|
240
|
+
# atomic, so a concurrent reader sees either the old file or the new one and
|
|
241
|
+
# never a half-written one. The rename also lands on the path itself, so a
|
|
242
|
+
# config.json symlink is replaced instead of its target being overwritten.
|
|
243
|
+
handle_fd, temporary_path = tempfile.mkstemp(dir=CONFIG_PATH.parent, prefix=".config-")
|
|
244
|
+
try:
|
|
245
|
+
with os.fdopen(handle_fd, "w", encoding="utf-8") as handle:
|
|
246
|
+
handle.write(json.dumps({"api_key": key}) + "\n")
|
|
247
|
+
os.replace(temporary_path, CONFIG_PATH)
|
|
248
|
+
except BaseException:
|
|
249
|
+
os.unlink(temporary_path)
|
|
250
|
+
raise
|
|
251
|
+
return _emit(
|
|
252
|
+
{"config": str(CONFIG_PATH), "mode": "600", "validated": True},
|
|
253
|
+
f"API key verified and saved to {CONFIG_PATH}.",
|
|
254
|
+
as_json,
|
|
255
|
+
)
|
|
193
256
|
|
|
194
257
|
|
|
195
258
|
def _status(args: argparse.Namespace, client: Apprentice) -> int:
|
|
@@ -474,9 +537,34 @@ def _api_key() -> str | None:
|
|
|
474
537
|
return os.environ["APPRENTICE_API_KEY"]
|
|
475
538
|
if not CONFIG_PATH.exists():
|
|
476
539
|
return None
|
|
477
|
-
|
|
540
|
+
try:
|
|
541
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
542
|
+
except json.JSONDecodeError as exc:
|
|
543
|
+
raise ApprenticeError(
|
|
544
|
+
f"Could not read {CONFIG_PATH}: invalid JSON. "
|
|
545
|
+
"Run `apprentice login` to replace it."
|
|
546
|
+
) from exc
|
|
547
|
+
except (OSError, UnicodeError) as exc:
|
|
548
|
+
raise ApprenticeError(
|
|
549
|
+
f"Could not read {CONFIG_PATH}: {exc}. "
|
|
550
|
+
"Fix its permissions or set APPRENTICE_API_KEY."
|
|
551
|
+
) from exc
|
|
552
|
+
if not isinstance(data, dict):
|
|
553
|
+
raise ApprenticeError(
|
|
554
|
+
f"Could not read {CONFIG_PATH}: expected a JSON object. "
|
|
555
|
+
"Run `apprentice login` to replace it."
|
|
556
|
+
)
|
|
478
557
|
key = data.get("api_key")
|
|
479
|
-
|
|
558
|
+
if key is None:
|
|
559
|
+
return None
|
|
560
|
+
# Present but not a string: the file is malformed, which is not the same as
|
|
561
|
+
# having no key. Saying so beats silently falling through to an auth error.
|
|
562
|
+
if not isinstance(key, str):
|
|
563
|
+
raise ApprenticeError(
|
|
564
|
+
f"Could not read {CONFIG_PATH}: api_key must be a string. "
|
|
565
|
+
"Run `apprentice login` to replace it."
|
|
566
|
+
)
|
|
567
|
+
return key
|
|
480
568
|
|
|
481
569
|
|
|
482
570
|
def _job_summary(job: JobSummary) -> dict[str, Any]:
|
|
@@ -35,6 +35,7 @@ from runapprentice.models import (
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
logger = logging.getLogger("runapprentice")
|
|
38
|
+
logger.addHandler(logging.NullHandler())
|
|
38
39
|
|
|
39
40
|
_DEFAULT_BASE_URL = "https://api.runapprentice.com"
|
|
40
41
|
_DEFAULT_TIMEOUT = 10.0
|
|
@@ -64,6 +65,45 @@ class ApprenticeError(Exception):
|
|
|
64
65
|
"""Raised for control-plane API failures."""
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
def _reject_unusable_api_key(api_key: str) -> None:
|
|
69
|
+
"""Reject a key that cannot become an HTTP header.
|
|
70
|
+
|
|
71
|
+
httpx encodes header values as ASCII, so a key carrying a smart quote or an
|
|
72
|
+
accented character raises UnicodeEncodeError deep inside the transport on the
|
|
73
|
+
first request. Checked here so every path that supplies a key (constructor,
|
|
74
|
+
APPRENTICE_API_KEY, the CLI config file) fails with the same clear error.
|
|
75
|
+
"""
|
|
76
|
+
if any(character.isspace() for character in api_key):
|
|
77
|
+
raise ApprenticeError("API key contains whitespace")
|
|
78
|
+
if not api_key.isascii() or not api_key.isprintable():
|
|
79
|
+
raise ApprenticeError("API key has characters that are not printable ASCII")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _printable(text: str) -> str:
|
|
83
|
+
"""Drop control characters from server-controlled text.
|
|
84
|
+
|
|
85
|
+
The message goes to a terminal and to logs. Escape sequences in it can clear
|
|
86
|
+
the screen, move the cursor, or repaint earlier lines, so a hostile or
|
|
87
|
+
compromised backend could make its error read as something else entirely.
|
|
88
|
+
"""
|
|
89
|
+
return "".join(character if character.isprintable() else " " for character in text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _reason(resp: httpx.Response) -> str:
|
|
93
|
+
"""The API's own explanation for a failed response, falling back to its body."""
|
|
94
|
+
try:
|
|
95
|
+
payload = resp.json()
|
|
96
|
+
except ValueError:
|
|
97
|
+
payload = None
|
|
98
|
+
# A body is not always a JSON object: a gateway can return a bare list or
|
|
99
|
+
# string, and FastAPI sends a list of dicts for 422. Only a string `detail`
|
|
100
|
+
# on an object is a written reason; everything else falls back to the body.
|
|
101
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
102
|
+
if isinstance(detail, str) and detail.strip():
|
|
103
|
+
return _printable(detail.strip())[:500]
|
|
104
|
+
return _printable(resp.text[:500]) or f"HTTP {resp.status_code}"
|
|
105
|
+
|
|
106
|
+
|
|
67
107
|
class Apprentice:
|
|
68
108
|
def __init__(
|
|
69
109
|
self,
|
|
@@ -78,6 +118,8 @@ class Apprentice:
|
|
|
78
118
|
base_url or os.environ.get("APPRENTICE_BASE_URL") or _DEFAULT_BASE_URL
|
|
79
119
|
).rstrip("/")
|
|
80
120
|
api_key = api_key or os.environ.get("APPRENTICE_API_KEY")
|
|
121
|
+
if api_key is not None:
|
|
122
|
+
_reject_unusable_api_key(api_key)
|
|
81
123
|
self._api_key_present = api_key is not None
|
|
82
124
|
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
83
125
|
self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
@@ -97,24 +139,48 @@ class Apprentice:
|
|
|
97
139
|
|
|
98
140
|
def _request(self, method: str, path: str, json: Any | None = None) -> Any:
|
|
99
141
|
t0 = time.monotonic()
|
|
142
|
+
# The path carries caller-supplied names, so an absurd one would otherwise
|
|
143
|
+
# put its whole length into every message and log line, defeating the cap
|
|
144
|
+
# on the response body. httpx.InvalidURL is not an HTTPError, so an
|
|
145
|
+
# over-long path would escape the handler below.
|
|
146
|
+
shown_path = path if len(path) <= 200 else f"{path[:200]}... ({len(path)} chars)"
|
|
100
147
|
try:
|
|
101
148
|
resp = self._http.request(method, path, json=json)
|
|
149
|
+
except httpx.InvalidURL as exc:
|
|
150
|
+
raise ApprenticeError(f"{method} {shown_path} is not a valid URL: {exc}") from exc
|
|
102
151
|
except httpx.HTTPError as exc:
|
|
103
|
-
logger.error("%s %s failed after %.0fms: %s", method,
|
|
152
|
+
logger.error("%s %s failed after %.0fms: %s", method, shown_path, _ms(t0), exc)
|
|
104
153
|
raise ApprenticeError(
|
|
105
|
-
f"{method} {
|
|
154
|
+
f"{method} {shown_path} failed: {exc}. "
|
|
106
155
|
f"Is the Apprentice backend reachable at {self.base_url}?"
|
|
107
156
|
) from exc
|
|
108
|
-
logger.debug("%s %s -> %s (%.0fms)", method,
|
|
157
|
+
logger.debug("%s %s -> %s (%.0fms)", method, shown_path, resp.status_code, _ms(t0))
|
|
109
158
|
if resp.status_code >= 400:
|
|
110
|
-
logger.error("%s %s -> %s: %s", method,
|
|
159
|
+
logger.error("%s %s -> %s: %s", method, shown_path, resp.status_code, resp.text[:500])
|
|
160
|
+
# The API sends a written reason in `detail`; lead with it so the first
|
|
161
|
+
# thing a reader sees is why the call failed. The route cannot tell us
|
|
162
|
+
# that: a 404 on /v1/prompts/{task} means the task has no optimized
|
|
163
|
+
# prompt yet, not that the task is missing.
|
|
111
164
|
suffix = (
|
|
112
|
-
"
|
|
165
|
+
" Get a free Apprentice API key at https://runapprentice.com"
|
|
113
166
|
if resp.status_code in {401, 403}
|
|
114
167
|
else ""
|
|
115
168
|
)
|
|
116
|
-
raise ApprenticeError(
|
|
117
|
-
|
|
169
|
+
raise ApprenticeError(
|
|
170
|
+
f"{_reason(resp)} ({method} {shown_path} -> {resp.status_code}){suffix}"
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
return resp.json()
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
# A success with no body (204) or a non-JSON body from a proxy would
|
|
176
|
+
# otherwise raise JSONDecodeError, escaping every caller that catches
|
|
177
|
+
# ApprenticeError.
|
|
178
|
+
if not resp.content:
|
|
179
|
+
return {}
|
|
180
|
+
raise ApprenticeError(
|
|
181
|
+
f"{method} {shown_path} -> {resp.status_code} returned a body that is not JSON: "
|
|
182
|
+
f"{resp.text[:200]}"
|
|
183
|
+
) from exc
|
|
118
184
|
|
|
119
185
|
def optimize(
|
|
120
186
|
self,
|
|
@@ -123,6 +189,7 @@ class Apprentice:
|
|
|
123
189
|
budget: Literal["quick", "thorough"] | None = None,
|
|
124
190
|
metric: str | None = None,
|
|
125
191
|
) -> Job:
|
|
192
|
+
"""Queue hosted optimization over gold and silver rows, returning its Job immediately."""
|
|
126
193
|
payload = {"task": task}
|
|
127
194
|
payload.update(
|
|
128
195
|
{
|
|
@@ -145,6 +212,7 @@ class Apprentice:
|
|
|
145
212
|
budget: Literal["quick", "thorough"] | None = None,
|
|
146
213
|
metric: str | None = None,
|
|
147
214
|
) -> OptimizationPreview:
|
|
215
|
+
"""Return metric guidance and cost estimates without queueing optimization."""
|
|
148
216
|
payload = {"task": task}
|
|
149
217
|
payload.update(
|
|
150
218
|
{
|
|
@@ -161,21 +229,26 @@ class Apprentice:
|
|
|
161
229
|
return OptimizationPreview.model_validate(data)
|
|
162
230
|
|
|
163
231
|
def job(self, job_id: str) -> Job:
|
|
232
|
+
"""Fetch current job information and return its Job proxy."""
|
|
164
233
|
data = self._request("GET", f"/v1/jobs/{job_id}")
|
|
165
234
|
return Job(self, JobInfo.model_validate(data))
|
|
166
235
|
|
|
167
236
|
def jobs(self, task: str) -> list[JobSummary]:
|
|
237
|
+
"""Return every hosted job for a task, newest first, whatever its kind."""
|
|
168
238
|
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/jobs")
|
|
169
239
|
return [JobSummary.model_validate(item) for item in data]
|
|
170
240
|
|
|
171
241
|
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
242
|
+
"""Request job cancellation and return the API response."""
|
|
172
243
|
return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
173
244
|
|
|
174
245
|
def local_runs(self, task: str) -> list[LocalRun]:
|
|
246
|
+
"""Return tracked local training runs for a task."""
|
|
175
247
|
data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/local-runs")
|
|
176
248
|
return [LocalRun.model_validate(item) for item in data]
|
|
177
249
|
|
|
178
250
|
def train(self, task: str) -> Job:
|
|
251
|
+
"""Queue hosted training over gold rows only, returning its Job immediately."""
|
|
179
252
|
data = self._request("POST", f"/v1/tasks/{quote(task, safe='')}/train", json={})
|
|
180
253
|
return Job(self, JobInfo.model_validate(data))
|
|
181
254
|
|
|
@@ -185,6 +258,7 @@ class Apprentice:
|
|
|
185
258
|
data: str | Path | None = None,
|
|
186
259
|
**options: Any,
|
|
187
260
|
) -> dict[str, Any]:
|
|
261
|
+
"""Optimize locally using a CSV or the task's gold and silver rows, returning results."""
|
|
188
262
|
from runapprentice.local import load_csv_rows, optimize_prompt_local
|
|
189
263
|
|
|
190
264
|
if data is None:
|
|
@@ -203,6 +277,7 @@ class Apprentice:
|
|
|
203
277
|
effort: str | None = None,
|
|
204
278
|
**options: Any,
|
|
205
279
|
) -> dict[str, Any]:
|
|
280
|
+
"""Train locally using a CSV or the task's gold rows, returning run results."""
|
|
206
281
|
from runapprentice.local import load_csv_rows, train_mlx_local
|
|
207
282
|
|
|
208
283
|
if data is None:
|
|
@@ -222,6 +297,7 @@ class Apprentice:
|
|
|
222
297
|
seed: int,
|
|
223
298
|
config: dict[str, Any] | None = None,
|
|
224
299
|
) -> LocalRun:
|
|
300
|
+
"""Register a local training run and return its tracking record."""
|
|
225
301
|
data = self._request(
|
|
226
302
|
"POST",
|
|
227
303
|
"/v1/local-runs",
|
|
@@ -236,6 +312,7 @@ class Apprentice:
|
|
|
236
312
|
return LocalRun.model_validate(data)
|
|
237
313
|
|
|
238
314
|
def complete_local_run(self, run_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
315
|
+
"""Submit a local run result and return the API response."""
|
|
239
316
|
return dict(self._request("POST", f"/v1/local-runs/{run_id}/complete", json=payload))
|
|
240
317
|
|
|
241
318
|
def feedback(
|
|
@@ -245,6 +322,7 @@ class Apprentice:
|
|
|
245
322
|
score: float | None = None,
|
|
246
323
|
note: str | None = None,
|
|
247
324
|
) -> None:
|
|
325
|
+
"""Flush pending captures, then submit feedback for a trace."""
|
|
248
326
|
if self._dispatcher is not None and not self._dispatcher.flush():
|
|
249
327
|
raise ApprenticeError(
|
|
250
328
|
"feedback not sent because pending captures did not finish before the flush timeout"
|
|
@@ -368,6 +446,7 @@ class Apprentice:
|
|
|
368
446
|
logger.debug("runapprentice: %s", message)
|
|
369
447
|
|
|
370
448
|
def close(self) -> None:
|
|
449
|
+
"""Flush pending captures and close transport resources."""
|
|
371
450
|
if self._dispatcher is not None:
|
|
372
451
|
self._dispatcher.flush()
|
|
373
452
|
self._dispatcher.close()
|
|
@@ -387,11 +466,18 @@ class Job:
|
|
|
387
466
|
def status(self) -> str:
|
|
388
467
|
return self.info.status
|
|
389
468
|
|
|
469
|
+
@property
|
|
470
|
+
def error(self) -> str | None:
|
|
471
|
+
"""Return failure detail reported for the job, if any."""
|
|
472
|
+
return self.info.error
|
|
473
|
+
|
|
390
474
|
def refresh(self) -> Job:
|
|
475
|
+
"""Refresh this proxy in place and return it."""
|
|
391
476
|
self.info = self._client.job(self.info.job_id).info
|
|
392
477
|
return self
|
|
393
478
|
|
|
394
479
|
def wait(self, poll_seconds: float = 5.0, timeout_seconds: float = 3600.0) -> Job:
|
|
480
|
+
"""Poll until the job finishes; raise ApprenticeError on timeout and return this Job."""
|
|
395
481
|
deadline = time.monotonic() + timeout_seconds
|
|
396
482
|
while self.refresh().info.status in ("queued", "running"):
|
|
397
483
|
if time.monotonic() > deadline:
|
|
@@ -400,9 +486,11 @@ class Job:
|
|
|
400
486
|
return self
|
|
401
487
|
|
|
402
488
|
def cancel(self) -> dict[str, Any]:
|
|
489
|
+
"""Request cancellation and return the API response."""
|
|
403
490
|
return self._client.cancel_job(self.info.job_id)
|
|
404
491
|
|
|
405
492
|
def report(self) -> JobReport:
|
|
493
|
+
"""Return the finished job report, or raise ApprenticeError when unavailable."""
|
|
406
494
|
if self.info.report is None:
|
|
407
495
|
raise ApprenticeError(
|
|
408
496
|
f"job {self.info.job_id} has no report (status={self.info.status})"
|
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import stat
|
|
6
|
+
from io import StringIO
|
|
5
7
|
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
8
|
+
from typing import Any, Callable
|
|
7
9
|
|
|
10
|
+
import httpx
|
|
8
11
|
import pytest
|
|
9
12
|
|
|
10
|
-
from runapprentice import ApprenticeError, cli
|
|
13
|
+
from runapprentice import Apprentice, ApprenticeError, cli
|
|
11
14
|
import runapprentice.local as local
|
|
12
15
|
from runapprentice.models import DatasetRow, JobInfo, JobSummary
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
def _mock_login_api(
|
|
19
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
20
|
+
handler: Callable[[httpx.Request], httpx.Response],
|
|
21
|
+
) -> None:
|
|
22
|
+
def client(*, api_key: str, **_kwargs: Any) -> Apprentice:
|
|
23
|
+
result = Apprentice(
|
|
24
|
+
api_key=api_key,
|
|
25
|
+
base_url="http://mock",
|
|
26
|
+
capture_async=False,
|
|
27
|
+
)
|
|
28
|
+
result._http = httpx.Client(
|
|
29
|
+
base_url="http://mock",
|
|
30
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
31
|
+
transport=httpx.MockTransport(handler),
|
|
32
|
+
)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(cli, "Apprentice", client)
|
|
36
|
+
|
|
37
|
+
|
|
15
38
|
def test_parser_supports_required_commands() -> None:
|
|
16
39
|
parser = cli.build_parser()
|
|
17
40
|
|
|
@@ -48,6 +71,33 @@ def test_parser_supports_required_commands() -> None:
|
|
|
48
71
|
assert parser.parse_args(["cancel", "j1"]).command == "cancel"
|
|
49
72
|
|
|
50
73
|
|
|
74
|
+
def test_task_not_found_prints_once(
|
|
75
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
76
|
+
) -> None:
|
|
77
|
+
client = Apprentice(api_key="test", base_url="http://mock", capture_async=False)
|
|
78
|
+
client._http = httpx.Client(
|
|
79
|
+
base_url="http://mock",
|
|
80
|
+
transport=httpx.MockTransport(
|
|
81
|
+
lambda _request: httpx.Response(404, json={"detail": "task not found"})
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
monkeypatch.setattr(cli, "Apprentice", lambda **_kwargs: client)
|
|
85
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.json")
|
|
86
|
+
monkeypatch.setattr(cli, "PENDING_DIR", tmp_path / "pending")
|
|
87
|
+
|
|
88
|
+
assert cli.main(["status", "no-such-task"]) == 1
|
|
89
|
+
|
|
90
|
+
assert capsys.readouterr().err.splitlines() == [
|
|
91
|
+
"task not found (GET /v1/datasets/no-such-task/status -> 404)"
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
assert cli.main(["--json", "status", "no-such-task"]) == 1
|
|
95
|
+
payload = json.loads(capsys.readouterr().err)
|
|
96
|
+
# --json keeps both halves: the reason to show a user, and the request that
|
|
97
|
+
# produced it so the failure can still be traced.
|
|
98
|
+
assert payload["error"] == "task not found (GET /v1/datasets/no-such-task/status -> 404)"
|
|
99
|
+
|
|
100
|
+
|
|
51
101
|
def test_cli_renders_all_job_kinds() -> None:
|
|
52
102
|
for kind, report in (
|
|
53
103
|
("optimize", {"task": "extract", "baseline_score": 50.0}),
|
|
@@ -283,13 +333,110 @@ def test_login_writes_config_0600(
|
|
|
283
333
|
config_path = config_dir / "config.json"
|
|
284
334
|
monkeypatch.setattr(cli, "CONFIG_DIR", config_dir)
|
|
285
335
|
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
336
|
+
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
|
|
286
337
|
monkeypatch.setattr("runapprentice.cli.getpass.getpass", lambda _: "secret-key")
|
|
338
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
287
339
|
|
|
288
340
|
assert cli.main(["login"]) == 0
|
|
289
341
|
|
|
290
342
|
assert json.loads(config_path.read_text(encoding="utf-8")) == {"api_key": "secret-key"}
|
|
291
343
|
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
292
|
-
|
|
344
|
+
out = capsys.readouterr().out
|
|
345
|
+
assert "secret-key" not in out
|
|
346
|
+
assert "API key verified" in out
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_login_rejects_invalid_key_without_writing(
|
|
350
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
351
|
+
) -> None:
|
|
352
|
+
config_path = tmp_path / "config.json"
|
|
353
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
354
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("invalid-key\n"))
|
|
355
|
+
_mock_login_api(
|
|
356
|
+
monkeypatch,
|
|
357
|
+
lambda _request: httpx.Response(401, json={"detail": "invalid API key"}),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
assert cli.main(["login"]) == 1
|
|
361
|
+
assert not config_path.exists()
|
|
362
|
+
assert capsys.readouterr().err == "API key was rejected and was not saved.\n"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_failed_login_does_not_clobber_existing_config(
|
|
366
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
367
|
+
) -> None:
|
|
368
|
+
config_path = tmp_path / "config.json"
|
|
369
|
+
original = '{"api_key": "working-key"}\n'
|
|
370
|
+
config_path.write_text(original, encoding="utf-8")
|
|
371
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
372
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("invalid-key\n"))
|
|
373
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(403, json={"detail": "forbidden"}))
|
|
374
|
+
|
|
375
|
+
assert cli.main(["login"]) == 1
|
|
376
|
+
assert config_path.read_text(encoding="utf-8") == original
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_login_reports_unreachable_api_differently_from_rejected_key(
|
|
380
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
381
|
+
) -> None:
|
|
382
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.json")
|
|
383
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("candidate-key\n"))
|
|
384
|
+
|
|
385
|
+
def unreachable(request: httpx.Request) -> httpx.Response:
|
|
386
|
+
raise httpx.ConnectError("connection refused", request=request)
|
|
387
|
+
|
|
388
|
+
_mock_login_api(monkeypatch, unreachable)
|
|
389
|
+
|
|
390
|
+
assert cli.main(["login"]) == 1
|
|
391
|
+
err = capsys.readouterr().err
|
|
392
|
+
assert "API is unavailable" in err
|
|
393
|
+
assert "rejected" not in err
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_login_reports_server_error_as_unavailable(
|
|
397
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
398
|
+
) -> None:
|
|
399
|
+
config_path = tmp_path / "config.json"
|
|
400
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
401
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("candidate-key\n"))
|
|
402
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(503))
|
|
403
|
+
|
|
404
|
+
assert cli.main(["login"]) == 1
|
|
405
|
+
assert not config_path.exists()
|
|
406
|
+
assert "API is unavailable" in capsys.readouterr().err
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_corrupt_config_reports_clean_error(
|
|
410
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
411
|
+
) -> None:
|
|
412
|
+
config_path = tmp_path / "config.json"
|
|
413
|
+
config_path.write_text("not json{", encoding="utf-8")
|
|
414
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
415
|
+
|
|
416
|
+
assert cli.main(["status", "extract"]) == 1
|
|
417
|
+
err = capsys.readouterr().err
|
|
418
|
+
assert "invalid JSON" in err
|
|
419
|
+
assert "apprentice login" in err
|
|
420
|
+
assert "Traceback" not in err
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_login_reads_piped_stdin_and_validates_it(
|
|
424
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
425
|
+
) -> None:
|
|
426
|
+
config_path = tmp_path / "config.json"
|
|
427
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
428
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO(" piped-key \n"))
|
|
429
|
+
|
|
430
|
+
def validate(request: httpx.Request) -> httpx.Response:
|
|
431
|
+
assert request.url.path == "/v1/tasks"
|
|
432
|
+
assert request.headers["Authorization"] == "Bearer piped-key"
|
|
433
|
+
return httpx.Response(200, json=[{"name": "extract"}])
|
|
434
|
+
|
|
435
|
+
_mock_login_api(monkeypatch, validate)
|
|
436
|
+
|
|
437
|
+
assert cli.main(["--json", "login"]) == 0
|
|
438
|
+
assert json.loads(config_path.read_text(encoding="utf-8")) == {"api_key": "piped-key"}
|
|
439
|
+
assert json.loads(capsys.readouterr().out)["validated"] is True
|
|
293
440
|
|
|
294
441
|
|
|
295
442
|
def test_env_api_key_overrides_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -388,3 +535,158 @@ def test_local_optimize_missing_dspy_message(
|
|
|
388
535
|
|
|
389
536
|
assert cli.main(["optimize", "extract", "--local", "--data", str(data)]) == 1
|
|
390
537
|
assert "pip install 'runapprentice[optimize]'" in capsys.readouterr().err
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_login_rejects_a_key_containing_whitespace(
|
|
541
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
542
|
+
) -> None:
|
|
543
|
+
"""A two-line file piped in produces a key with a newline in it.
|
|
544
|
+
|
|
545
|
+
httpx raises LocalProtocolError on the header before sending, so without
|
|
546
|
+
this guard the user is told the API is unavailable when nothing was sent.
|
|
547
|
+
"""
|
|
548
|
+
config_path = tmp_path / "config.json"
|
|
549
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
550
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\nJUNK-SECOND-LINE\n"))
|
|
551
|
+
|
|
552
|
+
def explode(_request: httpx.Request) -> httpx.Response:
|
|
553
|
+
raise AssertionError("a malformed key must never reach the network")
|
|
554
|
+
|
|
555
|
+
_mock_login_api(monkeypatch, explode)
|
|
556
|
+
|
|
557
|
+
assert cli.main(["login"]) == 1
|
|
558
|
+
assert not config_path.exists()
|
|
559
|
+
assert capsys.readouterr().err == "API key contains whitespace, so it was not saved.\n"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def test_login_directory_is_not_world_writable_under_a_permissive_umask(
|
|
563
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
564
|
+
) -> None:
|
|
565
|
+
"""mkdir's mode is masked by the umask, so it must be set explicitly.
|
|
566
|
+
|
|
567
|
+
A world-writable parent lets another local account replace the key file no
|
|
568
|
+
matter what the file's own mode is.
|
|
569
|
+
"""
|
|
570
|
+
config_path = tmp_path / "nested" / "config.json"
|
|
571
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
572
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
573
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
574
|
+
|
|
575
|
+
previous = os.umask(0)
|
|
576
|
+
try:
|
|
577
|
+
assert cli.main(["login"]) == 0
|
|
578
|
+
finally:
|
|
579
|
+
os.umask(previous)
|
|
580
|
+
|
|
581
|
+
assert stat.S_IMODE(config_path.parent.stat().st_mode) == 0o700
|
|
582
|
+
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def test_config_with_a_non_string_api_key_reports_instead_of_silently_ignoring(
|
|
586
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
587
|
+
) -> None:
|
|
588
|
+
config_path = tmp_path / "config.json"
|
|
589
|
+
config_path.write_text(json.dumps({"api_key": 12345}), encoding="utf-8")
|
|
590
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
591
|
+
monkeypatch.delenv("APPRENTICE_API_KEY", raising=False)
|
|
592
|
+
|
|
593
|
+
assert cli.main(["status", "any-task"]) == 1
|
|
594
|
+
assert "api_key must be a string" in capsys.readouterr().err
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@pytest.mark.parametrize(
|
|
598
|
+
"key",
|
|
599
|
+
["ap_live_café", "ap_live_\U0001f511", "ap_live_a\x01b"],
|
|
600
|
+
ids=["accented", "emoji", "control-char"],
|
|
601
|
+
)
|
|
602
|
+
def test_login_rejects_a_key_that_is_not_printable_ascii(
|
|
603
|
+
key: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
604
|
+
) -> None:
|
|
605
|
+
"""httpx encodes headers as ASCII and raises UnicodeEncodeError otherwise.
|
|
606
|
+
|
|
607
|
+
That is not an httpx.HTTPError, so without this guard it escapes _login's
|
|
608
|
+
handler and reaches the user as a traceback.
|
|
609
|
+
"""
|
|
610
|
+
config_path = tmp_path / "config.json"
|
|
611
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
612
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO(key + "\n"))
|
|
613
|
+
|
|
614
|
+
def explode(_request: httpx.Request) -> httpx.Response:
|
|
615
|
+
raise AssertionError("a malformed key must never reach the network")
|
|
616
|
+
|
|
617
|
+
_mock_login_api(monkeypatch, explode)
|
|
618
|
+
|
|
619
|
+
assert cli.main(["login"]) == 1
|
|
620
|
+
assert not config_path.exists()
|
|
621
|
+
assert "printable ASCII" in capsys.readouterr().err
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def test_login_never_leaves_the_key_world_readable(
|
|
625
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
626
|
+
) -> None:
|
|
627
|
+
"""The key must never exist on disk at a readable mode, even briefly.
|
|
628
|
+
|
|
629
|
+
write-then-chmod leaves it at 0666 under a permissive umask until the chmod
|
|
630
|
+
lands; a process killed in that window leaves it that way permanently. This
|
|
631
|
+
asserts the mode observed at creation, not just the final mode.
|
|
632
|
+
"""
|
|
633
|
+
config_path = tmp_path / "nested" / "config.json"
|
|
634
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
635
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
636
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
637
|
+
|
|
638
|
+
seen_modes: list[int] = []
|
|
639
|
+
real_replace = os.replace
|
|
640
|
+
|
|
641
|
+
def spy(src: Any, dst: Any) -> None:
|
|
642
|
+
# The moment before the file becomes visible at its final path.
|
|
643
|
+
seen_modes.append(stat.S_IMODE(os.stat(src).st_mode))
|
|
644
|
+
real_replace(src, dst)
|
|
645
|
+
|
|
646
|
+
monkeypatch.setattr(cli.os, "replace", spy)
|
|
647
|
+
|
|
648
|
+
previous = os.umask(0)
|
|
649
|
+
try:
|
|
650
|
+
assert cli.main(["login"]) == 0
|
|
651
|
+
finally:
|
|
652
|
+
os.umask(previous)
|
|
653
|
+
|
|
654
|
+
assert seen_modes == [0o600], f"key was written at {[oct(m) for m in seen_modes]}"
|
|
655
|
+
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def test_login_replaces_a_symlinked_config_instead_of_its_target(
|
|
659
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
660
|
+
) -> None:
|
|
661
|
+
victim = tmp_path / "victim.txt"
|
|
662
|
+
victim.write_text("IMPORTANT DATA", encoding="utf-8")
|
|
663
|
+
config_path = tmp_path / "config.json"
|
|
664
|
+
config_path.symlink_to(victim)
|
|
665
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
|
|
666
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
667
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
668
|
+
|
|
669
|
+
assert cli.main(["login"]) == 0
|
|
670
|
+
|
|
671
|
+
assert victim.read_text(encoding="utf-8") == "IMPORTANT DATA"
|
|
672
|
+
assert not config_path.is_symlink()
|
|
673
|
+
assert json.loads(config_path.read_text(encoding="utf-8"))["api_key"] == "good-key"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def test_login_reports_a_filesystem_failure_instead_of_a_traceback(
|
|
677
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
678
|
+
) -> None:
|
|
679
|
+
"""The key validated, so only the write can still fail.
|
|
680
|
+
|
|
681
|
+
In --json mode a traceback also means no JSON is emitted at all, which breaks
|
|
682
|
+
the contract that --json always prints one object.
|
|
683
|
+
"""
|
|
684
|
+
# A path whose parent cannot exist: /dev/null is a file, not a directory.
|
|
685
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", Path("/dev/null/nope/config.json"))
|
|
686
|
+
monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
|
|
687
|
+
_mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
|
|
688
|
+
|
|
689
|
+
assert cli.main(["--json", "login"]) == 1
|
|
690
|
+
|
|
691
|
+
payload = json.loads(capsys.readouterr().err)
|
|
692
|
+
assert "Could not save the API key" in payload["error"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Control-plane client: upload, status, optimize/job lifecycle, error clarity."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import logging
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
import re
|
|
6
7
|
import threading
|
|
@@ -10,7 +11,7 @@ from typing import Any
|
|
|
10
11
|
import httpx
|
|
11
12
|
import pytest
|
|
12
13
|
|
|
13
|
-
from runapprentice import Apprentice, ApprenticeError, TraceRecord
|
|
14
|
+
from runapprentice import Apprentice, ApprenticeError, Job, TraceRecord
|
|
14
15
|
from runapprentice._capture import CaptureDispatcher
|
|
15
16
|
import runapprentice.local as local
|
|
16
17
|
from runapprentice.models import (
|
|
@@ -38,6 +39,14 @@ def make_client(routes: dict[str, Any]) -> tuple[Apprentice, list[dict[str, Any]
|
|
|
38
39
|
return client, received
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
def test_package_logging_is_silent_by_default(capsys: pytest.CaptureFixture[str]) -> None:
|
|
43
|
+
logger = logging.getLogger("runapprentice")
|
|
44
|
+
|
|
45
|
+
assert any(isinstance(handler, logging.NullHandler) for handler in logger.handlers)
|
|
46
|
+
logger.error("must stay silent")
|
|
47
|
+
assert capsys.readouterr().err == ""
|
|
48
|
+
|
|
49
|
+
|
|
41
50
|
def test_dataset_upload_from_rows() -> None:
|
|
42
51
|
client, received = make_client(
|
|
43
52
|
{
|
|
@@ -234,6 +243,15 @@ def test_optimize_and_job_lifecycle() -> None:
|
|
|
234
243
|
assert report.optimized_score == 88.0
|
|
235
244
|
|
|
236
245
|
|
|
246
|
+
def test_job_error_is_reachable() -> None:
|
|
247
|
+
job = Job(
|
|
248
|
+
Apprentice(api_key="test", base_url="http://mock", capture_async=False),
|
|
249
|
+
JobInfo(job_id="j1", task="t1", kind="train", status="failed", error="training failed"),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
assert job.error == "training failed"
|
|
253
|
+
|
|
254
|
+
|
|
237
255
|
def test_optimization_report_round_trips_every_api_field() -> None:
|
|
238
256
|
payload = {
|
|
239
257
|
"task": "extract",
|
|
@@ -603,6 +621,79 @@ def test_http_4xx_raises_with_body() -> None:
|
|
|
603
621
|
client.tasks.create("nope")
|
|
604
622
|
|
|
605
623
|
|
|
624
|
+
def test_error_leads_with_the_reason_the_api_gave() -> None:
|
|
625
|
+
client, _ = make_client({})
|
|
626
|
+
|
|
627
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
628
|
+
client.datasets.status("no-such-task")
|
|
629
|
+
|
|
630
|
+
assert str(exc_info.value) == (
|
|
631
|
+
"not found (GET /v1/datasets/no-such-task/status -> 404)"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@pytest.mark.parametrize(
|
|
636
|
+
"body",
|
|
637
|
+
[["upstream failed"], "plain string", 42, None],
|
|
638
|
+
ids=["list", "string", "number", "null"],
|
|
639
|
+
)
|
|
640
|
+
def test_error_bodies_that_are_not_json_objects_still_raise_apprentice_error(body: object) -> None:
|
|
641
|
+
"""A gateway can return any JSON shape, not the object FastAPI sends.
|
|
642
|
+
|
|
643
|
+
Reading `.get("detail")` off a list or string raises AttributeError, which
|
|
644
|
+
escapes past every caller that catches ApprenticeError.
|
|
645
|
+
"""
|
|
646
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
647
|
+
client._http = httpx.Client(
|
|
648
|
+
base_url="http://mock",
|
|
649
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(500, json=body)),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
with pytest.raises(ApprenticeError):
|
|
653
|
+
client._request("GET", "/v1/jobs/x")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def test_a_long_server_detail_is_capped() -> None:
|
|
657
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
658
|
+
client._http = httpx.Client(
|
|
659
|
+
base_url="http://mock",
|
|
660
|
+
transport=httpx.MockTransport(
|
|
661
|
+
lambda _request: httpx.Response(500, json={"detail": "x" * 5000})
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
666
|
+
client._request("GET", "/v1/jobs/x")
|
|
667
|
+
|
|
668
|
+
assert len(str(exc_info.value)) < 700
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def test_a_404_does_not_claim_the_task_is_missing() -> None:
|
|
672
|
+
"""A 404 on a task-scoped route often means something else is missing.
|
|
673
|
+
|
|
674
|
+
/v1/templates/{task} and /v1/prompts/{task} both 404 for a task that exists
|
|
675
|
+
and is healthy, so reading "task not found" off the route shape states
|
|
676
|
+
something false. Verified against production: a template fetch for a task
|
|
677
|
+
with 24 rows and a succeeded optimize job returns 404 "no template".
|
|
678
|
+
"""
|
|
679
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
680
|
+
client._http = httpx.Client(
|
|
681
|
+
base_url="http://mock",
|
|
682
|
+
transport=httpx.MockTransport(
|
|
683
|
+
lambda _request: httpx.Response(
|
|
684
|
+
404, json={"detail": "no optimized prompt for task 'live' yet. Run optimize first."}
|
|
685
|
+
)
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
690
|
+
client._request("GET", "/v1/prompts/live")
|
|
691
|
+
|
|
692
|
+
message = str(exc_info.value)
|
|
693
|
+
assert message.startswith("no optimized prompt for task 'live' yet.")
|
|
694
|
+
assert "was not found" not in message
|
|
695
|
+
|
|
696
|
+
|
|
606
697
|
def test_auth_error_mentions_free_key() -> None:
|
|
607
698
|
client = Apprentice(api_key=None, base_url="http://mock", capture_async=False)
|
|
608
699
|
client._http = httpx.Client(
|
|
@@ -795,3 +886,82 @@ def test_task_names_with_url_delimiters_are_quoted() -> None:
|
|
|
795
886
|
client.datasets.status("a/b?c")
|
|
796
887
|
|
|
797
888
|
assert seen[0] == b"/v1/datasets/a%2Fb%3Fc/status"
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def test_a_success_with_no_body_does_not_leak_a_json_error() -> None:
|
|
892
|
+
"""204 and non-JSON success bodies must not escape as JSONDecodeError.
|
|
893
|
+
|
|
894
|
+
A caller catching ApprenticeError would otherwise crash on a proxy response.
|
|
895
|
+
"""
|
|
896
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
897
|
+
client._http = httpx.Client(
|
|
898
|
+
base_url="http://mock",
|
|
899
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(204)),
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
assert client._request("DELETE", "/v1/keys/x") == {}
|
|
903
|
+
|
|
904
|
+
client._http = httpx.Client(
|
|
905
|
+
base_url="http://mock",
|
|
906
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(200, text="<html>hi</html>")),
|
|
907
|
+
)
|
|
908
|
+
with pytest.raises(ApprenticeError, match="not JSON"):
|
|
909
|
+
client._request("GET", "/v1/jobs/x")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@pytest.mark.parametrize(
|
|
913
|
+
"key", ["ap_live_café", "ap_live_a b", "ap_live_\x01"], ids=["non-ascii", "space", "control"]
|
|
914
|
+
)
|
|
915
|
+
def test_client_rejects_a_key_that_cannot_become_a_header(key: str) -> None:
|
|
916
|
+
"""Every path that supplies a key must fail the same way.
|
|
917
|
+
|
|
918
|
+
httpx raises UnicodeEncodeError deep in the transport otherwise, which is not
|
|
919
|
+
an ApprenticeError and reaches a CLI user as a traceback.
|
|
920
|
+
"""
|
|
921
|
+
with pytest.raises(ApprenticeError):
|
|
922
|
+
Apprentice(api_key=key, base_url="http://mock", capture_async=False)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def test_api_key_from_the_environment_is_validated_too(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
926
|
+
monkeypatch.setenv("APPRENTICE_API_KEY", "ap_live_café")
|
|
927
|
+
with pytest.raises(ApprenticeError):
|
|
928
|
+
Apprentice(base_url="http://mock", capture_async=False)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def test_an_absurd_path_is_capped_and_never_leaks_invalid_url() -> None:
|
|
932
|
+
"""A caller-supplied name lands in the path, so it must not size the message.
|
|
933
|
+
|
|
934
|
+
The 500-char body cap is defeated if the path itself is unbounded, and
|
|
935
|
+
httpx.InvalidURL is not an HTTPError so it would escape the handler.
|
|
936
|
+
"""
|
|
937
|
+
client = Apprentice(api_key="safe", base_url="http://mock", capture_async=False)
|
|
938
|
+
client._http = httpx.Client(
|
|
939
|
+
base_url="http://mock",
|
|
940
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(400, json={"detail": "bad"})),
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
for size in (60_000, 66_000):
|
|
944
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
945
|
+
client.datasets.status("x" * size)
|
|
946
|
+
assert len(str(exc_info.value)) < 400, f"message was {len(str(exc_info.value))} chars"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def test_a_hostile_server_message_cannot_repaint_the_terminal() -> None:
|
|
950
|
+
"""Error text is server-controlled and lands in terminals and CI logs.
|
|
951
|
+
|
|
952
|
+
Escape sequences there can clear the screen and rewrite earlier lines, so a
|
|
953
|
+
backend could make its own error read as a success.
|
|
954
|
+
"""
|
|
955
|
+
hostile = "ok\x1b[2J\x1b[Hnothing to see\x07\rFAKE: login succeeded"
|
|
956
|
+
client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
|
|
957
|
+
client._http = httpx.Client(
|
|
958
|
+
base_url="http://mock",
|
|
959
|
+
transport=httpx.MockTransport(lambda _request: httpx.Response(400, json={"detail": hostile})),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
with pytest.raises(ApprenticeError) as exc_info:
|
|
963
|
+
client._request("GET", "/v1/jobs/x")
|
|
964
|
+
|
|
965
|
+
message = str(exc_info.value)
|
|
966
|
+
assert "\x1b" not in message and "\x07" not in message and "\r" not in message
|
|
967
|
+
assert "FAKE: login succeeded" in message # kept, but inert
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|