Compare commits
10 commits
a647c5e03e
...
5323dd0679
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5323dd0679 | ||
|
|
3ca046b6bc | ||
|
|
6a6e1a9484 | ||
|
|
7939795fa2 | ||
|
|
bc30a3d97b | ||
|
|
ac58d7f225 | ||
|
|
abe067a4e7 | ||
|
|
2b13654aae | ||
|
|
abe5676c3e | ||
|
|
4d8a329791 |
37 changed files with 6984 additions and 552 deletions
2
.github/workflows/rpc-health.yml
vendored
2
.github/workflows/rpc-health.yml
vendored
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
set +e
|
||||
python scripts/check_rpc_health.py --full 2>&1 | tee health-report.txt
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
|
||||
|
|
|
|||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.3] - 2026-03-03
|
||||
|
||||
### Added
|
||||
- **`ask --save-as-note`** - Save chat answers as notebook notes directly from the CLI (#135)
|
||||
- `notebooklm ask "question" --save-as-note` - Save response as a note
|
||||
- `notebooklm ask "question" --save-as-note --note-title "Title"` - Save with custom title
|
||||
- **`history --save`** - Save full conversation history as a notebook note (#135)
|
||||
- `notebooklm history --save` - Save history with default title
|
||||
- `notebooklm history --save --note-title "Title"` - Save with custom title
|
||||
- `notebooklm history --show-all` - Show full Q&A content instead of preview
|
||||
- **`generate report --append`** - Append custom instructions to built-in report format templates (#134)
|
||||
- Works with `briefing-doc`, `study-guide`, and `blog-post` formats (no effect on `custom`)
|
||||
- Example: `notebooklm generate report --format study-guide --append "Target audience: beginners"`
|
||||
- **`generate revise-slide`** - Revise individual slides in an existing slide deck (#129)
|
||||
- `notebooklm generate revise-slide "prompt" --artifact <id> --slide 0`
|
||||
- **PPTX download for slide decks** - Download slide decks as editable PowerPoint files (#129)
|
||||
- `notebooklm download slide-deck --format pptx` (web UI only offers PDF)
|
||||
|
||||
### Fixed
|
||||
- **Partial artifact ID in download commands** - Download commands now support partial artifact IDs (#130)
|
||||
- **Chat empty answer** - Fixed `ask` returning empty answer when API response marker changes (#123)
|
||||
- **X.com/Twitter content parsing** - Fixed parsing of X.com/Twitter source content (#119)
|
||||
- **Language sync on login** - Syncs server language setting to local config after `notebooklm login` (#124)
|
||||
- **Python version check** - Added runtime check with clear error message for Python < 3.10 (#125)
|
||||
- **RPC error diagnostics** - Improved error reporting for GET_NOTEBOOK and auth health check failures (#126, #127)
|
||||
- **Conversation persistence** - Chat conversations now persist server-side; conversation ID shown in `history` output (#138)
|
||||
- **History Q&A previews** - Fixed populating Q&A previews using conversation turns API (#136)
|
||||
- **`generate report --language`** - Fixed missing `--language` option for report generation (#109)
|
||||
|
||||
### Changed
|
||||
- **Chat history API** - Simplified history retrieval; removed `exchange_id`, improved conversation grouping with parallel fetching (#140, #141)
|
||||
- **Conversation ID tracking** - Server-side conversation lookup via new `hPTbtc` RPC (`GET_LAST_CONVERSATION_ID`) replaces local exchange ID tracking
|
||||
- **History Q&A population** - Now uses `khqZz` RPC (`GET_CONVERSATION_TURNS`) to fetch full Q&A turns with accurate previews (#136)
|
||||
|
||||
### Infrastructure
|
||||
- Bumped `actions/upload-artifact` from v6 to v7 (#131)
|
||||
|
||||
## [0.3.2] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
|
|
@ -282,7 +319,8 @@ This is the initial public release of `notebooklm-py`. While core functionality
|
|||
- **Authentication expiry**: CSRF tokens expire after some time. Re-run `notebooklm login` if you encounter auth errors.
|
||||
- **Large file uploads**: Files over 50MB may fail or timeout. Split large documents if needed.
|
||||
|
||||
[Unreleased]: https://github.com/teng-lin/notebooklm-py/compare/v0.3.2...HEAD
|
||||
[Unreleased]: https://github.com/teng-lin/notebooklm-py/compare/v0.3.3...HEAD
|
||||
[0.3.3]: https://github.com/teng-lin/notebooklm-py/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/teng-lin/notebooklm-py/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/teng-lin/notebooklm-py/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/teng-lin/notebooklm-py/compare/v0.2.1...v0.3.0
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
|------|---------|-----------------|
|
||||
| **Audio Overview** | 4 formats (deep-dive, brief, critique, debate), 3 lengths, 50+ languages | MP3/MP4 |
|
||||
| **Video Overview** | 2 formats, 9 visual styles (classic, whiteboard, kawaii, anime, etc.) | MP4 |
|
||||
| **Slide Deck** | Detailed or presenter format, adjustable length | PDF |
|
||||
| **Slide Deck** | Detailed or presenter format, adjustable length; individual slide revision | PDF, PPTX |
|
||||
| **Infographic** | 3 orientations, 3 detail levels | PNG |
|
||||
| **Quiz** | Configurable quantity and difficulty | JSON, Markdown, HTML |
|
||||
| **Flashcards** | Configurable quantity and difficulty | JSON, Markdown, HTML |
|
||||
|
|
@ -74,6 +74,10 @@ These features are available via API/CLI but not exposed in NotebookLM's web int
|
|||
- **Quiz/Flashcard export** - Get structured JSON, Markdown, or HTML (web UI only shows interactive view)
|
||||
- **Mind map data extraction** - Export hierarchical JSON for visualization tools
|
||||
- **Data table CSV export** - Download structured tables as spreadsheets
|
||||
- **Slide deck as PPTX** - Download editable PowerPoint files (web UI only offers PDF)
|
||||
- **Slide revision** - Modify individual slides with natural-language prompts
|
||||
- **Report template customization** - Append extra instructions to built-in format templates
|
||||
- **Save chat to notes** - Save Q&A answers or conversation history as notebook notes
|
||||
- **Source fulltext access** - Retrieve the indexed text content of any source
|
||||
- **Programmatic sharing** - Manage permissions without the UI
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# CLI Reference
|
||||
|
||||
**Status:** Active
|
||||
**Last Updated:** 2026-01-20
|
||||
**Last Updated:** 2026-03-02
|
||||
|
||||
Complete command reference for the `notebooklm` CLI—providing full programmatic access to all NotebookLM features, including capabilities not exposed in the web UI.
|
||||
|
||||
|
|
@ -77,8 +77,14 @@ See [Configuration](configuration.md) for details on environment variables and C
|
|||
| `ask <question>` | Ask a question | `notebooklm ask "What is this about?"` |
|
||||
| `ask -s <id>` | Ask using specific sources | `notebooklm ask "Summarize" -s src1 -s src2` |
|
||||
| `ask --json` | Get answer with source references | `notebooklm ask "Explain X" --json` |
|
||||
| `ask --save-as-note` | Save response as a note | `notebooklm ask "Explain X" --save-as-note` |
|
||||
| `ask --save-as-note --note-title` | Save response with custom note title | `notebooklm ask "Explain X" --save-as-note --note-title "Title"` |
|
||||
| `configure` | Set persona/mode | `notebooklm configure --mode learning-guide` |
|
||||
| `history` | View/clear history | `notebooklm history --clear` |
|
||||
| `history` | View conversation history | `notebooklm history` |
|
||||
| `history --clear` | Clear local conversation cache | `notebooklm history --clear` |
|
||||
| `history --save` | Save history as a note | `notebooklm history --save` |
|
||||
| `history --save --note-title` | Save history with custom title | `notebooklm history --save --note-title "Summary"` |
|
||||
| `history --show-all` | Show full Q&A content (not preview) | `notebooklm history --show-all` |
|
||||
|
||||
### Source Commands (`notebooklm source <cmd>`)
|
||||
|
||||
|
|
@ -118,12 +124,13 @@ All generate commands support:
|
|||
| `audio [description]` | `--format [deep-dive\|brief\|critique\|debate]`, `--length [short\|default\|long]`, `--wait` | `generate audio "Focus on history"` |
|
||||
| `video [description]` | `--format [explainer\|brief]`, `--style [auto\|classic\|whiteboard\|kawaii\|anime\|watercolor\|retro-print\|heritage\|paper-craft]`, `--wait` | `generate video "Explainer for kids"` |
|
||||
| `slide-deck [description]` | `--format [detailed\|presenter]`, `--length [default\|short]`, `--wait` | `generate slide-deck` |
|
||||
| `revise-slide <description>` | `-a/--artifact <id>` (required), `--slide N` (required), `--wait` | `generate revise-slide "Move title up" --artifact <id> --slide 0` |
|
||||
| `quiz [description]` | `--difficulty [easy\|medium\|hard]`, `--quantity [fewer\|standard\|more]`, `--wait` | `generate quiz --difficulty hard` |
|
||||
| `flashcards [description]` | `--difficulty [easy\|medium\|hard]`, `--quantity [fewer\|standard\|more]`, `--wait` | `generate flashcards` |
|
||||
| `infographic [description]` | `--orientation [landscape\|portrait\|square]`, `--detail [concise\|standard\|detailed]`, `--wait` | `generate infographic` |
|
||||
| `data-table <description>` | `--wait` | `generate data-table "compare concepts"` |
|
||||
| `mind-map` | *(sync, no wait needed)* | `generate mind-map` |
|
||||
| `report [description]` | `--format [briefing-doc\|study-guide\|blog-post\|custom]`, `--wait` | `generate report --format study-guide` |
|
||||
| `report [description]` | `--format [briefing-doc\|study-guide\|blog-post\|custom]`, `--append "extra instructions"`, `--wait` | `generate report --format study-guide` |
|
||||
|
||||
### Artifact Commands (`notebooklm artifact <cmd>`)
|
||||
|
||||
|
|
@ -144,7 +151,7 @@ All generate commands support:
|
|||
|---------|-----------|---------|---------|
|
||||
| `audio [path]` | Output path | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run` | `download audio --all` |
|
||||
| `video [path]` | Output path | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run` | `download video --latest` |
|
||||
| `slide-deck [path]` | Output directory | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run` | `download slide-deck ./slides/` |
|
||||
| `slide-deck [path]` | Output path | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run`, `--format [pdf\|pptx]` | `download slide-deck ./slides.pdf` |
|
||||
| `infographic [path]` | Output path | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run` | `download infographic ./info.png` |
|
||||
| `report [path]` | Output path | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run` | `download report ./report.md` |
|
||||
| `mind-map [path]` | Output path | `-a/--artifact`, `--all`, `--latest`, `--name`, `--force`, `--dry-run` | `download mind-map ./map.json` |
|
||||
|
|
@ -186,7 +193,11 @@ These CLI capabilities are not available in NotebookLM's web interface:
|
|||
| **Quiz/Flashcard export** | `download quiz --format json` | Export as JSON, Markdown, or HTML |
|
||||
| **Mind map extraction** | `download mind-map` | Export hierarchical JSON for visualization tools |
|
||||
| **Data table export** | `download data-table` | Download structured tables as CSV |
|
||||
| **Slide deck as PPTX** | `download slide-deck --format pptx` | Download as editable .pptx (web UI only offers PDF) |
|
||||
| **Slide revision** | `generate revise-slide "prompt" --artifact <id> --slide N` | Modify individual slides with a natural-language prompt |
|
||||
| **Report template append** | `generate report --format study-guide --append "..."` | Append instructions to built-in templates |
|
||||
| **Source fulltext** | `source fulltext <id>` | Retrieve the indexed text content of any source |
|
||||
| **Save chat to note** | `ask "..." --save-as-note` / `history --save` | Save Q&A answers or full conversation as notebook notes |
|
||||
| **Programmatic sharing** | `share` commands | Manage permissions without the UI |
|
||||
|
||||
---
|
||||
|
|
@ -538,6 +549,35 @@ notebooklm generate video -s src_123 -s src_456
|
|||
notebooklm generate video --json
|
||||
```
|
||||
|
||||
### Generate: `revise-slide`
|
||||
|
||||
Revise an individual slide in an existing slide deck using a natural-language prompt.
|
||||
|
||||
```bash
|
||||
notebooklm generate revise-slide <description> --artifact <id> --slide N [OPTIONS]
|
||||
```
|
||||
|
||||
**Required Options:**
|
||||
- `-a, --artifact ID` - The slide deck artifact ID to revise
|
||||
- `--slide N` - Zero-based index of the slide to revise (0 = first slide)
|
||||
|
||||
**Optional:**
|
||||
- `--wait` - Wait for revision to complete
|
||||
- `--json` - Machine-readable output
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Revise the first slide
|
||||
notebooklm generate revise-slide "Move the title up" --artifact art123 --slide 0
|
||||
|
||||
# Revise the fourth slide and wait for completion
|
||||
notebooklm generate revise-slide "Remove taxonomy table" --artifact art123 --slide 3 --wait
|
||||
```
|
||||
|
||||
**Note:** The slide deck must already be fully generated before using `revise-slide`. Use `artifact list` to find the artifact ID.
|
||||
|
||||
---
|
||||
|
||||
### Generate: `report`
|
||||
|
||||
Generate a text report (briefing doc, study guide, blog post, or custom).
|
||||
|
|
@ -548,6 +588,7 @@ notebooklm generate report [description] [OPTIONS]
|
|||
|
||||
**Options:**
|
||||
- `--format [briefing-doc|study-guide|blog-post|custom]` - Report format (default: briefing-doc)
|
||||
- `--append TEXT` - Append extra instructions to the built-in prompt (no effect with `--format custom`)
|
||||
- `-s, --source ID` - Use specific source(s) (repeatable, uses all if not specified)
|
||||
- `--wait` - Wait for generation to complete
|
||||
- `--json` - Output as JSON
|
||||
|
|
@ -562,6 +603,10 @@ notebooklm generate report --format study-guide -s src_001 -s src_002
|
|||
|
||||
# Custom report with description (auto-selects custom format)
|
||||
notebooklm generate report "Create a white paper analyzing the key trends"
|
||||
|
||||
# Append instructions to a built-in format
|
||||
notebooklm generate report --format study-guide --append "Target audience: beginners"
|
||||
notebooklm generate report --format briefing-doc --append "Focus on AI trends, keep it under 2 pages"
|
||||
```
|
||||
|
||||
### Download: `audio`, `video`, `slide-deck`, `infographic`, `report`, `mind-map`, `data-table`
|
||||
|
|
@ -578,7 +623,7 @@ notebooklm download <type> [OUTPUT_PATH] [OPTIONS]
|
|||
|------|-------------------|-------------|
|
||||
| `audio` | `.mp4` | Audio overview (podcast) in MP4 container |
|
||||
| `video` | `.mp4` | Video overview |
|
||||
| `slide-deck` | `.pdf` | Slide deck as PDF |
|
||||
| `slide-deck` | `.pdf` or `.pptx` | Slide deck as PDF (default) or PowerPoint |
|
||||
| `infographic` | `.png` | Infographic image |
|
||||
| `report` | `.md` | Report as Markdown (Briefing Doc, Study Guide, etc.) |
|
||||
| `mind-map` | `.json` | Mind map as JSON tree structure |
|
||||
|
|
@ -589,10 +634,11 @@ notebooklm download <type> [OUTPUT_PATH] [OPTIONS]
|
|||
- `--latest` - Download only the most recent artifact (default if no ID/name provided)
|
||||
- `--earliest` - Download only the oldest artifact
|
||||
- `--name NAME` - Download artifact with matching title (supports partial matches)
|
||||
- `-a, --artifact ID` - Select specific artifact by ID
|
||||
- `-a, --artifact ID` - Select specific artifact by ID (supports partial IDs)
|
||||
- `--dry-run` - Show what would be downloaded without actually downloading
|
||||
- `--force` - Overwrite existing files
|
||||
- `--no-clobber` - Skip if file already exists (default)
|
||||
- `--format [pdf|pptx]` - Slide deck format (slide-deck command only, default: pdf)
|
||||
- `--json` - Output result in JSON format
|
||||
|
||||
**Examples:**
|
||||
|
|
@ -606,6 +652,9 @@ notebooklm download infographic --all
|
|||
# Download a specific slide deck by name
|
||||
notebooklm download slide-deck --name "Final Presentation"
|
||||
|
||||
# Download slide deck as PPTX (editable PowerPoint)
|
||||
notebooklm download slide-deck --format pptx
|
||||
|
||||
# Preview a batch download
|
||||
notebooklm download audio --all --dry-run
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Python API Reference
|
||||
|
||||
**Status:** Active
|
||||
**Last Updated:** 2026-01-20
|
||||
**Last Updated:** 2026-03-02
|
||||
|
||||
Complete reference for the `notebooklm` Python library.
|
||||
|
||||
|
|
@ -486,7 +486,8 @@ else:
|
|||
|--------|------------|---------|-------------|
|
||||
| `ask(notebook_id, question, ...)` | `str, str, ...` | `AskResult` | Ask a question |
|
||||
| `configure(notebook_id, ...)` | `str, ...` | `bool` | Set chat persona |
|
||||
| `get_history(notebook_id)` | `str` | `list[ConversationTurn]` | Get conversation |
|
||||
| `get_history(notebook_id, limit=100, conversation_id=None)` | `str, int, str` | `list[tuple[str, str]]` | Get Q&A pairs from most recent conversation |
|
||||
| `get_conversation_id(notebook_id)` | `str` | `str \| None` | Get most recent conversation ID from server |
|
||||
|
||||
**ask() Parameters:**
|
||||
```python
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Release Checklist
|
||||
|
||||
**Status:** Active
|
||||
**Last Updated:** 2026-01-21
|
||||
**Last Updated:** 2026-03-02
|
||||
|
||||
Checklist for releasing a new version of `notebooklm-py`.
|
||||
|
||||
|
|
@ -78,6 +78,7 @@ Proceed with release preparation?
|
|||
|
||||
| Doc | Update when... |
|
||||
|-----|----------------|
|
||||
| [README.md](../README.md) | New features, changed capabilities, Beyond the Web UI section |
|
||||
| [SKILL.md](../src/notebooklm/data/SKILL.md) | New CLI commands, changed flags, new workflows |
|
||||
| [cli-reference.md](cli-reference.md) | Any CLI changes |
|
||||
| [python-api.md](python-api.md) | New/changed Python API |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# RPC & UI Reference
|
||||
|
||||
**Status:** Active
|
||||
**Last Updated:** 2026-01-18
|
||||
**Last Updated:** 2026-03-02
|
||||
**Source of Truth:** `src/notebooklm/rpc/types.py`
|
||||
**Purpose:** Complete reference for RPC methods, UI selectors, and payload structures
|
||||
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
| `R7cb6c` | CREATE_ARTIFACT | Unified artifact generation | `_artifacts.py` |
|
||||
| `gArtLc` | LIST_ARTIFACTS | List artifacts in a notebook | `_artifacts.py` |
|
||||
| `V5N4be` | DELETE_ARTIFACT | Delete artifact | `_artifacts.py` |
|
||||
| `hPTbtc` | GET_LAST_CONVERSATION_ID | Get most recent conversation ID | `_chat.py` |
|
||||
| `hPTbtc` | GET_CONVERSATION_ID | Get most recent conversation ID | `_chat.py` |
|
||||
| `khqZz` | GET_CONVERSATION_TURNS | Get Q&A turns for a conversation | `_chat.py` |
|
||||
| `CYK0Xb` | CREATE_NOTE | Create a note (placeholder) | `_notes.py` |
|
||||
| `cYAfTb` | UPDATE_NOTE | Update note content/title | `_notes.py` |
|
||||
|
|
@ -396,23 +396,24 @@ params = [
|
|||
]
|
||||
```
|
||||
|
||||
### RPC: GET_LAST_CONVERSATION_ID (hPTbtc)
|
||||
### RPC: GET_CONVERSATION_ID (hPTbtc)
|
||||
|
||||
**Source:** `_chat.py::get_last_conversation_id()`
|
||||
**Source:** `_chat.py::get_conversation_id()`
|
||||
|
||||
Returns only the most recent conversation ID — not a full history list. Use
|
||||
`GET_CONVERSATION_TURNS` to fetch the actual messages for a given conversation.
|
||||
Returns the most recent conversation ID for a notebook. The server always returns
|
||||
exactly one ID regardless of the `limit` param. Use `GET_CONVERSATION_TURNS` to
|
||||
fetch the actual messages for the returned conversation.
|
||||
|
||||
```python
|
||||
params = [
|
||||
[], # 0: Empty sources array
|
||||
None, # 1
|
||||
notebook_id, # 2
|
||||
limit, # 3: Max conversations (e.g., 20)
|
||||
1, # 3: Limit (server ignores this; always returns one ID)
|
||||
]
|
||||
```
|
||||
|
||||
**Response:** `[[[conv_id], [conv_id], ...]]` — each entry is a list containing only the conversation ID.
|
||||
**Response:** `[[[conv_id]]]` — single entry list containing the conversation ID.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -927,11 +928,15 @@ await rpc_call(
|
|||
|
||||
# Response structure:
|
||||
# [
|
||||
# [summary_text], # [0][0]: Summary string
|
||||
# [[ # [1][0]: Suggested topics array
|
||||
# [question, prompt], # Each topic has question and prompt
|
||||
# ...
|
||||
# ]],
|
||||
# [ # [0]: Outer container
|
||||
# [summary_text], # [0][0]: Summary wrapped in list; text at [0][0][0]
|
||||
# [[ # [0][1][0]: Suggested topics array
|
||||
# [question, prompt], # Each topic has question and prompt
|
||||
# ...
|
||||
# ]],
|
||||
# null, null, null,
|
||||
# [[question, score], ...], # [0][5]: Topics with relevance scores
|
||||
# ]
|
||||
# ]
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "notebooklm-py"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
description = "Unofficial Python library for automating Google NotebookLM"
|
||||
dynamic = ["readme"]
|
||||
requires-python = ">=3.10"
|
||||
|
|
@ -103,7 +103,7 @@ branch = true
|
|||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
fail_under = 70
|
||||
fail_under = 90
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
|
|
|
|||
|
|
@ -434,12 +434,15 @@ def get_test_params(method: RPCMethod, notebook_id: str | None) -> list[Any] | N
|
|||
|
||||
# Methods that take [[notebook_id]] as the only param
|
||||
if method in (
|
||||
RPCMethod.LIST_CONVERSATIONS,
|
||||
RPCMethod.GET_NOTES_AND_MIND_MAPS,
|
||||
RPCMethod.DISCOVER_SOURCES,
|
||||
):
|
||||
return [[notebook_id]]
|
||||
|
||||
# GET_LAST_CONVERSATION_ID: returns most recent conversation ID
|
||||
if method == RPCMethod.GET_LAST_CONVERSATION_ID:
|
||||
return [[], None, notebook_id, 1]
|
||||
|
||||
# GET_CONVERSATION_TURNS: placeholder conv ID - API echoes RPC ID even in error response
|
||||
if method == RPCMethod.GET_CONVERSATION_TURNS:
|
||||
return [[], None, None, "placeholder_conv_id", 2]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ from .types import AskResult, ChatReference, ConversationTurn
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BL = "boq_labs-tailwind-frontend_20260301.03_p0"
|
||||
|
||||
# UUID pattern for validating source IDs (compiled once at module level)
|
||||
_UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
|
|
@ -62,7 +64,6 @@ class ChatAPI:
|
|||
question: str,
|
||||
source_ids: list[str] | None = None,
|
||||
conversation_id: str | None = None,
|
||||
exchange_id: str | None = None,
|
||||
) -> AskResult:
|
||||
"""Ask the notebook a question.
|
||||
|
||||
|
|
@ -71,11 +72,9 @@ class ChatAPI:
|
|||
question: The question to ask.
|
||||
source_ids: Specific source IDs to query. If None, uses all sources.
|
||||
conversation_id: Existing conversation ID for follow-up questions.
|
||||
exchange_id: Exchange ID from previous response (enables server-side
|
||||
context lookup for follow-ups without replaying history).
|
||||
|
||||
Returns:
|
||||
AskResult with answer, conversation_id, exchange_id, and turn info.
|
||||
AskResult with answer, conversation_id, and turn info.
|
||||
|
||||
Example:
|
||||
# New conversation
|
||||
|
|
@ -112,9 +111,11 @@ class ChatAPI:
|
|||
conversation_history,
|
||||
[2, None, [1], [1]],
|
||||
conversation_id,
|
||||
None, # [5] - always null
|
||||
None, # [6] - always null
|
||||
notebook_id, # [7] - required for server-side conversation persistence
|
||||
1, # [8] - always 1
|
||||
]
|
||||
if exchange_id is not None:
|
||||
params += [None, None, exchange_id, 1]
|
||||
|
||||
params_json = json.dumps(params, separators=(",", ":"))
|
||||
f_req = [None, params_json]
|
||||
|
|
@ -131,7 +132,7 @@ class ChatAPI:
|
|||
|
||||
self._core._reqid_counter += 100000
|
||||
url_params = {
|
||||
"bl": os.environ.get("NOTEBOOKLM_BL", "boq_labs-tailwind-frontend_20251221.14_p0"),
|
||||
"bl": os.environ.get("NOTEBOOKLM_BL", _DEFAULT_BL),
|
||||
"hl": "en",
|
||||
"_reqid": str(self._core._reqid_counter),
|
||||
"rt": "c",
|
||||
|
|
@ -159,9 +160,13 @@ class ChatAPI:
|
|||
original_error=e,
|
||||
) from e
|
||||
|
||||
answer_text, references, new_exchange_id = self._parse_ask_response_with_references(
|
||||
answer_text, references, server_conv_id = self._parse_ask_response_with_references(
|
||||
response.text
|
||||
)
|
||||
# Prefer the conversation ID returned by the server over our locally generated UUID,
|
||||
# so that get_conversation_id() and get_conversation_turns() stay in sync.
|
||||
if server_conv_id:
|
||||
conversation_id = server_conv_id
|
||||
|
||||
turns = self._core.get_cached_conversation(conversation_id)
|
||||
if answer_text:
|
||||
|
|
@ -177,7 +182,6 @@ class ChatAPI:
|
|||
is_follow_up=not is_new_conversation,
|
||||
references=references,
|
||||
raw_response=response.text[:1000],
|
||||
exchange_id=new_exchange_id,
|
||||
)
|
||||
|
||||
async def get_conversation_turns(
|
||||
|
|
@ -209,11 +213,10 @@ class ChatAPI:
|
|||
source_path=f"/notebook/{notebook_id}",
|
||||
)
|
||||
|
||||
async def get_last_conversation_id(self, notebook_id: str) -> str | None:
|
||||
async def get_conversation_id(self, notebook_id: str) -> str | None:
|
||||
"""Get the most recent conversation ID from the API.
|
||||
|
||||
The underlying RPC (hPTbtc) only returns the last conversation ID,
|
||||
not a full conversation list.
|
||||
The underlying RPC (hPTbtc) returns the last conversation ID for a notebook.
|
||||
|
||||
Args:
|
||||
notebook_id: The notebook ID.
|
||||
|
|
@ -221,7 +224,7 @@ class ChatAPI:
|
|||
Returns:
|
||||
The most recent conversation ID, or None if no conversations exist.
|
||||
"""
|
||||
logger.debug("Getting last conversation ID for notebook %s", notebook_id)
|
||||
logger.debug("Getting conversation ID for notebook %s", notebook_id)
|
||||
params: list[Any] = [[], None, notebook_id, 1]
|
||||
raw = await self._core.rpc_call(
|
||||
RPCMethod.GET_LAST_CONVERSATION_ID,
|
||||
|
|
@ -233,28 +236,42 @@ class ChatAPI:
|
|||
for group in raw:
|
||||
if isinstance(group, list):
|
||||
for conv in group:
|
||||
if isinstance(conv, list) and conv:
|
||||
return str(conv[0])
|
||||
if isinstance(conv, list) and conv and isinstance(conv[0], str):
|
||||
return conv[0]
|
||||
logger.debug(
|
||||
"No conversation ID found in response (API structure may have changed): %s",
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_history(self, notebook_id: str, limit: int = 100) -> list[tuple[str, str]]:
|
||||
"""Get conversation history (all Q&A turns) from the server.
|
||||
|
||||
Fetches the most recent conversation and retrieves all turns.
|
||||
async def get_history(
|
||||
self,
|
||||
notebook_id: str,
|
||||
limit: int = 100,
|
||||
conversation_id: str | None = None,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get Q&A history for the most recent conversation.
|
||||
|
||||
Args:
|
||||
notebook_id: The notebook ID.
|
||||
limit: Maximum number of turns to retrieve.
|
||||
limit: Maximum number of Q&A turns to retrieve.
|
||||
conversation_id: Use this conversation ID instead of fetching it.
|
||||
Defaults to the most recent conversation if not provided.
|
||||
|
||||
Returns:
|
||||
List of (question, answer) tuples, ordered oldest-first.
|
||||
List of (question, answer) pairs, oldest-first.
|
||||
Returns an empty list if no conversations exist.
|
||||
"""
|
||||
logger.debug("Getting conversation history for notebook %s (limit=%d)", notebook_id, limit)
|
||||
conv_id = await self.get_last_conversation_id(notebook_id)
|
||||
conv_id = conversation_id or await self.get_conversation_id(notebook_id)
|
||||
if not conv_id:
|
||||
return []
|
||||
|
||||
turns_data = await self.get_conversation_turns(notebook_id, conv_id, limit=limit)
|
||||
try:
|
||||
turns_data = await self.get_conversation_turns(notebook_id, conv_id, limit=limit)
|
||||
except (ChatError, NetworkError) as e:
|
||||
logger.warning("Failed to fetch conversation turns for %s: %s", notebook_id, e)
|
||||
return []
|
||||
# API returns individual turns newest-first: [A2, Q2, A1, Q1, ...]
|
||||
# Reverse to chronological order [Q1, A1, Q2, A2, ...] so the
|
||||
# Q→A forward-pairing parser works correctly.
|
||||
|
|
@ -419,10 +436,11 @@ class ChatAPI:
|
|||
def _parse_ask_response_with_references(
|
||||
self, response_text: str
|
||||
) -> tuple[str, list[ChatReference], str | None]:
|
||||
"""Parse the streaming response to extract answer, references, and exchange ID.
|
||||
"""Parse the streaming response to extract answer, references, and conversation ID.
|
||||
|
||||
Returns:
|
||||
Tuple of (answer_text, list of ChatReference objects, exchange_id or None).
|
||||
Tuple of (answer_text, list of ChatReference objects, server_conversation_id).
|
||||
server_conversation_id is None if not present in the response.
|
||||
"""
|
||||
|
||||
if response_text.startswith(")]}'"):
|
||||
|
|
@ -432,20 +450,20 @@ class ChatAPI:
|
|||
best_marked_answer = ""
|
||||
best_unmarked_answer = ""
|
||||
all_references: list[ChatReference] = []
|
||||
found_exchange_id: str | None = None
|
||||
server_conv_id: str | None = None
|
||||
|
||||
def process_chunk(json_str: str) -> None:
|
||||
"""Process a JSON chunk, updating best answers and all_references."""
|
||||
nonlocal best_marked_answer, best_unmarked_answer, found_exchange_id
|
||||
text, is_answer, refs, exchange_id = self._extract_answer_and_refs_from_chunk(json_str)
|
||||
nonlocal best_marked_answer, best_unmarked_answer, server_conv_id
|
||||
text, is_answer, refs, conv_id = self._extract_answer_and_refs_from_chunk(json_str)
|
||||
if text:
|
||||
if is_answer and len(text) > len(best_marked_answer):
|
||||
best_marked_answer = text
|
||||
elif not is_answer and len(text) > len(best_unmarked_answer):
|
||||
best_unmarked_answer = text
|
||||
all_references.extend(refs)
|
||||
if exchange_id and not found_exchange_id:
|
||||
found_exchange_id = exchange_id
|
||||
if conv_id:
|
||||
server_conv_id = conv_id
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
|
|
@ -488,18 +506,17 @@ class ChatAPI:
|
|||
if ref.citation_number is None:
|
||||
ref.citation_number = idx
|
||||
|
||||
return longest_answer, all_references, found_exchange_id
|
||||
return longest_answer, all_references, server_conv_id
|
||||
|
||||
def _extract_answer_and_refs_from_chunk(
|
||||
self, json_str: str
|
||||
) -> tuple[str | None, bool, list[ChatReference], str | None]:
|
||||
"""Extract answer text, references, and exchange ID from a response chunk.
|
||||
"""Extract answer text, references, and conversation ID from a response chunk.
|
||||
|
||||
Response structure (discovered via reverse engineering):
|
||||
- first[0]: answer text
|
||||
- first[1]: None
|
||||
- first[2]: [conversation_id, exchange_id, numeric_hash]
|
||||
exchange_id is the server-assigned UUID for this exchange turn
|
||||
- first[2]: [conversation_id, numeric_hash]
|
||||
- first[3]: None
|
||||
- first[4]: Citation metadata
|
||||
- first[4][0]: Per-source citation positions with text spans
|
||||
|
|
@ -509,8 +526,11 @@ class ChatAPI:
|
|||
- cite[1][4]: array of [text_passage, char_positions] items
|
||||
- cite[1][5][0][0][0]: parent SOURCE ID (this is the real source UUID)
|
||||
|
||||
When item[2] is null and item[5] contains a UserDisplayableError, raises
|
||||
ChatError with a rate-limit message.
|
||||
|
||||
Returns:
|
||||
Tuple of (text, is_answer, references, exchange_id).
|
||||
Tuple of (text, is_answer, references, server_conversation_id).
|
||||
"""
|
||||
refs: list[ChatReference] = []
|
||||
|
||||
|
|
@ -530,6 +550,9 @@ class ChatAPI:
|
|||
|
||||
inner_json = item[2]
|
||||
if not isinstance(inner_json, str):
|
||||
# item[2] is null — check item[5] for a server-side error payload
|
||||
if len(item) > 5 and isinstance(item[5], list):
|
||||
self._raise_if_rate_limited(item[5])
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -548,23 +571,47 @@ class ChatAPI:
|
|||
and first[4][-1] == 1
|
||||
)
|
||||
|
||||
# Extract exchange_id from first[2][1]
|
||||
exchange_id = None
|
||||
# Extract the server-assigned conversation ID from first[2]
|
||||
server_conv_id: str | None = None
|
||||
if (
|
||||
len(first) > 2
|
||||
and isinstance(first[2], list)
|
||||
and len(first[2]) >= 2
|
||||
and isinstance(first[2][1], str)
|
||||
and first[2]
|
||||
and isinstance(first[2][0], str)
|
||||
):
|
||||
exchange_id = first[2][1]
|
||||
server_conv_id = first[2][0]
|
||||
|
||||
refs = self._parse_citations(first)
|
||||
return text, is_answer, refs, exchange_id
|
||||
return text, is_answer, refs, server_conv_id
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return None, False, refs, None
|
||||
|
||||
def _raise_if_rate_limited(self, error_payload: list) -> None:
|
||||
"""Raise ChatError if the payload contains a UserDisplayableError.
|
||||
|
||||
Args:
|
||||
error_payload: The item[5] list from a wrb.fr response chunk.
|
||||
|
||||
Raises:
|
||||
ChatError: When a UserDisplayableError is detected.
|
||||
"""
|
||||
try:
|
||||
# Structure: [8, None, [["type.googleapis.com/.../UserDisplayableError", ...]]]
|
||||
if len(error_payload) > 2 and isinstance(error_payload[2], list):
|
||||
for entry in error_payload[2]:
|
||||
if isinstance(entry, list) and entry and isinstance(entry[0], str):
|
||||
if "UserDisplayableError" in entry[0]:
|
||||
raise ChatError(
|
||||
"Chat request was rate limited or rejected by the API. "
|
||||
"Wait a few seconds and try again."
|
||||
)
|
||||
except ChatError:
|
||||
raise
|
||||
except Exception:
|
||||
pass # Ignore parse failures; let normal empty-answer handling proceed
|
||||
|
||||
def _parse_citations(self, first: list) -> list[ChatReference]:
|
||||
"""Parse citation details from response structure.
|
||||
|
||||
|
|
|
|||
|
|
@ -135,8 +135,14 @@ class NotebooksAPI:
|
|||
params,
|
||||
source_path=f"/notebook/{notebook_id}",
|
||||
)
|
||||
if result and isinstance(result, list) and len(result) > 0:
|
||||
return str(result[0]) if result[0] else ""
|
||||
# Response structure: [[[summary_string, ...], topics, ...]]
|
||||
# Summary is at result[0][0][0]
|
||||
try:
|
||||
if result and isinstance(result, list):
|
||||
summary = result[0][0][0]
|
||||
return str(summary) if summary else ""
|
||||
except (IndexError, TypeError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
async def get_description(self, notebook_id: str) -> NotebookDescription:
|
||||
|
|
@ -168,22 +174,30 @@ class NotebooksAPI:
|
|||
summary = ""
|
||||
suggested_topics: list[SuggestedTopic] = []
|
||||
|
||||
# Response structure: [[[summary_string], [[topics]], ...]]
|
||||
# Summary is at result[0][0][0], topics at result[0][1][0]
|
||||
if result and isinstance(result, list):
|
||||
# Summary at [0][0]
|
||||
if len(result) > 0 and isinstance(result[0], list) and len(result[0]) > 0:
|
||||
summary = result[0][0] if isinstance(result[0][0], str) else ""
|
||||
try:
|
||||
outer = result[0]
|
||||
|
||||
# Suggested topics at [1][0]
|
||||
if len(result) > 1 and isinstance(result[1], list) and len(result[1]) > 0:
|
||||
topics_list = result[1][0] if isinstance(result[1][0], list) else []
|
||||
for topic in topics_list:
|
||||
if isinstance(topic, list) and len(topic) >= 2:
|
||||
suggested_topics.append(
|
||||
SuggestedTopic(
|
||||
question=topic[0] if isinstance(topic[0], str) else "",
|
||||
prompt=topic[1] if isinstance(topic[1], str) else "",
|
||||
# Summary at outer[0][0]
|
||||
summary_val = outer[0][0]
|
||||
summary = str(summary_val) if summary_val else ""
|
||||
|
||||
# Suggested topics at outer[1][0]
|
||||
topics_list = outer[1][0]
|
||||
if isinstance(topics_list, list):
|
||||
for topic in topics_list:
|
||||
if isinstance(topic, list) and len(topic) >= 2:
|
||||
suggested_topics.append(
|
||||
SuggestedTopic(
|
||||
question=str(topic[0]) if topic[0] else "",
|
||||
prompt=str(topic[1]) if topic[1] else "",
|
||||
)
|
||||
)
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
# A partial result (e.g. summary but no topics) is possible.
|
||||
pass
|
||||
|
||||
return NotebookDescription(summary=summary, suggested_topics=suggested_topics)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,14 +16,12 @@ from ..types import ChatMode
|
|||
from .helpers import (
|
||||
console,
|
||||
get_current_conversation,
|
||||
get_current_exchange_id,
|
||||
get_current_notebook,
|
||||
json_output_response,
|
||||
require_notebook,
|
||||
resolve_notebook_id,
|
||||
resolve_source_ids,
|
||||
set_current_conversation,
|
||||
set_current_exchange_id,
|
||||
with_client,
|
||||
)
|
||||
|
||||
|
|
@ -32,7 +30,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
def _determine_conversation_id(
|
||||
*,
|
||||
new_conversation: bool,
|
||||
explicit_conversation_id: str | None,
|
||||
explicit_notebook_id: str | None,
|
||||
resolved_notebook_id: str,
|
||||
|
|
@ -40,14 +37,9 @@ def _determine_conversation_id(
|
|||
) -> str | None:
|
||||
"""Determine which conversation ID to use for the ask command.
|
||||
|
||||
Returns None if a new conversation should be started, otherwise returns
|
||||
Returns None if no cached conversation exists, otherwise returns
|
||||
the conversation ID to continue.
|
||||
"""
|
||||
if new_conversation:
|
||||
if not json_output:
|
||||
console.print("[dim]Starting new conversation...[/dim]")
|
||||
return None
|
||||
|
||||
if explicit_conversation_id:
|
||||
return explicit_conversation_id
|
||||
|
||||
|
|
@ -69,7 +61,7 @@ async def _get_latest_conversation_from_server(
|
|||
Returns None if unavailable or empty.
|
||||
"""
|
||||
try:
|
||||
conv_id = await client.chat.get_last_conversation_id(notebook_id)
|
||||
conv_id = await client.chat.get_conversation_id(notebook_id)
|
||||
if conv_id:
|
||||
if not json_output:
|
||||
console.print(f"[dim]Continuing conversation {conv_id[:8]}...[/dim]")
|
||||
|
|
@ -98,7 +90,6 @@ def register_chat_commands(cli):
|
|||
help="Notebook ID (uses current if not set)",
|
||||
)
|
||||
@click.option("--conversation-id", "-c", default=None, help="Continue a specific conversation")
|
||||
@click.option("--new", "new_conversation", is_flag=True, help="Start a new conversation")
|
||||
@click.option(
|
||||
"--source",
|
||||
"-s",
|
||||
|
|
@ -117,7 +108,6 @@ def register_chat_commands(cli):
|
|||
question,
|
||||
notebook_id,
|
||||
conversation_id,
|
||||
new_conversation,
|
||||
source_ids,
|
||||
json_output,
|
||||
save_as_note,
|
||||
|
|
@ -133,7 +123,6 @@ def register_chat_commands(cli):
|
|||
\b
|
||||
Example:
|
||||
notebooklm ask "what are the main themes?"
|
||||
notebooklm ask --new "start fresh question"
|
||||
notebooklm ask -c <id> "continue this one"
|
||||
notebooklm ask -s src_001 -s src_002 "question about specific sources"
|
||||
notebooklm ask "explain X" --json # Get answer with source references
|
||||
|
|
@ -145,25 +134,20 @@ def register_chat_commands(cli):
|
|||
async with NotebookLMClient(client_auth) as client:
|
||||
nb_id_resolved = await resolve_notebook_id(client, nb_id)
|
||||
effective_conv_id = _determine_conversation_id(
|
||||
new_conversation=new_conversation,
|
||||
explicit_conversation_id=conversation_id,
|
||||
explicit_notebook_id=notebook_id,
|
||||
resolved_notebook_id=nb_id_resolved,
|
||||
json_output=json_output,
|
||||
)
|
||||
|
||||
# Only use stored exchange_id when conv_id came from local cache
|
||||
# (not from explicit --conversation-id flag, which may not match)
|
||||
effective_exchange_id: str | None = None
|
||||
if effective_conv_id and not conversation_id:
|
||||
effective_exchange_id = get_current_exchange_id()
|
||||
elif not effective_conv_id:
|
||||
resumed_from_server = False
|
||||
if not effective_conv_id:
|
||||
# If no conversation ID yet, try to get the most recent one from server
|
||||
if not new_conversation:
|
||||
effective_conv_id = await _get_latest_conversation_from_server(
|
||||
client, nb_id_resolved, json_output
|
||||
)
|
||||
# Don't use stored exchange_id for server-derived conversations
|
||||
effective_conv_id = await _get_latest_conversation_from_server(
|
||||
client, nb_id_resolved, json_output
|
||||
)
|
||||
if effective_conv_id:
|
||||
resumed_from_server = True
|
||||
|
||||
sources = await resolve_source_ids(client, nb_id_resolved, source_ids)
|
||||
result = await client.chat.ask(
|
||||
|
|
@ -171,14 +155,10 @@ def register_chat_commands(cli):
|
|||
question,
|
||||
source_ids=sources,
|
||||
conversation_id=effective_conv_id,
|
||||
exchange_id=effective_exchange_id,
|
||||
)
|
||||
|
||||
if result.conversation_id:
|
||||
set_current_conversation(result.conversation_id)
|
||||
set_current_exchange_id(result.exchange_id)
|
||||
else:
|
||||
set_current_exchange_id(None)
|
||||
|
||||
if json_output:
|
||||
from dataclasses import asdict
|
||||
|
|
@ -192,7 +172,11 @@ def register_chat_commands(cli):
|
|||
else:
|
||||
console.print("[bold cyan]Answer:[/bold cyan]")
|
||||
console.print(result.answer)
|
||||
if result.is_follow_up:
|
||||
if result.is_follow_up and resumed_from_server:
|
||||
console.print(
|
||||
f"\n[dim]Resumed conversation: {result.conversation_id}[/dim]"
|
||||
)
|
||||
elif result.is_follow_up:
|
||||
console.print(
|
||||
f"\n[dim]Conversation: {result.conversation_id} (turn {result.turn_number or '?'})[/dim]"
|
||||
)
|
||||
|
|
@ -356,14 +340,17 @@ def register_chat_commands(cli):
|
|||
|
||||
nb_id = require_notebook(notebook_id)
|
||||
nb_id_resolved = await resolve_notebook_id(client, nb_id)
|
||||
qa_pairs = await client.chat.get_history(nb_id_resolved, limit=limit)
|
||||
conv_id = await client.chat.get_conversation_id(nb_id_resolved)
|
||||
qa_pairs = await client.chat.get_history(
|
||||
nb_id_resolved, limit=limit, conversation_id=conv_id
|
||||
)
|
||||
|
||||
if save_as_note:
|
||||
if not qa_pairs:
|
||||
raise click.ClickException(
|
||||
"No conversation history found for this notebook."
|
||||
)
|
||||
content = _format_all_qa(qa_pairs)
|
||||
content = _format_history(qa_pairs)
|
||||
title = note_title or "Chat History"
|
||||
note = await client.notes.create(nb_id_resolved, title, content)
|
||||
console.print(f"[green]Saved as note: {note.title} ({note.id[:8]}...)[/green]")
|
||||
|
|
@ -372,6 +359,7 @@ def register_chat_commands(cli):
|
|||
if json_output:
|
||||
data = {
|
||||
"notebook_id": nb_id_resolved,
|
||||
"conversation_id": conv_id,
|
||||
"count": len(qa_pairs),
|
||||
"qa_pairs": [
|
||||
{"turn": i, "question": q, "answer": a}
|
||||
|
|
@ -385,16 +373,20 @@ def register_chat_commands(cli):
|
|||
console.print("[yellow]No conversation history[/yellow]")
|
||||
return
|
||||
|
||||
console.print("[bold cyan]Conversation History:[/bold cyan]")
|
||||
|
||||
if show_all:
|
||||
console.print("[bold cyan]Conversation History:[/bold cyan]\n")
|
||||
if conv_id:
|
||||
console.print(f"\n[bold]── {conv_id} ──[/bold]")
|
||||
for i, (question, answer) in enumerate(qa_pairs, 1):
|
||||
console.print(f"[bold]#{i} Q:[/bold] {question}")
|
||||
console.print(f" A: {answer}\n")
|
||||
return
|
||||
|
||||
console.print("[bold cyan]Conversation History:[/bold cyan]")
|
||||
if conv_id:
|
||||
console.print(f"\n[dim]── {conv_id} ──[/dim]")
|
||||
table = Table()
|
||||
table.add_column("#", style="dim")
|
||||
table.add_column("#", style="dim", width=4)
|
||||
table.add_column("Question", style="white", max_width=50)
|
||||
table.add_column("Answer preview", style="dim", max_width=50)
|
||||
for i, (question, answer) in enumerate(qa_pairs, 1):
|
||||
|
|
@ -415,10 +407,9 @@ def _format_single_qa(question: str, answer: str) -> str:
|
|||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _format_all_qa(qa_results: list[tuple[str, str]]) -> str:
|
||||
"""Format multiple Q&A pairs as note content."""
|
||||
sections = []
|
||||
for i, (question, answer) in enumerate(qa_results, 1):
|
||||
section = f"## Turn {i}\n\n{_format_single_qa(question, answer)}"
|
||||
sections.append(section)
|
||||
return "\n\n---\n\n".join(sections)
|
||||
def _format_history(qa_pairs: list[tuple[str, str]]) -> str:
|
||||
"""Format Q&A history as note content."""
|
||||
turns = []
|
||||
for i, (question, answer) in enumerate(qa_pairs, 1):
|
||||
turns.append(f"### Turn {i}\n\n{_format_single_qa(question, answer)}")
|
||||
return "\n\n---\n\n".join(turns)
|
||||
|
|
|
|||
|
|
@ -177,20 +177,12 @@ def set_current_notebook(
|
|||
):
|
||||
"""Set the current notebook context.
|
||||
|
||||
If switching to a different notebook, the cached conversation_id is cleared
|
||||
since conversations are notebook-specific.
|
||||
conversation_id is never preserved — the server owns the canonical ID per
|
||||
notebook, and a stale local value would silently use the wrong UUID.
|
||||
"""
|
||||
context_file = get_context_path()
|
||||
context_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read existing context if available
|
||||
current_context: dict = {}
|
||||
if context_file.exists():
|
||||
try:
|
||||
current_context = json.loads(context_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass # Start with fresh context if file is corrupt
|
||||
|
||||
data: dict[str, str | bool] = {"notebook_id": notebook_id}
|
||||
if title:
|
||||
data["title"] = title
|
||||
|
|
@ -199,12 +191,6 @@ def set_current_notebook(
|
|||
if created_at:
|
||||
data["created_at"] = created_at
|
||||
|
||||
# Preserve conversation_id and exchange_id only if staying in the same notebook
|
||||
if current_context.get("notebook_id") == notebook_id:
|
||||
for key in ("conversation_id", "exchange_id"):
|
||||
if key in current_context:
|
||||
data[key] = current_context[key]
|
||||
|
||||
context_file.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
|
|
@ -225,16 +211,6 @@ def set_current_conversation(conversation_id: str | None):
|
|||
_set_context_value("conversation_id", conversation_id)
|
||||
|
||||
|
||||
def get_current_exchange_id() -> str | None:
|
||||
"""Get the current exchange ID from context."""
|
||||
return _get_context_value("exchange_id")
|
||||
|
||||
|
||||
def set_current_exchange_id(exchange_id: str | None) -> None:
|
||||
"""Set or clear the current exchange ID in context."""
|
||||
_set_context_value("exchange_id", exchange_id)
|
||||
|
||||
|
||||
def validate_id(entity_id: str, entity_name: str = "ID") -> str:
|
||||
"""Validate and normalize an entity ID.
|
||||
|
||||
|
|
@ -493,14 +469,16 @@ def with_client(f):
|
|||
return elapsed
|
||||
|
||||
try:
|
||||
auth = get_auth_tokens(ctx)
|
||||
try:
|
||||
auth = get_auth_tokens(ctx)
|
||||
except FileNotFoundError:
|
||||
log_result("failed", "not authenticated")
|
||||
handle_auth_error(json_output)
|
||||
return # unreachable (handle_auth_error raises SystemExit), but keeps mypy happy
|
||||
coro = f(ctx, *args, client_auth=auth, **kwargs)
|
||||
result = run_async(coro)
|
||||
log_result("completed")
|
||||
return result
|
||||
except FileNotFoundError:
|
||||
log_result("failed", "not authenticated")
|
||||
handle_auth_error(json_output)
|
||||
except Exception as e:
|
||||
log_result("failed", str(e))
|
||||
if json_output:
|
||||
|
|
|
|||
|
|
@ -135,14 +135,13 @@ Before starting workflows, verify the CLI is ready:
|
|||
| Check research status | `notebooklm research status` |
|
||||
| Wait for research | `notebooklm research wait --import-all` |
|
||||
| Chat | `notebooklm ask "question"` |
|
||||
| Chat (new conversation) | `notebooklm ask "question" --new` |
|
||||
| Chat (specific sources) | `notebooklm ask "question" -s src_id1 -s src_id2` |
|
||||
| Chat (with references) | `notebooklm ask "question" --json` |
|
||||
| Chat (save answer as note) | `notebooklm ask "question" --save-as-note` |
|
||||
| Chat (save with title) | `notebooklm ask "question" --save-as-note --note-title "Title"` |
|
||||
| Show conversation history | `notebooklm history` |
|
||||
| Save all history as note | `notebooklm history --save` |
|
||||
| Save one conversation as note | `notebooklm history --save -c <conversation_id>` |
|
||||
| Continue specific conversation | `notebooklm ask "question" -c <conversation_id>` |
|
||||
| Save history with title | `notebooklm history --save --note-title "My Research"` |
|
||||
| Get source fulltext | `notebooklm source fulltext <source_id>` |
|
||||
| Get source guide | `notebooklm source guide <source_id>` |
|
||||
|
|
@ -172,7 +171,7 @@ Before starting workflows, verify the CLI is ready:
|
|||
| Get language | `notebooklm language get` |
|
||||
| Set language | `notebooklm language set zh_Hans` |
|
||||
|
||||
**Parallel safety:** Use explicit notebook IDs in parallel workflows. Commands supporting `-n` shorthand: `artifact wait`, `source wait`, `research wait/status`, `download *`. Download commands also support `-a/--artifact`. Other commands use `--notebook`. For chat, use `--new` to start fresh conversations (avoids conversation ID conflicts).
|
||||
**Parallel safety:** Use explicit notebook IDs in parallel workflows. Commands supporting `-n` shorthand: `artifact wait`, `source wait`, `research wait/status`, `download *`. Download commands also support `-a/--artifact`. Other commands use `--notebook`. For chat, use `-c <conversation_id>` to target a specific conversation.
|
||||
|
||||
**Partial IDs:** Use first 6+ characters of UUIDs. Must be unique prefix (fails if ambiguous). Works for: `use`, `delete`, `wait` commands. For automation, prefer full UUIDs to avoid ambiguity.
|
||||
|
||||
|
|
|
|||
|
|
@ -1036,9 +1036,6 @@ class AskResult:
|
|||
is_follow_up: Whether this was a follow-up question.
|
||||
references: List of source references cited in the answer.
|
||||
raw_response: First 1000 chars of raw API response (for debugging).
|
||||
exchange_id: Server-assigned exchange UUID for this turn. When passed
|
||||
back in follow-up requests, enables server-side context lookup
|
||||
without replaying conversation history.
|
||||
"""
|
||||
|
||||
answer: str
|
||||
|
|
@ -1047,7 +1044,6 @@ class AskResult:
|
|||
is_follow_up: bool
|
||||
references: list["ChatReference"] = field(default_factory=list)
|
||||
raw_response: str = ""
|
||||
exchange_id: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ POLL_TIMEOUT = 60.0 # Max time to wait for operations
|
|||
# Helps avoid API rate limits when running multiple generation tests
|
||||
GENERATION_TEST_DELAY = 15.0
|
||||
|
||||
# Delay between chat tests (seconds) to avoid API rate limits from rapid ask() calls
|
||||
CHAT_TEST_DELAY = 5.0
|
||||
|
||||
|
||||
def assert_generation_started(result, artifact_type: str = "Artifact") -> None:
|
||||
"""Assert that artifact generation started successfully.
|
||||
|
|
@ -107,31 +110,31 @@ def pytest_collection_modifyitems(config, items):
|
|||
|
||||
|
||||
def pytest_runtest_teardown(item, nextitem):
|
||||
"""Add delay after generation tests to avoid API rate limits.
|
||||
"""Add delay after generation and chat tests to avoid API rate limits.
|
||||
|
||||
This hook runs after each test. If the test is in test_generation.py
|
||||
and uses the generation_notebook_id fixture, add a delay before the
|
||||
next test starts.
|
||||
This hook runs after each test. Adds delays for:
|
||||
- test_generation.py: 15s between generation tests (artifact quotas)
|
||||
- test_chat.py: 5s between chat tests (ask() rate limits)
|
||||
"""
|
||||
import time
|
||||
|
||||
# Only add delay for generation tests
|
||||
if item.path.name != "test_generation.py":
|
||||
return
|
||||
|
||||
# Only add delay if using generation_notebook_id fixture
|
||||
if "generation_notebook_id" not in item.fixturenames:
|
||||
return
|
||||
|
||||
# Only add delay if there's a next test (avoid delay at the end)
|
||||
if nextitem is None:
|
||||
return
|
||||
|
||||
# Add delay to spread out API calls
|
||||
logging.info(
|
||||
"Delaying %ss between generation tests to avoid rate limiting", GENERATION_TEST_DELAY
|
||||
)
|
||||
time.sleep(GENERATION_TEST_DELAY)
|
||||
if item.path.name == "test_generation.py":
|
||||
if "generation_notebook_id" not in item.fixturenames:
|
||||
return
|
||||
logging.info(
|
||||
"Delaying %ss between generation tests to avoid rate limiting", GENERATION_TEST_DELAY
|
||||
)
|
||||
time.sleep(GENERATION_TEST_DELAY)
|
||||
return
|
||||
|
||||
if item.path.name == "test_chat.py":
|
||||
if "multi_source_notebook_id" not in item.fixturenames:
|
||||
return
|
||||
logging.info("Delaying %ss between chat tests to avoid rate limiting", CHAT_TEST_DELAY)
|
||||
time.sleep(CHAT_TEST_DELAY)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import asyncio
|
|||
import pytest
|
||||
|
||||
from notebooklm import Artifact, ArtifactType, ReportSuggestion
|
||||
from notebooklm.exceptions import RPCTimeoutError
|
||||
|
||||
from .conftest import assert_generation_started, requires_auth
|
||||
|
||||
|
|
@ -144,7 +145,10 @@ class TestReportSuggestions:
|
|||
@pytest.mark.readonly
|
||||
async def test_suggest_reports(self, client, read_only_notebook_id):
|
||||
"""Read-only test - gets suggestions without generating."""
|
||||
suggestions = await client.artifacts.suggest_reports(read_only_notebook_id)
|
||||
try:
|
||||
suggestions = await client.artifacts.suggest_reports(read_only_notebook_id)
|
||||
except RPCTimeoutError:
|
||||
pytest.skip("GET_SUGGESTED_REPORTS timed out - API may be rate limited")
|
||||
|
||||
assert isinstance(suggestions, list)
|
||||
if suggestions:
|
||||
|
|
@ -163,6 +167,10 @@ class TestArtifactMutations:
|
|||
Delete test uses a separate quiz artifact to spread rate limits.
|
||||
"""
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="generation + wait_for_completion exceeds 60s pytest timeout; "
|
||||
"individual operations covered by other tests"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_rename_wait(self, client, temp_notebook):
|
||||
"""Test poll_status, rename, and wait_for_completion on ONE artifact.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,34 @@ Run with: pytest tests/e2e/test_chat.py -m e2e
|
|||
import pytest
|
||||
|
||||
from notebooklm import AskResult, ChatReference
|
||||
from notebooklm.exceptions import ChatError
|
||||
|
||||
from .conftest import requires_auth
|
||||
|
||||
_RATE_LIMIT_PHRASES = ("rate limit", "rate limited", "rejected by the api")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _skip_on_chat_rate_limit(client):
|
||||
"""Auto-skip any test that hits a chat API rate limit.
|
||||
|
||||
Only skips on actual rate limit errors (ChatError with rate-limit message).
|
||||
Other ChatErrors (HTTP failures, auth errors, etc.) are re-raised so they
|
||||
show as failures rather than silently skipping.
|
||||
"""
|
||||
original_ask = client.chat.ask
|
||||
|
||||
async def _ask_with_skip(*args, **kwargs):
|
||||
try:
|
||||
return await original_ask(*args, **kwargs)
|
||||
except ChatError as e:
|
||||
msg = str(e).lower()
|
||||
if any(phrase in msg for phrase in _RATE_LIMIT_PHRASES):
|
||||
pytest.skip(str(e))
|
||||
raise
|
||||
|
||||
client.chat.ask = _ask_with_skip
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@requires_auth
|
||||
|
|
@ -150,24 +175,33 @@ class TestChatE2E:
|
|||
@pytest.mark.e2e
|
||||
@requires_auth
|
||||
class TestChatHistoryE2E:
|
||||
"""E2E tests for chat history and conversation turns API (khqZz RPC)."""
|
||||
"""E2E tests for chat history and conversation turns API (khqZz RPC).
|
||||
|
||||
These tests use an existing read-only notebook with pre-existing conversation
|
||||
history. They do not ask new questions, since conversation persistence takes
|
||||
time and makes tests flaky.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation_turns_returns_qa(self, client, multi_source_notebook_id):
|
||||
"""get_conversation_turns returns Q&A turns for a conversation."""
|
||||
ask_result = await client.chat.ask(
|
||||
multi_source_notebook_id,
|
||||
"What is the main topic of these sources?",
|
||||
)
|
||||
assert ask_result.conversation_id
|
||||
@pytest.mark.readonly
|
||||
async def test_get_conversation_turns_returns_qa(self, client, read_only_notebook_id):
|
||||
"""get_conversation_turns returns Q&A turns for an existing conversation."""
|
||||
conv_id = await client.chat.get_conversation_id(read_only_notebook_id)
|
||||
if not conv_id:
|
||||
pytest.skip("No conversation history available in read-only notebook")
|
||||
|
||||
turns_data = await client.chat.get_conversation_turns(
|
||||
multi_source_notebook_id,
|
||||
ask_result.conversation_id,
|
||||
read_only_notebook_id,
|
||||
conv_id,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert turns_data is not None
|
||||
if not turns_data:
|
||||
pytest.skip(
|
||||
"Read-only notebook has a conversation but no chat turns — "
|
||||
"cannot verify turn structure. Seed the notebook with chat messages to enable this test."
|
||||
)
|
||||
assert isinstance(turns_data[0], list)
|
||||
turns = turns_data[0]
|
||||
assert len(turns) >= 1
|
||||
|
|
@ -176,41 +210,51 @@ class TestChatHistoryE2E:
|
|||
assert any(t in (1, 2) for t in turn_types), "Expected question or answer turns"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation_turns_question_text(self, client, multi_source_notebook_id):
|
||||
"""get_conversation_turns includes the original question text."""
|
||||
question = "What topics are covered in detail?"
|
||||
ask_result = await client.chat.ask(multi_source_notebook_id, question)
|
||||
assert ask_result.conversation_id
|
||||
@pytest.mark.readonly
|
||||
async def test_get_conversation_turns_question_text(self, client, read_only_notebook_id):
|
||||
"""get_conversation_turns includes question text in an existing conversation."""
|
||||
conv_id = await client.chat.get_conversation_id(read_only_notebook_id)
|
||||
if not conv_id:
|
||||
pytest.skip("No conversation history available in read-only notebook")
|
||||
|
||||
turns_data = await client.chat.get_conversation_turns(
|
||||
multi_source_notebook_id,
|
||||
ask_result.conversation_id,
|
||||
read_only_notebook_id,
|
||||
conv_id,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert turns_data is not None
|
||||
if not turns_data:
|
||||
pytest.skip(
|
||||
"Read-only notebook has a conversation but no chat turns — "
|
||||
"cannot verify question text. Seed the notebook with chat messages to enable this test."
|
||||
)
|
||||
turns = turns_data[0]
|
||||
question_turns = [t for t in turns if isinstance(t, list) and len(t) > 3 and t[2] == 1]
|
||||
assert question_turns, "No question turn found in response"
|
||||
assert question_turns[0][3] == question
|
||||
assert isinstance(question_turns[0][3], str)
|
||||
assert len(question_turns[0][3]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation_turns_answer_text(self, client, multi_source_notebook_id):
|
||||
"""get_conversation_turns includes the AI answer text."""
|
||||
ask_result = await client.chat.ask(
|
||||
multi_source_notebook_id,
|
||||
"Briefly describe what you know about this notebook.",
|
||||
)
|
||||
assert ask_result.conversation_id
|
||||
assert ask_result.answer
|
||||
@pytest.mark.readonly
|
||||
async def test_get_conversation_turns_answer_text(self, client, read_only_notebook_id):
|
||||
"""get_conversation_turns includes AI answer text in an existing conversation."""
|
||||
conv_id = await client.chat.get_conversation_id(read_only_notebook_id)
|
||||
if not conv_id:
|
||||
pytest.skip("No conversation history available in read-only notebook")
|
||||
|
||||
turns_data = await client.chat.get_conversation_turns(
|
||||
multi_source_notebook_id,
|
||||
ask_result.conversation_id,
|
||||
read_only_notebook_id,
|
||||
conv_id,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert turns_data is not None
|
||||
if not turns_data:
|
||||
pytest.skip(
|
||||
"Read-only notebook has a conversation but no chat turns — "
|
||||
"cannot verify answer text. Seed the notebook with chat messages to enable this test."
|
||||
)
|
||||
turns = turns_data[0]
|
||||
answer_turns = [t for t in turns if isinstance(t, list) and len(t) > 4 and t[2] == 2]
|
||||
assert answer_turns, "No answer turn found in response"
|
||||
|
|
@ -219,27 +263,23 @@ class TestChatHistoryE2E:
|
|||
assert len(answer_text) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_last_conversation_id(self, client, multi_source_notebook_id):
|
||||
"""get_last_conversation_id returns the conversation created by ask."""
|
||||
ask_result = await client.chat.ask(
|
||||
multi_source_notebook_id,
|
||||
"What is one key concept in these sources?",
|
||||
)
|
||||
assert ask_result.conversation_id
|
||||
@pytest.mark.readonly
|
||||
async def test_get_conversation_id(self, client, read_only_notebook_id):
|
||||
"""get_conversation_id returns an existing conversation ID."""
|
||||
conv_id = await client.chat.get_conversation_id(read_only_notebook_id)
|
||||
if not conv_id:
|
||||
pytest.skip("No conversation history available in read-only notebook")
|
||||
|
||||
conv_id = await client.chat.get_last_conversation_id(multi_source_notebook_id)
|
||||
assert conv_id == ask_result.conversation_id
|
||||
assert isinstance(conv_id, str)
|
||||
assert len(conv_id) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_returns_qa_pairs(self, client, multi_source_notebook_id):
|
||||
"""Full flow: ask → get_history returns Q&A pairs."""
|
||||
question = "List one important topic from the sources."
|
||||
ask_result = await client.chat.ask(multi_source_notebook_id, question)
|
||||
assert ask_result.conversation_id
|
||||
|
||||
qa_pairs = await client.chat.get_history(multi_source_notebook_id)
|
||||
assert qa_pairs, "get_history returned no Q&A pairs"
|
||||
assert isinstance(qa_pairs, list)
|
||||
@pytest.mark.readonly
|
||||
async def test_get_history_returns_qa_pairs(self, client, read_only_notebook_id):
|
||||
"""get_history returns Q&A pairs from existing conversation history."""
|
||||
qa_pairs = await client.chat.get_history(read_only_notebook_id)
|
||||
if not qa_pairs:
|
||||
pytest.skip("No conversation history available in read-only notebook")
|
||||
|
||||
# Each entry is a (question, answer) tuple
|
||||
q, a = qa_pairs[-1] # most recent Q&A
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ class TestNotebookOperations:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation_history(self, client, read_only_notebook_id):
|
||||
qa_pairs = await client.chat.get_history(read_only_notebook_id)
|
||||
assert isinstance(qa_pairs, list)
|
||||
conversations = await client.chat.get_history(read_only_notebook_id)
|
||||
assert isinstance(conversations, list)
|
||||
|
||||
|
||||
@requires_auth
|
||||
|
|
@ -60,7 +60,7 @@ class TestNotebookDescription:
|
|||
description = await client.notebooks.get_description(read_only_notebook_id)
|
||||
|
||||
assert isinstance(description, NotebookDescription)
|
||||
assert description.summary is not None
|
||||
assert description.summary, "Expected non-empty summary from get_description"
|
||||
assert isinstance(description.suggested_topics, list)
|
||||
|
||||
|
||||
|
|
@ -92,8 +92,7 @@ class TestNotebookSummary:
|
|||
async def test_get_summary(self, client, read_only_notebook_id):
|
||||
"""Test getting notebook summary."""
|
||||
summary = await client.notebooks.get_summary(read_only_notebook_id)
|
||||
# Summary may be empty string if not generated yet
|
||||
assert isinstance(summary, str)
|
||||
assert summary, "Expected non-empty summary from get_summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.readonly
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,22 @@
|
|||
"""Integration tests for client initialization and core functionality."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from notebooklm import NotebookLMClient
|
||||
from notebooklm._core import MAX_CONVERSATION_CACHE_SIZE, ClientCore, is_auth_error
|
||||
from notebooklm.rpc import (
|
||||
AuthError,
|
||||
ClientError,
|
||||
NetworkError,
|
||||
RateLimitError,
|
||||
RPCError,
|
||||
RPCMethod,
|
||||
RPCTimeoutError,
|
||||
ServerError,
|
||||
)
|
||||
|
||||
|
||||
class TestClientInitialization:
|
||||
|
|
@ -23,3 +37,412 @@ class TestClientInitialization:
|
|||
client = NotebookLMClient(auth_tokens)
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
await client.notebooks.list()
|
||||
|
||||
|
||||
class TestIsAuthError:
|
||||
"""Tests for the is_auth_error() helper function."""
|
||||
|
||||
def test_returns_true_for_auth_error(self):
|
||||
assert is_auth_error(AuthError("invalid credentials")) is True
|
||||
|
||||
def test_returns_false_for_network_error(self):
|
||||
assert is_auth_error(NetworkError("network down")) is False
|
||||
|
||||
def test_returns_false_for_rate_limit_error(self):
|
||||
assert is_auth_error(RateLimitError("rate limited")) is False
|
||||
|
||||
def test_returns_false_for_server_error(self):
|
||||
assert is_auth_error(ServerError("500 error")) is False
|
||||
|
||||
def test_returns_false_for_client_error(self):
|
||||
assert is_auth_error(ClientError("400 bad request")) is False
|
||||
|
||||
def test_returns_false_for_rpc_timeout_error(self):
|
||||
assert is_auth_error(RPCTimeoutError("timed out")) is False
|
||||
|
||||
def test_returns_true_for_401_http_status_error(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
error = httpx.HTTPStatusError("401", request=MagicMock(), response=mock_response)
|
||||
assert is_auth_error(error) is True
|
||||
|
||||
def test_returns_true_for_403_http_status_error(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 403
|
||||
error = httpx.HTTPStatusError("403", request=MagicMock(), response=mock_response)
|
||||
assert is_auth_error(error) is True
|
||||
|
||||
def test_returns_false_for_500_http_status_error(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
error = httpx.HTTPStatusError("500", request=MagicMock(), response=mock_response)
|
||||
assert is_auth_error(error) is False
|
||||
|
||||
def test_returns_true_for_rpc_error_with_auth_message(self):
|
||||
assert is_auth_error(RPCError("authentication expired")) is True
|
||||
|
||||
def test_returns_false_for_rpc_error_with_generic_message(self):
|
||||
assert is_auth_error(RPCError("some generic error")) is False
|
||||
|
||||
def test_returns_false_for_plain_exception(self):
|
||||
assert is_auth_error(ValueError("not an rpc error")) is False
|
||||
|
||||
|
||||
class TestRPCCallHTTPErrors:
|
||||
"""Tests for HTTP error handling in rpc_call()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_429_with_retry_after_header(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 429
|
||||
mock_response.headers = {"retry-after": "60"}
|
||||
mock_response.reason_phrase = "Too Many Requests"
|
||||
error = httpx.HTTPStatusError("429", request=MagicMock(), response=mock_response)
|
||||
|
||||
with (
|
||||
patch.object(core._http_client, "post", side_effect=error),
|
||||
pytest.raises(RateLimitError) as exc_info,
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
assert exc_info.value.retry_after == 60
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_429_without_retry_after_header(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 429
|
||||
mock_response.headers = {}
|
||||
mock_response.reason_phrase = "Too Many Requests"
|
||||
error = httpx.HTTPStatusError("429", request=MagicMock(), response=mock_response)
|
||||
|
||||
with (
|
||||
patch.object(core._http_client, "post", side_effect=error),
|
||||
pytest.raises(RateLimitError) as exc_info,
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
assert exc_info.value.retry_after is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_429_with_invalid_retry_after_header(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 429
|
||||
mock_response.headers = {"retry-after": "not-a-number"}
|
||||
mock_response.reason_phrase = "Too Many Requests"
|
||||
error = httpx.HTTPStatusError("429", request=MagicMock(), response=mock_response)
|
||||
|
||||
with (
|
||||
patch.object(core._http_client, "post", side_effect=error),
|
||||
pytest.raises(RateLimitError) as exc_info,
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
assert exc_info.value.retry_after is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_error_400(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.reason_phrase = "Bad Request"
|
||||
error = httpx.HTTPStatusError("400", request=MagicMock(), response=mock_response)
|
||||
|
||||
with (
|
||||
patch.object(core._http_client, "post", side_effect=error),
|
||||
pytest.raises(ClientError),
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_error_500(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.reason_phrase = "Internal Server Error"
|
||||
error = httpx.HTTPStatusError("500", request=MagicMock(), response=mock_response)
|
||||
|
||||
with (
|
||||
patch.object(core._http_client, "post", side_effect=error),
|
||||
pytest.raises(ServerError),
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_timeout_raises_network_error(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
core._http_client,
|
||||
"post",
|
||||
side_effect=httpx.ConnectTimeout("connect timeout"),
|
||||
),
|
||||
pytest.raises(NetworkError),
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_timeout_raises_rpc_timeout_error(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
core._http_client,
|
||||
"post",
|
||||
side_effect=httpx.ReadTimeout("read timeout"),
|
||||
),
|
||||
pytest.raises(RPCTimeoutError),
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_error_raises_network_error(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
core._http_client,
|
||||
"post",
|
||||
side_effect=httpx.ConnectError("connection refused"),
|
||||
),
|
||||
pytest.raises(NetworkError),
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_request_error_raises_network_error(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
core._http_client,
|
||||
"post",
|
||||
side_effect=httpx.RequestError("something went wrong"),
|
||||
),
|
||||
pytest.raises(NetworkError),
|
||||
):
|
||||
await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
|
||||
class TestRPCCallAuthRetry:
|
||||
"""Tests for auth retry path after decode_response raises RPCError."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_retry_on_decode_rpc_error(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
refresh_callback = AsyncMock()
|
||||
core._refresh_callback = refresh_callback
|
||||
import asyncio
|
||||
|
||||
core._refresh_lock = asyncio.Lock()
|
||||
|
||||
success_response = MagicMock()
|
||||
success_response.status_code = 200
|
||||
success_response.text = "some_valid_response"
|
||||
|
||||
with (
|
||||
patch.object(core._http_client, "post", return_value=success_response),
|
||||
patch(
|
||||
"notebooklm._core.decode_response",
|
||||
side_effect=[
|
||||
RPCError("authentication expired"),
|
||||
["result_data"],
|
||||
],
|
||||
),
|
||||
):
|
||||
result = await core.rpc_call(RPCMethod.LIST_NOTEBOOKS, [])
|
||||
|
||||
assert result == ["result_data"]
|
||||
refresh_callback.assert_called_once()
|
||||
|
||||
|
||||
class TestGetHttpClient:
|
||||
"""Tests for get_http_client() RuntimeError when not initialized."""
|
||||
|
||||
def test_get_http_client_raises_when_not_initialized(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
core.get_http_client()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_http_client_returns_client_when_initialized(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
http_client = client._core.get_http_client()
|
||||
assert isinstance(http_client, httpx.AsyncClient)
|
||||
|
||||
|
||||
class TestConversationCacheFIFOEviction:
|
||||
"""Tests for FIFO eviction when conversation cache exceeds MAX_CONVERSATION_CACHE_SIZE."""
|
||||
|
||||
def test_fifo_eviction_when_cache_is_full(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
|
||||
# Fill the cache to capacity
|
||||
for i in range(MAX_CONVERSATION_CACHE_SIZE):
|
||||
core.cache_conversation_turn(f"conv_{i}", f"q{i}", f"a{i}", i)
|
||||
|
||||
assert len(core._conversation_cache) == MAX_CONVERSATION_CACHE_SIZE
|
||||
|
||||
# Adding one more should evict the oldest (conv_0)
|
||||
core.cache_conversation_turn("conv_new", "q_new", "a_new", 0)
|
||||
|
||||
assert len(core._conversation_cache) == MAX_CONVERSATION_CACHE_SIZE
|
||||
assert "conv_0" not in core._conversation_cache
|
||||
assert "conv_new" in core._conversation_cache
|
||||
|
||||
def test_fifo_eviction_preserves_order(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
|
||||
# Fill cache to capacity
|
||||
for i in range(MAX_CONVERSATION_CACHE_SIZE):
|
||||
core.cache_conversation_turn(f"conv_{i}", f"q{i}", f"a{i}", i)
|
||||
|
||||
# Add two new conversations - should evict conv_0 then conv_1
|
||||
core.cache_conversation_turn("conv_new_1", "q1", "a1", 0)
|
||||
core.cache_conversation_turn("conv_new_2", "q2", "a2", 0)
|
||||
|
||||
assert "conv_0" not in core._conversation_cache
|
||||
assert "conv_1" not in core._conversation_cache
|
||||
assert "conv_new_1" in core._conversation_cache
|
||||
assert "conv_new_2" in core._conversation_cache
|
||||
|
||||
def test_adding_turns_to_existing_conversation_does_not_evict(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
|
||||
# Fill cache to capacity
|
||||
for i in range(MAX_CONVERSATION_CACHE_SIZE):
|
||||
core.cache_conversation_turn(f"conv_{i}", f"q{i}", f"a{i}", i)
|
||||
|
||||
# Adding a second turn to an EXISTING conversation should NOT evict anything
|
||||
core.cache_conversation_turn("conv_0", "q_extra", "a_extra", 1)
|
||||
|
||||
assert len(core._conversation_cache) == MAX_CONVERSATION_CACHE_SIZE
|
||||
assert len(core._conversation_cache["conv_0"]) == 2
|
||||
|
||||
|
||||
class TestClearConversationCacheNotFound:
|
||||
"""Tests for clear_conversation_cache() returning False when ID not found."""
|
||||
|
||||
def test_clear_nonexistent_conversation_returns_false(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
result = core.clear_conversation_cache("nonexistent_id")
|
||||
assert result is False
|
||||
|
||||
def test_clear_existing_conversation_returns_true(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
core.cache_conversation_turn("conv_abc", "question", "answer", 1)
|
||||
result = core.clear_conversation_cache("conv_abc")
|
||||
assert result is True
|
||||
assert "conv_abc" not in core._conversation_cache
|
||||
|
||||
def test_clear_all_conversations_returns_true(self, auth_tokens):
|
||||
core = ClientCore(auth_tokens)
|
||||
core.cache_conversation_turn("conv_1", "q1", "a1", 1)
|
||||
core.cache_conversation_turn("conv_2", "q2", "a2", 1)
|
||||
result = core.clear_conversation_cache()
|
||||
assert result is True
|
||||
assert len(core._conversation_cache) == 0
|
||||
|
||||
|
||||
class TestGetSourceIds:
|
||||
"""Tests for get_source_ids() extracting source IDs from notebook data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_source_ids_from_nested_data(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
mock_notebook_data = [
|
||||
[
|
||||
"notebook_title",
|
||||
[
|
||||
[["src_id_1", "extra"]],
|
||||
[["src_id_2", "extra"]],
|
||||
],
|
||||
]
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
core, "rpc_call", new_callable=AsyncMock, return_value=mock_notebook_data
|
||||
):
|
||||
ids = await core.get_source_ids("nb_123")
|
||||
|
||||
assert ids == ["src_id_1", "src_id_2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_data_is_none(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with patch.object(core, "rpc_call", new_callable=AsyncMock, return_value=None):
|
||||
ids = await core.get_source_ids("nb_123")
|
||||
|
||||
assert ids == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_data_is_empty_list(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with patch.object(core, "rpc_call", new_callable=AsyncMock, return_value=[]):
|
||||
ids = await core.get_source_ids("nb_123")
|
||||
|
||||
assert ids == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_sources_list_is_empty(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
# Notebook with no sources
|
||||
mock_notebook_data = [["notebook_title", []]]
|
||||
|
||||
with patch.object(
|
||||
core, "rpc_call", new_callable=AsyncMock, return_value=mock_notebook_data
|
||||
):
|
||||
ids = await core.get_source_ids("nb_123")
|
||||
|
||||
assert ids == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_data_is_not_list(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
with patch.object(
|
||||
core, "rpc_call", new_callable=AsyncMock, return_value="unexpected_string"
|
||||
):
|
||||
ids = await core.get_source_ids("nb_123")
|
||||
|
||||
assert ids == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_list_when_notebook_info_missing_sources(self, auth_tokens):
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
core = client._core
|
||||
|
||||
# notebook_data[0] exists but notebook_info[1] is missing
|
||||
mock_notebook_data = [["notebook_title_only"]]
|
||||
|
||||
with patch.object(
|
||||
core, "rpc_call", new_callable=AsyncMock, return_value=mock_notebook_data
|
||||
):
|
||||
ids = await core.get_source_ids("nb_123")
|
||||
|
||||
assert ids == []
|
||||
|
|
|
|||
|
|
@ -201,7 +201,9 @@ class TestSummary:
|
|||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
response = build_rpc_response(RPCMethod.SUMMARIZE, ["Summary of the notebook content..."])
|
||||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE, [[["Summary of the notebook content..."]]]
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
|
|
@ -313,7 +315,7 @@ class TestNotebooksAPIAdditional:
|
|||
"""Test getting notebook summary."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
["This is a comprehensive summary of the notebook content..."],
|
||||
[[["This is a comprehensive summary of the notebook content..."]]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
|
|
@ -372,13 +374,15 @@ class TestNotebooksAPIAdditional:
|
|||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
[
|
||||
["This notebook covers AI research."],
|
||||
[
|
||||
["This notebook covers AI research."],
|
||||
[
|
||||
["What are the main findings?", "Explain the key findings"],
|
||||
["How was the study conducted?", "Describe methodology"],
|
||||
]
|
||||
],
|
||||
[
|
||||
["What are the main findings?", "Explain the key findings"],
|
||||
["How was the study conducted?", "Describe methodology"],
|
||||
]
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
|
@ -522,7 +526,7 @@ class TestNotebookEdgeCases:
|
|||
"""Test getting description with no suggested topics."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
[["Summary text"], []],
|
||||
[[["Summary text"], []]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
|
|
@ -543,14 +547,16 @@ class TestNotebookEdgeCases:
|
|||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
[
|
||||
["Summary"],
|
||||
[
|
||||
["Summary"],
|
||||
[
|
||||
["Valid question", "Valid prompt"],
|
||||
["Only question"], # Missing prompt
|
||||
"not a list", # Not a list
|
||||
]
|
||||
],
|
||||
[
|
||||
["Valid question", "Valid prompt"],
|
||||
["Only question"], # Missing prompt
|
||||
"not a list", # Not a list
|
||||
]
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
|
@ -562,3 +568,133 @@ class TestNotebookEdgeCases:
|
|||
# Should only include valid topics
|
||||
assert len(description.suggested_topics) == 1
|
||||
assert description.suggested_topics[0].question == "Valid question"
|
||||
|
||||
|
||||
class TestDescribeEdgeCases:
|
||||
"""Tests for get_description() branch edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_description_no_topics_key(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""result has only outer[0] (no outer[1]) so topics stay empty."""
|
||||
# result = [[["A summary"]]] — outer[0] has summary, no outer[1] for topics
|
||||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
[[["A summary"]]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
description = await client.notebooks.get_description("nb_123")
|
||||
|
||||
assert description.summary == "A summary"
|
||||
assert description.suggested_topics == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_description_result_1_is_empty_list(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""outer[1] exists but is an empty list, so topics block is skipped."""
|
||||
# result = [[["A summary"], []]] — outer[1] is empty, so topics are skipped
|
||||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
[[["A summary"], []]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
description = await client.notebooks.get_description("nb_123")
|
||||
|
||||
assert description.summary == "A summary"
|
||||
assert description.suggested_topics == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_description_result_1_not_list(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""outer[1] is present but not a list, so topics block is skipped."""
|
||||
# result = [[["A summary"], "not-a-list"]] — outer[1] is not a list, topics skipped
|
||||
response = build_rpc_response(
|
||||
RPCMethod.SUMMARIZE,
|
||||
[[["A summary"], "not-a-list"]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
description = await client.notebooks.get_description("nb_123")
|
||||
|
||||
assert description.summary == "A summary"
|
||||
assert description.suggested_topics == []
|
||||
|
||||
|
||||
class TestShareEdgeCases:
|
||||
"""Tests for share() and get_share_url() branch edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_with_artifact_id(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 260: share() public=True with artifact_id builds deep-link URL."""
|
||||
response = build_rpc_response(RPCMethod.SHARE_ARTIFACT, None)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.notebooks.share("nb_123", public=True, artifact_id="art_456")
|
||||
|
||||
assert result["public"] is True
|
||||
assert result["url"] == "https://notebooklm.google.com/notebook/nb_123?artifactId=art_456"
|
||||
assert result["artifact_id"] == "art_456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_public_false_returns_none_url(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 264: share() public=False sets url to None."""
|
||||
response = build_rpc_response(RPCMethod.SHARE_ARTIFACT, None)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.notebooks.share("nb_123", public=False)
|
||||
|
||||
assert result["public"] is False
|
||||
assert result["url"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_share_url_without_artifact(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
):
|
||||
"""Line 288: get_share_url() without artifact_id returns base URL."""
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
url = client.notebooks.get_share_url("nb_123")
|
||||
|
||||
assert url == "https://notebooklm.google.com/notebook/nb_123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_share_url_with_artifact(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
):
|
||||
"""Lines 285-287: get_share_url() with artifact_id appends query param."""
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
url = client.notebooks.get_share_url("nb_123", artifact_id="art_789")
|
||||
|
||||
assert url == "https://notebooklm.google.com/notebook/nb_123?artifactId=art_789"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import pytest
|
|||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from notebooklm import NotebookLMClient
|
||||
from notebooklm.rpc import RPCMethod
|
||||
|
||||
|
||||
class TestResearchAPI:
|
||||
|
|
@ -244,3 +245,454 @@ class TestResearchAPI:
|
|||
result = await client.research.import_sources("nb_123", "task_123", [])
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPollEdgeCases:
|
||||
"""Tests for poll() parsing branch edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_unwrap_nested_result(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 132: result[0] is a list whose first element is also a list — unwrap one level."""
|
||||
# Outer list wraps the inner task list: result[0][0] is a list → unwrap
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[
|
||||
[
|
||||
[
|
||||
"task_wrap",
|
||||
[None, ["wrapped query"], None, [], 1],
|
||||
]
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["task_id"] == "task_wrap"
|
||||
assert result["query"] == "wrapped query"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_skips_non_list_task_data(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 137: task_data is not a list — continue, eventually return no_research."""
|
||||
# Outer list contains a non-list item then a too-short list
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
["not_a_list", ["only_one_elem"]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "no_research"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_skips_non_string_task_id(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 143: task_id is not str — continue, eventually return no_research."""
|
||||
# task_id is an integer (not str) and task_info is a list
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[[42, [None, ["query"], None, [], 1]]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "no_research"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_skips_non_list_task_info(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 143: task_info is not a list — continue, eventually return no_research."""
|
||||
# task_id is str but task_info is a string, not list
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[["task_bad", "not_a_list"]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "no_research"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_sources_and_summary_has_only_sources_no_summary(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 157->160: sources_and_summary has len 1 (sources only, no summary string)."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[
|
||||
[
|
||||
"task_nosummary",
|
||||
[
|
||||
None,
|
||||
["no summary query"],
|
||||
None,
|
||||
[
|
||||
[
|
||||
["https://example.com", "Title", "desc"],
|
||||
]
|
||||
# No second element — summary is absent
|
||||
],
|
||||
2,
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["summary"] == ""
|
||||
assert len(result["sources"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_skips_short_source_entry(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 163: a source entry in sources_data is too short (len < 2) — skipped."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[
|
||||
[
|
||||
"task_shortsrc",
|
||||
[
|
||||
None,
|
||||
["short src query"],
|
||||
None,
|
||||
[
|
||||
[
|
||||
["only_one_element"], # len < 2 → skipped
|
||||
["https://valid.com", "Valid"], # kept
|
||||
],
|
||||
"Summary text",
|
||||
],
|
||||
2,
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
# Only the valid source is returned
|
||||
assert len(result["sources"]) == 1
|
||||
assert result["sources"][0]["url"] == "https://valid.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_deep_research_source_none_first_element(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Lines 171-172: deep research source where src[0] is None — title extracted, url=''."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[
|
||||
[
|
||||
"task_deep",
|
||||
[
|
||||
None,
|
||||
["deep query"],
|
||||
None,
|
||||
[
|
||||
[
|
||||
[None, "Deep Research Title", None, "web"],
|
||||
],
|
||||
"Deep summary",
|
||||
],
|
||||
2,
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert len(result["sources"]) == 1
|
||||
assert result["sources"][0]["title"] == "Deep Research Title"
|
||||
assert result["sources"][0]["url"] == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_fast_research_source_with_url(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Lines 173-175: fast research source where src[0] is a str URL."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[
|
||||
[
|
||||
"task_fast",
|
||||
[
|
||||
None,
|
||||
["fast query"],
|
||||
None,
|
||||
[
|
||||
[
|
||||
["https://fast.example.com", "Fast Title", "desc", "web"],
|
||||
],
|
||||
"Fast summary",
|
||||
],
|
||||
1,
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "in_progress"
|
||||
assert result["sources"][0]["url"] == "https://fast.example.com"
|
||||
assert result["sources"][0]["title"] == "Fast Title"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_source_with_no_title_or_url_skipped(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 177->161: src has two elements but neither is title nor url — not appended."""
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[
|
||||
[
|
||||
"task_empty_src",
|
||||
[
|
||||
None,
|
||||
["empty src query"],
|
||||
None,
|
||||
[
|
||||
[
|
||||
# src[0] is not None and not str (e.g. integer), len < 3
|
||||
# so url="", title="" and nothing is appended
|
||||
[42, 99],
|
||||
],
|
||||
"summary here",
|
||||
],
|
||||
2,
|
||||
],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["sources"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_all_tasks_invalid_returns_no_research(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 193: all items in the loop fail validation — final no_research is returned."""
|
||||
# All task_data entries are short lists (len < 2) so every iteration hits `continue`
|
||||
response = build_rpc_response(
|
||||
RPCMethod.POLL_RESEARCH,
|
||||
[["only_one"], ["also_one"]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.poll("nb_123")
|
||||
|
||||
assert result["status"] == "no_research"
|
||||
|
||||
|
||||
class TestImportSourcesEdgeCases:
|
||||
"""Tests for import_sources() parsing branch edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_sources_skips_no_url_sources(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Lines 226, 228: sources without URLs are skipped; if ALL lack URLs, return []."""
|
||||
# No HTTP call should be made when all sources lack URLs
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.import_sources(
|
||||
"nb_123",
|
||||
"task_123",
|
||||
[{"title": "No URL source"}, {"title": "Also no URL"}],
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_sources_filters_some_no_url(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 226: sources without URLs are filtered, valid ones are imported."""
|
||||
# Double-wrap so the unwrap logic peels one layer: result[0][0] is a list
|
||||
response = build_rpc_response(
|
||||
RPCMethod.IMPORT_RESEARCH,
|
||||
[
|
||||
[
|
||||
[["src_good"], "Good Source"],
|
||||
]
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.import_sources(
|
||||
"nb_123",
|
||||
"task_123",
|
||||
[
|
||||
{"url": "https://good.com", "title": "Good Source"},
|
||||
{"title": "No URL source"}, # filtered out
|
||||
],
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "src_good"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_sources_no_double_nesting(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 257->265: result[0][0] is not a list — no unwrap, loop runs on original result.
|
||||
|
||||
The unwrap condition requires result[0][0] to be a list. When result[0][0] is a
|
||||
non-list value (e.g. None), the if-block is skipped and the for loop runs directly.
|
||||
"""
|
||||
# result[0] = [None, "Flat Title"] so result[0][0] = None (not a list) → no unwrap
|
||||
# The loop then processes each item in the original result directly.
|
||||
# [None, "Flat Title"] has src_data[0]=None → src_id = None → skipped (covers 270->265)
|
||||
# So we also include a valid entry to verify the loop ran:
|
||||
# However, we need result[0][0] to NOT be a list to avoid unwrap.
|
||||
# A valid entry looks like [["src_id"], "Title"] but result[0][0]=["src_id"] IS a list.
|
||||
# The only way to avoid unwrap AND get results is if result[0] is a list but
|
||||
# result[0][0] is not a list. Use result = ["not_a_list_entry", [["src_nw"], "Title"]].
|
||||
# result[0] = "not_a_list_entry" → isinstance(result[0], list) is False → no unwrap.
|
||||
response = build_rpc_response(
|
||||
RPCMethod.IMPORT_RESEARCH,
|
||||
# result[0] is a string, not a list → isinstance(result[0], list) is False
|
||||
# condition fails → no unwrap → loop runs on the original result
|
||||
["string_not_list", [["src_nw"], "No-Wrap Title"]],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.import_sources(
|
||||
"nb_123",
|
||||
"task_123",
|
||||
[{"url": "https://nowrap.example.com", "title": "No-Wrap Title"}],
|
||||
)
|
||||
|
||||
# "string_not_list" is not a list → skipped; [["src_nw"], "No-Wrap Title"] is valid
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "src_nw"
|
||||
assert result[0]["title"] == "No-Wrap Title"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_sources_src_data_too_short_skipped(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 266->265: src_data in result has len < 2 — skipped in loop."""
|
||||
# First entry is too short (len 1), second is valid
|
||||
response = build_rpc_response(
|
||||
RPCMethod.IMPORT_RESEARCH,
|
||||
[
|
||||
["short_only"], # len 1 — skipped
|
||||
[["src_valid"], "Valid"], # len 2 — kept
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.import_sources(
|
||||
"nb_123",
|
||||
"task_123",
|
||||
[{"url": "https://example.com", "title": "Valid"}],
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "src_valid"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_sources_src_id_none_skipped(
|
||||
self,
|
||||
auth_tokens,
|
||||
httpx_mock: HTTPXMock,
|
||||
build_rpc_response,
|
||||
):
|
||||
"""Line 270->265: src_data[0] is None (not a list) — src_id is None, entry skipped."""
|
||||
# src_data[0] is None — not a list, so src_id = None → skipped
|
||||
response = build_rpc_response(
|
||||
RPCMethod.IMPORT_RESEARCH,
|
||||
[
|
||||
[None, "Title with no ID"], # src_data[0] is None → skipped
|
||||
[["src_real"], "Real Title"], # valid
|
||||
],
|
||||
)
|
||||
httpx_mock.add_response(content=response.encode())
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.research.import_sources(
|
||||
"nb_123",
|
||||
"task_123",
|
||||
[{"url": "https://example.com", "title": "anything"}],
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "src_real"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -565,10 +565,10 @@ class TestChatAPI:
|
|||
@pytest.mark.vcr
|
||||
@pytest.mark.asyncio
|
||||
@notebooklm_vcr.use_cassette("chat_get_history.yaml")
|
||||
async def test_get_last_conversation_id(self):
|
||||
"""Get last conversation ID."""
|
||||
async def test_get_conversation_id(self):
|
||||
"""Get conversation ID."""
|
||||
async with vcr_client() as client:
|
||||
conv_id = await client.chat.get_last_conversation_id(MUTABLE_NOTEBOOK_ID)
|
||||
conv_id = await client.chat.get_conversation_id(MUTABLE_NOTEBOOK_ID)
|
||||
# May be None if no conversations, or a string UUID
|
||||
assert conv_id is None or isinstance(conv_id, str)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Tests for chat CLI commands (save-as-note, enhanced history)."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -27,11 +26,13 @@ def make_ask_result(answer="The answer is 42.") -> AskResult:
|
|||
)
|
||||
|
||||
|
||||
# get_history now returns list of (question, answer) tuples
|
||||
# get_history returns flat list of (question, answer) pairs
|
||||
MOCK_CONV_ID = "conv-abc123"
|
||||
MOCK_QA_PAIRS = [
|
||||
("What is ML?", "ML is a type of AI."),
|
||||
("Explain AI", "AI stands for Artificial Intelligence."),
|
||||
]
|
||||
MOCK_HISTORY = MOCK_QA_PAIRS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -57,7 +58,7 @@ class TestAskSaveAsNote:
|
|||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.ask = AsyncMock(return_value=make_ask_result())
|
||||
mock_client.chat.get_last_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.notes.create = AsyncMock(return_value=make_note())
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ class TestAskSaveAsNote:
|
|||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.ask = AsyncMock(return_value=make_ask_result())
|
||||
mock_client.chat.get_last_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.notes.create = AsyncMock(return_value=make_note(title="My Title"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
|
|
@ -105,7 +106,7 @@ class TestAskSaveAsNote:
|
|||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.ask = AsyncMock(return_value=make_ask_result())
|
||||
mock_client.chat.get_last_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.notes.create = AsyncMock(return_value=make_note())
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
|
|
@ -121,7 +122,8 @@ class TestHistoryCommand:
|
|||
def test_history_shows_qa_pairs(self, runner, mock_auth):
|
||||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_history = AsyncMock(return_value=MOCK_QA_PAIRS)
|
||||
mock_client.chat.get_history = AsyncMock(return_value=MOCK_HISTORY)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=MOCK_CONV_ID)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
|
|
@ -135,7 +137,8 @@ class TestHistoryCommand:
|
|||
def test_history_save_creates_note(self, runner, mock_auth):
|
||||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_history = AsyncMock(return_value=MOCK_QA_PAIRS)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=MOCK_CONV_ID)
|
||||
mock_client.chat.get_history = AsyncMock(return_value=MOCK_HISTORY)
|
||||
mock_client.notes.create = AsyncMock(return_value=make_note())
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
|
|
@ -149,6 +152,7 @@ class TestHistoryCommand:
|
|||
def test_history_empty_shows_message(self, runner, mock_auth):
|
||||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_history = AsyncMock(return_value=[])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
|
|
@ -162,7 +166,8 @@ class TestHistoryCommand:
|
|||
def test_history_json_outputs_valid_json(self, runner, mock_auth):
|
||||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_history = AsyncMock(return_value=MOCK_QA_PAIRS)
|
||||
mock_client.chat.get_history = AsyncMock(return_value=MOCK_HISTORY)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=MOCK_CONV_ID)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
|
|
@ -173,9 +178,9 @@ class TestHistoryCommand:
|
|||
import json
|
||||
|
||||
data = json.loads(result.output)
|
||||
assert data["count"] == 2
|
||||
assert data["notebook_id"] == "nb_123"
|
||||
assert len(data["qa_pairs"]) == 2
|
||||
assert data["conversation_id"] == MOCK_CONV_ID
|
||||
assert data["count"] == 2
|
||||
assert data["qa_pairs"][0]["turn"] == 1
|
||||
assert data["qa_pairs"][0]["question"] == "What is ML?"
|
||||
assert data["qa_pairs"][0]["answer"] == "ML is a type of AI."
|
||||
|
|
@ -185,6 +190,7 @@ class TestHistoryCommand:
|
|||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_history = AsyncMock(return_value=[])
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
|
|
@ -195,8 +201,8 @@ class TestHistoryCommand:
|
|||
import json
|
||||
|
||||
data = json.loads(result.output)
|
||||
assert data["count"] == 0
|
||||
assert data["qa_pairs"] == []
|
||||
assert data["count"] == 0
|
||||
|
||||
def test_history_show_all_outputs_full_text(self, runner, mock_auth):
|
||||
long_q = "Q" * 100
|
||||
|
|
@ -206,6 +212,7 @@ class TestHistoryCommand:
|
|||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_history = AsyncMock(return_value=pairs)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=MOCK_CONV_ID)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
|
|
@ -219,26 +226,28 @@ class TestHistoryCommand:
|
|||
assert long_a in flat
|
||||
|
||||
|
||||
class TestAskExchangeIdPersistence:
|
||||
def test_ask_cmd_saves_exchange_id_to_context(self, runner, mock_auth, tmp_path):
|
||||
"""ask command should persist exchange_id from result into context.json."""
|
||||
class TestAskServerResumed:
|
||||
def test_ask_shows_resumed_when_no_local_conv_but_server_has_one(
|
||||
self, runner, mock_auth, tmp_path
|
||||
):
|
||||
"""When context has no conv ID but server returns one, output should say 'Resumed'."""
|
||||
context_file = tmp_path / "context.json"
|
||||
context_file.write_text('{"notebook_id": "nb_123"}')
|
||||
|
||||
# is_follow_up=True because ask() was called with a conversation_id from server
|
||||
ask_result = AskResult(
|
||||
answer="The answer.",
|
||||
conversation_id="conv-uuid-123",
|
||||
conversation_id="conv-server-abc",
|
||||
turn_number=1,
|
||||
is_follow_up=False,
|
||||
is_follow_up=True,
|
||||
references=[],
|
||||
raw_response="",
|
||||
exchange_id="exch-uuid-456",
|
||||
)
|
||||
|
||||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.ask = AsyncMock(return_value=ask_result)
|
||||
mock_client.chat.get_last_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value="conv-server-abc")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with (
|
||||
|
|
@ -246,27 +255,24 @@ class TestAskExchangeIdPersistence:
|
|||
patch("notebooklm.cli.helpers.get_context_path", return_value=context_file),
|
||||
):
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["ask", "-n", "nb_123", "test question"])
|
||||
result = runner.invoke(cli, ["ask", "-n", "nb_123", "question"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
ctx = json.loads(context_file.read_text())
|
||||
assert ctx.get("exchange_id") == "exch-uuid-456"
|
||||
assert "Resumed conversation:" in result.output
|
||||
assert "(turn 1)" not in result.output
|
||||
|
||||
def test_ask_cmd_clears_exchange_id_on_new_conversation(self, runner, mock_auth, tmp_path):
|
||||
"""--new flag should clear exchange_id from context."""
|
||||
def test_ask_shows_turn_number_for_local_follow_up(self, runner, mock_auth, tmp_path):
|
||||
"""When context has a local conv ID, follow-up should show turn number."""
|
||||
context_file = tmp_path / "context.json"
|
||||
context_file.write_text(
|
||||
'{"notebook_id": "nb_123", "exchange_id": "old-exch-id", "conversation_id": "old-conv"}'
|
||||
)
|
||||
context_file.write_text('{"notebook_id": "nb_123", "conversation_id": "conv-local-abc"}')
|
||||
|
||||
ask_result = AskResult(
|
||||
answer="Fresh answer.",
|
||||
conversation_id="conv-new-123",
|
||||
turn_number=1,
|
||||
is_follow_up=False,
|
||||
answer="The answer.",
|
||||
conversation_id="conv-local-abc",
|
||||
turn_number=2,
|
||||
is_follow_up=True,
|
||||
references=[],
|
||||
raw_response="",
|
||||
exchange_id="new-exch-uuid",
|
||||
)
|
||||
|
||||
with patch_client_for_module("chat") as mock_client_cls:
|
||||
|
|
@ -279,9 +285,8 @@ class TestAskExchangeIdPersistence:
|
|||
patch("notebooklm.cli.helpers.get_context_path", return_value=context_file),
|
||||
):
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["ask", "-n", "nb_123", "--new", "fresh question"])
|
||||
result = runner.invoke(cli, ["ask", "-n", "nb_123", "follow-up question"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
ctx = json.loads(context_file.read_text())
|
||||
# After --new, exchange_id should be the NEW one from the response
|
||||
assert ctx.get("exchange_id") == "new-exch-uuid"
|
||||
assert "Conversation: conv-local-abc (turn 2)" in result.output
|
||||
assert "Resumed" not in result.output
|
||||
|
|
|
|||
|
|
@ -893,3 +893,691 @@ class TestRateLimitDetection:
|
|||
data = json.loads(result.output)
|
||||
assert data["error"] is True
|
||||
assert data["code"] == "RATE_LIMITED"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RESOLVE_LANGUAGE DIRECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestResolveLanguageDirect:
|
||||
"""Direct tests for resolve_language() covering uncovered branches."""
|
||||
|
||||
def test_invalid_language_raises_bad_parameter(self):
|
||||
"""Line 111: language not in SUPPORTED_LANGUAGES raises click.BadParameter."""
|
||||
import importlib
|
||||
|
||||
import click
|
||||
|
||||
generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
with pytest.raises(click.BadParameter) as exc_info:
|
||||
generate_module.resolve_language("xx_INVALID")
|
||||
assert "Unknown language code: xx_INVALID" in str(exc_info.value)
|
||||
assert "notebooklm language list" in str(exc_info.value)
|
||||
|
||||
def test_none_language_with_config_returns_config(self):
|
||||
"""Line 118: language is None, config_lang is not None → returns config_lang."""
|
||||
import importlib
|
||||
|
||||
generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
with patch.object(generate_module, "get_language", return_value="fr"):
|
||||
result = generate_module.resolve_language(None)
|
||||
assert result == "fr"
|
||||
|
||||
def test_none_language_no_config_returns_default(self):
|
||||
"""Line 139: language is None and config_lang is None → returns DEFAULT_LANGUAGE."""
|
||||
import importlib
|
||||
|
||||
generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
with patch.object(generate_module, "get_language", return_value=None):
|
||||
result = generate_module.resolve_language(None)
|
||||
assert result == "en"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _OUTPUT_GENERATION_STATUS DIRECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestOutputGenerationStatusDirect:
|
||||
"""Direct tests for _output_generation_status() covering uncovered branches."""
|
||||
|
||||
def setup_method(self):
|
||||
import importlib
|
||||
|
||||
self.generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
|
||||
def _make_status(
|
||||
self, *, is_complete=False, is_failed=False, task_id=None, url=None, error=None
|
||||
):
|
||||
status = MagicMock()
|
||||
status.is_complete = is_complete
|
||||
status.is_failed = is_failed
|
||||
status.task_id = task_id
|
||||
status.url = url
|
||||
status.error = error
|
||||
return status
|
||||
|
||||
def test_json_completed_with_url(self):
|
||||
"""Lines 200-201, 243: JSON output for completed status with URL."""
|
||||
status = self._make_status(
|
||||
is_complete=True, task_id="task_123", url="https://example.com/audio.mp3"
|
||||
)
|
||||
with patch.object(self.generate_module, "json_output_response") as mock_json:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=True)
|
||||
mock_json.assert_called_once_with(
|
||||
{"task_id": "task_123", "status": "completed", "url": "https://example.com/audio.mp3"}
|
||||
)
|
||||
|
||||
def test_json_failed(self):
|
||||
"""Line 251: JSON output for failed status."""
|
||||
status = self._make_status(is_failed=True, error="Something went wrong")
|
||||
with patch.object(self.generate_module, "json_error_response") as mock_err:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=True)
|
||||
mock_err.assert_called_once_with("GENERATION_FAILED", "Something went wrong")
|
||||
|
||||
def test_json_failed_no_error_message(self):
|
||||
"""Line 251: JSON failed output falls back to default message when error is None."""
|
||||
status = self._make_status(is_failed=True, error=None)
|
||||
with patch.object(self.generate_module, "json_error_response") as mock_err:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=True)
|
||||
mock_err.assert_called_once_with("GENERATION_FAILED", "Audio generation failed")
|
||||
|
||||
def test_json_pending_with_task_id(self):
|
||||
"""Lines 205-207, 257: JSON output for pending status extracts task_id from list."""
|
||||
# Use a list result (lines 205-207: list path in handle_generation_result)
|
||||
# and pending path in _output_generation_status (lines 255-257)
|
||||
status = MagicMock()
|
||||
status.is_complete = False
|
||||
status.is_failed = False
|
||||
status.task_id = "task_456"
|
||||
with patch.object(self.generate_module, "json_output_response") as mock_json:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=True)
|
||||
mock_json.assert_called_once_with({"task_id": "task_456", "status": "pending"})
|
||||
|
||||
def test_text_completed_with_url(self):
|
||||
"""Line 262: Text output for completed status with URL."""
|
||||
status = self._make_status(
|
||||
is_complete=True, task_id="task_123", url="https://example.com/audio.mp3"
|
||||
)
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=False)
|
||||
mock_console.print.assert_called_once_with(
|
||||
"[green]Audio ready:[/green] https://example.com/audio.mp3"
|
||||
)
|
||||
|
||||
def test_text_completed_without_url(self):
|
||||
"""Line 264: Text output for completed status without URL."""
|
||||
status = self._make_status(is_complete=True, task_id="task_123", url=None)
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=False)
|
||||
mock_console.print.assert_called_once_with("[green]Audio ready[/green]")
|
||||
|
||||
def test_text_failed(self):
|
||||
"""Line 266: Text output for failed status."""
|
||||
status = self._make_status(is_failed=True, error="Transcription error")
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=False)
|
||||
mock_console.print.assert_called_once_with("[red]Failed:[/red] Transcription error")
|
||||
|
||||
def test_text_pending_with_task_id(self):
|
||||
"""Line 268: Text output for pending status shows task_id."""
|
||||
status = self._make_status(task_id="task_789")
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=False)
|
||||
mock_console.print.assert_called_once_with("[yellow]Started:[/yellow] task_789")
|
||||
|
||||
def test_text_pending_without_task_id_shows_status(self):
|
||||
"""Line 268: Text output for pending status shows status object when no task_id."""
|
||||
status = MagicMock()
|
||||
status.is_complete = False
|
||||
status.is_failed = False
|
||||
# Make _extract_task_id return None by having no task_id attr and not a dict/list
|
||||
del status.task_id
|
||||
with (
|
||||
patch.object(self.generate_module, "_extract_task_id", return_value=None),
|
||||
patch.object(self.generate_module, "console") as mock_console,
|
||||
):
|
||||
self.generate_module._output_generation_status(status, "audio", json_output=False)
|
||||
mock_console.print.assert_called_once()
|
||||
call_args = mock_console.print.call_args[0][0]
|
||||
assert "[yellow]Started:[/yellow]" in call_args
|
||||
|
||||
|
||||
class TestExtractTaskIdDirect:
|
||||
"""Direct tests for _extract_task_id() covering list path."""
|
||||
|
||||
def setup_method(self):
|
||||
import importlib
|
||||
|
||||
self.generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
|
||||
def test_extract_from_list_first_string(self):
|
||||
"""Lines 231-232: list where first element is a string."""
|
||||
result = self.generate_module._extract_task_id(["task_abc", "other"])
|
||||
assert result == "task_abc"
|
||||
|
||||
def test_extract_from_list_first_not_string(self):
|
||||
"""Line 233: list where first element is not a string → returns None."""
|
||||
result = self.generate_module._extract_task_id([123, "other"])
|
||||
assert result is None
|
||||
|
||||
def test_extract_from_empty_list(self):
|
||||
"""Line 233: empty list → returns None."""
|
||||
result = self.generate_module._extract_task_id([])
|
||||
assert result is None
|
||||
|
||||
def test_extract_from_dict_task_id(self):
|
||||
"""Line 228: dict with task_id key."""
|
||||
result = self.generate_module._extract_task_id({"task_id": "t1", "status": "pending"})
|
||||
assert result == "t1"
|
||||
|
||||
def test_extract_from_dict_artifact_id(self):
|
||||
"""Line 228: dict with artifact_id key (no task_id)."""
|
||||
result = self.generate_module._extract_task_id({"artifact_id": "a1"})
|
||||
assert result == "a1"
|
||||
|
||||
def test_extract_from_object_with_task_id(self):
|
||||
"""Line 228: object with task_id attribute."""
|
||||
status = MagicMock()
|
||||
status.task_id = "task_obj"
|
||||
result = self.generate_module._extract_task_id(status)
|
||||
assert result == "task_obj"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _OUTPUT_MIND_MAP_RESULT DIRECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestOutputMindMapResultDirect:
|
||||
"""Direct tests for _output_mind_map_result() covering uncovered branches."""
|
||||
|
||||
def setup_method(self):
|
||||
import importlib
|
||||
|
||||
self.generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
|
||||
def test_falsy_result_json_calls_error(self):
|
||||
"""Lines 624-626: falsy result with json_output → json_error_response."""
|
||||
with patch.object(self.generate_module, "json_error_response") as mock_err:
|
||||
self.generate_module._output_mind_map_result(None, json_output=True)
|
||||
mock_err.assert_called_once_with("GENERATION_FAILED", "Mind map generation failed")
|
||||
|
||||
def test_falsy_result_no_json_prints_message(self):
|
||||
"""Lines 627-628: falsy result without json_output → console.print yellow."""
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_mind_map_result(None, json_output=False)
|
||||
mock_console.print.assert_called_with("[yellow]No result[/yellow]")
|
||||
|
||||
def test_truthy_result_json_calls_output(self):
|
||||
"""Line 631: truthy result with json_output → json_output_response."""
|
||||
result_data = {"note_id": "n1", "mind_map": {"name": "Root", "children": []}}
|
||||
with patch.object(self.generate_module, "json_output_response") as mock_json:
|
||||
self.generate_module._output_mind_map_result(result_data, json_output=True)
|
||||
mock_json.assert_called_once_with(result_data)
|
||||
|
||||
def test_truthy_result_dict_text_output(self):
|
||||
"""Lines 633-635: truthy result dict with text output prints note_id and children count."""
|
||||
result_data = {
|
||||
"note_id": "n1",
|
||||
"mind_map": {"name": "Root", "children": [{"label": "Child1"}, {"label": "Child2"}]},
|
||||
}
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_mind_map_result(result_data, json_output=False)
|
||||
printed_args = [call[0][0] for call in mock_console.print.call_args_list]
|
||||
assert any("n1" in arg for arg in printed_args)
|
||||
assert any("Root" in arg for arg in printed_args)
|
||||
assert any("2" in arg for arg in printed_args)
|
||||
|
||||
def test_truthy_result_non_dict_text_output(self):
|
||||
"""Non-dict truthy result with text output → console.print(result)."""
|
||||
result_data = "some-string-result"
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_mind_map_result(result_data, json_output=False)
|
||||
# Should print the result directly
|
||||
printed_args = [call[0][0] for call in mock_console.print.call_args_list]
|
||||
assert any("some-string-result" in str(arg) for arg in printed_args)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GENERATE REVISE-SLIDE CLI TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGenerateReviseSlide:
|
||||
"""Tests for the 'generate revise-slide' CLI command (lines 971-989)."""
|
||||
|
||||
def test_revise_slide_basic(self, runner, mock_auth):
|
||||
"""Lines 971-975: revise-slide command invokes client.artifacts.revise_slide."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.revise_slide = AsyncMock(
|
||||
return_value={"artifact_id": "art_rev_1", "status": "processing"}
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"revise-slide",
|
||||
"Make the title bigger",
|
||||
"--artifact",
|
||||
"art_1",
|
||||
"--slide",
|
||||
"0",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.artifacts.revise_slide.assert_called_once()
|
||||
|
||||
def test_revise_slide_passes_correct_args(self, runner, mock_auth):
|
||||
"""Lines 985-989: verify artifact_id, slide_index, and prompt are forwarded."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.revise_slide = AsyncMock(
|
||||
return_value={"artifact_id": "art_rev_2", "status": "processing"}
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"revise-slide",
|
||||
"Remove taxonomy",
|
||||
"--artifact",
|
||||
"art_1",
|
||||
"--slide",
|
||||
"3",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
call_kwargs = mock_client.artifacts.revise_slide.call_args
|
||||
assert call_kwargs is not None, "revise_slide was not called"
|
||||
assert call_kwargs.kwargs.get("artifact_id") == "art_1"
|
||||
assert call_kwargs.kwargs.get("slide_index") == 3
|
||||
assert call_kwargs.kwargs.get("prompt") == "Remove taxonomy"
|
||||
|
||||
def test_revise_slide_missing_artifact_fails(self, runner, mock_auth):
|
||||
"""revise-slide requires --artifact option."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"revise-slide",
|
||||
"Make bigger",
|
||||
"--slide",
|
||||
"0",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_revise_slide_missing_slide_fails(self, runner, mock_auth):
|
||||
"""revise-slide requires --slide option."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"revise-slide",
|
||||
"Make bigger",
|
||||
"--artifact",
|
||||
"art_1",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_revise_slide_json_output(self, runner, mock_auth):
|
||||
"""revise-slide with --json flag produces JSON output."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.revise_slide = AsyncMock(
|
||||
return_value={"artifact_id": "art_rev_3", "status": "processing"}
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"revise-slide",
|
||||
"Bold the title",
|
||||
"--artifact",
|
||||
"art_1",
|
||||
"--slide",
|
||||
"1",
|
||||
"-n",
|
||||
"nb_123",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert "task_id" in data or "artifact_id" in data or "status" in data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GENERATE REPORT WITH DESCRIPTION (LINE 1057)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGenerateReportWithNonBriefingFormat:
|
||||
"""Test generate report when description is provided with non-briefing-doc format.
|
||||
|
||||
Line 1057: the else-branch that sets custom_prompt = description when
|
||||
report_format != 'briefing-doc' and description is provided.
|
||||
"""
|
||||
|
||||
def test_report_description_with_study_guide_format(self, runner, mock_auth):
|
||||
"""Line 1057: description + non-default format → custom_prompt = description."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_report = AsyncMock(
|
||||
return_value={"artifact_id": "report_xyz", "status": "processing"}
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"report",
|
||||
"Focus on beginners",
|
||||
"--format",
|
||||
"study-guide",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.artifacts.generate_report.assert_called_once()
|
||||
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
|
||||
# custom_prompt should be the description argument
|
||||
assert call_kwargs.get("custom_prompt") == "Focus on beginners"
|
||||
|
||||
def test_report_description_with_blog_post_format(self, runner, mock_auth):
|
||||
"""Line 1057: description + blog-post format → custom_prompt set."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_report = AsyncMock(
|
||||
return_value={"artifact_id": "report_abc", "status": "processing"}
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"generate",
|
||||
"report",
|
||||
"Write in casual tone",
|
||||
"--format",
|
||||
"blog-post",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.artifacts.generate_report.assert_called_once()
|
||||
call_kwargs = mock_client.artifacts.generate_report.call_args.kwargs
|
||||
assert call_kwargs.get("custom_prompt") == "Write in casual tone"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HANDLE_GENERATION_RESULT PATHS (GenerationStatus and list result formats)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestHandleGenerationResultPaths:
|
||||
"""Test handle_generation_result branches: GenerationStatus input and list input."""
|
||||
|
||||
def test_generation_result_with_generation_status_object(self, runner, mock_auth):
|
||||
"""Lines 200-201: result is a GenerationStatus → task_id = result.task_id."""
|
||||
from notebooklm.types import GenerationStatus
|
||||
|
||||
status = GenerationStatus(
|
||||
task_id="task_gen_1", status="pending", error=None, error_code=None
|
||||
)
|
||||
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(return_value=status)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "task_gen_1" in result.output or "Started" in result.output
|
||||
|
||||
def test_generation_result_with_list_input(self, runner, mock_auth):
|
||||
"""Lines 205-207: result is a list → task_id from first element."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(return_value=["task_list_1", "extra"])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "task_list_1" in result.output or "Started" in result.output
|
||||
|
||||
def test_generation_result_falsy_shows_failed_message(self, runner, mock_auth):
|
||||
"""Line 173: falsy result → text error message."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "generation failed" in result.output.lower()
|
||||
|
||||
def test_generation_result_falsy_json_shows_error(self, runner, mock_auth):
|
||||
"""Line 173: falsy result with --json → json_error_response (exits with code 1)."""
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123", "--json"])
|
||||
|
||||
# json_error_response calls sys.exit(1), so exit_code is 1
|
||||
data = json.loads(result.output)
|
||||
assert data["error"] is True
|
||||
assert data["code"] == "GENERATION_FAILED"
|
||||
|
||||
def test_generation_with_wait_and_generation_status(self, runner, mock_auth):
|
||||
"""Line 213: wait=True with GenerationStatus triggers wait_for_completion."""
|
||||
from notebooklm.types import GenerationStatus
|
||||
|
||||
initial_status = GenerationStatus(
|
||||
task_id="task_wait_1", status="pending", error=None, error_code=None
|
||||
)
|
||||
completed_status = GenerationStatus(
|
||||
task_id="task_wait_1",
|
||||
status="completed",
|
||||
error=None,
|
||||
error_code=None,
|
||||
url="https://example.com/result.mp3",
|
||||
)
|
||||
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial_status)
|
||||
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123", "--wait"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.artifacts.wait_for_completion.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ADDITIONAL TARGETED COVERAGE TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGenerateWithRetryConsoleOutput:
|
||||
"""Test generate_with_retry console output branch (line 111)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_shows_console_message_when_not_json(self):
|
||||
"""Line 111: console.print shown during retry when json_output=False."""
|
||||
import importlib
|
||||
|
||||
from notebooklm.types import GenerationStatus
|
||||
|
||||
generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
|
||||
rate_limited = GenerationStatus(
|
||||
task_id="", status="failed", error="Rate limited", error_code="USER_DISPLAYABLE_ERROR"
|
||||
)
|
||||
success_result = GenerationStatus(
|
||||
task_id="task_123", status="pending", error=None, error_code=None
|
||||
)
|
||||
generate_fn = AsyncMock(side_effect=[rate_limited, success_result])
|
||||
|
||||
with (
|
||||
patch.object(generate_module, "console") as mock_console,
|
||||
patch("asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
result = await generate_module.generate_with_retry(
|
||||
generate_fn, max_retries=1, artifact_type="audio", json_output=False
|
||||
)
|
||||
|
||||
assert result == success_result
|
||||
# Console should have been called with the retry message
|
||||
mock_console.print.assert_called_once()
|
||||
call_text = mock_console.print.call_args[0][0]
|
||||
assert "rate limited" in call_text.lower() or "Retrying" in call_text
|
||||
|
||||
|
||||
class TestHandleGenerationResultListPathAndWait:
|
||||
"""Test handle_generation_result: list path and wait with console message."""
|
||||
|
||||
def test_wait_with_task_id_shows_generating_message(self, runner, mock_auth):
|
||||
"""Line 211->213: wait=True, task_id present, not json → console.print generating."""
|
||||
from notebooklm.types import GenerationStatus
|
||||
|
||||
initial_status = GenerationStatus(
|
||||
task_id="task_console_1", status="pending", error=None, error_code=None
|
||||
)
|
||||
completed_status = GenerationStatus(
|
||||
task_id="task_console_1",
|
||||
status="completed",
|
||||
error=None,
|
||||
error_code=None,
|
||||
url="https://example.com/audio.mp3",
|
||||
)
|
||||
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(return_value=initial_status)
|
||||
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123", "--wait"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# The console message "Generating audio... Task: task_console_1" should appear
|
||||
assert "task_console_1" in result.output or "Generating" in result.output
|
||||
mock_client.artifacts.wait_for_completion.assert_called_once()
|
||||
|
||||
def test_list_result_extracts_task_id_for_wait(self, runner, mock_auth):
|
||||
"""Lines 205->210, 213: list result + wait=True → task_id from list[0]."""
|
||||
from notebooklm.types import GenerationStatus
|
||||
|
||||
completed_status = GenerationStatus(
|
||||
task_id="task_list_wait",
|
||||
status="completed",
|
||||
error=None,
|
||||
error_code=None,
|
||||
url="https://example.com/audio.mp3",
|
||||
)
|
||||
|
||||
with patch_client_for_module("generate") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.artifacts.generate_audio = AsyncMock(
|
||||
return_value=["task_list_wait", "extra"]
|
||||
)
|
||||
mock_client.artifacts.wait_for_completion = AsyncMock(return_value=completed_status)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["generate", "audio", "-n", "nb_123", "--wait"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.artifacts.wait_for_completion.assert_called_once()
|
||||
|
||||
|
||||
class TestOutputMindMapNonDictMindMap:
|
||||
"""Test _output_mind_map_result when mind_map value is not a dict (line 985->else)."""
|
||||
|
||||
def setup_method(self):
|
||||
import importlib
|
||||
|
||||
self.generate_module = importlib.import_module("notebooklm.cli.generate")
|
||||
|
||||
def test_mind_map_non_dict_value_prints_directly(self):
|
||||
"""Line 985->else (988-989): mind_map is not a dict → console.print(result)."""
|
||||
result_data = {
|
||||
"note_id": "n1",
|
||||
"mind_map": ["node1", "node2"], # list, not dict → else branch
|
||||
}
|
||||
with patch.object(self.generate_module, "console") as mock_console:
|
||||
self.generate_module._output_mind_map_result(result_data, json_output=False)
|
||||
printed_calls = [call[0][0] for call in mock_console.print.call_args_list]
|
||||
# Should print the header and Note ID, then the raw result
|
||||
assert any("n1" in str(arg) for arg in printed_calls)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from notebooklm.cli.helpers import (
|
|||
# Auth helpers
|
||||
get_client,
|
||||
get_current_conversation,
|
||||
get_current_exchange_id,
|
||||
# Context helpers
|
||||
get_current_notebook,
|
||||
get_source_type_display,
|
||||
|
|
@ -28,7 +27,6 @@ from notebooklm.cli.helpers import (
|
|||
require_notebook,
|
||||
run_async,
|
||||
set_current_conversation,
|
||||
set_current_exchange_id,
|
||||
set_current_notebook,
|
||||
# Decorator
|
||||
with_client,
|
||||
|
|
@ -321,37 +319,14 @@ class TestContextManagement:
|
|||
result = get_current_notebook()
|
||||
assert result is None
|
||||
|
||||
def test_get_current_exchange_id_returns_none_when_missing(self, tmp_path):
|
||||
def test_set_current_notebook_clears_conversation_on_switch(self, tmp_path):
|
||||
context_file = tmp_path / "context.json"
|
||||
context_file.write_text('{"notebook_id": "nb_123"}')
|
||||
with patch("notebooklm.cli.helpers.get_context_path", return_value=context_file):
|
||||
assert get_current_exchange_id() is None
|
||||
|
||||
def test_set_and_get_current_exchange_id(self, tmp_path):
|
||||
context_file = tmp_path / "context.json"
|
||||
context_file.write_text('{"notebook_id": "nb_123"}')
|
||||
with patch("notebooklm.cli.helpers.get_context_path", return_value=context_file):
|
||||
set_current_exchange_id("exch-uuid-789")
|
||||
assert get_current_exchange_id() == "exch-uuid-789"
|
||||
|
||||
def test_set_current_exchange_id_none_clears_it(self, tmp_path):
|
||||
context_file = tmp_path / "context.json"
|
||||
context_file.write_text('{"notebook_id": "nb_123", "exchange_id": "exch-uuid-789"}')
|
||||
with patch("notebooklm.cli.helpers.get_context_path", return_value=context_file):
|
||||
set_current_exchange_id(None)
|
||||
assert get_current_exchange_id() is None
|
||||
|
||||
def test_set_current_notebook_clears_exchange_id_on_switch(self, tmp_path):
|
||||
context_file = tmp_path / "context.json"
|
||||
context_file.write_text(
|
||||
'{"notebook_id": "nb_old", "conversation_id": "conv_1", "exchange_id": "exch_1"}'
|
||||
)
|
||||
context_file.write_text('{"notebook_id": "nb_old", "conversation_id": "conv_1"}')
|
||||
with patch("notebooklm.cli.helpers.get_context_path", return_value=context_file):
|
||||
set_current_notebook("nb_new", title="New Notebook")
|
||||
data = json.loads(context_file.read_text())
|
||||
assert data["notebook_id"] == "nb_new"
|
||||
assert "conversation_id" not in data
|
||||
assert "exchange_id" not in data
|
||||
|
||||
|
||||
class TestRequireNotebook:
|
||||
|
|
@ -472,6 +447,36 @@ class TestWithClientDecorator:
|
|||
assert result.exit_code == 1
|
||||
assert "login" in result.output.lower()
|
||||
|
||||
def test_decorator_file_not_found_in_command_not_treated_as_auth_error(self):
|
||||
"""Test that FileNotFoundError from command logic is NOT treated as auth error.
|
||||
|
||||
Regression test for GitHub issue #153: `source add --type file` with a
|
||||
missing file was incorrectly showing 'Not logged in' because the
|
||||
with_client decorator caught all FileNotFoundError as auth errors.
|
||||
"""
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
|
||||
@click.command()
|
||||
@with_client
|
||||
def test_cmd(ctx, client_auth):
|
||||
async def _run():
|
||||
raise FileNotFoundError("File not found: /tmp/nonexistent.pdf")
|
||||
|
||||
return _run()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("notebooklm.cli.helpers.load_auth_from_storage") as mock_load:
|
||||
mock_load.return_value = {"SID": "test"}
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(test_cmd)
|
||||
|
||||
assert result.exit_code == 1
|
||||
# Should show the actual file error, NOT an auth error
|
||||
assert "File not found" in result.output
|
||||
assert "login" not in result.output.lower()
|
||||
|
||||
def test_decorator_handles_exception_non_json(self):
|
||||
"""Test error handling in non-JSON mode"""
|
||||
import click
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import importlib
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
|
@ -180,3 +180,233 @@ class TestGenerateUsesConfigLanguage:
|
|||
assert result.exit_code == 0
|
||||
assert "--language" in result.output
|
||||
assert "from config" in result.output.lower() or "default" in result.output.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET_CONFIG ERROR PATHS (lines 116-121)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetConfigErrorPaths:
|
||||
def test_get_config_json_decode_error(self, tmp_path):
|
||||
"""Test get_config() returns {} when config file has invalid JSON."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("this is not valid json{{{")
|
||||
|
||||
with patch.object(language_module, "get_config_path", return_value=config_file):
|
||||
result = language_module.get_config()
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_get_config_oserror(self, tmp_path):
|
||||
"""Test get_config() returns {} when config file can't be read (OSError)."""
|
||||
config_file = tmp_path / "config.json"
|
||||
# Create the file so exists() returns True, then mock read_text to raise OSError
|
||||
config_file.write_text('{"language": "en"}')
|
||||
|
||||
with (
|
||||
patch.object(language_module, "get_config_path", return_value=config_file),
|
||||
patch.object(
|
||||
config_file.__class__, "read_text", side_effect=OSError("permission denied")
|
||||
),
|
||||
):
|
||||
result = language_module.get_config()
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _SYNC_LANGUAGE_TO_SERVER AND _GET_LANGUAGE_FROM_SERVER (lines 162-164, 176-186)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSyncLanguageToServer:
|
||||
def test_sync_language_to_server_success(self):
|
||||
"""Test _sync_language_to_server returns run_async result on success."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.obj = {"auth": {"SID": "test", "HSID": "test", "SSID": "test"}}
|
||||
|
||||
with (
|
||||
patch.object(language_module, "get_auth_tokens", return_value={"SID": "test"}),
|
||||
patch.object(language_module, "run_async", return_value="en") as mock_run,
|
||||
):
|
||||
result = language_module._sync_language_to_server("en", mock_ctx)
|
||||
|
||||
assert result == "en"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_sync_language_to_server_exception_returns_none(self):
|
||||
"""Test _sync_language_to_server returns None when exception occurs."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.obj = {}
|
||||
|
||||
with patch.object(language_module, "get_auth_tokens", side_effect=Exception("no auth")):
|
||||
result = language_module._sync_language_to_server("en", mock_ctx)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_sync_language_to_server_run_async_exception(self):
|
||||
"""Test _sync_language_to_server returns None when run_async raises."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.obj = {}
|
||||
|
||||
with (
|
||||
patch.object(language_module, "get_auth_tokens", return_value={"SID": "test"}),
|
||||
patch.object(language_module, "run_async", side_effect=Exception("connection error")),
|
||||
):
|
||||
result = language_module._sync_language_to_server("en", mock_ctx)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetLanguageFromServer:
|
||||
def test_get_language_from_server_success(self):
|
||||
"""Test _get_language_from_server returns the server language on success."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.obj = {"auth": {"SID": "test"}}
|
||||
|
||||
with (
|
||||
patch.object(language_module, "get_auth_tokens", return_value={"SID": "test"}),
|
||||
patch.object(language_module, "run_async", return_value="fr") as mock_run,
|
||||
):
|
||||
result = language_module._get_language_from_server(mock_ctx)
|
||||
|
||||
assert result == "fr"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_get_language_from_server_exception_returns_none(self):
|
||||
"""Test _get_language_from_server returns None when exception occurs."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.obj = {}
|
||||
|
||||
with patch.object(language_module, "get_auth_tokens", side_effect=Exception("no auth")):
|
||||
result = language_module._get_language_from_server(mock_ctx)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_language_from_server_run_async_exception(self):
|
||||
"""Test _get_language_from_server returns None when run_async raises."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.obj = {}
|
||||
|
||||
with (
|
||||
patch.object(language_module, "get_auth_tokens", return_value={"SID": "test"}),
|
||||
patch.object(language_module, "run_async", side_effect=Exception("rpc error")),
|
||||
):
|
||||
result = language_module._get_language_from_server(mock_ctx)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LANGUAGE GET SERVER SYNC PATHS (lines 244-250, 270)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLanguageGetServerSyncPaths:
|
||||
def test_language_get_server_has_different_value_updates_local(self, runner, mock_config_file):
|
||||
"""Test 'language get' updates local config when server has a different value."""
|
||||
# Local is "en", server returns "fr" → local should be updated to "fr"
|
||||
mock_config_file.write_text(json.dumps({"language": "en"}))
|
||||
|
||||
with patch.object(language_module, "_get_language_from_server", return_value="fr"):
|
||||
result = runner.invoke(cli, ["language", "get"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Local config should be updated to "fr"
|
||||
config = json.loads(mock_config_file.read_text())
|
||||
assert config["language"] == "fr"
|
||||
# Output should show "fr" (the server value)
|
||||
assert "fr" in result.output
|
||||
|
||||
def test_language_get_server_different_shows_synced(self, runner, mock_config_file):
|
||||
"""Test 'language get' shows synced message when server differs from local."""
|
||||
mock_config_file.write_text(json.dumps({"language": "en"}))
|
||||
|
||||
with patch.object(language_module, "_get_language_from_server", return_value="ja"):
|
||||
result = runner.invoke(cli, ["language", "get"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "synced" in result.output.lower()
|
||||
|
||||
def test_language_get_server_same_value_no_update(self, runner, mock_config_file):
|
||||
"""Test 'language get' does not update local when server value matches."""
|
||||
mock_config_file.write_text(json.dumps({"language": "en"}))
|
||||
|
||||
with (
|
||||
patch.object(language_module, "_get_language_from_server", return_value="en"),
|
||||
patch.object(language_module, "set_language") as mock_set,
|
||||
):
|
||||
result = runner.invoke(cli, ["language", "get"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_set.assert_not_called()
|
||||
|
||||
def test_language_get_no_language_shows_not_set(self, runner, mock_config_file):
|
||||
"""Test 'language get' shows 'not set' when no language is configured and server returns None."""
|
||||
# No language configured locally
|
||||
with patch.object(language_module, "_get_language_from_server", return_value=None):
|
||||
result = runner.invoke(cli, ["language", "get"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "not set" in result.output
|
||||
|
||||
def test_language_get_server_sync_json_output(self, runner, mock_config_file):
|
||||
"""Test 'language get --json' reflects synced_from_server when values differ."""
|
||||
mock_config_file.write_text(json.dumps({"language": "en"}))
|
||||
|
||||
with patch.object(language_module, "_get_language_from_server", return_value="de"):
|
||||
result = runner.invoke(cli, ["language", "get", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["language"] == "de"
|
||||
assert data["synced_from_server"] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LANGUAGE SET SYNC FAILED AND JSON PATHS (lines 316-320, 335-336)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLanguageSetSyncFailedAndJsonPaths:
|
||||
def test_language_set_sync_failed_shows_local_only_message(self, runner, mock_config_file):
|
||||
"""Test 'language set' shows local-only message when server sync fails."""
|
||||
with patch.object(language_module, "_sync_language_to_server", return_value=None):
|
||||
result = runner.invoke(cli, ["language", "set", "en"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "saved locally" in result.output or "server sync failed" in result.output
|
||||
|
||||
def test_language_set_sync_success_shows_synced_message(self, runner, mock_config_file):
|
||||
"""Test 'language set' shows synced message when server sync succeeds."""
|
||||
with patch.object(language_module, "_sync_language_to_server", return_value="en"):
|
||||
result = runner.invoke(cli, ["language", "set", "en"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "synced" in result.output.lower()
|
||||
# Should NOT show "server sync failed"
|
||||
assert "server sync failed" not in result.output
|
||||
|
||||
def test_language_set_json_output_with_server_sync(self, runner, mock_config_file):
|
||||
"""Test 'language set --json' includes synced_to_server field."""
|
||||
with patch.object(language_module, "_sync_language_to_server", return_value="fr"):
|
||||
result = runner.invoke(cli, ["language", "set", "fr", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["language"] == "fr"
|
||||
assert data["name"] == "Français"
|
||||
assert "synced_to_server" in data
|
||||
assert data["synced_to_server"] is True
|
||||
|
||||
def test_language_set_json_output_sync_failed(self, runner, mock_config_file):
|
||||
"""Test 'language set --json' shows synced_to_server=False when sync fails."""
|
||||
with patch.object(language_module, "_sync_language_to_server", return_value=None):
|
||||
result = runner.invoke(cli, ["language", "set", "ko", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["language"] == "ko"
|
||||
assert "synced_to_server" in data
|
||||
assert data["synced_to_server"] is False
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ class TestNotebookHistory:
|
|||
with patch_main_cli_client() as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_history = AsyncMock(return_value=[("Q1?", "A1"), ("Q2?", "A2")])
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value="conv_001")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
|
|
@ -387,6 +388,7 @@ class TestNotebookHistory:
|
|||
def test_notebook_history_empty(self, runner, mock_auth):
|
||||
with patch_main_cli_client() as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_history = AsyncMock(return_value=[])
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
|
|
@ -428,7 +430,7 @@ class TestNotebookAsk:
|
|||
turn_number=1,
|
||||
)
|
||||
)
|
||||
mock_client.chat.get_last_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with (
|
||||
|
|
@ -444,28 +446,6 @@ class TestNotebookAsk:
|
|||
assert result.exit_code == 0
|
||||
assert "This is the answer" in result.output
|
||||
|
||||
def test_notebook_ask_new_conversation(self, runner, mock_auth):
|
||||
with patch_main_cli_client() as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.chat.ask = AsyncMock(
|
||||
return_value=AskResult(
|
||||
answer="Fresh answer",
|
||||
conversation_id="new_conv",
|
||||
is_follow_up=False,
|
||||
turn_number=1,
|
||||
)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["ask", "-n", "nb_123", "--new", "Fresh question"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Starting new conversation" in result.output or "New conversation" in result.output
|
||||
)
|
||||
|
||||
def test_notebook_ask_continue_conversation(self, runner, mock_auth):
|
||||
with patch_main_cli_client() as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import pytest
|
|||
from click.testing import CliRunner
|
||||
|
||||
from notebooklm.notebooklm_cli import cli
|
||||
from notebooklm.types import Source
|
||||
from notebooklm.types import (
|
||||
Source,
|
||||
SourceFulltext,
|
||||
SourceNotFoundError,
|
||||
SourceProcessingError,
|
||||
SourceTimeoutError,
|
||||
)
|
||||
|
||||
from .conftest import create_mock_client, patch_client_for_module
|
||||
|
||||
|
|
@ -630,3 +636,428 @@ class TestSourceCommandsExist:
|
|||
assert result.exit_code == 0
|
||||
assert "SOURCE_ID" in result.output
|
||||
assert "exit code" in result.output.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SOURCE ADD AUTO-DETECT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSourceAddAutoDetect:
|
||||
def test_source_add_autodetect_file(self, runner, mock_auth, tmp_path):
|
||||
"""Pass a real file path without --type; should auto-detect as 'file'."""
|
||||
test_file = tmp_path / "notes.txt"
|
||||
test_file.write_text("Some file content")
|
||||
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.add_file = AsyncMock(
|
||||
return_value=Source(id="src_file", title="notes.txt")
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["source", "add", str(test_file), "-n", "nb_123"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.sources.add_file.assert_called_once()
|
||||
|
||||
def test_source_add_autodetect_plain_text(self, runner, mock_auth):
|
||||
"""Pass plain text (not URL, not existing path) without --type.
|
||||
|
||||
Should auto-detect as 'text' with default title 'Pasted Text'.
|
||||
"""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.add_text = AsyncMock(
|
||||
return_value=Source(id="src_text", title="Pasted Text")
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["source", "add", "This is just some plain text content", "-n", "nb_123"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Verify add_text was called with the default "Pasted Text" title
|
||||
mock_client.sources.add_text.assert_called_once()
|
||||
call_args = mock_client.sources.add_text.call_args
|
||||
assert call_args[0][1] == "Pasted Text" # title arg
|
||||
|
||||
def test_source_add_autodetect_text_with_custom_title(self, runner, mock_auth):
|
||||
"""Pass plain text without --type but with --title.
|
||||
|
||||
Title should be the custom title, not 'Pasted Text'.
|
||||
"""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.add_text = AsyncMock(
|
||||
return_value=Source(id="src_text", title="Custom Title")
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"source",
|
||||
"add",
|
||||
"This is just some plain text content",
|
||||
"--title",
|
||||
"Custom Title",
|
||||
"-n",
|
||||
"nb_123",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
mock_client.sources.add_text.assert_called_once()
|
||||
call_args = mock_client.sources.add_text.call_args
|
||||
assert call_args[0][1] == "Custom Title" # title arg
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SOURCE FULLTEXT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSourceFulltext:
|
||||
def test_source_fulltext_console_output(self, runner, mock_auth):
|
||||
"""Short content (<= 2000 chars) is displayed in full."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.get_fulltext = AsyncMock(
|
||||
return_value=SourceFulltext(
|
||||
source_id="src_123",
|
||||
title="Test Source",
|
||||
content="This is the full text content.",
|
||||
char_count=30,
|
||||
url=None,
|
||||
)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "fulltext", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "src_123" in result.output
|
||||
assert "Test Source" in result.output
|
||||
assert "This is the full text content." in result.output
|
||||
# Should NOT show truncation message for short content
|
||||
assert "more chars" not in result.output
|
||||
|
||||
def test_source_fulltext_truncated_output(self, runner, mock_auth):
|
||||
"""Long content (> 2000 chars) is truncated with a 'more chars' message."""
|
||||
long_content = "A" * 3000
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.get_fulltext = AsyncMock(
|
||||
return_value=SourceFulltext(
|
||||
source_id="src_123",
|
||||
title="Long Source",
|
||||
content=long_content,
|
||||
char_count=3000,
|
||||
url=None,
|
||||
)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "fulltext", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "more chars" in result.output
|
||||
|
||||
def test_source_fulltext_save_to_file(self, runner, mock_auth, tmp_path):
|
||||
"""-o flag saves content to file."""
|
||||
output_file = tmp_path / "output.txt"
|
||||
content = "Full text content to save."
|
||||
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.get_fulltext = AsyncMock(
|
||||
return_value=SourceFulltext(
|
||||
source_id="src_123",
|
||||
title="Test Source",
|
||||
content=content,
|
||||
char_count=len(content),
|
||||
url=None,
|
||||
)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["source", "fulltext", "src_123", "-n", "nb_123", "-o", str(output_file)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Saved" in result.output
|
||||
assert output_file.read_text(encoding="utf-8") == content
|
||||
|
||||
def test_source_fulltext_json_output(self, runner, mock_auth):
|
||||
"""--json outputs JSON with fulltext fields."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.get_fulltext = AsyncMock(
|
||||
return_value=SourceFulltext(
|
||||
source_id="src_123",
|
||||
title="Test Source",
|
||||
content="Some content",
|
||||
char_count=12,
|
||||
url=None,
|
||||
)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(
|
||||
cli, ["source", "fulltext", "src_123", "-n", "nb_123", "--json"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["source_id"] == "src_123"
|
||||
assert data["title"] == "Test Source"
|
||||
assert data["content"] == "Some content"
|
||||
assert data["char_count"] == 12
|
||||
|
||||
def test_source_fulltext_with_url(self, runner, mock_auth):
|
||||
"""Shows URL field when present in fulltext."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Web Source")]
|
||||
)
|
||||
mock_client.sources.get_fulltext = AsyncMock(
|
||||
return_value=SourceFulltext(
|
||||
source_id="src_123",
|
||||
title="Web Source",
|
||||
content="Web page content.",
|
||||
char_count=17,
|
||||
url="https://example.com/page",
|
||||
)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "fulltext", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "https://example.com/page" in result.output
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SOURCE WAIT TESTS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSourceWait:
|
||||
def test_source_wait_success(self, runner, mock_auth):
|
||||
"""wait_until_ready returns a Source → prints 'ready'."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
return_value=Source(id="src_123", title="Test Source", status=2)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "ready" in result.output.lower()
|
||||
|
||||
def test_source_wait_success_with_title(self, runner, mock_auth):
|
||||
"""Source has a title → prints the title after 'ready' message."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="My Source Title")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
return_value=Source(id="src_123", title="My Source Title", status=2)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "My Source Title" in result.output
|
||||
|
||||
def test_source_wait_success_json(self, runner, mock_auth):
|
||||
"""--json output on successful wait."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
return_value=Source(id="src_123", title="Test Source", status=2)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["source_id"] == "src_123"
|
||||
assert data["status"] == "ready"
|
||||
|
||||
def test_source_wait_not_found(self, runner, mock_auth):
|
||||
"""Raises SourceNotFoundError → exit code 1."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
side_effect=SourceNotFoundError("src_123")
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.output.lower()
|
||||
|
||||
def test_source_wait_not_found_json(self, runner, mock_auth):
|
||||
"""--json on SourceNotFoundError → JSON with status 'not_found', exit 1."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
side_effect=SourceNotFoundError("src_123")
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123", "--json"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert data["status"] == "not_found"
|
||||
assert data["source_id"] == "src_123"
|
||||
|
||||
def test_source_wait_processing_error(self, runner, mock_auth):
|
||||
"""Raises SourceProcessingError → exit code 1."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
side_effect=SourceProcessingError("src_123", status=3)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "processing failed" in result.output.lower()
|
||||
|
||||
def test_source_wait_processing_error_json(self, runner, mock_auth):
|
||||
"""--json on SourceProcessingError → JSON with status 'error', exit 1."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
side_effect=SourceProcessingError("src_123", status=3)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123", "--json"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
data = json.loads(result.output)
|
||||
assert data["status"] == "error"
|
||||
assert data["source_id"] == "src_123"
|
||||
assert data["status_code"] == 3
|
||||
|
||||
def test_source_wait_timeout(self, runner, mock_auth):
|
||||
"""Raises SourceTimeoutError → exit code 2."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
side_effect=SourceTimeoutError("src_123", timeout=30.0, last_status=1)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "timeout" in result.output.lower()
|
||||
|
||||
def test_source_wait_timeout_json(self, runner, mock_auth):
|
||||
"""--json on SourceTimeoutError → JSON with status 'timeout', exit 2."""
|
||||
with patch_client_for_module("source") as mock_client_cls:
|
||||
mock_client = create_mock_client()
|
||||
mock_client.sources.list = AsyncMock(
|
||||
return_value=[Source(id="src_123", title="Test Source")]
|
||||
)
|
||||
mock_client.sources.wait_until_ready = AsyncMock(
|
||||
side_effect=SourceTimeoutError("src_123", timeout=30.0, last_status=1)
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("notebooklm.cli.helpers.fetch_tokens", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = ("csrf", "session")
|
||||
result = runner.invoke(cli, ["source", "wait", "src_123", "-n", "nb_123", "--json"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
data = json.loads(result.output)
|
||||
assert data["status"] == "timeout"
|
||||
assert data["source_id"] == "src_123"
|
||||
assert data["timeout_seconds"] == 30
|
||||
assert data["last_status_code"] == 1
|
||||
|
|
|
|||
|
|
@ -254,13 +254,15 @@ class TestGetNotebookDescription:
|
|||
async def test_get_notebook_description_parses_response(self, mock_client):
|
||||
"""Test get_notebook_description parses full response."""
|
||||
mock_response = [
|
||||
["This notebook explores **AI** and **machine learning**."],
|
||||
[
|
||||
["This notebook explores **AI** and **machine learning**."],
|
||||
[
|
||||
["What is the future of AI?", "Create a detailed briefing..."],
|
||||
["How does ML work?", "Explain the fundamentals..."],
|
||||
]
|
||||
],
|
||||
[
|
||||
["What is the future of AI?", "Create a detailed briefing..."],
|
||||
["How does ML work?", "Explain the fundamentals..."],
|
||||
]
|
||||
],
|
||||
]
|
||||
]
|
||||
mock_client._core.rpc_call = AsyncMock(return_value=mock_response)
|
||||
|
||||
|
|
|
|||
|
|
@ -212,53 +212,14 @@ class TestFormatHelpers:
|
|||
result = _format_single_qa("", "")
|
||||
assert result == ""
|
||||
|
||||
def test_format_all_qa_single_pair(self):
|
||||
from notebooklm.cli.chat import _format_all_qa
|
||||
|
||||
result = _format_all_qa([("Q1?", "A1.")])
|
||||
assert "## Turn 1" in result
|
||||
assert "**Q:** Q1?" in result
|
||||
assert "**A:** A1." in result
|
||||
assert "---" not in result # no separator for single item
|
||||
|
||||
def test_format_all_qa_multiple_pairs(self):
|
||||
from notebooklm.cli.chat import _format_all_qa
|
||||
|
||||
pairs = [("Q1?", "A1."), ("Q2?", "A2.")]
|
||||
result = _format_all_qa(pairs)
|
||||
assert "## Turn 1" in result
|
||||
assert "## Turn 2" in result
|
||||
assert "**Q:** Q1?" in result
|
||||
assert "**Q:** Q2?" in result
|
||||
assert "---" in result # separator between turns
|
||||
|
||||
def test_format_all_qa_empty_list(self):
|
||||
from notebooklm.cli.chat import _format_all_qa
|
||||
|
||||
result = _format_all_qa([])
|
||||
assert result == ""
|
||||
|
||||
|
||||
class TestDetermineConversationId:
|
||||
"""Tests for _determine_conversation_id CLI helper."""
|
||||
|
||||
def test_new_conversation_returns_none(self):
|
||||
from notebooklm.cli.chat import _determine_conversation_id
|
||||
|
||||
result = _determine_conversation_id(
|
||||
new_conversation=True,
|
||||
explicit_conversation_id=None,
|
||||
explicit_notebook_id=None,
|
||||
resolved_notebook_id="nb_123",
|
||||
json_output=True,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_explicit_conversation_id_used(self):
|
||||
from notebooklm.cli.chat import _determine_conversation_id
|
||||
|
||||
result = _determine_conversation_id(
|
||||
new_conversation=False,
|
||||
explicit_conversation_id="conv_explicit",
|
||||
explicit_notebook_id=None,
|
||||
resolved_notebook_id="nb_123",
|
||||
|
|
@ -271,7 +232,6 @@ class TestDetermineConversationId:
|
|||
|
||||
with patch("notebooklm.cli.chat.get_current_notebook", return_value="nb_old"):
|
||||
result = _determine_conversation_id(
|
||||
new_conversation=False,
|
||||
explicit_conversation_id=None,
|
||||
explicit_notebook_id="nb_new",
|
||||
resolved_notebook_id="nb_new",
|
||||
|
|
@ -287,7 +247,6 @@ class TestDetermineConversationId:
|
|||
patch("notebooklm.cli.chat.get_current_conversation", return_value="conv_cached"),
|
||||
):
|
||||
result = _determine_conversation_id(
|
||||
new_conversation=False,
|
||||
explicit_conversation_id=None,
|
||||
explicit_notebook_id="nb_123",
|
||||
resolved_notebook_id="nb_123",
|
||||
|
|
@ -300,7 +259,6 @@ class TestDetermineConversationId:
|
|||
|
||||
with patch("notebooklm.cli.chat.get_current_conversation", return_value="conv_cached"):
|
||||
result = _determine_conversation_id(
|
||||
new_conversation=False,
|
||||
explicit_conversation_id=None,
|
||||
explicit_notebook_id=None,
|
||||
resolved_notebook_id="nb_123",
|
||||
|
|
@ -317,7 +275,7 @@ class TestGetLatestConversationFromServer:
|
|||
from notebooklm.cli.chat import _get_latest_conversation_from_server
|
||||
|
||||
client = MagicMock()
|
||||
client.chat.get_last_conversation_id = AsyncMock(return_value="conv_from_server")
|
||||
client.chat.get_conversation_id = AsyncMock(return_value="conv_from_server")
|
||||
|
||||
result = await _get_latest_conversation_from_server(client, "nb_123", json_output=True)
|
||||
assert result == "conv_from_server"
|
||||
|
|
@ -327,7 +285,7 @@ class TestGetLatestConversationFromServer:
|
|||
from notebooklm.cli.chat import _get_latest_conversation_from_server
|
||||
|
||||
client = MagicMock()
|
||||
client.chat.get_last_conversation_id = AsyncMock(return_value=None)
|
||||
client.chat.get_conversation_id = AsyncMock(return_value=None)
|
||||
|
||||
result = await _get_latest_conversation_from_server(client, "nb_123", json_output=True)
|
||||
assert result is None
|
||||
|
|
@ -337,7 +295,7 @@ class TestGetLatestConversationFromServer:
|
|||
from notebooklm.cli.chat import _get_latest_conversation_from_server
|
||||
|
||||
client = MagicMock()
|
||||
client.chat.get_last_conversation_id = AsyncMock(side_effect=RuntimeError("Network error"))
|
||||
client.chat.get_conversation_id = AsyncMock(side_effect=RuntimeError("Network error"))
|
||||
|
||||
result = await _get_latest_conversation_from_server(client, "nb_123", json_output=True)
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Tests for conversation functionality."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from notebooklm import AskResult, NotebookLMClient
|
||||
from notebooklm._chat import ChatAPI
|
||||
from notebooklm._core import ClientCore
|
||||
from notebooklm.auth import AuthTokens
|
||||
from notebooklm.exceptions import ChatError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -98,73 +98,32 @@ class TestAsk:
|
|||
assert result.is_follow_up is True
|
||||
assert result.turn_number == 2
|
||||
|
||||
|
||||
class TestParseExchangeId:
|
||||
def test_extracts_exchange_id_from_response(self):
|
||||
"""_parse_ask_response_with_references returns exchange_id from first[2][1]."""
|
||||
inner_json = json.dumps(
|
||||
[
|
||||
[
|
||||
"The answer text.",
|
||||
None,
|
||||
["conv-uuid-111", "exchange-uuid-222", 12345],
|
||||
None,
|
||||
[1],
|
||||
]
|
||||
]
|
||||
)
|
||||
chunk_json = json.dumps([["wrb.fr", None, inner_json]])
|
||||
response_body = f")]}}'\n{len(chunk_json)}\n{chunk_json}\n"
|
||||
|
||||
auth = AuthTokens(
|
||||
cookies={"SID": "test"},
|
||||
csrf_token="test_csrf",
|
||||
session_id="test_session",
|
||||
)
|
||||
core = ClientCore(auth)
|
||||
api = ChatAPI(core)
|
||||
|
||||
_, _, exchange_id = api._parse_ask_response_with_references(response_body)
|
||||
assert exchange_id == "exchange-uuid-222"
|
||||
|
||||
def test_returns_none_when_first2_missing(self):
|
||||
"""Gracefully returns None if first[2] is absent."""
|
||||
inner_json = json.dumps([["The answer text.", None, None, None, [1]]])
|
||||
chunk_json = json.dumps([["wrb.fr", None, inner_json]])
|
||||
response_body = f")]}}'\n{len(chunk_json)}\n{chunk_json}\n"
|
||||
|
||||
auth = AuthTokens(
|
||||
cookies={"SID": "test"},
|
||||
csrf_token="test_csrf",
|
||||
session_id="test_session",
|
||||
)
|
||||
core = ClientCore(auth)
|
||||
api = ChatAPI(core)
|
||||
|
||||
_, _, exchange_id = api._parse_ask_response_with_references(response_body)
|
||||
assert exchange_id is None
|
||||
|
||||
|
||||
class TestAskExchangeId:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_returns_exchange_id(self, auth_tokens, httpx_mock):
|
||||
"""ask() should return the exchange_id from first[2][1]."""
|
||||
import re
|
||||
|
||||
inner_json = json.dumps(
|
||||
async def test_ask_raises_chat_error_on_rate_limit(self, auth_tokens, httpx_mock):
|
||||
"""ask() raises ChatError when the server returns UserDisplayableError."""
|
||||
error_chunk = json.dumps(
|
||||
[
|
||||
[
|
||||
"The answer. Long enough to be valid for testing purposes.",
|
||||
"wrb.fr",
|
||||
None,
|
||||
["conv-uuid-000", "exchange-uuid-abc", 99999],
|
||||
None,
|
||||
[1],
|
||||
None,
|
||||
None,
|
||||
[
|
||||
8,
|
||||
None,
|
||||
[
|
||||
[
|
||||
"type.googleapis.com/google.internal.labs.tailwind"
|
||||
".orchestration.v1.UserDisplayableError",
|
||||
[None, [None, [[1]]]],
|
||||
]
|
||||
],
|
||||
],
|
||||
]
|
||||
]
|
||||
)
|
||||
chunk_json = json.dumps([["wrb.fr", None, inner_json]])
|
||||
response_body = f")]}}'\n{len(chunk_json)}\n{chunk_json}\n"
|
||||
|
||||
response_body = f")]}}'\n{len(error_chunk)}\n{error_chunk}\n"
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r".*GenerateFreeFormStreamed.*"),
|
||||
content=response_body.encode(),
|
||||
|
|
@ -172,23 +131,19 @@ class TestAskExchangeId:
|
|||
)
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.chat.ask(
|
||||
notebook_id="nb_123",
|
||||
question="What is this?",
|
||||
source_ids=["test_source"],
|
||||
)
|
||||
|
||||
assert result.exchange_id == "exchange-uuid-abc"
|
||||
with pytest.raises(ChatError, match="rate limited"):
|
||||
await client.chat.ask("nb_123", "What is this?", source_ids=["test_source"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_follow_up_accepts_exchange_id(self, auth_tokens, httpx_mock):
|
||||
"""Follow-up with exchange_id should succeed and return new exchange_id."""
|
||||
async def test_ask_returns_server_conversation_id(self, auth_tokens, httpx_mock):
|
||||
"""ask() uses the conversation_id from the server response, not a local UUID."""
|
||||
server_conv_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
inner_json = json.dumps(
|
||||
[
|
||||
[
|
||||
"Follow-up answer. Long enough to be valid for testing.",
|
||||
"Server answer text that is long enough to be valid.",
|
||||
None,
|
||||
["conv-uuid-000", "exchange-uuid-xyz", 99999],
|
||||
[server_conv_id, "hash123"],
|
||||
None,
|
||||
[1],
|
||||
]
|
||||
|
|
@ -196,16 +151,13 @@ class TestAskExchangeId:
|
|||
)
|
||||
chunk_json = json.dumps([["wrb.fr", None, inner_json]])
|
||||
response_body = f")]}}'\n{len(chunk_json)}\n{chunk_json}\n"
|
||||
|
||||
httpx_mock.add_response(content=response_body.encode(), method="POST")
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r".*GenerateFreeFormStreamed.*"),
|
||||
content=response_body.encode(),
|
||||
method="POST",
|
||||
)
|
||||
|
||||
async with NotebookLMClient(auth_tokens) as client:
|
||||
result = await client.chat.ask(
|
||||
notebook_id="nb_123",
|
||||
question="Follow up?",
|
||||
conversation_id="conv-uuid-000",
|
||||
exchange_id="exchange-uuid-abc",
|
||||
source_ids=["test_source"],
|
||||
)
|
||||
result = await client.chat.ask("nb_123", "What is this?", source_ids=["test_source"])
|
||||
|
||||
assert result.exchange_id == "exchange-uuid-xyz"
|
||||
assert result.conversation_id == server_conv_id
|
||||
|
|
|
|||
|
|
@ -716,25 +716,6 @@ class TestAskResult:
|
|||
|
||||
assert result.references == []
|
||||
|
||||
def test_ask_result_has_exchange_id_field(self):
|
||||
result = AskResult(
|
||||
answer="The answer",
|
||||
conversation_id="conv-uuid-here",
|
||||
turn_number=1,
|
||||
is_follow_up=False,
|
||||
)
|
||||
assert result.exchange_id is None # optional, defaults to None
|
||||
|
||||
def test_ask_result_exchange_id_can_be_set(self):
|
||||
result = AskResult(
|
||||
answer="The answer",
|
||||
conversation_id="conv-uuid-here",
|
||||
turn_number=1,
|
||||
is_follow_up=False,
|
||||
exchange_id="exch-uuid-here",
|
||||
)
|
||||
assert result.exchange_id == "exch-uuid-here"
|
||||
|
||||
|
||||
class TestChatReference:
|
||||
def test_creation_minimal(self):
|
||||
|
|
|
|||
2
uv.lock
2
uv.lock
|
|
@ -432,7 +432,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "notebooklm-py"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue