Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- uses: actions/checkout@v7
- uses: PyO3/maturin-action@v1
with:
args: --release --out dist -i python3.10 -i python3.11 -i python3.12 -i python3.13
args: --profile dist --out dist -i python3.10 -i python3.11 -i python3.12 -i python3.13
manylinux: auto
- uses: actions/upload-artifact@v7
with:
Expand Down
14 changes: 14 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,18 @@ extension-module = ["pyo3/extension-module"]
too_many_arguments = "allow"

[profile.release]
lto = false
codegen-units = 16

[profile.release.package.exhash]
incremental = true

[profile.dist]
inherits = "release"
lto = true
incremental = false
codegen-units = 1
strip = true

[profile.dist.package.exhash]
incremental = false
2 changes: 1 addition & 1 deletion DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ No local build is required for release; CI runs the release build, creates a Git

## How the CLIs work

The `exhash` and `lnhashview` commands are Python console scripts declared in `[project.scripts]` (`python/exhash/_cli.py`). They handle argument parsing, file I/O (atomic writes, binary/UTF-8 rejection, `--stdin`/`--dry-run`), and delegate command parsing and editing to the extension: `_cli` calls the `exhash_argv` binding, which runs `parse_commands_from_args` (ex-style `a/i/c` text blocks terminated by `.`) plus `edit_text_with_sw`.
The commands are Python console scripts declared in `[project.scripts]` (`python/exhash/_cli.py`). `exhash` and `exhash-cell` handle argument parsing, atomic file I/O, and delegate compact command parsing and editing to the extension. `lnhashview` and `lnhashview-cell` provide the corresponding address views. `exhash-open` is a fastcore `call_parse` wrapper over the document outline API.

## Command parsing modes

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,29 @@ cat file.txt | exhash --stdin - '1|abcd|s/foo/bar/'

In `--stdin` mode, multiline `a/i/c` text blocks are not available.

### Notebook cells

`lnhashview-cell` and `exhash-cell` apply the same workflow to one notebook cell, addressed by its exact or unique ID prefix:

```bash
lnhashview-cell nbs/00_core.ipynb ab12cd34
exhash-cell nbs/00_core.ipynb ab12cd34 '3|beef|s/old/new/'
exhash-cell --dry-run nbs/00_core.ipynb ab12cd34 '3|beef|d'
```

### Document outlines

`exhash-open` opens Markdown, source code, notebooks, URLs, or stdin as a verified section tree. Its default output is the immediate outline; copy a displayed token back to read that section:

```bash
exhash-open README.md
exhash-open README.md '1.2.|21|e675|,101|426c|'
exhash-open README.md --paths --depth 2
exhash-open README.md --search 'CLI|console'
exhash-open README.md --lnhashs
exhash-open https://example.com/llms.txt --links
```

## Python API

```py
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Issues = "https://github.com/AnswerDotAI/exhash/issues"
[project.scripts]
exhash = "exhash._cli:exhash_main"
lnhashview = "exhash._cli:lnhashview_main"
exhash-cell = "exhash._cli:exhash_cell_main"
lnhashview-cell = "exhash._cli:lnhashview_cell_main"
exhash-open = "exhash._cli:open_doc_main"

[project.entry-points.pyskills]
exhash = "exhash.skill"
Expand Down
98 changes: 96 additions & 2 deletions python/exhash/_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"Console-script entry points for the `exhash` and `lnhashview` CLIs."
import os, re, sys, tempfile
"Console-script entry points for exhash tools."
import json, os, re, sys, tempfile
from pathlib import Path
from fastcore.script import call_parse

from .exhash import exhash_argv as _exhash_argv, lnhashview as _lnhashview

Expand Down Expand Up @@ -33,6 +34,13 @@
LNHASHVIEW_USAGE = ("Usage: lnhashview <file> [start_line [end_line]]\n\n"
"Prints lines as: <lineno>|<hash>|<content>; start_line/end_line are 1-based inclusive.")

EXHASH_CELL_USAGE = """\
Usage: exhash-cell [-h] [--dry-run] [--sw N] <notebook> <cell-id> [commands...]

Apply exhash commands to one notebook cell. Multiline a/i/c text blocks are read
from stdin and terminated by a line containing only '.'.
"""


def _die(msg, code=1):
print(msg, file=sys.stderr)
Expand Down Expand Up @@ -129,3 +137,89 @@ def _int(v, name):
text = _read_text_or_die(file)
if start is not None and end is None: end = start # single arg shows just that line
for line in _lnhashview(text, start, end): print(line)


@call_parse(pos=['start', 'end'])
def lnhashview_cell_main(
file:str, # Notebook path
cell_id:str, # Exact or uniquely prefixed cell ID
start:int=None, # First source line to show
end:int=None, # Last source line to show
):
"Show hash-addressed source lines from one notebook cell."
from . import lnhashview_cell
if start is not None and end is None: end = start
try: print(lnhashview_cell(file, cell_id, start, end))
except Exception as e: _die(f"error: {e}")


def exhash_cell_main(argv=None):
"Entry point for the `exhash-cell` console script."
argv = list(sys.argv[1:] if argv is None else argv)
dry_run, sw, i = False, 4, 0
while i < len(argv):
a = argv[i]
if a == '--dry-run': dry_run, i = True, i+1
elif a == '--sw':
if i+1 >= len(argv): _die("error: --sw requires an integer argument", 2)
try: sw = int(argv[i+1])
except ValueError: _die(f"error: invalid --sw value {argv[i+1]!r}", 2)
i += 2
elif a in ('-h', '--help'):
print(EXHASH_CELL_USAGE, file=sys.stderr)
return
elif a.startswith('-') and len(a) > 1: _die(f"error: unknown flag {a}\n{EXHASH_CELL_USAGE}", 2)
else: break
if len(argv)-i < 2: _die(EXHASH_CELL_USAGE, 2)
file, cell_id, cmds = argv[i], argv[i+1], argv[i+2:]
text_block = sys.stdin.read() if any(_needs_text_block(c) for c in cmds) else ""
try:
from . import _cell_text, _load_cell
nb, cell = _load_cell(file, cell_id)
text = _cell_text(cell)
res = _exhash_argv(text, cmds, text_block, sw)
new = '\n'.join(res.lines)
if text.endswith('\n') and new: new += '\n'
if new != text and not dry_run:
cell['source'] = new.splitlines(keepends=True) if isinstance(cell['source'], list) else new
_atomic_write(file, json.dumps(nb, sort_keys=True, indent=1, ensure_ascii=False) + '\n')
except Exception as e: _die(f"error: {e}", 2)
if (diff := res.format_diff(1)): sys.stdout.write(diff)


def _open_doc(src):
from . import open_doc
if src == '-': return open_doc(sys.stdin.read())
if re.match(r'https?://', src): return open_doc(src)
return open_doc(fname=src)


@call_parse(pos=['token'])
def open_doc_main(
src:str, # File path, URL, or - for stdin
token:str=None, # Verified section token to view
paths:bool=False, # Show the complete section outline?
depth:int=None, # Maximum section depth for --paths
search:str=None, # Search sections using a case-insensitive regex
links:bool=False, # List links in document order?
open_link:int=None, # Open a numbered link and show its outline
nums:bool=False, # Prefix viewed lines with source line numbers?
lnhashs:bool=False, # Prefix viewed lines with hash-verified addresses?
):
"Open a document as a navigable, verified section outline."
modes = bool(token) + paths + (search is not None) + links + (open_link is not None)
if modes > 1: _die("error: token, --paths, --search, --links, and --open-link are mutually exclusive", 2)
if depth is not None and not paths: _die("error: --depth requires --paths", 2)
if (nums or lnhashs) and (paths or search is not None or links or open_link is not None):
_die("error: --nums and --lnhashs apply only to document or token views", 2)
try:
d = _open_doc(src)
if token: res = d.view(token, nums=nums, lnhashs=lnhashs)
elif nums or lnhashs: res = d.view(nums=nums, lnhashs=lnhashs)
elif paths: res = d.paths(depth)
elif search is not None: res = d.search(search)
elif links: res = d.links()
elif open_link is not None: res = d.open(open_link)
else: res = d
except Exception as e: _die(f"error: {e}")
print(res)
12 changes: 12 additions & 0 deletions tests/test_cells.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json, pytest
from exhash import lnhash, lnhashview_cell, lnhashview_cells, cell_exhash, file_exhash
from exhash._cli import exhash_cell_main

def mk_nb(path, cells):
"Write a minimal notebook; `cells` is a list of (id, source) with source str or list"
Expand Down Expand Up @@ -69,6 +70,17 @@ def test_cell_exhash_writes_by_default(tmp_path):
assert json.loads(p.read_text())['cells'][0]['source'] == 'x=9' # written by default


def test_exhash_cell_cli_dry_run_then_write(tmp_path, capsys):
p = tmp_path/'t.ipynb'
mk_nb(p, [('aaaa1111', 'x=1')])
cmd = f"{lnhash(1, 'x=1')}s/1/2/"
exhash_cell_main(['--dry-run', str(p), 'aaaa', cmd])
assert json.loads(p.read_text())['cells'][0]['source'] == 'x=1'
exhash_cell_main([str(p), 'aaaa', cmd])
assert json.loads(p.read_text())['cells'][0]['source'] == 'x=2'
assert 'x=2' in capsys.readouterr().out


def test_cell_exhash_stacks_call_start_addresses(tmp_path):
p = tmp_path/'t.ipynb'
mk_nb(p, [('aaaa1111', 'x = one + two')])
Expand Down