Random changes.
authorScott Gasch <[email protected]>
Tue, 13 Jul 2021 03:52:49 +0000 (20:52 -0700)
committerScott Gasch <[email protected]>
Tue, 13 Jul 2021 03:52:49 +0000 (20:52 -0700)
bootstrap.py
config.py
datetime_utils.py
decorator_utils.py
exec_utils.py
file_utils.py
lockfile.py [new file with mode: 0644]
logging_utils.py
math_utils.py
text_utils.py
timer.py [new file with mode: 0644]

index da421b6f0a35dd2c0c269728f71427750e7e6708..3c886efc94f583e7b13a7bbc19d63a174890d120 100644 (file)
@@ -3,13 +3,14 @@
 import functools
 import logging
 import os
+import pdb
 import sys
-import time
 import traceback
 
 # This module is commonly used by others in here and should avoid
 # taking any unnecessary dependencies back on them.
-import argparse_utils
+
+from argparse_utils import ActionNoYes
 import config
 
 
@@ -20,9 +21,9 @@ args = config.add_commandline_args(
     'Args related to python program bootstrapper and Swiss army knife')
 args.add_argument(
     '--debug_unhandled_exceptions',
-    action=argparse_utils.ActionNoYes,
+    action=ActionNoYes,
     default=False,
-    help='Break into debugger on top level unhandled exceptions for interactive debugging'
+    help='Break into pdb on top level unhandled exceptions.'
 )
 
 
@@ -38,31 +39,41 @@ def handle_uncaught_exception(
     traceback.print_exception(exc_type, exc_value, exc_traceback)
     if config.config['debug_unhandled_exceptions']:
         logger.info("Invoking the debugger...")
-        breakpoint()
+        pdb.pm()
 
 
-def initialize(funct):
-    import logging_utils
+def initialize(entry_point):
 
     """Remember to initialize config and logging before running main."""
-    @functools.wraps(funct)
+    @functools.wraps(entry_point)
     def initialize_wrapper(*args, **kwargs):
         sys.excepthook = handle_uncaught_exception
-        config.parse()
+        config.parse(entry_point.__globals__['__file__'])
+
+        import logging_utils
         logging_utils.initialize_logging(logging.getLogger())
+
         config.late_logging()
-        logger.debug(f'Starting {funct.__name__}')
-        start = time.perf_counter()
-        ret = funct(*args, **kwargs)
-        end = time.perf_counter()
-        logger.debug(f'{funct} returned {ret}.')
+
+        logger.debug(f'Starting {entry_point.__name__} (program entry point)')
+
+        ret = None
+        import timer
+        with timer.Timer() as t:
+            ret = entry_point(*args, **kwargs)
+        logger.debug(
+            f'{entry_point.__name__} (program entry point) returned {ret}.'
+        )
+
+        walltime = t()
         (utime, stime, cutime, cstime, elapsed_time) = os.times()
-        logger.debug(f'\nuser: {utime}s\n'
+        logger.debug(f'\n'
+                     f'user: {utime}s\n'
                      f'system: {stime}s\n'
                      f'child user: {cutime}s\n'
                      f'child system: {cstime}s\n'
                      f'elapsed: {elapsed_time}s\n'
-                     f'walltime: {end - start}s\n')
+                     f'walltime: {walltime}s\n')
         if ret != 0:
             logger.info(f'Exit {ret}')
         else:
index 672e1ae0328e6d7e5b99ccbc4d702adfcd516f32..dccfc27d5e9e64fa1bc83bfc3b4e825f89949fe4 100644 (file)
--- a/config.py
+++ b/config.py
@@ -100,10 +100,12 @@ class LoadFromFile(argparse.Action):
 
 
 # A global parser that we will collect arguments into.
+prog = os.path.basename(sys.argv[0])
 args = argparse.ArgumentParser(
-    description=f"This program uses config.py ({__file__}) for global, cross-module configuration.",
+    description=None,
     formatter_class=argparse.ArgumentDefaultsHelpFormatter,
     fromfile_prefix_chars="@",
+    epilog=f'-----------------------------------------------------------------------------\n{prog} uses config.py ({__file__}) for global, cross-module configuration setup and parsing.\n-----------------------------------------------------------------------------'
 )
 config_parse_called = False
 
@@ -155,9 +157,7 @@ def is_flag_already_in_argv(var: str):
     return False
 
 
-def parse() -> Dict[str, Any]:
-    import string_utils
-
+def parse(entry_module: str) -> Dict[str, Any]:
     """Main program should call this early in main()"""
     global config_parse_called
     if config_parse_called:
@@ -165,8 +165,23 @@ def parse() -> Dict[str, Any]:
     config_parse_called = True
     global saved_messages
 
+    # If we're about to do the usage message dump, put the main module's
+    # argument group first in the list, please.
+    reordered_action_groups = []
+    prog = sys.argv[0]
+    for arg in sys.argv:
+        if arg == '--help' or arg == '-h':
+            print(entry_module)
+            for group in args._action_groups:
+                if entry_module in group.title or prog in group.title:
+                    reordered_action_groups.insert(0, group)
+                else:
+                    reordered_action_groups.append(group)
+            args._action_groups = reordered_action_groups
+
     # Examine the environment variables to settings that match
-    # known flags.
+    # known flags.  For a flag called --example_flag the corresponding
+    # environment variable would be called EXAMPLE_FLAG.
     usage_message = args.format_usage()
     optional = False
     var = ''
@@ -193,7 +208,8 @@ def parse() -> Dict[str, Any]:
                         saved_messages.append(
                             f'Initialized from environment: {var} = {value}'
                         )
-                        if len(chunks) == 1 and string_utils.to_bool(value):
+                        from string_utils import to_bool
+                        if len(chunks) == 1 and to_bool(value):
                             sys.argv.append(var)
                         elif len(chunks) > 1:
                             sys.argv.append(var)
@@ -204,12 +220,13 @@ def parse() -> Dict[str, Any]:
             next
 
     # Parse (possibly augmented) commandline args with argparse normally.
-    #config.update(vars(args.parse_args()))
     known, unknown = args.parse_known_args()
     config.update(vars(known))
 
     # Reconstruct the argv with unrecognized flags for the benefit of
-    # future argument parsers.
+    # future argument parsers.  For example, unittest_main in python
+    # has some of its own flags.  If we didn't recognize it, maybe
+    # someone else will.
     sys.argv = sys.argv[:1] + unknown
 
     if config['config_savefile']:
index f2cae8b9f06b2f69e5132685f611e4aaceeb02d2..7787c6f0b0b74b84b84fca28bba8d2381e1d8668 100644 (file)
@@ -51,12 +51,18 @@ def date_and_time_to_datetime(date: datetime.date,
     )
 
 
-def datetime_to_date(date: datetime.datetime) -> datetime.date:
-    return datetime.date(
-        date.year,
-        date.month,
-        date.day
-    )
+def datetime_to_date_and_time(
+        dt: datetime.datetime
+) -> Tuple[datetime.date, datetime.time]:
+    return (dt.date(), dt.timetz())
+
+
+def datetime_to_date(dt: datetime.datetime) -> datetime.date:
+    return datetime_to_date_and_time(dt)[0]
+
+
+def datetime_to_time(dt: datetime.datetime) -> datetime.time:
+    return datetime_to_date_and_time(dt)[1]
 
 
 # An enum to represent units with which we can compute deltas.
@@ -330,6 +336,8 @@ def minute_number_to_time_string(minute_num: MinuteOfDay) -> str:
 
 def parse_duration(duration: str) -> int:
     """Parse a duration in string form."""
+    if duration.isdigit():
+        return int(duration)
     seconds = 0
     m = re.search(r'(\d+) *d[ays]*', duration)
     if m is not None:
index 2817239c88c2396b0e5dcc56e7c535b8afdd99d9..0d5b3e3213c393e8843c5f1abb677d3e3e36cdb4 100644 (file)
@@ -192,7 +192,7 @@ def retry_predicate(
     tries: int,
     *,
     predicate: Callable[..., bool],
-    delay_sec: float = 3,
+    delay_sec: float = 3.0,
     backoff: float = 2.0,
 ):
     """Retries a function or method up to a certain number of times
@@ -202,10 +202,10 @@ def retry_predicate(
     delay_sec sets the initial delay period in seconds.
     backoff is a multiplied (must be >1) used to modify the delay.
     predicate is a function that will be passed the retval of the
-      decorated function and must return True to stop or False to
-      retry.
+    decorated function and must return True to stop or False to
+    retry.
     """
-    if backoff < 1:
+    if backoff < 1.0:
         msg = f"backoff must be greater than or equal to 1, got {backoff}"
         logger.critical(msg)
         raise ValueError(msg)
@@ -225,9 +225,11 @@ def retry_predicate(
         @functools.wraps(f)
         def f_retry(*args, **kwargs):
             mtries, mdelay = tries, delay_sec  # make mutable
+            logger.debug(f'deco_retry: will make up to {mtries} attempts...')
             retval = f(*args, **kwargs)
             while mtries > 0:
                 if predicate(retval) is True:
+                    logger.debug('Predicate succeeded, deco_retry is done.')
                     return retval
                 logger.debug("Predicate failed, sleeping and retrying.")
                 mtries -= 1
index c669f5460b2d97490d308c38b285233f431a9ffa..1b587405fb1a706a1d7b1c128fde2ad878401137 100644 (file)
@@ -2,10 +2,10 @@
 
 import shlex
 import subprocess
-from typing import List
+from typing import List, Optional
 
 
-def cmd_with_timeout(command: str, timeout_seconds: float) -> int:
+def cmd_with_timeout(command: str, timeout_seconds: Optional[float]) -> int:
     return subprocess.check_call(
         ["/bin/bash", "-c", command], timeout=timeout_seconds
     )
index d5451244bb71bf8ce7fc4e0d03c1162b01e0f3e8..464b0e76cfba0ef4e80ba5343c24bf433584b9b5 100644 (file)
@@ -7,6 +7,7 @@ import errno
 import hashlib
 import logging
 import os
+import pathlib
 import time
 from typing import Optional
 import glob
@@ -219,6 +220,10 @@ def describe_file_mtime(filename: str, *, brief=False) -> Optional[str]:
     return describe_file_timestamp(filename, lambda x: x.st_mtime, brief=brief)
 
 
+def touch_file(filename: str) -> bool:
+    return pathlib.Path(filename).touch()
+
+
 def expand_globs(in_filename: str):
     for filename in glob.glob(in_filename):
         yield filename
diff --git a/lockfile.py b/lockfile.py
new file mode 100644 (file)
index 0000000..ee8c255
--- /dev/null
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+
+from dataclasses import dataclass
+import datetime
+import json
+import logging
+import os
+import signal
+import sys
+from typing import Optional
+
+import decorator_utils
+
+
+logger = logging.getLogger(__name__)
+
+
+class LockFileException(Exception):
+    pass
+
+
+@dataclass
+class LockFileContents:
+    pid: int
+    commandline: str
+    expiration_timestamp: float
+
+
+class LockFile(object):
+    """A file locking mechanism that has context-manager support so you
+    can use it in a with statement.
+    """
+
+    def __init__(
+            self,
+            lockfile_path: str,
+            *,
+            do_signal_cleanup: bool = True,
+            expiration_timestamp: Optional[float] = None,
+    ) -> None:
+        self.is_locked = False
+        self.lockfile = lockfile_path
+        if do_signal_cleanup:
+            signal.signal(signal.SIGINT, self._signal)
+            signal.signal(signal.SIGTERM, self._signal)
+        self.expiration_timestamp = expiration_timestamp
+
+    def locked(self):
+        return self.is_locked
+
+    def available(self):
+        return not os.path.exists(self.lockfile)
+
+    def try_acquire_lock_once(self) -> bool:
+        logger.debug(f"Trying to acquire {self.lockfile}.")
+        try:
+            # Attempt to create the lockfile.  These flags cause
+            # os.open to raise an OSError if the file already
+            # exists.
+            fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
+            with os.fdopen(fd, "a") as f:
+                contents = self._get_lockfile_contents()
+                logger.debug(contents)
+                f.write(contents)
+            logger.debug(f'Success; I own {self.lockfile}.')
+            self.is_locked = True
+            return True
+        except OSError:
+            pass
+        logger.debug(f'Failed; I could not acquire {self.lockfile}.')
+        return False
+
+    def acquire_with_retries(
+            self,
+            *,
+            initial_delay: float = 1.0,
+            backoff_factor: float = 2.0,
+            max_attempts = 5
+    ) -> bool:
+
+        @decorator_utils.retry_if_false(tries = max_attempts,
+                                        delay_sec = initial_delay,
+                                        backoff = backoff_factor)
+        def _try_acquire_lock_with_retries() -> bool:
+            success = self.try_acquire_lock_once()
+            if not success and os.path.exists(self.lockfile):
+                self._detect_stale_lockfile()
+            return success
+
+        if os.path.exists(self.lockfile):
+            self._detect_stale_lockfile()
+        return _try_acquire_lock_with_retries()
+
+    def release(self):
+        try:
+            os.unlink(self.lockfile)
+        except Exception as e:
+            logger.exception(e)
+        self.is_locked = False
+
+    def __enter__(self):
+        if self.acquire_with_retries():
+            return self
+        msg = f"Couldn't acquire {self.lockfile}; giving up."
+        logger.warning(msg)
+        raise LockFileException(msg)
+
+    def __exit__(self, type, value, traceback):
+        self.release()
+
+    def __del__(self):
+        if self.is_locked:
+            self.release()
+
+    def _signal(self, *args):
+        if self.is_locked:
+            self.release()
+
+    def _get_lockfile_contents(self) -> str:
+        contents = LockFileContents(
+            pid = os.getpid(),
+            commandline = ' '.join(sys.argv),
+            expiration_timestamp = self.expiration_timestamp
+        )
+        return json.dumps(contents.__dict__)
+
+    def _detect_stale_lockfile(self) -> None:
+        try:
+            with open(self.lockfile, 'r') as rf:
+                lines = rf.readlines()
+                if len(lines) == 1:
+                    line = lines[0]
+                    line_dict = json.loads(line)
+                    contents = LockFileContents(**line_dict)
+                    logger.debug(f'Blocking lock contents="{contents}"')
+
+                    # Does the PID exist still?
+                    try:
+                        os.kill(contents.pid, 0)
+                    except OSError:
+                        logger.debug('The pid seems stale; killing the lock.')
+                        self.release()
+
+                    # Has the lock expiration expired?
+                    if contents.expiration_timestamp is not None:
+                        now = datetime.datetime.now().timestamp()
+                        if now > contents.expiration_datetime:
+                            logger.debug('The expiration time has passed; ' +
+                                         'killing the lock')
+                            self.release()
+        except Exception:
+            pass
index 9c78f3f685aa6fa9c09f6a1a64e7186707384e75..328ea6fe9d3570b8e9fe6af3f09eadba774eca1c 100644 (file)
@@ -36,7 +36,7 @@ cfg.add_argument(
 cfg.add_argument(
     '--logging_format',
     type=str,
-    default='%(levelname)s:%(asctime)s: %(message)s',
+    default='%(levelname).1s:%(asctime)s: %(message)s',
     help='The format for lines logged via the logger module.'
 )
 cfg.add_argument(
index 2e126990fba0d7b9138f4a4b6238440aae89bb82..56fb7072366ab97621e032e9aed11d13d7740b5e 100644 (file)
@@ -1,5 +1,6 @@
 #!/usr/bin/env python3
 
+import functools
 import math
 from typing import List
 from heapq import heappush, heappop
@@ -60,6 +61,7 @@ def truncate_float(n: float, decimals: int = 2):
     return int(n * multiplier) / multiplier
 
 
[email protected]_cache(maxsize=1024, typed=True)
 def is_prime(n: int) -> bool:
     """Returns True if n is prime and False otherwise"""
     if not isinstance(n, int):
index 76b5db600ec588e10e6d9c951fc1bb9b3960d70e..93e4b638ba9c840dfd05a27a83d01865643352bd 100644 (file)
@@ -167,3 +167,30 @@ def generate_padded_columns(text: List[str]) -> str:
             word = justify_string(word, width=width, alignment='l')
             out += f'{word} '
         yield out
+
+
+class Indenter:
+    """
+    with Indenter() as i:
+      i.print('test')
+      with i:
+        i.print('-ing')
+        with i:
+          i.print('1, 2, 3')
+    """
+    def __init__(self):
+        self.level = -1
+
+    def __enter__(self):
+        self.level += 1
+        return self
+
+    def __exit__(self, exc_type, exc_value, exc_tb):
+        self.level -= 1
+        if self.level < -1:
+            self.level = -1
+
+    def print(self, *arg, **kwargs):
+        import string_utils
+        text = string_utils.sprintf(*arg, **kwargs)
+        print("    " * self.level + text)
diff --git a/timer.py b/timer.py
new file mode 100644 (file)
index 0000000..752c7ed
--- /dev/null
+++ b/timer.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+import time
+from typing import Callable
+
+
+class Timer(object):
+    """
+    with timer.Timer() as t:
+        do_the_thing()
+
+    walltime = t()
+    print(f'That took {walltime}s.')
+    """
+
+    def __init__(self) -> None:
+        self.start = None
+        self.end = None
+        pass
+
+    def __enter__(self) -> Callable[[], float]:
+        self.start = time.perf_counter()
+        self.end = 0.0
+        return lambda: self.end - self.start
+
+    def __exit__(self, *args) -> bool:
+        self.end = time.perf_counter()
+        return True