]> arthur.barton.de Git - bup.git/blob - lib/cmd/bup
bup: filter stdout/stderr via thread/pipe for internal subcommands
[bup.git] / lib / cmd / bup
1 #!/bin/sh
2 """": # -*-python-*-
3 set -e
4 # https://sourceware.org/bugzilla/show_bug.cgi?id=26034
5 export "BUP_ARGV_0"="$0"
6 arg_i=1
7 for arg in "$@"; do
8     export "BUP_ARGV_${arg_i}"="$arg"
9     shift
10     arg_i=$((arg_i + 1))
11 done
12 # Here to end of preamble replaced during install
13 # Find our directory
14 top="$(pwd)"
15 cmdpath="$0"
16 # loop because macos doesn't have recursive readlink/realpath utils
17 while test -L "$cmdpath"; do
18     link="$(readlink "$cmdpath")"
19     cd "$(dirname "$cmdpath")"
20     cmdpath="$link"
21 done
22 script_home="$(cd "$(dirname "$cmdpath")" && pwd -P)"
23 cd "$top"
24 exec "$script_home/../../config/bin/python" "$0"
25 """
26 # end of bup preamble
27
28 from __future__ import absolute_import, print_function
29
30 import os, sys
31 sys.path[:0] = [os.path.dirname(os.path.realpath(__file__)) + '/..']
32
33 from importlib import import_module
34 from subprocess import PIPE
35 from threading import Thread
36 import errno, getopt, os, re, select, signal, subprocess, sys
37
38 from bup import compat, path, helpers
39 from bup.compat import (
40     ModuleNotFoundError,
41     add_ex_ctx,
42     add_ex_tb,
43     argv_bytes,
44     environ,
45     fsdecode,
46     int_types,
47     wrap_main
48 )
49 from bup.compat import add_ex_tb, add_ex_ctx, argv_bytes, wrap_main
50 from bup.helpers import (
51     columnate,
52     debug1,
53     handle_ctrl_c,
54     log,
55     merge_dict,
56     tty_width
57 )
58 from bup.git import close_catpipes
59 from bup.io import byte_stream, path_msg
60 from bup.options import _tty_width
61
62 def maybe_import_early(argv):
63     """Scan argv and import any modules specified by --import-py-module."""
64     while argv:
65         if argv[0] != '--import-py-module':
66             argv = argv[1:]
67             continue
68         if len(argv) < 2:
69             log("bup: --import-py-module must have an argument\n")
70             exit(2)
71         mod = argv[1]
72         import_module(mod)
73         argv = argv[2:]
74
75 maybe_import_early(compat.argv)
76
77 handle_ctrl_c()
78
79 cmdpath = path.cmddir()
80
81 # Remove once we finish the internal command transition
82 transition_cmdpath = path.libdir() + b'/bup/cmd'
83
84 # We manipulate the subcmds here as strings, but they must be ASCII
85 # compatible, since we're going to be looking for exactly
86 # b'bup-SUBCMD' to exec.
87
88 def usage(msg=""):
89     log('Usage: bup [-?|--help] [-d BUP_DIR] [--debug] [--profile] '
90         '<command> [options...]\n\n')
91     common = dict(
92         ftp = 'Browse backup sets using an ftp-like client',
93         fsck = 'Check backup sets for damage and add redundancy information',
94         fuse = 'Mount your backup sets as a filesystem',
95         help = 'Print detailed help for the given command',
96         index = 'Create or display the index of files to back up',
97         on = 'Backup a remote machine to the local one',
98         restore = 'Extract files from a backup set',
99         save = 'Save files into a backup set (note: run "bup index" first)',
100         tag = 'Tag commits for easier access',
101         web = 'Launch a web server to examine backup sets',
102     )
103
104     log('Common commands:\n')
105     for cmd,synopsis in sorted(common.items()):
106         log('    %-10s %s\n' % (cmd, synopsis))
107     log('\n')
108     
109     log('Other available commands:\n')
110     cmds = []
111     for c in sorted(os.listdir(cmdpath)):
112         if c.startswith(b'bup-') and c.find(b'.') < 0:
113             cname = fsdecode(c[4:])
114             if cname not in common:
115                 cmds.append(c[4:].decode(errors='backslashreplace'))
116     log(columnate(cmds, '    '))
117     log('\n')
118     
119     log("See 'bup help COMMAND' for more information on " +
120         "a specific command.\n")
121     if msg:
122         log("\n%s\n" % msg)
123     sys.exit(99)
124
125 argv = compat.argv
126 if len(argv) < 2:
127     usage()
128
129 # Handle global options.
130 try:
131     optspec = ['help', 'version', 'debug', 'profile', 'bup-dir=',
132                'import-py-module=']
133     global_args, subcmd = getopt.getopt(argv[1:], '?VDd:', optspec)
134 except getopt.GetoptError as ex:
135     usage('error: %s' % ex.msg)
136
137 subcmd = [argv_bytes(x) for x in subcmd]
138 help_requested = None
139 do_profile = False
140 bup_dir = None
141
142 for opt in global_args:
143     if opt[0] in ['-?', '--help']:
144         help_requested = True
145     elif opt[0] in ['-V', '--version']:
146         subcmd = [b'version']
147     elif opt[0] in ['-D', '--debug']:
148         helpers.buglvl += 1
149         environ[b'BUP_DEBUG'] = b'%d' % helpers.buglvl
150     elif opt[0] in ['--profile']:
151         do_profile = True
152     elif opt[0] in ['-d', '--bup-dir']:
153         bup_dir = argv_bytes(opt[1])
154     elif opt[0] == '--import-py-module':
155         pass
156     else:
157         usage('error: unexpected option "%s"' % opt[0])
158
159 # Make BUP_DIR absolute, so we aren't affected by chdir (i.e. save -C, etc.).
160 if bup_dir:
161     environ[b'BUP_DIR'] = os.path.abspath(bup_dir)
162
163 if len(subcmd) == 0:
164     if help_requested:
165         subcmd = [b'help']
166     else:
167         usage()
168
169 if help_requested and subcmd[0] != b'help':
170     subcmd = [b'help'] + subcmd
171
172 if len(subcmd) > 1 and subcmd[1] == b'--help' and subcmd[0] != b'help':
173     subcmd = [b'help', subcmd[0]] + subcmd[2:]
174
175 subcmd_name = subcmd[0]
176 if not subcmd_name:
177     usage()
178
179 try:
180     if subcmd_name not in []:
181         raise ModuleNotFoundError()
182     cmd_module = import_module('bup.cmd.'
183                                + subcmd_name.decode('ascii').replace('-', '_'))
184 except ModuleNotFoundError as ex:
185     cmd_module = None
186
187 if not cmd_module:
188     subcmd[0] = os.path.join(cmdpath, b'bup-' + subcmd_name)
189     if not os.path.exists(subcmd[0]):
190         subcmd[0] = b'%s/%s.py' % (transition_cmdpath,
191                                    subcmd_name.replace(b'-', b'_'))
192     if not os.path.exists(subcmd[0]):
193         usage('error: unknown command "%s"' % path_msg(subcmd_name))
194
195 already_fixed = int(environ.get(b'BUP_FORCE_TTY', 0))
196 if subcmd_name in [b'mux', b'ftp', b'help']:
197     already_fixed = True
198 fix_stdout = not already_fixed and os.isatty(1)
199 fix_stderr = not already_fixed and os.isatty(2)
200
201 if fix_stdout or fix_stderr:
202     tty_env = merge_dict(environ,
203                          {b'BUP_FORCE_TTY': (b'%d'
204                                              % ((fix_stdout and 1 or 0)
205                                                 + (fix_stderr and 2 or 0))),
206                           b'BUP_TTY_WIDTH': b'%d' % _tty_width(), })
207 else:
208     tty_env = environ
209
210
211 sep_rx = re.compile(br'([\r\n])')
212
213 def print_clean_line(dest, content, width, sep=None):
214     """Write some or all of content, followed by sep, to the dest fd after
215     padding the content with enough spaces to fill the current
216     terminal width or truncating it to the terminal width if sep is a
217     carriage return."""
218     global sep_rx
219     assert sep in (b'\r', b'\n', None)
220     if not content:
221         if sep:
222             os.write(dest, sep)
223         return
224     for x in content:
225         assert not sep_rx.match(x)
226     content = b''.join(content)
227     if sep == b'\r' and len(content) > width:
228         content = content[width:]
229     os.write(dest, content)
230     if len(content) < width:
231         os.write(dest, b' ' * (width - len(content)))
232     if sep:
233         os.write(dest, sep)
234
235 def filter_output(srcs, dests):
236     """Transfer data from file descriptors in srcs to the corresponding
237     file descriptors in dests print_clean_line until all of the srcs
238     have closed.
239
240     """
241     global sep_rx
242     assert all(type(x) in int_types for x in srcs)
243     assert all(type(x) in int_types for x in srcs)
244     assert len(srcs) == len(dests)
245     srcs = tuple(srcs)
246     dest_for = dict(zip(srcs, dests))
247     pending = {}
248     pending_ex = None
249     try:
250         while srcs:
251             ready_fds, _, _ = select.select(srcs, [], [])
252             width = tty_width()
253             for fd in ready_fds:
254                 buf = os.read(fd, 4096)
255                 dest = dest_for[fd]
256                 if not buf:
257                     srcs = tuple([x for x in srcs if x is not fd])
258                     print_clean_line(dest, pending.pop(fd, []), width)
259                 else:
260                     split = sep_rx.split(buf)
261                     while len(split) > 1:
262                         content, sep = split[:2]
263                         split = split[2:]
264                         print_clean_line(dest,
265                                          pending.pop(fd, []) + [content],
266                                          width,
267                                          sep)
268                     assert len(split) == 1
269                     if split[0]:
270                         pending.setdefault(fd, []).extend(split)
271     except BaseException as ex:
272         pending_ex = add_ex_ctx(add_ex_tb(ex), pending_ex)
273     try:
274         # Try to finish each of the streams
275         for fd, pending_items in compat.items(pending):
276             dest = dest_for[fd]
277             width = tty_width()
278             try:
279                 print_clean_line(dest, pending_items, width)
280             except (EnvironmentError, EOFError) as ex:
281                 pending_ex = add_ex_ctx(add_ex_tb(ex), pending_ex)
282     except BaseException as ex:
283         pending_ex = add_ex_ctx(add_ex_tb(ex), pending_ex)
284     if pending_ex:
285         raise pending_ex
286
287
288 def import_and_run_main(module, args):
289     if do_profile:
290         import cProfile
291         f = compile('module.main(args)', __file__, 'exec')
292         cProfile.runctx(f, globals(), locals())
293     else:
294         module.main(args)
295
296
297 def run_module_cmd(module, args):
298     if not (fix_stdout or fix_stderr):
299         import_and_run_main(module, args)
300         return
301     # Interpose filter_output between all attempts to write to the
302     # stdout/stderr and the real stdout/stderr (e.g. the fds that
303     # connect directly to the terminal) via a thread that runs
304     # filter_output in a pipeline.
305     srcs = []
306     dests = []
307     real_out_fd = real_err_fd = stdout_pipe = stderr_pipe = None
308     filter_thread = filter_thread_started = None
309     pending_ex = None
310     try:
311         if fix_stdout:
312             sys.stdout.flush()
313             stdout_pipe = os.pipe()  # monitored_by_filter, stdout_everyone_uses
314             real_out_fd = os.dup(sys.stdout.fileno())
315             os.dup2(stdout_pipe[1], sys.stdout.fileno())
316             srcs.append(stdout_pipe[0])
317             dests.append(real_out_fd)
318         if fix_stderr:
319             sys.stderr.flush()
320             stderr_pipe = os.pipe()  # monitored_by_filter, stderr_everyone_uses
321             real_err_fd = os.dup(sys.stderr.fileno())
322             os.dup2(stderr_pipe[1], sys.stderr.fileno())
323             srcs.append(stderr_pipe[0])
324             dests.append(real_err_fd)
325
326         filter_thread = Thread(name='output filter',
327                                target=lambda : filter_output(srcs, dests))
328         filter_thread.start()
329         filter_thread_started = True
330         import_and_run_main(module, args)
331     except Exception as ex:
332         add_ex_tb(ex)
333         pending_ex = ex
334         raise
335     finally:
336         # Try to make sure that whatever else happens, we restore
337         # stdout and stderr here, if that's possible, so that we don't
338         # risk just losing some output.
339         try:
340             real_out_fd is not None and os.dup2(real_out_fd, sys.stdout.fileno())
341         except Exception as ex:
342             add_ex_tb(ex)
343             add_ex_ctx(ex, pending_ex)
344         try:
345             real_err_fd is not None and os.dup2(real_err_fd, sys.stderr.fileno())
346         except Exception as ex:
347             add_ex_tb(ex)
348             add_ex_ctx(ex, pending_ex)
349         # Kick filter loose
350         try:
351             stdout_pipe is not None and os.close(stdout_pipe[1])
352         except Exception as ex:
353             add_ex_tb(ex)
354             add_ex_ctx(ex, pending_ex)
355         try:
356             stderr_pipe is not None and os.close(stderr_pipe[1])
357         except Exception as ex:
358             add_ex_tb(ex)
359             add_ex_ctx(ex, pending_ex)
360         try:
361             close_catpipes()
362         except Exception as ex:
363             add_ex_tb(ex)
364             add_ex_ctx(ex, pending_ex)
365     if pending_ex:
366         raise pending_ex
367     # There's no point in trying to join unless we finished the finally block.
368     if filter_thread_started:
369         filter_thread.join()
370
371
372 def run_subproc_cmd(args):
373
374     c = (do_profile and [sys.executable, b'-m', b'cProfile'] or []) + args
375     if not (fix_stdout or fix_stderr):
376         os.execvp(c[0], c)
377
378     sys.stdout.flush()
379     sys.stderr.flush()
380     out = byte_stream(sys.stdout)
381     err = byte_stream(sys.stderr)
382     p = None
383     try:
384         p = subprocess.Popen(c,
385                              stdout=PIPE if fix_stdout else out,
386                              stderr=PIPE if fix_stderr else err,
387                              env=tty_env, bufsize=4096, close_fds=True)
388         # Assume p will receive these signals and quit, which will
389         # then cause us to quit.
390         for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
391             signal.signal(sig, signal.SIG_IGN)
392
393         srcs = []
394         dests = []
395         if fix_stdout:
396             srcs.append(p.stdout.fileno())
397             dests.append(out.fileno())
398         if fix_stderr:
399             srcs.append(p.stderr.fileno())
400             dests.append(err.fileno())
401         filter_output(srcs, dests)
402         return p.wait()
403     except BaseException as ex:
404         add_ex_tb(ex)
405         try:
406             if p and p.poll() == None:
407                 os.kill(p.pid, signal.SIGTERM)
408                 p.wait()
409         except BaseException as kill_ex:
410             raise add_ex_ctx(add_ex_tb(kill_ex), ex)
411         raise ex
412
413
414 def run_subcmd(module, args):
415     if module:
416         run_module_cmd(module, args)
417     else:
418         run_subproc_cmd(args)
419
420
421 wrap_main(lambda : run_subcmd(cmd_module, subcmd))