Skip to content

Commit 748aea2

Browse files
committed
fixes #35
1 parent ab77641 commit 748aea2

3 files changed

Lines changed: 138 additions & 61 deletions

File tree

nbs/01_edit.ipynb

Lines changed: 102 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"> file_create('~/a/b/c.py', 'content here')\n",
1717
"> file_str_replace('myfile.py', 'old_name', 'new_name')\n",
1818
"> file_del_lines('myfile.py', 2, 4)\n",
19+
"> file_replace_lines('myfile.py', new_content=src) # no line numbers: replace the entire contents\n",
20+
"> \n",
21+
"> In `replace_lines` and its `file_`/`cell_` wrappers, `start_line=None` means line 1 and `end_line=None` means the last line, so the defaults replace the whole contents - the idiomatic full-file (or full-notebook-cell) rewrite. `del_lines` is destructive, so it takes no defaults: state the range explicitly (`1, -1` deletes all lines).\n",
1922
"> \n",
2023
"> ## Line filtering\n",
2124
"> \n",
@@ -68,18 +71,18 @@
6871
"#| export\n",
6972
"def file_view(\n",
7073
" path:str, # Path to view (expands `~` if needed)\n",
71-
" startline:int=1, # Starting line to view\n",
72-
" endline:int=None # End line (defaults to last line if None)\n",
74+
" start_line:int=1, # Starting line to view\n",
75+
" end_line:int=None # End line (defaults to last line if None; may be past EOF, which clamps to the last line - handy when the file size is unknown)\n",
7376
"):\n",
7477
" \"Read file contents, optionally limited to 1-based line range\"\n",
7578
" path = Path(path).expanduser()\n",
7679
" lines = path.read_text().splitlines()\n",
7780
" if not lines: return ''\n",
78-
" if endline is None: endline = len(lines)\n",
79-
" if endline < 0: endline = len(lines)+endline+1\n",
80-
" if not (1 <= startline <= len(lines)): return f'error: Invalid startline {startline}. Valid range: 1-{len(lines)}'\n",
81-
" if endline > len(lines): endline = len(lines)\n",
82-
" return '\\n'.join(f'{i}: {l}' for i,l in enumerate(lines[startline-1:endline], startline))"
81+
" if end_line is None: end_line = len(lines)\n",
82+
" if end_line < 0: end_line = len(lines)+end_line+1\n",
83+
" if not (1 <= start_line <= len(lines)): return f'error: Invalid start_line {start_line}. Valid range: 1-{len(lines)}'\n",
84+
" if end_line > len(lines): end_line = len(lines)\n",
85+
" return '\\n'.join(f'{i}: {l}' for i,l in enumerate(lines[start_line-1:end_line], start_line))"
8386
]
8487
},
8588
{
@@ -254,6 +257,24 @@
254257
"print(res)"
255258
]
256259
},
260+
{
261+
"cell_type": "code",
262+
"execution_count": null,
263+
"id": "3ae109f7",
264+
"metadata": {},
265+
"outputs": [],
266+
"source": [
267+
"#| export\n",
268+
"def _norm_lines(n:int, start:int=None, end:int=None):\n",
269+
" \"Normalize and validate line range; `None` start/end mean first/last line. Returns (start, end) or raises ValueError.\"\n",
270+
" if start is None: start = 1\n",
271+
" if end is None: end = n\n",
272+
" if end < 0: end = n + end + 1\n",
273+
" if not (1 <= start <= n): raise ValueError(f'Invalid start line {start}. Valid range: 1-{n}')\n",
274+
" if not (start <= end <= n): raise ValueError(f'Invalid end line {end}. Valid range: {start}-{n}')\n",
275+
" return start, end\n"
276+
]
277+
},
257278
{
258279
"cell_type": "code",
259280
"execution_count": null,
@@ -282,9 +303,10 @@
282303
" return pat.sub(new_str, s, count=n_matches or 0)\n",
283304
" if s.count(old_str) == 0: raise _err(s)\n",
284305
" return s.replace(old_str, new_str) if n_matches is None else s.replace(old_str, new_str, n_matches)\n",
285-
" if re_filter or start_line or end_line:\n",
306+
" if re_filter or start_line is not None or end_line is not None:\n",
286307
" lines = text.splitlines(True)\n",
287-
" s,e = (start_line or 1)-1, end_line or len(lines)\n",
308+
" s,e = _norm_lines(len(lines), start_line, end_line)\n",
309+
" s -= 1\n",
288310
" if not re_filter: return ''.join(lines[:s]) + _repl(''.join(lines[s:e]), f' in lines {s+1}-{e}') + ''.join(lines[e:])\n",
289311
" pat = re.compile(re_filter)\n",
290312
" matched = [i for i in range(s,e) if bool(pat.search(lines[i])) != invert_filter]\n",
@@ -352,6 +374,25 @@
352374
"test_fail(lambda: str_replace('abc', r'\\bxyz\\b', 'q', use_regex=True), contains='Failed to find')"
353375
]
354376
},
377+
{
378+
"cell_type": "markdown",
379+
"id": "ceb03583",
380+
"metadata": {},
381+
"source": [
382+
"Line ranges use the same rules as `replace_lines`: `None` bounds mean first/last line, and a negative `end_line` counts inclusively from the end (`-1` is the last line):"
383+
]
384+
},
385+
{
386+
"cell_type": "code",
387+
"execution_count": null,
388+
"id": "c55d6e90",
389+
"metadata": {},
390+
"outputs": [],
391+
"source": [
392+
"test_eq(str_replace('xa\\nxb\\nxc\\n', 'x', 'y', start_line=2, end_line=-1), 'xa\\nyb\\nyc\\n')\n",
393+
"test_eq(str_replace('xa\\nxb\\nxc\\n', 'x', 'y', end_line=2), 'ya\\nyb\\nxc\\n')"
394+
]
395+
},
355396
{
356397
"cell_type": "code",
357398
"execution_count": null,
@@ -419,23 +460,6 @@
419460
"print(res)"
420461
]
421462
},
422-
{
423-
"cell_type": "code",
424-
"execution_count": null,
425-
"id": "7558e897",
426-
"metadata": {},
427-
"outputs": [],
428-
"source": [
429-
"#| export\n",
430-
"def _norm_lines(n:int, start:int, end:int=None):\n",
431-
" \"Normalize and validate line range. Returns (start, end) or raises ValueError.\"\n",
432-
" if end is None: end = start\n",
433-
" if end < 0: end = n + end + 1\n",
434-
" if not (1 <= start <= n): raise ValueError(f'Invalid start line {start}. Valid range: 1-{n}')\n",
435-
" if not (start <= end <= n): raise ValueError(f'Invalid end line {end}. Valid range: {start}-{n}')\n",
436-
" return start, end"
437-
]
438-
},
439463
{
440464
"cell_type": "code",
441465
"execution_count": null,
@@ -446,18 +470,19 @@
446470
"#| export\n",
447471
"def replace_lines(\n",
448472
" text:str,\n",
449-
" start_line:int, # Starting line number to replace (1-based indexing)\n",
450-
" end_line:int=None, # Ending line number to replace (1-based, inclusive, negative counts from end, None for single line)\n",
473+
" start_line:int=None, # Starting line number to replace (1-based); None means line 1\n",
474+
" end_line:int=None, # Ending line number to replace (1-based, inclusive, negative counts from end); None means the last line\n",
451475
" new_content:str='', # New content to replace the specified lines\n",
452476
"):\n",
453-
" \"Replace line range with new content\"\n",
477+
" \"Replace line range with new content; the defaults replace the entire contents\"\n",
478+
" if not text: return new_content\n",
454479
" lines = text.splitlines(keepends=True)\n",
455480
" s,e = _norm_lines(len(lines), start_line, end_line)\n",
456-
" if lines and new_content and not new_content.endswith('\\n'): new_content += '\\n'\n",
481+
" if new_content and not new_content.endswith('\\n'): new_content += '\\n'\n",
457482
" lines[s-1:e] = [new_content] if new_content else []\n",
458483
" return ''.join(lines)\n",
459484
"\n",
460-
"file_replace_lines = file_edit(replace_lines, 'file_replace_lines')"
485+
"file_replace_lines = file_edit(replace_lines, 'file_replace_lines')\n"
461486
]
462487
},
463488
{
@@ -486,6 +511,27 @@
486511
"print(res)"
487512
]
488513
},
514+
{
515+
"cell_type": "markdown",
516+
"id": "4e9e8f0a",
517+
"metadata": {},
518+
"source": [
519+
"`start_line=None` means line 1 and `end_line=None` means the last line, so with no line numbers `replace_lines` replaces the entire contents (an empty text included), and a bare `start_line` runs through to the end. This is the idiomatic whole-file or whole-cell rewrite: `file_replace_lines(path, new_content=src)`, `cell_replace_lines(fname, id, new_content=src)`.\n"
520+
]
521+
},
522+
{
523+
"cell_type": "code",
524+
"execution_count": null,
525+
"id": "1c2cd054",
526+
"metadata": {},
527+
"outputs": [],
528+
"source": [
529+
"test_eq(replace_lines('a\\nb\\nc\\n', new_content='x\\ny\\n'), 'x\\ny\\n')\n",
530+
"test_eq(replace_lines('', new_content='x\\n'), 'x\\n')\n",
531+
"test_eq(replace_lines('a\\nb\\nc\\n', 2, new_content='x\\n'), 'a\\nx\\n')\n",
532+
"test_eq(replace_lines('a\\nb\\nc\\n', end_line=2, new_content='x\\n'), 'x\\nc\\n')\n"
533+
]
534+
},
489535
{
490536
"cell_type": "code",
491537
"execution_count": null,
@@ -496,12 +542,13 @@
496542
"#| export\n",
497543
"def del_lines(\n",
498544
" text:str,\n",
499-
" start_line:int, # Starting line number to delete (1-based indexing)\n",
500-
" end_line:int=None, # Ending line number to delete (1-based, inclusive, negative counts from end, None for single line)\n",
545+
" start_line:int, # Starting line number to delete (1-based); required\n",
546+
" end_line:int, # Ending line number to delete (1-based, inclusive, negative counts from end); required\n",
501547
" re_filter:str=None, # If provided, only delete lines matching this regex (like g// in ex)\n",
502548
" invert_filter:bool=False, # Invert the filter (like g!// in ex)\n",
503549
"):\n",
504-
" \"Delete line range\"\n",
550+
" \"Delete line range; deletion is destructive, so both line numbers must be given explicitly (`1, -1` for all lines)\"\n",
551+
" if start_line is None or end_line is None: raise ValueError('del_lines requires explicit start_line and end_line')\n",
505552
" lines = text.splitlines(keepends=True)\n",
506553
" s,e = _norm_lines(len(lines), start_line, end_line)\n",
507554
" if re_filter:\n",
@@ -512,7 +559,27 @@
512559
" else: del lines[s-1:e]\n",
513560
" return ''.join(lines)\n",
514561
"\n",
515-
"file_del_lines = file_edit(del_lines, 'file_del_lines')"
562+
"file_del_lines = file_edit(del_lines, 'file_del_lines')\n"
563+
]
564+
},
565+
{
566+
"cell_type": "markdown",
567+
"id": "e84d4194",
568+
"metadata": {},
569+
"source": [
570+
"`del_lines` is destructive, so it takes no defaults and rejects `None`: state the range explicitly (`1, -1` deletes all lines, `5, 5` just line 5)."
571+
]
572+
},
573+
{
574+
"cell_type": "code",
575+
"execution_count": null,
576+
"id": "71b5220a",
577+
"metadata": {},
578+
"outputs": [],
579+
"source": [
580+
"test_eq(del_lines('a\\nb\\nc\\n', 2, 2), 'a\\nc\\n')\n",
581+
"test_eq(del_lines('a\\nb\\nc\\n', 1, -1), '')\n",
582+
"test_fail(lambda: del_lines('a\\nb\\n', 1, None), contains='explicit')"
516583
]
517584
},
518585
{

nbs/02_ipynb.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@
282282
}
283283
],
284284
"source": [
285-
"res = cell_del_lines(nb_path, cid, 1)\n",
285+
"res = cell_del_lines(nb_path, cid, 1, 1)\n",
286286
"test_eq(nb_src().splitlines()[0], 'two')\n",
287287
"print(res)"
288288
]

pyskills/edit.py

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
file_create('~/a/b/c.py', 'content here')
99
file_str_replace('myfile.py', 'old_name', 'new_name')
1010
file_del_lines('myfile.py', 2, 4)
11+
file_replace_lines('myfile.py', new_content=src) # no line numbers: replace the entire contents
12+
13+
In `replace_lines` and its `file_`/`cell_` wrappers, `start_line=None` means line 1 and `end_line=None` means the last line, so the defaults replace the whole contents - the idiomatic full-file (or full-notebook-cell) rewrite. `del_lines` is destructive, so it takes no defaults: state the range explicitly (`1, -1` deletes all lines).
1114
1215
## Line filtering
1316
@@ -31,18 +34,18 @@
3134
# %% ../nbs/01_edit.ipynb #8478e86e
3235
def file_view(
3336
path:str, # Path to view (expands `~` if needed)
34-
startline:int=1, # Starting line to view
35-
endline:int=None # End line (defaults to last line if None)
37+
start_line:int=1, # Starting line to view
38+
end_line:int=None # End line (defaults to last line if None; may be past EOF, which clamps to the last line - handy when the file size is unknown)
3639
):
3740
"Read file contents, optionally limited to 1-based line range"
3841
path = Path(path).expanduser()
3942
lines = path.read_text().splitlines()
4043
if not lines: return ''
41-
if endline is None: endline = len(lines)
42-
if endline < 0: endline = len(lines)+endline+1
43-
if not (1 <= startline <= len(lines)): return f'error: Invalid startline {startline}. Valid range: 1-{len(lines)}'
44-
if endline > len(lines): endline = len(lines)
45-
return '\n'.join(f'{i}: {l}' for i,l in enumerate(lines[startline-1:endline], startline))
44+
if end_line is None: end_line = len(lines)
45+
if end_line < 0: end_line = len(lines)+end_line+1
46+
if not (1 <= start_line <= len(lines)): return f'error: Invalid start_line {start_line}. Valid range: 1-{len(lines)}'
47+
if end_line > len(lines): end_line = len(lines)
48+
return '\n'.join(f'{i}: {l}' for i,l in enumerate(lines[start_line-1:end_line], start_line))
4649

4750
# %% ../nbs/01_edit.ipynb #84615568
4851
def file_create(
@@ -94,6 +97,17 @@ def insert_line(
9497

9598
file_insert_line = file_edit(insert_line, 'file_insert_line')
9699

100+
# %% ../nbs/01_edit.ipynb #3ae109f7
101+
def _norm_lines(n:int, start:int=None, end:int=None):
102+
"Normalize and validate line range; `None` start/end mean first/last line. Returns (start, end) or raises ValueError."
103+
if start is None: start = 1
104+
if end is None: end = n
105+
if end < 0: end = n + end + 1
106+
if not (1 <= start <= n): raise ValueError(f'Invalid start line {start}. Valid range: 1-{n}')
107+
if not (start <= end <= n): raise ValueError(f'Invalid end line {end}. Valid range: {start}-{n}')
108+
return start, end
109+
110+
97111
# %% ../nbs/01_edit.ipynb #52428b51
98112
def str_replace(
99113
text:str,
@@ -115,9 +129,10 @@ def _err(s): return ValueError(f"Failed to find {old_str!r}{label} in: {s[:200]!
115129
return pat.sub(new_str, s, count=n_matches or 0)
116130
if s.count(old_str) == 0: raise _err(s)
117131
return s.replace(old_str, new_str) if n_matches is None else s.replace(old_str, new_str, n_matches)
118-
if re_filter or start_line or end_line:
132+
if re_filter or start_line is not None or end_line is not None:
119133
lines = text.splitlines(True)
120-
s,e = (start_line or 1)-1, end_line or len(lines)
134+
s,e = _norm_lines(len(lines), start_line, end_line)
135+
s -= 1
121136
if not re_filter: return ''.join(lines[:s]) + _repl(''.join(lines[s:e]), f' in lines {s+1}-{e}') + ''.join(lines[e:])
122137
pat = re.compile(re_filter)
123138
matched = [i for i in range(s,e) if bool(pat.search(lines[i])) != invert_filter]
@@ -151,40 +166,34 @@ def strs_replace(
151166

152167
file_strs_replace = file_edit(strs_replace, 'file_strs_replace')
153168

154-
# %% ../nbs/01_edit.ipynb #7558e897
155-
def _norm_lines(n:int, start:int, end:int=None):
156-
"Normalize and validate line range. Returns (start, end) or raises ValueError."
157-
if end is None: end = start
158-
if end < 0: end = n + end + 1
159-
if not (1 <= start <= n): raise ValueError(f'Invalid start line {start}. Valid range: 1-{n}')
160-
if not (start <= end <= n): raise ValueError(f'Invalid end line {end}. Valid range: {start}-{n}')
161-
return start, end
162-
163169
# %% ../nbs/01_edit.ipynb #03685bf9
164170
def replace_lines(
165171
text:str,
166-
start_line:int, # Starting line number to replace (1-based indexing)
167-
end_line:int=None, # Ending line number to replace (1-based, inclusive, negative counts from end, None for single line)
172+
start_line:int=None, # Starting line number to replace (1-based); None means line 1
173+
end_line:int=None, # Ending line number to replace (1-based, inclusive, negative counts from end); None means the last line
168174
new_content:str='', # New content to replace the specified lines
169175
):
170-
"Replace line range with new content"
176+
"Replace line range with new content; the defaults replace the entire contents"
177+
if not text: return new_content
171178
lines = text.splitlines(keepends=True)
172179
s,e = _norm_lines(len(lines), start_line, end_line)
173-
if lines and new_content and not new_content.endswith('\n'): new_content += '\n'
180+
if new_content and not new_content.endswith('\n'): new_content += '\n'
174181
lines[s-1:e] = [new_content] if new_content else []
175182
return ''.join(lines)
176183

177184
file_replace_lines = file_edit(replace_lines, 'file_replace_lines')
178185

186+
179187
# %% ../nbs/01_edit.ipynb #cc1b61a5
180188
def del_lines(
181189
text:str,
182-
start_line:int, # Starting line number to delete (1-based indexing)
183-
end_line:int=None, # Ending line number to delete (1-based, inclusive, negative counts from end, None for single line)
190+
start_line:int, # Starting line number to delete (1-based); required
191+
end_line:int, # Ending line number to delete (1-based, inclusive, negative counts from end); required
184192
re_filter:str=None, # If provided, only delete lines matching this regex (like g// in ex)
185193
invert_filter:bool=False, # Invert the filter (like g!// in ex)
186194
):
187-
"Delete line range"
195+
"Delete line range; deletion is destructive, so both line numbers must be given explicitly (`1, -1` for all lines)"
196+
if start_line is None or end_line is None: raise ValueError('del_lines requires explicit start_line and end_line')
188197
lines = text.splitlines(keepends=True)
189198
s,e = _norm_lines(len(lines), start_line, end_line)
190199
if re_filter:
@@ -196,3 +205,4 @@ def del_lines(
196205
return ''.join(lines)
197206

198207
file_del_lines = file_edit(del_lines, 'file_del_lines')
208+

0 commit comments

Comments
 (0)