rgapi 0.1.0__tar.gz → 0.1.2__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,12 +21,13 @@ 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:
28
28
  python-version: ${{ matrix.python }}
29
- - run: tools/build.sh release
29
+ - run: pip install fastship
30
+ - run: ship-rs-prep --release
30
31
  - uses: PyO3/maturin-action@v1
31
32
  with:
32
33
  command: build
@@ -44,7 +45,7 @@ jobs:
44
45
  id-token: write
45
46
  contents: write
46
47
  steps:
47
- - uses: actions/checkout@v4
48
+ - uses: actions/checkout@v6
48
49
  - uses: PyO3/maturin-action@v1
49
50
  with:
50
51
  command: sdist
@@ -1,3 +1,4 @@
1
+ dist/
1
2
  Cargo.lock
2
3
  tags
3
4
  /target/
@@ -306,7 +306,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
306
306
 
307
307
  [[package]]
308
308
  name = "rgapi"
309
- version = "0.1.0"
309
+ version = "0.1.2"
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.2"
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.2
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
@@ -54,6 +54,7 @@ pip install rgapi
54
54
  ## Semantics
55
55
 
56
56
  `fd` and `walk` return slash-separated paths relative to `root`. They use the `ignore` crate, so `.gitignore` and the usual ripgrep filters apply by default. Hidden files are skipped unless `hidden=True`. Pass `ignore=False` to disable ignore filtering. Symlinks are not followed unless `follow_links=True`; `same_file_system=True` avoids crossing filesystem boundaries. Traversal is parallel, and result order is not guaranteed; use `sorted(...)` if order matters.
57
+ `root` arguments accept `str` or `pathlib.Path` and expand `~`; `search_path` also accepts path-like file paths. Display labels such as `display_path` are stringified without expansion.
57
58
 
58
59
  `fd` adds fd-like filtering on top of `walk`: `pattern` is a substring match on the relative path, and `include`/`exclude` use glob syntax. `glob=` is accepted as an alias for `include=`. A basename glob such as `*.py` also matches recursively, so it finds `src/app.py`. Use `ext="py"` or `ext=["py", "rs"]` for extension filters, `min_depth=`/`max_depth=` to bound recursion, and `max_filesize=` to skip files above a byte limit.
59
60
 
@@ -73,7 +74,7 @@ matches list of (start, end) byte offsets for match rows
73
74
 
74
75
  `SearchLine` has a structured `repr`, an rg-style `str`, and `SearchLine.asdict()` returns row fields as a plain Python dict. `rg(..., paths=True)` returns unique matched paths, and `rg(..., count=True)` returns the total number of match spans. `paths` and `count` cannot both be set.
75
76
 
76
- `before_context`, `after_context`, and `context` match the shape of `rg -B`, `rg -A`, and `rg -C`. Files containing NUL bytes or invalid UTF-8 are skipped.
77
+ `before_context`, `after_context`, and `context` are like `rg -B`, `rg -A`, and `rg -C`. Files containing NUL bytes or invalid UTF-8 are skipped.
77
78
 
78
79
  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
80
 
@@ -39,6 +39,7 @@ pip install rgapi
39
39
  ## Semantics
40
40
 
41
41
  `fd` and `walk` return slash-separated paths relative to `root`. They use the `ignore` crate, so `.gitignore` and the usual ripgrep filters apply by default. Hidden files are skipped unless `hidden=True`. Pass `ignore=False` to disable ignore filtering. Symlinks are not followed unless `follow_links=True`; `same_file_system=True` avoids crossing filesystem boundaries. Traversal is parallel, and result order is not guaranteed; use `sorted(...)` if order matters.
42
+ `root` arguments accept `str` or `pathlib.Path` and expand `~`; `search_path` also accepts path-like file paths. Display labels such as `display_path` are stringified without expansion.
42
43
 
43
44
  `fd` adds fd-like filtering on top of `walk`: `pattern` is a substring match on the relative path, and `include`/`exclude` use glob syntax. `glob=` is accepted as an alias for `include=`. A basename glob such as `*.py` also matches recursively, so it finds `src/app.py`. Use `ext="py"` or `ext=["py", "rs"]` for extension filters, `min_depth=`/`max_depth=` to bound recursion, and `max_filesize=` to skip files above a byte limit.
44
45
 
@@ -58,7 +59,7 @@ matches list of (start, end) byte offsets for match rows
58
59
 
59
60
  `SearchLine` has a structured `repr`, an rg-style `str`, and `SearchLine.asdict()` returns row fields as a plain Python dict. `rg(..., paths=True)` returns unique matched paths, and `rg(..., count=True)` returns the total number of match spans. `paths` and `count` cannot both be set.
60
61
 
61
- `before_context`, `after_context`, and `context` match the shape of `rg -B`, `rg -A`, and `rg -C`. Files containing NUL bytes or invalid UTF-8 are skipped.
62
+ `before_context`, `after_context`, and `context` are like `rg -B`, `rg -A`, and `rg -C`. Files containing NUL bytes or invalid UTF-8 are skipped.
62
63
 
63
64
  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
65
 
@@ -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.2"
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"
@@ -24,3 +24,9 @@ Issues = "https://github.com/AnswerDotAI/rgapi/issues"
24
24
  features = ["extension-module"]
25
25
  python-source = "python"
26
26
  module-name = "rgapi._core"
27
+
28
+ [tool.fastship]
29
+ branch = "main"
30
+
31
+ [tool.fastship.rs]
32
+ bins = []
@@ -0,0 +1,211 @@
1
+ from os import fspath
2
+ from pathlib import Path
3
+
4
+ from . import _core
5
+
6
+ Regex = _core.Regex
7
+ SearchLine = _core.SearchLine
8
+ RgIter = _core.RgIter
9
+ def compile(
10
+ pattern:str, # Regex pattern to compile
11
+ case_sensitive:bool|None=None, # True/False forces case; None allows `smart_case`
12
+ smart_case:bool=False # Match `rg --smart-case` behavior
13
+ ) -> Regex:
14
+ "Compile a regex matcher for `search_text`, `search_path`, and direct matching."
15
+ return _core.compile(pattern, case_sensitive=case_sensitive, smart_case=smart_case)
16
+
17
+ class SearchResults(list):
18
+ "List of `SearchLine` rows with rg-style text display."
19
+ def __str__(self): return "\n".join(map(str, self))
20
+ def _repr_pretty_(self, p, cycle): p.text("..." if cycle else str(self))
21
+
22
+
23
+ def _listify(value):
24
+ if value is None: return []
25
+ if isinstance(value, str): return [value]
26
+ return list(value)
27
+ def _fs_path(path): return str(Path(path).expanduser())
28
+ def _display_path(path): return None if path is None else fspath(path)
29
+ def _filters(glob=None, include=None, exclude=None, ext=None):
30
+ includes = _listify(include) + _listify(glob)
31
+ for suffix in _listify(ext):
32
+ suffix = str(suffix)
33
+ if suffix.startswith("."): suffix = suffix[1:]
34
+ includes.append(f"*.{suffix}")
35
+ return includes, _listify(exclude)
36
+
37
+
38
+ def _context(context, before_context, after_context):
39
+ if context: return context, context
40
+ return before_context, after_context
41
+
42
+
43
+ def walk(
44
+ root:str|Path=".", # Directory to walk (expands `~`)
45
+ hidden:bool=False, # Include hidden files and directories
46
+ ignore:bool=True, # Respect `.gitignore` and other ignore files
47
+ max_depth:int|None=None, # Maximum directory depth to descend
48
+ min_depth:int|None=None, # Minimum depth for returned paths
49
+ max_filesize:int|None=None, # Skip files larger than this many bytes
50
+ follow_links:bool=False, # Follow symbolic links while walking
51
+ same_file_system:bool=False, # Do not cross filesystem boundaries
52
+ path_re:str|None=None, # Regex that returned relative paths must match
53
+ skip_path_re:str|None=None, # Regex for relative paths to skip
54
+ skip_dir=None, # Directory glob or globs to prune
55
+ skip_dir_re:str|None=None, # Directory regex used to prune traversal
56
+ files:bool=True, # Include files in results
57
+ dirs:bool=False # Include directories in results
58
+ ) -> list[str]:
59
+ "Walk a directory and return relative file and/or directory paths."
60
+ return _core.walk(_fs_path(root), hidden, ignore, max_depth, min_depth, max_filesize, follow_links,
61
+ same_file_system, path_re, skip_path_re, _listify(skip_dir), skip_dir_re, files, dirs)
62
+
63
+
64
+ def fd(
65
+ root:str|Path=".", # Directory to walk (expands `~`)
66
+ pattern:str|None=None, # Substring that relative paths must contain
67
+ glob=None, # Include glob or globs; alias for `include`
68
+ include=None, # Include glob or globs, e.g. `*.py`
69
+ exclude=None, # Exclude glob or globs, e.g. `test_*.py`
70
+ ext=None, # Extension or extensions to include, without needing `*.`
71
+ hidden:bool=False, # Include hidden files and directories
72
+ ignore:bool=True, # Respect `.gitignore` and other ignore files
73
+ max_depth:int|None=None, # Maximum directory depth to descend
74
+ min_depth:int|None=None, # Minimum depth for returned paths
75
+ max_filesize:int|None=None, # Skip files larger than this many bytes
76
+ follow_links:bool=False, # Follow symbolic links while walking
77
+ same_file_system:bool=False, # Do not cross filesystem boundaries
78
+ path_re:str|None=None, # Regex that returned relative paths must match
79
+ skip_path_re:str|None=None, # Regex for relative paths to skip
80
+ skip_dir=None, # Directory glob or globs to prune
81
+ skip_dir_re:str|None=None, # Directory regex used to prune traversal
82
+ files:bool=True, # Include files in results
83
+ dirs:bool=False # Include directories in results
84
+ ) -> list[str]:
85
+ "Find paths with fd-style filters and gitignore support."
86
+ include, exclude = _filters(glob, include, exclude, ext)
87
+ return _core.find(_fs_path(root), pattern, include, exclude, hidden, ignore, max_depth, min_depth, max_filesize,
88
+ follow_links, same_file_system, path_re, skip_path_re, _listify(skip_dir), skip_dir_re, files, dirs)
89
+
90
+
91
+ def _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
92
+ follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
93
+ before_context, after_context, context):
94
+ include, exclude = _filters(glob, include, exclude, ext)
95
+ before_context, after_context = _context(context, before_context, after_context)
96
+ return (pattern, _fs_path(root), include, exclude, hidden, ignore, max_depth, min_depth, max_filesize, follow_links, same_file_system,
97
+ path_re, skip_path_re, _listify(skip_dir), skip_dir_re, case_sensitive, smart_case, before_context, after_context)
98
+
99
+
100
+ def rg(
101
+ pattern:str, # Regex pattern to search for
102
+ root:str|Path=".", # Directory to search (expands `~`)
103
+ glob=None, # Include glob or globs; alias for `include`
104
+ include=None, # Include glob or globs, e.g. `*.py`
105
+ exclude=None, # Exclude glob or globs, e.g. `test_*.py`
106
+ ext=None, # Extension or extensions to include, without needing `*.`
107
+ hidden:bool=False, # Include hidden files and directories
108
+ ignore:bool=True, # Respect `.gitignore` and other ignore files
109
+ max_depth:int|None=None, # Maximum directory depth to descend
110
+ min_depth:int|None=None, # Minimum depth for returned/searched files
111
+ max_filesize:int|None=None, # Skip files larger than this many bytes
112
+ follow_links:bool=False, # Follow symbolic links while walking
113
+ same_file_system:bool=False, # Do not cross filesystem boundaries
114
+ path_re:str|None=None, # Regex that searched relative paths must match
115
+ skip_path_re:str|None=None, # Regex for relative paths to skip
116
+ skip_dir=None, # Directory glob or globs to prune
117
+ skip_dir_re:str|None=None, # Directory regex used to prune traversal
118
+ case_sensitive:bool|None=None, # True/False forces case; None allows `smart_case`
119
+ smart_case:bool=False, # Match `rg --smart-case` behavior
120
+ before_context:int=0, # Lines of context before each match, like `rg -B`
121
+ after_context:int=0, # Lines of context after each match, like `rg -A`
122
+ context:int=0, # Sets both before and after context, like `rg -C`
123
+ paths:bool=False, # Return unique matched paths instead of rows
124
+ count:bool=False # Return total match span count instead of rows
125
+ ):
126
+ "Search files and return `SearchResults`, matched paths, or a count."
127
+ assert not (paths and count), "paths and count are mutually exclusive"
128
+ args = _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
129
+ follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
130
+ before_context, after_context, context)
131
+ if paths:
132
+ seen, res = set(), []
133
+ for row in _core.rg_iter(*args):
134
+ if row.kind != "match" or row.path in seen: continue
135
+ seen.add(row.path)
136
+ res.append(row.path)
137
+ return res
138
+ if count: return sum(len(row.matches) for row in _core.rg_iter(*args) if row.kind == "match")
139
+ return SearchResults(_core.rg(*args))
140
+
141
+
142
+ def rg_iter(
143
+ pattern:str, # Regex pattern to search for
144
+ root:str|Path=".", # Directory to search (expands `~`)
145
+ glob=None, # Include glob or globs; alias for `include`
146
+ include=None, # Include glob or globs, e.g. `*.py`
147
+ exclude=None, # Exclude glob or globs, e.g. `test_*.py`
148
+ ext=None, # Extension or extensions to include, without needing `*.`
149
+ hidden:bool=False, # Include hidden files and directories
150
+ ignore:bool=True, # Respect `.gitignore` and other ignore files
151
+ max_depth:int|None=None, # Maximum directory depth to descend
152
+ min_depth:int|None=None, # Minimum depth for returned/searched files
153
+ max_filesize:int|None=None, # Skip files larger than this many bytes
154
+ follow_links:bool=False, # Follow symbolic links while walking
155
+ same_file_system:bool=False, # Do not cross filesystem boundaries
156
+ path_re:str|None=None, # Regex that searched relative paths must match
157
+ skip_path_re:str|None=None, # Regex for relative paths to skip
158
+ skip_dir=None, # Directory glob or globs to prune
159
+ skip_dir_re:str|None=None, # Directory regex used to prune traversal
160
+ case_sensitive:bool|None=None, # True/False forces case; None allows `smart_case`
161
+ smart_case:bool=False, # Match `rg --smart-case` behavior
162
+ before_context:int=0, # Lines of context before each match, like `rg -B`
163
+ after_context:int=0, # Lines of context after each match, like `rg -A`
164
+ context:int=0 # Sets both before and after context, like `rg -C`
165
+ ) -> RgIter:
166
+ "Search files lazily, yielding `SearchLine` rows."
167
+ args = _rg_args(pattern, root, glob, include, exclude, ext, hidden, ignore, max_depth, min_depth, max_filesize,
168
+ follow_links, same_file_system, path_re, skip_path_re, skip_dir, skip_dir_re, case_sensitive, smart_case,
169
+ before_context, after_context, context)
170
+ return _core.rg_iter(*args)
171
+
172
+
173
+ def search_text(
174
+ matcher:Regex, # Compiled `Regex` from `compile()`
175
+ text:str, # Text to search
176
+ path:str|Path="<text>", # Path label stored in results
177
+ before_context:int=0, # Lines of context before each match
178
+ after_context:int=0, # Lines of context after each match
179
+ context:int=0 # Sets both before and after context, like `rg -C`
180
+ ) -> SearchResults:
181
+ "Search an in-memory string with a compiled matcher."
182
+ before_context, after_context = _context(context, before_context, after_context)
183
+ return SearchResults(_core.search_text(matcher, text, _display_path(path), before_context, after_context))
184
+
185
+
186
+ def search_path(
187
+ matcher:Regex, # Compiled `Regex` from `compile()`
188
+ path:str|Path, # File path to search (expands `~`)
189
+ display_path:str|Path|None=None, # Path stored in results; defaults to `path`
190
+ before_context:int=0, # Lines of context before each match
191
+ after_context:int=0, # Lines of context after each match
192
+ context:int=0 # Sets both before and after context, like `rg -C`
193
+ ) -> SearchResults:
194
+ "Search one file with a compiled matcher."
195
+ before_context, after_context = _context(context, before_context, after_context)
196
+ return SearchResults(_core.search_path(matcher, _fs_path(path), _display_path(display_path), before_context, after_context))
197
+
198
+
199
+ __all__ = [
200
+ "Regex",
201
+ "RgIter",
202
+ "SearchLine",
203
+ "SearchResults",
204
+ "compile",
205
+ "fd",
206
+ "rg",
207
+ "rg_iter",
208
+ "search_path",
209
+ "search_text",
210
+ "walk",
211
+ ]
@@ -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
 
@@ -31,6 +35,21 @@ def test_fd_is_relative_and_respects_ignore_hidden_and_globs(tmp_path):
31
35
  assert set(fd(str(tmp_path), exclude="*.py")) == {"bad.txt", "bin.dat"}
32
36
  assert set(walk(str(tmp_path), files=True, dirs=False)) == found
33
37
 
38
+ def test_pathlike_arguments_and_expanduser(tmp_path, monkeypatch):
39
+ make_tree(tmp_path)
40
+ assert "src/app.py" in fd(tmp_path)
41
+ assert walk(tmp_path, path_re=r"\.py$") == ["src/app.py"]
42
+ assert [r.path for r in rg("TODO", tmp_path, include="*.py")] == ["src/app.py"]
43
+ assert list(rg_iter("TODO", tmp_path, include="*.py")) == rg("TODO", tmp_path, include="*.py")
44
+ matcher = compile("TODO")
45
+ text_label = tmp_path / "memory.txt"
46
+ assert search_text(matcher, "TODO\n", path=text_label)[0].path == str(text_label)
47
+ display = tmp_path / "display.py"
48
+ assert search_path(matcher, tmp_path / "src" / "app.py", display_path=display)[0].path == str(display)
49
+
50
+ monkeypatch.setenv("HOME", str(tmp_path))
51
+ assert fd("~", glob="*.py") == ["src/app.py"]
52
+
34
53
 
35
54
  def test_path_filters_prune_dirs_and_follow_links(tmp_path):
36
55
  (tmp_path / "src").mkdir()
@@ -102,6 +121,15 @@ def test_rg_returns_structured_matches_context_and_relative_paths(tmp_path):
102
121
  assert str(stream) == repr(stream)
103
122
 
104
123
 
124
+ def test_rg_keyboard_interrupt_cancels(tmp_path):
125
+ (tmp_path / "big.txt").write_text("alpha beta gamma\n" * 1_000_000)
126
+ timer = threading.Timer(0.001, _thread.interrupt_main)
127
+ try:
128
+ with pytest.raises(KeyboardInterrupt):
129
+ timer.start()
130
+ rg("needle_that_is_not_present", str(tmp_path))
131
+ finally: timer.cancel()
132
+
105
133
  def test_direct_regex_and_search_apis(tmp_path):
106
134
  make_tree(tmp_path)
107
135
  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
- ]
@@ -1,5 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- profile=${1:-debug}
4
- if [ "$profile" = "release" ]; then flags="--release"; else flags=""; fi
5
- cargo build $flags
rgapi-0.1.0/tools/bump.sh DELETED
@@ -1,6 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- cur=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
4
- new=$(echo "$cur" | awk -F. '{print $1"."$2"."$3+1}')
5
- sed -i '' "s/^version = \"$cur\"/version = \"$new\"/" pyproject.toml Cargo.toml
6
- echo "$cur -> $new"
@@ -1,6 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- cur=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
4
- new=$(echo "$cur" | awk -F. '{print $1"."$2+1"."0}')
5
- sed -i '' "s/^version = \"$cur\"/version = \"$new\"/" pyproject.toml Cargo.toml
6
- echo "$cur -> $new"
@@ -1,6 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- v=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
4
- git tag "v$v"
5
- git push origin main --tags
6
- echo "Released v$v"
rgapi-0.1.0/tools/test.sh DELETED
@@ -1,4 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- cargo test
4
- pytest -q
File without changes
File without changes
File without changes
File without changes