From 2def8d0e6b4553f8adf3420ebdad1c27a7626839 Mon Sep 17 00:00:00 2001 From: Jeremy Howard Date: Wed, 9 Sep 2026 12:32:32 +1000 Subject: [PATCH] Cell diffs show context, outputs, directives, and word-level changes --- nbdev/_modidx.py | 9 +- nbdev/diff.py | 157 +++++++-- nbs/api/19_diff.ipynb | 738 +++++++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- 4 files changed, 724 insertions(+), 182 deletions(-) diff --git a/nbdev/_modidx.py b/nbdev/_modidx.py index af684911c..07a022545 100644 --- a/nbdev/_modidx.py +++ b/nbdev/_modidx.py @@ -82,10 +82,17 @@ 'nbdev.diff': { 'nbdev.diff._cell_changes': ('api/diff.html#_cell_changes', 'nbdev/diff.py'), 'nbdev.diff._diff_order': ('api/diff.html#_diff_order', 'nbdev/diff.py'), 'nbdev.diff._dline': ('api/diff.html#_dline', 'nbdev/diff.py'), - 'nbdev.diff._file_srcs': ('api/diff.html#_file_srcs', 'nbdev/diff.py'), + 'nbdev.diff._dtag': ('api/diff.html#_dtag', 'nbdev/diff.py'), + 'nbdev.diff._file_cells': ('api/diff.html#_file_cells', 'nbdev/diff.py'), 'nbdev.diff._nb_srcdict': ('api/diff.html#_nb_srcdict', 'nbdev/diff.py'), + 'nbdev.diff._out_lines': ('api/diff.html#_out_lines', 'nbdev/diff.py'), + 'nbdev.diff._refine': ('api/diff.html#_refine', 'nbdev/diff.py'), 'nbdev.diff._src': ('api/diff.html#_src', 'nbdev/diff.py'), 'nbdev.diff._srcdict': ('api/diff.html#_srcdict', 'nbdev/diff.py'), + 'nbdev.diff._tokens': ('api/diff.html#_tokens', 'nbdev/diff.py'), + 'nbdev.diff._trunc': ('api/diff.html#_trunc', 'nbdev/diff.py'), + 'nbdev.diff._wline': ('api/diff.html#_wline', 'nbdev/diff.py'), + 'nbdev.diff._word_diff': ('api/diff.html#_word_diff', 'nbdev/diff.py'), 'nbdev.diff.cell_diffs': ('api/diff.html#cell_diffs', 'nbdev/diff.py'), 'nbdev.diff.changed_cells': ('api/diff.html#changed_cells', 'nbdev/diff.py'), 'nbdev.diff.nb_diff': ('api/diff.html#nb_diff', 'nbdev/diff.py'), diff --git a/nbdev/diff.py b/nbdev/diff.py index 12ac4518d..cb5eec917 100644 --- a/nbdev/diff.py +++ b/nbdev/diff.py @@ -5,19 +5,20 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/19_diff.ipynb. # %% auto #0 -__all__ = ['MAXLEN', 'read_nb_from_git', 'nbs_pair', 'changed_cells', 'source_diff', 'cell_diffs', 'render_diff', 'nb_diff', - 'nbdev_diff_driver', 'nbdev_diff'] +__all__ = ['MAXLEN', 'OUT_MAXLEN', 'MIN_RATIO', 'read_nb_from_git', 'nbs_pair', 'changed_cells', 'source_diff', 'cell_diffs', + 'render_diff', 'nb_diff', 'nbdev_diff_driver', 'nbdev_diff'] # %% ../nbs/api/19_diff.ipynb #10c8ad0b import json from fastcore.utils import * from fastcore.meta import delegates from fastcore.script import * -from difflib import unified_diff +from difflib import unified_diff, SequenceMatcher from subprocess import CalledProcessError from typing import Annotated from fastgit import Git from fastcore.nbio import * +from fastcore.nbio import _directive from .doclinks import nbglob # %% ../nbs/api/19_diff.ipynb #a8981115 @@ -37,7 +38,10 @@ def read_nb_from_git( return dict2nb(json.loads(raw)) # %% ../nbs/api/19_diff.ipynb #3ac25702 -def _src(c): return c.get('source','') +def _src(c): + "Source of cell `c` without its leading directive lines" + dirs,code = c._partition() + return ''.join([l for l in dirs if not _directive(l, c.lang_)] + code) def _srcdict(nb, f=noop): "Dict of cell id->`f(cell)`, with positional fallback ids for pre-4.5 notebooks" @@ -45,6 +49,13 @@ def _srcdict(nb, f=noop): def _nb_srcdict(g:Git, nb_path, ref=None, f=noop): return _srcdict(read_nb_from_git(g, nb_path, ref), f) +# %% ../nbs/api/19_diff.ipynb #17749428 +def _dtag(c): + "Cell `c`'s merged directives as a ` [k k=v]` header tag, in `dir_tag`'s form; `''` if none" + tag = ' '.join(k if not v else f'{k}={v}' for k,v in c.directives.items()) + return f' [{tag}]' if tag else '' + + # %% ../nbs/api/19_diff.ipynb #27ff9a7b def nbs_pair( nb_path, # Path to the notebook @@ -82,7 +93,6 @@ def cell_content(c): if dels: res |= {cid: fn(cid, old[cid], '') for cid in old if cid not in new} return res -# %% ../nbs/api/19_diff.ipynb #6a723bc8 @delegates(_cell_changes) def changed_cells(nb_path, **kwargs): "Return set of cell IDs for changed/added/deleted cells between two refs" @@ -106,14 +116,78 @@ def f(cid,o,n): return source_diff(o,n) # %% ../nbs/api/19_diff.ipynb #d670e58e MAXLEN = 180 # Most characters shown per displayed line -_dcolors = {'-':'\x1b[31m', '+':'\x1b[32m', '@':'\x1b[36m', '#':'\x1b[1m', '# ':'\x1b[1;7m'} +OUT_MAXLEN = 512 # Most characters shown of a cell's stored output +MIN_RATIO = 0.5 # Least similar (by shared words) a changed line pair can be and still merge into one word-diff line +_dcolors = {'## …':'\x1b[34m', '# ':'\x1b[1;7m', '#':'\x1b[1m', '-':'\x1b[31m', '+':'\x1b[32m', '@':'\x1b[36m'} + +def _trunc(s, maxlen): + "`s` cut to `maxlen`, ending `…[n]` with `n` the number of characters missing" + if not maxlen or len(s)<=maxlen: return s + return truncstr(s, maxlen, suf=lambda n: f'…[{humanize(n)}]') def _dline(l, maxlen=MAXLEN, color=False): - "Truncate `l` to `maxlen` with an ellipsis, optionally colored by its leading diff marker" - if maxlen and len(l)>maxlen: l = l[:maxlen]+'…' - c = (_dcolors.get(l[:2]) or _dcolors.get(l[:1])) if color else None + "Truncate `l` with `_trunc`, optionally colored by its leading diff marker; word-diff spans render via `_wline`" + if not isinstance(l, str): return _wline(l, maxlen, color) + l = _trunc(l, maxlen) + c = first(v for k,v in _dcolors.items() if l.startswith(k)) if color else None return f'{c}{l}\x1b[0m' if c else l +# %% ../nbs/api/19_diff.ipynb #7a1d7b28 +_wcolors = {'-':'\x1b[31;9m', '+':'\x1b[32m'} +_wmarks = {'-':('[-','-]'), '+':('{+','+}')} + +def _tokens(s): return re.findall(r'\w+|\s+|[^\w\s]', s) + +def _word_diff(a, b, maxlen=MAXLEN): + "`(kind, text)` spans merging lines `a` and `b` (' ' shared, '-' only in `a`, '+' only in `b`); None when too different, or longer than `maxlen`" + if maxlen and max(len(a),len(b))>maxlen: return None + if SequenceMatcher(None, re.findall(r'\w+', a), re.findall(r'\w+', b), autojunk=False).ratio()i1: res.append(('-', ''.join(ta[i1:i2]))) + if j2>j1: res.append(('+', ''.join(tb[j1:j2]))) + return res + +def _wline(spans, maxlen=MAXLEN, color=False): + "One `!` line from word-diff `spans`: deletions red and struck out, additions green, or `[-…-]`/`{+…+}` markers without `color`" + if not color: return _dline('!'+''.join(t if k==' ' else _wmarks[k][0]+t+_wmarks[k][1] for k,t in spans), maxlen) + full = '!'+''.join(t for _,t in spans) + cut = _trunc(full, maxlen) + suf = cut[cut.rfind('…['):] if cut!=full else '' + res,room = '!',len(cut)-len(suf)-1 + for k,t in spans: + t = t[:room] + room -= len(t) + res += t if k==' ' else f'{_wcolors[k]}{t}\x1b[0m' + if not room: break + return res+suf + +# %% ../nbs/api/19_diff.ipynb #2bf82340 +def _refine(lines, maxlen=MAXLEN): + "Diff `lines` with each positionally paired `-`/`+` line merged into word-diff spans where `_word_diff` allows" + res,i = [],0 + while i`f(cell)`, with positional fallback ids for pre-4.5 notebooks\"\n", @@ -196,6 +282,40 @@ "def _nb_srcdict(g:Git, nb_path, ref=None, f=noop): return _srcdict(read_nb_from_git(g, nb_path, ref), f)" ] }, + { + "cell_type": "markdown", + "id": "1c7510a1", + "metadata": {}, + "source": [ + "Cells are keyed by id. Notebooks saved before nbformat 4.5 have no cell ids, so their cells get positional `c` keys instead, which still pair correctly as long as no cells were reordered:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd89216c", + "metadata": {}, + "outputs": [], + "source": [ + "nb0 = new_nb(['a', 'b'])\n", + "for c in nb0.cells: c.pop('id')\n", + "test_eq(_srcdict(nb0, _src), {'c0': 'a', 'c1': 'b'})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17749428", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _dtag(c):\n", + " \"Cell `c`'s merged directives as a ` [k k=v]` header tag, in `dir_tag`'s form; `''` if none\"\n", + " tag = ' '.join(k if not v else f'{k}={v}' for k,v in c.directives.items())\n", + " return f' [{tag}]' if tag else ''\n" + ] + }, { "cell_type": "code", "execution_count": null, @@ -216,6 +336,14 @@ " return _nb_srcdict(g, nb_path, ref_a, f), _nb_srcdict(g, nb_path, ref_b, f)" ] }, + { + "cell_type": "markdown", + "id": "a16cffca", + "metadata": {}, + "source": [ + "By default `nbs_pair` compares HEAD with the working directory, returning each version as `{id: cell}`. `f` maps the cells, so passing `_src` shows just the edited version's sources, without their directive lines:" + ] + }, { "cell_type": "code", "execution_count": null, @@ -225,22 +353,12 @@ { "data": { "text/plain": [ - "{'390c8c7d': {'cell_type': 'code',\n", - " 'execution_count': None,\n", - " 'id': '390c8c7d',\n", - " 'metadata': {},\n", - " 'outputs': [],\n", - " 'source': 'x=1',\n", - " 'idx_': 0,\n", - " 'lang_': 'python'},\n", - " '7247342c': {'cell_type': 'code',\n", - " 'execution_count': None,\n", - " 'id': '7247342c',\n", - " 'metadata': {},\n", - " 'outputs': [],\n", - " 'source': 'y=2',\n", - " 'idx_': 1,\n", - " 'lang_': 'python'}}" + "{'390c8c7d': 'x = 100',\n", + " '7247342c': 'y=2',\n", + " 'd8100f2f': 'a=3',\n", + " '6f770d65': 'b=4',\n", + " 'd670e58e': 'c=5',\n", + " '0351d8ae': 'x+1'}" ] }, "execution_count": null, @@ -250,7 +368,7 @@ ], "source": [ "a,b = nbs_pair(nb_path)\n", - "a" + "nbs_pair(nb_path, f=_src)[1]\n" ] }, { @@ -283,17 +401,8 @@ " if adds: res |= {cid: fn(cid, '', new[cid]) for cid in new if cid not in old}\n", " if changes: res |= {cid: fn(cid, old[cid], new[cid]) for cid in new if cid in old and new[cid] != old[cid]}\n", " if dels: res |= {cid: fn(cid, old[cid], '') for cid in old if cid not in new}\n", - " return res" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6a723bc8", - "metadata": {}, - "outputs": [], - "source": [ - "#| export\n", + " return res\n", + "\n", "@delegates(_cell_changes)\n", "def changed_cells(nb_path, **kwargs):\n", " \"Return set of cell IDs for changed/added/deleted cells between two refs\"\n", @@ -301,6 +410,14 @@ " return set(_cell_changes(nb_path, f, **kwargs).keys())" ] }, + { + "cell_type": "markdown", + "id": "ea498747", + "metadata": {}, + "source": [ + "Added and modified cells are reported by default. Deleted cells are opt-in via `dels`, since they have no content in the new notebook to look at, and `metadata` and `outputs` widen what counts as a change. The demo repo's two edits:" + ] + }, { "cell_type": "code", "execution_count": null, @@ -310,7 +427,7 @@ { "data": { "text/plain": [ - "{'390c8c7d', 'd8100f2f'}" + "{'0351d8ae', '390c8c7d'}" ] }, "execution_count": null, @@ -322,6 +439,24 @@ "changed_cells(td/'test.ipynb')" ] }, + { + "cell_type": "markdown", + "id": "7e86dbcc", + "metadata": {}, + "source": [ + "Each kind can be switched off; `changes=False` leaves just the added cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b97046fc", + "metadata": {}, + "outputs": [], + "source": [ + "test_eq(changed_cells(td/'test.ipynb', changes=False), {nb.cells[-1].id})" + ] + }, { "cell_type": "code", "execution_count": null, @@ -338,6 +473,14 @@ " return '\\n'.join(unified_diff(old_source.splitlines(), new_source.splitlines(), lineterm=''))" ] }, + { + "cell_type": "markdown", + "id": "2e18384e", + "metadata": {}, + "source": [ + "`unified_diff` starts with `---`/`+++` file-header lines, empty here since cells have no filenames, which is why `render_diff` drops the first two lines of each diff:" + ] + }, { "cell_type": "code", "execution_count": null, @@ -376,6 +519,14 @@ " return _cell_changes(nb_path, f, **kwargs)" ] }, + { + "cell_type": "markdown", + "id": "51d820c5", + "metadata": {}, + "source": [ + "`cell_diffs` pairs each changed id with its source diff, so the modified cell shows its old and new line, and the added cell only new lines:" + ] + }, { "cell_type": "code", "execution_count": null, @@ -385,8 +536,8 @@ { "data": { "text/plain": [ - "{'d8100f2f': '--- \\n+++ \\n@@ -0,0 +1 @@\\n+z=3',\n", - " '390c8c7d': '--- \\n+++ \\n@@ -1 +1 @@\\n-x=1\\n+x = 100'}" + "{'0351d8ae': '--- \\n+++ \\n@@ -0,0 +1 @@\\n+x+1',\n", + " '390c8c7d': '--- \\n+++ \\n@@ -1,2 +1,2 @@\\n #| export\\n-x=1\\n+x = 100'}" ] }, "execution_count": null, @@ -404,9 +555,15 @@ "id": "6f770d65", "metadata": {}, "source": [ - "## Rendering\n", - "\n", - "`cell_diffs` gives us the data; for `git diff` we need something readable in a terminal. `render_diff` prints one section per changed cell, in notebook order, headed by the cell id and the kind of change. Cell sources can contain very long lines (big markdown cells, embedded data) which line-oriented diff output would dump in full, so every line is truncated to `maxlen` characters, with an ellipsis marking the cut. Colors are plain ANSI escapes keyed on each line's leading diff character." + "## Rendering" + ] + }, + { + "cell_type": "markdown", + "id": "2d9a6393", + "metadata": {}, + "source": [ + "`render_diff` combines cell headers, source changes, context cells, and stored outputs into a terminal display. Similar changed lines use word-level highlighting. The examples below develop these parts before rendering the demo notebook." ] }, { @@ -418,15 +575,31 @@ "source": [ "#| export\n", "MAXLEN = 180 # Most characters shown per displayed line\n", - "_dcolors = {'-':'\\x1b[31m', '+':'\\x1b[32m', '@':'\\x1b[36m', '#':'\\x1b[1m', '# ':'\\x1b[1;7m'}\n", + "OUT_MAXLEN = 512 # Most characters shown of a cell's stored output\n", + "MIN_RATIO = 0.5 # Least similar (by shared words) a changed line pair can be and still merge into one word-diff line\n", + "_dcolors = {'## …':'\\x1b[34m', '# ':'\\x1b[1;7m', '#':'\\x1b[1m', '-':'\\x1b[31m', '+':'\\x1b[32m', '@':'\\x1b[36m'}\n", + "\n", + "def _trunc(s, maxlen):\n", + " \"`s` cut to `maxlen`, ending `…[n]` with `n` the number of characters missing\"\n", + " if not maxlen or len(s)<=maxlen: return s\n", + " return truncstr(s, maxlen, suf=lambda n: f'…[{humanize(n)}]')\n", "\n", "def _dline(l, maxlen=MAXLEN, color=False):\n", - " \"Truncate `l` to `maxlen` with an ellipsis, optionally colored by its leading diff marker\"\n", - " if maxlen and len(l)>maxlen: l = l[:maxlen]+'…'\n", - " c = (_dcolors.get(l[:2]) or _dcolors.get(l[:1])) if color else None\n", + " \"Truncate `l` with `_trunc`, optionally colored by its leading diff marker; word-diff spans render via `_wline`\"\n", + " if not isinstance(l, str): return _wline(l, maxlen, color)\n", + " l = _trunc(l, maxlen)\n", + " c = first(v for k,v in _dcolors.items() if l.startswith(k)) if color else None\n", " return f'{c}{l}\\x1b[0m' if c else l" ] }, + { + "cell_type": "markdown", + "id": "c3ef8005", + "metadata": {}, + "source": [ + "`_dline` truncates one line to `maxlen`, ending a cut line with `…` and the number of characters cut in brackets. It also colors a line by its leading marker: deletions red, additions green, hunk markers cyan, skipped-cell markers blue, cell headers bold, and the `# path` file header bold and inverse. Context lines stay plain:" + ] + }, { "cell_type": "code", "execution_count": null, @@ -434,19 +607,177 @@ "metadata": {}, "outputs": [], "source": [ - "test_eq(_dline('x'*200, maxlen=10), 'x'*10+'…')\n", + "test_eq(_dline('x'*200, maxlen=10), 'xxxx…[196]')\n", "test_eq(_dline('-del', color=True), '\\x1b[31m-del\\x1b[0m')\n", "test_eq(_dline(' context', color=True), ' context')\n", - "test_eq(_dline('# nb.ipynb', color=True), '\\x1b[1;7m# nb.ipynb\\x1b[0m') # file header: bold+inverse\n", - "test_eq(_dline('## modified', color=True), '\\x1b[1m## modified\\x1b[0m') # cell header: bold" + "test_eq(_dline('# nb.ipynb', color=True), '\\x1b[1;7m# nb.ipynb\\x1b[0m')\n", + "test_eq(_dline('## modified', color=True), '\\x1b[1m## modified\\x1b[0m')\n", + "test_eq(_dline('## … 2 cells', color=True), '\\x1b[34m## … 2 cells\\x1b[0m')\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7a1d7b28", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "_wcolors = {'-':'\\x1b[31;9m', '+':'\\x1b[32m'}\n", + "_wmarks = {'-':('[-','-]'), '+':('{+','+}')}\n", + "\n", + "def _tokens(s): return re.findall(r'\\w+|\\s+|[^\\w\\s]', s)\n", + "\n", + "def _word_diff(a, b, maxlen=MAXLEN):\n", + " \"`(kind, text)` spans merging lines `a` and `b` (' ' shared, '-' only in `a`, '+' only in `b`); None when too different, or longer than `maxlen`\"\n", + " if maxlen and max(len(a),len(b))>maxlen: return None\n", + " if SequenceMatcher(None, re.findall(r'\\w+', a), re.findall(r'\\w+', b), autojunk=False).ratio()i1: res.append(('-', ''.join(ta[i1:i2])))\n", + " if j2>j1: res.append(('+', ''.join(tb[j1:j2])))\n", + " return res\n", + "\n", + "def _wline(spans, maxlen=MAXLEN, color=False):\n", + " \"One `!` line from word-diff `spans`: deletions red and struck out, additions green, or `[-…-]`/`{+…+}` markers without `color`\"\n", + " if not color: return _dline('!'+''.join(t if k==' ' else _wmarks[k][0]+t+_wmarks[k][1] for k,t in spans), maxlen)\n", + " full = '!'+''.join(t for _,t in spans)\n", + " cut = _trunc(full, maxlen)\n", + " suf = cut[cut.rfind('…['):] if cut!=full else ''\n", + " res,room = '!',len(cut)-len(suf)-1\n", + " for k,t in spans:\n", + " t = t[:room]\n", + " room -= len(t)\n", + " res += t if k==' ' else f'{_wcolors[k]}{t}\\x1b[0m'\n", + " if not room: break\n", + " return res+suf" ] }, { "cell_type": "markdown", - "id": "8e4f6eac", + "id": "3a88ae39", + "metadata": {}, + "source": [ + "A deleted line and the added line in the same position merge into one `!` line when their word similarity reaches `MIN_RATIO`. Similarity uses `SequenceMatcher` on `\\w+` runs, excluding whitespace and punctuation. The displayed diff retains all three: word runs, whitespace runs, and individual punctuation characters.\n", + "\n", + "Without color, deletions use `[-…-]` and additions use `{+…+}`. With color, deletions are red and struck out, and additions are green:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96de50e8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[(' ', 'x'), ('+', ' '), (' ', '='), ('-', '1'), ('+', ' 100')]" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "w = _word_diff('x=1', 'x = 100')\n", + "test_eq(_wline(w), '!x{+ +}=[-1-]{+ 100+}')\n", + "test_eq(_wline(w, color=True), '!x\\x1b[32m \\x1b[0m=\\x1b[31;9m1\\x1b[0m\\x1b[32m 100\\x1b[0m')\n", + "test_eq(_wline(w, maxlen=6, color=True), '!x…[7]')\n", + "w" + ] + }, + { + "cell_type": "markdown", + "id": "01865b36", + "metadata": {}, + "source": [ + "Lines too different to merge stay a `-`/`+` pair, and lines beyond `maxlen` are never merged, since a merged line longer than the display would show little of either version:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98654ebf", + "metadata": {}, + "outputs": [], + "source": [ + "assert _word_diff('import os', 'from pathlib import Path') is None\n", + "assert _word_diff('from fasttransport.errors import APIError', 'from fastspec.spec import OpSpec') is None\n", + "assert _word_diff('x=1', 'x = 100', maxlen=5) is None" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2bf82340", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _refine(lines, maxlen=MAXLEN):\n", + " \"Diff `lines` with each positionally paired `-`/`+` line merged into word-diff spans where `_word_diff` allows\"\n", + " res,i = [],0\n", + " while i121 for l in nb_diff(nb_path, maxlen=0).splitlines() if l.startswith('+y'))" + "## Git integration" ] }, { "cell_type": "markdown", - "id": "e989cb97", + "id": "164ef549", "metadata": {}, "source": [ - "## Git integration\n", - "\n", "`nbdev_diff_driver` is a [git external diff driver](https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver): git calls it once per changed notebook, passing the repo path plus temp files holding the pre- and post-change versions (`/dev/null` when the file was added or deleted). `nbdev-install-hooks` registers it, after which plain `git diff` shows cell-level output for notebooks; pass `--diff false` there to install the merge driver only. When git detects a rename it appends two extra arguments, and for an unmerged path it passes just the repo path." ] }, @@ -641,11 +1059,11 @@ "outputs": [], "source": [ "#| export\n", - "def _file_srcs(path):\n", - " \"`{id: source}` from an ipynb file; `{}` when missing or empty (e.g. `/dev/null`)\"\n", + "def _file_cells(path):\n", + " \"`{id: cell}` from an ipynb file; `{}` when missing or empty (e.g. `/dev/null`)\"\n", " p = Path(path)\n", " if not p.exists() or not p.stat().st_size: return {}\n", - " return _srcdict(read_nb(p), _src)\n", + " return _srcdict(read_nb(p))\n", "\n", "_pos = Annotated[str, {'opt':False, 'nargs':'?'}] # optional positional CLI arg, as git passes them\n", "\n", @@ -660,12 +1078,14 @@ " new_mode:_pos=None, # Post-change file mode\n", " rename_to:_pos=None, # New repo path, when git detected a rename\n", " similarity:_pos=None, # Similarity score, when git detected a rename\n", - " maxlen:int=MAXLEN # Truncate diff lines to this width (0 for no limit)\n", + " maxlen:int=MAXLEN, # Truncate diff lines to this width (0 for no limit)\n", + " context:int=1, # Unchanged cells to show either side of each change\n", + " show_out:bool=True # Show the stored outputs of changed and added cells?\n", "):\n", " \"Git external diff driver for notebooks; installed by `nbdev-install-hooks`\"\n", " if not new_file: return print(f'unmerged: {path}')\n", " color = sys.stdout.isatty() or os.environ.get('GIT_PAGER_IN_USE')=='true'\n", - " res = render_diff(_file_srcs(old_file), _file_srcs(new_file), maxlen=maxlen, color=color)\n", + " res = render_diff(_file_cells(old_file), _file_cells(new_file), maxlen=maxlen, color=color, context=context, show_out=show_out)\n", " if not res: return\n", " print(_dline(f'# {path}', 0, color))\n", " print(res, end='\\n\\n')" @@ -690,19 +1110,27 @@ "output_type": "stream", "text": [ "# test.ipynb\n", - "## modified 390c8c7d:\n", + "## modified 390c8c7d [export]:\n", "@@ -1 +1 @@\n", - "-x=1\n", - "+x = 100\n", + "!x{+ +}=[-1-]{+ 100+}\n", "\n", "## modified 7247342c:\n", "@@ -1 +1 @@\n", "-y=2\n", - "+y = 2 # note 0, note 1, note 2, note 3, note 4, note 5, note 6, note 7, note 8, note 9, note 10, note 11, note 12, not…\n", + "+y = 2 # note 0, note 1, note 2, note 3, note 4, note 5, note 6, note 7, note 8, note 9, note 10, note 11, note 12, note 13, note 14, note 15, note 16, note 17, note 18, not…[268]\n", "\n", - "## added d8100f2f:\n", + "## d8100f2f:\n", + " a=3\n", + "\n", + "## … 1 cells\n", + "\n", + "## d670e58e:\n", + " c=5\n", + "\n", + "## added 0351d8ae [eval=false]:\n", "@@ -0,0 +1 @@\n", - "+z=3\n", + "+x+1\n", + "| 101\n", "\n" ] } @@ -718,7 +1146,7 @@ "id": "5a53d84c", "metadata": {}, "source": [ - "When cell sources are unchanged (e.g. only outputs or metadata differ), the driver prints nothing at all, not even the path header:" + "When sources and directives are unchanged, the driver prints nothing, including the path header. Stored outputs and other metadata do not count as changes:" ] }, { @@ -733,14 +1161,6 @@ "test_eq(s.getvalue(), '')" ] }, - { - "cell_type": "markdown", - "id": "5c71e549", - "metadata": {}, - "source": [ - "`nbdev-diff` is the standalone version, like a cell-level `git diff` for notebooks: it shows changes between two refs (or a ref and the working directory) for one notebook, a directory, or the whole project." - ] - }, { "cell_type": "code", "execution_count": null, @@ -755,15 +1175,25 @@ " ref_a:str='HEAD', # First git ref\n", " ref_b:str=None, # Second git ref (default: working directory)\n", " maxlen:int=MAXLEN, # Truncate diff lines to this width (0 for no limit)\n", - " color:bool_arg=None # Add ANSI colors? (default: only if stdout is a tty)\n", + " color:bool_arg=None, # Add ANSI colors? (default: only if stdout is a tty)\n", + " context:int=1, # Unchanged cells to show either side of each change\n", + " show_out:bool=True # Show the stored outputs of changed and added cells?\n", "):\n", " \"Cell-level diffs for changed notebooks between two git refs\"\n", " if color is None: color = sys.stdout.isatty()\n", " for p in sorted(nbglob(path)):\n", - " res = nb_diff(p, ref_a, ref_b, maxlen=maxlen, color=color)\n", + " res = nb_diff(p, ref_a, ref_b, maxlen=maxlen, color=color, context=context, show_out=show_out)\n", " if res: print(_dline(f'# {p}', 0, color), res, sep='\\n', end='\\n\\n')" ] }, + { + "cell_type": "markdown", + "id": "5c71e549", + "metadata": {}, + "source": [ + "`nbdev-diff` is the standalone version, like a cell-level `git diff` for notebooks: it shows changes between two refs (or a ref and the working directory) for one notebook, a directory, or the whole project. Run from the demo repo, it renders the same pending changes, headed by the notebook path:\n" + ] + }, { "cell_type": "code", "execution_count": null, @@ -775,19 +1205,27 @@ "output_type": "stream", "text": [ "# test.ipynb\n", - "## modified 390c8c7d:\n", + "## modified 390c8c7d [export]:\n", "@@ -1 +1 @@\n", - "-x=1\n", - "+x = 100\n", + "!x{+ +}=[-1-]{+ 100+}\n", "\n", "## modified 7247342c:\n", "@@ -1 +1 @@\n", "-y=2\n", - "+y = 2 # note 0, note 1, note 2, note 3, note 4, note 5, note 6, note 7, note 8, note 9, note 10, note 11, note 12, not…\n", + "+y = 2 # note 0, note 1, note 2, note 3, note 4, note 5, note 6, note 7, note 8, note 9, note 10, note 11, note 12, note 13, note 14, note 15, note 16, note 17, note 18, not…[268]\n", + "\n", + "## d8100f2f:\n", + " a=3\n", + "\n", + "## … 1 cells\n", + "\n", + "## d670e58e:\n", + " c=5\n", "\n", - "## added d8100f2f:\n", + "## added 0351d8ae [eval=false]:\n", "@@ -0,0 +1 @@\n", - "+z=3\n", + "+x+1\n", + "| 101\n", "\n" ] } @@ -811,7 +1249,7 @@ "g('config', 'diff.jupyternotebook.command', f'\"{sys.executable}\" \"{drv}\"')\n", "(td/'.gitattributes').write_text('*.ipynb diff=jupyternotebook\\n')\n", "out = g.diff()\n", - "assert '## modified 390c8c7d:' in out and '## added' in out" + "assert f'## modified {ids[0]} [export]:' in out and '## added' in out" ] }, { @@ -834,7 +1272,7 @@ "metadata": {}, "outputs": [], "source": [ - "shutil.rmtree(td)" + "tmp.cleanup()" ] }, { diff --git a/pyproject.toml b/pyproject.toml index c8f2ad31e..dd4494278 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", ] dynamic = ["version"] -dependencies = [ "fastcore>=2.2.7", "execnb>=0.3.3", "astunparse", "ghapi>=2.0.2", "watchdog", "asttokens", +dependencies = [ "fastcore>=2.2.23", "execnb>=0.3.3", "astunparse", "ghapi>=2.0.2", "watchdog", "asttokens", "setuptools", "build", "fastgit>=0.0.7", "pyyaml", "tomli; python_version < '3.11'", ] [project.optional-dependencies]