Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,18 @@ 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

```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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <id>` 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 <id>` 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

Expand All @@ -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.

Expand Down
31 changes: 13 additions & 18 deletions python/exhash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`` 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'}

Expand Down Expand Up @@ -419,29 +421,22 @@ 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 <id>``; 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 <id>`` 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)
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
Expand Down
16 changes: 8 additions & 8 deletions python/exhash/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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 <id>` headers. `cell_exhash` edits one cell.
- `lnhashview_cells` views one or more notebook cells' sources in an `.ipynb` file, with `# cell <id>` 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.
Expand Down Expand Up @@ -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
Expand All @@ -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 <path> [<cell_id>] <address> <a|i|c>` applies one command whose payload is everything below the magic line, taken verbatim (one trailing newline stripped). Passing `<cell_id>` 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 <path> [<cell_id>] % 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 <path> 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:
Expand Down
16 changes: 8 additions & 8 deletions tests/test_cells.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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')
Expand All @@ -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'
Expand Down
Loading