qol-scripts/ls.py

305 lines
10 KiB
Python
Executable File

#!/usr/bin/python3
import os
import sys
import pwd
import grp
import time
import argparse
from enum import Enum
import subprocess
import functools
from collections import namedtuple
# ANSI escape codes for colors
COLORS = {
'RESET': '\033[0m',
'BOLD': '\033[1m',
'DIM': '\033[2m',
'RED': '\033[91m',
'GREEN': '\033[92m',
'YELLOW': '\033[93m',
'BLUE': '\033[94m',
'MAGENTA': '\033[95m',
'CYAN': '\033[96m',
'WHITE': '\033[97m'
}
class Filetype(Enum):
S_IFMT = 0o0170000
S_IFSOCK = 0o140000
S_IFLNK = 0o120000
S_IFREG = 0o100000
S_IFBLK = 0o060000
S_IFDIR = 0o040000
S_IFCHR = 0o020000
S_IFIFO = 0o010000
ERR = 0
FileMeta = namedtuple("FileMeta", "name rel_file_path abs_file_path stats")
Permissions = namedtuple("Permissions", "mode type char color prettybits")
FileString = namedtuple("FileString", ["path", "file", "file_color",
"path_color", "colored_path",
"colored_file", "colored_filepath",
"link_sep", "link_dest",
"link_dest_full", "link_color",
"colored_link_dest"
], defaults=['','','','',''])
def colorwrap(text, color):
return f"{COLORS[color]}{text}{COLORS['RESET']}"
def escape(str):
if ' ' in str:
return '\'' + str + '\''
return str
def sizeof_fmt(num):
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
if abs(num) < 1024.0:
if unit == "": return f"{num}" #exception to not do .0 for bytes
return f"{num:3.1f}{unit}"
num /= 1024.0
return f"{num:.1f}Y"
def parse_permissions(mode):
char = "-"
color = "WHITE"
prettybits = ""
filetype = Filetype(mode & Filetype.S_IFMT.value) or Filetype.ERR
match filetype:
case Filetype.S_IFSOCK:
char = 's'
color = "MAGENTA"
case Filetype.S_IFLNK:
char = 'l'
color = "CYAN"
case Filetype.S_IFREG:
char = '-'
if mode & 0o100 or mode & 0o010 or mode & 0o001: # is file executable?
color = "GREEN"
else:
color = "WHITE"
case Filetype.S_IFBLK:
char = 'b'
color = "YELLOW"
case Filetype.S_IFDIR:
char = 'd'
color = "BLUE"
case Filetype.S_IFCHR:
char = 'c'
color = "YELLOW"
case Filetype.S_IFIFO:
char = 'f'
color = "MAGENTA"
case _:
char = '?'
color = "RED"
prettybits = char + ''.join([
"r" if mode & 0o400 else "-",
"w" if mode & 0o200 else "-",
"x" if mode & 0o100 else "-",
"r" if mode & 0o040 else "-",
"w" if mode & 0o020 else "-",
"x" if mode & 0o010 else "-",
"r" if mode & 0o004 else "-",
"w" if mode & 0o002 else "-",
"x" if mode & 0o001 else "-"
])
#return {'mode':mode, 'type': filetype, 'char': char, 'color': color, 'prettybits': prettybits }
return Permissions(mode, filetype, char, color, prettybits)
def file_string_gen(filepath, mode):
path, file = os.path.split(filepath)
perms = parse_permissions(mode)
file_color = perms.color
path_color = 'DIM'
colored_path = colorwrap(path+'/', path_color) if path else ''
colored_file = colorwrap(file, file_color)
colored_filepath = colored_path + colored_file
# links
if perms.type == Filetype.S_IFLNK:
link_sep = ' -> '
link_dest = os.readlink(filepath)
link_dest_real = os.path.join(os.path.dirname(filepath), os.readlink(filepath))
link_color = "GREEN" if os.path.exists(link_dest_real) else "RED"
colored_link_dest = colorwrap(link_dest, link_color)
return FileString(path, file, file_color, path_color, colored_path, colored_file, colored_filepath, link_sep, link_dest, link_dest_real, link_color, colored_link_dest)
return FileString(path, file, file_color, path_color, colored_path, colored_file, colored_filepath)
def is_path_git_repo_dir(path, mode):
#I hate having to take in the mode here, but it makes it easier for usage
return Filetype(mode & Filetype.S_IFMT.value) == Filetype.S_IFDIR and os.path.exists(os.path.join(path, '.git'))
def git_gud(path):
try:
output = subprocess.check_output(['git', 'status', '--porcelain'], cwd=path).strip().decode("utf-8")
if output.strip():
lines=output.split('\n')
mods = len([x for x in lines if x[0] == 'M'])
unt = len([x for x in lines if x[0] == '?'])
ding = ''
ding += colorwrap('!'+str(mods), "YELLOW") if mods > 0 else ''
ding += ' ' if mods > 0 and unt > 0 else ''
ding += colorwrap('?'+str(unt), "CYAN") if unt > 0 else ''
return ding
# output = subprocess.check_output(['git', 'rev-list', '--count', '--left-only', '@{u}...HEAD'], cwd=file_path, stderr=subprocess.DEVNULL)
# if int(output.strip()) > 0:
# print("bbbb")
except (subprocess.CalledProcessError, FileNotFoundError):
pass
return ''
def short_listing(files_meta):
for file in files_meta:
colored_file = file_string_gen(file.name, file.stats.st_mode).colored_filepath
print(colored_file)
#colored_file = gen_color_file_string(file.abs_file_path, file.stats.st_mode)
# rel is broken
#print(colored_file)
def long_listing(files_meta):
use_full_path = True
print(f"ls.py {' '.join(sys.argv[1:])}")
print(f"files: {len(files_meta)}")
term_width = os.get_terminal_size().columns
longest_size_string_len = len(str(functools.reduce(lambda a, b: a if len(str(a.stats.st_size)) > len(str(b.stats.st_size)) else b, files_meta).stats.st_size))
if use_full_path:
longest_filename_string_len = len(functools.reduce(lambda a, b: a if len(a.abs_file_path) > len(b.abs_file_path) else b, files_meta).abs_file_path)
else:
longest_filename_string_len = len(functools.reduce(lambda a, b: a if len(a.rel_file_path) > len(b.rel_file_path) else b, files_meta).rel_file_path)
if longest_filename_string_len + longest_size_string_len + 60 > term_width:
use_full_path = False
for file in files_meta:
size = file.stats.st_size
user = pwd.getpwuid(file.stats.st_uid).pw_name
group = grp.getgrgid(file.stats.st_gid).gr_name
permissions = parse_permissions(file.stats.st_mode)
nlinks = file.stats.st_nlink
perm_prettybits = permissions.prettybits
timestring = time.strftime("%b %d %H:%M", time.localtime(file.stats.st_mtime))
if args.sparse:
if perm_prettybits == 'l':
perm_prettybits = 'l '
if permissions.type == Filetype.S_IFDIR:
size=""
if args.human:
size=sizeof_fmt(size)
if args.full_time:
timestring = time.strftime("%b %d %Y %X %z", time.localtime(file.stats.st_mtime)) # -0400
#mtime = time.strftime("%b %d %X %Z", time.localtime(stats.st_mtime)) # CST
file_strings = file_string_gen(file.abs_file_path, file.stats.st_mode)
if use_full_path:
file_str = ' ' + escape(file_strings.colored_filepath) + file_strings.link_sep + file_strings.colored_link_dest
else:
file_str = ' ' + escape(file_strings.colored_file) + file_strings.link_sep + file_strings.colored_link_dest
#print(file_string_gen(file["abs_file_path"], file["stats"].st_mode))
gitstat = git_gud(file.abs_file_path) if (args.extend and is_path_git_repo_dir(file.abs_file_path, file.stats.st_mode)) else ''
## do printing
print(f"{perm_prettybits} {nlinks:2d} {user:<8s} {group:<8s} {size:>{longest_size_string_len}} {timestring} {file_str} {gitstat}")
def list_files(directory='.', args={}):
dir_filenames = os.listdir(directory)
#get file metadata
files = []
# since os.listdir does not handle . or .. dirs
files += [FileMeta('.', directory + '/.', os.path.abspath(directory )+'/.', os.lstat(directory +'/.'))]
files += [FileMeta('..', directory + '/..', os.path.abspath(directory )+'/..', os.lstat(directory +'/..'))]
for name in dir_filenames:
rel_file_path = os.path.join(directory, name) # for rel paths
abs_file_path = os.path.abspath(rel_file_path) # for full path
stats = os.lstat(rel_file_path)
files.append(FileMeta(name, rel_file_path, abs_file_path, stats))
# filter out options
if not args.all:
files = [file for file in files if not file.name.startswith('.')]
# Sort stuff
if args.time_sorted:
files.sort(key=lambda f: f.stats.st_mtime, reverse=(not args.reverse))
elif args.size_sorted:
files.sort(key=lambda f: f.stats.st_size, reverse=(not args.reverse))
else:
files.sort(key=lambda f:f.name, reverse=(args.reverse))
# normal listing basically
if not args.long:
short_listing(files)
else:
long_listing(files)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="List files in a directory", add_help=False)
parser.add_argument('directory', nargs='?', default='.', help="Directory path")
# filter options
parser.add_argument('-a', '--all', action='store_true', help="Show hidden files")
# output information options
parser.add_argument('-l', '--long', action='store_true', help="Use a long listing format")
parser.add_argument('-F', '--full-time', action='store_true', help="Show full full time")
parser.add_argument('-s', '--sparse', action='store_true', help="Blank irrelevant information")
# ouput format options
sortargs = parser.add_mutually_exclusive_group()
sortargs.add_argument('-t', '--time-sorted', action='store_true', help="Sort by time")
sortargs.add_argument('-S', '--size-sorted', action='store_true', help="Sort by file size")
parser.add_argument('-r', '--reverse', action='store_true', help="Reverse sort order")
parser.add_argument('-h', '--human', action='store_true', help="Human Readable size")
# extra
parser.add_argument('-e', '--extend', action='store_true', help="Do some extra processing on the directory")
args = parser.parse_args()
list_files(args.directory, args)