Compare commits

...

15 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 5c4f84e198 chore: do not run full prebuild of python wheels on PR 2026-06-16 17:15:28 -07:00
Dmitriy Kovalenko 67f27ace3c refactor(python): use From trait for core type conversions 2026-06-16 17:04:49 -07:00
Dmitriy Kovalenko f2c9559d1b chore(release): bump python version with sed instead of inline python 2026-06-16 16:53:59 -07:00
Dmitriy Kovalenko 366fa8ff15 chore(python): align version to 0.9.4 for unified release 2026-06-16 16:37:59 -07:00
Dmitriy Kovalenko e538a4a419 chore(python): rename pypi distribution to fff-search 2026-06-16 16:12:39 -07:00
Dmitriy Kovalenko c1ec7ac6b9 fix(python): health_check defaults to indexed path and reports cwd errors; expand readme 2026-06-16 15:33:57 -07:00
Dmitriy Kovalenko 3c75374fe4 fix(python): consistent frecency type and combo defaults matching node 2026-06-16 15:27:40 -07:00
Dmitriy Kovalenko 8c25486c04 feat(python): async wait_for_scan with blocking variant 2026-06-16 14:19:27 -07:00
4fu 73c6b9e3c7 refactor(python): polish binding API and GIL handling
Make the Python binding API more idiomatic before merge: replace getter-style methods with properties, keep close() as the single explicit shutdown API, add container semantics for result objects, and tighten type stubs with Literal/Sequence/PathLike support.

Also reduce Rust binding duplication with shared option/result helpers and release the Python GIL around blocking filesystem, git, and query-history operations.
2026-06-16 15:01:21 +08:00
4fu c5f55c22b4 fix(python): align type stubs and runtime API 2026-06-16 13:45:47 +08:00
4fu 0143702531 refactor(python): split lib.rs and improve Pythonic API
- Split crates/fff-python/src/lib.rs into modules:
  - types.rs: all pyclass result types
  - finder.rs: FileFinder implementation
  - conversions.rs: From/core conversions

- Make API more Pythonic:
  - FileFinder now accepts pathlib.Path / os.PathLike for base_path and reindex
  - Add close() alias for destroy()
  - grep/multi_grep now raise FFFException for invalid modes
  - GrepResult gains has_more property and next_cursor() method

- Add type stubs:
  - packages/fff-python/src/fff/__init__.pyi
  - packages/fff-python/src/fff/py.typed

- Add __repr__ implementations for all exposed pyclasses

- Expand Python tests for pathlib, close(), reprs, invalid mode, and cursor pagination
2026-06-16 13:34:26 +08:00
4fu 15288542fd fix(python): sync release versions and expand tests 2026-06-16 13:15:47 +08:00
4fu dd35759eb0 fix(python): address review feedback and release CI
Rust bindings:

- Release GIL during heavy search/grep operations via py.allow_threads

- Add MixedFileItem/MixedDirItem::from_core to avoid double-cloning

- Return PyDict directly from health_check and drop serde_json dependency

- Call destroy() in __exit__ so the context manager releases resources

- Add mode parameter to multi_grep for parity with grep

- Preserve cache budget overrides across reindex()

- Rename combo_boost param to match FuzzySearchOptions field

Docs/tests:

- Update Python test for dict-returning health_check

- Add Python bindings section to main README

CI:

- Fix pypi-publish job to depend on build-python/build-python-sdist

  instead of release, making the workflow_dispatch checkbox functional
2026-06-16 12:03:28 +08:00
4fu 7a97802cbe ci: add Python CI workflow and release wheel builds
- Add .github/workflows/python.yml to test bindings on Ubuntu/macOS/Windows
- Extend release.yaml with Python wheel builds (x86_64/aarch64) and sdist
- Add optional PyPI publish job using trusted publishing
2026-06-14 19:02:49 +08:00
4fu 735dc17d5c feat(python): migrate Python bindings from ctypes to PyO3
- Add native PyO3 extension in crates/fff-python
- Replace ctypes wrapper with maturin-built package in packages/fff-python
- Expose FileFinder, search/glob/directory/mixed/grep APIs and result types
- Use PyPI package name fff-python (import name remains fff)
- Update workspace Cargo.toml/Cargo.lock and .gitignore for Python artifacts
2026-06-14 19:02:49 +08:00
21 changed files with 3078 additions and 4 deletions
+48
View File
@@ -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
+150 -4
View File
@@ -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]
+32
View File
@@ -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
*~
Generated
+109
View File
@@ -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"
+2
View File
@@ -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]
+69
View File
@@ -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.
<details id="python-bindings">
<summary>
<h2>Python bindings</h2>
</summary>
### 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/).
</details>
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?
+18
View File
@@ -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"] }
+107
View File
@@ -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(),
}
}
}
+979
View File
@@ -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<Score> {
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<PathBuf>,
history_db_path: Option<PathBuf>,
enable_mmap_cache: bool,
enable_content_indexing: bool,
watch: bool,
ai_mode: bool,
log_file_path: Option<PathBuf>,
log_level: Option<String>,
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<Self> {
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<bool> {
Ok(self.picker.read().map_err(py_err)?.is_none())
}
#[getter]
fn base_path(&self) -> PyResult<Option<String>> {
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<ScanProgress> {
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<String> {
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<String>,
max_threads: u32,
page_index: u32,
page_size: u32,
combo_boost_score_multiplier: i32,
min_combo_count: u32,
) -> PyResult<SearchResult> {
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<String>,
max_threads: u32,
page_index: u32,
page_size: u32,
) -> PyResult<SearchResult> {
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<String>,
max_threads: u32,
page_index: u32,
page_size: u32,
) -> PyResult<DirSearchResult> {
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<String>,
max_threads: u32,
page_index: u32,
page_size: u32,
combo_boost_score_multiplier: i32,
min_combo_count: u32,
) -> PyResult<MixedSearchResult> {
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<MixedItem> = 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<Vec<PyObject>> = 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<GrepResult> {
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<String>,
constraints: Option<String>,
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<GrepResult> {
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<bool> {
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<bool> {
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<i64> {
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<bool> {
let picker = self.picker.clone();
let query_tracker = self.query_tracker.clone();
let query = query.to_string();
py.allow_threads(move || -> PyResult<bool> {
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<Option<String>> {
let picker = self.picker.clone();
let query_tracker = self.query_tracker.clone();
py.allow_threads(move || -> PyResult<Option<String>> {
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<PathBuf>) -> PyResult<Py<PyDict>> {
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())
}
}
+44
View File
@@ -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: std::fmt::Display>(e: E) -> PyErr {
PyErr::new::<FFFException, _>(format!("{}", e))
}
pub fn parse_grep_mode(mode: &str) -> PyResult<fff::GrepMode> {
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::<finder::FileFinder>()?;
m.add_class::<types::Score>()?;
m.add_class::<types::FileItem>()?;
m.add_class::<types::DirItem>()?;
m.add_class::<types::MixedFileItem>()?;
m.add_class::<types::MixedDirItem>()?;
m.add_class::<types::SearchResult>()?;
m.add_class::<types::DirSearchResult>()?;
m.add_class::<types::MixedSearchResult>()?;
m.add_class::<types::MatchRange>()?;
m.add_class::<types::GrepMatch>()?;
m.add_class::<types::GrepResult>()?;
m.add_class::<types::ScanProgress>()?;
m.add_class::<types::GrepCursor>()?;
m.add("FFFException", m.py().get_type::<FFFException>())?;
Ok(())
}
+408
View File
@@ -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<MatchRange>,
#[pyo3(get)]
pub context_before: Vec<String>,
#[pyo3(get)]
pub context_after: Vec<String>,
#[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<u16>,
#[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<FileItem>,
#[pyo3(get)]
pub scores: Vec<Score>,
#[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<DirItem>,
#[pyo3(get)]
pub scores: Vec<Score>,
#[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<PyObject>,
#[pyo3(get)]
pub scores: Vec<Score>,
#[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<GrepMatch>,
#[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<String>,
}
#[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<Option<Py<GrepCursor>>> {
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)
}
}
+1
View File
@@ -0,0 +1 @@
3.10
+75
View File
@@ -0,0 +1,75 @@
# fff-search
Python bindings for [FFF (Fast File Finder)](https://github.com/dmtrKovalenko/fff.nvim), built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/). Install with `pip install fff-search`; import as `fff`.
## Requirements
- Python >= 3.10
- Rust toolchain (to build the native extension)
- [uv](https://docs.astral.sh/uv/) (recommended)
## Development setup
```bash
cd packages/fff-python
uv sync --all-extras
uv run maturin develop --release
```
## Running tests
```bash
cd packages/fff-python
uv run pytest -v
```
## Standalone example
```bash
cd packages/fff-python
uv run python examples/basic.py .
```
## Basic usage
```python
from fff import FileFinder
with FileFinder("/path/to/project", watch=False) as finder:
finder.wait_for_scan_blocking(timeout_ms=5000)
print(f"Indexed under {finder.base_path}")
result = finder.search("main")
if result:
print(f"Showing {len(result)} of {result.total_matched} matches")
for item, score in zip(result.items, result.scores):
print(f"{item.relative_path}: {score.total}")
```
### Async usage
`wait_for_scan` is a coroutine that polls the scan status and yields to the
event loop, so it never blocks other tasks. Use `wait_for_scan_blocking` from
synchronous code.
```python
import asyncio
from fff import FileFinder
async def main():
with FileFinder("/path/to/project", watch=False) as finder:
await finder.wait_for_scan(timeout_ms=5000)
result = finder.search("main")
print(result)
asyncio.run(main())
```
## Building wheels
```bash
cd packages/fff-python
uv run maturin build --release
```
The produced wheel is `abi3` compatible with Python 3.10+.
+43
View File
@@ -0,0 +1,43 @@
"""Standalone example of using fff Python bindings."""
from __future__ import annotations
import sys
import time
from fff import FileFinder
def main() -> int:
base_path = sys.argv[1] if len(sys.argv) > 1 else "."
print(f"Indexing {base_path}...")
start = time.time()
with FileFinder(base_path, watch=False) as finder:
print(f"Created in {time.time() - start:.2f}s")
print("Waiting for scan...")
finder.wait_for_scan_blocking(timeout_ms=30000)
progress = finder.scan_progress
print(f"Indexed {progress.scanned_files_count} files")
print("\nFuzzy file search for 'main':")
result = finder.search("main", page_size=5)
for item, score in zip(result.items, result.scores):
print(f" {item.relative_path:<50} score={score.total}")
print("\nGlob search '*.py':")
result = finder.glob("*.py", page_size=5)
for item in result.items:
print(f" {item.relative_path}")
print("\nGrep for 'def ':")
result = finder.grep("def ", page_limit=5)
for match in result.items:
print(f" {match.relative_path}:{match.line_number}: {match.line_content.strip()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+33
View File
@@ -0,0 +1,33 @@
[project]
name = "fff-search"
version = "0.9.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"
+70
View File
@@ -0,0 +1,70 @@
"""Python bindings for FFF (Fast File Finder)."""
from __future__ import annotations
import asyncio
from fff._fff_python import (
DirItem,
DirSearchResult,
FFFException,
FileItem,
GrepMatch,
GrepResult,
GrepCursor,
MatchRange,
MixedDirItem,
MixedFileItem,
MixedSearchResult,
ScanProgress,
Score,
SearchResult,
)
from fff._fff_python import FileFinder as _FileFinder
_SCAN_POLL_INTERVAL = 0.05
class FileFinder(_FileFinder):
"""File finder with an async, event-loop-friendly scan wait.
Inherits every method from the native finder; only adds the async
``wait_for_scan`` on top. Use ``wait_for_scan_blocking`` when a
synchronous wait is acceptable.
"""
async def wait_for_scan(self, timeout_ms: int = 5000) -> bool:
"""Wait for the initial scan without blocking the event loop.
Polls ``is_scanning`` and yields to the loop between checks.
Returns ``True`` if the scan completed, ``False`` on timeout.
"""
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout_ms / 1000
while self.is_scanning():
if loop.time() >= deadline:
return False
await asyncio.sleep(_SCAN_POLL_INTERVAL)
return True
__version__ = "0.9.4"
__all__ = [
"FFFException",
"FileFinder",
"FileItem",
"DirItem",
"Score",
"SearchResult",
"DirSearchResult",
"MixedFileItem",
"MixedDirItem",
"MixedSearchResult",
"MatchRange",
"GrepMatch",
"GrepResult",
"GrepCursor",
"ScanProgress",
"__version__",
]
+256
View File
@@ -0,0 +1,256 @@
"""Type stubs for fff Python bindings."""
from __future__ import annotations
from collections.abc import Sequence
from os import PathLike
from typing import Any, Literal, TypeAlias
_PathInput: TypeAlias = str | PathLike[str]
_GrepMode: TypeAlias = Literal["plain", "regex", "fuzzy"]
__version__: str
class FFFException(Exception):
"""Base exception for fff errors."""
class Score:
total: int
base_score: int
filename_bonus: int
special_filename_bonus: int
frecency_boost: int
distance_penalty: int
current_file_penalty: int
combo_match_boost: int
path_alignment_bonus: int
exact_match: bool
match_type: str
def __repr__(self) -> str: ...
class FileItem:
relative_path: str
file_name: str
git_status: str
size: int
modified: int
access_frecency_score: int
modification_frecency_score: int
total_frecency_score: int
is_binary: bool
def __repr__(self) -> str: ...
class DirItem:
relative_path: str
dir_name: str
max_access_frecency: int
def __repr__(self) -> str: ...
class MixedFileItem:
relative_path: str
file_name: str
git_status: str
size: int
modified: int
access_frecency_score: int
modification_frecency_score: int
total_frecency_score: int
is_binary: bool
def __repr__(self) -> str: ...
class MixedDirItem:
relative_path: str
dir_name: str
max_access_frecency: int
def __repr__(self) -> str: ...
class MatchRange:
start: int
end: int
def __repr__(self) -> str: ...
class GrepMatch:
relative_path: str
file_name: str
git_status: str
line_content: str
match_ranges: list[MatchRange]
context_before: list[str]
context_after: list[str]
size: int
modified: int
total_frecency_score: int
access_frecency_score: int
modification_frecency_score: int
line_number: int
byte_offset: int
col: int
fuzzy_score: int | None
is_definition: bool
is_binary: bool
def __repr__(self) -> str: ...
class SearchResult:
items: list[FileItem]
scores: list[Score]
total_matched: int
total_files: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class DirSearchResult:
items: list[DirItem]
scores: list[Score]
total_matched: int
total_dirs: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class MixedSearchResult:
items: list[MixedFileItem | MixedDirItem]
scores: list[Score]
total_matched: int
total_files: int
total_dirs: int
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
def __repr__(self) -> str: ...
class GrepResult:
items: list[GrepMatch]
total_matched: int
total_files_searched: int
total_files: int
filtered_file_count: int
next_file_offset: int
regex_fallback_error: str | None
def __len__(self) -> int: ...
def __bool__(self) -> bool: ...
@property
def has_more(self) -> bool: ...
def next_cursor(self) -> GrepCursor | None: ...
def __repr__(self) -> str: ...
class ScanProgress:
scanned_files_count: int
is_scanning: bool
is_watcher_ready: bool
is_warmup_complete: bool
def __repr__(self) -> str: ...
class GrepCursor:
offset: int
def __init__(self, offset: int) -> None: ...
def __repr__(self) -> str: ...
class FileFinder:
def __init__(
self,
base_path: _PathInput,
*,
frecency_db_path: _PathInput | None = None,
history_db_path: _PathInput | None = None,
enable_mmap_cache: bool = True,
enable_content_indexing: bool = True,
watch: bool = True,
ai_mode: bool = False,
log_file_path: _PathInput | None = None,
log_level: str | None = None,
cache_budget_max_files: int = 0,
cache_budget_max_bytes: int = 0,
cache_budget_max_file_size: int = 0,
enable_fs_root_scanning: bool = False,
enable_home_dir_scanning: bool = False,
) -> None: ...
def __enter__(self) -> FileFinder: ...
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ...
def __repr__(self) -> str: ...
@property
def closed(self) -> bool: ...
@property
def base_path(self) -> str | None: ...
@property
def scan_progress(self) -> ScanProgress: ...
def close(self) -> None: ...
def search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
combo_boost_score_multiplier: int = 0,
min_combo_count: int = 0,
) -> SearchResult: ...
def glob(
self,
pattern: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
) -> SearchResult: ...
def directory_search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
) -> DirSearchResult: ...
def mixed_search(
self,
query: str,
*,
current_file: str | None = None,
max_threads: int = 0,
page_index: int = 0,
page_size: int = 0,
combo_boost_score_multiplier: int = 0,
min_combo_count: int = 0,
) -> MixedSearchResult: ...
def grep(
self,
query: str,
*,
mode: _GrepMode = "plain",
max_file_size: int = 0,
max_matches_per_file: int = 0,
smart_case: bool = True,
cursor: GrepCursor | None = None,
page_limit: int = 0,
time_budget_ms: int = 0,
before_context: int = 0,
after_context: int = 0,
classify_definitions: bool = False,
) -> GrepResult: ...
def multi_grep(
self,
patterns: Sequence[str],
*,
constraints: str | None = None,
mode: _GrepMode = "plain",
max_file_size: int = 0,
max_matches_per_file: int = 0,
smart_case: bool = True,
cursor: GrepCursor | None = None,
page_limit: int = 0,
time_budget_ms: int = 0,
before_context: int = 0,
after_context: int = 0,
classify_definitions: bool = False,
) -> GrepResult: ...
def scan_files(self) -> None: ...
def is_scanning(self) -> bool: ...
async def wait_for_scan(self, timeout_ms: int = 5000) -> bool: ...
def wait_for_scan_blocking(self, timeout_ms: int = 5000) -> bool: ...
def reindex(self, new_path: _PathInput) -> None: ...
def refresh_git_status(self) -> int: ...
def track_query(self, query: str, selected_file_path: _PathInput) -> bool: ...
def get_historical_query(self, offset: int) -> str | None: ...
def health_check(self, test_path: _PathInput | None = None) -> dict[str, Any]: ...
+391
View File
@@ -0,0 +1,391 @@
"""Tests for fff Python bindings."""
from __future__ import annotations
import importlib.metadata as metadata
import tempfile
from pathlib import Path
import pytest
import fff
from fff import FFFException, FileFinder, GrepCursor, MixedDirItem, MixedFileItem
def rel(path: str) -> str:
return path.replace("\\", "/")
@pytest.fixture
def sample_dir() -> str:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "src").mkdir()
(root / "docs").mkdir()
(root / "src" / "main.py").write_text(
"from utils import helper\n\n"
"def main():\n"
" value = helper()\n"
" return value\n"
)
(root / "src" / "utils.py").write_text(
"def helper():\n"
" return 'alpha'\n"
)
(root / "src" / "profile.ts").write_text(
"class Profile {}\n"
"function renderProfile() { return new Profile(); }\n"
)
(root / "docs" / "guide.txt").write_text(
"alpha line before\n"
"needle target\n"
"omega line after\n"
)
(root / "README.md").write_text("# Sample project\n")
yield str(root)
def test_imports_and_package_version() -> None:
assert fff.__version__ == metadata.version("fff-search")
assert GrepCursor(12).offset == 12
assert "GrepCursor" in fff.__all__
def test_pathlib_base_path(sample_dir: str) -> None:
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.closed is False
assert finder.base_path is not None
assert finder.scan_progress.scanned_files_count >= 1
result = finder.search("main")
assert result.total_matched >= 1
async def test_wait_for_scan_async(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert await finder.wait_for_scan(timeout_ms=5000) is True
assert finder.is_scanning() is False
result = finder.search("main")
assert result.total_matched >= 1
async def test_wait_for_scan_async_does_not_block_loop(sample_dir: str) -> None:
import asyncio
ticks = 0
async def ticker() -> None:
nonlocal ticks
while True:
ticks += 1
await asyncio.sleep(0.01)
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
background = asyncio.ensure_future(ticker())
try:
assert await finder.wait_for_scan(timeout_ms=5000) is True
finally:
background.cancel()
# the loop kept running other tasks while we awaited the scan
assert ticks > 0
async def test_wait_for_scan_blocking_and_async_agree(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000) is True
# already finished, so the async wait resolves immediately to True
assert await finder.wait_for_scan(timeout_ms=5000) is True
def test_keyword_only_options_and_cursor_constructor(sample_dir: str) -> None:
with pytest.raises(TypeError):
GrepCursor()
with pytest.raises(TypeError):
FileFinder(Path(sample_dir), None)
with FileFinder(Path(sample_dir), watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
with pytest.raises(TypeError):
finder.search("main", None)
with pytest.raises(TypeError):
finder.glob("*.py", None)
with pytest.raises(TypeError):
finder.directory_search("src", None)
with pytest.raises(TypeError):
finder.mixed_search("src", None)
with pytest.raises(TypeError):
finder.grep("needle", "plain")
with pytest.raises(TypeError):
finder.multi_grep(["needle"], None)
def test_close_and_context_manager(sample_dir: str) -> None:
finder = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.closed is False
assert finder.base_path is not None
finder.close()
assert finder.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
finder.search("main")
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as ctx_finder:
assert ctx_finder.wait_for_scan_blocking(timeout_ms=5000)
assert ctx_finder.closed is False
assert ctx_finder.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
ctx_finder.search("main")
fresh = FileFinder(sample_dir, watch=False, enable_content_indexing=False)
assert fresh.wait_for_scan_blocking(timeout_ms=5000)
fresh.close()
assert fresh.closed is True
with pytest.raises(FFFException, match="File picker not initialized"):
fresh.search("main")
def test_reprs(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert repr(finder).startswith("FileFinder(")
result = finder.search("main")
assert repr(result).startswith("SearchResult(")
assert repr(result.items[0]).startswith("FileItem(")
assert repr(result.scores[0]).startswith("Score(")
grep_result = finder.grep("needle")
assert repr(grep_result).startswith("GrepResult(")
assert repr(grep_result.items[0]).startswith("GrepMatch(")
assert repr(grep_result.items[0].match_ranges[0]).startswith("MatchRange(")
dir_result = finder.directory_search("src")
assert repr(dir_result).startswith("DirSearchResult(")
assert repr(dir_result.items[0]).startswith("DirItem(")
mixed = finder.mixed_search("src", page_size=10)
assert repr(mixed).startswith("MixedSearchResult(")
cursor = GrepCursor(42)
assert repr(cursor) == "GrepCursor(offset=42)"
progress = finder.scan_progress
assert repr(progress).startswith("ScanProgress(")
def test_file_search_scores_and_pagination(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("main", page_size=1)
assert result.total_matched >= 1
assert len(result) == len(result.items) == 1
assert bool(result) is True
assert any("main.py" in rel(item.relative_path) for item in result.items)
score = result.scores[0]
assert isinstance(score.total, int)
assert isinstance(score.exact_match, bool)
assert isinstance(score.match_type, str)
second_page = finder.search("", page_index=1, page_size=1)
assert len(second_page) == len(second_page.items) == 1
empty = finder.search("definitely_no_such_file_xyz")
assert len(empty) == 0
assert bool(empty) is False
def test_glob_variants(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
py_files = finder.glob("*.py")
assert {Path(rel(item.relative_path)).name for item in py_files.items} == {
"main.py",
"utils.py",
}
src_files = finder.glob("src/*.py")
assert src_files.total_matched == 2
md_files = finder.glob("*.md")
assert md_files.total_matched == 1
assert rel(md_files.items[0].relative_path) == "README.md"
def test_directory_and_mixed_search(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=False) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
dirs = finder.directory_search("src")
assert dirs.total_matched >= 1
assert len(dirs) == len(dirs.items)
assert bool(dirs) is True
assert any(rel(item.relative_path).startswith("src") for item in dirs.items)
mixed = finder.mixed_search("src", page_size=10)
assert mixed.total_matched >= 3
assert len(mixed) == len(mixed.items)
assert bool(mixed) is True
assert any(isinstance(item, MixedDirItem) for item in mixed.items)
assert any(isinstance(item, MixedFileItem) for item in mixed.items)
# max_access_frecency is the same field on both dir item types, so the
# values must agree for a shared directory regardless of which search
# produced them.
dir_frecency = {
rel(item.relative_path): item.max_access_frecency for item in dirs.items
}
for item in mixed.items:
if isinstance(item, MixedDirItem):
path = rel(item.relative_path)
if path in dir_frecency:
assert item.max_access_frecency == dir_frecency[path]
def test_grep_plain_regex_fuzzy_and_context(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
plain = finder.grep("needle", before_context=1, after_context=1)
assert plain.total_matched == 1
assert len(plain) == len(plain.items) == 1
assert bool(plain) is True
match = plain.items[0]
assert rel(match.relative_path) == "docs/guide.txt"
assert match.line_content == "needle target"
assert match.context_before == ["alpha line before"]
assert match.context_after == ["omega line after"]
assert [(r.start, r.end) for r in match.match_ranges] == [(0, 6)]
regex = finder.grep(r"def \w+", mode="regex")
assert regex.total_matched == 2
assert {Path(rel(m.relative_path)).name for m in regex.items} == {
"main.py",
"utils.py",
}
fuzzy = finder.grep("df mn", mode="fuzzy")
assert fuzzy.total_matched >= 1
assert any(rel(m.relative_path) == "src/main.py" for m in fuzzy.items)
assert any(m.fuzzy_score is not None for m in fuzzy.items)
invalid = finder.grep("[", mode="regex")
assert invalid.regex_fallback_error is not None
def test_grep_invalid_mode_raises(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
with pytest.raises(FFFException, match="invalid grep mode"):
finder.grep("needle", mode="typo")
with pytest.raises(FFFException, match="invalid grep mode"):
finder.multi_grep(["needle"], mode="typo")
def test_grep_cursor_paginates_by_file(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
first = finder.grep("def", page_limit=1)
assert first.total_matched >= 1
assert first.next_file_offset > 0
assert first.has_more is True
assert first.next_cursor() is not None
assert first.next_cursor().offset == first.next_file_offset
second = finder.grep("def", cursor=GrepCursor(first.next_file_offset), page_limit=1)
assert second.total_matched >= 1
first_paths = {rel(m.relative_path) for m in first.items}
second_paths = {rel(m.relative_path) for m in second.items}
assert first_paths.isdisjoint(second_paths)
exhausted = finder.grep("nonexistent_xyz")
assert exhausted.has_more is False
assert exhausted.next_cursor() is None
assert len(exhausted) == 0
assert bool(exhausted) is False
def test_multi_grep_and_error_handling(sample_dir: str) -> None:
with FileFinder(sample_dir, watch=False, enable_content_indexing=True) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.multi_grep(("def main", "def helper"))
assert result.total_matched == 2
assert {Path(rel(m.relative_path)).name for m in result.items} == {
"main.py",
"utils.py",
}
with pytest.raises(FFFException, match="patterns must not be empty"):
finder.multi_grep([])
def test_query_history_persists(sample_dir: str, tmp_path: Path) -> None:
history_db = tmp_path / "history"
selected_file = Path(sample_dir) / "src" / "main.py"
with FileFinder(
sample_dir,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.track_query("main", selected_file)
assert finder.get_historical_query(0) == "main"
with FileFinder(
sample_dir,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
assert finder.get_historical_query(0) == "main"
def test_reindex_and_health_check(sample_dir: str, tmp_path: Path) -> None:
other = tmp_path / "other-project"
other.mkdir()
(other / "other.py").write_text("def other():\n return 42\n")
frecency_db = tmp_path / "frecency"
history_db = tmp_path / "history"
with FileFinder(
sample_dir,
frecency_db_path=frecency_db,
history_db_path=history_db,
watch=False,
enable_content_indexing=False,
) as finder:
assert finder.wait_for_scan_blocking(timeout_ms=5000)
health = finder.health_check(Path(sample_dir))
assert health["file_picker"]["initialized"] is True
assert health["frecency"]["initialized"] is True
assert health["query_tracker"]["initialized"] is True
# With no explicit path, the check inspects the indexed base path
# rather than the process cwd.
default_health = finder.health_check()
assert default_health["file_picker"]["base_path"] is not None
assert "error" not in default_health.get("git", {}) or default_health["git"][
"error"
] != "could not determine current directory"
finder.reindex(str(other))
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result = finder.search("other")
assert result.total_matched == 1
assert rel(result.items[0].relative_path) == "other.py"
finder.reindex(Path(other))
assert finder.wait_for_scan_blocking(timeout_ms=5000)
result2 = finder.search("other")
assert result2.total_matched == 1
+208
View File
@@ -0,0 +1,208 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fff-search"
version = "0.9.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" },
]
+35
View File
@@ -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"