Compare commits

...

8 Commits

2 changed files with 88 additions and 34 deletions

View File

@ -12,7 +12,7 @@ Usage
----- -----
``` ```
kitsune [--match-filename pattern] [paths] kitsune [--match-filename pattern] [--start] [paths]
``` ```
There are zero or more paths. If 0 paths are provided, "." (the current There are zero or more paths. If 0 paths are provided, "." (the current
@ -26,10 +26,13 @@ any new file matching pattern will also added.
pattern is a standard shell glob pattern (as implemented by Pythons pattern is a standard shell glob pattern (as implemented by Pythons
fnmatch module). fnmatch module).
The output contains of the file name, a timestamp and the contents of By default, kitsune will print only content added after program start.
each line for each line in the watched files. It pads the file names to The `--start` option will dump each file from the beginning.
the length of the longest file name seen so far, so the three columns
should line up nicely. The output contains of the file name, a timestamp, a deletion indicator
and the contents of each line for each line in the watched files. It
pads the file names to the length of the longest file name seen so far,
so the three columns should line up nicely.
Name Name
---- ----

109
kitsune
View File

@ -9,19 +9,29 @@ import stat
import time import time
class WatchedFile: class WatchedFile:
def __init__(self, path): def __init__(self, path, seek_to_end):
self.path = path self.path = path
self.reopen() self.seek_to_end = seek_to_end
stf = os.stat(self.path)
self.st_ctime_ns = stf.st_ctime_ns
self.st_size = stf.st_size
self.fileno = None
if stf.st_ctime_ns >= time.time_ns() - 1E9:
self.reopen()
def reopen(self): def reopen(self):
self.fd = open(self.path, errors='replace') self.fd = open(self.path, errors='replace')
self.fileno = self.fd.fileno() self.fileno = self.fd.fileno()
self.last_ts = 0 self.last_ts = 0
if self.seek_to_end:
self.fd.seek(self.st_size, 0)
self.seek_to_end = False
def format_ts(ts_ns): def format_ts(ts_ns):
return datetime.datetime.fromtimestamp(ts_ns / 1E9).strftime("%H:%M:%S.%f") return datetime.datetime.fromtimestamp(ts_ns / 1E9).strftime("%H:%M:%S.%f")
def watch(args): def watch(args):
seek_to_end = not args.start
watched_dirs = [] watched_dirs = []
watched_files = {} watched_files = {}
filename_length = 0 filename_length = 0
@ -30,7 +40,7 @@ def watch(args):
if stat.S_ISDIR(st.st_mode): if stat.S_ISDIR(st.st_mode):
watched_dirs.append(a) watched_dirs.append(a)
elif stat.S_ISREG(st.st_mode): elif stat.S_ISREG(st.st_mode):
watched_files[a] = WatchedFile(a) watched_files[a] = WatchedFile(a, seek_to_end)
if len(a) > filename_length: if len(a) > filename_length:
filename_length = len(a) filename_length = len(a)
else: else:
@ -44,40 +54,81 @@ def watch(args):
if st.st_mtime_ns > last_ts: if st.st_mtime_ns > last_ts:
last_ts = st.st_mtime_ns last_ts = st.st_mtime_ns
for de in os.scandir(d): for de in os.scandir(d):
if de.is_file(): try:
if args.match_filename is None or fnmatch.fnmatch(de.name, args.match_filename): if de.is_file():
if de.path not in watched_files: if args.match_filename is None or fnmatch.fnmatch(de.name, args.match_filename):
watched_files[de.path] = WatchedFile(de.path) if de.path not in watched_files:
if len(de.path) > filename_length: watched_files[de.path] = WatchedFile(de.path, seek_to_end)
filename_length = len(de.path) if len(de.path) > filename_length:
filename_length = len(de.path)
except (PermissionError, OSError):
# ignore,
# or maybe remove from watched_files?
# or just mark as unavailable?
pass
# has any of the files changed # has any of the files changed
for f in watched_files.values(): for f in watched_files.values():
# XXX - we should also detect replaced files. # XXX - we should also detect replaced files.
st = os.stat(f.fileno) if f.fileno:
try: st = os.stat(f.fileno)
stf = os.stat(f.path) try:
if stf.st_ino != st.st_ino: stf = os.stat(f.path)
f.reopen() if stf.st_ino != st.st_ino:
except FileNotFoundError: f.reopen()
# ignore, st = os.stat(f.fileno)
# or maybe remove from watched_files? except (FileNotFoundError, PermissionError):
# or just mark as deleted? # ignore,
pass # or maybe remove from watched_files?
if st.st_mtime_ns > f.last_ts: # or just mark as deleted?
f.last_ts = st.st_mtime_ns pass
new_content = f.fd.read() if st.st_mtime_ns > f.last_ts:
lines = new_content.split("\n") if st.st_size < f.fd.tell():
if lines[-1] == "": # We are beyond the end of the file, so it has probably
lines.pop() # been truncated and rewritten - read from beginning
for ln in lines: f.fd.seek(0, 0)
print(f"{f.path:{filename_length}}", format_ts(f.last_ts), ln) dead = "✝" if st.st_nlink == 0 else " "
f.last_ts = st.st_mtime_ns
new_content = f.fd.read()
lines = new_content.split("\n")
if lines[-1] == "":
lines.pop()
for ln in lines:
s = ""
if args.print_filename:
s += f"{f.path:{filename_length}} "
if args.print_timestamp:
s += format_ts(f.last_ts) + " "
s += dead + " " + ln
print(s)
else:
try:
stf = os.stat(f.path)
if stf.st_ctime_ns != f.st_ctime_ns:
f.reopen()
except (FileNotFoundError, PermissionError):
# ignore,
# or maybe remove from watched_files?
# or just mark as deleted?
pass
time.sleep(0.1) time.sleep(0.1)
seek_to_end = False
if __name__ == "__main__": if __name__ == "__main__":
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--match-filename") ap.add_argument("--start", action='store_true',
ap.add_argument("files", nargs="*", default=["."]) help="dump existing files from the start")
ap.add_argument("--match-filename",
help="follow only matching files in directories",
metavar="GLOB-PATTERN")
ap.add_argument("--no-filename", "--no-print-filename", action="store_false",
dest="print_filename",
help="don't print filename in each line")
ap.add_argument("--no-timestamp", "--no-print-timestamp", action="store_false",
dest="print_timestamp",
help="don't print timestamp in each line")
ap.add_argument("files", nargs="*", default=["."],
metavar="file")
args = ap.parse_args() args = ap.parse_args()
watch(args) watch(args)