2.0 ready
diff --git a/wally/main.py b/wally/main.py
new file mode 100644
index 0000000..c25d0b6
--- /dev/null
+++ b/wally/main.py
@@ -0,0 +1,303 @@
+import os
+import sys
+import time
+import signal
+import logging
+import argparse
+import functools
+import contextlib
+
+from yaml import load as _yaml_load
+
+try:
+    from yaml import CLoader
+    yaml_load = functools.partial(_yaml_load, Loader=CLoader)
+except ImportError:
+    yaml_load = _yaml_load
+
+
+import texttable
+
+try:
+    import faulthandler
+except ImportError:
+    faulthandler = None
+
+
+from wally.timeseries import SensorDatastore
+from wally import utils, run_test, pretty_yaml
+from wally.config import (load_config, setup_loggers,
+                          get_test_files, save_run_params, load_run_params)
+
+
+logger = logging.getLogger("wally")
+
+
+class Context(object):
+    def __init__(self):
+        self.build_meta = {}
+        self.nodes = []
+        self.clear_calls_stack = []
+        self.openstack_nodes_ids = []
+        self.sensors_mon_q = None
+        self.hw_info = []
+
+
+def get_stage_name(func):
+    nm = get_func_name(func)
+    if nm.endswith("stage"):
+        return nm
+    else:
+        return nm + " stage"
+
+
+def get_test_names(raw_res):
+    res = []
+    for tp, data in raw_res:
+        if not isinstance(data, list):
+            raise ValueError()
+
+        keys = []
+        for dt in data:
+            if not isinstance(dt, dict):
+                raise ValueError()
+
+            keys.append(",".join(dt.keys()))
+
+        res.append(tp + "(" + ",".join(keys) + ")")
+    return res
+
+
+def list_results(path):
+    results = []
+
+    for dname in os.listdir(path):
+        try:
+            files_cfg = get_test_files(os.path.join(path, dname))
+
+            if not os.path.isfile(files_cfg['raw_results']):
+                continue
+
+            mt = os.path.getmtime(files_cfg['raw_results'])
+            res_mtime = time.ctime(mt)
+
+            raw_res = yaml_load(open(files_cfg['raw_results']).read())
+            test_names = ",".join(sorted(get_test_names(raw_res)))
+
+            params = load_run_params(files_cfg['run_params_file'])
+
+            comm = params.get('comment')
+            results.append((mt, dname, test_names, res_mtime,
+                           '-' if comm is None else comm))
+        except ValueError:
+            pass
+
+    tab = texttable.Texttable(max_width=200)
+    tab.set_deco(tab.HEADER | tab.VLINES | tab.BORDER)
+    tab.set_cols_align(["l", "l", "l", "l"])
+    results.sort()
+
+    for data in results[::-1]:
+        tab.add_row(data[1:])
+
+    tab.header(["Name", "Tests", "etime", "Comment"])
+
+    print(tab.draw())
+
+
+def get_func_name(obj):
+    if hasattr(obj, '__name__'):
+        return obj.__name__
+    if hasattr(obj, 'func_name'):
+        return obj.func_name
+    return obj.func.func_name
+
+
+@contextlib.contextmanager
+def log_stage(func):
+    msg_templ = "Exception during {0}: {1!s}"
+    msg_templ_no_exc = "During {0}"
+
+    logger.info("Start " + get_stage_name(func))
+
+    try:
+        yield
+    except utils.StopTestError as exc:
+        logger.error(msg_templ.format(
+            get_func_name(func), exc))
+    except Exception:
+        logger.exception(msg_templ_no_exc.format(
+            get_func_name(func)))
+
+
+def make_storage_dir_struct(cfg):
+    utils.mkdirs_if_unxists(cfg.results_dir)
+    utils.mkdirs_if_unxists(cfg.sensor_storage)
+    utils.mkdirs_if_unxists(cfg.hwinfo_directory)
+    utils.mkdirs_if_unxists(cfg.results_storage)
+
+
+def log_nodes_statistic_stage(_, ctx):
+    utils.log_nodes_statistic(ctx.nodes)
+
+
+def parse_args(argv):
+    descr = "Disk io performance test suite"
+    parser = argparse.ArgumentParser(prog='wally', description=descr)
+    parser.add_argument("-l", '--log-level',
+                        help="print some extra log info")
+
+    subparsers = parser.add_subparsers(dest='subparser_name')
+
+    # ---------------------------------------------------------------------
+    compare_help = 'list all results'
+    report_parser = subparsers.add_parser('ls', help=compare_help)
+    report_parser.add_argument("result_storage", help="Folder with test results")
+
+    # ---------------------------------------------------------------------
+    compare_help = 'compare two results'
+    report_parser = subparsers.add_parser('compare', help=compare_help)
+    report_parser.add_argument("data_path1", help="First folder with test results")
+    report_parser.add_argument("data_path2", help="Second folder with test results")
+
+    # ---------------------------------------------------------------------
+    report_help = 'run report on previously obtained results'
+    report_parser = subparsers.add_parser('report', help=report_help)
+    report_parser.add_argument('--load_report',  action='store_true')
+    report_parser.add_argument("data_dir", help="folder with rest results")
+
+    # ---------------------------------------------------------------------
+    test_parser = subparsers.add_parser('test', help='run tests')
+    test_parser.add_argument('--build-description',
+                             type=str, default="Build info")
+    test_parser.add_argument('--build-id', type=str, default="id")
+    test_parser.add_argument('--build-type', type=str, default="GA")
+    test_parser.add_argument('-n', '--no-tests', action='store_true',
+                             help="Don't run tests", default=False)
+    test_parser.add_argument('--load_report',  action='store_true')
+    test_parser.add_argument("-k", '--keep-vm', action='store_true',
+                             help="Don't remove test vm's", default=False)
+    test_parser.add_argument("-d", '--dont-discover-nodes', action='store_true',
+                             help="Don't connect/discover fuel nodes",
+                             default=False)
+    test_parser.add_argument('--no-report', action='store_true',
+                             help="Skip report stages", default=False)
+    test_parser.add_argument("comment", help="Test information")
+    test_parser.add_argument("config_file", help="Yaml config file")
+
+    # ---------------------------------------------------------------------
+
+    return parser.parse_args(argv[1:])
+
+
+def main(argv):
+    if faulthandler is not None:
+        faulthandler.register(signal.SIGUSR1, all_threads=True)
+
+    opts = parse_args(argv)
+    stages = []
+    report_stages = []
+
+    ctx = Context()
+    ctx.results = {}
+    ctx.sensors_data = SensorDatastore()
+
+    if opts.subparser_name == 'test':
+        cfg = load_config(opts.config_file)
+        make_storage_dir_struct(cfg)
+        cfg.comment = opts.comment
+        save_run_params(cfg)
+
+        with open(cfg.saved_config_file, 'w') as fd:
+            fd.write(pretty_yaml.dumps(cfg.__dict__))
+
+        stages = [
+            run_test.discover_stage
+        ]
+
+        stages.extend([
+            run_test.reuse_vms_stage,
+            log_nodes_statistic_stage,
+            run_test.save_nodes_stage,
+            run_test.connect_stage])
+
+        if cfg.settings.get('collect_info', True):
+            stages.append(run_test.collect_hw_info_stage)
+
+        stages.extend([
+            # deploy_sensors_stage,
+            run_test.run_tests_stage,
+            run_test.store_raw_results_stage,
+            # gather_sensors_stage
+        ])
+
+        cfg.keep_vm = opts.keep_vm
+        cfg.no_tests = opts.no_tests
+        cfg.dont_discover_nodes = opts.dont_discover_nodes
+
+        ctx.build_meta['build_id'] = opts.build_id
+        ctx.build_meta['build_descrption'] = opts.build_description
+        ctx.build_meta['build_type'] = opts.build_type
+
+    elif opts.subparser_name == 'ls':
+        list_results(opts.result_storage)
+        return 0
+
+    elif opts.subparser_name == 'report':
+        cfg = load_config(get_test_files(opts.data_dir)['saved_config_file'])
+        stages.append(run_test.load_data_from(opts.data_dir))
+        opts.no_report = False
+        # load build meta
+
+    elif opts.subparser_name == 'compare':
+        x = run_test.load_data_from_path(opts.data_path1)
+        y = run_test.load_data_from_path(opts.data_path2)
+        print(run_test.IOPerfTest.format_diff_for_console(
+            [x['io'][0], y['io'][0]]))
+        return 0
+
+    if not opts.no_report:
+        report_stages.append(run_test.console_report_stage)
+        if opts.load_report:
+            report_stages.append(run_test.test_load_report_stage)
+        report_stages.append(run_test.html_report_stage)
+
+    if opts.log_level is not None:
+        str_level = opts.log_level
+    else:
+        str_level = cfg.settings.get('log_level', 'INFO')
+
+    setup_loggers(getattr(logging, str_level), cfg.log_file)
+    logger.info("All info would be stored into " + cfg.results_dir)
+
+    for stage in stages:
+        ok = False
+        with log_stage(stage):
+            stage(cfg, ctx)
+            ok = True
+        if not ok:
+            break
+
+    exc, cls, tb = sys.exc_info()
+    for stage in ctx.clear_calls_stack[::-1]:
+        with log_stage(stage):
+            stage(cfg, ctx)
+
+    logger.debug("Start utils.cleanup")
+    for clean_func, args, kwargs in utils.iter_clean_func():
+        with log_stage(clean_func):
+            clean_func(*args, **kwargs)
+
+    if exc is None:
+        for report_stage in report_stages:
+            with log_stage(report_stage):
+                report_stage(cfg, ctx)
+
+    logger.info("All info stored into " + cfg.results_dir)
+
+    if exc is None:
+        logger.info("Tests finished successfully")
+        return 0
+    else:
+        logger.error("Tests are failed. See detailed error above")
+        return 1