From cf304275cd4bd04a9c571c02988d9742da4f78ff Mon Sep 17 00:00:00 2001
From: James Graham <j.graham@soton.ac.uk>
Date: Thu, 26 Nov 2020 22:03:02 +0000
Subject: [PATCH] refactor: prettify console output

---
 poetry.lock           | 66 +++++++++++++++++++++++++++++++-
 pycgtool/__main__.py  | 89 ++++++++++++++++++++++++++++++-------------
 pycgtool/frame.py     |  5 ++-
 pycgtool/mapping.py   |  6 +--
 pyproject.toml        |  1 +
 test/test_pycgtool.py |  4 +-
 6 files changed, 136 insertions(+), 35 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index ce586f3..0a63a9a 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -85,6 +85,17 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "commonmark"
+version = "0.9.1"
+description = "Python parser for the CommonMark Markdown spec"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
+
 [[package]]
 name = "coverage"
 version = "5.3"
@@ -104,6 +115,14 @@ category = "main"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
+[[package]]
+name = "dataclasses"
+version = "0.7"
+description = "A backport of the dataclasses module for Python 3.6"
+category = "main"
+optional = false
+python-versions = ">=3.6, <3.7"
+
 [[package]]
 name = "docutils"
 version = "0.16"
@@ -376,7 +395,7 @@ name = "pygments"
 version = "2.7.1"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "main"
-optional = true
+optional = false
 python-versions = ">=3.5"
 
 [[package]]
@@ -537,6 +556,24 @@ python-versions = "*"
 [package.dependencies]
 astroid = ">=1.4"
 
+[[package]]
+name = "rich"
+version = "9.2.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[package.dependencies]
+colorama = ">=0.4.0,<0.5.0"
+commonmark = ">=0.9.0,<0.10.0"
+dataclasses = {version = ">=0.7,<0.8", markers = "python_version >= \"3.6\" and python_version < \"3.7\""}
+pygments = ">=2.6.0,<3.0.0"
+typing-extensions = ">=3.7.4,<4.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
+
 [[package]]
 name = "rstcheck"
 version = "3.3.1"
@@ -737,6 +774,14 @@ category = "main"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "typing-extensions"
+version = "3.7.4.3"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "unidecode"
 version = "1.1.1"
@@ -804,7 +849,7 @@ test = []
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6"
-content-hash = "4ec5a3e4bfd4075233d7356350beeb69f09f659a18bb7d1450d209e40f15447a"
+content-hash = "684ae060e918da15c709fa66630983cb36502abed30a288f1e015ed337f0c1a0"
 
 [metadata.files]
 alabaster = [
@@ -843,6 +888,10 @@ colorama = [
     {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
     {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
 ]
+commonmark = [
+    {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
+    {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
+]
 coverage = [
     {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"},
     {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"},
@@ -914,6 +963,10 @@ cython = [
     {file = "Cython-0.29.21-py2.py3-none-any.whl", hash = "sha256:5c4276fdcbccdf1e3c1756c7aeb8395e9a36874fa4d30860e7694f43d325ae13"},
     {file = "Cython-0.29.21.tar.gz", hash = "sha256:e57acb89bd55943c8d8bf813763d20b9099cc7165c0f16b707631a7654be9cad"},
 ]
+dataclasses = [
+    {file = "dataclasses-0.7-py3-none-any.whl", hash = "sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836"},
+    {file = "dataclasses-0.7.tar.gz", hash = "sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6"},
+]
 docutils = [
     {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"},
     {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"},
@@ -1140,6 +1193,10 @@ requests = [
 requirements-detector = [
     {file = "requirements-detector-0.7.tar.gz", hash = "sha256:0d1e13e61ed243f9c3c86e6cbb19980bcb3a0e0619cde2ec1f3af70fdbee6f7b"},
 ]
+rich = [
+    {file = "rich-9.2.0-py3-none-any.whl", hash = "sha256:564fee761e0756c3a4fbe97517166703754e11b4e861983cf184c78c9de8b570"},
+    {file = "rich-9.2.0.tar.gz", hash = "sha256:7003a1cce3b79bf4d34a26099b00a4b67e208d4a6896a1c25368603d5f49f295"},
+]
 rstcheck = [
     {file = "rstcheck-3.3.1.tar.gz", hash = "sha256:92c4f79256a54270e0402ba16a2f92d0b3c15c8f4410cb9c57127067c215741f"},
 ]
@@ -1235,6 +1292,11 @@ typed-ast = [
     {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
     {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
 ]
+typing-extensions = [
+    {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
+    {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
+    {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
+]
 unidecode = [
     {file = "Unidecode-1.1.1-py2.py3-none-any.whl", hash = "sha256:1d7a042116536098d05d599ef2b8616759f02985c85b4fef50c78a5aaf10822a"},
     {file = "Unidecode-1.1.1.tar.gz", hash = "sha256:2b6aab710c2a1647e928e36d69c21e76b453cd455f4e2621000e54b2a9b8cce8"},
diff --git a/pycgtool/__main__.py b/pycgtool/__main__.py
index 7c31421..a49263f 100755
--- a/pycgtool/__main__.py
+++ b/pycgtool/__main__.py
@@ -5,8 +5,11 @@ import cProfile
 import logging
 import pathlib
 import sys
+import textwrap
 import typing
 
+from rich.logging import RichHandler
+
 from .frame import Frame
 from .mapping import Mapping
 from .bondset import BondSet
@@ -37,31 +40,31 @@ def measure_bonds(frame: Frame, mapping: typing.Optional[Mapping],
     :param mapping:
     :param config: Program arguments from argparse
     """
-    bonds = BondSet(config.bnd, config)
-
-    logger.info("Bond measurements will be made")
+    bonds = BondSet(config.bondset, config)
     bonds.apply(frame)
 
-    if config.map and config.trajectory:
+    if config.mapping and config.trajectory:
         # Only perform Boltzmann Inversion if we have a mapping and a trajectory.
         # Otherwise we get infinite force constants.
-        logger.info("Beginning Boltzmann Inversion")
+        logger.info('Starting Boltzmann Inversion')
         bonds.boltzmann_invert()
+        logger.info('Finished Boltzmann Inversion')
 
         if config.output_forcefield:
-            logger.info("Creating GROMACS forcefield directory")
+            logger.info("Writing GROMACS forcefield directory")
             out_dir = pathlib.Path(config.out_dir)
             forcefield = ForceField(config.output_name, dir_path=out_dir)
             forcefield.write(config.output_name, mapping, bonds)
-            logger.info("GROMACS forcefield directory created")
+            logger.info("Finished writing GROMACS forcefield directory")
 
         else:
             bonds.write_itp(get_output_filepath('itp', config),
                             mapping=mapping)
 
     if config.dump_measurements:
-        logger.info("Dumping bond measurements to file")
+        logger.info('Writing bond measurements to file')
         bonds.dump_values(config.dump_n_values, config.out_dir)
+        logger.info('Finished writing bond measurements to file')
 
 
 def mapping_loop(frame: Frame, config) -> typing.Tuple[Frame, Mapping]:
@@ -70,11 +73,12 @@ def mapping_loop(frame: Frame, config) -> typing.Tuple[Frame, Mapping]:
     :param frame:
     :param config: Program arguments from argparse
     """
-    logger.info("Mapping will be performed")
-    mapping = Mapping(config.map, config, itp_filename=config.itp)
+    logger.info('Starting AA->CG mapping')
+    mapping = Mapping(config.mapping, config, itp_filename=config.itp)
 
     cg_frame = mapping.apply(frame)
     cg_frame.save(get_output_filepath('gro', config), frame_number=0)
+    logging.info('Finished AA->CG mapping')
 
     return cg_frame, mapping
 
@@ -92,24 +96,24 @@ def full_run(config):
         frame_start=config.begin,
         frame_end=config.end)
 
-    if config.map:
+    if config.mapping:
         cg_frame, mapping = mapping_loop(frame, config)
 
     else:
-        logger.info("Mapping will not be performed")
+        logger.info('Skipping AA->CG mapping')
         mapping = None
         cg_frame = frame
 
     if config.output_xtc:
         cg_frame.save(get_output_filepath('xtc', config))
 
-    if config.bnd:
+    if config.bondset:
         measure_bonds(cg_frame, mapping, config)
 
 
 class BooleanAction(argparse.Action):
     """Set up a boolean argparse argument with matching `--no` argument.
-    
+
     Based on https://thisdataguy.com/2017/07/03/no-options-with-argparse-and-python/
     """
     def __init__(self, option_strings, dest, nargs=None, **kwargs):
@@ -134,9 +138,9 @@ def parse_arguments(arg_list):
                              help="AA simulation topology - e.g. PDB, GRO, etc.")
     input_files.add_argument('trajectory', type=str, nargs='?',
                              help="AA simulation trajectory - e.g. XTC, DCD, etc.")
-    input_files.add_argument('-m', '--map', type=str,
+    input_files.add_argument('-m', '--mapping', type=str,
                              help="Mapping file")
-    input_files.add_argument('-b', '--bnd', type=str,
+    input_files.add_argument('-b', '--bondset', type=str,
                              help="Bonds file")
     input_files.add_argument('-i', '--itp', type=str,
                              help="GROMACS ITP file")
@@ -200,6 +204,9 @@ def parse_arguments(arg_list):
 
     run_options.add_argument('--profile', '--no-profile', default=False, action=BooleanAction,
                              help="Profile performance?")
+    run_options.add_argument('--log-level', default='INFO',
+                             choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'),
+                             help="Which log messages should be shown?")
     # yapf: enable
 
     args = parser.parse_args(arg_list)
@@ -219,12 +226,12 @@ def validate_arguments(args):
     :param args: Parsed arguments from ArgumentParser
     """
     if not args.dump_measurements:
-        args.dump_measurements = bool(args.bnd) and not bool(args.map)
+        args.dump_measurements = bool(args.bondset) and not bool(args.mapping)
 
     if not args.map_only:
-        args.map_only = not bool(args.bnd)
+        args.map_only = not bool(args.bondset)
 
-    if not args.map and not args.bnd:
+    if not args.mapping and not args.bondset:
         raise ArgumentValidationError("One or both of -m and -b is required.")
 
     return args
@@ -233,17 +240,47 @@ def validate_arguments(args):
 def main():
     args = parse_arguments(sys.argv[1:])
 
-    print("Using GRO: {0}".format(args.topology))
-    print("Using XTC: {0}".format(args.trajectory))
+    logging.basicConfig(level=args.log_level,
+                        format='%(message)s',
+                        datefmt='[%X]',
+                        handlers=[RichHandler()])
+
+    banner = """\
+         _____        _____ _____ _______ ____   ____  _      
+        |  __ \      / ____/ ____|__   __/ __ \ / __ \| |     
+        | |__) |   _| |   | |  __   | | | |  | | |  | | |     
+        |  ___/ | | | |   | | |_ |  | | | |  | | |  | | |     
+        | |   | |_| | |___| |__| |  | | | |__| | |__| | |____ 
+        |_|    \__, |\_____\_____|  |_|  \____/ \____/|______|
+                __/ |                                         
+               |___/                                          
+    """  # noqa
+
+    logger.info('[bold blue]%s[/]',
+                textwrap.dedent(banner),
+                extra={'markup': True})
+
+    logger.info(30 * '-')
+    logger.info('Topology:\t%s', args.topology)
+    logger.info('Trajectory:\t%s', args.trajectory)
+    logger.info('Mapping:\t%s', args.mapping)
+    logger.info('Bondset:\t%s', args.bondset)
+    logger.info(30 * '-')
+
+    try:
+        if args.profile:
+            with cProfile.Profile() as profiler:
+                full_run(args)
+
+            profiler.dump_stats('gprof.out')
 
-    if args.profile:
-        with cProfile.Profile() as profiler:
+        else:
             full_run(args)
 
-        profiler.dump_stats('gprof.out')
+        logger.info('Finished processing - goodbye!')
 
-    else:
-        full_run(args)
+    except Exception as exc:
+        logger.error(exc)
 
 
 if __name__ == "__main__":
diff --git a/pycgtool/frame.py b/pycgtool/frame.py
index d3bfe79..eb212ef 100644
--- a/pycgtool/frame.py
+++ b/pycgtool/frame.py
@@ -50,15 +50,18 @@ class Frame:
         """
         if topology_file is not None:
             try:
+                logging.info('Loading topology file')
                 self._trajectory = mdtraj.load(str(topology_file))
                 self._topology = self._trajectory.topology
+                logging.info('Finished loading topology file')
 
                 if trajectory_file is not None:
                     try:
+                        logging.info('Loading trajectory file - this may take a while')
                         self._trajectory = mdtraj.load(str(trajectory_file),
                                                        top=self._topology)
-
                         self._slice_trajectory(frame_start, frame_end)
+                        logging.info('Finished loading trajectory file')
 
                     except ValueError as exc:
                         raise NonMatchingSystemError from exc
diff --git a/pycgtool/mapping.py b/pycgtool/mapping.py
index 7046654..d47508d 100644
--- a/pycgtool/mapping.py
+++ b/pycgtool/mapping.py
@@ -249,7 +249,7 @@ class Mapping:
                     name, typ, first, *atoms = mol_section[i][1:]
 
                 except KeyError as exc:
-                    raise ValueError(f'"{name}" line prefix invalid') from exc
+                    raise ValueError(f'Invalid line prefix "{name}" in mapping file') from exc
 
             try:
                 # Allow optional charge in mapping file
@@ -262,8 +262,7 @@ class Mapping:
                 atoms.insert(0, first)
 
             if not atoms:
-                # TODO should this stop execution?
-                logger.warning('Bead %s specification contains no atoms', name)
+                raise ValueError(f'Bead {name} specification contains no atoms')
 
             mol_map.append(
                 bead_class(name, i, type=typ, atoms=atoms, charge=charge))
@@ -421,7 +420,6 @@ class Mapping:
         return cg_frame
 
 
-# TODO: Use MDTraj instead
 def calc_coords_weight(ref_coords, coords, weights, box=None):
     """Calculate the coordinates of a single CG bead from weighted component atom coordinates.
 
diff --git a/pyproject.toml b/pyproject.toml
index 1bcfe2c..9fa18b8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ wheel = "^0.35.1"
 numpy = "^1.19.1"
 cython = "^0.29.21"
 mdtraj = "^1.9.4"
+rich = "^9.2.0"
 
 # Optional extras to enable additional functionality
 sphinx-autoapi = { version = "^1.5.0", optional = true }
diff --git a/test/test_pycgtool.py b/test/test_pycgtool.py
index 1d1cc7a..d0e7db3 100644
--- a/test/test_pycgtool.py
+++ b/test/test_pycgtool.py
@@ -57,7 +57,7 @@ class PycgtoolTest(unittest.TestCase):
         ])
 
         self.assertEqual('TOPOLOGY', args.topology)
-        self.assertEqual('MAP', args.map)
+        self.assertEqual('MAP', args.mapping)
         self.assertEqual(1000, args.begin)
 
     def test_map_only(self):
@@ -117,7 +117,7 @@ class PycgtoolTest(unittest.TestCase):
         with tempfile.TemporaryDirectory() as tmpdir:
             tmp_path = pathlib.Path(tmpdir)
             args = get_args('sugar', tmp_path, extra={
-                'map': None,
+                'mapping': None,
                 'trajectory': None,
             })
 
-- 
GitLab