Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ dependencies = [
"bm25s>=0.2.0",
"pathspec>=0.12",
"tree-sitter>=0.25,<0.26",
"tree-sitter-language-pack>=1.0,<1.8.0,!=1.6.3",
"orjson",
"questionary>=2.0,<3.0",
"semble-grammars>=0.1.2",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -146,3 +146,6 @@ exclude_also = ["if __name__ == .__main__.:", "@abstractmethod"]

[tool.uv]
exclude-newer = "1 week"

[tool.uv.exclude-newer-package]
semble-grammars = false
4 changes: 2 additions & 2 deletions src/semble/chunking/chunking.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging

from semble.chunking.core import chunk, chunk_lines, is_supported_language
from semble.chunking.core import chunk, chunk_lines
from semble.types import Chunk

logger = logging.getLogger(__name__)
Expand All @@ -15,7 +15,7 @@ def chunk_source(source: str, file_path: str, language: str | None) -> list[Chun
if not source.strip():
return []
chunk_boundaries = None
if language is not None and is_supported_language(language):
if language is not None:
chunk_boundaries = chunk(source, language, _DESIRED_CHUNK_LENGTH_CHARS)
# This is an if because the error state of the parser above
# is a None.
Expand Down
15 changes: 4 additions & 11 deletions src/semble/chunking/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,15 @@
from functools import cache
from logging import getLogger

from semble_grammars import LanguageNotFoundError, UnsupportedPlatformError, get_parser
from tree_sitter import Node, Parser
from tree_sitter_language_pack import DownloadError, LanguageNotFoundError, SupportedLanguage, get_parser

from semble.index.files import ALL_LANGUAGES

logger = getLogger(__name__)

_RECURSION_DEPTH = 500
_MIN_CHUNK_SIZE = 50


def is_supported_language(language: str) -> bool:
"""Check if the language is supported by tree-sitter."""
return language in ALL_LANGUAGES


@dataclass
class ChunkBoundary:
"""The output of the internal chunking algorithm."""
Expand All @@ -29,14 +22,14 @@ class ChunkBoundary:


@cache
def _cached_get_parser(language: SupportedLanguage) -> Parser | None:
def _cached_get_parser(language: str) -> Parser | None:
"""Gets a parser from tree_sitter."""
try:
return get_parser(language)
except LanguageNotFoundError:
logger.warning("Language %s not found, falling back to line chunking", language)
except DownloadError:
logger.warning("Failed to download language %s, falling back to line chunking", language)
except UnsupportedPlatformError:
logger.warning("No bundled grammars for this platform, falling back to line chunking")
except Exception:
logger.error("Uncaught exception in _cached_get_parser", exc_info=True)
return None
Expand Down
22 changes: 7 additions & 15 deletions src/semble/installer/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from __future__ import annotations

import json
from functools import cache
from pathlib import Path
from typing import Literal, TypeVar, cast
from typing import Literal, TypeVar

from semble_grammars import get_parser
from tree_sitter import Node, Parser
from tree_sitter_language_pack import SupportedLanguage, download, get_parser

from semble.installer.agents import SEMBLE_END, SEMBLE_START, Action

Expand All @@ -15,23 +16,14 @@
_CODEX_MCP_HEADER = "[mcp_servers.semble]"
_CODEX_MCP_BLOCK = '[mcp_servers.semble]\ncommand = "uvx"\nargs = ["--from", "semble[mcp]", "semble"]\n'

_json5_parser_cache: Parser | None | bool = False # False = not yet attempted


@cache
def _json5_parser() -> Parser | None:
"""Return a tree-sitter JSON5 parser, downloading the grammar if needed.

"json5" ships in tree-sitter-language-pack but isn't in its typed language list, hence the cast.
"""
global _json5_parser_cache
if _json5_parser_cache is not False:
return _json5_parser_cache # type: ignore[return-value]
"""Return a tree-sitter JSON5 parser, or None if unavailable."""
try:
download(["json5"])
_json5_parser_cache = get_parser(cast(SupportedLanguage, "json5"))
return get_parser("json5")
except Exception:
_json5_parser_cache = None
return _json5_parser_cache # type: ignore[return-value]
return None


def _json5_object(text: str) -> JsonObjectResult:
Expand Down
2 changes: 1 addition & 1 deletion src/semble/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version_triple__ = (0, 5, 1)
__version_triple__ = (0, 5, 2)
__version__ = ".".join(map(str, __version_triple__))
13 changes: 6 additions & 7 deletions tests/test_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from unittest.mock import patch

import pytest
from tree_sitter_language_pack import DownloadError
from semble_grammars import UnsupportedPlatformError

from semble.chunking.chunking import Chunk, chunk_lines, chunk_source
from semble.chunking.core import ChunkBoundary, _cached_get_parser, chunk
Expand Down Expand Up @@ -102,12 +102,11 @@ def test_get_parser(caplog: pytest.LogCaptureFixture) -> None:
_cached_get_parser("hello")
assert len(caplog.records) == 0

with patch("semble.chunking.core.get_parser", side_effect=DownloadError):
with patch("semble.chunking.core.get_parser", side_effect=UnsupportedPlatformError):
with caplog.at_level(logging.WARNING, logger="semble.chunking.core"):
_cached_get_parser("Python")
assert len(caplog.records) == 1
assert "Failed to download" in caplog.records[0].message
assert "Python" in caplog.records[0].message
assert "No bundled grammars for this platform" in caplog.records[0].message

caplog.clear()
_cached_get_parser("Python")
Expand All @@ -131,9 +130,9 @@ def test_chunks_is_none() -> None:
assert chunks is None


def test_download_error() -> None:
"""Test that chunk returns None when parser is not available."""
with patch("semble.chunking.core.get_parser", side_effect=DownloadError):
def test_unsupported_platform_error() -> None:
"""Test that chunk returns None when the current platform has no bundled grammars."""
with patch("semble.chunking.core.get_parser", side_effect=UnsupportedPlatformError):
chunks = chunk("x = 1", "python", 10)
assert chunks is None

Expand Down
8 changes: 5 additions & 3 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from semble.installer.config import (
_CODEX_MCP_HEADER,
_json5_parser,
merge_json_member,
merge_toml_block,
remove_json_member,
Expand Down Expand Up @@ -167,12 +168,13 @@ def test_merge_mcp_writes_under_agent_key(tmp_path, agent_id, key):


def test_mcp_skipped_when_grammar_unavailable(claude_agent, monkeypatch):
"""When the JSON5 grammar cannot be downloaded, merge/remove return 'skipped'."""
"""When the JSON5 grammar is unavailable, merge/remove return 'skipped'."""
claude_agent.mcp.path.write_text('{ "mcpServers": {} }')
monkeypatch.setattr("semble.installer.config.download", lambda _: 1 / 0)
monkeypatch.setattr("semble.installer.config._json5_parser_cache", False)
monkeypatch.setattr("semble.installer.config.get_parser", lambda _: 1 / 0)
_json5_parser.cache_clear()
assert merge_mcp(claude_agent).action == "skipped"
assert remove_mcp(claude_agent).action == "skipped"
_json5_parser.cache_clear()


def test_merge_mcp_reparse_guard(claude_agent, monkeypatch):
Expand Down
37 changes: 21 additions & 16 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading