#!/usr/bin/python3 # The MIT License (MIT) # # Copyright © 2020-2025 pacman64 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the “Software”), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from math import ceil from re import compile from sys import argv, exit, stderr, stdin, stdout from typing import Iterable, List col_sep = '█' tab_stop = 4 max_auto_width = 80 info = ''' sbs [column count...] [filepaths/URIs...] Side-By-Side lays out lines read from all inputs given into several columns, separating them with a special symbol. If no named inputs are given, lines are read from the standard input instead; names can refer to files, but can also be HTTP/HTTPS URIs. If a column-count isn't given, the script tries to find the most columns which can fit a reasonable width-limit; when even a single column can't fit that limit, it simply emits all lines, which is the same as using 1 column. Trailing carriage-returns from input lines are ignored; all output lines end with a line-feed. ''' # a leading help-option arg means show the help message and quit if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'): print(info.strip()) exit(0) # ansi_re matches ANSI-style sequences, and is used by func unstyled_width ansi_re = compile('\x1b\\[[0-9;]*[A-Za-z]') def can_fit(lines: List[str], ncols: int, sep: str, width: int) -> bool: ''' Check if lines laid out on the column-count given can fit the horizontal width given. ''' if len(lines) == 0 or ncols < 1: return True total = 0 colcount = 0 sepwidth = (len(sep) + 2) for w in col_widths(lines, ncols): total += w colcount += 1 if total + sepwidth * max(colcount - 1, 0) > width: return False return total + sepwidth * max(colcount - 1, 0) <= width def unstyled_width(s: str) -> int: 'Count symbols in strings, excluding ANSI-styles.' extra = sum(m.end() - m.start() for m in ansi_re.finditer(s)) return len(s) - extra def output_height(lines: List[str], numcols: int) -> int: return int(ceil(len(lines) / numcols)) def col_widths(lines: List[str], numcols: int) -> Iterable: 'Find the max widths for all columns.' if len(lines) == 0 or numcols < 1: return tuple() height = output_height(lines, numcols) for start in range(0, len(lines), height): # don't go out of bounds on the last sub-slice end = min(start + height, len(lines)) if start == end: continue # find the max line-width in the current sub-slice, which # acts as a `virtual` column for the output yield max(unstyled_width(lines[j]) for j in range(start, end)) def sbs(w, lines: List[str], numcols: int, colsep: str) -> None: if len(lines) == 0 or numcols < 1: return if numcols < 2: for s in lines: w.write(s) w.write('\n') return # use fewer columns, when there are too few input lines numcols = min(numcols, len(lines)) height = output_height(lines, numcols) widths = tuple(col_widths(lines, numcols)) numcols = min(numcols, len(widths)) # make tuple of all runs of spaces up to the most needed to pad columns max_spaces = max(widths) if len(widths) > 0 else 0 spaces = tuple(i * ' ' for i in range(max_spaces + 1)) colsep = f' {colsep}' for row in range(height): for col in range(numcols): w.write(colsep if col > 0 else '') k = col * height + row s = lines[k] if k < len(lines) else '' if col < numcols - 1: w.write(' ' if col > 0 else '') w.write(s) # right-pad column with spaces, to align the next one w.write(spaces[widths[col] - unstyled_width(s)]) elif s: # empty-string check avoided extra trailing spaces on last # columns; original input lines can still have trailers w.write(' ' if col > 0 else '') w.write(s) w.write('\n') def find_fit(lines: List[str], col_sep: str, max_width: int) -> int: ''' Find max columns which can fit a predetermined n-symbols width; if that's not possible for any column-count, stick to 1 column. ''' if len(lines) == 0 or max(unstyled_width(l) for l in lines) > max_width: return 1 max_possible_cols = int(max_width / (len(col_sep) + 2)) most = min(max_possible_cols, len(lines)) # it's sometimes possible to fit more columns after the lowest # number of columns which fails the test, so loop backward for cols in range(most, 2, -1): if can_fit(lines, cols, col_sep, max_width): return cols return 1 def seems_url(s: str) -> bool: protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:') return any(s.startswith(p) for p in protocols) def handle_lines(paths: List[str], handle, expand) -> None: ''' Read all lines from all input sources needed, deferring how exactly to handle each line to the funcs given. ''' def fix_line(line: str) -> str: return expand(line.rstrip('\r\n').rstrip('\n')) if paths.count('-') > 1: msg = 'reading from `-` (standard input) more than once not allowed' raise ValueError(msg) if any(seems_url(e) for e in paths): from urllib.request import urlopen for path in paths: if path == '-': for line in stdin: handle(fix_line(line)) continue if seems_url(path): with urlopen(path) as inp: for line in inp: handle(fix_line(str(line, encoding='utf-8'))) continue with open(path, encoding='utf-8') as inp: for line in inp: handle(fix_line(line)) # read from stdin, when given no paths if len(paths) == 0: for line in stdin: handle(fix_line(line)) args = argv[1:] # handle optional leading number of columns to use num_cols = 0 got_cols = False try: n = int(args[0]) if n > 0: num_cols = n got_cols = True args = args[1:] except Exception: pass try: if got_cols and num_cols == 1: # no need to remember lines, which takes more memory and time def writeln(s: str) -> None: stdout.write(s) stdout.write('\n') # handle_lines(args, writeln, lambda s: s) handle_lines(args, writeln, lambda s: s.expandtabs(tab_stop)) exit(0) # read all lines to handle the normal case, with multiple columns lines = [] ts = tab_stop handle_lines(args, lambda s: lines.append(s), lambda s: s.expandtabs(ts)) if not got_cols: num_cols = find_fit(lines, col_sep, max_auto_width) sbs(stdout, lines, num_cols, col_sep) except BrokenPipeError: # quit quietly, instead of showing a confusing error message stderr.close() except KeyboardInterrupt: exit(2) except Exception as e: print(f'\x1b[31m{e}\x1b[0m', file=stderr) exit(1)