Skip to content

Commit 6004710

Browse files
committed
fixes #28
1 parent 54e03a8 commit 6004710

3 files changed

Lines changed: 49 additions & 1 deletion

File tree

python/exhash/magic.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"IPython cell magic carrying a/i/c payload text verbatim, so delimiter-hostile payloads need no Python string quoting."
2+
import shlex
3+
4+
def exhash_magic(line, cell):
5+
"""Apply one exhash a/i/c command with the cell body as its payload.
6+
7+
Usage: %%exhash <path> [<cell_id>] <address> <a|i|c>
8+
The payload is the rest of the cell, taken verbatim except that one trailing
9+
newline is stripped. With <cell_id>, edits that notebook cell via exhash_cell."""
10+
from . import exhash_file, exhash_cell
11+
args = shlex.split(line)
12+
if len(args) not in (3,4): raise ValueError('usage: %%exhash <path> [<cell_id>] <address> <a|i|c>')
13+
*target, addr, cmd = args
14+
if cmd not in ('a','i','c'): raise ValueError(f'command must be a, i, or c; got {cmd!r}')
15+
if cell.endswith('\n'): cell = cell[:-1]
16+
cmds = [(addr, cmd, cell)]
17+
return exhash_cell(*target, cmds) if len(target)==2 else exhash_file(target[0], cmds)
18+
19+
def load_ipython_extension(ipython): ipython.register_magic_function(exhash_magic, 'cell', 'exhash')

python/exhash/skill.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,16 @@
4444
4545
Important:
4646
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. 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 across files, use file-qualified `m`/`t` address or destination strings; cross-file source ranges are invalid. Missing files can only be created through `(r"0|0000|", "a", text)` or `(r"0|0000|", "i", text)` creation semantics.
47+
48+
The `%%exhash` cell magic:
49+
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 (`exhash_cell`); 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, so this is the idiomatic way to create a file (`%%exhash path 0|0000| a`) and to make any large insert. It is also the idiomatic way to replace a large region: first delete the old lines with a tuple `d` command, then `%%exhash` an `a` payload addressed to the line just above the deleted range -- that line keeps its lineno and hash across the delete, so both addresses come straight from the one view taken before editing. Reserve tuple `a`/`i`/`c` payloads for short, quote-free text.
4750
"""
4851

49-
from . import exhash, exhash_cell, exhash_file, line_hash, lnhash, lnhashview, lnhashview_cell, lnhashview_cells, lnhashview_file
52+
from . import exhash, exhash_cell, exhash_file, line_hash, lnhash, lnhashview, lnhashview_cell, lnhashview_cells, lnhashview_file, magic
5053

5154
__all__ = ["line_hash", "lnhash", "lnhashview", "lnhashview_file", "lnhashview_cell", "lnhashview_cells", "exhash", "exhash_file", "exhash_cell"]
5255

56+
import builtins
57+
_ip = getattr(builtins, 'get_ipython', lambda: None)()
58+
if _ip is not None: magic.load_ipython_extension(_ip)
59+

tests/test_magic.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import json, pytest
2+
from pathlib import Path
3+
from exhash import lnhash
4+
from exhash.magic import exhash_magic
5+
6+
def test_exhash_magic(tmp_path):
7+
p = str(tmp_path / "f.py")
8+
payload = "x = '''one'''\ny = \"\"\"two\"\"\"\nz = r'\\n raw'"
9+
exhash_magic(f"{p} 0|0000| a", payload + "\n") # cell arrives with trailing newline; stripped once
10+
assert Path(p).read_text() == payload + "\n"
11+
res = exhash_magic(f"{p} {lnhash(2, 'y = \"\"\"two\"\"\"')} c", "y = 2\n")
12+
assert "y = 2" in str(res)
13+
assert Path(p).read_text() == "x = '''one'''\ny = 2\nz = r'\\n raw'\n"
14+
exhash_magic(f"{p} 0|0000| i", "# header\n")
15+
assert Path(p).read_text().startswith("# header\n")
16+
nb = dict(cells=[dict(id="abc", cell_type="code", source="x=1\n", metadata={})], metadata={}, nbformat=4, nbformat_minor=5)
17+
nbp = str(tmp_path / "nb.ipynb")
18+
Path(nbp).write_text(json.dumps(nb))
19+
exhash_magic(f"{nbp} abc {lnhash(1, 'x=1')} c", "x = '''nb'''\n")
20+
assert json.loads(Path(nbp).read_text())["cells"][0]["source"] == "x = '''nb'''\n"
21+
with pytest.raises(ValueError): exhash_magic(f"{p} 1|abcd| d", "")
22+
with pytest.raises(ValueError): exhash_magic(p, "text")

0 commit comments

Comments
 (0)