nbdev 3.3.26__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.
- {nbdev-3.3.26/nbdev.egg-info → nbdev-3.3.28}/PKG-INFO +2 -1
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/__init__.py +1 -1
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/_modidx.py +1 -8
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/diff.py +40 -98
- nbdev-3.3.28/nbdev/diff_core.py +138 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/quarto_render.py +4 -3
- {nbdev-3.3.26 → nbdev-3.3.28/nbdev.egg-info}/PKG-INFO +2 -1
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev.egg-info/SOURCES.txt +2 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev.egg-info/requires.txt +1 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/pyproject.toml +1 -1
- nbdev-3.3.28/tests/test_diff.py +78 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/tests/test_quarto_render.py +6 -3
- {nbdev-3.3.26 → nbdev-3.3.28}/CONTRIBUTING.md +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/LICENSE +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/README.md +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/clean.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/cli.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/config.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/doclinks.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/export.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/extract_attachments.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/frontmatter.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/imports.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/maker.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/merge.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/migrate.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/moddocs.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/process.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/processors.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/qmd.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/quarto.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/readme_filter.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/release.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/scrubmagics.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/serve.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/serve_drv.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/showdoc.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/skill.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/sync.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev/test.py +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev.egg-info/dependency_links.txt +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev.egg-info/entry_points.txt +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/nbdev.egg-info/top_level.txt +0 -0
- {nbdev-3.3.26 → nbdev-3.3.28}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nbdev
|
|
3
|
-
Version: 3.3.
|
|
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.
|
|
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__ = ['
|
|
9
|
-
'
|
|
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 =
|
|
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(
|
|
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):
|
|
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)}:'
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
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)
|
|
@@ -6,8 +6,8 @@ from fastcore.parallel import parallel
|
|
|
6
6
|
from fastcore.utils import *
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def _quarto(path, *args):
|
|
10
|
-
res = subprocess.run(['quarto', *map(str, args)], cwd=path, capture_output=True, text=True)
|
|
9
|
+
def _quarto(path, *args, env=None):
|
|
10
|
+
res = subprocess.run(['quarto', *map(str, args)], cwd=path, capture_output=True, text=True, env=env)
|
|
11
11
|
if res.returncode: raise RuntimeError(res.stderr or res.stdout or f'Quarto exited with status {res.returncode}')
|
|
12
12
|
return res.stdout
|
|
13
13
|
|
|
@@ -25,7 +25,8 @@ def quarto_stale(path, output_dir):
|
|
|
25
25
|
|
|
26
26
|
def _render_batch(job):
|
|
27
27
|
path, inputs, output = job
|
|
28
|
-
|
|
28
|
+
env = {**os.environ, 'XDG_CACHE_HOME': str(path/'.cache')} if sys.platform == 'linux' else None
|
|
29
|
+
_quarto(path, 'render', *inputs, '--no-cache', '--no-clean', '--output-dir', output, env=env)
|
|
29
30
|
return output
|
|
30
31
|
|
|
31
32
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nbdev
|
|
3
|
-
Version: 3.3.
|
|
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
|
|
@@ -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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import json, shutil, subprocess, pytest
|
|
1
|
+
import json, shutil, subprocess, sys, pytest
|
|
2
2
|
from xml.etree import ElementTree as ET
|
|
3
3
|
from fastcore.test import test_eq as teq
|
|
4
4
|
from nbdev.quarto_render import render_quarto, quarto_stale
|
|
@@ -40,7 +40,9 @@ Documentation = "https://example.com/project/"
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
@pytest.mark.skipif(not shutil.which('quarto'), reason='requires Quarto')
|
|
43
|
-
def test_website(tmp_path):
|
|
43
|
+
def test_website(tmp_path, monkeypatch):
|
|
44
|
+
cache = tmp_path/'cache'
|
|
45
|
+
monkeypatch.setenv('XDG_CACHE_HOME', str(cache))
|
|
44
46
|
root = tmp_path/'source'
|
|
45
47
|
root.mkdir()
|
|
46
48
|
(root/'_quarto.yml').write_text(r'''project:
|
|
@@ -68,9 +70,10 @@ Page {name}. [Alpha](alpha.qmd#detail). [Download](data.txt).
|
|
|
68
70
|
(root/'other.qmd').write_text('---\noutput-file: renamed.html\n---\n# Other')
|
|
69
71
|
serial, parallel = tmp_path/'serial', root/'_site'
|
|
70
72
|
assert quarto_stale(root, parallel)
|
|
71
|
-
render_quarto(root, serial, n_workers=1)
|
|
72
73
|
render_quarto(root, parallel, n_workers=2)
|
|
74
|
+
if sys.platform == 'linux': assert not (cache/'quarto/sass').exists()
|
|
73
75
|
assert not quarto_stale(root, parallel)
|
|
76
|
+
render_quarto(root, serial, n_workers=1)
|
|
74
77
|
search = lambda d: {e['href']: e['text'] for e in json.loads((d/'search.json').read_text())}
|
|
75
78
|
urls = lambda d: {e.text for e in ET.parse(d/'sitemap.xml').findall('.//{*}loc')}
|
|
76
79
|
teq(search(serial), search(parallel))
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|