forked from teng-lin/notebooklm-py
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patherror_handler.py
More file actions
350 lines (314 loc) · 13.2 KB
/
Copy patherror_handler.py
File metadata and controls
350 lines (314 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
"""Centralized CLI error handling.
This module provides a context manager for consistent error handling
across all CLI commands.
"""
import json
import logging
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any, NoReturn
import click
from ..exceptions import (
ArtifactTimeoutError,
AuthError,
ConfigurationError,
NetworkError,
NotebookLimitError,
NotebookLMError,
NotFoundError,
RateLimitError,
RPCError,
ValidationError,
)
from ._encoding import safe_echo
logger = logging.getLogger(__name__)
# NOTE: ``click.ClickException`` / raw ``raise SystemExit`` sites outside this
# module are governed by inline marker comments
# (``# cli-input-validation: <reason>`` / ``# cli-raw-exit: <reason>``), checked
# by ``tests/_guardrails/test_error_handler_allowlist.py``. The previous
# ``ALLOWED_*_SITES`` line-number allowlists were removed in issue #1298 because
# any edit above a site shifted its line and failed CI with no behavior change.
def current_json_output(default: bool = False) -> bool:
"""Infer the active Click command's JSON-output flag, if any."""
ctx = click.get_current_context(silent=True)
if ctx is None:
return default
try:
current: click.Context | None = ctx
while current is not None:
for key in ("json_output", "json"):
value = current.params.get(key)
if isinstance(value, bool):
return value
current = current.parent
except (AttributeError, RuntimeError):
return default
return default
def exit_with_code(exit_code: int = 1) -> NoReturn:
"""Canonical raw exit path for callers that already emitted their payload."""
raise SystemExit(exit_code)
# Per-``*NotFoundError`` resource-id attribute names, in MRO order. Each
# concrete subclass stores its missing-id under exactly one of these (e.g.
# ``SourceNotFoundError.source_id``, ``NotebookNotFoundError.notebook_id``),
# so the central handler can surface the id in the ``NOT_FOUND`` JSON envelope
# the same way the per-command sites do — without importing every subclass.
_NOT_FOUND_ID_ATTRS = (
"notebook_id",
"source_id",
"artifact_id",
"note_id",
"mind_map_id",
"label_id",
)
def _not_found_extra(error: NotFoundError) -> dict[str, Any]:
"""Build the JSON ``extra`` block for a ``*NotFoundError`` envelope.
Surfaces whichever resource-id attribute the concrete subclass carries
(``source_id`` / ``artifact_id`` / ``note_id`` / ``mind_map_id`` /
``label_id`` / ``notebook_id``) under both its native key and a generic
``id`` key, so
automation can read the id without knowing the exact not-found subtype —
mirroring the per-command ``source``/``artifact``/``note get`` payloads.
Returns an empty dict when no known id attribute is present (e.g. a future
``*NotFoundError`` subclass); the caller drops an empty ``extra``.
"""
extra: dict[str, Any] = {}
for attr in _NOT_FOUND_ID_ATTRS:
value = getattr(error, attr, None)
if value is not None:
extra[attr] = value
extra["id"] = value
break
return extra
def _generation_status_extra(status: Any) -> dict[str, Any]:
"""Serialize a GenerationStatus-like object for JSON error payloads."""
return {
"task_id": getattr(status, "task_id", None),
"status": getattr(status, "status", None),
"url": getattr(status, "url", None),
"error": getattr(status, "error", None),
"error_code": getattr(status, "error_code", None),
"metadata": getattr(status, "metadata", None),
}
def _output_error(
message: str,
code: str,
json_output: bool,
exit_code: int,
extra: dict[str, Any] | None = None,
hint: str | None = None,
) -> NoReturn:
"""Output error message in text or JSON format and exit.
Args:
message: Human-readable error message
code: Error code for JSON output (e.g., "RATE_LIMITED", "AUTH_ERROR")
json_output: If True, output as JSON; otherwise as text
exit_code: Exit code to use
extra: Additional fields to include in JSON output
hint: Additional hint to show in text mode
Note:
Also exported as the public alias :func:`output_error`. The leading
underscore name pre-dates the public-CLI-boundary contract enforced by
``tests/_guardrails/test_cli_boundary.py``; sibling ``cli/*`` modules may
import the private name directly (intra-package, level-1 relative
import), but ``cli/services/*`` and any other layer that crosses up
through ``..error_handler`` must use the public alias to stay on the
public side of that contract.
"""
if json_output:
response: dict = {"error": True, "code": code, "message": message}
if extra:
response.update(extra)
click.echo(json.dumps(response, indent=2, default=str, ensure_ascii=False))
else:
safe_echo(message, err=True)
if hint:
safe_echo(hint, err=True)
raise SystemExit(exit_code)
#: Public alias for :func:`_output_error` — see the function docstring for the
#: rationale. ``cli/services/*`` and other layers that must cross the CLI
#: package boundary import this name to stay on the public side of the
#: boundary contract enforced by ``tests/_guardrails/test_cli_boundary.py``.
output_error = _output_error
def emit_cancelled_and_exit(
resume_hint: str | None = None,
*,
json_output: bool = False,
extra: dict[str, Any] | None = None,
) -> NoReturn:
"""Emit a Ctrl-C cancellation message with an optional resume hint and exit 130.
Used by the long-running ``--wait`` paths so SIGINT during a poll
surfaces a friendly resume hint instead of a Python traceback. The hint
follows the canonical phrasing from the audit:
Cancelled. Resume with: notebooklm artifact poll <task_id>
For ``source wait`` the parallel hint is ``notebooklm source wait <id>``
(no separate poll command exists for sources).
Args:
resume_hint: Free-form resume command string. When ``None`` the helper
emits a plain ``Cancelled.`` line, matching the generic
KeyboardInterrupt branch in ``handle_errors``.
json_output: When True, emit a structured envelope on stdout
(``{"error": true, "code": "CANCELLED", ...}``) so automation can
still parse the cancellation. When False, write to stderr.
extra: Optional dict merged into the JSON envelope (e.g. ``{"task_id":
"abc"}``). Ignored in text mode — the resume hint already names
the resource.
Always raises ``SystemExit(130)`` (128 + signal 2 / SIGINT).
"""
if json_output:
response: dict[str, Any] = {
"error": True,
"code": "CANCELLED",
"message": "Cancelled by user",
}
if resume_hint:
response["resume_hint"] = resume_hint
if extra:
response.update(extra)
click.echo(json.dumps(response, indent=2, default=str, ensure_ascii=False))
else:
if resume_hint:
safe_echo(f"\nCancelled. Resume with: {resume_hint}", err=True)
else:
safe_echo("\nCancelled.", err=True)
raise SystemExit(130)
@contextmanager
def handle_errors(verbose: bool = False, json_output: bool = False) -> Generator[None, None, None]:
"""Context manager for consistent CLI error handling.
Catches library exceptions and converts them to user-friendly
error messages with appropriate exit codes.
Exit codes:
1: User/application error (validation, auth, rate limit, etc.)
2: System/unexpected error (bugs, unhandled exceptions)
130: Keyboard interrupt (128 + signal 2)
Args:
verbose: If True, show additional debug info (method_id, etc.)
json_output: If True, output errors as JSON
Example:
@click.command()
def my_command():
with handle_errors():
# ... command logic ...
"""
try:
yield
except KeyboardInterrupt:
if json_output:
_output_error("Cancelled by user", "CANCELLED", True, 130)
else:
safe_echo("\nCancelled.", err=True)
raise SystemExit(130) from None
except RateLimitError as e:
retry_msg = f" Retry after {e.retry_after}s." if e.retry_after else ""
extra_data: dict[str, Any] = {}
if e.retry_after:
extra_data["retry_after"] = e.retry_after
if verbose and e.method_id:
extra_data["method_id"] = e.method_id
_output_error(
f"Error: Rate limited.{retry_msg}",
"RATE_LIMITED",
json_output,
1,
extra=extra_data,
)
except AuthError as e:
_output_error(
f"Authentication error: {e}",
"AUTH_ERROR",
json_output,
1,
hint="Run 'notebooklm login' to re-authenticate.",
)
except ValidationError as e:
_output_error(f"Validation error: {e}", "VALIDATION_ERROR", json_output, 1)
except ConfigurationError as e:
_output_error(f"Configuration error: {e}", "CONFIG_ERROR", json_output, 1)
except NetworkError as e:
_output_error(
f"Network error: {e}",
"NETWORK_ERROR",
json_output,
1,
hint="Check your internet connection and try again.",
)
except NotebookLimitError as e:
_output_error(
str(e),
"NOTEBOOK_LIMIT",
json_output,
1,
extra=e.to_error_response_extra(),
)
except ArtifactTimeoutError as e:
extra_data = {
"notebook_id": e.notebook_id,
"task_id": e.task_id,
"timeout_seconds": e.timeout_seconds,
"last_status": e.last_status,
"status_history": list(e.status_history),
"status_transitions": [
_generation_status_extra(status) for status in e.status_transitions
],
"stalled_phase": e.stalled_phase,
}
_output_error(
f"Artifact timeout: {e}",
"ARTIFACT_TIMEOUT",
json_output,
1,
extra=extra_data,
)
except NotFoundError as e:
# The ``NotFoundError`` umbrella catches every concrete
# ``*NotFoundError`` (notebook / source / artifact / note / mind map),
# which all derive ``(NotFoundError, RPCError, <Domain>Error)``. Placed
# AFTER the more-specific branches (none of which are ancestors of a
# ``*NotFoundError`` — verified by the handler's exception MRO) and
# BEFORE the generic ``NotebookLMError`` catch-all so a missing resource
# emits the typed ``NOT_FOUND`` envelope (matching the per-command
# ``source``/``artifact``/``note get`` convention) instead of the
# generic ``NOTEBOOKLM_ERROR``. This makes the central handler faithful
# to the v0.8.0 raise-sites (e.g. ``get()`` -> raise,
# ``rename``/``update`` on a missing target).
nf_extra = _not_found_extra(e)
if verbose and isinstance(e, RPCError) and e.method_id:
nf_extra["method_id"] = e.method_id
_output_error(
f"Error: {e}",
"NOT_FOUND",
json_output,
1,
extra=nf_extra or None,
)
except NotebookLMError as e:
extra_info: dict[str, Any] | None = None
if verbose and isinstance(e, RPCError) and e.method_id:
extra_info = {"method_id": e.method_id}
_output_error(f"Error: {e}", "NOTEBOOKLM_ERROR", json_output, 1, extra=extra_info)
except click.ClickException:
# Let Click handle its own exceptions (--help, bad args, etc.)
raise
except Exception as e:
# Emit only the exception's primary message (``args[0]``) to
# the user. ``str(e)`` would walk Python's default representation,
# which for some third-party exceptions includes repr of every arg
# — surfacing whatever the raise site put in (potentially full
# subprocess output, response bodies, etc.). Pinning to ``args[0]``
# keeps the contract: raise sites are responsible for producing a
# safe message; the handler does not re-render.
# Third-party exceptions can put non-string
# objects in ``args[0]`` (e.g. ``ValueError(42)``, ``SomeErr({"code":
# 404})``). The f-string below would call ``str()`` implicitly anyway,
# but the explicit cast makes the contract obvious and avoids surprises
# if the f-string is ever replaced with a different formatter.
primary = str(e.args[0]) if e.args else type(e).__name__
# Route the full exception (with cause chain + traceback) to the
# redacting DEBUG logger so ``-vv`` users can still diagnose.
logger.debug("Unexpected CLI exception", exc_info=True)
_output_error(
f"Unexpected error: {primary}",
"UNEXPECTED_ERROR",
json_output,
2,
hint="This may be a bug. Please report at https://github.com/teng-lin/notebooklm-py/issues",
)