Skip to content

Commit 48a78f1

Browse files
committed
fixes #2
1 parent 594620f commit 48a78f1

4 files changed

Lines changed: 90 additions & 59 deletions

File tree

DEV.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ src/
1818
bin/exhash.rs CLI editor (atomic in-place edit, dry-run, stdin mode)
1919
bin/lnhashview.rs CLI viewer
2020
python/exhash/
21-
__init__.py Python wrapper (EditResult class, exhash function)
21+
__init__.py Python wrapper functions with typed/docstring API (+ exhash_result helper)
2222
python/exhash.data/scripts/
2323
exhash native binary (built, not checked in)
2424
lnhashview native binary (built, not checked in)

README.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ In `--stdin` mode, multiline `a/i/c` text blocks are not available.
6565
## Python API
6666

6767
```py
68-
from exhash import exhash, lnhash, lnhashview, line_hash
68+
from exhash import exhash, exhash_result, lnhash, lnhashview, line_hash
6969
```
7070

7171
### Viewing
@@ -82,8 +82,8 @@ view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
8282
```py
8383
addr = lnhash(1, "foo") # "1|a1b2|"
8484
res = exhash(text, [f"{addr}s/foo/baz/"])
85-
print(res.text()) # "baz\nbar"
86-
print(res.modified) # [1]
85+
print(res["lines"]) # ["baz", "bar"]
86+
print(res["modified"]) # [1]
8787

8888
# Multiple commands
8989
a1, a2 = lnhash(1, "foo"), lnhash(2, "bar")
@@ -93,15 +93,14 @@ res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
9393
res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
9494
```
9595

96-
### EditResult
96+
### Result dict
9797

98-
- `.lines` — list of output lines
99-
- `.hashes` — lnhash for each output line
100-
- `.modified` — 1-based line numbers of modified/added lines
101-
- `.deleted` — 1-based line numbers of removed lines (in original)
102-
- `.text()` — joined output
103-
- `.view()` — output in lnhash format
104-
- `repr()` — shows only modified lines in lnhash format
98+
- `lines` — list of output lines
99+
- `hashes` — lnhash for each output line
100+
- `modified` — 1-based line numbers of modified/added lines
101+
- `deleted` — 1-based line numbers of removed lines (in original)
102+
103+
`exhash_result([res1, res2, ...])` renders modified lines in lnhash format, matching the old `repr(EditResult)` style.
105104

106105
## Tests
107106

python/exhash/__init__.py

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
1-
from .exhash import line_hash, lnhash, lnhashview, exhash as _exhash
1+
from .exhash import line_hash as _line_hash, lnhash as _lnhash, lnhashview as _lnhashview, exhash as _exhash
22

3-
class EditResult:
4-
def __init__(self, r): self.lines, self.hashes, self.modified, self.deleted = r.lines, r.hashes, r.modified, r.deleted
5-
def text(self): return '\n'.join(self.lines)
6-
def view(self): return '\n'.join(f"{h} {l}" for h, l in zip(self.hashes, self.lines))
7-
def __repr__(self): return '\n'.join(f"{self.hashes[i-1]} {self.lines[i-1]}" for i in self.modified if i-1 < len(self.hashes))
3+
def line_hash(line:str) -> str:
4+
'Return a 4-char lowercase hex hash for a single line of text.'
5+
return _line_hash(line)
86

9-
def exhash(text:str, cmds:list[str]):
10-
"""Verified line-addressed editor. Apply commands to `text`, return `EditResult`.
7+
8+
def lnhash(lineno:int, line:str) -> str:
9+
'Return an lnhash address ``lineno|hash|`` for ``line`` at 1-based ``lineno``.'
10+
return _lnhash(lineno, line)
11+
12+
13+
def lnhashview(text:str) -> list[str]:
14+
'Return lines formatted as ``lineno|hash| content`` for each line in ``text``.'
15+
return _lnhashview(text)
16+
17+
18+
def exhash_result(results:list[dict]) -> str:
19+
"""Format modified lines from exhash result dicts in lnhash view format."""
20+
if not isinstance(results, list): raise TypeError("results must be a list[dict]")
21+
out = []
22+
for r in results:
23+
if not isinstance(r, dict): raise TypeError("results must be a list[dict]")
24+
lines, hashes, modified = r.get("lines"), r.get("hashes"), r.get("modified")
25+
if not isinstance(lines, list) or not isinstance(hashes, list) or not isinstance(modified, list):
26+
raise TypeError("each result must include list fields: lines, hashes, modified")
27+
out += [f"{hashes[i-1]} {lines[i-1]}" for i in modified if isinstance(i, int) and 0 < i <= len(hashes)]
28+
return '\n'.join(out)
29+
30+
31+
def exhash(text:str, cmds:list[str]) -> dict:
32+
"""Verified line-addressed editor. Apply commands to `text`, return a result dict.
1133
1234
Commands use lnhash addresses: ``lineno|hash|cmd`` where hash is a 4-char
1335
hex content hash. Use ``lnhashview(text)`` or ``lnhash(lineno, line)`` to
@@ -37,14 +59,11 @@ def exhash(text:str, cmds:list[str]):
3759
For a/i/c, remaining lines in the command string are the text block
3860
(no '.' terminator needed, unlike the CLI).
3961
40-
Returns EditResult with:
41-
.lines list of output lines
42-
.hashes lnhash for each output line
43-
.modified 1-based line numbers of modified/added lines
44-
.deleted 1-based line numbers of removed lines (in original)
45-
.text() joined output
46-
.view() output in lnhash format
47-
repr() shows only modified lines in lnhash format
62+
Returns a dict with:
63+
lines list of output lines
64+
hashes lnhash for each output line
65+
modified 1-based line numbers of modified/added lines
66+
deleted 1-based line numbers of removed lines (in original)
4867
4968
`cmds` is a required list of command strings. For `a`/`i`/`c`, include the
5069
text block in the same command string after a newline.
@@ -55,8 +74,9 @@ def exhash(text:str, cmds:list[str]):
5574
text = "foo\\nbar\\n"
5675
addr = lnhash(1, "foo") # "1|a1b2|"
5776
res = exhash(text, [f"{addr}s/foo/baz/"])
58-
print(res) # "1|c2da| baz" (modified lines only)
59-
res.text() # "baz\\nbar"
77+
print(res["lines"]) # ["baz", "bar"]
78+
"\\n".join(res["lines"]) # "baz\\nbar"
6079
res = exhash(text, [f"{addr}a\\nnew line 1\\nnew line 2"])
6180
"""
62-
return EditResult(_exhash(text, *cmds))
81+
r = _exhash(text, *cmds)
82+
return dict(lines=r.lines, hashes=r.hashes, modified=r.modified, deleted=r.deleted)

tests/test_exhash.py

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import pytest
2-
from exhash import line_hash, lnhash, lnhashview, exhash
2+
from exhash import line_hash, lnhash, lnhashview, exhash, exhash_result
33

44
def test_line_hash_returns_4_hex():
55
h = line_hash("hello")
@@ -27,86 +27,98 @@ def test_lnhashview_empty(): assert lnhashview("") == []
2727

2828
def test_exhash_noop():
2929
res = exhash("foo\nbar\n", [])
30-
assert res.lines == ["foo", "bar"]
31-
assert res.text() == "foo\nbar"
32-
assert res.modified == []
33-
assert res.deleted == []
30+
assert res["lines"] == ["foo", "bar"]
31+
assert '\n'.join(res["lines"]) == "foo\nbar"
32+
assert res["modified"] == []
33+
assert res["deleted"] == []
3434

3535
def test_exhash_substitute():
3636
text = "foo\nbar\n"
3737
addr = lnhash(1, "foo")
3838
res = exhash(text, [f"{addr}s/foo/baz/"])
39-
assert res.lines == ["baz", "bar"]
40-
assert res.modified == [1]
41-
assert len(res.hashes) == 2
39+
assert res["lines"] == ["baz", "bar"]
40+
assert res["modified"] == [1]
41+
assert len(res["hashes"]) == 2
4242

4343
def test_exhash_delete():
4444
text = "a\nb\nc\n"
4545
addr = lnhash(2, "b")
4646
res = exhash(text, [f"{addr}d"])
47-
assert res.lines == ["a", "c"]
48-
assert 2 in res.deleted
47+
assert res["lines"] == ["a", "c"]
48+
assert 2 in res["deleted"]
4949

5050
def test_exhash_append():
5151
text = "a\nb\n"
5252
addr = lnhash(1, "a")
5353
res = exhash(text, [f"{addr}a\nx\ny"])
54-
assert res.lines == ["a", "x", "y", "b"]
55-
assert res.modified == [2, 3]
54+
assert res["lines"] == ["a", "x", "y", "b"]
55+
assert res["modified"] == [2, 3]
5656

5757
def test_exhash_insert():
5858
text = "a\nb\n"
5959
addr = lnhash(2, "b")
6060
res = exhash(text, [f"{addr}i\nx"])
61-
assert res.lines == ["a", "x", "b"]
62-
assert res.modified == [2]
61+
assert res["lines"] == ["a", "x", "b"]
62+
assert res["modified"] == [2]
6363

6464
def test_exhash_stale_hash_raises():
6565
text = "hello\nworld\n"
6666
addr = lnhash(1, "wrong")
6767
with pytest.raises(ValueError): exhash(text, [f"{addr}d"])
6868

69-
def test_exhash_repr_shows_modified():
69+
def test_exhash_result_is_dict():
7070
text = "foo\nbar\n"
7171
addr = lnhash(1, "foo")
7272
res = exhash(text, [f"{addr}s/foo/baz/"])
73-
r = repr(res)
74-
assert "baz" in r
75-
assert "bar" not in r
76-
assert r == f"{lnhash(1, 'baz')} baz"
73+
assert isinstance(res, dict)
74+
assert set(res.keys()) == {"lines", "hashes", "modified", "deleted"}
75+
76+
77+
def test_exhash_result_formats_modified():
78+
text = "foo\nbar\n"
79+
addr = lnhash(1, "foo")
80+
res = exhash(text, [f"{addr}s/foo/baz/"])
81+
assert exhash_result([res]) == f"{lnhash(1, 'baz')} baz"
82+
83+
84+
def test_exhash_result_no_modifications_is_empty(): assert exhash_result([exhash("foo\n", [])]) == ""
85+
86+
87+
def test_exhash_result_requires_list_of_dict():
88+
with pytest.raises(TypeError): exhash_result(dict(lines=[], hashes=[], modified=[]))
89+
with pytest.raises(TypeError): exhash_result([1])
7790

78-
def test_exhash_repr_noop_empty(): assert repr(exhash("foo\n", [])) == ""
7991

8092
def test_exhash_view():
8193
text = "foo\nbar\n"
8294
res = exhash(text, [])
83-
assert res.view() == f"{lnhash(1, 'foo')} foo\n{lnhash(2, 'bar')} bar"
95+
view = '\n'.join(f"{h} {l}" for h, l in zip(res["hashes"], res["lines"]))
96+
assert view == f"{lnhash(1, 'foo')} foo\n{lnhash(2, 'bar')} bar"
8497

8598
def test_exhash_result_hashes_match():
8699
text = "foo\nbar\n"
87100
res = exhash(text, [])
88-
for i, (h, line) in enumerate(zip(res.hashes, res.lines)): assert h == lnhash(i + 1, line)
101+
for i, (h, line) in enumerate(zip(res["hashes"], res["lines"])): assert h == lnhash(i + 1, line)
89102

90103
def test_exhash_multiple_cmds():
91104
text = "a\nb\nc\n"
92105
a1, a3 = lnhash(1, "a"), lnhash(3, "c")
93106
res = exhash(text, [f"{a1}s/a/A/", f"{a3}s/c/C/"])
94-
assert res.lines == ["A", "b", "C"]
95-
assert res.modified == [1, 3]
107+
assert res["lines"] == ["A", "b", "C"]
108+
assert res["modified"] == [1, 3]
96109

97110
def test_exhash_append_trailing_newline():
98111
text = "a\nb\n"
99112
addr = lnhash(1, "a")
100113
res = exhash(text, [f"{addr}a\nx\n"])
101-
assert res.lines == ["a", "x", "", "b"]
114+
assert res["lines"] == ["a", "x", "", "b"]
102115

103116
def test_exhash_multiline_non_text_cmd_raises():
104117
text = "a\nb\n"
105118
addr = lnhash(1, "a")
106119
with pytest.raises(ValueError): exhash(text, [f"{addr}d\nextra"])
107120

108-
def test_exhash_accepts_tuple_cmds():
121+
def test_exhash_requires_list_cmds():
109122
text = "a\nb\n"
110123
a1, a2 = lnhash(1, "a"), lnhash(2, "b")
111-
res = exhash(text, (f"{a1}s/a/A/", f"{a2}s/b/B/"))
112-
assert res.lines == ["A", "B"]
124+
with pytest.raises(TypeError): exhash(text, (f"{a1}s/a/A/", f"{a2}s/b/B/"))

0 commit comments

Comments
 (0)