diff --git a/LICENSE.txt b/LICENSE.txt index 224316ffe29b1313a73b1f8676df6a3104b5395a..32725ef3f1647490b59e466d4c69644e4cec288a 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,11 @@ The MIT License (MIT) -Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> +Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +Use of this package is governed by the MIT license, see LICENSE file. + +This project was initially written for personal use of the original author. Later +it was developed much further and used for project of author`s employer. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 750041b7dd70cad820f9b267f7c729601575f0f9..c3a61157a92f8eacd600aa6e82b4f4965cfe13f4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build -SPHINXPROJ = ZenKit-PythonScriptAndDaemonToolkit +SPHINXPROJ = PyZenKit-Python3ScriptAndDaemonToolkit SOURCEDIR = . BUILDDIR = doc/_build diff --git a/README.rst b/README.rst index 4200298441be5dc8c461d73827187aad6daff961..ecb1e040cd34b61f4dc228b8eb20e6dfd0167ea0 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,59 @@ -PyZenKit +PyZenKit - Python script and daemon toolkit's documentation! ================================================================================ -Collection of usefull tools and utilities for Python 3. .. warning:: - This library is still work in progress. + Although production code is based on this library, it should still be considered + as work in progress. -.. note:: - For usage and examples please see the source code, for demonstration execute - the appropriate module with Python3 interpreter. +Introduction +-------------------------------------------------------------------------------- + +This package contains collection of usefull tools and utilities for creating +console applications, scripts and system services (daemons) in Python 3. It +provides easily extendable and customizable base implementations of generic +application, script or daemon and which take care of many common issues and +tasks like configuration loading and merging, command line argument parsing, +logging setup, etc. + +The extensive documentation and tutorials is still under development, however +usage examples and demonstration applications are provided right in the source +code of appropriate module. Just execute the module with Python3 interpretter +to see the demonstration:: + + python3 path/to/application.py --help + + +Features +-------------------------------------------------------------------------------- + +Currently the package contains following features: + +:py:mod:`pyzenkit.jsonconf` + Module for handling JSON based configuration files and directories. + +:py:mod:`pyzenkit.daemonizer` + Module for taking care of all process daemonization tasks. + +:py:mod:`pyzenkit.baseapp` + Module for writing generic console applications. + +:py:mod:`pyzenkit.zenscript` + Module for writing generic console scripts with built-in support for repeated + executions (for example by cron-like service). + +:py:mod:`pyzenkit.zendaemon` + Module for writing generic system services (daemons). + Copyright -------------------------------------------------------------------------------- -Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> +Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> Use of this package is governed by the MIT license, see LICENSE file. + +This project was initially written for personal use of the original author. Later +it was developed much further and used for project of author`s employer. diff --git a/conf.py b/conf.py index 0436043284a06aa79a009373edd79d6b4785162c..b2a3803489c2510b110d2e4362bf95ada4c3ff31 100644 --- a/conf.py +++ b/conf.py @@ -67,9 +67,9 @@ author = u'Jan Mach' # built documents. # # The short X.Y version. -version = u'1.0' +version = u'0.32' # The full version, including alpha/beta/rc tags. -release = u'1.0' +release = u'0.32' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -168,4 +168,4 @@ texinfo_documents = [ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = {'python': ('https://docs.python.org/3.4', None)} diff --git a/doc/_pages/_inc.bin.app-opt.rst b/doc/_pages/_inc.bin.app-opt.rst new file mode 100644 index 0000000000000000000000000000000000000000..da4228a13bc9a5c481383c8f6f77ea3c4849ba5a --- /dev/null +++ b/doc/_pages/_inc.bin.app-opt.rst @@ -0,0 +1,75 @@ +``--help`` + Display help and usage description and exit (*flag*) + +``--name alternative-name`` + Alternative name for application instead of default ``$0``, using which names + for log, runlog, pid, status and other files will be generated. + + *Type:* ``string``, *default:* ``$0`` + +``--quiet`` + Run in quiet mode (*flag*). + + Do not write anything to ``stdout`` or ``stderr``. + + *Type:* ``boolean``, *default:* ``False`` + +``--verbose`` + Increase application output verbosity (*flag*, *repeatable*). + + *Type:* ``boolean``, *default:* ``False`` + +``--log-file file-name`` + Name of the log file. + + *Type:* ``string``, *default:* autodetected + +``--log-level level`` + Logging level [``debug``, ``info``, ``warning``, ``error``, ``critical``]. + + *Type:* ``string``, *default:* ``info`` + +``--runlog-dir dir-name`` + Name of the runlog directory. + + *Type:* ``string``, *default:* autodetected + +``--runlog-dump`` + Dump runlog to stdout when done processing (*flag*). + + *Type:* ``boolean``, *default:* ``False`` + +``--runlog_log`` + Write runlog to logging service when done processing (*flag*) + + *Type:* ``boolean``, *default:* ``False`` + +``--pstate_file file-name`` + Name of the persistent state file. + + *Type:* ``string``, *default:* autodetected + +``--pstate_dump`` + Dump persistent state to stdout when done processing (*flag*). + + *Type:* ``boolean``, *default:* ``False`` + +``--pstate_log`` + Write persistent state to logging service when done processing (*flag*). + + *Type:* ``boolean``, *default:* ``False`` + +``--action action`` + Execute given quick action and exit. List of available actions can be displayed with ``--help`` option. + + *Type:* ``string``, *default:* ``None`` + +``--user name-or-id`` + Name/gid of the system user for process permissions. + + *Type:* ``string``, *default:* ``None`` + +``--group name-or-id`` + Name/gid of the system group for process permissions. + + *Type:* ``string``, *default:* ``None`` diff --git a/doc/_pages/_inc.bin.daemon-opt.rst b/doc/_pages/_inc.bin.daemon-opt.rst new file mode 100644 index 0000000000000000000000000000000000000000..cad8aa9230cd3aaf8ab74c6c9c0e51c517e034c0 --- /dev/null +++ b/doc/_pages/_inc.bin.daemon-opt.rst @@ -0,0 +1,39 @@ +``--no-daemon`` + Do not daemonize and stay in foreground (*flag*). + + *Type:* ``boolean``, *default:* ``False`` + +``--chroot-dir dir-name`` + Name of the chroot directory. + + *Type:* ``string``, *default:* ``None`` + +``--work_dir dir-name`` + Name of the process work directory. + + *Type:* ``string``, *default:* ``/`` + +``--pid_file file-name`` + Name of the pid file. + + *Type:* ``string``, *default:* autodetected + +``--state_file file-name`` + Name of the state file. + + *Type:* ``string``, *default:* autodetected + +``--umask mask`` + Default file umask. + + *Type:* ``string``, *default:* ``0o002`` + +``--stats_interval interval`` + Processing statistics display interval in seconds. + + *Type:* ``integer``, *default:* ``300`` + +``--paralel`` + Run in paralel mode (*flag*). + + *Type:* ``boolean``, *default:* ``False`` diff --git a/doc/_pages/_inc.bin.script-opt.rst b/doc/_pages/_inc.bin.script-opt.rst new file mode 100644 index 0000000000000000000000000000000000000000..257384f3d2ac8795b3093ab06e4329d4a7f48df0 --- /dev/null +++ b/doc/_pages/_inc.bin.script-opt.rst @@ -0,0 +1,29 @@ +``--regular`` + Operational mode: regular script execution (*flag*). Conflicts with ``--shell`` option. + + *Type:* ``boolean``, *default:* ``False`` + +``--shell`` + Operational mode: manual script execution from shell (*flag*). Conflicts with ``--regular`` option. + + *Type:* ``boolean``, *default:* ``False`` + +``--command name`` + Name of the script command to be executed. + + *Type:* ``string``, *default:* autodetected + +``--interval interval`` + Execution interval. This value should correspond with related cron script. + + *Type:* ``string``, *default:* ``daily`` + +``--adjust_thresholds`` + Round-up time interval threshols to interval size (*flag*). + + *Type:* ``boolean``, *default:* ``False`` + +``--time_high time`` + Upper time interval threshold. + + *Type:* ``float``, *default:* time.time diff --git a/doc/_pages/api.rst b/doc/_pages/api.rst index 41a2b81335ba16dff9f6dca1edb5387693ac0a44..047e32c79d5ecaedaef12f0f21b832c50c1bea2b 100644 --- a/doc/_pages/api.rst +++ b/doc/_pages/api.rst @@ -5,11 +5,17 @@ API .. warning:: - Although production code is based on this library, it should still be considered work in progress. + Although a working production code is based on this library, it should still + be considered to be work in progress. .. toctree:: :maxdepth: 1 :caption: API Contents: :glob: - api_* + api_pyzenkit.jsonconf + api_pyzenkit.daemonizer + api_pyzenkit.baseapp + api_pyzenkit.zenscript + api_pyzenkit.zendaemon + api_pyzenkit.zencli diff --git a/doc/_pages/api_pyzenkit.baseapp.rst b/doc/_pages/api_pyzenkit.baseapp.rst index b4c441fa8439337c017f8b30d60876eabd7d07f5..a1921a0c7a40d289abdce2af2ab8ad1dfbad25ea 100644 --- a/doc/_pages/api_pyzenkit.baseapp.rst +++ b/doc/_pages/api_pyzenkit.baseapp.rst @@ -5,3 +5,5 @@ pyzenkit.baseapp .. automodule:: pyzenkit.baseapp :members: + :private-members: + :special-members: diff --git a/doc/_pages/api_pyzenkit.zendaemon.rst b/doc/_pages/api_pyzenkit.zendaemon.rst index 2cbb885e11caeb0a0bbdc6e036d7549ebe9272af..5b6a8a4dac208d54e680f0e340446fec62c303c6 100644 --- a/doc/_pages/api_pyzenkit.zendaemon.rst +++ b/doc/_pages/api_pyzenkit.zendaemon.rst @@ -5,3 +5,5 @@ pyzenkit.zendaemon .. automodule:: pyzenkit.zendaemon :members: + :private-members: + :special-members: diff --git a/doc/_pages/api_pyzenkit.zenscript.rst b/doc/_pages/api_pyzenkit.zenscript.rst index 0f378dce0c9c1f7935e59ee853cfeab702f344c1..5fbc68d07e31dfa562a5abc4bf2ba82761ae39ff 100644 --- a/doc/_pages/api_pyzenkit.zenscript.rst +++ b/doc/_pages/api_pyzenkit.zenscript.rst @@ -5,3 +5,5 @@ pyzenkit.zenscript .. automodule:: pyzenkit.zenscript :members: + :private-members: + :special-members: diff --git a/doc/_pages/architecture.rst b/doc/_pages/architecture.rst new file mode 100644 index 0000000000000000000000000000000000000000..d8aea34dead58a02b93574b0a6f67c8a45c7cdb8 --- /dev/null +++ b/doc/_pages/architecture.rst @@ -0,0 +1,63 @@ +Application architecture +================================================================================ + + +Configuration +-------------------------------------------------------------------------------- + +Every application supports multiple means for adjusting the internal configurations. +When appropriate the default values for each configuration is hardcoded in module +source code. However there are several options to change the value: + +* Override the internal default value when instantinating the application object + by passing different value to object constructor. +* Pass the different value by configuration file. +* Pass the different value by command line option. + +The configuration values are assigned from the sources mentioned above in that +particular order, so the value given by command line option overwrites the value +written in configuration file. + + +Command line options +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Configuration can be passed down to application by command line options. These options +have the highest priority and will overwrite any other configuration values. Depending +on the base object of the application different set of options is available. + +Common application options +```````````````````````````````````````````````````````````````````````````````` + +Following configuration options are available for all applications based on +:py:mod:`pyzenkit.baseapp`: + +.. include:: _inc.bin.app-opt.rst + +Common script options +```````````````````````````````````````````````````````````````````````````````` + +Following configuration options are available on top of common applicationsoptions +for all applications based on :py:mod:`pyzenkit.zenscript`: + +.. include:: _inc.bin.script-opt.rst + +Common daemon options +```````````````````````````````````````````````````````````````````````````````` + +Following configuration options are available on top of common applicationsoptions +for all applications based on :py:mod:`pyzenkit.zendaemon`: + +.. include:: _inc.bin.daemon-opt.rst + + +Configuration files and directories +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Configuration can be passed down to application using a combination of configuration +file or configuration directory. The configuration file + +The available configuration keys are very similar to command line options and the +names differ only in the use of ``_`` character instead of ``-``. However there is +a certain set of configuration keys that is available only through command line +options and not through configuration file and vice versa. diff --git a/manual.rst b/manual.rst index 1c660bc2e523c5fa83dd1b251beee6f9070bcfc0..ab7892a6507b9e9018b25c71d81d7787acc58f22 100644 --- a/manual.rst +++ b/manual.rst @@ -1,19 +1,20 @@ .. PyZenKit - Python script and daemon toolkit documentation master file, created by sphinx-quickstart on Wed Feb 15 10:49:01 2017. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. Welcome to PyZenKit - Python script and daemon toolkit's documentation! ================================================================================ .. warning:: - Although production code is based on this library, it should still be considered work in progress. + Although production code is based on this library, it should still be considered + as work in progress. .. toctree:: :maxdepth: 2 :caption: Contents: + README + doc/_pages/architecture doc/_pages/api Indices and tables diff --git a/pyzenkit/__init__.py b/pyzenkit/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..16bbe090b036cfd6e408221d7c530f923d45bf31 100644 --- a/pyzenkit/__init__.py +++ b/pyzenkit/__init__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +#------------------------------------------------------------------------------- +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. +#------------------------------------------------------------------------------- + +__version__ = "0.32" diff --git a/pyzenkit/baseapp.py b/pyzenkit/baseapp.py index b3d0d4241c2b8321cf8f29cfe308b52048a81f09..08b21a8d3e0260d028c2d0263fb6ee7851880d15 100644 --- a/pyzenkit/baseapp.py +++ b/pyzenkit/baseapp.py @@ -1,37 +1,245 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- + """ -Implementation of generic processing script/daemon/application. +This module provides base implementation of generic processing application with +many usefull features including (but not limited to) following: + +Application configuration service + The base application provides tools for loading configurations from multiple + sources and merging it into single dictionary. Currently the following + configuration sources are available: + + * Optional configuration via configuration directory. + * Optional configuration via external JSON configuration file. + * Optional configuration via command line arguments and options. + +Logging service + The base application is capable of automated setup of logging service based + on configuration values into followin destinations: + + * Optional logging to console. + * Optional logging to text file. + +Persistent state service + The base application contains optinal persistent state feature, which is capable + of storing data between multiple executions. Simple JSON files are used for the + data storage. + +Application runlog service + The base application provides optional runlog service, which is a intended to + provide storage for relevant data and results during the processing and enable + further analysis later. Simple JSON files are used for the data storage. + +Plugin system + The application provides tools for writing and using plugins, that can be used + to further enhance the functionality of application and improve code reusability + by composing the application from smaller building blocks. + +Application actions + The application provides tools for quick actions. These are intended to be used + for application management tasks such as vieving or validating configuration + without executing the application itself, listing and evaluating runlogs and + so on. There is a number of built-in actions and more can be implemented very + easily. + + +Application usage modes +^^^^^^^^^^^^^^^^^^^^^^^ + +Applications created based on this framework can be utilized in two work modes: + +* **run** +* **plugin** + +In a **run** mode all application features are configured and desired action or +application code is immediatelly executed. + +In a **plugin** mode the application is only configured and any other interactions +must be performed manually. This approach enables users to plug the apllication into +another one on a wider scope. One example use case may be the implementation of an +user command line interface that controls multiple applications (much like *git*). + + +Application life-cycle +^^^^^^^^^^^^^^^^^^^^^^ + +Depending on the mode of operation (**run** or **plugin**) the application code goes +through a different set of stages during its life span. + +In a **plugin** mode the following stages are performed: + +* **__init__** +* **setup** + +In a **run** mode the following stages are performed in case some *action* is being +handled: + +* **__init__** +* **setup** +* **action** + +In a **run** mode the following stages are performed in case of normal processing: + +* **__init__** +* **setup** +* **process** +* **evaluate** +* **teardown** + +Stage __init__ +`````````````` + +The **__init__** stage is responsible for creating and basic initialization of +application object. No exception should occur or be raised during the initialization +stage and the code should always work regardles of environment setup, so no files +should be opened, etc. Any exception during this stage will intentionally not get +handled in any way and result in full traceback dump and immediate application +termination. + +These more advanced setup tasks should be performed during the +**setup** stage, which is capable of intelligent catching and displaying/logging +of any exceptions. There are following substages in this stage: + +* init command line argument parser: :py:func:`BaseApp._init_argparser` +* parse command line arguments: :py:func:`BaseApp._parse_cli_arguments` +* initialize application name: :py:func:`BaseApp._init_name` +* initialize filesystem paths: :py:func:`BaseApp._init_paths` +* initialize application runlog: :py:func:`BaseApp._init_runlog` +* initialize default configurations: :py:func:`BaseApp._init_config` +* subclass hook for additional initializations: :py:func:`BaseApp._sub_stage_init` + +Any of the previous substages may be overriden in a subclass to enhance or alter +the functionality, but always be sure of what you are doing. + +Stage *setup* +````````````` + +The **setup** stage is responsible for bootstrapping the whole application. Any failure +It +consists of couple of substages: + +* setup configuration: :py:func:`BaseApp._stage_setup_configuration` +* setup user and group privileges: :py:func:`BaseApp._stage_setup_privileges` +* setup logging: :py:func:`BaseApp._stage_setup_logging` +* setup persistent state: :py:func:`BaseApp._stage_setup_pstate` +* setup plugins: :py:func:`BaseApp._stage_setup_plugins` +* subclass hook for additional setup: :py:func:`BaseApp._sub_stage_setup` +* setup dump: :py:func:`BaseApp._stage_setup_dump` + +Stage *action* +`````````````` + +The **action** stage takes care of executing built-in actions. + +Stage *process* +``````````````` + +The **process** stage is supposed to perform any required task and process runlog. + +Stage *evaluate* +```````````````` + +The **evaluate** stage is supposed to perform any evaluation of current runlog. + +Stage *teardown* +```````````````` + +The **teardown** stage is supposed to perform any cleanup tasks before the application +exits. It consists of couple of substages: + +* :py:func:`BaseApp._sub_stage_teardown` +* :py:func:`BaseApp._stage_teardown_pstate` +* :py:func:`BaseApp._stage_teardown_runlog` + + +Programming API +^^^^^^^^^^^^^^^ + +* public attributes: + + * ``self.name`` + * ``self.paths`` + * ``self.runlog`` + * ``self.config`` + * ``self.logger`` + * ``self.pstate`` + * ``self.retc`` + +* public methods: + + * ``self.c`` + * ``self.cc`` + * ``self.p`` + * ``self.dbgout`` + * ``self.excout`` + * ``self.error`` + * ``self.json_dump`` + * ``self.json_load`` + * ``self.json_save`` + + +Application actions +^^^^^^^^^^^^^^^^^^^ + +* *config-view* +* *runlog-dump* +* *runlog-view* +* *runlogs-dump* +* *runlogs-list* +* *runlogs-evaluate* + -This class provides base implementation of generic processing application with many -usefull features including (but not limited to) following: +Subclass extension hooks +^^^^^^^^^^^^^^^^^^^^^^^^ -* Optional configuration via external JSON configuration file -* Optional configuration via configuration directory -* Optional configuration via command line arguments and options -* Optional logging to console -* Optional logging to text file -* Optional persistent state storage between script executions -* Optional runlog saving after each script execution -* Integrated runlog analysis tools +* :py:func:`BaseApp._sub_stage_init` +* :py:func:`BaseApp._sub_stage_setup` +* :py:func:`BaseApp._sub_stage_process` +* :py:func:`BaseApp._sub_stage_teardown` +* :py:func:`BaseApp._sub_runlog_analyze` +* :py:func:`BaseApp._sub_runlog_format_analysis` +* :py:func:`BaseApp._sub_runlogs_evaluate` +* :py:func:`BaseApp._sub_runlogs_format_evaluation` + +Module contents +^^^^^^^^^^^^^^^ + +* :py:class:`ZenAppException` + + * :py:class:`ZenAppSetupException` + * :py:class:`ZenAppProcessException` + * :py:class:`ZenAppEvaluateException` + * :py:class:`ZenAppTeardownException` + +* :py:class:`ZenAppPlugin` +* :py:class:`BaseApp` +* :py:class:`DemoBaseApp` """ + +__author__ = "Jan Mach <honza.mach.ml@gmail.com>" + + import os import sys import pwd import grp import re -import shutil import glob -import math import time -import json import argparse import logging import logging.handlers @@ -40,76 +248,140 @@ import subprocess import datetime import traceback -# Generate the path to custom 'lib' directory -lib = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) -sys.path.insert(0, lib) - # # Custom libraries. # import pyzenkit.jsonconf import pydgets.widgets -# -# Global variables. -# -# Global flag, that turns on additional debugging messages. -FLAG_DEBUG = False +#------------------------------------------------------------------------------- -def _json_default(o): - return repr(o) class ZenAppException(Exception): """ - Base class for all ZenApp specific exceptions. - - These exceptions will be catched, error will be displayed to the user and - script will attempt to gracefully terminate without dumping the traceback - to the user. These exceptions should be used for anticipated errors, which - can occur during normal script execution and do not mean there is anything - wrong with the script itself, for example missing configuration file, etc... + Base class for all ZenApp custom exceptions. + + When appropriate, these exceptions will be catched, error will be displayed + to the user and the application will attempt to gracefully terminate without + dumping the traceback visibly to the user. These exceptions should be used + for anticipated errors, which can occur during normal application execution and + do not mean there is anything wrong with the code itself, for example missing + configuration file, etc... """ - def __init__(self, description): - self._description = description + def __init__(self, description, **params): + """ + Initialize new exception with given description and optional additional + parameters. + + :param str description: Description of the problem. + :param params: Optional additional parameters. + """ + super().__init__() + + self.description = description + self.params = params + def __str__(self): - return repr(self._description) + """ + Operator override for automatic string output. + """ + return repr(self.description) class ZenAppSetupException(ZenAppException): """ - Describes problems or errors during the script 'setup' phase. + Describes problems or errors that occur during the **setup** phase. """ pass class ZenAppProcessException(ZenAppException): """ - Describes problems or errors during the script 'process' phase. + Describes problems or errors that occur during the **process** phase. """ pass class ZenAppEvaluateException(ZenAppException): """ - Describes problems or errors during the script 'evaluate' phase. + Describes problems or errors that occur during the **evaluate** phase. """ pass class ZenAppTeardownException(ZenAppException): """ - Describes problems or errors during the script 'teardown' phase. + Describes problems or errors that occur during the **teardown** phase. """ pass -class BaseApp: + +#------------------------------------------------------------------------------- + + +class ZenAppPlugin: + """ + Base class for all ZenApp application plugins. Plugins can be used to further + enhance the code reusability by composing the application from smaller building + blocks. """ - Base implementation of generic executable script. - This class attempts to provide robust and stable framework, which can be used - to writing all kinds of scripts or daemons. This is however low level framework - and should not be used directly, use the zenscript.py or zendaemon.py - modules for writing custom scripts or daemons respectively. + def __str__(self): + """ + Operator override for automatic string output. + """ + return self.__class__.__name__ + + def init_argparser(self, app, argparser, **kwargs): + """ + Callback to be called during argparser initialization phase. + """ + return argparser + + def init_config(self, app, config, **kwargs): + """ + Callback to be called during default configuration initialization phase. + """ + return config + + def init_runlog(self, app, runlog, **kwargs): + """ + Callback to be called during runlog initialization phase. + """ + return runlog + + def configure(self, app): + """ + Callback to be called during configuration phase (after initialization). + """ + raise NotImplementedError('This method must be implemented in subclass') + + def setup(self, app): + """ + Callback to be called during setup phase (after setup). + """ + raise NotImplementedError('This method must be implemented in subclass') + + +#------------------------------------------------------------------------------- + + +class BaseApp: + """ + Base implementation of generic executable application. This class attempts to + provide robust and stable framework, which can be used to writing all kinds + of scripts or daemons. Although is is usable, this is however a low level framework + and should not be used directly, use the :py:mod:`pyzenkit.zenscript` or :py:mod:`pyzenkit.zendaemon` + modules for writing custom scripts or daemons respectively. That being said, + the :py:class:`pyzenkit.baseapp.DemoBaseApp` class is an example implementation + of using this class directly without any additional overhead. """ - # List of all possible return codes + # + # Class constants. + # + + # Global flag, that turns on additional debugging messages. + FLAG_DEBUG = False + + # List of all possible return codes. RC_SUCCESS = os.EX_OK RC_FAILURE = 1 @@ -117,10 +389,11 @@ class BaseApp: RESULT_SUCCESS = 'success' RESULT_FAILURE = 'failure' - # String patterns + # String patterns. PTRN_ACTION_CBK = 'cbk_action_' + PTRN_APP_NAME = '^[_a-zA-Z][-_a-zA-Z0-9.]*$' - # Paths + # Paths. PATH_BIN = 'bin' PATH_CFG = 'cfg' PATH_LOG = 'log' @@ -130,8 +403,8 @@ class BaseApp: # List of core configuration keys. CORE = '__core__' CORE_LOGGING = 'logging' - CORE_LOGGING_TOFILE = 'tofile' - CORE_LOGGING_TOCONS = 'toconsole' + CORE_LOGGING_TOFILE = 'to_file' + CORE_LOGGING_TOCONS = 'to_console' CORE_LOGGING_LEVEL = 'level' CORE_LOGGING_LEVELF = 'level_file' CORE_LOGGING_LEVELC = 'level_console' @@ -140,12 +413,15 @@ class BaseApp: CORE_RUNLOG = 'runlog' CORE_RUNLOG_SAVE = 'save' - # List of possible configuration keys. + # List of configuration keys. + CONFIG_PLUGINS = 'plugins' CONFIG_DEBUG = 'debug' CONFIG_QUIET = 'quiet' CONFIG_VERBOSITY = 'verbosity' CONFIG_RUNLOG_DUMP = 'runlog_dump' CONFIG_PSTATE_DUMP = 'pstate_dump' + CONFIG_RUNLOG_LOG = 'runlog_log' + CONFIG_PSTATE_LOG = 'pstate_log' CONFIG_NAME = 'name' CONFIG_ACTION = 'action' CONFIG_INPUT = 'input' @@ -159,20 +435,19 @@ class BaseApp: CONFIG_PSTATE_FILE = 'pstate_file' CONFIG_RUNLOG_DIR = 'runlog_dir' - # Runlog keys - RLKEY_NAME = 'name' - RLKEY_PID = 'pid' - RLKEY_ARGV = 'argv' - RLKEY_COMMAND = 'command' - RLKEY_TS = 'ts' - RLKEY_TSFSF = 'ts_fsf' - RLKEY_TSSTR = 'ts_str' - RLKEY_RESULT = 'result' - RLKEY_RC = 'rc' - RLKEY_ERRORS = 'errors' - RLKEY_TMARKS = 'time_marks' - - # Runlog analysis keys + # List of runlog keys. + RLKEY_NAME = 'name' # Application name. + RLKEY_PID = 'pid' # Application process PID. + RLKEY_ARGV = 'argv' # Application command line arguments. + RLKEY_TS = 'ts' # Timestamp as float. + RLKEY_TSFSF = 'ts_fsf' # Timestamp as sortable string (usefull for generating sortable file names). + RLKEY_TSSTR = 'ts_str' # Timestamp as readable string. + RLKEY_RESULT = 'result' # Result as a string. + RLKEY_RC = 'rc' # Result as numeric return code. + RLKEY_ERRORS = 'errors' # List of arrors during execution. + RLKEY_TMARKS = 'time_marks' # Time measuring marks. + + # List of runlog analysis keys. RLANKEY_LABEL = 'label' RLANKEY_COMMAND = 'command' RLANKEY_AGE = 'age' @@ -185,177 +460,206 @@ class BaseApp: RLANKEY_DURATIONS = 'durations' RLANKEY_EFFECTIVITY = 'effectivity' - # Runlog evaluation keys + # List of runlog evaluation keys. RLEVKEY_ANALYSES = 'analyses' + + #--------------------------------------------------------------------------- + # "__INIT__" STAGE METHODS. + #--------------------------------------------------------------------------- + + def __init__(self, **kwargs): """ - Default script object constructor. + Base application object constructor. Only defines core internal variables. + The actual object initialization, during which command line arguments and + configuration files are parsed, is done during the configure() stage of + the run() sequence. - Only defines core internal variables. The actual object initialization, - during which command line arguments and configuration files are parsed, - is done during the configure() stage of the run() sequence. + :param kwargs: Various additional parameters. """ - # [PUBLIC] Default script help description. - self.description = kwargs.get('description', 'BaseApp - Simple generic script') + # Initialize list of desired plugins. + self._plugins = kwargs.get(self.CONFIG_PLUGINS, []) + + # [PUBLIC] Default application help description. + self.description = kwargs.get('description', 'BaseApp - Generic application') # [PUBLIC] Initialize command line argument parser. self.argparser = self._init_argparser(**kwargs) # Parse CLI arguments immediatelly, we need to check for a few priority - # flags and switches - self._config_cli = self._parse_cli_arguments() + # flags and switches. + self._config_cli = self._parse_cli_arguments(self.argparser) + self._config_file = None + self._config_dir = None - # [PUBLIC] Detect name of the script. + # [PUBLIC] Detect name of the application. self.name = self._init_name(**kwargs) - # [PUBLIC] Script paths. + # [PUBLIC] Script paths, will be used to construct various absolute file paths. self.paths = self._init_paths(**kwargs) # [PUBLIC] Script processing runlog. self.runlog = self._init_runlog(**kwargs) - # [PUBLIC] Storage for script configurations. - self.config = self._init_config(**kwargs) - # [PUBLIC] Logger object. + # [PUBLIC] Storage for application configurations. + self.config = self._init_config((), **kwargs) + # [PUBLIC] Internal logger object. self.logger = None # [PUBLIC] Persistent state object. self.pstate = None # [PUBLIC] Final return code. - self.rc = self.RC_SUCCESS + self.retc = self.RC_SUCCESS # Perform subinitializations on default configurations and argument parser. - self._init_custom(self.config, self.argparser, **kwargs) + self._sub_stage_init(**kwargs) - def __del__(self): - """ - Default script object destructor. Perform generic cleanup. + def _init_argparser(self, **kwargs): """ - pass + Initialize application command line argument parser. This method may be overriden + in subclasses, however it must return valid :py:class:`argparse.ArgumentParser` + object. - #--------------------------------------------------------------------------- - # Object initialization helper methods - #--------------------------------------------------------------------------- + Gets called from main constructor :py:func:`BaseApp.__init__`. - def _init_argparser(self, **kwargs): - """ - Initialize script command line argument parser. + :param kwargs: Various additional parameters passed down from constructor. + :return: Initialized argument parser object. + :rtype: argparse.ArgumentParser """ argparser = argparse.ArgumentParser(description = self.description) - # Option flag indicating that script is running in debug mode. This option + # Option flag indicating that application is running in debug mode. This option # will enable displaying of additional helpful debugging messages. The # messages will be printed directly to terminal, without the use of # logging framework. argparser.add_argument('--debug', help = 'run in debug mode (flag)', action = 'store_true', default = None) - # Option flag indicating that script is running in quiet mode. This option - # will prevent script from displaying information to console. - argparser.add_argument('--quiet', help = 'run in quiet mode (flag)', action = 'store_true', default = None) - - # Option for setting the output verbosity level. - argparser.add_argument('--verbosity', help = 'increase output verbosity', action = 'count', default = None) - - # Option flag indicating that the script should dump the runlog to logger, - # when the processing is done. - argparser.add_argument('--runlog-dump', help = 'dump runlog when done processing (flag)', action = 'store_true', default = None) - - # Option flag indicating that the script should dump the persistent state to logger, - # when the processing is done. - argparser.add_argument('--pstate-dump', help = 'dump persistent state when done processing (flag)', action = 'store_true', default = None) - - # Option for overriding the name of the component. - argparser.add_argument('--name', help = 'name of the component') - - # Option for setting the desired action. - argparser.add_argument('--action', help = 'choose which action should be performed', choices = self._utils_detect_actions()) + # Setup mutually exclusive group for quiet x verbose mode option. + group_a = argparser.add_mutually_exclusive_group() - # Option for setting the desired operation. - argparser.add_argument('--input', help = 'file to be used as source file in action') + # Option flag indicating that application is running in quiet mode. This option + # will prevent application from displaying any information to console. + group_a.add_argument('--quiet', help = 'run in quiet mode (flag)', action = 'store_true', default = None) - # Option for setting the result limit. - argparser.add_argument('--limit', help = 'apply given limit to the result', type = int) - - # Option for overriding the process UID. - argparser.add_argument('--user', help = 'process UID or user name') - - # Option for overriding the process GID. - argparser.add_argument('--group', help = 'process GID or group name') + # Option for setting the output verbosity level. + group_a.add_argument('--verbose', help = 'increase output verbosity (flag, repeatable)', action = 'count', default = None, dest = 'verbosity') - # Option for overriding the name of the configuration file. - argparser.add_argument('--config-file', help = 'name of the config file') + # + # Create and populate options group for common application arguments. + # + arggroup_common = argparser.add_argument_group('common application arguments') - # Option for overriding the name of the configuration directory. - argparser.add_argument('--config-dir', help = 'name of the config directory') + arggroup_common.add_argument('--name', help = 'name of the application', type = str, default = None) + arggroup_common.add_argument('--user', help = 'process UID or user name', default = None) + arggroup_common.add_argument('--group', help = 'process GID or group name', default = None) - # Option for overriding the name of the log file. - argparser.add_argument('--log-file', help = 'name of the log file') + arggroup_common.add_argument('--config-file', help = 'name of the configuration file', type = str, default = None) + arggroup_common.add_argument('--config-dir', help = 'name of the configuration directory', type = str, default = None) + arggroup_common.add_argument('--log-file', help = 'name of the log file', type = str, default = None) + arggroup_common.add_argument('--log-level', help = 'set logging level', choices = ['debug', 'info', 'warning', 'error', 'critical'], type = str, default = None) - # Option for setting the level of logging information. - argparser.add_argument('--log-level', help = 'set logging level', choices = ['debug', 'info', 'warning', 'error', 'critical']) + arggroup_common.add_argument('--action', help = 'name of the quick action to be performed', choices = self._utils_detect_actions(), type = str, default = None) + arggroup_common.add_argument('--input', help = 'file to be used as source file in action', type = str, default = None) + arggroup_common.add_argument('--limit', help = 'apply given limit to the result', type = int, default = None) - # Option for overriding the name of the persistent state file. - argparser.add_argument('--pstate-file', help = 'name of the persistent state file') + arggroup_common.add_argument('--pstate-file', help = 'name of the persistent state file', type = str, default = None) + arggroup_common.add_argument('--pstate-dump', help = 'dump persistent state to stdout when done processing (flag)', action = 'store_true', default = None) + arggroup_common.add_argument('--pstate-log', help = 'write persistent state to logging service when done processing (flag)', action = 'store_true', default = None) - # Option for overriding the name of the runlog directory. - argparser.add_argument('--runlog-dir', help = 'name of the runlog directory') + arggroup_common.add_argument('--runlog-dir', help = 'name of the runlog directory', type = str, default = None) + arggroup_common.add_argument('--runlog-dump', help = 'dump runlog to stdout when done processing (flag)', action = 'store_true', default = None) + arggroup_common.add_argument('--runlog-log', help = 'write runlog to logging service when done processing (flag)', action = 'store_true', default = None) #argparser.add_argument('args', help = 'optional additional arguments', nargs='*') + for plugin in self._plugins: + argparser = plugin.init_argparser(self, argparser, **kwargs) + return argparser - def _parse_cli_arguments(self): + def _parse_cli_arguments(self, argparser): """ - Load and initialize script configuration received from command line. + Load and initialize application configuration received as command line arguments. + Use the previously configured ;py:class:`argparse.ArgumentParser` object + for parsing CLI arguments. Immediatelly perform dirty check for ``--debug`` + flag to turn on debug output as soon as possible. + + Gets called from main constructor :py:func:`BaseApp.__init__`. - Use the configured ArgumentParser object for parsing CLI arguments. + :param argparse.ArgumentParser argparser: Argument parser object to use. + :return: Parsed command line arguments. + :rtype: dict """ - # Finally actually process command line arguments. - cli_args = vars(self.argparser.parse_args()) + # Actually process command line arguments. + cli_cfgs = vars(argparser.parse_args()) - # Check for debug flag - if cli_args.get(self.CONFIG_DEBUG, False): - global FLAG_DEBUG - FLAG_DEBUG = True - self.dbgout("[STATUS] FLAG_DEBUG set to 'True' via command line argument") + # Immediatelly check for debug flag. + if cli_cfgs.get(self.CONFIG_DEBUG, False): + BaseApp.FLAG_DEBUG = True + self.dbgout("FLAG_DEBUG set to 'True' via command line option") - self.dbgout("[STATUS] Parsed command line arguments: '{}'".format(' '.join(sys.argv))) - return cli_args + self.dbgout("Received command line arguments: '{}'".format(' '.join(sys.argv))) + return cli_cfgs def _init_name(self, **kwargs): """ - Initialize script name. + Initialize application name. The application name will then be used to + autogenerate default paths to various files and directories, like log + file, persistent state file etc. The default value for application name + is automagically detected from command line, or it may be explicitly set + either using command line option ``--name``, or by using parameter ``name`` + of application object constructor. + + Gets called from main constructor :py:func:`BaseApp.__init__`. + + :param kwargs: Various additional parameters passed down from constructor. + :return: Name of the application. + :rtype: str """ cli_name = self._config_cli.get(self.CONFIG_NAME) if cli_name: - if re.fullmatch('^[_a-zA-Z][-_a-zA-Z0-9.]*$', cli_name): - self.dbgout("[STATUS] Using custom script name '{}".format(cli_name)) + if re.fullmatch(self.PTRN_APP_NAME, cli_name): + self.dbgout("Using custom application name '{}' received as command line option".format(cli_name)) return cli_name - else: - raise ZenAppException("Invalid script name '{}'. Valid pattern is '^[a-zA-Z][-_a-zA-Z0-9]*$'".format(cli_name)) - elif 'name' in kwargs: - if re.fullmatch('^[_a-zA-Z][-_a-zA-Z0-9.]*$', kwargs['name']): - self.dbgout("[STATUS] Using custom script name '{}".format(kwargs['name'])) - return kwargs['name'] - else: - raise ZenAppException("Invalid script name '{}'. Valid pattern is '^[a-zA-Z][-_a-zA-Z0-9]*$'".format(cli_name)) - else: - scr_name = os.path.basename(sys.argv[0]) - self.dbgout("[STATUS] Using default script name '{}".format(scr_name)) - return scr_name + raise ZenAppException("Invalid application name '{}'. Valid pattern is '{}'".format(cli_name, self.PTRN_APP_NAME)) + + if self.CONFIG_NAME in kwargs: + if re.fullmatch(self.PTRN_APP_NAME, kwargs[self.CONFIG_NAME]): + self.dbgout("Using custom application name '{}' received as constructor option".format(kwargs[self.CONFIG_NAME])) + return kwargs[self.CONFIG_NAME] + raise ZenAppException("Invalid application name '{}'. Valid pattern is '{}'".format(cli_name, self.PTRN_APP_NAME)) + + app_name = os.path.basename(sys.argv[0]) + self.dbgout("Using default application name '{}".format(app_name)) + return app_name def _init_paths(self, **kwargs): """ - Initialize various script paths. + Initialize various application filesystem paths like temp directory, log + directory etc. These values will when be used to autogenerate default paths + to various files and directories, like log file, persistent state file etc. + + Gets called from main constructor :py:func:`BaseApp.__init__`. + + :param kwargs: Various additional parameters passed down from constructor. + :return: Configurations for various filesystem paths. + :rtype: dict """ return { - self.PATH_BIN: kwargs.get('path_bin', "/usr/local/bin"), # Application executable directory. - self.PATH_CFG: kwargs.get('path_cfg', "/etc"), # Configuration directory. - self.PATH_LOG: kwargs.get('path_log', "/var/log"), # Log directory. - self.PATH_RUN: kwargs.get('path_run', "/var/run"), # Runlog directory. - self.PATH_TMP: kwargs.get('path_tmp', "/var/tmp"), # Temporary file directory. + self.PATH_BIN: kwargs.get('path_{}'.format(self.PATH_BIN), "/usr/local/bin"), # Application executable directory. + self.PATH_CFG: kwargs.get('path_{}'.format(self.PATH_CFG), "/etc"), # Configuration directory. + self.PATH_LOG: kwargs.get('path_{}'.format(self.PATH_LOG), "/var/log"), # Log directory. + self.PATH_RUN: kwargs.get('path_{}'.format(self.PATH_RUN), "/var/run"), # Runlog directory. + self.PATH_TMP: kwargs.get('path_{}'.format(self.PATH_TMP), "/var/tmp"), # Temporary file directory. } def _init_runlog(self, **kwargs): """ - Initialize script runlog. + Initialize application runlog. Runlog should contain vital information about + application progress and it will be stored into file upon exit. + + Gets called from main constructor :py:func:`BaseApp.__init__`. + + :param kwargs: Various additional parameters passed down from constructor. + :return: Runlog structure. + :rtype: dict """ runlog = { self.RLKEY_NAME: self.name, @@ -371,11 +675,23 @@ class BaseApp: # Timestamp as readable string. runlog[self.RLKEY_TSSTR] = time.strftime('%Y-%m-%d %X', time.localtime(runlog[self.RLKEY_TS])) + for plugin in self._plugins: + runlog = plugin.init_runlog(self, runlog, **kwargs) + return runlog - def _init_config(self, **kwargs): + def _init_config(self, cfgs, **kwargs): """ - Initialize script configurations to default values. + Initialize default application configurations. This method may be used + from subclasses by passing any additional configurations in ``cfgs`` + parameter. + + Gets called from main constructor :py:func:`BaseApp.__init__`. + + :param list cfgs: Additional set of configurations. + :param kwargs: Various additional parameters passed down from constructor. + :return: Default configuration structure. + :rtype: dict """ cfgs = ( (self.CONFIG_DEBUG, False), @@ -383,6 +699,8 @@ class BaseApp: (self.CONFIG_VERBOSITY, 0), (self.CONFIG_RUNLOG_DUMP, False), (self.CONFIG_PSTATE_DUMP, False), + (self.CONFIG_RUNLOG_LOG, False), + (self.CONFIG_PSTATE_LOG, False), (self.CONFIG_ACTION, None), (self.CONFIG_INPUT, None), (self.CONFIG_LIMIT, None), @@ -394,286 +712,276 @@ class BaseApp: (self.CONFIG_LOG_LEVEL, 'info'), (self.CONFIG_PSTATE_FILE, os.path.join(self.paths.get(self.PATH_RUN), "{}.pstate".format(self.name))), (self.CONFIG_RUNLOG_DIR, os.path.join(self.paths.get(self.PATH_RUN), "{}".format(self.name))), - ) + ) + cfgs config = {} - for c in cfgs: - config[c[0]] = kwargs.get('default_' + c[0], c[1]) + for cfg in cfgs: + config[cfg[0]] = kwargs.get('default_{}'.format(cfg[0]), cfg[1]) + + for plugin in self._plugins: + config = plugin.init_config(self, config, **kwargs) + return config - def _init_custom(self, config, argparser, **kwargs): - """ - Perform subinitializations on default configurations and argument parser. - """ - pass #--------------------------------------------------------------------------- - # Template method hooks (intended to be used by subclassess) + # TEMPLATE METHOD HOOKS (INTENDED TO BE USED BY SUBCLASSESS). #--------------------------------------------------------------------------- - def _sub_runlog_analyze(self, runlog, analysis): - """ - Analyze given runlog (hook for subclasses). - """ - return analysis - def _sub_runlog_format_analysis(self, analysis): + def _sub_stage_init(self, **kwargs): """ - Format given runlog analysis (hook for subclasses). + **SUBCLASS HOOK**: Perform additional custom initialization actions in **__init__** stage. + + :param kwargs: Various additional parameters passed down from constructor. """ pass - def _sub_runlogs_evaluate(self, runlogs, evaluation): + def _sub_stage_setup(self): """ - Evaluate given runlog analyses (hook for subclasses). - """ - return evaluation + **SUBCLASS HOOK**: Perform additional custom setup actions in **setup** stage. - def _sub_runlogs_format_evaluation(self, evaluation): - """ - Format given runlogs evaluation (hook for subclasses). + Gets called from :py:func:`BaseApp._stage_setup` and it is a **SETUP SUBSTAGE 06**. """ pass - #--------------------------------------------------------------------------- - # Helpers and shortcut methods - #--------------------------------------------------------------------------- - - def get_fn_runlog(self): + def _sub_stage_process(self): """ - Return the name of the runlog file for current process. + **SUBCLASS HOOK**: Perform some actual processing in **process** stage. """ - return os.path.join(self.c(self.CONFIG_RUNLOG_DIR), "{}.runlog".format(self.runlog[self.RLKEY_TSFSF])) + raise NotImplementedError("Method must be implemented in subclass") - def get_fn_pstate(self): - """ - Return the name of the persistent state file for current process. + def _sub_stage_teardown(self): """ - return self.c(self.CONFIG_PSTATE_FILE) + **SUBCLASS HOOK**: Perform additional teardown actions in **teardown** stage. - def c(self, key, default = None): + Gets called from :py:func:`BaseApp._stage_teardown` and it is a **TEARDOWN SUBSTAGE 01**. """ - Shortcut method: Get given configuration value. - """ - if default is None: - return self.config.get(key) - else: - return self.config.get(key, default) + pass - def cc(self, key, default = None): + def _sub_runlog_analyze(self, runlog, analysis): """ - Shortcut method: Get given CORE configuration value. + **SUBCLASS HOOK**: Analyze given runlog. """ - if default is None: - return self.config[self.CORE].get(key) - else: - return self.config[self.CORE].get(key, default) + return analysis - def p(self, string, level = 0): + def _sub_runlog_format_analysis(self, analysis): """ - Shortcut method: Print given string. + **SUBCLASS HOOK**: Format given runlog analysis. """ - if not self.c(self.CONFIG_QUIET) and self.c(self.CONFIG_VERBOSITY) >= level: - print(string) + pass - def error(self, error, rc = None, tb = None): + def _sub_runlogs_evaluate(self, runlogs, evaluation): """ - Method for registering error, that occured during script run. + **SUBCLASS HOOK**: Evaluate given runlog analyses. """ - self.rc = rc if rc is not None else self.RC_FAILURE - - errstr = "{}".format(error) - self.logger.error(errstr) - - if tb: - tbexc = traceback.format_tb(tb) - self.logger.error("\n" + "".join(tbexc)) - - self.runlog[self.RLKEY_ERRORS].append(errstr) - self.runlog[self.RLKEY_RESULT] = self.RESULT_FAILURE - self.runlog[self.RLKEY_RC] = self.rc + return evaluation - def dbgout(self, message): + def _sub_runlogs_format_evaluation(self, evaluation): """ - Routine for printing additional debug messages. - - The given message is printed only in case the global 'FLAG_DEBUG' flag is - set to True. + **SUBCLASS HOOK**: Format given runlogs evaluation. """ - if FLAG_DEBUG: - print("*DBG* {} {}".format(time.strftime('%Y-%m-%d %X', time.localtime()), message), file=sys.stderr) + pass - def errout(self, exception): - """ - Routine for printing error messages. - """ - print("*ERR* {} {}".format(time.strftime('%Y-%m-%d %X', time.localtime()), exception), file=sys.stderr) - sys.exit(self.RC_FAILURE) #--------------------------------------------------------------------------- - # Internal utilities + # "SETUP:CONFIGURATION" SUBSTAGE METHODS. #--------------------------------------------------------------------------- - def _utils_detect_actions(self): - """ - Returns the sorted list of all available actions current script is capable - of performing. The detection algorithm is based on string analysis of all - available methods. Any method starting with string 'cbk_action_' will - be appended to the list, lowercased and with '_' characters replaced with '-'. - """ - ptrn = re.compile(self.PTRN_ACTION_CBK) - attrs = sorted(dir(self)) - result = [] - for a in attrs: - if not callable(getattr(self, a)): - continue - match = ptrn.match(a) - if match: - result.append(a.replace(self.PTRN_ACTION_CBK,'').replace('_','-').lower()) - return result - #--------------------------------------------------------------------------- - # CONFIGURATION RELATED METHODS (SETUP CONFIGURATION SUBSTAGE) - #--------------------------------------------------------------------------- - - def _configure_cli(self): + def _configure_from_cli(self): """ - Load and initialize script configuration received from command line. + Process application configurations received from command line. It would be + much cleaner to do the parsing in this method. However the arguments had + to be already parsed and loaded, because we needed to hack the process to + check for ``--debug`` option to turn the debug flag on and enable output + of debug messages. So only task this method has is to perform some additional + processing. The presence of ``--config-file`` or ``--config-dir`` options + will cause the appropriate default values to be overwritten. This way an + alternative configuration file or directory will be loaded in next step. - Use the configured ArgumentParser object for parsing CLI arguments. + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. """ # IMPORTANT! Immediatelly rewrite the default value for configuration file - # and configuration directory names, if the value was received as command - # line argument. + # names, if the value was received as command line argument. if self._config_cli[self.CONFIG_CFG_FILE] is not None: - self.dbgout("[STATUS] Config file option override from '{}' to '{}'".format(self.config[self.CONFIG_CFG_FILE], self._config_cli[self.CONFIG_CFG_FILE])) self.config[self.CONFIG_CFG_FILE] = self._config_cli[self.CONFIG_CFG_FILE] + self.dbgout("Config file name overridden from '{}' to '{}' by command line option".format(self.config[self.CONFIG_CFG_FILE], self._config_cli[self.CONFIG_CFG_FILE])) + + # IMPORTANT! Immediatelly rewrite the default value for configuration file + # names, if the value was received as command line argument. if self._config_cli[self.CONFIG_CFG_DIR] is not None: - self.dbgout("[STATUS] Config directory option override from '{}' to '{}'".format(self.config[self.CONFIG_CFG_DIR], self._config_cli[self.CONFIG_CFG_DIR])) self.config[self.CONFIG_CFG_DIR] = self._config_cli[self.CONFIG_CFG_DIR] + self.dbgout("Config directory name overridden from '{}' to '{}' by command line option".format(self.config[self.CONFIG_CFG_DIR], self._config_cli[self.CONFIG_CFG_DIR])) - return self._config_cli - - def _configure_file(self): + def _configure_from_file(self): """ - Load and initialize script configuration received from configuration file. + Load and initialize application configurations from single configuration file. + + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. """ try: self._config_file = pyzenkit.jsonconf.config_load(self.c(self.CONFIG_CFG_FILE)) - self.dbgout("[STATUS] Loaded configuration file '{}'".format(self.c(self.CONFIG_CFG_FILE))) + self.dbgout("Loaded contents of configuration file '{}'".format(self.c(self.CONFIG_CFG_FILE))) + except FileNotFoundError as exc: raise ZenAppSetupException("Configuration file '{}' does not exist".format(self.c(self.CONFIG_CFG_FILE))) - def _configure_dir(self): + def _configure_from_dir(self): """ - Load and initialize script configuration received from configuration directory. + Load and initialize application configurations from multiple files in + configuration directory. + + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. """ try: self._config_dir = pyzenkit.jsonconf.config_load_dir(self.c(self.CONFIG_CFG_DIR)) - self.dbgout("[STATUS] Loaded configuration directory '{}'".format(self.c(self.CONFIG_CFG_DIR))) + self.dbgout("Loaded contents of configuration directory '{}'".format(self.c(self.CONFIG_CFG_DIR))) + except FileNotFoundError as exc: raise ZenAppSetupException("Configuration directory '{}' does not exist".format(self.c(self.CONFIG_CFG_DIR))) def _configure_merge(self): """ - Configure script and produce final configuration by merging all available + Configure application and produce final configuration by merging all available configuration values in appropriate order ('default' <= 'dir' <= 'file' <= 'cli'). + + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. """ + exceptions = (self.CONFIG_CFG_FILE, self.CONFIG_CFG_DIR) + # Merge configuration directory values with current config, if possible. if self.c(self.CONFIG_CFG_DIR, False): - self.dbgout("[STATUS] Merging global config with DIRECTORY configurations") - self.config.update((k, v) for k, v in self._config_dir.items() if v is not None) + self.config.update((key, val) for key, val in self._config_dir.items() if val is not None and key not in exceptions) + self.dbgout("Merged directory configurations into global configurations") # Merge configuration file values with current config, if possible. if self.c(self.CONFIG_CFG_FILE, False): - self.dbgout("[STATUS] Merging global config with FILE configurations") - self.config.update((k, v) for k, v in self._config_file.items() if v is not None) + self.config.update((key, val) for key, val in self._config_file.items() if val is not None and key not in exceptions) + self.dbgout("Merged file configurations into global configurations") # Merge command line values with current config, if possible. - self.dbgout("[STATUS] Merging global config with CLI configurations") - self.config.update((k, v) for k, v in self._config_cli.items() if v is not None) + self.config.update((key, val) for key, val in self._config_cli.items() if val is not None) + self.dbgout("Merged command line configurations into global configurations") def _configure_postprocess(self): """ - Perform configuration postprocessing. + Perform configuration postprocessing and calculate core configurations. + + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. """ + # Always mstart with a clean slate. self.config[self.CORE] = {} - cc = {} - cc[self.CORE_LOGGING_TOCONS] = True - cc[self.CORE_LOGGING_TOFILE] = True - cc[self.CORE_LOGGING_LEVEL] = self.c(self.CONFIG_LOG_LEVEL).upper() - cc[self.CORE_LOGGING_LEVELF] = cc[self.CORE_LOGGING_LEVEL] - cc[self.CORE_LOGGING_LEVELC] = cc[self.CORE_LOGGING_LEVEL] - self.config[self.CORE][self.CORE_LOGGING] = cc - - cc = {} - cc[self.CORE_PSTATE_SAVE] = True - self.config[self.CORE][self.CORE_PSTATE] = cc - - cc = {} - cc[self.CORE_RUNLOG_SAVE] = True - self.config[self.CORE][self.CORE_RUNLOG] = cc - + # Initial logging configurations. + ccfg = {} + ccfg[self.CORE_LOGGING_TOCONS] = True + ccfg[self.CORE_LOGGING_TOFILE] = True + ccfg[self.CORE_LOGGING_LEVEL] = self.c(self.CONFIG_LOG_LEVEL).upper() + ccfg[self.CORE_LOGGING_LEVELF] = ccfg[self.CORE_LOGGING_LEVEL] + ccfg[self.CORE_LOGGING_LEVELC] = ccfg[self.CORE_LOGGING_LEVEL] + self.config[self.CORE][self.CORE_LOGGING] = ccfg + + # Initial persistent configurations. + ccfg = {} + ccfg[self.CORE_PSTATE_SAVE] = True + self.config[self.CORE][self.CORE_PSTATE] = ccfg + + # Initial runlog configurations. + ccfg = {} + ccfg[self.CORE_RUNLOG_SAVE] = True + self.config[self.CORE][self.CORE_RUNLOG] = ccfg + + # Postprocess user account configurations, when necessary. if self.config[self.CONFIG_USER]: - u = self.config[self.CONFIG_USER] + usa = self.config[self.CONFIG_USER] res = None if not res: try: - res = pwd.getpwnam(u) + res = pwd.getpwnam(usa) self.config[self.CONFIG_USER] = [res[0], res[2]] except: pass if not res: try: - res = pwd.getpwuid(int(u)) + res = pwd.getpwuid(int(usa)) self.config[self.CONFIG_USER] = [res[0], res[2]] except: pass if not res: - raise ZenAppSetupException("Unknown user account '{}'".format(u)) + raise ZenAppSetupException("Requested unknown user account '{}'".format(usa)) + self.dbgout("System user account will be set to '{}':'{}'".format(res[0], res[2])) + + # Postprocess group account configurations, when necessary. if self.config[self.CONFIG_GROUP]: - g = self.config[self.CONFIG_GROUP] + gra = self.config[self.CONFIG_GROUP] res = None if not res: try: - res = grp.getgrnam(g) + res = grp.getgrnam(gra) self.config[self.CONFIG_GROUP] = [res[0], res[2]] except: pass if not res: try: - res = grp.getgrgid(int(g)) + res = grp.getgrgid(int(gra)) self.config[self.CONFIG_GROUP] = [res[0], res[2]] except: pass if not res: - raise ZenAppSetupException("Unknown group account '{}'".format(g)) + raise ZenAppSetupException("Requested unknown group account '{}'".format(gra)) + + self.dbgout("System group account will be set to '{}':'{}'".format(res[0], res[2])) + + def _configure_plugins(self): + """ + Perform configurations of all application plugins. + + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. + """ + for plugin in self._plugins: + self.dbgout("Configuring application plugin '{}'".format(plugin)) + plugin.configure(self) def _configure_check(self): """ - TODO: Implement config checking mechanism. + Perform configuration validation and checking. + + Gets called from :py:func:`BaseApp._stage_setup_configuration` and is + therefore part of **SETUP** stage. + + .. todo:: + + Missing implementation, work in progress. """ pass #--------------------------------------------------------------------------- - # "SETUP" STAGE RELATED METHODS + # "SETUP" STAGE METHODS. #--------------------------------------------------------------------------- def _stage_setup_configuration(self): """ - Setup script configurations. + **SETUP SUBSTAGE 01:** Setup application configurations. + + Gets called from :py:func:`BaseApp._stage_setup`. """ # Load configurations from command line. - self._configure_cli() + self._configure_from_cli() # Load configurations from config file, if the appropriate feature is enabled. if self.c(self.CONFIG_CFG_FILE, False): - self._configure_file() + self._configure_from_file() # Load configurations from config directory, if the appropriate feature is enabled. if self.c(self.CONFIG_CFG_DIR, False): - self._configure_dir() + self._configure_from_dir() # Merge all available configurations together with default. self._configure_merge() @@ -681,39 +989,47 @@ class BaseApp: # Postprocess loaded configurations self._configure_postprocess() + # Postprocess loaded configurations + self._configure_plugins() + # Check all loaded configurations. self._configure_check() def _stage_setup_privileges(self): """ - Adjust the script privileges according to the configration. + **SETUP SUBSTAGE 02:** Setup application privileges (user and group account). + + Gets called from :py:func:`BaseApp._stage_setup`. """ - g = self.c(self.CONFIG_GROUP, None) - if g and g[1] != os.getgid(): - cg = grp.getgrgid(os.getgid()) - self.dbgout("[STATUS] Dropping group privileges from '{}':'{}' to '{}':'{}'".format(cg[0], cg[2], g[0], g[1])) - os.setgid(g[1]) - u = self.c(self.CONFIG_USER, None) - if u and u[1] != os.getuid(): - cu = pwd.getpwuid(os.getuid()) - self.dbgout("[STATUS] Dropping user privileges from '{}':'{}' to '{}':'{}'".format(cu[0], cu[2], u[0], u[1])) - os.setuid(u[1]) + gra = self.c(self.CONFIG_GROUP, None) + if gra and gra[1] != os.getgid(): + cga = grp.getgrgid(os.getgid()) + self.dbgout("Dropping group privileges from '{}':'{}' to '{}':'{}'".format(cga[0], cga[2], gra[0], gra[1])) + os.setgid(gra[1]) + + usa = self.c(self.CONFIG_USER, None) + if usa and usa[1] != os.getuid(): + cua = pwd.getpwuid(os.getuid()) + self.dbgout("Dropping user privileges from '{}':'{}' to '{}':'{}'".format(cua[0], cua[2], usa[0], usa[1])) + os.setuid(usa[1]) def _stage_setup_logging(self): """ - Setup terminal and file logging facilities. + **SETUP SUBSTAGE 03:** Setup terminal and file logging facilities. + + Gets called from :py:func:`BaseApp._stage_setup`. """ cc = self.cc(self.CORE_LOGGING, {}) if cc[self.CORE_LOGGING_TOCONS] or cc[self.CORE_LOGGING_TOFILE]: # [PUBLIC] Register the logger object as internal attribute. - self.logger = logging.getLogger('zenlogger') + self.logger = logging.getLogger('zenapplogger') self.logger.setLevel(cc[self.CORE_LOGGING_LEVEL]) - # Setup console logging + # Setup console logging. if cc[self.CORE_LOGGING_TOCONS]: logging_level = getattr(logging, cc[self.CORE_LOGGING_LEVELC], None) if not isinstance(logging_level, int): - raise ValueError("Invalid log level: '{}'".format(cc[self.CORE_LOGGING_LEVELC])) + raise ValueError("Invalid log severity level '{}'".format(cc[self.CORE_LOGGING_LEVELC])) # Initialize console logging handler. fm1 = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') @@ -721,13 +1037,13 @@ class BaseApp: ch1.setLevel(logging_level) ch1.setFormatter(fm1) self.logger.addHandler(ch1) - self.dbgout("[STATUS] Logging to console with level threshold '{}'".format(cc[self.CORE_LOGGING_LEVELC])) + self.dbgout("Logging to console with severity threshold '{}'".format(cc[self.CORE_LOGGING_LEVELC])) # Setup file logging if cc[self.CORE_LOGGING_TOFILE]: logging_level = getattr(logging, cc[self.CORE_LOGGING_LEVELF], None) if not isinstance(logging_level, int): - raise ValueError("Invalid log level: '{}'".format(cc[self.CORE_LOGGING_LEVELF])) + raise ValueError("Invalid log severity level '{}'".format(cc[self.CORE_LOGGING_LEVELF])) lfn = self.c(self.CONFIG_LOG_FILE) fm2 = logging.Formatter('%(asctime)s {} [%(process)d] %(levelname)s: %(message)s'.format(self.name)) @@ -736,83 +1052,97 @@ class BaseApp: ch2.setLevel(logging_level) ch2.setFormatter(fm2) self.logger.addHandler(ch2) - self.dbgout("[STATUS] Logging to log file '{}' with level threshold '{}'".format(lfn, cc[self.CORE_LOGGING_LEVELF])) + self.dbgout("Logging to log file '{}' with severity threshold '{}'".format(lfn, cc[self.CORE_LOGGING_LEVELF])) def _stage_setup_pstate(self): """ - Setup persistent state. + **SETUP SUBSTAGE 04:** Setup persistent state from external JSON file. - Load persistent script state from external file (JSON). + Gets called from :py:func:`BaseApp._stage_setup`. """ if os.path.isfile(self.c(self.CONFIG_PSTATE_FILE)): - self.dbgout("[STATUS] Loading persistent state from file '{}'".format(self.c(self.CONFIG_PSTATE_FILE))) + self.dbgout("Loading persistent state from file '{}'".format(self.c(self.CONFIG_PSTATE_FILE))) self.pstate = self.json_load(self.c(self.CONFIG_PSTATE_FILE)) else: - self.dbgout("[STATUS] Setting default persistent state".format(self.c(self.CONFIG_PSTATE_FILE))) + self.dbgout("Setting default empty persistent state") self.pstate = {} - def _stage_setup_custom(self): + def _stage_setup_plugins(self): """ - Custom setup. + **SETUP SUBSTAGE 05:** Perform setup of all application plugins. + + Gets called from :py:func:`BaseApp._stage_setup`. """ - pass + for plugin in self._plugins: + self.dbgout("Setting-up application plugin '{}'".format(plugin)) + plugin.setup(self) def _stage_setup_dump(self): """ - Dump script setup information. + **SETUP SUBSTAGE 07:** Dump application setup information. This method will + display all vital information about application setup like filesystem paths, + configurations etc. - This method will display information about script system paths, configuration - loaded from CLI arguments or config file, final merged configuration. + Gets called from :py:func:`BaseApp._stage_setup`. """ - self.logger.debug("Script name detected as '{}'".format(self.name)) - self.logger.debug("System paths >>>\n{}".format(self.json_dump(self.paths, default=_json_default))) + self.logger.debug("Application name is '%s'", self.name) + self.logger.debug("System paths >>>\n%s", self.json_dump(self.paths)) if self.c(self.CONFIG_CFG_DIR, False): - self.logger.debug("Loaded DIRECTORY configurations >>>\n{}".format(self.json_dump(self._config_dir, default=_json_default))) + self.logger.debug("Loaded directory configurations >>>\n%s", self.json_dump(self._config_dir)) if self.c(self.CONFIG_CFG_FILE, False): - self.logger.debug("Loaded FILE configurations >>>\n{}".format(self.json_dump(self._config_file, default=_json_default))) - self.logger.debug("Loaded CLI configurations >>>\n{}".format(self.json_dump(self._config_cli, default=_json_default))) - self.logger.debug("Script configurations >>>\n{}".format(self.json_dump(self.config, default=_json_default))) - self.logger.debug("Loaded persistent state >>>\n{}".format(self.json_dump(self.pstate, default=_json_default))) + self.logger.debug("Loaded file configurations >>>\n%s", self.json_dump(self._config_file)) + self.logger.debug("Loaded command line configurations >>>\n%s", self.json_dump(self._config_cli)) + self.logger.debug("Final application configurations >>>\n%s", self.json_dump(self.config)) + self.logger.debug("Loaded persistent state >>>\n%s", self.json_dump(self.pstate)) + self.logger.debug("Application plugins >>>\n%s", self.json_dump(self._plugins)) + #--------------------------------------------------------------------------- - # "TEARDOWN" STAGE RELATED METHODS + # "TEARDOWN" STAGE METHODS. #--------------------------------------------------------------------------- - def _stage_teardown_custom(self): - """ - Custom teardown. - """ - pass def _stage_teardown_pstate(self): """ - Teardown state. + **TEARDOWN SUBSTAGE 02:** Save application persistent state into JSON file, dump + it to ``stdout`` or write it to logging service. - Save persistent script state to external file (JSON). + Gets called from :py:func:`BaseApp._stage_teardown`. """ if self.cc(self.CORE_PSTATE, {}).get(self.CORE_PSTATE_SAVE): - self.pstate_save(self.pstate) + self._utils_pstate_save(self.pstate) if self.c(self.CONFIG_PSTATE_DUMP): - self.pstate_dump(self.pstate) + self._utils_pstate_dump(self.pstate) + if self.c(self.CONFIG_PSTATE_LOG): + self._utils_pstate_log(self.pstate) def _stage_teardown_runlog(self): """ - Teardown runlog. + **TEARDOWN SUBSTAGE 03:** Save application runlog into JSON file, dump it to + ``stdout`` or write it to logging service. - Save runlog to external file (JSON) and dump runlog to log. + Gets called from :py:func:`BaseApp._stage_teardown`. """ if self.cc(self.CORE_RUNLOG, {}).get(self.CORE_RUNLOG_SAVE): - self.runlog_save(self.runlog) + self._utils_runlog_save(self.runlog) if self.c(self.CONFIG_RUNLOG_DUMP): - self.runlog_dump(self.runlog) + self._utils_runlog_dump(self.runlog) + if self.c(self.CONFIG_RUNLOG_LOG): + self._utils_runlog_log(self.runlog) + #--------------------------------------------------------------------------- - # MAIN STAGE METHODS + # MAIN STAGE METHODS. #--------------------------------------------------------------------------- - def stage_setup(self): + + def _stage_setup(self): """ - Script lifecycle stage: SETUP + **STAGE:** *SETUP*. + + Perform full application bootstrap. Any exception during this stage will + get caught, logged using :py:func:`~BaseApp.excout` method and the application + will immediatelly terminate. """ self.time_mark('stage_setup_start', 'Start of the setup stage') @@ -820,7 +1150,7 @@ class BaseApp: # Setup configurations. self._stage_setup_configuration() - # Setup script privileges + # Setup application privileges self._stage_setup_privileges() # Setup logging, if the appropriate feature is enabled. @@ -831,70 +1161,89 @@ class BaseApp: if self.c(self.CONFIG_PSTATE_FILE): self._stage_setup_pstate() - # Perform custom setup operations. - self._stage_setup_custom() + # Perform plugin setup actions. + self._stage_setup_plugins() - # Finally dump information about the script setup. + # Perform custom setup actions. + self._sub_stage_setup() + + # Finally dump information about the application setup. self._stage_setup_dump() except ZenAppSetupException as exc: - # At this point the logging facilities are not yet configured, so we must - # use other means of diplaying the error to the user. Use custom function - # to suppres the backtrace print for known issues and errors. - self.errout(exc) + # At this point the logging facilities may not yet be configured, so + # we must use other means of diplaying the error to the user. For that + # reason use custom function to supress the traceback print for known + # issues and errors. + self.excout(exc) self.time_mark('stage_setup_stop', 'End of the setup stage') - def stage_action(self): + def _stage_action(self): """ - Script lifecycle stage: ACTION + **STAGE:** *ACTION*. + + Perform a quick action. Following method will call appropriate callback + method to service the requested action. The application will immediatelly + terminate afterwards. - Perform some quick action. Following method will call appropriate - callback method to service the selected action. + Name of the callback method is calculated from the name of the action by + prepending string ``cbk_action_`` and replacing all ``-`` with ``_``. """ self.time_mark('stage_action_start', 'Start of the action stage') try: - # Determine, which operation to execute. + # Determine, which action to execute. self.runlog[self.CONFIG_ACTION] = self.c(self.CONFIG_ACTION) - opname = self.c(self.CONFIG_ACTION) - opcbkname = self.PTRN_ACTION_CBK + opname.lower().replace('-','_') + actname = self.c(self.CONFIG_ACTION) + actcbkname = self.PTRN_ACTION_CBK + actname.lower().replace('-','_') - cbk = getattr(self, opcbkname, None) + cbk = getattr(self, actcbkname, None) if cbk: + self.dbgout("Executing callback '{}' for action '{}'".format(actcbkname, actname)) cbk() else: - raise ZenAppProcessException("Invalid action '{}', callback '{}' does not exist".format(opname, opcbkname)) + raise ZenAppProcessException("Invalid action '{}', callback '{}' does not exist".format(actname, actcbkname)) except subprocess.CalledProcessError as err: self.error("System command error: {}".format(err)) except ZenAppProcessException as exc: - self.error("ZenAppProcessException: {}".format(exc)) + self.error("Action exception: {}".format(exc)) except ZenAppException as exc: - self.error("ZenAppException: {}".format(exc)) + self.error("Application exception: {}".format(exc)) self.time_mark('stage_action_stop', 'End of the action stage') - def stage_process(self): + def _stage_process(self): """ - Script lifecycle stage: PROCESSING + **STAGE:** *PROCESS*. - Perform some real work (finally). Following method will call appropriate - callback method operation to service the selected operation. + Finally perform some real work. This method will call :py:func:`_sub_stage_process` + hook, which must be implemented in subclass. """ - #self.time_mark('stage_process_start', 'Start of the processing stage') + self.time_mark('stage_process_start', 'Start of the processing stage') - raise Exception("stage_process() method must be implemented in subclass") + try: + self._sub_stage_process() - #self.time_mark('stage_process_stop', 'End of the processing stage') + except subprocess.CalledProcessError as err: + self.error("System command error: {}".format(err)) + + except ZenAppProcessException as exc: + self.error("Processing exception: {}".format(exc)) - def stage_evaluate(self): + except ZenAppException as exc: + self.error("Application exception: {}".format(exc)) + + self.time_mark('stage_process_stop', 'End of the processing stage') + + def _stage_evaluate(self): """ - Script lifecycle stage: EVALUATE + **STAGE:** *EVALUATE*. - Perform script runlog evaluation. + Perform application runlog postprocessing evaluation. """ self.time_mark('stage_evaluate_start', 'Start of the evaluation stage') @@ -902,23 +1251,25 @@ class BaseApp: pass except ZenAppEvaluateException as exc: - self.error("ZenAppEvaluateException: {}".format(exc)) + self.error("Evaluation exception: {}".format(exc)) + + except ZenAppException as exc: + self.error("Application exception: {}".format(exc)) self.time_mark('stage_evaluate_stop', 'End of the evaluation stage') - def stage_teardown(self): + def _stage_teardown(self): """ - Script lifecycle stage: TEARDOWN + **STAGE:** *TEARDOWN* Main teardown routine. This method will call the sequence of all configured teardown routines. """ try: - # Perform custom teardown operations. - self._stage_teardown_custom() + # Perform custom teardown actions. + self._sub_stage_teardown() - # Teardown persistent state, if the appropriate feature is enabled and - # also we are running in regular mode. + # Teardown persistent state. if self.c(self.CONFIG_PSTATE_FILE): self._stage_teardown_pstate() @@ -927,102 +1278,109 @@ class BaseApp: self._stage_teardown_runlog() except ZenAppTeardownException as exc: - self.error("ZenAppTeardownException: {}".format(exc)) + self.error("Teardown exception: {}".format(exc)) + + except ZenAppException as exc: + self.error("Application exception: {}".format(exc)) + #--------------------------------------------------------------------------- - # MAIN RUN METHODS + # APPLICATION MODE METHODS (MAIN RUN METHODS). #--------------------------------------------------------------------------- + def run(self): """ - Standalone script mode - Main processing method. + **APPLICATION MODE:** *Standalone application mode* - Main processing method. - Run as standalone script, performs all stages of script object life cycle: + Run as standalone application, performs all stages of object life cycle: 1. setup stage - 2.1 action processing stage - 2.2.1 script processing stage - 2.2.2 script evaluation stage - 2.2.3 script teardown stage + 2.1 action stage + 2.2.1 processing stage + 2.2.2 evaluation stage + 2.2.3 teardown stage """ - self.stage_setup() + self._stage_setup() if self.c(self.CONFIG_ACTION): - self.stage_action() + self._stage_action() else: - self.stage_process() - self.stage_evaluate() - self.stage_teardown() + self._stage_process() + self._stage_evaluate() + self._stage_teardown() - self.dbgout("[STATUS] Exiting with return code '{}'".format(self.rc)) - sys.exit(self.rc) + self.dbgout("Exiting with return code '{}'".format(self.retc)) + sys.exit(self.retc) def plugin(self): """ - Plugin mode - Main processing method. + **APPLICATION MODE:** *Plugin mode* - Main processing method. This method allows the object to be used as plugin within larger framework. Only the necessary setup is performed. """ - self.stage_setup() + self._stage_setup() + #--------------------------------------------------------------------------- - # BUILT-IN ACTION CALLBACK METHODS + # BUILT-IN ACTION CALLBACK METHODS. #--------------------------------------------------------------------------- + def cbk_action_config_view(self): """ - Parse and view script configurations. + **ACTION:** Parse and view application configurations. """ - print("Script configurations:") + self.p("Script configurations:") tree = pydgets.widgets.TreeWidget() - tree.display(self.config) + self.p(tree.render(self.config)) def cbk_action_runlog_dump(self): """ - Dump given script runlog. + **ACTION:** Dump given application runlog. """ rld = self.c(self.CONFIG_RUNLOG_DIR) input_file = self.c(self.CONFIG_INPUT, False) if not input_file: rlfn = os.path.join(rld, '*.runlog') runlog_files = sorted(glob.glob(rlfn), reverse = True) - if len(runlog_files): + if runlog_files: input_file = runlog_files.pop(0) else: - print("There are no runlog files") + self.p("There are no runlog files") return - print("Viewing script runlog '{}':".format(input_file)) + self.p("Viewing application runlog '{}':".format(input_file)) runlog = self.json_load(input_file) - print("") + self.p("") tree = pydgets.widgets.TreeWidget() - tree.display(runlog) + self.p(tree.render(runlog)) def cbk_action_runlog_view(self): """ - View details of given script runlog. + **ACTION:** View details of given application runlog. """ rld = self.c(self.CONFIG_RUNLOG_DIR) input_file = self.c(self.CONFIG_INPUT, False) if not input_file: rlfn = os.path.join(rld, '*.runlog') runlog_files = sorted(glob.glob(rlfn), reverse = True) - if len(runlog_files): + if runlog_files: input_file = runlog_files.pop(0) else: - print("There are no runlog files") + self.p("There are no runlog files") return - print("Viewing script runlog '{}':".format(input_file)) + self.p("Viewing application runlog '{}':".format(input_file)) runlog = self.json_load(input_file) - print("") + self.p("") analysis = self.runlog_analyze(runlog) self.runlog_format_analysis(analysis) def cbk_action_runlogs_list(self): """ - View the list of all available script runlogs. + **ACTION:** View list of all available application runlogs. """ rld = self.c(self.CONFIG_RUNLOG_DIR) limit = self.c(self.CONFIG_LIMIT) @@ -1032,18 +1390,18 @@ class BaseApp: for rlf in runlog_files: runlogtree[rld].append(rlf) - print("Listing script runlogs in directory '{}':".format(rld)) - print(" Runlog(s) found: {:,d}".format(rlcount)) + self.p("Listing application runlogs in directory '{}':".format(rld)) + self.p(" Runlog(s) found: {:,d}".format(rlcount)) if limit: - print(" Result limit: {:,d}".format(limit)) - if len(runlogtree[rld]): - print("") + self.p(" Result limit: {:,d}".format(limit)) + if runlogtree[rld]: + self.p("") tree = pydgets.widgets.TreeWidget() - tree.display(runlogtree) + self.p(tree.render(runlogtree)) def cbk_action_runlogs_dump(self): """ - View the list of all available script runlogs. + **ACTION:** View list of all available application runlogs. """ rld = self.c(self.CONFIG_RUNLOG_DIR) limit = self.c(self.CONFIG_LIMIT) @@ -1052,20 +1410,20 @@ class BaseApp: for rlf in runlog_files: runlogs.append((rlf, self.json_load(rlf))) - print("Dumping script runlog(s) in directory '{}':".format(rld)) - print(" Runlog(s) found: {:,d}".format(rlcount)) + self.p("Dumping application runlog(s) in directory '{}':".format(rld)) + self.p(" Runlog(s) found: {:,d}".format(rlcount)) if limit: - print(" Result limit: {:,d}".format(limit)) - if len(runlogs): - print("") + self.p(" Result limit: {:,d}".format(limit)) + if runlogs: + self.p("") tree = pydgets.widgets.TreeWidget() - for rl in runlogs: - print("Runlog '{}':".format(rl[0])) - tree.display(rl[1]) + for runl in runlogs: + self.p("Runlog '{}':".format(runl[0])) + self.p(tree.render(runl[1])) def cbk_action_runlogs_evaluate(self): """ - Evaluate previous script runlogs. + **ACTION:** Evaluate previous application runlogs. """ rld = self.c(self.CONFIG_RUNLOG_DIR) limit = self.c(self.CONFIG_LIMIT) @@ -1074,53 +1432,55 @@ class BaseApp: for rlf in runlog_files: runlogs.append(self.json_load(rlf)) - print("Evaluating script runlogs in directory '{}':".format(rld)) - print(" Runlog(s) found: {:,d}".format(rlcount)) + self.p("Evaluating application runlogs in directory '{}':".format(rld)) + self.p(" Runlog(s) found: {:,d}".format(rlcount)) if limit: - print(" Result limit: {:,d}".format(limit)) - if len(runlogs): - print("") + self.p(" Result limit: {:,d}".format(limit)) + if runlogs: + self.p("") evaluation = self.runlogs_evaluate(runlogs) self.runlogs_format_evaluation(evaluation) + #--------------------------------------------------------------------------- # ACTION HELPERS #--------------------------------------------------------------------------- + def runlog_analyze(self, runlog): """ Analyze given runlog. """ - ct = int(time.time()) + curt = int(time.time()) tm_tmp = {} analysis = {self.RLANKEY_DURPRE: 0, self.RLANKEY_DURPROC: 0, self.RLANKEY_DURPOST: 0, self.RLANKEY_DURATIONS: {}} analysis[self.RLANKEY_RUNLOG] = runlog analysis[self.RLANKEY_LABEL] = runlog[self.RLKEY_TSSTR] - analysis[self.RLANKEY_AGE] = ct - runlog[self.RLKEY_TS] + analysis[self.RLANKEY_AGE] = curt - runlog[self.RLKEY_TS] analysis[self.RLANKEY_RESULT] = runlog[self.RLKEY_RESULT] analysis[self.RLANKEY_COMMAND] = runlog.get(self.RLANKEY_COMMAND, runlog.get('operation', 'unknown')) - # Calculate script processing duration + # Calculate application processing duration analysis[self.RLANKEY_DURRUN] = runlog[self.RLKEY_TMARKS][-1]['time'] - runlog[self.RLKEY_TMARKS][0]['time'] # Calculate separate durations for all stages - for tm in runlog[self.RLKEY_TMARKS]: + for tmark in runlog[self.RLKEY_TMARKS]: ptrna = re.compile('^(.*)_start$') ptrnb = re.compile('^(.*)_stop$') - m = ptrna.match(tm['ident']) - if m: - mg = m.group(1) - tm_tmp[mg] = tm['time'] + match = ptrna.match(tmark['ident']) + if match: + matchg = match.group(1) + tm_tmp[matchg] = tmark['time'] continue - m = ptrnb.match(tm['ident']) - if m: - mg = m.group(1) - analysis[self.RLANKEY_DURATIONS][mg] = tm['time'] - tm_tmp[mg] - if mg in ('stage_configure', 'stage_check', 'stage_setup'): - analysis[self.RLANKEY_DURPRE] += analysis[self.RLANKEY_DURATIONS][mg] - elif mg in ('stage_process'): - analysis[self.RLANKEY_DURPROC] += analysis[self.RLANKEY_DURATIONS][mg] - elif mg in ('stage_evaluate', 'stage_teardown'): - analysis[self.RLANKEY_DURPOST] += analysis[self.RLANKEY_DURATIONS][mg] + match = ptrnb.match(tmark['ident']) + if match: + matchg = match.group(1) + analysis[self.RLANKEY_DURATIONS][matchg] = tmark['time'] - tm_tmp[matchg] + if matchg in ('stage_configure', 'stage_check', 'stage_setup'): + analysis[self.RLANKEY_DURPRE] += analysis[self.RLANKEY_DURATIONS][matchg] + elif matchg in ('stage_process'): + analysis[self.RLANKEY_DURPROC] += analysis[self.RLANKEY_DURATIONS][matchg] + elif matchg in ('stage_evaluate', 'stage_teardown'): + analysis[self.RLANKEY_DURPOST] += analysis[self.RLANKEY_DURATIONS][matchg] continue analysis[self.RLANKEY_EFFECTIVITY] = ((analysis[self.RLANKEY_DURPROC]/analysis[self.RLANKEY_DURRUN])*100) @@ -1136,12 +1496,12 @@ class BaseApp: { 'label': 'Value', 'data_formating': '{:s}', 'align': '>' }, ] tbody = [ - ['Label:', analysis[self.RLANKEY_LABEL]], - ['Age:', str(datetime.timedelta(seconds=int(analysis[self.RLANKEY_AGE])))], - ['Command:', analysis[self.RLANKEY_COMMAND]], - ['Result:', analysis[self.RLANKEY_RESULT]], - ] - tablew.display(tbody, columns = tcols, enumerate = False, header = False) + ['Label:', analysis[self.RLANKEY_LABEL]], + ['Age:', str(datetime.timedelta(seconds=int(analysis[self.RLANKEY_AGE])))], + ['Command:', analysis[self.RLANKEY_COMMAND]], + ['Result:', analysis[self.RLANKEY_RESULT]], + ] + self.p(tablew.render(tbody, columns = tcols, enumerate = False, header = False)) #treew = pydgets.widgets.TreeWidget() #treew.display(analysis) @@ -1153,8 +1513,8 @@ class BaseApp: Evaluate given runlogs. """ evaluation = {self.RLEVKEY_ANALYSES: []} - for rl in runlogs: - rslt = self.runlog_analyze(rl) + for runl in runlogs: + rslt = self.runlog_analyze(runl) evaluation[self.RLEVKEY_ANALYSES].append(rslt) return self._sub_runlogs_evaluate(runlogs, evaluation) @@ -1163,122 +1523,287 @@ class BaseApp: Format runlog evaluation. """ table_columns = [ - { 'label': 'Date' }, - { 'label': 'Age', 'data_formating': '{}', 'align': '>' }, - { 'label': 'Runtime', 'data_formating': '{}', 'align': '>' }, - { 'label': 'Process', 'data_formating': '{}', 'align': '>' }, - { 'label': 'E [%]', 'data_formating': '{:6.2f}', 'align': '>' }, - { 'label': 'Errors', 'data_formating': '{:,d}', 'align': '>' }, - { 'label': 'Command', 'data_formating': '{}', 'align': '>' }, - { 'label': 'Result', 'data_formating': '{}', 'align': '>' }, - ] + { 'label': 'Date' }, + { 'label': 'Age', 'data_formating': '{}', 'align': '>' }, + { 'label': 'Runtime', 'data_formating': '{}', 'align': '>' }, + { 'label': 'Process', 'data_formating': '{}', 'align': '>' }, + { 'label': 'E [%]', 'data_formating': '{:6.2f}', 'align': '>' }, + { 'label': 'Errors', 'data_formating': '{:,d}', 'align': '>' }, + { 'label': 'Command', 'data_formating': '{}', 'align': '>' }, + { 'label': 'Result', 'data_formating': '{}', 'align': '>' }, + ] table_data = [] - for an in evaluation[self.RLEVKEY_ANALYSES]: + for anl in evaluation[self.RLEVKEY_ANALYSES]: table_data.append( [ - an[self.RLANKEY_LABEL], - str(datetime.timedelta(seconds=int(an[self.RLANKEY_AGE]))), - str(datetime.timedelta(seconds=int(an[self.RLANKEY_DURRUN]))), - str(datetime.timedelta(seconds=int(an[self.RLANKEY_DURPROC]))), - an[self.RLANKEY_EFFECTIVITY], - len(an[self.RLANKEY_RUNLOG][self.RLKEY_ERRORS]), - an[self.RLANKEY_COMMAND], - an[self.RLANKEY_RESULT], + anl[self.RLANKEY_LABEL], + str(datetime.timedelta(seconds=int(anl[self.RLANKEY_AGE]))), + str(datetime.timedelta(seconds=int(anl[self.RLANKEY_DURRUN]))), + str(datetime.timedelta(seconds=int(anl[self.RLANKEY_DURPROC]))), + anl[self.RLANKEY_EFFECTIVITY], + len(anl[self.RLANKEY_RUNLOG][self.RLKEY_ERRORS]), + anl[self.RLANKEY_COMMAND], + anl[self.RLANKEY_RESULT], ] ) - print("General script processing statistics:") + self.p("General application processing statistics:") tablew = pydgets.widgets.TableWidget() - tablew.display(table_data, columns = table_columns) + self.p(tablew.render(table_data, columns = table_columns)) self._sub_runlogs_format_evaluation(evaluation) - def runlog_dump(self, runlog, **kwargs): + def runlogs_list(self, **kwargs): + """ + List all available runlogs. + """ + reverse = kwargs.get('reverse', False) + limit = kwargs.get('limit', None) + rlfn = os.path.join(self.c(self.CONFIG_RUNLOG_DIR), '*.runlog') + rllist = sorted(glob.glob(rlfn), reverse = reverse) + rlcount = len(rllist) + + if limit: + return (rllist[:limit], rlcount) + return (rllist, rlcount) + + + #--------------------------------------------------------------------------- + # INTERNAL UTILITIES. + #--------------------------------------------------------------------------- + + + def _get_fn_runlog(self): + """ + Return the name of the runlog file for current process. + + :return: Name of the runlog file. + :rtype: str + """ + return os.path.join(self.c(self.CONFIG_RUNLOG_DIR), "{}.runlog".format(self.runlog[self.RLKEY_TSFSF])) + + def _get_fn_pstate(self): + """ + Return the name of the persistent state file for current process. + + :return: Name of the persistent state file. + :rtype: str + """ + return self.c(self.CONFIG_PSTATE_FILE) + + def _utils_detect_actions(self): + """ + Returns the sorted list of all available actions current application is capable + of performing. The detection algorithm is based on string analysis of all + available methods. Any method starting with string ``cbk_action_`` will + be appended to the list, lowercased and with ``_`` characters replaced with ``-``. + """ + ptrn = re.compile(self.PTRN_ACTION_CBK) + attrs = sorted(dir(self)) + result = [] + for atr in attrs: + if not callable(getattr(self, atr)): + continue + match = ptrn.match(atr) + if match: + result.append(atr.replace(self.PTRN_ACTION_CBK,'').replace('_','-').lower()) + return result + + def _utils_runlog_dump(self, runlog): + """ + Write application runlog into ``stdout``. + + :param dict runlog: Structure containing application runlog. + """ + self.p("Application runlog >>>\n{}".format(self.json_dump(runlog))) + + def _utils_runlog_log(self, runlog): """ - Dump runlog. + Write application runlog into logging service. - Dump script runlog to terminal (JSON). + :param dict runlog: Structure containing application runlog. """ - # Dump current script runlog. - #self.logger.debug("Script runlog >>>\n{}".format(json.dumps(runlog, sort_keys=True, indent=4))) - print("Script runlog >>>\n{}".format(self.json_dump(runlog, default=_json_default))) + self.p("Application runlog >>>\n{}".format(self.json_dump(runlog))) - def runlog_save(self, runlog, **kwargs): + def _utils_runlog_save(self, runlog): """ - Save runlog. + Write application runlog to external JSON file. - Save script runlog to external file (JSON). + :param dict runlog: Structure containing application runlog. """ - # Attempt to create script runlog directory. + # Attempt to create application runlog directory. if not os.path.isdir(self.c(self.CONFIG_RUNLOG_DIR)): - self.logger.info("Creating runlog directory '{}'".format(self.c(self.CONFIG_RUNLOG_DIR))) + self.logger.info("Creating application runlog directory '%s'", self.c(self.CONFIG_RUNLOG_DIR)) os.makedirs(self.c(self.CONFIG_RUNLOG_DIR)) - rlfn = self.get_fn_runlog() - self.dbgout("[STATUS] Saving script runlog to file '{}'".format(rlfn)) + + rlfn = self._get_fn_runlog() + self.dbgout("Saving application runlog to file '{}'".format(rlfn)) self.json_save(rlfn, runlog) - self.logger.info("Script runlog saved to file '{}'".format(rlfn)) + self.logger.info("Application runlog saved to file '%s'", rlfn) - def runlogs_list(self, **kwargs): + def _utils_pstate_dump(self, state): """ - List all available runlogs. + Write persistent state into ``stdout``. + + :param dict state: Structure containing application persistent state. """ - reverse = kwargs.get('reverse', False) - limit = kwargs.get('limit', None) - rlfn = os.path.join(self.c(self.CONFIG_RUNLOG_DIR), '*.runlog') - rllist = sorted(glob.glob(rlfn), reverse = reverse) - rlcount = len(rllist) - if limit: - return (rllist[:limit], rlcount) - else: - return (rllist, rlcount) + self.p("Application persistent state >>>\n{}".format(self.json_dump(state))) - def pstate_dump(self, state, **kwargs): + def _utils_pstate_log(self, state): """ - Dump persistent state. + Write persistent state into logging service. - Dump script persistent state to terminal (JSON). + :param dict state: Structure containing application persistent state. """ - # Dump current script state. - #self.logger.debug("Script state >>>\n{}".format(json.dumps(state, sort_keys=True, indent=4))) - print("Script state >>>\n{}".format(self.json_dump(state, default=_json_default))) + self.logger.info("Application persistent state >>>\n%s", self.json_dump(state)) - def pstate_save(self, state, **kwargs): + def _utils_pstate_save(self, state): """ - Save persistent state. + Write application persistent state to external JSON file. - Save script persistent state to external file (JSON). + :param dict state: Structure containing application persistent state. """ - sfn = self.get_fn_pstate() - self.dbgout("[STATUS] Saving script persistent state to file '{}'".format(sfn)) + sfn = self._get_fn_pstate() + self.dbgout("Saving application persistent state to file '{}'".format(sfn)) self.json_save(sfn, state) - self.logger.info("Script persistent state saved to file '{}'".format(sfn)) + self.logger.info("Application persistent state saved to file '%s'", sfn) + #--------------------------------------------------------------------------- - # TOOLS + # SHORTCUT METHODS, HELPERS AND TOOLS. #--------------------------------------------------------------------------- - def execute_command(self, command, can_fail=False): + + def c(self, key, default = None): """ - Execute given shell command + Shortcut method: Get given configuration value, shortcut for: + + self.config.get(key, default) + + :param str key: Name of the configuration value. + :param default: Default value to be returned when key is not set. + :return: Configuration value fogr given key. """ - self.logger.info("Executing system command >>>\n{}".format(command)) - #result = subprocess.run(command) + if default is None: + return self.config.get(key) + return self.config.get(key, default) + + def cc(self, key, default = None): + """ + Shortcut method: Get given core configuration value, shortcut for: + + self.config[self.CORE].get(key, default) + + Core configurations are special configurations under configuration key + ``__CORE__``, which may only either be hardcoded, or calculated from other + configurations. + + :param str key: Name of the core configuration value. + :param default: Default value to be returned when key is not set. + :return: Core configuration value fogr given key. + """ + if default is None: + return self.config[self.CORE].get(key) + return self.config[self.CORE].get(key, default) + + def p(self, string, level = 0): + """ + Print given string to ``sys.stdout`` with respect to ``quiet`` and ``verbosity`` + settings. + + :param str string: String to print. + :param int level: Required minimal verbosity level to print the message. + """ + if not self.c(self.CONFIG_QUIET) and self.c(self.CONFIG_VERBOSITY) >= level: + print(string) + + def error(self, error, retc = None, trcb = None): + """ + Register given error, that occured during application run. Registering in + the case of this method means printing the error message to logging facility, + storing the message within the appropriate runlog data structure, generating + the traceback when required and altering the runlog result and return code + attributes accordingly. + + :param str error: Error message to be written. + :param int retc: Requested return code with which to terminate the application. + :param Exception trcb: Optional exception object. + """ + self.retc = retc if retc is not None else self.RC_FAILURE + + errstr = "{}".format(error) + self.logger.error(errstr) + + if trcb: + tbexc = traceback.format_tb(trcb) + self.logger.error("\n" + "".join(tbexc)) + + self.runlog[self.RLKEY_ERRORS].append(errstr) + self.runlog[self.RLKEY_RESULT] = self.RESULT_FAILURE + self.runlog[self.RLKEY_RC] = self.retc + + @staticmethod + def dbgout(message): + """ + Routine for printing additional debug messages. The given message will be + printed only in case the static class variable ``FLAG_DEBUG`` flag is set + to ``True``. This can be done either by explicit assignment in code, or + using command line argument ``--debug``, which is evaluated ASAP and sets + the variable to ``True``. The message will be printed to ``sys.stderr``. + + :param str message: Message do be written. + """ + if BaseApp.FLAG_DEBUG: + print("* {} DBGOUT: {}".format(time.strftime('%Y-%m-%d %X', time.localtime()), message), file=sys.stderr) + + @staticmethod + def excout(exception, retc = None): + """ + Routine for displaying the exception message to the user without traceback + and terminating the application. This routine is intended to display information + about application errors, that are not caused by the application code itself + (like missing configuration file, non-writable directories, etc.) and that + can not be logged because of the fact, that the logging service was not yet + initialized. For that reason this method is used to handle exceptions during + the **__init__** and **setup** stages. + + :param Exception exception: Exception object. + :param int retc: Requested return code with which to terminate the application. + """ + retc = retc if retc is not None else BaseApp.RC_FAILURE + + print("{} CRITICAL ERROR: {}".format(time.strftime('%Y-%m-%d %X', time.localtime()), exception), file=sys.stderr) + sys.exit(retc) + + def execute_command(self, command, can_fail = False): + """ + Execute given shell command. + """ + self.logger.info("Executing system command >>>\n%s", command) + result = None if can_fail: - result = subprocess.call(command, shell=True) + result = subprocess.call(command, shell = True) else: - result = subprocess.check_output(command, shell=True) - self.logger.debug("System command result >>>\n{}".format(pprint.pformat(result,indent=4))) + result = subprocess.check_output(command, shell = True) + + self.logger.debug("System command result >>>\n%s", pprint.pformat(result, indent=4)) return result def time_mark(self, ident, descr): """ - Mark current time with additional identifiers and descriptions + Mark current time with additional identifier and description to application + runlog. + + :param str ident: Time mark identifier. + :param str descr: Time mark description. + :return: Time mark data structure. + :rtype: dict """ mark = { - 'ident': ident, - 'descr': descr, - 'time': time.time() - } + 'ident': ident, + 'descr': descr, + 'time': time.time() + } self.runlog[self.RLKEY_TMARKS].append(mark) return mark @@ -1286,6 +1811,11 @@ class BaseApp: def json_dump(data, **kwargs): """ Dump given data structure into JSON string. + + :param dict data: Data to be dumped to JSON. + :param kwargs: Optional arguments to pass to :py:func:`pyzenkit.jsonconf.json_dump` method. + :return: Data structure as JSON string. + :rtype: str """ return pyzenkit.jsonconf.json_dump(data, **kwargs) @@ -1293,78 +1823,125 @@ class BaseApp: def json_save(json_file, data, **kwargs): """ Save given data structure into given JSON file. + + :param str json_file: Name of the JSON file to write to. + :param dict data: Data to be dumped to JSON. + :param kwargs: Optional arguments to pass to :py:func:`pyzenkit.jsonconf.json_save` method. + :return: Always returns ``True``. + :rtype: bool """ return pyzenkit.jsonconf.json_save(json_file, data, **kwargs) @staticmethod - def json_load(json_file, **kwargs): + def json_load(json_file): """ - Load data structure from given json file. + Load data structure from given JSON file. + + :param str json_file: Name of the JSON file to read from. + :return: Loaded data structure. + :rtype: dict """ - return pyzenkit.jsonconf.json_load(json_file, **kwargs) + return pyzenkit.jsonconf.json_load(json_file) - def format_progress_bar(self, percent, done, barLen = 50): + @staticmethod + def format_progress_bar(percent, bar_len = 50): """ - Format progress bar from given values + Format progress bar from given values. """ progress = "" - for i in range(barLen): - if i < int(barLen * percent): + for i in range(bar_len): + if i < int(bar_len * percent): progress += "=" else: progress += " " return " [%s] %.2f%%" % (progress, percent * 100) - def draw_progress_bar(self, percent, done, barLen = 50): + @staticmethod + def draw_progress_bar(percent, bar_len = 50): """ - Draw progress bar on standard output terminal + Draw progress bar on standard output terminal. """ sys.stdout.write("\r") - sys.stdout.write(self.format_progress_bar(percent, done, barLen)) + sys.stdout.write(BaseApp.format_progress_bar(percent, bar_len)) sys.stdout.flush() -class _DemoBaseApp(BaseApp): + +#------------------------------------------------------------------------------- + + +class DemoBaseApp(BaseApp): """ - Minimalistic class for demonstration purposes. + Minimalistic class for demonstration purposes. Study implementation of this + class for tutorial on how to use this framework. """ - def stage_process(self): + def __init__(self, name = None, description = None): """ - Script lifecycle stage: PROCESSING + Initialize demonstration application. This method overrides the base + implementation in :py:func:`baseapp.BaseApp.__init__` and it aims to + even more simplify the application object creation. - Perform some real work (finally). Following method will call appropriate - callback method operation to service the selected operation. + :param str name: Optional application name. + :param str description: Optional application description. """ - self.time_mark('stage_process_start', 'Start of the processing stage') + name = 'demo-baseapp.py' if not name else name + description = 'DemoBaseApp - Demonstration application' if not description else description - # Log something to show we have reached this point of execution. - self.logger.info("Demo implementation for default command") + super().__init__( + name = name, + description = description, - # Test direct console output with conjunction with verbosity - self.p("Hello world") - self.p("Hello world, verbosity level 1", 1) - self.p("Hello world, verbosity level 2", 2) - self.p("Hello world, verbosity level 3", 3) + # + # Configure required application paths to harmless locations. + # + path_bin = '/tmp', + path_cfg = '/tmp', + path_log = '/tmp', + path_tmp = '/tmp', + path_run = '/tmp' + ) + def _sub_stage_process(self): + """ + Script lifecycle stage **PROCESS**. + """ # Update the persistent state to view the changes. self.pstate['counter'] = self.pstate.get('counter', 0) + 1 - self.time_mark('stage_process_stop', 'End of the processing stage') + # Log something to show we have reached this point of execution. + self.logger.info("Demonstration implementation for BaseApp demo application") + self.logger.info("Try executing this demo with following parameters:") + self.logger.info("* python3 pyzenkit/baseapp.py --help") + self.logger.info("* python3 pyzenkit/baseapp.py --verbose") + self.logger.info("* python3 pyzenkit/baseapp.py --verbose --verbose") + self.logger.info("* python3 pyzenkit/baseapp.py --verbose --verbose --verbose") + self.logger.info("* python3 pyzenkit/baseapp.py --debug") + self.logger.info("* python3 pyzenkit/baseapp.py --log-level debug") + self.logger.info("* python3 pyzenkit/baseapp.py --pstate-dump") + self.logger.info("* python3 pyzenkit/baseapp.py --runlog-dump") + self.logger.info("Number of BaseApp runs from persistent state: '%d'", self.pstate.get('counter')) + + # Test direct console output with conjunction with verbosity levels. + self.p("Hello world from BaseApp") + self.p("Hello world from BaseApp, verbosity level 1", 1) + self.p("Hello world from BaseApp, verbosity level 2", 2) + self.p("Hello world from BaseApp, verbosity level 3", 3) + +#------------------------------------------------------------------------------- + +# +# Perform the demonstration. +# if __name__ == "__main__": - """ - Perform the demonstration. - """ - # Prepare the environment - if not os.path.isdir('/tmp/baseapp.py'): - os.mkdir('/tmp/baseapp.py') - BaseApp.json_save('/tmp/baseapp.py.conf', {'test_a':1}) - script = _DemoBaseApp( - path_cfg = '/tmp', - path_log = '/tmp', - path_tmp = '/tmp', - path_run = '/tmp', - description = 'DemoBaseApp - generic base script (DEMO)' - ) - script.run() + # Prepare demonstration environment. + APP_NAME = 'demo-baseapp.py' + BaseApp.json_save('/tmp/{}.conf'.format(APP_NAME), {'test_a':1}) + try: + os.mkdir('/tmp/{}'.format(APP_NAME)) + except FileExistsError: + pass + + BASE_APP = DemoBaseApp(APP_NAME) + BASE_APP.run() diff --git a/pyzenkit/daemonizer.py b/pyzenkit/daemonizer.py index 2d4dbec9ea13eac99996ca03d50d4516667ee940..6e78a5d2da657547a4ebc2027313214bf6637729 100644 --- a/pyzenkit/daemonizer.py +++ b/pyzenkit/daemonizer.py @@ -1,9 +1,14 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Pavel Kacha <ph@rook.cz> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- diff --git a/pyzenkit/jsonconf.py b/pyzenkit/jsonconf.py index 819aed06c4eb7dd990206941c0fe068eecbc8357..98c2a333ae17b554871785fae3e66ad1b1864038 100644 --- a/pyzenkit/jsonconf.py +++ b/pyzenkit/jsonconf.py @@ -1,20 +1,25 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- """ -This module provides following tools for manipulation with JSON configuration -files: +This module provides tools for manipulating JSON configuration files: * Simple writing of formated JSON configuration files * Simple reading of any JSON configuration files -* Reading and merging of multiple JSON configuration files/directories -* Support for single line comments in JSON files (``#``,``//``) -* JSON schema validation +* Merging multiple JSON configuration files or configuration directories +* Support for single line comments in JSON files (``#``, ``//``) +* Support for semi-automated JSON schema validation """ @@ -232,7 +237,7 @@ def config_load_n(config_files, schema = None): .. warning:: - The merge is done using :py:func:``dict.update`` method and occurs only + The merge is done using :py:func:`dict.update` method and occurs only at highest level. :param str config_files: List of names of the source JSON config files to be loaded. @@ -264,7 +269,7 @@ def config_load_dir(config_dir, schema = None, extension = '.json.conf'): .. warning:: - The merge is done using :py:func:``dict.update`` method and occurs only + The merge is done using :py:func:`dict.update` method and occurs only at highest level. :param str config_dir: Names of the configuration directory. diff --git a/pyzenkit/tests/__init__.py b/pyzenkit/tests/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..df1f5e250196009111232bd65e0c33cd5465df25 100644 --- a/pyzenkit/tests/__init__.py +++ b/pyzenkit/tests/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +#------------------------------------------------------------------------------- +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. +#------------------------------------------------------------------------------- diff --git a/pyzenkit/tests/test_baseapp.py b/pyzenkit/tests/test_baseapp.py index d982b4f21dd4d7ba83be23f786630691231cb5c3..2cec19d1f9e7c1e7b31f61037d64bda1c60849dd 100644 --- a/pyzenkit/tests/test_baseapp.py +++ b/pyzenkit/tests/test_baseapp.py @@ -1,10 +1,17 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- + import unittest from unittest.mock import Mock, MagicMock, call from pprint import pformat, pprint @@ -13,21 +20,24 @@ import os import sys import shutil + # Generate the path to custom 'lib' directory lib = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')) sys.path.insert(0, lib) import pyzenkit.baseapp + # # Global variables # -SCR_NAME = 'test_baseapp.py' # Name of the script process +APP_NAME = 'test-baseapp.py' # Name of the application process JSON_FILE_NAME = '/tmp/script-state.json' # Name of the test JSON file -CFG_FILE_NAME = '/tmp/{}.conf'.format(SCR_NAME) # Name of the script configuration file -CFG_DIR_NAME = '/tmp/{}'.format(SCR_NAME) # Name of the script configuration directory +CFG_FILE_NAME = '/tmp/{}.conf'.format(APP_NAME) # Name of the application configuration file +CFG_DIR_NAME = '/tmp/{}'.format(APP_NAME) # Name of the application configuration directory + -class TestPyzenkitScript(unittest.TestCase): +class TestPyzenkitBaseApp(unittest.TestCase): def setUp(self): pyzenkit.baseapp.BaseApp.json_save(CFG_FILE_NAME, {'test': 'x'}) @@ -36,13 +46,9 @@ class TestPyzenkitScript(unittest.TestCase): except FileExistsError: pass - self.obj = pyzenkit.baseapp._DemoBaseApp( - name = SCR_NAME, - path_cfg = '/tmp', - path_log = '/tmp', - path_tmp = '/tmp', - path_run = '/tmp', - description = 'DemoBaseApp - generic base script (DEMO)' + self.obj = pyzenkit.baseapp.DemoBaseApp( + name = APP_NAME, + description = 'TestBaseApp - Testing application' ) def tearDown(self): os.remove(CFG_FILE_NAME) @@ -50,29 +56,61 @@ class TestPyzenkitScript(unittest.TestCase): def test_01_utils(self): """ - Perform tests of generic script utils. + Perform tests of generic application utils. """ - # Test the saving of JSON files - self.assertEqual(self.obj.name, SCR_NAME) + self.maxDiff = None - # Test the saving of JSON files + # Test the name generation capabilities. + self.assertEqual(self.obj.name, APP_NAME) + + # Test the saving of JSON files. self.assertTrue(self.obj.json_save(JSON_FILE_NAME, { "test": 1 })) - # Test that the JSON file was really created + # Test that the JSON file was really created. self.assertTrue(os.path.isfile(JSON_FILE_NAME)) - # Test the loading of JSON files + # Test the loading of JSON files. self.assertEqual(self.obj.json_load(JSON_FILE_NAME), { "test": 1 }) - # Remove the JSON file we are done with + # Remove the JSON file we are done with. os.remove(JSON_FILE_NAME) - def test_02_basic(self): + def test_02_argument_parsing(self): + """ + Perform tests of argument parsing. + """ + self.maxDiff = None + + # Test argument parsing. + argp = self.obj._init_argparser() + self.assertEqual(vars(argp.parse_args(['--verbose'])), {'action': None, + 'config_dir': None, + 'config_file': None, + 'debug': None, + 'group': None, + 'input': None, + 'limit': None, + 'log_file': None, + 'log_level': None, + 'name': None, + 'pstate_dump': None, + 'pstate_file': None, + 'pstate_log': None, + 'quiet': None, + 'runlog_dir': None, + 'runlog_dump': None, + 'runlog_log': None, + 'user': None, + 'verbosity': 1 + }) + + def test_03_plugin(self): """ - Perform the basic operativity tests. + Perform tests of plugin mode. """ + self.maxDiff = None + self.obj.plugin() if __name__ == "__main__": unittest.main() - diff --git a/pyzenkit/tests/test_daemonizer.py b/pyzenkit/tests/test_daemonizer.py index 604a8d1a937897048600a098ceb9aa92db30a8f2..b8e12b3ead1756712bac3e6a80c065461934bff9 100644 --- a/pyzenkit/tests/test_daemonizer.py +++ b/pyzenkit/tests/test_daemonizer.py @@ -1,12 +1,16 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Pavel Kacha <ph@rook.cz> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- - import unittest from unittest.mock import Mock, MagicMock, call from pprint import pformat, pprint diff --git a/pyzenkit/tests/test_jsonconf.py b/pyzenkit/tests/test_jsonconf.py index e5759a7b93bd2fa7fce54e67c392b372b174804c..39eee7c735352c50bf61c64524b7feaf34da7eb9 100644 --- a/pyzenkit/tests/test_jsonconf.py +++ b/pyzenkit/tests/test_jsonconf.py @@ -1,8 +1,14 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- import unittest @@ -188,4 +194,3 @@ class TestPyzenkitJsonconf(unittest.TestCase): if __name__ == "__main__": unittest.main() - diff --git a/pyzenkit/tests/test_zendaemon.py b/pyzenkit/tests/test_zendaemon.py index 90de2751c09274c3446e16f5dc26da152f8a7a1f..938774a6f19d50f662e6304edb6a0da10d2061e0 100644 --- a/pyzenkit/tests/test_zendaemon.py +++ b/pyzenkit/tests/test_zendaemon.py @@ -1,8 +1,14 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- import unittest diff --git a/pyzenkit/tests/test_zenscript.py b/pyzenkit/tests/test_zenscript.py index 582538b1b46979f00e739054b4aa0e00146f576b..13349a419797fbb06c88855de6b25ea96cdf39de 100644 --- a/pyzenkit/tests/test_zenscript.py +++ b/pyzenkit/tests/test_zenscript.py @@ -1,10 +1,17 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- + import unittest from unittest.mock import Mock, MagicMock, call from pprint import pformat, pprint @@ -12,6 +19,8 @@ from pprint import pformat, pprint import os import sys import shutil +import datetime + # Generate the path to custom 'lib' directory lib = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')) @@ -20,13 +29,14 @@ sys.path.insert(0, lib) import pyzenkit.baseapp import pyzenkit.zenscript + # # Global variables # -SCR_NAME = 'test_zenscript.py' # Name of the script process -JSON_FILE_NAME = '/tmp/daemon-state.json' # Name of the test JSON file -CFG_FILE_NAME = '/tmp/{}.conf'.format(SCR_NAME) # Name of the script configuration file -CFG_DIR_NAME = '/tmp/{}'.format(SCR_NAME) # Name of the script configuration directory +SCR_NAME = 'test-zenscript.py' # Name of the script process +CFG_FILE_NAME = '/tmp/{}.conf'.format(SCR_NAME) # Name of the script configuration file +CFG_DIR_NAME = '/tmp/{}'.format(SCR_NAME) # Name of the script configuration directory + class TestPyzenkitZenScript(unittest.TestCase): @@ -37,13 +47,9 @@ class TestPyzenkitZenScript(unittest.TestCase): except FileExistsError: pass - self.obj = pyzenkit.zenscript._DemoZenScript( - name = SCR_NAME, - path_cfg = '/tmp', - path_log = '/tmp', - path_tmp = '/tmp', - path_run = '/tmp', - description = 'DemoZenScript - generic script (DEMO)' + self.obj = pyzenkit.zenscript.DemoZenScript( + name = SCR_NAME, + description = 'TestZenScript - Testing script' ) def tearDown(self): os.remove(CFG_FILE_NAME) @@ -53,16 +59,21 @@ class TestPyzenkitZenScript(unittest.TestCase): """ Perform the basic utility tests. """ + self.maxDiff = None + + self.obj.plugin() # Test the interval threshold calculations - self.assertEqual(self.obj.calculate_interval_thresholds('daily', time_cur = 1454934631), (1454848231, 1454934631)) - self.assertEqual(self.obj.calculate_interval_thresholds('daily', time_cur = 1454934631, flag_floor = True), (1454803200, 1454889600)) + self.assertEqual(self.obj.calculate_interval_thresholds(time_high = 1454934631, interval = 'daily'), (datetime.datetime(2016, 2, 7, 13, 30, 31), datetime.datetime(2016, 2, 8, 13, 30, 31))) + self.assertEqual(self.obj.calculate_interval_thresholds(time_high = 1454934631, interval = 'daily', adjust = True), (datetime.datetime(2016, 2, 7, 1, 0), datetime.datetime(2016, 2, 8, 1, 0))) - def test_02_basic(self): + def test_02_plugin(self): """ Perform the basic operativity tests. """ - self.assertTrue(True) + self.maxDiff = None + + self.obj.plugin() if __name__ == "__main__": unittest.main() diff --git a/pyzenkit/zencli.py b/pyzenkit/zencli.py index c72cce05fd4e4373193c6dbd187fe0945096575b..103ff7018dfa8ddd55609e04b7fff815f490e518 100644 --- a/pyzenkit/zencli.py +++ b/pyzenkit/zencli.py @@ -1,9 +1,15 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. # +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. +#------------------------------------------------------------------------------- # Notes: # - The concept for dynamic module loading was taken from here # [1] https://lextoumbourou.com/blog/posts/dynamically-loading-modules-and-classes-in-python/ diff --git a/pyzenkit/zendaemon.py b/pyzenkit/zendaemon.py index b31c70fb828b9b6f966cdba3b2bc931c6cec151f..24e28132c5548e4db43e26058cfaa082c230a19e 100644 --- a/pyzenkit/zendaemon.py +++ b/pyzenkit/zendaemon.py @@ -1,14 +1,44 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- + """ -Base implementation of generic daemon. +This module provides base implementation of generic daemon. It builds on top of +:py:mod:`pyzenkit.baseapp` and adds following usefull features: + +* Event driven design. +* Support for arbitrary signal handling. +* Support for modularity with daemon components. +* Fully automated daemonization process. + +Events and event queue +^^^^^^^^^^^^^^^^^^^^^^ + +Signal handling +^^^^^^^^^^^^^^^ + +Daemon components +^^^^^^^^^^^^^^^^^ + +Daemonization +^^^^^^^^^^^^^ + """ + +__author__ = "Jan Mach <honza.mach.ml@gmail.com>" + + import os import re import sys @@ -23,145 +53,196 @@ import math import glob import pprint -# Generate the path to custom 'lib' directory -lib = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) -sys.path.insert(0, lib) - # # Custom libraries. # import pyzenkit.baseapp import pyzenkit.daemonizer + # Translation table to translate signal numbers to their names. SIGNALS_TO_NAMES_DICT = dict((getattr(signal, n), n) \ for n in dir(signal) if n.startswith('SIG') and '_' not in n ) -# Simple method for JSON serialization -def _json_default(o): - if isinstance(o, ZenDaemonComponent): - return "COMPONENT({})".format(o.__class__.__name__) - elif callable(o): - return "CALLBACK({}:{})".format(o.__self__.__class__.__name__, o.__name__) - else: - return repr(o) + +def _json_default(obj): + """ + Fallback method for serializing unknown objects into JSON. + """ + if isinstance(obj, ZenDaemonComponent): + return "COMPONENT({})".format(obj.__class__.__name__) + if callable(obj): + return "CALLBACK({}:{})".format(obj.__self__.__class__.__name__, obj.__name__) + return repr(obj) + + +#------------------------------------------------------------------------------- + class QueueEmptyException(Exception): """ - Exception representing empty event queue. + Exception representing empty event queue. This exception will be thrown by + :py:class:`zendaemon.EventQueueManager` in the event of empty event queue. """ + def __init__(self, description, **params): + """ + Initialize new exception with given description and optional additional + parameters. + + :param str description: Description of the problem. + :param params: Optional additional parameters. + """ + super().__init__() + + self.description = description + self.params = params - def __init__(self, description): - self._description = description def __str__(self): - return repr(self._description) + """ + Operator override for automatic string output. + """ + return repr(self.description) + class EventQueueManager: """ - Implementation of event queue manager. - - This implementation supports scheduling of both generic sequential events and - timed events. + Implementation of event queue manager. This implementation supports scheduling + of both sequential events and timed events (events scheduled for specific time). + The actual event object, that is added into the queue may be arbitrary object, + there are no restrictions for its type or interface, because the queue manager + does not interacts with the event itself. Internally two separate event queues + are used, one for sequentialy scheduled events and another for timed events. + For best performance the sequential queue is implemented using :py:class:`collections.dequeue` + object and the timed queue is implemented using :py:mod:`heapq` module. """ - def __init__(self, **kwargs): + def __init__(self): """ - + Base event queue manager constructor. Initialize internal event queues. """ self.events = collections.deque() self.events_at = [] - def __del__(self): - """ - Default script object destructor. Perform generic cleanup. - """ - pass - def schedule(self, event, args = None): """ Schedule new event to the end of the event queue. + + :param event: Event to be scheduled. + :param args: Optional event arguments to be stored alongside the event. """ self.events.append((event, args)) def schedule_next(self, event, args = None): """ Schedule new event to the beginning of the event queue. + + :param event: Event to be scheduled. + :param args: Optional event arguments to be stored alongside the event. """ self.events.appendleft((event, args)) - def schedule_at(self, ts, event, args = None): + def schedule_at(self, tstamp, event, args = None): """ Schedule new event for a specific time. + + :param float tstamp: Timestamp to which to schedule the event (compatible with :py:func:`time.time`). + :param event: Event to be scheduled. + :param args: Optional event arguments to be stored alongside the event. """ - heapq.heappush(self.events_at, (ts, event, args)) + heapq.heappush(self.events_at, (tstamp, event, args)) def schedule_after(self, delay, event, args = None): """ - Schedule new event after a given. + Schedule new event after a given time delay. + + :param float delay: Time delay after which to schedule the event. + :param event: Event to be scheduled. + :param args: Optional event arguments to be stored alongside the event. """ - ts = time.time() + delay - heapq.heappush(self.events_at, (ts, event, args)) + tstamp = time.time() + delay + heapq.heappush(self.events_at, (tstamp, event, args)) def next(self): """ - Fetch next event from event queue. + Fetch next event from queue. + + :raises QueueEmptyException: If the queue is empty. + :return: Return next scheduled event from queue along with its optional arguments. + :rtype: tuple """ - l1 = len(self.events_at) - if l1: + len1 = len(self.events_at) + if len1: if self.events_at[0][0] <= time.time(): - (ts, event, args) = heapq.heappop(self.events_at) + (tstamp, event, args) = heapq.heappop(self.events_at) return (event, args) - l2 = len(self.events) - if l2: + len2 = len(self.events) + if len2: return self.events.popleft() - if (l1 + l2) == 0: + if (len1 + len2) == 0: raise QueueEmptyException("Event queue is empty") return (None, None) def when(self): """ - Determine the time when the next event is scheduled. + Determine the timestamp of the next scheduled event. + + :return: Unix timestamp of next scheduled event. + :rtype: float """ + if self.events: + return time.time() return self.events_at[0][0] def wait(self): """ - Calculate the waiting period until the next even is due. + Calculate the waiting period until the next event in queue is due. + + :return: Time interval for which to wait until the next event is due. + :rtype: float """ + if self.events: + return 0 return self.events_at[0][0] - time.time() def count(self): """ Count the total number of scheduled events. + + :return: Number of events. + :rtype: int """ return len(self.events_at) + len(self.events) -class ZenDaemonComponentException(Exception): - """ +#------------------------------------------------------------------------------- + + +class ZenDaemonComponentException(pyzenkit.baseapp.ZenAppProcessException): """ - def __init__(self, description): - self._description = description - def __str__(self): - return repr(self._description) + Describes problems specific to daemon components. + """ + pass + class ZenDaemonComponent: """ - Base implementation of all daemon components. + Base implementation for all daemon components. Daemon components are building + blocks of each daemon and they are responsible for the actual work to be done. + This approach enables very easy reusability. """ def __init__(self, **kwargs): """ - + Base daemon component object constructor. """ self.statistics_cur = {} self.statistics_prev = {} self.statistics_ts = time.time() - self.pattern_stats = "{}\n\t{:15s} {:12,d} (+{:8,d}, {:8,.2f} #/s)" + self.pattern_stats = "{}\n\t{:15s} {:12,d} (+{:8,d}, {:8,.2f} #/s)" - def inc_statistics(self, key, increment = 1): + def inc_statistic(self, key, increment = 1): """ - Raise given statistics key with given increment. + Raise given statistic key with given increment. """ self.statistics_cur[key] = self.statistics_cur.get(key, 0) + increment @@ -169,41 +250,46 @@ class ZenDaemonComponent: """ Get the list of event names and their appropriate callback handlers. """ - raise Exception("This method must be implemented in subclass") + raise NotImplementedError("This method must be implemented in subclass") - def get_state(self, daemon): + def get_state(self): """ Get the current internal state of component (for debugging). """ - return {} + return { + 'statistics': self.statistics_cur + } - def calc_statistics(self, daemon, stats_cur, stats_prev, tdiff): + @staticmethod + def calc_statistics(stats_cur, stats_prev, tdiff): """ - + Calculate daemon component statistics. """ result = {} - for k in stats_cur: - if isinstance(stats_cur[k], int): - result[k] = { - 'cnt': stats_cur[k], - 'inc': stats_cur[k] - stats_prev.get(k, 0), - 'spd': (stats_cur[k] - stats_prev.get(k, 0)) / tdiff - } - elif isinstance(stats_cur[k], dict): - result[k] = self.calc_statistics(daemon, stats_cur[k], stats_prev.get(k, {}), tdiff) + for key in stats_cur: + result[key] = { + # Absolute count. + 'cnt': stats_cur[key], + # Increase from previous value. + 'inc': stats_cur[key] - stats_prev.get(key, 0), + # Processing speed (#/s) + 'spd': (stats_cur[key] - stats_prev.get(key, 0)) / tdiff, + # Percentage increase. + 'pct': (stats_cur[key] - stats_prev.get(key, 0)) / (stats_cur[key] / 100) + } return result - def get_statistics(self, daemon): + def get_statistics(self): """ Calculate processing statistics """ - ct = time.time() - tdiff = ct - self.statistics_ts + curts = time.time() + tdiff = curts - self.statistics_ts - stats = self.calc_statistics(daemon, self.statistics_cur, self.statistics_prev, tdiff) + stats = self.calc_statistics(self.statistics_cur, self.statistics_prev, tdiff) self.statistics_prev = copy.copy(self.statistics_cur) - self.statistics_ts = ct + self.statistics_ts = curts return stats def setup(self, daemon): @@ -218,31 +304,41 @@ class ZenDaemonComponent: """ pass -class ZenDaemonException(pyzenkit.baseapp.ZenAppException): + +#------------------------------------------------------------------------------- + + +class ZenDaemonException(pyzenkit.baseapp.ZenAppProcessException): """ Describes problems specific to daemons. """ pass + class ZenDaemon(pyzenkit.baseapp.BaseApp): """ Base implementation of generic daemon. """ + # + # Class constants. + # + # Event loop processing flags. FLAG_CONTINUE = 1 FLAG_STOP = 0 + # List of event names. EVENT_SIGNAL_HUP = 'signal_hup' EVENT_SIGNAL_USR1 = 'signal_usr1' EVENT_SIGNAL_USR2 = 'signal_usr2' EVENT_LOG_STATISTICS = 'log_statistics' # List of core configuration keys. - CORE_STATE = 'state' - CORE_STATE_SAVE = 'save' + CORE_STATE = 'state' + CORE_STATE_SAVE = 'save' - # List of possible configuration keys. + # List of configuration keys. CONFIG_COMPONENTS = 'components' CONFIG_NODAEMON = 'no_daemon' CONFIG_CHROOT_DIR = 'chroot_dir' @@ -253,12 +349,21 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): CONFIG_STATS_INTERVAL = 'stats_interval' CONFIG_PARALEL = 'paralel' + def __init__(self, **kwargs): """ - Default script object constructor. + Default application object constructor. + + Only defines core internal variables. The actual object initialization, + during which command line arguments and configuration files are parsed, + is done during the configure() stage of the run() sequence. This method + overrides the base implementation in :py:func:`baseapp.BaseApp.__init__`. + + :param kwargs: Various additional parameters. """ super().__init__(**kwargs) + self.flag_done = False self.queue = EventQueueManager() self.components = [] self.callbacks = {} @@ -267,56 +372,58 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): self._init_components(**kwargs) self._init_schedule(**kwargs) - def _init_config(self, **kwargs): - """ - Initialize script configurations to default values. + def _init_config(self, cfgs, **kwargs): """ - config = super()._init_config(**kwargs) + Initialize default application configurations. This method overrides the + base implementation in :py:func:`baseapp.BaseApp._init_argparser` and it + adds additional configurations via ``cfgs`` parameter. + Gets called from main constructor :py:func:`BaseApp.__init__`. + + :param list cfgs: Additional set of configurations. + :param kwargs: Various additional parameters passed down from constructor. + :return: Default configuration structure. + :rtype: dict + """ cfgs = ( (self.CONFIG_NODAEMON, False), (self.CONFIG_CHROOT_DIR, None), (self.CONFIG_WORK_DIR, '/'), (self.CONFIG_PID_FILE, os.path.join(self.paths.get(self.PATH_RUN), "{}.pid".format(self.name))), (self.CONFIG_STATE_FILE, os.path.join(self.paths.get(self.PATH_RUN), "{}.state".format(self.name))), - (self.CONFIG_UMASK, None), + (self.CONFIG_UMASK, 0o002), (self.CONFIG_STATS_INTERVAL, 300), (self.CONFIG_PARALEL, False), ) - for c in cfgs: - config[c[0]] = kwargs.pop('default_' + c[0], c[1]) - return config + return super()._init_config(cfgs, **kwargs) def _init_argparser(self, **kwargs): """ - Initialize script command line argument parser. - """ - argparser = super()._init_argparser(**kwargs) - - # Option flag indicating that the script should not daemonize and stay - # in foreground (usefull for debugging or testing). - argparser.add_argument('--no-daemon', help = 'do not daemonize, stay in foreground (flag)', action='store_true', default = None) - - # Option for overriding the name of the chroot directory. - argparser.add_argument('--chroot-dir', help = 'name of the chroot directory') - - # Option for overriding the name of the work directory. - argparser.add_argument('--work-dir', help = 'name of the work directory') - - # Option for overriding the name of the PID file. - argparser.add_argument('--pid-file', help = 'name of the pid file') + Initialize application command line argument parser. This method overrides + the base implementation in :py:func:`baseapp.BaseApp._init_argparser` and + it must return valid :py:class:`argparse.ArgumentParser` object. - # Option for overriding the name of the state file. - argparser.add_argument('--state-file', help = 'name of the state file') + Gets called from main constructor :py:func:`BaseApp.__init__`. - # Option for overriding the default umask. - argparser.add_argument('--umask', help = 'default file umask') + :param kwargs: Various additional parameters passed down from constructor. + :return: Initialized argument parser object. + :rtype: argparse.ArgumentParser + """ + argparser = super()._init_argparser(**kwargs) - # Option for defining processing statistics display interval. - argparser.add_argument('--stats-interval', help = 'define processing statistics display interval') + # + # Create and populate options group for common daemon arguments. + # + arggroup_daemon = argparser.add_argument_group('common daemon arguments') - # Option flag indicating that the script may run in paralel processes. - argparser.add_argument('--paralel', help = 'run in paralel mode (flag)', action = 'store_true', default = None) + arggroup_daemon.add_argument('--no-daemon', help = 'do not fully daemonize and stay in foreground (flag)', action='store_true', default = None) + arggroup_daemon.add_argument('--chroot-dir', help = 'name of the chroot directory', type = str, default = None) + arggroup_daemon.add_argument('--work-dir', help = 'name of the process work directory', type = str, default = None) + arggroup_daemon.add_argument('--pid-file', help = 'name of the pid file', type = str, default = None) + arggroup_daemon.add_argument('--state-file', help = 'name of the state file', type = str, default = None) + arggroup_daemon.add_argument('--umask', help = 'default file umask', default = None) + arggroup_daemon.add_argument('--stats-interval', help = 'processing statistics display interval in seconds', type = int) + arggroup_daemon.add_argument('--paralel', help = 'run in paralel mode (flag)', action = 'store_true', default = None) return argparser @@ -336,6 +443,7 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): Initialize internal event callbacks. """ for event in self.get_events(): + self.dbgout("Initializing event callback '{}':'{}'".format(str(event['event']), str(event['callback']))) self._init_event_callback(event['event'], event['callback'], event['prepend']) def _init_components(self, **kwargs): @@ -366,21 +474,26 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): for event in initial_events: self.queue.schedule_after(*event) + #--------------------------------------------------------------------------- + def _configure_postprocess(self): """ - Setup internal script core mechanics. Config postprocessing routine. + Perform configuration postprocessing and calculate core configurations. + This method overrides the base implementation in :py:func:`baseapp.BaseApp._configure_postprocess`. + + Gets called from :py:func:`BaseApp._stage_setup_configuration`. """ super()._configure_postprocess() - cc = {} - cc[self.CORE_STATE_SAVE] = True - self.config[self.CORE][self.CORE_STATE] = cc + ccfg = {} + ccfg[self.CORE_STATE_SAVE] = True + self.config[self.CORE][self.CORE_STATE] = ccfg if self.c(self.CONFIG_NODAEMON): - self.dbgout("[STATUS] Console log output is enabled via '--no-daemon' configuration") self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOCONS] = True + self.dbgout("Console log output is enabled via '--no-daemon' configuration") else: self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOCONS] = False @@ -388,11 +501,14 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): self.config[self.CORE][self.CORE_RUNLOG][self.CORE_RUNLOG_SAVE] = True self.config[self.CORE][self.CORE_PSTATE][self.CORE_PSTATE_SAVE] = True - def _stage_setup_custom(self): + def _sub_stage_setup(self): """ - Perform custom daemon related setup. + **SUBCLASS HOOK**: Perform additional custom setup actions in **setup** stage. + + Gets called from :py:func:`BaseApp._stage_setup` and it is a **SETUP SUBSTAGE 06**. """ for component in self.components: + self.dbgout("Configuring daemon component '{}'".format(component)) component.setup(self) def _stage_setup_dump(self): @@ -404,105 +520,121 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): """ super()._stage_setup_dump() - self.logger.debug("Daemon component list >>>\n{}".format(json.dumps(self.components, sort_keys=True, indent=4, default=_json_default))) - self.logger.debug("Registered event callbacks >>>\n{}".format(json.dumps(self.callbacks, sort_keys=True, indent=4, default=_json_default))) + self.logger.debug("Daemon component list >>>\n%s", json.dumps(self.components, sort_keys=True, indent=4, default=_json_default)) + self.logger.debug("Registered event callbacks >>>\n%s", json.dumps(self.callbacks, sort_keys=True, indent=4, default=_json_default)) self.logger.debug("Daemon component setup >>>\n") for component in self.components: - self.logger.debug(">>> {} >>>\n".format(component.__class__.__name__)) + self.logger.debug(">>> %s >>>\n", component.__class__.__name__) component.setup_dump(self) + #--------------------------------------------------------------------------- + def _hnd_signal_wakeup(self, signum, frame): """ - Minimal signal handler - wakeup after sleep/pause. + Signal handler - wakeup after sleep/pause. """ - self.logger.info("Wakeup after pause") + self.logger.info("Received wakeup signal (%s)", signum) def _hnd_signal_hup(self, signum, frame): """ - Minimal signal handler - SIGHUP + Signal handler - **SIGHUP** Implementation of the handler is intentionally brief, actual signal - handling is done via scheduling and handling event 'signal_hup'. + handling is done via scheduling and handling event ``signal_hup``. """ - self.logger.warning("Received signal 'SIGHUP'") - self.queue.schedule_next('signal_hup') + self.logger.warning("Received signal 'SIGHUP' (%s)", signum) + self.queue.schedule_next(self.EVENT_SIGNAL_HUP) def _hnd_signal_usr1(self, signum, frame): """ - Minimal signal handler - SIGUSR1 + Signal handler - **SIGUSR1** Implementation of the handler is intentionally brief, actual signal - handling is done via scheduling and handling event 'signal_usr1'. + handling is done via scheduling and handling event ``signal_usr1``. """ - self.logger.info("Received signal 'SIGUSR1'") - self.queue.schedule_next('signal_usr1') + self.logger.info("Received signal 'SIGUSR1' (%s)", signum) + self.queue.schedule_next(self.EVENT_SIGNAL_USR1) def _hnd_signal_usr2(self, signum, frame): """ - Minimal signal handler - SIGUSR2 + Signal handler - **SIGUSR2** Implementation of the handler is intentionally brief, actual signal - handling is done via scheduling and handling event 'signal_usr2'. + handling is done via scheduling and handling event ``signal_usr2``. """ - self.logger.info("Received signal 'SIGUSR2'") - self.queue.schedule_next('signal_usr2') + self.logger.info("Received signal 'SIGUSR2' (%s)", signum) + self.queue.schedule_next(self.EVENT_SIGNAL_USR2) + #--------------------------------------------------------------------------- + def cbk_event_signal_hup(self, daemon, args = None): """ - Event callback to handle signal - SIGHUP + Event callback for handling signal - **SIGHUP** + + .. todo:: + + In the future this signal should be responsible for soft restart of + daemon process. Currently work in progress. """ self.logger.warning("Handling event for signal 'SIGHUP'") - return (self.FLAG_CONTINUE, None) + return (self.FLAG_CONTINUE, args) def cbk_event_signal_usr1(self, daemon, args = None): """ - Event callback to handle signal - SIGUSR1 + Event callback for handling signal - **SIGUSR1** + + This signal forces the daemon process to save the current runlog to JSON + file. """ self.logger.info("Handling event for signal 'SIGUSR1'") - self.runlog_save(self.runlog) - return (self.FLAG_CONTINUE, None) + self._utils_runlog_save(self.runlog) + return (self.FLAG_CONTINUE, args) def cbk_event_signal_usr2(self, daemon, args = None): """ - Event callback to handle signal - SIGUSR2 + Event callback for handling signal - **SIGUSR2** + + This signal forces the daemon process to save the current state to JSON + file. State is more verbose than runlog and it contains almost all + internal data. """ self.logger.info("Handling event for signal 'SIGUSR2'") if self.c(self.CONFIG_NODAEMON): - self.state_dump(self._get_state()) + self._utils_state_dump(self._get_state()) else: - self.state_save(self._get_state()) - return (self.FLAG_CONTINUE, None) + self._utils_state_save(self._get_state()) + return (self.FLAG_CONTINUE, args) def cbk_event_log_statistics(self, daemon, args): """ Periodical processing statistics logging. """ self.queue.schedule_after(self.c(self.CONFIG_STATS_INTERVAL), self.EVENT_LOG_STATISTICS) - return (self.FLAG_CONTINUE, None) + return (self.FLAG_CONTINUE, args) #--------------------------------------------------------------------------- - def send_signal(self, s): + def send_signal(self, sign): """ - Send given signal to currently running daemon(s). + Send given signal to all currently running daemon(s). """ pid = None try: pidfl = None # PID file list if not self.c(self.CONFIG_PARALEL): - pidfl = [self.get_fn_pidfile()] + pidfl = [self._get_fn_pidfile()] else: - pidfl = self.pidfiles_list() + pidfl = self._pidfiles_list() for pidfn in pidfl: pid = pyzenkit.daemonizer.read_pid(pidfn) if pid: - print("Sending signal '{}' to process '{}' [{}]".format(SIGNALS_TO_NAMES_DICT.get(s, s), pid, pidfn)) - os.kill(pid, s) + print("Sending signal '{}' to process '{}' [{}]".format(SIGNALS_TO_NAMES_DICT.get(sign, sign), pid, pidfn)) + os.kill(pid, sign) except FileNotFoundError: print("PID file '{}' does not exist".format(self.c(self.CONFIG_PID_FILE))) @@ -514,7 +646,7 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): print("Process with PID '{}' does not exist".format(pid)) except PermissionError: - print("Insufficient permissions to send signal '{}' to process '{}'".format(SIGNALS_TO_NAMES_DICT.get(s, s), pid)) + print("Insufficient permissions to send signal '{}' to process '{}'".format(SIGNALS_TO_NAMES_DICT.get(sign, sign), pid)) def cbk_action_signal_check(self): """ @@ -552,15 +684,17 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): """ self.send_signal(signal.SIGUSR2) + #--------------------------------------------------------------------------- + def _get_state(self): """ - + Get current daemon state. """ state = { 'time': time.time(), - 'rc': self.rc, + 'rc': self.retc, 'config': self.config, 'paths': self.paths, 'pstate': self.pstate, @@ -575,7 +709,7 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): def _get_statistics(self): """ - + Get current daemon statistics. """ statistics = { 'time': time.time(), @@ -585,7 +719,17 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): statistics['components'][component.__class__.__name__] = component.get_statistics(self) return statistics - def state_dump(self, state): + def _utils_state_dump(self, state): + """ + Dump current daemon state. + + Dump current daemon state to terminal (JSON). + """ + # Dump current script state. + #self.logger.debug("Current daemon state >>>\n{}".format(json.dumps(state, sort_keys=True, indent=4))) + print("Current daemon state >>>\n{}".format(self.json_dump(state, default=_json_default))) + + def _utils_state_log(self, state): """ Dump current daemon state. @@ -595,20 +739,20 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): #self.logger.debug("Current daemon state >>>\n{}".format(json.dumps(state, sort_keys=True, indent=4))) print("Current daemon state >>>\n{}".format(self.json_dump(state, default=_json_default))) - def state_save(self, state): + def _utils_state_save(self, state): """ Save current daemon state. Save current daemon state to external file (JSON). """ - sfn = self.get_fn_state() - self.dbgout("[STATUS] Saving current daemon state to file '{}'".format(sfn)) + sfn = self._get_fn_state() + self.dbgout("Saving current daemon state to file '{}'".format(sfn)) pprint.pprint(state) - self.dbgout("[STATUS] Current daemon state:\n{}".format(self.json_dump(state, default=_json_default))) + self.dbgout("Current daemon state:\n{}".format(self.json_dump(state, default=_json_default))) self.json_save(sfn, state, default=_json_default) - self.logger.info("Current daemon state saved to file '{}'".format(sfn)) + self.logger.info("Current daemon state saved to file '%s'", sfn) - def pidfiles_list(self, **kwargs): + def _pidfiles_list(self, **kwargs): """ List all available pidfiles. """ @@ -616,38 +760,42 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): pfn = os.path.join(self.paths['run'], '{}*.pid'.format(self.name)) return sorted(glob.glob(pfn), reverse = reverse) - def get_fn_state(self): + def _get_fn_state(self): """ Return the name of the state file for current process. """ if not self.c(self.CONFIG_PARALEL): return self.c(self.CONFIG_STATE_FILE) - else: - fn = re.sub("\.state$",".{:05d}.state".format(os.getpid()), self.c(self.CONFIG_STATE_FILE)) - self.dbgout("[STATUS] Paralel mode: using '{}' as state file".format(fn)) - return fn - def get_fn_pidfile(self): + sfn = re.sub(r'\.state$',".{:05d}.state".format(os.getpid()), self.c(self.CONFIG_STATE_FILE)) + self.dbgout("Paralel mode: using '{}' as state file".format(sfn)) + return sfn + + def _get_fn_pidfile(self): """ Return the name of the pidfile for current process. """ if not self.c(self.CONFIG_PARALEL): return self.c(self.CONFIG_PID_FILE) - else: - fn = re.sub("\.pid$",".{:05d}.pid".format(os.getpid()), self.c(self.CONFIG_PID_FILE)) - self.dbgout("[STATUS] Paralel mode: using '{}' as pid file".format(fn)) - return fn - def get_fn_runlog(self): + pfn = re.sub(r'\.pid$',".{:05d}.pid".format(os.getpid()), self.c(self.CONFIG_PID_FILE)) + self.dbgout("Paralel mode: using '{}' as pid file".format(pfn)) + return pfn + + def _get_fn_runlog(self): """ Return the name of the runlog file for current process. """ if not self.c(self.CONFIG_PARALEL): return os.path.join(self.c(self.CONFIG_RUNLOG_DIR), "{}.runlog".format(self.runlog[self.RLKEY_TSFSF])) - else: - fn = os.path.join(self.c(self.CONFIG_RUNLOG_DIR), "{}.{:05d}.runlog".format(self.runlog[self.RLKEY_TSFSF], os.getpid())) - self.dbgout("[STATUS] Paralel mode: using '{}' as runlog file".format(fn)) - return fn + + rfn = os.path.join(self.c(self.CONFIG_RUNLOG_DIR), "{}.{:05d}.runlog".format(self.runlog[self.RLKEY_TSFSF], os.getpid())) + self.dbgout("Paralel mode: using '{}' as runlog file".format(rfn)) + return rfn + + + #--------------------------------------------------------------------------- + def get_events(self): """ @@ -666,7 +814,7 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): """ period = math.ceil(period) if period > 0: - self.logger.info("Waiting for '{}' seconds until next scheduled event".format(period)) + self.logger.info("Waiting for '%d' seconds until next scheduled event", period) signal.signal(signal.SIGALRM, self._hnd_signal_wakeup) signal.alarm(period) signal.pause() @@ -676,7 +824,7 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): """ Set the DONE flag to True. """ - self.done = True + self.flag_done = True def _daemonize(self): """ @@ -684,14 +832,14 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): """ # Perform full daemonization if not self.c(self.CONFIG_NODAEMON): - self.dbgout("[STATUS] Performing full daemonization") + self.dbgout("Performing full daemonization") self.logger.info("Performing full daemonization") logs = pyzenkit.daemonizer.get_logger_files(self.logger) pyzenkit.daemonizer.daemonize( chroot_dir = self.c(self.CONFIG_CHROOT_DIR), work_dir = self.c(self.CONFIG_WORK_DIR), - pidfile = self.get_fn_pidfile(), + pid_file = self._get_fn_pidfile(), umask = self.c(self.CONFIG_UMASK), files_preserve = logs, signals = { @@ -706,13 +854,13 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): # Perform simple daemonization else: - self.dbgout("[STATUS] Performing simple daemonization") + self.dbgout("Performing simple daemonization") self.logger.info("Performing simple daemonization") pyzenkit.daemonizer.daemonize_lite( chroot_dir = self.c(self.CONFIG_CHROOT_DIR), work_dir = self.c(self.CONFIG_WORK_DIR), - pidfile = self.get_fn_pidfile(), + pid_file = self._get_fn_pidfile(), umask = self.c(self.CONFIG_UMASK), signals = { signal.SIGHUP: self._hnd_signal_hup, @@ -724,13 +872,12 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): self.logger.info("Simple daemonization done") self.runlog[self.RLKEY_PID] = os.getpid() - def _event_loop(self): """ Main event processing loop. """ - self.done = False - while not self.done: + self.flag_done = False + while not self.flag_done: try: (event, args) = self.queue.next() if event: @@ -741,23 +888,18 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): if flag != self.FLAG_CONTINUE: break else: - w = self.queue.wait() - if w > 0: - self.wait(w) - pass + wait_time = self.queue.wait() + if wait_time > 0: + self.wait(wait_time) + except QueueEmptyException: self.logger.info("Event queue is empty, terminating") - self.done = True - pass + self.flag_done = True - def stage_process(self): + def _sub_stage_process(self): """ - Script lifecycle stage: PROCESSING - - Perform some real work (finally). Following method will call appropriate - callback method operation to service the selected operation. + **SUBCLASS HOOK**: Perform some actual processing in **process** stage. """ - self.time_mark('stage_process_start', 'Start of the processing stage') try: self._daemonize() @@ -776,12 +918,10 @@ class ZenDaemon(pyzenkit.baseapp.BaseApp): self.error("ZenAppException: {}".format(exc)) except: - (t, v, tb) = sys.exc_info() - self.error("Exception: {}".format(v), tb = tb) + (exct, excv, exctb) = sys.exc_info() + self.error("Exception: {}".format(excv), trcb = exctb) - self.time_mark('stage_process_stop', 'End of the processing stage') - -class _DemoDaemonComponent(ZenDaemonComponent): +class DemoDaemonComponent(ZenDaemonComponent): """ Minimalistic class for demonstration purposes. """ @@ -803,31 +943,44 @@ class _DemoDaemonComponent(ZenDaemonComponent): time.sleep(1) return (daemon.FLAG_CONTINUE, None) -class _DemoZenDaemon(ZenDaemon): +class DemoZenDaemon(ZenDaemon): """ Minimalistic class for demonstration purposes. """ - pass +#------------------------------------------------------------------------------- + +# +# Perform the demonstration. +# if __name__ == "__main__": - """ - Perform the demonstration. - """ - # Prepare the environment - if not os.path.isdir('/tmp/zendaemon.py'): - os.mkdir('/tmp/zendaemon.py') - pyzenkit.baseapp.BaseApp.json_save('/tmp/zendaemon.py.conf', {'test_a':1}) - - daemon = _DemoZenDaemon( - path_cfg = '/tmp', - path_log = '/tmp', - path_tmp = '/tmp', - path_run = '/tmp', - description = 'DemoZenDaemon - generic daemon (DEMO)', - schedule = [('default',)], - components = [ - _DemoDaemonComponent() - ] - ) - daemon.run() + + # Prepare demonstration environment. + pyzenkit.baseapp.BaseApp.json_save('/tmp/demo-zendaemon.py.conf', {'test_a':1}) + try: + os.mkdir('/tmp/demo-zendaemon.py') + except FileExistsError: + pass + + ZENDAEMON = DemoZenDaemon( + name = 'demo-zenscript.py', + description = 'DemoZenDaemon - Demonstration daemon', + + # + # Configure required application paths to harmless locations. + # + path_bin = '/tmp', + path_cfg = '/tmp', + path_log = '/tmp', + path_tmp = '/tmp', + path_run = '/tmp', + + default_no_daemon = True, + + schedule = [('default',)], + components = [ + DemoDaemonComponent() + ] + ) + ZENDAEMON.run() diff --git a/pyzenkit/zenscript.py b/pyzenkit/zenscript.py index d30853ba0294140d2c24d6856d7e3217d8a844cc..4c1f7e9fc2f9b71842cf634cd1b80f37af0230a3 100644 --- a/pyzenkit/zenscript.py +++ b/pyzenkit/zenscript.py @@ -1,36 +1,93 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # -*- coding: utf-8 -*- #------------------------------------------------------------------------------- -# Copyright (C) since 2016 Jan Mach <honza.mach.ml@gmail.com> -# Use of this source is governed by the MIT license, see LICENSE file. +# This file is part of PyZenKit package. +# +# Copyright (C) since 2016 CESNET, z.s.p.o (http://www.ces.net/) +# Copyright (C) since 2015 Jan Mach <honza.mach.ml@gmail.com> +# Use of this package is governed by the MIT license, see LICENSE file. +# +# This project was initially written for personal use of the original author. Later +# it was developed much further and used for project of author`s employer. #------------------------------------------------------------------------------- + """ -Base implementation of generic one time execution script with cron support. +This module provides base implementation of generic script with built-in support +for regular executions. It builds on top of :py:mod:`pyzenkit.baseapp` module and +adds couple of other usefull features: + +* Support for executing multiple different **commands**. +* Support for regular executions. + + +Script commands +^^^^^^^^^^^^^^^ + +Every script provides support for more, possibly similar, commands to be implemented +within one script. + + +Script execution modes +^^^^^^^^^^^^^^^^^^^^^^ + +Script execution supports following modes: + +* **regular** +* **shell** +* **default** + +In a **regular** mode the script is intended to be executed in regular time intervals +from a *cron-like* service. The internal application configuration is forced into +following state: + +* Console output is explicitly suppressed +* Console logging level is explicitly forced to 'warning' level +* Logging to log file is explicitly forced to be enabled +* Runlog saving is explicitly forced to be enabled +* Persistent state saving is explicitly forced to be enabled + +In a **shell** mode the script is intended to be executed by hand from interactive +shell. It is intended to be used for debugging or experimental purposes and the +internal application configuration is forced into following state: + +* Logging to log file is explicitly suppressed +* Runlog saving is explicitly suppressed +* Persistent state saving is explicitly suppressed + + +Module contents +^^^^^^^^^^^^^^^ + +* :py:class:`ZenScriptException` +* :py:class:`ZenScript` +* :py:class:`DemoZenScript` """ + +__author__ = "Jan Mach <honza.mach.ml@gmail.com>" + + import os import re -import sys -import json import time -import math -import subprocess -import pprint - -# Generate the path to custom 'lib' directory -lib = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) -sys.path.insert(0, lib) +import datetime # # Custom libraries. # import pyzenkit.baseapp + # # Predefined constants for runtime intervals # RUN_INTERVALS = { + '5_minutes': 300, + '10_minutes': 600, + '15_minutes': 900, + '20_minutes': 1200, + '30_minutes': 1800, 'hourly': 3600, '2_hourly': (2*3600), '3_hourly': (3*3600), @@ -43,185 +100,315 @@ RUN_INTERVALS = { '4_weekly': (28*86400), } -class ZenScriptException(pyzenkit.baseapp.ZenAppException): +RE_TIMESTAMP = re.compile(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt ]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?([Zz]|(?:[+-][0-9]{2}:[0-9]{2}))$") + + +#------------------------------------------------------------------------------- + + +def t_datetime(val): + """ + Convert/validate datetime. The value received by this conversion function may + be either ``datetime.datetime`` object (in that case no action will be done), + unix timestamp as ``float`` or datetime as RFC3339 string. + + :param any val: Value to be converted/validated + :return: Datetime object + :rtype: datetime.datetime + :raises ValueError: if the value could not be converted to datetime.datetime object + """ + # Maybe there is nothing to do + if isinstance(val, datetime.datetime): + return val + + # Try numeric type + try: + return datetime.datetime.fromtimestamp(float(val)) + except (TypeError, ValueError): + pass + # Try RFC3339 string + res = RE_TIMESTAMP.match(val) + if res is not None: + year, month, day, hour, minute, second = (int(n or 0) for n in res.group(*range(1, 7))) + us_str = (res.group(7) or '0')[:6].ljust(6, '0') + us_int = int(us_str) + zonestr = res.group(8) + zonespl = (0, 0) if zonestr in ['z', 'Z'] else [int(i) for i in zonestr.split(':')] + zonediff = datetime.timedelta(minutes = zonespl[0]*60+zonespl[1]) + return datetime.datetime(year, month, day, hour, minute, second, us_int) - zonediff + else: + raise ValueError("Invalid datetime '{:s}'".format(val)) + + +#------------------------------------------------------------------------------- + + +class ZenScriptException(pyzenkit.baseapp.ZenAppProcessException): """ Describes problems specific to scripts. """ pass + +#------------------------------------------------------------------------------- + + class ZenScript(pyzenkit.baseapp.BaseApp): """ - Base implementation of generic one time execution script with cron support. + Base implementation of generic one-time execution script with built-in regular + execution interval support. """ # # Class constants. # - # String patterns + # String patterns. PTRN_COMMAND_CBK = 'cbk_command_' - # List of possible configuration keys. - CONFIG_REGULAR = 'regular' - CONFIG_SHELL = 'shell' - CONFIG_INTERVAL = 'interval' - CONFIG_COMMAND = 'command' + # List of configuration keys. + CONFIG_REGULAR = 'regular' + CONFIG_SHELL = 'shell' + CONFIG_COMMAND = 'command' + CONFIG_INTERVAL = 'interval' + CONFIG_ADJUST_THRESHOLDS = 'adjust_thresholds' + CONFIG_TIME_HIGH = 'time_high' + + # List of runlog keys. + RLKEY_COMMAND = 'command' + + + #--------------------------------------------------------------------------- + def _init_argparser(self, **kwargs): """ - Initialize script command line argument parser. + Initialize application command line argument parser. This method overrides + the base implementation in :py:func:`baseapp.BaseApp._init_argparser` and + it must return valid :py:class:`argparse.ArgumentParser` object. + + Gets called from main constructor :py:func:`BaseApp.__init__`. + + :param kwargs: Various additional parameters passed down from constructor. + :return: Initialized argument parser object. + :rtype: argparse.ArgumentParser """ argparser = super()._init_argparser(**kwargs) - # Setup mutually exclusive group. - group_a = argparser.add_mutually_exclusive_group() - # Option flag indicating that the script was executed via CRON tool. - # This option will make sure, that no output will be produced to terminal. - group_a.add_argument('--regular', help = 'regular script execution (flag)', action='store_true', default = None) + # + # Create and populate options group for common script arguments. + # + arggroup_script = argparser.add_argument_group('common script arguments') - # Option flag indicating that the script was executed manually via terminal. - # This optional will make sure, that no changes will be made in 'log', - # 'runlog' or 'state' files. - group_a.add_argument('--shell', help = 'manual script execution from shell (flag)', action = 'store_true', default = None) + # Setup mutually exclusive group for regular x shell mode option. + group_a = arggroup_script.add_mutually_exclusive_group() - # Option for setting the interval for regular script runs. - argparser.add_argument('--interval', help = 'define interval for regular executions', choices = RUN_INTERVALS.keys()) + group_a.add_argument('--regular', help = 'operational mode: regular script execution (flag)', action='store_true', default = None) + group_a.add_argument('--shell', help = 'operational mode: manual script execution from shell (flag)', action = 'store_true', default = None) - # Option for setting the desired command. - argparser.add_argument('--command', help = 'choose which command should be performed', choices = self._utils_detect_commands()) + arggroup_script.add_argument('--command', help = 'name of the script command to be executed', choices = self._utils_detect_commands(), type = str, default = None) + arggroup_script.add_argument('--interval', help = 'time interval for regular executions', choices = RUN_INTERVALS.keys(), type = str, default = None) + arggroup_script.add_argument('--adjust-thresholds', help = 'round-up time interval threshols to interval size (flag)', action = 'store_true', default = None) + arggroup_script.add_argument('--time-high', help = 'upper time interval threshold', type = float, default = None) return argparser - def _init_config(self, **kwargs): - """ - Initialize script configurations to default values. + def _init_config(self, cfgs, **kwargs): """ - config = super()._init_config(**kwargs) + Initialize default application configurations. This method overrides the + base implementation in :py:func:`baseapp.BaseApp._init_argparser` and it + adds additional configurations via ``cfgs`` parameter. + + Gets called from main constructor :py:func:`BaseApp.__init__`. + :param list cfgs: Additional set of configurations. + :param kwargs: Various additional parameters passed down from constructor. + :return: Default configuration structure. + :rtype: dict + """ cfgs = ( - (self.CONFIG_REGULAR, False), - (self.CONFIG_SHELL, False), - (self.CONFIG_INTERVAL, None), - (self.CONFIG_COMMAND, self.get_default_command()), - ) - for c in cfgs: - config[c[0]] = kwargs.pop('default_' + c[0], c[1]) - return config + (self.CONFIG_REGULAR, False), + (self.CONFIG_SHELL, False), + (self.CONFIG_INTERVAL, None), + (self.CONFIG_COMMAND, self.get_default_command()), + (self.CONFIG_ADJUST_THRESHOLDS, False), + (self.CONFIG_TIME_HIGH, time.time()), + ) + cfgs + return super()._init_config(cfgs, **kwargs) + + def _configure_postprocess(self): + """ + Perform configuration postprocessing and calculate core configurations. + This method overrides the base implementation in :py:func:`baseapp.BaseApp._configure_postprocess`. + + Gets called from :py:func:`BaseApp._stage_setup_configuration`. + """ + super()._configure_postprocess() + + if self.c(self.CONFIG_SHELL): + self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOFILE] = False + self.dbgout("Logging to log file is explicitly suppressed by '--shell' configuration") + + self.config[self.CORE][self.CORE_RUNLOG][self.CORE_RUNLOG_SAVE] = False + self.dbgout("Runlog saving is explicitly suppressed by '--shell' configuration") + + self.config[self.CORE][self.CORE_PSTATE][self.CORE_PSTATE_SAVE] = False + self.dbgout("Persistent state saving is explicitly suppressed by '--shell' configuration") + + elif self.c(self.CONFIG_REGULAR): + self.config[self.CONFIG_QUIET] = True + self.dbgout("Console output is explicitly suppressed by '--regular' configuration") + + self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_LEVELC] = 'WARNING' + self.dbgout("Console logging level is explicitly forced to 'warning' by '--regular' configuration") + + self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOFILE] = True + self.dbgout("Logging to log file is explicitly forced by '--regular' configuration") + + self.config[self.CORE][self.CORE_RUNLOG][self.CORE_RUNLOG_SAVE] = True + self.dbgout("Runlog saving is explicitly forced by '--regular' configuration") + + self.config[self.CORE][self.CORE_PSTATE][self.CORE_PSTATE_SAVE] = True + self.dbgout("Persistent state saving is explicitly forced by '--regular' configuration") + + else: + self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOFILE] = True + self.config[self.CORE][self.CORE_RUNLOG][self.CORE_RUNLOG_SAVE] = True + self.config[self.CORE][self.CORE_PSTATE][self.CORE_PSTATE_SAVE] = True + + def _sub_stage_process(self): + """ + **SUBCLASS HOOK**: Perform some actual processing in **process** stage. + """ + # Determine, which command to execute. + cmdname = self.c(self.CONFIG_COMMAND) + self.runlog[self.RLKEY_COMMAND] = cmdname + + # Execute. + self.execute_script_command(cmdname) + #--------------------------------------------------------------------------- + def _utils_detect_commands(self): """ Returns the sorted list of all available commands current script is capable of performing. The detection algorithm is based on string analysis of all - available methods. Any method starting with string 'cbk_command_' will - be appended to the list, lowercased and with '_' characters replaced with '-'. + available methods. Any method starting with string ``cbk_command_`` will + be appended to the list, lowercased and with ``_`` characters replaced with ``-``. """ ptrn = re.compile(self.PTRN_COMMAND_CBK) attrs = sorted(dir(self)) result = [] - for a in attrs: - if not callable(getattr(self, a)): + for atr in attrs: + if not callable(getattr(self, atr)): continue - match = ptrn.match(a) + match = ptrn.match(atr) if match: - result.append(a.replace(self.PTRN_COMMAND_CBK,'').replace('_','-').lower()) + result.append(atr.replace(self.PTRN_COMMAND_CBK,'').replace('_','-').lower()) return result + + #--------------------------------------------------------------------------- + + def get_default_command(self): """ - Return the name of the default operation. This method must be present and - overriden in subclass and must return the name of desired default operation. - Following code is just a reminder for programmer to not forget to implement + Return the name of the default command. This method must be present and + overriden in subclass and must return the name of desired default command. + Following code is just a reminder for developer to not forget to implement this method. - """ - raise Exception("get_default_command() method must be implemented in subclass") - def calculate_interval_thresholds(self, thr_type = 'daily', time_cur = None, flag_floor = False, merge_count = 1, skip_count = 0, last_ts = None): + :return: Name of the default command. + :rtype: str """ - Calculate time thresholds based on following optional arguments: - """ - if not thr_type in RUN_INTERVALS: - raise Exception("Invalid threshold interval '{}'".format(thr_type)) - interval = RUN_INTERVALS[thr_type] - - time_l = 0 # Lower threshold. - time_h = 0 # Upper threshold. - - # Define the upper interval threshold as current timestamp, or use the - # one given as argument. - time_h = time_cur - if not time_h: - time_h = math.floor(time.time()); - - # Adjust the upper interval threshold. - if flag_floor: - time_h = time_h - (time_h % interval) + raise NotImplementedError("This method must be implemented in subclass") - # Calculate the lower time threshold. - time_l = time_h - interval - - return (time_l, time_h); + def execute_script_command(self, command_name): + """ + Execute given script command and store the received results into script runlog. - #--------------------------------------------------------------------------- + Following method will call appropriate callback method to service the + requested script command. - def _configure_postprocess(self): - """ - Setup internal script core mechanics. + Name of the callback method is generated from the name of the command by + prepending string ``cbk_command_`` and replacing all ``-`` with ``_``. """ - super()._configure_postprocess() - - if self.c(self.CONFIG_SHELL): - self.dbgout("[STATUS] Logging to log file is suppressed via '--shell' configuration") - self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOFILE] = False - self.dbgout("[STATUS] Runlog saving is suppressed via '--shell' configuration") - self.config[self.CORE][self.CORE_RUNLOG][self.CORE_RUNLOG_SAVE] = False - self.dbgout("[STATUS] Persistent state saving is suppressed via '--shell' configuration") - self.config[self.CORE][self.CORE_PSTATE][self.CORE_PSTATE_SAVE] = False + command_name = command_name.lower().replace('-','_') + command_cbkname = '{}{}'.format(self.PTRN_COMMAND_CBK, command_name) + self.dbgout("Executing callback '{}' for script command '{}'".format(command_cbkname, command_name)) + + cbk = getattr(self, command_cbkname, None) + if cbk: + self.logger.info("Executing script command '%s'", command_name) + self.runlog[command_name] = cbk() else: - self.config[self.CORE][self.CORE_LOGGING][self.CORE_LOGGING_TOFILE] = True - self.config[self.CORE][self.CORE_RUNLOG][self.CORE_RUNLOG_SAVE] = True - self.config[self.CORE][self.CORE_PSTATE][self.CORE_PSTATE_SAVE] = True + raise ZenScriptException("Invalid script command '{}', callback '{}' does not exist".format(command_name, command_cbkname)) - def stage_process(self): + def calculate_interval_thresholds(self, time_high = None, interval = 'daily', adjust = False): """ - Script lifecycle stage: PROCESSING - - Perform some real work (finally). Following method will call appropriate - callback method operation to service the selected operation. + Calculate time interval thresholds based on given upper time interval boundary and + time interval size. + + :param int time_high: Unix timestamp for upper time threshold. + :param str interval: Time interval, one of the interval defined in :py:mod:`pyzenkit.zenscript`. + :param bool adjust: Adjust time thresholds to round values (floor). + :return: Lower and upper time interval boundaries. + :rtype: tuple of datetime.datetime """ - self.time_mark('stage_process_start', 'Start of the processing stage') + if interval not in RUN_INTERVALS: + raise ValueError("Invalid time interval '{}', valid values are: '{}'".format(interval, ','.join(RUN_INTERVALS.keys()))) + interval_delta = RUN_INTERVALS[interval] - try: - # Determine, which command to execute. - self.runlog[self.RLKEY_COMMAND] = self.c(self.CONFIG_COMMAND) - opname = self.c(self.CONFIG_COMMAND) - opcbkname = self.PTRN_COMMAND_CBK + opname.lower().replace('-','_') - self.logger.debug("Performing script command '{}' with method '{}'".format(opname, opcbkname)) + if not time_high: + time_high = time.time() - cbk = getattr(self, opcbkname, None) - if cbk: - self.logger.info("Executing command '{}'".format(opname)) - self.runlog[opname] = cbk() - else: - raise pyzenkit.baseapp.ZenAppProcessException("Invalid command '{}', callback '{}' does not exist".format(opname, opcbkname)) + time_high = t_datetime(time_high) + time_low = time_high - datetime.timedelta(seconds=interval_delta) + self.logger.debug("Calculated time interval thresholds: '%s' -> '%s' (%s, %i -> %i)", str(time_low), str(time_high), interval, time_low.timestamp(), time_high.timestamp()) - except subprocess.CalledProcessError as err: - self.error("System command error: {}".format(err)) + if adjust: + ts_h = t_datetime(time_high.timestamp() - (time_high.timestamp() % interval_delta)) + ts_l = ts_h - datetime.timedelta(seconds=interval_delta) + time_high = ts_h + time_low = ts_l + self.logger.debug("Adjusted time interval thresholds: '%s' -> '%s' (%s, %i -> %i)", str(time_low), str(time_high), interval, time_low.timestamp(), time_high.timestamp()) - except pyzenkit.baseapp.ZenAppProcessException as exc: - self.error("ZenAppProcessException: {}".format(exc)) + return (time_low, time_high) - except pyzenkit.baseapp.ZenAppException as exc: - self.error("ZenAppException: {}".format(exc)) - self.time_mark('stage_process_stop', 'End of the processing stage') - -class _DemoZenScript(ZenScript): +class DemoZenScript(ZenScript): """ Minimalistic class for demonstration purposes. """ + def __init__(self, name = None, description = None): + """ + Initialize demonstration script. This method overrides the base + implementation in :py:func:`baseapp.BaseApp.__init__` and it aims to + even more simplify the script object creation. + + :param str name: Optional script name. + :param str description: Optional script description. + """ + name = 'demo-zenscript.py' if not name else name + description = 'DemoZenScript - Demonstration script' if not description else description + + super().__init__( + name = name, + description = description, + + # + # Configure required application paths to harmless locations. + # + path_bin = '/tmp', + path_cfg = '/tmp', + path_log = '/tmp', + path_tmp = '/tmp', + path_run = '/tmp' + ) + def get_default_command(self): """ Return the name of a default script operation. @@ -230,36 +417,67 @@ class _DemoZenScript(ZenScript): def cbk_command_default(self): """ - Default script operation. + Default script command. """ - # Log something to show we have reached this point of execution. - self.logger.info("Demo implementation for default command") + # Update the persistent state to view the changes. + self.pstate['counter'] = self.pstate.get('counter', 0) + 1 - # Test direct console output with conjunction with verbosity + # Log something to show we have reached this point of execution. + self.logger.info("Demonstration implementation for default script command") + self.logger.info("Try executing this demo with following parameters:") + self.logger.info("* python3 pyzenkit/zenscript.py --help") + self.logger.info("* python3 pyzenkit/zenscript.py --verbose") + self.logger.info("* python3 pyzenkit/zenscript.py --verbose --verbose") + self.logger.info("* python3 pyzenkit/zenscript.py --verbose --verbose --verbose") + self.logger.info("* python3 pyzenkit/zenscript.py --debug") + self.logger.info("* python3 pyzenkit/zenscript.py --log-level debug") + self.logger.info("* python3 pyzenkit/zenscript.py --pstate-dump") + self.logger.info("* python3 pyzenkit/zenscript.py --runlog-dump") + self.logger.info("* python3 pyzenkit/zenscript.py --command alternative") + self.logger.info("Number of runs from persistent state: '%d'", self.pstate.get('counter')) + + # Test direct console output with conjunction with verbosity levels. self.p("Hello world") self.p("Hello world, verbosity level 1", 1) self.p("Hello world, verbosity level 2", 2) self.p("Hello world, verbosity level 3", 3) + return { 'result': self.RESULT_SUCCESS, 'data': 5 } + + def cbk_command_alternative(self): + """ + Alternative script command. + """ # Update the persistent state to view the changes. self.pstate['counter'] = self.pstate.get('counter', 0) + 1 - return self.RESULT_SUCCESS + # Log something to show we have reached this point of execution. + self.logger.info("Demonstration implementation for alternative script command") + self.logger.info("Number of runs from persistent state: '%d'", self.pstate.get('counter')) + # Test direct console output with conjunction with verbosity levels. + self.p("Hello world") + self.p("Hello world, verbosity level 1", 1) + self.p("Hello world, verbosity level 2", 2) + self.p("Hello world, verbosity level 3", 3) + + return { 'result': self.RESULT_SUCCESS, 'data': 100 } + + +#------------------------------------------------------------------------------- + +# +# Perform the demonstration. +# if __name__ == "__main__": - """ - Perform the demonstration. - """ - # Prepare the environment - if not os.path.isdir('/tmp/zenscript.py'): - os.mkdir('/tmp/zenscript.py') - pyzenkit.baseapp.BaseApp.json_save('/tmp/zenscript.py.conf', {'test_a':1}) - script = _DemoZenScript( - path_cfg = '/tmp', - path_log = '/tmp', - path_tmp = '/tmp', - path_run = '/tmp', - description = 'DemoZenScript - generic script (DEMO)' - ) - script.run() + # Prepare demonstration environment. + SCR_NAME = 'demo-zenscript.py' + pyzenkit.baseapp.BaseApp.json_save('/tmp/{}.conf'.format(SCR_NAME), {'test_a':1}) + try: + os.mkdir('/tmp/{}'.format(SCR_NAME)) + except FileExistsError: + pass + + ZENSCRIPT = DemoZenScript(SCR_NAME) + ZENSCRIPT.run() diff --git a/setup.py b/setup.py index 33d82ce223c4d4c74f7b4372fd1746ae2f9720d2..d0a5c1de22e94e414b34537186f7f5d5d3271965 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ with open(path.join(here, 'README.rst'), encoding='utf-8') as f: setup( name = 'pyzenkit', - version = '0.20', + version = '0.32', description = 'Python 3 script and daemon toolkit', long_description = long_description, classifiers = [