rgapi 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ jobs:
10
10
  test:
11
11
  runs-on: ubuntu-latest
12
12
  steps:
13
- - uses: actions/checkout@v4
13
+ - uses: actions/checkout@v6
14
14
  - run: cargo test
15
15
 
16
16
  build:
@@ -21,7 +21,7 @@ jobs:
21
21
  python: ['3.10', '3.11', '3.12', '3.13']
22
22
  runs-on: ${{ matrix.os }}
23
23
  steps:
24
- - uses: actions/checkout@v4
24
+ - uses: actions/checkout@v6
25
25
  - uses: dtolnay/rust-toolchain@stable
26
26
  - uses: actions/setup-python@v5
27
27
  with:
@@ -44,7 +44,7 @@ jobs:
44
44
  id-token: write
45
45
  contents: write
46
46
  steps:
47
- - uses: actions/checkout@v4
47
+ - uses: actions/checkout@v6
48
48
  - uses: PyO3/maturin-action@v1
49
49
  with:
50
50
  command: sdist
@@ -306,7 +306,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
306
306
 
307
307
  [[package]]
308
308
  name = "rgapi"
309
- version = "0.1.0"
309
+ version = "0.1.1"
310
310
  dependencies = [
311
311
  "globset",
312
312
  "grep-matcher",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rgapi"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  edition = "2021"
5
5
  license = "MIT OR Apache-2.0"
6
6
  description = "Python API for ripgrep-style file walking and searching"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rgapi
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Summary: Python API for ripgrep-style file walking and searching
@@ -77,6 +77,8 @@ matches list of (start, end) byte offsets for match rows
77
77
 
78
78
  Search is case-sensitive by default, matching `rg`. Use `smart_case=True` for `rg --smart-case` behavior, or `case_sensitive=False` to force case-insensitive matching.
79
79
 
80
+ `rg` and `rg_iter` check Python signals while waiting for search results, so notebook interrupts and `KeyboardInterrupt` cancel traversal cooperatively. Cancellation is prompt between files and while results are streaming; a single huge file that produces no callbacks may continue until that file scan returns.
81
+
80
82
  ## Benchmarks
81
83
 
82
84
  `tools/bench.py` compares the `rg` CLI with in-process `rgapi`. Run it against a release build. One run on this machine, using best time from seven repeats:
@@ -62,6 +62,8 @@ matches list of (start, end) byte offsets for match rows
62
62
 
63
63
  Search is case-sensitive by default, matching `rg`. Use `smart_case=True` for `rg --smart-case` behavior, or `case_sensitive=False` to force case-insensitive matching.
64
64
 
65
+ `rg` and `rg_iter` check Python signals while waiting for search results, so notebook interrupts and `KeyboardInterrupt` cancel traversal cooperatively. Cancellation is prompt between files and while results are streaming; a single huge file that produces no callbacks may continue until that file scan returns.
66
+
65
67
  ## Benchmarks
66
68
 
67
69
  `tools/bench.py` compares the `rg` CLI with in-process `rgapi`. Run it against a release build. One run on this machine, using best time from seven repeats:
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "rgapi"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Python API for ripgrep-style file walking and searching"
9
9
  license = {text = "MIT OR Apache-2.0"}
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,208 @@
1
+ from . import _core
2
+
3
+ Regex = _core.Regex
4
+ SearchLine = _core.SearchLine
5
+ RgIter = _core.RgIter
6
+ def compile(
7
+ pattern, # Regex pattern to compile
8
+ case_sensitive=None, # True/False forces case; None allows `smart_case`
9
+ smart_case=False # Match `rg --smart-case` behavior
10
+ ):
11
+ "Compile a regex matcher for `search_text`, `search_path`, and direct matching."
12
+ return _core.compile(pattern, case_sensitive=case_sensitive, smart_case=smart_case)
13
+
14
+ class SearchResults(list):
15
+ "List of `SearchLine` rows with rg-style text display."
16
+ def __str__(self): return "\n".join(map(str, self))
17
+ def _repr_pretty_(self, p, cycle): p.text("..." if cycle else str(self))
18
+
19
+
20
+ def _listify(value):
21
+ if value is None: return []
22
+ if isinstance(value, str): return [value]
23
+ return list(value)
24
+
25
+
26
+ def _filters(glob=None, include=None, exclude=None, ext=None):
27
+ includes = _listify(include) + _listify(glob)
28
+ for suffix in _listify(ext):
29
+ suffix = str(suffix)
30
+ if suffix.startswith("."): suffix = suffix[1:]
31
+ includes.append(f"*.{suffix}")
32
+ return includes, _listify(exclude)
33
+
34
+
35
+ def _context(context, before_context, after_context):
36
+ if context: return context, context
37
+ return before_context, after_context
38
+
39
+
40
+ def walk(
41
+ root=".", # Directory to walk
42
+ hidden=False, # Include hidden files and directories
43
+ ignore=True, # Respect `.gitignore` and other ignore files
44
+ max_depth=None, # Maximum directory depth to descend
45
+ min_depth=None, # Minimum depth for returned paths
46
+ max_filesize=None, # Skip files larger than this many bytes
47
+ follow_links=False, # Follow symbolic links while walking
48
+ same_file_system=False, # Do not cross filesystem boundaries
49
+ path_re=None, # Regex that returned relative paths must match
50
+ skip_path_re=None, # Regex for relative paths to skip
51
+ skip_dir=None, # Directory glob or globs to prune
52
+ skip_dir_re=None, # Directory regex used to prune traversal
53
+ files=True, # Include files in results
54
+ dirs=False # Include directories in results
55
+ ):
56
+ "Walk a directory and return relative file and/or directory paths."
57
+ return _core.walk(root, hidden, ignore, max_depth, min_depth, max_filesize, follow_links,
58
+ same_file_system, path_re, skip_path_re, _listify(skip_dir), skip_dir_re, files, dirs)
59
+
60
+
61
+ def fd(
62
+ root=".", # Directory to walk
63
+ pattern=None, # Substring that relative paths must contain
64
+ glob=None, # Include glob or globs; alias for `include`
65
+ include=None, # Include glob or globs, e.g. `*.py`
66
+ exclude=None, # Exclude glob or globs, e.g. `test_*.py`
67
+ ext=None, # Extension or extensions to include, without needing `*.`
68
+ hidden=False, # Include hidden files and directories
69
+ ignore=True, # Respect `.gitignore` and other ignore files
70
+ max_depth=None, # Maximum directory depth to descend
71
+ min_depth=None, # Minimum depth for returned paths
72
+ max_filesize=None, # Skip files larger than this many bytes
73
+ follow_links=False, # Follow symbolic links while walking
74
+ same_file_system=False, # Do not cross filesystem boundaries
75
+ path_re=None, # Regex that returned relative paths must match
76
+ skip_path_re=None, # Regex for relative paths to skip
77
+ skip_dir=None, # Directory glob or globs to prune
78
+ skip_dir_re=None, # Directory regex used to prune traversal
79
+ files=True, # Include files in results
80
+ dirs=False # Include directories in results
81
+ ):
82
+ "Find paths with fd-style filters and gitignore support."
83
+ include, exclude = _filters(glob, include, exclude, ext)
84
+ return _core.find(root, pattern, include, exclude, hidden, ignore, max_depth, min_depth, max_filesize,
85
+ follow_links, same_file_system, path_re, skip_path_re, _listify(skip_dir), skip_dir_re, files, dirs)
86
+
87
+
88
+ def _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
89
+ follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
90
+ before_context, after_context, context):
91
+ include, exclude = _filters(glob, include, exclude, ext)
92
+ before_context, after_context = _context(context, before_context, after_context)
93
+ return (pattern, root, include, exclude, hidden, ignore, max_depth, min_depth, max_filesize, follow_links, same_file_system,
94
+ path_re, skip_path_re, _listify(skip_dir), skip_dir_re, case_sensitive, smart_case, before_context, after_context)
95
+
96
+
97
+ def rg(
98
+ pattern, # Regex pattern to search for
99
+ root=".", # Directory to search
100
+ glob=None, # Include glob or globs; alias for `include`
101
+ include=None, # Include glob or globs, e.g. `*.py`
102
+ exclude=None, # Exclude glob or globs, e.g. `test_*.py`
103
+ ext=None, # Extension or extensions to include, without needing `*.`
104
+ hidden=False, # Include hidden files and directories
105
+ ignore=True, # Respect `.gitignore` and other ignore files
106
+ max_depth=None, # Maximum directory depth to descend
107
+ min_depth=None, # Minimum depth for returned/searched files
108
+ max_filesize=None, # Skip files larger than this many bytes
109
+ follow_links=False, # Follow symbolic links while walking
110
+ same_file_system=False, # Do not cross filesystem boundaries
111
+ path_re=None, # Regex that searched relative paths must match
112
+ skip_path_re=None, # Regex for relative paths to skip
113
+ skip_dir=None, # Directory glob or globs to prune
114
+ skip_dir_re=None, # Directory regex used to prune traversal
115
+ case_sensitive=None, # True/False forces case; None allows `smart_case`
116
+ smart_case=False, # Match `rg --smart-case` behavior
117
+ before_context=0, # Lines of context before each match, like `rg -B`
118
+ after_context=0, # Lines of context after each match, like `rg -A`
119
+ context=0, # Sets both before and after context, like `rg -C`
120
+ paths=False, # Return unique matched paths instead of rows
121
+ count=False # Return total match span count instead of rows
122
+ ):
123
+ "Search files and return `SearchResults`, matched paths, or a count."
124
+ assert not (paths and count), "paths and count are mutually exclusive"
125
+ args = _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
126
+ follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
127
+ before_context, after_context, context)
128
+ if paths:
129
+ seen, res = set(), []
130
+ for row in _core.rg_iter(*args):
131
+ if row.kind != "match" or row.path in seen: continue
132
+ seen.add(row.path)
133
+ res.append(row.path)
134
+ return res
135
+ if count: return sum(len(row.matches) for row in _core.rg_iter(*args) if row.kind == "match")
136
+ return SearchResults(_core.rg(*args))
137
+
138
+
139
+ def rg_iter(
140
+ pattern, # Regex pattern to search for
141
+ root=".", # Directory to search
142
+ glob=None, # Include glob or globs; alias for `include`
143
+ include=None, # Include glob or globs, e.g. `*.py`
144
+ exclude=None, # Exclude glob or globs, e.g. `test_*.py`
145
+ ext=None, # Extension or extensions to include, without needing `*.`
146
+ hidden=False, # Include hidden files and directories
147
+ ignore=True, # Respect `.gitignore` and other ignore files
148
+ max_depth=None, # Maximum directory depth to descend
149
+ min_depth=None, # Minimum depth for returned/searched files
150
+ max_filesize=None, # Skip files larger than this many bytes
151
+ follow_links=False, # Follow symbolic links while walking
152
+ same_file_system=False, # Do not cross filesystem boundaries
153
+ path_re=None, # Regex that searched relative paths must match
154
+ skip_path_re=None, # Regex for relative paths to skip
155
+ skip_dir=None, # Directory glob or globs to prune
156
+ skip_dir_re=None, # Directory regex used to prune traversal
157
+ case_sensitive=None, # True/False forces case; None allows `smart_case`
158
+ smart_case=False, # Match `rg --smart-case` behavior
159
+ before_context=0, # Lines of context before each match, like `rg -B`
160
+ after_context=0, # Lines of context after each match, like `rg -A`
161
+ context=0 # Sets both before and after context, like `rg -C`
162
+ ):
163
+ "Search files lazily, yielding `SearchLine` rows."
164
+ args = _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
165
+ follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
166
+ before_context, after_context, context)
167
+ return _core.rg_iter(*args)
168
+
169
+
170
+ def search_text(
171
+ matcher, # Compiled `Regex` from `compile()`
172
+ text, # Text to search
173
+ path="<text>", # Path label stored in results
174
+ before_context=0, # Lines of context before each match
175
+ after_context=0, # Lines of context after each match
176
+ context=0 # Sets both before and after context, like `rg -C`
177
+ ):
178
+ "Search an in-memory string with a compiled matcher."
179
+ before_context, after_context = _context(context, before_context, after_context)
180
+ return SearchResults(_core.search_text(matcher, text, path, before_context, after_context))
181
+
182
+
183
+ def search_path(
184
+ matcher, # Compiled `Regex` from `compile()`
185
+ path, # File path to search
186
+ display_path=None, # Path stored in results; defaults to `path`
187
+ before_context=0, # Lines of context before each match
188
+ after_context=0, # Lines of context after each match
189
+ context=0 # Sets both before and after context, like `rg -C`
190
+ ):
191
+ "Search one file with a compiled matcher."
192
+ before_context, after_context = _context(context, before_context, after_context)
193
+ return SearchResults(_core.search_path(matcher, path, display_path, before_context, after_context))
194
+
195
+
196
+ __all__ = [
197
+ "Regex",
198
+ "RgIter",
199
+ "SearchLine",
200
+ "SearchResults",
201
+ "compile",
202
+ "fd",
203
+ "rg",
204
+ "rg_iter",
205
+ "search_path",
206
+ "search_text",
207
+ "walk",
208
+ ]
@@ -1,5 +1,7 @@
1
1
  use grep_matcher::Matcher;
2
2
  use std::path::PathBuf;
3
+ use std::sync::mpsc::RecvTimeoutError;
4
+ use std::time::Duration;
3
5
 
4
6
  use pyo3::exceptions::PyValueError;
5
7
  use pyo3::prelude::*;
@@ -7,7 +9,7 @@ use pyo3::types::{PyAny, PyDict};
7
9
 
8
10
  use crate::search::spans_for;
9
11
  use crate::{
10
- compile_regex, find, rg, rg_iter as rg_iter_core, search_path as search_path_core,
12
+ compile_regex, find, rg_iter as rg_iter_core, search_path as search_path_core,
11
13
  search_text as search_text_core, FindOptions, RgIter, RgOptions, SearchLine,
12
14
  };
13
15
 
@@ -70,12 +72,11 @@ impl RgIterPy {
70
72
  fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
71
73
  slf
72
74
  }
73
- fn __next__(mut slf: PyRefMut<'_, Self>) -> PyResult<Option<SearchLinePy>> {
74
- match slf.inner.next() {
75
- Some(Ok(line)) => Ok(Some(SearchLinePy::from(line))),
76
- Some(Err(err)) => Err(PyValueError::new_err(err.to_string())),
77
- None => Ok(None),
78
- }
75
+ fn __next__(mut slf: PyRefMut<'_, Self>, py: Python<'_>) -> PyResult<Option<SearchLinePy>> {
76
+ next_rg_line_py(py, &mut slf.inner)
77
+ }
78
+ fn cancel(&self) {
79
+ self.inner.cancel();
79
80
  }
80
81
  fn __repr__(&self) -> String {
81
82
  "RgIter(SearchLine stream)".to_string()
@@ -93,6 +94,35 @@ impl RgIterPy {
93
94
  Ok(())
94
95
  }
95
96
  }
97
+ fn check_signals_or_cancel(py: Python<'_>, iter: &RgIter) -> PyResult<()> {
98
+ if let Err(err) = py.check_signals() {
99
+ iter.cancel();
100
+ return Err(err);
101
+ }
102
+ Ok(())
103
+ }
104
+
105
+ fn next_rg_line_py(py: Python<'_>, iter: &mut RgIter) -> PyResult<Option<SearchLinePy>> {
106
+ loop {
107
+ check_signals_or_cancel(py, iter)?;
108
+ let res = py.allow_threads(|| iter.next_timeout(Duration::from_millis(50)));
109
+ check_signals_or_cancel(py, iter)?;
110
+ match res {
111
+ Ok(Ok(line)) => return Ok(Some(SearchLinePy::from(line))),
112
+ Ok(Err(err)) => return Err(PyValueError::new_err(err.to_string())),
113
+ Err(RecvTimeoutError::Disconnected) => return Ok(None),
114
+ Err(RecvTimeoutError::Timeout) => continue,
115
+ }
116
+ }
117
+ }
118
+
119
+ fn collect_rg_py(py: Python<'_>, mut iter: RgIter) -> PyResult<Vec<SearchLinePy>> {
120
+ let mut res = Vec::new();
121
+ while let Some(line) = next_rg_line_py(py, &mut iter)? {
122
+ res.push(line);
123
+ }
124
+ Ok(res)
125
+ }
96
126
  #[pyclass(name = "Regex")]
97
127
  #[derive(Clone)]
98
128
  struct RegexPy {
@@ -294,6 +324,7 @@ fn search_path_py(
294
324
  #[pyfunction(name = "rg")]
295
325
  #[pyo3(signature = (pattern, root=".", include=None, exclude=None, hidden=false, ignore=true, max_depth=None, min_depth=None, max_filesize=None, follow_links=false, same_file_system=false, path_re=None, skip_path_re=None, skip_dir=None, skip_dir_re=None, case_sensitive=None, smart_case=false, before_context=0, after_context=0))]
296
326
  fn rg_py(
327
+ py: Python<'_>,
297
328
  pattern: String,
298
329
  root: &str,
299
330
  include: Option<Vec<String>>,
@@ -335,9 +366,8 @@ fn rg_py(
335
366
  before_context,
336
367
  after_context,
337
368
  };
338
- rg(&opts)
339
- .map(|lines| lines.into_iter().map(SearchLinePy::from).collect())
340
- .map_err(|e| PyValueError::new_err(e.to_string()))
369
+ let iter = rg_iter_core(&opts).map_err(|e| PyValueError::new_err(e.to_string()))?;
370
+ collect_rg_py(py, iter)
341
371
  }
342
372
  #[pyfunction(name = "rg_iter")]
343
373
  #[pyo3(signature = (pattern, root=".", include=None, exclude=None, hidden=false, ignore=true, max_depth=None, min_depth=None, max_filesize=None, follow_links=false, same_file_system=false, path_re=None, skip_path_re=None, skip_dir=None, skip_dir_re=None, case_sensitive=None, smart_case=false, before_context=0, after_context=0))]
@@ -1,6 +1,10 @@
1
1
  use std::path::{Path, PathBuf};
2
- use std::sync::mpsc::{self, Receiver, Sender};
3
- use std::sync::Arc;
2
+ use std::sync::{
3
+ atomic::{AtomicBool, Ordering},
4
+ mpsc::{self, Receiver, RecvTimeoutError, Sender},
5
+ Arc,
6
+ };
7
+ use std::time::Duration;
4
8
 
5
9
  use grep_matcher::Matcher;
6
10
  use grep_regex::{RegexMatcher, RegexMatcherBuilder};
@@ -109,25 +113,49 @@ pub fn rg_iter(opts: &RgOptions) -> Result<RgIter, RgApiError> {
109
113
  )?);
110
114
  let matcher = compile_regex(&opts.pattern, opts.case_sensitive, opts.smart_case)?;
111
115
  let opts = opts.clone();
116
+ let cancel = Arc::new(AtomicBool::new(false));
117
+ let worker_cancel = cancel.clone();
112
118
  let (tx, rx) = mpsc::channel();
113
- let worker = std::thread::spawn(move || run_parallel_search(root, opts, filters, matcher, tx));
119
+ let worker = std::thread::spawn(move || {
120
+ run_parallel_search(root, opts, filters, matcher, tx, worker_cancel)
121
+ });
114
122
  Ok(RgIter {
115
123
  rx,
124
+ cancel,
116
125
  _worker: worker,
117
126
  })
118
127
  }
119
128
 
120
129
  pub struct RgIter {
121
130
  rx: Receiver<Result<SearchLine, RgApiError>>,
131
+ cancel: Arc<AtomicBool>,
122
132
  _worker: std::thread::JoinHandle<()>,
123
133
  }
124
134
 
135
+ impl RgIter {
136
+ pub fn cancel(&self) {
137
+ self.cancel.store(true, Ordering::Relaxed);
138
+ }
139
+
140
+ pub fn next_timeout(
141
+ &mut self,
142
+ timeout: Duration,
143
+ ) -> Result<Result<SearchLine, RgApiError>, RecvTimeoutError> {
144
+ self.rx.recv_timeout(timeout)
145
+ }
146
+ }
147
+
125
148
  impl Iterator for RgIter {
126
149
  type Item = Result<SearchLine, RgApiError>;
127
150
  fn next(&mut self) -> Option<Self::Item> {
128
151
  self.rx.recv().ok()
129
152
  }
130
153
  }
154
+ impl Drop for RgIter {
155
+ fn drop(&mut self) {
156
+ self.cancel();
157
+ }
158
+ }
131
159
 
132
160
  fn run_parallel_search(
133
161
  root: PathBuf,
@@ -135,6 +163,7 @@ fn run_parallel_search(
135
163
  filters: Arc<PathFilters>,
136
164
  matcher: RegexMatcher,
137
165
  tx: Sender<Result<SearchLine, RgApiError>>,
166
+ cancel: Arc<AtomicBool>,
138
167
  ) {
139
168
  let mut walker = WalkBuilder::new(&root);
140
169
  configure_walker(
@@ -157,6 +186,7 @@ fn run_parallel_search(
157
186
  let matcher = matcher.clone();
158
187
  let before_context = before_context;
159
188
  let after_context = after_context;
189
+ let cancel = cancel.clone();
160
190
  Box::new(move |entry| {
161
191
  search_entry(
162
192
  entry,
@@ -166,6 +196,7 @@ fn run_parallel_search(
166
196
  before_context,
167
197
  after_context,
168
198
  &tx,
199
+ &cancel,
169
200
  )
170
201
  })
171
202
  });
@@ -179,7 +210,11 @@ fn search_entry(
179
210
  before_context: usize,
180
211
  after_context: usize,
181
212
  tx: &Sender<Result<SearchLine, RgApiError>>,
213
+ cancel: &Arc<AtomicBool>,
182
214
  ) -> WalkState {
215
+ if is_cancelled(cancel) {
216
+ return WalkState::Quit;
217
+ }
183
218
  let dent = match entry {
184
219
  Ok(dent) => dent,
185
220
  Err(err) => return send_search_error(tx, RgApiError::new(err.to_string())),
@@ -198,10 +233,20 @@ fn search_entry(
198
233
  if !filters.path_allowed(&rel) {
199
234
  return WalkState::Continue;
200
235
  }
201
- match search_path(path, rel, matcher.clone(), before_context, after_context) {
236
+ match search_path_cancelable(
237
+ path,
238
+ rel,
239
+ matcher.clone(),
240
+ before_context,
241
+ after_context,
242
+ Some(cancel.clone()),
243
+ ) {
202
244
  Ok(lines) => {
245
+ if is_cancelled(cancel) {
246
+ return WalkState::Quit;
247
+ }
203
248
  for line in lines {
204
- if tx.send(Ok(line)).is_err() {
249
+ if is_cancelled(cancel) || tx.send(Ok(line)).is_err() {
205
250
  return WalkState::Quit;
206
251
  }
207
252
  }
@@ -211,6 +256,10 @@ fn search_entry(
211
256
  }
212
257
  }
213
258
 
259
+ fn is_cancelled(cancel: &Arc<AtomicBool>) -> bool {
260
+ cancel.load(Ordering::Relaxed)
261
+ }
262
+
214
263
  fn send_search_error(tx: &Sender<Result<SearchLine, RgApiError>>, err: RgApiError) -> WalkState {
215
264
  let _ = tx.send(Err(err));
216
265
  WalkState::Quit
@@ -250,6 +299,24 @@ pub fn search_path(
250
299
  matcher: RegexMatcher,
251
300
  before_context: usize,
252
301
  after_context: usize,
302
+ ) -> Result<Vec<SearchLine>, RgApiError> {
303
+ search_path_cancelable(
304
+ path,
305
+ display_path,
306
+ matcher,
307
+ before_context,
308
+ after_context,
309
+ None,
310
+ )
311
+ }
312
+
313
+ fn search_path_cancelable(
314
+ path: &Path,
315
+ display_path: String,
316
+ matcher: RegexMatcher,
317
+ before_context: usize,
318
+ after_context: usize,
319
+ cancel: Option<Arc<AtomicBool>>,
253
320
  ) -> Result<Vec<SearchLine>, RgApiError> {
254
321
  let mut builder = SearcherBuilder::new();
255
322
  builder
@@ -264,6 +331,7 @@ pub fn search_path(
264
331
  path: display_path,
265
332
  matcher,
266
333
  lines: &mut out,
334
+ cancel,
267
335
  };
268
336
  match searcher.search_path(search_matcher, path, sink) {
269
337
  Ok(()) => Ok(out),
@@ -305,6 +373,7 @@ fn search_bytes(
305
373
  path: display_path,
306
374
  matcher,
307
375
  lines: &mut out,
376
+ cancel: None,
308
377
  };
309
378
  searcher
310
379
  .search_slice(search_matcher, bytes, sink)
@@ -316,6 +385,15 @@ struct CollectSink<'a> {
316
385
  path: String,
317
386
  matcher: RegexMatcher,
318
387
  lines: &'a mut Vec<SearchLine>,
388
+ cancel: Option<Arc<AtomicBool>>,
389
+ }
390
+ impl CollectSink<'_> {
391
+ fn cancelled(&self) -> bool {
392
+ match &self.cancel {
393
+ Some(cancel) => is_cancelled(cancel),
394
+ None => false,
395
+ }
396
+ }
319
397
  }
320
398
 
321
399
  impl Sink for CollectSink<'_> {
@@ -326,6 +404,9 @@ impl Sink for CollectSink<'_> {
326
404
  _searcher: &grep_searcher::Searcher,
327
405
  mat: &SinkMatch<'_>,
328
406
  ) -> Result<bool, Self::Error> {
407
+ if self.cancelled() {
408
+ return Ok(false);
409
+ }
329
410
  let line = bytes_to_line(mat.bytes())?;
330
411
  let spans = spans_for(&self.matcher, mat.bytes())?;
331
412
  self.lines.push(SearchLine {
@@ -335,7 +416,7 @@ impl Sink for CollectSink<'_> {
335
416
  line,
336
417
  matches: spans,
337
418
  });
338
- Ok(true)
419
+ Ok(!self.cancelled())
339
420
  }
340
421
 
341
422
  fn context(
@@ -343,6 +424,9 @@ impl Sink for CollectSink<'_> {
343
424
  _searcher: &grep_searcher::Searcher,
344
425
  ctx: &SinkContext<'_>,
345
426
  ) -> Result<bool, Self::Error> {
427
+ if self.cancelled() {
428
+ return Ok(false);
429
+ }
346
430
  let kind = match ctx.kind() {
347
431
  SinkContextKind::Before => SearchKind::Before,
348
432
  SinkContextKind::After => SearchKind::After,
@@ -355,7 +439,7 @@ impl Sink for CollectSink<'_> {
355
439
  line: bytes_to_line(ctx.bytes())?,
356
440
  matches: Vec::new(),
357
441
  });
358
- Ok(true)
442
+ Ok(!self.cancelled())
359
443
  }
360
444
  fn binary_data(
361
445
  &mut self,
@@ -1,3 +1,7 @@
1
+ import _thread, threading
2
+
3
+ import pytest
4
+
1
5
  from rgapi import Regex, SearchResults, compile, fd, rg, rg_iter, search_path, search_text, walk
2
6
 
3
7
 
@@ -102,6 +106,15 @@ def test_rg_returns_structured_matches_context_and_relative_paths(tmp_path):
102
106
  assert str(stream) == repr(stream)
103
107
 
104
108
 
109
+ def test_rg_keyboard_interrupt_cancels(tmp_path):
110
+ (tmp_path / "big.txt").write_text("alpha beta gamma\n" * 1_000_000)
111
+ timer = threading.Timer(0.001, _thread.interrupt_main)
112
+ try:
113
+ with pytest.raises(KeyboardInterrupt):
114
+ timer.start()
115
+ rg("needle_that_is_not_present", str(tmp_path))
116
+ finally: timer.cancel()
117
+
105
118
  def test_direct_regex_and_search_apis(tmp_path):
106
119
  make_tree(tmp_path)
107
120
  matcher = compile("todo")
@@ -1,106 +0,0 @@
1
- from . import _core
2
-
3
- Regex = _core.Regex
4
- SearchLine = _core.SearchLine
5
- RgIter = _core.RgIter
6
- compile = _core.compile
7
-
8
- class SearchResults(list):
9
- def __str__(self): return "\n".join(map(str, self))
10
- def _repr_pretty_(self, p, cycle): p.text("..." if cycle else str(self))
11
-
12
-
13
- def _listify(value):
14
- if value is None: return []
15
- if isinstance(value, str): return [value]
16
- return list(value)
17
-
18
-
19
- def _filters(glob=None, include=None, exclude=None, ext=None):
20
- includes = _listify(include) + _listify(glob)
21
- for suffix in _listify(ext):
22
- suffix = str(suffix)
23
- if suffix.startswith("."): suffix = suffix[1:]
24
- includes.append(f"*.{suffix}")
25
- return includes, _listify(exclude)
26
-
27
-
28
- def _context(context, before_context, after_context):
29
- if context: return context, context
30
- return before_context, after_context
31
-
32
-
33
- def walk(root=".", hidden=False, ignore=True, max_depth=None, min_depth=None, max_filesize=None, follow_links=False,
34
- same_file_system=False, path_re=None, skip_path_re=None, skip_dir=None, skip_dir_re=None, files=True, dirs=False):
35
- return _core.walk(root, hidden, ignore, max_depth, min_depth, max_filesize, follow_links,
36
- same_file_system, path_re, skip_path_re, _listify(skip_dir), skip_dir_re, files, dirs)
37
-
38
-
39
- def fd(root=".", pattern=None, glob=None, include=None, exclude=None, ext=None, hidden=False, ignore=True, max_depth=None,
40
- min_depth=None, max_filesize=None, follow_links=False, same_file_system=False, path_re=None,
41
- skip_path_re=None, skip_dir=None, skip_dir_re=None, files=True, dirs=False):
42
- include, exclude = _filters(glob, include, exclude, ext)
43
- return _core.find(root, pattern, include, exclude, hidden, ignore, max_depth, min_depth, max_filesize,
44
- follow_links, same_file_system, path_re, skip_path_re, _listify(skip_dir), skip_dir_re, files, dirs)
45
-
46
-
47
- def _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
48
- follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
49
- before_context, after_context, context):
50
- include, exclude = _filters(glob, include, exclude, ext)
51
- before_context, after_context = _context(context, before_context, after_context)
52
- return (pattern, root, include, exclude, hidden, ignore, max_depth, min_depth, max_filesize, follow_links, same_file_system,
53
- path_re, skip_path_re, _listify(skip_dir), skip_dir_re, case_sensitive, smart_case, before_context, after_context)
54
-
55
-
56
- def rg(pattern, root=".", glob=None, include=None, exclude=None, ext=None, hidden=False, ignore=True, max_depth=None,
57
- min_depth=None, max_filesize=None, follow_links=False, same_file_system=False, path_re=None,
58
- skip_path_re=None, skip_dir=None, skip_dir_re=None, case_sensitive=None, smart_case=False, before_context=0, after_context=0,
59
- context=0, paths=False, count=False):
60
- assert not (paths and count), "paths and count are mutually exclusive"
61
- args = _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
62
- follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
63
- before_context, after_context, context)
64
- if paths:
65
- seen, res = set(), []
66
- for row in _core.rg_iter(*args):
67
- if row.kind != "match" or row.path in seen: continue
68
- seen.add(row.path)
69
- res.append(row.path)
70
- return res
71
- if count: return sum(len(row.matches) for row in _core.rg_iter(*args) if row.kind == "match")
72
- return SearchResults(_core.rg(*args))
73
-
74
-
75
- def rg_iter(pattern, root=".", glob=None, include=None, exclude=None, ext=None, hidden=False, ignore=True, max_depth=None,
76
- min_depth=None, max_filesize=None, follow_links=False, same_file_system=False, path_re=None, skip_path_re=None,
77
- skip_dir=None, skip_dir_re=None, case_sensitive=None, smart_case=False, before_context=0, after_context=0, context=0):
78
- args = _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
79
- follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
80
- before_context, after_context, context)
81
- return _core.rg_iter(*args)
82
-
83
-
84
- def search_text(matcher, text, path="<text>", before_context=0, after_context=0, context=0):
85
- before_context, after_context = _context(context, before_context, after_context)
86
- return SearchResults(_core.search_text(matcher, text, path, before_context, after_context))
87
-
88
-
89
- def search_path(matcher, path, display_path=None, before_context=0, after_context=0, context=0):
90
- before_context, after_context = _context(context, before_context, after_context)
91
- return SearchResults(_core.search_path(matcher, path, display_path, before_context, after_context))
92
-
93
-
94
- __all__ = [
95
- "Regex",
96
- "RgIter",
97
- "SearchLine",
98
- "SearchResults",
99
- "compile",
100
- "fd",
101
- "rg",
102
- "rg_iter",
103
- "search_path",
104
- "search_text",
105
- "walk",
106
- ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes