#!/usr/bin/python3
import os
import sys
import time
import datetime
import re
import signal
import subprocess
import json
import argparse

try:
    import helpers_shellcolor as sc
except ImportError:
    sc = None

    

    
class TailFollower(object):
    """ Imitation of tail -F, because actual tail -F seems to give up in some cases, which we can't control.  
        Could stand some further testing.
    """
    def __init__(self, print_callback):
        ''' print_callback should take two strings, source and message '''
        self.data = {} # filename -> ( fob, inode )
        self.print_callback = print_callback
        
    def add_filename(self, filename):
        ' start following another filename '
        if not os.path.exists(filename):
            raise OSError('File does not exist')
        if filename not in self.data:
            ino = os.stat(filename).st_ino
            fob = open(filename)
            fob.seek(0,2)        
            self.data[filename] = (fob, ino)

    def remove_filename(self, filename):
        ' stop following a filename '
        if filename in self.data:
            fob, ino = self.data[filename]
            fob.close()
            del self.data[filename]
        
    def update(self):
        ''' one cycle of dealing with what files may have changed - checks that files are still there, and are still the same files '''
        # should deal with
        # - filename disappearing, and later reappearing (os.path.exists False somewhere. inode is not relevant)
        # - file being replaced   (different inode; should close and open)
        # ...largely because logrotate may cause both, depending on its config and how fast it is
        
        for filename in sorted(self.data.keys()):
            #print( "checking %s"%filename )
            fob, ino = self.data[filename]

            if fob==None: # disappeared before, see if it reapeared
                if os.path.exists(filename):
                    print( "file reappeared, opening: %r"%filename )
                    fob = open(filename)
                    fob.seek(0,2)
                    ino = os.stat(filename).st_ino
                    self.data[filename] = (fob, ino)
                # CONSIDER: a (longish, configurable)  timeout to retry opens in
                
            else: # not marked disappeared

                try:
                    if not os.path.exists(filename):
                        print( "file disappeared, closing: %r"%filename )
                        fob.close()
                        self.data[filename] = (None, None)

                    else: # filename still exists, check for being replaecd (e.g. by logrotate)
                        stob = os.stat(filename)
                        if stob.st_ino != ino:
                            print( "file was replaced, reopening: %r"%filename )
                            fob.close()
                            fob = open(filename)
                            fob.seek(0,2)
                            self.data[filename] = (fob, stob.st_ino)

                    ### see if there are new lines to print
                    pos = fob.tell()
                    if pos != stob.st_size: # we assume things are only ever appended
                        if stob.st_size < pos: # ...mostly.
                            print( "file shrunk, seeking to end: %r"%filename )
                            fob.seek( 0,2 )
                        for line in fob.readlines():
                            self.print_callback( os.path.basename(filename), line )

                # TODO: figure out all possible exceptions             
                # perhaps the most important if the file disappears _after_ the exists() check, things like the tell() and read() will fail instead, 
                except ValueError as ve:  # ...which seems to be reported as a ValueError
                    pass # we can ignore that because we'll get to it later
                
                except OSError as oe: # I got "telling position disabled by next() call", which seems to be our own thread's reading temporarily disabling tell (?)
                    pass # if so we can ignore that, we can print these lines next iteration
                # TODO: tests for these cases more specifically, right now this is probably too catch-all
                
                    
    def start_thread(self, interval_sec=0.5):
        " if you're not in a position to regularly call update(), you can get this thread to do it for you "
        import threading
        def threadfunc(ob):
            while True:
                ob.update()
                time.sleep( interval_sec )
        thr = threading.Thread(target=threadfunc, args=(self,), name='file_follow')
        thr.daemon = True # basically, don't be the reason Ctrl-C doesn't work
        thr.start()
        return thr
        



    
class JournalctlReader(object):
    " runs journalctl on given units "
    def __init__(self, print_callback, add_time=False):
        ''' print_callback should take two strings, source and message '''
        self.add_time = add_time
        self.pid = None
        self.proc = None
        self.print_callback = print_callback
        # TODO: take recency and turn into   --since "2 hours ago" style thing
        
    def change_units(self, units):
        ' start listening to new given set of units ' 
        if self.pid:
            os.kill( self.pid, signal.SIGKILL )
            self.proc.wait()

        cmd = ['/bin/journalctl',
            b'-o',b'json',
            #b'-n',b'10', # show n last entries
            b'-f'
        ]
        for unit in units:
            cmd.extend( ['-u', unit] )
        #print( cmd )
        self.proc = subprocess.Popen(cmd, shell=False, stdout=subprocess.PIPE)

        
    def update(self):
        ''' one cycle of dealing with what changed '''
        if self.proc != None:
            line = self.proc.stdout.readline() # delay in thread can be tiny because this blocks
            try:
                d = json.loads(line)
                # I'm still figuring out which fields are present when. Documentation is barely helpful here...
                # and in the process, these are spammy:
                for debug_remove in ('__CURSOR', '_BOOT_ID', '_MACHINE_ID', '_HOSTNAME',
                                    # '__REALTIME_TIMESTAMP', '__MONOTONIC_TIMESTAMP', '_SOURCE_MONOTONIC_TIMESTAMP', 
                                    ):
                    if debug_remove in d:
                        del d[debug_remove]

                msg  = d.get('MESSAGE')

                if msg==None: # ...why?
                    print( "MESSAGE missing in %r"%d ) # leaving this in to see what cases this is
                    return # I think we can ignore this, though?
                

                transport = d.get('_TRANSPORT',None)
                if transport=='journal':
                    #print( d ) # figuring out the fields
                    sourcename = d.get('UNIT','unknown unit')        #sourcename = d.get('_SYSTEMD_UNIT',None) # this seems to be a scope. Um?
                    if sourcename.endswith('.service'):
                        sourcename = sourcename[:-8]
                        
                elif transport=='kernel':
                    #print( d ) # figuring out the fields
                    t = d.get('_KERNEL_SUBSYSTEM') # not always present
                    if t==None:
                        t = d.get('SYSLOG_IDENTIFIER') # may be 'kernel'
                    if t=='kernel':    
                        sourcename = 'kernel'
                    else:
                        sourcename = 'kernel/%s'%t

                elif transport=='syslog':
                    sourcename = 'syslog/%s'%d.get('SYSLOG_IDENTIFIER')

                elif transport=='stdout': # service's stdout, try to figure uot service name
                    #print( d ) # figuring out the fields
                    t = d.get('_SYSTEMD_UNIT') # not always present
                    if t==None:
                        t = d.get('SYSLOG_IDENTIFIER') # may be 'kernel'
                    if t!=None:
                        if t.endswith('.service'):
                            t = t[:-8]
                        sourcename = '%s'%t
                    else:
                        sourcename = 'stdout'
                    if type(msg) is type([]): # what the hell?
                        msg = bytes(msg).decode('u8') # decode u8 is a guess, maybe add fallback
                
                elif transport=='driver':
                    #print( d ) # figuring out the fields
                    sourcename = 'driver'
                    
                elif transport=='audit':
                    #print( d ) # figuring out the fields
                    sourcename = 'audit'
                    
                else: # right now no others exist (accordign to docs, at least).   Though might it be missing?
                    #print( d ) # figuring out the fields
                    sourcename = transport

                if self.add_time:
                    tve = d.get('_SOURCE_REALTIME_TIMESTAMP') # TODO: figure out which of the timestamps is most useful
                    if tve!=None: # fall back to printing the current time. Not ideal in terms of monotonicity, but more helpful than nothing. Put on parameter?
                        msg = datetime.datetime.fromtimestamp( float(tve)/1000000 ).strftime('%b %-d %H:%M:%S') + '  ' + msg
                    #else:
                    #    print( d )
                self.print_callback( '[%s]'%sourcename, msg )

            except (ValueError, KeyError) as e:  # AttributeError
                print( e )
                pass

    def start_thread(self):
        " if you're not in a position to regularly call update(), you can get this thread to do it for you "
        import threading
        def threadfunc(ob):
            while True:
                ob.update()
                #time.sleep( interval_sec )
        thr = threading.Thread(target=threadfunc, args=(self,), name='journalctl_follow')
        thr.daemon = True # basically, don't be the reason Ctrl-C doesn't work
        thr.start()
        return thr



#### helpers ###########################################################


def path_indicates_possible_log(fullpath):
    """ Filename-based guess of whether this is likely to be a lof file.
        Used to clean up entries from homedirs and other places where you may see a lot of non-logs
        Returns True for things we want to check, 
                False for things we know won't be logs (which will never be a complete list, but helps speed a little).
        In practice this does very little.         
    """
    basename = os.path.basename(fullpath)
    if fullpath.endswith('.gz') or fullpath.endswith('.bz2'): 
        #print( "filename NO %r"%fullpath )
        return False
    if ('atop_' in fullpath or basename.endswith('.jounal') or basename in ('btmp','wtmp')):
        #print( "filename NO %r"%fullpath )
        return False
    if (fullpath.endswith('.1') or fullpath.endswith('.2') or fullpath.endswith('.3') or
        fullpath.endswith('.4') or fullpath.endswith('.5') or fullpath.endswith('.6') or
        fullpath.endswith('.7') or fullpath.endswith('.8') or fullpath.endswith('.9')
        ): # assume these are rotated. This may be overzealous, as it typically won't be current rnough to show anyway.
        #print( "filename NO %r"%fullpath )
        return False

    if (fullpath.endswith('.rb')  or fullpath.endswith('.gemspec') or fullpath.endswith('.conf') or
        fullpath.endswith('.cpp') or fullpath.endswith('.py')      or
        fullpath.endswith('.svn-base') or fullpath.endswith('.html')    or
        fullpath.endswith('.gif') or fullpath.endswith('.jpg')     or fullpath.endswith('.png')
        ):
        #print( "filename NO %r"%fullpath )
        return False
    if fullpath.endswith('.OU') or fullpath.endswith('.ER'):  # Torque PBS's convention
        #print( "filename MAYBE %r"%fullpath )
        return True
    if re.compile(r'\blog').search( os.path.basename(fullpath) ): # could be a little less wide, perhaps?
        #print( "filename MAYBE %r"%fullpath )
        return True
    #print( "filename UNDECIDED,MAYBE %r"%fullpath )
    return True


def is_recent(filename, recency_minutes=60):
    ''' Returns true if the youngest of (mtime,ctime) for the file is no longer than some amount of minutes ago
        ...compared to the system clock, so take care e.g. on embedded systems without an RTC.
    '''
    stob = os.stat(filename)
    latest_time = max([stob.st_mtime,stob.st_ctime])
    tdiff_sec = int(time.time()-latest_time)
    #print( filename )
    #print( '  latest change: %s'%latest_time )
    #print( '  time ago:      %s hours '%round(tdiff_sec/3600., 1) )
    if tdiff_sec < (60.*recency_minutes):
        return True
    return False


def is_nonbinary(filename=None, data=None, amt=200):
    """ Given either a filename or some data from it, returns whether this is probably text, rather than binary data.
        amt: controls how much of data we use, or how much to read from the filename to be opened

        Written for 'is this possibly a log' test, so specialcases compressed files because of log rotation.
    """
    if data==None:
        if filename==None:
            raise ValueError("You need to give either a filename or some of the file's content data")
        else:
            f=open(filename,'rb')
            data=f.read(amt)
            f.close()

    if data[:2]==b'\x1f\x8b': # gzip magic
        #print( 'Ignoring %r, is Gzip'%filename )
        return False
    elif data[:3]==b'BZh':    # bzip2 magic.  There's also 'BZ0' for bzip1, but meh.
        #print( "Ignoring %r, is bzip2"%filename )
        return False
    elif data[:3]==b'\xFD\x37\x7A\x58\x5A\x00':    # xz magic (TODO: test)
        #print( "Ignoring %r, is xz"%filename )
        return False

    # list of ordinals
    ol = list(ch  for ch in data[:amt]) # ord in py2
    printable,nonprintable=0,0
    for o in ol:
        if (o>=0x20 and o<0x7f) or o in (0x0d, 0x0a):
            printable    += 1
        else:
            nonprintable += 1

    #print(filename, printable, nonprintable )
    if nonprintable==0: #only printable - this is text.
        return True
    else:
        # could be made slightly cleverer, but this is probably enough.
        printable_ratio = float(printable)/float(nonprintable+printable)
        if printable_ratio>0.95: # assume that's enough
            return True
        else:
            return False





class Rescanner(object):
    ''' The bulk of the logic checking for new files and new units. 

        TODO: This class has taken on too many responsibilities, we should restructure.
    '''
    def __init__(self,
                 scan_dirs = [b'/var/log'],
                 add_home = False,
                 onlys = None,
                 nots = None,
                 grep = None,
                 grepout = None,
                 recency_minutes = 24.0*60.,
                 check_interval_sec = 20,
                 nomatch_rescan_interval_sec = 5,
                 verbose = False,
                 add_time = True,
                 truecolor = False,
                 ):
        """ Constructor takes most settings.
            add_home         - look in current user's home directory
            recency_minutes  - log needs to be this young to be listed.   0/False/None means don't filter. 

            If onlys isn't None or [], it is used as a whitelist of substrings that have to appear somewhere in the path.
            This lets you easily filter for some specific named logs (or directories)
        """
       

        def print_message(source, message):
            " Unify printing style. "
            # CONSIDER: detecting and doing 256 colors when we can
            # CONSIDER: I didn't I have a function for this?

            if grep!=None: # CONSIDER comma separated?
                if grep not in message:
                    return
            elif grepout!=None:
                if grep in message:
                    return                
            
            alisource = '%25s'%source
            if sc==None: # we specified --no-color, or that color helper wasn't there
                sys.stdout.write( alisource  )
            else:
                if truecolor:
                    sys.stdout.write( sc.hash_color( alisource, rgb=True ) )
                else: # basic ~8 colors plus some bright variants (or none if the helper detected no color support)
                    color_funcs = [ sc.blue, sc.red, sc.green, sc.cyan, sc.magenta, sc.yellow, sc.brightblue, sc.brightred, sc.brightcyan, sc.brightmagenta, sc.brightyellow, sc.brightgreen ]
                    strhash = sum( ord(ch)  for ch in source )
                    sys.stdout.write( color_funcs[strhash%len(color_funcs)]( alisource ) )
            sys.stdout.write( ': ')
            sys.stdout.write( message.rstrip() )
            sys.stdout.write( '\n')
            sys.stdout.flush()

        
        # members of main class, so that the scopey references are a little ugly
        self.tf = TailFollower(     print_message )
        self.tf.start_thread( )
        self.jr = JournalctlReader( print_message, add_time=True )
        self.jr.start_thread( )
         
        self.verbose = verbose

        self.add_home = add_home
        self.log_filenames = []
        self.saw_new_filenames = False
        self.scan_dirs = scan_dirs
        self.recency_minutes = recency_minutes
        
        self.onlys = onlys
        self.nots = nots
        
        if check_interval_sec!=None:
            check_interval_sec = max(1,check_interval_sec) # less than 1 second makes little sense.
        self.check_interval_sec = check_interval_sec
        self.nomatch_rescan_interval_sec = nomatch_rescan_interval_sec
        
        proc = subprocess.Popen(['/usr/bin/which', 'systemctl'], stdout=subprocess.PIPE )
        out1, _ = proc.communicate()
        self._systemctl_path = out1.strip()
        self.is_systemd = (self._systemctl_path != '')
        self.log_units = []
        
        
    def journalctl_list(self, striptype=False):
        """ in class so we can reuse detected paths  """
        proc = subprocess.Popen([self._systemctl_path, b'list-units', b'--type=service',
                                     b'--plain', b'--no-legend',  # easier to parse
                                     b'--all'                     # not just active
                                     ], stdout=subprocess.PIPE )
        out, _ = proc.communicate()
        ret = []
        for line in out.splitlines():
            line = line.rstrip().decode('utf-8')  # encode: subprocess gives bytes, we're about to filter by cmdline arguments which are unicode
            unit, load, active, sub, description = line.split(None, 4)
            typeless, _ = unit.rsplit('.',1)
            if striptype: # from returned unit names
                unit = typeless

            if self.onlys!=None and len(self.onlys)>0:
                whitelist_condition = False
                for filtstr in self.onlys:
                    if filtstr in typeless:
                        whitelist_condition = True
                        break
                if not whitelist_condition:
                    if self.verbose >= 2:
                        print( "%60r  Doesn't match whitelist"%unit )
                    continue

            if self.nots!=None and len(self.nots)>0:
                blacklist_condition = False
                for filtstr in self.nots:
                    if filtstr in typeless:
                        blacklist_condition=True
                        break
                if blacklist_condition:
                    if self.verbose >= 2:
                        print( "%60r  Matches blacklist"%unit )
                    continue
            
            ret.append(unit)            
        return ret
    

    def rescan_systemdunits(self):
        if not self.is_systemd: # avoid work when we never found systemctl
            return
        #TODO: integrate proper whitelist/blacklist logic
        units_now = self.journalctl_list()
        new_units = set(units_now).difference( self.log_units )
        if len(new_units)==0:
            self.saw_new_units = False
        else:
            self.saw_new_units = True
            if self.verbose >= 1:
                print( "NEW UNITS turned up:   %s"%( ', '.join( sorted([v.replace('.service','') for v in new_units]) ))  )
            #if self.verbose >= 2:
            #    print( "  unit set now %s"%units_now )
        self.log_units = units_now

    
    def rescan_filelogs(self):
        """ Scans for logs with the configuration as listed in the obeject.
            Returns matching filenames.
        """
        
        if self.verbose >= 2:
            if self.recency_minutes:
                print( "Finding recently used files that look like logs..." )
            else:
                print( "Finding files that look like logs..." )
            
        justnow_filenames = []
        
        if self.add_home: # TODO: we may want more restrictions here, e.g. looking only for filenames ending in .log
            scan_dirs.append( os.path.expanduser('~') )
        
        sawfiles_num = 0
        nopermission_num = 0
        for scan_dir in self.scan_dirs:
            if self.verbose >= 2:
                print( "\n ...in %s"%scan_dir )

            try:
                # this indexing also does most filtering
                for curdir, _, files in os.walk( os.path.abspath(scan_dir) ):
                    for filename in files:
                        fullpath = os.path.join(curdir,filename)
                        sawfiles_num += 1
                        
                        # only if onlys is specified do we use it as a "only allow things that are in this"
                        # (so it's white+black, all+black, or neither)
                        if self.onlys!=None and len(self.onlys)>0:
                            whitelist_condition = False
                            for filtstr in self.onlys:
                                if filtstr in fullpath:
                                    whitelist_condition = True
                                    break
                            if not whitelist_condition:
                                if self.verbose >= 2:
                                    print( "%60r  Doesn't match onlys"%fullpath )
                                continue
                        
                        if self.nots!=None and len(self.nots)>0:
                            blacklist_condition = False
                            for filtstr in self.nots:
                                if filtstr in fullpath:
                                    blacklist_condition=True
                                    break
                            if blacklist_condition:
                                if self.verbose >= 2:
                                    print( "%60r  Matches nots"%fullpath)
                                continue
                            
                        if path_indicates_possible_log( fullpath ) == False:
                            if self.verbose >= 2:
                                print( "%60r  Filename doesn't look like log"%fullpath )
                            continue
                            
                        # above the content guess, below the filename based guess, because of relative cost
                        # (though mtime test should be essentially free because os.walk stats too)
                        if self.recency_minutes:
                            #print( self.recency_minutes )
                            if not is_recent(fullpath, recency_minutes=recency_minutes):
                                if self.verbose >= 2:
                                    print( "%60r  Not recent"%fullpath )
                                continue

                        try:
                            likely_log = is_nonbinary(fullpath, amt=400)
                            if not likely_log:
                                if self.verbose >= 2:
                                    print( "%60r  Contents do not look like log"%fullpath )
                            else:
                                if self.verbose >= 2:
                                    print( "%60r  Looks good"%fullpath )
                                justnow_filenames.append(fullpath)
                        except PermissionError: # quick hack to make it work, will address properly later
                            nopermission_num += 1
                                
                #if self.verbose:
                #    print("scanned %d files for logness"%sawfiles_num)
                #    if nopermission_num>0:
                #        print("no access to %d files"%nopermission_num)
                # TODO: give 'no permission to some files' error *once*
                                
            except Exception as exception:
                #if self.verbose:
                sys.stderr.write( "Error (%s), ignoring..."%str(exception))
                raise

        if self.verbose >= 2:
            print( "\nDone, found %d logs (in %d files)"%( len(justnow_filenames), sawfiles_num ) )

        # Check whether we saw new filenames
        #   we only care about seeing files we had not seen before,
        #   as tail deals with and mentions disappearance, and now-silent logs are not shown.
        new_files = set(justnow_filenames).difference( self.log_filenames )
        if len(new_files)==0:
            self.saw_new_filenames = False
        else:
            self.saw_new_filenames = True                        
            if self.verbose >= 1:
                print( "NEW FILES turned up:   %s"% ', '.join(new_files) )
        self.log_filenames = justnow_filenames


    def scan_and_follow(self):
        """ Scans, runs a tail subprocess on the result. """
        while True:
            # updates some of our state
            self.rescan_systemdunits()                    
            self.rescan_filelogs()     

            if self.verbose >= 2:
                print( "\nMatching log files: %r"%self.log_filenames )
                print( "\nMatching systemd units: %r"%self.log_units )

            if len(self.log_filenames)==0 and len(self.log_units)==0: # no matching logs at all.
                # Usually means no matches when starting up because of overly strict filters
                # can also mean all matching logs were removed.
                # In either case, we keep rescanning
                print( "Zero matching logs, waiting until some do" )
            else:
                if self.saw_new_filenames==False and self.saw_new_units==False: # ...but nothing changed. Do nothing.
                    if self.verbose >= 1:
                        print( "\nNo changes in matching logs" )
                    # note: will skip both blocks below and do only the interval sleep
                else:
                    if self.saw_new_units:
                        #if self.verbose >= 1:
                        sys.stdout.write( 'Changing journalctl to listen to %d units\n'%len(self.log_units) )
                        sys.stdout.flush()
                        self.jr.change_units( self.log_units )

                    if self.saw_new_filenames:
                        #if self.verbose >= 1:
                        sys.stdout.write( 'Changing tail to listen to %d files\n'%len(self.log_filenames) )
                        sys.stdout.flush()
                        for filename in self.log_filenames: # thigns we already listen to will be ignored
                            self.tf.add_filename( filename )
            
            ### 
            if self.verbose >= 2:
                print( "Sleeping %.1f"%self.check_interval_sec )
            time.sleep( self.check_interval_sec )



                    

if __name__=='__main__':
    try: 
        import setproctitle    # I like my tmux titles informative
        setproctitle.setproctitle( os.path.basename(sys.argv[0]) )
    except ImportError:
        pass

    def parse_hms(v):
        ''' string interval spec to seconds, for times at most a bunch of days, e.g.  
             '1h30m' -> 5400, 
             '1 day, 30 min, 4 sec' -> 88204
            Very naive implementation (just looks for words starting with w,d,h,m,s)
        '''
        ret_sec = 0
        things = re.findall(r'([0-9.]+)\s*([a-z]+)[,;\s-]*', v.lower())
        if len(things)==0:
            try:
                ret_sec = float(v) # see if it's unitless, interpret as seconds
            except:
                raise ValueError("don't understand value %r"%unit)
        for num,unit in things:
            n = float(num)
            if unit[0]=='w':
                n *= 60*60*24*7
            elif unit[0]=='d':
                n *= 60*60*24
            elif unit[0]=='h':
                n *= 60*60
            elif unit[0]=='m':
                n *= 60
            elif unit[0]=='s':
                pass
            else:
                raise ValueError("don't understand time unit %r"%unit)
            ret_sec += n
        return ret_sec
    

    try:
        parser = argparse.ArgumentParser()
        parser.add_argument('-o',"--onlys",               action="store",       default=None,
                                help="if specified, matches only names with one of these substrings (comma separated)")
        parser.add_argument('-n',"--nots",                action="store",       default=None,
                                help="if specified, everything except names with one of these substrings (comma separated)")
        parser.add_argument("-T", "--no-journalctl-time", action="store_false",  default=True,
                                help="by default we add the time field to journalctil lines. Specify this to turn that off.")
                                # CONSIDER: parameter to format that time
        parser.add_argument("--recency",                  action="store",       default="1d",
                                help="ignore logs with mtime/ctime older than this, defaults to 1d")
        parser.add_argument("--check-interval",           action="store",       default="30s",
                                help="check for log source changes every this often, defaults to 30s")
        parser.add_argument("--scandirs",                 action="store",       default="/var/log/,/var/lib/log/",
                                help="paths to look for logs under, comma-separated, defaults to /var/log/,/var/lib/log/ ")
        parser.add_argument("--home",                     action="store_true",  default=False,
                                help="look for log-ish text files in homedir, defaults off since it's slow and may turn up crud.")

        parser.add_argument("-g", "--grep",               action="store",       default=None,  help="only print messages containing substring")        
        parser.add_argument("-G", "--grep-out",           action="store",       default=None,  help="don't print messages containing substring")
        
        #parser.add_argument("-c", "--force-color",         action="store_true",  default=False,
        #                        help="force colors, even if context suggests it is not supported (potentially useful in pipes)")        
        parser.add_argument("-C", "--no-color",           action="store_true",  default=False,
                                help="no coloring, even when context seems to support it.")        
        parser.add_argument("-t", "--true-color",         action="store_true",  default=False,
                                help="use true-color escapes, for more colors when you know it's supported")
        
        parser.add_argument('-v', "--verbose",            action="count",  default=0,   help="debug verbosity  (0, 1, 2)")
        args = parser.parse_args()
        
        check_interval  = parse_hms( args.check_interval )
        recency_minutes = parse_hms( args.recency ) / 60.
        scan_dirs       = args.scandirs.split(',')
        onlys, nots = [], []
        if args.onlys:
            onlys.extend( args.onlys.split(',') )
        if args.nots:
            nots.extend(  args.nots.split(',')  )

        #if 'follow-cluster-logs' in sys.argv[0]:  # different behaviour if executable is linked as a different name
        #    scan_dirs = []
        #    add_home = 1
        #    onlys.extend( ['OU','ER'] )
        
        if args.no_color:
            sc = None
            
        s = Rescanner( scan_dirs          = scan_dirs,
                       add_home           = args.home,
                       recency_minutes    = recency_minutes,
                       check_interval_sec = check_interval,
                       onlys              = onlys,
                       nots               = nots,
                       grep               = args.grep,
                       grepout            = args.grep_out,
                       verbose            = args.verbose,
                       truecolor          = args.true_color,
        )
        
        print( "Looking for logs..." )
        s.scan_and_follow()

        
    except KeyboardInterrupt:
        pass
        # shutdown threads?

