-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Expand file tree
/
Copy pathresolve.py
More file actions
538 lines (457 loc) · 20.2 KB
/
resolve.py
File metadata and controls
538 lines (457 loc) · 20.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
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
"""CLI notebook/entity ID resolution helpers."""
from __future__ import annotations
import asyncio
import os
import re
import sys
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
import click
from rich.console import Console
from .. import paths as paths_module
from ..paths import get_context_path
from . import context as context_helpers
from . import rendering as rendering_helpers
from .error_handler import exit_with_code
ContextPathFn = Callable[..., Path]
ListFn = Callable[[], Awaitable[list[Any]]]
# Backend entity IDs are canonical UUIDs in the RFC 4122 8-4-4-4-12 hex layout
# (e.g. ``abc12345-6789-4abc-def0-1234567890ab``). Anything shorter - even a
# unique 25-char prefix - must go through the local list-and-match path, or it
# will reach the backend as a truncated ID and 404. The character classes are
# both upper- and lower-case hex so mixed-case IDs returned by the backend keep
# fast-pathing. Exposed publicly because the canonical sync resolver core and
# the download helper share the same shape rule.
#
# Tightened past the plan's `^[0-9a-fA-F-]{36}$` to the exact 8-4-4-4-12 dash
# layout so degenerate-but-length-36 inputs (`"-" * 36`, 36 unbroken hex chars,
# wrong dash placement) cannot bypass local resolution - the looser pattern's
# false-positives would still 404 against the backend, but rejecting them
# locally gives the user a clearer error and keeps the contract honest.
FULL_ID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def validate_id(entity_id: str, entity_name: str = "ID") -> str:
"""Validate and normalize an entity ID.
Args:
entity_id: The ID to validate.
entity_name: Name for error messages, e.g. ``"notebook"`` or
``"source"``.
Returns:
Stripped ID.
Raises:
click.ClickException: If ID is empty or whitespace-only.
"""
if not entity_id or not entity_id.strip():
raise click.ClickException(f"{entity_name} ID cannot be empty")
return entity_id.strip()
def _is_full_id_candidate(entity_id: str) -> bool:
"""Return whether ``entity_id`` is shaped like a concrete backend UUID.
Only canonical 36-char hex-and-dash strings (case-insensitive) qualify for
the fast-path. A 25-char prefix of a 36-char UUID - which is unique enough
to be human-pasted - must still go through the local list-and-match path so
it can be expanded to the full ID before any backend call. See
:data:`FULL_ID_PATTERN`.
"""
return FULL_ID_PATTERN.fullmatch(entity_id) is not None
def _helpers_attr(name: str) -> Any | None:
"""Return a patched ``cli.helpers`` attribute when the facade is imported."""
helpers_module = sys.modules.get("notebooklm.cli.helpers")
if helpers_module is None:
return None
return getattr(helpers_module, name, None)
def _default_context_path_fn() -> ContextPathFn:
"""Resolve the call-time context path provider.
``cli.helpers`` is now a compatibility facade for resolver names, but many
existing tests still patch ``notebooklm.cli.helpers.get_context_path``.
Prefer that patched facade value when present; otherwise use this module's
own call-time lookup so ``notebooklm.cli.resolve.get_context_path`` patches
continue to work.
"""
helpers_get_context_path = _helpers_attr("get_context_path")
if (
callable(helpers_get_context_path)
and helpers_get_context_path is not paths_module.get_context_path
):
return helpers_get_context_path
return get_context_path
def _default_stdout_console(console: Console | None) -> Console:
if console is not None:
return console
helpers_console = _helpers_attr("console")
if helpers_console is not None and helpers_console is not rendering_helpers.console:
return helpers_console
return rendering_helpers.console
def _default_stderr_console(console: Console | None) -> Console:
if console is not None:
return console
helpers_console = _helpers_attr("stderr_console")
if helpers_console is not None and helpers_console is not rendering_helpers.stderr_console:
return helpers_console
return rendering_helpers.stderr_console
def require_notebook(
notebook_id: str | None,
*,
context_path_fn: ContextPathFn | None = None,
output_console: Console | None = None,
) -> str:
"""Get notebook ID from argument, env var, or active context.
Resolution order (env-var precedence):
1. ``notebook_id`` argument (the resolved value of the ``-n/--notebook``
Click flag, which is already env-var-aware via ``cli/options.py``).
2. ``NOTEBOOKLM_NOTEBOOK`` environment variable.
3. The persisted active-notebook context written by ``notebooklm use``.
4. Hard error with a discoverability hint listing all resolution paths.
Args:
notebook_id: Optional notebook ID from command argument. When the
Click flag was omitted and the env var was unset, this is ``None``.
context_path_fn: Context-path resolver, injectable for compatibility
wrappers and tests. ``None`` keeps the module-level
``get_context_path`` lookup call-time patchable.
output_console: Console used for the no-notebook diagnostic.
Returns:
Notebook ID from argument, env var, or context, validated and stripped.
Raises:
SystemExit: If no notebook ID can be resolved from any source.
click.ClickException: If the resolved notebook ID is empty/whitespace
after stripping.
"""
if notebook_id:
return validate_id(notebook_id, "Notebook")
env_value = os.environ.get("NOTEBOOKLM_NOTEBOOK")
if env_value and env_value.strip():
return validate_id(env_value, "Notebook")
current = context_helpers.get_current_notebook(
context_path_fn=context_path_fn or _default_context_path_fn()
)
if current:
return validate_id(current, "Notebook")
output_console = _default_stdout_console(output_console)
output_console.print(
"[red]No notebook specified. Use 'notebooklm use <id>' to set context, "
"pass -n/--notebook, or set NOTEBOOKLM_NOTEBOOK.[/red]"
)
exit_with_code(1)
# Accessor types for the sync resolver core. The default ``_attr_id`` /
# ``_attr_title`` accessors match the async ``_resolve_partial_id`` shape
# (entities with ``.id`` / ``.title`` attributes); ``resolve_partial_id_in_items``
# accepts custom accessors so callers with pre-fetched ``dict``-shaped data
# (e.g. ``download_helpers.resolve_partial_artifact_id``) can share the same
# matching logic without first reshaping their inputs.
ItemIdFn = Callable[[Any], str]
ItemTitleFn = Callable[[Any], str | None]
ErrorFactoryFn = Callable[[str], Exception]
def _attr_id(item: Any) -> str:
return item.id
def _attr_title(item: Any) -> str | None:
return item.title
def resolve_partial_id_in_items(
partial_id: str,
items: list[Any],
*,
entity_name: str,
list_command: str,
id_of: ItemIdFn = _attr_id,
title_of: ItemTitleFn = _attr_title,
error_factory: ErrorFactoryFn = click.ClickException,
emit_match_status: bool = True,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
allow_full_id_passthrough: bool = True,
) -> str:
"""Resolve a partial ID against a **pre-fetched** item list.
Sync core of the partial-ID matching logic. Encapsulates the rules that
both the async resolver (``_resolve_partial_id``) and the download
pre-fetched-list resolver (``download_helpers.resolve_partial_artifact_id``)
share, so behavior cannot drift between the two call paths.
Matching rules (in order):
1. Empty/whitespace partial id -> ``error_factory("...cannot be empty")``.
2. Canonical 36-char 8-4-4-4-12 hex+dash UUID -> returned verbatim (no
listing required); see :data:`FULL_ID_PATTERN`.
3. Case-insensitive exact match against any item id -> returned (wins over
prefix ambiguity so a short-but-complete id isn't reported as
ambiguous when it's also a prefix of another item).
4. Case-insensitive prefix match: unique -> return; ambiguous -> raise via
``error_factory`` with up to 5 candidates listed; no match -> raise.
Args:
partial_id: Full or partial ID to resolve.
items: Pre-fetched list of items the caller already loaded from the
backend. Each item is opaque; ``id_of`` and ``title_of``
accessors extract the relevant fields.
entity_name: Name for error messages, e.g. ``"notebook"``,
``"artifact"``.
list_command: CLI command users should run to discover items, e.g.
``"source list"`` or ``"artifact list"``. Included in the
"no match" error message.
id_of: Accessor returning the canonical id for an item. Defaults to
``item.id`` (attribute access) for the dataclass-style items the
async path consumes; the download pre-fetched-list path passes
``lambda a: a["id"]`` for its ``ArtifactDict`` shape.
title_of: Accessor returning the title for diagnostics. Same default
as ``id_of``.
error_factory: Exception class to raise on empty input, no match,
and ambiguous match. ``click.ClickException`` for the async
CLI path (auto-converted to exit 1 + stderr by Click);
``ValueError`` for callers like ``download_helpers`` that catch
and re-shape the error themselves.
emit_match_status: Whether a successful partial match should emit the
"Matched: ..." diagnostic. Async CLI resolvers use the default;
pre-fetched helpers that historically returned silently can turn it
off while sharing the same matching rules.
json_output: When true, the "Matched: ..." diagnostic routes to stderr
so stdout stays parseable JSON.
stdout_console: Console for human-mode diagnostics.
stderr_output_console: Console for JSON-mode diagnostics.
allow_full_id_passthrough: When true (default), a canonical
8-4-4-4-12 UUID is returned verbatim without scanning
``items``. Callers that have already fetched the
authoritative item list (e.g. download helpers) pass
``False`` so a full-shape ID that isn't in the list raises
the canonical "not found" error instead of silently
propagating to a backend 404.
Returns:
Full ID of the matched item.
Raises:
``error_factory(...)``: If ID is empty, no match exists, or the
prefix is ambiguous. The exception **type** is determined by
``error_factory`` so callers can pick the shape that fits their
error-handling layer.
"""
# ``validate_id`` raises ``click.ClickException`` unconditionally; for
# callers that asked for a different ``error_factory`` (e.g. ``ValueError``
# in the download path), we re-shape the empty-input error here so the
# caller doesn't have to know about ``click.ClickException`` at all.
if not partial_id or not partial_id.strip():
raise error_factory(f"{entity_name} ID cannot be empty")
partial_id = partial_id.strip()
# Concrete IDs are passed through so direct get/delete commands can hit
# the backend by ID without forcing an extra list RPC first. Callers
# that already hold an authoritative item list (download helpers) opt
# out via ``allow_full_id_passthrough=False`` so a full-shape ID that
# isn't in the list falls through to the membership check below and
# surfaces the canonical "not found" error rather than a silent 404.
if allow_full_id_passthrough and _is_full_id_candidate(partial_id):
return partial_id
partial_id_lower = partial_id.lower()
matches: list[Any] = []
for item in items:
item_id = id_of(item)
item_id_lower = item_id.lower()
# Exact short IDs win over prefix matches to avoid false ambiguity.
if item_id_lower == partial_id_lower:
return item_id
if item_id_lower.startswith(partial_id_lower):
matches.append(item)
if len(matches) == 1:
matched_id = id_of(matches[0])
if emit_match_status and matched_id != partial_id:
title = title_of(matches[0]) or "(untitled)"
rendering_helpers.emit_status(
f"[dim]Matched: {matched_id[:12]}... ({title})[/dim]",
json_output=json_output,
stdout_console=_default_stdout_console(stdout_console),
stderr_output_console=_default_stderr_console(stderr_output_console),
)
return matched_id
if len(matches) == 0:
raise error_factory(
f"No {entity_name} found starting with '{partial_id}'. "
f"Run 'notebooklm {list_command}' to see available {entity_name}s."
)
lines = [f"Ambiguous ID '{partial_id}' matches {len(matches)} {entity_name}s:"]
for item in matches[:5]:
matched_id = id_of(item)
title = title_of(item) or "(untitled)"
lines.append(f" {matched_id[:12]}... {title}")
if len(matches) > 5:
lines.append(f" ... and {len(matches) - 5} more")
lines.append("\nSpecify more characters to narrow down.")
raise error_factory("\n".join(lines))
async def _resolve_partial_id(
partial_id: str,
list_fn: ListFn,
entity_name: str,
list_command: str,
*,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
) -> str:
"""Resolve a case-insensitive partial ID prefix to a full entity ID.
Allows users to type partial IDs like ``abc`` instead of full IDs.
Exact matches are preferred before case-insensitive prefix matches so a
short-but-complete ID is not treated as ambiguous when another entity
shares that prefix.
Thin async adapter over :func:`resolve_partial_id_in_items` - handles
the full-ID fast-path locally to avoid a wasted ``await list_fn()`` for
canonical UUIDs, then defers to the sync core for the prefix-matching
work so behavior stays in lockstep with
``download_helpers.resolve_partial_artifact_id``.
Args:
partial_id: Full or partial ID to resolve.
list_fn: Async function returning items with ``id`` and ``title``
attributes.
entity_name: Name for error messages, e.g. ``"notebook"``.
list_command: CLI command to list items, e.g. ``"source list"``.
json_output: When true, the successful "Matched..." diagnostic routes
to stderr so stdout stays parseable JSON.
stdout_console: Console for human-mode diagnostics.
stderr_output_console: Console for JSON-mode diagnostics.
Returns:
Full ID of the matched item.
Raises:
click.ClickException: If ID is empty, no match exists, or the prefix is
ambiguous.
"""
# Validate + fast-path BEFORE awaiting ``list_fn`` so canonical UUIDs
# don't pay for a backend listing they don't need.
partial_id = validate_id(partial_id, entity_name)
if _is_full_id_candidate(partial_id):
return partial_id
items = await list_fn()
return resolve_partial_id_in_items(
partial_id,
items,
entity_name=entity_name,
list_command=list_command,
# async path uses the attribute-style accessors (default).
json_output=json_output,
stdout_console=stdout_console,
stderr_output_console=stderr_output_console,
# async path keeps ``click.ClickException`` (Click -> exit 1 + stderr).
)
async def resolve_notebook_id(
client,
partial_id: str,
*,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
) -> str:
"""Resolve partial notebook ID to full ID.
When ``json_output`` is true, the successful "Matched..." diagnostic routes
to stderr so stdout stays parseable JSON.
"""
return await _resolve_partial_id(
partial_id,
list_fn=lambda: client.notebooks.list(),
entity_name="notebook",
list_command="list",
json_output=json_output,
stdout_console=stdout_console,
stderr_output_console=stderr_output_console,
)
async def resolve_source_id(
client,
notebook_id: str,
partial_id: str,
*,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
) -> str:
"""Resolve partial source ID to full ID.
When ``json_output`` is true, the successful "Matched..." diagnostic routes
to stderr so stdout stays parseable JSON.
"""
return await _resolve_partial_id(
partial_id,
list_fn=lambda: client.sources.list(notebook_id),
entity_name="source",
list_command="source list",
json_output=json_output,
stdout_console=stdout_console,
stderr_output_console=stderr_output_console,
)
async def resolve_artifact_id(
client,
notebook_id: str,
partial_id: str,
*,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
) -> str:
"""Resolve partial artifact ID to full ID.
When ``json_output`` is true, the successful "Matched..." diagnostic routes
to stderr so stdout stays parseable JSON.
"""
return await _resolve_partial_id(
partial_id,
list_fn=lambda: client.artifacts.list(notebook_id),
entity_name="artifact",
list_command="artifact list",
json_output=json_output,
stdout_console=stdout_console,
stderr_output_console=stderr_output_console,
)
async def resolve_note_id(
client,
notebook_id: str,
partial_id: str,
*,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
) -> str:
"""Resolve partial note ID to full ID.
When ``json_output`` is true, the successful "Matched..." diagnostic routes
to stderr so stdout stays parseable JSON.
"""
return await _resolve_partial_id(
partial_id,
list_fn=lambda: client.notes.list(notebook_id),
entity_name="note",
list_command="note list",
json_output=json_output,
stdout_console=stdout_console,
stderr_output_console=stderr_output_console,
)
async def resolve_source_ids(
client,
notebook_id: str,
source_ids: tuple[str, ...],
*,
json_output: bool = False,
stdout_console: Console | None = None,
stderr_output_console: Console | None = None,
) -> list[str] | None:
"""Resolve multiple partial source IDs to full IDs.
Args:
client: NotebookLM client.
notebook_id: Resolved notebook ID.
source_ids: Tuple of partial source IDs from CLI.
json_output: When true, "Matched..." diagnostics for partial matches
route to stderr so stdout stays parseable JSON.
stdout_console: Console for human-mode diagnostics.
stderr_output_console: Console for JSON-mode diagnostics.
Returns:
List of resolved source IDs, or ``None`` if no source IDs were provided.
"""
if not source_ids:
return None
validated_source_ids = tuple(validate_id(source_id, "source") for source_id in source_ids)
if all(_is_full_id_candidate(source_id) for source_id in validated_source_ids):
return list(validated_source_ids)
sources = await client.sources.list(notebook_id)
async def list_sources():
return sources
unique_source_ids = tuple(dict.fromkeys(validated_source_ids))
resolved_unique = await asyncio.gather(
*(
_resolve_partial_id(
source_id,
list_fn=list_sources,
entity_name="source",
list_command="source list",
json_output=json_output,
stdout_console=stdout_console,
stderr_output_console=stderr_output_console,
)
for source_id in unique_source_ids
)
)
resolved_by_input = dict(zip(unique_source_ids, resolved_unique, strict=True))
return [resolved_by_input[source_id] for source_id in validated_source_ids]