Skip to content

Commit 68f3c45

Browse files
committed
feat: make max file size configurable via SEMBLE_MAX_FILE_BYTES
Files larger than 1 MB are skipped during indexing without any indication, silently leaving gaps in search results (#250). Resolve the limit per call from the SEMBLE_MAX_FILE_BYTES environment variable (following SEMBLE_CACHE_LOCATION / SEMBLE_CLONE_TIMEOUT / SEMBLE_MODEL_NAME), falling back to the unchanged 1 MB default; malformed or nonpositive values warn and fall back instead of crashing indexing. Warn at index time naming files skipped for size, with the CLI surfacing warnings on stderr via an idempotent, CLI-owned handler.
1 parent 9218491 commit 68f3c45

5 files changed

Lines changed: 109 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ Savings are calculated as follows: for each call, semble records the total chara
174174

175175
By default, your Semble savings statistics and any saved indexes are stored in the OS cache folder (`~/Library/Caches/semble/` on macOS, `~/.cache/semble/` on Linux, `%LOCALAPPDATA%\semble\Cache\` on Windows). To override this location you can supply an environment variable `SEMBLE_CACHE_LOCATION` which should be the full path to the target cache location e.g. `~/my-folder/my-caches/semble`.
176176

177+
Files larger than 1 MB are skipped during indexing to keep index builds lean. Skipped files are reported as a warning at index time. If you work with large generated or ingested documents, you can raise (or lower) this limit with the `SEMBLE_MAX_FILE_BYTES` environment variable (in bytes).
178+
177179
On first use, Semble also downloads the embedding model from Hugging Face and caches it in the standard Hugging Face cache (`~/.cache/huggingface/` by default, or `$HF_HOME` if set); this only happens once and requires network access.
178180

179181
Use `semble clear` to remove cached data: `semble clear index` (saved indexes), `semble clear savings` (usage stats), `semble clear orphans` (indexes for repos no longer present on disk), or `semble clear all` (everything).

src/semble/cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import asyncio
33
import io
44
import json
5+
import logging
56
import re
67
import sys
78
import warnings
@@ -207,7 +208,24 @@ def _run_clear(clear_type: _CLEAR_CHOICE) -> None:
207208
_clear_orphans(cache_folder)
208209

209210

211+
class _CliLogHandler(logging.StreamHandler):
212+
"""stderr handler owned by the CLI; setup is idempotent on this type, not on foreign handlers."""
213+
214+
215+
def _configure_cli_logging() -> None:
216+
"""Surface semble warnings (e.g. skipped oversized files) on stderr without touching the root logger."""
217+
package_logger = logging.getLogger("semble")
218+
if any(isinstance(handler, _CliLogHandler) for handler in package_logger.handlers):
219+
return
220+
handler = _CliLogHandler(sys.stderr)
221+
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
222+
package_logger.addHandler(handler)
223+
if package_logger.level == logging.NOTSET:
224+
package_logger.setLevel(logging.WARNING)
225+
226+
210227
def _cli_main() -> None:
228+
_configure_cli_logging()
211229
parser = argparse.ArgumentParser(prog="semble")
212230
parser.add_argument("-V", "--version", action="version", version=__version__)
213231
sub = parser.add_subparsers(dest="command")

src/semble/index/create.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import contextlib
2+
import logging
23
from collections.abc import Sequence
34
from pathlib import Path
45

@@ -15,13 +16,29 @@
1516
detect_language,
1617
get_extensions,
1718
get_file_status,
19+
get_max_file_bytes,
1820
read_file_text,
1921
)
2022
from semble.index.sparse import enrich_for_bm25
2123
from semble.index.types import FileManifestEntry, PreviousIndex, make_chunk_id
2224
from semble.tokens import tokenize
2325
from semble.types import Chunk, ContentType, EmbeddingMatrix
2426

27+
logger = logging.getLogger(__name__)
28+
29+
30+
def _warn_skipped_large(skipped_large: list[str]) -> None:
31+
"""Warn about files skipped for exceeding the maximum indexable file size."""
32+
if skipped_large:
33+
logger.warning(
34+
"Skipped %d file(s) exceeding the maximum file size of %d bytes "
35+
"(raise SEMBLE_MAX_FILE_BYTES to include them): %s%s",
36+
len(skipped_large),
37+
get_max_file_bytes(),
38+
", ".join(skipped_large[:5]),
39+
" ..." if len(skipped_large) > 5 else "",
40+
)
41+
2542

2643
def _reindex_file(
2744
bm25_index: BM25,
@@ -79,10 +96,14 @@ def create_index_from_path(
7996
manifest: dict[str, FileManifestEntry] = {}
8097
embedding_parts: list[tuple[int, int, int]] = []
8198

99+
skipped_large: list[str] = []
100+
82101
for file_path in walk_files(path, resolved_extensions):
83102
language = detect_language(file_path)
84103
with contextlib.suppress(OSError):
85104
file_status = get_file_status(file_path, None)
105+
if file_status is FileStatus.TOO_LARGE:
106+
skipped_large.append(str(file_path))
86107
if file_status != FileStatus.VALID:
87108
continue
88109

@@ -109,6 +130,8 @@ def create_index_from_path(
109130
for indexed_path in previous_manifest.keys() - manifest.keys():
110131
_reindex_file(bm25_index, indexed_path, [], previous_manifest[indexed_path])
111132

133+
_warn_skipped_large(skipped_large)
134+
112135
if not chunks:
113136
raise ValueError(f"No supported files found under {path}.")
114137

src/semble/index/files.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import logging
2+
import os
13
from collections import defaultdict
24
from collections.abc import Sequence
35
from enum import Enum
46
from pathlib import Path
57

68
from semble.types import ContentType
79

8-
_MAX_FILE_BYTES = 1_000_000 # 1 MB max file size to read and index
10+
_DEFAULT_MAX_FILE_BYTES = 1_000_000 # Default 1 MB max file size to read and index
911
_EMPTY_FILE_BYTES = 128
12+
13+
logger = logging.getLogger(__name__)
1014
_EXTENSION_TO_LANGUAGE = {
1115
".4th": "forth",
1216
".ada": "ada",
@@ -488,14 +492,33 @@ def read_file_text(file_path: Path) -> str:
488492
return file_path.read_text(encoding="utf-8", errors="replace")
489493

490494

495+
def get_max_file_bytes() -> int:
496+
"""Resolve the maximum file size to index from SEMBLE_MAX_FILE_BYTES, falling back to the default.
497+
498+
Malformed or nonpositive values warn and fall back to the default rather than crash indexing.
499+
"""
500+
raw = os.environ.get("SEMBLE_MAX_FILE_BYTES")
501+
if raw is None:
502+
return _DEFAULT_MAX_FILE_BYTES
503+
try:
504+
value = int(raw)
505+
except ValueError:
506+
logger.warning("Invalid SEMBLE_MAX_FILE_BYTES %r, using the default of %d bytes", raw, _DEFAULT_MAX_FILE_BYTES)
507+
return _DEFAULT_MAX_FILE_BYTES
508+
if value <= 0:
509+
logger.warning("SEMBLE_MAX_FILE_BYTES must be positive, using the default of %d bytes", _DEFAULT_MAX_FILE_BYTES)
510+
return _DEFAULT_MAX_FILE_BYTES
511+
return value
512+
513+
491514
def get_file_status(file_path: Path, write_time: float | None) -> FileStatus:
492515
"""Checks if a file should be indexed based on its size and modification time."""
493516
stat = file_path.stat()
494517
if write_time is not None and stat.st_mtime > write_time:
495518
# Index invalid, file invalid
496519
return FileStatus.NEWER
497520
size = stat.st_size
498-
if size > _MAX_FILE_BYTES:
521+
if size > get_max_file_bytes():
499522
# index valid, file invalid
500523
return FileStatus.TOO_LARGE
501524
if size < _EMPTY_FILE_BYTES and not read_file_text(file_path).strip():

tests/index/test_index.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from pathlib import Path
23
from typing import Any
34
from unittest.mock import MagicMock, patch
@@ -8,7 +9,7 @@
89

910
from semble import SembleIndex
1011
from semble.index.create import create_index_from_path
11-
from semble.index.files import _MAX_FILE_BYTES, FileStatus, get_file_status
12+
from semble.index.files import _DEFAULT_MAX_FILE_BYTES, FileStatus, get_file_status, get_max_file_bytes
1213
from semble.types import ContentType
1314
from tests.conftest import make_chunk
1415

@@ -69,11 +70,45 @@ def test_index_empty_returns_zero_chunks(mock_model: StaticModel, tmp_path: Path
6970
create_index_from_path(tmp_path, mock_model)
7071

7172

72-
def test_oversized_file_is_skipped(mock_model: StaticModel, tmp_path: Path) -> None:
73-
"""Files exceeding _MAX_FILE_BYTES are silently skipped during indexing."""
74-
(tmp_path / "big.py").write_bytes(b"x" * (_MAX_FILE_BYTES + 1))
75-
with pytest.raises(ValueError): # no indexable content remains
76-
create_index_from_path(tmp_path, mock_model)
73+
def test_max_file_bytes_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
74+
"""The limit resolves from SEMBLE_MAX_FILE_BYTES, falling back to the 1 MB default."""
75+
monkeypatch.delenv("SEMBLE_MAX_FILE_BYTES", raising=False)
76+
assert get_max_file_bytes() == _DEFAULT_MAX_FILE_BYTES
77+
monkeypatch.setenv("SEMBLE_MAX_FILE_BYTES", str(_DEFAULT_MAX_FILE_BYTES + 5))
78+
assert get_max_file_bytes() == _DEFAULT_MAX_FILE_BYTES + 5
79+
80+
81+
def test_max_file_bytes_invalid_values_fall_back(monkeypatch: pytest.MonkeyPatch) -> None:
82+
"""Malformed or nonpositive SEMBLE_MAX_FILE_BYTES values fall back to the default."""
83+
monkeypatch.delenv("SEMBLE_MAX_FILE_BYTES", raising=False)
84+
assert get_max_file_bytes() == _DEFAULT_MAX_FILE_BYTES
85+
for bad in ("not-a-number", "0", "-5"):
86+
monkeypatch.setenv("SEMBLE_MAX_FILE_BYTES", bad)
87+
assert get_max_file_bytes() == _DEFAULT_MAX_FILE_BYTES
88+
89+
90+
def test_oversized_file_is_skipped_with_warning(
91+
mock_model: StaticModel, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
92+
) -> None:
93+
"""Files exceeding the limit are skipped during indexing, with a warning naming them."""
94+
monkeypatch.delenv("SEMBLE_MAX_FILE_BYTES", raising=False)
95+
(tmp_path / "big.py").write_bytes(b"x" * (_DEFAULT_MAX_FILE_BYTES + 1))
96+
with caplog.at_level(logging.WARNING, logger="semble.index.create"):
97+
with pytest.raises(ValueError): # no indexable content remains
98+
create_index_from_path(tmp_path, mock_model)
99+
assert "big.py" in caplog.text
100+
assert str(_DEFAULT_MAX_FILE_BYTES) in caplog.text
101+
assert "SEMBLE_MAX_FILE_BYTES" in caplog.text
102+
103+
104+
def test_oversized_file_indexed_when_limit_raised(
105+
mock_model: StaticModel, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
106+
) -> None:
107+
"""Raising SEMBLE_MAX_FILE_BYTES lets oversized files into the index."""
108+
monkeypatch.setenv("SEMBLE_MAX_FILE_BYTES", str(_DEFAULT_MAX_FILE_BYTES + 1024))
109+
(tmp_path / "big.py").write_bytes(b"x = 1\n" + b"#" * _DEFAULT_MAX_FILE_BYTES)
110+
_, _, chunks, _ = create_index_from_path(tmp_path, mock_model)
111+
assert any(chunk.file_path.endswith("big.py") for chunk in chunks)
77112

78113

79114
def test_tiny_invalid_utf8_file_status_does_not_crash(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)