├── .gitignore ├── LICENSE ├── README.rst ├── multitail ├── multitail.py ├── screenshot.png ├── setup.py └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | http://www.opensource.org/licenses/mit-license.php 2 | 3 | Copyright (c) 2013 Tyler Hobbs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 9 | to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or 12 | substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 16 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | multitail-curses 2 | ================ 3 | A curses-based utility script for tailing multiple files simultaneously. 4 | 5 | I made this script for quickly tailing several log files in the same terminal 6 | window. 7 | 8 | .. image:: https://raw.github.com/thobbs/multitail-curses/master/screenshot.png 9 | 10 | Installation 11 | ------------ 12 | The easiest way to install this is through pip or easy_install:: 13 | 14 | pip install -U multitail-curses 15 | 16 | You can also install from source by running:: 17 | 18 | python setup.py install 19 | 20 | Usage 21 | ----- 22 | You can tail one to four files simultaneously. For example:: 23 | 24 | multitail foo.log bar.log 25 | 26 | Press ``CTRL-C`` to stop tailing. 27 | 28 | License 29 | ------- 30 | This project is open source under the `MIT license `_. 31 | -------------------------------------------------------------------------------- /multitail: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import curses 5 | import multitail 6 | 7 | def main(stdscr, *args, **kwargs): 8 | curses.curs_set(0) 9 | multitail.tail_files(sys.argv[1:]) 10 | 11 | if __name__ == "__main__": 12 | if len(sys.argv) < 2: 13 | print >>sys.stderr, "Usage: multitail FILE [FILE2] [FILE3] [FILE4]" 14 | sys.exit(1) 15 | curses.wrapper(main) 16 | -------------------------------------------------------------------------------- /multitail.py: -------------------------------------------------------------------------------- 1 | import curses 2 | import fcntl 3 | import os 4 | import struct 5 | import termios 6 | import threading 7 | import time 8 | 9 | 10 | __version_info__ = (1, 0, 4) 11 | __version__ = '.'.join(map(str, __version_info__)) 12 | 13 | 14 | def tail_files(filenames): 15 | """ 16 | `filenames` is a list of files to tail simultaneously. 17 | """ 18 | columns, rows = _terminal_size() 19 | half_columns = columns / 2 20 | 21 | windows = [] 22 | if len(filenames) == 1: 23 | windows.append(curses.newwin(rows, columns, 0, 0)) 24 | elif len(filenames) == 2: 25 | windows.append(curses.newwin(rows, half_columns, 0, 0)) 26 | windows.append(curses.newwin(rows, half_columns, 0, half_columns)) 27 | elif len(filenames) == 3: 28 | windows.append(curses.newwin(rows / 2, half_columns, 0, 0)) 29 | windows.append(curses.newwin(rows / 2, half_columns, 0, half_columns)) 30 | windows.append(curses.newwin(rows / 2, half_columns, rows / 2, 0)) 31 | elif len(filenames) == 4: 32 | windows.append(curses.newwin(rows / 2, half_columns, 0, 0)) 33 | windows.append(curses.newwin(rows / 2, half_columns, 0, half_columns)) 34 | windows.append(curses.newwin(rows / 2, half_columns, rows / 2, 0)) 35 | windows.append(curses.newwin(rows / 2, half_columns, rows / 2, half_columns)) 36 | 37 | lock = threading.Lock() 38 | threads = set() 39 | for filename, window in zip(filenames, windows): 40 | window.border() 41 | thread = threading.Thread( 42 | target=_tail_in_window, args=(filename, window, lock)) 43 | thread.daemon = True 44 | threads.add(thread) 45 | thread.start() 46 | 47 | try: 48 | while threads: 49 | alive_threads = set() 50 | for thread in threads: 51 | thread.join(1) 52 | if thread.is_alive(): 53 | alive_threads.add(thread) 54 | threads = alive_threads 55 | except KeyboardInterrupt: 56 | return 57 | 58 | 59 | def _seek_to_n_lines_from_end(f, numlines=10): 60 | """ 61 | Seek to `numlines` lines from the end of the file `f`. 62 | """ 63 | buf = "" 64 | buf_pos = 0 65 | f.seek(0, 2) # seek to the end of the file 66 | line_count = 0 67 | 68 | while line_count < numlines: 69 | newline_pos = buf.rfind("\n", 0, buf_pos) 70 | file_pos = f.tell() 71 | 72 | if newline_pos == -1: 73 | if file_pos == 0: 74 | # start of file 75 | break 76 | else: 77 | toread = min(1024, file_pos) 78 | f.seek(-toread, 1) 79 | buf = f.read(toread) + buf[:buf_pos] 80 | f.seek(-toread, 1) 81 | buf_pos = len(buf) - 1 82 | else: 83 | # found a line 84 | buf_pos = newline_pos 85 | line_count += 1 86 | 87 | if line_count == numlines: 88 | f.seek(buf_pos + 1, 1) 89 | 90 | 91 | def _tail(filename, starting_lines=10): 92 | """ 93 | A generator for reading new lines off of the end of a file. To start with, 94 | the last `starting_lines` lines will be read from the end. 95 | """ 96 | f = open(filename) 97 | current_size = os.stat(filename).st_size 98 | _seek_to_n_lines_from_end(f, starting_lines) 99 | while True: 100 | new_size = os.stat(filename).st_size 101 | 102 | where = f.tell() 103 | line = f.readline() 104 | if not line: 105 | if new_size < current_size: 106 | # the file was probably truncated, reopen 107 | f = open(filename) 108 | current_size = new_size 109 | dashes = "-" * 20 110 | yield "\n" 111 | yield "\n" 112 | yield "%s file was truncated %s" % (dashes, dashes) 113 | yield "\n" 114 | yield "\n" 115 | time.sleep(0.25) 116 | else: 117 | time.sleep(0.25) 118 | f.seek(where) 119 | else: 120 | current_size = new_size 121 | yield line 122 | 123 | 124 | def _tail_in_window(filename, window, lock): 125 | """ 126 | Update a curses window with tailed lines from a file. 127 | """ 128 | title = " %s " % (filename,) 129 | max_lines, max_chars = window.getmaxyx() 130 | max_line_len = max_chars - 2 131 | window.move(1, 0) 132 | 133 | for line in _tail(filename, max_lines - 2): 134 | if len(line) > max_line_len: 135 | first_portion = line[0:max_line_len - 1] + "\n" 136 | trailing_len = max_line_len - (len("> ") + 1) 137 | remaining = ["> " + line[i:i + trailing_len] + "\n" 138 | for i in range(max_line_len - 1, len(line), trailing_len)] 139 | remaining[-1] = remaining[-1][0:-1] 140 | line_portions = [first_portion] + remaining 141 | else: 142 | line_portions = [line] 143 | 144 | for line_portion in line_portions: 145 | with lock: 146 | try: 147 | y, x = window.getyx() 148 | if y >= max_lines - 1: 149 | window.move(1, 1) 150 | window.deleteln() 151 | window.move(y - 1, 1) 152 | window.deleteln() 153 | window.addstr(line_portion) 154 | else: 155 | window.move(y, x + 1) 156 | window.addstr(line_portion) 157 | 158 | window.border() 159 | y, x = window.getyx() 160 | window.addstr(0, max_chars / 2 - len(title) / 2, title) 161 | window.move(y, x) 162 | window.refresh() 163 | except KeyboardInterrupt: 164 | return 165 | 166 | def _terminal_size(): 167 | raw = fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0)) 168 | h, w, hp, wp = struct.unpack('HHHH', raw) 169 | return w, h 170 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thobbs/multitail-curses/62c4bc9bee7af6d64e408c28c8408c9fd6d72d7a/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | import multitail 5 | 6 | def read(fname): 7 | f = os.path.join(os.path.dirname(__file__), fname) 8 | if os.path.exists(f): 9 | return open(f).read() 10 | else: 11 | return "" 12 | 13 | 14 | setup( 15 | name = "multitail-curses", 16 | version = multitail.__version__, 17 | author = "Tyler Hobbs", 18 | author_email = "tylerlhobbs@gmail.com", 19 | description = ("A curses-based utility script for tailing multiple " 20 | "files simultaneously."), 21 | license = "MIT", 22 | keywords = "tail script multitail", 23 | url = "http://github.com/thobbs/multitail-curses", 24 | download_url = 'http://github.com/thobbs/multitail-curses/archive/%s.tar.gz' % (multitail.__version__,), 25 | packages=[], 26 | py_modules=['multitail'], 27 | scripts=['multitail'], 28 | long_description=read('README.rst'), 29 | classifiers=[ 30 | "Development Status :: 5 - Production/Stable", 31 | "Topic :: Utilities", 32 | "License :: OSI Approved :: MIT License", 33 | 'Natural Language :: English', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 2', 37 | 'Programming Language :: Python :: 2.6', 38 | 'Programming Language :: Python :: 2.7', 39 | 'Programming Language :: Python :: 2 :: Only', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from tempfile import TemporaryFile as temp 3 | 4 | from nose.tools import assert_equal 5 | 6 | import multitail 7 | 8 | class TestSeeking(unittest.TestCase): 9 | 10 | def test_basic(self): 11 | with temp() as f: 12 | for i in range(10): 13 | f.write("%d\n" % i) 14 | 15 | for i in range(10): 16 | multitail._seek_to_n_lines_from_end(f, i) 17 | assert_equal(i, len(f.readlines())) 18 | 19 | def test_short_file(self): 20 | """ The file is shorter than the number of lines we request """ 21 | with temp() as f: 22 | for i in range(3): 23 | f.write("%d\n" % i) 24 | 25 | multitail._seek_to_n_lines_from_end(f, 100) 26 | assert_equal(3, len(f.readlines())) 27 | 28 | def test_multiple_buffers(self): 29 | """ We need to buffer multiple times """ 30 | with temp() as f: 31 | for i in range(10000): 32 | f.write("%d\n" % i) 33 | 34 | multitail._seek_to_n_lines_from_end(f, 5000) 35 | assert_equal(5000, len(f.readlines())) 36 | --------------------------------------------------------------------------------