diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4c0850..cf7e3cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/Cargo.toml b/Cargo.toml index 4dfa428..311a42d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/DEV.md b/DEV.md index b3dd313..e2212b3 100644 --- a/DEV.md +++ b/DEV.md @@ -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 diff --git a/README.md b/README.md index f0a584b..175a89e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index fd9e2a9..3e6746b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/python/exhash/_cli.py b/python/exhash/_cli.py index 9be19a0..53e93f1 100644 --- a/python/exhash/_cli.py +++ b/python/exhash/_cli.py @@ -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 @@ -33,6 +34,13 @@ LNHASHVIEW_USAGE = ("Usage: lnhashview [start_line [end_line]]\n\n" "Prints lines as: ||; start_line/end_line are 1-based inclusive.") +EXHASH_CELL_USAGE = """\ +Usage: exhash-cell [-h] [--dry-run] [--sw N] [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) @@ -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) diff --git a/tests/test_cells.py b/tests/test_cells.py index 24febff..3dc2c66 100644 --- a/tests/test_cells.py +++ b/tests/test_cells.py @@ -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" @@ -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')])