|
16 | 16 | "> file_create('~/a/b/c.py', 'content here')\n", |
17 | 17 | "> file_str_replace('myfile.py', 'old_name', 'new_name')\n", |
18 | 18 | "> 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", |
19 | 22 | "> \n", |
20 | 23 | "> ## Line filtering\n", |
21 | 24 | "> \n", |
|
68 | 71 | "#| export\n", |
69 | 72 | "def file_view(\n", |
70 | 73 | " 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", |
73 | 76 | "):\n", |
74 | 77 | " \"Read file contents, optionally limited to 1-based line range\"\n", |
75 | 78 | " path = Path(path).expanduser()\n", |
76 | 79 | " lines = path.read_text().splitlines()\n", |
77 | 80 | " 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))" |
83 | 86 | ] |
84 | 87 | }, |
85 | 88 | { |
|
254 | 257 | "print(res)" |
255 | 258 | ] |
256 | 259 | }, |
| 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 | + }, |
257 | 278 | { |
258 | 279 | "cell_type": "code", |
259 | 280 | "execution_count": null, |
|
282 | 303 | " return pat.sub(new_str, s, count=n_matches or 0)\n", |
283 | 304 | " if s.count(old_str) == 0: raise _err(s)\n", |
284 | 305 | " 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", |
286 | 307 | " 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", |
288 | 310 | " if not re_filter: return ''.join(lines[:s]) + _repl(''.join(lines[s:e]), f' in lines {s+1}-{e}') + ''.join(lines[e:])\n", |
289 | 311 | " pat = re.compile(re_filter)\n", |
290 | 312 | " matched = [i for i in range(s,e) if bool(pat.search(lines[i])) != invert_filter]\n", |
|
352 | 374 | "test_fail(lambda: str_replace('abc', r'\\bxyz\\b', 'q', use_regex=True), contains='Failed to find')" |
353 | 375 | ] |
354 | 376 | }, |
| 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 | + }, |
355 | 396 | { |
356 | 397 | "cell_type": "code", |
357 | 398 | "execution_count": null, |
|
419 | 460 | "print(res)" |
420 | 461 | ] |
421 | 462 | }, |
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 | | - }, |
439 | 463 | { |
440 | 464 | "cell_type": "code", |
441 | 465 | "execution_count": null, |
|
446 | 470 | "#| export\n", |
447 | 471 | "def replace_lines(\n", |
448 | 472 | " 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", |
451 | 475 | " new_content:str='', # New content to replace the specified lines\n", |
452 | 476 | "):\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", |
454 | 479 | " lines = text.splitlines(keepends=True)\n", |
455 | 480 | " 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", |
457 | 482 | " lines[s-1:e] = [new_content] if new_content else []\n", |
458 | 483 | " return ''.join(lines)\n", |
459 | 484 | "\n", |
460 | | - "file_replace_lines = file_edit(replace_lines, 'file_replace_lines')" |
| 485 | + "file_replace_lines = file_edit(replace_lines, 'file_replace_lines')\n" |
461 | 486 | ] |
462 | 487 | }, |
463 | 488 | { |
|
486 | 511 | "print(res)" |
487 | 512 | ] |
488 | 513 | }, |
| 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 | + }, |
489 | 535 | { |
490 | 536 | "cell_type": "code", |
491 | 537 | "execution_count": null, |
|
496 | 542 | "#| export\n", |
497 | 543 | "def del_lines(\n", |
498 | 544 | " 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", |
501 | 547 | " re_filter:str=None, # If provided, only delete lines matching this regex (like g// in ex)\n", |
502 | 548 | " invert_filter:bool=False, # Invert the filter (like g!// in ex)\n", |
503 | 549 | "):\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", |
505 | 552 | " lines = text.splitlines(keepends=True)\n", |
506 | 553 | " s,e = _norm_lines(len(lines), start_line, end_line)\n", |
507 | 554 | " if re_filter:\n", |
|
512 | 559 | " else: del lines[s-1:e]\n", |
513 | 560 | " return ''.join(lines)\n", |
514 | 561 | "\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')" |
516 | 583 | ] |
517 | 584 | }, |
518 | 585 | { |
|
0 commit comments