Compare commits

..

7 Commits

6 changed files with 101 additions and 37 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
venv/ venv/
*.egg-info/ *.egg-info/
__pycache__/ __pycache__/
*.test_*.srt
dist/ dist/

View File

@ -1,9 +1,25 @@
# subprompt # subprompt
Substitute every match of a regex in multiple files - with confirmation prompts. Usage: Interactively change every line matching a regex in multiple files.
```
subprompt [REGEX] [SUB] [FILES...] ## Installation
``` Through PyPI:
Install it through PyPi:
``` ```
python3.9 -m pip install subprompt python3.9 -m pip install subprompt
``` ```
## Usage
```
usage: subprompt [-h] (-d | -r SUB) [-n N] REGEX FILES [FILES ...]
Modifies lines matched by a regex interactively
positional arguments:
REGEX
FILES
optional arguments:
-h, --help show this help message and exit
-d delete line
-r SUB replace match with expression
-n N size of lines preview (default=3)
```

4
TODO.md Normal file
View File

@ -0,0 +1,4 @@
- Refactor code, break into functions and clean everything
- Add unit tests
- Improve efficiency
- (maybe?) Add append and prepend flags (already possible with sub expr, so maybe not needed)

View File

@ -1 +1,3 @@
colorama==0.4.3 colorama==0.4.3
build==0.9.0
twine==4.0.1

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
name = subprompt name = subprompt
version = 0.0.4 version = 0.0.9
author = Augusto Lenz Gunsch author = Augusto Lenz Gunsch
author_email = augusto@augustogunsch.com author_email = augusto@augustogunsch.com
description = Substitute Regex in files with prompt confirmation description = Substitute Regex in files with prompt confirmation

View File

@ -25,27 +25,40 @@
import re import re
import hashlib import hashlib
import os import os
import argparse
from sys import argv, stderr from sys import argv, stderr
from colorama import init, Fore, Back, Style from colorama import init, Fore, Back, Style
init(autoreset=True) init(autoreset=True)
class Filter: class Filter:
def __init__(self, regex, sub): def __init__(self, args):
self.replace_all = False self.replace_all = False
self.quit_loop = False self.quit_loop = False
self.sub_count = 0 self.sub_count = 0
self.match_count = 0 self.match_count = 0
self.regex = re.compile(regex) self.regex = re.compile(args.regex)
self.sub = sub self.sub = args.r
self.delete = args.d
self.spread = args.n+1
def _prompt(self, matchobj, line, line_n, fname): self.lines_to_delete = []
def _number_lines(_, lines, start_n):
return [Fore.YELLOW + Style.BRIGHT +
str(n + start_n + 1) +
Fore.RESET + Style.RESET_ALL +
':' + line
for (n, line) in enumerate(lines)]
def _prompt(self, matchobj, lines, line, line_n, fname):
curr_match = matchobj.group(0) curr_match = matchobj.group(0)
if self.quit_loop: if self.quit_loop:
return curr_match return curr_match
if not self.delete:
replaced_match = self.regex.sub(self.sub, curr_match) replaced_match = self.regex.sub(self.sub, curr_match)
if replaced_match == curr_match: if replaced_match == curr_match:
@ -55,24 +68,40 @@ class Filter:
if self.replace_all: if self.replace_all:
self.sub_count += 1 self.sub_count += 1
if self.delete:
self.lines_to_delete.append(line_n)
return None
return replaced_match return replaced_match
start_n = max(0, line_n - self.spread)
end_n = min(len(lines)+1, line_n + self.spread)
rel_line_n = line_n-start_n
cut = lines[start_n:end_n]
highlighted = line.replace(curr_match, highlighted = line.replace(curr_match,
Back.RED + curr_match + Back.RESET) Back.RED + curr_match + Back.RESET)
replaced = line.replace(curr_match,
cut_highlighted = cut
cut_highlighted[rel_line_n] = highlighted
cut_highlighted = self._number_lines(cut_highlighted, start_n)
cut_replaced = cut
if not self.delete:
cut_replaced[rel_line_n] = line.replace(curr_match,
Back.YELLOW + Fore.BLACK + replaced_match + Back.RESET + Fore.RESET) Back.YELLOW + Fore.BLACK + replaced_match + Back.RESET + Fore.RESET)
else:
cut_replaced.pop(rel_line_n)
cut_replaced = self._number_lines(cut_replaced, start_n)
print(Fore.GREEN + Style.BRIGHT + fname) print(Fore.GREEN + Style.BRIGHT + fname)
print(Fore.YELLOW + Style.BRIGHT + str(line_n), end='') print(''.join(cut_highlighted))
print(':{0}'.format(highlighted))
print('Becomes the following:') print('Becomes the following:')
print(Fore.YELLOW + Style.BRIGHT + str(line_n), end='')
print(':{0}'.format(replaced))
print() print()
print(''.join(cut_replaced))
while True: while True:
answer = input('Confirm the change? [Y/n/a/q] ').lower() answer = input('Confirm the change? [Y/n/a/q] ').lower()
@ -80,6 +109,9 @@ class Filter:
if answer in ['y', 'yes', '']: if answer in ['y', 'yes', '']:
self.sub_count += 1 self.sub_count += 1
print() print()
if self.delete:
self.lines_to_delete.append(line_n)
return None
return replaced_match return replaced_match
elif answer in ['n', 'no']: elif answer in ['n', 'no']:
@ -89,6 +121,9 @@ class Filter:
elif answer in ['a', 'all']: elif answer in ['a', 'all']:
self.sub_count += 1 self.sub_count += 1
self.replace_all = True self.replace_all = True
if self.delete:
self.lines_to_delete.append(line_n)
return None
return replaced_match return replaced_match
elif answer in ['q', 'quit']: elif answer in ['q', 'quit']:
@ -101,17 +136,23 @@ class Filter:
with open(fname, 'r') as file: with open(fname, 'r') as file:
contents = file.read() contents = file.read()
lines = contents.splitlines() lines = contents.splitlines(keepends=True)
lines = [ lines = [
self.regex.sub( self.regex.sub(
lambda matchobj: self._prompt(matchobj, line, line_n, fname), lambda matchobj: self._prompt(matchobj, lines, line, line_n, fname),
line line
) )
for (line_n, line) in enumerate(lines) for (line_n, line) in enumerate(lines)
] ]
new_contents = '\n'.join(lines) if self.delete:
for line in reversed(self.lines_to_delete):
lines.pop(line)
self.lines_to_delete = []
new_contents = ''.join(lines)
hash_old = hashlib.md5(contents.encode()) hash_old = hashlib.md5(contents.encode())
hash_new = hashlib.md5(new_contents.encode()) hash_new = hashlib.md5(new_contents.encode())
@ -119,26 +160,29 @@ class Filter:
if hash_old.digest() != hash_new.digest(): if hash_old.digest() != hash_new.digest():
with open(fname, 'w') as file: with open(fname, 'w') as file:
file.write(new_contents) file.write(new_contents)
file.write('\n')
def run(args): def run(args):
if len(args) < 3: parser = argparse.ArgumentParser(description='Modifies lines matched by a regex interactively')
print('usage: subprompt [REGEX] [SUB] [FILES...]', file=stderr) parser.add_argument('regex', metavar='REGEX', type=str)
exit(1) action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('-d', help='delete line', action='store_true')
action.add_argument('-r', help='replace match with expression', type=str, metavar='SUB')
parser.add_argument('files', metavar='FILES', nargs='+', type=str)
parser.add_argument('-n', help='size of lines preview (default=3)', type=int, default=3)
regex = args[0] args = parser.parse_args(args)
sub = args[1]
files = args[2:]
filter_obj = Filter(regex, sub) filter_obj = Filter(args)
for file in files: for file in args.files:
# Check if file is writable # Check if file is writable
if os.access(file, os.W_OK): if os.access(file, os.W_OK):
filter_obj.filter_file(file) filter_obj.filter_file(file)
if filter_obj.quit_loop: if filter_obj.quit_loop:
break break
else:
print('WARNING: File {0} is not writable. Skipping...'.format(file))
if filter_obj.match_count == 0: if filter_obj.match_count == 0:
print(Fore.RED + Style.BRIGHT + 'No matches found.') print(Fore.RED + Style.BRIGHT + 'No matches found.')
@ -148,8 +192,7 @@ def run(args):
Fore.WHITE + str(filter_obj.match_count) + Fore.YELLOW)) Fore.WHITE + str(filter_obj.match_count) + Fore.YELLOW))
def main(): def main():
argv.pop(0) run(argv[1:])
run(argv)
if __name__ == '__main__': if __name__ == '__main__':
main() main()