rgapi 0.1.8__tar.gz → 0.1.9__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.
@@ -13,13 +13,13 @@ dependencies = [
13
13
 
14
14
  [[package]]
15
15
  name = "bstr"
16
- version = "1.12.1"
16
+ version = "1.12.2"
17
17
  source = "registry+https://github.com/rust-lang/crates.io-index"
18
- checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
18
+ checksum = "a4e308e67087593070530a666f7fe8815cbd2e61d8f5b7313b71b7d721d1dfde"
19
19
  dependencies = [
20
20
  "memchr",
21
21
  "regex-automata",
22
- "serde",
22
+ "serde_core",
23
23
  ]
24
24
 
25
25
  [[package]]
@@ -276,7 +276,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
276
276
 
277
277
  [[package]]
278
278
  name = "rgapi"
279
- version = "0.1.8"
279
+ version = "0.1.9"
280
280
  dependencies = [
281
281
  "globset",
282
282
  "grep-matcher",
@@ -295,15 +295,6 @@ dependencies = [
295
295
  "winapi-util",
296
296
  ]
297
297
 
298
- [[package]]
299
- name = "serde"
300
- version = "1.0.228"
301
- source = "registry+https://github.com/rust-lang/crates.io-index"
302
- checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
303
- dependencies = [
304
- "serde_core",
305
- ]
306
-
307
298
  [[package]]
308
299
  name = "serde_core"
309
300
  version = "1.0.228"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rgapi"
3
- version = "0.1.8"
3
+ version = "0.1.9"
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.8
3
+ Version: 0.1.9
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Requires-Dist: fastship>=0.0.12 ; extra == 'dev'
@@ -35,6 +35,14 @@ for row in rg_iter("TODO", ".", include="*.py", context=2): print(row.asdict())
35
35
  rg("TODO", ".", ext="py", skip_dir=".venv", paths=True)
36
36
  ```
37
37
 
38
+ For cell-aware search of Jupyter notebooks (see [Notebooks](#notebooks)):
39
+
40
+ ```python
41
+ from rgapi import nbrg
42
+
43
+ nbrg("read_csv", ".", cell_context=1)
44
+ ```
45
+
38
46
  For direct access to the regex, search, and walk pieces:
39
47
 
40
48
  ```python
@@ -82,6 +90,35 @@ matches list of (start, end) byte offsets for match rows
82
90
 
83
91
  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.
84
92
 
93
+ ## Notebooks
94
+
95
+ `nbrg` searches Jupyter `.ipynb` files cell-by-cell, so results are *cells* rather than raw JSON lines. Searching a notebook with plain `rg` matches the escaped JSON text (including outputs and metadata) and reports JSON line numbers; `nbrg` instead searches each cell's reconstructed **source** and returns the cells that matched.
96
+
97
+ ```python
98
+ from rgapi import nbrg
99
+
100
+ nbrg("read_csv", ".") # cells whose source matches, across all notebooks under "."
101
+ nbrg("read_csv", ".", cell_context=1) # also include neighbouring cells as context
102
+ ```
103
+
104
+ It finds notebooks with the fast parallel Rust walker (`fd`), then matches each cell's source through the same Rust engine as `rg` (via `search_text`), so regex behaviour and the `case_sensitive`/`smart_case` flags match `rg`. Only cell `source` is searched, not outputs or metadata. `nbrg` accepts the same discovery filters as `fd`/`rg` (`include`, `exclude`, `glob`, `hidden`, `max_depth`, `skip_dir`, …).
105
+
106
+ `nbrg` returns `NbResults`, a list of `NbCell`. Each `NbCell` has:
107
+
108
+ ```text
109
+ path notebook path relative to root
110
+ cell_index 0-based position of the cell in the notebook
111
+ cell_id nbformat cell id (falls back to the cell index for notebooks without ids)
112
+ cell_type 'code', 'markdown', or 'raw'
113
+ kind 'match' or 'context'
114
+ source full cell source
115
+ matches list of SearchLine rows for the matched lines within the cell
116
+ ```
117
+
118
+ `NbCell.asdict()` returns those fields as a plain dict (with `matches` as `SearchLine` dicts). `str()`/pretty display is one truncated, newline-escaped line per cell: `path:cell_id:source` for matches and `path:cell_id-source` for context cells. A cell with several matches appears once, with every hit collected in `matches`.
119
+
120
+ `cell_context=N` includes the `N` cells before and after each matching cell as `kind="context"` rows (deduplicated per notebook). `prefilter=True` first narrows candidate notebooks with `rg` before the per-cell search; it is faster on notebook-heavy trees but can miss matches whose pattern is affected by JSON escaping (quotes, backslashes, regex metacharacters straddling escapes), so it is off by default.
121
+
85
122
  ## Benchmarks
86
123
 
87
124
  `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:
@@ -16,6 +16,14 @@ for row in rg_iter("TODO", ".", include="*.py", context=2): print(row.asdict())
16
16
  rg("TODO", ".", ext="py", skip_dir=".venv", paths=True)
17
17
  ```
18
18
 
19
+ For cell-aware search of Jupyter notebooks (see [Notebooks](#notebooks)):
20
+
21
+ ```python
22
+ from rgapi import nbrg
23
+
24
+ nbrg("read_csv", ".", cell_context=1)
25
+ ```
26
+
19
27
  For direct access to the regex, search, and walk pieces:
20
28
 
21
29
  ```python
@@ -63,6 +71,35 @@ matches list of (start, end) byte offsets for match rows
63
71
 
64
72
  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.
65
73
 
74
+ ## Notebooks
75
+
76
+ `nbrg` searches Jupyter `.ipynb` files cell-by-cell, so results are *cells* rather than raw JSON lines. Searching a notebook with plain `rg` matches the escaped JSON text (including outputs and metadata) and reports JSON line numbers; `nbrg` instead searches each cell's reconstructed **source** and returns the cells that matched.
77
+
78
+ ```python
79
+ from rgapi import nbrg
80
+
81
+ nbrg("read_csv", ".") # cells whose source matches, across all notebooks under "."
82
+ nbrg("read_csv", ".", cell_context=1) # also include neighbouring cells as context
83
+ ```
84
+
85
+ It finds notebooks with the fast parallel Rust walker (`fd`), then matches each cell's source through the same Rust engine as `rg` (via `search_text`), so regex behaviour and the `case_sensitive`/`smart_case` flags match `rg`. Only cell `source` is searched, not outputs or metadata. `nbrg` accepts the same discovery filters as `fd`/`rg` (`include`, `exclude`, `glob`, `hidden`, `max_depth`, `skip_dir`, …).
86
+
87
+ `nbrg` returns `NbResults`, a list of `NbCell`. Each `NbCell` has:
88
+
89
+ ```text
90
+ path notebook path relative to root
91
+ cell_index 0-based position of the cell in the notebook
92
+ cell_id nbformat cell id (falls back to the cell index for notebooks without ids)
93
+ cell_type 'code', 'markdown', or 'raw'
94
+ kind 'match' or 'context'
95
+ source full cell source
96
+ matches list of SearchLine rows for the matched lines within the cell
97
+ ```
98
+
99
+ `NbCell.asdict()` returns those fields as a plain dict (with `matches` as `SearchLine` dicts). `str()`/pretty display is one truncated, newline-escaped line per cell: `path:cell_id:source` for matches and `path:cell_id-source` for context cells. A cell with several matches appears once, with every hit collected in `matches`.
100
+
101
+ `cell_context=N` includes the `N` cells before and after each matching cell as `kind="context"` rows (deduplicated per notebook). `prefilter=True` first narrows candidate notebooks with `rg` before the per-cell search; it is faster on notebook-heavy trees but can miss matches whose pattern is affected by JSON escaping (quotes, backslashes, regex metacharacters straddling escapes), so it is off by default.
102
+
66
103
  ## Benchmarks
67
104
 
68
105
  `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:
@@ -196,4 +196,6 @@ def search_path(
196
196
  return SearchResults(_core.search_path(matcher, _fs_path(path), _display_path(display_path), before_context, after_context))
197
197
 
198
198
 
199
- __all__ = [ "RgIter", "fd", "rg", "rg_iter" ]
199
+ from .nb import NbCell, NbResults, nbrg, search_nb
200
+
201
+ __all__ = [ "RgIter", "fd", "rg", "rg_iter", "nbrg" ]
@@ -0,0 +1,96 @@
1
+ "Notebook-aware search: run `rg`-style search over `.ipynb` files, returning matched cells instead of raw JSON lines."
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from fastcore.meta import delegates
6
+
7
+ from . import compile, fd, rg, search_text
8
+
9
+
10
+ def _cell_source(cell):
11
+ src = cell.get("source", "")
12
+ return "".join(src) if isinstance(src, list) else src
13
+
14
+ def _preview(text, width=120):
15
+ text = text.rstrip("\n").replace("\n", "\\n")
16
+ return text if len(text) <= width else text[:width] + "…"
17
+
18
+
19
+ class NbCell:
20
+ "A notebook cell that matched (or provides context for) a search."
21
+ def __init__(self, path, cell_index, cell_id, cell_type, kind, source, matches):
22
+ self.path,self.cell_index,self.cell_id = path,cell_index,cell_id
23
+ self.cell_type,self.kind,self.source,self.matches = cell_type,kind,source,matches
24
+
25
+ def asdict(self):
26
+ return dict(path=self.path, cell_index=self.cell_index, cell_id=self.cell_id,
27
+ cell_type=self.cell_type, kind=self.kind, source=self.source,
28
+ matches=[m.asdict() for m in self.matches])
29
+
30
+ def __repr__(self):
31
+ return (f"NbCell(path={self.path!r}, cell_id={self.cell_id!r}, cell_type={self.cell_type!r}, "
32
+ f"kind={self.kind!r}, matches={len(self.matches)})")
33
+
34
+ def __str__(self):
35
+ sep = ":" if self.kind == "match" else "-"
36
+ return f"{self.path}:{self.cell_id}{sep}{_preview(self.source)}"
37
+
38
+ def _repr_pretty_(self, p, cycle): p.text("..." if cycle else str(self))
39
+
40
+
41
+ class NbResults(list):
42
+ "List of `NbCell` rows with rg-style text display."
43
+ def __str__(self): return "\n".join(map(str, self))
44
+ def _repr_pretty_(self, p, cycle): p.text("..." if cycle else str(self))
45
+
46
+
47
+ def search_nb(
48
+ matcher, # Compiled `Regex` from `compile()`
49
+ path, # Notebook file to search (expands `~`)
50
+ cell_context:int=0, # Cells of context to include before/after each matching cell
51
+ display_path:str=None # Path stored in results; defaults to `path`
52
+ ) -> NbResults:
53
+ "Search one `.ipynb` file's cell sources with a compiled matcher."
54
+ disp = display_path if display_path is not None else str(path)
55
+ try: nb = json.loads(Path(path).expanduser().read_text())
56
+ except (ValueError, OSError, UnicodeDecodeError): return NbResults()
57
+ cells = nb.get("cells")
58
+ if not isinstance(cells, list): return NbResults()
59
+ info,matched = {},{}
60
+ for i,cell in enumerate(cells):
61
+ if not isinstance(cell, dict): continue
62
+ src = _cell_source(cell)
63
+ info[i] = (str(cell.get("id", i)), cell.get("cell_type", ""), src)
64
+ hits = [m for m in search_text(matcher, src, path=disp) if m.kind == "match"]
65
+ if hits: matched[i] = hits
66
+ if not matched: return NbResults()
67
+ emit = {i:"match" for i in matched}
68
+ if cell_context:
69
+ for i in matched:
70
+ for j in range(max(0, i-cell_context), min(len(cells), i+cell_context+1)): emit.setdefault(j, "context")
71
+ res = NbResults()
72
+ for i in sorted(emit):
73
+ if i not in info: continue
74
+ cid,ctype,src = info[i]
75
+ res.append(NbCell(disp, i, cid, ctype, emit[i], src, matched.get(i, [])))
76
+ return res
77
+
78
+
79
+ @delegates(fd, but=["ext", "files", "dirs"])
80
+ def nbrg(
81
+ pattern:str, # Regex pattern to search for
82
+ root:str=".", # Directory to search (expands `~`)
83
+ prefilter:bool=False, # Pre-narrow notebooks with `rg` before per-cell search (may miss escape-affected patterns)
84
+ cell_context:int=0, # Cells of context to include before/after each matching cell
85
+ case_sensitive:bool|None=None,# True/False forces case; None allows `smart_case`
86
+ smart_case:bool=False, # Match `rg --smart-case` behavior
87
+ **kwargs
88
+ ) -> NbResults:
89
+ "Search `.ipynb` cell sources under `root`, returning matched cells."
90
+ matcher = compile(pattern, case_sensitive=case_sensitive, smart_case=smart_case)
91
+ if prefilter: paths = rg(pattern, root, ext="ipynb", case_sensitive=case_sensitive, smart_case=smart_case, paths=True, **kwargs)
92
+ else: paths = fd(root, ext="ipynb", **kwargs)
93
+ base = Path(root).expanduser()
94
+ res = NbResults()
95
+ for rel in sorted(paths): res += search_nb(matcher, base/rel, cell_context=cell_context, display_path=rel)
96
+ return res
@@ -5,6 +5,7 @@ rgapi wraps the same `ignore`, `grep-regex`, and `grep-searcher` crates ripgrep
5
5
  Core APIs:
6
6
  - `fd(root=".", ...)` finds paths with fd-style filters (`pattern` substring, `include`/`exclude`/`glob`, `ext`); returns slash-separated relative paths.
7
7
  - `rg(pattern, root=".", ...)` searches and returns `SearchResults` (or `paths=True` for unique matched paths, `count=True` for a match-span total). NB: The `SearchResults` repr shows an rg-style multiline string, which is usually the most ergonomic approach.
8
+ - `nbrg(pattern, root=".", cell_context=0, ...)` searches Jupyter `.ipynb` files (cell source only) and returns matched cells as `NbResults`/`NbCell`, which have a nice repr like rg(). Use this for notebooks rather than `rg`, to avoid escaping problems, and to get back message IDs.
8
9
 
9
10
  SearchLine rows:
10
11
  kind 'match', 'before', 'after', or 'context'
@@ -14,11 +15,20 @@ SearchLine rows:
14
15
  matches list of (start, end) byte offsets, for 'match' rows
15
16
  asdict() returns the row fields as a plain dict
16
17
 
18
+ NbCell rows (from `nbrg`):
19
+ path/cell_index/cell_id/cell_type locate the cell ('code'/'markdown'/'raw')
20
+ kind 'match' or 'context'
21
+ source full cell source
22
+ matches list of SearchLine rows for the matched lines within the cell
23
+ asdict() returns the cell fields as a plain dict
24
+ `cell_context=N` adds N neighbour cells as 'context'. `prefilter=True` narrows notebooks with `rg` first (faster, but can miss patterns affected by JSON escaping; off by default).
25
+
17
26
  Important:
18
27
  Traversal is parallel and result order is NOT guaranteed; wrap in `sorted(...)` if you need stable order. `path_re`/`skip_path_re` filter the returned/searched paths but do not prune traversal; use `skip_dir`/`skip_dir_re` to prune whole subtrees for speed. Run `doc(func)` for full parameter docments.
19
28
  """
20
29
 
21
30
  from . import Regex, RgIter, SearchLine, SearchResults, compile, fd, rg, rg_iter, search_path, search_text, walk
31
+ from . import NbCell, NbResults, nbrg, search_nb
22
32
 
23
- __all__ = ["Regex", "RgIter", "SearchLine", "SearchResults", "compile", "fd", "rg", "rg_iter", "search_path", "search_text", "walk"]
33
+ __all__ = [ "RgIter", "fd", "rg", "rg_iter", "nbrg" ]
24
34
 
@@ -1,4 +1,4 @@
1
- import _thread, threading
1
+ import _thread, json, threading
2
2
 
3
3
  import pytest
4
4
 
@@ -188,3 +188,107 @@ def test_direct_regex_and_search_apis(tmp_path):
188
188
  assert [(r.kind, r.path, r.line_number, r.line, r.matches) for r in path_res] == [
189
189
  ("match", "display.py", 2, "TODO here", [(0, 4)])]
190
190
  assert path_res[0].asdict() == dict(kind="match", path="display.py", line_number=2, line="TODO here", matches=[(0, 4)])
191
+
192
+
193
+ def write_nb(path, cells):
194
+ nb = {"cells": cells, "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
195
+ path.write_text(json.dumps(nb))
196
+
197
+ def _cell(cell_type, source, cid=None, outputs=None):
198
+ c = {"cell_type": cell_type, "metadata": {}, "source": source}
199
+ if cell_type == "code":
200
+ c["execution_count"] = None
201
+ c["outputs"] = outputs or []
202
+ if cid is not None: c["id"] = cid
203
+ return c
204
+
205
+
206
+ def test_nbrg_source_only_with_cells(tmp_path):
207
+ from rgapi import nbrg
208
+ write_nb(tmp_path / "nb.ipynb", [
209
+ _cell("code", ["import os\n", "def foo():\n", " return 1\n"], cid="c1",
210
+ outputs=[{"output_type": "stream", "name": "stdout", "text": ["foo ran\n"]}]),
211
+ _cell("code", ["print('hi')\n"], cid="c2",
212
+ outputs=[{"output_type": "stream", "name": "stdout", "text": ["foo in output\n"]}]),
213
+ _cell("markdown", ["# Title\n", "use foo here\n"], cid="m1"),
214
+ ])
215
+ res = nbrg("foo", str(tmp_path))
216
+ # c2 has 'foo' only in its output, so source-only search must skip it
217
+ assert {c.cell_id for c in res} == {"c1", "m1"}
218
+ c1 = next(c for c in res if c.cell_id == "c1")
219
+ assert c1.cell_type == "code" and c1.kind == "match"
220
+ assert [m.line_number for m in c1.matches] == [2]
221
+ assert c1.matches[0].line == "def foo():"
222
+ assert c1.matches[0].matches == [(4, 7)]
223
+ m1 = next(c for c in res if c.cell_id == "m1")
224
+ assert m1.cell_type == "markdown"
225
+ assert [m.line for m in m1.matches] == ["use foo here"]
226
+
227
+
228
+ def test_nbrg_string_source_and_missing_id(tmp_path):
229
+ from rgapi import nbrg
230
+ write_nb(tmp_path / "nb.ipynb", [_cell("code", "x = 1\nfoo = 2\n")])
231
+ res = nbrg("foo", str(tmp_path))
232
+ assert len(res) == 1
233
+ assert res[0].cell_id == "0"
234
+ assert res[0].matches[0].line == "foo = 2"
235
+ assert res[0].matches[0].line_number == 2
236
+
237
+
238
+ def test_nbrg_multiple_matches_single_cell_appears_once(tmp_path):
239
+ from rgapi import nbrg
240
+ write_nb(tmp_path / "nb.ipynb", [
241
+ _cell("code", ["foo = 1\n", "bar = 2\n", "baz = foo + foo\n"], cid="c1"),
242
+ ])
243
+ res = nbrg("foo", str(tmp_path))
244
+ assert len(res) == 1 # cell appears once, not once per match
245
+ assert [c.cell_id for c in res] == ["c1"]
246
+ cell = res[0]
247
+ assert [m.line_number for m in cell.matches] == [1, 3] # both matching lines kept
248
+ assert cell.matches[1].matches == [(6, 9), (12, 15)] # two spans on the same line
249
+
250
+
251
+ def test_nbrg_cell_context(tmp_path):
252
+ from rgapi import nbrg
253
+ write_nb(tmp_path / "nb.ipynb", [
254
+ _cell("code", ["a = 1\n"], cid="c0"),
255
+ _cell("code", ["target = 2\n"], cid="c1"),
256
+ _cell("code", ["b = 3\n"], cid="c2"),
257
+ _cell("code", ["c = 4\n"], cid="c3"),
258
+ ])
259
+ res = nbrg("target", str(tmp_path), cell_context=1)
260
+ kinds = {c.cell_id: c.kind for c in res}
261
+ assert kinds == {"c0": "context", "c1": "match", "c2": "context"}
262
+
263
+
264
+ def test_nbrg_skips_bad_json_and_prefilter_parity(tmp_path):
265
+ from rgapi import nbrg
266
+ write_nb(tmp_path / "good.ipynb", [_cell("code", ["read_csv(x)\n"], cid="g1")])
267
+ (tmp_path / "bad.ipynb").write_text("{not valid json")
268
+ a = nbrg("read_csv", str(tmp_path))
269
+ b = nbrg("read_csv", str(tmp_path), prefilter=True)
270
+ assert {c.cell_id for c in a} == {"g1"}
271
+ assert {c.cell_id for c in b} == {"g1"}
272
+
273
+
274
+ def test_search_nb_single_and_asdict_str(tmp_path):
275
+ from rgapi import compile, search_nb
276
+ p = tmp_path / "nb.ipynb"
277
+ write_nb(p, [_cell("code", ["def foo():\n", " return 1\n"], cid="c1")])
278
+ res = search_nb(compile("foo"), p, display_path="nb.ipynb")
279
+ assert len(res) == 1
280
+ cell = res[0]
281
+ assert str(cell) == "nb.ipynb:c1:def foo():\\n return 1" # cell-oriented: whole cell, newlines escaped
282
+ d = cell.asdict()
283
+ assert d["cell_id"] == "c1" and d["cell_type"] == "code" and d["kind"] == "match"
284
+ assert d["matches"][0]["line"] == "def foo():"
285
+
286
+
287
+ def test_nbcell_str_truncates_and_escapes(tmp_path):
288
+ from rgapi import compile, search_nb
289
+ p = tmp_path / "nb.ipynb"
290
+ write_nb(p, [_cell("code", ["x = '" + "a" * 500 + "' # foo\n"], cid="c1")])
291
+ s = str(search_nb(compile("foo"), p, display_path="nb.ipynb")[0])
292
+ assert "\n" not in s # newlines escaped to a single display line
293
+ assert s.endswith("…") # long cell is truncated
294
+ assert s.startswith("nb.ipynb:c1:")
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