Skip to content

Commit a9c63f6

Browse files
committed
Improve Python scripts: type hints, bug fixes, cleaner code
print_lines.py: - Fix UnboundLocalError when file is empty (count_lines uses sum()) - Use shutil.which() instead of wasteful subprocess.check_call() - Proper main() function for clean imports - Better error handling with specific exception types unique_files_search.py: - Add type hints throughout - Search full path, not just filename (e.g., "work" finds work/todo.md) - Extract helper functions for better testability - Use splitlines() instead of split('\n') shorten_path_for_notational_fzf.py: - Add type hints and docstrings - Cleaner code structure with main() function - Remove commented-out code - Better variable naming
1 parent c29ed19 commit a9c63f6

3 files changed

Lines changed: 226 additions & 177 deletions

File tree

print_lines.py

Lines changed: 77 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,90 @@
33
"""Colorize the current line in the preview window in bold red."""
44

55
import os.path as path
6-
import sys
6+
import shutil
77
import subprocess
8+
import sys
89

9-
line = int(sys.argv[1])
10-
file = sys.argv[2]
11-
height = int(sys.argv[3])
10+
# ANSI escape sequences
11+
RED = "\033[1;31m"
12+
RESET = "\033[0;0m"
13+
BOLD = "\033[;1m"
1214

1315

14-
def opcount(fname):
16+
def count_lines(fname: str) -> int:
17+
"""Count lines in a file, returning 0 on error."""
1518
try:
1619
with open(fname, encoding="utf-8", errors="replace") as f:
17-
for i, _ in enumerate(f):
18-
pass
19-
return i + 1
20-
except Exception:
20+
return sum(1 for _ in f)
21+
except (OSError, IOError):
2122
return 0
2223

2324

24-
if __name__ == "__main__":
25+
def preview_with_bat(filepath: str, target_line: int, height: int) -> bool:
26+
"""Preview file using bat. Returns True on success."""
27+
if not shutil.which("bat"):
28+
return False
29+
30+
lines = count_lines(filepath)
31+
cmd = [
32+
"bat",
33+
"--style=numbers",
34+
"--color=always",
35+
f"--highlight-line={target_line}",
36+
]
37+
38+
# Center the view around the target line if possible
39+
if target_line > height // 2 and lines >= height:
40+
start = max(1, target_line - height // 2)
41+
cmd.append(f"--line-range={start}:{lines}")
42+
43+
cmd.append(path.normpath(filepath))
44+
45+
try:
46+
subprocess.run(cmd, check=False)
47+
return True
48+
except (OSError, subprocess.SubprocessError):
49+
return False
50+
51+
52+
def preview_with_fallback(filepath: str, target_line: int, height: int) -> None:
53+
"""Preview file with basic Python fallback (no bat)."""
2554
try:
26-
# fail fast
27-
subprocess.check_call(["bat"])
28-
lines = opcount(file)
29-
cmd = [
30-
"bat",
31-
"--style=numbers",
32-
"--color=always",
33-
"--highlight-line={}".format(line),
34-
]
35-
if (line > (height / 2)) and (lines >= height):
36-
cmd.append("--line-range={}:{}".format(int(line - (height / 2)), lines))
37-
cmd.append(path.normpath(file))
38-
subprocess.run(cmd)
39-
except Exception:
40-
is_sel = False
41-
# ANSI escape sequences for coloring matched line
42-
RED = "\033[1;31m"
43-
RESET = "\033[0;0m"
44-
BOLD = "\033[;1m"
45-
try:
46-
with open(path.normpath(file), encoding="utf-8", errors="replace") as f:
47-
for linenum, line_content in enumerate(f, start=1):
48-
if linenum == line:
49-
print(BOLD + RED + line_content.rstrip() + RESET)
50-
is_sel = True
51-
elif is_sel or (line - linenum <= (height / 2 - 1)):
52-
print(line_content.rstrip())
53-
except Exception:
54-
# If file can't be read, print error message
55-
print(RED + "Error: Could not read file" + RESET)
55+
with open(path.normpath(filepath), encoding="utf-8", errors="replace") as f:
56+
lines = list(f)
57+
except (OSError, IOError):
58+
print(f"{RED}Error: Could not read file{RESET}")
59+
return
60+
61+
# Calculate window around target line
62+
start = max(0, target_line - height // 2 - 1)
63+
end = min(len(lines), start + height)
64+
65+
for i in range(start, end):
66+
linenum = i + 1
67+
content = lines[i].rstrip()
68+
if linenum == target_line:
69+
print(f"{BOLD}{RED}{content}{RESET}")
70+
else:
71+
print(content)
72+
73+
74+
def main() -> None:
75+
if len(sys.argv) < 4:
76+
print(f"Usage: {sys.argv[0]} <line> <file> <height>", file=sys.stderr)
77+
sys.exit(1)
78+
79+
try:
80+
target_line = int(sys.argv[1])
81+
filepath = sys.argv[2]
82+
height = int(sys.argv[3])
83+
except ValueError as e:
84+
print(f"Error: Invalid argument: {e}", file=sys.stderr)
85+
sys.exit(1)
86+
87+
if not preview_with_bat(filepath, target_line, height):
88+
preview_with_fallback(filepath, target_line, height)
89+
90+
91+
if __name__ == "__main__":
92+
main()

shorten_path_for_notational_fzf.py

Lines changed: 63 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,113 @@
1-
#!/usr/bin/env pypy3
2-
# encoding: utf-8
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Shorten file paths for notational-fzf-vim display.
35
4-
# Supposedly, importing so that you don't need dots in names speeds up a
5-
# script, and the point of this one is to run fast.
6+
Reads lines in format `filename:linenum:contents` from stdin,
7+
outputs `filename:linenum:shortname:linenum:contents` with colored paths.
8+
"""
9+
10+
from __future__ import annotations
611

712
import platform
13+
import sys
814
from os import pardir
915
from os.path import abspath, expanduser, join, sep, split, splitdrive
1016
from pathlib import PurePath
11-
from sys import stdin
1217

18+
# ANSI color codes
19+
GREEN = "\033[32m"
20+
PURPLE = "\033[35m"
21+
CYAN = "\033[36m"
22+
RESET = "\033[0m"
1323

14-
# These are floated to the top so they aren't recalculated every loop. The
15-
# most restrictive replacements should come earlier.
24+
# Path replacements (most restrictive first)
1625
REPLACEMENTS = ("", pardir, "~")
17-
old_paths = [abspath(expanduser(replacement)) for replacement in REPLACEMENTS]
26+
OLD_PATHS = [abspath(expanduser(r)) for r in REPLACEMENTS]
1827
IS_WINDOWS = platform.system().lower() == "windows"
1928

2029

30+
def color(text: str, color_code: str) -> str:
31+
"""Wrap text in ANSI color codes."""
32+
return f"{color_code}{text}{RESET}"
33+
34+
2135
def prettyprint_path(path: str, old_path: str, replacement: str) -> str:
22-
# Pretty print the path prefix
36+
"""Replace path prefix and shorten remaining components to first char."""
2337
path = path.replace(old_path, replacement, 1)
24-
# Truncate the rest of the path to a single character.
25-
short_path = join(replacement, *[x[0] for x in PurePath(path).parts[1:]])
38+
parts = PurePath(path).parts[1:] # Skip the replacement prefix
39+
short_path = join(replacement, *(p[0] for p in parts))
2640
return short_path
2741

2842

29-
def shorten(path: str):
30-
"""Returns 2 strings, the shortened parent directory and the filename."""
31-
# We don't want to shorten the filename, just its parent directory, so we
32-
# `split()` and just shorten `path`.
43+
def shorten(path: str) -> tuple[str, str]:
44+
"""Returns shortened parent directory and filename."""
3345
path, filename = split(path)
3446

35-
# use empty replacement for current directory. it expands correctly
36-
37-
for replacement, old_path in zip(REPLACEMENTS, old_paths):
47+
for replacement, old_path in zip(REPLACEMENTS, OLD_PATHS):
3848
if path.startswith(old_path):
3949
short_path = prettyprint_path(path, old_path, replacement)
40-
# to avoid multiple replacements
4150
break
42-
43-
# If no replacement was found, shorten the entire path.
4451
else:
45-
short_path = join(*[x[0] for x in PurePath(path).parts])
52+
# No replacement matched - shorten entire path
53+
parts = PurePath(path).parts
54+
short_path = join(*(p[0] for p in parts)) if parts else ""
4655

4756
return short_path, filename
4857

4958

50-
GREEN = "\033[32m"
51-
PURPLE = "\033[35m" # looks pink to me
52-
CYAN = "\033[36m"
53-
54-
RESET = "\033[0m"
55-
56-
# RED = '\033[31m'
57-
# BLUE = '\033[34m'
58-
# LIGHTRED = '\033[91m'
59-
# YELLOW = '\033[93m'
60-
# LIGHTBLUE = '\033[94m'
61-
# LIGHTCYAN = '\033[96m'
62-
63-
64-
def color(line, color):
65-
return color + line + RESET
66-
67-
6859
def process_line(line: str) -> str:
69-
# Expected format is colon separated `name:line number:contents`
70-
60+
"""Process a single line from ripgrep output."""
61+
# Handle Windows drive letters (e.g., C:\path)
7162
if IS_WINDOWS:
72-
# Windows paths may contain a colon, e.g. C:\Windows\ which messes up the split
73-
# splitdrive(string) results in the following:
74-
# Windows drive letter, e.g. C:\Windows\Folder\Foo.txt -> ('C', '\Windows\Folder\Foo.txt')
75-
# Windows UNC path, e.g. \\Server\Share\Folder\Foo.txt -> ('\\Server\Share', '\Folder\Foo.txt')
76-
# *nix, e.g. /any/path/to/file.txt -> ('', '/any/path/to/file.txt')
77-
_, line = splitdrive(line) # Toss the drive letter since it's not necessary.
78-
filename, linenum, contents = line.split(sep=":", maxsplit=2)
79-
80-
# Drop trailing newline.
63+
_, line = splitdrive(line)
64+
65+
# Parse filename:linenum:contents
66+
try:
67+
filename, linenum, contents = line.split(sep=":", maxsplit=2)
68+
except ValueError:
69+
return line # Return unchanged if format unexpected
70+
8171
contents = contents.rstrip()
8272

83-
# Normalize path for further processing.
73+
# Normalize path (skip on Windows to avoid prepending cwd)
8474
if not IS_WINDOWS:
85-
# This prepends cwd in Windows which is unnecessary.
8675
filename = abspath(filename)
8776

8877
shortened_parent, basename = shorten(filename)
89-
# The conditional is to avoid a leading slash if the parent is replaced
90-
# with an empty directory. The slash is manually colored because otherwise
91-
# `os.path.join` won't do it.
78+
79+
# Build colored short name
9280
if shortened_parent:
93-
colored_short_name = color(shortened_parent + sep, PURPLE) + color(
94-
basename, CYAN
95-
)
81+
colored_short_name = color(shortened_parent + sep, PURPLE) + color(basename, CYAN)
9682
else:
9783
colored_short_name = color(basename, CYAN)
9884

99-
# Format is: long form, line number, short form, line number, rest of line. This is so Vim can process it.
100-
formatted_line = ":".join(
101-
[
102-
color(filename, CYAN),
103-
color(linenum, GREEN),
104-
colored_short_name,
105-
color(linenum, GREEN),
106-
contents,
107-
]
108-
)
109-
return formatted_line
110-
111-
# We print the long and short forms, and one form is picked in the Vim script that uses this.
112-
# print(formatted_line)
85+
# Output format: long_path:linenum:short_path:linenum:contents
86+
return ":".join([
87+
color(filename, CYAN),
88+
color(linenum, GREEN),
89+
colored_short_name,
90+
color(linenum, GREEN),
91+
contents,
92+
])
11393

11494

115-
if __name__ == "__main__":
116-
# Use stdin.buffer to handle binary data, then decode with error handling
117-
# This prevents the script from crashing on files with non-UTF-8 content
118-
for raw_line in stdin.buffer:
95+
def main() -> None:
96+
"""Read from stdin and process each line."""
97+
for raw_line in sys.stdin.buffer:
11998
try:
12099
line = raw_line.decode("utf-8")
121100
print(process_line(line))
122101
except UnicodeDecodeError:
123-
# Skip lines that can't be decoded as UTF-8 (e.g., binary files)
124-
# Try latin-1 as fallback since it can decode any byte sequence
102+
# Try latin-1 as fallback (can decode any byte sequence)
125103
try:
126104
line = raw_line.decode("latin-1")
127105
print(process_line(line))
128106
except Exception:
129-
# If all else fails, skip this line entirely
130-
pass
107+
pass # Skip completely malformed lines
131108
except Exception:
132-
# Skip lines that cause other errors (malformed input, etc.)
133-
pass
109+
pass # Skip lines that cause other errors
110+
111+
112+
if __name__ == "__main__":
113+
main()

0 commit comments

Comments
 (0)