watch.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env python3
  2. #
  3. # Traditional watch command, but with higher resolution updates and a bit
  4. # different options/output format
  5. #
  6. # Example:
  7. # ./scripts/watch.py -s0.1 date
  8. #
  9. # Copyright (c) 2022, The littlefs authors.
  10. # SPDX-License-Identifier: BSD-3-Clause
  11. #
  12. import collections as co
  13. import errno
  14. import fcntl
  15. import io
  16. import os
  17. import pty
  18. import re
  19. import shutil
  20. import struct
  21. import subprocess as sp
  22. import sys
  23. import termios
  24. import time
  25. try:
  26. import inotify_simple
  27. except ModuleNotFoundError:
  28. inotify_simple = None
  29. def openio(path, mode='r', buffering=-1):
  30. # allow '-' for stdin/stdout
  31. if path == '-':
  32. if mode == 'r':
  33. return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
  34. else:
  35. return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
  36. else:
  37. return open(path, mode, buffering)
  38. def inotifywait(paths):
  39. # wait for interesting events
  40. inotify = inotify_simple.INotify()
  41. flags = (inotify_simple.flags.ATTRIB
  42. | inotify_simple.flags.CREATE
  43. | inotify_simple.flags.DELETE
  44. | inotify_simple.flags.DELETE_SELF
  45. | inotify_simple.flags.MODIFY
  46. | inotify_simple.flags.MOVED_FROM
  47. | inotify_simple.flags.MOVED_TO
  48. | inotify_simple.flags.MOVE_SELF)
  49. # recurse into directories
  50. for path in paths:
  51. if os.path.isdir(path):
  52. for dir, _, files in os.walk(path):
  53. inotify.add_watch(dir, flags)
  54. for f in files:
  55. inotify.add_watch(os.path.join(dir, f), flags)
  56. else:
  57. inotify.add_watch(path, flags)
  58. # wait for event
  59. inotify.read()
  60. class LinesIO:
  61. def __init__(self, maxlen=None):
  62. self.maxlen = maxlen
  63. self.lines = co.deque(maxlen=maxlen)
  64. self.tail = io.StringIO()
  65. # trigger automatic sizing
  66. if maxlen == 0:
  67. self.resize(0)
  68. def write(self, s):
  69. # note using split here ensures the trailing string has no newline
  70. lines = s.split('\n')
  71. if len(lines) > 1 and self.tail.getvalue():
  72. self.tail.write(lines[0])
  73. lines[0] = self.tail.getvalue()
  74. self.tail = io.StringIO()
  75. self.lines.extend(lines[:-1])
  76. if lines[-1]:
  77. self.tail.write(lines[-1])
  78. def resize(self, maxlen):
  79. self.maxlen = maxlen
  80. if maxlen == 0:
  81. maxlen = shutil.get_terminal_size((80, 5))[1]
  82. if maxlen != self.lines.maxlen:
  83. self.lines = co.deque(self.lines, maxlen=maxlen)
  84. canvas_lines = 1
  85. def draw(self):
  86. # did terminal size change?
  87. if self.maxlen == 0:
  88. self.resize(0)
  89. # first thing first, give ourself a canvas
  90. while LinesIO.canvas_lines < len(self.lines):
  91. sys.stdout.write('\n')
  92. LinesIO.canvas_lines += 1
  93. # clear the bottom of the canvas if we shrink
  94. shrink = LinesIO.canvas_lines - len(self.lines)
  95. if shrink > 0:
  96. for i in range(shrink):
  97. sys.stdout.write('\r')
  98. if shrink-1-i > 0:
  99. sys.stdout.write('\x1b[%dA' % (shrink-1-i))
  100. sys.stdout.write('\x1b[K')
  101. if shrink-1-i > 0:
  102. sys.stdout.write('\x1b[%dB' % (shrink-1-i))
  103. sys.stdout.write('\x1b[%dA' % shrink)
  104. LinesIO.canvas_lines = len(self.lines)
  105. for i, line in enumerate(self.lines):
  106. # move cursor, clear line, disable/reenable line wrapping
  107. sys.stdout.write('\r')
  108. if len(self.lines)-1-i > 0:
  109. sys.stdout.write('\x1b[%dA' % (len(self.lines)-1-i))
  110. sys.stdout.write('\x1b[K')
  111. sys.stdout.write('\x1b[?7l')
  112. sys.stdout.write(line)
  113. sys.stdout.write('\x1b[?7h')
  114. if len(self.lines)-1-i > 0:
  115. sys.stdout.write('\x1b[%dB' % (len(self.lines)-1-i))
  116. sys.stdout.flush()
  117. def main(command, *,
  118. lines=0,
  119. cat=False,
  120. sleep=None,
  121. keep_open=False,
  122. keep_open_paths=None,
  123. exit_on_error=False):
  124. returncode = 0
  125. try:
  126. while True:
  127. # reset ring each run
  128. if cat:
  129. ring = sys.stdout
  130. else:
  131. ring = LinesIO(lines)
  132. try:
  133. # run the command under a pseudoterminal
  134. mpty, spty = pty.openpty()
  135. # forward terminal size
  136. w, h = shutil.get_terminal_size((80, 5))
  137. if lines:
  138. h = lines
  139. fcntl.ioctl(spty, termios.TIOCSWINSZ,
  140. struct.pack('HHHH', h, w, 0, 0))
  141. proc = sp.Popen(command,
  142. stdout=spty,
  143. stderr=spty,
  144. close_fds=False)
  145. os.close(spty)
  146. mpty = os.fdopen(mpty, 'r', 1)
  147. while True:
  148. try:
  149. line = mpty.readline()
  150. except OSError as e:
  151. if e.errno != errno.EIO:
  152. raise
  153. break
  154. if not line:
  155. break
  156. ring.write(line)
  157. if not cat:
  158. ring.draw()
  159. mpty.close()
  160. proc.wait()
  161. if exit_on_error and proc.returncode != 0:
  162. returncode = proc.returncode
  163. break
  164. except OSError as e:
  165. if e.errno != errno.ETXTBSY:
  166. raise
  167. pass
  168. # try to inotifywait
  169. if keep_open and inotify_simple is not None:
  170. if keep_open_paths:
  171. paths = set(keep_paths)
  172. else:
  173. # guess inotify paths from command
  174. paths = set()
  175. for p in command:
  176. for p in {
  177. p,
  178. re.sub('^-.', '', p),
  179. re.sub('^--[^=]+=', '', p)}:
  180. if p and os.path.exists(p):
  181. paths.add(p)
  182. ptime = time.time()
  183. inotifywait(paths)
  184. # sleep for a minimum amount of time, this helps issues around
  185. # rapidly updating files
  186. time.sleep(max(0, (sleep or 0.1) - (time.time()-ptime)))
  187. else:
  188. time.sleep(sleep or 0.1)
  189. except KeyboardInterrupt:
  190. pass
  191. if not cat:
  192. sys.stdout.write('\n')
  193. sys.exit(returncode)
  194. if __name__ == "__main__":
  195. import sys
  196. import argparse
  197. parser = argparse.ArgumentParser(
  198. description="Traditional watch command, but with higher resolution "
  199. "updates and a bit different options/output format.",
  200. allow_abbrev=False)
  201. parser.add_argument(
  202. 'command',
  203. nargs=argparse.REMAINDER,
  204. help="Command to run.")
  205. parser.add_argument(
  206. '-n', '--lines',
  207. nargs='?',
  208. type=lambda x: int(x, 0),
  209. const=0,
  210. help="Show this many lines of history. 0 uses the terminal height. "
  211. "Defaults to 0.")
  212. parser.add_argument(
  213. '-z', '--cat',
  214. action='store_true',
  215. help="Pipe directly to stdout.")
  216. parser.add_argument(
  217. '-s', '--sleep',
  218. type=float,
  219. help="Seconds to sleep between runs. Defaults to 0.1.")
  220. parser.add_argument(
  221. '-k', '--keep-open',
  222. action='store_true',
  223. help="Try to use inotify to wait for changes.")
  224. parser.add_argument(
  225. '-K', '--keep-open-path',
  226. dest='keep_open_paths',
  227. action='append',
  228. help="Use this path for inotify. Defaults to guessing.")
  229. parser.add_argument(
  230. '-e', '--exit-on-error',
  231. action='store_true',
  232. help="Exit on error.")
  233. sys.exit(main(**{k: v
  234. for k, v in vars(parser.parse_args()).items()
  235. if v is not None}))