Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c4f84e198 | |||
| 67f27ace3c | |||
| f2c9559d1b | |||
| 366fa8ff15 | |||
| e538a4a419 | |||
| c1ec7ac6b9 | |||
| 3c75374fe4 | |||
| 8c25486c04 | |||
| 73c6b9e3c7 | |||
| c5f55c22b4 | |||
| 0143702531 | |||
| 15288542fd | |||
| dd35759eb0 | |||
| 7a97802cbe | |||
| 735dc17d5c |
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3.10
|
||||
@@ -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+.
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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]: ...
|
||||
@@ -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
|
||||
Generated
+208
@@ -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" },
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user