From d06d412a27bf57bbaa7de56d609ed27fb95be1dc Mon Sep 17 00:00:00 2001 From: Math Date: Wed, 5 Aug 2026 06:48:22 -0500 Subject: [PATCH 1/2] fix(upload): align local gate with 43 supported extensions --- docs/FILE_UPLOAD_IMPLEMENTATION.md | 13 ++- src/notebooklm_tools/cli/ai_docs.py | 3 +- src/notebooklm_tools/core/constants.py | 32 +++++++ src/notebooklm_tools/core/sources.py | 38 +++----- src/notebooklm_tools/data/SKILL.md | 9 +- src/notebooklm_tools/mcp/tools/sources.py | 13 ++- tests/test_file_upload.py | 110 ++++++++++++++++++++++ 7 files changed, 174 insertions(+), 44 deletions(-) diff --git a/docs/FILE_UPLOAD_IMPLEMENTATION.md b/docs/FILE_UPLOAD_IMPLEMENTATION.md index 853f587d..a6ee6d28 100644 --- a/docs/FILE_UPLOAD_IMPLEMENTATION.md +++ b/docs/FILE_UPLOAD_IMPLEMENTATION.md @@ -44,11 +44,14 @@ source_add(notebook_id="...", source_type="file", file_path="/path/to/file.pdf") ### Supported File Types -- Documents (`.pdf`, `.txt`, `.md`, `.docx`, `.csv`) -- Ebooks (`.epub`) -- Audio (`.mp3`, `.m4a`, `.wav`, `.aac`, `.ogg`, `.opus`) -- Video (`.mp4`) -- Images (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`) +The local gate admits the official 43-extension contract case-insensitively: + +OFFICIAL_FILE_EXTENSIONS: .pdf, .txt, .md, .docx, .csv, .pptx, .epub, .avif, .bmp, .gif, .heic, .heif, .ico, .jp2, .jpe, .jpeg, .jpg, .png, .tif, .tiff, .webp, .3g2, .3gp, .aac, .aif, .aifc, .aiff, .amr, .au, .avi, .cda, .m4a, .mid, .mp3, .mp4, .mpeg, .ogg, .opus, .ra, .ram, .snd, .wav, .wma + +This establishes local admission eligibility only. NotebookLM may still reject or +fail to process corrupt files, misleading extensions, inaccessible media, or +reference-only formats such as some `.cda` and `.ram` files. Those outcomes are +provider ingestion errors, not local unsupported-extension errors. ### Advantages diff --git a/src/notebooklm_tools/cli/ai_docs.py b/src/notebooklm_tools/cli/ai_docs.py index ead71d2f..7cde259b 100644 --- a/src/notebooklm_tools/cli/ai_docs.py +++ b/src/notebooklm_tools/cli/ai_docs.py @@ -233,7 +233,8 @@ nlm source add --drive # Add Drive doc nlm source add --drive --type slides # Add Drive slides # Types: doc, slides, sheets, pdf -# Supported file types: PDF, TXT, MD, DOCX, CSV, EPUB, MP3, M4A, WAV, AAC, OGG, OPUS, MP4, JPG, JPEG, PNG, GIF, WEBP +# OFFICIAL_FILE_EXTENSIONS: .pdf, .txt, .md, .docx, .csv, .pptx, .epub, .avif, .bmp, .gif, .heic, .heif, .ico, .jp2, .jpe, .jpeg, .jpg, .png, .tif, .tiff, .webp, .3g2, .3gp, .aac, .aif, .aifc, .aiff, .amr, .au, .avi, .cda, .m4a, .mid, .mp3, .mp4, .mpeg, .ogg, .opus, .ra, .ram, .snd, .wav, .wma +# Local admission does not guarantee provider processing success for every file. nlm source get # Get source metadata nlm source get --json # JSON output diff --git a/src/notebooklm_tools/core/constants.py b/src/notebooklm_tools/core/constants.py index 1002ff1c..edfee1eb 100644 --- a/src/notebooklm_tools/core/constants.py +++ b/src/notebooklm_tools/core/constants.py @@ -146,6 +146,38 @@ def names(self) -> list[str]: } ) +# ============================================================================= +# Official local file-upload admission contract +# ============================================================================= +# This registry controls local eligibility only. NotebookLM may still reject or +# fail to process corrupt, misleading, inaccessible, or reference-only files. +SUPPORTED_FILE_EXTENSIONS: frozenset[str] = frozenset( + { + ".pdf", ".txt", ".md", ".docx", ".csv", ".pptx", ".epub", + ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jp2", + ".jpe", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp", + ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", + ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", + ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", + ".wma", + } +) + +# Media sources can transiently report an upstream processing error immediately +# after upload. This is a processing classification, not a second admission list. +TRANSIENT_MEDIA_FILE_EXTENSIONS: frozenset[str] = frozenset( + { + ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", + ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", + ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", + ".wma", + } +) + +if len(SUPPORTED_FILE_EXTENSIONS) != 43: # pragma: no cover - import-time invariant + raise RuntimeError("NotebookLM official file extension contract must contain 43 entries.") + + # ============================================================================= # Source Types (Notebook Content) # ============================================================================= diff --git a/src/notebooklm_tools/core/sources.py b/src/notebooklm_tools/core/sources.py index ada0c6f4..bb548630 100644 --- a/src/notebooklm_tools/core/sources.py +++ b/src/notebooklm_tools/core/sources.py @@ -944,7 +944,9 @@ def add_file( 2. Start upload session with SOURCE_ID → get upload URL 3. Stream upload file content (memory-efficient for large files) - Supported file types: PDF, TXT, MD, DOCX, CSV, EPUB, MP3, M4A, WAV, AAC, OGG, OPUS, MP4, JPG, PNG, GIF, WEBP + Supported local-admission formats are defined by the official 43-extension + registry in core.constants. Provider processing can still fail after upload. + OFFICIAL_FILE_EXTENSIONS: .pdf, .txt, .md, .docx, .csv, .pptx, .epub, .avif, .bmp, .gif, .heic, .heif, .ico, .jp2, .jpe, .jpeg, .jpg, .png, .tif, .tiff, .webp, .3g2, .3gp, .aac, .aif, .aifc, .aiff, .amr, .au, .avi, .cda, .m4a, .mid, .mp3, .mp4, .mpeg, .ogg, .opus, .ra, .ram, .snd, .wav, .wma Args: notebook_id: The notebook ID to add the source to @@ -975,32 +977,13 @@ def add_file( if file_size == 0: raise FileValidationError(f"File is empty: {file_path}") - # Validate file type - supported_extensions = { - ".pdf", - ".txt", - ".md", - ".docx", - ".csv", # Documents - ".epub", # Ebooks - ".mp3", - ".m4a", - ".wav", - ".aac", - ".ogg", - ".opus", # Audio - ".mp4", # Video - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webp", # Images - } + # Validate local admission eligibility against the provider contract. file_extension = file_path.suffix.lower() - if file_extension not in supported_extensions: + if file_extension not in constants.SUPPORTED_FILE_EXTENSIONS: raise FileValidationError( - f"Unsupported file type: {file_extension}\n" - f"Supported types: {', '.join(sorted(supported_extensions))}" + f"Unsupported file type: {file_extension or '[none]'}\n" + "Supported types: " + f"{', '.join(sorted(constants.SUPPORTED_FILE_EXTENSIONS))}" ) # Step 1: Register source intent → get SOURCE_ID @@ -1015,12 +998,13 @@ def add_file( result = {"id": source_id, "title": filename} if wait: - media_extensions = {".mp3", ".m4a", ".wav", ".aac", ".ogg", ".opus", ".mp4"} return self.wait_for_source_ready( notebook_id, source_id, wait_timeout, - allow_transient_error=file_extension in media_extensions, + allow_transient_error=( + file_extension in constants.TRANSIENT_MEDIA_FILE_EXTENSIONS + ), ) return result diff --git a/src/notebooklm_tools/data/SKILL.md b/src/notebooklm_tools/data/SKILL.md index e296a488..76be1703 100644 --- a/src/notebooklm_tools/data/SKILL.md +++ b/src/notebooklm_tools/data/SKILL.md @@ -197,10 +197,11 @@ Use `source_add` with these `source_type` values: - `url` - Web page or YouTube URL (`url` param) - `text` - Pasted content (`text` + `title` params) - `file` - Server-local file upload (`file_path` param). The path must exist on - the machine running the MCP server, not merely on the client host. Failures - preserve the concrete reason and include a host-path hint. Supported: - `PDF, TXT, MD, DOCX, CSV, EPUB, MP3, M4A, WAV, AAC, OGG, OPUS, MP4, JPG, - JPEG, PNG, GIF, WEBP`. + the machine running the MCP server, not merely on the client host. Local + admission is case-insensitive and follows the official 43-extension contract: + OFFICIAL_FILE_EXTENSIONS: .pdf, .txt, .md, .docx, .csv, .pptx, .epub, .avif, .bmp, .gif, .heic, .heif, .ico, .jp2, .jpe, .jpeg, .jpg, .png, .tif, .tiff, .webp, .3g2, .3gp, .aac, .aif, .aifc, .aiff, .amr, .au, .avi, .cda, .m4a, .mid, .mp3, .mp4, .mpeg, .ogg, .opus, .ra, .ram, .snd, .wav, .wma + Admission does not guarantee provider processing success. Corrupt, misleading, + inaccessible, or reference-only files can still fail during NotebookLM ingestion. - `drive` - Google Drive doc (`document_id` + `doc_type` params) Other tools: `source_list_drive` (`skip_freshness=True` reports diff --git a/src/notebooklm_tools/mcp/tools/sources.py b/src/notebooklm_tools/mcp/tools/sources.py index cc615d5d..dc9b91b0 100644 --- a/src/notebooklm_tools/mcp/tools/sources.py +++ b/src/notebooklm_tools/mcp/tools/sources.py @@ -36,13 +36,12 @@ def source_add( - url: Web page or YouTube URL - text: Pasted text content - drive: Google Drive document - - file: Local file upload. Supported extensions: - PDF, TXT, MD, DOCX, CSV, EPUB, MP3, M4A, WAV, AAC, OGG, - OPUS, MP4, JPG, JPEG, PNG, GIF, WEBP. Image-bearing - sources (PDF / JPG / PNG / etc.) feed Studio video - generation's visual-crop pipeline — charts, photos, and - diagrams may be extracted as on-screen aids in Video - Overviews. + - file: Local file upload. The canonical local-admission contract has + 43 case-insensitive extensions. Admission does not guarantee provider + processing success for an individual file. + OFFICIAL_FILE_EXTENSIONS: .pdf, .txt, .md, .docx, .csv, .pptx, .epub, .avif, .bmp, .gif, .heic, .heif, .ico, .jp2, .jpe, .jpeg, .jpg, .png, .tif, .tiff, .webp, .3g2, .3gp, .aac, .aif, .aifc, .aiff, .amr, .au, .avi, .cda, .m4a, .mid, .mp3, .mp4, .mpeg, .ogg, .opus, .ra, .ram, .snd, .wav, .wma + Image-bearing sources may feed Studio video generation's visual-crop + pipeline; charts, photos, and diagrams may be extracted as on-screen aids. url: URL to add (for source_type=url) urls: List of URLs to add in bulk (for source_type=url, alternative to url) text: Text content to add (for source_type=text) diff --git a/tests/test_file_upload.py b/tests/test_file_upload.py index 4f72f624..e493965e 100644 --- a/tests/test_file_upload.py +++ b/tests/test_file_upload.py @@ -1,5 +1,7 @@ """Tests for file upload functionality.""" +import inspect +import re import tempfile from pathlib import Path from unittest.mock import MagicMock, Mock, patch @@ -7,7 +9,10 @@ import httpx import pytest +from notebooklm_tools.core import constants from notebooklm_tools.core.exceptions import FileUploadError, FileValidationError +from notebooklm_tools.core.sources import SourceMixin +from notebooklm_tools.mcp.tools.sources import source_add class TestFileValidation: @@ -400,3 +405,108 @@ def temp_notebook(): client.delete_notebook(notebook.id) except Exception: pass # Ignore cleanup errors + + +# Gate F1: official file-extension contract alignment +EXPECTED_EXTENSIONS = frozenset({ + ".pdf", ".txt", ".md", ".docx", ".csv", ".pptx", ".epub", + ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jp2", ".jpe", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp", + ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", ".wma", +}) +EXPECTED_MEDIA_EXTENSIONS = frozenset({ + ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", ".wma", +}) +CONTRACT_PATTERN = re.compile(r"OFFICIAL_FILE_EXTENSIONS:\s*([^\n]+)") + + +def _client() -> SourceMixin: + return SourceMixin.__new__(SourceMixin) + + +def _parse_contract(text: str) -> frozenset[str]: + match = CONTRACT_PATTERN.search(text) + assert match, "official extension contract marker is missing" + return frozenset(item.strip() for item in match.group(1).split(",")) + + +def test_official_registry_is_exact_and_normalized() -> None: + assert len(EXPECTED_EXTENSIONS) == 43 + assert constants.SUPPORTED_FILE_EXTENSIONS == EXPECTED_EXTENSIONS + assert all(item.startswith(".") and item == item.lower() for item in EXPECTED_EXTENSIONS) + assert constants.TRANSIENT_MEDIA_FILE_EXTENSIONS == EXPECTED_MEDIA_EXTENSIONS + assert constants.TRANSIENT_MEDIA_FILE_EXTENSIONS < constants.SUPPORTED_FILE_EXTENSIONS + + +@pytest.mark.parametrize("extension", sorted(EXPECTED_EXTENSIONS)) +def test_all_official_extensions_pass_local_gate(tmp_path: Path, extension: str) -> None: + path = tmp_path / f"evidence{extension}" + path.write_bytes(b"gate-f1") + client = _client() + with ( + patch.object(client, "_register_file_source", return_value="source-id"), + patch.object(client, "_start_resumable_upload", return_value="upload-url"), + patch.object(client, "_upload_file_streaming"), + ): + result = client.add_file("notebook-id", path) + assert result == {"id": "source-id", "title": path.name} + + +@pytest.mark.parametrize("filename", ["slides.PPTX", "photo.HeIc", "clip.3GP", "audio.WmA"]) +def test_extension_gate_is_case_insensitive(tmp_path: Path, filename: str) -> None: + path = tmp_path / filename + path.write_bytes(b"gate-f1") + client = _client() + with ( + patch.object(client, "_register_file_source", return_value="source-id"), + patch.object(client, "_start_resumable_upload", return_value="upload-url"), + patch.object(client, "_upload_file_streaming"), + ): + result = client.add_file("notebook-id", path) + assert result["id"] == "source-id" + + +@pytest.mark.parametrize("filename", ["payload.json", "payload.exe", "extensionless"]) +def test_unlisted_and_extensionless_files_remain_rejected(tmp_path: Path, filename: str) -> None: + path = tmp_path / filename + path.write_bytes(b"gate-f1") + with pytest.raises(FileValidationError, match="Unsupported file type"): + _client().add_file("notebook-id", path) + + +@pytest.mark.parametrize( + ("extension", "expected"), + [(".txt", False), (".pptx", False), (".3gp", True), (".ram", True), (".wma", True)], +) +def test_wait_processing_classification_uses_media_subset( + tmp_path: Path, extension: str, expected: bool +) -> None: + path = tmp_path / f"evidence{extension}" + path.write_bytes(b"gate-f1") + client = _client() + with ( + patch.object(client, "_register_file_source", return_value="source-id"), + patch.object(client, "_start_resumable_upload", return_value="upload-url"), + patch.object(client, "_upload_file_streaming"), + patch.object(client, "wait_for_source_ready", return_value={"id": "source-id"}) as wait, + ): + client.add_file("notebook-id", path, wait=True) + wait.assert_called_once_with( + "notebook-id", "source-id", 120.0, allow_transient_error=expected + ) + + +def test_mcp_schema_docstring_matches_registry() -> None: + assert _parse_contract(inspect.unwrap(source_add).__doc__ or "") == EXPECTED_EXTENSIONS + + +@pytest.mark.parametrize( + "relative_path", + [ + "src/notebooklm_tools/data/SKILL.md", + "src/notebooklm_tools/cli/ai_docs.py", + "docs/FILE_UPLOAD_IMPLEMENTATION.md", + ], +) +def test_bundled_documentation_matches_registry(relative_path: str) -> None: + repository = Path(__file__).resolve().parents[1] + assert _parse_contract((repository / relative_path).read_text(encoding="utf-8")) == EXPECTED_EXTENSIONS From bc78c829b9e717bbda22d317198080a9e64da201 Mon Sep 17 00:00:00 2001 From: Math Date: Wed, 5 Aug 2026 06:54:11 -0500 Subject: [PATCH 2/2] style: apply ruff formatting --- src/notebooklm_tools/core/constants.py | 72 ++++++++++++++++++--- src/notebooklm_tools/core/sources.py | 4 +- tests/test_file_upload.py | 90 ++++++++++++++++++++++---- 3 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/notebooklm_tools/core/constants.py b/src/notebooklm_tools/core/constants.py index edfee1eb..fa944d30 100644 --- a/src/notebooklm_tools/core/constants.py +++ b/src/notebooklm_tools/core/constants.py @@ -153,12 +153,48 @@ def names(self) -> list[str]: # fail to process corrupt, misleading, inaccessible, or reference-only files. SUPPORTED_FILE_EXTENSIONS: frozenset[str] = frozenset( { - ".pdf", ".txt", ".md", ".docx", ".csv", ".pptx", ".epub", - ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jp2", - ".jpe", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp", - ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", - ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", - ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", + ".pdf", + ".txt", + ".md", + ".docx", + ".csv", + ".pptx", + ".epub", + ".avif", + ".bmp", + ".gif", + ".heic", + ".heif", + ".ico", + ".jp2", + ".jpe", + ".jpeg", + ".jpg", + ".png", + ".tif", + ".tiff", + ".webp", + ".3g2", + ".3gp", + ".aac", + ".aif", + ".aifc", + ".aiff", + ".amr", + ".au", + ".avi", + ".cda", + ".m4a", + ".mid", + ".mp3", + ".mp4", + ".mpeg", + ".ogg", + ".opus", + ".ra", + ".ram", + ".snd", + ".wav", ".wma", } ) @@ -167,9 +203,27 @@ def names(self) -> list[str]: # after upload. This is a processing classification, not a second admission list. TRANSIENT_MEDIA_FILE_EXTENSIONS: frozenset[str] = frozenset( { - ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", - ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", - ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", + ".3g2", + ".3gp", + ".aac", + ".aif", + ".aifc", + ".aiff", + ".amr", + ".au", + ".avi", + ".cda", + ".m4a", + ".mid", + ".mp3", + ".mp4", + ".mpeg", + ".ogg", + ".opus", + ".ra", + ".ram", + ".snd", + ".wav", ".wma", } ) diff --git a/src/notebooklm_tools/core/sources.py b/src/notebooklm_tools/core/sources.py index bb548630..40584a76 100644 --- a/src/notebooklm_tools/core/sources.py +++ b/src/notebooklm_tools/core/sources.py @@ -1002,9 +1002,7 @@ def add_file( notebook_id, source_id, wait_timeout, - allow_transient_error=( - file_extension in constants.TRANSIENT_MEDIA_FILE_EXTENSIONS - ), + allow_transient_error=(file_extension in constants.TRANSIENT_MEDIA_FILE_EXTENSIONS), ) return result diff --git a/tests/test_file_upload.py b/tests/test_file_upload.py index e493965e..99fec8d4 100644 --- a/tests/test_file_upload.py +++ b/tests/test_file_upload.py @@ -408,14 +408,79 @@ def temp_notebook(): # Gate F1: official file-extension contract alignment -EXPECTED_EXTENSIONS = frozenset({ - ".pdf", ".txt", ".md", ".docx", ".csv", ".pptx", ".epub", - ".avif", ".bmp", ".gif", ".heic", ".heif", ".ico", ".jp2", ".jpe", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp", - ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", ".wma", -}) -EXPECTED_MEDIA_EXTENSIONS = frozenset({ - ".3g2", ".3gp", ".aac", ".aif", ".aifc", ".aiff", ".amr", ".au", ".avi", ".cda", ".m4a", ".mid", ".mp3", ".mp4", ".mpeg", ".ogg", ".opus", ".ra", ".ram", ".snd", ".wav", ".wma", -}) +EXPECTED_EXTENSIONS = frozenset( + { + ".pdf", + ".txt", + ".md", + ".docx", + ".csv", + ".pptx", + ".epub", + ".avif", + ".bmp", + ".gif", + ".heic", + ".heif", + ".ico", + ".jp2", + ".jpe", + ".jpeg", + ".jpg", + ".png", + ".tif", + ".tiff", + ".webp", + ".3g2", + ".3gp", + ".aac", + ".aif", + ".aifc", + ".aiff", + ".amr", + ".au", + ".avi", + ".cda", + ".m4a", + ".mid", + ".mp3", + ".mp4", + ".mpeg", + ".ogg", + ".opus", + ".ra", + ".ram", + ".snd", + ".wav", + ".wma", + } +) +EXPECTED_MEDIA_EXTENSIONS = frozenset( + { + ".3g2", + ".3gp", + ".aac", + ".aif", + ".aifc", + ".aiff", + ".amr", + ".au", + ".avi", + ".cda", + ".m4a", + ".mid", + ".mp3", + ".mp4", + ".mpeg", + ".ogg", + ".opus", + ".ra", + ".ram", + ".snd", + ".wav", + ".wma", + } +) CONTRACT_PATTERN = re.compile(r"OFFICIAL_FILE_EXTENSIONS:\s*([^\n]+)") @@ -490,9 +555,7 @@ def test_wait_processing_classification_uses_media_subset( patch.object(client, "wait_for_source_ready", return_value={"id": "source-id"}) as wait, ): client.add_file("notebook-id", path, wait=True) - wait.assert_called_once_with( - "notebook-id", "source-id", 120.0, allow_transient_error=expected - ) + wait.assert_called_once_with("notebook-id", "source-id", 120.0, allow_transient_error=expected) def test_mcp_schema_docstring_matches_registry() -> None: @@ -509,4 +572,7 @@ def test_mcp_schema_docstring_matches_registry() -> None: ) def test_bundled_documentation_matches_registry(relative_path: str) -> None: repository = Path(__file__).resolve().parents[1] - assert _parse_contract((repository / relative_path).read_text(encoding="utf-8")) == EXPECTED_EXTENSIONS + assert ( + _parse_contract((repository / relative_path).read_text(encoding="utf-8")) + == EXPECTED_EXTENSIONS + )