diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..43426ef9 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,48 @@ +name: Python CI + +on: + push: + branches: [main] + paths-ignore: + - '**.md' + - 'doc/**' + pull_request: + branches: [main] + paths-ignore: + - '**.md' + - 'doc/**' + +env: + CARGO_TERM_COLOR: always + MACOSX_DEPLOYMENT_TARGET: "13.0" + +jobs: + test: + name: Python bindings (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.11.14" + enable-cache: true + + - name: Build and test Python bindings + working-directory: packages/fff-python + shell: bash + run: | + uv sync --all-extras + uv run maturin develop --release + uv run pytest -v diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a7294925..47f69d00 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,6 +6,13 @@ on: tags: - "v*" pull_request: + workflow_dispatch: + inputs: + publish_pypi: + description: "Manually build and publish Python wheels to PyPI" + required: false + default: false + type: boolean env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -359,9 +366,101 @@ jobs: name: mcp-${{ matrix.target }} path: fff-mcp-${{ matrix.target }}* + build-python: + name: Build Python wheels ${{ matrix.target }} (${{ matrix.os }}) + # Wheels are release artifacts; PR validation uses the develop build in + # python.yml, so skip the cross-compile matrix on pull requests. + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64 + container: "off" + - os: ubuntu-latest + target: aarch64 + container: "off" + - os: macos-latest + target: x86_64 + - os: macos-latest + target: aarch64 + - os: windows-latest + target: x86_64 + + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + + - name: Install cargo-zigbuild + if: contains(matrix.os, 'ubuntu') + run: cargo install cargo-zigbuild + + - name: Install aarch64 cross compiler + if: matrix.target == 'aarch64' && contains(matrix.os, 'ubuntu') + run: | + sudo apt-get update -qq + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + + - name: Build wheels + uses: PyO3/maturin-action@v1 + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc + CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ + AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar + with: + target: ${{ matrix.target }} + args: --release --out dist --features zlob + sccache: "true" + working-directory: packages/fff-python + container: ${{ matrix.container || '' }} + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: python-wheels-${{ matrix.os }}-${{ matrix.target }} + path: packages/fff-python/dist/ + + build-python-sdist: + name: Build Python sdist + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + working-directory: packages/fff-python + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: python-sdist + path: packages/fff-python/dist/ + release: name: Release - needs: [build-nvim, build-c, build-mcp] + needs: [build-nvim, build-c, build-mcp, build-python, build-python-sdist] runs-on: ubuntu-latest if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fix/use-trusted-publishing' || startsWith(github.ref, 'refs/tags/v')) permissions: @@ -420,6 +519,20 @@ jobs: rmdir "$dir" 2>/dev/null || true done + - name: Move Python wheels to release directory + working-directory: ./binaries + run: | + mkdir -p python + for dir in python-wheels-*/ python-sdist/; do + [ -d "$dir" ] || continue + for file in "$dir"*; do + if [ -f "$file" ]; then + mv "$file" "python/$(basename "$file")" + fi + done + rmdir "$dir" 2>/dev/null || true + done + - name: Remove npm package artifacts from release binaries working-directory: ./binaries run: | @@ -429,7 +542,7 @@ jobs: working-directory: ./binaries run: | ls -la - for file in *; do + for file in * python/*; do if [ -f "$file" ] && [[ ! "$file" == *.sha256 ]]; then sha256sum "$file" > "${file}.sha256" fi @@ -454,14 +567,16 @@ jobs: name: "${{ steps.version.outputs.version }}" tag_name: "${{ steps.version.outputs.release_tag }}" token: ${{ github.token }} - files: ./binaries/* + files: | + ./binaries/* + ./binaries/python/* draft: false prerelease: ${{ steps.version.outputs.is_release != 'true' }} generate_release_notes: ${{ steps.version.outputs.is_release == 'true' }} body: | ${{ steps.version.outputs.is_release == 'true' && format('Release {0}', steps.version.outputs.version) || format('Nightly release from commit: {0}', github.sha) }} - npm packages and rust crates are available under this version ${{ steps.version.outputs.version }} + npm packages, rust crates and python wheels are available under this version ${{ steps.version.outputs.version }} ## Neovim Plugin - `{target}.so` / `.dylib` / `.dll` - Lua module for Neovim @@ -472,6 +587,10 @@ jobs: ## MCP Server - `fff-mcp-{target}` - MCP server binary + ## Python Package + - `python/*.whl` / `python/*.tar.gz` - Python wheels and sdist + - Install from PyPI: `pip install fff-search` (when published) + Update mcp via: ```sh curl -fsSL https://raw.githubusercontent.com/dmtrKovalenko/fff.nvim/main/install-mcp.sh | bash @@ -498,6 +617,33 @@ jobs: commit_user_name: github-actions[bot] commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com + pypi-publish: + name: Publish Python wheels to PyPI + needs: [build-python, build-python-sdist] + runs-on: ubuntu-latest + if: | + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PUBLISH_TO_PYPI == 'true') || + (github.event_name == 'workflow_dispatch' && inputs.publish_pypi == true) + environment: + name: pypi + url: https://pypi.org/p/fff-search + permissions: + contents: read + id-token: write + steps: + - name: Download Python wheels and sdist + uses: actions/download-artifact@v4 + with: + pattern: python-* + path: dist + merge-multiple: true + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + skip-existing: true + crates-publish: name: Publish Rust crates needs: [build-nvim, build-c, build-mcp] diff --git a/.gitignore b/.gitignore index 26183ffb..4d026b32 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,41 @@ scripts/benchmark-results/ *.dylib *.so *.dll +*.pdb # Instruments traces *.trace/ # Test logs fff-test.log + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.pyd +*.egg-info/ +*.egg +.eggs/ +build/ +*.whl +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +# uv +# Testing / linting +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ diff --git a/Cargo.lock b/Cargo.lock index 7eb9d48f..f92ba3f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,6 +699,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "fff-python" +version = "0.9.4" +dependencies = [ + "fff-query-parser", + "fff-search", + "git2", + "pyo3", +] + [[package]] name = "fff-query-parser" version = "0.9.4" @@ -1191,6 +1201,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.11.1" @@ -1435,6 +1454,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mimalloc" version = "0.1.48" @@ -1778,6 +1806,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1838,6 +1872,69 @@ dependencies = [ "unarray", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -2299,6 +2396,12 @@ dependencies = [ "syn", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -2544,6 +2647,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 64454117..3138ac20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,11 @@ members = [ "crates/fff-core", "crates/fff-mcp", "crates/fff-nvim", + "crates/fff-python", "crates/fff-query-parser", "crates/fff-grep", ] + resolver = "2" [workspace.dependencies] diff --git a/README.md b/README.md index ba9e4909..98619cd7 100644 --- a/README.md +++ b/README.md @@ -664,6 +664,75 @@ Source: [`crates/fff-c/`](./crates/fff-c/). Stable C ABI. Bind from C/C++, Zig, Go via cgo, Python via ctypes, or anything with C FFI. +
+ +

Python bindings

+
+ +### Install + +```bash +pip install fff-search +``` + +Or build and install from source: + +```bash +cd packages/fff-python +uv sync --all-extras +uv run maturin develop --release +``` + +### Basic usage + +```python +from fff import FileFinder + +with FileFinder("/path/to/project", watch=False) as finder: + finder.wait_for_scan_blocking(timeout_ms=5000) + + result = finder.search("main") + for item, score in zip(result.items, result.scores): + print(f"{item.relative_path}: {score.total}") + + hits = finder.grep("class Profile", mode="plain", before_context=1, after_context=1) +``` + +### Async usage + +`wait_for_scan` is a coroutine that polls 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()) +``` + +### What you get + +- `search`, `glob`, `directory_search`, `mixed_search` — frecency-ranked fuzzy file/dir search +- `grep` / `multi_grep` — plain, regex, or fuzzy content search with context lines and cursor pagination +- `track_query` / `get_historical_query` — optional frecency and query-history databases +- `reindex`, `refresh_git_status`, `scan_progress`, `health_check` — lifecycle and diagnostics + +Typed result objects (`FileItem`, `Score`, `GrepMatch`, …) with `py.typed` +stubs included. Ships as an `abi3` wheel compatible with Python 3.10+. + +Source: [`packages/fff-python/`](./packages/fff-python/). + +
+ +Native Python bindings built with PyO3. Use them for notebooks, agent scripts, or any Python tool that needs fast file search. + --- ## What is FFF and why use it over ripgrep or fzf? diff --git a/crates/fff-python/Cargo.toml b/crates/fff-python/Cargo.toml new file mode 100644 index 00000000..97586356 --- /dev/null +++ b/crates/fff-python/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fff-python" +version = "0.9.4" +edition = "2024" + +[lib] +name = "fff_python" +crate-type = ["cdylib"] + +[features] +default = [] +zlob = ["fff/zlob"] + +[dependencies] +fff = { package = "fff-search", path = "../fff-core", version = "0.9.4" } +fff-query-parser = { path = "../fff-query-parser", version = "0.9.4" } +git2 = { workspace = true } +pyo3 = { version = "0.24.0", features = ["extension-module", "abi3-py310"] } diff --git a/crates/fff-python/src/conversions.rs b/crates/fff-python/src/conversions.rs new file mode 100644 index 00000000..1656632d --- /dev/null +++ b/crates/fff-python/src/conversions.rs @@ -0,0 +1,107 @@ +use fff::file_picker::FilePicker; + +use crate::types::{DirItem, FileItem, GrepMatch, MatchRange, MixedDirItem, MixedFileItem, Score}; + +pub enum MixedItem { + File(MixedFileItem), + Dir(MixedDirItem), +} + +impl From<&fff::Score> for Score { + fn from(s: &fff::Score) -> Self { + Self { + total: s.total, + base_score: s.base_score, + filename_bonus: s.filename_bonus, + special_filename_bonus: s.special_filename_bonus, + frecency_boost: s.frecency_boost, + distance_penalty: s.distance_penalty, + current_file_penalty: s.current_file_penalty, + combo_match_boost: s.combo_match_boost, + path_alignment_bonus: s.path_alignment_bonus, + exact_match: s.exact_match, + match_type: s.match_type.to_string(), + } + } +} + +impl From<(&fff::FileItem, &FilePicker)> for FileItem { + fn from((item, picker): (&fff::FileItem, &FilePicker)) -> Self { + Self { + relative_path: item.relative_path(picker), + file_name: item.file_name(picker), + git_status: fff::git::format_git_status(item.git_status).to_string(), + size: item.size, + modified: item.modified, + access_frecency_score: item.access_frecency_score as i64, + modification_frecency_score: item.modification_frecency_score as i64, + total_frecency_score: item.total_frecency_score() as i64, + is_binary: item.is_binary(), + } + } +} + +impl From<(&fff::DirItem, &FilePicker)> for DirItem { + fn from((item, picker): (&fff::DirItem, &FilePicker)) -> Self { + Self { + relative_path: item.relative_path(picker), + dir_name: item.dir_name(picker), + max_access_frecency: item.max_access_frecency(), + } + } +} + +impl From<(&fff::FileItem, &FilePicker)> for MixedFileItem { + fn from((item, picker): (&fff::FileItem, &FilePicker)) -> Self { + Self { + relative_path: item.relative_path(picker), + file_name: item.file_name(picker), + git_status: fff::git::format_git_status(item.git_status).to_string(), + size: item.size, + modified: item.modified, + access_frecency_score: item.access_frecency_score as i64, + modification_frecency_score: item.modification_frecency_score as i64, + total_frecency_score: item.total_frecency_score() as i64, + is_binary: item.is_binary(), + } + } +} + +impl From<(&fff::DirItem, &FilePicker)> for MixedDirItem { + fn from((item, picker): (&fff::DirItem, &FilePicker)) -> Self { + Self { + relative_path: item.relative_path(picker), + dir_name: item.dir_name(picker), + max_access_frecency: item.max_access_frecency(), + } + } +} + +impl From<(&fff::GrepMatch, &fff::FileItem, &FilePicker)> for GrepMatch { + fn from((m, file, picker): (&fff::GrepMatch, &fff::FileItem, &FilePicker)) -> Self { + Self { + relative_path: file.relative_path(picker), + file_name: file.file_name(picker), + git_status: fff::git::format_git_status(file.git_status).to_string(), + line_content: m.line_content.clone(), + match_ranges: m + .match_byte_offsets + .iter() + .map(|&(s, e)| MatchRange { start: s, end: e }) + .collect(), + context_before: m.context_before.clone(), + context_after: m.context_after.clone(), + size: file.size, + modified: file.modified, + total_frecency_score: file.total_frecency_score() as i64, + access_frecency_score: file.access_frecency_score as i64, + modification_frecency_score: file.modification_frecency_score as i64, + line_number: m.line_number, + byte_offset: m.byte_offset, + col: m.col as u32, + fuzzy_score: m.fuzzy_score, + is_definition: m.is_definition, + is_binary: file.is_binary(), + } + } +} diff --git a/crates/fff-python/src/finder.rs b/crates/fff-python/src/finder.rs new file mode 100644 index 00000000..e61232fc --- /dev/null +++ b/crates/fff-python/src/finder.rs @@ -0,0 +1,979 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use fff::file_picker::FilePicker; +use fff::frecency::FrecencyTracker; +use fff::query_tracker::QueryTracker; +use fff::{ + FFFMode, FilePickerOptions, FuzzySearchOptions, GrepSearchOptions, PaginationArgs, QueryParser, + SharedFilePicker, SharedFrecency, SharedQueryTracker, +}; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +use crate::conversions::MixedItem; +use crate::types::{ + DirItem, DirSearchResult, FileItem, GrepCursor, GrepMatch, GrepResult, MixedDirItem, + MixedFileItem, MixedSearchResult, ScanProgress, Score, SearchResult, +}; +use crate::{parse_grep_mode, py_err}; + +const DEFAULT_SEARCH_PAGE_SIZE: usize = 100; +// Sentinel-to-default conversion for combo boosting, mirroring the C/Node +// bindings: `0` means "use the engine default", not "disable". +const DEFAULT_COMBO_BOOST_MULTIPLIER: i32 = 100; +const DEFAULT_MIN_COMBO_COUNT: u32 = 3; + +fn defaulted_usize(value: u32, default: usize) -> usize { + if value == 0 { default } else { value as usize } +} + +fn defaulted_u64(value: u64, default: u64) -> u64 { + if value == 0 { default } else { value } +} + +fn defaulted_i32(value: i32, default: i32) -> i32 { + if value == 0 { default } else { value } +} + +fn defaulted_u32(value: u32, default: u32) -> u32 { + if value == 0 { default } else { value } +} + +fn create_parent_dir(path: &Path) -> PyResult<()> { + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(py_err)?; + } + Ok(()) +} + +fn pagination_args(page_index: u32, page_size: u32) -> PaginationArgs { + PaginationArgs { + offset: page_index as usize, + limit: defaulted_usize(page_size, DEFAULT_SEARCH_PAGE_SIZE), + } +} + +fn fuzzy_options<'a>( + max_threads: u32, + current_file: Option<&'a str>, + project_path: &'a Path, + page_index: u32, + page_size: u32, + combo_boost_score_multiplier: i32, + min_combo_count: u32, +) -> FuzzySearchOptions<'a> { + FuzzySearchOptions { + max_threads: max_threads as usize, + current_file, + project_path: Some(project_path), + combo_boost_score_multiplier, + min_combo_count, + pagination: pagination_args(page_index, page_size), + } +} + +#[allow(clippy::too_many_arguments)] +fn grep_options( + mode: fff::GrepMode, + cursor_offset: usize, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, +) -> GrepSearchOptions { + let defaults = GrepSearchOptions::default(); + GrepSearchOptions { + max_file_size: defaulted_u64(max_file_size, defaults.max_file_size), + max_matches_per_file: max_matches_per_file as usize, + smart_case, + file_offset: cursor_offset, + page_limit: defaulted_usize(page_limit, defaults.page_limit), + mode, + time_budget_ms, + before_context: before_context as usize, + after_context: after_context as usize, + classify_definitions, + trim_whitespace: false, + abort_signal: None, + } +} + +fn convert_scores(scores: &[fff::Score]) -> Vec { + scores.iter().map(Score::from).collect() +} + +fn convert_grep_result(result: fff::grep::GrepResult<'_>, picker: &FilePicker) -> GrepResult { + let items = result + .matches + .iter() + .map(|m| GrepMatch::from((m, result.files[m.file_index], picker))) + .collect(); + + GrepResult { + items, + total_matched: result.matches.len() as u32, + total_files_searched: result.total_files_searched as u32, + total_files: result.total_files as u32, + filtered_file_count: result.filtered_file_count as u32, + next_file_offset: result.next_file_offset as u32, + regex_fallback_error: result.regex_fallback_error, + } +} + +fn clear_shared_state( + picker: &SharedFilePicker, + frecency: &SharedFrecency, + query_tracker: &SharedQueryTracker, +) { + if let Ok(mut guard) = picker.write() { + guard.take(); + } + if let Ok(mut guard) = frecency.write() { + *guard = None; + } + if let Ok(mut guard) = query_tracker.write() { + *guard = None; + } +} + +#[pyclass(subclass)] +pub struct FileFinder { + picker: SharedFilePicker, + frecency: SharedFrecency, + query_tracker: SharedQueryTracker, + cache_budget_max_files: usize, + cache_budget_max_bytes: u64, + cache_budget_max_file_size: u64, +} + +impl Drop for FileFinder { + fn drop(&mut self) { + clear_shared_state(&self.picker, &self.frecency, &self.query_tracker); + } +} + +#[pymethods] +impl FileFinder { + #[new] + #[pyo3(signature = ( + base_path, + *, + frecency_db_path=None, + history_db_path=None, + enable_mmap_cache=true, + enable_content_indexing=true, + watch=true, + ai_mode=false, + log_file_path=None, + log_level=None, + cache_budget_max_files=0, + cache_budget_max_bytes=0, + cache_budget_max_file_size=0, + enable_fs_root_scanning=false, + enable_home_dir_scanning=false, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + base_path: PathBuf, + frecency_db_path: Option, + history_db_path: Option, + enable_mmap_cache: bool, + enable_content_indexing: bool, + watch: bool, + ai_mode: bool, + log_file_path: Option, + log_level: Option, + cache_budget_max_files: u64, + cache_budget_max_bytes: u64, + cache_budget_max_file_size: u64, + enable_fs_root_scanning: bool, + enable_home_dir_scanning: bool, + ) -> PyResult { + let shared_picker = SharedFilePicker::default(); + let shared_frecency = SharedFrecency::default(); + let query_tracker = SharedQueryTracker::default(); + let cache_budget_max_files = cache_budget_max_files as usize; + + let init_picker = shared_picker.clone(); + let init_frecency = shared_frecency.clone(); + let init_query_tracker = query_tracker.clone(); + + py.allow_threads(move || -> PyResult<()> { + if let Some(path) = frecency_db_path { + create_parent_dir(&path)?; + let tracker = FrecencyTracker::open(&path).map_err(py_err)?; + init_frecency.init(tracker).map_err(py_err)?; + } + + if let Some(path) = history_db_path { + create_parent_dir(&path)?; + let tracker = QueryTracker::open(&path).map_err(py_err)?; + init_query_tracker.init(tracker).map_err(py_err)?; + } + + if let Some(path) = log_file_path { + create_parent_dir(&path)?; + let path = path.to_string_lossy(); + fff::log::init_tracing(&path, log_level.as_deref(), None).map_err(py_err)?; + } + + let mode = if ai_mode { + FFFMode::Ai + } else { + FFFMode::Neovim + }; + + FilePicker::new_with_shared_state( + init_picker, + init_frecency, + FilePickerOptions { + base_path: base_path.to_string_lossy().to_string(), + enable_mmap_cache, + enable_content_indexing, + watch, + mode, + cache_budget: fff::ContentCacheBudget::from_overrides( + cache_budget_max_files, + cache_budget_max_bytes, + cache_budget_max_file_size, + ), + follow_symlinks: false, + enable_fs_root_scanning, + enable_home_dir_scanning, + }, + ) + .map_err(py_err) + })?; + + Ok(Self { + picker: shared_picker, + frecency: shared_frecency, + query_tracker, + cache_budget_max_files, + cache_budget_max_bytes, + cache_budget_max_file_size, + }) + } + + fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __exit__(&mut self, _exc_type: PyObject, _exc_value: PyObject, _traceback: PyObject) { + let _ = self.close(); + } + + fn close(&mut self) -> PyResult<()> { + clear_shared_state(&self.picker, &self.frecency, &self.query_tracker); + Ok(()) + } + + #[getter] + fn closed(&self) -> PyResult { + Ok(self.picker.read().map_err(py_err)?.is_none()) + } + + #[getter] + fn base_path(&self) -> PyResult> { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard + .as_ref() + .map(|p| p.base_path().to_string_lossy().to_string())) + } + + #[getter] + fn scan_progress(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + let picker = guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let p = picker.get_scan_progress(); + Ok(ScanProgress { + scanned_files_count: p.scanned_files_count as u64, + is_scanning: p.is_scanning, + is_watcher_ready: p.is_watcher_ready, + is_warmup_complete: p.is_warmup_complete, + }) + } + + fn __repr__(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + Ok(format!( + "FileFinder(base_path={:?}, closed=False)", + picker.base_path().to_string_lossy() + )) + } else { + Ok("FileFinder(closed=True)".to_string()) + } + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + *, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + combo_boost_score_multiplier=0, + min_combo_count=0, + ))] + fn search( + &self, + py: Python<'_>, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + combo_boost_score_multiplier: i32, + min_combo_count: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parsed = QueryParser::default().parse(&query); + let result = picker.fuzzy_search( + &parsed, + qt_guard.as_ref(), + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + defaulted_i32(combo_boost_score_multiplier, DEFAULT_COMBO_BOOST_MULTIPLIER), + defaulted_u32(min_combo_count, DEFAULT_MIN_COMBO_COUNT), + ), + ); + + Ok(SearchResult { + items: result + .items + .iter() + .map(|i| FileItem::from((*i, picker))) + .collect(), + scores: convert_scores(&result.scores), + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + }) + }) + } + + #[pyo3(signature = ( + pattern, + *, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + ))] + fn glob( + &self, + py: Python<'_>, + pattern: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let pattern = pattern.to_string(); + + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let result = picker.glob( + &pattern, + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + 0, + 0, + ), + ); + + Ok(SearchResult { + items: result + .items + .iter() + .map(|i| FileItem::from((*i, picker))) + .collect(), + scores: convert_scores(&result.scores), + total_matched: result.total_matched as u32, + total_files: result.total_files as u32, + }) + }) + } + + #[pyo3(signature = ( + query, + *, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + ))] + fn directory_search( + &self, + py: Python<'_>, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let query = query.to_string(); + + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let parsed = QueryParser::new(fff_query_parser::DirSearchConfig).parse(&query); + let result = picker.fuzzy_search_directories( + &parsed, + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + 0, + 0, + ), + ); + + Ok(DirSearchResult { + items: result + .items + .iter() + .map(|i| DirItem::from((*i, picker))) + .collect(), + scores: convert_scores(&result.scores), + total_matched: result.total_matched as u32, + total_dirs: result.total_dirs as u32, + }) + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + *, + current_file=None, + max_threads=0, + page_index=0, + page_size=0, + combo_boost_score_multiplier=0, + min_combo_count=0, + ))] + fn mixed_search( + &self, + py: Python<'_>, + query: &str, + current_file: Option, + max_threads: u32, + page_index: u32, + page_size: u32, + combo_boost_score_multiplier: i32, + min_combo_count: u32, + ) -> PyResult { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + let (items, scores, total_matched, total_files, total_dirs) = + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + let qt_guard = query_tracker.read().map_err(py_err)?; + + let parsed = QueryParser::new(fff_query_parser::MixedSearchConfig).parse(&query); + let result = picker.fuzzy_search_mixed( + &parsed, + qt_guard.as_ref(), + fuzzy_options( + max_threads, + current_file.as_deref(), + picker.base_path(), + page_index, + page_size, + defaulted_i32(combo_boost_score_multiplier, DEFAULT_COMBO_BOOST_MULTIPLIER), + defaulted_u32(min_combo_count, DEFAULT_MIN_COMBO_COUNT), + ), + ); + + let items: Vec = result + .items + .iter() + .map(|item| match item { + fff::MixedItemRef::File(file) => { + MixedItem::File(MixedFileItem::from((*file, picker))) + } + fff::MixedItemRef::Dir(dir) => { + MixedItem::Dir(MixedDirItem::from((*dir, picker))) + } + }) + .collect(); + + Ok(( + items, + convert_scores(&result.scores), + result.total_matched as u32, + result.total_files as u32, + result.total_dirs as u32, + )) + })?; + + let items: PyResult> = items + .into_iter() + .map(|item| match item { + MixedItem::File(file) => Ok(Py::new(py, file)?.into_any()), + MixedItem::Dir(dir) => Ok(Py::new(py, dir)?.into_any()), + }) + .collect(); + + Ok(MixedSearchResult { + items: items?, + scores, + total_matched, + total_files, + total_dirs, + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + query, + *, + mode="plain", + max_file_size=0, + max_matches_per_file=0, + smart_case=true, + cursor=None, + page_limit=0, + time_budget_ms=0, + before_context=0, + after_context=0, + classify_definitions=false, + ))] + fn grep( + &self, + py: Python<'_>, + query: &str, + mode: &str, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + cursor: Option<&GrepCursor>, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, + ) -> PyResult { + let picker = self.picker.clone(); + let query = query.to_string(); + let mode = parse_grep_mode(mode)?; + let cursor_offset = cursor.map(|c| c.offset as usize).unwrap_or(0); + + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + let parsed = if picker.mode().is_ai() { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(&query) + } else { + fff::grep::parse_grep_query(&query) + }; + let options = grep_options( + mode, + cursor_offset, + max_file_size, + max_matches_per_file, + smart_case, + page_limit, + time_budget_ms, + before_context, + after_context, + classify_definitions, + ); + + Ok(convert_grep_result(picker.grep(&parsed, &options), picker)) + }) + } + + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + patterns, + *, + constraints=None, + mode="plain", + max_file_size=0, + max_matches_per_file=0, + smart_case=true, + cursor=None, + page_limit=0, + time_budget_ms=0, + before_context=0, + after_context=0, + classify_definitions=false, + ))] + fn multi_grep( + &self, + py: Python<'_>, + patterns: Vec, + constraints: Option, + mode: &str, + max_file_size: u64, + max_matches_per_file: u32, + smart_case: bool, + cursor: Option<&GrepCursor>, + page_limit: u32, + time_budget_ms: u64, + before_context: u32, + after_context: u32, + classify_definitions: bool, + ) -> PyResult { + let picker = self.picker.clone(); + let mode = parse_grep_mode(mode)?; + let cursor_offset = cursor.map(|c| c.offset as usize).unwrap_or(0); + + py.allow_threads(move || -> PyResult<_> { + let picker_guard = picker.read().map_err(py_err)?; + let picker = picker_guard + .as_ref() + .ok_or_else(|| py_err("File picker not initialized"))?; + + if patterns.is_empty() || patterns.iter().all(|p| p.is_empty()) { + return Err(py_err("patterns must not be empty")); + } + let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect(); + + let parsed_constraints = constraints.as_ref().map(|c| { + if picker.mode().is_ai() { + QueryParser::new(fff_query_parser::AiGrepConfig).parse(c) + } else { + fff::grep::parse_grep_query(c) + } + }); + let constraint_refs: &[fff::Constraint<'_>] = match &parsed_constraints { + Some(q) => &q.constraints, + None => &[], + }; + let options = grep_options( + mode, + cursor_offset, + max_file_size, + max_matches_per_file, + smart_case, + page_limit, + time_budget_ms, + before_context, + after_context, + classify_definitions, + ); + + Ok(convert_grep_result( + picker.multi_grep(&pattern_refs, constraint_refs, &options), + picker, + )) + }) + } + + fn scan_files(&self) -> PyResult<()> { + self.picker + .trigger_full_rescan_async(&self.frecency) + .map_err(py_err) + } + + fn is_scanning(&self) -> PyResult { + let guard = self.picker.read().map_err(py_err)?; + Ok(guard.as_ref().map(|p| p.is_scan_active()).unwrap_or(false)) + } + + #[pyo3(signature = (timeout_ms=5000))] + fn wait_for_scan_blocking(&self, py: Python<'_>, timeout_ms: u64) -> PyResult { + let picker = self.picker.clone(); + py.allow_threads(move || Ok(picker.wait_for_scan(Duration::from_millis(timeout_ms)))) + } + + fn reindex(&self, py: Python<'_>, new_path: PathBuf) -> PyResult<()> { + let picker = self.picker.clone(); + let frecency = self.frecency.clone(); + let cache_budget_max_files = self.cache_budget_max_files; + let cache_budget_max_bytes = self.cache_budget_max_bytes; + let cache_budget_max_file_size = self.cache_budget_max_file_size; + + py.allow_threads(move || -> PyResult<()> { + if !new_path.exists() { + return Err(py_err(format!( + "Path does not exist: {}", + new_path.display() + ))); + } + let canonical = fff::path_utils::canonicalize(&new_path).map_err(py_err)?; + + let (warmup_caches, content_indexing, watch, mode, fs_root, home_dir) = { + let guard = picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + ( + picker.has_mmap_cache(), + picker.has_content_indexing(), + picker.has_watcher(), + picker.mode(), + picker.fs_root_scanning_enabled(), + picker.home_dir_scanning_enabled(), + ) + } else { + (false, true, true, FFFMode::default(), false, false) + } + }; + + FilePicker::new_with_shared_state( + picker.clone(), + frecency, + FilePickerOptions { + base_path: canonical.to_string_lossy().to_string(), + enable_mmap_cache: warmup_caches, + enable_content_indexing: content_indexing, + watch, + mode, + cache_budget: fff::ContentCacheBudget::from_overrides( + cache_budget_max_files, + cache_budget_max_bytes, + cache_budget_max_file_size, + ), + follow_symlinks: false, + enable_fs_root_scanning: fs_root, + enable_home_dir_scanning: home_dir, + }, + ) + .map_err(py_err) + }) + } + + fn refresh_git_status(&self, py: Python<'_>) -> PyResult { + let picker = self.picker.clone(); + let frecency = self.frecency.clone(); + py.allow_threads(move || { + picker + .refresh_git_status(&frecency) + .map_err(py_err) + .map(|c| c as i64) + }) + } + + #[pyo3(signature = (query, selected_file_path))] + fn track_query( + &self, + py: Python<'_>, + query: &str, + selected_file_path: PathBuf, + ) -> PyResult { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + let query = query.to_string(); + + py.allow_threads(move || -> PyResult { + let file_path = fff::path_utils::canonicalize(&selected_file_path).map_err(py_err)?; + let project_path = { + let guard = picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(false), + }; + + let mut qt_guard = query_tracker.write().map_err(py_err)?; + if let Some(ref mut tracker) = *qt_guard { + tracker + .track_query_completion(&query, &project_path, &file_path) + .map_err(py_err)?; + Ok(true) + } else { + Ok(false) + } + }) + } + + fn get_historical_query(&self, py: Python<'_>, offset: u64) -> PyResult> { + let picker = self.picker.clone(); + let query_tracker = self.query_tracker.clone(); + + py.allow_threads(move || -> PyResult> { + let project_path = { + let guard = picker.read().map_err(py_err)?; + guard.as_ref().map(|p| p.base_path().to_path_buf()) + }; + let project_path = match project_path { + Some(p) => p, + None => return Ok(None), + }; + + let qt_guard = query_tracker.read().map_err(py_err)?; + if let Some(ref tracker) = *qt_guard { + tracker + .get_historical_query(&project_path, offset as usize) + .map_err(py_err) + } else { + Ok(None) + } + }) + } + + #[pyo3(signature = (test_path=None))] + fn health_check(&self, py: Python<'_>, test_path: Option) -> PyResult> { + let picker = self.picker.clone(); + let frecency = self.frecency.clone(); + let query_tracker = self.query_tracker.clone(); + + let ( + git_version, + repository_found, + workdir, + git_error, + picker_initialized, + picker_base_path, + picker_is_scanning, + picker_indexed_files, + frecency_initialized, + query_tracker_initialized, + ) = py.allow_threads(move || -> PyResult<_> { + // Resolve the path to inspect: explicit arg → indexed base path → + // process cwd. Report a cwd-resolution failure instead of silently + // discovering from an empty path. + let (test_path, cwd_error) = match test_path { + Some(p) => (Some(p), None), + None => { + let base = picker + .read() + .ok() + .and_then(|g| g.as_ref().map(|p| p.base_path().to_path_buf())); + match base { + Some(p) => (Some(p), None), + None => match std::env::current_dir() { + Ok(p) => (Some(p), None), + Err(e) => ( + None, + Some(format!("could not determine current directory: {}", e)), + ), + }, + } + } + }; + + let git_version = git2::Version::get(); + let (major, minor, rev) = git_version.libgit2_version(); + let git_version = format!("{}.{}.{}", major, minor, rev); + let (repository_found, workdir, git_error) = match test_path { + None => (false, None, cwd_error), + Some(test_path) => match git2::Repository::discover(&test_path) { + Ok(repo) => ( + true, + repo.workdir().map(|p| p.to_string_lossy().to_string()), + None, + ), + Err(e) => (false, None, Some(e.message().to_string())), + }, + }; + + let (picker_initialized, picker_base_path, picker_is_scanning, picker_indexed_files) = { + let guard = picker.read().map_err(py_err)?; + if let Some(ref picker) = *guard { + let progress = picker.get_scan_progress(); + ( + true, + Some(picker.base_path().to_string_lossy().to_string()), + Some(picker.is_scan_active()), + Some(progress.scanned_files_count), + ) + } else { + (false, None, None, None) + } + }; + let frecency_initialized = frecency.read().map_err(py_err)?.is_some(); + let query_tracker_initialized = query_tracker.read().map_err(py_err)?.is_some(); + + Ok(( + git_version, + repository_found, + workdir, + git_error, + picker_initialized, + picker_base_path, + picker_is_scanning, + picker_indexed_files, + frecency_initialized, + query_tracker_initialized, + )) + })?; + + let dict = PyDict::new(py); + dict.set_item("version", env!("CARGO_PKG_VERSION"))?; + + let git_info = PyDict::new(py); + git_info.set_item("available", true)?; + git_info.set_item("libgit2_version", git_version)?; + git_info.set_item("repository_found", repository_found)?; + if let Some(workdir) = workdir { + git_info.set_item("workdir", workdir)?; + } + if let Some(error) = git_error { + git_info.set_item("error", error)?; + } + dict.set_item("git", git_info)?; + + let picker_info = PyDict::new(py); + picker_info.set_item("initialized", picker_initialized)?; + if let Some(base_path) = picker_base_path { + picker_info.set_item("base_path", base_path)?; + } + if let Some(is_scanning) = picker_is_scanning { + picker_info.set_item("is_scanning", is_scanning)?; + } + if let Some(indexed_files) = picker_indexed_files { + picker_info.set_item("indexed_files", indexed_files)?; + } + dict.set_item("file_picker", picker_info)?; + + let frecency_info = PyDict::new(py); + frecency_info.set_item("initialized", frecency_initialized)?; + dict.set_item("frecency", frecency_info)?; + + let query_info = PyDict::new(py); + query_info.set_item("initialized", query_tracker_initialized)?; + dict.set_item("query_tracker", query_info)?; + + Ok(dict.unbind()) + } +} diff --git a/crates/fff-python/src/lib.rs b/crates/fff-python/src/lib.rs new file mode 100644 index 00000000..94d21946 --- /dev/null +++ b/crates/fff-python/src/lib.rs @@ -0,0 +1,44 @@ +use pyo3::create_exception; +use pyo3::prelude::*; + +mod conversions; +mod finder; +mod types; + +create_exception!(fff_python, FFFException, pyo3::exceptions::PyException); + +fn py_err(e: E) -> PyErr { + PyErr::new::(format!("{}", e)) +} + +pub fn parse_grep_mode(mode: &str) -> PyResult { + match mode { + "plain" => Ok(fff::GrepMode::PlainText), + "regex" => Ok(fff::GrepMode::Regex), + "fuzzy" => Ok(fff::GrepMode::Fuzzy), + _ => Err(py_err(format!( + "invalid grep mode: {:?}. Must be one of: plain, regex, fuzzy", + mode + ))), + } +} + +#[pymodule] +fn _fff_python(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add("FFFException", m.py().get_type::())?; + Ok(()) +} diff --git a/crates/fff-python/src/types.rs b/crates/fff-python/src/types.rs new file mode 100644 index 00000000..32c2f134 --- /dev/null +++ b/crates/fff-python/src/types.rs @@ -0,0 +1,408 @@ +use pyo3::prelude::*; + +#[pyclass] +#[derive(Clone)] +pub struct Score { + #[pyo3(get)] + pub total: i32, + #[pyo3(get)] + pub base_score: i32, + #[pyo3(get)] + pub filename_bonus: i32, + #[pyo3(get)] + pub special_filename_bonus: i32, + #[pyo3(get)] + pub frecency_boost: i32, + #[pyo3(get)] + pub distance_penalty: i32, + #[pyo3(get)] + pub current_file_penalty: i32, + #[pyo3(get)] + pub combo_match_boost: i32, + #[pyo3(get)] + pub path_alignment_bonus: i32, + #[pyo3(get)] + pub exact_match: bool, + #[pyo3(get)] + pub match_type: String, +} + +#[pymethods] +impl Score { + fn __repr__(&self) -> String { + format!( + "Score(total={}, base_score={}, filename_bonus={}, match_type={:?})", + self.total, self.base_score, self.filename_bonus, self.match_type + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct FileItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pymethods] +impl FileItem { + fn __repr__(&self) -> String { + format!( + "FileItem(relative_path={:?}, file_name={:?}, size={})", + self.relative_path, self.file_name, self.size + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct DirItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub dir_name: String, + #[pyo3(get)] + pub max_access_frecency: i32, +} + +#[pymethods] +impl DirItem { + fn __repr__(&self) -> String { + format!( + "DirItem(relative_path={:?}, dir_name={:?})", + self.relative_path, self.dir_name + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct MixedFileItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pymethods] +impl MixedFileItem { + fn __repr__(&self) -> String { + format!( + "MixedFileItem(relative_path={:?}, file_name={:?}, size={})", + self.relative_path, self.file_name, self.size + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct MixedDirItem { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub dir_name: String, + #[pyo3(get)] + pub max_access_frecency: i32, +} + +#[pymethods] +impl MixedDirItem { + fn __repr__(&self) -> String { + format!( + "MixedDirItem(relative_path={:?}, dir_name={:?})", + self.relative_path, self.dir_name + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct MatchRange { + #[pyo3(get)] + pub start: u32, + #[pyo3(get)] + pub end: u32, +} + +#[pymethods] +impl MatchRange { + fn __repr__(&self) -> String { + format!("MatchRange(start={}, end={})", self.start, self.end) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepMatch { + #[pyo3(get)] + pub relative_path: String, + #[pyo3(get)] + pub file_name: String, + #[pyo3(get)] + pub git_status: String, + #[pyo3(get)] + pub line_content: String, + #[pyo3(get)] + pub match_ranges: Vec, + #[pyo3(get)] + pub context_before: Vec, + #[pyo3(get)] + pub context_after: Vec, + #[pyo3(get)] + pub size: u64, + #[pyo3(get)] + pub modified: u64, + #[pyo3(get)] + pub total_frecency_score: i64, + #[pyo3(get)] + pub access_frecency_score: i64, + #[pyo3(get)] + pub modification_frecency_score: i64, + #[pyo3(get)] + pub line_number: u64, + #[pyo3(get)] + pub byte_offset: u64, + #[pyo3(get)] + pub col: u32, + #[pyo3(get)] + pub fuzzy_score: Option, + #[pyo3(get)] + pub is_definition: bool, + #[pyo3(get)] + pub is_binary: bool, +} + +#[pymethods] +impl GrepMatch { + fn __repr__(&self) -> String { + format!( + "GrepMatch(relative_path={:?}, line_number={}, line_content={:?})", + self.relative_path, self.line_number, self.line_content + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct SearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files: u32, +} + +#[pymethods] +impl SearchResult { + fn __repr__(&self) -> String { + format!( + "SearchResult(items={}, total_matched={}, total_files={})", + self.items.len(), + self.total_matched, + self.total_files + ) + } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } +} + +#[pyclass] +#[derive(Clone)] +pub struct DirSearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_dirs: u32, +} + +#[pymethods] +impl DirSearchResult { + fn __repr__(&self) -> String { + format!( + "DirSearchResult(items={}, total_matched={}, total_dirs={})", + self.items.len(), + self.total_matched, + self.total_dirs + ) + } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } +} + +#[pyclass] +pub struct MixedSearchResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub scores: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files: u32, + #[pyo3(get)] + pub total_dirs: u32, +} + +#[pymethods] +impl MixedSearchResult { + fn __repr__(&self) -> String { + format!( + "MixedSearchResult(items={}, total_matched={}, total_files={}, total_dirs={})", + self.items.len(), + self.total_matched, + self.total_files, + self.total_dirs + ) + } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepResult { + #[pyo3(get)] + pub items: Vec, + #[pyo3(get)] + pub total_matched: u32, + #[pyo3(get)] + pub total_files_searched: u32, + #[pyo3(get)] + pub total_files: u32, + #[pyo3(get)] + pub filtered_file_count: u32, + #[pyo3(get)] + pub next_file_offset: u32, + #[pyo3(get)] + pub regex_fallback_error: Option, +} + +#[pymethods] +impl GrepResult { + fn __repr__(&self) -> String { + format!( + "GrepResult(items={}, total_matched={}, next_file_offset={})", + self.items.len(), + self.total_matched, + self.next_file_offset + ) + } + + fn __len__(&self) -> usize { + self.items.len() + } + + fn __bool__(&self) -> bool { + !self.items.is_empty() + } + + #[getter] + fn has_more(&self) -> bool { + self.next_file_offset > 0 + } + + fn next_cursor(&self, py: Python<'_>) -> PyResult>> { + if self.next_file_offset > 0 { + Ok(Some(Py::new(py, GrepCursor::new(self.next_file_offset))?)) + } else { + Ok(None) + } + } +} + +#[pyclass] +#[derive(Clone)] +pub struct ScanProgress { + #[pyo3(get)] + pub scanned_files_count: u64, + #[pyo3(get)] + pub is_scanning: bool, + #[pyo3(get)] + pub is_watcher_ready: bool, + #[pyo3(get)] + pub is_warmup_complete: bool, +} + +#[pymethods] +impl ScanProgress { + fn __repr__(&self) -> String { + format!( + "ScanProgress(scanned_files_count={}, is_scanning={})", + self.scanned_files_count, self.is_scanning + ) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct GrepCursor { + #[pyo3(get)] + pub offset: u32, +} + +#[pymethods] +impl GrepCursor { + #[new] + fn new(offset: u32) -> Self { + Self { offset } + } + + fn __repr__(&self) -> String { + format!("GrepCursor(offset={})", self.offset) + } +} diff --git a/packages/fff-python/.python-version b/packages/fff-python/.python-version new file mode 100644 index 00000000..c8cfe395 --- /dev/null +++ b/packages/fff-python/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/packages/fff-python/README.md b/packages/fff-python/README.md new file mode 100644 index 00000000..f70686d6 --- /dev/null +++ b/packages/fff-python/README.md @@ -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+. diff --git a/packages/fff-python/examples/basic.py b/packages/fff-python/examples/basic.py new file mode 100644 index 00000000..5e296c31 --- /dev/null +++ b/packages/fff-python/examples/basic.py @@ -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()) diff --git a/packages/fff-python/pyproject.toml b/packages/fff-python/pyproject.toml new file mode 100644 index 00000000..9080525c --- /dev/null +++ b/packages/fff-python/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "fff-search" +version = "0.9.4" +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" diff --git a/packages/fff-python/src/fff/__init__.py b/packages/fff-python/src/fff/__init__.py new file mode 100644 index 00000000..4a42000b --- /dev/null +++ b/packages/fff-python/src/fff/__init__.py @@ -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.4" + +__all__ = [ + "FFFException", + "FileFinder", + "FileItem", + "DirItem", + "Score", + "SearchResult", + "DirSearchResult", + "MixedFileItem", + "MixedDirItem", + "MixedSearchResult", + "MatchRange", + "GrepMatch", + "GrepResult", + "GrepCursor", + "ScanProgress", + "__version__", +] diff --git a/packages/fff-python/src/fff/__init__.pyi b/packages/fff-python/src/fff/__init__.pyi new file mode 100644 index 00000000..1b1c6564 --- /dev/null +++ b/packages/fff-python/src/fff/__init__.pyi @@ -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]: ... diff --git a/packages/fff-python/src/fff/py.typed b/packages/fff-python/src/fff/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/fff-python/tests/test_finder.py b/packages/fff-python/tests/test_finder.py new file mode 100644 index 00000000..c85b4e3b --- /dev/null +++ b/packages/fff-python/tests/test_finder.py @@ -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 diff --git a/packages/fff-python/uv.lock b/packages/fff-python/uv.lock new file mode 100644 index 00000000..a01dcde2 --- /dev/null +++ b/packages/fff-python/uv.lock @@ -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.4" +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" }, +] diff --git a/scripts/release.sh b/scripts/release.sh index 59f61a5f..e3acd4fc 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -30,6 +30,41 @@ cargo install cargo-edit --force --locked echo "→ Updating Cargo.toml versions to $VERSION" cargo set-version "$VERSION" +echo "→ Updating Python package version to $VERSION" +PYPROJECT="packages/fff-python/pyproject.toml" +PYINIT="packages/fff-python/src/fff/__init__.py" +UVLOCK="packages/fff-python/uv.lock" + +sed_inplace() { + local file="$1"; shift + local tmp + tmp="$(mktemp)" + sed "$@" "$file" >"$tmp" + mv "$tmp" "$file" +} + +sed_inplace "$PYPROJECT" -e 's/^version = ".*"$/version = "'"$VERSION"'"/' +sed_inplace "$PYINIT" -e 's/^__version__ = ".*"$/__version__ = "'"$VERSION"'"/' + +# uv.lock has a `version` line per package; only touch the one directly +# after the `fff-search` package entry. Multi `-e` keeps the `{ }` block +# portable across BSD and GNU sed. +if [ -f "$UVLOCK" ]; then + sed_inplace "$UVLOCK" \ + -e '/^name = "fff-search"$/{' \ + -e 'n' \ + -e 's/^version = ".*"$/version = "'"$VERSION"'"/' \ + -e '}' +fi + +# Fail loudly if any substitution did not land (mirrors the old guard). +grep -q "^version = \"$VERSION\"$" "$PYPROJECT" || { echo "Error: failed to update version in $PYPROJECT"; exit 1; } +grep -q "^__version__ = \"$VERSION\"$" "$PYINIT" || { echo "Error: failed to update version in $PYINIT"; exit 1; } +if [ -f "$UVLOCK" ]; then + grep -A1 '^name = "fff-search"$' "$UVLOCK" | grep -q "^version = \"$VERSION\"$" \ + || { echo "Error: failed to update fff-search version in $UVLOCK"; exit 1; } +fi + git add -A git commit -m "chore: release $VERSION"