lkj 0.1.35__tar.gz → 0.1.37__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lkj
3
- Version: 0.1.35
3
+ Version: 0.1.37
4
4
  Summary: A dump of homeless useful utils
5
5
  Home-page: https://github.com/thorwhalen/lkj
6
6
  Author: Thor Whalen
@@ -25,9 +25,38 @@ That is, modules are all self contained (so can easily be copy-paste-vendored
25
25
  Further, many functions will contain their own imports: Those functions can even be
26
26
  copy-paste-vendored by just copying the function body.
27
27
 
28
-
29
28
  # Examples of utils
30
29
 
30
+ ## Find and replace
31
+
32
+ `FindReplaceTool` is a general-purpose find-and-replace tool that can treat the input text as a continuous sequence of characters,
33
+ even if operations such as viewing context are performed line by line.
34
+
35
+ The basic usage is
36
+
37
+ ```python
38
+ FindReplaceTool("apple banana apple").find_and_print_matches(r'apple')
39
+ ```
40
+
41
+ Match 0 (around line 1):
42
+ apple banana apple
43
+ ^^^^^
44
+ ----------------------------------------
45
+ Match 1 (around line 1):
46
+ apple banana apple
47
+ ^^^^^
48
+ ----------------------------------------
49
+
50
+ ```python
51
+ FindReplaceTool("apple banana apple").find_and_replace(r'apple', "orange")
52
+ ```
53
+
54
+ 'orange banana orange'
55
+
56
+ [See more examples in documentation](https://i2mint.github.io/lkj/module_docs/lkj/strings.html#lkj.strings.FindReplaceTool)
57
+
58
+ [See here a example of how I used this to edit my CI yamls](https://github.com/i2mint/lkj/discussions/4#discussioncomment-12104547)
59
+
31
60
  ## loggers
32
61
 
33
62
  ### clog
@@ -14,9 +14,38 @@ That is, modules are all self contained (so can easily be copy-paste-vendored
14
14
  Further, many functions will contain their own imports: Those functions can even be
15
15
  copy-paste-vendored by just copying the function body.
16
16
 
17
-
18
17
  # Examples of utils
19
18
 
19
+ ## Find and replace
20
+
21
+ `FindReplaceTool` is a general-purpose find-and-replace tool that can treat the input text as a continuous sequence of characters,
22
+ even if operations such as viewing context are performed line by line.
23
+
24
+ The basic usage is
25
+
26
+ ```python
27
+ FindReplaceTool("apple banana apple").find_and_print_matches(r'apple')
28
+ ```
29
+
30
+ Match 0 (around line 1):
31
+ apple banana apple
32
+ ^^^^^
33
+ ----------------------------------------
34
+ Match 1 (around line 1):
35
+ apple banana apple
36
+ ^^^^^
37
+ ----------------------------------------
38
+
39
+ ```python
40
+ FindReplaceTool("apple banana apple").find_and_replace(r'apple', "orange")
41
+ ```
42
+
43
+ 'orange banana orange'
44
+
45
+ [See more examples in documentation](https://i2mint.github.io/lkj/module_docs/lkj/strings.html#lkj.strings.FindReplaceTool)
46
+
47
+ [See here a example of how I used this to edit my CI yamls](https://github.com/i2mint/lkj/discussions/4#discussioncomment-12104547)
48
+
20
49
  ## loggers
21
50
 
22
51
  ### clog
@@ -16,6 +16,7 @@ from lkj.dicts import (
16
16
  )
17
17
  from lkj.filesys import get_app_data_dir, get_watermarked_dir, enable_sourcing_from_file
18
18
  from lkj.strings import (
19
+ FindReplaceTool, # Tool for finding and replacing substrings in a string
19
20
  indent_lines, # Indent all lines of a string
20
21
  most_common_indent, # Get the most common indent of a multiline string
21
22
  regex_based_substitution,
@@ -18,8 +18,8 @@ def inclusive_subdict(d, include):
18
18
  include (set): The set of keys to include in the new dictionary.
19
19
 
20
20
  Example:
21
- >>> inclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'})
22
- {'a': 1, 'c': 3}
21
+
22
+ >>> assert inclusive_subdict({'a': 1, 'b': 2, 'c': 3}, {'a', 'c'}) == {'a': 1, 'c': 3}
23
23
 
24
24
  """
25
25
  return {k: d[k] for k in d.keys() & include}
@@ -408,3 +408,281 @@ def unique_affixes(
408
408
  # Postprocess affixes using egress
409
409
  affixes = list(map(egress, affixes))
410
410
  return affixes
411
+
412
+
413
+ from typing import Union, Callable, Dict, Any
414
+
415
+ # A match is represented as a dictionary (keys like "start", "end", etc.)
416
+ # and the replacement is either a static string or a callable that takes that
417
+ # dictionary and returns a string.
418
+ Replacement = Union[str, Callable[[Dict[str, Any]], str]]
419
+
420
+
421
+ class FindReplaceTool:
422
+ r"""
423
+ A general-purpose find-and-replace tool that can treat the input text
424
+ as a continuous sequence of characters, even if operations such as viewing
425
+ context are performed line by line. The tool can analyze matches based on
426
+ a user-supplied regular expression, navigate through the matches with context,
427
+ and perform replacements either interactively or in bulk. Replacements can be
428
+ provided as either a static string or via a callback function that receives details
429
+ of the match.
430
+
431
+ Instead of keeping a single modified text, this version maintains a history of
432
+ text versions in self._text_versions, where self._text_versions[0] is the original
433
+ text and self._text_versions[-1] is the current text. Each edit is performed on the
434
+ current version and appended to the history. Additional methods allow reverting changes.
435
+
436
+ 1: Basic usage
437
+ -----------------------------------------------------
438
+ >>> FindReplaceTool("apple banana apple").find_and_print_matches(r'apple')
439
+ Match 0 (around line 1):
440
+ apple banana apple
441
+ ^^^^^
442
+ ----------------------------------------
443
+ Match 1 (around line 1):
444
+ apple banana apple
445
+ ^^^^^
446
+ ----------------------------------------
447
+ >>> FindReplaceTool("apple banana apple").find_and_replace(r'apple', "orange")
448
+ 'orange banana orange'
449
+
450
+
451
+ 2: Using line_mode=True with a static replacement.
452
+ --------------------------------------------------------
453
+ >>> text1 = "apple\nbanana apple\ncherry"
454
+ >>> tool = FindReplaceTool(text1, line_mode=True, flags=re.MULTILINE)
455
+ >>> import re
456
+ >>> # Find all occurrences of "apple" (two in total).
457
+ >>> _ = tool.analyze(r'apple')
458
+ >>> len(tool._matches)
459
+ 2
460
+ >>> # Replace the first occurrence ("apple" on the first line) with "orange".
461
+ >>> tool.replace_one(0, "orange").get_modified_text()
462
+ 'orange\nbanana apple\ncherry'
463
+
464
+ 3: Using line_mode=False with a callback replacement.
465
+ -----------------------------------------------------------
466
+ >>> text2 = "apple banana apple"
467
+ >>> tool2 = FindReplaceTool(text2, line_mode=False)
468
+ >>> # Find all occurrences of "apple" in the continuous text.
469
+ >>> len(tool2.analyze(r'apple')._matches)
470
+ 2
471
+ >>> # Define a callback that converts each matched text to uppercase.
472
+ >>> def to_upper(match):
473
+ ... return match["matched_text"].upper()
474
+ >>> tool2.replace_all(to_upper).get_modified_text()
475
+ 'APPLE banana APPLE'
476
+
477
+ 4: Reverting changes.
478
+ ---------------------------
479
+ >>> text3 = "one two three"
480
+ >>> tool3 = FindReplaceTool(text3)
481
+ >>> import re
482
+ >>> # Analyze to match the first word "one" (at the start of the text).
483
+ >>> tool3.analyze(r'^one').replace_one(0, "ONE").get_modified_text()
484
+ 'ONE two three'
485
+ >>> # Revert the edit.
486
+ >>> tool3.revert()
487
+ 'one two three'
488
+ """
489
+
490
+ def __init__(
491
+ self,
492
+ text: str,
493
+ *,
494
+ line_mode: bool = False,
495
+ flags: int = 0,
496
+ show_line_numbers: bool = True,
497
+ context_size: int = 2,
498
+ highlight_char: str = "^",
499
+ ):
500
+ # Maintain a list of text versions; the first element is the original text.
501
+ self._text_versions = [text]
502
+ self.line_mode = line_mode
503
+ self.flags = flags
504
+ self.show_line_numbers = show_line_numbers
505
+ self.context_size = context_size
506
+ self.highlight_char = highlight_char
507
+
508
+ # Internal storage for matches; each entry is a dict with:
509
+ # "start": start offset in the current text,
510
+ # "end": end offset in the current text,
511
+ # "matched_text": the text that was matched,
512
+ # "groups": any named groups from the regex,
513
+ # "line_number": the line number where the match occurs.
514
+ self._matches = []
515
+
516
+ # ----------------------------------------------------------------------------------
517
+ # Main methods
518
+
519
+ # TODO: Would like to have these functions be stateless
520
+ def find_and_print_matches(self, pattern: str) -> None:
521
+ """
522
+ Searches the current text (the last version) for occurrences matching the given
523
+ regular expression. Any match data (including group captures) is stored internally.
524
+ """
525
+ return self.analyze(pattern).view_matches()
526
+
527
+ def find_and_replace(self, pattern: str, replacement: Replacement) -> None:
528
+ """
529
+ Searches the current text (the last version) for occurrences matching the given
530
+ regular expression. Any match data (including group captures) is stored internally.
531
+ """
532
+ return self.analyze(pattern).replace_all(replacement).get_modified_text()
533
+
534
+ # ----------------------------------------------------------------------------------
535
+ # Advanced methods
536
+
537
+ def analyze(self, pattern: str) -> None:
538
+ """
539
+ Searches the current text (the last version) for occurrences matching the given
540
+ regular expression. Any match data (including group captures) is stored internally.
541
+ """
542
+ import re
543
+
544
+ self._matches.clear()
545
+ current_text = self._text_versions[-1]
546
+ for match in re.finditer(pattern, current_text, self.flags):
547
+ match_data = {
548
+ "start": match.start(),
549
+ "end": match.end(),
550
+ "matched_text": match.group(0),
551
+ "groups": match.groupdict(),
552
+ "line_number": current_text.count("\n", 0, match.start()),
553
+ }
554
+ self._matches.append(match_data)
555
+
556
+ return self
557
+
558
+ def view_matches(self) -> None:
559
+ """
560
+ Displays all stored matches along with surrounding context. When line_mode
561
+ is enabled, the context is provided in full lines with (optionally) line numbers,
562
+ and a line is added below the matched line to indicate the matched portion.
563
+ In non-line mode, a snippet of characters around the match is shown.
564
+ """
565
+ current_text = self._text_versions[-1]
566
+ if not self._matches:
567
+ print("No matches found.")
568
+ return
569
+
570
+ if self.line_mode:
571
+ lines = current_text.splitlines()
572
+ for idx, m in enumerate(self._matches):
573
+ line_num = m["line_number"]
574
+ start_pos = m["start"]
575
+ end_pos = m["end"]
576
+ start_context = max(0, line_num - self.context_size)
577
+ end_context = min(len(lines), line_num + self.context_size + 1)
578
+ print(f"Match {idx} at line {line_num+1}:")
579
+ for ln in range(start_context, end_context):
580
+ prefix = f"{ln+1:>4} " if self.show_line_numbers else ""
581
+ print(prefix + lines[ln])
582
+ # For the match line, mark the position of the match.
583
+ if ln == line_num:
584
+ pos_in_line = start_pos - (
585
+ len("\n".join(lines[:ln])) + (1 if ln > 0 else 0)
586
+ )
587
+ highlight = " " * (
588
+ len(prefix) + pos_in_line
589
+ ) + self.highlight_char * (end_pos - start_pos)
590
+ print(highlight)
591
+ print("-" * 40)
592
+ else:
593
+ snippet_radius = 20
594
+ for idx, m in enumerate(self._matches):
595
+ start, end = m["start"], m["end"]
596
+ if self.line_mode:
597
+ snippet_start = current_text.rfind("\n", 0, start) + 1
598
+ else:
599
+ snippet_start = max(0, start - snippet_radius)
600
+ snippet_end = min(len(current_text), end + snippet_radius)
601
+ snippet = current_text[snippet_start:snippet_end]
602
+ print(f"Match {idx} (around line {m['line_number']+1}):")
603
+ print(snippet)
604
+ highlight = " " * (start - snippet_start) + self.highlight_char * (
605
+ end - start
606
+ )
607
+ print(highlight)
608
+ print("-" * 40)
609
+
610
+ def replace_one(self, match_index: int, replacement: Replacement) -> None:
611
+ """
612
+ Replaces a single match, identified by match_index, with a new string.
613
+ The 'replacement' argument may be either a static string or a callable.
614
+ When it is a callable, it is called with a dictionary containing the match data
615
+ (including any captured groups) and should return the replacement string.
616
+ The replacement is performed on the current text version, and the new text is
617
+ appended as a new version in the history.
618
+ """
619
+
620
+ if match_index < 0 or match_index >= len(self._matches):
621
+ print(f"Invalid match index: {match_index}")
622
+ return
623
+
624
+ m = self._matches[match_index]
625
+ start, end = m["start"], m["end"]
626
+ current_text = self._text_versions[-1]
627
+
628
+ # Determine the replacement string.
629
+ if callable(replacement):
630
+ new_replacement = replacement(m)
631
+ else:
632
+ new_replacement = replacement
633
+
634
+ # Create the new text version.
635
+ new_text = current_text[:start] + new_replacement + current_text[end:]
636
+ self._text_versions.append(new_text)
637
+ offset_diff = len(new_replacement) - (end - start)
638
+
639
+ # Update offsets for subsequent matches (so they refer to the new text version).
640
+ for i in range(match_index + 1, len(self._matches)):
641
+ self._matches[i]["start"] += offset_diff
642
+ self._matches[i]["end"] += offset_diff
643
+
644
+ # Update the current match record.
645
+ m["end"] = start + len(new_replacement)
646
+ m["matched_text"] = new_replacement
647
+
648
+ return self
649
+
650
+ def replace_all(self, replacement: Replacement) -> None:
651
+ """
652
+ Replaces all stored matches in the current text version. The 'replacement' argument may
653
+ be a static string or a callable (see replace_one for details). Replacements are performed
654
+ from the last match to the first, so that earlier offsets are not affected.
655
+ """
656
+ for idx in reversed(range(len(self._matches))):
657
+ self.replace_one(idx, replacement)
658
+
659
+ return self
660
+
661
+ def get_original_text(self) -> str:
662
+ """Returns the original text (first version)."""
663
+ return self._text_versions[0]
664
+
665
+ def get_modified_text(self) -> str:
666
+ """Returns the current (latest) text version."""
667
+ return self._text_versions[-1]
668
+
669
+ def revert(self, steps: int = 1):
670
+ """
671
+ Reverts the current text version by removing the last 'steps' versions
672
+ from the history. The original text (version 0) is never removed.
673
+ Returns the new current text.
674
+
675
+ >>> text = "one two three"
676
+ >>> tool = FindReplaceTool(text)
677
+ >>> import re
678
+ >>> tool.analyze(r'^one').replace_one(0, "ONE").get_modified_text()
679
+ 'ONE two three'
680
+ >>> tool.revert()
681
+ 'one two three'
682
+ """
683
+ if steps < 1:
684
+ return self.get_modified_text()
685
+ while steps > 0 and len(self._text_versions) > 1:
686
+ self._text_versions.pop()
687
+ steps -= 1
688
+ return self.get_modified_text()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lkj
3
- Version: 0.1.35
3
+ Version: 0.1.37
4
4
  Summary: A dump of homeless useful utils
5
5
  Home-page: https://github.com/thorwhalen/lkj
6
6
  Author: Thor Whalen
@@ -25,9 +25,38 @@ That is, modules are all self contained (so can easily be copy-paste-vendored
25
25
  Further, many functions will contain their own imports: Those functions can even be
26
26
  copy-paste-vendored by just copying the function body.
27
27
 
28
-
29
28
  # Examples of utils
30
29
 
30
+ ## Find and replace
31
+
32
+ `FindReplaceTool` is a general-purpose find-and-replace tool that can treat the input text as a continuous sequence of characters,
33
+ even if operations such as viewing context are performed line by line.
34
+
35
+ The basic usage is
36
+
37
+ ```python
38
+ FindReplaceTool("apple banana apple").find_and_print_matches(r'apple')
39
+ ```
40
+
41
+ Match 0 (around line 1):
42
+ apple banana apple
43
+ ^^^^^
44
+ ----------------------------------------
45
+ Match 1 (around line 1):
46
+ apple banana apple
47
+ ^^^^^
48
+ ----------------------------------------
49
+
50
+ ```python
51
+ FindReplaceTool("apple banana apple").find_and_replace(r'apple', "orange")
52
+ ```
53
+
54
+ 'orange banana orange'
55
+
56
+ [See more examples in documentation](https://i2mint.github.io/lkj/module_docs/lkj/strings.html#lkj.strings.FindReplaceTool)
57
+
58
+ [See here a example of how I used this to edit my CI yamls](https://github.com/i2mint/lkj/discussions/4#discussioncomment-12104547)
59
+
31
60
  ## loggers
32
61
 
33
62
  ### clog
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = lkj
3
- version = 0.1.35
3
+ version = 0.1.37
4
4
  url = https://github.com/thorwhalen/lkj
5
5
  platforms = any
6
6
  description_file = README.md
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