exhash 0.3.2__tar.gz → 0.3.4__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.
Files changed (28) hide show
  1. {exhash-0.3.2 → exhash-0.3.4}/Cargo.lock +11 -11
  2. {exhash-0.3.2 → exhash-0.3.4}/Cargo.toml +1 -1
  3. {exhash-0.3.2 → exhash-0.3.4}/DEV.md +8 -2
  4. {exhash-0.3.2 → exhash-0.3.4}/PKG-INFO +51 -8
  5. {exhash-0.3.2 → exhash-0.3.4}/README.md +50 -7
  6. {exhash-0.3.2 → exhash-0.3.4}/pyproject.toml +3 -1
  7. exhash-0.3.4/python/exhash/__init__.py +337 -0
  8. exhash-0.3.4/python/exhash/skill.py +65 -0
  9. {exhash-0.3.2 → exhash-0.3.4}/src/bin/exhash.rs +50 -30
  10. {exhash-0.3.2 → exhash-0.3.4}/src/engine.rs +67 -44
  11. {exhash-0.3.2 → exhash-0.3.4}/src/lib.rs +6 -2
  12. {exhash-0.3.2 → exhash-0.3.4}/src/lnhash.rs +42 -10
  13. {exhash-0.3.2 → exhash-0.3.4}/src/parse.rs +77 -39
  14. {exhash-0.3.2 → exhash-0.3.4}/src/python.rs +31 -12
  15. {exhash-0.3.2 → exhash-0.3.4}/tests/cli.rs +67 -9
  16. {exhash-0.3.2 → exhash-0.3.4}/tests/test_exhash.py +111 -0
  17. exhash-0.3.2/python/exhash/__init__.py +0 -99
  18. {exhash-0.3.2 → exhash-0.3.4}/.github/workflows/ci.yml +0 -0
  19. {exhash-0.3.2 → exhash-0.3.4}/.gitignore +0 -0
  20. {exhash-0.3.2 → exhash-0.3.4}/_config.yml +0 -0
  21. {exhash-0.3.2 → exhash-0.3.4}/_layouts/default.html +0 -0
  22. {exhash-0.3.2 → exhash-0.3.4}/python/exhash.data/scripts/.gitkeep +0 -0
  23. {exhash-0.3.2 → exhash-0.3.4}/src/bin/lnhashview.rs +0 -0
  24. {exhash-0.3.2 → exhash-0.3.4}/tools/build.sh +0 -0
  25. {exhash-0.3.2 → exhash-0.3.4}/tools/bump.sh +0 -0
  26. {exhash-0.3.2 → exhash-0.3.4}/tools/bump2.sh +0 -0
  27. {exhash-0.3.2 → exhash-0.3.4}/tools/release.sh +0 -0
  28. {exhash-0.3.2 → exhash-0.3.4}/tools/test.sh +0 -0
@@ -13,9 +13,9 @@ dependencies = [
13
13
 
14
14
  [[package]]
15
15
  name = "autocfg"
16
- version = "1.5.0"
16
+ version = "1.5.1"
17
17
  source = "registry+https://github.com/rust-lang/crates.io-index"
18
- checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
18
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
19
19
 
20
20
  [[package]]
21
21
  name = "cfg-if"
@@ -25,7 +25,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
25
25
 
26
26
  [[package]]
27
27
  name = "exhash"
28
- version = "0.3.2"
28
+ version = "0.3.4"
29
29
  dependencies = [
30
30
  "pyo3",
31
31
  "regex",
@@ -48,15 +48,15 @@ dependencies = [
48
48
 
49
49
  [[package]]
50
50
  name = "libc"
51
- version = "0.2.185"
51
+ version = "0.2.186"
52
52
  source = "registry+https://github.com/rust-lang/crates.io-index"
53
- checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
53
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
54
54
 
55
55
  [[package]]
56
56
  name = "memchr"
57
- version = "2.8.0"
57
+ version = "2.8.1"
58
58
  source = "registry+https://github.com/rust-lang/crates.io-index"
59
- checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
59
+ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
60
60
 
61
61
  [[package]]
62
62
  name = "memoffset"
@@ -162,9 +162,9 @@ dependencies = [
162
162
 
163
163
  [[package]]
164
164
  name = "regex"
165
- version = "1.12.3"
165
+ version = "1.12.4"
166
166
  source = "registry+https://github.com/rust-lang/crates.io-index"
167
- checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
167
+ checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
168
168
  dependencies = [
169
169
  "aho-corasick",
170
170
  "memchr",
@@ -185,9 +185,9 @@ dependencies = [
185
185
 
186
186
  [[package]]
187
187
  name = "regex-syntax"
188
- version = "0.8.10"
188
+ version = "0.8.11"
189
189
  source = "registry+https://github.com/rust-lang/crates.io-index"
190
- checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
190
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
191
191
 
192
192
  [[package]]
193
193
  name = "rustversion"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "exhash"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  edition = "2021"
5
5
  license = "MIT OR Apache-2.0"
6
6
  description = "Verified line-addressed file editor using lnhash addresses"
@@ -18,7 +18,8 @@ src/
18
18
  bin/exhash.rs CLI editor (atomic in-place edit, dry-run, stdin mode)
19
19
  bin/lnhashview.rs CLI viewer
20
20
  python/exhash/
21
- __init__.py Python wrapper functions with typed/docstring API (+ exhash_result helper)
21
+ __init__.py Python wrapper functions plus file-aware exhash_file orchestration
22
+ skill.py pyskills entry point exposing exhash APIs for LLM tools
22
23
  python/exhash.data/scripts/
23
24
  exhash native binary (built, not checked in)
24
25
  lnhashview native binary (built, not checked in)
@@ -50,6 +51,9 @@ cargo test && pytest -q
50
51
  `edit_text` verifies lnhashes command-by-command against the current in-memory buffer, immediately before each command executes (not all upfront). If an earlier command shifts or rewrites a later target line, that later command will fail with a stale-hash error unless you recompute addresses.
51
52
  The `$` (last line) and `%` (whole file) address forms are resolved against the current buffer and do not require hashes.
52
53
  `edit_text_with_sw` exposes configurable shift width for `<` and `>`; `edit_text` defaults to `sw=4`.
54
+ In CLI and Python file-helper flows, a missing file is treated as empty input only when the parsed command set is valid against an empty buffer (for example `0|0000|a`); otherwise the original file-not-found error is preserved.
55
+ Python `exhash_file` adds the file-qualified orchestration layer. It parses optional `path:` prefixes, applies each command to the current in-memory buffer for that file, rejects cross-file source ranges, and writes changed files only after every command succeeds.
56
+ `lnhashview` range requests clamp `end` past EOF to the last available line, while invalid `start` values still error.
53
57
 
54
58
  ## Release
55
59
 
@@ -86,9 +90,11 @@ Maturin's `data` option in `pyproject.toml` points to `python/exhash.data/`. Fil
86
90
 
87
91
  The Rust core has three parsing functions:
88
92
 
89
- - `parse_commands_from_strs(&[&str])` — for the Python API; each string is one command, text blocks are the remaining lines (no `.` terminator; a trailing `.` line is literal text and the Python binding warns about this common mistake)
93
+ - `parse_commands_from_strs(&[&str])` — for the Python API; each string is one command, and multiline `a/i/c` text blocks must be in that same string using newlines, e.g. `["12|abcd|c\nnew line 1\nnew line 2"]`. Do not use `.` terminators or split the inserted text into separate command entries; a trailing `.` line is literal text and the Python binding warns about this common mistake.
90
94
  - `parse_commands_from_script(&str)` — for script strings; commands separated by newlines, text blocks terminated by `.`
91
95
  - `parse_commands_from_args(&[String], &mut BufRead)` — for the CLI; each arg is a command, text blocks read from stdin terminated by `.`
92
96
 
97
+ File-qualified addresses are parsed by the Python `exhash_file` wrapper; the Rust parser and CLI remain single-buffer.
98
+
93
99
  Substitute parsing keeps Rust regex escapes intact (`\d`, `\w`, etc.) while still allowing escaped command delimiters (`\/`) in pattern and replacement.
94
100
  Transliteration uses `y/src/dst/` and validates equal character counts at parse time.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: exhash
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Summary: Verified line-addressed file editor using lnhash addresses
@@ -52,6 +52,8 @@ lnhashview path/to/file.txt
52
52
  lnhashview path/to/file.txt 10 20
53
53
  ```
54
54
 
55
+ If `end` is past EOF, `lnhashview` returns through the last available line instead of failing.
56
+
55
57
  ### Edit
56
58
 
57
59
  ```bash
@@ -80,6 +82,12 @@ exhash file.txt '%j'
80
82
 
81
83
  # Move a line to EOF using $ as the destination
82
84
  exhash file.txt '12|abcd|m$'
85
+
86
+ # Create a missing file by treating it as empty input
87
+ exhash new.txt '0|0000|a' <<'EOF'
88
+ first line
89
+ .
90
+ EOF
83
91
  ```
84
92
 
85
93
  Substitute uses Rust regex syntax:
@@ -99,6 +107,8 @@ For `a/i/c` commands, provide the text block on stdin:
99
107
  printf "new line 1\nnew line 2\n.\n" | exhash file.txt "2|beef|a"
100
108
  ```
101
109
 
110
+ If the file does not exist and the command set is valid on empty input, exhash treats it as an empty file and writes the result. For example, `0|0000|a` can create a new file.
111
+
102
112
  ### Stdin filter mode
103
113
 
104
114
  ```bash
@@ -117,13 +127,13 @@ from exhash import exhash, exhash_file, lnhash, lnhashview, lnhashview_file, lin
117
127
 
118
128
  ```py
119
129
  text = "foo\nbar\n"
120
- view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
121
- view = lnhashview_file("f.py") # same but reads from file
130
+ view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
131
+ view = lnhashview_file("f.py", start=1, end=260) # end past EOF is clamped
122
132
  ```
123
133
 
124
134
  ### Editing
125
135
 
126
- `exhash(text, cmds, sw=4)` takes the text and a required iterable of command strings (use `[]` for no-op). `sw` controls how far `<` and `>` shift. For `a`/`i`/`c` commands, lines after the command are the text block. Do not include an ex-style trailing `.` line here: unlike CLI/script mode, `exhash(text, cmds)` does not use one, and a final `.` line is inserted literally.
136
+ `exhash(text, cmds, sw=4)` takes the text and a required iterable of command strings (use `[]` for no-op). `sw` controls how far `<` and `>` shift. For multiline `a`/`i`/`c` commands, include the inserted text in the same command string using newline characters, e.g. `["12|abcd|c\nnew line 1\nnew line 2"]`. Do not use `.` terminators, and do not split the text block into separate `cmds` entries. If you include a final `.` line, it is inserted literally and exhash emits a warning.
127
137
 
128
138
  ```py
129
139
  addr = lnhash(1, "foo") # "1|a1b2|"
@@ -138,12 +148,15 @@ res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
138
148
  # Hashes are checked just-in-time per command.
139
149
  # If earlier commands change/shift a later target line, recompute lnhash first.
140
150
 
141
- # Append multiline text (no dot terminator)
151
+ # Append multiline text in the same command string (no dot terminator)
142
152
  res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
143
153
 
144
154
  # Wrong for the Python API: the trailing "." would be inserted literally
145
155
  # res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2\n."])
146
156
 
157
+ # Also wrong: do not split the inserted text into separate cmds entries
158
+ # res = exhash(text, [f"{addr}a", "new line 1", "new line 2"])
159
+
147
160
  # Change shift width for < and >
148
161
  res = exhash(text, [f"{addr}>1"], sw=2)
149
162
 
@@ -157,18 +170,46 @@ res = exhash("foo\nbar\n", [f"{a1},{a2}s/foo\nbar/replaced/"])
157
170
 
158
171
  ### File helpers
159
172
 
160
- `exhash_file` and `lnhashview_file` read directly from a file path:
173
+ `lnhashview_file` reads directly from one file path. `exhash_file(path, cmds, sw=4, inplace=False)` uses `path` as the default file context for unqualified addresses, and also accepts file-qualified source and `m`/`t` destination addresses:
161
174
 
162
175
  ```py
163
176
  view = lnhashview_file("file.py")
164
177
 
165
- # Returns EditResult, file unchanged
178
+ # Returns FileSetEditResult, files unchanged
166
179
  res = exhash_file("file.py", [f"{addr}s/foo/bar/"])
180
+ print(res.changed) # ["file.py"]
181
+ print(res["file.py"].lines)
182
+ print(res.format_diff()) # includes --- file.py / +++ file.py headers
167
183
 
168
- # With inplace=True, writes back on success and returns diff string
184
+ # With inplace=True, writes changed files after every command succeeds
185
+ # and returns the combined diff string.
169
186
  diff = exhash_file("file.py", [f"{addr}s/foo/bar/"], inplace=True)
187
+
188
+ # Missing files are treated as empty only when the command is valid on empty input.
189
+ diff = exhash_file("new.py", ["0|0000|a\nprint('hi')"], inplace=True)
190
+
191
+ # File-qualified addresses can edit or transfer lines across files.
192
+ cmds = [
193
+ "src/a.py:24|8f12|,38|c0de|m src/b.py:$",
194
+ r"src/a.py:5|91aa|s/from \.b import old/from \.b import helper/",
195
+ ]
196
+ diff = exhash_file("src/a.py", cmds, inplace=True)
170
197
  ```
171
198
 
199
+ A file prefix is separated from the address with `:`. Escape literal colons in filenames as `\:` and literal backslashes as `\\`.
200
+
201
+ `exhash_file(..., inplace=False)` returns a `FileSetEditResult`:
202
+
203
+ - `res.files` — dict of path to `FileEditResult`
204
+ - `res.changed` — changed paths, in first-touch order
205
+ - `res.default_path` — the default path passed to `exhash_file`
206
+ - `res[path]` — shorthand for `res.files[path]`
207
+ - `res.format_diff(context=1)` — combined diff with `--- path` / `+++ path` headers
208
+
209
+ ### Pyskill
210
+
211
+ The package registers `exhash.skill` as a pyskill exposing the primary Python APIs with LLM-oriented workflow docs. Use `doc(exhash.skill)` after importing it through a pyskills host.
212
+
172
213
  ### EditResult
173
214
 
174
215
  `exhash()` returns an `EditResult` with attributes (also accessible via `res["key"]`):
@@ -184,6 +225,8 @@ diff = exhash_file("file.py", [f"{addr}s/foo/bar/"], inplace=True)
184
225
  ```py
185
226
  res = exhash(text, [f"{addr}s/foo/baz/"])
186
227
  print(res.format_diff())
228
+ # --- original
229
+ # +++ modified
187
230
  # -1|a1b2| foo
188
231
  # +1|c3d4| baz
189
232
  # 2|e5f6| bar
@@ -37,6 +37,8 @@ lnhashview path/to/file.txt
37
37
  lnhashview path/to/file.txt 10 20
38
38
  ```
39
39
 
40
+ If `end` is past EOF, `lnhashview` returns through the last available line instead of failing.
41
+
40
42
  ### Edit
41
43
 
42
44
  ```bash
@@ -65,6 +67,12 @@ exhash file.txt '%j'
65
67
 
66
68
  # Move a line to EOF using $ as the destination
67
69
  exhash file.txt '12|abcd|m$'
70
+
71
+ # Create a missing file by treating it as empty input
72
+ exhash new.txt '0|0000|a' <<'EOF'
73
+ first line
74
+ .
75
+ EOF
68
76
  ```
69
77
 
70
78
  Substitute uses Rust regex syntax:
@@ -84,6 +92,8 @@ For `a/i/c` commands, provide the text block on stdin:
84
92
  printf "new line 1\nnew line 2\n.\n" | exhash file.txt "2|beef|a"
85
93
  ```
86
94
 
95
+ If the file does not exist and the command set is valid on empty input, exhash treats it as an empty file and writes the result. For example, `0|0000|a` can create a new file.
96
+
87
97
  ### Stdin filter mode
88
98
 
89
99
  ```bash
@@ -102,13 +112,13 @@ from exhash import exhash, exhash_file, lnhash, lnhashview, lnhashview_file, lin
102
112
 
103
113
  ```py
104
114
  text = "foo\nbar\n"
105
- view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
106
- view = lnhashview_file("f.py") # same but reads from file
115
+ view = lnhashview(text) # ["1|a1b2| foo", "2|c3d4| bar"]
116
+ view = lnhashview_file("f.py", start=1, end=260) # end past EOF is clamped
107
117
  ```
108
118
 
109
119
  ### Editing
110
120
 
111
- `exhash(text, cmds, sw=4)` takes the text and a required iterable of command strings (use `[]` for no-op). `sw` controls how far `<` and `>` shift. For `a`/`i`/`c` commands, lines after the command are the text block. Do not include an ex-style trailing `.` line here: unlike CLI/script mode, `exhash(text, cmds)` does not use one, and a final `.` line is inserted literally.
121
+ `exhash(text, cmds, sw=4)` takes the text and a required iterable of command strings (use `[]` for no-op). `sw` controls how far `<` and `>` shift. For multiline `a`/`i`/`c` commands, include the inserted text in the same command string using newline characters, e.g. `["12|abcd|c\nnew line 1\nnew line 2"]`. Do not use `.` terminators, and do not split the text block into separate `cmds` entries. If you include a final `.` line, it is inserted literally and exhash emits a warning.
112
122
 
113
123
  ```py
114
124
  addr = lnhash(1, "foo") # "1|a1b2|"
@@ -123,12 +133,15 @@ res = exhash(text, [f"{a1}s/foo/FOO/", f"{a2}s/bar/BAR/"])
123
133
  # Hashes are checked just-in-time per command.
124
134
  # If earlier commands change/shift a later target line, recompute lnhash first.
125
135
 
126
- # Append multiline text (no dot terminator)
136
+ # Append multiline text in the same command string (no dot terminator)
127
137
  res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2"])
128
138
 
129
139
  # Wrong for the Python API: the trailing "." would be inserted literally
130
140
  # res = exhash(text, [f"{addr}a\nnew line 1\nnew line 2\n."])
131
141
 
142
+ # Also wrong: do not split the inserted text into separate cmds entries
143
+ # res = exhash(text, [f"{addr}a", "new line 1", "new line 2"])
144
+
132
145
  # Change shift width for < and >
133
146
  res = exhash(text, [f"{addr}>1"], sw=2)
134
147
 
@@ -142,18 +155,46 @@ res = exhash("foo\nbar\n", [f"{a1},{a2}s/foo\nbar/replaced/"])
142
155
 
143
156
  ### File helpers
144
157
 
145
- `exhash_file` and `lnhashview_file` read directly from a file path:
158
+ `lnhashview_file` reads directly from one file path. `exhash_file(path, cmds, sw=4, inplace=False)` uses `path` as the default file context for unqualified addresses, and also accepts file-qualified source and `m`/`t` destination addresses:
146
159
 
147
160
  ```py
148
161
  view = lnhashview_file("file.py")
149
162
 
150
- # Returns EditResult, file unchanged
163
+ # Returns FileSetEditResult, files unchanged
151
164
  res = exhash_file("file.py", [f"{addr}s/foo/bar/"])
165
+ print(res.changed) # ["file.py"]
166
+ print(res["file.py"].lines)
167
+ print(res.format_diff()) # includes --- file.py / +++ file.py headers
152
168
 
153
- # With inplace=True, writes back on success and returns diff string
169
+ # With inplace=True, writes changed files after every command succeeds
170
+ # and returns the combined diff string.
154
171
  diff = exhash_file("file.py", [f"{addr}s/foo/bar/"], inplace=True)
172
+
173
+ # Missing files are treated as empty only when the command is valid on empty input.
174
+ diff = exhash_file("new.py", ["0|0000|a\nprint('hi')"], inplace=True)
175
+
176
+ # File-qualified addresses can edit or transfer lines across files.
177
+ cmds = [
178
+ "src/a.py:24|8f12|,38|c0de|m src/b.py:$",
179
+ r"src/a.py:5|91aa|s/from \.b import old/from \.b import helper/",
180
+ ]
181
+ diff = exhash_file("src/a.py", cmds, inplace=True)
155
182
  ```
156
183
 
184
+ A file prefix is separated from the address with `:`. Escape literal colons in filenames as `\:` and literal backslashes as `\\`.
185
+
186
+ `exhash_file(..., inplace=False)` returns a `FileSetEditResult`:
187
+
188
+ - `res.files` — dict of path to `FileEditResult`
189
+ - `res.changed` — changed paths, in first-touch order
190
+ - `res.default_path` — the default path passed to `exhash_file`
191
+ - `res[path]` — shorthand for `res.files[path]`
192
+ - `res.format_diff(context=1)` — combined diff with `--- path` / `+++ path` headers
193
+
194
+ ### Pyskill
195
+
196
+ The package registers `exhash.skill` as a pyskill exposing the primary Python APIs with LLM-oriented workflow docs. Use `doc(exhash.skill)` after importing it through a pyskills host.
197
+
157
198
  ### EditResult
158
199
 
159
200
  `exhash()` returns an `EditResult` with attributes (also accessible via `res["key"]`):
@@ -169,6 +210,8 @@ diff = exhash_file("file.py", [f"{addr}s/foo/bar/"], inplace=True)
169
210
  ```py
170
211
  res = exhash(text, [f"{addr}s/foo/baz/"])
171
212
  print(res.format_diff())
213
+ # --- original
214
+ # +++ modified
172
215
  # -1|a1b2| foo
173
216
  # +1|c3d4| baz
174
217
  # 2|e5f6| bar
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "exhash"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Verified line-addressed file editor using lnhash addresses"
9
9
  license = {text = "MIT OR Apache-2.0"}
10
10
  requires-python = ">=3.10"
@@ -19,6 +19,8 @@ classifiers = [
19
19
  Homepage = "https://github.com/AnswerDotAI/exhash"
20
20
  Repository = "https://github.com/AnswerDotAI/exhash"
21
21
  Issues = "https://github.com/AnswerDotAI/exhash/issues"
22
+ [project.entry-points.pyskills]
23
+ exhash = "exhash.skill"
22
24
 
23
25
  [tool.maturin]
24
26
  features = ["extension-module"]