diff --git a/README.md b/README.md index 89a295f..ca43b68 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ In `--stdin` mode, multiline `a/i/c` text blocks are not available. ## Python API ```py -from exhash import exhash, file_exhash, lnhash, lnhashview, lnhashview_file, line_hash +from exhash import exhash, file_exhash, lnhash, lnhashview, lnhashview_files, line_hash ``` ### Viewing @@ -111,10 +111,10 @@ from exhash import exhash, file_exhash, lnhash, lnhashview, lnhashview_file, lin ```py text = "foo\nbar\n" view = lnhashview(text) # ["1|a1b2|foo", "2|c3d4|bar"] -view = lnhashview_file("f.py", start=1, end=260) # end past EOF is clamped +view = lnhashview_files("f.py", start=1, end=260) # end past EOF is clamped ``` -`lnhashview`/`lnhashview_file` return a `list` subclass whose repr shows the rows verbatim, one per line, so a bare call in IPython displays a ready-to-copy view. +`lnhashview`/`lnhashview_files` return a `list` subclass whose repr shows the rows verbatim, one per line, so a bare call in IPython displays a ready-to-copy view. ### Editing @@ -172,10 +172,10 @@ res = exhash("abc\n", [(lnhash(1, "abc"), "y", "abc", "ABC")]) ### File helpers -`lnhashview_file` reads directly from one file path. All file paths, including file-qualified addresses, expand a leading `~` to your home directory. `file_exhash(path, *cmds, sw=4, inplace=True)` uses `path` as the default file context for unqualified addresses. Pass each command as its own tuple argument. Put file-qualified source and `m`/`t` destination addresses in the address/destination tuple fields: +`lnhashview_files` reads directly from file paths. All file paths, including file-qualified addresses, expand a leading `~` to your home directory. `file_exhash(path, *cmds, sw=4, inplace=True)` uses `path` as the default file context for unqualified addresses. Pass each command as its own tuple argument. Put file-qualified source and `m`/`t` destination addresses in the address/destination tuple fields: ```py -view = lnhashview_file("file.py") +view = lnhashview_files("file.py") # By default, writes changed files after every command succeeds # and returns the combined diff string. @@ -209,7 +209,7 @@ A file prefix is separated from the address with `:`. Escape literal colons in f ### Notebook cells -`lnhashview_cell(path, cell_id, ...)` returns a normal lnhash view for one cell. `lnhashview_cells(path, *cell_ids, ...)` returns the requested cells in order, using `# cell ` headers before each cell's normal `lineno|hash|content` lines. `cell_exhash(path, cell_id, *cmds, sw=4, inplace=True)` edits one cell; pass each command as its own tuple argument. Like `file_exhash` it writes and returns a diff by default, and `inplace=False` previews the `EditResult` without touching the file. +`lnhashview_cells(path, *cell_ids, ...)` returns a normal lnhash view for one cell, or several cells in order, using `# cell ` headers before each cell's normal `lineno|hash|content` lines. `cell_exhash(path, cell_id, *cmds, sw=4, inplace=True)` edits one cell; pass each command as its own tuple argument. Like `file_exhash` it writes and returns a diff by default, and `inplace=False` previews the `EditResult` without touching the file. ### The `%%exhash` cell magic @@ -223,7 +223,7 @@ new line 2 - `%%exhash new.py 0|0000| a` creates a missing file. - `%%exhash f.py % c` replaces the whole file (`%` needs no hashes). With a cell id, `%%exhash nb.ipynb ab12 % c` replaces that notebook cell's source. -- `%%exhash f.py 12|a3f2|,15|b1c3| c` replaces just that range, both addresses from one `lnhashview_file` view. +- `%%exhash f.py 12|a3f2|,15|b1c3| c` replaces just that range, both addresses from one `lnhashview_files` view. - One trailing newline (the cell terminator) is stripped; to end the payload with a blank line, leave one extra blank line at the bottom. - Each magic cell applies one command and returns the diff. diff --git a/python/exhash/__init__.py b/python/exhash/__init__.py index 3eca320..c318176 100644 --- a/python/exhash/__init__.py +++ b/python/exhash/__init__.py @@ -29,10 +29,12 @@ def lnhashview(text:str, start:int=None, end:int=None) -> "LnhashView": @fail_clean(*stdexcs) -def lnhashview_file(path:str, start:int=None, end:int=None) -> "LnhashView": - 'Return lines formatted as space-padded ``lineno|hash|content`` for file at ``path``. Optional 1-based ``start``/``end`` filter the range; ``end`` past EOF is clamped.' - return LnhashView(_lnhashview(Path(path).expanduser().read_text(), start, end)) - +def lnhashview_files(*paths:str, start:int=None, end:int=None) -> "LnhashView": + 'Return lines formatted as space-padded ``lineno|hash|content`` for one or more files, each after a ``# file `` header when several. Optional 1-based ``start``/``end`` filter the range per file; ``end`` past EOF is clamped.' + if not paths: raise ValueError("lnhashview_files() requires at least one path") + views = [_lnhashview(Path(p).expanduser().read_text(), start, end) for p in paths] + if len(views)==1: return LnhashView(views[0]) + return LnhashView(x for p,v in zip(paths,views) for x in (f"# file {p}", *v)) _NOFIELD = {'d', 'p', 'j', 'sort'} @@ -419,21 +421,14 @@ def _cell_text(cell): return src if isinstance(src, str) else ''.join(src) -@fail_clean(*stdexcs) -def lnhashview_cell(path:str, cell_id:str, start:int=None, end:int=None) -> "LnhashView": - 'Return lines formatted as ``lineno|hash|content`` for the source of notebook cell ``cell_id`` in ipynb file at ``path``. ``cell_id`` may be an exact id or unique prefix; optional 1-based ``start``/``end`` filter the range.' - return LnhashView(_lnhashview(_cell_text(_load_cell(path, cell_id)[1]), start, end)) - - @fail_clean(*stdexcs) def lnhashview_cells(path:str, *cell_ids:str, start:int=None, end:int=None) -> "LnhashView": - 'Return grouped lnhash views for explicit notebook cell ids. Each group starts with ``# cell ``; following lines keep normal ``lineno|hash|content`` format.' - out = [] - for cell_id in cell_ids: - _, cell = _load_cell(path, cell_id) - out.append(f"# cell {cell.get('id', cell_id)}") - out += _lnhashview(_cell_text(cell), start, end) - return LnhashView(out) + 'Return lines formatted as ``lineno|hash|content`` for the source of one or more notebook cells in ipynb file at ``path``, each after a ``# cell `` header when several. Each cell id may be exact or a unique prefix; optional 1-based ``start``/``end`` filter the range per cell.' + if not cell_ids: raise ValueError("lnhashview_cells() requires at least one cell id") + cells = [_load_cell(path, c)[1] for c in cell_ids] + views = [_lnhashview(_cell_text(c), start, end) for c in cells] + if len(views)==1: return LnhashView(views[0]) + return LnhashView(x for i,c,v in zip(cell_ids,cells,views) for x in (f"# cell {c.get('id', i)}", *v)) @fail_clean(*stdexcs) @@ -441,7 +436,7 @@ def cell_exhash(path:str, cell_id:str, *cmds:tuple, sw:int=4, inplace:bool=True) """Apply exhash commands to the source of notebook cell ``cell_id`` in ipynb file at ``path``. Command tuples are the ``exhash.skill`` module docstring's; use - ``lnhashview_cell(path, cell_id)`` for addresses. + ``lnhashview_cells(path, cell_id)`` for addresses. ``cell_id`` may be an exact id or unique prefix. By default (``inplace=True``) write the edited source back when the source actually diff --git a/python/exhash/skill.py b/python/exhash/skill.py index d201586..238ef8f 100644 --- a/python/exhash/skill.py +++ b/python/exhash/skill.py @@ -2,16 +2,16 @@ Exhash's purpose is to make edits precise and auditable. First view a file as `lineno|hash|text` (line numbers may be space-padded for alignment); then issue ex-style commands against those exact addresses. Every addressed line's hash is checked immediately before the command runs, so stale context or wrong targets fail instead of editing nearby text. Within one call, a single-line address may match the line's current content or its content at call start, so commands can stack on one line; across calls, re-view. Structural edits still shift lines as they apply, so work *backwards* (bottom-to-top). -Prefer exhash over ad hoc patching for text file modifications, and prefer reading with `lnhashview_file` over plain file reads whenever an edit may follow: the view doubles as the address book, so the edit needs no second read. +Prefer exhash over ad hoc patching for text file modifications, and prefer reading with `lnhashview_files` over plain file reads whenever an edit may follow: the view doubles as the address book, so the edit needs no second read. Core APIs: -- `lnhashview_file` lists hashed lines. +- `lnhashview_files` lists hashed lines; several paths in one call get `# file ` headers. - `exhash` is the in-memory command engine; this docstring is the complete command reference, and `doc(exhash)` adds engine details (strict `s` matching, EditResult fields). - `file_exhash` is the file-aware engine; unqualified addresses use `path` and file-qualified addresses can edit or transfer across files. -- `lnhashview_cell` views one notebook cell's source in an `.ipynb` file; `lnhashview_cells` views several explicit cells with `# cell ` headers. `cell_exhash` edits one cell. +- `lnhashview_cells` views one or more notebook cells' sources in an `.ipynb` file, with `# cell ` headers when several. `cell_exhash` edits one cell. Workflow: -1. `lnhashview_file(...)`, ending the cell with the bare call: the result displays verbatim, one `lineno|hash|content` line each, so never join, print, or reformat it. +1. `lnhashview_files(...)`, ending the cell with the bare call: the result displays verbatim, one `lineno|hash|content` line each, so never join, print, or reformat it. 2. Copy exact displayed `lineno|hash|` addresses. 3. Use tuple command specs; pass each command as its own positional argument, e.g. `file_exhash(path, (addr1, "d"), (addr2, "s", pat, repl))`. Use raw triple-quoted Python strings for address, pattern, replacement, and payload text when composing commands. 4. Use `file_exhash(path, *cmds)` (or `cell_exhash(path, cell_id, *cmds)` for one notebook cell) to apply the edit: both write to disk and return a diff by default. Pass `inplace=False` to preview the result object without touching the file. @@ -49,7 +49,7 @@ Cut/copy/paste between files and notebook cells: -Any `m` (cut+paste) or `t` (copy+paste) address can carry a target prefix: `path:` for another file, or `path.ipynb:cellid:` for one cell's source (`cellid` exact or unique prefix). This is THE way to transfer existing lines between locations: the lines never pass through your output, so opaque content (base64 blobs, hashes, long literals) cannot be mistyped. Take source addresses from `lnhashview_file`/`lnhashview_cell` of each target as usual: +Any `m` (cut+paste) or `t` (copy+paste) address can carry a target prefix: `path:` for another file, or `path.ipynb:cellid:` for one cell's source (`cellid` exact or unique prefix). This is THE way to transfer existing lines between locations: the lines never pass through your output, so opaque content (base64 blobs, hashes, long literals) cannot be mistyped. Take source addresses from `lnhashview_files`/`lnhashview_cells` of each target as usual: file_exhash(path, ("src/a.py:10|aaaa|,20|bbbb|", "m", "src/b.py:$")) # cut a.py lines 10-20, paste at end of b.py file_exhash(path, ("nb.ipynb:ab12cd34:6|830e|", "t", "other.ipynb:9f8e:$")) # copy one cell line into another notebook's cell @@ -59,16 +59,16 @@ Reformatting a section like `gq`, plus optional indents: `j` the range onto one line, re-view, then split with ONE g-flagged `s` alternating the tokens (picked by eye) that should start each new line, captured and restored with the break and indent in the replacement: `(addr, "s", r", ('foo'|'bar'|'baz')", ",\n $1", "g")` Important: -Do not pass raw commands to Python APIs. Do not create addresses by text search or remembered line numbers, and never construct them by computing hashes (e.g. via `line_hash`): addresses come only from a fresh view immediately before the edit. On stale hash, re-view and rebuild. Where rgapi is installed, hits from `rg(pattern, lnhashs=True)` count as fresh views too: their addresses drop straight into commands (and `nbrg` finds the cell ids that `lnhashview_cell` takes). If reaching an address seems to need arithmetic, scraping, or a guessed hash, a step on that route was skipped. Tuple text fields can contain newlines wherever the command accepts text. For example, `(addr, "s", "foo", "bar\nbaz")` replaces one line with two. Text fields are taken verbatim: a two-character `\n` sequence stays literal; use an actual newline when you want a line break. For `a`/`i`/`c`, put all text in one tuple payload: `"first\nsecond"` starts with `first`, while `"\nfirst"` inserts a leading blank line before `first`. For moving/copying between files or cells, use the qualified `m`/`t` addresses shown above. Missing files can only be created through `(r"0|0000|", "a", text)` or `(r"0|0000|", "i", text)` creation semantics. +Do not pass raw commands to Python APIs. Do not create addresses by text search or remembered line numbers, and never construct them by computing hashes (e.g. via `line_hash`): addresses come only from a fresh view immediately before the edit. On stale hash, re-view and rebuild. Where rgapi is installed, hits from `rg(pattern, lnhashs=True)` count as fresh views too: their addresses drop straight into commands (and `nbrg` finds the cell ids that `lnhashview_cells` takes). If reaching an address seems to need arithmetic, scraping, or a guessed hash, a step on that route was skipped. Tuple text fields can contain newlines wherever the command accepts text. For example, `(addr, "s", "foo", "bar\nbaz")` replaces one line with two. Text fields are taken verbatim: a two-character `\n` sequence stays literal; use an actual newline when you want a line break. For `a`/`i`/`c`, put all text in one tuple payload: `"first\nsecond"` starts with `first`, while `"\nfirst"` inserts a leading blank line before `first`. For moving/copying between files or cells, use the qualified `m`/`t` addresses shown above. Missing files can only be created through `(r"0|0000|", "a", text)` or `(r"0|0000|", "i", text)` creation semantics. The `%%exhash` cell magic: In IPython sessions, importing this module registers the `%%exhash` cell magic: `%%exhash []
` applies one command whose payload is everything below the magic line, taken verbatim (one trailing newline stripped). Passing `` targets that cell in an .ipynb file instead of a plain file (`cell_exhash`); the magic dispatches on token count, so no separate cell magic exists. Because the payload is never parsed as Python, no quoting or escaping applies. Use it for EVERY `a`/`i`/`c` command, however innocent the payload looks: create a file with `%%exhash path 0|0000| a`; replace a whole cell or file with `%%exhash [] % c` (`%` needs no hashes: a full replace has no neighboring lines to mis-hit); replace a region within one with a range address and `c` (`%%exhash 12|a3f2|,15|b1c3| c`), both addresses straight from the one pre-edit view. Tuple `a`/`i`/`c` payloads are only for contexts without magics (scripts, tests): interactively they add a Python quoting layer whose failure modes are not reliably foreseeable, so do not use them. IPython expands `{expr}` and `$var` in the magic line from the user namespace (its standard `var_expand` for all magics), so a path or cell id held in a variable needs no retyping: `%%exhash {path} {cid} % c`. Only the line expands; the payload stays verbatim. """ -from . import exhash, cell_exhash, file_exhash, line_hash, lnhash, lnhashview, lnhashview_cell, lnhashview_cells, lnhashview_file, magic +from . import exhash, cell_exhash, file_exhash, line_hash, lnhash, lnhashview, lnhashview_cells, lnhashview_files, magic -__all__ = ["line_hash", "lnhash", "lnhashview", "lnhashview_file", "lnhashview_cell", "lnhashview_cells", "exhash", "file_exhash", "cell_exhash"] +__all__ = ["line_hash", "lnhash", "lnhashview", "lnhashview_files", "lnhashview_cells", "exhash", "file_exhash", "cell_exhash"] import sys if 'IPython' in sys.modules: diff --git a/tests/test_cells.py b/tests/test_cells.py index 24febff..53cd915 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -1,5 +1,5 @@ import json, pytest -from exhash import lnhash, lnhashview_cell, lnhashview_cells, cell_exhash, file_exhash +from exhash import lnhash, lnhashview_cells, cell_exhash, file_exhash def mk_nb(path, cells): "Write a minimal notebook; `cells` is a list of (id, source) with source str or list" @@ -8,17 +8,17 @@ def mk_nb(path, cells): path.write_text(json.dumps(nb)) return nb -def test_lnhashview_cell(tmp_path): +def test_lnhashview_cells(tmp_path): p = tmp_path/'t.ipynb' mk_nb(p, [('aaaa1111', ['def f():\n', ' return 1']), ('bbbb2222', 'x=1')]) - lines = lnhashview_cell(p, 'aaaa1111') + lines = lnhashview_cells(p, 'aaaa1111') assert len(lines) == 2 assert lines[0].startswith(lnhash(1, 'def f():')) assert lines[1].endswith('| return 1') assert str(lines) == chr(10).join(lines) -def test_lnhashview_cells(tmp_path): +def test_lnhashview_cells_many(tmp_path): p = tmp_path/'t.ipynb' mk_nb(p, [('aaaa1111', ['def f():\n', ' return 1']), ('bbbb2222', 'x=1')]) lines = lnhashview_cells(p, 'aaaa', 'bbbb') @@ -28,12 +28,12 @@ def test_lnhashview_cells(tmp_path): assert lines[4].startswith(lnhash(1, 'x=1')) assert str(lines) == chr(10).join(lines) -def test_lnhashview_cell_prefix_and_errors(tmp_path): +def test_lnhashview_cells_prefix_and_errors(tmp_path): p = tmp_path/'t.ipynb' mk_nb(p, [('aaaa1111', 'x=1'), ('aabb2222', 'y=2')]) - assert lnhashview_cell(p, 'aabb')[0].endswith('|y=2') - with pytest.raises(KeyError): lnhashview_cell(p, 'aa') # ambiguous prefix - with pytest.raises(KeyError): lnhashview_cell(p, 'zzzz') # no such cell + assert lnhashview_cells(p, 'aabb')[0].endswith('|y=2') + with pytest.raises(KeyError): lnhashview_cells(p, 'aa') # ambiguous prefix + with pytest.raises(KeyError): lnhashview_cells(p, 'zzzz') # no such cell def test_cell_exhash_inplace_list_source(tmp_path): p = tmp_path/'t.ipynb' diff --git a/tests/test_exhash.py b/tests/test_exhash.py index 1c08775..09abf2d 100644 --- a/tests/test_exhash.py +++ b/tests/test_exhash.py @@ -1,5 +1,5 @@ import warnings, pytest -from exhash import line_hash, lnhash, lnhashview, lnhashview_file, exhash, file_exhash, lnhashview_cell, cell_exhash, truncate_diff +from exhash import line_hash, lnhash, lnhashview, lnhashview_files, exhash, file_exhash, lnhashview_cells, cell_exhash, truncate_diff def test_lnhashview_display(tmp_path): q = chr(39) txt = f'x = "a" + {q}b{q}' + chr(10) + 'y = 1' + chr(10) @@ -8,7 +8,7 @@ def test_lnhashview_display(tmp_path): assert f'"a" + {q}b{q}' in str(v) f = tmp_path/'t.py' f.write_text(txt) - assert str(lnhashview_file(str(f))) == str(v) + assert str(lnhashview_files(str(f))) == str(v) @@ -316,7 +316,7 @@ def test_exhash_literal_newline_in_replacement(): def test_file_exhash_read(tmp_path): f = tmp_path / "test.txt" f.write_text("hello\nworld\n") - lines = lnhashview_file(str(f)) + lines = lnhashview_files(str(f)) assert len(lines) == 2 assert "hello" in lines[0] @@ -486,23 +486,35 @@ def test_lnhashview_end_only(): assert lines[0].startswith("1|") assert lines[1].startswith("2|") -def test_lnhashview_file_start_end(tmp_path): +def test_lnhashview_files_start_end(tmp_path): f = tmp_path / "test.txt" f.write_text("a\nb\nc\nd\n") - lines = lnhashview_file(str(f), start=2, end=3) + lines = lnhashview_files(str(f), start=2, end=3) assert len(lines) == 2 assert lines[0].startswith("2|") assert lines[1].startswith("3|") -def test_lnhashview_file_clamps_end_past_eof(tmp_path): +def test_lnhashview_files_clamps_end_past_eof(tmp_path): f = tmp_path / "test.txt" f.write_text("a\nb\nc\n") - lines = lnhashview_file(str(f), start=1, end=260) + lines = lnhashview_files(str(f), start=1, end=260) assert len(lines) == 3 assert lines[0].startswith("1|") assert lines[-1].startswith("3|") +def test_lnhashview_files_many(tmp_path): + f, g = tmp_path / "a.txt", tmp_path / "b.txt" + f.write_text("a\nb\nc\n") + g.write_text("x\ny\n") + lines = lnhashview_files(str(f), str(g), start=2, end=2) + assert lines[0] == f"# file {f}" + assert lines[1].startswith(lnhash(2, "b")) + assert lines[2] == f"# file {g}" + assert lines[3].startswith(lnhash(2, "y")) + assert str(lines) == "\n".join(lines) + + def test_file_exhash_accepts_padded_range_addresses(tmp_path): f = tmp_path / "test.txt" f.write_text("a\nb\nc\n") @@ -514,14 +526,14 @@ def test_tilde_expansion(tmp_path, monkeypatch): import json monkeypatch.setenv("HOME", str(tmp_path)) (tmp_path / "f.txt").write_text("foo\nbar\n") - assert "foo" in lnhashview_file("~/f.txt")[0] + assert "foo" in lnhashview_files("~/f.txt")[0] file_exhash("~/f.txt", (lnhash(1, "foo"), "s", "foo", "baz")) assert (tmp_path / "f.txt").read_text() == "baz\nbar\n" file_exhash("~/f.txt", (r"~/g.txt:0|0000|", "a", "hi")) assert (tmp_path / "g.txt").read_text() == "hi\n" nb = dict(cells=[dict(id="abc", cell_type="code", source="x=1\n", metadata={})], metadata={}, nbformat=4, nbformat_minor=5) (tmp_path / "nb.ipynb").write_text(json.dumps(nb)) - assert "x=1" in lnhashview_cell("~/nb.ipynb", "abc")[0] + assert "x=1" in lnhashview_cells("~/nb.ipynb", "abc")[0] cell_exhash("~/nb.ipynb", "abc", (lnhash(1, "x=1"), "s", "x=1", "x=2")) assert json.loads((tmp_path / "nb.ipynb").read_text())["cells"][0]["source"] == "x=2\n" @@ -590,7 +602,7 @@ def test_file_exhash_print_only_returns_bare_view_and_writes_nothing(tmp_path): assert str(out) == " 2|8767|line 2\n11|2808|line 11\n" # padded like lnhashview, no tag, no headers assert f.read_text() == "".join(f"line {i}\n" for i in range(1, 13)) whole = file_exhash(str(f), ("%", "p")) - assert str(whole) == "\n".join(lnhashview_file(str(f))) + "\n" + assert str(whole) == "\n".join(lnhashview_files(str(f))) + "\n" def test_file_exhash_print_bypasses_truncation(tmp_path): @@ -636,7 +648,7 @@ def test_cell_exhash_print_only_returns_bare_view_and_writes_nothing(tmp_path): p.write_text(json.dumps(nb)) before = p.read_text() out = cell_exhash(str(p), "abc123", (lnhash(2, "y = 2"), "p")) - assert str(out) == "\n".join(lnhashview_cell(str(p), "abc123", 2, 2)) + "\n" + assert str(out) == "\n".join(lnhashview_cells(str(p), "abc123", start=2, end=2)) + "\n" assert p.read_text() == before grouped = str(file_exhash(str(p), (f"{p}:abc123:{lnhash(3, 'z = 3')}", "p"), (f"{p}:def456:{lnhash(1, 'a = 10')}", "p"))) assert grouped == f"# cell abc123\n{lnhash(3, 'z = 3')}z = 3\n# cell def456\n{lnhash(1, 'a = 10')}a = 10\n"