#!/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)