diff --git a/.gitignore b/.gitignore index 85d30d6..c83befc 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ +/tests/reports/ # Translations *.mo diff --git a/.travis.yml b/.travis.yml index 4cbb064..56c146c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,15 @@ language: python + python: - "3.8" +before_install: + - "pip install -U pip" + - "python setup.py install" install: - - pip install -e . + - pip install pytest pandas dist: xenial services: - xvfb -script: eyeloop --video 'misc/travis-sample/Frmd7.m4v' +script: pytest diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..5529574 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include requirements.txt requirements_testing.txt tox.ini diff --git a/README.md b/README.md index e236f30..3be5977 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,17 @@ The default graphical user interface in EyeLoop is [*minimum-gui*.](https://gith > EyeLoop is compatible with custom graphical user interfaces through its modular logic. [Click here](https://github.com/simonarvin/eyeloop/blob/master/eyeloop/guis/README.md) for instructions on how to build your own. +## Running unit tests ## + +Install testing requirements by running in a terminal: + +`pip install -r requirements_testing.txt` + +Then run tox: `tox` + +Reports and results will be outputted to `/tests/reports` + + ## Known issues ## - [ ] Respawning/freezing windows when running *minimum-gui* in Ubuntu. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..90aede0 --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +# Extra configuration for pytest +# The presence of this file in {project_dir} will force pytest to add the dir to PYTHONPATH diff --git a/eyeloop/__init__.py b/eyeloop/__init__.py new file mode 100644 index 0000000..17aa839 --- /dev/null +++ b/eyeloop/__init__.py @@ -0,0 +1,7 @@ +__all__ = ["constants", "engine", "extractors", "guis", "importers", "utilities", "run_eyeloop", "config"] + +from eyeloop import utilities +from eyeloop import constants +from eyeloop import guis +from eyeloop import importers +from eyeloop import utilities diff --git a/eyeloop/constants/__init__.py b/eyeloop/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eyeloop/engine/__init__.py b/eyeloop/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eyeloop/engine/engine.py b/eyeloop/engine/engine.py index ff9b0c9..c3fbbe4 100644 --- a/eyeloop/engine/engine.py +++ b/eyeloop/engine/engine.py @@ -1,4 +1,6 @@ +import logging import time +from typing import Optional import cv2 @@ -7,6 +9,8 @@ from eyeloop.engine.processor import Shape from eyeloop.utilities.general_operations import to_int, tuple_int +logger = logging.getLogger(__name__) + class Engine: def __init__(self, eyeloop): @@ -21,6 +25,7 @@ def __init__(self, eyeloop): else: # Enables markers to remove artifacts. -m 1 self.place_markers = self.real_place_markers self.marks = [] + self.blink: Optional[int] = None if config.arguments.tracking == 0: # Recording mode. --tracking 0 self.iterate = self.record @@ -103,7 +108,8 @@ def arm(self, width, height, image) -> None: self.norm_cr_artefact = int(6 * self.norm) self.mean = np.mean(image) - self.blink_threshold = 25.1*np.log(np.var(image)) - 182 #0.046 * np.var(image) - 68.11 + # self.blink_threshold = 25.1 * np.log(np.var(image)) - 182 # 0.046 * np.var(image) - 68.11 + self.blink_threshold = 0.046 * np.var(image) - 58 self.base_mean = -1 self.blink = 0 @@ -119,28 +125,35 @@ def arm(self, width, height, image) -> None: for cr_processor in self.cr_processors: cr_processor.binarythreshold = float(np.min(self.source)) * .7 - def check_blink(self, threshold=5) -> bool: + def check_blink(self, threshold: Optional[float] = None) -> bool: """ Analyzes the monochromatic distribution of the frame, to infer blinking. Change in mean during blinking is very distinct. + :returns: True if blink detected, False otherwise """ - + # TODO calculate threshold using another method? + if threshold is None: + threshold = self.blink_threshold + # print(f"threshold = {threshold}") mean = np.mean(self.source) delta = self.mean - mean self.mean = mean - # print("delta", delta) - if abs(delta) > self.blink_threshold: - self.blink = 10 - print("blink!") - return False + if threshold < 0: + raise ValueError(f"check_blink() threshold must be greater than 0! Threshold was {threshold}") + + # print(f"blink delta = {abs(delta)}") + if abs(delta) > threshold: + self.blink = 10 # TODO does this parameter mean a blink lasts for 10 frames? Should this be here? + print("blink detected!") + return True elif self.blink != 0: self.blink -= 1 + return True + else: + self.blink = 0 return False - self.blink = 0 - - return True def track(self) -> None: """ @@ -154,10 +167,11 @@ def track(self) -> None: timestamp = time.time() cr_width = cr_height = cr_center = cr_angle = pupil_center = pupil_width = pupil_height = pupil_angle = -1 - blink = 0 - cr_log=self.cr_log_stock.copy() - if self.check_blink(): + cr_log = self.cr_log_stock.copy() + + if self.check_blink() is False: + blink = 0 try: pupil_area = self.pupil_processor.area offsetx, offsety = -self.pupil_processor.corners[0] @@ -176,7 +190,7 @@ def track(self) -> None: cr_center, cr_width, cr_height, cr_angle, cr_dimensions_int = cr_processor.ellipse.parameters() cr_log[i] = ((cr_width, cr_height), cr_center, cr_angle) except: - pass #for now, let's pass any errors arising from Shape().track() - this caused EyeLoop to crash when cr_processor.track() failed. + pass # for now, let's pass any errors arising from Shape().track() - this caused EyeLoop to crash when cr_processor.track() failed. self.refresh_pupil(self.pupil_source) # lambda _: None when pupil not selected in gui. self.place_markers() # lambda: None when markerless (-m 0). @@ -188,13 +202,13 @@ def track(self) -> None: else: blink = 1 + self.blink_i = blink try: config.graphical_user_interface.update_track(blink) except Exception as e: - print("Error! Did you assign the graphical user interface (GUI) correctly?") - print("Error message: ", e) + logger.exception("Did you assign the graphical user interface (GUI) correctly? Attempting to release()") self.release() return @@ -217,8 +231,8 @@ def activate(self) -> None: for extractor in self.extractors: try: extractor.activate() - except: - pass + except AttributeError: + logger.warning(f"Extractor {extractor} has no activate() method") def release(self) -> None: """ @@ -231,14 +245,10 @@ def release(self) -> None: for extractor in self.extractors: try: extractor.release() - except: - pass - - try: - config.importer.release() - except: - pass + except AttributeError: + logger.warning(f"Extractor {extractor} has no release() method") + config.importer.release() def update_feed(self, img) -> None: diff --git a/eyeloop/extractors/DAQ.py b/eyeloop/extractors/DAQ.py index b45733b..865c514 100644 --- a/eyeloop/extractors/DAQ.py +++ b/eyeloop/extractors/DAQ.py @@ -1,22 +1,20 @@ import json -import time +import logging +from pathlib import Path class DAQ_extractor: - def __init__(self, dir): - self.dir = dir - timestamp = time.strftime("%Y%m%d-%H%M%S") - filename = "{}/log{}.json".format(dir, timestamp) - self.log = open(filename, 'w') + def __init__(self, output_dir): + self.output_dir = output_dir + self.datalog_path = Path(output_dir, f"datalog.json") def fetch(self, engine): - try: - self.log.write(json.dumps(engine.dataout) + "\n") - except: - pass + with open(self.datalog_path, "a") as datalog: + datalog.write(json.dumps(engine.dataout) + "\n") def release(self): - self.log.close() + logging.debug("DAQ_extractor.release() called") + pass # def set_digital_line(channel, value): # digital_output = PyDAQmx.Task() diff --git a/eyeloop/extractors/__init__.py b/eyeloop/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eyeloop/guis/__init__.py b/eyeloop/guis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eyeloop/guis/minimum/minimum_gui.py b/eyeloop/guis/minimum/minimum_gui.py index 18a9b3c..6857467 100644 --- a/eyeloop/guis/minimum/minimum_gui.py +++ b/eyeloop/guis/minimum/minimum_gui.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import numpy as np @@ -224,7 +225,7 @@ def arm(self, width: int, height: int) -> None: self.binary_height = max(height, 200) fourcc = cv2.VideoWriter_fourcc(*'MPEG') - output_vid = config.arguments.output_dir / "output.avi" + output_vid = Path(config.file_manager.new_folderpath, "output.avi") self.out = cv2.VideoWriter(str(output_vid), fourcc, 50.0, (self.width, self.height)) self.PStock = np.zeros((self.binary_height, self.binary_width)) diff --git a/eyeloop/importers/__init__.py b/eyeloop/importers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eyeloop/importers/cv.py b/eyeloop/importers/cv.py index df2a5cf..eab6e75 100644 --- a/eyeloop/importers/cv.py +++ b/eyeloop/importers/cv.py @@ -1,15 +1,20 @@ +import logging from pathlib import Path +from typing import Optional, Callable import cv2 import eyeloop.config as config from eyeloop.importers.importer import IMPORTER +logger = logging.getLogger(__name__) + class Importer(IMPORTER): def __init__(self) -> None: super().__init__() + self.route_frame: Optional[Callable] = None # Dynamically assigned at runtime depending on input type def first_frame(self) -> None: self.vid_path = Path(config.arguments.video) @@ -31,7 +36,9 @@ def first_frame(self) -> None: except: image = image[..., 0] else: - raise ValueError("Failed to initialize video stream.\nMake sure that the video path is correct, or that your webcam is plugged in and compatible with opencv.") + raise ValueError( + "Failed to initialize video stream.\n" + "Make sure that the video path is correct, or that your webcam is plugged in and compatible with opencv.") elif self.vid_path.is_dir(): @@ -45,7 +52,8 @@ def first_frame(self) -> None: height, width, _ = image.shape image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) self.route_frame = self.route_sequence_sing - except: # TODO fix bare except + except Exception: # TODO fix bare except + logger.exception("first_frame() error: ") height, width = image.shape self.route_frame = self.route_sequence_flat @@ -57,14 +65,9 @@ def first_frame(self) -> None: def route(self) -> None: self.first_frame() while True: - try: + if self.route_frame is not None: self.route_frame() - except ValueError: - config.engine.release() - print("Importer released (1).") - break - except TypeError: - print("Importer released (2).") + else: break def proceed(self, image) -> None: @@ -86,7 +89,6 @@ def route_sequence_flat(self) -> None: self.proceed(image) - def route_cam(self) -> None: """ Routes the capture frame to: @@ -97,14 +99,15 @@ def route_cam(self) -> None: _, image = self.capture.read() if image is not None: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + self.proceed(image) else: - raise ValueError("No more frames.") - return - - self.proceed(image) + logger.info("No more frames to process, exiting.") + self.release() def release(self) -> None: + logger.debug(f"cv.Importer.release() called") if self.capture is not None: self.capture.release() - self.route_frame = lambda _: None + self.route_frame = None + cv2.destroyAllWindows() diff --git a/eyeloop/run_eyeloop.py b/eyeloop/run_eyeloop.py index 1d7ea8c..73386a8 100644 --- a/eyeloop/run_eyeloop.py +++ b/eyeloop/run_eyeloop.py @@ -1,17 +1,23 @@ -from eyeloop.engine.engine import Engine - -from eyeloop.utilities.format_print import welcome -from eyeloop.utilities.argument_parser import Arguments -from eyeloop.utilities.file_manager import File_Manager +import importlib +import logging +import sys +from pathlib import Path +import eyeloop.config as config +from eyeloop.engine.engine import Engine from eyeloop.extractors.DAQ import DAQ_extractor from eyeloop.extractors.frametimer import FPS_extractor - from eyeloop.guis.minimum.minimum_gui import GUI +from eyeloop.utilities.argument_parser import Arguments +from eyeloop.utilities.file_manager import File_Manager +from eyeloop.utilities.format_print import welcome +from eyeloop.utilities.shared_logging import setup_logging -import eyeloop.config as config +EYELOOP_DIR = Path(__file__).parent +PROJECT_DIR = EYELOOP_DIR.parent + +logger = logging.getLogger(__name__) -import sys class EyeLoop: """ @@ -20,12 +26,14 @@ class EyeLoop: Git: https://github.com/simonarvin/eyeloop """ - def __init__(self): + def __init__(self, args, logger=None): welcome("Server") - config.arguments = Arguments() + config.arguments = Arguments(args) config.file_manager = File_Manager(output_root=config.arguments.output_dir) + if logger is None: + logger, logger_filename = setup_logging(log_dir=config.file_manager.new_folderpath, module_name="run_eyeloop") config.graphical_user_interface = GUI() @@ -38,18 +46,16 @@ def __init__(self): config.engine.load_extractors(extractors) try: - print("Initiating tracking via {}".format(config.arguments.importer)) - import_command = "from eyeloop.importers.{} import Importer".format(config.arguments.importer) - - exec(import_command, globals()) + logger.info(f"Initiating tracking via Importer: {config.arguments.importer}") + importer_module = importlib.import_module(f"eyeloop.importers.{config.arguments.importer}") + config.importer = importer_module.Importer() + config.importer.route() - except Exception as e: - print("Invalid importer selected.\n", e) + # exec(import_command, globals()) - config.importer = Importer() - config.importer.route() - sys.exit(0) + except ImportError: + logger.exception("Invalid importer selected") if __name__ == '__main__': - EyeLoop() + EyeLoop(sys.argv[1:], logger=None) diff --git a/eyeloop/utilities/__init__.py b/eyeloop/utilities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eyeloop/utilities/argument_parser.py b/eyeloop/utilities/argument_parser.py index 8422b62..2587c4c 100644 --- a/eyeloop/utilities/argument_parser.py +++ b/eyeloop/utilities/argument_parser.py @@ -10,8 +10,21 @@ class Arguments: Parses all command-line arguments and config.pupt parameters. """ - def __init__(self) -> None: - + def __init__(self, args) -> None: + self.config = None + self.markers = None + self.video = None + self.output_dir = None + self.importer = None + self.scale = None + self.tracking = None + self.model = None + + self.parsed_args = self.parse_args(args) + self.build_config(parsed_args=self.parsed_args) + + @staticmethod + def parse_args(args): parser = argparse.ArgumentParser(description='Help list') parser.add_argument("-v", "--video", default="0", type=str, help="Input a video sequence for offline processing.") @@ -29,25 +42,24 @@ def __init__(self) -> None: parser.add_argument("-tr", "--tracking", default=1, type=int, help="Enable/disable tracking (1/enabled: default).") - args = parser.parse_args() + return parser.parse_args(args) - self.config = args.config + def build_config(self, parsed_args): + self.config = parsed_args.config if self.config != "0": # config file was set. self.parse_config(self.config) - self.markers = args.markers - self.video = Path(args.video.strip("\'\"")).absolute() # Handle quotes used in argument - self.output_dir = Path(args.output_dir.strip("\'\"")).absolute() - self.importer = args.importer.lower() - self.scale = args.scale - self.tracking = args.tracking - self.model = args.model.lower() + self.markers = parsed_args.markers + self.video = Path(parsed_args.video.strip("\'\"")).absolute() # Handle quotes used in argument + self.output_dir = Path(parsed_args.output_dir.strip("\'\"")).absolute() + self.importer = parsed_args.importer.lower() + self.scale = parsed_args.scale + self.tracking = parsed_args.tracking + self.model = parsed_args.model.lower() def parse_config(self, config: str) -> None: - - try: - content = open(config, "r") + with open(config, "r") as content: print("Loading config preset: ", config) for line in content: split = line.split("=") @@ -76,7 +88,3 @@ def parse_config(self, config: str) -> None: print("Markers preset: ", parameter) self.markers = parameter print("") - - except Exception as e: - print("Error opening .pupt config preset.") - print(e) diff --git a/eyeloop/utilities/logging_config.yaml b/eyeloop/utilities/logging_config.yaml new file mode 100644 index 0000000..25f7d26 --- /dev/null +++ b/eyeloop/utilities/logging_config.yaml @@ -0,0 +1,39 @@ +# Logging config +version: 1 +disable_existing_loggers: False +formatters: + simple: + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +handlers: + info_file_handler: + class: logging.handlers.RotatingFileHandler + level: INFO + formatter: simple + filename: logs/python_logging.log + encoding: utf8 + + debug_file_handler: + class: logging.handlers.RotatingFileHandler + level: DEBUG + formatter: simple + filename: logs/python_logging.log + encoding: utf8 + + error_file_handler: + class: logging.handlers.RotatingFileHandler + level: ERROR + formatter: simple + filename: logs/python_logging.log + encoding: utf8 + + console: + class: logging.StreamHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout +root: + level: DEBUG + handlers: [debug_file_handler, console] + propogate: True + diff --git a/eyeloop/utilities/shared_logging.py b/eyeloop/utilities/shared_logging.py new file mode 100644 index 0000000..13e4a31 --- /dev/null +++ b/eyeloop/utilities/shared_logging.py @@ -0,0 +1,50 @@ +import logging +import logging.config +import os +from datetime import datetime +from pathlib import Path + +import yaml + + +def setup_logging(log_config_path=f"{Path(__file__).parent}/logging_config.yaml", log_dir="logs", module_name=None) -> \ + (logging.Logger, str): + """ + Setup logging configuration. Returns logger object. + + :param log_config_path: Path to logging config yaml file + :param log_dir: Directory that log files will be written into (relative or full path) + :param module_name: Module name to append to log filename. If none given __name__ will be used. + :returns: Tuple of (the newly created logging object, path to log file (possibly None if no config was found)) + """ + log_filename = None + + # Check for permissions and change log dir if write access isn't granted + + if Path(log_dir).exists() is False: + print(f"log dir not found, Attempting to create dir {log_dir}") + Path(log_dir).mkdir(parents=True, exist_ok=True) + + print(f"Writing log to {Path(log_dir).absolute()}") + + if module_name is None: + module_name = __name__ + + if os.path.exists(log_config_path): + with open(log_config_path, 'rt') as f: + config = yaml.safe_load(f.read()) + + # Set + for handler_name, handler in config["handlers"].items(): + if handler_name != "console": + log_filename = rf"{log_dir}/{datetime.now().strftime('%Y-%m-%d-%H%M%S')}_{module_name}.log" + handler["filename"] = log_filename + + logging.config.dictConfig(config) + + else: + raise ValueError(f"Loading logger config failed from {log_config_path} for module {module_name}") + + new_logger = logging.getLogger(module_name) + + return new_logger, log_filename diff --git a/requirements.txt b/requirements.txt index 62a2dd0..4ada6ea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ opencv-contrib-python>=4.2.* pymba==0.3.* numpy==1.19.* +PyYaml diff --git a/requirements_testing.txt b/requirements_testing.txt new file mode 100644 index 0000000..fbeac5d --- /dev/null +++ b/requirements_testing.txt @@ -0,0 +1,5 @@ +pytest +tox +pandas +pytest-html +pytest-cov diff --git a/setup.py b/setup.py index 482de5c..3fc4239 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,7 @@ #!/usr/bin/env python from setuptools import setup, find_packages - -install_requires = install_requires = [] +install_requires = [] with open('requirements.txt') as f: for line in f.readlines(): @@ -11,7 +10,6 @@ continue install_requires.append(req) - setup( name='eyeloop', description='EyeLoop is a Python 3-based eye-tracker tailored specifically to dynamic, ' @@ -26,7 +24,7 @@ version='0.1', entry_points={ 'console_scripts': [ - 'eyeloop=eyeloop.run_eyeloop:EyeLoop' + 'eyeloop=eyeloop.run_eyeloop:__main__' ] }, packages=find_packages(include=["eyeloop.*"]), diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..b2c12e6 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,63 @@ +# Basic integration tests +import json +import logging +from pathlib import Path + +import pandas as pd +import pytest + +from eyeloop import run_eyeloop + +TESTDATA_DIR = Path(__file__).parent / "testdata" + +TEST_VIDEOS = { # Basic short videos to use for end to end testing + "short_human_3blink": { + "path": Path(TESTDATA_DIR, "short_human_3blink.mp4").absolute(), + "animal": "human", + "blinks": 3, + "n_frames": 282, + }, + "short_mouse_noblink": { + "path": Path(TESTDATA_DIR, "short_mouse_noblink.m4v").absolute(), + "animal": "mouse", + "blinks": 0, + "n_frames": 307, + } +} +logger = logging.getLogger(__name__) + + +def output_json_parser(json_file: Path) -> pd.DataFrame: + data_log = json_file.read_text().splitlines() + data_dicts = [json.loads(line) for line in data_log] + return pd.DataFrame(data_dicts) + + +class TestIntegration: + @pytest.mark.parametrize("test_video_name", ["short_human_3blink", "short_mouse_noblink"]) + def test_integration(self, tmpdir, test_video_name: str): + test_video = TEST_VIDEOS[test_video_name] + print(f"Running test on video {test_video}") + testargs = ["--video", str(test_video["path"]), + "--output_dir", str(tmpdir)] + eyeloop_obj = run_eyeloop.EyeLoop(args=testargs, logger=logger) + + # Ensure output is expected + data_dir = list(Path(tmpdir).glob("trial_*"))[0] + vid_frames = list(Path(data_dir).glob("frame_*.jpg")) + assert len(vid_frames) == test_video["n_frames"] + 1 # Account for 0-indexing + datalog = Path(data_dir, "datalog.json") + assert datalog.exists() + data_df = output_json_parser(datalog) + assert max(data_df.frame) == test_video["n_frames"] + assert Path(data_dir, "output.avi").exists() + # TODO add assertions based on blink, cr and pupil values + + def test_no_video_stream_error(self): + with pytest.raises(ValueError) as excinfo: + run_eyeloop.EyeLoop(args=[]) + assert "Failed to initialize video stream" in str(excinfo.value) + +# Tests for each importer + +# TODO Add tests that use that animal tag of the videos diff --git a/tests/testdata/short_human_3blink.mp4 b/tests/testdata/short_human_3blink.mp4 new file mode 100644 index 0000000..78747f0 Binary files /dev/null and b/tests/testdata/short_human_3blink.mp4 differ diff --git a/tests/testdata/short_mouse_noblink.m4v b/tests/testdata/short_mouse_noblink.m4v new file mode 100644 index 0000000..73eaf13 Binary files /dev/null and b/tests/testdata/short_mouse_noblink.m4v differ diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c2a3032 --- /dev/null +++ b/tox.ini @@ -0,0 +1,29 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py38 + +[testenv] +# using -Ur to force updating of requirements as the files change doesn't work yet, see https://github.com/tox-dev/tox/issues/149 +deps = + pytest + pytest-cov + pytest-html + -Ur{toxinidir}/requirements.txt + -Ur{toxinidir}/requirements_testing.txt + +commands = + pytest --cov=eyeloop --cov-report html:tests/reports/coverage --html=tests/reports/pytest_results.html + +[pytest] +log_cli = True +log_format = %(asctime)s %(levelname)s %(message)s +log_date_format = %Y-%m-%d %H:%M:%S +log_cli_date_format = %Y-%m-%d %H:%M:%S +log_cli_format = %(asctime)s %(levelname)s %(message)s +log_cli_level = DEBUG + +