From e29a4c0affe3e7b66ff19d70e0d2abbc1e7fdf21 Mon Sep 17 00:00:00 2001 From: Julian Smith Date: Fri, 15 May 2020 11:41:30 +0100 Subject: Added experimental swig wrapper for gsapi. --- toolbin/gsapiwrap.py | 699 ++++++++++++++++++++++++++ toolbin/jlib.py | 1354 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2053 insertions(+) create mode 100755 toolbin/gsapiwrap.py create mode 100644 toolbin/jlib.py (limited to 'toolbin') diff --git a/toolbin/gsapiwrap.py b/toolbin/gsapiwrap.py new file mode 100755 index 000000000..0ef0bb088 --- /dev/null +++ b/toolbin/gsapiwrap.py @@ -0,0 +1,699 @@ +#! /usr/bin/env python3 + +''' +Use Swig to build wrappers for gsapi. + +Example usage: + + Note that we use mupdf's scripts/jlib.py, and assume that there is a mupdf + checkout in the parent directory of the ghostpdl checkout - see 'import + jlib' below. + + ./toolbin/gsapiwrap.py --python -l -0 -1 -t + Build python wrapper for gsapi and run simple test. + + ./toolbin/gsapiwrap.py --csharp -l -0 -1 -t + Build C# wrapper for gsapi and run simple test. + +Args: + + -c: + Clean language-specific out-dir. + + -l: + Build libgs.so (by running make). + + -0: + Run swig to generate language-specific files. + + -1: + Generate language wrappers by compiling/linking the files generated by + -0. + + --csharp: + Generate C# wrappers (requires Mono on Linux). Should usually be first + param. + + --python + Generate Python wrappers. Should usually be first param. + + --swig + Set location of swig binary. + + -t + Run simple test of language wrappers generated by -1. + +Status: + As of 2020-05-22: + Some python wrappers seem to work ok. + + C# wrappers are not implemented for gsapi_set_poll() and + gsapi_set_stdio(). +''' + +import os +import re +import sys +import textwrap + +import jlib + + +def devpython_info(): + ''' + Use python3-config to find libpython.so and python-dev include path etc. + ''' + python_configdir = jlib.system( 'python3-config --configdir', out='return') + libpython_so = os.path.join( + python_configdir.strip(), + f'libpython{sys.version_info[0]}.{sys.version_info[1]}.so', + ) + assert os.path.isfile( libpython_so), f'cannot find libpython_so={libpython_so}' + + python_includes = jlib.system( 'python3-config --includes', out='return') + python_includes = python_includes.strip() + return python_includes, libpython_so + +def swig_version( swig='swig'): + t = jlib.system( f'{swig} -version', out='return') + m = re.search( 'SWIG Version ([0-9]+)[.]([0-9]+)[.]([0-9]+)', t) + assert m + swig_major = int( m.group(1)) + return swig_major + + +dir_ghostpdl = os.path.abspath( f'{__file__}/../../') + '/' + + +def out_dir( language): + if language == 'python': + return 'gsapiwrap/python/' + if language == 'csharp': + return 'gsapiwrap/csharp/' + assert 0 + +def out_so( language): + ''' + Returns name of .so that implements language-specific wrapper. I think + these names have to match what the language runtime requires. + + For python, Swig generates a module foo.py which does 'import _foo'. + + Similarly C# assumes a file called 'libfoo.so'. + ''' + if language == 'python': + return f'{out_dir(language)}_gsapi.so' + if language == 'csharp': + return f'{out_dir(language)}libgsapi.so' + assert 0 + +def lib_gs_info(): + return f'{dir_ghostpdl}sodebugbin/libgs.so', 'make sodebug' + return f'{dir_ghostpdl}sobin/libgs.so', 'make so' + +def lib_gs(): + ''' + Returns name of the gs shared-library. + ''' + return lib_gs_info()[0] + + +def swig_i( swig, language): + ''' + Returns text for a swig .i file for psi/iapi.h. + ''' + swig_major = swig_version( swig) + + + # We need to redeclare or wrap some functions, e.g. to add OUTPUT + # annotations. We use #define, %ignore and #undef to hide the original + # declarations in the .h file. + # + fns_redeclare = ( + 'gsapi_run_file', + 'gsapi_run_string', + 'gsapi_run_string_begin', + 'gsapi_run_string_continue', + 'gsapi_run_string_end', + 'gsapi_run_string_with_length', + 'gsapi_set_poll', + 'gsapi_set_poll_with_handle', + 'gsapi_set_stdio', + 'gsapi_set_stdio_with_handle', + 'gsapi_new_instance', + ) + + + swig_i_text = textwrap.dedent(f''' + %module(directors="1") gsapi + + %include cpointer.i + %pointer_functions(int, pint); + + // This seems to be necessary to make csharp handle OUTPUT args. + // + %include typemaps.i + + // For gsapi_init_with_args(). + %include argcargv.i + + %include cstring.i + + // Include type information in python doc strings. If we have + // swig-4, we can propogate comments from the C api instead, which + // is preferred. + // + {'%feature("autodoc", "3");' if swig_major < 4 else ''} + + %{{ + #include "psi/iapi.h" + //#include "base/gserrors.h" + + // Define wrapper functions that present a modified API that + // swig can cope with. + // + + // Swig cannot handle void** out-param. + // + static void* new_instance( void* caller_handle, int* out) + {{ + void* ret = NULL; + *out = gsapi_new_instance( &ret, caller_handle); + printf( "gsapi_new_instance() returned *out=%i ret=%p\\n", *out, ret); + fflush( stdout); + return ret; + }} + + // Swig cannot handle (const char* str, int strlen) args. + // + static int run_string_continue(void *instance, const char *str, int user_errors, int *pexit_code) {{ + + return gsapi_run_string_continue( instance, str, strlen(str), user_errors, pexit_code); + }} + %}} + + // Strip gsapi_ prefix from all generated names. + // + %rename("%(strip:[gsapi_])s") ""; + + // Tell Swig about gsapi_get_default_device_list()'s out-params, so + // it adds them to the returned object. + // + // I think the '(void) *$1' will ensure that swig code doesn't + // attempt to free() the returned string. + // + {'%cstring_output_allocate_size(char **list, int *listlen, (void) *$1);' if language == 'python' else ''} + + // Tell swig about the (argc,argv) args in gsapi_init_with_args(). + // + %apply (int ARGC, char **ARGV) {{ (int argc, char **argv) }} + + // Support for wrapping various functions that take function + // pointer args. For each, we define a wrapper function that, + // instead of having function pointer args, takes a class with + // virtual methods. This allows swig to wrap things - python/c# etc + // can create a derived class that implements these virtual methods + // in the python/c# world. + // + + // Wrap gsapi_set_stdio_with_handle(). + // + %feature("director") set_stdio_class; + + %inline {{ + struct set_stdio_class {{ + + virtual int stdin_fn( char* buf, int len) = 0; + virtual int stdout_fn( const char* buf, int len) = 0; + virtual int stderr_fn( const char* buf, int len) = 0; + + static int stdin_fn_wrap( void *caller_handle, char *buf, int len) {{ + return ((set_stdio_class*) caller_handle)->stdin_fn(buf, len); + }} + static int stdout_fn_wrap( void *caller_handle, const char *buf, int len) {{ + return ((set_stdio_class*) caller_handle)->stdout_fn(buf, len); + }} + static int stderr_fn_wrap( void *caller_handle, const char *buf, int len) {{ + return ((set_stdio_class*) caller_handle)->stderr_fn(buf, len); + }} + + virtual ~set_stdio_class() {{}} + }}; + + int set_stdio_with_class( void *instance, set_stdio_class* class_) {{ + return gsapi_set_stdio_with_handle( + instance, + set_stdio_class::stdin_fn_wrap, + set_stdio_class::stdout_fn_wrap, + set_stdio_class::stderr_fn_wrap, + (void*) class_ + ); + }} + + + }} + + // Wrap gsapi_set_poll(). + // + %feature("director") set_poll_class; + + %inline {{ + struct set_poll_class {{ + virtual int poll_fn() = 0; + + static int poll_fn_wrap( void* caller_handle) {{ + return ((set_poll_class*) caller_handle)->poll_fn(); + }} + + virtual ~set_poll_class() {{}} + }}; + + int set_poll_with_class( void* instance, set_poll_class* class_) {{ + return gsapi_set_poll_with_handle( + instance, + set_poll_class::poll_fn_wrap, + (void*) class_ + ); + }} + + }} + + // For functions that we re-declare (typically to specify OUTPUT on + // one or more args), use a macro to rename the declaration in the + // header file and tell swig to ignore these renamed declarations. + // + ''') + + for fn in fns_redeclare: + swig_i_text += f'#define {fn} {fn}0\n' + + for fn in fns_redeclare: + swig_i_text += f'%ignore {fn}0;\n' + + swig_i_text += textwrap.dedent(f''' + #include "psi/iapi.h" + //#include "base/gserrors.h" + ''') + + for fn in fns_redeclare: + swig_i_text += f'#undef {fn}\n' + + + swig_i_text += textwrap.dedent(f''' + // Tell swig about our wrappers and altered declarations. + // + + // Use swig's OUTPUT annotation for out-parameters. + // + int gsapi_run_file(void *instance, const char *file_name, int user_errors, int *OUTPUT); + int gsapi_run_string_begin(void *instance, int user_errors, int *OUTPUT); + int gsapi_run_string_end(void *instance, int user_errors, int *OUTPUT); + //int gsapi_run_string_with_length(void *instance, const char *str, unsigned int length, int user_errors, int *OUTPUT); + int gsapi_run_string(void *instance, const char *str, int user_errors, int *OUTPUT); + + // Declare functions defined above that we want swig to wrap. These + // don't have the gsapi_ prefix, so that they can internally call + // the wrapped gsapi_*() function. [We've told swig to strip the + // gsapi_ prefix on generated functions anyway, so this doesn't + // afffect the generated names.] + // + static int run_string_continue(void *instance, const char *str, int user_errors, int *OUTPUT); + static void* new_instance(void* caller_handle, int* OUTPUT); + ''') + + if language == 'python': + swig_i_text += textwrap.dedent(f''' + + // Define python code that is needed to handle functions with + // function-pointer args. + // + %pythoncode %{{ + + set_stdio_g = None + def set_stdio( instance, stdin, stdout, stderr): + class derived( set_stdio_class): + def stdin_fn( self): + return stdin() + def stdout_fn( self, text, len): + return stdout( text, len) + def stderr_fn( self, text, len): + return stderr( text) + + global set_stdio_g + set_stdio_g = derived() + return set_stdio_with_class( instance, set_stdio_g) + + set_poll_g = None + def set_poll( instance, fn): + class derived( set_poll_class): + def poll_fn( self): + return fn() + global set_poll_g + set_poll_g = derived() + return set_poll_with_class( instance, set_poll_g) + %}} + ''') + + return swig_i_text + + + +def run_swig( swig, language): + ''' + Runs swig using a generated .i file. + + The .i file modifies the gsapi API in places to allow specification of + out-parameters that swig understands - e.g. void** OUTPUT doesn't work. + ''' + os.makedirs( out_dir(language), exist_ok=True) + swig_major = swig_version( swig) + + swig_i_text = swig_i( swig, language) + swig_i_filename = f'{out_dir(language)}iapi.i' + jlib.update_file( swig_i_text, swig_i_filename) + + out_cpp = f'{out_dir(language)}gsapi.cpp' + + if language == 'python': + out_lang = f'{out_dir(language)}gsapi.py' + elif language == 'csharp': + out_lang = f'{out_dir(language)}gsapi.cs' + else: + assert 0 + + out_files = (out_cpp, out_lang) + + doxygen_arg = '' + if swig_major >= 4 and language == 'python': + doxygen_arg = '-doxygen' + + extra = '' + if language == 'csharp': + # Tell swig to put all generated csharp code into a single file. + extra = f'-outfile gsapi.cs' + + command = (textwrap.dedent(f''' + {swig} + -Wall + -c++ + -{language} + {doxygen_arg} + -module gsapi + -outdir {out_dir(language)} + -o {out_cpp} + {extra} + -includeall + -I{dir_ghostpdl} + -ignoremissing + {swig_i_filename} + ''').strip().replace( '\n', ' \\\n') + ) + + jlib.build( + (swig_i_filename,), + out_files, + command, + prefix=' ', + ) + + +def main( argv): + + swig = 'swig' + language = 'python' + + args = jlib.Args( sys.argv[1:]) + while 1: + try: + arg = args.next() + except StopIteration: + break + + if 0: + pass + + elif arg == '-c': + jlib.system( f'rm {out_dir(language)}* || true', verbose=1, prefix=' ') + + elif arg == '-l': + command = lib_gs_info()[1] + jlib.system( command, verbose=1, prefix=' ') + + elif arg == '-0': + run_swig( swig, language) + + elif arg == '-1': + + libs = [lib_gs()] + includes = [dir_ghostpdl] + file_cpp = f'{out_dir(language)}gsapi.cpp' + + if language == 'python': + python_includes, libpython_so = devpython_info() + libs.append( libpython_so) + includes.append( python_includes) + + includes_text = '' + for i in includes: + includes_text += f' -I{i}' + command = textwrap.dedent(f''' + g++ + -g + -Wall -W + -o {out_so(language)} + -fPIC + -shared + {includes_text} + {jlib.link_l_flags(libs)} + {file_cpp} + ''').strip().replace( '\n', ' \\\n') + jlib.build( + (file_cpp, lib_gs(), 'psi/iapi.h'), + (out_so(language),), + command, + prefix=' ', + ) + + elif arg == '--csharp': + language = 'csharp' + + elif arg == '--python': + language = 'python' + + elif arg == '--swig': + swig = args.next() + + elif arg == '-t': + + if language == 'python': + text = textwrap.dedent(''' + #!/usr/bin/env python3 + + import os + import sys + + import gsapi + + gsapi.gs_error_Quit = -101 + + def main(): + minst, code = gsapi.new_instance(None) + print( f'minst={minst} code={code}') + + if 1: + def stdin_local(len): + # Not sure whether this is right. + return sys.stdin.read(len) + def stdout_local(text, l): + sys.stdout.write(text[:l]) + return l + def stderr_local(text, l): + sys.stderr.write(text[:l]) + return l + gsapi.set_stdio( minst, None, stdout_local, stderr_local); + + if 1: + def poll_fn(): + return 0 + gsapi.set_poll(minst, poll_fn) + if 1: + s = 'display x11alpha x11 bbox' + gsapi.set_default_device_list( minst, s, len(s)) + + e, text = gsapi.get_default_device_list( minst) + print( f'gsapi.get_default_device_list() returned e={e} text={text!r}') + + out = 'out.pdf' + if os.path.exists( out): + os.remove( out) + assert not os.path.exists( out) + + gsargv = [''] + gsargv += f'-dNOPAUSE -dBATCH -dSAFER -sDEVICE=pdfwrite -sOutputFile={out} contrib/pcl3/ps/levels-test.ps'.split() + print( f'gsargv={gsargv}') + code = gsapi.set_arg_encoding(minst, gsapi.GS_ARG_ENCODING_UTF8) + if code == 0: + code = gsapi.init_with_args(minst, gsargv) + + code, exit_code = gsapi.run_string_begin( minst, 0) + print( f'gsapi.run_string_begin() returned code={code} exit_code={exit_code}') + assert code == 0 + assert exit_code == 0 + + gsapi.run_string + + code1 = gsapi.exit(minst) + if (code == 0 or code == gsapi.gs_error_Quit): + code = code1 + gsapi.delete_instance(minst) + assert os.path.isfile( out) + if code == 0 or code == gsapi.gs_error_Quit: + return 0 + return 1 + + if __name__ == '__main__': + code = main() + assert code == 0 + sys.exit( code) + ''') + text = text[1:] # skip leading \n. + test_py = f'{out_dir(language)}test.py' + jlib.update_file( text, test_py) + os.chmod( test_py, 0o744) + + jlib.system( + f'LD_LIBRARY_PATH={os.path.abspath( f"{lib_gs()}/..")}' + f' PYTHONPATH={out_dir(language)}' + f' {test_py}' + , + verbose = 1, + prefix=' ', + ) + + elif language == 'csharp': + # See: https://github.com/swig/swig/blob/master/Lib/csharp/typemaps.i + # + text = textwrap.dedent(''' + using System; + public class runme { + static void Main() { + int code; + SWIGTYPE_p_void instance; + Console.WriteLine("hello world"); + instance = gsapi.new_instance(null, out code); + Console.WriteLine("code is: " + code); + gsapi.add_control_path(instance, 0, "hello"); + } + } + ''') + test_cs = f'{out_dir(language)}test.cs' + jlib.update_file( text, test_cs) + files_in = f'{out_dir(language)}gsapi.cs', test_cs + file_out = f'{out_dir(language)}test.exe' + command = f'mono-csc -debug+ -out:{file_out} {" ".join(files_in)}' + jlib.build( files_in, (file_out,), command, prefix=' ') + + ld_library_path = f'{dir_ghostpdl}sobin' + jlib.system( f'LD_LIBRARY_PATH={ld_library_path} {file_out}', verbose=jlib.log, prefix=' ') + + elif arg == '--tt': + # small swig test case. + os.makedirs( 'swig-tt', exist_ok=True) + i = textwrap.dedent(f''' + %include cpointer.i + %include cstring.i + %feature("autodoc", "3"); + %cstring_output_allocate_size(char **list, int *listlen, (void) *$1); + %inline {{ + static inline int gsapi_get_default_device_list(void *instance, char **list, int *listlen) + {{ + *list = (char*) "hello world"; + *listlen = 6; + return 0; + }} + }} + ''') + jlib.update_file(i, 'swig-tt/tt.i') + jlib.system('swig -c++ -python -module tt -outdir swig-tt -o swig-tt/tt.cpp swig-tt/tt.i', verbose=1) + p = textwrap.dedent(f''' + #!/usr/bin/env python3 + import tt + print( tt.gsapi_get_default_device_list(None)) + ''')[1:] + jlib.update_file( p, 'swig-tt/test.py') + python_includes, python_so = devpython_info() + includes = f'-I {python_includes}' + link_flags = jlib.link_l_flags( [python_so]) + jlib.system( f'g++ -shared -fPIC {includes} {link_flags} -o swig-tt/_tt.so swig-tt/tt.cpp', verbose=1) + jlib.system( f'cd swig-tt; python3 test.py', verbose=1) + + elif arg == '-T': + # Very simple test that we can create c# wrapper for trivial code. + os.makedirs( 'swig-cs-test', exist_ok=True) + example_cpp = textwrap.dedent(''' + #include + double My_variable = 3.0; + + int fact(int n) { + if (n <= 1) return 1; + else return n*fact(n-1); + } + + int my_mod(int x, int y) { + return (x%y); + } + + char *get_time() + { + time_t ltime; + time(<ime); + return ctime(<ime); + } + ''') + jlib.update_file( example_cpp, 'swig-cs-test/example.cpp') + + example_i = textwrap.dedent(''' + %module example + %{ + /* Put header files here or function declarations like below */ + extern double My_variable; + extern int fact(int n); + extern int my_mod(int x, int y); + extern char *get_time(); + %} + + extern double My_variable; + extern int fact(int n); + extern int my_mod(int x, int y); + extern char *get_time(); + ''') + jlib.update_file( example_i, 'swig-cs-test/example.i') + + runme_cs = textwrap.dedent(''' + using System; + public class runme { + static void Main() { + Console.WriteLine(example.My_variable); + Console.WriteLine(example.fact(5)); + Console.WriteLine(example.get_time()); + } + } + ''') + jlib.update_file( runme_cs, 'swig-cs-test/runme.cs') + jlib.system( 'g++ -g -fPIC -shared -o swig-cs-test/libfoo.so swig-cs-test/example.cpp', verbose=1) + jlib.system( 'swig -c++ -csharp -module example -outdir swig-cs-test -o swig-cs-test/example_wrap.cpp -outfile example.cs swig-cs-test/example.i', verbose=1) + jlib.system( 'g++ -g -fPIC -shared -L swig-cs-test -l foo swig-cs-test/example_wrap.cpp -o swig-cs-test/libexample.so', verbose=1) + jlib.system( 'cd swig-cs-test; mono-csc -out:runme.exe example.cs runme.cs', verbose=1) + jlib.system( 'cd swig-cs-test; LD_LIBRARY_PATH=`pwd` ./runme.exe', verbose=1) + jlib.system( 'ls -l swig-cs-test', verbose=1) + + + else: + raise Exception( f'unrecognised arg: {arg}') + +if __name__ == '__main__': + try: + main( sys.argv) + except Exception as e: + jlib.exception_info( out=sys.stdout) + sys.exit(1) diff --git a/toolbin/jlib.py b/toolbin/jlib.py new file mode 100644 index 000000000..54ee0a222 --- /dev/null +++ b/toolbin/jlib.py @@ -0,0 +1,1354 @@ +from __future__ import print_function + +import codecs +import inspect +import io +import os +import shutil +import subprocess +import sys +import time +import traceback + + +def place( frame_record): + ''' + Useful debugging function - returns representation of source position of + caller. + ''' + filename = frame_record.filename + line = frame_record.lineno + function = frame_record.function + ret = os.path.split( filename)[1] + ':' + str( line) + ':' + function + ':' + if 0: + tid = str( threading.currentThread()) + ret = '[' + tid + '] ' + ret + return ret + + +def expand_nv( text, caller): + ''' + Returns with special handling of {} items. + + text: + String containing {} items. + caller: + If an int, the number of frames to step up when looking for file:line + information or evaluating expressions. + + Otherwise should be a frame record as returned by inspect.stack()[]. + + is evaluated in 's context using eval(), and expanded + to or =. + + If ends with '=', this character is removed and we prefix the + result with =. + + E.g.: + x = 45 + y = 'hello' + expand_nv( 'foo {x} {y=}') + returns: + foo 45 y=hello + + can also use ':' and '!' to control formatting, like + str.format(). + ''' + if isinstance( caller, int): + frame_record = inspect.stack()[ caller] + else: + frame_record = caller + frame = frame_record.frame + try: + def get_items(): + ''' + Yields (pre, item), where is contents of next {...} or None, + and
 is preceding text.
+            '''
+            pos = 0
+            pre = ''
+            while 1:
+                if pos == len( text):
+                    yield pre, None
+                    break
+                rest = text[ pos:]
+                if rest.startswith( '{{') or rest.startswith( '}}'):
+                    pre += rest[0]
+                    pos += 2
+                elif text[ pos] == '{':
+                    close = text.find( '}', pos)
+                    if close < 0:
+                        raise Exception( 'After "{" at offset %s, cannot find closing "}". text is: %r' % (
+                                pos, text))
+                    yield pre, text[ pos+1 : close]
+                    pre = ''
+                    pos = close + 1
+                else:
+                    pre += text[ pos]
+                    pos += 1
+
+        ret = ''
+        for pre, item in get_items():
+            ret += pre
+            nv = False
+            if item:
+                if item.endswith( '='):
+                    nv = True
+                    item = item[:-1]
+                expression, tail = split_first_of( item, '!:')
+                try:
+                    value = eval( expression, frame.f_globals, frame.f_locals)
+                    value_text = ('{0%s}' % tail).format( value)
+                except Exception as e:
+                    value_text = '{??Failed to evaluate %r in context %s:%s because: %s??}' % (
+                            expression,
+                            frame_record.filename,
+                            frame_record.lineno,
+                            e,
+                            )
+                if nv:
+                    ret += '%s=' % expression
+                ret += value_text
+
+        return ret
+
+    finally:
+        del frame
+
+
+class LogPrefixTime:
+    def __init__( self, date=False, time_=True, elapsed=False):
+        self.date = date
+        self.time = time_
+        self.elapsed = elapsed
+        self.t0 = time.time()
+    def __call__( self):
+        ret = ''
+        if self.date:
+            ret += time.strftime( ' %F')
+        if self.time:
+            ret += time.strftime( ' %T')
+        if self.elapsed:
+            ret += ' (+%s)' % time_duration( time.time() - self.t0, s_format='%.1f')
+        if ret:
+            ret = ret.strip() + ': '
+        return ret
+
+class LogPrefixFileLine:
+    def __call__( self, caller):
+        if isinstance( caller, int):
+            caller = inspect.stack()[ caller]
+        return place( caller) + ' '
+
+class LogPrefixScopes:
+    '''
+    Internal use only.
+    '''
+    def __init__( self):
+        self.items = []
+    def __call__( self):
+        ret = ''
+        for item in self.items:
+            if callable( item):
+                item = item()
+            ret += item
+        return ret
+
+
+class LogPrefixScope:
+    '''
+    Can be used to insert scoped prefix to log output.
+    '''
+    def __init__( self, prefix):
+        g_log_prefixe_scopes.items.append( prefix)
+    def __enter__( self):
+        pass
+    def __exit__( self, exc_type, exc_value, traceback):
+        global g_log_prefix
+        g_log_prefixe_scopes.items.pop()
+
+
+g_log_delta = 0
+
+class LogDeltaScope:
+    '''
+    Can be used to temporarily change verbose level of logging.
+
+    E.g to temporarily increase logging:
+
+        with jlib.LogDeltaScope(-1):
+            ...
+    '''
+    def __init__( self, delta):
+        self.delta = delta
+        global g_log_delta
+        g_log_delta += self.delta
+    def __enter__( self):
+        pass
+    def __exit__( self, exc_type, exc_value, traceback):
+        global g_log_delta
+        g_log_delta -= self.delta
+
+# Special item that can be inserted into  to enable
+# temporary addition of text into log prefixes.
+#
+g_log_prefixe_scopes = LogPrefixScopes()
+
+# List of items that form prefix for all output from log().
+#
+g_log_prefixes = []
+
+
+def log_text( text=None, caller=1, nv=True):
+    '''
+    Returns log text, prepending all lines with text from g_log_prefixes.
+
+    text:
+        The text to output. Each line is prepended with prefix text.
+    caller:
+        If an int, the number of frames to step up when looking for file:line
+        information or evaluating expressions.
+
+        Otherwise should be a frame record as returned by inspect.stack()[].
+    nv:
+        If true, we expand {...} in  using expand_nv().
+    '''
+    if isinstance( caller, int):
+        caller += 1
+    prefix = ''
+    for p in g_log_prefixes:
+        if callable( p):
+            if isinstance( p, LogPrefixFileLine):
+                p = p(caller)
+            else:
+                p = p()
+        prefix += p
+
+    if text is None:
+        return prefix
+
+    if nv:
+        text = expand_nv( text, caller)
+
+    if text.endswith( '\n'):
+        text = text[:-1]
+    lines = text.split( '\n')
+
+    text = ''
+    for line in lines:
+        text += prefix + line + '\n'
+    return text
+
+
+
+s_log_levels_cache = dict()
+s_log_levels_items = []
+
+def log_levels_find( caller):
+    if not s_log_levels_items:
+        return 0
+
+    tb = traceback.extract_stack( None, 1+caller)
+    if len(tb) == 0:
+        return 0
+    filename, line, function, text = tb[0]
+
+    key = function, filename, line,
+    delta = s_log_levels_cache.get( key)
+
+    if delta is None:
+        # Calculate and populate cache.
+        delta = 0
+        for item_function, item_filename, item_delta in s_log_levels_items:
+            if item_function and not function.startswith( item_function):
+                continue
+            if item_filename and not filename.startswith( item_filename):
+                continue
+            delta = item_delta
+            break
+
+        s_log_levels_cache[ key] = delta
+
+    return delta
+
+
+def log_levels_add( delta, filename_prefix, function_prefix):
+    '''
+    log() calls from locations with filenames starting with 
+    and/or function names starting with  will have 
+    added to their level.
+
+    Use -ve delta to increase verbosity from particular filename or function
+    prefixes.
+    '''
+    log( 'adding level: {filename_prefix=!r} {function_prefix=!r}')
+
+    # Sort in reverse order so that long functions and filename specs come
+    # first.
+    #
+    s_log_levels_items.append( (function_prefix, filename_prefix, delta))
+    s_log_levels_items.sort( reverse=True)
+
+
+def log( text, level=0, caller=1, nv=True, out=None):
+    '''
+    Writes log text, with special handling of {} items in 
+    similar to python3's f-strings.
+
+    text:
+        The text to output.
+    caller:
+        How many frames to step up to get caller's context when evaluating
+        file:line information and/or expressions. Or frame record as returned
+        by inspect.stack()[].
+    nv:
+        If true, we expand {...} in  using expand_nv().
+    out:
+        Where to send output. If None we use sys.stdout.
+
+     is evaluated in our caller's context ( stack frames up)
+    using eval(), and expanded to  or =.
+
+    If  ends with '=', this character is removed and we prefix the
+    result with =.
+
+    E.g.:
+        x = 45
+        y = 'hello'
+        expand_nv( 'foo {x} {y=}')
+    returns:
+        foo 45 y=hello
+
+     can also use ':' and '!' to control formatting, like
+    str.format().
+    '''
+    if out is None:
+        out = sys.stdout
+    level += g_log_delta
+    if isinstance( caller, int):
+        caller += 1
+    level += log_levels_find( caller)
+    if level <= 0:
+        text = log_text( text, caller, nv=nv)
+        out.write( text)
+        out.flush()
+
+
+def log0( text, caller=1, nv=True, out=None):
+    '''
+    Most verbose log. Same as log().
+    '''
+    log( text, level=0, caller=caller+1, nv=nv, out=out)
+
+def log1( text, caller=1, nv=True, out=None):
+    log( text, level=1, caller=caller+1, nv=nv, out=out)
+
+def log2( text, caller=1, nv=True, out=None):
+    log( text, level=2, caller=caller+1, nv=nv, out=out)
+
+def log3( text, caller=1, nv=True, out=None):
+    log( text, level=3, caller=caller+1, nv=nv, out=out)
+
+def log4( text, caller=1, nv=True, out=None):
+    log( text, level=4, caller=caller+1, nv=nv, out=out)
+
+def log5( text, caller=1, nv=True, out=None):
+    '''
+    Least verbose log.
+    '''
+    log( text, level=5, caller=caller+1, nv=nv, out=out)
+
+def logx( text, caller=1, nv=True, out=None):
+    '''
+    Does nothing, useful when commenting out a log().
+    '''
+    pass
+
+
+def log_levels_add_env( name='JLIB_log_levels'):
+    '''
+    Added log levels encoded in an environmental variable.
+    '''
+    t = os.environ.get( name)
+    if t:
+        for ffll in t.split( ','):
+            ffl, delta = ffll.split( '=', 1)
+            delta = int( delta)
+            ffl = ffl.split( ':')
+            if 0:
+                pass
+            elif len( ffl) == 1:
+                filename = ffl
+                function = None
+            elif len( ffl) == 2:
+                filename, function = ffl
+            else:
+                assert 0
+            log_levels_add( delta, filename, function)
+
+
+def strpbrk( text, substrings):
+    '''
+    Finds first occurrence of any item in  in .
+
+    Returns (pos, substring) or (len(text), None) if not found.
+    '''
+    ret_pos = len( text)
+    ret_substring = None
+    for substring in substrings:
+        pos = text.find( substring)
+        if pos >= 0 and pos < ret_pos:
+            ret_pos = pos
+            ret_substring = substring
+    return ret_pos, ret_substring
+
+
+def split_first_of( text, substrings):
+    '''
+    Returns (pre, post), where 
 doesn't contain any item in 
+    and  is empty or starts with an item in .
+    '''
+    pos, _ = strpbrk( text, substrings)
+    return text[ :pos], text[ pos:]
+
+
+
+log_levels_add_env()
+
+
+def force_line_buffering():
+    '''
+    Ensure sys.stdout and sys.stderr are line-buffered. E.g. makes things work
+    better if output is piped to a file via 'tee'.
+
+    Returns original out,err streams.
+    '''
+    stdout0 = sys.stdout
+    stderr0 = sys.stderr
+    sys.stdout = os.fdopen( os.dup( sys.stdout.fileno()), 'w', 1)
+    sys.stderr = os.fdopen( os.dup( sys.stderr.fileno()), 'w', 1)
+    return stdout0, stderr0
+
+
+def exception_info( exception=None, limit=None, out=None, prefix='', oneline=False):
+    '''
+    General replacement for traceback.* functions that print/return information
+    about exceptions. This function provides a simple way of getting the
+    functionality provided by these traceback functions:
+
+        traceback.format_exc()
+        traceback.format_exception()
+        traceback.print_exc()
+        traceback.print_exception()
+
+    Returns:
+        A string containing description of specified exception and backtrace.
+
+    Inclusion of outer frames:
+        We improve upon traceback.* in that we also include stack frames above
+        the point at which an exception was caught - frames from the top-level
+         or thread creation fn to the try..catch block, which makes
+        backtraces much more useful.
+
+        Google 'sys.exc_info backtrace incomplete' for more details.
+
+        We deliberately leave a slightly curious pair of items in the backtrace
+        - the point in the try: block that ended up raising an exception, and
+        the point in the associated except: block from which we were called.
+
+        For clarity, we insert an empty frame in-between these two items, so
+        that one can easily distinguish the two parts of the backtrace.
+
+        So the backtrace looks like this:
+
+            root (e.g.  or /usr/lib/python2.7/threading.py:778:__bootstrap():
+            ...
+            file:line in the except: block where the exception was caught.
+            ::(): marker
+            file:line in the try: block.
+            ...
+            file:line where the exception was raised.
+
+        The items after the ::(): marker are the usual items that traceback.*
+        shows for an exception.
+
+    Also the backtraces that are generated are more concise than those provided
+    by traceback.* - just one line per frame instead of two - and filenames are
+    output relative to the current directory if applicatble. And one can easily
+    prefix all lines with a specified string, e.g. to indent the text.
+
+    Returns a string containing backtrace and exception information, and sends
+    returned string to  if specified.
+
+    exception:
+        None, or a (type, value, traceback) tuple, e.g. from sys.exc_info(). If
+        None, we call sys.exc_info() and use its return value.
+    limit:
+        None or maximum number of stackframes to output.
+    out:
+        None or callable taking single  parameter or object with a
+        'write' member that takes a single  parameter.
+    prefix:
+        Used to prefix all lines of text.
+    '''
+    if exception is None:
+        exception = sys.exc_info()
+    etype, value, tb = exception
+
+    if sys.version_info[0] == 2:
+        out2 = io.BytesIO()
+    else:
+        out2 = io.StringIO()
+    try:
+
+        frames = []
+
+        # Get frames above point at which exception was caught - frames
+        # starting at top-level  or thread creation fn, and ending
+        # at the point in the catch: block from which we were called.
+        #
+        # These frames are not included explicitly in sys.exc_info()[2] and are
+        # also omitted by traceback.* functions, which makes for incomplete
+        # backtraces that miss much useful information.
+        #
+        for f in reversed(inspect.getouterframes(tb.tb_frame)):
+            ff = f[1], f[2], f[3], f[4][0].strip()
+            frames.append(ff)
+
+        if 1:
+            # It's useful to see boundary between upper and lower frames.
+            frames.append( None)
+
+        # Append frames from point in the try: block that caused the exception
+        # to be raised, to the point at which the exception was thrown.
+        #
+        # [One can get similar information using traceback.extract_tb(tb):
+        #   for f in traceback.extract_tb(tb):
+        #       frames.append(f)
+        # ]
+        for f in inspect.getinnerframes(tb):
+            ff = f[1], f[2], f[3], f[4][0].strip()
+            frames.append(ff)
+
+        cwd = os.getcwd() + os.sep
+        if oneline:
+            if etype and value:
+                # The 'exception_text' variable below will usually be assigned
+                # something like ': ', unless
+                # there was no explanatory text provided (e.g. "raise Exception()").
+                # In this case, str(value) will evaluate to ''.
+                exception_text = traceback.format_exception_only(etype, value)[0].strip()
+                filename, line, fnname, text = frames[-1]
+                if filename.startswith(cwd):
+                    filename = filename[len(cwd):]
+                if not str(value):
+                    # The exception doesn't have any useful explanatory text
+                    # (for example, maybe it was raised by an expression like
+                    # "assert " without a subsequent comma).  In
+                    # the absence of anything more helpful, print the code that
+                    # raised the exception.
+                    exception_text += ' (%s)' % text
+                line = '%s%s at %s:%s:%s()' % (prefix, exception_text, filename, line, fnname)
+                out2.write(line)
+        else:
+            out2.write( '%sBacktrace:\n' % prefix)
+            for frame in frames:
+                if frame is None:
+                    out2.write( '%s    ^except raise:\n' % prefix)
+                    continue
+                filename, line, fnname, text = frame
+                if filename.startswith( cwd):
+                    filename = filename[ len(cwd):]
+                if filename.startswith( './'):
+                    filename = filename[ 2:]
+                out2.write( '%s    %s:%s:%s(): %s\n' % (
+                        prefix, filename, line, fnname, text))
+
+            if etype and value:
+                out2.write( '%sException:\n' % prefix)
+                lines = traceback.format_exception_only( etype, value)
+                for line in lines:
+                    out2.write( '%s    %s' % ( prefix, line))
+
+        text = out2.getvalue()
+
+        # Write text to  if specified.
+        out = getattr( out, 'write', out)
+        if callable( out):
+            out( text)
+        return text
+
+    finally:
+        # clear things to avoid cycles.
+        exception = None
+        etype = None
+        value = None
+        tb = None
+        frames = None
+
+
+def number_sep( s):
+    '''
+    Simple number formatter, adds commas in-between thousands.  can
+    be a number or a string. Returns a string.
+    '''
+    if not isinstance( s, str):
+        s = str( s)
+    c = s.find( '.')
+    if c==-1:   c = len(s)
+    end = s.find('e')
+    if end == -1:   end = s.find('E')
+    if end == -1:   end = len(s)
+    ret = ''
+    for i in range( end):
+        ret += s[i]
+        if ic and i interval or override:
+        debug_periodic_t0[0] = t
+        debug(text)
+
+
+def time_duration( seconds, verbose=False, s_format='%i'):
+    '''
+    Returns string expressing an interval.
+
+    seconds:
+        The duration in seconds
+    verbose:
+        If true, return like '4 days 1 hour 2 mins 23 secs', otherwise as
+        '4d3h2m23s'.
+    s_format:
+        If specified, use as printf-style format string for seconds.
+    '''
+    x = abs(seconds)
+    ret = ''
+    i = 0
+    for div, text in [
+            ( 60, 'sec'),
+            ( 60, 'min'),
+            ( 24, 'hour'),
+            ( None, 'day'),
+            ]:
+        force = ( x == 0 and i == 0)
+        if div:
+            remainder = x % div
+            x = int( x/div)
+        else:
+            remainder = x
+        if not verbose:
+            text = text[0]
+        if remainder or force:
+            if verbose and remainder > 1:
+                # plural.
+                text += 's'
+            if verbose:
+                text = ' %s ' % text
+            if i == 0:
+                remainder = s_format % remainder
+            ret = '%s%s%s' % ( remainder, text, ret)
+        i += 1
+    ret = ret.strip()
+    if ret == '':
+        ret = '0s'
+    if seconds < 0:
+        ret = '-%s' % ret
+    return ret
+
+assert time_duration( 303333) == '3d12h15m33s'
+assert time_duration( 303333.33, s_format='%.1f') == '3d12h15m33.3s'
+assert time_duration( 303333, verbose=True) == '3 days 12 hours 15 mins 33 secs'
+assert time_duration( 303333.33, verbose=True, s_format='%.1f') == '3 days 12 hours 15 mins 33.3 secs'
+
+assert time_duration( 0) == '0s'
+assert time_duration( 0, verbose=True) == '0 sec'
+
+
+def date_time( t=None):
+    if t is None:
+        t = time.time()
+    return time.strftime( "%F-%T", time.gmtime( t))
+
+def stream_prefix_time( stream):
+    '''
+    Returns StreamPrefix that prefixes lines with time and elapsed time.
+    '''
+    t_start = time.time()
+    def prefix_time():
+        return '%s (+%s): ' % (
+                time.strftime( '%T'),
+                time_duration( time.time() - t_start, s_format='0.1f'),
+                )
+    return StreamPrefix( stream, prefix_time)
+
+def stdout_prefix_time():
+    '''
+    Changes sys.stdout to prefix time and elapsed time; returns original
+    sys.stdout.
+    '''
+    ret = sys.stdout
+    sys.stdout = stream_prefix_time( sys.stdout)
+    return ret
+
+
+def make_stream( out):
+    '''
+    If  already has a .write() member, returns .
+
+    Otherwise a stream-like object with a .write() method that writes to .
+
+    out:
+        Where output is sent.
+        If None, output is lost.
+        Otherwise if an integer, we do: os.write( out, text)
+        Otherwise if callable, we do: out( text)
+        Otherwise we assume  is python stream or similar already.
+    '''
+    if getattr( out, 'write', None):
+        return out
+    class Ret:
+        def flush():
+            pass
+    ret = Ret()
+    if out is None:
+        ret.write = lambda text: None
+    elif isinstance( out, int):
+        ret.write = lambda text: os.write( out, text)
+    elif callable( out):
+        ret.write = out
+    else:
+        ret.write = lambda text: out.write( text)
+    return ret
+
+
+def system_raw(
+        command,
+        out=None,
+        shell=True,
+        encoding='latin_1',
+        errors='strict',
+        buffer_len=-1,
+        ):
+    '''
+    Runs command, writing output to  which can be an int fd, a python
+    stream or a Stream object.
+
+    Args:
+        command:
+            The command to run.
+        out:
+            Where output is sent.
+            If None, output is lost.
+            If -1, output is sent to stdout and stderr.
+            Otherwise if an integer, we do: os.write( out, text)
+            Otherwise if callable, we do: out( text)
+            Otherwise we assume  is python stream or similar, and do: out.write(text)
+        shell:
+            Whether to run command inside a shell (see subprocess.Popen).
+        encoding:
+            Sepecify the encoding used to translate the command's output
+            to characters.
+
+            Note that if  is None and we are being run by python3,
+             will be passed bytes, not a string.
+
+            Note that latin_1 will never raise a UnicodeDecodeError.
+        errors:
+            How to handle encoding errors; see docs for codecs module for
+            details.
+        buffer_len:
+            The number of bytes we attempt to read at a time. If -1 we read
+            output one line at a time.
+
+    Returns:
+        subprocess's , i.e. -N means killed by signal N, otherwise
+        the exit value (e.g. 12 if command terminated with exit(12)).
+    '''
+    if out == -1:
+        stdin = 0
+        stdout = 1
+        stderr = 2
+    else:
+        stdin = None
+        stdout = subprocess.PIPE
+        stderr = subprocess.STDOUT
+    child = subprocess.Popen(
+            command,
+            shell=shell,
+            stdin=stdin,
+            stdout=stdout,
+            stderr=stderr,
+            close_fds=True,
+            #encoding=encoding - only python-3.6+.
+            )
+
+    child_out = child.stdout
+    if encoding:
+        child_out = codecs.getreader( encoding)( child_out, errors)
+
+    out = make_stream( out)
+
+    if stdout == subprocess.PIPE:
+        if buffer_len == -1:
+            for line in child_out:
+                out.write( line)
+        else:
+            while 1:
+                text = child_out.read( buffer_len)
+                if not text:
+                    break
+                out.write( text)
+    #decode( lambda : os.read( child_out.fileno(), 100), outfn, encoding)
+
+    return child.wait()
+
+if __name__ == '__main__':
+
+    if os.getenv( 'jtest_py_system_raw_test') == '1':
+        out = io.StringIO()
+        system_raw(
+                'jtest_py_system_raw_test=2 python jlib.py',
+                sys.stdout,
+                encoding='utf-8',
+                #'latin_1',
+                errors='replace',
+                )
+        print( repr( out.getvalue()))
+
+    elif os.getenv( 'jtest_py_system_raw_test') == '2':
+        for i in range(256):
+            sys.stdout.write( chr(i))
+
+
+def system(
+        command,
+        verbose=None,
+        raise_errors=True,
+        out=None,
+        prefix=None,
+        rusage=False,
+        shell=True,
+        encoding=None,
+        errors='replace',
+        buffer_len=-1,
+        ):
+    '''
+    Runs a command like os.system() or subprocess.*, but with more flexibility.
+
+    We give control over where the command's output is sent, whether to return
+    the output and/or exit code, and whether to raise an exception if the
+    command fails.
+
+    We also support the use of /usr/bin/time to gather rusage information.
+
+        command:
+            The command to run.
+        verbose:
+            If true, we output information about the command that we run, and
+            its result.
+
+            If callable or something with a .write() method, information is
+            sent to  itself. Otherwise it is sent to  (without
+            applying ).
+        raise_errors:
+            If true, we raise an exception if the command fails, otherwise we
+            return the failing error code or zero.
+        out:
+            Python stream, fd, callable or Stream instance to which output is
+            sent.
+
+            If  is 'return', we buffer the output and return (e,
+            ). Note that if raise_errors is true, we only return if 
+            is zero.
+
+            If -1, output is sent to stdout and stderr.
+        prefix:
+            If not None, should be prefix string or callable used to prefix
+            all output. [This is for convenience to avoid the need to do
+            out=StreamPrefix(...).]
+        rusage:
+            If true, we run via /usr/bin/time and return rusage string
+            containing information on execution.  and
+            out='return' are ignored.
+        shell:
+            Passed to underlying subprocess.Popen() call.
+        encoding:
+            Sepecify the encoding used to translate the command's output
+            to characters. Defaults to utf-8.
+        errors:
+            How to handle encoding errors; see docs for codecs module
+            for details. Defaults to 'replace' so we never raise a
+            UnicodeDecodeError.
+        buffer_len:
+            The number of bytes we attempt to read at a time. If -1 we read
+            output one line at a time.
+
+    Returns:
+        If  is true, we return the rusage text.
+
+        Else if raise_errors is true:
+            If the command failed, we raise an exception.
+            Else if  is 'return' we return the text output from the command.
+            Else we return None
+
+        Else if  is 'return', we return (e, text) where  is the
+        command's exit code and  is the output from the command.
+
+        Else we return , the command's exit code.
+    '''
+    if encoding is None:
+        if sys.version_info[0] == 2:
+            # python-2 doesn't seem to implement 'replace' properly.
+            encoding = None
+            errors = None
+        else:
+            encoding = 'utf-8'
+            errors = 'replace'
+
+    out_original = out
+    if out is None:
+        out = sys.stdout
+    elif out == 'return':
+        # Store the output ourselves so we can return it.
+        out = io.StringIO()
+    else:
+        out = make_stream( out)
+
+    if verbose:
+        if getattr( verbose, 'write', None):
+            pass
+        elif callable( verbose):
+            verbose = make_stream( verbose)
+        else:
+            verbose = out
+
+    if prefix:
+        out = StreamPrefix( out, prefix)
+
+    if verbose:
+        verbose.write( 'running: %s\n' % command)
+
+    if rusage:
+        command2 = ''
+        command2 += '/usr/bin/time -o ubt-out -f "D=%D E=%D F=%F I=%I K=%K M=%M O=%O P=%P R=%r S=%S U=%U W=%W X=%X Z=%Z c=%c e=%e k=%k p=%p r=%r s=%s t=%t w=%w x=%x C=%C"'
+        command2 += ' '
+        command2 += command
+        system_raw( command2, out, shell, encoding, errors, buffer_len=buffer_len)
+        with open('ubt-out') as f:
+            rusage_text = f.read()
+        #print 'have read rusage output: %r' % rusage_text
+        if rusage_text.startswith( 'Command '):
+            # Annoyingly, /usr/bin/time appears to write 'Command
+            # exited with ...' or 'Command terminated by ...' to the
+            # output file before the rusage info if command doesn't
+            # exit 0.
+            nl = rusage_text.find('\n')
+            rusage_text = rusage_text[ nl+1:]
+        return rusage_text
+    else:
+        e = system_raw( command, out, shell, encoding, errors, buffer_len=buffer_len)
+
+        if verbose:
+            verbose.write( '[returned e=%s]\n' % e)
+
+        if raise_errors:
+            if e:
+                raise Exception( 'command failed: %s' % command)
+            if out_original == 'return':
+                return out.getvalue()
+            return
+
+        if out_original == 'return':
+            return e, out.getvalue()
+        return e
+
+def get_gitfiles( directory, submodules=False):
+    '''
+    Returns list of all files known to git in ;  must be
+    somewhere within a git checkout.
+
+    Returned names are all relative to .
+
+    If .git directory, we also create /jtest-git-files. Otherwise we
+    assume a this file already exists.
+    '''
+    if os.path.isdir( '%s/.git' % directory):
+        command = 'cd ' + directory + ' && git ls-files'
+        if submodules:
+            command += ' --recurse-submodules'
+        command += ' > jtest-git-files'
+        system( command, verbose=sys.stdout)
+
+    with open( '%s/jtest-git-files' % directory, 'r') as f:
+        text = f.read()
+    ret = text.split( '\n')
+    return ret
+
+def get_git_id_raw( directory):
+    if not os.path.isdir( '%s/.git' % directory):
+        return
+    text = system(
+            f'cd {directory} && (PAGER= git show --pretty=oneline|head -n 1 && git diff)',
+            out='return',
+            )
+    return text
+
+def get_git_id( directory, allow_none=False):
+    '''
+    Returns text where first line is ' ' and remaining
+    lines contain output from 'git diff' in .
+
+    directory:
+        Root of git checkout.
+    allow_none:
+        If true, we return None if  is not a git checkout and
+        jtest-git-id file does not exist.
+    '''
+    filename = f'{directory}/jtest-git-id'
+    text = get_git_id_raw( directory)
+    if text:
+        with open( filename, 'w') as f:
+            f.write( text)
+    elif os.path.isfile( filename):
+        with open( filename) as f:
+            text = f.read()
+    else:
+        if not allow_none:
+            raise Exception( f'Not in git checkout, and no file {filename}.')
+        text = None
+    return text
+
+class Args:
+    '''
+    Iterates over argv items. Does getopt-style splitting of args starting with
+    single '-' character.
+    '''
+    def __init__( self, argv):
+        self.argv = argv
+        self.pos = 0
+        self.pos_sub = None
+    def next( self):
+        while 1:
+            if self.pos >= len(self.argv):
+                raise StopIteration()
+            arg = self.argv[self.pos]
+            if (not self.pos_sub
+                    and arg.startswith('-')
+                    and not arg.startswith('--')
+                    ):
+                # Start splitting current arg.
+                self.pos_sub = 1
+            if self.pos_sub and self.pos_sub >= len(arg):
+                # End of '-' sub-arg.
+                self.pos += 1
+                self.pos_sub = None
+                continue
+            if self.pos_sub:
+                # Return '-' sub-arg.
+                ret = arg[self.pos_sub]
+                self.pos_sub += 1
+                return f'-{ret}'
+            # Return normal arg.
+            self.pos += 1
+            return arg
+
+def update_file( text, filename):
+    '''
+    Writes  to . Does nothing if contents of  are
+    already .
+    '''
+    try:
+        with open( filename) as f:
+            text0 = f.read()
+    except OSError:
+        text0 = None
+    if text == text0:
+        log( 'Unchanged: ' + filename)
+    else:
+        log( 'Updating:  ' + filename)
+        # Write to temp file and rename, to ensure we are atomic.
+        filename_temp = f'{filename}-jlib-temp'
+        with open( filename_temp, 'w') as f:
+            f.write( text)
+        os.rename( filename_temp, filename)
+
+
+def mtime( filename, default=0):
+    '''
+    Returns mtime of file, or  if error - e.g. doesn't exist.
+    '''
+    try:
+        return os.path.getmtime( filename)
+    except OSError:
+        return default
+
+def get_filenames( paths):
+    '''
+    Yields each file in , walking any directories.
+    '''
+    if isinstance( paths, str):
+        paths = (paths,)
+    for name in paths:
+        if os.path.isdir( name):
+            for dirpath, dirnames, filenames in os.walk( name):
+                for filename in filenames:
+                    path = os.path.join( dirpath, filename)
+                    yield path
+        else:
+            yield name
+
+def remove( path):
+    '''
+    Removes file or directory, without raising exception if it doesn't exist.
+
+    We assert-fail if the path still exists when we return, in case of
+    permission problems etc.
+    '''
+    try:
+        os.remove( path)
+    except Exception:
+        pass
+    shutil.rmtree( path, ignore_errors=1)
+    assert not os.path.exists( path)
+
+
+# Things for figuring out whether files need updating, using mtimes.
+#
+def newest( names):
+    '''
+    Returns mtime of newest file in . Returns 0 if no file exists.
+    '''
+    assert isinstance( names, (list, tuple))
+    assert names
+    ret_t = 0
+    ret_name = None
+    for filename in get_filenames( names):
+        t = mtime( filename)
+        if t > ret_t:
+            ret_t = t
+            ret_name = filename
+    return ret_t, ret_name
+
+def oldest( names):
+    '''
+    Returns mtime of oldest file in  or 0 if no file exists.
+    '''
+    assert isinstance( names, (list, tuple))
+    assert names
+    ret_t = None
+    ret_name = None
+    for filename in get_filenames( names):
+        t = mtime( filename)
+        if ret_t is None or t < ret_t:
+            ret_t = t
+            ret_name = filename
+    if ret_t is None:
+        ret_t = 0
+    return ret_t, ret_name
+
+def update_needed( infiles, outfiles):
+    '''
+    If any file in  is newer than any file in , returns
+    string description. Otherwise returns None.
+    '''
+    in_tmax, in_tmax_name = newest( infiles)
+    out_tmin, out_tmin_name = oldest( outfiles)
+    if in_tmax > out_tmin:
+        text = f'{in_tmax_name} is newer than {out_tmin_name}'
+        return text
+
+def build(
+        infiles,
+        outfiles,
+        command,
+        force_rebuild=False,
+        out=None,
+        all_reasons=False,
+        verbose=True,
+        prefix=None,
+        ):
+    '''
+    Ensures that  are up to date using enhanced makefile-like
+    determinism of dependencies.
+
+    Rebuilds  by running  if we determine that any of them
+    are out of date.
+
+    infiles:
+        Names of files that are read by . Can be a single filename. If
+        an item is a directory, we expand to all filenames in the directory's
+        tree.
+    outfiles:
+        Names of files that are written by . Can also be a single
+        filename.
+    command:
+        Command to run.
+    force_rebuild:
+        If true, we always re-run the command.
+    out:
+        A callable, passed to jlib.system(). If None, we use jlib.log() with
+        our caller's stack record.
+    all_reasons:
+        If true we check all ways for a build being needed, even if we already
+        know a build is needed; this only affects the diagnostic that we
+        output.
+    verbose:
+        Passed to jlib.system().
+    prefix:
+        Passed to jlib.system().
+
+    We compare mtimes of  and , and we also detect changes
+    to the command itself.
+
+    If any of infiles are newer than any of outfiles, or  is
+    different to contents of commandfile '.cmd, then truncates
+    commandfile and runs . If  succeeds we writes 
+    to commandfile.
+    '''
+    if isinstance( infiles, str):
+        infiles = (infiles,)
+    if isinstance( outfiles, str):
+        infiles = (outfiles,)
+
+    if not out:
+        out_frame_record = inspect.stack()[1]
+        out = lambda text: log( text, nv=0, caller=out_frame_record)
+
+    command_filename = f'{outfiles[0]}.cmd'
+
+    reasons = []
+
+    if not reasons or all_reasons:
+        if force_rebuild:
+            reasons.append( 'force_rebuild was specified')
+
+    if not reasons or all_reasons:
+        try:
+            with open( command_filename) as f:
+                command0 = f.read()
+        except Exception:
+            command0 = None
+        if command != command0:
+            if command0:
+                reasons.append( 'command has changed')
+            else:
+                reasons.append( 'no previous command')
+
+    if not reasons or all_reasons:
+        reason = update_needed( infiles, outfiles)
+        if reason:
+            reasons.append( reason)
+
+    if not reasons:
+        out( 'Already up to date: ' + ' '.join(outfiles))
+        return
+
+    if out:
+        out( 'Rebuilding because %s: %s' % (
+                ', and '.join( reasons),
+                ' '.join(outfiles),
+                ))
+
+    # Empty . For each unique item we
+    use -L with parent directory, and -l with embedded name (without leading
+    'lib' or trailing '.co').
+    '''
+    dirs = set()
+    names = []
+    if isinstance( sos, str):
+        sos = (sos,)
+    for so in sos:
+        dir_ = os.path.dirname( so)
+        name = os.path.basename( so)
+        assert name.startswith( 'lib')
+        assert name.endswith ( '.so')
+        name = name[3:-3]
+        dirs.add( dir_)
+        names.append( name)
+    ret = ''
+    # Important to use sorted() here, otherwise ordering from set() is
+    # arbitrary causing occasional spurious rebuilds by jlib.build().
+    for dir_ in sorted(dirs):
+        ret += f' -L {dir_}'
+    for name in names:
+        ret += f' -l {name}'
+    return ret
-- 
cgit v1.2.1