chore: import upstream snapshot with attribution
e2e Tests / e2e (macos-latest) (push) Waiting to run
e2e Tests / e2e (windows-latest) (push) Waiting to run
Nix CI / check (push) Waiting to run
Python CI / Python bindings (macos-latest) (push) Waiting to run
Python CI / Python bindings (windows-latest) (push) Waiting to run
Build & Publish / Build Neovim aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Neovim x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build Neovim x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI aarch64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build C FFI x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build C FFI x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build MCP x86_64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP x86_64-pc-windows-msvc (push) Waiting to run
Build & Publish / Build Python wheels aarch64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (macos-latest) (push) Waiting to run
Build & Publish / Build Python wheels x86_64 (windows-latest) (push) Waiting to run
Build & Publish / Release (push) Blocked by required conditions
Build & Publish / Publish Python wheels to PyPI (push) Blocked by required conditions
Build & Publish / Publish Rust crates (push) Blocked by required conditions
Build & Publish / Publish npm packages (push) Blocked by required conditions
Rust CI / Test (macos-latest) (push) Waiting to run
Rust CI / Test (windows-latest) (push) Waiting to run
Rust CI / Fuzz Tests (macos-latest) (push) Waiting to run
Rust CI / Fuzz Tests (windows-latest) (push) Waiting to run
Build & Publish / Build MCP aarch64-apple-darwin (push) Waiting to run
Build & Publish / Build MCP aarch64-pc-windows-msvc (push) Waiting to run
Lua CI / lua-language-server type check (push) Failing after 1s
Lua CI / luacheck lint (push) Failing after 1s
Python CI / Python bindings (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Neovim aarch64-linux-android (push) Failing after 3s
Build & Publish / Build Neovim aarch64-unknown-linux-gnu (push) Failing after 0s
Build & Publish / Build Neovim aarch64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build Neovim x86_64-unknown-linux-musl (push) Failing after 1s
Build & Publish / Build C FFI aarch64-linux-android (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build C FFI x86_64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build C FFI x86_64-unknown-linux-musl (push) Failing after 3s
Build & Publish / Build MCP aarch64-unknown-linux-gnu (push) Failing after 1s
Build & Publish / Build MCP aarch64-unknown-linux-musl (push) Failing after 0s
Build & Publish / Build MCP x86_64-unknown-linux-gnu (push) Failing after 2s
Rust CI / Fuzz Tests (ubuntu-latest) (push) Failing after 1s
Rust CI / Build i686-unknown-linux-gnu (push) Failing after 1s
Rust CI / cargo fmt (push) Failing after 1s
Build & Publish / Build MCP x86_64-unknown-linux-musl (push) Failing after 4s
Build & Publish / Build Python wheels aarch64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python wheels x86_64 (ubuntu-latest) (push) Failing after 1s
Build & Publish / Build Python sdist (push) Failing after 0s
Rust CI / Test (ubuntu-latest) (push) Failing after 1s
Rust CI / cargo clippy (push) Failing after 1s
Spelling / Spell Check with Typos (push) Failing after 1s
Stylua / Check lua files using Stylua (push) Failing after 1s
e2e Tests / e2e (ubuntu-latest) (push) Failing after 4s
e2e Tests / e2e (alpine-musl) (push) Successful in 58m54s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:42 +08:00
commit 1b279d4d10
328 changed files with 87463 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
3.10
+75
View File
@@ -0,0 +1,75 @@
# fff-search
Python bindings for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim), built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/). Install with `pip install fff-search`; import as `fff`.
## Requirements
- Python >= 3.10
- Rust toolchain (to build the native extension)
- [uv](https://docs.astral.sh/uv/) (recommended)
## Development setup
```bash
cd packages/fff-python
uv sync --all-extras
uv run maturin develop --release
```
## Running tests
```bash
cd packages/fff-python
uv run pytest -v
```
## Standalone example
```bash
cd packages/fff-python
uv run python examples/basic.py .
```
## Basic usage
```python
from fff import FileFinder
with FileFinder("/path/to/project", watch=False) as finder:
finder.wait_for_scan_blocking(timeout_ms=5000)
print(f"Indexed under {finder.base_path}")
result = finder.search("main")
if result:
print(f"Showing {len(result)} of {result.total_matched} matches")
for item, score in zip(result.items, result.scores):
print(f"{item.relative_path}: {score.total}")
```
### Async usage
`wait_for_scan` is a coroutine that polls the scan status and yields to the
event loop, so it never blocks other tasks. Use `wait_for_scan_blocking` from
synchronous code.
```python
import asyncio
from fff import FileFinder
async def main():
with FileFinder("/path/to/project", watch=False) as finder:
await finder.wait_for_scan(timeout_ms=5000)
result = finder.search("main")
print(result)
asyncio.run(main())
```
## Building wheels
```bash
cd packages/fff-python
uv run maturin build --release
```
The produced wheel is `abi3` compatible with Python 3.10+.
+43
View File
@@ -0,0 +1,43 @@
"""Standalone example of using fff Python bindings."""
from __future__ import annotations
import sys
import time
from fff import FileFinder
def main() -> int:
base_path = sys.argv[1] if len(sys.argv) > 1 else "."
print(f"Indexing {base_path}...")
start = time.time()
with FileFinder(base_path, watch=False) as finder:
print(f"Created in {time.time() - start:.2f}s")
print("Waiting for scan...")
finder.wait_for_scan_blocking(timeout_ms=30000)
progress = finder.scan_progress
print(f"Indexed {progress.scanned_files_count} files")
print("\nFuzzy file search for 'main':")
result = finder.search("main", page_size=5)
for item, score in zip(result.items, result.scores):
print(f" {item.relative_path:<50} score={score.total}")
print("\nGlob search '*.py':")
result = finder.glob("*.py", page_size=5)
for item in result.items:
print(f" {item.relative_path}")
print("\nGrep for 'def ':")
result = finder.grep("def ", page_limit=5)
for match in result.items:
print(f" {match.relative_path}:{match.line_number}: {match.line_content.strip()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+33
View File
@@ -0,0 +1,33 @@
[project]
name = "fff-search"
version = "0.9.6"
description = "Python bindings for FFF (Fast File Finder)"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"maturin>=1.0",
]
[project.urls]
Repository = "https://github.com/dmtrKovalenko/fff.nvim"
Issues = "https://github.com/dmtrKovalenko/fff.nvim/issues"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[tool.maturin]
manifest-path = "../../crates/fff-python/Cargo.toml"
module-name = "fff._fff_python"
python-source = "src"
exclude = ["src/**/__pycache__/*", "src/**/*.pyc"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+70
View File
@@ -0,0 +1,70 @@
"""Python bindings for FFF (Fast File Finder)."""
from __future__ import annotations
import asyncio
from fff._fff_python import (
DirItem,
DirSearchResult,
FFFException,
FileItem,
GrepMatch,
GrepResult,
GrepCursor,
MatchRange,
MixedDirItem,
MixedFileItem,
MixedSearchResult,
ScanProgress,
Score,
SearchResult,
)
from fff._fff_python import FileFinder as _FileFinder
_SCAN_POLL_INTERVAL = 0.05
class FileFinder(_FileFinder):
"""File finder with an async, event-loop-friendly scan wait.
Inherits every method from the native finder; only adds the async
``wait_for_scan`` on top. Use ``wait_for_scan_blocking`` when a
synchronous wait is acceptable.
"""
async def wait_for_scan(self, timeout_ms: int = 5000) -> bool:
"""Wait for the initial scan without blocking the event loop.
Polls ``is_scanning`` and yields to the loop between checks.
Returns ``True`` if the scan completed, ``False`` on timeout.
"""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout_ms / 1000
while self.is_scanning():
if loop.time() >= deadline:
return False
await asyncio.sleep(_SCAN_POLL_INTERVAL)
return True
__version__ = "0.9.6"
__all__ = [
"FFFException",
"FileFinder",
"FileItem",
"DirItem",
"Score",
"SearchResult",
"DirSearchResult",
"MixedFileItem",
"MixedDirItem",
"MixedSearchResult",
"MatchRange",
"GrepMatch",
"GrepResult",
"GrepCursor",
"ScanProgress",
"__version__",
]
+256
View File
@@ -0,0 +1,256 @@
"""Type stubs for fff Python bindings."""
from __future__ import annotations
from collections.abc import Sequence
from os import PathLike
from typing import Any, Literal, TypeAlias
_PathInput: TypeAlias = str | PathLike[str]
_GrepMode: TypeAlias = Literal["plain", "regex", "fuzzy"]
__version__: str
class FFFException(Exception):
"""Base exception for fff errors."""
class Score:
total: int
base_score: int
filename_bonus: int
special_filename_bonus: int
frecency_boost: int
distance_penalty: int
current_file_penalty: int
combo_match_boost: int
path_alignment_bonus: int
exact_match: bool
match_type: str
def __repr__(self) -> str: ...
class FileItem:
relative_path: str
file_name: str
git_status: str
size: int
modified: int
access_frecency_score: int
modification_frecency_score: int
total_frecency_score: int
is_binary: bool
def __repr__(self) -> str: ...
class DirItem:
relative_path: str
dir_name: str
max_access_frecency: int
def __repr__(self) -> str: ...
class MixedFileItem:
relative_path: str
file_name: str
git_status: str
size: int
modified: int
access_frecency_score: int
modification_frecency_score: int
total_frecency_score: int
is_binary: bool
def __repr__(self) -> str: ...
class MixedDirItem:
relative_path: str
dir_name: str
max_access_frecency: int
def __repr__(self) -> str: ...
class MatchRange:
start: int
end: int
def __repr__(self) -> str: ...
class GrepMatch:
relative_path: str
file_name: str
git_status: str
line_content: str
match_ranges: list[MatchRange]
context_before: list[str]
context_after: list[str]
size: int
modified: int
total_frecency_score: int
access_frecency_score: int
modification_frecency_score: int
line_number: int
byte_offset: int
col: int
fuzzy_score: int | None
is_definition: bool
is_binary: bool
def __repr__(self) -> str: ...
class SearchResult:
items: list[FileItem]
scores: list[Score]
total_matched: int
total_files: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class DirSearchResult:
items: list[DirItem]
scores: list[Score]
total_matched: int
total_dirs: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class MixedSearchResult:
items: list[MixedFileItem | MixedDirItem]
scores: list[Score]
total_matched: int
total_files: int
total_dirs: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class GrepResult:
items: list[GrepMatch]
total_matched: int
total_files_searched: int
total_files: int
filtered_file_count: int
next_file_offset: int
regex_fallback_error: str | None
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
@property
def has_more(self) -> bool: ...
def next_cursor(self) -> GrepCursor | None: ...
def __repr__(self) -> str: ...
class ScanProgress:
scanned_files_count: int
is_scanning: bool
is_watcher_ready: bool
is_warmup_complete: bool
def __repr__(self) -> str: ...
class GrepCursor:
offset: int
def __init__(self, offset: int) -> None: ...
def __repr__(self) -> str: ...
class FileFinder:
def __init__(
self,
base_path: _PathInput,
*,
frecency_db_path: _PathInput | None = None,
history_db_path: _PathInput | None = None,
enable_mmap_cache: bool = True,
enable_content_indexing: bool = True,
watch: bool = True,
ai_mode: bool = False,
log_file_path: _PathInput | None = None,
log_level: str | None = None,
cache_budget_max_files: int = 0,
cache_budget_max_bytes: int = 0,
cache_budget_max_file_size: int = 0,
enable_fs_root_scanning: bool = False,
enable_home_dir_scanning: bool = False,
) -> None: ...
def __enter__(self) -> FileFinder: ...
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ...
def __repr__(self) -> str: ...
@property
def closed(self) -> bool: ...
@property
def base_path(self) -> str | None: ...
@property
def scan_progress(self) -> ScanProgress: ...
def close(self) -> None: ...
def search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
combo_boost_score_multiplier: int = 0,
min_combo_count: int = 0,
) -> SearchResult: ...
def glob(
self,
pattern: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
) -> SearchResult: ...
def directory_search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
) -> DirSearchResult: ...
def mixed_search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
combo_boost_score_multiplier: int = 0,
min_combo_count: int = 0,
) -> MixedSearchResult: ...
def grep(
self,
query: str,
*,
mode: _GrepMode = "plain",
max_file_size: int = 0,
max_matches_per_file: int = 0,
smart_case: bool = True,
cursor: GrepCursor | None = None,
page_limit: int = 0,
time_budget_ms: int = 0,
before_context: int = 0,
after_context: int = 0,
classify_definitions: bool = False,
) -> GrepResult: ...
def multi_grep(
self,
patterns: Sequence[str],
*,
constraints: str | None = None,
mode: _GrepMode = "plain",
max_file_size: int = 0,
max_matches_per_file: int = 0,
smart_case: bool = True,
cursor: GrepCursor | None = None,
page_limit: int = 0,
time_budget_ms: int = 0,
before_context: int = 0,
after_context: int = 0,
classify_definitions: bool = False,
) -> GrepResult: ...
def scan_files(self) -> None: ...
def is_scanning(self) -> bool: ...
async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: ...
def wait_for_scan_blocking(self, timeout_ms: int = 5000) -> bool: ...
def reindex(self, new_path: _PathInput) -> None: ...
def refresh_git_status(self) -> int: ...
def track_query(self, query: str, selected_file_path: _PathInput) -> bool: ...
def get_historical_query(self, offset: int) -> str | None: ...
def health_check(self, test_path: _PathInput | None = None) -> dict[str, Any]: ...
+391
View File
@@ -0,0 +1,391 @@
"""Tests for fff Python bindings."""
from __future__ import annotations
import importlib.metadata as metadata
import tempfile
from pathlib import Path
import pytest
import fff
from fff import FFFException, FileFinder, GrepCursor, MixedDirItem, MixedFileItem
def rel(path: str) -> str:
return path.replace("\\", "/")
@pytest.fixture
def sample_dir() -> str:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "src").mkdir()
(root / "docs").mkdir()
(root / "src" / "main.py").write_text(
"from utils import helper\n\n"
"def main():\n"
" value = helper()\n"
" return value\n"
)
(root / "src" / "utils.py").write_text(
"def helper():\n"
" return 'alpha'\n"
)
(root / "src" / "profile.ts").write_text(
"class Profile {}\n"
"function renderProfile() { return new Profile(); }\n"
)
(root / "docs" / "guide.txt").write_text(
"alpha line before\n"
"needle target\n"
"omega line after\n"
)
(root / "README.md").write_text("# Sample project\n")
yield str(root)
def test_imports_and_package_version() -> None:
assert fff.__version__ == metadata.version("fff-search")
assert GrepCursor(12).offset == 12
assert "GrepCursor" in fff.__all__
def test_pathlib_base_path(sample_dir: str) -> None:
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.closed is False
assert finder.base_path is not None
assert finder.scan_progress.scanned_files_count >= 1
result = finder.search("main")
assert result.total_matched >= 1
async def test_wait_for_scan_async(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert await finder.wait_for_scan(timeout_ms=5000) is True
assert finder.is_scanning() is False
result = finder.search("main")
assert result.total_matched >= 1
async def test_wait_for_scan_async_does_not_block_loop(sample_dir: str) -> None:
import asyncio
ticks = 0
async def ticker() -> None:
nonlocal ticks
while True:
ticks += 1
await asyncio.sleep(0.01)
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
background = asyncio.ensure_future(ticker())
try:
assert await finder.wait_for_scan(timeout_ms=5000) is True
finally:
background.cancel()
# the loop kept running other tasks while we awaited the scan
assert ticks > 0
async def test_wait_for_scan_blocking_and_async_agree(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000) is True
# already finished, so the async wait resolves immediately to True
assert await finder.wait_for_scan(timeout_ms=5000) is True
def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None:
with pytest.raises(TypeError):
GrepCursor()
with pytest.raises(TypeError):
FileFinder(Path(sample_dir), None)
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
with pytest.raises(TypeError):
finder.search("main", None)
with pytest.raises(TypeError):
finder.glob("*.py", None)
with pytest.raises(TypeError):
finder.directory_search("src", None)
with pytest.raises(TypeError):
finder.mixed_search("src", None)
with pytest.raises(TypeError):
finder.grep("needle", "plain")
with pytest.raises(TypeError):
finder.multi_grep(["needle"], None)
def test_close_and_context_manager(sample_dir: str) -> None:
finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.closed is False
assert finder.base_path is not None
finder.close()
assert finder.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
finder.search("main")
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder:
assert ctx_finder.wait_for_scan_blocking(timeout_ms=5000)
assert ctx_finder.closed is False
assert ctx_finder.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
ctx_finder.search("main")
fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
assert fresh.wait_for_scan_blocking(timeout_ms=5000)
fresh.close()
assert fresh.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
fresh.search("main")
def test_reprs(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert repr(finder).startswith("FileFinder(")
result = finder.search("main")
assert repr(result).startswith("SearchResult(")
assert repr(result.items[0]).startswith("FileItem(")
assert repr(result.scores[0]).startswith("Score(")
grep_result = finder.grep("needle")
assert repr(grep_result).startswith("GrepResult(")
assert repr(grep_result.items[0]).startswith("GrepMatch(")
assert repr(grep_result.items[0].match_ranges[0]).startswith("MatchRange(")
dir_result = finder.directory_search("src")
assert repr(dir_result).startswith("DirSearchResult(")
assert repr(dir_result.items[0]).startswith("DirItem(")
mixed = finder.mixed_search("src", page_size=10)
assert repr(mixed).startswith("MixedSearchResult(")
cursor = GrepCursor(42)
assert repr(cursor) == "GrepCursor(offset=42)"
progress = finder.scan_progress
assert repr(progress).startswith("ScanProgress(")
def test_file_search_scores_and_pagination(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("main", page_size=1)
assert result.total_matched >= 1
assert len(result) == len(result.items) == 1
assert bool(result) is True
assert any("main.py" in rel(item.relative_path) for item in result.items)
score = result.scores[0]
assert isinstance(score.total, int)
assert isinstance(score.exact_match, bool)
assert isinstance(score.match_type, str)
second_page = finder.search("", page_index=1, page_size=1)
assert len(second_page) == len(second_page.items) == 1
empty = finder.search("definitely_no_such_file_xyz")
assert len(empty) == 0
assert bool(empty) is False
def test_glob_variants(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
py_files = finder.glob("*.py")
assert {Path(rel(item.relative_path)).name for item in py_files.items} == {
"main.py",
"utils.py",
}
src_files = finder.glob("src/*.py")
assert src_files.total_matched == 2
md_files = finder.glob("*.md")
assert md_files.total_matched == 1
assert rel(md_files.items[0].relative_path) == "README.md"
def test_directory_and_mixed_search(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
dirs = finder.directory_search("src")
assert dirs.total_matched >= 1
assert len(dirs) == len(dirs.items)
assert bool(dirs) is True
assert any(rel(item.relative_path).startswith("src") for item in dirs.items)
mixed = finder.mixed_search("src", page_size=10)
assert mixed.total_matched >= 3
assert len(mixed) == len(mixed.items)
assert bool(mixed) is True
assert any(isinstance(item, MixedDirItem) for item in mixed.items)
assert any(isinstance(item, MixedFileItem) for item in mixed.items)
# max_access_frecency is the same field on both dir item types, so the
# values must agree for a shared directory regardless of which search
# produced them.
dir_frecency = {
rel(item.relative_path): item.max_access_frecency for item in dirs.items
}
for item in mixed.items:
if isinstance(item, MixedDirItem):
path = rel(item.relative_path)
if path in dir_frecency:
assert item.max_access_frecency == dir_frecency[path]
def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
plain = finder.grep("needle", before_context=1, after_context=1)
assert plain.total_matched == 1
assert len(plain) == len(plain.items) == 1
assert bool(plain) is True
match = plain.items[0]
assert rel(match.relative_path) == "docs/guide.txt"
assert match.line_content == "needle target"
assert match.context_before == ["alpha line before"]
assert match.context_after == ["omega line after"]
assert [(r.start, r.end) for r in match.match_ranges] == [(0, 6)]
regex = finder.grep(r"def \w+", mode="regex")
assert regex.total_matched == 2
assert {Path(rel(m.relative_path)).name for m in regex.items} == {
"main.py",
"utils.py",
}
fuzzy = finder.grep("df mn", mode="fuzzy")
assert fuzzy.total_matched >= 1
assert any(rel(m.relative_path) == "src/main.py" for m in fuzzy.items)
assert any(m.fuzzy_score is not None for m in fuzzy.items)
invalid = finder.grep("[", mode="regex")
assert invalid.regex_fallback_error is not None
def test_grep_invalid_mode_raises(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
with pytest.raises(FFFException, match="invalid grep mode"):
finder.grep("needle", mode="typo")
with pytest.raises(FFFException, match="invalid grep mode"):
finder.multi_grep(["needle"], mode="typo")
def test_grep_cursor_paginates_by_file(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
first = finder.grep("def", page_limit=1)
assert first.total_matched >= 1
assert first.next_file_offset > 0
assert first.has_more is True
assert first.next_cursor() is not None
assert first.next_cursor().offset == first.next_file_offset
second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1)
assert second.total_matched >= 1
first_paths = {rel(m.relative_path) for m in first.items}
second_paths = {rel(m.relative_path) for m in second.items}
assert first_paths.isdisjoint(second_paths)
exhausted = finder.grep("nonexistent_xyz")
assert exhausted.has_more is False
assert exhausted.next_cursor() is None
assert len(exhausted) == 0
assert bool(exhausted) is False
def test_multi_grep_and_error_handling(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.multi_grep(("def main", "def helper"))
assert result.total_matched == 2
assert {Path(rel(m.relative_path)).name for m in result.items} == {
"main.py",
"utils.py",
}
with pytest.raises(FFFException, match="patterns must not be empty"):
finder.multi_grep([])
def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None:
history_db = tmp_path / "history"
selected_file = Path(sample_dir) / "src" / "main.py"
with FileFinder(
sample_dir,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.track_query("main", selected_file)
assert finder.get_historical_query(0) == "main"
with FileFinder(
sample_dir,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.get_historical_query(0) == "main"
def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None:
other = tmp_path / "other-project"
other.mkdir()
(other / "other.py").write_text("def other():\n return 42\n")
frecency_db = tmp_path / "frecency"
history_db = tmp_path / "history"
with FileFinder(
sample_dir,
frecency_db_path=frecency_db,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
health = finder.health_check(Path(sample_dir))
assert health["file_picker"]["initialized"] is True
assert health["frecency"]["initialized"] is True
assert health["query_tracker"]["initialized"] is True
# With no explicit path, the check inspects the indexed base path
# rather than the process cwd.
default_health = finder.health_check()
assert default_health["file_picker"]["base_path"] is not None
assert "error" not in default_health.get("git", {}) or default_health["git"][
"error"
] != "could not determine current directory"
finder.reindex(str(other))
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("other")
assert result.total_matched == 1
assert rel(result.items[0].relative_path) == "other.py"
finder.reindex(Path(other))
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result2 = finder.search("other")
assert result2.total_matched == 1
+208
View File
@@ -0,0 +1,208 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fff-search"
version = "0.9.6"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "maturin" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "maturin", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
]
provides-extras = ["dev"]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "maturin"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/d0/b7c8b7778cc44df3efbc96eb23acaa995e06ea1a60eb9b02f29858fcbd08/maturin-1.14.0.tar.gz", hash = "sha256:f7f82a6aca4a6c402bf00b99200be199d4874d04b9b9e74e825726a3478bba7f", size = 367010, upload-time = "2026-06-12T00:13:30.811Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/51/49367dcd8f6ec139e69ef0c695c8ff5075223673382101812b4affa53216/maturin-1.14.0-py3-none-linux_armv6l.whl", hash = "sha256:019ea3ec7e71f4c9759a367d4d21022ed5a3a621a2ce123abf3fb114ab3711ca", size = 10204135, upload-time = "2026-06-12T00:13:34.308Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2a/487ce56c838d25e0ce64350e75ec4e3dc89544c0a6233221c229d6aa1a84/maturin-1.14.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6948a10f5f3470b791f79319be51debdd8bfd1778b36f2409f98e1314bc3859b", size = 19736800, upload-time = "2026-06-12T00:13:40.456Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a5/12f2efc18f419edce3282a93629cba16278bb502135dac95cd04ef7c2eae/maturin-1.14.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1506e86b1e273a98074a62e281b13f27ac96f8cdef85f7f98d3e3589a9387a23", size = 10201144, upload-time = "2026-06-12T00:13:26.842Z" },
{ url = "https://files.pythonhosted.org/packages/bf/95/3789e72273fd8bc80c33a11c787634b3251c4989d7a7203a92438836d4ff/maturin-1.14.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:df10ce4f7ba97fd3423f624f39b94c888ae3e5b470642a91918e1ccec81282fd", size = 10182394, upload-time = "2026-06-12T00:13:13.693Z" },
{ url = "https://files.pythonhosted.org/packages/40/79/15957eb4e055597f217e6310963a9c1371372e63c5b4a3e30803365addd2/maturin-1.14.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:75bcd4468a7fe597652cc2980c6bb16ce4bb8c411e3eb85dac2c4418cef0e95a", size = 10616603, upload-time = "2026-06-12T00:13:22.795Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4b/d1822f88cd5e855640f0e10ee00c39b9be614c1ef2f827e9792332d94b9f/maturin-1.14.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d123337e817f8dfe23755d6760139c01104137bb63e9e20c289c547e25ec857", size = 10075309, upload-time = "2026-06-12T00:13:38.274Z" },
{ url = "https://files.pythonhosted.org/packages/c0/82/c1b160d2163e8784489285e82a5c811fdcef3e0704e35b34c1cfe1828de3/maturin-1.14.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:107f84110d890090a01bb1ecd01761fdfae925c23c659ba492c9b83dd179eab4", size = 10024058, upload-time = "2026-06-12T00:13:16.49Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/88a9d1872997d4535af10ebe79f550e834880bf613cf8e50b50d2d938e3b/maturin-1.14.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:9a84277aa907961cd47ad26fef1539e79efa30611972eaf7499606e773e991b2", size = 13302073, upload-time = "2026-06-12T00:13:29.027Z" },
{ url = "https://files.pythonhosted.org/packages/4a/13/3f6d28bb7b744558b9bc78c995c1855d7e5ff21ad475f46d9de5c3dab039/maturin-1.14.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:095714b2a904927e3c868a1c5d078257ff0443c5049f7623777352966768306e", size = 10863616, upload-time = "2026-06-12T00:13:32.191Z" },
{ url = "https://files.pythonhosted.org/packages/24/06/39352d2b402efa3a7dd01d4ed197b301ea35eec10208ba2b8c649101f4df/maturin-1.14.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:20229d332f87166b930e4ca07cdbee8a1726f2eea87a337610aa25bba3ddf4b4", size = 10399943, upload-time = "2026-06-12T00:13:36.273Z" },
{ url = "https://files.pythonhosted.org/packages/58/77/641504541336240fef3836b2d15a785eaeb33c941fb118513c267dd70840/maturin-1.14.0-py3-none-win32.whl", hash = "sha256:4ba1e3c3f33609f461d587b7549104c81a15fd6d42ba63a73cea9376a1e9876e", size = 8905117, upload-time = "2026-06-12T00:13:18.38Z" },
{ url = "https://files.pythonhosted.org/packages/02/4a/ca247a0c43069b2f48cf783c5b13c3a9eb92c8f596dc7fbdb9f75fea4414/maturin-1.14.0-py3-none-win_amd64.whl", hash = "sha256:cb09a313f097adeb4dda0082277871a28d1bd26615dbadab42e6234b6df6fe69", size = 10309099, upload-time = "2026-06-12T00:13:20.523Z" },
{ url = "https://files.pythonhosted.org/packages/8b/a4/f14a3f6086cc3caaa90d12e832e4aa41de771c310041959f0d35dd4efe17/maturin-1.14.0-py3-none-win_arm64.whl", hash = "sha256:8c1a8188195f5b6ce1aab99ae2d92e342900298f901456b43ca028947fd3b288", size = 9719100, upload-time = "2026-06-12T00:13:24.741Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]