cli.py 12.5 KB
Newer Older
1
#!/usr/bin/env python
Guillaume BINET's avatar
Guillaume BINET committed
2

3 4 5
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
Guillaume BINET's avatar
Guillaume BINET committed
6 7 8 9 10 11 12 13 14 15 16
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program; if not, write to the Free Software
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

17
import argparse
18
import locale
19
import logging
20
import os
21
import sys
Guillaume Binet's avatar
Guillaume Binet committed
22 23
from os import path, sep, getcwd, access, W_OK
from platform import system
Pedro Rodrigues's avatar
Pedro Rodrigues committed
24
import ast
25

26
from errbot.logs import root_logger
27 28
from errbot.plugin_wizard import new_plugin_wizard
from errbot.version import VERSION
29

30 31
log = logging.getLogger(__name__)

Guillaume Binet's avatar
Guillaume Binet committed
32

Nick Groenen's avatar
Nick Groenen committed
33
# noinspection PyUnusedLocal
34 35 36
def debug(sig, frame):
    """Interrupt running process, and provide a python prompt for
    interactive debugging."""
Guillaume Binet's avatar
Guillaume Binet committed
37
    d = {'_frame': frame}  # Allow access to frame object.
38 39 40 41
    d.update(frame.f_globals)  # Unless shadowed by global
    d.update(frame.f_locals)

    i = code.InteractiveConsole(d)
42
    message = 'Signal received : entering python shell.\nTraceback:\n'
43 44 45
    message += ''.join(traceback.format_stack(frame))
    i.interact(message)

Nick Groenen's avatar
Nick Groenen committed
46

47
ON_WINDOWS = system() == 'Windows'
48

49 50
if not ON_WINDOWS:
    from daemonize import Daemonize
51 52 53
    import code
    import traceback
    import signal
54

55
    signal.signal(signal.SIGUSR1, debug)  # Register handler for debugging
56

57

58
def get_config(config_path):
59
    config_fullpath = config_path
60
    if not path.exists(config_fullpath):
61 62 63 64 65
        log.error(f'I cannot find the config file {config_path}.')
        log.error('You can change this path with the -c parameter see --help')
        log.info(f'You can use the template {os.path.realpath(os.path.join(__file__, os.pardir, "config-template.py"))}'
                 f' as a base and copy it to {config_path}.')
        log.info('You can then customize it.')
66 67 68
        exit(-1)

    try:
69
        config = __import__(path.splitext(path.basename(config_fullpath))[0])
Guillaume Binet's avatar
Guillaume Binet committed
70 71 72
        log.info('Config check passed...')
        return config
    except Exception:
73
        log.exception(f'I could not import your config from {config_fullpath}, please check the error below...')
74 75
        exit(-1)

76

77 78
def _read_dict():
    import collections
Pedro Rodrigues's avatar
Pedro Rodrigues committed
79
    new_dict = ast.literal_eval(sys.stdin.read())
80
    if not isinstance(new_dict, collections.Mapping):
81 82
        raise ValueError(f'A dictionary written in python is needed from stdin. '
                         f'Type={type(new_dict)}, Value = {repr(new_dict)}.')
83 84 85
    return new_dict


86
def main():
87 88 89

    execution_dir = getcwd()

90
    # By default insert the execution path (useful to be able to execute Errbot from
Nick Groenen's avatar
Nick Groenen committed
91
    # the source tree directly without installing it.
92 93
    sys.path.insert(0, execution_dir)

mr.Shu's avatar
mr.Shu committed
94
    parser = argparse.ArgumentParser(description='The main entry point of the errbot.')
Nick Groenen's avatar
Nick Groenen committed
95
    parser.add_argument('-c', '--config', default=None,
96
                        help='Full path to your config.py (default: config.py in current working directory).')
97 98

    mode_selection = parser.add_mutually_exclusive_group()
99
    mode_selection.add_argument('-v', '--version', action='version', version=f'Errbot version {VERSION}')
100 101 102
    mode_selection.add_argument('-r', '--restore', nargs='?', default=None, const='default',
                                help='restore a bot from backup.py (default: backup.py from the bot data directory)')
    mode_selection.add_argument('-l', '--list', action='store_true', help='list all available backends')
103 104
    mode_selection.add_argument('--new-plugin', nargs='?', default=None, const='current_dir',
                                help='create a new plugin in the specified directory')
105 106 107 108 109 110 111 112
    mode_selection.add_argument('-i', '--init',
                                nargs='?',
                                default=None,
                                const='.',
                                help='Initialize a simple bot minimal configuration in the optionally '
                                     'given directory (otherwise it will be the working directory). '
                                     'This will create a data subdirectory for the bot data dir and a plugins directory'
                                     ' for your plugin development with an example in it to get you started.')
113 114 115 116 117 118
    # storage manipulation
    mode_selection.add_argument('--storage-set', nargs=1, help='DANGER: Delete the given storage namespace '
                                                               'and set the python dictionary expression '
                                                               'passed on stdin.')
    mode_selection.add_argument('--storage-merge', nargs=1, help='DANGER: Merge in the python dictionary expression '
                                                                 'passed on stdin into the given storage namespace.')
Guillaume Binet's avatar
Guillaume Binet committed
119
    mode_selection.add_argument('--storage-get', nargs=1, help='Dump the given storage namespace in a '
120 121 122
                                                               'format compatible for --storage-set and '
                                                               '--storage-merge.')

123 124 125 126
    mode_selection.add_argument('-T', '--text', dest="backend", action='store_const', const="Text",
                                help='force local text backend')
    mode_selection.add_argument('-G', '--graphic', dest="backend", action='store_const', const="Graphic",
                                help='force local graphical backend')
127

128
    if not ON_WINDOWS:
129
        option_group = parser.add_argument_group('optional daemonization arguments')
130
        option_group.add_argument('-d', '--daemon', action='store_true', help='Detach the process from the console')
Nick Groenen's avatar
Nick Groenen committed
131 132
        option_group.add_argument('-p', '--pidfile', default=None,
                                  help='Specify the pid file for the daemon (default: current bot data directory)')
133

134
    args = vars(parser.parse_args())  # create a dictionary of args
135

136 137 138 139 140 141 142
    if args['init']:
        try:
            import jinja2
            import shutil
            base_dir = os.getcwd() if args['init'] == '.' else args['init']

            if not os.path.isdir(base_dir):
143
                print(f'Target directory {base_dir} must exist. Please create it.')
144

145
            base_dir = os.path.abspath(base_dir)
146 147 148 149 150
            data_dir = os.path.join(base_dir, 'data')
            extra_plugin_dir = os.path.join(base_dir, 'plugins')
            example_plugin_dir = os.path.join(extra_plugin_dir, 'err-example')
            log_path = os.path.join(base_dir, 'errbot.log')
            templates_dir = os.path.join(os.path.dirname(__file__), 'templates', 'initdir')
Pedro Rodrigues's avatar
Pedro Rodrigues committed
151
            env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
            config_template = env.get_template('config.py.tmpl')

            os.mkdir(data_dir)
            os.mkdir(extra_plugin_dir)
            os.mkdir(example_plugin_dir)

            with open(os.path.join(base_dir, 'config.py'), 'w') as f:
                f.write(config_template.render(data_dir=data_dir,
                                               extra_plugin_dir=extra_plugin_dir,
                                               log_path=log_path))
            shutil.copyfile(os.path.join(templates_dir, 'example.plug'),
                            os.path.join(example_plugin_dir, 'example.plug'))
            shutil.copyfile(os.path.join(templates_dir, 'example.py'), os.path.join(example_plugin_dir, 'example.py'))
            print('Your Errbot directory has been correctly initialized !')
            if base_dir == os.getcwd():
                print('Just do "errbot" and it should start in text/development mode.')
            else:
169
                print(f'Just do "cd {args["init"]}" then "errbot" and it should start in text/development mode.')
170 171
            sys.exit(0)
        except Exception as e:
172
            print(f'The initialization of your errbot directory failed: {e}.')
173
            sys.exit(1)
174

175 176 177 178
    # This must come BEFORE the config is loaded below, to avoid printing
    # logs as a side effect of config loading.
    if args['new_plugin']:
        directory = os.getcwd() if args['new_plugin'] == "current_dir" else args['new_plugin']
179
        for handler in logging.getLogger().handlers:
180
            root_logger.removeHandler(handler)
181 182 183 184
        try:
            new_plugin_wizard(directory)
        except KeyboardInterrupt:
            sys.exit(1)
185 186 187
        except Exception as e:
            sys.stderr.write(str(e) + "\n")
            sys.exit(1)
188 189 190
        finally:
            sys.exit(0)

191
    config_path = args['config']
192
    # setup the environment to be able to import the config.py
193
    if config_path:
194 195
        # appends the current config in order to find config.py
        sys.path.insert(0, path.dirname(path.abspath(config_path)))
196
    else:
197
        config_path = execution_dir + sep + 'config.py'
198

199 200
    config = get_config(config_path)  # will exit if load fails
    if args['list']:
201
        from errbot.bootstrap import enumerate_backends
202 203
        print('Available backends:')
        for backend_name in enumerate_backends(config):
204
            print(f'\t\t{backend_name}')
205 206
        sys.exit(0)

207 208
    def storage_action(namespace, fn):
        # Used to defer imports until it is really necessary during the loading time.
209
        from errbot.bootstrap import get_storage_plugin
210 211 212 213 214 215 216 217 218 219 220
        from errbot.storage import StoreMixin
        try:
            with StoreMixin() as sdm:
                sdm.open_storage(get_storage_plugin(config), namespace)
                fn(sdm)
            return 0
        except Exception as e:
            print(str(e), file=sys.stderr)
            return -3

    if args['storage_get']:
Guillaume Binet's avatar
Guillaume Binet committed
221 222 223
        def p(sdm):
            print(repr(dict(sdm)))
        err_value = storage_action(args['storage_get'][0], p)
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
        sys.exit(err_value)

    if args['storage_set']:
        def replace(sdm):
            new_dict = _read_dict()  # fail early and don't erase the storage if the input is invalid.
            sdm.clear()
            sdm.update(new_dict)
        err_value = storage_action(args['storage_set'][0], replace)
        sys.exit(err_value)

    if args['storage_merge']:
        def merge(sdm):
            new_dict = _read_dict()
            sdm.update(new_dict)
        err_value = storage_action(args['storage_merge'][0], merge)
        sys.exit(err_value)

241
    if args['restore']:
242
        backend = 'Null'  # we don't want any backend when we restore
243 244 245 246
    elif args['backend'] is None:
        if not hasattr(config, 'BACKEND'):
            log.fatal("The BACKEND configuration option is missing in config.py")
            sys.exit(1)
Guillaume Binet's avatar
Guillaume Binet committed
247
        backend = config.BACKEND
248
    else:
249
        backend = args['backend']
250

251
    log.info(f'Selected backend {backend}.')
252

253 254
    # Check if at least we can start to log something before trying to start
    # the bot (esp. daemonize it).
Nick Groenen's avatar
Nick Groenen committed
255

256
    log.info(f'Checking for {config.BOT_DATA_DIR}...')
257
    if not path.exists(config.BOT_DATA_DIR):
258
        raise Exception(f'The data directory "{config.BOT_DATA_DIR}" for the bot does not exist.')
259
    if not access(config.BOT_DATA_DIR, W_OK):
260
        raise Exception(f'The data directory "{config.BOT_DATA_DIR}" should be writable for the bot.')
261

262
    if (not ON_WINDOWS) and args['daemon']:
263
        if args['backend'] == 'Text':
264
            raise Exception('You cannot run in text and daemon mode at the same time')
265 266
        if args['restore']:
            raise Exception('You cannot restore a backup in daemon mode.')
267 268 269
        if args['pidfile']:
            pid = args['pidfile']
        else:
270
            pid = config.BOT_DATA_DIR + sep + 'err.pid'
271

Nick Groenen's avatar
Nick Groenen committed
272
        # noinspection PyBroadException
273
        try:
274
            def action():
275 276
                from errbot.bootstrap import bootstrap
                bootstrap(backend, root_logger, config)
Nick Groenen's avatar
Nick Groenen committed
277

278
            daemon = Daemonize(app="err", pid=pid, action=action, chdir=os.getcwd())
279
            log.info("Daemonizing")
280
            daemon.start()
Guillaume Binet's avatar
Guillaume Binet committed
281
        except Exception:
282
            log.exception('Failed to daemonize the process')
283
        exit(0)
284
    from errbot.bootstrap import bootstrap
285 286 287
    restore = args['restore']
    if restore == 'default':  # restore with no argument, get the default location
        restore = path.join(config.BOT_DATA_DIR, 'backup.py')
Nick Groenen's avatar
Nick Groenen committed
288

289
    bootstrap(backend, root_logger, config, restore)
290
    log.info('Process exiting')
291

Nick Groenen's avatar
Nick Groenen committed
292

293 294
if __name__ == "__main__":
    main()