nbdev 3.3.27__tar.gz → 3.3.28__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 (44) hide show
  1. {nbdev-3.3.27/nbdev.egg-info → nbdev-3.3.28}/PKG-INFO +2 -1
  2. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/__init__.py +1 -1
  3. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/_modidx.py +1 -8
  4. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/diff.py +40 -98
  5. nbdev-3.3.28/nbdev/diff_core.py +138 -0
  6. {nbdev-3.3.27 → nbdev-3.3.28/nbdev.egg-info}/PKG-INFO +2 -1
  7. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev.egg-info/SOURCES.txt +2 -0
  8. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev.egg-info/requires.txt +1 -0
  9. {nbdev-3.3.27 → nbdev-3.3.28}/pyproject.toml +1 -1
  10. nbdev-3.3.28/tests/test_diff.py +78 -0
  11. {nbdev-3.3.27 → nbdev-3.3.28}/CONTRIBUTING.md +0 -0
  12. {nbdev-3.3.27 → nbdev-3.3.28}/LICENSE +0 -0
  13. {nbdev-3.3.27 → nbdev-3.3.28}/README.md +0 -0
  14. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/clean.py +0 -0
  15. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/cli.py +0 -0
  16. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/config.py +0 -0
  17. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/doclinks.py +0 -0
  18. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/export.py +0 -0
  19. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/extract_attachments.py +0 -0
  20. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/frontmatter.py +0 -0
  21. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/imports.py +0 -0
  22. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/maker.py +0 -0
  23. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/merge.py +0 -0
  24. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/migrate.py +0 -0
  25. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/moddocs.py +0 -0
  26. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/process.py +0 -0
  27. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/processors.py +0 -0
  28. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/qmd.py +0 -0
  29. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/quarto.py +0 -0
  30. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/quarto_render.py +0 -0
  31. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/readme_filter.py +0 -0
  32. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/release.py +0 -0
  33. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/scrubmagics.py +0 -0
  34. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/serve.py +0 -0
  35. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/serve_drv.py +0 -0
  36. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/showdoc.py +0 -0
  37. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/skill.py +0 -0
  38. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/sync.py +0 -0
  39. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev/test.py +0 -0
  40. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev.egg-info/dependency_links.txt +0 -0
  41. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev.egg-info/entry_points.txt +0 -0
  42. {nbdev-3.3.27 → nbdev-3.3.28}/nbdev.egg-info/top_level.txt +0 -0
  43. {nbdev-3.3.27 → nbdev-3.3.28}/setup.cfg +0 -0
  44. {nbdev-3.3.27 → nbdev-3.3.28}/tests/test_quarto_render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdev
3
- Version: 3.3.27
3
+ Version: 3.3.28
4
4
  Summary: Create delightful software with Jupyter Notebooks
5
5
  Author-email: "Jeremy Howard and the fast.ai community" <j@fast.ai>
6
6
  License-Expression: Apache-2.0
@@ -40,6 +40,7 @@ Requires-Dist: sphinx; extra == "dev"
40
40
  Requires-Dist: plum-dispatch; extra == "dev"
41
41
  Requires-Dist: nbclient; extra == "dev"
42
42
  Requires-Dist: ipykernel; extra == "dev"
43
+ Requires-Dist: pytest; extra == "dev"
43
44
  Dynamic: license-file
44
45
 
45
46
  # Getting Started
@@ -6,7 +6,7 @@ Modules:
6
6
  - `nbdev.moddocs`: # Module docs: creating them from existing notebooks
7
7
  - `nbdev.skill`: Write, read and review nbdev notebooks as source, documentation, examples and tests. You MUST read this before any work on a project with notebooks."""
8
8
 
9
- __version__ = "3.3.27"
9
+ __version__ = "3.3.28"
10
10
 
11
11
  from .doclinks import nbdev_export
12
12
  from .showdoc import show_doc
@@ -81,18 +81,12 @@ d = { 'settings': { 'branch': 'main',
81
81
  'nbdev.config.write_cells': ('api/config.html#write_cells', 'nbdev/config.py')},
82
82
  'nbdev.diff': { 'nbdev.diff._cell_changes': ('api/diff.html#_cell_changes', 'nbdev/diff.py'),
83
83
  'nbdev.diff._diff_order': ('api/diff.html#_diff_order', 'nbdev/diff.py'),
84
- 'nbdev.diff._dline': ('api/diff.html#_dline', 'nbdev/diff.py'),
85
84
  'nbdev.diff._dtag': ('api/diff.html#_dtag', 'nbdev/diff.py'),
86
85
  'nbdev.diff._file_cells': ('api/diff.html#_file_cells', 'nbdev/diff.py'),
87
86
  'nbdev.diff._nb_srcdict': ('api/diff.html#_nb_srcdict', 'nbdev/diff.py'),
88
87
  'nbdev.diff._out_lines': ('api/diff.html#_out_lines', 'nbdev/diff.py'),
89
- 'nbdev.diff._refine': ('api/diff.html#_refine', 'nbdev/diff.py'),
90
88
  'nbdev.diff._src': ('api/diff.html#_src', 'nbdev/diff.py'),
91
89
  'nbdev.diff._srcdict': ('api/diff.html#_srcdict', 'nbdev/diff.py'),
92
- 'nbdev.diff._tokens': ('api/diff.html#_tokens', 'nbdev/diff.py'),
93
- 'nbdev.diff._trunc': ('api/diff.html#_trunc', 'nbdev/diff.py'),
94
- 'nbdev.diff._wline': ('api/diff.html#_wline', 'nbdev/diff.py'),
95
- 'nbdev.diff._word_diff': ('api/diff.html#_word_diff', 'nbdev/diff.py'),
96
90
  'nbdev.diff.cell_diffs': ('api/diff.html#cell_diffs', 'nbdev/diff.py'),
97
91
  'nbdev.diff.changed_cells': ('api/diff.html#changed_cells', 'nbdev/diff.py'),
98
92
  'nbdev.diff.nb_diff': ('api/diff.html#nb_diff', 'nbdev/diff.py'),
@@ -100,8 +94,7 @@ d = { 'settings': { 'branch': 'main',
100
94
  'nbdev.diff.nbdev_diff_driver': ('api/diff.html#nbdev_diff_driver', 'nbdev/diff.py'),
101
95
  'nbdev.diff.nbs_pair': ('api/diff.html#nbs_pair', 'nbdev/diff.py'),
102
96
  'nbdev.diff.read_nb_from_git': ('api/diff.html#read_nb_from_git', 'nbdev/diff.py'),
103
- 'nbdev.diff.render_diff': ('api/diff.html#render_diff', 'nbdev/diff.py'),
104
- 'nbdev.diff.source_diff': ('api/diff.html#source_diff', 'nbdev/diff.py')},
97
+ 'nbdev.diff.render_diff': ('api/diff.html#render_diff', 'nbdev/diff.py')},
105
98
  'nbdev.doclinks': { 'nbdev.doclinks.NbdevLookup': ('api/doclinks.html#nbdevlookup', 'nbdev/doclinks.py'),
106
99
  'nbdev.doclinks.NbdevLookup.__getitem__': ( 'api/doclinks.html#nbdevlookup.__getitem__',
107
100
  'nbdev/doclinks.py'),
@@ -5,21 +5,21 @@ Docs: https://nbdev.fast.ai/api/diff.html.md"""
5
5
  # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/19_diff.ipynb.
6
6
 
7
7
  # %% auto #0
8
- __all__ = ['MAXLEN', 'OUT_MAXLEN', 'MIN_RATIO', 'read_nb_from_git', 'nbs_pair', 'changed_cells', 'source_diff', 'cell_diffs',
9
- 'render_diff', 'nb_diff', 'nbdev_diff_driver', 'nbdev_diff']
8
+ __all__ = ['OUT_MAXLEN', 'read_nb_from_git', 'nbs_pair', 'changed_cells', 'cell_diffs', 'render_diff', 'nb_diff',
9
+ 'nbdev_diff_driver', 'nbdev_diff']
10
10
 
11
11
  # %% ../nbs/api/19_diff.ipynb #10c8ad0b
12
12
  import json
13
13
  from fastcore.utils import *
14
14
  from fastcore.meta import delegates
15
15
  from fastcore.script import *
16
- from difflib import unified_diff, SequenceMatcher
17
16
  from subprocess import CalledProcessError
18
17
  from typing import Annotated
19
18
  from fastgit import Git
20
19
  from fastcore.nbio import *
21
20
  from fastcore.nbio import _directive
22
21
  from .doclinks import nbglob
22
+ from .diff_core import *
23
23
 
24
24
  # %% ../nbs/api/19_diff.ipynb #a8981115
25
25
  def read_nb_from_git(
@@ -99,94 +99,16 @@ def changed_cells(nb_path, **kwargs):
99
99
  def f(cid,o,n): return cid
100
100
  return set(_cell_changes(nb_path, f, **kwargs).keys())
101
101
 
102
- # %% ../nbs/api/19_diff.ipynb #079eedb0
103
- def source_diff(
104
- old_source, # Original source string
105
- new_source # New source string
106
- ): # Unified diff string
107
- "Return unified diff string for source change"
108
- return '\n'.join(unified_diff(old_source.splitlines(), new_source.splitlines(), lineterm=''))
109
-
110
102
  # %% ../nbs/api/19_diff.ipynb #38216afa
111
103
  @delegates(_cell_changes)
112
104
  def cell_diffs(nb_path, **kwargs):
113
105
  "{cell_id:diff} for changed/added/deleted cells between two refs"
114
- def f(cid,o,n): return source_diff(o,n)
106
+ def f(cid,o,n): return source_diff(o,n,final_newline=False)
115
107
  return _cell_changes(nb_path, f, **kwargs)
116
108
 
117
109
  # %% ../nbs/api/19_diff.ipynb #d670e58e
118
- MAXLEN = 180 # Most characters shown per displayed line
119
110
  OUT_MAXLEN = 512 # Most characters shown of a cell's stored output
120
- MIN_RATIO = 0.5 # Least similar (by shared words) a changed line pair can be and still merge into one word-diff line
121
- _dcolors = {'## …':'\x1b[34m', '# ':'\x1b[1;7m', '#':'\x1b[1m', '-':'\x1b[31m', '+':'\x1b[32m', '@':'\x1b[36m'}
122
-
123
- def _trunc(s, maxlen):
124
- "`s` cut to `maxlen`, ending `…[n]` with `n` the number of characters missing"
125
- if not maxlen or len(s)<=maxlen: return s
126
- return truncstr(s, maxlen, suf=lambda n: f'…[{humanize(n)}]')
127
-
128
- def _dline(l, maxlen=MAXLEN, color=False):
129
- "Truncate `l` with `_trunc`, optionally colored by its leading diff marker; word-diff spans render via `_wline`"
130
- if not isinstance(l, str): return _wline(l, maxlen, color)
131
- l = _trunc(l, maxlen)
132
- c = first(v for k,v in _dcolors.items() if l.startswith(k)) if color else None
133
- return f'{c}{l}\x1b[0m' if c else l
134
111
 
135
- # %% ../nbs/api/19_diff.ipynb #7a1d7b28
136
- _wcolors = {'-':'\x1b[31;9m', '+':'\x1b[32m'}
137
- _wmarks = {'-':('[-','-]'), '+':('{+','+}')}
138
-
139
- def _tokens(s): return re.findall(r'\w+|\s+|[^\w\s]', s)
140
-
141
- def _word_diff(a, b, maxlen=MAXLEN):
142
- "`(kind, text)` spans merging lines `a` and `b` (' ' shared, '-' only in `a`, '+' only in `b`); None when too different, or longer than `maxlen`"
143
- if maxlen and max(len(a),len(b))>maxlen: return None
144
- if SequenceMatcher(None, re.findall(r'\w+', a), re.findall(r'\w+', b), autojunk=False).ratio()<MIN_RATIO: return None
145
- ta,tb = _tokens(a),_tokens(b)
146
- res = []
147
- for tag,i1,i2,j1,j2 in SequenceMatcher(None, ta, tb, autojunk=False).get_opcodes():
148
- if tag=='equal': res.append((' ', ''.join(ta[i1:i2])))
149
- else:
150
- if i2>i1: res.append(('-', ''.join(ta[i1:i2])))
151
- if j2>j1: res.append(('+', ''.join(tb[j1:j2])))
152
- return res
153
-
154
- def _wline(spans, maxlen=MAXLEN, color=False):
155
- "One `!` line from word-diff `spans`: deletions red and struck out, additions green, or `[-…-]`/`{+…+}` markers without `color`"
156
- if not color: return _dline('!'+''.join(t if k==' ' else _wmarks[k][0]+t+_wmarks[k][1] for k,t in spans), maxlen)
157
- full = '!'+''.join(t for _,t in spans)
158
- cut = _trunc(full, maxlen)
159
- suf = cut[cut.rfind('…['):] if cut!=full else ''
160
- res,room = '!',len(cut)-len(suf)-1
161
- for k,t in spans:
162
- t = t[:room]
163
- room -= len(t)
164
- res += t if k==' ' else f'{_wcolors[k]}{t}\x1b[0m'
165
- if not room: break
166
- return res+suf
167
-
168
- # %% ../nbs/api/19_diff.ipynb #2bf82340
169
- def _refine(lines, maxlen=MAXLEN):
170
- "Diff `lines` with each positionally paired `-`/`+` line merged into word-diff spans where `_word_diff` allows"
171
- res,i = [],0
172
- while i<len(lines):
173
- if not lines[i].startswith('-'):
174
- res.append(lines[i])
175
- i += 1
176
- continue
177
- j = i
178
- while j<len(lines) and lines[j].startswith('-'): j += 1
179
- k = j
180
- while k<len(lines) and lines[k].startswith('+'): k += 1
181
- olds,news = lines[i:j],lines[j:k]
182
- bo,bn = [],[]
183
- for o,n in zip(olds,news):
184
- w = _word_diff(o[1:], n[1:], maxlen)
185
- if w: res,bo,bn = res+bo+bn+[w],[],[]
186
- else: bo,bn = bo+[o],bn+[n]
187
- res += bo+olds[len(news):]+bn+news[len(olds):]
188
- i = k
189
- return res
190
112
 
191
113
  # %% ../nbs/api/19_diff.ipynb #342fc231
192
114
  def _diff_order(old, new):
@@ -201,7 +123,7 @@ def _diff_order(old, new):
201
123
  # %% ../nbs/api/19_diff.ipynb #eb3fc128
202
124
  def _out_lines(c):
203
125
  "Stored outputs of cell `c` rendered as text, capped at `OUT_MAXLEN` chars, each line prefixed `| `"
204
- r = _trunc(render_text(c.get('outputs', [])), OUT_MAXLEN)
126
+ r = truncate(render_text(c.get('outputs', [])), OUT_MAXLEN)
205
127
  return [f'| {l}' for l in r.splitlines()]
206
128
 
207
129
  def render_diff(
@@ -210,6 +132,7 @@ def render_diff(
210
132
  maxlen:int=MAXLEN, # Truncate diff lines to this width (falsy: no limit)
211
133
  color:bool=False, # Add ANSI colors?
212
134
  context:int=1, # Unchanged cells to show either side of each change
135
+ line_context:int=3, # Unchanged source lines to show either side of each change
213
136
  show_out:bool=True # Show the stored outputs of changed and added cells?
214
137
  ): # One section per shown cell, in notebook order
215
138
  "Render cell-level changes between two notebooks as truncated unified diffs, with context cells and outputs"
@@ -220,16 +143,19 @@ def render_diff(
220
143
  res,prev = [],-1
221
144
  for i,cid in enumerate(order):
222
145
  if i not in show: continue
223
- if 0<=prev<i-1: res.append(_dline(f'## … {i-prev-1} cells', maxlen, color))
146
+ if 0<=prev<i-1: res.append(render_line(f'## … {i-prev-1} cells', maxlen, color))
224
147
  prev = i
225
148
  c = new.get(cid) or old[cid]
226
- if not _changed(cid): lines = [f'## {cid}{_dtag(c)}:'] + [' '+l for l in _src(c).splitlines()]
149
+ if not _changed(cid):
150
+ lines = [render_line(l, maxlen, color) for l in [f'## {cid}{_dtag(c)}:'] + [' '+l for l in _src(c).splitlines()]]
227
151
  else:
228
152
  o,n = (_src(old[cid]) if cid in old else ''), (_src(new[cid]) if cid in new else '')
229
153
  kind = 'modified' if cid in old and cid in new else 'added' if cid in new else 'deleted'
230
- lines = [f'## {kind} {cid}{_dtag(c)}:'] + _refine(source_diff(o,n).splitlines()[2:], maxlen)
231
- if show_out and cid in new: lines += _out_lines(new[cid])
232
- res.append('\n'.join(_dline(l, maxlen, color) for l in lines))
154
+ lines = [render_line(f'## {kind} {cid}{_dtag(c)}:', maxlen, color)]
155
+ source = render_source_diff(o, n, maxlen=maxlen, color=color, context=line_context, final_newline=False)
156
+ if source: lines.append(source)
157
+ if show_out and cid in new: lines += [render_line(l, maxlen, color) for l in _out_lines(new[cid])]
158
+ res.append('\n'.join(lines))
233
159
  return '\n\n'.join(res)
234
160
 
235
161
  # %% ../nbs/api/19_diff.ipynb #7f90d675
@@ -240,11 +166,12 @@ def nb_diff(
240
166
  maxlen:int=MAXLEN, # Truncate diff lines to this width (falsy: no limit)
241
167
  color:bool=False, # Add ANSI colors?
242
168
  context:int=1, # Unchanged cells to show either side of each change
169
+ line_context:int=3, # Unchanged source lines to show either side of each change
243
170
  show_out:bool=True # Show the stored outputs of changed and added cells?
244
171
  ): # Rendered diff of changed cells
245
172
  "Rendered cell diff for `nb_path` between two refs"
246
173
  a,b = nbs_pair(nb_path, ref_a, ref_b)
247
- return render_diff(a, b, maxlen=maxlen, color=color, context=context, show_out=show_out)
174
+ return render_diff(a, b, maxlen=maxlen, color=color, context=context, line_context=line_context, show_out=show_out)
248
175
 
249
176
  # %% ../nbs/api/19_diff.ipynb #ab9f0b26
250
177
  def _file_cells(path):
@@ -257,7 +184,7 @@ _pos = Annotated[str, {'opt':False, 'nargs':'?'}] # optional positional CLI arg
257
184
 
258
185
  @call_parse
259
186
  def nbdev_diff_driver(
260
- path:str, # Repo path of the notebook being diffed
187
+ path:str, # Repo path of the file being diffed
261
188
  old_file:_pos=None, # Pre-change file (git temp file, or /dev/null)
262
189
  old_hex:_pos=None, # Pre-change blob hash
263
190
  old_mode:_pos=None, # Pre-change file mode
@@ -267,16 +194,30 @@ def nbdev_diff_driver(
267
194
  rename_to:_pos=None, # New repo path, when git detected a rename
268
195
  similarity:_pos=None, # Similarity score, when git detected a rename
269
196
  maxlen:int=MAXLEN, # Truncate diff lines to this width (0 for no limit)
270
- context:int=1, # Unchanged cells to show either side of each change
197
+ context:int=1, # Unchanged notebook cells to show either side of each change
198
+ line_context:int=3, # Unchanged source lines to show either side of each change
271
199
  show_out:bool=True # Show the stored outputs of changed and added cells?
272
200
  ):
273
- "Git external diff driver for notebooks; installed by `nbdev-install-hooks`"
201
+ "Git external diff driver for notebooks and plain text files"
274
202
  if not new_file: return print(f'unmerged: {path}')
275
203
  color = sys.stdout.isatty() or os.environ.get('GIT_PAGER_IN_USE')=='true'
276
- res = render_diff(_file_cells(old_file), _file_cells(new_file), maxlen=maxlen, color=color, context=context, show_out=show_out)
277
- if not res: return
278
- print(_dline(f'# {path}', 0, color))
279
- print(res, end='\n\n')
204
+ if Path(path).suffix=='.ipynb':
205
+ res = render_diff(_file_cells(old_file), _file_cells(new_file), maxlen=maxlen, color=color,
206
+ context=context, line_context=line_context, show_out=show_out)
207
+ else:
208
+ old,new = Path(old_file).read_bytes(),Path(new_file).read_bytes()
209
+ res = 'Binary files differ' if old!=new else ''
210
+ if b'\0' not in old and b'\0' not in new:
211
+ try: res = render_source_diff(old.decode('utf-8'), new.decode('utf-8'), maxlen=maxlen, color=color, context=line_context)
212
+ except UnicodeDecodeError: pass
213
+ meta = []
214
+ if rename_to: meta.append(f'rename to {rename_to}')
215
+ if old_mode and new_mode and old_mode!=new_mode: meta.append(f'mode {old_mode} -> {new_mode}')
216
+ if not res and not meta: return
217
+ print(render_line(f'# {path}', 0, color))
218
+ for line in meta: print(render_line('# '+line, 0, color))
219
+ if res: print(res)
220
+ print()
280
221
 
281
222
  # %% ../nbs/api/19_diff.ipynb #2604c719
282
223
  @call_parse
@@ -287,10 +228,11 @@ def nbdev_diff(
287
228
  maxlen:int=MAXLEN, # Truncate diff lines to this width (0 for no limit)
288
229
  color:bool_arg=None, # Add ANSI colors? (default: only if stdout is a tty)
289
230
  context:int=1, # Unchanged cells to show either side of each change
231
+ line_context:int=3, # Unchanged source lines to show either side of each change
290
232
  show_out:bool=True # Show the stored outputs of changed and added cells?
291
233
  ):
292
234
  "Cell-level diffs for changed notebooks between two git refs"
293
235
  if color is None: color = sys.stdout.isatty()
294
236
  for p in sorted(nbglob(path)):
295
- res = nb_diff(p, ref_a, ref_b, maxlen=maxlen, color=color, context=context, show_out=show_out)
296
- if res: print(_dline(f'# {p}', 0, color), res, sep='\n', end='\n\n')
237
+ res = nb_diff(p, ref_a, ref_b, maxlen=maxlen, color=color, context=context, line_context=line_context, show_out=show_out)
238
+ if res: print(render_line(f'# {p}', 0, color), res, sep='\n', end='\n\n')
@@ -0,0 +1,138 @@
1
+ "Word-level terminal diffs of plain text, shared by file and notebook rendering."
2
+
3
+ from difflib import unified_diff, SequenceMatcher
4
+ from fastcore.utils import *
5
+
6
+ __all__ = ['MAXLEN', 'MIN_RATIO', 'truncate', 'render_line', 'source_diff', 'render_source_diff']
7
+
8
+ MAXLEN = 180 # Most visible characters per displayed line; 0 means unlimited
9
+ MIN_RATIO = 0.5 # Minimum shared-word similarity for merging a changed line pair
10
+ _dcolors = {'## …':'\x1b[34m', '# ':'\x1b[1;7m', '#':'\x1b[1m', '-':'\x1b[31m', '+':'\x1b[32m', '@':'\x1b[36m'}
11
+ _wcolors = {'-':'\x1b[31;9m', '+':'\x1b[32m'}
12
+ _wmarks = {'-':('[-','-]'), '+':('{+','+}')}
13
+
14
+
15
+ def truncate(s, maxlen):
16
+ "Cut `s` to `maxlen`, marking the number of omitted characters; 0 means unlimited."
17
+ if not maxlen or len(s)<=maxlen: return s
18
+ return truncstr(s, maxlen, suf=lambda n: f'…[{humanize(n)}]')[:maxlen]
19
+
20
+
21
+ def render_line(
22
+ line, # A display line, including its diff or heading marker
23
+ maxlen=MAXLEN, # Maximum visible length; 0 means unlimited
24
+ color=False # Add ANSI colors?
25
+ ):
26
+ "Truncate and optionally color a line by its leading display marker."
27
+ line = truncate(line, maxlen)
28
+ c = next((v for k,v in _dcolors.items() if line.startswith(k)), '') if color else ''
29
+ return f'{c}{line}\x1b[0m' if c else line
30
+
31
+
32
+ def _source_lines(old, new, context, final_newline):
33
+ def lines(s): return s.splitlines(keepends=True) if final_newline else [l+'\n' for l in s.splitlines()]
34
+ return list(unified_diff(lines(old), lines(new), n=context))
35
+
36
+
37
+ def source_diff(
38
+ old_source, # Original source string
39
+ new_source, # New source string
40
+ context=3, # Unchanged lines shown either side of a change
41
+ final_newline=True # Distinguish a missing final newline (unnecessary for notebook cells)?
42
+ ):
43
+ "Unified source diff, including missing-final-newline markers."
44
+ lines = _source_lines(old_source, new_source, context, final_newline)
45
+ return '\n'.join(l.removesuffix('\n') + ('' if l.endswith('\n') else '\n\') for l in lines)
46
+
47
+
48
+ def _word_diff(a, b):
49
+ if a==b: return None
50
+ if SequenceMatcher(None, re.findall(r'\w+', a), re.findall(r'\w+', b), autojunk=False).ratio()<MIN_RATIO: return None
51
+ ta,tb = (re.findall(r'\w+|\s+|[^\w\s]', s) for s in (a,b))
52
+ res = []
53
+ for tag,i1,i2,j1,j2 in SequenceMatcher(None, ta, tb, autojunk=False).get_opcodes():
54
+ if tag=='equal': res.append((' ', ''.join(ta[i1:i2])))
55
+ else:
56
+ if i2>i1: res.append(('-', ''.join(ta[i1:i2])))
57
+ if j2>j1: res.append(('+', ''.join(tb[j1:j2])))
58
+ return res
59
+
60
+
61
+ def _refine(lines):
62
+ res,i = [],0
63
+ while i<len(lines):
64
+ if not lines[i].startswith('-'):
65
+ res.append(lines[i])
66
+ i += 1
67
+ continue
68
+ j = i
69
+ while j<len(lines) and lines[j].startswith('-'): j += 1
70
+ k = j
71
+ while k<len(lines) and lines[k].startswith('+'): k += 1
72
+ olds,news = lines[i:j],lines[j:k]
73
+ bo,bn = [],[]
74
+ for o,n in zip(olds,news):
75
+ w = _word_diff(o[1:], n[1:])
76
+ if w: res,bo,bn = res+bo+bn+[w],[],[]
77
+ else: bo,bn = bo+[o],bn+[n]
78
+ res += bo+olds[len(news):]+bn+news[len(olds):]
79
+ i = k
80
+ return res
81
+
82
+
83
+ def _slice_spans(spans, start, end):
84
+ res,pos = [],0
85
+ for k,t in spans:
86
+ a,b = max(0,start-pos), min(len(t),end-pos)
87
+ if a<b: res.append((k,t[a:b]))
88
+ pos += len(t)
89
+ if pos>=end: break
90
+ return res
91
+
92
+
93
+ def _span_width(spans, color): return sum(len(t)+(4 if k!=' ' and not color else 0) for k,t in spans)
94
+
95
+
96
+ def _render_spans(spans, maxlen, color):
97
+ total = sum(len(t) for _,t in spans)
98
+ start,end = 0,total
99
+ if maxlen and _span_width(spans, color)+1>maxlen:
100
+ first = int(spans[0][0]==' ')
101
+ prefix = len(spans[0][1]) if first else 0
102
+ last = first
103
+ while last<len(spans) and spans[last][0]!=' ': last += 1
104
+ room = maxlen-1-_span_width(spans[first:last], color)-2*len(f'…[{total}]')
105
+ start = max(0,prefix-min(20,maxlen//4,max(0,room)))
106
+ end = min(total,start+maxlen-1)
107
+ while True:
108
+ shown = _slice_spans(spans, start, end)
109
+ head,tail = (f'…[{start}]' if start else ''), (f'…[{total-end}]' if end<total else '')
110
+ excess = 1+len(head)+_span_width(shown, color)+len(tail)-maxlen
111
+ if excess<=0: break
112
+ if end==start: return truncate('!'+head+tail, maxlen)
113
+ end = max(start,end-excess)
114
+ else: shown,head,tail = spans,'',''
115
+ def fmt(k,t):
116
+ if k==' ': return t
117
+ if color: return f'{_wcolors[k]}{t}\x1b[0m'
118
+ return _wmarks[k][0]+t+_wmarks[k][1]
119
+ return '!'+head+''.join(fmt(k,t) for k,t in shown)+tail
120
+
121
+
122
+ def render_source_diff(
123
+ old_source, # Original source string
124
+ new_source, # New source string
125
+ maxlen=MAXLEN, # Maximum visible length; 0 means unlimited
126
+ color=False, # Add ANSI colors instead of [-removed-]/{+added+} markers?
127
+ context=3, # Unchanged lines shown either side of a change
128
+ final_newline=True # Distinguish a missing final newline (unnecessary for notebook cells)?
129
+ ):
130
+ "Render source changes, cropping long word-diff lines around the first change."
131
+ lines = _source_lines(old_source, new_source, context, final_newline)
132
+ if not lines: return ''
133
+ body = [l.removesuffix('\n') for l in lines[2:]]
134
+ res = [render_line(l, maxlen, color) if isinstance(l,str) else _render_spans(l, maxlen, color) for l in _refine(body)]
135
+ if final_newline:
136
+ for name,text in (('old',old_source), ('new',new_source)):
137
+ if text and not text.endswith('\n'): res.append(render_line(fr'\ No newline at end of {name} file', maxlen, color))
138
+ return '\n'.join(res)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nbdev
3
- Version: 3.3.27
3
+ Version: 3.3.28
4
4
  Summary: Create delightful software with Jupyter Notebooks
5
5
  Author-email: "Jeremy Howard and the fast.ai community" <j@fast.ai>
6
6
  License-Expression: Apache-2.0
@@ -40,6 +40,7 @@ Requires-Dist: sphinx; extra == "dev"
40
40
  Requires-Dist: plum-dispatch; extra == "dev"
41
41
  Requires-Dist: nbclient; extra == "dev"
42
42
  Requires-Dist: ipykernel; extra == "dev"
43
+ Requires-Dist: pytest; extra == "dev"
43
44
  Dynamic: license-file
44
45
 
45
46
  # Getting Started
@@ -8,6 +8,7 @@ nbdev/clean.py
8
8
  nbdev/cli.py
9
9
  nbdev/config.py
10
10
  nbdev/diff.py
11
+ nbdev/diff_core.py
11
12
  nbdev/doclinks.py
12
13
  nbdev/export.py
13
14
  nbdev/extract_attachments.py
@@ -37,4 +38,5 @@ nbdev.egg-info/dependency_links.txt
37
38
  nbdev.egg-info/entry_points.txt
38
39
  nbdev.egg-info/requires.txt
39
40
  nbdev.egg-info/top_level.txt
41
+ tests/test_diff.py
40
42
  tests/test_quarto_render.py
@@ -26,3 +26,4 @@ sphinx
26
26
  plum-dispatch
27
27
  nbclient
28
28
  ipykernel
29
+ pytest
@@ -22,7 +22,7 @@ dependencies = [ "fastcore>=2.2.28", "execnb>=0.3.3", "astunparse", "ghapi>=2.0.
22
22
  "setuptools", "build", "fastgit>=0.0.7", "pyyaml", "tomli; python_version < '3.11'", ]
23
23
 
24
24
  [project.optional-dependencies]
25
- dev = [ "ipywidgets", "nbdev-numpy", "nbdev-stdlib", "pandas", "matplotlib", "black", "svg.py", "pysym2md>=0.0.6", "llms-txt", "sphinx", "plum-dispatch", "nbclient", "ipykernel", ]
25
+ dev = [ "ipywidgets", "nbdev-numpy", "nbdev-stdlib", "pandas", "matplotlib", "black", "svg.py", "pysym2md>=0.0.6", "llms-txt", "sphinx", "plum-dispatch", "nbclient", "ipykernel", "pytest", ]
26
26
 
27
27
  [project.urls]
28
28
  Repository = "https://github.com/AnswerDotAI/nbdev"
@@ -0,0 +1,78 @@
1
+ import os, re, shlex, subprocess, sys
2
+ from fastcore.nbio import new_nb, write_nb
3
+ from nbdev.diff_core import render_source_diff, source_diff
4
+
5
+
6
+ def test_source_changes():
7
+ old = 'x=1\ny=2\nz=3\nk=4\n'
8
+ new = 'x = 100\ny=2\nw=5\nq=6\n'
9
+ result = render_source_diff(old, new)
10
+ assert '!x{+ +}=[-1-]{+ 100+}' in result
11
+ assert '-z=3\n-k=4\n+w=5\n+q=6' in result
12
+ assert '+hello' in render_source_diff('', 'hello\n')
13
+ assert '-hello' in render_source_diff('hello\n', '')
14
+ assert not render_source_diff(old, old)
15
+
16
+
17
+ def test_long_line_changes():
18
+ prefix = ' '.join(f'word{i}' for i in range(80))+' '
19
+ tail = ' '.join(f'tail{i}' for i in range(80))
20
+ old, new = prefix+'green '+tail+' old\n', prefix+'blue '+tail+' new\n'
21
+ for color in (False, True):
22
+ result = render_source_diff(old, new, maxlen=80, color=color)
23
+ visible = re.sub(r'\x1b\[[\d;]*m', '', result)
24
+ assert all(len(line)<=80 for line in visible.splitlines())
25
+ assert 'green' in result and 'blue' in result and 'word0 ' not in result
26
+ assert '…[' in result and ' new' not in result
27
+ if color: assert '\x1b[31;9mgreen\x1b[0m' in result and '\x1b[32mblue\x1b[0m' in result
28
+ else: assert '[-green-]{+blue+}' in result
29
+ full = render_source_diff(old, new, maxlen=0)
30
+ assert 'word0 ' in full and '[-old-]{+new+}' in full
31
+ before,after = 'a'*22,'b'*22
32
+ result = render_source_diff(prefix+before+' '+tail, prefix+after+' '+tail, maxlen=80)
33
+ assert f'[-{before}-]{{+{after}+}}' in result
34
+
35
+
36
+ def test_final_newline():
37
+ assert r'' in source_diff('hello\n', 'hello')
38
+ assert 'new file' in render_source_diff('hello\n', 'hello')
39
+ assert 'old file' in render_source_diff('hello', 'hello\n')
40
+ assert not render_source_diff('hello\n', 'hello', final_newline=False)
41
+
42
+
43
+ def test_git_driver(tmp_path):
44
+ env = {**os.environ, 'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1'}
45
+ def git(*args):
46
+ return subprocess.run(['git', *args], cwd=tmp_path, env=env, capture_output=True, text=True, check=True).stdout
47
+ driver = tmp_path/'driver.py'
48
+ driver.write_text('from nbdev.diff import nbdev_diff_driver\nnbdev_diff_driver()')
49
+ git('init', '-q')
50
+ git('config', 'user.name', 'nbdev')
51
+ git('config', 'user.email', 'nbdev@fast.ai')
52
+ git('config', 'diff.nbdev.command', shlex.join([sys.executable, str(driver), '--maxlen', '80']))
53
+ (tmp_path/'.gitattributes').write_text('*.md diff=nbdev\n*.py diff=nbdev\n*.ipynb diff=nbdev\n')
54
+ note, source = tmp_path/'note.md', tmp_path/'source.py'
55
+ prefix = 'A notebook has words. '*20
56
+ note.write_text(prefix+'This is a green notebook.\n')
57
+ source.write_text('#| export\nx=1\n')
58
+ nb_path = tmp_path/'demo.ipynb'
59
+ nb = new_nb(['#| export\nx=1'])
60
+ write_nb(nb, nb_path)
61
+ git('add', '.')
62
+ git('commit', '-qm', 'initial')
63
+ note.write_text(prefix+'This is a blue notebook.\n')
64
+ source.write_text('#| exports\nx=100\n')
65
+ nb.cells[0].source = '#| export\nx=100'
66
+ write_nb(nb, nb_path)
67
+ result = git('diff')
68
+ assert '[-green-]{+blue+}' in result
69
+ assert all(len(line)<=80 for line in result.splitlines() if line.startswith('!'))
70
+ assert '-#| export\n+#| exports' in result and '## modified' in result and '[export]' in result
71
+ assert '[-1-]{+100+}' in result
72
+ git('add', '.')
73
+ git('commit', '-qm', 'edited')
74
+ git('mv', 'note.md', 'renamed.md')
75
+ git('update-index', '--chmod=+x', 'source.py')
76
+ result = git('diff', '--cached', '--find-renames')
77
+ assert 'note.md' in result and 'renamed.md' in result
78
+ assert '100644' in result and '100755' in result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes