119ea8026Sopenharmony_ci#!/usr/bin/env python3
219ea8026Sopenharmony_ci#
319ea8026Sopenharmony_ci# Traditional watch command, but with higher resolution updates and a bit
419ea8026Sopenharmony_ci# different options/output format
519ea8026Sopenharmony_ci#
619ea8026Sopenharmony_ci# Example:
719ea8026Sopenharmony_ci# ./scripts/watch.py -s0.1 date
819ea8026Sopenharmony_ci#
919ea8026Sopenharmony_ci# Copyright (c) 2022, The littlefs authors.
1019ea8026Sopenharmony_ci# SPDX-License-Identifier: BSD-3-Clause
1119ea8026Sopenharmony_ci#
1219ea8026Sopenharmony_ci
1319ea8026Sopenharmony_ciimport collections as co
1419ea8026Sopenharmony_ciimport errno
1519ea8026Sopenharmony_ciimport fcntl
1619ea8026Sopenharmony_ciimport io
1719ea8026Sopenharmony_ciimport os
1819ea8026Sopenharmony_ciimport pty
1919ea8026Sopenharmony_ciimport re
2019ea8026Sopenharmony_ciimport shutil
2119ea8026Sopenharmony_ciimport struct
2219ea8026Sopenharmony_ciimport subprocess as sp
2319ea8026Sopenharmony_ciimport sys
2419ea8026Sopenharmony_ciimport termios
2519ea8026Sopenharmony_ciimport time
2619ea8026Sopenharmony_ci
2719ea8026Sopenharmony_citry:
2819ea8026Sopenharmony_ci    import inotify_simple
2919ea8026Sopenharmony_ciexcept ModuleNotFoundError:
3019ea8026Sopenharmony_ci    inotify_simple = None
3119ea8026Sopenharmony_ci
3219ea8026Sopenharmony_ci
3319ea8026Sopenharmony_cidef openio(path, mode='r', buffering=-1):
3419ea8026Sopenharmony_ci    # allow '-' for stdin/stdout
3519ea8026Sopenharmony_ci    if path == '-':
3619ea8026Sopenharmony_ci        if mode == 'r':
3719ea8026Sopenharmony_ci            return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
3819ea8026Sopenharmony_ci        else:
3919ea8026Sopenharmony_ci            return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
4019ea8026Sopenharmony_ci    else:
4119ea8026Sopenharmony_ci        return open(path, mode, buffering)
4219ea8026Sopenharmony_ci
4319ea8026Sopenharmony_cidef inotifywait(paths):
4419ea8026Sopenharmony_ci    # wait for interesting events
4519ea8026Sopenharmony_ci    inotify = inotify_simple.INotify()
4619ea8026Sopenharmony_ci    flags = (inotify_simple.flags.ATTRIB
4719ea8026Sopenharmony_ci        | inotify_simple.flags.CREATE
4819ea8026Sopenharmony_ci        | inotify_simple.flags.DELETE
4919ea8026Sopenharmony_ci        | inotify_simple.flags.DELETE_SELF
5019ea8026Sopenharmony_ci        | inotify_simple.flags.MODIFY
5119ea8026Sopenharmony_ci        | inotify_simple.flags.MOVED_FROM
5219ea8026Sopenharmony_ci        | inotify_simple.flags.MOVED_TO
5319ea8026Sopenharmony_ci        | inotify_simple.flags.MOVE_SELF)
5419ea8026Sopenharmony_ci
5519ea8026Sopenharmony_ci    # recurse into directories
5619ea8026Sopenharmony_ci    for path in paths:
5719ea8026Sopenharmony_ci        if os.path.isdir(path):
5819ea8026Sopenharmony_ci            for dir, _, files in os.walk(path):
5919ea8026Sopenharmony_ci                inotify.add_watch(dir, flags)
6019ea8026Sopenharmony_ci                for f in files:
6119ea8026Sopenharmony_ci                    inotify.add_watch(os.path.join(dir, f), flags)
6219ea8026Sopenharmony_ci        else:
6319ea8026Sopenharmony_ci            inotify.add_watch(path, flags)
6419ea8026Sopenharmony_ci
6519ea8026Sopenharmony_ci    # wait for event
6619ea8026Sopenharmony_ci    inotify.read()
6719ea8026Sopenharmony_ci
6819ea8026Sopenharmony_ciclass LinesIO:
6919ea8026Sopenharmony_ci    def __init__(self, maxlen=None):
7019ea8026Sopenharmony_ci        self.maxlen = maxlen
7119ea8026Sopenharmony_ci        self.lines = co.deque(maxlen=maxlen)
7219ea8026Sopenharmony_ci        self.tail = io.StringIO()
7319ea8026Sopenharmony_ci
7419ea8026Sopenharmony_ci        # trigger automatic sizing
7519ea8026Sopenharmony_ci        if maxlen == 0:
7619ea8026Sopenharmony_ci            self.resize(0)
7719ea8026Sopenharmony_ci
7819ea8026Sopenharmony_ci    def write(self, s):
7919ea8026Sopenharmony_ci        # note using split here ensures the trailing string has no newline
8019ea8026Sopenharmony_ci        lines = s.split('\n')
8119ea8026Sopenharmony_ci
8219ea8026Sopenharmony_ci        if len(lines) > 1 and self.tail.getvalue():
8319ea8026Sopenharmony_ci            self.tail.write(lines[0])
8419ea8026Sopenharmony_ci            lines[0] = self.tail.getvalue()
8519ea8026Sopenharmony_ci            self.tail = io.StringIO()
8619ea8026Sopenharmony_ci
8719ea8026Sopenharmony_ci        self.lines.extend(lines[:-1])
8819ea8026Sopenharmony_ci
8919ea8026Sopenharmony_ci        if lines[-1]:
9019ea8026Sopenharmony_ci            self.tail.write(lines[-1])
9119ea8026Sopenharmony_ci
9219ea8026Sopenharmony_ci    def resize(self, maxlen):
9319ea8026Sopenharmony_ci        self.maxlen = maxlen
9419ea8026Sopenharmony_ci        if maxlen == 0:
9519ea8026Sopenharmony_ci            maxlen = shutil.get_terminal_size((80, 5))[1]
9619ea8026Sopenharmony_ci        if maxlen != self.lines.maxlen:
9719ea8026Sopenharmony_ci            self.lines = co.deque(self.lines, maxlen=maxlen)
9819ea8026Sopenharmony_ci
9919ea8026Sopenharmony_ci    canvas_lines = 1
10019ea8026Sopenharmony_ci    def draw(self):
10119ea8026Sopenharmony_ci        # did terminal size change?
10219ea8026Sopenharmony_ci        if self.maxlen == 0:
10319ea8026Sopenharmony_ci            self.resize(0)
10419ea8026Sopenharmony_ci
10519ea8026Sopenharmony_ci        # first thing first, give ourself a canvas
10619ea8026Sopenharmony_ci        while LinesIO.canvas_lines < len(self.lines):
10719ea8026Sopenharmony_ci            sys.stdout.write('\n')
10819ea8026Sopenharmony_ci            LinesIO.canvas_lines += 1
10919ea8026Sopenharmony_ci
11019ea8026Sopenharmony_ci        # clear the bottom of the canvas if we shrink
11119ea8026Sopenharmony_ci        shrink = LinesIO.canvas_lines - len(self.lines)
11219ea8026Sopenharmony_ci        if shrink > 0:
11319ea8026Sopenharmony_ci            for i in range(shrink):
11419ea8026Sopenharmony_ci                sys.stdout.write('\r')
11519ea8026Sopenharmony_ci                if shrink-1-i > 0:
11619ea8026Sopenharmony_ci                    sys.stdout.write('\x1b[%dA' % (shrink-1-i))
11719ea8026Sopenharmony_ci                sys.stdout.write('\x1b[K')
11819ea8026Sopenharmony_ci                if shrink-1-i > 0:
11919ea8026Sopenharmony_ci                    sys.stdout.write('\x1b[%dB' % (shrink-1-i))
12019ea8026Sopenharmony_ci            sys.stdout.write('\x1b[%dA' % shrink)
12119ea8026Sopenharmony_ci            LinesIO.canvas_lines = len(self.lines)
12219ea8026Sopenharmony_ci
12319ea8026Sopenharmony_ci        for i, line in enumerate(self.lines):
12419ea8026Sopenharmony_ci            # move cursor, clear line, disable/reenable line wrapping
12519ea8026Sopenharmony_ci            sys.stdout.write('\r')
12619ea8026Sopenharmony_ci            if len(self.lines)-1-i > 0:
12719ea8026Sopenharmony_ci                sys.stdout.write('\x1b[%dA' % (len(self.lines)-1-i))
12819ea8026Sopenharmony_ci            sys.stdout.write('\x1b[K')
12919ea8026Sopenharmony_ci            sys.stdout.write('\x1b[?7l')
13019ea8026Sopenharmony_ci            sys.stdout.write(line)
13119ea8026Sopenharmony_ci            sys.stdout.write('\x1b[?7h')
13219ea8026Sopenharmony_ci            if len(self.lines)-1-i > 0:
13319ea8026Sopenharmony_ci                sys.stdout.write('\x1b[%dB' % (len(self.lines)-1-i))
13419ea8026Sopenharmony_ci        sys.stdout.flush()
13519ea8026Sopenharmony_ci
13619ea8026Sopenharmony_ci
13719ea8026Sopenharmony_cidef main(command, *,
13819ea8026Sopenharmony_ci        lines=0,
13919ea8026Sopenharmony_ci        cat=False,
14019ea8026Sopenharmony_ci        sleep=None,
14119ea8026Sopenharmony_ci        keep_open=False,
14219ea8026Sopenharmony_ci        keep_open_paths=None,
14319ea8026Sopenharmony_ci        exit_on_error=False):
14419ea8026Sopenharmony_ci    returncode = 0
14519ea8026Sopenharmony_ci    try:
14619ea8026Sopenharmony_ci        while True:
14719ea8026Sopenharmony_ci            # reset ring each run
14819ea8026Sopenharmony_ci            if cat:
14919ea8026Sopenharmony_ci                ring = sys.stdout
15019ea8026Sopenharmony_ci            else:
15119ea8026Sopenharmony_ci                ring = LinesIO(lines)
15219ea8026Sopenharmony_ci
15319ea8026Sopenharmony_ci            try:
15419ea8026Sopenharmony_ci                # run the command under a pseudoterminal
15519ea8026Sopenharmony_ci                mpty, spty = pty.openpty()
15619ea8026Sopenharmony_ci
15719ea8026Sopenharmony_ci                # forward terminal size
15819ea8026Sopenharmony_ci                w, h = shutil.get_terminal_size((80, 5))
15919ea8026Sopenharmony_ci                if lines:
16019ea8026Sopenharmony_ci                    h = lines
16119ea8026Sopenharmony_ci                fcntl.ioctl(spty, termios.TIOCSWINSZ,
16219ea8026Sopenharmony_ci                    struct.pack('HHHH', h, w, 0, 0))
16319ea8026Sopenharmony_ci
16419ea8026Sopenharmony_ci                proc = sp.Popen(command,
16519ea8026Sopenharmony_ci                    stdout=spty,
16619ea8026Sopenharmony_ci                    stderr=spty,
16719ea8026Sopenharmony_ci                    close_fds=False)
16819ea8026Sopenharmony_ci                os.close(spty)
16919ea8026Sopenharmony_ci                mpty = os.fdopen(mpty, 'r', 1)
17019ea8026Sopenharmony_ci
17119ea8026Sopenharmony_ci                while True:
17219ea8026Sopenharmony_ci                    try:
17319ea8026Sopenharmony_ci                        line = mpty.readline()
17419ea8026Sopenharmony_ci                    except OSError as e:
17519ea8026Sopenharmony_ci                        if e.errno != errno.EIO:
17619ea8026Sopenharmony_ci                            raise
17719ea8026Sopenharmony_ci                        break
17819ea8026Sopenharmony_ci                    if not line:
17919ea8026Sopenharmony_ci                        break
18019ea8026Sopenharmony_ci
18119ea8026Sopenharmony_ci                    ring.write(line)
18219ea8026Sopenharmony_ci                    if not cat:
18319ea8026Sopenharmony_ci                        ring.draw()
18419ea8026Sopenharmony_ci
18519ea8026Sopenharmony_ci                mpty.close()
18619ea8026Sopenharmony_ci                proc.wait()
18719ea8026Sopenharmony_ci                if exit_on_error and proc.returncode != 0:
18819ea8026Sopenharmony_ci                    returncode = proc.returncode
18919ea8026Sopenharmony_ci                    break
19019ea8026Sopenharmony_ci            except OSError as e:
19119ea8026Sopenharmony_ci                if e.errno != errno.ETXTBSY:
19219ea8026Sopenharmony_ci                    raise
19319ea8026Sopenharmony_ci                pass
19419ea8026Sopenharmony_ci
19519ea8026Sopenharmony_ci            # try to inotifywait
19619ea8026Sopenharmony_ci            if keep_open and inotify_simple is not None:
19719ea8026Sopenharmony_ci                if keep_open_paths:
19819ea8026Sopenharmony_ci                    paths = set(keep_paths)
19919ea8026Sopenharmony_ci                else:
20019ea8026Sopenharmony_ci                    # guess inotify paths from command
20119ea8026Sopenharmony_ci                    paths = set()
20219ea8026Sopenharmony_ci                    for p in command:
20319ea8026Sopenharmony_ci                        for p in {
20419ea8026Sopenharmony_ci                                p,
20519ea8026Sopenharmony_ci                                re.sub('^-.', '', p),
20619ea8026Sopenharmony_ci                                re.sub('^--[^=]+=', '', p)}:
20719ea8026Sopenharmony_ci                            if p and os.path.exists(p):
20819ea8026Sopenharmony_ci                                paths.add(p)
20919ea8026Sopenharmony_ci                ptime = time.time()
21019ea8026Sopenharmony_ci                inotifywait(paths)
21119ea8026Sopenharmony_ci                # sleep for a minimum amount of time, this helps issues around
21219ea8026Sopenharmony_ci                # rapidly updating files
21319ea8026Sopenharmony_ci                time.sleep(max(0, (sleep or 0.1) - (time.time()-ptime)))
21419ea8026Sopenharmony_ci            else:
21519ea8026Sopenharmony_ci                time.sleep(sleep or 0.1)
21619ea8026Sopenharmony_ci    except KeyboardInterrupt:
21719ea8026Sopenharmony_ci        pass
21819ea8026Sopenharmony_ci
21919ea8026Sopenharmony_ci    if not cat:
22019ea8026Sopenharmony_ci        sys.stdout.write('\n')
22119ea8026Sopenharmony_ci    sys.exit(returncode)
22219ea8026Sopenharmony_ci
22319ea8026Sopenharmony_ci
22419ea8026Sopenharmony_ciif __name__ == "__main__":
22519ea8026Sopenharmony_ci    import sys
22619ea8026Sopenharmony_ci    import argparse
22719ea8026Sopenharmony_ci    parser = argparse.ArgumentParser(
22819ea8026Sopenharmony_ci        description="Traditional watch command, but with higher resolution "
22919ea8026Sopenharmony_ci            "updates and a bit different options/output format.",
23019ea8026Sopenharmony_ci        allow_abbrev=False)
23119ea8026Sopenharmony_ci    parser.add_argument(
23219ea8026Sopenharmony_ci        'command',
23319ea8026Sopenharmony_ci        nargs=argparse.REMAINDER,
23419ea8026Sopenharmony_ci        help="Command to run.")
23519ea8026Sopenharmony_ci    parser.add_argument(
23619ea8026Sopenharmony_ci        '-n', '--lines',
23719ea8026Sopenharmony_ci        nargs='?',
23819ea8026Sopenharmony_ci        type=lambda x: int(x, 0),
23919ea8026Sopenharmony_ci        const=0,
24019ea8026Sopenharmony_ci        help="Show this many lines of history. 0 uses the terminal height. "
24119ea8026Sopenharmony_ci            "Defaults to 0.")
24219ea8026Sopenharmony_ci    parser.add_argument(
24319ea8026Sopenharmony_ci        '-z', '--cat',
24419ea8026Sopenharmony_ci        action='store_true',
24519ea8026Sopenharmony_ci        help="Pipe directly to stdout.")
24619ea8026Sopenharmony_ci    parser.add_argument(
24719ea8026Sopenharmony_ci        '-s', '--sleep',
24819ea8026Sopenharmony_ci        type=float,
24919ea8026Sopenharmony_ci        help="Seconds to sleep between runs. Defaults to 0.1.")
25019ea8026Sopenharmony_ci    parser.add_argument(
25119ea8026Sopenharmony_ci        '-k', '--keep-open',
25219ea8026Sopenharmony_ci        action='store_true',
25319ea8026Sopenharmony_ci        help="Try to use inotify to wait for changes.")
25419ea8026Sopenharmony_ci    parser.add_argument(
25519ea8026Sopenharmony_ci        '-K', '--keep-open-path',
25619ea8026Sopenharmony_ci        dest='keep_open_paths',
25719ea8026Sopenharmony_ci        action='append',
25819ea8026Sopenharmony_ci        help="Use this path for inotify. Defaults to guessing.")
25919ea8026Sopenharmony_ci    parser.add_argument(
26019ea8026Sopenharmony_ci        '-e', '--exit-on-error',
26119ea8026Sopenharmony_ci        action='store_true',
26219ea8026Sopenharmony_ci        help="Exit on error.")
26319ea8026Sopenharmony_ci    sys.exit(main(**{k: v
26419ea8026Sopenharmony_ci        for k, v in vars(parser.parse_args()).items()
26519ea8026Sopenharmony_ci        if v is not None}))
266