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.
Files changed (22) hide show
  1. {runapprentice-0.4.1 → runapprentice-0.4.2}/PKG-INFO +5 -5
  2. {runapprentice-0.4.1 → runapprentice-0.4.2}/README.md +4 -4
  3. {runapprentice-0.4.1 → runapprentice-0.4.2}/pyproject.toml +1 -1
  4. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/cli.py +97 -9
  5. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/client.py +95 -7
  6. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_cli.py +305 -3
  7. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_client.py +171 -1
  8. {runapprentice-0.4.1 → runapprentice-0.4.2}/.gitignore +0 -0
  9. {runapprentice-0.4.1 → runapprentice-0.4.2}/LICENSE +0 -0
  10. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/__init__.py +0 -0
  11. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/_capture.py +0 -0
  12. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/langchain.py +0 -0
  13. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/local.py +0 -0
  14. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/models.py +0 -0
  15. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/openai.py +0 -0
  16. {runapprentice-0.4.1 → runapprentice-0.4.2}/src/runapprentice/py.typed +0 -0
  17. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_capture.py +0 -0
  18. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_dispatcher.py +0 -0
  19. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_failopen.py +0 -0
  20. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_langchain_prompts.py +0 -0
  21. {runapprentice-0.4.1 → runapprentice-0.4.2}/tests/test_local.py +0 -0
  22. {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.1
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 on a CSV alone, no Apprentice account. No golden dataset yet? `apprentice
54
- login` also lets Apprentice generate a starter set and route rows to a subject-matter expert
55
- for review, and tracks every run in the console. `apprentice --help` (or any
56
- subcommand `--help`) documents every flag; `--json` gives clean machine-readable output.
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 on a CSV alone, no Apprentice account. No golden dataset yet? `apprentice
25
- login` also lets Apprentice generate a starter set and route rows to a subject-matter expert
26
- for review, and tracks every run in the console. `apprentice --help` (or any
27
- subcommand `--help`) documents every flag; `--json` gives clean machine-readable output.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- api_key = _api_key()
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 optimize/train jobs for a task, newest first")
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 = getpass.getpass("Apprentice API key: ").strip()
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
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
190
- CONFIG_PATH.write_text(json.dumps({"api_key": key}) + "\n", encoding="utf-8")
191
- CONFIG_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR)
192
- return _emit({"config": str(CONFIG_PATH), "mode": "600"}, "Saved API key.", bool(args.json))
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
- data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
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
- return key if isinstance(key, str) else None
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, path, _ms(t0), exc)
152
+ logger.error("%s %s failed after %.0fms: %s", method, shown_path, _ms(t0), exc)
104
153
  raise ApprenticeError(
105
- f"{method} {path} failed: {exc}. "
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, path, resp.status_code, _ms(t0))
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, path, resp.status_code, resp.text[:500])
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
- ". Get a free Apprentice API key at https://runapprentice.com"
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(f"{method} {path} -> {resp.status_code}: {resp.text[:500]}{suffix}")
117
- return resp.json()
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
- assert "secret-key" not in capsys.readouterr().out
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