| 1 | #!/usr/bin/env python2
 | 
| 2 | """
 | 
| 3 | time.py -- Replacement for coreutils 'time'.
 | 
| 4 | 
 | 
| 5 | TODO:
 | 
| 6 | (Must be Python 3 because it's used before a Python 2 WEDGE can be installed.)
 | 
| 7 | 
 | 
| 8 | The interface of this program is modelled after:
 | 
| 9 | 
 | 
| 10 | /usr/bin/time --append --output foo.txt --format '%x %e'
 | 
| 11 | 
 | 
| 12 | Why we're not using /usr/bin/time:
 | 
| 13 | - We need to print extra TSV fields at the end of the row, but it prints a newline
 | 
| 14 | - It prints extraneous output: 'Command exited with non-zero status 1'
 | 
| 15 | - Elapsed time only has 2 digits of precision.  Apparently it uses times()
 | 
| 16 |   rather than getrusage()?  https://unix.stackexchange.com/questions/70653/increase-e-precision-with-usr-bin-time-shell-command 
 | 
| 17 | 
 | 
| 18 | Problems with resource.getrusage() in Python:
 | 
| 19 | 
 | 
| 20 |   The memory usage of dash and bash get obscured by Python!  Because
 | 
| 21 |   subprocess.call() does a fork(), which includes Python's address space.
 | 
| 22 |   # https://stackoverflow.com/questions/13880724/python-getrusage-with-rusage-children-behaves-stangely
 | 
| 23 | 
 | 
| 24 | Problems with bash time builtin
 | 
| 25 | - has no way to get the exit code
 | 
| 26 | - writes to stderr, so you it's annoying to get both process stderr and the
 | 
| 27 |   timing
 | 
| 28 | 
 | 
| 29 | This program also writes CSV and TSV directly.
 | 
| 30 | - CSV values get escaped
 | 
| 31 | - TSV values can't have tabs
 | 
| 32 | 
 | 
| 33 | So we use a tiny C program time-helper.c to do it, and not /usr/bin/time.
 | 
| 34 | """
 | 
| 35 | from __future__ import print_function
 | 
| 36 | 
 | 
| 37 | import csv
 | 
| 38 | try:
 | 
| 39 |     import hashlib  # PY3
 | 
| 40 | except ImportError:
 | 
| 41 |     hashlib = None
 | 
| 42 |     import md5
 | 
| 43 | 
 | 
| 44 | import optparse
 | 
| 45 | import os
 | 
| 46 | import sys
 | 
| 47 | import subprocess
 | 
| 48 | import time
 | 
| 49 | 
 | 
| 50 | THIS_DIR = os.path.dirname(os.path.abspath(sys.argv[0]))
 | 
| 51 | 
 | 
| 52 | time1 = os.path.abspath(os.path.join(THIS_DIR, '../_devbuild/bin/time-helper'))
 | 
| 53 | # Pre-built one
 | 
| 54 | time2 = '/wedge/oils-for-unix.org/pkg/time-helper/2023-02-28/time-helper'
 | 
| 55 | 
 | 
| 56 | if os.path.exists(time1):
 | 
| 57 |     TIME_HELPER = time1
 | 
| 58 | elif os.path.exists(time2):
 | 
| 59 |     TIME_HELPER = time2
 | 
| 60 | else:
 | 
| 61 |     raise AssertionError('time-helper not found')
 | 
| 62 | 
 | 
| 63 | 
 | 
| 64 | def log(msg, *args):
 | 
| 65 |     if args:
 | 
| 66 |         msg = msg % args
 | 
| 67 |     print(msg, file=sys.stderr)
 | 
| 68 | 
 | 
| 69 | 
 | 
| 70 | def Options():
 | 
| 71 |     """Returns an option parser instance."""
 | 
| 72 |     p = optparse.OptionParser('time.py [options] ARGV...')
 | 
| 73 |     p.add_option('--tsv',
 | 
| 74 |                  dest='tsv',
 | 
| 75 |                  default=False,
 | 
| 76 |                  action='store_true',
 | 
| 77 |                  help='Write output in TSV format')
 | 
| 78 |     p.add_option(
 | 
| 79 |         '--rusage',
 | 
| 80 |         dest='rusage',
 | 
| 81 |         default=False,
 | 
| 82 |         action='store_true',
 | 
| 83 |         help='Also show user time, system time, and max resident set size')
 | 
| 84 |     p.add_option('--rusage-2',
 | 
| 85 |                  dest='rusage_2',
 | 
| 86 |                  default=False,
 | 
| 87 |                  action='store_true',
 | 
| 88 |                  help='Also show page faults, context switches, etc.')
 | 
| 89 |     p.add_option('--time-span',
 | 
| 90 |                  dest='time_span',
 | 
| 91 |                  default=False,
 | 
| 92 |                  action='store_true',
 | 
| 93 |                  help='Also show start_time and end_time')
 | 
| 94 |     p.add_option('-o',
 | 
| 95 |                  '--output',
 | 
| 96 |                  dest='output',
 | 
| 97 |                  default=None,
 | 
| 98 |                  help='Name of output file to write to to')
 | 
| 99 |     p.add_option('-a',
 | 
| 100 |                  '--append',
 | 
| 101 |                  dest='append',
 | 
| 102 |                  default=False,
 | 
| 103 |                  action='store_true',
 | 
| 104 |                  help='Append to the file instead of overwriting it')
 | 
| 105 |     p.add_option(
 | 
| 106 |         '--field',
 | 
| 107 |         dest='fields',
 | 
| 108 |         default=[],
 | 
| 109 |         action='append',
 | 
| 110 |         help='A string to append to each row, after the exit code and status')
 | 
| 111 |     p.add_option('--time-fmt',
 | 
| 112 |                  dest='time_fmt',
 | 
| 113 |                  default='%.4f',
 | 
| 114 |                  help='sprintf format for elapsed seconds (float)')
 | 
| 115 |     p.add_option(
 | 
| 116 |         '--stdout',
 | 
| 117 |         dest='stdout',
 | 
| 118 |         default=None,
 | 
| 119 |         help='Save stdout to this file, and add a column for its md5 checksum')
 | 
| 120 |     p.add_option(
 | 
| 121 |         '--print-header',
 | 
| 122 |         dest='print_header',
 | 
| 123 |         default=False,
 | 
| 124 |         action='store_true',
 | 
| 125 |         help=
 | 
| 126 |         'Print an XSV header, respecting --time-span, --rusage, --rusage-2, --stdout, --field, and --tsv'
 | 
| 127 |     )
 | 
| 128 |     return p
 | 
| 129 | 
 | 
| 130 | 
 | 
| 131 | def MakeTableOutput(f, tsv):
 | 
| 132 |     if tsv:  # TSV output.
 | 
| 133 |         out = csv.writer(f,
 | 
| 134 |                          delimiter='\t',
 | 
| 135 |                          lineterminator='\n',
 | 
| 136 |                          doublequote=False,
 | 
| 137 |                          quoting=csv.QUOTE_NONE)
 | 
| 138 |     else:
 | 
| 139 |         out = csv.writer(f)
 | 
| 140 |     return out
 | 
| 141 | 
 | 
| 142 | 
 | 
| 143 | def main(argv):
 | 
| 144 |     (opts, child_argv) = Options().parse_args(argv[1:])
 | 
| 145 | 
 | 
| 146 |     # Used only for 'time' format string.  For --field, we use our own.
 | 
| 147 |     sep = '\t' if opts.tsv else ','
 | 
| 148 | 
 | 
| 149 |     if opts.print_header:
 | 
| 150 |         if child_argv:
 | 
| 151 |             raise RuntimeError('No arguments allowed with --print-header')
 | 
| 152 |         names = ['status', 'elapsed_secs']
 | 
| 153 |         if opts.time_span:
 | 
| 154 |             names.extend(['start_time', 'end_time'])
 | 
| 155 |         if opts.rusage:
 | 
| 156 |             names.extend(['user_secs', 'sys_secs', 'max_rss_KiB'])
 | 
| 157 |         if opts.rusage_2:
 | 
| 158 |             names.extend([
 | 
| 159 |                 'minor_faults', 'major_faults', 'swaps', 'in_block',
 | 
| 160 |                 'out_block', 'signals', 'voluntary_ctx', 'involuntary_ctx'
 | 
| 161 |             ])
 | 
| 162 |         if opts.stdout:
 | 
| 163 |             names.append('stdout_md5sum')
 | 
| 164 |         names.extend(opts.fields)
 | 
| 165 | 
 | 
| 166 |         if opts.output:
 | 
| 167 |             f = open(opts.output, 'w')
 | 
| 168 |         else:
 | 
| 169 |             f = sys.stdout
 | 
| 170 |         table_out = MakeTableOutput(f, opts.tsv)
 | 
| 171 |         table_out.writerow(names)
 | 
| 172 |         return 0
 | 
| 173 | 
 | 
| 174 |     if not child_argv:
 | 
| 175 |         raise RuntimeError('Expected a command')
 | 
| 176 | 
 | 
| 177 |     # built by build/dev.sh all
 | 
| 178 |     time_argv = [TIME_HELPER, '-d', sep]
 | 
| 179 | 
 | 
| 180 |     if opts.append:
 | 
| 181 |         time_argv.append('-a')
 | 
| 182 | 
 | 
| 183 |     if opts.output:
 | 
| 184 |         time_argv.extend(['-o', opts.output])
 | 
| 185 |     else:
 | 
| 186 |         pass
 | 
| 187 |         # Somehow this doesn't work?  Getting binary data?
 | 
| 188 |         # time_argv.extend(['-o', '/dev/stdout'])
 | 
| 189 | 
 | 
| 190 |     # %x: exit status
 | 
| 191 |     # %e: elapsed
 | 
| 192 |     time_argv.extend(['-x', '-e'])
 | 
| 193 |     if opts.time_span:
 | 
| 194 |         # begin and end time
 | 
| 195 |         time_argv.extend(['-y', '-z'])
 | 
| 196 |     if opts.rusage:
 | 
| 197 |         # %U: user time
 | 
| 198 |         # %S: sys time
 | 
| 199 |         # %M: Max RSS
 | 
| 200 |         time_argv.extend(['-U', '-S', '-M'])
 | 
| 201 |     if opts.rusage_2:
 | 
| 202 |         time_argv.extend(['-m'])
 | 
| 203 | 
 | 
| 204 |     time_argv.append('--')
 | 
| 205 |     time_argv.extend(child_argv)
 | 
| 206 |     #log('time_argv %s', time_argv)
 | 
| 207 | 
 | 
| 208 |     start_time = time.time()
 | 
| 209 |     try:
 | 
| 210 |         if opts.stdout:
 | 
| 211 |             # We don't want to intermingle 'time' stdout with the program's stdout
 | 
| 212 |             if not opts.output:
 | 
| 213 |                 raise RuntimeError('Flag -o is required when --stdout')
 | 
| 214 |             with open(opts.stdout, 'w') as f:
 | 
| 215 |                 exit_code = subprocess.call(time_argv, stdout=f)
 | 
| 216 |         else:
 | 
| 217 |             exit_code = subprocess.call(time_argv)
 | 
| 218 |     except OSError as e:
 | 
| 219 |         log('Error executing %s: %s', time_argv, e)
 | 
| 220 |         return 1
 | 
| 221 | 
 | 
| 222 |     elapsed = time.time() - start_time
 | 
| 223 |     if opts.stdout:
 | 
| 224 |         if hashlib:
 | 
| 225 |             m = hashlib.md5()  # PY3
 | 
| 226 |         else:
 | 
| 227 |             m = md5.new()  # PY2
 | 
| 228 |         with open(opts.stdout, 'rb') as f:
 | 
| 229 |             while True:
 | 
| 230 |                 chunk = f.read(4096)
 | 
| 231 |                 if not chunk:
 | 
| 232 |                     break
 | 
| 233 |                 m.update(chunk)
 | 
| 234 |         maybe_stdout = [m.hexdigest()]
 | 
| 235 |     else:
 | 
| 236 |         maybe_stdout = []  # no field
 | 
| 237 | 
 | 
| 238 |     more_cells = maybe_stdout + opts.fields
 | 
| 239 | 
 | 
| 240 |     if opts.output:
 | 
| 241 |         with open(opts.output, 'a') as f:  # append
 | 
| 242 |             if more_cells:
 | 
| 243 |                 f.write(sep)  # tab or comma for more cells
 | 
| 244 |                 table_out = MakeTableOutput(f, opts.tsv)
 | 
| 245 |                 table_out.writerow(more_cells)
 | 
| 246 |             else:
 | 
| 247 |                 f.write('\n')  # end the row
 | 
| 248 |     else:
 | 
| 249 |         if more_cells:
 | 
| 250 |             log("More cells that -o would have written: %s", more_cells)
 | 
| 251 | 
 | 
| 252 |     # Preserve the command's exit code.  (This means you can't distinguish
 | 
| 253 |     # between a failure of time.py and the command, but that's better than
 | 
| 254 |     # swallowing the error.)
 | 
| 255 |     return exit_code
 | 
| 256 | 
 | 
| 257 | 
 | 
| 258 | if __name__ == '__main__':
 | 
| 259 |     try:
 | 
| 260 |         status = main(sys.argv)
 | 
| 261 |     except KeyboardInterrupt as e:
 | 
| 262 |         print('%s: interrupted with Ctrl-C' % sys.argv[0], file=sys.stderr)
 | 
| 263 |         sys.exit(1)
 | 
| 264 |     except RuntimeError as e:
 | 
| 265 |         log('time_.py: %s', e)
 | 
| 266 |         status = 2
 | 
| 267 |     sys.exit(status)
 |