├── python ├── src │ └── nblm │ │ ├── py.typed │ │ ├── __init__.pyi │ │ ├── _auth.pyi │ │ ├── __init__.py │ │ └── _models.pyi ├── .python-version ├── tests │ ├── test_audio.py │ ├── test_sources.py │ ├── test_notebooks.py │ ├── test_oauth.py │ └── test_basic.py ├── pyproject.toml ├── manual_test_env.py └── .gitignore ├── docs ├── .python-version ├── pyproject.toml ├── cli │ ├── share.md │ ├── auth.md │ └── README.md ├── rust │ └── getting-started.md ├── guides │ └── troubleshooting.md ├── getting-started │ ├── installation.md │ └── configuration.md ├── api │ └── limitations.md ├── index.md ├── .gitignore └── python │ ├── README.md │ └── quickstart.md ├── crates ├── nblm-core │ ├── src │ │ ├── models │ │ │ ├── mod.rs │ │ │ └── enterprise │ │ │ │ ├── mod.rs │ │ │ │ ├── audio.rs │ │ │ │ └── notebook.rs │ │ ├── client │ │ │ ├── api │ │ │ │ └── backends │ │ │ │ │ └── enterprise │ │ │ │ │ ├── models │ │ │ │ │ ├── responses │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── list.rs │ │ │ │ │ │ ├── source.rs │ │ │ │ │ │ └── audio.rs │ │ │ │ │ ├── requests │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ ├── audio.rs │ │ │ │ │ │ ├── source.rs │ │ │ │ │ │ └── notebook.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── notebook.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── audio.rs │ │ │ └── url │ │ │ │ ├── mod.rs │ │ │ │ └── enterprise.rs │ │ ├── doctor │ │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── auth │ │ │ └── oauth │ │ │ │ ├── error.rs │ │ │ │ ├── testing.rs │ │ │ │ └── config.rs │ │ └── error.rs │ ├── Makefile.toml │ └── Cargo.toml ├── nblm-cli │ ├── Makefile.toml │ ├── tests │ │ ├── _helpers │ │ │ ├── mod.rs │ │ │ └── cmd.rs │ │ ├── audio_delete.rs │ │ ├── sources_add.rs │ │ ├── notebooks_recent.rs │ │ ├── notebooks_create.rs │ │ ├── sources_upload.rs │ │ ├── errors_retry.rs │ │ ├── notebooks_delete.rs │ │ ├── sources_delete.rs │ │ └── json_output.rs │ ├── src │ │ ├── ops │ │ │ ├── mod.rs │ │ │ ├── doctor.rs │ │ │ ├── notebooks.rs │ │ │ ├── audio.rs │ │ │ └── auth.rs │ │ ├── util │ │ │ ├── mod.rs │ │ │ └── validate.rs │ │ └── main.rs │ └── Cargo.toml └── nblm-python │ ├── src │ ├── runtime.rs │ ├── error.rs │ ├── lib.rs │ └── models │ │ ├── mod.rs │ │ ├── audio.rs │ │ ├── source.rs │ │ ├── notebook.rs │ │ └── responses.rs │ └── Cargo.toml ├── rust-toolchain.toml ├── Cargo.toml ├── renovate.json ├── .pre-commit-config.yaml ├── .gitignore ├── codecov.yml ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── mkdocs.yml │ ├── coverage.yml │ ├── rust-publish.yml │ ├── rust-ci.yml │ ├── python-ci.yml │ ├── rust-release.yml │ ├── python-release.yml │ └── python-publish.yml ├── LICENSE ├── scripts └── bump-version.sh ├── mkdocs.yml └── Makefile.toml /python/src/nblm/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /python/.python-version: -------------------------------------------------------------------------------- 1 | 3.14 2 | -------------------------------------------------------------------------------- /crates/nblm-core/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod enterprise; 2 | -------------------------------------------------------------------------------- /crates/nblm-cli/Makefile.toml: -------------------------------------------------------------------------------- 1 | extend = "../../Makefile.toml" 2 | -------------------------------------------------------------------------------- /crates/nblm-core/Makefile.toml: -------------------------------------------------------------------------------- 1 | extend = "../../Makefile.toml" 2 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/_helpers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cmd; 2 | pub mod mock; 3 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["clippy", "rustfmt"] 4 | -------------------------------------------------------------------------------- /crates/nblm-core/src/models/enterprise/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod audio; 2 | pub mod notebook; 3 | pub mod source; 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/nblm-core", "crates/nblm-cli", "crates/nblm-python"] 3 | resolver = "2" 4 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/ops/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod audio; 2 | pub mod auth; 3 | pub mod doctor; 4 | pub mod notebooks; 5 | pub mod sources; 6 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/responses/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod audio; 2 | pub mod list; 3 | pub mod source; 4 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/requests/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod audio; 2 | pub mod notebook; 3 | pub mod source; 4 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod io; 3 | pub mod oauth_bootstrap; 4 | pub mod oauth_browser; 5 | pub mod validate; 6 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod notebook; 2 | pub mod requests; 3 | pub mod responses; 4 | pub mod source; 5 | -------------------------------------------------------------------------------- /crates/nblm-core/src/doctor/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod checks; 2 | 3 | pub use checks::{ 4 | check_api_connectivity, check_commands, check_drive_access_token, check_environment_variables, 5 | CheckResult, CheckStatus, DiagnosticsSummary, 6 | }; 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "timezone": "Asia/Tokyo", 7 | "schedule": [ 8 | "on saturday at 3pm" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: make-all 5 | name: Run 'cargo make all' 6 | entry: cargo make all 7 | language: system 8 | always_run: true 9 | pass_filenames: false 10 | -------------------------------------------------------------------------------- /docs/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "docs" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.14" 7 | dependencies = [ 8 | "mkdocs>=1.6.1", 9 | "mkdocs-material>=9.6.22", 10 | ] 11 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/mod.rs: -------------------------------------------------------------------------------- 1 | mod audio; 2 | mod converter; 3 | pub(crate) mod models; 4 | mod notebooks; 5 | mod sources; 6 | 7 | pub(crate) use audio::EnterpriseAudioBackend; 8 | pub(crate) use notebooks::EnterpriseNotebooksBackend; 9 | pub(crate) use sources::EnterpriseSourcesBackend; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | /.idea 5 | /.vscode 6 | 7 | # insta snapshots (pending review) 8 | **/*.pending-snap 9 | 10 | *.pdb 11 | 12 | # Generated by cargo mutants 13 | # Contains mutation testing data 14 | **/mutants.out*/ 15 | 16 | .mcp.json 17 | 18 | /working 19 | /sandbox 20 | site/ 21 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 1% 11 | 12 | ignore: 13 | - "crates/nblm-python/**/*" 14 | 15 | comment: 16 | layout: "reach, diff, flags, files" 17 | behavior: default 18 | require_changes: false 19 | 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | ## Testing 6 | 7 | - [ ] Pre-commit hooks passed (automatically checked on commit) 8 | 9 | 10 | 11 | ## Additional Context 12 | 13 | 14 | -------------------------------------------------------------------------------- /crates/nblm-python/src/runtime.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{map_runtime_error, IntoPyResult, PyResult}; 2 | use std::future::Future; 3 | use tokio::runtime::Handle; 4 | 5 | /// Block on an async future, creating a Tokio runtime if needed. 6 | pub fn block_on_with_runtime(future: F) -> PyResult 7 | where 8 | F: Future> + Send + 'static, 9 | T: Send + 'static, 10 | { 11 | if let Ok(handle) = Handle::try_current() { 12 | return handle.block_on(future).into_py_result(); 13 | } 14 | 15 | let runtime = tokio::runtime::Builder::new_multi_thread() 16 | .enable_all() 17 | .build() 18 | .map_err(map_runtime_error)?; 19 | runtime.block_on(future).into_py_result() 20 | } 21 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/audio_delete.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn audio_delete_success() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | let notebook_id = "test-notebook-id"; 13 | 14 | mock.stub_audio_delete(&args.project_number, &args.location, notebook_id) 15 | .await; 16 | 17 | let mut cmd = _helpers::cmd::nblm(); 18 | args.with_base_url(&mut cmd, &mock.base_url()); 19 | cmd.args(["audio", "delete", "--notebook-id", notebook_id]); 20 | 21 | cmd.assert().success().stdout(predicate::str::contains( 22 | "Audio overview deleted successfully", 23 | )); 24 | } 25 | -------------------------------------------------------------------------------- /crates/nblm-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod client; 3 | pub mod doctor; 4 | pub mod env; 5 | mod error; 6 | pub mod models; 7 | 8 | pub use auth::oauth::{ 9 | AuthorizeContext, AuthorizeParams, FileRefreshTokenStore, OAuthConfig, OAuthFlow, OAuthTokens, 10 | RefreshTokenProvider, RefreshTokenStore, SerializedTokens, TokenCacheEntry, TokenStoreKey, 11 | }; 12 | pub use auth::{ 13 | ensure_drive_scope, EnvTokenProvider, GcloudTokenProvider, ProviderKind, StaticTokenProvider, 14 | TokenProvider, 15 | }; 16 | pub use client::{NblmClient, RetryConfig, Retryer}; 17 | pub use env::{ApiProfile, EnvironmentConfig, ProfileParams, PROFILE_EXPERIMENT_FLAG}; 18 | pub use error::{Error, Result}; 19 | 20 | use std::sync::Arc; 21 | 22 | pub type DynTokenProvider = Arc; 23 | -------------------------------------------------------------------------------- /crates/nblm-python/src/error.rs: -------------------------------------------------------------------------------- 1 | use pyo3::create_exception; 2 | use pyo3::exceptions::PyException; 3 | use pyo3::exceptions::PyRuntimeError; 4 | use pyo3::prelude::*; 5 | 6 | create_exception!(nblm, NblmError, PyException); 7 | 8 | pub type PyResult = Result; 9 | 10 | pub(crate) fn map_nblm_error(err: nblm_core::Error) -> PyErr { 11 | NblmError::new_err(err.to_string()) 12 | } 13 | 14 | pub(crate) fn map_runtime_error(err: impl std::fmt::Display) -> PyErr { 15 | PyRuntimeError::new_err(format!("Failed to execute async operation: {err}")) 16 | } 17 | 18 | pub(crate) trait IntoPyResult { 19 | fn into_py_result(self) -> PyResult; 20 | } 21 | 22 | impl IntoPyResult for Result { 23 | fn into_py_result(self) -> PyResult { 24 | self.map_err(map_nblm_error) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /python/tests/test_audio.py: -------------------------------------------------------------------------------- 1 | """Tests for audio operations bindings.""" 2 | 3 | import nblm 4 | 5 | 6 | def test_audio_types_available() -> None: 7 | """Ensure audio-related types are accessible.""" 8 | 9 | assert nblm.AudioOverviewRequest is not None 10 | assert nblm.AudioOverviewResponse is not None 11 | 12 | 13 | def test_audio_request_instantiation() -> None: 14 | """Test creating AudioOverviewRequest instances.""" 15 | 16 | # Should create successfully with no arguments 17 | request = nblm.AudioOverviewRequest() 18 | assert request is not None 19 | 20 | 21 | def test_client_audio_methods_exist() -> None: 22 | """Verify NblmClient exposes audio methods without instantiation.""" 23 | 24 | assert hasattr(nblm.NblmClient, "create_audio_overview") 25 | assert hasattr(nblm.NblmClient, "delete_audio_overview") 26 | -------------------------------------------------------------------------------- /crates/nblm-python/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nblm-python" 3 | version = "0.2.3" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "Python bindings for nblm-core (NotebookLM Enterprise API client)" 7 | repository = "https://github.com/K-dash/nblm-rs" 8 | homepage = "https://github.com/K-dash/nblm-rs" 9 | readme = "../../README.md" 10 | keywords = ["notebooklm", "api", "google-cloud", "gemini", "python"] 11 | categories = ["api-bindings"] 12 | authors = ["K-dash"] 13 | 14 | [lib] 15 | name = "nblm" 16 | crate-type = ["cdylib"] 17 | 18 | [dependencies] 19 | nblm-core = { path = "../nblm-core" } 20 | pyo3 = { version = "0.23", features = ["extension-module", "abi3-py312"] } 21 | tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } 22 | serde_json = "1.0" 23 | mime_guess = "2.0.5" 24 | reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls"] } 25 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/sources_add.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn sources_add_web_url() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | let notebook_id = "test-notebook"; 13 | 14 | mock.stub_sources_batch_create(&args.project_number, &args.location, notebook_id) 15 | .await; 16 | 17 | let mut cmd = _helpers::cmd::nblm(); 18 | args.with_base_url(&mut cmd, &mock.base_url()); 19 | cmd.args([ 20 | "sources", 21 | "add", 22 | "--notebook-id", 23 | notebook_id, 24 | "--web-url", 25 | "https://example.com", 26 | "--web-name", 27 | "Example", 28 | ]); 29 | 30 | cmd.assert() 31 | .success() 32 | .stdout(predicate::str::contains("sources")); 33 | } 34 | -------------------------------------------------------------------------------- /python/tests/test_sources.py: -------------------------------------------------------------------------------- 1 | """Tests for sources operations bindings.""" 2 | 3 | import nblm 4 | 5 | 6 | def test_sources_response_types_available() -> None: 7 | """Ensure source-related response classes are accessible.""" 8 | 9 | assert nblm.BatchCreateSourcesResponse is not None 10 | assert nblm.BatchDeleteSourcesResponse is not None 11 | assert nblm.UploadSourceFileResponse is not None 12 | assert nblm.NotebookSource is not None 13 | assert nblm.WebSource is not None 14 | assert nblm.TextSource is not None 15 | assert nblm.VideoSource is not None 16 | 17 | 18 | def test_client_sources_methods_exist() -> None: 19 | """Verify NblmClient exposes sources methods without instantiation.""" 20 | 21 | assert hasattr(nblm.NblmClient, "add_sources") 22 | assert hasattr(nblm.NblmClient, "delete_sources") 23 | assert hasattr(nblm.NblmClient, "upload_source_file") 24 | assert hasattr(nblm.NblmClient, "get_source") 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 K' 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crates/nblm-core/src/models/enterprise/audio.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | 6 | /// Domain-level request for creating an audio overview. 7 | /// 8 | /// As of today the API expects an empty object, but fields are kept optional for future use. 9 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct AudioOverviewRequest {} 12 | 13 | /// Domain-level response for audio overview operations. 14 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct AudioOverviewResponse { 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub audio_overview_id: Option, 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub name: Option, 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub status: Option, 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub generation_options: Option, 25 | #[serde(flatten)] 26 | pub extra: HashMap, 27 | } 28 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Parser; 3 | 4 | mod app; 5 | mod args; 6 | mod ops; 7 | mod util; 8 | 9 | #[tokio::main(flavor = "multi_thread")] 10 | async fn main() -> Result<()> { 11 | // Check if this is the doctor command before requiring global args 12 | let args: Vec = std::env::args().collect(); 13 | let has_doctor = args.iter().any(|arg| arg == "doctor"); 14 | let has_json = args.iter().any(|arg| arg == "--json"); 15 | 16 | // If both doctor and --json are present, bail immediately 17 | if has_doctor && has_json { 18 | anyhow::bail!("The --json flag is not supported for the 'doctor' command"); 19 | } 20 | 21 | // Check for special commands that need to bypass NblmApp initialization 22 | if let Some(cmd) = args::parse_pre_command(&args) { 23 | match cmd { 24 | args::SpecialCommand::Doctor(args) => return ops::doctor::run(args).await, 25 | args::SpecialCommand::Auth(cmd) => return ops::auth::run(cmd).await, 26 | } 27 | } 28 | 29 | let cli = args::Cli::parse(); 30 | app::NblmApp::new(cli)?.run().await 31 | } 32 | -------------------------------------------------------------------------------- /docs/cli/share.md: -------------------------------------------------------------------------------- 1 | # Share Commands (Deprecated) 2 | 3 | !!! danger "Feature Discontinued" 4 | **This feature has been discontinued as of November 7, 2025.** 5 | 6 | The NotebookLM Enterprise Share API has been removed from the official documentation, and this project no longer supports notebook sharing functionality. All related code and commands have been removed from nblm-rs. 7 | 8 | ## Background 9 | 10 | Previously, nblm-rs provided the ability to share notebooks with other users via the `nblm share add` command. This functionality relied on the NotebookLM Enterprise Share API. 11 | 12 | As of November 7, 2025, the Share API documentation has been removed from the official Google Cloud documentation, indicating that this feature is no longer supported by the NotebookLM Enterprise API. 13 | 14 | > [Share notebooks - Google Cloud](https://cloud.google.com/gemini/enterprise/notebooklm-enterprise/docs/share-notebooks) 15 | 16 | 17 | ## See Also 18 | 19 | - [Notebooks Commands](notebooks.md) - Create and manage notebooks 20 | - [Sources Commands](sources.md) - Add content to notebooks 21 | - [Audio Commands](audio.md) - Create audio overviews 22 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/url/mod.rs: -------------------------------------------------------------------------------- 1 | mod enterprise; 2 | 3 | use std::sync::Arc; 4 | 5 | use reqwest::Url; 6 | 7 | use crate::env::ApiProfile; 8 | use crate::error::Result; 9 | 10 | pub(crate) use enterprise::EnterpriseUrlBuilder; 11 | 12 | /// Profile-aware URL builder interface. 13 | pub(crate) trait UrlBuilder: Send + Sync { 14 | fn notebooks_collection(&self) -> String; 15 | fn notebook_path(&self, notebook_id: &str) -> String; 16 | fn build_url(&self, path: &str) -> Result; 17 | fn build_upload_url(&self, path: &str) -> Result; 18 | } 19 | 20 | pub(crate) fn new_url_builder( 21 | profile: ApiProfile, 22 | base: String, 23 | parent: String, 24 | ) -> Arc { 25 | // TODO(profile-support): add profile-specific builders when new SKUs become available. 26 | match profile { 27 | ApiProfile::Enterprise => Arc::new(EnterpriseUrlBuilder::new(base, parent)), 28 | ApiProfile::Personal | ApiProfile::Workspace => { 29 | unimplemented!( 30 | "UrlBuilder for profile '{}' is not implemented", 31 | profile.as_str() 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/notebooks_recent.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn notebooks_recent_success() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | 13 | mock.stub_notebooks_recent(&args.project_number, &args.location) 14 | .await; 15 | 16 | let mut cmd = _helpers::cmd::nblm(); 17 | args.with_base_url(&mut cmd, &mock.base_url()); 18 | cmd.args(["notebooks", "recent"]); 19 | 20 | cmd.assert() 21 | .success() 22 | .stdout(predicate::str::contains("notebooks")) 23 | .stdout(predicate::str::contains("nb1")); 24 | } 25 | 26 | #[tokio::test] 27 | #[serial] 28 | async fn notebooks_recent_with_page_size() { 29 | let mock = MockApi::start().await; 30 | let args = CommonArgs::default(); 31 | 32 | mock.stub_notebooks_recent_with_page_size(&args.project_number, &args.location, 10) 33 | .await; 34 | 35 | let mut cmd = _helpers::cmd::nblm(); 36 | args.with_base_url(&mut cmd, &mock.base_url()); 37 | cmd.args(["notebooks", "recent", "--page-size", "10"]); 38 | 39 | cmd.assert().success(); 40 | } 41 | -------------------------------------------------------------------------------- /crates/nblm-core/src/auth/oauth/error.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Error as ReqwestError; 2 | use serde_json::Error as SerdeJsonError; 3 | use thiserror::Error; 4 | 5 | /// Errors that can occur during OAuth2 operations. 6 | #[derive(Error, Debug)] 7 | pub enum OAuthError { 8 | #[error("OAuth client configuration error: {0}")] 9 | Config(String), 10 | 11 | #[error("OAuth authorization flow failed: {0}")] 12 | Flow(String), 13 | 14 | #[error("Token refresh failed: {0}")] 15 | Refresh(String), 16 | 17 | #[error("Token revocation failed: {0}")] 18 | Revocation(String), 19 | 20 | #[error("Token storage error: {0}")] 21 | Storage(#[from] std::io::Error), 22 | 23 | #[error("CSRF state mismatch: expected {expected}, got {actual}")] 24 | StateMismatch { expected: String, actual: String }, 25 | 26 | #[error("Missing required environment variable: {0}")] 27 | MissingEnvVar(&'static str), 28 | 29 | #[error("HTTP request failed: {0}")] 30 | Http(#[from] ReqwestError), 31 | 32 | #[error("Invalid token response: {0}")] 33 | InvalidResponse(String), 34 | 35 | #[error("JSON serialization error: {0}")] 36 | Json(#[from] SerdeJsonError), 37 | } 38 | 39 | pub type Result = std::result::Result; 40 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy MkDocs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "docs/**" 9 | - "mkdocs.yml" 10 | - ".github/workflows/mkdocs.yml" 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: write 15 | 16 | jobs: 17 | deploy: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 22 | 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v4 25 | with: 26 | enable-cache: true 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 30 | with: 31 | python-version-file: "docs/.python-version" 32 | 33 | - name: Install dependencies 34 | working-directory: docs 35 | run: uv sync 36 | 37 | - name: Build documentation 38 | run: uv run --project docs mkdocs build --config-file mkdocs.yml --site-dir site 39 | 40 | - name: Deploy to GitHub Pages 41 | uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 42 | with: 43 | github_token: ${{ secrets.GITHUB_TOKEN }} 44 | publish_dir: ./site 45 | publish_branch: gh-pages 46 | force_orphan: true 47 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/notebooks_create.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn notebooks_create_with_base_url() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | 13 | mock.stub_notebooks_create(&args.project_number, &args.location, "Hello") 14 | .await; 15 | 16 | let mut cmd = _helpers::cmd::nblm(); 17 | args.with_base_url(&mut cmd, &mock.base_url()); 18 | cmd.args(["notebooks", "create", "--title", "Hello"]); 19 | 20 | cmd.assert() 21 | .success() 22 | .stdout(predicate::str::contains("test-notebook-id")) 23 | .stdout(predicate::str::contains("Hello")); 24 | } 25 | 26 | #[tokio::test] 27 | #[serial] 28 | async fn notebooks_create_with_env_base_url() { 29 | let mock = MockApi::start().await; 30 | let args = CommonArgs::default(); 31 | 32 | mock.stub_notebooks_create(&args.project_number, &args.location, "World") 33 | .await; 34 | 35 | let mut cmd = _helpers::cmd::nblm(); 36 | cmd.env("NBLM_BASE_URL", mock.base_url()); 37 | args.apply(&mut cmd); 38 | cmd.args(["notebooks", "create", "--title", "World"]); 39 | 40 | cmd.assert() 41 | .success() 42 | .stdout(predicate::str::contains("test-notebook-id")); 43 | } 44 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/sources_upload.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use std::io::Write; 4 | 5 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 6 | use predicates::prelude::*; 7 | use serial_test::serial; 8 | use tempfile::NamedTempFile; 9 | 10 | #[tokio::test] 11 | #[serial] 12 | async fn sources_upload_file() { 13 | let mock = MockApi::start().await; 14 | let args = CommonArgs::default(); 15 | let notebook_id = "notebook-upload"; 16 | 17 | mock.stub_sources_upload_file( 18 | &args.project_number, 19 | &args.location, 20 | notebook_id, 21 | "source-upload", 22 | ) 23 | .await; 24 | 25 | let mut temp_file = NamedTempFile::new().expect("temp file"); 26 | writeln!(temp_file, "hello world").expect("write temp file"); 27 | let file_path = temp_file.into_temp_path(); 28 | let file_str = file_path.to_str().expect("path to str").to_string(); 29 | 30 | let mut cmd = _helpers::cmd::nblm(); 31 | args.with_base_url(&mut cmd, &mock.base_url()); 32 | cmd.args([ 33 | "sources", 34 | "upload", 35 | "--notebook-id", 36 | notebook_id, 37 | "--file", 38 | &file_str, 39 | "--content-type", 40 | "text/plain", 41 | "--display-name", 42 | "Sample.txt", 43 | ]); 44 | 45 | cmd.assert() 46 | .success() 47 | .stdout(predicate::str::contains("Created source:")); 48 | } 49 | -------------------------------------------------------------------------------- /docs/rust/getting-started.md: -------------------------------------------------------------------------------- 1 | # Rust SDK – Getting Started 2 | 3 | !!! warning "Work in Progress" 4 | The Rust SDK is currently being refactored. A complete Getting Started guide will be added once the new core APIs are finalized. 5 | 6 | Stay tuned — we'll update this document soon! 7 | 8 | ## Installation 9 | 10 | Add the necessary dependencies to your project's Cargo.toml: 11 | 12 | ``` 13 | 14 | 15 | ``` 16 | 17 | ## Prerequisites 18 | 19 | - Rust 1.70 or later (for modern async features) 20 | - Google Cloud project with NotebookLM API enabled 21 | - gcloud CLI installed and authenticated (for simple credential management) 22 | 23 | ## Basic Setup 24 | 25 | 1. Authenticate with gcloud 26 | 27 | ``` 28 | 29 | ``` 30 | 31 | 2. Set Your Project Number 32 | 33 | ``` 34 | 35 | ``` 36 | 37 | 3. Initialize and List Notebooks 38 | 39 | ``` 40 | 41 | ``` 42 | 43 | ## Complete Example 44 | 45 | Here's a complete workflow demonstrating typical API operations. 46 | 47 | ``` 48 | // 1. Initialize client 49 | 50 | // 2. Create a notebook 51 | 52 | // 3. Add sources 53 | 54 | // 4. Create audio overview 55 | 56 | // 5. List your notebooks 57 | 58 | // Optional Cleanup: Delete the created notebook 59 | 60 | ``` 61 | 62 | ## Authentication Options 63 | 64 | 1. gcloud CLI (Recommended) 65 | 66 | ``` 67 | 68 | ``` 69 | 70 | ## Configuration 71 | 72 | Environment Variables 73 | 74 | ``` 75 | 76 | ``` 77 | 78 | ## Next Steps 79 | -------------------------------------------------------------------------------- /crates/nblm-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nblm-core" 3 | version = "0.2.3" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "Core library for NotebookLM Enterprise API client" 7 | repository = "https://github.com/K-dash/nblm-rs" 8 | homepage = "https://github.com/K-dash/nblm-rs" 9 | readme = "../../README.md" 10 | keywords = ["notebooklm", "api", "google-cloud", "gemini"] 11 | categories = ["api-bindings"] 12 | authors = ["K-dash"] 13 | 14 | [lib] 15 | doctest = false 16 | 17 | [dependencies] 18 | anyhow = "1.0.100" 19 | async-trait = "0.1.89" 20 | reqwest = { version = "0.12.24", default-features = false, features = [ 21 | "json", 22 | "rustls-tls", 23 | ] } 24 | serde = { version = "1.0.228", features = ["derive"] } 25 | serde_json = "1.0.145" 26 | thiserror = "2.0.17" 27 | tokio = { version = "1.48.0", features = [ 28 | "macros", 29 | "rt-multi-thread", 30 | "process", 31 | "time", 32 | "fs", 33 | ] } 34 | url = "2.5.7" 35 | backon = "1.6.0" 36 | tracing = "0.1.41" 37 | httpdate = "1.0.3" 38 | bytes = "1.7.1" 39 | colored = "3.0.0" 40 | rand = { version = "0.9.2", features = ["std"] } 41 | base64 = "0.22" 42 | time = { version = "0.3", features = ["serde", "parsing", "formatting"] } 43 | directories = "6.0.0" 44 | parking_lot = "0.12" 45 | oauth2 = { version = "5.0", features = ["reqwest"] } 46 | 47 | [features] 48 | default = [] 49 | 50 | [dev-dependencies] 51 | wiremock = "0.6.5" 52 | serial_test = "3.2.0" 53 | rstest = "0.26.1" 54 | tempfile = "3.12.0" 55 | -------------------------------------------------------------------------------- /docs/guides/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | This guide collects the most common issues reported for the NotebookLM Enterprise clients (CLI and Python SDK) and outlines quick checks to resolve them. 4 | 5 | ## Authentication errors 6 | 7 | - Confirm that `GOOGLE_APPLICATION_CREDENTIALS` points to the correct service-account JSON file. 8 | - If you rely on the gcloud CLI, refresh Application Default Credentials with `gcloud auth application-default login`. 9 | - Verify that the notebook region and the project number used for authentication match the resources you are operating against. 10 | 11 | ## 403 or 404 responses from the API 12 | 13 | - Run `nblm-cli doctor` to double-check the `--project-number` and `--location` values. 14 | - Ensure the NotebookLM Enterprise API is enabled for the target project in Cloud Console. 15 | 16 | ## Upload timeouts 17 | 18 | - The CLI uses a default timeout of a few minutes. Increase it with `--timeout-seconds` and re-run the command. 19 | - For the Python SDK, pass a higher `timeout` value to `client.upload_source` and enable the retry policy. 20 | - Consider compressing or splitting very large files before uploading them. 21 | 22 | ## When you need more help 23 | 24 | - Launch commands with `--debug` to capture verbose logs. 25 | - When filing a GitHub Issue, include the command that failed, environment details, and sanitized log snippets (omit sensitive data). 26 | - For contribution guidelines, refer to [CONTRIBUTING.md](https://github.com/K-dash/nblm-rs/blob/main/CONTRIBUTING.md). 27 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/ops/doctor.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::Args; 3 | use colored::Colorize; 4 | use nblm_core::doctor::{ 5 | check_api_connectivity, check_commands, check_drive_access_token, check_environment_variables, 6 | DiagnosticsSummary, 7 | }; 8 | 9 | #[derive(Args)] 10 | pub struct DoctorArgs { 11 | /// Skip the API connectivity check 12 | #[arg(long)] 13 | pub skip_api_check: bool, 14 | } 15 | 16 | pub async fn run(args: DoctorArgs) -> Result<()> { 17 | println!("Running NotebookLM environment diagnostics...\n"); 18 | 19 | // Run all checks 20 | let mut all_checks = Vec::new(); 21 | all_checks.extend(check_environment_variables()); 22 | all_checks.extend(check_drive_access_token().await); 23 | all_checks.extend(check_commands()); 24 | 25 | // Only run API connectivity check if not skipped 26 | if !args.skip_api_check { 27 | all_checks.extend(check_api_connectivity().await); 28 | } 29 | 30 | // Print individual check results 31 | for check in &all_checks { 32 | println!("{}", check.format_colored()); 33 | } 34 | 35 | // Print summary 36 | let summary = DiagnosticsSummary::new(all_checks); 37 | println!("{}", summary.format_summary_colored()); 38 | 39 | // Determine exit behavior 40 | let exit_code = summary.exit_code(); 41 | if exit_code == 0 { 42 | println!( 43 | "\n{}", 44 | "All critical checks passed. You're ready to use nblm.".green() 45 | ); 46 | } 47 | 48 | std::process::exit(exit_code); 49 | } 50 | -------------------------------------------------------------------------------- /crates/nblm-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nblm-cli" 3 | version = "0.2.3" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "Command-line interface for NotebookLM Enterprise API" 7 | repository = "https://github.com/K-dash/nblm-rs" 8 | homepage = "https://github.com/K-dash/nblm-rs" 9 | readme = "../../README.md" 10 | keywords = ["notebooklm", "cli", "google-cloud", "gemini"] 11 | categories = ["command-line-utilities"] 12 | authors = ["K-dash"] 13 | 14 | [[bin]] 15 | name = "nblm" 16 | path = "src/main.rs" 17 | 18 | [dependencies] 19 | anyhow = "1" 20 | clap = { version = "4.5.49", features = ["derive", "env"] } 21 | serde = { version = "1.0.228", features = ["derive"] } 22 | serde_json = "1.0.145" 23 | tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } 24 | async-trait = "0.1.83" 25 | tracing = "0.1.41" 26 | tracing-subscriber = { version = "0.3.20", features = [ 27 | "env-filter", 28 | "fmt", 29 | "json", 30 | ] } 31 | nblm-core = { version = "0.2.3", path = "../nblm-core" } 32 | humantime = "2.3.0" 33 | url = "2.5.7" 34 | mime_guess = "2.0.5" 35 | colored = "3.0.0" 36 | webbrowser = "1.0" 37 | urlencoding = "2.1" 38 | reqwest = { version = "0.12.24", default-features = false, features = [ 39 | "json", 40 | "rustls-tls", 41 | ] } 42 | time = { version = "0.3", features = ["serde", "parsing", "formatting"] } 43 | 44 | [dev-dependencies] 45 | assert_cmd = "2.0.17" 46 | predicates = "3.1.3" 47 | wiremock = "0.6.5" 48 | serial_test = "3.2.0" 49 | tempfile = "3.23.0" 50 | insta = { version = "1.43.2", features = ["json"] } 51 | rstest = "0.26.1" 52 | -------------------------------------------------------------------------------- /python/tests/test_notebooks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for notebook operations 3 | 4 | Note: These tests only verify that the types and interfaces are available. 5 | Integration tests with actual API calls would require: 6 | - Valid Google Cloud credentials 7 | - A real project number 8 | - Network access to NotebookLM API 9 | 10 | For unit testing Rust-backed PyO3 classes, mocking is not feasible. 11 | Consider using integration tests or end-to-end tests instead. 12 | """ 13 | 14 | from nblm import ( 15 | BatchDeleteNotebooksResponse, 16 | EnvTokenProvider, 17 | GcloudTokenProvider, 18 | ListRecentlyViewedResponse, 19 | NblmClient, 20 | Notebook, 21 | ) 22 | 23 | 24 | def test_notebook_type_imports() -> None: 25 | """Test that notebook-related types can be imported""" 26 | 27 | assert Notebook is not None 28 | assert ListRecentlyViewedResponse is not None 29 | assert BatchDeleteNotebooksResponse is not None 30 | 31 | 32 | def test_client_methods_exist() -> None: 33 | """Test that NblmClient has the expected methods""" 34 | 35 | # Verify method signatures exist (without calling them) 36 | assert hasattr(NblmClient, "create_notebook") 37 | assert hasattr(NblmClient, "list_recently_viewed") 38 | assert hasattr(NblmClient, "delete_notebooks") 39 | assert hasattr(NblmClient, "add_sources") 40 | assert hasattr(NblmClient, "delete_sources") 41 | 42 | 43 | def test_token_provider_types() -> None: 44 | """Test that token provider types can be imported""" 45 | 46 | assert GcloudTokenProvider is not None 47 | assert EnvTokenProvider is not None 48 | -------------------------------------------------------------------------------- /python/src/nblm/__init__.pyi: -------------------------------------------------------------------------------- 1 | """Type stubs for nblm Python bindings""" 2 | 3 | from ._auth import ( 4 | DEFAULT_ENV_TOKEN_KEY, 5 | DEFAULT_GCLOUD_BINARY, 6 | EnvTokenProvider, 7 | GcloudTokenProvider, 8 | NblmError, 9 | UserOAuthProvider, 10 | login, 11 | ) 12 | from ._client import NblmClient 13 | from ._models import ( 14 | AudioOverviewRequest, 15 | AudioOverviewResponse, 16 | BatchCreateSourcesResponse, 17 | BatchDeleteNotebooksResponse, 18 | BatchDeleteSourcesResponse, 19 | GoogleDriveSource, 20 | ListRecentlyViewedResponse, 21 | Notebook, 22 | NotebookMetadata, 23 | NotebookSource, 24 | NotebookSourceId, 25 | NotebookSourceMetadata, 26 | NotebookSourceSettings, 27 | NotebookSourceYoutubeMetadata, 28 | TextSource, 29 | UploadSourceFileResponse, 30 | VideoSource, 31 | WebSource, 32 | ) 33 | 34 | __version__: str 35 | 36 | __all__ = [ 37 | "DEFAULT_ENV_TOKEN_KEY", 38 | "DEFAULT_GCLOUD_BINARY", 39 | "AudioOverviewRequest", 40 | "AudioOverviewResponse", 41 | "BatchCreateSourcesResponse", 42 | "BatchDeleteNotebooksResponse", 43 | "BatchDeleteSourcesResponse", 44 | "EnvTokenProvider", 45 | "GcloudTokenProvider", 46 | "GoogleDriveSource", 47 | "ListRecentlyViewedResponse", 48 | "NblmClient", 49 | "NblmError", 50 | "Notebook", 51 | "NotebookMetadata", 52 | "NotebookSource", 53 | "NotebookSourceId", 54 | "NotebookSourceMetadata", 55 | "NotebookSourceSettings", 56 | "NotebookSourceYoutubeMetadata", 57 | "TextSource", 58 | "UploadSourceFileResponse", 59 | "UserOAuthProvider", 60 | "VideoSource", 61 | "WebSource", 62 | "login", 63 | ] 64 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/_helpers/cmd.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | 3 | /// Create a Command for the nblm CLI binary with common setup 4 | pub fn nblm() -> Command { 5 | Command::new(assert_cmd::cargo::cargo_bin!("nblm")) 6 | } 7 | 8 | /// Common arguments for all CLI tests 9 | pub struct CommonArgs { 10 | pub project_number: String, 11 | pub location: String, 12 | pub endpoint_location: String, 13 | pub auth: String, 14 | pub token: String, 15 | } 16 | 17 | impl Default for CommonArgs { 18 | fn default() -> Self { 19 | Self { 20 | project_number: "123456".to_string(), 21 | location: "global".to_string(), 22 | endpoint_location: "us".to_string(), 23 | auth: "env".to_string(), 24 | token: "DUMMY_TOKEN".to_string(), 25 | } 26 | } 27 | } 28 | 29 | impl CommonArgs { 30 | pub fn apply(&self, cmd: &mut Command) { 31 | cmd.args([ 32 | "--project-number", 33 | &self.project_number, 34 | "--location", 35 | &self.location, 36 | "--endpoint-location", 37 | &self.endpoint_location, 38 | "--auth", 39 | &self.auth, 40 | "--token", 41 | &self.token, 42 | ]); 43 | } 44 | 45 | // Some integration tests reuse this helper while others do not; keep the helper available 46 | // without tripping per-test dead_code lints. 47 | #[allow(dead_code)] 48 | pub fn with_base_url(&self, cmd: &mut Command, base_url: &str) { 49 | self.apply(cmd); 50 | cmd.args(["--base-url", base_url]); 51 | // Enable fast retry for tests 52 | cmd.env("NBLM_RETRY_FAST", "1"); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /crates/nblm-core/src/auth/oauth/testing.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod fake { 3 | use serde_json::json; 4 | use wiremock::matchers::{method, path}; 5 | use wiremock::{Mock, MockServer, ResponseTemplate}; 6 | 7 | /// Helper struct that spins up a fake OAuth server for tests. 8 | pub struct FakeOAuthServer { 9 | mock_server: MockServer, 10 | } 11 | 12 | impl FakeOAuthServer { 13 | /// Start the fake server with default token and revocation endpoints. 14 | pub async fn start() -> Self { 15 | let mock_server = MockServer::start().await; 16 | 17 | Mock::given(method("POST")) 18 | .and(path("/token")) 19 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 20 | "access_token": "fake_access_token", 21 | "refresh_token": "fake_refresh_token", 22 | "token_type": "Bearer", 23 | "expires_in": 3600 24 | }))) 25 | .mount(&mock_server) 26 | .await; 27 | 28 | Mock::given(method("POST")) 29 | .and(path("/revoke")) 30 | .respond_with(ResponseTemplate::new(200)) 31 | .mount(&mock_server) 32 | .await; 33 | 34 | Self { mock_server } 35 | } 36 | 37 | pub fn token_endpoint(&self) -> String { 38 | format!("{}/token", self.mock_server.uri()) 39 | } 40 | 41 | pub fn revoke_endpoint(&self) -> String { 42 | format!("{}/revoke", self.mock_server.uri()) 43 | } 44 | 45 | pub fn base_uri(&self) -> String { 46 | self.mock_server.uri() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /python/src/nblm/_auth.pyi: -------------------------------------------------------------------------------- 1 | """Authentication providers for nblm""" 2 | 3 | DEFAULT_GCLOUD_BINARY: str 4 | DEFAULT_ENV_TOKEN_KEY: str 5 | 6 | def login(drive_access: bool = False, force: bool = False) -> None: 7 | """ 8 | Log in via Google Cloud SDK (gcloud auth login). 9 | 10 | Args: 11 | drive_access: If True, requests Google Drive access. 12 | force: If True, forces re-authentication. 13 | """ 14 | 15 | class NblmError(Exception): 16 | """Base exception for nblm errors""" 17 | 18 | class GcloudTokenProvider: 19 | """Token provider that uses gcloud CLI for authentication""" 20 | 21 | def __init__(self, binary: str = DEFAULT_GCLOUD_BINARY) -> None: 22 | """ 23 | Create a new GcloudTokenProvider 24 | 25 | Args: 26 | binary: Path to gcloud binary (default: DEFAULT_GCLOUD_BINARY) 27 | """ 28 | 29 | class EnvTokenProvider: 30 | """Token provider that reads access token from environment variable""" 31 | 32 | def __init__(self, key: str = DEFAULT_ENV_TOKEN_KEY) -> None: 33 | """ 34 | Create a new EnvTokenProvider 35 | 36 | Args: 37 | key: Environment variable name (default: DEFAULT_ENV_TOKEN_KEY) 38 | """ 39 | 40 | class UserOAuthProvider: 41 | """Token provider that reuses refresh tokens created via the CLI's user-oauth flow""" 42 | 43 | @staticmethod 44 | def from_file( 45 | project_number: int | None = ..., 46 | location: str = "global", 47 | user: str | None = ..., 48 | endpoint_location: str | None = ..., 49 | ) -> UserOAuthProvider: 50 | """Load refresh tokens from the shared credentials file.""" 51 | 52 | @property 53 | def endpoint_location(self) -> str: 54 | """Return the endpoint location associated with the stored token.""" 55 | 56 | TokenProvider = GcloudTokenProvider | EnvTokenProvider | UserOAuthProvider 57 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/responses/list.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use super::super::notebook::Notebook; 4 | 5 | /// Response from list recently viewed notebooks API. 6 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct ListRecentlyViewedResponse { 9 | #[serde(default)] 10 | pub notebooks: Vec, 11 | } 12 | 13 | #[cfg(test)] 14 | mod tests { 15 | use super::*; 16 | 17 | #[test] 18 | fn list_recently_viewed_response_deserializes_correctly() { 19 | let json = r#"{ 20 | "notebooks": [ 21 | {"title": "Notebook 1", "name": "notebooks/123"}, 22 | {"title": "Notebook 2", "name": "notebooks/456"} 23 | ] 24 | }"#; 25 | let response: ListRecentlyViewedResponse = serde_json::from_str(json).unwrap(); 26 | assert_eq!(response.notebooks.len(), 2); 27 | assert_eq!(response.notebooks[0].title, "Notebook 1"); 28 | assert_eq!(response.notebooks[1].title, "Notebook 2"); 29 | } 30 | 31 | #[test] 32 | fn list_recently_viewed_response_deserializes_empty() { 33 | let json = r#"{}"#; 34 | let response: ListRecentlyViewedResponse = serde_json::from_str(json).unwrap(); 35 | assert_eq!(response.notebooks.len(), 0); 36 | } 37 | 38 | #[test] 39 | fn list_recently_viewed_response_serializes_correctly() { 40 | let response = ListRecentlyViewedResponse { 41 | notebooks: vec![Notebook { 42 | name: Some("notebooks/123".to_string()), 43 | title: "Test Notebook".to_string(), 44 | ..Default::default() 45 | }], 46 | }; 47 | let json = serde_json::to_string(&response).unwrap(); 48 | assert!(json.contains("notebooks")); 49 | assert!(json.contains("Test Notebook")); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - README.md 9 | - CONTRIBUTING.md 10 | pull_request: 11 | branches: 12 | - main 13 | paths-ignore: 14 | - README.md 15 | - CONTRIBUTING.md 16 | 17 | env: 18 | CARGO_TERM_COLOR: always 19 | RUSTFLAGS: -Cinstrument-coverage 20 | 21 | jobs: 22 | coverage: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 27 | 28 | - name: Install toolchain 29 | uses: dtolnay/rust-toolchain@stable 30 | 31 | - name: Install cargo-llvm-cov 32 | uses: taiki-e/install-action@e43a5023a747770bfcb71ae048541a681714b951 # v2.62.33 33 | with: 34 | tool: cargo-llvm-cov 35 | 36 | - name: Cache cargo registry 37 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 38 | with: 39 | path: | 40 | ~/.cargo/registry 41 | ~/.cargo/git 42 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 43 | restore-keys: ${{ runner.os }}-cargo- 44 | 45 | - name: Cache target 46 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 47 | with: 48 | path: target 49 | key: ${{ runner.os }}-coverage-target-${{ hashFiles('**/Cargo.lock') }} 50 | restore-keys: ${{ runner.os }}-coverage-target- 51 | 52 | - name: Run coverage 53 | run: cargo llvm-cov --workspace --all-features --lcov --output-path lcov.info 54 | 55 | - name: Upload coverage to Codecov 56 | uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 57 | with: 58 | token: ${{ secrets.CODECOV_TOKEN }} 59 | files: lcov.info 60 | fail_ci_if_error: true 61 | flags: rust 62 | 63 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/errors_retry.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn retry_429_then_success() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | 13 | // Configure mock to return 429 twice, then 200 14 | mock.stub_notebooks_recent_429_then_success(&args.project_number, &args.location, 2) 15 | .await; 16 | 17 | let mut cmd = _helpers::cmd::nblm(); 18 | args.with_base_url(&mut cmd, &mock.base_url()); 19 | cmd.args(["notebooks", "recent"]); 20 | 21 | cmd.assert() 22 | .success() 23 | .stdout(predicate::str::contains("notebooks")); 24 | } 25 | 26 | #[tokio::test] 27 | #[serial] 28 | async fn retry_429_exhausted() { 29 | let mock = MockApi::start().await; 30 | let args = CommonArgs::default(); 31 | 32 | // Configure mock to always return 429 33 | mock.stub_notebooks_recent_persistent_429(&args.project_number, &args.location) 34 | .await; 35 | 36 | let mut cmd = _helpers::cmd::nblm(); 37 | args.with_base_url(&mut cmd, &mock.base_url()); 38 | cmd.args(["notebooks", "recent"]); 39 | 40 | cmd.assert() 41 | .failure() 42 | .stderr(predicate::str::contains("Too Many Requests")); 43 | } 44 | 45 | #[tokio::test] 46 | #[serial] 47 | async fn retry_401_then_success() { 48 | let mock = MockApi::start().await; 49 | let args = CommonArgs::default(); 50 | 51 | // Configure mock to return 401 once, then 200 (simulates token refresh) 52 | mock.stub_notebooks_recent_401_then_success(&args.project_number, &args.location) 53 | .await; 54 | 55 | let mut cmd = _helpers::cmd::nblm(); 56 | args.with_base_url(&mut cmd, &mock.base_url()); 57 | cmd.args(["notebooks", "recent"]); 58 | 59 | cmd.assert() 60 | .success() 61 | .stdout(predicate::str::contains("notebooks")); 62 | } 63 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/requests/audio.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// Audio Overview creation request. 4 | /// 5 | /// # Known Issues (as of 2025-10-19) 6 | /// 7 | /// Despite the API documentation mentioning fields like `sourceIds`, `episodeFocus`, 8 | /// and `languageCode`, the actual API only accepts an empty request body `{}`. 9 | /// Any fields sent result in "Unknown name" errors. 10 | /// These configuration options are likely set through the NotebookLM UI after creation. 11 | /// 12 | /// The fields below are commented out but kept for future compatibility if the API 13 | /// implements them. 14 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 15 | pub struct AudioOverviewRequest { 16 | // TODO: Uncomment when API supports these fields 17 | // #[serde(skip_serializing_if = "Option::is_none", rename = "sourceIds")] 18 | // pub source_ids: Option>, 19 | // #[serde(skip_serializing_if = "Option::is_none", rename = "episodeFocus")] 20 | // pub episode_focus: Option, 21 | // #[serde(skip_serializing_if = "Option::is_none", rename = "languageCode")] 22 | // pub language_code: Option, 23 | } 24 | 25 | // TODO: Uncomment when API supports sourceIds field 26 | // #[derive(Debug, Clone, Serialize, Deserialize)] 27 | // pub struct SourceId { 28 | // pub id: String, 29 | // } 30 | 31 | #[cfg(test)] 32 | mod tests { 33 | use super::*; 34 | 35 | #[test] 36 | fn audio_overview_request_serializes_to_empty_object() { 37 | let request = AudioOverviewRequest::default(); 38 | let json = serde_json::to_string(&request).unwrap(); 39 | assert_eq!(json, "{}"); 40 | } 41 | 42 | #[test] 43 | fn audio_overview_request_deserializes_from_empty_object() { 44 | let json = r#"{}"#; 45 | let request: AudioOverviewRequest = serde_json::from_str(json).unwrap(); 46 | let _ = request; // Verify it deserializes successfully 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/notebooks_delete.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn notebooks_delete_single() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | 13 | mock.stub_notebooks_batch_delete(&args.project_number, &args.location) 14 | .await; 15 | 16 | let notebook_name = format!( 17 | "projects/{}/locations/{}/notebooks/test-nb-123", 18 | args.project_number, args.location 19 | ); 20 | 21 | let mut cmd = _helpers::cmd::nblm(); 22 | args.with_base_url(&mut cmd, &mock.base_url()); 23 | cmd.args(["notebooks", "delete", "--notebook-name", ¬ebook_name]); 24 | 25 | cmd.assert().success().stdout(predicate::str::contains( 26 | "Deleted 1 notebook(s) successfully", 27 | )); 28 | } 29 | 30 | #[tokio::test] 31 | #[serial] 32 | async fn notebooks_delete_multiple() { 33 | let mock = MockApi::start().await; 34 | let args = CommonArgs::default(); 35 | 36 | // Mock will be called twice (sequential deletion due to API limitation) 37 | mock.stub_notebooks_batch_delete(&args.project_number, &args.location) 38 | .await; 39 | 40 | let notebook_name1 = format!( 41 | "projects/{}/locations/{}/notebooks/test-nb-1", 42 | args.project_number, args.location 43 | ); 44 | let notebook_name2 = format!( 45 | "projects/{}/locations/{}/notebooks/test-nb-2", 46 | args.project_number, args.location 47 | ); 48 | 49 | let mut cmd = _helpers::cmd::nblm(); 50 | args.with_base_url(&mut cmd, &mock.base_url()); 51 | cmd.args([ 52 | "notebooks", 53 | "delete", 54 | "--notebook-name", 55 | ¬ebook_name1, 56 | "--notebook-name", 57 | ¬ebook_name2, 58 | ]); 59 | 60 | cmd.assert().success().stdout(predicate::str::contains( 61 | "Deleted 2 notebook(s) successfully", 62 | )); 63 | } 64 | -------------------------------------------------------------------------------- /python/src/nblm/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | NotebookLM Enterprise API client for Python 3 | 4 | This package provides Python bindings for the NotebookLM Enterprise API. 5 | """ 6 | 7 | from importlib.metadata import PackageNotFoundError, version 8 | from typing import TYPE_CHECKING 9 | 10 | from .nblm import ( 11 | DEFAULT_ENV_TOKEN_KEY, 12 | DEFAULT_GCLOUD_BINARY, 13 | AudioOverviewRequest, 14 | AudioOverviewResponse, 15 | BatchCreateSourcesResponse, 16 | BatchDeleteNotebooksResponse, 17 | BatchDeleteSourcesResponse, 18 | EnvTokenProvider, 19 | GcloudTokenProvider, 20 | GoogleDriveSource, 21 | ListRecentlyViewedResponse, 22 | NblmClient, 23 | NblmError, 24 | Notebook, 25 | NotebookMetadata, 26 | NotebookSource, 27 | NotebookSourceId, 28 | NotebookSourceMetadata, 29 | NotebookSourceSettings, 30 | NotebookSourceYoutubeMetadata, 31 | TextSource, 32 | UploadSourceFileResponse, 33 | UserOAuthProvider, 34 | VideoSource, 35 | WebSource, 36 | login, 37 | ) 38 | 39 | try: 40 | __version__ = version("nblm") 41 | except PackageNotFoundError: 42 | # Package metadata not available (running from source without installation) 43 | __version__ = "0.0.0" 44 | 45 | __all__ = [ 46 | "DEFAULT_ENV_TOKEN_KEY", 47 | "DEFAULT_GCLOUD_BINARY", 48 | "AudioOverviewRequest", 49 | "AudioOverviewResponse", 50 | "BatchCreateSourcesResponse", 51 | "BatchDeleteNotebooksResponse", 52 | "BatchDeleteSourcesResponse", 53 | "EnvTokenProvider", 54 | "GcloudTokenProvider", 55 | "GoogleDriveSource", 56 | "ListRecentlyViewedResponse", 57 | "NblmClient", 58 | "NblmError", 59 | "Notebook", 60 | "NotebookMetadata", 61 | "NotebookSource", 62 | "NotebookSourceId", 63 | "NotebookSourceMetadata", 64 | "NotebookSourceSettings", 65 | "NotebookSourceYoutubeMetadata", 66 | "TextSource", 67 | "UploadSourceFileResponse", 68 | "UserOAuthProvider", 69 | "VideoSource", 70 | "WebSource", 71 | "login", 72 | ] 73 | -------------------------------------------------------------------------------- /.github/workflows/rust-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Rust Crates 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "Tag version (e.g., v0.1.3)" 11 | required: false 12 | type: string 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | jobs: 18 | publish: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 23 | 24 | - name: Install toolchain 25 | uses: dtolnay/rust-toolchain@stable 26 | 27 | - name: Verify tag matches crate version 28 | id: verify 29 | run: | 30 | if [ -n "${{ inputs.tag }}" ]; then 31 | TAG_VERSION="${{ inputs.tag }}" 32 | TAG_VERSION="${TAG_VERSION#v}" 33 | else 34 | TAG_VERSION="${GITHUB_REF_NAME#v}" 35 | fi 36 | CLI_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name=="nblm-cli") | .version') 37 | CORE_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name=="nblm-core") | .version') 38 | echo "Tag version: ${TAG_VERSION}" 39 | echo "nblm-core version: ${CORE_VERSION}" 40 | echo "nblm-cli version: ${CLI_VERSION}" 41 | if [ "${TAG_VERSION}" != "${CORE_VERSION}" ] || [ "${TAG_VERSION}" != "${CLI_VERSION}" ]; then 42 | echo "::error::Tag version (${TAG_VERSION}) must match crate versions (nblm-core=${CORE_VERSION}, nblm-cli=${CLI_VERSION})" 43 | exit 1 44 | fi 45 | 46 | - name: Publish nblm-core 47 | run: cargo publish -p nblm-core --locked 48 | env: 49 | CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} 50 | 51 | - name: Wait for crate index update 52 | run: sleep 30 53 | 54 | - name: Publish nblm-cli 55 | run: cargo publish -p nblm-cli --locked 56 | env: 57 | CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} 58 | -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Usage: ./scripts/bump-version.sh 5 | 6 | # Fetch latest tags from remote 7 | echo "Fetching latest tags from remote..." 8 | git fetch --tags --quiet 9 | 10 | # Get the latest tag version 11 | LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -n 1) 12 | if [ -z "$LATEST_TAG" ]; then 13 | echo "No existing tags found." 14 | LATEST_VERSION="none" 15 | else 16 | LATEST_VERSION="${LATEST_TAG#v}" 17 | echo "Current latest tag: ${LATEST_TAG} (${LATEST_VERSION})" 18 | fi 19 | 20 | # Prompt for new version 21 | echo "" 22 | read -p "Enter new version: " NEW_VERSION 23 | 24 | if [ -z "$NEW_VERSION" ]; then 25 | echo "Error: Version cannot be empty" 26 | exit 1 27 | fi 28 | 29 | echo "Bumping version to ${NEW_VERSION}..." 30 | 31 | # Rust crates 32 | echo "Updating Rust crates..." 33 | sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" Cargo.toml 34 | sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" crates/nblm-core/Cargo.toml 35 | sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" crates/nblm-cli/Cargo.toml 36 | sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" crates/nblm-python/Cargo.toml 37 | 38 | # Update nblm-core dependency version in nblm-cli 39 | echo "Updating nblm-core dependency in nblm-cli..." 40 | sed -i.bak "s/^nblm-core = { version = \"[^\"]*\"/nblm-core = { version = \"${NEW_VERSION}\"/" crates/nblm-cli/Cargo.toml 41 | 42 | # Python package 43 | echo "Updating Python package..." 44 | sed -i.bak "s/^version = \".*\"/version = \"${NEW_VERSION}\"/" python/pyproject.toml 45 | (cd python && uv sync) 46 | 47 | # Clean up backup files 48 | find . -name "*.bak" -delete 49 | 50 | # Update Cargo.lock 51 | echo "Updating Cargo.lock..." 52 | cargo check --quiet 53 | 54 | echo "✅ Version bumped to ${NEW_VERSION}" 55 | echo "" 56 | echo "Next steps:" 57 | echo " 1. Review changes: git diff" 58 | echo " 2. Commit: git add -A && git commit -m 'chore: bump version to ${NEW_VERSION}'" 59 | echo " 3. Tag: git tag v${NEW_VERSION}" 60 | echo " 4. Push: git push origin main && git push origin v${NEW_VERSION}" 61 | 62 | -------------------------------------------------------------------------------- /docs/cli/auth.md: -------------------------------------------------------------------------------- 1 | # Auth Commands 2 | 3 | Commands for managing authentication with Google Cloud. 4 | 5 | ## Overview 6 | 7 | The `auth` command simplifies the authentication process by wrapping the Google Cloud SDK (`gcloud`) authentication flow. It allows you to log in and check your authentication status directly from the `nblm` CLI. 8 | 9 | ## Commands 10 | 11 | ### `login` 12 | 13 | Log in to Google Cloud using the `gcloud` CLI. This command opens a browser window to authenticate with your Google account. 14 | 15 | ```bash 16 | nblm auth login [OPTIONS] 17 | ``` 18 | 19 | **Options:** 20 | 21 | - `--drive-access`: Request Google Drive access. This adds the `https://www.googleapis.com/auth/drive` scope to your credentials, which is required for notebooks that access Drive files. 22 | 23 | **Behavior:** 24 | 25 | 1. Executes `gcloud auth login` (with `--enable-gdrive-access` if requested). 26 | 2. Opens your default web browser for Google authentication. 27 | 3. Saves credentials to the standard `gcloud` configuration location. 28 | 29 | **Exit Codes:** 30 | 31 | - `0`: Authentication successful. 32 | - `1`: Authentication failed (e.g., user cancelled, network error). 33 | 34 | ### `status` 35 | 36 | Check the current authentication status. 37 | 38 | ```bash 39 | nblm auth status 40 | ``` 41 | 42 | **Output (Authenticated):** 43 | 44 | ```text 45 | Authenticated 46 | Account: user@example.com 47 | Backend: gcloud 48 | ``` 49 | 50 | **Output (Not Authenticated):** 51 | 52 | ```text 53 | Not authenticated. 54 | Run 'nblm auth login' to log in. 55 | ``` 56 | 57 | **Exit Codes:** 58 | 59 | - `0`: User is authenticated. 60 | - `1`: User is NOT authenticated. 61 | 62 | ## Examples 63 | 64 | ### Initial Setup 65 | 66 | ```bash 67 | # 1. Log in 68 | nblm auth login 69 | 70 | # 2. Verify status 71 | nblm auth status 72 | 73 | # 3. Start using nblm 74 | nblm notebooks recent 75 | ``` 76 | 77 | ### Scripting 78 | 79 | You can use the exit code of `nblm auth status` to check if the user is logged in before running other commands. 80 | 81 | ```bash 82 | if ! nblm auth status > /dev/null 2>&1; then 83 | echo "Please log in first." 84 | exit 1 85 | fi 86 | 87 | nblm notebooks recent 88 | ``` 89 | -------------------------------------------------------------------------------- /crates/nblm-python/src/lib.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | 3 | mod auth; 4 | mod client; 5 | mod error; 6 | mod models; 7 | mod runtime; 8 | 9 | pub use auth::{ 10 | login, EnvTokenProvider, GcloudTokenProvider, TokenProvider, UserOAuthProvider, 11 | DEFAULT_ENV_TOKEN_KEY, DEFAULT_GCLOUD_BINARY, 12 | }; 13 | pub use client::NblmClient; 14 | pub use error::NblmError; 15 | pub use models::{ 16 | AudioOverviewRequest, AudioOverviewResponse, BatchCreateSourcesResponse, 17 | BatchDeleteNotebooksResponse, BatchDeleteSourcesResponse, GoogleDriveSource, 18 | ListRecentlyViewedResponse, Notebook, NotebookMetadata, NotebookSource, NotebookSourceId, 19 | NotebookSourceMetadata, NotebookSourceSettings, NotebookSourceYoutubeMetadata, TextSource, 20 | UploadSourceFileResponse, VideoSource, WebSource, 21 | }; 22 | 23 | /// NotebookLM Enterprise API client for Python 24 | #[pymodule] 25 | fn nblm(m: &Bound<'_, PyModule>) -> PyResult<()> { 26 | m.add_function(wrap_pyfunction!(auth::login, m)?)?; 27 | m.add_class::()?; 28 | m.add_class::()?; 29 | m.add_class::()?; 30 | m.add_class::()?; 31 | m.add_class::()?; 32 | m.add_class::()?; 33 | m.add_class::()?; 34 | m.add_class::()?; 35 | m.add_class::()?; 36 | m.add_class::()?; 37 | m.add_class::()?; 38 | m.add_class::()?; 39 | m.add_class::()?; 40 | m.add_class::()?; 41 | m.add_class::()?; 42 | m.add_class::()?; 43 | m.add_class::()?; 44 | m.add_class::()?; 45 | m.add_class::()?; 46 | m.add_class::()?; 47 | m.add_class::()?; 48 | m.add_class::()?; 49 | m.add("NblmError", m.py().get_type::())?; 50 | m.add("DEFAULT_GCLOUD_BINARY", DEFAULT_GCLOUD_BINARY)?; 51 | m.add("DEFAULT_ENV_TOKEN_KEY", DEFAULT_ENV_TOKEN_KEY)?; 52 | 53 | Ok(()) 54 | } 55 | -------------------------------------------------------------------------------- /crates/nblm-core/src/models/enterprise/notebook.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Value; 5 | 6 | use super::source::NotebookSource; 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct Notebook { 11 | pub name: Option, 12 | pub title: String, 13 | #[serde(rename = "notebookId", skip_serializing_if = "Option::is_none")] 14 | pub notebook_id: Option, 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub emoji: Option, 17 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 18 | pub sources: Vec, 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub metadata: Option, 21 | #[serde(flatten)] 22 | pub extra: HashMap, 23 | } 24 | 25 | #[derive(Debug, Clone, Serialize, Deserialize)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct NotebookRef { 28 | pub notebook_id: String, 29 | pub name: String, 30 | } 31 | 32 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 33 | #[serde(rename_all = "camelCase")] 34 | pub struct NotebookMetadata { 35 | #[serde(skip_serializing_if = "Option::is_none")] 36 | pub create_time: Option, 37 | #[serde(skip_serializing_if = "Option::is_none")] 38 | pub is_shareable: Option, 39 | #[serde(skip_serializing_if = "Option::is_none")] 40 | pub is_shared: Option, 41 | #[serde(skip_serializing_if = "Option::is_none")] 42 | pub last_viewed: Option, 43 | #[serde(flatten)] 44 | pub extra: HashMap, 45 | } 46 | 47 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 48 | pub struct BatchDeleteNotebooksRequest { 49 | pub names: Vec, 50 | } 51 | 52 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 53 | pub struct BatchDeleteNotebooksResponse { 54 | #[serde(flatten)] 55 | pub extra: HashMap, 56 | } 57 | 58 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 59 | #[serde(rename_all = "camelCase")] 60 | pub struct ListRecentlyViewedResponse { 61 | #[serde(default)] 62 | pub notebooks: Vec, 63 | } 64 | -------------------------------------------------------------------------------- /crates/nblm-python/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pyo3::types::{PyBool, PyDict, PyFloat, PyList, PyNone, PyString}; 3 | use pyo3::IntoPyObject; 4 | use serde_json::Value; 5 | use std::collections::HashMap; 6 | 7 | use crate::error::PyResult; 8 | 9 | mod audio; 10 | mod notebook; 11 | mod notebook_source; 12 | mod responses; 13 | mod source; 14 | 15 | pub use audio::*; 16 | pub use notebook::*; 17 | pub use notebook_source::*; 18 | pub use responses::*; 19 | pub use source::*; 20 | 21 | /// Convert `serde_json::Value` to a Python object. 22 | pub(crate) fn json_value_to_py(py: Python, value: &Value) -> PyResult> { 23 | Ok(match value { 24 | Value::Null => PyNone::get(py).to_owned().into_any().unbind(), 25 | Value::Bool(b) => PyBool::new(py, *b).to_owned().into_any().unbind(), 26 | Value::Number(n) => { 27 | if let Some(i) = n.as_i64() { 28 | i.into_pyobject(py)?.into_any().unbind() 29 | } else if let Some(u) = n.as_u64() { 30 | u.into_pyobject(py)?.into_any().unbind() 31 | } else if let Some(f) = n.as_f64() { 32 | PyFloat::new(py, f).into_any().unbind() 33 | } else { 34 | PyString::new(py, &n.to_string()).into_any().unbind() 35 | } 36 | } 37 | Value::String(s) => PyString::new(py, s).into_any().unbind(), 38 | Value::Array(arr) => { 39 | let list = PyList::empty(py); 40 | for item in arr { 41 | list.append(json_value_to_py(py, item)?)?; 42 | } 43 | list.into_any().unbind() 44 | } 45 | Value::Object(map) => { 46 | let dict = PyDict::new(py); 47 | for (k, v) in map { 48 | dict.set_item(k, json_value_to_py(py, v)?)?; 49 | } 50 | dict.into_any().unbind() 51 | } 52 | }) 53 | } 54 | 55 | /// Convert `HashMap` to `PyDict`. 56 | pub(crate) fn extra_to_pydict(py: Python, extra: &HashMap) -> PyResult> { 57 | let dict = PyDict::new(py); 58 | for (k, v) in extra { 59 | dict.set_item(k, json_value_to_py(py, v)?)?; 60 | } 61 | Ok(dict.unbind()) 62 | } 63 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/sources_delete.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use predicates::prelude::*; 5 | use serial_test::serial; 6 | 7 | #[tokio::test] 8 | #[serial] 9 | async fn sources_delete_single() { 10 | let mock = MockApi::start().await; 11 | let args = CommonArgs::default(); 12 | let notebook_id = "test-notebook-id"; 13 | 14 | mock.stub_sources_batch_delete(&args.project_number, &args.location, notebook_id) 15 | .await; 16 | 17 | let source_name = format!( 18 | "projects/{}/locations/{}/notebooks/{}/sources/src-123", 19 | args.project_number, args.location, notebook_id 20 | ); 21 | 22 | let mut cmd = _helpers::cmd::nblm(); 23 | args.with_base_url(&mut cmd, &mock.base_url()); 24 | cmd.args([ 25 | "sources", 26 | "delete", 27 | "--notebook-id", 28 | notebook_id, 29 | "--source-name", 30 | &source_name, 31 | ]); 32 | 33 | cmd.assert() 34 | .success() 35 | .stdout(predicate::str::contains("Deleted 1 source(s) successfully")); 36 | } 37 | 38 | #[tokio::test] 39 | #[serial] 40 | async fn sources_delete_multiple() { 41 | let mock = MockApi::start().await; 42 | let args = CommonArgs::default(); 43 | let notebook_id = "test-notebook-id"; 44 | 45 | mock.stub_sources_batch_delete(&args.project_number, &args.location, notebook_id) 46 | .await; 47 | 48 | let source_name1 = format!( 49 | "projects/{}/locations/{}/notebooks/{}/sources/src-1", 50 | args.project_number, args.location, notebook_id 51 | ); 52 | let source_name2 = format!( 53 | "projects/{}/locations/{}/notebooks/{}/sources/src-2", 54 | args.project_number, args.location, notebook_id 55 | ); 56 | 57 | let mut cmd = _helpers::cmd::nblm(); 58 | args.with_base_url(&mut cmd, &mock.base_url()); 59 | cmd.args([ 60 | "sources", 61 | "delete", 62 | "--notebook-id", 63 | notebook_id, 64 | "--source-name", 65 | &source_name1, 66 | "--source-name", 67 | &source_name2, 68 | ]); 69 | 70 | cmd.assert() 71 | .success() 72 | .stdout(predicate::str::contains("Deleted 2 source(s) successfully")); 73 | } 74 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/ops/notebooks.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Args, Subcommand}; 3 | use nblm_core::NblmClient; 4 | 5 | use crate::util::io::{emit_notebook, emit_recent}; 6 | 7 | #[derive(Subcommand)] 8 | pub enum Command { 9 | Create(CreateArgs), 10 | Recent(RecentArgs), 11 | Delete(DeleteArgs), 12 | } 13 | 14 | #[derive(Args)] 15 | pub struct CreateArgs { 16 | #[arg(long)] 17 | pub title: String, 18 | } 19 | 20 | #[derive(Args)] 21 | pub struct RecentArgs { 22 | /// Page size for pagination (1-500, default: 500) 23 | #[arg(long)] 24 | pub page_size: Option, 25 | } 26 | 27 | #[derive(Args)] 28 | pub struct DeleteArgs { 29 | /// Full notebook resource name (e.g., projects/PROJECT_NUMBER/locations/LOCATION/notebooks/NOTEBOOK_ID). 30 | /// Can be specified multiple times. Note: API limitation requires sequential deletion (one at a time). 31 | #[arg(long = "notebook-name", value_name = "NAME", required = true)] 32 | pub notebook_names: Vec, 33 | } 34 | 35 | pub async fn run(cmd: Command, client: &NblmClient, json_mode: bool) -> Result<()> { 36 | match cmd { 37 | Command::Create(args) => { 38 | let notebook = client.create_notebook(args.title).await?; 39 | emit_notebook(¬ebook, json_mode); 40 | } 41 | Command::Recent(args) => { 42 | let response = client.list_recently_viewed(args.page_size).await?; 43 | emit_recent(&response, json_mode)?; 44 | } 45 | Command::Delete(args) => { 46 | let response = client.delete_notebooks(args.notebook_names.clone()).await?; 47 | if !json_mode { 48 | println!( 49 | "Deleted {} notebook(s) successfully", 50 | args.notebook_names.len() 51 | ); 52 | } else { 53 | use serde_json::json; 54 | crate::util::io::emit_json( 55 | json!({ 56 | "status": "deleted", 57 | "count": args.notebook_names.len(), 58 | "response": response 59 | }), 60 | json_mode, 61 | ); 62 | } 63 | } 64 | } 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: NBLM Docs 2 | site_description: Official documentation for the NotebookLM Enterprise client 3 | site_url: https://k-dash.github.io/nblm-rs/ 4 | repo_url: https://github.com/K-dash/nblm-rs 5 | repo_name: K-dash/nblm-rs 6 | docs_dir: docs 7 | theme: 8 | name: material 9 | language: en 10 | icon: 11 | logo: material/notebook 12 | repo: fontawesome/brands/github 13 | features: 14 | - navigation.instant 15 | - content.code.copy 16 | - navigation.expand 17 | palette: 18 | - scheme: slate 19 | primary: brown 20 | accent: yellow 21 | toggle: 22 | icon: material/weather-sunny 23 | name: Switch to light mode 24 | default: true 25 | - scheme: default 26 | primary: brown 27 | accent: yellow 28 | toggle: 29 | icon: material/weather-night 30 | name: Switch to dark mode 31 | plugins: 32 | - search 33 | markdown_extensions: 34 | - admonition 35 | - attr_list 36 | - md_in_html 37 | - pymdownx.details 38 | - pymdownx.highlight 39 | - pymdownx.inlinehilite 40 | - pymdownx.superfences 41 | - pymdownx.tabbed: 42 | alternate_style: true 43 | - toc: 44 | permalink: true 45 | - pymdownx.emoji: 46 | emoji_index: !!python/name:material.extensions.emoji.twemoji 47 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 48 | nav: 49 | - Home: index.md 50 | - Getting Started: 51 | - Installation: getting-started/installation.md 52 | - Authentication: getting-started/authentication.md 53 | - Configuration: getting-started/configuration.md 54 | - CLI: 55 | - Overview: cli/README.md 56 | - Notebooks: cli/notebooks.md 57 | - Sources: cli/sources.md 58 | - Audio: cli/audio.md 59 | - Share: cli/share.md 60 | - Doctor: cli/doctor.md 61 | - Python SDK: 62 | - Overview: python/README.md 63 | - Quickstart: python/quickstart.md 64 | - API Reference: python/api-reference.md 65 | - Source Management: python/sources.md 66 | - Notebooks: python/notebooks.md 67 | - Audio: python/audio.md 68 | - Error Handling: python/error-handling.md 69 | - Rust: 70 | - Getting Started: rust/getting-started.md 71 | - Guides: 72 | - Troubleshooting: guides/troubleshooting.md 73 | - API: 74 | - Limitations: api/limitations.md 75 | -------------------------------------------------------------------------------- /.github/workflows/rust-ci.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - README.md 9 | - CONTRIBUTING.md 10 | - docs/** 11 | pull_request: 12 | branches: 13 | - main 14 | paths-ignore: 15 | - README.md 16 | - CONTRIBUTING.md 17 | - docs/** 18 | env: 19 | CARGO_TERM_COLOR: always 20 | 21 | jobs: 22 | checks: 23 | runs-on: ${{ matrix.os }} 24 | strategy: 25 | matrix: 26 | include: 27 | - os: ubuntu-latest 28 | target: x86_64-unknown-linux-gnu 29 | use-cross: false 30 | - os: ubuntu-latest 31 | target: aarch64-unknown-linux-gnu 32 | use-cross: true 33 | - os: macos-latest 34 | target: x86_64-apple-darwin 35 | use-cross: false 36 | - os: macos-latest 37 | target: aarch64-apple-darwin 38 | use-cross: false 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 42 | 43 | - name: Install toolchain 44 | uses: dtolnay/rust-toolchain@stable 45 | with: 46 | targets: ${{ matrix.target }} 47 | 48 | - name: Cache cargo registry 49 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 50 | with: 51 | path: | 52 | ~/.cargo/registry 53 | ~/.cargo/git 54 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 55 | restore-keys: ${{ runner.os }}-cargo- 56 | 57 | - name: Cache target 58 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 59 | with: 60 | path: target 61 | key: ${{ runner.os }}-ci-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} 62 | restore-keys: ${{ runner.os }}-ci-${{ matrix.target }}- 63 | 64 | - name: Install cargo-make 65 | uses: taiki-e/install-action@e43a5023a747770bfcb71ae048541a681714b951 # v2.62.33 66 | with: 67 | tool: cargo-make 68 | 69 | - name: Install cross 70 | if: matrix.use-cross == true 71 | uses: taiki-e/install-action@e43a5023a747770bfcb71ae048541a681714b951 # v2.62.33 72 | with: 73 | tool: cross 74 | 75 | - name: Run CI task (cross) 76 | if: matrix.use-cross == true 77 | run: CARGO=cross cargo make ci 78 | 79 | - name: Run CI task 80 | if: matrix.use-cross != true 81 | run: cargo make ci 82 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/requests/source.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::super::source::UserContent; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct BatchCreateSourcesRequest { 10 | #[serde(rename = "userContents")] 11 | pub user_contents: Vec, 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 15 | pub struct BatchDeleteSourcesRequest { 16 | pub names: Vec, 17 | } 18 | 19 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 20 | pub struct BatchDeleteSourcesResponse { 21 | // API may return empty response or status information 22 | #[serde(flatten)] 23 | pub extra: HashMap, 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | use super::super::super::source::{TextContent, WebContent}; 29 | use super::*; 30 | 31 | #[test] 32 | fn batch_create_sources_request_serializes_with_user_contents() { 33 | let request = BatchCreateSourcesRequest { 34 | user_contents: vec![ 35 | UserContent::Web { 36 | web_content: WebContent { 37 | url: "https://example.com".to_string(), 38 | source_name: None, 39 | }, 40 | }, 41 | UserContent::Text { 42 | text_content: TextContent { 43 | content: "Sample text".to_string(), 44 | source_name: Some("My text".to_string()), 45 | }, 46 | }, 47 | ], 48 | }; 49 | let json = serde_json::to_string(&request).unwrap(); 50 | assert!(json.contains("userContents")); 51 | assert!(json.contains("https://example.com")); 52 | assert!(json.contains("Sample text")); 53 | } 54 | 55 | #[test] 56 | fn batch_delete_sources_request_serializes_correctly() { 57 | let request = BatchDeleteSourcesRequest { 58 | names: vec!["sources/123".to_string()], 59 | }; 60 | let json = serde_json::to_string(&request).unwrap(); 61 | assert!(json.contains(r#""names""#)); 62 | assert!(json.contains("sources/123")); 63 | } 64 | 65 | #[test] 66 | fn batch_delete_sources_response_deserializes_empty() { 67 | let json = r#"{}"#; 68 | let response: BatchDeleteSourcesResponse = serde_json::from_str(json).unwrap(); 69 | assert!(response.extra.is_empty()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/python-ci.yml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "python/**" 9 | - "crates/nblm-python/**" 10 | - "crates/nblm-core/**" 11 | - ".github/workflows/python-ci.yml" 12 | - "Makefile.toml" 13 | pull_request: 14 | branches: 15 | - main 16 | paths: 17 | - "python/**" 18 | - "crates/nblm-python/**" 19 | - "crates/nblm-core/**" 20 | - ".github/workflows/python-ci.yml" 21 | - "Makefile.toml" 22 | 23 | env: 24 | CARGO_TERM_COLOR: always 25 | 26 | jobs: 27 | python-checks: 28 | runs-on: ${{ matrix.os }} 29 | strategy: 30 | matrix: 31 | os: [ubuntu-latest, macos-latest] 32 | python-version: ["3.12", "3.13", "3.14"] 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 36 | 37 | - name: Install Rust toolchain 38 | uses: dtolnay/rust-toolchain@stable 39 | 40 | - name: Install uv 41 | uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 42 | with: 43 | enable-cache: true 44 | cache-dependency-glob: "python/uv.lock" 45 | 46 | - name: Set up Python ${{ matrix.python-version }} 47 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | 51 | - name: Cache cargo registry 52 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 53 | with: 54 | path: | 55 | ~/.cargo/registry 56 | ~/.cargo/git 57 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 58 | restore-keys: ${{ runner.os }}-cargo- 59 | 60 | - name: Cache target 61 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 62 | with: 63 | path: target 64 | key: ${{ runner.os }}-python-ci-${{ hashFiles('**/Cargo.lock') }} 65 | restore-keys: ${{ runner.os }}-python-ci- 66 | 67 | - name: Install cargo-make 68 | uses: taiki-e/install-action@e43a5023a747770bfcb71ae048541a681714b951 # v2.62.33 69 | with: 70 | tool: cargo-make 71 | 72 | - name: Build Python package 73 | run: cargo make py-build 74 | 75 | - name: Format check 76 | run: cargo make py-fmt-check 77 | 78 | - name: Lint 79 | run: cargo make py-lint 80 | 81 | - name: Type check 82 | run: cargo make py-type 83 | 84 | - name: Run tests 85 | run: cargo make py-test 86 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/responses/source.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::super::source::{NotebookSource, NotebookSourceId}; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct BatchCreateSourcesResponse { 10 | #[serde(default)] 11 | pub sources: Vec, 12 | #[serde(skip_serializing_if = "Option::is_none")] 13 | pub error_count: Option, 14 | } 15 | 16 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct UploadSourceFileResponse { 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub source_id: Option, 21 | #[serde(flatten)] 22 | pub extra: HashMap, 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use super::*; 28 | 29 | #[test] 30 | fn batch_create_sources_response_deserializes_correctly() { 31 | let json = r#"{ 32 | "sources": [ 33 | { 34 | "name": "projects/123/locations/global/notebooks/abc/sources/123", 35 | "title": "Test Source", 36 | "metadata": { 37 | "wordCount": 100 38 | } 39 | } 40 | ], 41 | "errorCount": 0 42 | }"#; 43 | let response: BatchCreateSourcesResponse = serde_json::from_str(json).unwrap(); 44 | assert_eq!(response.sources.len(), 1); 45 | assert_eq!(response.error_count, Some(0)); 46 | assert_eq!( 47 | response.sources[0].name, 48 | "projects/123/locations/global/notebooks/abc/sources/123" 49 | ); 50 | assert_eq!(response.sources[0].title.as_ref().unwrap(), "Test Source"); 51 | } 52 | 53 | #[test] 54 | fn upload_source_file_response_deserializes() { 55 | let json = r#"{ 56 | "sourceId": { 57 | "id": "projects/123/locations/global/notebooks/abc/sources/source-id" 58 | }, 59 | "requestId": "abc123" 60 | }"#; 61 | let response: UploadSourceFileResponse = serde_json::from_str(json).unwrap(); 62 | assert_eq!( 63 | response.source_id.as_ref().and_then(|id| id.id.as_deref()), 64 | Some("projects/123/locations/global/notebooks/abc/sources/source-id") 65 | ); 66 | assert_eq!( 67 | response 68 | .extra 69 | .get("requestId") 70 | .and_then(|value| value.as_str()), 71 | Some("abc123") 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/requests/notebook.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Clone, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct CreateNotebookRequest { 8 | pub title: String, 9 | } 10 | 11 | /// Batch delete notebooks request. 12 | /// 13 | /// # Known Issues (as of 2025-10-19) 14 | /// 15 | /// Despite the API being named "batchDelete" and accepting an array of names, 16 | /// the API returns HTTP 400 error when multiple notebook names are provided. 17 | /// Only single notebook deletion works (array with 1 element). 18 | /// 19 | /// To delete multiple notebooks, call this API multiple times with one notebook at a time. 20 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 21 | pub struct BatchDeleteNotebooksRequest { 22 | pub names: Vec, 23 | } 24 | 25 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 26 | pub struct BatchDeleteNotebooksResponse { 27 | // API returns empty response or status information 28 | #[serde(flatten)] 29 | pub extra: HashMap, 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | 36 | #[test] 37 | fn create_notebook_request_serializes_correctly() { 38 | let request = CreateNotebookRequest { 39 | title: "Test Notebook".to_string(), 40 | }; 41 | let json = serde_json::to_string(&request).unwrap(); 42 | assert!(json.contains(r#""title":"Test Notebook""#)); 43 | } 44 | 45 | #[test] 46 | fn batch_delete_notebooks_request_serializes_correctly() { 47 | let request = BatchDeleteNotebooksRequest { 48 | names: vec!["notebooks/123".to_string(), "notebooks/456".to_string()], 49 | }; 50 | let json = serde_json::to_string(&request).unwrap(); 51 | assert!(json.contains(r#""names""#)); 52 | assert!(json.contains("notebooks/123")); 53 | assert!(json.contains("notebooks/456")); 54 | } 55 | 56 | #[test] 57 | fn batch_delete_notebooks_response_deserializes_empty() { 58 | let json = r#"{}"#; 59 | let response: BatchDeleteNotebooksResponse = serde_json::from_str(json).unwrap(); 60 | assert!(response.extra.is_empty()); 61 | } 62 | 63 | #[test] 64 | fn batch_delete_notebooks_response_deserializes_with_extra() { 65 | let json = r#"{"customField":"value"}"#; 66 | let response: BatchDeleteNotebooksResponse = serde_json::from_str(json).unwrap(); 67 | assert_eq!(response.extra.len(), 1); 68 | assert_eq!( 69 | response.extra.get("customField").unwrap().as_str().unwrap(), 70 | "value" 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /python/tests/test_oauth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import tempfile 3 | from collections.abc import Iterator 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from nblm import UserOAuthProvider 9 | 10 | 11 | @pytest.fixture() 12 | def temp_config_dir(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: 13 | temp_dir = tempfile.TemporaryDirectory() 14 | monkeypatch.setenv("NBLM_CONFIG_DIR", temp_dir.name) 15 | try: 16 | yield Path(temp_dir.name) 17 | finally: 18 | temp_dir.cleanup() 19 | 20 | 21 | def write_credentials(config_dir: Path, key: str) -> None: 22 | config_dir.mkdir(parents=True, exist_ok=True) 23 | payload = { 24 | "version": 1, 25 | "entries": { 26 | key: { 27 | "refresh_token": "fake_refresh_token_xyz", 28 | "scopes": ["https://www.googleapis.com/auth/cloud-platform"], 29 | "expires_at": None, 30 | "token_type": "Bearer", 31 | "updated_at": "2025-01-01T00:00:00Z", 32 | } 33 | }, 34 | } 35 | (config_dir / "credentials.json").write_text(json.dumps(payload)) 36 | 37 | 38 | def token_store_key(project_number: int, endpoint_location: str, user: str | None = None) -> str: 39 | parts = ["enterprise", f"project={project_number}", f"location={endpoint_location}"] 40 | if user: 41 | parts.append(f"user={user}") 42 | return ":".join(parts) 43 | 44 | 45 | def test_user_oauth_provider_from_file( 46 | temp_config_dir: Path, monkeypatch: pytest.MonkeyPatch 47 | ) -> None: 48 | monkeypatch.setenv("NBLM_OAUTH_CLIENT_ID", "fake-client-id") 49 | write_credentials(temp_config_dir, token_store_key(123456, "global")) 50 | 51 | provider = UserOAuthProvider.from_file( 52 | project_number=123456, 53 | location="us-central1", 54 | ) 55 | 56 | assert provider is not None 57 | assert provider.endpoint_location == "global" 58 | 59 | 60 | def test_user_oauth_provider_missing_client_id( 61 | monkeypatch: pytest.MonkeyPatch, temp_config_dir: Path 62 | ) -> None: 63 | monkeypatch.delenv("NBLM_OAUTH_CLIENT_ID", raising=False) 64 | write_credentials(temp_config_dir, token_store_key(999999, "global")) 65 | 66 | with pytest.raises(ValueError): 67 | UserOAuthProvider.from_file(project_number=999999) 68 | 69 | 70 | def test_user_oauth_provider_missing_credentials( 71 | monkeypatch: pytest.MonkeyPatch, temp_config_dir: Path 72 | ) -> None: 73 | _ = temp_config_dir # ensure fixture runs without lint complaints 74 | monkeypatch.setenv("NBLM_OAUTH_CLIENT_ID", "fake-client-id") 75 | 76 | with pytest.raises(ValueError, match="No refresh token found"): 77 | UserOAuthProvider.from_file(project_number=42) 78 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install the NotebookLM Enterprise API client as a CLI tool or Python SDK. 4 | 5 | ## Prerequisites 6 | 7 | - Google Cloud project with NotebookLM API enabled 8 | - Google Cloud authentication (gcloud CLI recommended) 9 | 10 | ## CLI Installation 11 | 12 | === "Homebrew (macOS)" 13 | 14 | ```bash 15 | brew tap k-dash/nblm https://github.com/K-dash/homebrew-nblm 16 | brew install k-dash/nblm/nblm 17 | nblm --version 18 | ``` 19 | 20 | === "From crates.io" 21 | 22 | ```bash 23 | cargo install nblm-cli 24 | ``` 25 | 26 | === "From Source" 27 | 28 | ```bash 29 | git clone https://github.com/K-dash/nblm-rs.git 30 | cd nblm-rs 31 | cargo build --release 32 | ``` 33 | 34 | The binary will be available at `target/release/nblm`. 35 | 36 | **Optional**: Add to PATH 37 | 38 | ```bash 39 | # Linux/macOS 40 | sudo cp target/release/nblm /usr/local/bin/ 41 | 42 | # Or add to your shell profile 43 | export PATH="$PATH:/path/to/nblm-rs/target/release" 44 | ``` 45 | 46 | ### Verify Installation 47 | 48 | ```bash 49 | nblm --version 50 | 51 | # nblm 0.2.1 52 | ``` 53 | 54 | ## Python SDK Installation 55 | 56 | === "With pip" 57 | 58 | ```bash 59 | pip install nblm 60 | ``` 61 | 62 | === "With uv" 63 | 64 | ```bash 65 | uv add nblm 66 | ``` 67 | 68 | === "From Source" 69 | 70 | ```bash 71 | git clone https://github.com/K-dash/nblm-rs.git 72 | cd nblm-rs 73 | cd python 74 | pip install maturin 75 | maturin develop 76 | ``` 77 | 78 | ### Verify Installation 79 | 80 | ```python 81 | import nblm 82 | print(nblm.__version__) 83 | ``` 84 | 85 | ## Platform Support 86 | 87 | | Platform | CLI | Python SDK | 88 | | ------------------------ | ---------------- | ---------------- | 89 | | 🐧 Linux (x86_64) | ✅ Supported | ✅ Supported | 90 | | 🐧 Linux (aarch64) | ✅ Supported | ✅ Supported | 91 | | 🍎 macOS (Intel) | ✅ Supported | ✅ Supported | 92 | | 🍎 macOS (Apple Silicon) | ✅ Supported | ✅ Supported | 93 | | 🪟 Windows | ❌ Not Supported | ❌ Not Supported | 94 | 95 | !!! note "Windows Support" 96 | Windows support is not available. Consider using WSL (Windows Subsystem for Linux) as a workaround. 97 | 98 | ## Next Steps 99 | 100 | - [Authentication Setup](authentication.md) - Configure authentication 101 | - [Configuration](configuration.md) - Set up project numbers and locations 102 | - [CLI Overview](../cli/README.md) - Start using the CLI 103 | - [Python Quickstart](../python/quickstart.md) - Start using the Python SDK 104 | -------------------------------------------------------------------------------- /python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.0,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "nblm" 7 | version = "0.2.3" 8 | description = "Python bindings for NotebookLM Enterprise API client" 9 | readme = "README.md" 10 | requires-python = ">=3.14" 11 | license = { text = "MIT" } 12 | keywords = ["notebooklm", "api", "google-cloud", "gemini"] 13 | authors = [{ name = "K-dash" }] 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3.14", 21 | "Programming Language :: Rust", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | dependencies = [] 25 | 26 | [project.optional-dependencies] 27 | dev = ["pytest>=7.0.0", "mypy>=1.0.0", "ruff>=0.1.0", "maturin>=1.0,<2.0"] 28 | 29 | [project.urls] 30 | Homepage = "https://github.com/K-dash/nblm-rs" 31 | Repository = "https://github.com/K-dash/nblm-rs" 32 | "Bug Tracker" = "https://github.com/K-dash/nblm-rs/issues" 33 | 34 | [tool.maturin] 35 | module-name = "nblm" 36 | project-name = "nblm" 37 | manifest-path = "../crates/nblm-python/Cargo.toml" 38 | python-source = "src" 39 | features = ["pyo3/extension-module"] 40 | 41 | [tool.pytest.ini_options] 42 | minversion = "6.0" 43 | testpaths = ["tests"] 44 | python_files = "test_*.py" 45 | python_classes = "Test*" 46 | python_functions = "test_*" 47 | 48 | [tool.mypy] 49 | python_version = "3.13" 50 | warn_return_any = true 51 | warn_unused_configs = true 52 | disallow_untyped_defs = true 53 | disallow_any_unimported = false 54 | no_implicit_optional = true 55 | warn_redundant_casts = true 56 | warn_unused_ignores = true 57 | warn_no_return = true 58 | check_untyped_defs = true 59 | strict_equality = true 60 | 61 | [[tool.mypy.overrides]] 62 | module = "nblm.nblm" 63 | ignore_missing_imports = true 64 | 65 | [tool.ruff] 66 | target-version = "py313" 67 | line-length = 100 68 | 69 | [tool.ruff.lint] 70 | select = [ 71 | "E", # pycodestyle errors 72 | "W", # pycodestyle warnings 73 | "F", # pyflakes 74 | "I", # isort 75 | "B", # flake8-bugbear 76 | "C4", # flake8-comprehensions 77 | "UP", # pyupgrade 78 | "ANN", # flake8-annotations 79 | "TCH", # flake8-type-checking 80 | "ARG", # flake8-unused-arguments 81 | "PIE", # flake8-pie 82 | "BLE", # flake8-blind-except 83 | "RUF", # Ruff-specific rules 84 | ] 85 | ignore = [ 86 | "E501", # line too long (handled by formatter) 87 | ] 88 | 89 | [tool.ruff.lint.per-file-ignores] 90 | "__init__.py" = ["F401"] 91 | 92 | [tool.ruff.format] 93 | quote-style = "double" 94 | indent-style = "space" 95 | skip-magic-trailing-comma = false 96 | line-ending = "auto" 97 | -------------------------------------------------------------------------------- /python/tests/test_basic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic tests for nblm Python bindings 3 | """ 4 | 5 | 6 | def test_import() -> None: 7 | """Test that the module can be imported and has a version""" 8 | import nblm 9 | 10 | # Version should be a non-empty string 11 | assert isinstance(nblm.__version__, str) 12 | assert len(nblm.__version__) > 0 13 | # When installed, version should not be the fallback 14 | # (This check is optional and may be "0.0.0" when running from source) 15 | assert nblm.__version__ is not None 16 | 17 | 18 | def test_classes_available() -> None: 19 | """Test that all main classes are available""" 20 | from nblm import ( 21 | DEFAULT_ENV_TOKEN_KEY, 22 | DEFAULT_GCLOUD_BINARY, 23 | BatchCreateSourcesResponse, 24 | BatchDeleteNotebooksResponse, 25 | BatchDeleteSourcesResponse, 26 | EnvTokenProvider, 27 | GcloudTokenProvider, 28 | GoogleDriveSource, 29 | ListRecentlyViewedResponse, 30 | NblmClient, 31 | NblmError, 32 | Notebook, 33 | UserOAuthProvider, 34 | ) 35 | 36 | assert NblmClient is not None 37 | assert GcloudTokenProvider is not None 38 | assert EnvTokenProvider is not None 39 | assert NblmError is not None 40 | assert Notebook is not None 41 | assert UserOAuthProvider is not None 42 | assert ListRecentlyViewedResponse is not None 43 | assert BatchCreateSourcesResponse is not None 44 | assert BatchDeleteSourcesResponse is not None 45 | assert BatchDeleteNotebooksResponse is not None 46 | assert GoogleDriveSource is not None 47 | assert DEFAULT_GCLOUD_BINARY == "gcloud" 48 | assert DEFAULT_ENV_TOKEN_KEY == "NBLM_ACCESS_TOKEN" 49 | 50 | 51 | def test_gcloud_token_provider_creation() -> None: 52 | """Test creating a GcloudTokenProvider""" 53 | from nblm import GcloudTokenProvider 54 | 55 | provider = GcloudTokenProvider() 56 | assert provider is not None 57 | 58 | provider_custom = GcloudTokenProvider(binary="/usr/bin/gcloud") 59 | assert provider_custom is not None 60 | 61 | 62 | def test_env_token_provider_creation() -> None: 63 | """Test creating an EnvTokenProvider""" 64 | from nblm import EnvTokenProvider 65 | 66 | provider = EnvTokenProvider() 67 | assert provider is not None 68 | 69 | provider_custom = EnvTokenProvider(key="MY_CUSTOM_TOKEN") 70 | assert provider_custom is not None 71 | 72 | 73 | def test_client_creation() -> None: 74 | """Test creating an NblmClient""" 75 | from nblm import GcloudTokenProvider, NblmClient 76 | 77 | provider = GcloudTokenProvider() 78 | client = NblmClient( 79 | token_provider=provider, 80 | project_number="123456789012", 81 | location="global", 82 | endpoint_location="global", 83 | ) 84 | assert client is not None 85 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/notebook.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::source::NotebookSource; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct Notebook { 10 | pub name: Option, 11 | pub title: String, 12 | #[serde(rename = "notebookId", skip_serializing_if = "Option::is_none")] 13 | pub notebook_id: Option, 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub emoji: Option, 16 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 17 | pub sources: Vec, 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub metadata: Option, 20 | #[serde(flatten)] 21 | pub extra: HashMap, 22 | } 23 | 24 | #[derive(Debug, Clone, Serialize, Deserialize)] 25 | #[serde(rename_all = "camelCase")] 26 | pub struct NotebookRef { 27 | pub notebook_id: String, 28 | pub name: String, 29 | } 30 | 31 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 32 | #[serde(rename_all = "camelCase")] 33 | pub struct NotebookMetadata { 34 | #[serde(skip_serializing_if = "Option::is_none")] 35 | pub create_time: Option, 36 | #[serde(skip_serializing_if = "Option::is_none")] 37 | pub is_shareable: Option, 38 | #[serde(skip_serializing_if = "Option::is_none")] 39 | pub is_shared: Option, 40 | #[serde(skip_serializing_if = "Option::is_none")] 41 | pub last_viewed: Option, 42 | #[serde(flatten)] 43 | pub extra: HashMap, 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use super::*; 49 | 50 | #[test] 51 | fn notebook_skips_notebook_id_when_none() { 52 | let notebook = Notebook { 53 | name: Some("test".to_string()), 54 | title: "Test Notebook".to_string(), 55 | notebook_id: None, 56 | emoji: None, 57 | metadata: None, 58 | sources: Vec::new(), 59 | extra: Default::default(), 60 | }; 61 | let json = serde_json::to_string(¬ebook).unwrap(); 62 | assert!(!json.contains("notebookId")); 63 | } 64 | 65 | #[test] 66 | fn notebook_includes_notebook_id_when_some() { 67 | let notebook = Notebook { 68 | name: Some("test".to_string()), 69 | title: "Test Notebook".to_string(), 70 | notebook_id: Some("nb123".to_string()), 71 | emoji: None, 72 | metadata: None, 73 | sources: Vec::new(), 74 | extra: Default::default(), 75 | }; 76 | let json = serde_json::to_string(¬ebook).unwrap(); 77 | assert!(json.contains("notebookId")); 78 | assert!(json.contains("nb123")); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /crates/nblm-python/src/models/audio.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pyo3::types::PyDict; 3 | 4 | use crate::error::PyResult; 5 | 6 | use super::extra_to_pydict; 7 | 8 | /// Request for creating an audio overview. 9 | /// 10 | /// Note: As of the current API version, this request must be empty. 11 | /// All fields are reserved for future use. 12 | #[pyclass(module = "nblm")] 13 | #[derive(Clone, Default)] 14 | pub struct AudioOverviewRequest { 15 | // Currently, the API only accepts an empty request body 16 | // Fields are commented out for future compatibility 17 | // #[pyo3(get, set)] 18 | // pub source_ids: Option>, 19 | // #[pyo3(get, set)] 20 | // pub episode_focus: Option, 21 | // #[pyo3(get, set)] 22 | // pub language_code: Option, 23 | } 24 | 25 | #[pymethods] 26 | impl AudioOverviewRequest { 27 | #[new] 28 | pub fn new() -> Self { 29 | Self {} 30 | } 31 | 32 | pub fn __repr__(&self) -> String { 33 | "AudioOverviewRequest()".to_string() 34 | } 35 | 36 | pub fn __str__(&self) -> String { 37 | self.__repr__() 38 | } 39 | } 40 | 41 | impl AudioOverviewRequest { 42 | pub(crate) fn to_core(&self) -> nblm_core::models::enterprise::audio::AudioOverviewRequest { 43 | nblm_core::models::enterprise::audio::AudioOverviewRequest::default() 44 | } 45 | } 46 | 47 | /// Response from creating or getting an audio overview. 48 | #[pyclass(module = "nblm")] 49 | pub struct AudioOverviewResponse { 50 | #[pyo3(get)] 51 | pub audio_overview_id: Option, 52 | #[pyo3(get)] 53 | pub name: Option, 54 | #[pyo3(get)] 55 | pub status: Option, 56 | #[pyo3(get)] 57 | pub generation_options: Py, 58 | #[pyo3(get)] 59 | pub extra: Py, 60 | } 61 | 62 | #[pymethods] 63 | impl AudioOverviewResponse { 64 | pub fn __repr__(&self) -> String { 65 | format!( 66 | "AudioOverviewResponse(audio_overview_id={:?}, name={:?}, status={:?})", 67 | self.audio_overview_id, self.name, self.status 68 | ) 69 | } 70 | 71 | pub fn __str__(&self) -> String { 72 | self.__repr__() 73 | } 74 | } 75 | 76 | impl AudioOverviewResponse { 77 | pub(crate) fn from_core( 78 | py: Python, 79 | response: nblm_core::models::enterprise::audio::AudioOverviewResponse, 80 | ) -> PyResult { 81 | let generation_options = match response.generation_options { 82 | Some(value) => crate::models::json_value_to_py(py, &value)?, 83 | None => py.None(), 84 | }; 85 | 86 | Ok(Self { 87 | audio_overview_id: response.audio_overview_id, 88 | name: response.name, 89 | status: response.status, 90 | generation_options, 91 | extra: extra_to_pydict(py, &response.extra)?, 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/url/enterprise.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Url; 2 | 3 | use super::UrlBuilder; 4 | use crate::error::{Error, Result}; 5 | 6 | /// Enterprise-specific URL builder. 7 | #[derive(Clone)] 8 | pub(crate) struct EnterpriseUrlBuilder { 9 | base: String, 10 | parent: String, 11 | } 12 | 13 | impl EnterpriseUrlBuilder { 14 | pub fn new(base: String, parent: String) -> Self { 15 | Self { base, parent } 16 | } 17 | } 18 | 19 | impl UrlBuilder for EnterpriseUrlBuilder { 20 | fn notebooks_collection(&self) -> String { 21 | format!("{}/notebooks", self.parent) 22 | } 23 | 24 | fn notebook_path(&self, notebook_id: &str) -> String { 25 | format!("{}/notebooks/{}", self.parent, notebook_id) 26 | } 27 | 28 | fn build_url(&self, path: &str) -> Result { 29 | let path = path.trim_start_matches('/'); 30 | Url::parse(&format!("{}/{}", self.base, path)).map_err(Error::from) 31 | } 32 | 33 | fn build_upload_url(&self, path: &str) -> Result { 34 | let base = self.base.trim_end_matches('/'); 35 | let trimmed_path = path.trim_start_matches('/'); 36 | let upload_base = if let Some((prefix, _)) = base.rsplit_once("/v1alpha") { 37 | format!("{}/upload/v1alpha/{}", prefix, trimmed_path) 38 | } else { 39 | format!("{}/upload/{}", base, trimmed_path) 40 | }; 41 | Url::parse(&upload_base).map_err(Error::from) 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | 49 | #[test] 50 | fn build_url_combines_base_and_path_correctly() { 51 | let builder = EnterpriseUrlBuilder::new( 52 | "http://example.com/v1alpha".to_string(), 53 | "projects/123/locations/global".to_string(), 54 | ); 55 | 56 | // Test with leading slash 57 | let url = builder.build_url("/projects/123/notebooks").unwrap(); 58 | assert_eq!( 59 | url.as_str(), 60 | "http://example.com/v1alpha/projects/123/notebooks" 61 | ); 62 | 63 | // Test without leading slash 64 | let url = builder.build_url("projects/123/notebooks").unwrap(); 65 | assert_eq!( 66 | url.as_str(), 67 | "http://example.com/v1alpha/projects/123/notebooks" 68 | ); 69 | } 70 | 71 | #[test] 72 | fn build_upload_url_handles_v1alpha_correctly() { 73 | let builder = EnterpriseUrlBuilder::new( 74 | "https://us-discoveryengine.googleapis.com/v1alpha".to_string(), 75 | "projects/123/locations/global".to_string(), 76 | ); 77 | 78 | let url = builder 79 | .build_upload_url("/projects/123/notebooks/abc/sources:uploadFile") 80 | .unwrap(); 81 | assert_eq!( 82 | url.as_str(), 83 | "https://us-discoveryengine.googleapis.com/upload/v1alpha/projects/123/notebooks/abc/sources:uploadFile" 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /crates/nblm-python/src/models/source.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | 3 | /// Source type for adding web URLs to a notebook. 4 | #[pyclass(module = "nblm")] 5 | #[derive(Clone)] 6 | pub struct WebSource { 7 | #[pyo3(get)] 8 | pub url: String, 9 | #[pyo3(get)] 10 | pub name: Option, 11 | } 12 | 13 | #[pymethods] 14 | impl WebSource { 15 | #[new] 16 | #[pyo3(signature = (url, name=None))] 17 | fn new(url: String, name: Option) -> Self { 18 | Self { url, name } 19 | } 20 | 21 | pub fn __repr__(&self) -> String { 22 | format!("WebSource(url={:?}, name={:?})", self.url, self.name) 23 | } 24 | 25 | pub fn __str__(&self) -> String { 26 | self.__repr__() 27 | } 28 | } 29 | 30 | /// Source type for adding text content to a notebook. 31 | #[pyclass(module = "nblm")] 32 | #[derive(Clone)] 33 | pub struct TextSource { 34 | #[pyo3(get)] 35 | pub content: String, 36 | #[pyo3(get)] 37 | pub name: Option, 38 | } 39 | 40 | #[pymethods] 41 | impl TextSource { 42 | #[new] 43 | #[pyo3(signature = (content, name=None))] 44 | fn new(content: String, name: Option) -> Self { 45 | Self { content, name } 46 | } 47 | 48 | pub fn __repr__(&self) -> String { 49 | format!( 50 | "TextSource(content={:?}, name={:?})", 51 | self.content, self.name 52 | ) 53 | } 54 | 55 | pub fn __str__(&self) -> String { 56 | self.__repr__() 57 | } 58 | } 59 | 60 | /// Source type for adding Google Drive documents to a notebook. 61 | #[pyclass(module = "nblm")] 62 | #[derive(Clone)] 63 | pub struct GoogleDriveSource { 64 | #[pyo3(get)] 65 | pub document_id: String, 66 | #[pyo3(get)] 67 | pub mime_type: String, 68 | #[pyo3(get)] 69 | pub name: Option, 70 | } 71 | 72 | #[pymethods] 73 | impl GoogleDriveSource { 74 | #[new] 75 | #[pyo3(signature = (document_id, mime_type, name=None))] 76 | fn new(document_id: String, mime_type: String, name: Option) -> Self { 77 | Self { 78 | document_id, 79 | mime_type, 80 | name, 81 | } 82 | } 83 | 84 | pub fn __repr__(&self) -> String { 85 | format!( 86 | "GoogleDriveSource(document_id={:?}, mime_type={:?}, name={:?})", 87 | self.document_id, self.mime_type, self.name 88 | ) 89 | } 90 | 91 | pub fn __str__(&self) -> String { 92 | self.__repr__() 93 | } 94 | } 95 | 96 | /// Source type for adding YouTube videos to a notebook. 97 | #[pyclass(module = "nblm")] 98 | #[derive(Clone)] 99 | pub struct VideoSource { 100 | #[pyo3(get)] 101 | pub url: String, 102 | } 103 | 104 | #[pymethods] 105 | impl VideoSource { 106 | #[new] 107 | fn new(url: String) -> Self { 108 | Self { url } 109 | } 110 | 111 | pub fn __repr__(&self) -> String { 112 | format!("VideoSource(url={:?})", self.url) 113 | } 114 | 115 | pub fn __str__(&self) -> String { 116 | self.__repr__() 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /crates/nblm-core/src/error.rs: -------------------------------------------------------------------------------- 1 | use reqwest::StatusCode; 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | pub enum Error { 6 | #[error("token provider error: {0}")] 7 | TokenProvider(String), 8 | #[error("invalid endpoint configuration: {0}")] 9 | Endpoint(String), 10 | #[error("request error: {0}")] 11 | Request(#[from] reqwest::Error), 12 | #[error("http error {status}: {message}")] 13 | Http { 14 | status: StatusCode, 15 | message: String, 16 | body: String, 17 | }, 18 | #[error("json deserialize error: {0}")] 19 | Json(#[from] serde_json::Error), 20 | #[error("url parse error: {0}")] 21 | Url(#[from] url::ParseError), 22 | #[error("validation error: {0}")] 23 | Validation(String), 24 | } 25 | 26 | pub type Result = std::result::Result; 27 | 28 | impl Error { 29 | pub fn http(status: StatusCode, body: impl Into) -> Self { 30 | let body = body.into(); 31 | let message = extract_error_message(&body).unwrap_or_else(|| body.clone()); 32 | Self::Http { 33 | status, 34 | message, 35 | body, 36 | } 37 | } 38 | 39 | pub fn validation(message: impl Into) -> Self { 40 | Self::Validation(message.into()) 41 | } 42 | } 43 | 44 | fn extract_error_message(body: &str) -> Option { 45 | let json: serde_json::Value = serde_json::from_str(body).ok()?; 46 | json.get("error") 47 | .and_then(|err| err.get("message")) 48 | .and_then(|msg| msg.as_str()) 49 | .map(|s| s.to_string()) 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::*; 55 | 56 | #[test] 57 | fn extract_error_message_from_gcp_response() { 58 | let body = r#"{"error":{"message":"Not Found"}}"#; 59 | assert_eq!(extract_error_message(body), Some("Not Found".to_string())); 60 | } 61 | 62 | #[test] 63 | fn extract_error_message_missing() { 64 | assert_eq!(extract_error_message("{}"), None); 65 | assert_eq!(extract_error_message("invalid"), None); 66 | } 67 | 68 | #[test] 69 | fn http_error_uses_message_field_if_available() { 70 | let e = Error::http( 71 | StatusCode::TOO_MANY_REQUESTS, 72 | r#"{"error":{"message":"Too Many Requests"}}"#, 73 | ); 74 | match e { 75 | Error::Http { 76 | message, 77 | body, 78 | status, 79 | } => { 80 | assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); 81 | assert_eq!(message, "Too Many Requests"); 82 | assert!(body.contains("Too Many Requests")); 83 | } 84 | _ => panic!("expected Error::Http"), 85 | } 86 | } 87 | 88 | #[test] 89 | fn http_error_uses_body_when_no_message() { 90 | let e = Error::http(StatusCode::BAD_REQUEST, "plain error text"); 91 | match e { 92 | Error::Http { message, body, .. } => { 93 | assert_eq!(message, "plain error text"); 94 | assert_eq!(body, "plain error text"); 95 | } 96 | _ => panic!("expected Error::Http"), 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/ops/audio.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::{Args, Subcommand}; 3 | use nblm_core::{models::enterprise::audio::AudioOverviewRequest, NblmClient}; 4 | use serde_json::json; 5 | 6 | use crate::util::io::emit_json; 7 | 8 | #[derive(Subcommand)] 9 | pub enum Command { 10 | Create(CreateArgs), 11 | Delete(DeleteArgs), 12 | } 13 | 14 | #[derive(Args)] 15 | pub struct CreateArgs { 16 | #[arg(long, value_name = "ID")] 17 | pub notebook_id: String, 18 | // TODO: Uncomment when API supports these fields (as of 2025-10-19, they return "Unknown name" errors) 19 | // /// Source IDs to include in the audio overview 20 | // #[arg(long = "source-id", value_name = "SOURCE_ID")] 21 | // pub source_ids: Vec, 22 | // 23 | // /// Focus topic for the episode 24 | // #[arg(long, value_name = "TEXT")] 25 | // pub episode_focus: Option, 26 | // 27 | // /// Language code (e.g., ja-JP, en-US) 28 | // #[arg(long, value_name = "CODE")] 29 | // pub language_code: Option, 30 | } 31 | 32 | #[derive(Args)] 33 | pub struct DeleteArgs { 34 | #[arg(long, value_name = "ID")] 35 | pub notebook_id: String, 36 | } 37 | 38 | pub async fn run(cmd: Command, client: &NblmClient, json_mode: bool) -> Result<()> { 39 | match cmd { 40 | Command::Create(args) => { 41 | // TODO: Uncomment when API supports configuration fields 42 | // let source_ids = if args.source_ids.is_empty() { 43 | // None 44 | // } else { 45 | // Some( 46 | // args.source_ids 47 | // .into_iter() 48 | // .map(|id| SourceId { id }) 49 | // .collect(), 50 | // ) 51 | // }; 52 | // 53 | // let request = AudioOverviewRequest { 54 | // source_ids, 55 | // episode_focus: args.episode_focus, 56 | // language_code: args.language_code, 57 | // }; 58 | 59 | let request = AudioOverviewRequest::default(); 60 | 61 | let response = client 62 | .create_audio_overview(&args.notebook_id, request) 63 | .await?; 64 | 65 | if json_mode { 66 | // In CLI json mode, wrap with audioOverview to match original format 67 | emit_json(json!({"audioOverview": response}), json_mode); 68 | } else { 69 | println!("Audio overview created successfully:"); 70 | if let Some(id) = &response.audio_overview_id { 71 | println!(" Audio Overview ID: {}", id); 72 | } 73 | if let Some(name) = &response.name { 74 | println!(" Name: {}", name); 75 | } 76 | if let Some(status) = &response.status { 77 | println!(" Status: {}", status); 78 | } 79 | } 80 | } 81 | Command::Delete(args) => { 82 | client.delete_audio_overview(&args.notebook_id).await?; 83 | if !json_mode { 84 | println!("Audio overview deleted successfully"); 85 | } else { 86 | emit_json(json!({"status": "deleted"}), json_mode); 87 | } 88 | } 89 | } 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/util/validate.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, bail, Result}; 2 | 3 | pub fn validate_url(url: &str) -> Result<()> { 4 | let parsed = url::Url::parse(url).map_err(|err| anyhow!("invalid URL {url}: {err}"))?; 5 | match parsed.scheme() { 6 | "http" | "https" => Ok(()), 7 | other => bail!("unsupported URL scheme: {other}"), 8 | } 9 | } 10 | 11 | pub fn pair_with_names( 12 | values: &[String], 13 | names: &[String], 14 | field: &str, 15 | ) -> Result)>> { 16 | if names.len() > values.len() { 17 | bail!("{field} count exceeds number of values"); 18 | } 19 | Ok(values 20 | .iter() 21 | .enumerate() 22 | .map(|(idx, value)| { 23 | let name = names.get(idx).and_then(|s| { 24 | let trimmed = s.trim(); 25 | if trimmed.is_empty() { 26 | None 27 | } else { 28 | Some(trimmed.to_string()) 29 | } 30 | }); 31 | (value.clone(), name) 32 | }) 33 | .collect()) 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use super::*; 39 | 40 | #[test] 41 | fn trims_and_pairs_names() { 42 | let values = vec!["https://example.com".to_string()]; 43 | let names = vec![" Example ".to_string()]; 44 | let pairs = pair_with_names(&values, &names, "--name").unwrap(); 45 | assert_eq!(pairs[0].0, "https://example.com"); 46 | assert_eq!(pairs[0].1.as_deref(), Some("Example")); 47 | } 48 | 49 | #[test] 50 | fn empty_name_becomes_none() { 51 | let values = vec!["https://example.com".to_string()]; 52 | let names = vec![" ".to_string()]; 53 | let pairs = pair_with_names(&values, &names, "--name").unwrap(); 54 | assert!(pairs[0].1.is_none()); 55 | } 56 | 57 | #[test] 58 | fn len_mismatch_errors() { 59 | let values = vec!["https://example.com".to_string()]; 60 | let names = vec!["one".to_string(), "two".to_string()]; 61 | assert!(pair_with_names(&values, &names, "--name").is_err()); 62 | } 63 | 64 | #[test] 65 | fn validate_url_accepts_http() { 66 | assert!(validate_url("http://example.com").is_ok()); 67 | } 68 | 69 | #[test] 70 | fn validate_url_accepts_https() { 71 | assert!(validate_url("https://example.com").is_ok()); 72 | } 73 | 74 | #[test] 75 | fn validate_url_rejects_ftp() { 76 | let result = validate_url("ftp://example.com"); 77 | assert!(result.is_err()); 78 | assert!(result 79 | .unwrap_err() 80 | .to_string() 81 | .contains("unsupported URL scheme")); 82 | } 83 | 84 | #[test] 85 | fn validate_url_rejects_file() { 86 | let result = validate_url("file:///etc/passwd"); 87 | assert!(result.is_err()); 88 | assert!(result 89 | .unwrap_err() 90 | .to_string() 91 | .contains("unsupported URL scheme")); 92 | } 93 | 94 | #[test] 95 | fn validate_url_rejects_invalid() { 96 | let result = validate_url("not a url"); 97 | assert!(result.is_err()); 98 | assert!(result.unwrap_err().to_string().contains("invalid URL")); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /crates/nblm-python/src/models/notebook.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pyo3::types::{PyDict, PyList}; 3 | 4 | use crate::error::PyResult; 5 | 6 | use super::{extra_to_pydict, NotebookSource}; 7 | 8 | #[pyclass(module = "nblm")] 9 | pub struct NotebookMetadata { 10 | #[pyo3(get)] 11 | pub create_time: Option, 12 | #[pyo3(get)] 13 | pub is_shareable: Option, 14 | #[pyo3(get)] 15 | pub is_shared: Option, 16 | #[pyo3(get)] 17 | pub last_viewed: Option, 18 | #[pyo3(get)] 19 | pub extra: Py, 20 | } 21 | 22 | #[pymethods] 23 | impl NotebookMetadata { 24 | pub fn __repr__(&self) -> String { 25 | format!( 26 | "NotebookMetadata(create_time={:?}, last_viewed={:?})", 27 | self.create_time, self.last_viewed 28 | ) 29 | } 30 | 31 | pub fn __str__(&self) -> String { 32 | self.__repr__() 33 | } 34 | } 35 | 36 | impl NotebookMetadata { 37 | pub(crate) fn from_core( 38 | py: Python, 39 | metadata: nblm_core::models::enterprise::notebook::NotebookMetadata, 40 | ) -> PyResult { 41 | Ok(Self { 42 | create_time: metadata.create_time, 43 | is_shareable: metadata.is_shareable, 44 | is_shared: metadata.is_shared, 45 | last_viewed: metadata.last_viewed, 46 | extra: extra_to_pydict(py, &metadata.extra)?, 47 | }) 48 | } 49 | } 50 | 51 | #[pyclass(module = "nblm")] 52 | pub struct Notebook { 53 | #[pyo3(get)] 54 | pub name: Option, 55 | #[pyo3(get)] 56 | pub title: String, 57 | #[pyo3(get)] 58 | pub notebook_id: Option, 59 | #[pyo3(get)] 60 | pub emoji: Option, 61 | #[pyo3(get)] 62 | pub metadata: Option>, 63 | #[pyo3(get)] 64 | pub sources: Py, 65 | #[pyo3(get)] 66 | pub extra: Py, 67 | } 68 | 69 | #[pymethods] 70 | impl Notebook { 71 | pub fn __repr__(&self, py: Python) -> String { 72 | let source_count = self.sources.bind(py).len(); 73 | format!( 74 | "Notebook(title='{}', notebook_id={:?}, sources={} items)", 75 | self.title, self.notebook_id, source_count 76 | ) 77 | } 78 | 79 | pub fn __str__(&self, py: Python) -> String { 80 | self.__repr__(py) 81 | } 82 | } 83 | 84 | impl Notebook { 85 | pub fn from_core( 86 | py: Python, 87 | notebook: nblm_core::models::enterprise::notebook::Notebook, 88 | ) -> PyResult { 89 | let extra = extra_to_pydict(py, ¬ebook.extra)?; 90 | let metadata = match notebook.metadata { 91 | Some(meta) => Some(Py::new(py, NotebookMetadata::from_core(py, meta)?)?), 92 | None => None, 93 | }; 94 | let sources_list = PyList::empty(py); 95 | for source in notebook.sources { 96 | let py_source = NotebookSource::from_core(py, source)?; 97 | sources_list.append(py_source)?; 98 | } 99 | Ok(Self { 100 | name: notebook.name, 101 | title: notebook.title, 102 | notebook_id: notebook.notebook_id, 103 | emoji: notebook.emoji, 104 | metadata, 105 | sources: sources_list.unbind(), 106 | extra, 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crates/nblm-cli/tests/json_output.rs: -------------------------------------------------------------------------------- 1 | mod _helpers; 2 | 3 | use _helpers::{cmd::CommonArgs, mock::MockApi}; 4 | use serial_test::serial; 5 | 6 | #[tokio::test] 7 | #[serial] 8 | async fn notebooks_create_json_output() { 9 | let mock = MockApi::start().await; 10 | let args = CommonArgs::default(); 11 | 12 | mock.stub_notebooks_create(&args.project_number, &args.location, "JSON Test") 13 | .await; 14 | 15 | let mut cmd = _helpers::cmd::nblm(); 16 | args.with_base_url(&mut cmd, &mock.base_url()); 17 | cmd.args(["--json", "notebooks", "create", "--title", "JSON Test"]); 18 | 19 | let output = cmd.assert().success().get_output().stdout.clone(); 20 | let json_output: serde_json::Value = 21 | serde_json::from_slice(&output).expect("valid JSON output"); 22 | 23 | insta::assert_json_snapshot!(json_output, @r###" 24 | { 25 | "notebook": { 26 | "name": "projects/123456/locations/global/notebooks/test-notebook-id", 27 | "notebookId": "test-notebook-id", 28 | "title": "JSON Test" 29 | }, 30 | "notebook_id": "test-notebook-id" 31 | } 32 | "###); 33 | } 34 | 35 | #[tokio::test] 36 | #[serial] 37 | async fn notebooks_recent_json_output() { 38 | let mock = MockApi::start().await; 39 | let args = CommonArgs::default(); 40 | 41 | mock.stub_notebooks_recent(&args.project_number, &args.location) 42 | .await; 43 | 44 | let mut cmd = _helpers::cmd::nblm(); 45 | args.with_base_url(&mut cmd, &mock.base_url()); 46 | cmd.args(["--json", "notebooks", "recent"]); 47 | 48 | let output = cmd.assert().success().get_output().stdout.clone(); 49 | let json_output: serde_json::Value = 50 | serde_json::from_slice(&output).expect("valid JSON output"); 51 | 52 | insta::assert_json_snapshot!(json_output, @r###" 53 | { 54 | "notebooks": [ 55 | { 56 | "name": "projects/123456/locations/global/notebooks/nb1", 57 | "notebookId": "nb1", 58 | "title": "Test Notebook 1" 59 | } 60 | ] 61 | } 62 | "###); 63 | } 64 | 65 | #[tokio::test] 66 | #[serial] 67 | async fn sources_add_json_output() { 68 | let mock = MockApi::start().await; 69 | let args = CommonArgs::default(); 70 | let notebook_id = "test-notebook"; 71 | 72 | mock.stub_sources_batch_create(&args.project_number, &args.location, notebook_id) 73 | .await; 74 | 75 | let mut cmd = _helpers::cmd::nblm(); 76 | args.with_base_url(&mut cmd, &mock.base_url()); 77 | cmd.args([ 78 | "--json", 79 | "sources", 80 | "add", 81 | "--notebook-id", 82 | notebook_id, 83 | "--web-url", 84 | "https://example.com", 85 | "--web-name", 86 | "Example", 87 | ]); 88 | 89 | let output = cmd.assert().success().get_output().stdout.clone(); 90 | let json_output: serde_json::Value = 91 | serde_json::from_slice(&output).expect("valid JSON output"); 92 | 93 | insta::assert_json_snapshot!(json_output, @r#" 94 | { 95 | "error_count": null, 96 | "notebook_id": "test-notebook", 97 | "sources": [ 98 | { 99 | "displayName": "Test Source", 100 | "name": "projects/123456/locations/global/notebooks/test-notebook/sources/src1" 101 | } 102 | ] 103 | } 104 | "#); 105 | } 106 | -------------------------------------------------------------------------------- /docs/api/limitations.md: -------------------------------------------------------------------------------- 1 | # NotebookLM API Known Limitations 2 | 3 | This document tracks verified API limitations and workarounds implemented in nblm-rs. 4 | 5 | !!! info "Last Updated" 6 | **2025-10-31** 7 | 8 | ## Batch Delete Notebooks 9 | 10 | **Discovered**: 2025-10-19 11 | **Status**: Confirmed API limitation 12 | 13 | ### Issue 14 | 15 | The `batchDeleteNotebooks` API endpoint accepts an array of notebook names in the request body, but only successfully processes a single notebook at a time. Attempting to delete multiple notebooks in one request results in HTTP 400 error. 16 | 17 | **API Endpoint**: `POST /v1alpha1/projects/{project}/locations/{location}/notebooks:batchDelete` 18 | 19 | **Request Format**: 20 | 21 | ```json 22 | { 23 | "names": [ 24 | "projects/123/locations/global/notebooks/abc", 25 | "projects/123/locations/global/notebooks/def" 26 | ] 27 | } 28 | ``` 29 | 30 | **Behavior**: 31 | 32 | - ✓ Works: Array with 1 element 33 | - ✗ Fails: Array with 2+ elements (HTTP 400) 34 | 35 | ### Workaround 36 | 37 | nblm-rs implements sequential deletion: 38 | 39 | ```rust 40 | pub async fn delete_notebooks(&self, notebook_names: Vec) -> Result<...> { 41 | for name in ¬ebook_names { 42 | let request = BatchDeleteNotebooksRequest { 43 | names: vec![name.clone()], // Single item only 44 | }; 45 | self.batch_delete_notebooks(request).await?; 46 | } 47 | Ok(...) 48 | } 49 | ``` 50 | 51 | ### Impact 52 | 53 | - Multiple deletions take longer (sequential API calls) 54 | - Cannot leverage true batch operation benefits 55 | - Retry logic applies to each individual deletion 56 | 57 | ## Pagination Not Implemented 58 | 59 | **Discovered**: 2025-10-19 (per README) 60 | **Status**: Confirmed API limitation 61 | 62 | ### Issue 63 | 64 | The `listRecentlyViewed` API accepts `pageSize` and `pageToken` parameters but never returns `nextPageToken` in responses, indicating pagination is not currently implemented. 65 | 66 | **API Endpoint**: `GET /v1alpha1/projects/{project}/locations/{location}/notebooks:listRecentlyViewed` 67 | 68 | ### Behavior 69 | 70 | - `pageSize` parameter is accepted but may not be honored 71 | - `nextPageToken` is never returned in responses 72 | - All accessible notebooks appear to be returned in single response 73 | 74 | ### Workaround 75 | 76 | None needed. The API returns all results in one call. 77 | 78 | ### Impact 79 | 80 | - Cannot paginate through large notebook lists 81 | - May cause performance issues with very large notebook collections (untested) 82 | 83 | ## Audio Overview Configuration Fields Not Supported 84 | 85 | **Discovered**: 2025-10-19 (per README) 86 | **Status**: Confirmed API limitation 87 | 88 | ### Issue 89 | 90 | API documentation mentions configuration fields (`languageCode`, `sourceIds`, `episodeFocus`), but the API rejects all of these fields with "Unknown name" errors. Only empty request body `{}` is accepted. 91 | 92 | **API Endpoint**: `POST /v1alpha1/.../audioOverviews` 93 | 94 | ### Behavior 95 | 96 | **Documented (but rejected)**: 97 | 98 | ```json 99 | { 100 | "languageCode": "en", 101 | "sourceIds": [...], 102 | "episodeFocus": "..." 103 | } 104 | ``` 105 | 106 | **Actually accepted**: 107 | 108 | ```json 109 | {} 110 | ``` 111 | 112 | ### Workaround 113 | 114 | Create audio overview with empty request, then configure settings through NotebookLM web UI. 115 | -------------------------------------------------------------------------------- /python/manual_test_env.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Manual test script for nblm Python bindings using EnvTokenProvider 4 | 5 | This script demonstrates authentication using an environment variable token. 6 | Run with: python manual_test_env.py 7 | 8 | Prerequisites: 9 | export NBLM_ACCESS_TOKEN=$(gcloud auth print-access-token) 10 | export NBLM_PROJECT_NUMBER="your_project_number" 11 | """ 12 | 13 | import os 14 | import sys 15 | from nblm import NblmClient, EnvTokenProvider, NblmError 16 | 17 | 18 | def main() -> None: 19 | # Get configuration from environment 20 | access_token = os.getenv("NBLM_ACCESS_TOKEN") 21 | if not access_token: 22 | print("Error: NBLM_ACCESS_TOKEN environment variable not set") 23 | print("Run: export NBLM_ACCESS_TOKEN=$(gcloud auth print-access-token)") 24 | sys.exit(1) 25 | 26 | project_number = os.getenv("NBLM_PROJECT_NUMBER") 27 | if not project_number: 28 | print("Error: NBLM_PROJECT_NUMBER environment variable not set") 29 | sys.exit(1) 30 | 31 | location = os.getenv("NBLM_LOCATION", "global") 32 | endpoint_location = os.getenv("NBLM_ENDPOINT_LOCATION", "global") 33 | 34 | print("=== EnvTokenProvider Test ===") 35 | print(f"Project Number: {project_number}") 36 | print(f"Location: {location}") 37 | print(f"Endpoint Location: {endpoint_location}") 38 | print(f"Token: {access_token[:20]}...\n") 39 | 40 | # Initialize client with environment token provider 41 | try: 42 | token_provider = EnvTokenProvider("NBLM_ACCESS_TOKEN") 43 | client = NblmClient( 44 | project_number=project_number, 45 | location=location, 46 | endpoint_location=endpoint_location, 47 | token_provider=token_provider, 48 | ) 49 | print("✓ Client initialized with EnvTokenProvider\n") 50 | except NblmError as e: 51 | print(f"✗ Failed to initialize client: {e}") 52 | sys.exit(1) 53 | 54 | # Test: Create a notebook 55 | print("Test: Creating a notebook...") 56 | try: 57 | notebook = client.create_notebook(title="EnvToken Test Notebook") 58 | print(f"✓ Notebook created: {notebook.name}") 59 | print(f" Title: {notebook.title}\n") 60 | except NblmError as e: 61 | print(f"✗ Failed to create notebook: {e}\n") 62 | sys.exit(1) 63 | 64 | # Test: List recently viewed notebooks 65 | print("Test: Listing recently viewed notebooks...") 66 | try: 67 | response = client.list_recently_viewed() 68 | print(f"✓ Found {len(response.notebooks)} notebook(s)") 69 | for nb in response.notebooks[:3]: # Show first 3 70 | print(f" - {nb.title} ({nb.name})") 71 | print() 72 | except NblmError as e: 73 | print(f"✗ Failed to list notebooks: {e}\n") 74 | 75 | # Test: Delete the created notebook 76 | print("Test: Deleting the test notebook...") 77 | try: 78 | response = client.delete_notebooks([notebook.name]) 79 | print(f"✓ Deleted {len(response.deleted_notebooks)} notebook(s)") 80 | for name in response.deleted_notebooks: 81 | print(f" - {name}") 82 | if response.failed_notebooks: 83 | print(f" Failed: {response.failed_notebooks}") 84 | print() 85 | except NblmError as e: 86 | print(f"✗ Failed to delete notebook: {e}\n") 87 | 88 | print("All tests completed with EnvTokenProvider!") 89 | 90 | 91 | if __name__ == "__main__": 92 | main() 93 | -------------------------------------------------------------------------------- /.github/workflows/rust-release.yml: -------------------------------------------------------------------------------- 1 | name: Release Rust 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | build: 13 | name: Build ${{ matrix.target }} 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | include: 18 | - os: ubuntu-latest 19 | target: x86_64-unknown-linux-gnu 20 | artifact: nblm-linux-x86_64.tar.gz 21 | binary: nblm 22 | use-cross: false 23 | - os: ubuntu-latest 24 | target: aarch64-unknown-linux-gnu 25 | artifact: nblm-linux-aarch64.tar.gz 26 | binary: nblm 27 | use-cross: true 28 | - os: macos-latest 29 | target: x86_64-apple-darwin 30 | artifact: nblm-macos-x86_64.tar.gz 31 | binary: nblm 32 | use-cross: false 33 | - os: macos-latest 34 | target: aarch64-apple-darwin 35 | artifact: nblm-macos-aarch64.tar.gz 36 | binary: nblm 37 | use-cross: false 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 41 | 42 | - name: Install toolchain 43 | uses: dtolnay/rust-toolchain@stable 44 | with: 45 | targets: ${{ matrix.target }} 46 | 47 | - name: Install cross 48 | if: matrix.use-cross == true 49 | uses: taiki-e/install-action@e43a5023a747770bfcb71ae048541a681714b951 # v2.62.33 50 | with: 51 | tool: cross 52 | 53 | - name: Build release binary (cross) 54 | if: matrix.use-cross == true 55 | run: cross build --release --package nblm-cli --target ${{ matrix.target }} 56 | 57 | - name: Build release binary (native) 58 | if: matrix.use-cross != true 59 | run: cargo build --release --package nblm-cli --target ${{ matrix.target }} 60 | 61 | - name: Package binary 62 | run: | 63 | mkdir -p dist 64 | STRIP=strip 65 | if command -v ${STRIP} >/dev/null 2>&1; then 66 | ${STRIP} target/${{ matrix.target }}/release/${{ matrix.binary }} || true 67 | fi 68 | tar -C target/${{ matrix.target }}/release -czf dist/${{ matrix.artifact }} ${{ matrix.binary }} 69 | 70 | - name: Upload artifact 71 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 72 | with: 73 | name: ${{ matrix.artifact }} 74 | path: dist/${{ matrix.artifact }} 75 | 76 | - name: Generate SHA256 checksum 77 | run: | 78 | cd dist 79 | if command -v sha256sum &> /dev/null; then 80 | sha256sum ${{ matrix.artifact }} > ${{ matrix.artifact }}.sha256 81 | else 82 | shasum -a 256 ${{ matrix.artifact }} > ${{ matrix.artifact }}.sha256 83 | fi 84 | 85 | - name: Upload checksum 86 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 87 | with: 88 | name: ${{ matrix.artifact }}.sha256 89 | path: dist/${{ matrix.artifact }}.sha256 90 | 91 | release: 92 | needs: build 93 | runs-on: ubuntu-latest 94 | permissions: 95 | contents: write 96 | steps: 97 | - name: Download artifacts 98 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 99 | with: 100 | path: artifacts 101 | merge-multiple: true 102 | 103 | - name: Publish GitHub Release 104 | uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 105 | with: 106 | files: artifacts/* 107 | generate_release_notes: true 108 | draft: true 109 | env: 110 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 111 | -------------------------------------------------------------------------------- /crates/nblm-core/src/auth/oauth/config.rs: -------------------------------------------------------------------------------- 1 | use super::{OAuthConfig, OAuthError, Result}; 2 | 3 | /// OAuth client configuration loaded from the environment. 4 | #[derive(Debug, Clone)] 5 | pub struct OAuthClientConfig { 6 | pub client_id: String, 7 | pub client_secret: Option, 8 | pub redirect_uri: String, 9 | pub audience: Option, 10 | } 11 | 12 | impl OAuthClientConfig { 13 | /// Load client configuration from environment variables. 14 | pub fn from_env() -> Result { 15 | let client_id = std::env::var("NBLM_OAUTH_CLIENT_ID") 16 | .map_err(|_| OAuthError::MissingEnvVar("NBLM_OAUTH_CLIENT_ID"))?; 17 | 18 | let client_secret = std::env::var("NBLM_OAUTH_CLIENT_SECRET").ok(); 19 | let redirect_uri = std::env::var("NBLM_OAUTH_REDIRECT_URI") 20 | .unwrap_or_else(|_| OAuthConfig::DEFAULT_REDIRECT_URI.to_string()); 21 | let audience = std::env::var("NBLM_OAUTH_AUDIENCE").ok(); 22 | 23 | Ok(Self { 24 | client_id, 25 | client_secret, 26 | redirect_uri, 27 | audience, 28 | }) 29 | } 30 | 31 | /// Convert this configuration into a complete `OAuthConfig` value. 32 | pub fn into_oauth_config(self) -> OAuthConfig { 33 | OAuthConfig { 34 | auth_endpoint: OAuthConfig::AUTH_ENDPOINT.to_string(), 35 | token_endpoint: OAuthConfig::TOKEN_ENDPOINT.to_string(), 36 | client_id: self.client_id, 37 | client_secret: self.client_secret, 38 | redirect_uri: self.redirect_uri, 39 | scopes: vec![ 40 | OAuthConfig::SCOPE_CLOUD_PLATFORM.to_string(), 41 | OAuthConfig::SCOPE_DRIVE_FILE.to_string(), 42 | ], 43 | audience: self.audience, 44 | additional_params: Default::default(), 45 | } 46 | } 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use super::*; 52 | use serial_test::serial; 53 | 54 | struct EnvGuard { 55 | key: &'static str, 56 | original: Option, 57 | } 58 | 59 | impl EnvGuard { 60 | fn new(key: &'static str) -> Self { 61 | Self { 62 | key, 63 | original: std::env::var(key).ok(), 64 | } 65 | } 66 | } 67 | 68 | impl Drop for EnvGuard { 69 | fn drop(&mut self) { 70 | if let Some(value) = &self.original { 71 | std::env::set_var(self.key, value); 72 | } else { 73 | std::env::remove_var(self.key); 74 | } 75 | } 76 | } 77 | 78 | #[test] 79 | #[serial] 80 | fn from_env_requires_client_id() { 81 | let _guard = EnvGuard::new("NBLM_OAUTH_CLIENT_ID"); 82 | std::env::remove_var("NBLM_OAUTH_CLIENT_ID"); 83 | let err = OAuthClientConfig::from_env().unwrap_err(); 84 | assert!(matches!( 85 | err, 86 | OAuthError::MissingEnvVar("NBLM_OAUTH_CLIENT_ID") 87 | )); 88 | } 89 | 90 | #[test] 91 | #[serial] 92 | fn from_env_uses_defaults() { 93 | let _guard_id = EnvGuard::new("NBLM_OAUTH_CLIENT_ID"); 94 | let _guard_secret = EnvGuard::new("NBLM_OAUTH_CLIENT_SECRET"); 95 | let _guard_redirect = EnvGuard::new("NBLM_OAUTH_REDIRECT_URI"); 96 | let _guard_audience = EnvGuard::new("NBLM_OAUTH_AUDIENCE"); 97 | 98 | std::env::set_var("NBLM_OAUTH_CLIENT_ID", "client-id"); 99 | std::env::remove_var("NBLM_OAUTH_CLIENT_SECRET"); 100 | std::env::remove_var("NBLM_OAUTH_REDIRECT_URI"); 101 | std::env::remove_var("NBLM_OAUTH_AUDIENCE"); 102 | 103 | let config = OAuthClientConfig::from_env().unwrap(); 104 | assert_eq!(config.client_id, "client-id"); 105 | assert_eq!(config.redirect_uri, OAuthConfig::DEFAULT_REDIRECT_URI); 106 | assert!(config.client_secret.is_none()); 107 | assert!(config.audience.is_none()); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crates/nblm-cli/src/ops/auth.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use colored::Colorize; 3 | use std::process::Stdio; 4 | use tokio::process::Command; 5 | 6 | use crate::args::{AuthCommand, AuthSubcommand}; 7 | 8 | pub async fn run(cmd: AuthCommand) -> Result<()> { 9 | match cmd.command { 10 | AuthSubcommand::Login(args) => login(args).await, 11 | AuthSubcommand::Status => status().await, 12 | } 13 | } 14 | 15 | async fn login(args: crate::args::LoginArgs) -> Result<()> { 16 | println!("{}", "Starting Google Cloud authentication...".cyan()); 17 | println!("This will open your browser to authenticate with Google."); 18 | 19 | let mut command = build_login_command(&args); 20 | 21 | println!("(Executing: {:?})\n", command); 22 | 23 | let status = command 24 | .stdin(Stdio::inherit()) 25 | .stdout(Stdio::inherit()) 26 | .stderr(Stdio::inherit()) 27 | .status() 28 | .await 29 | .context("Failed to execute 'gcloud'. Please ensure Google Cloud SDK is installed and in your PATH.")?; 30 | 31 | if status.success() { 32 | println!("\n{}", "Authentication successful!".green().bold()); 33 | println!("You can now use nblm commands."); 34 | } else { 35 | println!("\n{}", "Authentication failed.".red().bold()); 36 | if let Some(code) = status.code() { 37 | println!("gcloud exited with code: {}", code); 38 | } 39 | anyhow::bail!("gcloud auth login failed"); 40 | } 41 | 42 | Ok(()) 43 | } 44 | 45 | async fn status() -> Result<()> { 46 | // Check if we can get a token 47 | let output = Command::new("gcloud") 48 | .arg("auth") 49 | .arg("print-access-token") 50 | .output() 51 | .await 52 | .context("Failed to execute 'gcloud'. Please ensure Google Cloud SDK is installed.")?; 53 | 54 | if !output.status.success() { 55 | println!("{}", "Not authenticated.".yellow()); 56 | println!("Run '{}' to log in.", "nblm auth login".bold()); 57 | anyhow::bail!("Not authenticated"); 58 | } 59 | 60 | // Try to get the current account email for better status info 61 | let account_output = Command::new("gcloud") 62 | .arg("config") 63 | .arg("get-value") 64 | .arg("account") 65 | .output() 66 | .await; 67 | 68 | let account = if let Ok(out) = account_output { 69 | String::from_utf8_lossy(&out.stdout).trim().to_string() 70 | } else { 71 | "Unknown account".to_string() 72 | }; 73 | 74 | println!("{}", "Authenticated".green().bold()); 75 | if !account.is_empty() { 76 | println!("Account: {}", account.cyan()); 77 | } 78 | println!("Backend: gcloud"); 79 | 80 | Ok(()) 81 | } 82 | 83 | fn build_login_command(args: &crate::args::LoginArgs) -> Command { 84 | let mut command = Command::new("gcloud"); 85 | command.arg("auth").arg("login"); 86 | 87 | if args.drive_access { 88 | println!("(Requesting Google Drive access)"); 89 | command.arg("--enable-gdrive-access"); 90 | } 91 | command 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use super::*; 97 | use crate::args::LoginArgs; 98 | 99 | #[test] 100 | fn test_build_login_command_default() { 101 | let args = LoginArgs { 102 | drive_access: false, 103 | }; 104 | let cmd = build_login_command(&args); 105 | let cmd_str = format!("{:?}", cmd); 106 | assert!(cmd_str.contains("gcloud")); 107 | assert!(cmd_str.contains("auth")); 108 | assert!(cmd_str.contains("login")); 109 | assert!(!cmd_str.contains("--enable-gdrive-access")); 110 | } 111 | 112 | #[test] 113 | fn test_build_login_command_drive_access() { 114 | let args = LoginArgs { drive_access: true }; 115 | let cmd = build_login_command(&args); 116 | let cmd_str = format!("{:?}", cmd); 117 | assert!(cmd_str.contains("--enable-gdrive-access")); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/audio.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use reqwest::Method; 3 | 4 | use crate::client::api::backends::{AudioBackend, BackendContext}; 5 | use crate::error::Result; 6 | use crate::models::enterprise::audio::{AudioOverviewRequest, AudioOverviewResponse}; 7 | 8 | use super::models::{ 9 | requests::audio as wire_audio_req, responses::audio::AudioOverviewApiResponse, 10 | }; 11 | 12 | pub(crate) struct EnterpriseAudioBackend { 13 | ctx: BackendContext, 14 | } 15 | 16 | impl EnterpriseAudioBackend { 17 | pub fn new(ctx: BackendContext) -> Self { 18 | Self { ctx } 19 | } 20 | } 21 | 22 | #[async_trait] 23 | impl AudioBackend for EnterpriseAudioBackend { 24 | async fn create_audio_overview( 25 | &self, 26 | notebook_id: &str, 27 | request: AudioOverviewRequest, 28 | ) -> Result { 29 | let path = format!( 30 | "{}/audioOverviews", 31 | self.ctx.url_builder.notebook_path(notebook_id) 32 | ); 33 | let url = self.ctx.url_builder.build_url(&path)?; 34 | 35 | let wire_request: wire_audio_req::AudioOverviewRequest = request.into(); 36 | let api_response: AudioOverviewApiResponse = self 37 | .ctx 38 | .http 39 | .request_json(Method::POST, url, Some(&wire_request)) 40 | .await?; 41 | 42 | Ok(api_response.audio_overview.into()) 43 | } 44 | 45 | async fn delete_audio_overview(&self, notebook_id: &str) -> Result<()> { 46 | let path = format!( 47 | "{}/audioOverviews/default", 48 | self.ctx.url_builder.notebook_path(notebook_id) 49 | ); 50 | let url = self.ctx.url_builder.build_url(&path)?; 51 | let _response: serde_json::Value = self 52 | .ctx 53 | .http 54 | .request_json(Method::DELETE, url, None::<&()>) 55 | .await?; 56 | Ok(()) 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use super::*; 63 | use crate::auth::StaticTokenProvider; 64 | use crate::client::http::HttpClient; 65 | use crate::client::url::new_url_builder; 66 | use crate::client::{RetryConfig, Retryer}; 67 | use crate::env::EnvironmentConfig; 68 | use std::sync::Arc; 69 | use std::time::Duration; 70 | 71 | fn create_test_backend() -> EnterpriseAudioBackend { 72 | let env = EnvironmentConfig::enterprise("123", "global", "us").unwrap(); 73 | let client = reqwest::Client::builder() 74 | .timeout(Duration::from_millis(10)) 75 | .build() 76 | .unwrap(); 77 | let token = Arc::new(StaticTokenProvider::new("token")); 78 | let retryer = Retryer::new(RetryConfig::default()); 79 | let http = Arc::new(HttpClient::new(client, token, retryer, None)); 80 | let url_builder = new_url_builder( 81 | env.profile(), 82 | env.base_url().to_string(), 83 | env.parent_path().to_string(), 84 | ); 85 | let ctx = BackendContext::new(http, url_builder); 86 | EnterpriseAudioBackend::new(ctx) 87 | } 88 | 89 | #[test] 90 | fn create_audio_overview_url_construction() { 91 | let backend = create_test_backend(); 92 | let path = format!( 93 | "{}/audioOverviews", 94 | backend.ctx.url_builder.notebook_path("test-notebook") 95 | ); 96 | let url = backend.ctx.url_builder.build_url(&path).unwrap(); 97 | assert!(url.as_str().contains("test-notebook")); 98 | assert!(url.as_str().contains("audioOverviews")); 99 | assert!(!url.as_str().contains("default")); 100 | } 101 | 102 | #[test] 103 | fn delete_audio_overview_url_construction() { 104 | let backend = create_test_backend(); 105 | let path = format!( 106 | "{}/audioOverviews/default", 107 | backend.ctx.url_builder.notebook_path("test-notebook") 108 | ); 109 | let url = backend.ctx.url_builder.build_url(&path).unwrap(); 110 | assert!(url.as_str().contains("test-notebook")); 111 | assert!(url.as_str().contains("audioOverviews/default")); 112 | } 113 | 114 | #[test] 115 | fn backend_construction() { 116 | let backend = create_test_backend(); 117 | assert!(Arc::strong_count(&backend.ctx.http) >= 1); 118 | assert!(Arc::strong_count(&backend.ctx.url_builder) >= 1); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /crates/nblm-core/src/client/api/backends/enterprise/models/responses/audio.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Wrapper for the API response that contains audioOverview 6 | #[derive(Debug, Clone, Deserialize)] 7 | #[serde(rename_all = "camelCase")] 8 | pub(crate) struct AudioOverviewApiResponse { 9 | pub audio_overview: AudioOverviewResponse, 10 | } 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct AudioOverviewResponse { 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub audio_overview_id: Option, 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub name: Option, 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub status: Option, 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub generation_options: Option, 23 | #[serde(flatten)] 24 | pub extra: HashMap, 25 | } 26 | 27 | #[cfg(test)] 28 | mod tests { 29 | use super::*; 30 | 31 | #[test] 32 | fn audio_overview_response_deserializes_correctly() { 33 | let json = r#"{ 34 | "audioOverviewId": "c825b865-ad95-42d7-8abb-fb4ed46f4cc9", 35 | "name": "projects/224840249322/locations/global/notebooks/123/audioOverviews/456", 36 | "status": "AUDIO_OVERVIEW_STATUS_IN_PROGRESS" 37 | }"#; 38 | let response: AudioOverviewResponse = serde_json::from_str(json).unwrap(); 39 | assert_eq!( 40 | response.audio_overview_id.as_ref().unwrap(), 41 | "c825b865-ad95-42d7-8abb-fb4ed46f4cc9" 42 | ); 43 | assert_eq!( 44 | response.name.as_ref().unwrap(), 45 | "projects/224840249322/locations/global/notebooks/123/audioOverviews/456" 46 | ); 47 | assert_eq!( 48 | response.status.as_ref().unwrap(), 49 | "AUDIO_OVERVIEW_STATUS_IN_PROGRESS" 50 | ); 51 | } 52 | 53 | #[test] 54 | fn audio_overview_response_skips_none_fields_on_serialize() { 55 | let response = AudioOverviewResponse { 56 | audio_overview_id: Some("test-id".to_string()), 57 | name: Some("notebooks/123/audioOverviews/456".to_string()), 58 | status: None, 59 | generation_options: None, 60 | extra: HashMap::new(), 61 | }; 62 | let json = serde_json::to_string(&response).unwrap(); 63 | assert!(json.contains("audioOverviewId")); 64 | assert!(json.contains("name")); 65 | assert!(!json.contains("status")); 66 | assert!(!json.contains("generationOptions")); 67 | } 68 | 69 | #[test] 70 | fn audio_overview_response_with_extra_fields() { 71 | let json = r#"{ 72 | "audioOverviewId": "test-id", 73 | "name": "notebooks/123/audioOverviews/456", 74 | "status": "AUDIO_OVERVIEW_STATUS_IN_PROGRESS", 75 | "generationOptions": {}, 76 | "customField": "value" 77 | }"#; 78 | let response: AudioOverviewResponse = serde_json::from_str(json).unwrap(); 79 | assert_eq!( 80 | response.name.as_ref().unwrap(), 81 | "notebooks/123/audioOverviews/456" 82 | ); 83 | assert_eq!( 84 | response.status.as_ref().unwrap(), 85 | "AUDIO_OVERVIEW_STATUS_IN_PROGRESS" 86 | ); 87 | assert_eq!( 88 | response.extra.get("customField").unwrap().as_str().unwrap(), 89 | "value" 90 | ); 91 | } 92 | 93 | #[test] 94 | fn audio_overview_api_response_deserializes_correctly() { 95 | let json = r#"{ 96 | "audioOverview": { 97 | "audioOverviewId": "c825b865-ad95-42d7-8abb-fb4ed46f4cc9", 98 | "name": "projects/224840249322/locations/global/notebooks/123/audioOverviews/456", 99 | "status": "AUDIO_OVERVIEW_STATUS_IN_PROGRESS", 100 | "generationOptions": {} 101 | } 102 | }"#; 103 | let api_response: AudioOverviewApiResponse = serde_json::from_str(json).unwrap(); 104 | let response = api_response.audio_overview; 105 | assert_eq!( 106 | response.audio_overview_id.as_ref().unwrap(), 107 | "c825b865-ad95-42d7-8abb-fb4ed46f4cc9" 108 | ); 109 | assert_eq!( 110 | response.name.as_ref().unwrap(), 111 | "projects/224840249322/locations/global/notebooks/123/audioOverviews/456" 112 | ); 113 | assert_eq!( 114 | response.status.as_ref().unwrap(), 115 | "AUDIO_OVERVIEW_STATUS_IN_PROGRESS" 116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /.github/workflows/python-release.yml: -------------------------------------------------------------------------------- 1 | name: Python Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | verify-version: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 17 | 18 | - name: Install Python 19 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 20 | with: 21 | python-version: "3.14" 22 | 23 | - name: Verify tag matches package version 24 | run: | 25 | TAG_VERSION="${GITHUB_REF_NAME#v}" 26 | # Extract version from pyproject.toml 27 | PACKAGE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('python/pyproject.toml', 'rb'))['project']['version'])") 28 | echo "Tag version: ${TAG_VERSION}" 29 | echo "Package version: ${PACKAGE_VERSION}" 30 | if [ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]; then 31 | echo "::error::Tag version (${TAG_VERSION}) must match package version (${PACKAGE_VERSION})" 32 | exit 1 33 | fi 34 | 35 | build: 36 | needs: verify-version 37 | name: Build ${{ matrix.os }}-${{ matrix.target }} 38 | runs-on: ${{ matrix.os }} 39 | strategy: 40 | matrix: 41 | include: 42 | # Linux 43 | - os: ubuntu-latest 44 | target: x86_64 45 | - os: ubuntu-latest 46 | target: aarch64 47 | # macOS - use specific runners to avoid cross-compilation issues 48 | - os: macos-13 # Intel 49 | target: x86_64 50 | - os: macos-14 # Apple Silicon 51 | target: aarch64 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 55 | 56 | - name: Install Rust toolchain 57 | uses: dtolnay/rust-toolchain@stable 58 | 59 | - name: Build wheels 60 | uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4 61 | with: 62 | target: ${{ matrix.target }} 63 | args: --release --out dist 64 | sccache: "true" 65 | manylinux: auto 66 | working-directory: python 67 | # Using pyproject.toml configuration (with project-name override) and forwarding CFLAGS so ring's ARM assembly sees __ARM_ARCH when cross-compiling 68 | docker-options: >- 69 | -v ${{ github.workspace }}/crates:/crates 70 | ${{ matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64' && '-e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8 -e CFLAGS_aarch64-unknown-linux-gnu=-D__ARM_ARCH=8' || '' }} 71 | 72 | - name: Upload wheels 73 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 74 | with: 75 | name: wheels-${{ matrix.os }}-${{ matrix.target }} 76 | path: python/dist 77 | 78 | build-sdist: 79 | needs: verify-version 80 | runs-on: ubuntu-latest 81 | steps: 82 | - name: Checkout 83 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 84 | 85 | - name: Install Rust toolchain 86 | uses: dtolnay/rust-toolchain@stable 87 | 88 | - name: Build sdist 89 | uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4 90 | with: 91 | command: sdist 92 | args: --out dist 93 | working-directory: python 94 | # sdist always runs on Linux, so always pass the ARM CFLAGS override 95 | docker-options: >- 96 | -v ${{ github.workspace }}/crates:/crates 97 | -e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8 98 | -e CFLAGS_aarch64-unknown-linux-gnu=-D__ARM_ARCH=8 99 | 100 | - name: Upload sdist 101 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 102 | with: 103 | name: sdist 104 | path: python/dist 105 | 106 | release: 107 | needs: [build, build-sdist] 108 | runs-on: ubuntu-latest 109 | permissions: 110 | contents: write 111 | steps: 112 | - name: Download artifacts 113 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 114 | with: 115 | path: artifacts 116 | merge-multiple: true 117 | 118 | - name: Publish GitHub Release 119 | uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 120 | with: 121 | files: artifacts/* 122 | generate_release_notes: true 123 | draft: true 124 | env: 125 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 126 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # nblm-rs Documentation 2 | 3 | Complete documentation for the NotebookLM Enterprise API client (CLI & Python SDK). 4 | 5 | !!! important "Unofficial Project" 6 | This project is not affiliated with, sponsored, or endorsed by Google. nblm-rs is an independent, unofficial tool. It is provided "as is" without any warranty. 7 | 8 | ## Getting Started 9 | 10 | **New to nblm-rs?** Start here: 11 | 12 | - [Installation](getting-started/installation.md) - Install CLI or Python SDK 13 | - [Authentication](getting-started/authentication.md) - Set up authentication with gcloud 14 | - [Configuration](getting-started/configuration.md) - Project numbers, locations, environment variables 15 | 16 | ## Features 17 | 18 | !!! note "API Status" 19 | The NotebookLM API is currently in **alpha**. Some features may not work as documented due to API limitations. See [API Limitations](api/limitations.md) for details. 20 | 21 | ### Notebooks 22 | 23 | | Feature | CLI | Python | Status | Notes | 24 | | --------------------- | --- | ------ | ------- | ------------------------------------ | 25 | | Create notebook | ✅ | ✅ | Working | | 26 | | List recent notebooks | ✅ | ✅ | Working | Pagination not implemented by API | 27 | | Delete notebook(s) | ✅ | ✅ | Working | Sequential deletion (API limitation) | 28 | 29 | ### Sources 30 | 31 | | Feature | CLI | Python | Status | Notes | 32 | | ------------------- | --- | ------ | ------- | --------------------------- | 33 | | Add web URL | ✅ | ✅ | Working | | 34 | | Add text content | ✅ | ✅ | Working | | 35 | | Add video (YouTube) | ✅ | ✅ | Working | Uses `youtubeUrl` field | 36 | | Add Google Drive | ✅ | ✅ | Working | Requires Drive-enabled auth | 37 | | Upload file | ✅ | ✅ | Working | | 38 | | Delete source(s) | ✅ | ✅ | Working | | 39 | | Get source by ID | ✅ | ✅ | Working | | 40 | 41 | ### Audio Overview 42 | 43 | | Feature | CLI | Python | Status | Notes | 44 | | --------------------- | --- | ------ | ------- | --------------------------- | 45 | | Create audio overview | ✅ | ✅ | Working | Config fields not supported | 46 | | Delete audio overview | ✅ | ✅ | Working | | 47 | 48 | ### Sharing 49 | 50 | | Feature | CLI | Python | Status | Notes | 51 | | -------------- | --- | ------ | -------- | ------------------------- | 52 | | Share notebook | ✅ | ❌ | Untested | Requires additional users | 53 | 54 | ## CLI Reference 55 | 56 | Complete command-line interface documentation: 57 | 58 | - [CLI Overview](cli/README.md) - Command structure and common options 59 | - [Notebooks Commands](cli/notebooks.md) - Create, list, and delete notebooks 60 | - [Sources Commands](cli/sources.md) - Add, upload, and manage sources 61 | - [Audio Commands](cli/audio.md) - Create and delete audio overviews 62 | - [Doctor Command](cli/doctor.md) - Run environment diagnostics 63 | 64 | ## Python SDK Reference 65 | 66 | Python bindings documentation: 67 | 68 | - [Python SDK Overview](python/README.md) - Installation and basic usage 69 | - [Quickstart](python/quickstart.md) - Get started in 5 minutes 70 | - [API Reference](python/api-reference.md) - All classes and methods 71 | - [Source Management](python/sources.md) - Source operations in detail 72 | - [Notebooks API](python/notebooks.md) - Notebook operations in detail 73 | - [Audio API](python/audio.md) - Audio overview operations 74 | - [Error Handling](python/error-handling.md) - Exception handling patterns 75 | 76 | ## Rust SDK 77 | 78 | Rust library documentation: 79 | 80 | - [Getting Started](rust/getting-started.md) - Rust SDK setup and usage 81 | 82 | !!! note "Work in Progress" 83 | The Rust SDK is currently being refactored. The Getting Started guide will be updated once the new core APIs are finalized. 84 | 85 | ## Guides 86 | 87 | Additional guides and tutorials: 88 | 89 | - [Troubleshooting](guides/troubleshooting.md) - Common issues and solutions 90 | 91 | ## API Information 92 | 93 | - [API Limitations](api/limitations.md) - Known limitations and workarounds 94 | - [NotebookLM API Documentation](https://cloud.google.com/gemini/enterprise/notebooklm-enterprise/docs/overview) - Official API docs 95 | 96 | ## Contributing 97 | 98 | - [Contributing Guide](https://github.com/K-dash/nblm-rs/blob/main/CONTRIBUTING.md) - Development setup and guidelines 99 | 100 | --- 101 | 102 | !!! note 103 | The `investigation/` directory contains internal research notes and experiments with the NotebookLM API. 104 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "Tag version (e.g., v0.1.2)" 11 | required: false 12 | type: string 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | jobs: 18 | verify-version: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 23 | 24 | - name: Install Python 25 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 26 | with: 27 | python-version: "3.14" 28 | 29 | - name: Verify tag matches package version 30 | run: | 31 | if [ -n "${{ inputs.tag }}" ]; then 32 | TAG_VERSION="${{ inputs.tag }}" 33 | TAG_VERSION="${TAG_VERSION#v}" 34 | else 35 | TAG_VERSION="${GITHUB_REF_NAME#v}" 36 | fi 37 | PACKAGE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('python/pyproject.toml', 'rb'))['project']['version'])") 38 | echo "Tag version: ${TAG_VERSION}" 39 | echo "Package version: ${PACKAGE_VERSION}" 40 | if [ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]; then 41 | echo "::error::Tag version (${TAG_VERSION}) must match package version (${PACKAGE_VERSION})" 42 | exit 1 43 | fi 44 | 45 | build-wheels: 46 | needs: verify-version 47 | runs-on: ${{ matrix.os }} 48 | strategy: 49 | matrix: 50 | include: 51 | # Linux 52 | - os: ubuntu-latest 53 | target: x86_64 54 | - os: ubuntu-latest 55 | target: aarch64 56 | # macOS - use specific runners to avoid cross-compilation issues 57 | - os: macos-13 # Intel 58 | target: x86_64 59 | - os: macos-14 # Apple Silicon 60 | target: aarch64 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 64 | 65 | - name: Install Rust toolchain 66 | uses: dtolnay/rust-toolchain@stable 67 | 68 | - name: Build wheels 69 | uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4 70 | with: 71 | target: ${{ matrix.target }} 72 | args: --release --out dist 73 | sccache: "true" 74 | manylinux: auto 75 | working-directory: python 76 | # Rely on pyproject.toml (project-name override) and mount crates directory, forwarding ARM CFLAGS when cross-compiling in Docker 77 | docker-options: >- 78 | -v ${{ github.workspace }}/crates:/crates 79 | ${{ matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64' && '-e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8 -e CFLAGS_aarch64-unknown-linux-gnu=-D__ARM_ARCH=8' || '' }} 80 | 81 | - name: Upload wheels 82 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 83 | with: 84 | name: wheels-${{ matrix.os }}-${{ matrix.target }} 85 | path: python/dist 86 | 87 | build-sdist: 88 | needs: verify-version 89 | runs-on: ubuntu-latest 90 | steps: 91 | - name: Checkout 92 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 93 | 94 | - name: Install Rust toolchain 95 | uses: dtolnay/rust-toolchain@stable 96 | 97 | - name: Build sdist 98 | uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4 99 | with: 100 | command: sdist 101 | args: --out dist 102 | working-directory: python 103 | # sdist runs on Linux, so always forward the ARM CFLAGS override 104 | docker-options: >- 105 | -v ${{ github.workspace }}/crates:/crates 106 | -e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8 107 | -e CFLAGS_aarch64-unknown-linux-gnu=-D__ARM_ARCH=8 108 | 109 | - name: Upload sdist 110 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 111 | with: 112 | name: sdist 113 | path: python/dist 114 | 115 | publish: 116 | needs: [build-wheels, build-sdist] 117 | runs-on: ubuntu-latest 118 | permissions: 119 | id-token: write # Required for trusted publishing 120 | steps: 121 | - name: Download artifacts 122 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 123 | with: 124 | path: dist 125 | merge-multiple: true 126 | 127 | - name: Publish to PyPI 128 | uses: pypa/gh-action-pypi-publish@release/v1 129 | with: 130 | packages-dir: dist/ 131 | -------------------------------------------------------------------------------- /docs/getting-started/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configure the NotebookLM Enterprise API client with your Google Cloud project. 4 | 5 | ## Project Number 6 | 7 | The NotebookLM API requires a Google Cloud project number (not project ID). 8 | 9 | ### Get Your Project Number 10 | 11 | ```bash 12 | gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)" 13 | ``` 14 | 15 | Example output: `123456789012` 16 | 17 | ### Difference Between Project ID and Project Number 18 | 19 | - **Project ID**: Human-readable identifier (e.g., `my-project-2024`) 20 | - **Project Number**: Unique numerical identifier (e.g., `123456789012`) 21 | 22 | The API requires the **project number**. 23 | 24 | ## Locations 25 | 26 | The NotebookLM API supports the following multi-region locations: 27 | 28 | | Location | Description | Recommendation | 29 | | -------- | ----------------------------- | --------------------------- | 30 | | `global` | Best performance and features | **Recommended** | 31 | | `us` | United States only | For compliance requirements | 32 | | `eu` | European Union only | For compliance requirements | 33 | 34 | !!! important "Location consistency" 35 | `location` and `endpoint_location` must always be set to the same value. The API treats them as a pair, and mismatched values result in `INVALID_ARGUMENT` errors. 36 | 37 | ## Environment Variables 38 | 39 | Set environment variables to avoid repeating options in every command. 40 | 41 | ### Debug Logging 42 | 43 | Set `NBLM_DEBUG_HTTP=1` to emit full HTTP response bodies for every API call. This works for both the CLI and Python SDK and is handy when you need to inspect raw JSON during contract changes. 44 | 45 | ```bash 46 | # Enable verbose HTTP logging 47 | export NBLM_DEBUG_HTTP=1 48 | ``` 49 | 50 | !!! warning "Sensitive data" 51 | The full response payload can contain sensitive information. Only enable debug logging in trusted environments and disable it once you finish troubleshooting. 52 | 53 | ### CLI 54 | 55 | ```bash 56 | # Required 57 | export NBLM_PROJECT_NUMBER="123456789012" 58 | 59 | # Recommended 60 | export NBLM_LOCATION="global" 61 | export NBLM_ENDPOINT_LOCATION="global" 62 | 63 | # Optional (for specific authentication methods) 64 | export NBLM_ACCESS_TOKEN="your-access-token" 65 | ``` 66 | 67 | ### Python SDK 68 | 69 | ```python 70 | import os 71 | 72 | # Set before creating client 73 | os.environ["NBLM_PROJECT_NUMBER"] = "123456789012" 74 | os.environ["NBLM_LOCATION"] = "global" 75 | os.environ["NBLM_ENDPOINT_LOCATION"] = "global" 76 | ``` 77 | 78 | Or pass directly to the client: 79 | 80 | ```python 81 | from nblm import NblmClient, GcloudTokenProvider 82 | 83 | client = NblmClient( 84 | token_provider=GcloudTokenProvider(), 85 | project_number="123456789012", 86 | location="global", 87 | endpoint_location="global" 88 | ) 89 | ``` 90 | 91 | ## Configuration File 92 | 93 | ### CLI 94 | 95 | The CLI does not currently support a configuration file. Use environment variables instead. 96 | 97 | ### Python SDK 98 | 99 | You can create a configuration wrapper: 100 | 101 | ```python 102 | # config.py 103 | import os 104 | from nblm import NblmClient, GcloudTokenProvider 105 | 106 | def create_client(): 107 | return NblmClient( 108 | token_provider=GcloudTokenProvider(), 109 | project_number=os.getenv("NBLM_PROJECT_NUMBER", "123456789012"), 110 | location=os.getenv("NBLM_LOCATION", "global"), 111 | endpoint_location=os.getenv("NBLM_ENDPOINT_LOCATION", "global"), 112 | ) 113 | ``` 114 | 115 | Then use it in your code: 116 | 117 | ```python 118 | from config import create_client 119 | 120 | client = create_client() 121 | notebook = client.create_notebook(title="My Notebook") 122 | ``` 123 | 124 | ## Verification 125 | 126 | ### CLI 127 | 128 | ```bash 129 | # Should work without additional flags if environment variables are set 130 | nblm notebooks recent 131 | ``` 132 | 133 | ### Python SDK 134 | 135 | ```python 136 | from nblm import NblmClient, GcloudTokenProvider 137 | 138 | client = NblmClient( 139 | token_provider=GcloudTokenProvider(), 140 | project_number="123456789012" 141 | ) 142 | 143 | # Should successfully list notebooks 144 | response = client.list_recently_viewed() 145 | print(f"Found {len(response.notebooks)} notebooks") 146 | ``` 147 | 148 | !!! tip "Validate with doctor" 149 | Once your configuration variables are in place, run [`nblm doctor`](../cli/doctor.md) to verify authentication, project bindings, and location settings before moving to production. 150 | 151 | ## Next Steps 152 | 153 | - [CLI Overview](../cli/README.md) - Start using the CLI 154 | - [Python Quickstart](../python/quickstart.md) - Start using Python SDK 155 | - [Troubleshooting](../guides/troubleshooting.md) - Common configuration issues 156 | -------------------------------------------------------------------------------- /python/src/nblm/_models.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class WebSource: 4 | """Source type for adding web URLs to a notebook.""" 5 | 6 | url: str 7 | name: str | None 8 | 9 | def __init__(self, url: str, name: str | None = None) -> None: 10 | """ 11 | Create a WebSource. 12 | 13 | Args: 14 | url: Web URL to add 15 | name: Optional display name for the source 16 | """ 17 | 18 | class TextSource: 19 | """Source type for adding text content to a notebook.""" 20 | 21 | content: str 22 | name: str | None 23 | 24 | def __init__(self, content: str, name: str | None = None) -> None: 25 | """ 26 | Create a TextSource. 27 | 28 | Args: 29 | content: Text content to add 30 | name: Optional display name for the source 31 | """ 32 | 33 | class GoogleDriveSource: 34 | """Source type for adding Google Drive documents to a notebook.""" 35 | 36 | document_id: str 37 | mime_type: str 38 | name: str | None 39 | 40 | def __init__( 41 | self, 42 | document_id: str, 43 | mime_type: str, 44 | name: str | None = None, 45 | ) -> None: 46 | """ 47 | Create a GoogleDriveSource. 48 | 49 | Args: 50 | document_id: Google Drive document ID 51 | mime_type: MIME type returned by the Drive API 52 | name: Optional display name for the source 53 | """ 54 | 55 | class VideoSource: 56 | """Source type for adding YouTube videos to a notebook.""" 57 | 58 | url: str 59 | 60 | def __init__(self, url: str) -> None: 61 | """ 62 | Create a VideoSource. 63 | 64 | Args: 65 | url: YouTube video URL to add 66 | """ 67 | 68 | class BatchCreateSourcesResponse: 69 | """Response from adding sources to a notebook.""" 70 | 71 | sources: list[NotebookSource] 72 | error_count: int | None 73 | 74 | class BatchDeleteSourcesResponse: 75 | """Response from deleting sources from a notebook.""" 76 | 77 | extra: dict[str, Any] 78 | 79 | class UploadSourceFileResponse: 80 | """Response from uploading a file source to a notebook.""" 81 | 82 | source_id: NotebookSourceId | None 83 | extra: dict[str, Any] 84 | 85 | """Data models for nblm""" 86 | 87 | class NotebookSourceYoutubeMetadata: 88 | """Metadata for YouTube sources that were ingested into a notebook.""" 89 | 90 | channel_name: str | None 91 | video_id: str | None 92 | extra: dict[str, Any] 93 | 94 | class NotebookSourceSettings: 95 | """Source-level ingestion settings returned by the API.""" 96 | 97 | status: str | None 98 | extra: dict[str, Any] 99 | 100 | class NotebookSourceId: 101 | """Internal identifier for a notebook source.""" 102 | 103 | id: str | None 104 | extra: dict[str, Any] 105 | 106 | class NotebookSourceMetadata: 107 | """Timestamps and other attributes describing a notebook source.""" 108 | 109 | source_added_timestamp: str | None 110 | word_count: int | None 111 | youtube_metadata: NotebookSourceYoutubeMetadata | None 112 | extra: dict[str, Any] 113 | 114 | class NotebookSource: 115 | """A single source that has been added to a notebook.""" 116 | 117 | name: str 118 | title: str | None 119 | metadata: NotebookSourceMetadata | None 120 | settings: NotebookSourceSettings | None 121 | source_id: NotebookSourceId | None 122 | extra: dict[str, Any] 123 | 124 | class NotebookMetadata: 125 | """Top-level metadata describing a notebook.""" 126 | 127 | create_time: str | None 128 | is_shareable: bool | None 129 | is_shared: bool | None 130 | last_viewed: str | None 131 | extra: dict[str, Any] 132 | 133 | class Notebook: 134 | """Represents a NotebookLM notebook with structured fields.""" 135 | 136 | name: str | None 137 | title: str 138 | notebook_id: str | None 139 | emoji: str | None 140 | metadata: NotebookMetadata | None 141 | sources: list[NotebookSource] 142 | extra: dict[str, Any] 143 | 144 | class ListRecentlyViewedResponse: 145 | """Response from listing recently viewed notebooks.""" 146 | 147 | notebooks: list[Notebook] 148 | 149 | class BatchDeleteNotebooksResponse: 150 | """Aggregated results from batch notebook deletion.""" 151 | 152 | deleted_notebooks: list[str] 153 | failed_notebooks: list[str] 154 | 155 | class AudioOverviewRequest: 156 | """Request for creating an audio overview. 157 | 158 | Note: As of the current API version, this request must be empty. 159 | All fields are reserved for future use. 160 | """ 161 | 162 | def __init__(self) -> None: 163 | """Create an empty AudioOverviewRequest.""" 164 | 165 | class AudioOverviewResponse: 166 | """Response from creating or getting an audio overview.""" 167 | 168 | audio_overview_id: str | None 169 | name: str | None 170 | status: str | None 171 | generation_options: Any 172 | extra: dict[str, Any] 173 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | # Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | # poetry.lock 109 | # poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | # pdm.lock 116 | # pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | # pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # Redis 135 | *.rdb 136 | *.aof 137 | *.pid 138 | 139 | # RabbitMQ 140 | mnesia/ 141 | rabbitmq/ 142 | rabbitmq-data/ 143 | 144 | # ActiveMQ 145 | activemq-data/ 146 | 147 | # SageMath parsed files 148 | *.sage.py 149 | 150 | # Environments 151 | .env 152 | .envrc 153 | .venv 154 | env/ 155 | venv/ 156 | ENV/ 157 | env.bak/ 158 | venv.bak/ 159 | 160 | # Spyder project settings 161 | .spyderproject 162 | .spyproject 163 | 164 | # Rope project settings 165 | .ropeproject 166 | 167 | # mkdocs documentation 168 | /site 169 | 170 | # mypy 171 | .mypy_cache/ 172 | .dmypy.json 173 | dmypy.json 174 | 175 | # Pyre type checker 176 | .pyre/ 177 | 178 | # pytype static type analyzer 179 | .pytype/ 180 | 181 | # Cython debug symbols 182 | cython_debug/ 183 | 184 | # PyCharm 185 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 186 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 187 | # and can be added to the global gitignore or merged into this file. For a more nuclear 188 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 189 | # .idea/ 190 | 191 | # Abstra 192 | # Abstra is an AI-powered process automation framework. 193 | # Ignore directories containing user credentials, local state, and settings. 194 | # Learn more at https://abstra.io/docs 195 | .abstra/ 196 | 197 | # Visual Studio Code 198 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 199 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 200 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 201 | # you could uncomment the following to ignore the entire vscode folder 202 | # .vscode/ 203 | 204 | # Ruff stuff: 205 | .ruff_cache/ 206 | 207 | # PyPI configuration file 208 | .pypirc 209 | 210 | # Marimo 211 | marimo/_static/ 212 | marimo/_lsp/ 213 | __marimo__/ 214 | 215 | # Streamlit 216 | .streamlit/secrets.toml 217 | -------------------------------------------------------------------------------- /python/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[codz] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | # Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | # poetry.lock 109 | # poetry.toml 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. 114 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control 115 | # pdm.lock 116 | # pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # pixi 121 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. 122 | # pixi.lock 123 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one 124 | # in the .venv directory. It is recommended not to include this directory in version control. 125 | .pixi 126 | 127 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 128 | __pypackages__/ 129 | 130 | # Celery stuff 131 | celerybeat-schedule 132 | celerybeat.pid 133 | 134 | # Redis 135 | *.rdb 136 | *.aof 137 | *.pid 138 | 139 | # RabbitMQ 140 | mnesia/ 141 | rabbitmq/ 142 | rabbitmq-data/ 143 | 144 | # ActiveMQ 145 | activemq-data/ 146 | 147 | # SageMath parsed files 148 | *.sage.py 149 | 150 | # Environments 151 | .env 152 | .envrc 153 | .venv 154 | env/ 155 | venv/ 156 | ENV/ 157 | env.bak/ 158 | venv.bak/ 159 | 160 | # Spyder project settings 161 | .spyderproject 162 | .spyproject 163 | 164 | # Rope project settings 165 | .ropeproject 166 | 167 | # mkdocs documentation 168 | /site 169 | 170 | # mypy 171 | .mypy_cache/ 172 | .dmypy.json 173 | dmypy.json 174 | 175 | # Pyre type checker 176 | .pyre/ 177 | 178 | # pytype static type analyzer 179 | .pytype/ 180 | 181 | # Cython debug symbols 182 | cython_debug/ 183 | 184 | # PyCharm 185 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 186 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 187 | # and can be added to the global gitignore or merged into this file. For a more nuclear 188 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 189 | # .idea/ 190 | 191 | # Abstra 192 | # Abstra is an AI-powered process automation framework. 193 | # Ignore directories containing user credentials, local state, and settings. 194 | # Learn more at https://abstra.io/docs 195 | .abstra/ 196 | 197 | # Visual Studio Code 198 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 199 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 200 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 201 | # you could uncomment the following to ignore the entire vscode folder 202 | # .vscode/ 203 | 204 | # Ruff stuff: 205 | .ruff_cache/ 206 | 207 | # PyPI configuration file 208 | .pypirc 209 | 210 | # Marimo 211 | marimo/_static/ 212 | marimo/_lsp/ 213 | __marimo__/ 214 | 215 | # Streamlit 216 | .streamlit/secrets.toml 217 | -------------------------------------------------------------------------------- /docs/python/README.md: -------------------------------------------------------------------------------- 1 | # Python SDK Overview 2 | 3 | Python bindings for the NotebookLM Enterprise API, powered by Rust via PyO3. 4 | 5 | ## Features 6 | 7 | - **Type-safe API**: Full type hints for IDE autocomplete and static analysis 8 | - **Fast**: Powered by Rust for high performance 9 | - **Easy to use**: Pythonic API with sensible defaults 10 | - **Comprehensive**: Supports notebooks, sources, and audio overviews 11 | 12 | ## Supported Operations 13 | 14 | | Category | Operations | Status | 15 | | ------------------ | ------------------------------------------------- | ------------- | 16 | | **Notebooks** | Create, list, delete | Available | 17 | | **Sources** | Add (web, text, video), upload files, get, delete | Available | 18 | | **Audio Overview** | Create, delete | Available | 19 | | **Sharing** | Share with users | Not available | 20 | 21 | ## Installation 22 | 23 | ```bash 24 | pip install nblm 25 | ``` 26 | 27 | Or with uv: 28 | 29 | ```bash 30 | uv add nblm 31 | ``` 32 | 33 | **Requirements**: Python 3.14 or later 34 | 35 | ## Quick Example 36 | 37 | ```python 38 | from nblm import NblmClient, GcloudTokenProvider, WebSource 39 | 40 | # Initialize client 41 | client = NblmClient( 42 | token_provider=GcloudTokenProvider(), 43 | project_number="123456789012" 44 | ) 45 | 46 | # Create notebook 47 | notebook = client.create_notebook(title="My Notebook") 48 | 49 | # Add sources 50 | client.add_sources( 51 | notebook_id=notebook.notebook_id, 52 | web_sources=[WebSource(url="https://example.com", name="Example")] 53 | ) 54 | 55 | # Create audio overview 56 | audio = client.create_audio_overview(notebook.notebook_id) 57 | print(f"Audio status: {audio.status}") 58 | ``` 59 | 60 | ## Documentation 61 | 62 | ### Getting Started 63 | 64 | - [Quickstart](quickstart.md) - Get started in 5 minutes 65 | - [Authentication](../getting-started/authentication.md) - Set up authentication 66 | - [Configuration](../getting-started/configuration.md) - Configure project and location 67 | 68 | ### API Reference 69 | 70 | - [API Reference](api-reference.md) - Complete API documentation 71 | - [Notebooks API](notebooks.md) - Notebook operations 72 | - [Sources API](sources.md) - Source operations 73 | - [Audio API](audio.md) - Audio overview operations 74 | - [Error Handling](error-handling.md) - Exception handling 75 | 76 | ## Authentication Methods 77 | 78 | ```python 79 | from nblm import ( 80 | GcloudTokenProvider, # Use gcloud CLI 81 | EnvTokenProvider, # Use environment variable 82 | NblmClient 83 | ) 84 | 85 | # Method 1: gcloud CLI (recommended) 86 | provider = GcloudTokenProvider() 87 | 88 | # Method 2: Environment variable 89 | import os 90 | os.environ["NBLM_ACCESS_TOKEN"] = "your-token" 91 | provider = EnvTokenProvider() 92 | 93 | # Create client 94 | client = NblmClient( 95 | token_provider=provider, 96 | project_number="123456789012" 97 | ) 98 | ``` 99 | 100 | ## Debugging HTTP Responses 101 | 102 | Set `NBLM_DEBUG_HTTP=1` before importing `nblm` to print the raw JSON bodies returned by the API. The payload can include notebook contents, so only enable this in trusted environments. 103 | 104 | ```bash 105 | export NBLM_DEBUG_HTTP=1 106 | python monitor_api.py --debug-http 107 | ``` 108 | 109 | ## Type Support 110 | 111 | The SDK includes full type hints: 112 | 113 | ```python 114 | from nblm import ( 115 | NblmClient, 116 | Notebook, 117 | NotebookSource, 118 | AudioOverviewResponse, 119 | ListRecentlyViewedResponse, 120 | BatchCreateSourcesResponse, 121 | WebSource, 122 | TextSource, 123 | VideoSource, 124 | ) 125 | 126 | # All operations are fully typed 127 | client: NblmClient 128 | notebook: Notebook = client.create_notebook(title="Title") 129 | sources: BatchCreateSourcesResponse = client.add_sources(...) 130 | audio: AudioOverviewResponse = client.create_audio_overview(...) 131 | ``` 132 | 133 | ## Quick Start 134 | 135 | ```python 136 | import nblm 137 | 138 | # 1. Authenticate (opens browser) 139 | nblm.login() 140 | 141 | # 2. Initialize client (uses gcloud credentials by default) 142 | client = nblm.NblmClient() 143 | 144 | # 3. List recent notebooks 145 | response = client.list_notebooks() 146 | for notebook in response.notebooks: 147 | print(f"{notebook.title} ({notebook.notebook_id})") 148 | ``` 149 | 150 | ## Error Handling 151 | 152 | ```python 153 | from nblm import NblmClient, NblmError 154 | 155 | try: 156 | notebook = client.create_notebook(title="My Notebook") 157 | except NblmError as e: 158 | print(f"Error: {e}") 159 | ``` 160 | 161 | See [Error Handling](error-handling.md) for details. 162 | 163 | ## Performance 164 | 165 | The Python SDK is powered by Rust, providing: 166 | 167 | - **Fast execution**: Native code performance 168 | - **Memory efficiency**: Rust's memory management 169 | - **Thread safety**: Safe concurrent operations 170 | 171 | ## Limitations 172 | 173 | - **Sharing operations**: Not currently supported 174 | - **Google Drive sources**: Supported via `GoogleDriveSource`; the SDK validates Drive scope (`drive`/`drive.file`) and document access before ingesting, returning an error if the requirements are not met 175 | - **Audio configuration**: API only accepts empty request (as of 2025-10-25) 176 | 177 | ## Next Steps 178 | 179 | - [Quickstart](quickstart.md) - Start building with the Python SDK 180 | - [API Reference](api-reference.md) - Explore all available methods 181 | - [Examples](notebooks.md) - See practical examples 182 | -------------------------------------------------------------------------------- /docs/python/quickstart.md: -------------------------------------------------------------------------------- 1 | # Python SDK Quickstart 2 | 3 | Get started with the NotebookLM Python SDK in 5 minutes. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pip install nblm 9 | ``` 10 | 11 | ## Prerequisites 12 | 13 | - Python 3.14 or later 14 | - Google Cloud project with NotebookLM API enabled 15 | - gcloud CLI installed and authenticated 16 | 17 | ## Basic Setup 18 | 19 | ### 1. Authenticate with gcloud 20 | 21 | ```bash 22 | gcloud auth login 23 | ``` 24 | 25 | ### 2. Get your project number 26 | 27 | ```bash 28 | gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)" 29 | ``` 30 | 31 | Example output: `123456789012` 32 | 33 | ### 3. Create your first notebook 34 | 35 | ```python 36 | from nblm import NblmClient, GcloudTokenProvider 37 | 38 | # Initialize client 39 | client = NblmClient( 40 | token_provider=GcloudTokenProvider(), 41 | project_number="123456789012" 42 | ) 43 | 44 | # Create a notebook 45 | notebook = client.create_notebook(title="My First Notebook") 46 | print(f"Created: {notebook.title}") 47 | print(f"Notebook ID: {notebook.notebook_id}") 48 | ``` 49 | 50 | ## Complete Example 51 | 52 | Here's a complete workflow from creating a notebook to generating an audio overview: 53 | 54 | ```python 55 | from nblm import ( 56 | NblmClient, 57 | GcloudTokenProvider, 58 | WebSource, 59 | TextSource, 60 | AudioOverviewRequest 61 | ) 62 | 63 | # 1. Initialize client 64 | client = NblmClient( 65 | token_provider=GcloudTokenProvider(), 66 | project_number="123456789012" 67 | ) 68 | 69 | # 2. Create a notebook 70 | notebook = client.create_notebook(title="Python Tutorial Notebook") 71 | notebook_id = notebook.notebook_id 72 | print(f"Created notebook: {notebook_id}") 73 | 74 | # 3. Add sources 75 | response = client.add_sources( 76 | notebook_id=notebook_id, 77 | web_sources=[ 78 | WebSource(url="https://docs.python.org/3/", name="Python Docs"), 79 | WebSource(url="https://realpython.com/") 80 | ], 81 | text_sources=[ 82 | TextSource(content="My learning notes", name="Notes") 83 | ] 84 | ) 85 | print(f"Added {len(response.sources)} sources") 86 | 87 | # 4. Upload a file 88 | upload_response = client.upload_source_file( 89 | notebook_id=notebook_id, 90 | path="tutorial.pdf" 91 | ) 92 | print(f"Uploaded file: {upload_response.source_id}") 93 | 94 | # 5. Create audio overview 95 | audio = client.create_audio_overview( 96 | notebook_id=notebook_id, 97 | request=AudioOverviewRequest() 98 | ) 99 | print(f"Audio overview created: {audio.status}") 100 | 101 | # 6. List your notebooks 102 | notebooks = client.list_recently_viewed(page_size=10) 103 | print(f"Total notebooks: {len(notebooks.notebooks)}") 104 | ``` 105 | 106 | ## Authentication Options 107 | 108 | ### Option 1: gcloud CLI (Recommended) 109 | 110 | ```python 111 | from nblm import NblmClient, GcloudTokenProvider 112 | 113 | client = NblmClient( 114 | token_provider=GcloudTokenProvider(), 115 | project_number="123456789012" 116 | ) 117 | ``` 118 | 119 | ### Option 2: Environment Variable 120 | 121 | ```python 122 | import os 123 | from nblm import NblmClient, EnvTokenProvider 124 | 125 | # Set access token 126 | os.environ["NBLM_ACCESS_TOKEN"] = "your-access-token" 127 | 128 | client = NblmClient( 129 | token_provider=EnvTokenProvider(), 130 | project_number="123456789012" 131 | ) 132 | ``` 133 | 134 | ### Option 3: Custom gcloud Binary Path 135 | 136 | ```python 137 | from nblm import NblmClient, GcloudTokenProvider 138 | 139 | client = NblmClient( 140 | token_provider=GcloudTokenProvider(binary="/custom/path/gcloud"), 141 | project_number="123456789012" 142 | ) 143 | ``` 144 | 145 | ## Common Operations 146 | 147 | ### Create and populate a notebook 148 | 149 | ```python 150 | from nblm import NblmClient, GcloudTokenProvider, WebSource 151 | 152 | client = NblmClient( 153 | token_provider=GcloudTokenProvider(), 154 | project_number="123456789012" 155 | ) 156 | 157 | # Create 158 | notebook = client.create_notebook(title="Research Notebook") 159 | 160 | # Add multiple web sources 161 | urls = [ 162 | "https://example.com/article1", 163 | "https://example.com/article2", 164 | "https://example.com/article3" 165 | ] 166 | 167 | client.add_sources( 168 | notebook_id=notebook.notebook_id, 169 | web_sources=[WebSource(url=url) for url in urls] 170 | ) 171 | ``` 172 | 173 | ### Error handling 174 | 175 | ```python 176 | from nblm import NblmClient, GcloudTokenProvider, NblmError 177 | 178 | client = NblmClient( 179 | token_provider=GcloudTokenProvider(), 180 | project_number="123456789012" 181 | ) 182 | 183 | try: 184 | notebook = client.create_notebook(title="Test Notebook") 185 | print(f"Success: {notebook.notebook_id}") 186 | except NblmError as e: 187 | print(f"Failed: {e}") 188 | ``` 189 | 190 | ## Configuration 191 | 192 | ### Using environment variables 193 | 194 | ```python 195 | import os 196 | from nblm import NblmClient, GcloudTokenProvider 197 | 198 | # Set once 199 | os.environ["NBLM_PROJECT_NUMBER"] = "123456789012" 200 | os.environ["NBLM_LOCATION"] = "global" 201 | os.environ["NBLM_ENDPOINT_LOCATION"] = "global" 202 | 203 | # Create client (will use environment variables) 204 | client = NblmClient( 205 | token_provider=GcloudTokenProvider(), 206 | project_number=os.environ["NBLM_PROJECT_NUMBER"] 207 | ) 208 | ``` 209 | 210 | ### Custom locations 211 | 212 | ```python 213 | from nblm import NblmClient, GcloudTokenProvider 214 | 215 | # Use US location (for compliance requirements) 216 | client = NblmClient( 217 | token_provider=GcloudTokenProvider(), 218 | project_number="123456789012", 219 | location="us", 220 | endpoint_location="us" 221 | ) 222 | ``` 223 | 224 | ## Next Steps 225 | 226 | - [API Reference](api-reference.md) - Complete API documentation 227 | - [Notebooks API](notebooks.md) - Detailed notebook operations 228 | - [Sources API](sources.md) - Detailed source operations 229 | - [Error Handling](error-handling.md) - Exception handling patterns 230 | -------------------------------------------------------------------------------- /docs/cli/README.md: -------------------------------------------------------------------------------- 1 | # CLI Overview 2 | 3 | Command-line interface for the NotebookLM Enterprise API. 4 | 5 | ## Command Structure 6 | 7 | ```bash 8 | nblm [GLOBAL_OPTIONS] [COMMAND_OPTIONS] 9 | ``` 10 | 11 | ## Global Options 12 | 13 | Options that can be used with any command: 14 | 15 | | Option | Description | Required | Default | 16 | | -------------------------------- | ------------------------------------------- | -------- | -------- | 17 | | `--auth ` | Authentication method: `gcloud` or `env` | Yes | - | 18 | | `--project-number ` | Google Cloud project number | Yes\* | From env | 19 | | `--location ` | API location: `global`, `us`, or `eu` | No | `global` | 20 | | `--endpoint-location ` | Endpoint location (must match `--location`) | No | `global` | 21 | | `--json` | Output in JSON format | No | false | 22 | | `--debug-http` | Print raw HTTP responses to stderr | No | false | 23 | | `-h, --help` | Print help information | No | - | 24 | | `-V, --version` | Print version information | No | - | 25 | 26 | \*Can be set via `NBLM_PROJECT_NUMBER` environment variable. 27 | 28 | ## Commands 29 | 30 | | Command | Description | Documentation | 31 | | ----------- | --------------------------- | ---------------------------- | 32 | | `doctor` | Run environment diagnostics | [doctor.md](doctor.md) | 33 | | `auth` | Manage authentication | [auth.md](auth.md) | 34 | | `notebooks` | Manage notebooks | [notebooks.md](notebooks.md) | 35 | | `sources` | Manage notebook sources | [sources.md](sources.md) | 36 | | `audio` | Manage audio overviews | [audio.md](audio.md) | 37 | | `share` | Share notebooks with users | [share.md](share.md) | 38 | 39 | ## Authentication 40 | 41 | Two authentication methods are supported: 42 | 43 | ### gcloud CLI (Recommended) 44 | 45 | ```bash 46 | gcloud auth login 47 | nblm --auth gcloud notebooks recent 48 | # or let environment variables fill in project details 49 | nblm --auth gcloud --project-number "$NBLM_PROJECT_NUMBER" notebooks recent 50 | ``` 51 | 52 | ### Environment Variable 53 | 54 | ```bash 55 | export NBLM_ACCESS_TOKEN=$(gcloud auth print-access-token) 56 | nblm --auth env notebooks recent 57 | ``` 58 | 59 | See [Authentication Guide](../getting-started/authentication.md) for details. 60 | 61 | ## Environment Variables 62 | 63 | Reduce command verbosity by setting environment variables: 64 | 65 | ```bash 66 | export NBLM_PROJECT_NUMBER="123456789012" 67 | export NBLM_LOCATION="global" 68 | export NBLM_ENDPOINT_LOCATION="global" 69 | 70 | # Now you can omit these flags 71 | nblm notebooks recent 72 | ``` 73 | 74 | ### Raw HTTP Logging 75 | 76 | Use the new `--debug-http` flag (or set `NBLM_DEBUG_HTTP=1`) to print the raw JSON payload returned by the API. Logged bodies may contain sensitive data, so enable this only on trusted machines. 77 | 78 | ## Output Formats 79 | 80 | ### Human-Readable (Default) 81 | 82 | ```bash 83 | nblm notebooks recent 84 | ``` 85 | 86 | Output: 87 | 88 | ``` 89 | Title: My Notebook 90 | Notebook ID: abc123 91 | Updated: 2025-10-25T10:30:00Z 92 | ``` 93 | 94 | ### JSON Format 95 | 96 | ```bash 97 | nblm --json notebooks recent 98 | ``` 99 | 100 | Output: 101 | 102 | ```json 103 | { 104 | "notebooks": [ 105 | { 106 | "title": "My Notebook", 107 | "notebookId": "abc123", 108 | "updateTime": "2025-10-25T10:30:00Z" 109 | } 110 | ] 111 | } 112 | ``` 113 | 114 | The `--json` flag can be placed anywhere in the command: 115 | 116 | ```bash 117 | # All equivalent 118 | nblm --json notebooks recent 119 | nblm notebooks recent --json 120 | ``` 121 | 122 | ## Error Handling 123 | 124 | ### Exit Codes 125 | 126 | | Code | Description | 127 | | ---- | -------------------- | 128 | | 0 | Success | 129 | | 1 | General error | 130 | | 2 | Authentication error | 131 | 132 | ### Automatic Retries 133 | 134 | The CLI automatically retries transient failures (HTTP 429, 500, 502, 503, 504) with exponential backoff. 135 | 136 | ### Error Messages 137 | 138 | Errors are printed to stderr in a human-readable format: 139 | 140 | ```bash 141 | Error: Failed to create notebook 142 | Cause: API returned 403 Forbidden 143 | ``` 144 | 145 | In JSON mode, errors are also in JSON format: 146 | 147 | ```json 148 | { 149 | "error": "Failed to create notebook", 150 | "cause": "API returned 403 Forbidden" 151 | } 152 | ``` 153 | 154 | ## Getting Help 155 | 156 | ### General Help 157 | 158 | ```bash 159 | nblm --help 160 | ``` 161 | 162 | ### Command-Specific Help 163 | 164 | ```bash 165 | nblm notebooks --help 166 | nblm sources add --help 167 | ``` 168 | 169 | ## Examples 170 | 171 | ### Quick Start 172 | 173 | ```bash 174 | # Set up 175 | export NBLM_PROJECT_NUMBER="123456789012" 176 | gcloud auth login 177 | 178 | # Create notebook 179 | nblm notebooks create --title "My Notebook" 180 | 181 | # List notebooks 182 | nblm notebooks recent 183 | 184 | # Add source 185 | nblm sources add \ 186 | --notebook-id abc123 \ 187 | --web-url "https://example.com" 188 | ``` 189 | 190 | ### JSON Output with jq 191 | 192 | ```bash 193 | # Get all notebook titles 194 | nblm --json notebooks recent | jq '.notebooks[].title' 195 | 196 | # Get first notebook ID 197 | nblm --json notebooks recent | jq -r '.notebooks[0].notebookId' 198 | 199 | # Count notebooks 200 | nblm --json notebooks recent | jq '.notebooks | length' 201 | ``` 202 | 203 | ## Next Steps 204 | 205 | - [Notebooks Commands](notebooks.md) - Notebook management 206 | - [Sources Commands](sources.md) - Source management 207 | - [Audio Commands](audio.md) - Audio overview operations 208 | - [Auth Commands](auth.md) - Authentication management 209 | -------------------------------------------------------------------------------- /crates/nblm-python/src/models/responses.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pyo3::types::{PyDict, PyList}; 3 | 4 | use crate::error::PyResult; 5 | 6 | use super::{extra_to_pydict, Notebook, NotebookSource, NotebookSourceId}; 7 | 8 | #[pyclass(module = "nblm")] 9 | pub struct ListRecentlyViewedResponse { 10 | #[pyo3(get)] 11 | pub notebooks: Py, 12 | } 13 | 14 | #[pymethods] 15 | impl ListRecentlyViewedResponse { 16 | pub fn __repr__(&self, py: Python) -> String { 17 | let count = self.notebooks.bind(py).len(); 18 | format!("ListRecentlyViewedResponse(notebooks={} items)", count) 19 | } 20 | 21 | pub fn __str__(&self, py: Python) -> String { 22 | self.__repr__(py) 23 | } 24 | } 25 | 26 | impl ListRecentlyViewedResponse { 27 | pub fn from_core( 28 | py: Python, 29 | response: nblm_core::models::enterprise::notebook::ListRecentlyViewedResponse, 30 | ) -> PyResult { 31 | let notebooks_list = PyList::empty(py); 32 | for notebook in response.notebooks { 33 | let py_notebook = Notebook::from_core(py, notebook)?; 34 | notebooks_list.append(py_notebook)?; 35 | } 36 | Ok(Self { 37 | notebooks: notebooks_list.unbind(), 38 | }) 39 | } 40 | } 41 | 42 | #[pyclass(module = "nblm")] 43 | pub struct BatchDeleteNotebooksResponse { 44 | #[pyo3(get)] 45 | pub deleted_notebooks: Py, 46 | #[pyo3(get)] 47 | pub failed_notebooks: Py, 48 | } 49 | 50 | #[pymethods] 51 | impl BatchDeleteNotebooksResponse { 52 | pub fn __repr__(&self, py: Python) -> String { 53 | let deleted_count = self.deleted_notebooks.bind(py).len(); 54 | let failed_count = self.failed_notebooks.bind(py).len(); 55 | format!( 56 | "BatchDeleteNotebooksResponse(deleted={}, failed={})", 57 | deleted_count, failed_count 58 | ) 59 | } 60 | 61 | pub fn __str__(&self, py: Python) -> String { 62 | self.__repr__(py) 63 | } 64 | } 65 | 66 | impl BatchDeleteNotebooksResponse { 67 | pub fn from_core( 68 | py: Python, 69 | _response: nblm_core::models::enterprise::notebook::BatchDeleteNotebooksResponse, 70 | deleted: Vec, 71 | failed: Vec, 72 | ) -> PyResult { 73 | let deleted_list = PyList::empty(py); 74 | for name in deleted { 75 | deleted_list.append(name)?; 76 | } 77 | let failed_list = PyList::empty(py); 78 | for name in failed { 79 | failed_list.append(name)?; 80 | } 81 | Ok(Self { 82 | deleted_notebooks: deleted_list.unbind(), 83 | failed_notebooks: failed_list.unbind(), 84 | }) 85 | } 86 | } 87 | 88 | #[pyclass(module = "nblm")] 89 | pub struct BatchCreateSourcesResponse { 90 | #[pyo3(get)] 91 | pub sources: Py, 92 | #[pyo3(get)] 93 | pub error_count: Option, 94 | } 95 | 96 | #[pymethods] 97 | impl BatchCreateSourcesResponse { 98 | pub fn __repr__(&self, py: Python) -> String { 99 | let count = self.sources.bind(py).len(); 100 | format!( 101 | "BatchCreateSourcesResponse(sources={} items, error_count={:?})", 102 | count, self.error_count 103 | ) 104 | } 105 | 106 | pub fn __str__(&self, py: Python) -> String { 107 | self.__repr__(py) 108 | } 109 | } 110 | 111 | impl BatchCreateSourcesResponse { 112 | pub fn from_core( 113 | py: Python, 114 | response: nblm_core::models::enterprise::source::BatchCreateSourcesResponse, 115 | ) -> PyResult { 116 | let sources_list = PyList::empty(py); 117 | for source in response.sources { 118 | let py_source = NotebookSource::from_core(py, source)?; 119 | sources_list.append(py_source)?; 120 | } 121 | Ok(Self { 122 | sources: sources_list.unbind(), 123 | error_count: response.error_count, 124 | }) 125 | } 126 | } 127 | 128 | #[pyclass(module = "nblm")] 129 | pub struct BatchDeleteSourcesResponse { 130 | #[pyo3(get)] 131 | pub extra: Py, 132 | } 133 | 134 | #[pymethods] 135 | impl BatchDeleteSourcesResponse { 136 | pub fn __repr__(&self, py: Python) -> String { 137 | let keys = self.extra.bind(py).len(); 138 | format!("BatchDeleteSourcesResponse(extra_keys={})", keys) 139 | } 140 | 141 | pub fn __str__(&self, py: Python) -> String { 142 | self.__repr__(py) 143 | } 144 | } 145 | 146 | impl BatchDeleteSourcesResponse { 147 | pub fn from_core( 148 | py: Python, 149 | response: nblm_core::models::enterprise::source::BatchDeleteSourcesResponse, 150 | ) -> PyResult { 151 | Ok(Self { 152 | extra: extra_to_pydict(py, &response.extra)?, 153 | }) 154 | } 155 | } 156 | 157 | #[pyclass(module = "nblm")] 158 | pub struct UploadSourceFileResponse { 159 | #[pyo3(get)] 160 | pub source_id: Option>, 161 | #[pyo3(get)] 162 | pub extra: Py, 163 | } 164 | 165 | #[pymethods] 166 | impl UploadSourceFileResponse { 167 | pub fn __repr__(&self, py: Python) -> String { 168 | let has_id = self.source_id.is_some(); 169 | let extra_keys = self.extra.bind(py).len(); 170 | format!( 171 | "UploadSourceFileResponse(source_id={}, extra_keys={})", 172 | has_id, extra_keys 173 | ) 174 | } 175 | 176 | pub fn __str__(&self, py: Python) -> String { 177 | self.__repr__(py) 178 | } 179 | } 180 | 181 | impl UploadSourceFileResponse { 182 | pub fn from_core( 183 | py: Python, 184 | response: nblm_core::models::enterprise::source::UploadSourceFileResponse, 185 | ) -> PyResult { 186 | let source_id = match response.source_id { 187 | Some(id) => Some(Py::new(py, NotebookSourceId::from_core(py, id)?)?), 188 | None => None, 189 | }; 190 | Ok(Self { 191 | source_id, 192 | extra: extra_to_pydict(py, &response.extra)?, 193 | }) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [config] 2 | skip_core_tasks = true 3 | default_to_workspace = false 4 | 5 | [env] 6 | UV_RUN_ARGS = "run;--project;python;--extra;dev;--directory;python" 7 | 8 | [tasks.before-build] 9 | 10 | # ------------------------- Pre-commit ------------------------- 11 | [tasks.install] 12 | description = "Install prek and set up git hooks" 13 | dependencies = ["install-prek", "setup-prek"] 14 | 15 | [tasks.install-prek] 16 | description = "Install prek using cargo install" 17 | command = "cargo" 18 | args = ["install", "--locked", "--git", "https://github.com/j178/prek"] 19 | 20 | [tasks.setup-prek] 21 | description = "Set up prek git hooks" 22 | command = "prek" 23 | args = ["install"] 24 | 25 | [tasks.default] 26 | description = "Default task" 27 | dependencies = ["fmt", "lint", "check"] 28 | 29 | [tasks.fmt] 30 | description = "Apply rustfmt to all projects" 31 | command = "cargo" 32 | args = ["fmt", "--all"] 33 | 34 | [tasks.fmt-ci] 35 | description = "Verify formatting" 36 | command = "cargo" 37 | args = ["fmt", "--all", "--", "--check"] 38 | 39 | [tasks.lint] 40 | description = "Verify zero clippy warnings" 41 | command = "cargo" 42 | args = [ 43 | "clippy", 44 | "--workspace", 45 | "--exclude", 46 | "nblm-python", 47 | "--all-targets", 48 | "--all-features", 49 | "--", 50 | "-D", 51 | "warnings", 52 | ] 53 | 54 | [tasks.test] 55 | description = "Run test suite" 56 | command = "cargo" 57 | args = ["test", "--workspace", "--exclude", "nblm-python"] 58 | 59 | [tasks.check] 60 | dependencies = ["before-build"] 61 | command = "cargo" 62 | args = ["check", "--workspace", "--exclude", "nblm-python"] 63 | 64 | [tasks.build] 65 | dependencies = ["before-build"] 66 | command = "cargo" 67 | args = ["build", "--workspace", "--exclude", "nblm-python", "${@}"] 68 | 69 | [tasks.clean] 70 | dependencies = ["before-build"] 71 | command = "cargo" 72 | args = ["clean"] 73 | 74 | [tasks.fetch] 75 | dependencies = ["before-build"] 76 | command = "cargo" 77 | args = ["fetch"] 78 | 79 | [tasks.coverage] 80 | description = "Generate coverage report using cargo-llvm-cov" 81 | dependencies = ["before-build"] 82 | command = "cargo" 83 | args = [ 84 | "llvm-cov", 85 | "--workspace", 86 | "--all-features", 87 | "--exclude", 88 | "nblm-python", 89 | "--open", 90 | ] 91 | 92 | [tasks.all] 93 | description = "Run fmt, lint, and test together" 94 | dependencies = ["install","fmt", "lint", "test", "py-all"] 95 | 96 | # ------------------------- CI ------------------------- 97 | [tasks.ci] 98 | description = "Run CI checks (fmt, clippy, tests)" 99 | dependencies = ["fmt-ci", "clippy-ci", "test-ci"] 100 | 101 | [tasks.clippy-ci] 102 | dependencies = ["before-build"] 103 | command = "cargo" 104 | args = [ 105 | "clippy", 106 | "--locked", 107 | "--workspace", 108 | "--exclude", 109 | "nblm-python", 110 | "--all-targets", 111 | "--all-features", 112 | "--", 113 | "-D", 114 | "warnings", 115 | ] 116 | 117 | [tasks.test-ci] 118 | dependencies = ["before-build"] 119 | command = "cargo" 120 | args = [ 121 | "test", 122 | "--locked", 123 | "--workspace", 124 | "--exclude", 125 | "nblm-python", 126 | "--all-targets", 127 | ] 128 | 129 | # ------------------------- Version Management ------------------------- 130 | [tasks.bump] 131 | description = "Bump version across all packages (Usage: ./scripts/bump-version.sh )" 132 | script_runner = "@shell" 133 | script = ''' 134 | echo "To bump version, run:" 135 | echo " ./scripts/bump-version.sh " 136 | echo "" 137 | echo "Example:" 138 | echo " ./scripts/bump-version.sh 0.1.1" 139 | ''' 140 | 141 | # ------------------------- Python ------------------------- 142 | [tasks.py-fmt] 143 | description = "Format Python code with ruff" 144 | dependencies = ["py-setup"] 145 | command = "uv" 146 | args = ["@@split(UV_RUN_ARGS,;)", "ruff", "format", "src", "tests"] 147 | 148 | [tasks.py-fmt-check] 149 | description = "Check Python code formatting" 150 | dependencies = ["py-setup"] 151 | command = "uv" 152 | args = ["@@split(UV_RUN_ARGS,;)", "ruff", "format", "--check", "src", "tests"] 153 | 154 | [tasks.py-lint] 155 | description = "Lint Python code with ruff" 156 | dependencies = ["py-setup"] 157 | command = "uv" 158 | args = ["@@split(UV_RUN_ARGS,;)", "ruff", "check", "src", "tests"] 159 | 160 | [tasks.py-lint-fix] 161 | description = "Lint and fix Python code with ruff" 162 | dependencies = ["py-setup"] 163 | command = "uv" 164 | args = ["@@split(UV_RUN_ARGS,;)", "ruff", "check", "--fix", "src", "tests"] 165 | 166 | [tasks.py-type] 167 | description = "Run mypy type checking" 168 | dependencies = ["py-setup"] 169 | command = "uv" 170 | args = ["@@split(UV_RUN_ARGS,;)", "mypy", "src", "tests"] 171 | 172 | [tasks.py-test] 173 | description = "Run Python tests with pytest" 174 | dependencies = ["py-setup"] 175 | command = "uv" 176 | args = ["@@split(UV_RUN_ARGS,;)", "pytest", "tests", "-v"] 177 | 178 | [tasks.py-build] 179 | description = "Build Python package with maturin" 180 | dependencies = ["py-setup"] 181 | command = "uv" 182 | args = ["@@split(UV_RUN_ARGS,;)", "maturin", "develop"] 183 | 184 | [tasks.py-all] 185 | description = "Run all Python checks (fmt, lint, type, test)" 186 | dependencies = ["py-fmt", "py-lint-fix", "py-type", "py-build"] 187 | 188 | [tasks.py-setup] 189 | private = true 190 | condition = { files_modified = { input = [ 191 | "python/pyproject.toml", 192 | "python/uv.lock", 193 | ], output = [ 194 | "python/.venv/timestamp.txt", 195 | ] } } 196 | cwd = "python" 197 | command = "bash" 198 | args = [ 199 | "-euo", 200 | "pipefail", 201 | "-c", 202 | "uv venv --clear && uv sync --frozen && touch .venv/timestamp.txt", 203 | ] 204 | 205 | # ------------------------- Docs ------------------------- 206 | [tasks.docs-serve] 207 | description = "Serve documentation locally" 208 | command = "uv" 209 | args = [ 210 | "run", 211 | "--project", 212 | "docs", 213 | "mkdocs", 214 | "serve", 215 | ] 216 | 217 | [tasks.docs-deploy] 218 | description = "Deploy documentation to GitHub Pages (manual emergency deployment)" 219 | command = "uv" 220 | args = [ 221 | "run", 222 | "--project", 223 | "docs", 224 | "mkdocs", 225 | "gh-deploy", 226 | "--config-file", 227 | "mkdocs.yml", 228 | "--remote-name", 229 | "origin", 230 | "--remote-branch", 231 | "gh-pages", 232 | "--force", 233 | ] 234 | --------------------------------------------------------------------------------