Exemple #1
0
class Py3statusWrapper():
    """
    This is the py3status wrapper.
    """

    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.config = {}
        self.i3bar_running = True
        self.last_refresh_ts = time.time()
        self.lock = Event()
        self.modules = {}
        self.notified_messages = set()
        self.output_modules = {}
        self.py3_modules = []
        self.queue = deque()

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser('~')

        # defaults
        config = {
            'cache_timeout': 60,
            'interval': 1,
            'minimum_interval': 0.1,  # minimum module update interval
            'dbus_notify': False,
        }

        # include path to search for user modules
        config['include_paths'] = [
            '{}/.i3/py3status/'.format(home_path),
            '{}/i3status/py3status'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
            '{}/i3/py3status'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
        ]

        # package version
        try:
            import pkg_resources
            version = pkg_resources.get_distribution('py3status').version
        except:
            version = 'unknown'
        config['version'] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            '{}/.i3status.conf'.format(home_path),
            '{}/i3status/config'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
            '/etc/i3status.conf',
            '{}/i3status/config'.format(os.environ.get('XDG_CONFIG_DIRS',
                                                       '/etc/xdg'))
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = '{}/.i3/i3status.conf'.format(
                home_path)

        # command line options
        parser = argparse.ArgumentParser(
            description='The agile, python-powered, i3status wrapper')
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument('-b',
                            '--dbus-notify',
                            action="store_true",
                            default=False,
                            dest="dbus_notify",
                            help="""use notify-send to send user notifications
                                    rather than i3-nagbar,
                                    requires a notification daemon eg dunst""")
        parser.add_argument('-c',
                            '--config',
                            action="store",
                            dest="i3status_conf",
                            type=str,
                            default=i3status_config_file_default,
                            help="path to i3status config file")
        parser.add_argument('-d',
                            '--debug',
                            action="store_true",
                            help="be verbose in syslog")
        parser.add_argument('-i',
                            '--include',
                            action="append",
                            dest="include_paths",
                            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""")
        parser.add_argument('-l',
                            '--log-file',
                            action="store",
                            dest="log_file",
                            type=str,
                            default=None,
                            help="path to py3status log file")
        parser.add_argument('-n',
                            '--interval',
                            action="store",
                            dest="interval",
                            type=float,
                            default=config['interval'],
                            help="update interval in seconds (default 1 sec)")
        parser.add_argument('-s',
                            '--standalone',
                            action="store_true",
                            help="standalone mode, do not use i3status")
        parser.add_argument('-t',
                            '--timeout',
                            action="store",
                            dest="cache_timeout",
                            type=int,
                            default=config['cache_timeout'],
                            help="""default injection cache timeout in seconds
                            (default 60 sec)""")
        parser.add_argument('-v',
                            '--version',
                            action="store_true",
                            help="""show py3status version and exit""")
        parser.add_argument('cli_command', nargs='*', help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config['cli_command'] = options.cli_command

        # only asked for version
        if options.version:
            from platform import python_version
            print('py3status version {} (python {})'.format(config['version'],
                                                            python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config['cache_timeout'] = options.cache_timeout
        config['debug'] = options.debug
        config['dbus_notify'] = options.dbus_notify
        if options.include_paths:
            config['include_paths'] = options.include_paths
        config['interval'] = int(options.interval)
        config['log_file'] = options.log_file
        config['standalone'] = options.standalone
        config['i3status_config_path'] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in self.config['include_paths']:
            include_path = os.path.abspath(include_path) + '/'
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith('.py'):
                    continue
                module_name = f_name[:-3]
                # do not overwrite modules if already found
                if module_name in user_modules:
                    pass
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(' ')[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only start and handle modules with available methods
                if my_m.methods:
                    my_m.start()
                    self.modules[module] = my_m
                elif self.config['debug']:
                    self.log(
                        'ignoring module "{}" (no methods found)'.format(
                            module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'Loading module "{}" failed ({}).'.format(module, err)
                self.notify_user(msg, level='warning')

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # SIGTSTP will be received from i3bar indicating that all output should
        # stop and we should consider py3status suspended.  It is however
        # important that any processes using i3 ipc should continue to receive
        # those events otherwise it can lead to a stall in i3.
        signal(SIGTSTP, self.i3bar_stop)
        # SIGCONT indicates output should be resumed.
        signal(SIGCONT, self.i3bar_start)

        # setup configuration
        self.config = self.get_config()

        if self.config.get('cli_command'):
            self.handle_cli_command(self.config)
            sys.exit()

        if self.config['debug']:
            self.log(
                'py3status started with config {}'.format(self.config))

        # setup i3status thread
        self.i3status_thread = I3status(self)

        # If standalone or no i3status modules then use the mock i3status
        # else start i3status thread.
        i3s_modules = self.i3status_thread.config['i3s_modules']
        if self.config['standalone'] or not i3s_modules:
            self.i3status_thread.mock()
            i3s_mode = 'mocked'
        else:
            i3s_mode = 'started'
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    # i3status is having a bad day, so tell the user what went
                    # wrong and do the best we can with just py3status modules.
                    err = self.i3status_thread.error
                    self.notify_user(err)
                    self.i3status_thread.mock()
                    i3s_mode = 'mocked'
                    break
                time.sleep(0.1)
        if self.config['debug']:
            self.log('i3status thread {} with config {}'.format(
                i3s_mode, self.i3status_thread.config))

        # setup input events thread
        self.events_thread = Events(self)
        self.events_thread.start()
        if self.config['debug']:
            self.log('events thread started')

        # suppress modules' ouput wrt issue #20
        if not self.config['debug']:
            sys.stdout = open('/dev/null', 'w')
            sys.stderr = open('/dev/null', 'w')

        # get the list of py3status configured modules
        self.py3_modules = self.i3status_thread.config['py3_modules']

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config['debug']:
            self.log('user_modules={}'.format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def notify_user(self, msg, level='error'):
        """
        Display notification to user via i3-nagbar or send-notify
        We also make sure to log anything to keep trace of it.

        NOTE: Message should end with a '.' for consistency.
        """
        dbus = self.config.get('dbus_notify')
        if not dbus:
            msg = 'py3status: {}'.format(msg)
        if level != 'info':
            fix_msg = '{} Please try to fix this and reload i3wm (Mod+Shift+R)'
            msg = fix_msg.format(msg)
        try:
            if msg in self.notified_messages:
                return
            else:
                self.log(msg, level)
                self.notified_messages.add(msg)

            if dbus:
                # fix any html entities
                msg = msg.replace('&', '&')
                msg = msg.replace('<', '&lt;')
                msg = msg.replace('>', '&gt;')
                cmd = ['notify-send', '-u', DBUS_LEVELS.get(level, 'normal'),
                       '-t', '10000', 'py3status', msg]
            else:
                cmd = ['i3-nagbar', '-m', msg, '-t', level]
            Popen(cmd,
                  stdout=open('/dev/null', 'w'),
                  stderr=open('/dev/null', 'w'))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        try:
            self.lock.clear()
            if self.config['debug']:
                self.log('lock cleared, exiting')
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
            self.i3status_thread.cleanup_tmpfile()
        except:
            pass

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        so we force i3status to refresh by sending it a SIGUSR1
        and we clear all py3status modules' cache.

        To prevent abuse, we rate limit this function to 100ms.
        """
        if time.time() > (self.last_refresh_ts + 0.1):
            self.log('received USR1, forcing refresh')

            # send SIGUSR1 to i3status
            call(['killall', '-s', 'USR1', 'i3status'])

            # clear the cache of all modules
            self.clear_modules_cache()

            # reset the refresh timestamp
            self.last_refresh_ts = time.time()
        else:
            self.log('received USR1 but rate limit is in effect, calm down')

    def clear_modules_cache(self):
        """
        For every module, reset the 'cached_until' of all its methods.
        """
        for module in self.modules.values():
            module.force_update()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    def notify_update(self, update):
        """
        Name or list of names of modules that have updated.
        """
        if not isinstance(update, list):
            update = [update]
        self.queue.extend(update)

        # find groups that use the modules updated
        module_groups = self.i3status_thread.config['.module_groups']
        groups_to_update = set()
        for item in update:
            if item in module_groups:
                groups_to_update.update(set(module_groups[item]))
        # force groups to update
        for group in groups_to_update:
            group_module = self.output_modules.get(group)
            if group_module:
                group_module['module'].force_update()

    def log(self, msg, level='info'):
        """
        log this information to syslog or user provided logfile.
        """
        if not self.config['log_file']:
            # If level was given as a str then convert to actual level
            level = LOG_LEVELS.get(level, level)
            syslog(level, msg)
        else:
            with open(self.config['log_file'], 'a') as f:
                log_time = time.strftime("%Y-%m-%d %H:%M:%S")
                f.write('{} {} {}\n'.format(log_time, level.upper(), msg))

    def report_exception(self, msg, notify_user=True):
        """
        Report details of an exception to the user.
        This should only be called within an except: block Details of the
        exception are reported eg filename, line number and exception type.

        Because stack trace information outside of py3status or it's modules is
        not helpful in actually finding and fixing the error, we try to locate
        the first place that the exception affected our code.

        NOTE: msg should not end in a '.' for consistency.
        """
        # Get list of paths that our stack trace should be found in.
        py3_paths = [os.path.dirname(__file__)]
        user_paths = self.config['include_paths']
        py3_paths += [os.path.abspath(path) + '/' for path in user_paths]
        traceback = None

        try:
            # We need to make sure to delete tb even if things go wrong.
            exc_type, exc_obj, tb = sys.exc_info()
            stack = extract_tb(tb)
            error_str = '{}: {}\n'.format(exc_type.__name__, exc_obj)
            traceback = [error_str] + format_tb(tb)
            # Find first relevant trace in the stack.
            # it should be in py3status or one of it's modules.
            found = False
            for item in reversed(stack):
                filename = item[0]
                for path in py3_paths:
                    if filename.startswith(path):
                        # Found a good trace
                        filename = os.path.basename(item[0])
                        line_no = item[1]
                        found = True
                        break
                if found:
                    break
            # all done!  create our message.
            msg = '{} ({}) {} line {}.'.format(
                msg, exc_type.__name__, filename, line_no)
        except:
            # something went wrong report what we can.
            msg = '{}.'.format(msg)
        finally:
            # delete tb!
            del tb
        # log the exception and notify user
        self.log(msg, 'warning')
        if traceback and self.config['log_file']:
            self.log(''.join(['Traceback\n'] + traceback))
        if notify_user:
            self.notify_user(msg, level='error')

    def create_output_modules(self):
        """
        Setup our output modules to allow easy updating of py3modules and
        i3status modules allows the same module to be used multiple times.
        """
        config = self.i3status_thread.config
        i3modules = self.i3status_thread.i3modules
        output_modules = self.output_modules
        # position in the bar of the modules
        positions = {}
        for index, name in enumerate(config['order']):
            if name not in positions:
                positions[name] = []
            positions[name].append(index)

        # py3status modules
        for name in self.modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = self.modules[name]
                output_modules[name]['type'] = 'py3status'
        # i3status modules
        for name in i3modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = i3modules[name]
                output_modules[name]['type'] = 'i3status'

        self.output_modules = output_modules

    def i3bar_stop(self, signum, frame):
        self.i3bar_running = False
        # i3status should be stopped
        self.i3status_thread.suspend_i3status()
        self.sleep_modules()

    def i3bar_start(self, signum, frame):
        self.i3bar_running = True
        self.wake_modules()

    def sleep_modules(self):
        # Put all py3modules to sleep so they stop updating
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].sleep()

    def wake_modules(self):
        # Wake up all py3modules.
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].wake()

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        i3status_thread = self.i3status_thread
        config = i3status_thread.config
        self.create_output_modules()

        # update queue populate with all py3modules
        self.queue.extend(self.modules)

        # this will be our output set to the correct length for the number of
        # items in the bar
        output = [None] * len(config['order'])

        interval = self.config['interval']
        last_sec = 0

        # start our output
        header = {
            'version': 1,
            'click_events': True,
            'stop_signal': SIGTSTP
        }
        print_line(dumps(header))
        print_line('[[]')

        # main loop
        while True:
            while not self.i3bar_running:
                time.sleep(0.1)

            sec = int(time.time())

            # only check everything is good each second
            if sec > last_sec:
                last_sec = sec

                # check i3status thread
                if not i3status_thread.is_alive():
                    err = i3status_thread.error
                    if not err:
                        err = 'I3status died horribly.'
                    self.notify_user(err)

                # check events thread
                if not self.events_thread.is_alive():
                    # don't spam the user with i3-nagbar warnings
                    if not hasattr(self.events_thread, 'nagged'):
                        self.events_thread.nagged = True
                        err = 'Events thread died, click events are disabled.'
                        self.notify_user(err, level='warning')

                # update i3status time/tztime items
                if interval == 0 or sec % interval == 0:
                    i3status_thread.update_times()

            # check if an update is needed
            if self.queue:
                while (len(self.queue)):
                    module_name = self.queue.popleft()
                    module = self.output_modules[module_name]
                    for index in module['position']:
                        # store the output as json
                        # modules can have more than one output
                        out = module['module'].get_latest()
                        output[index] = ', '.join([dumps(x) for x in out])

                # build output string
                out = ','.join([x for x in output if x])
                # dump the line to stdout
                print_line(',[{}]'.format(out))

            # sleep a bit before doing this again to avoid killing the CPU
            time.sleep(0.1)

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config['cli_command']
        # aliases
        if cmd[0] in ['mod', 'module', 'modules']:
            cmd[0] = 'modules'

        # allowed cli commands
        if cmd[:2] in (['modules', 'list'], ['modules', 'details']):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (['docstring', 'check'], ['docstring', 'update']):
            if cmd[1] == 'check':
                show_diff = len(cmd) > 2 and cmd[2] == 'diff'
                docstrings.check_docstrings(show_diff, config)
            if cmd[1] == 'update':
                if len(cmd) < 3:
                    print_stderr('Error: you must specify what to update')
                    sys.exit(1)

                if cmd[2] == 'modules':
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (['modules', 'enable'], ['modules', 'disable']):
            # TODO: to be implemented
            pass
        else:
            print_stderr('Error: unknown command')
            sys.exit(1)
Exemple #2
0
class Py3statusWrapper():
    """
    This is the py3status wrapper.
    """

    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.config = {}
        self.i3bar_running = True
        self.last_refresh_ts = time.time()
        self.lock = Event()
        self.modules = {}
        self.none_setting = NoneSetting()
        self.notified_messages = set()
        self.output_modules = {}
        self.py3_modules = []
        self.py3_modules_initialized = False
        self.queue = deque()

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser('~')

        # defaults
        config = {
            'cache_timeout': 60,
            'interval': 1,
            'minimum_interval': 0.1,  # minimum module update interval
            'dbus_notify': False,
        }

        # include path to search for user modules
        config['include_paths'] = [
            '{}/.i3/py3status/'.format(home_path),
            '{}/i3status/py3status'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
            '{}/i3/py3status'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
        ]
        config['version'] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            '{}/.i3status.conf'.format(home_path),
            '{}/i3status/config'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
            '/etc/i3status.conf',
            '{}/i3status/config'.format(os.environ.get('XDG_CONFIG_DIRS',
                                                       '/etc/xdg'))
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = '{}/.i3/i3status.conf'.format(
                home_path)

        # command line options
        parser = argparse.ArgumentParser(
            description='The agile, python-powered, i3status wrapper')
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument('-b',
                            '--dbus-notify',
                            action="store_true",
                            default=False,
                            dest="dbus_notify",
                            help="""use notify-send to send user notifications
                                    rather than i3-nagbar,
                                    requires a notification daemon eg dunst""")
        parser.add_argument('-c',
                            '--config',
                            action="store",
                            dest="i3status_conf",
                            type=str,
                            default=i3status_config_file_default,
                            help="path to i3status config file")
        parser.add_argument('-d',
                            '--debug',
                            action="store_true",
                            help="be verbose in syslog")
        parser.add_argument('-i',
                            '--include',
                            action="append",
                            dest="include_paths",
                            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""")
        parser.add_argument('-l',
                            '--log-file',
                            action="store",
                            dest="log_file",
                            type=str,
                            default=None,
                            help="path to py3status log file")
        parser.add_argument('-n',
                            '--interval',
                            action="store",
                            dest="interval",
                            type=float,
                            default=config['interval'],
                            help="update interval in seconds (default 1 sec)")
        parser.add_argument('-s',
                            '--standalone',
                            action="store_true",
                            help="standalone mode, do not use i3status")
        parser.add_argument('-t',
                            '--timeout',
                            action="store",
                            dest="cache_timeout",
                            type=int,
                            default=config['cache_timeout'],
                            help="""default injection cache timeout in seconds
                            (default 60 sec)""")
        parser.add_argument('-v',
                            '--version',
                            action="store_true",
                            help="""show py3status version and exit""")
        parser.add_argument('cli_command', nargs='*', help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config['cli_command'] = options.cli_command

        # only asked for version
        if options.version:
            print('py3status version {} (python {})'.format(config['version'],
                                                            python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config['cache_timeout'] = options.cache_timeout
        config['debug'] = options.debug
        config['dbus_notify'] = options.dbus_notify
        if options.include_paths:
            config['include_paths'] = options.include_paths
        config['interval'] = int(options.interval)
        config['log_file'] = options.log_file
        config['standalone'] = options.standalone
        config['i3status_config_path'] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in self.config['include_paths']:
            include_path = os.path.abspath(include_path) + '/'
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith('.py'):
                    continue
                module_name = f_name[:-3]
                # do not overwrite modules if already found
                if module_name in user_modules:
                    pass
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(' ')[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only handle modules with available methods
                if my_m.methods:
                    self.modules[module] = my_m
                elif self.config['debug']:
                    self.log(
                        'ignoring module "{}" (no methods found)'.format(
                            module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'Loading module "{}" failed ({}).'.format(module, err)
                self.report_exception(msg, level='warning')

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # SIGTSTP will be received from i3bar indicating that all output should
        # stop and we should consider py3status suspended.  It is however
        # important that any processes using i3 ipc should continue to receive
        # those events otherwise it can lead to a stall in i3.
        signal(SIGTSTP, self.i3bar_stop)
        # SIGCONT indicates output should be resumed.
        signal(SIGCONT, self.i3bar_start)

        # setup configuration
        self.config = self.get_config()

        if self.config.get('cli_command'):
            self.handle_cli_command(self.config)
            sys.exit()

        # logging functionality now available
        # log py3status and python versions
        self.log('=' * 8)
        self.log('Starting py3status version {} python {}'.format(
            self.config['version'], python_version())
        )

        try:
            # if running from git then log the branch and last commit
            # we do this by looking in the .git directory
            git_path = os.path.join(os.path.dirname(__file__), '..', '.git')
            # branch
            with open(os.path.join(git_path, 'HEAD'), 'r') as f:
                out = f.readline()
            branch = '/'.join(out.strip().split('/')[2:])
            self.log('git branch: {}'.format(branch))
            # last commit
            log_path = os.path.join(git_path, 'logs', 'refs', 'heads', branch)
            with open(log_path, 'r') as f:
                out = f.readlines()[-1]
            sha = out.split(' ')[1][:7]
            msg = ':'.join(out.strip().split('\t')[-1].split(':')[1:])
            self.log('git commit: {}{}'.format(sha, msg))
        except:
            pass

        if self.config['debug']:
            self.log(
                'py3status started with config {}'.format(self.config))

        # read i3status.conf
        config_path = self.config['i3status_config_path']
        self.config['py3_config'] = process_config(config_path, self)

        # setup i3status thread
        self.i3status_thread = I3status(self)

        # If standalone or no i3status modules then use the mock i3status
        # else start i3status thread.
        i3s_modules = self.config['py3_config']['i3s_modules']
        if self.config['standalone'] or not i3s_modules:
            self.i3status_thread.mock()
            i3s_mode = 'mocked'
        else:
            i3s_mode = 'started'
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    # i3status is having a bad day, so tell the user what went
                    # wrong and do the best we can with just py3status modules.
                    err = self.i3status_thread.error
                    self.notify_user(err)
                    self.i3status_thread.mock()
                    i3s_mode = 'mocked'
                    break
                time.sleep(0.1)
        if self.config['debug']:
            self.log('i3status thread {} with config {}'.format(
                i3s_mode, self.config['py3_config']))

        # setup input events thread
        self.events_thread = Events(self)
        self.events_thread.start()
        if self.config['debug']:
            self.log('events thread started')

        # initialise the command server
        self.commands_thread = CommandServer(self)
        self.commands_thread.daemon = True
        self.commands_thread.start()
        if self.config['debug']:
            self.log('commands thread started')

        # suppress modules' ouput wrt issue #20
        if not self.config['debug']:
            sys.stdout = open('/dev/null', 'w')
            sys.stderr = open('/dev/null', 'w')

        # get the list of py3status configured modules
        self.py3_modules = self.config['py3_config']['py3_modules']

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config['debug']:
            self.log('user_modules={}'.format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def notify_user(self, msg, level='error', rate_limit=None, module_name=''):
        """
        Display notification to user via i3-nagbar or send-notify
        We also make sure to log anything to keep trace of it.

        NOTE: Message should end with a '.' for consistency.
        """
        dbus = self.config.get('dbus_notify')
        if dbus:
            # force msg to be a string
            msg = u'{}'.format(msg)
        else:
            msg = u'py3status: {}'.format(msg)
        if level != 'info' and module_name == '':
            fix_msg = u'{} Please try to fix this and reload i3wm (Mod+Shift+R)'
            msg = fix_msg.format(msg)
        # Rate limiting. If rate limiting then we need to calculate the time
        # period for which the message should not be repeated.  We just use
        # A simple chunked time model where a message cannot be repeated in a
        # given time period. Messages can be repeated more frequently but must
        # be in different time periods.

        limit_key = ''
        if rate_limit:
            try:
                limit_key = time.time() // rate_limit
            except TypeError:
                pass
        # We use a hash to see if the message is being repeated.  This is crude
        # and imperfect but should work for our needs.
        msg_hash = hash(u'{}#{}#{}'.format(module_name, limit_key, msg))
        if msg_hash in self.notified_messages:
            return
        else:
            self.log(msg, level)
            self.notified_messages.add(msg_hash)

        try:
            if dbus:
                # fix any html entities
                msg = msg.replace('&', '&amp;')
                msg = msg.replace('<', '&lt;')
                msg = msg.replace('>', '&gt;')
                cmd = ['notify-send', '-u', DBUS_LEVELS.get(level, 'normal'),
                       '-t', '10000', 'py3status', msg]
            else:
                py3_config = self.config['py3_config']
                nagbar_font = py3_config.get('py3status').get('nagbar_font')
                if nagbar_font:
                    cmd = ['i3-nagbar', '-f', nagbar_font, '-m', msg, '-t', level]
                else:
                    cmd = ['i3-nagbar', '-m', msg, '-t', level]
            Popen(cmd,
                  stdout=open('/dev/null', 'w'),
                  stderr=open('/dev/null', 'w'))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        # stop the command server
        try:
            self.commands_thread.kill()
        except:
            pass

        try:
            self.lock.clear()
            if self.config['debug']:
                self.log('lock cleared, exiting')
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
        except:
            pass

    def refresh_modules(self, module_string=None, exact=True):
        """
        Update modules.
        if module_string is None all modules are refreshed
        if module_string then modules with the exact name or those starting
        with the given string depending on exact parameter will be refreshed.
        If a module is an i3status one then we refresh i3status.
        To prevent abuse, we rate limit this function to 100ms for full
        refreshes.
        """
        if not module_string:
            if time.time() > (self.last_refresh_ts + 0.1):
                self.last_refresh_ts = time.time()
            else:
                # rate limiting
                return
        update_i3status = False
        for name, module in self.output_modules.items():
            if (module_string is None or
                    (exact and name == module_string) or
                    (not exact and name.startswith(module_string))):
                if module['type'] == 'py3status':
                    if self.config['debug']:
                        self.log('refresh py3status module {}'.format(name))
                    module['module'].force_update()
                else:
                    if self.config['debug']:
                        self.log('refresh i3status module {}'.format(name))
                    update_i3status = True
        if update_i3status:
            self.i3status_thread.refresh_i3status()

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        """
        self.log('received USR1')
        self.refresh_modules()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    def purge_module(self, module_name):
        """
        A module has been removed e.g. a module that had an error.
        We need to find any containers and remove the module from them.
        """
        containers = self.config['py3_config']['.module_groups']
        containers_to_update = set()
        if module_name in containers:
            containers_to_update.update(set(containers[module_name]))
        for container in containers_to_update:
            try:
                self.modules[container].module_class.items.remove(module_name)
            except ValueError:
                pass

    def notify_update(self, update, urgent=False):
        """
        Name or list of names of modules that have updated.
        """
        if not isinstance(update, list):
            update = [update]
        self.queue.extend(update)

        # if all our py3status modules are not ready to receive updates then we
        # don't want to get them to update.
        if not self.py3_modules_initialized:
            return

        # find containers that use the modules that updated
        containers = self.config['py3_config']['.module_groups']
        containers_to_update = set()
        for item in update:
            if item in containers:
                containers_to_update.update(set(containers[item]))
        # force containers to update
        for container in containers_to_update:
            container_module = self.output_modules.get(container)
            if container_module:
                # If the container registered a urgent_function then call it
                # if this update is urgent.
                if urgent and container_module.get('urgent_function'):
                    container_module['urgent_function'](update)
                # If a container has registered a content_function we use that
                # to see if the container needs to be updated.
                # We only need to update containers if their active content has
                # changed.
                if container_module.get('content_function'):
                    if set(update) & container_module['content_function']():
                        container_module['module'].force_update()
                else:
                    # we don't know so just update.
                    container_module['module'].force_update()

    def log(self, msg, level='info'):
        """
        log this information to syslog or user provided logfile.
        """
        if not self.config['log_file']:
            # If level was given as a str then convert to actual level
            level = LOG_LEVELS.get(level, level)
            syslog(level, u'{}'.format(msg))
        else:
            # Binary mode so fs encoding setting is not an issue
            with open(self.config['log_file'], 'ab') as f:
                log_time = time.strftime("%Y-%m-%d %H:%M:%S")
                # nice formating of data structures using pretty print
                if isinstance(msg, (dict, list, set, tuple)):
                    msg = pformat(msg)
                    # if multiline then start the data output on a fresh line
                    # to aid readability.
                    if '\n' in msg:
                        msg = u'\n' + msg
                out = u'{} {} {}\n'.format(log_time, level.upper(), msg)
                try:
                    # Encode unicode strings to bytes
                    f.write(out.encode('utf-8'))
                except (AttributeError, UnicodeDecodeError):
                    # Write any byte strings straight to log
                    f.write(out)

    def report_exception(self, msg, notify_user=True, level='error',
                         error_frame=None):
        """
        Report details of an exception to the user.
        This should only be called within an except: block Details of the
        exception are reported eg filename, line number and exception type.

        Because stack trace information outside of py3status or it's modules is
        not helpful in actually finding and fixing the error, we try to locate
        the first place that the exception affected our code.

        Alternatively if the error occurs in a module via a Py3 call that
        catches and reports the error then we receive an error_frame and use
        that as the source of the error.

        NOTE: msg should not end in a '.' for consistency.
        """
        # Get list of paths that our stack trace should be found in.
        py3_paths = [os.path.dirname(__file__)]
        user_paths = self.config['include_paths']
        py3_paths += [os.path.abspath(path) + '/' for path in user_paths]
        traceback = None

        try:
            # We need to make sure to delete tb even if things go wrong.
            exc_type, exc_obj, tb = sys.exc_info()
            stack = extract_tb(tb)
            error_str = '{}: {}\n'.format(exc_type.__name__, exc_obj)
            traceback = [error_str]

            if error_frame:
                # The error occurred in a py3status module so the traceback
                # should be made to appear correct.  We caught the exception
                # but make it look as though we did not.
                traceback += format_stack(error_frame, 1) + format_tb(tb)
                filename = os.path.basename(error_frame.f_code.co_filename)
                line_no = error_frame.f_lineno
            else:
                # This is a none module based error
                traceback += format_tb(tb)
                # Find first relevant trace in the stack.
                # it should be in py3status or one of it's modules.
                found = False
                for item in reversed(stack):
                    filename = item[0]
                    for path in py3_paths:
                        if filename.startswith(path):
                            # Found a good trace
                            filename = os.path.basename(item[0])
                            line_no = item[1]
                            found = True
                            break
                    if found:
                        break
            # all done!  create our message.
            msg = '{} ({}) {} line {}.'.format(
                msg, exc_type.__name__, filename, line_no)
        except:
            # something went wrong report what we can.
            msg = '{}.'.format(msg)
        finally:
            # delete tb!
            del tb
        # log the exception and notify user
        self.log(msg, 'warning')
        if traceback and self.config['log_file']:
            self.log(''.join(['Traceback\n'] + traceback))
        if notify_user:
            self.notify_user(msg, level=level)

    def create_output_modules(self):
        """
        Setup our output modules to allow easy updating of py3modules and
        i3status modules allows the same module to be used multiple times.
        """
        py3_config = self.config['py3_config']
        i3modules = self.i3status_thread.i3modules
        output_modules = self.output_modules
        # position in the bar of the modules
        positions = {}
        for index, name in enumerate(py3_config['order']):
            if name not in positions:
                positions[name] = []
            positions[name].append(index)

        # py3status modules
        for name in self.modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = self.modules[name]
                output_modules[name]['type'] = 'py3status'
        # i3status modules
        for name in i3modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = i3modules[name]
                output_modules[name]['type'] = 'i3status'

        self.output_modules = output_modules

    def get_config_attribute(self, name, attribute):
        """
        Look for the attribute in the config.  Start with the named module and
        then walk up through any containing group and then try the general
        section of the config.
        """

        py3_config = self.config['py3_config']
        # A user can set a param to None in the config to prevent a param
        # being used.  This is important when modules do something like
        #
        # color = self.py3.COLOR_MUTED or self.py3.COLOR_BAD
        param = py3_config[name].get(attribute, self.none_setting)
        if hasattr(param, 'none_setting') and name in py3_config['.module_groups']:
            for module in py3_config['.module_groups'][name]:
                if attribute in py3_config.get(module, {}):
                    param = py3_config[module].get(attribute)
                    break
        if hasattr(param, 'none_setting'):
            # check py3status config section
            param = py3_config['py3status'].get(attribute, self.none_setting)
        if hasattr(param, 'none_setting'):
            # check py3status general section
            param = py3_config['general'].get(attribute, self.none_setting)
        return param

    def create_mappings(self, config):
        """
        Create any mappings needed for global substitutions eg. colors
        """
        mappings = {}
        for name, cfg in config.items():
            # Ignore special config sections.
            if name in CONFIG_SPECIAL_SECTIONS:
                continue
            color = self.get_config_attribute(name, 'color')
            if hasattr(color, 'none_setting'):
                color = None
            mappings[name] = color
        # Store mappings for later use.
        self.mappings_color = mappings

    def process_module_output(self, outputs):
        """
        Process the output for a module and return a json string representing it.
        Color processing occurs here.
        """
        for output in outputs:
            # Color: substitute the config defined color
            if 'color' not in output:
                # Get the module name from the output.
                module_name = '{} {}'.format(
                    output['name'], output.get('instance', '').split(' ')[0]
                ).strip()
                color = self.mappings_color.get(module_name)
                if color:
                    output['color'] = color
        # Create the json string output.
        return ','.join([dumps(x) for x in outputs])

    def i3bar_stop(self, signum, frame):
        self.i3bar_running = False
        # i3status should be stopped
        self.i3status_thread.suspend_i3status()
        self.sleep_modules()

    def i3bar_start(self, signum, frame):
        self.i3bar_running = True
        self.wake_modules()

    def sleep_modules(self):
        # Put all py3modules to sleep so they stop updating
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].sleep()

    def wake_modules(self):
        # Wake up all py3modules.
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].wake()

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        i3status_thread = self.i3status_thread
        py3_config = self.config['py3_config']

        # prepare the color mappings
        self.create_mappings(py3_config)

        # self.output_modules needs to have been created before modules are
        # started.  This is so that modules can do things like register their
        # content_function.
        self.create_output_modules()

        # Some modules need to be prepared before they can run
        # eg run their post_config_hook
        for module in self.modules.values():
            module.prepare_module()

        # modules can now receive updates
        self.py3_modules_initialized = True

        # start modules
        for module in self.modules.values():
            module.start_module()

        # this will be our output set to the correct length for the number of
        # items in the bar
        output = [None] * len(py3_config['order'])

        interval = self.config['interval']
        last_sec = 0

        # start our output
        header = {
            'version': 1,
            'click_events': True,
            'stop_signal': SIGTSTP
        }
        print_line(dumps(header))
        print_line('[[]')

        # main loop
        while True:
            # sleep a bit to avoid killing the CPU
            # by doing this at the begining rather than the end
            # of the loop we ensure a smoother first render of the i3bar
            time.sleep(0.1)

            while not self.i3bar_running:
                time.sleep(0.1)

            sec = int(time.time())

            # only check everything is good each second
            if sec > last_sec:
                last_sec = sec

                # check i3status thread
                if not i3status_thread.is_alive():
                    err = i3status_thread.error
                    if not err:
                        err = 'I3status died horribly.'
                    self.notify_user(err)

                # check events thread
                if not self.events_thread.is_alive():
                    # don't spam the user with i3-nagbar warnings
                    if not hasattr(self.events_thread, 'nagged'):
                        self.events_thread.nagged = True
                        err = 'Events thread died, click events are disabled.'
                        self.notify_user(err, level='warning')

                # update i3status time/tztime items
                if interval == 0 or sec % interval == 0:
                    i3status_thread.update_times()

            # check if an update is needed
            if self.queue:
                while (len(self.queue)):
                    module_name = self.queue.popleft()
                    module = self.output_modules[module_name]
                    for index in module['position']:
                        # store the output as json
                        out = module['module'].get_latest()
                        output[index] = self.process_module_output(out)

                # build output string
                out = ','.join([x for x in output if x])
                # dump the line to stdout
                print_line(',[{}]'.format(out))

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config['cli_command']
        # aliases
        if cmd[0] in ['mod', 'module', 'modules']:
            cmd[0] = 'modules'

        # allowed cli commands
        if cmd[:2] in (['modules', 'list'], ['modules', 'details']):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (['docstring', 'check'], ['docstring', 'update']):
            if cmd[1] == 'check':
                show_diff = len(cmd) > 2 and cmd[2] == 'diff'
                if show_diff:
                    mods = cmd[3:]
                else:
                    mods = cmd[2:]
                docstrings.check_docstrings(show_diff, config, mods)
            if cmd[1] == 'update':
                if len(cmd) < 3:
                    print_stderr('Error: you must specify what to update')
                    sys.exit(1)

                if cmd[2] == 'modules':
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (['modules', 'enable'], ['modules', 'disable']):
            # TODO: to be implemented
            pass
        else:
            print_stderr('Error: unknown command')
            sys.exit(1)
Exemple #3
0
class Py3statusWrapper():
    """
    This is the py3status wrapper.
    """
    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.config = {}
        self.i3bar_running = True
        self.last_refresh_ts = time.time()
        self.lock = Event()
        self.modules = {}
        self.none_setting = NoneSetting()
        self.notified_messages = set()
        self.output_modules = {}
        self.py3_modules = []
        self.py3_modules_initialized = False
        self.queue = deque()

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser('~')

        # defaults
        config = {
            'cache_timeout': 60,
            'interval': 1,
            'minimum_interval': 0.1,  # minimum module update interval
            'dbus_notify': False,
        }

        # include path to search for user modules
        config['include_paths'] = [
            '{}/.i3/py3status/'.format(home_path),
            '{}/i3status/py3status'.format(
                os.environ.get('XDG_CONFIG_HOME',
                               '{}/.config'.format(home_path))),
            '{}/i3/py3status'.format(
                os.environ.get('XDG_CONFIG_HOME',
                               '{}/.config'.format(home_path))),
        ]
        config['version'] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            '{}/.i3status.conf'.format(home_path), '{}/i3status/config'.format(
                os.environ.get('XDG_CONFIG_HOME',
                               '{}/.config'.format(home_path))),
            '/etc/i3status.conf', '{}/i3status/config'.format(
                os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg'))
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = '{}/.i3/i3status.conf'.format(
                home_path)

        # command line options
        parser = argparse.ArgumentParser(
            description='The agile, python-powered, i3status wrapper')
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument('-b',
                            '--dbus-notify',
                            action="store_true",
                            default=False,
                            dest="dbus_notify",
                            help="""use notify-send to send user notifications
                                    rather than i3-nagbar,
                                    requires a notification daemon eg dunst""")
        parser.add_argument('-c',
                            '--config',
                            action="store",
                            dest="i3status_conf",
                            type=str,
                            default=i3status_config_file_default,
                            help="path to i3status config file")
        parser.add_argument('-d',
                            '--debug',
                            action="store_true",
                            help="be verbose in syslog")
        parser.add_argument('-i',
                            '--include',
                            action="append",
                            dest="include_paths",
                            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""")
        parser.add_argument('-l',
                            '--log-file',
                            action="store",
                            dest="log_file",
                            type=str,
                            default=None,
                            help="path to py3status log file")
        parser.add_argument('-n',
                            '--interval',
                            action="store",
                            dest="interval",
                            type=float,
                            default=config['interval'],
                            help="update interval in seconds (default 1 sec)")
        parser.add_argument('-s',
                            '--standalone',
                            action="store_true",
                            help="standalone mode, do not use i3status")
        parser.add_argument('-t',
                            '--timeout',
                            action="store",
                            dest="cache_timeout",
                            type=int,
                            default=config['cache_timeout'],
                            help="""default injection cache timeout in seconds
                            (default 60 sec)""")
        parser.add_argument('-v',
                            '--version',
                            action="store_true",
                            help="""show py3status version and exit""")
        parser.add_argument('cli_command', nargs='*', help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config['cli_command'] = options.cli_command

        # only asked for version
        if options.version:
            print('py3status version {} (python {})'.format(
                config['version'], python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config['cache_timeout'] = options.cache_timeout
        config['debug'] = options.debug
        config['dbus_notify'] = options.dbus_notify
        if options.include_paths:
            config['include_paths'] = options.include_paths
        config['interval'] = int(options.interval)
        config['log_file'] = options.log_file
        config['standalone'] = options.standalone
        config['i3status_config_path'] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in self.config['include_paths']:
            include_path = os.path.abspath(include_path) + '/'
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith('.py'):
                    continue
                module_name = f_name[:-3]
                # do not overwrite modules if already found
                if module_name in user_modules:
                    pass
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(' ')[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only handle modules with available methods
                if my_m.methods:
                    self.modules[module] = my_m
                elif self.config['debug']:
                    self.log('ignoring module "{}" (no methods found)'.format(
                        module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'Loading module "{}" failed ({}).'.format(module, err)
                self.report_exception(msg, level='warning')

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # SIGTSTP will be received from i3bar indicating that all output should
        # stop and we should consider py3status suspended.  It is however
        # important that any processes using i3 ipc should continue to receive
        # those events otherwise it can lead to a stall in i3.
        signal(SIGTSTP, self.i3bar_stop)
        # SIGCONT indicates output should be resumed.
        signal(SIGCONT, self.i3bar_start)

        # setup configuration
        self.config = self.get_config()

        if self.config.get('cli_command'):
            self.handle_cli_command(self.config)
            sys.exit()

        # logging functionality now available
        # log py3status and python versions
        self.log('=' * 8)
        self.log('Starting py3status version {} python {}'.format(
            self.config['version'], python_version()))

        try:
            # if running from git then log the branch and last commit
            # we do this by looking in the .git directory
            git_path = os.path.join(os.path.dirname(__file__), '..', '.git')
            # branch
            with open(os.path.join(git_path, 'HEAD'), 'r') as f:
                out = f.readline()
            branch = '/'.join(out.strip().split('/')[2:])
            self.log('git branch: {}'.format(branch))
            # last commit
            log_path = os.path.join(git_path, 'logs', 'refs', 'heads', branch)
            with open(log_path, 'r') as f:
                out = f.readlines()[-1]
            sha = out.split(' ')[1][:7]
            msg = ':'.join(out.strip().split('\t')[-1].split(':')[1:])
            self.log('git commit: {}{}'.format(sha, msg))
        except:
            pass

        if self.config['debug']:
            self.log('py3status started with config {}'.format(self.config))

        # read i3status.conf
        config_path = self.config['i3status_config_path']
        self.config['py3_config'] = process_config(config_path, self)

        # setup i3status thread
        self.i3status_thread = I3status(self)

        # If standalone or no i3status modules then use the mock i3status
        # else start i3status thread.
        i3s_modules = self.config['py3_config']['i3s_modules']
        if self.config['standalone'] or not i3s_modules:
            self.i3status_thread.mock()
            i3s_mode = 'mocked'
        else:
            i3s_mode = 'started'
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    # i3status is having a bad day, so tell the user what went
                    # wrong and do the best we can with just py3status modules.
                    err = self.i3status_thread.error
                    self.notify_user(err)
                    self.i3status_thread.mock()
                    i3s_mode = 'mocked'
                    break
                time.sleep(0.1)
        if self.config['debug']:
            self.log('i3status thread {} with config {}'.format(
                i3s_mode, self.config['py3_config']))

        # setup input events thread
        self.events_thread = Events(self)
        self.events_thread.start()
        if self.config['debug']:
            self.log('events thread started')

        # initialise the command server
        self.commands_thread = CommandServer(self)
        self.commands_thread.daemon = True
        self.commands_thread.start()
        if self.config['debug']:
            self.log('commands thread started')

        # suppress modules' ouput wrt issue #20
        if not self.config['debug']:
            sys.stdout = open('/dev/null', 'w')
            sys.stderr = open('/dev/null', 'w')

        # get the list of py3status configured modules
        self.py3_modules = self.config['py3_config']['py3_modules']

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config['debug']:
            self.log('user_modules={}'.format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def notify_user(self, msg, level='error', rate_limit=None, module_name=''):
        """
        Display notification to user via i3-nagbar or send-notify
        We also make sure to log anything to keep trace of it.

        NOTE: Message should end with a '.' for consistency.
        """
        dbus = self.config.get('dbus_notify')
        if dbus:
            # force msg to be a string
            msg = u'{}'.format(msg)
        else:
            msg = u'py3status: {}'.format(msg)
        if level != 'info' and module_name == '':
            fix_msg = u'{} Please try to fix this and reload i3wm (Mod+Shift+R)'
            msg = fix_msg.format(msg)
        # Rate limiting. If rate limiting then we need to calculate the time
        # period for which the message should not be repeated.  We just use
        # A simple chunked time model where a message cannot be repeated in a
        # given time period. Messages can be repeated more frequently but must
        # be in different time periods.

        limit_key = ''
        if rate_limit:
            try:
                limit_key = time.time() // rate_limit
            except TypeError:
                pass
        # We use a hash to see if the message is being repeated.  This is crude
        # and imperfect but should work for our needs.
        msg_hash = hash(u'{}#{}#{}'.format(module_name, limit_key, msg))
        if msg_hash in self.notified_messages:
            return
        else:
            self.log(msg, level)
            self.notified_messages.add(msg_hash)

        try:
            if dbus:
                # fix any html entities
                msg = msg.replace('&', '&amp;')
                msg = msg.replace('<', '&lt;')
                msg = msg.replace('>', '&gt;')
                cmd = [
                    'notify-send', '-u',
                    DBUS_LEVELS.get(level, 'normal'), '-t', '10000',
                    'py3status', msg
                ]
            else:
                py3_config = self.config['py3_config']
                nagbar_font = py3_config.get('py3status').get('nagbar_font')
                if nagbar_font:
                    cmd = [
                        'i3-nagbar', '-f', nagbar_font, '-m', msg, '-t', level
                    ]
                else:
                    cmd = ['i3-nagbar', '-m', msg, '-t', level]
            Popen(cmd,
                  stdout=open('/dev/null', 'w'),
                  stderr=open('/dev/null', 'w'))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        # stop the command server
        try:
            self.commands_thread.kill()
        except:
            pass

        try:
            self.lock.clear()
            if self.config['debug']:
                self.log('lock cleared, exiting')
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
        except:
            pass

    def refresh_modules(self, module_string=None, exact=True):
        """
        Update modules.
        if module_string is None all modules are refreshed
        if module_string then modules with the exact name or those starting
        with the given string depending on exact parameter will be refreshed.
        If a module is an i3status one then we refresh i3status.
        To prevent abuse, we rate limit this function to 100ms for full
        refreshes.
        """
        if not module_string:
            if time.time() > (self.last_refresh_ts + 0.1):
                self.last_refresh_ts = time.time()
            else:
                # rate limiting
                return
        update_i3status = False
        for name, module in self.output_modules.items():
            if (module_string is None or (exact and name == module_string)
                    or (not exact and name.startswith(module_string))):
                if module['type'] == 'py3status':
                    if self.config['debug']:
                        self.log('refresh py3status module {}'.format(name))
                    module['module'].force_update()
                else:
                    if self.config['debug']:
                        self.log('refresh i3status module {}'.format(name))
                    update_i3status = True
        if update_i3status:
            self.i3status_thread.refresh_i3status()

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        """
        self.log('received USR1')
        self.refresh_modules()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    def purge_module(self, module_name):
        """
        A module has been removed e.g. a module that had an error.
        We need to find any containers and remove the module from them.
        """
        containers = self.config['py3_config']['.module_groups']
        containers_to_update = set()
        if module_name in containers:
            containers_to_update.update(set(containers[module_name]))
        for container in containers_to_update:
            try:
                self.modules[container].module_class.items.remove(module_name)
            except ValueError:
                pass

    def notify_update(self, update, urgent=False):
        """
        Name or list of names of modules that have updated.
        """
        if not isinstance(update, list):
            update = [update]
        self.queue.extend(update)

        # if all our py3status modules are not ready to receive updates then we
        # don't want to get them to update.
        if not self.py3_modules_initialized:
            return

        # find containers that use the modules that updated
        containers = self.config['py3_config']['.module_groups']
        containers_to_update = set()
        for item in update:
            if item in containers:
                containers_to_update.update(set(containers[item]))
        # force containers to update
        for container in containers_to_update:
            container_module = self.output_modules.get(container)
            if container_module:
                # If the container registered a urgent_function then call it
                # if this update is urgent.
                if urgent and container_module.get('urgent_function'):
                    container_module['urgent_function'](update)
                # If a container has registered a content_function we use that
                # to see if the container needs to be updated.
                # We only need to update containers if their active content has
                # changed.
                if container_module.get('content_function'):
                    if set(update) & container_module['content_function']():
                        container_module['module'].force_update()
                else:
                    # we don't know so just update.
                    container_module['module'].force_update()

    def log(self, msg, level='info'):
        """
        log this information to syslog or user provided logfile.
        """
        if not self.config['log_file']:
            # If level was given as a str then convert to actual level
            level = LOG_LEVELS.get(level, level)
            syslog(level, u'{}'.format(msg))
        else:
            # Binary mode so fs encoding setting is not an issue
            with open(self.config['log_file'], 'ab') as f:
                log_time = time.strftime("%Y-%m-%d %H:%M:%S")
                # nice formating of data structures using pretty print
                if isinstance(msg, (dict, list, set, tuple)):
                    msg = pformat(msg)
                    # if multiline then start the data output on a fresh line
                    # to aid readability.
                    if '\n' in msg:
                        msg = u'\n' + msg
                out = u'{} {} {}\n'.format(log_time, level.upper(), msg)
                try:
                    # Encode unicode strings to bytes
                    f.write(out.encode('utf-8'))
                except (AttributeError, UnicodeDecodeError):
                    # Write any byte strings straight to log
                    f.write(out)

    def report_exception(self,
                         msg,
                         notify_user=True,
                         level='error',
                         error_frame=None):
        """
        Report details of an exception to the user.
        This should only be called within an except: block Details of the
        exception are reported eg filename, line number and exception type.

        Because stack trace information outside of py3status or it's modules is
        not helpful in actually finding and fixing the error, we try to locate
        the first place that the exception affected our code.

        Alternatively if the error occurs in a module via a Py3 call that
        catches and reports the error then we receive an error_frame and use
        that as the source of the error.

        NOTE: msg should not end in a '.' for consistency.
        """
        # Get list of paths that our stack trace should be found in.
        py3_paths = [os.path.dirname(__file__)]
        user_paths = self.config['include_paths']
        py3_paths += [os.path.abspath(path) + '/' for path in user_paths]
        traceback = None

        try:
            # We need to make sure to delete tb even if things go wrong.
            exc_type, exc_obj, tb = sys.exc_info()
            stack = extract_tb(tb)
            error_str = '{}: {}\n'.format(exc_type.__name__, exc_obj)
            traceback = [error_str]

            if error_frame:
                # The error occurred in a py3status module so the traceback
                # should be made to appear correct.  We caught the exception
                # but make it look as though we did not.
                traceback += format_stack(error_frame, 1) + format_tb(tb)
                filename = os.path.basename(error_frame.f_code.co_filename)
                line_no = error_frame.f_lineno
            else:
                # This is a none module based error
                traceback += format_tb(tb)
                # Find first relevant trace in the stack.
                # it should be in py3status or one of it's modules.
                found = False
                for item in reversed(stack):
                    filename = item[0]
                    for path in py3_paths:
                        if filename.startswith(path):
                            # Found a good trace
                            filename = os.path.basename(item[0])
                            line_no = item[1]
                            found = True
                            break
                    if found:
                        break
            # all done!  create our message.
            msg = '{} ({}) {} line {}.'.format(msg, exc_type.__name__,
                                               filename, line_no)
        except:
            # something went wrong report what we can.
            msg = '{}.'.format(msg)
        finally:
            # delete tb!
            del tb
        # log the exception and notify user
        self.log(msg, 'warning')
        if traceback and self.config['log_file']:
            self.log(''.join(['Traceback\n'] + traceback))
        if notify_user:
            self.notify_user(msg, level=level)

    def create_output_modules(self):
        """
        Setup our output modules to allow easy updating of py3modules and
        i3status modules allows the same module to be used multiple times.
        """
        py3_config = self.config['py3_config']
        i3modules = self.i3status_thread.i3modules
        output_modules = self.output_modules
        # position in the bar of the modules
        positions = {}
        for index, name in enumerate(py3_config['order']):
            if name not in positions:
                positions[name] = []
            positions[name].append(index)

        # py3status modules
        for name in self.modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = self.modules[name]
                output_modules[name]['type'] = 'py3status'
        # i3status modules
        for name in i3modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = i3modules[name]
                output_modules[name]['type'] = 'i3status'

        self.output_modules = output_modules

    def get_config_attribute(self, name, attribute):
        """
        Look for the attribute in the config.  Start with the named module and
        then walk up through any containing group and then try the general
        section of the config.
        """

        py3_config = self.config['py3_config']
        # A user can set a param to None in the config to prevent a param
        # being used.  This is important when modules do something like
        #
        # color = self.py3.COLOR_MUTED or self.py3.COLOR_BAD
        param = py3_config[name].get(attribute, self.none_setting)
        if hasattr(param,
                   'none_setting') and name in py3_config['.module_groups']:
            for module in py3_config['.module_groups'][name]:
                if attribute in py3_config.get(module, {}):
                    param = py3_config[module].get(attribute)
                    break
        if hasattr(param, 'none_setting'):
            # check py3status config section
            param = py3_config['py3status'].get(attribute, self.none_setting)
        if hasattr(param, 'none_setting'):
            # check py3status general section
            param = py3_config['general'].get(attribute, self.none_setting)
        return param

    def create_mappings(self, config):
        """
        Create any mappings needed for global substitutions eg. colors
        """
        mappings = {}
        for name, cfg in config.items():
            # Ignore special config sections.
            if name in CONFIG_SPECIAL_SECTIONS:
                continue
            color = self.get_config_attribute(name, 'color')
            if hasattr(color, 'none_setting'):
                color = None
            mappings[name] = color
        # Store mappings for later use.
        self.mappings_color = mappings

    def process_module_output(self, outputs):
        """
        Process the output for a module and return a json string representing it.
        Color processing occurs here.
        """
        for output in outputs:
            # Color: substitute the config defined color
            if 'color' not in output:
                # Get the module name from the output.
                module_name = '{} {}'.format(
                    output['name'],
                    output.get('instance', '').split(' ')[0]).strip()
                color = self.mappings_color.get(module_name)
                if color:
                    output['color'] = color
        # Create the json string output.
        return ','.join([dumps(x) for x in outputs])

    def i3bar_stop(self, signum, frame):
        self.i3bar_running = False
        # i3status should be stopped
        self.i3status_thread.suspend_i3status()
        self.sleep_modules()

    def i3bar_start(self, signum, frame):
        self.i3bar_running = True
        self.wake_modules()

    def sleep_modules(self):
        # Put all py3modules to sleep so they stop updating
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].sleep()

    def wake_modules(self):
        # Wake up all py3modules.
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].wake()

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        i3status_thread = self.i3status_thread
        py3_config = self.config['py3_config']

        # prepare the color mappings
        self.create_mappings(py3_config)

        # self.output_modules needs to have been created before modules are
        # started.  This is so that modules can do things like register their
        # content_function.
        self.create_output_modules()

        # Some modules need to be prepared before they can run
        # eg run their post_config_hook
        for module in self.modules.values():
            module.prepare_module()

        # modules can now receive updates
        self.py3_modules_initialized = True

        # start modules
        for module in self.modules.values():
            module.start_module()

        # this will be our output set to the correct length for the number of
        # items in the bar
        output = [None] * len(py3_config['order'])

        interval = self.config['interval']
        last_sec = 0

        # start our output
        header = {'version': 1, 'click_events': True, 'stop_signal': SIGTSTP}
        print_line(dumps(header))
        print_line('[[]')

        # main loop
        while True:
            # sleep a bit to avoid killing the CPU
            # by doing this at the begining rather than the end
            # of the loop we ensure a smoother first render of the i3bar
            time.sleep(0.1)

            while not self.i3bar_running:
                time.sleep(0.1)

            sec = int(time.time())

            # only check everything is good each second
            if sec > last_sec:
                last_sec = sec

                # check i3status thread
                if not i3status_thread.is_alive():
                    err = i3status_thread.error
                    if not err:
                        err = 'I3status died horribly.'
                    self.notify_user(err)

                # check events thread
                if not self.events_thread.is_alive():
                    # don't spam the user with i3-nagbar warnings
                    if not hasattr(self.events_thread, 'nagged'):
                        self.events_thread.nagged = True
                        err = 'Events thread died, click events are disabled.'
                        self.notify_user(err, level='warning')

                # update i3status time/tztime items
                if interval == 0 or sec % interval == 0:
                    i3status_thread.update_times()

            # check if an update is needed
            if self.queue:
                while (len(self.queue)):
                    module_name = self.queue.popleft()
                    module = self.output_modules[module_name]
                    for index in module['position']:
                        # store the output as json
                        out = module['module'].get_latest()
                        output[index] = self.process_module_output(out)

                # build output string
                out = ','.join([x for x in output if x])
                # dump the line to stdout
                print_line(',[{}]'.format(out))

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config['cli_command']
        # aliases
        if cmd[0] in ['mod', 'module', 'modules']:
            cmd[0] = 'modules'

        # allowed cli commands
        if cmd[:2] in (['modules', 'list'], ['modules', 'details']):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (['docstring', 'check'], ['docstring', 'update']):
            if cmd[1] == 'check':
                show_diff = len(cmd) > 2 and cmd[2] == 'diff'
                if show_diff:
                    mods = cmd[3:]
                else:
                    mods = cmd[2:]
                docstrings.check_docstrings(show_diff, config, mods)
            if cmd[1] == 'update':
                if len(cmd) < 3:
                    print_stderr('Error: you must specify what to update')
                    sys.exit(1)

                if cmd[2] == 'modules':
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (['modules', 'enable'], ['modules', 'disable']):
            # TODO: to be implemented
            pass
        else:
            print_stderr('Error: unknown command')
            sys.exit(1)
Exemple #4
0
class Py3statusWrapper():
    """
    This is the py3status wrapper.
    """

    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.config = {}
        self.i3bar_running = True
        self.last_refresh_ts = time.time()
        self.lock = Event()
        self.modules = {}
        self.notified_messages = set()
        self.output_modules = {}
        self.py3_modules = []
        self.queue = deque()

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser('~')

        # defaults
        config = {
            'cache_timeout': 60,
            'interval': 1,
            'minimum_interval': 0.1,  # minimum module update interval
            'dbus_notify': False,
        }

        # include path to search for user modules
        config['include_paths'] = [
            '{}/.i3/py3status/'.format(home_path),
            '{}/i3status/py3status'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
            '{}/i3/py3status'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
        ]
        config['version'] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            '{}/.i3status.conf'.format(home_path),
            '{}/i3status/config'.format(os.environ.get(
                'XDG_CONFIG_HOME', '{}/.config'.format(home_path))),
            '/etc/i3status.conf',
            '{}/i3status/config'.format(os.environ.get('XDG_CONFIG_DIRS',
                                                       '/etc/xdg'))
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = '{}/.i3/i3status.conf'.format(
                home_path)

        # command line options
        parser = argparse.ArgumentParser(
            description='The agile, python-powered, i3status wrapper')
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument('-b',
                            '--dbus-notify',
                            action="store_true",
                            default=False,
                            dest="dbus_notify",
                            help="""use notify-send to send user notifications
                                    rather than i3-nagbar,
                                    requires a notification daemon eg dunst""")
        parser.add_argument('-c',
                            '--config',
                            action="store",
                            dest="i3status_conf",
                            type=str,
                            default=i3status_config_file_default,
                            help="path to i3status config file")
        parser.add_argument('-d',
                            '--debug',
                            action="store_true",
                            help="be verbose in syslog")
        parser.add_argument('-i',
                            '--include',
                            action="append",
                            dest="include_paths",
                            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""")
        parser.add_argument('-l',
                            '--log-file',
                            action="store",
                            dest="log_file",
                            type=str,
                            default=None,
                            help="path to py3status log file")
        parser.add_argument('-n',
                            '--interval',
                            action="store",
                            dest="interval",
                            type=float,
                            default=config['interval'],
                            help="update interval in seconds (default 1 sec)")
        parser.add_argument('-s',
                            '--standalone',
                            action="store_true",
                            help="standalone mode, do not use i3status")
        parser.add_argument('-t',
                            '--timeout',
                            action="store",
                            dest="cache_timeout",
                            type=int,
                            default=config['cache_timeout'],
                            help="""default injection cache timeout in seconds
                            (default 60 sec)""")
        parser.add_argument('-v',
                            '--version',
                            action="store_true",
                            help="""show py3status version and exit""")
        parser.add_argument('cli_command', nargs='*', help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config['cli_command'] = options.cli_command

        # only asked for version
        if options.version:
            from platform import python_version
            print('py3status version {} (python {})'.format(config['version'],
                                                            python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config['cache_timeout'] = options.cache_timeout
        config['debug'] = options.debug
        config['dbus_notify'] = options.dbus_notify
        if options.include_paths:
            config['include_paths'] = options.include_paths
        config['interval'] = int(options.interval)
        config['log_file'] = options.log_file
        config['standalone'] = options.standalone
        config['i3status_config_path'] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in self.config['include_paths']:
            include_path = os.path.abspath(include_path) + '/'
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith('.py'):
                    continue
                module_name = f_name[:-3]
                # do not overwrite modules if already found
                if module_name in user_modules:
                    pass
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(' ')[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only start and handle modules with available methods
                if my_m.methods:
                    my_m.start()
                    self.modules[module] = my_m
                elif self.config['debug']:
                    self.log(
                        'ignoring module "{}" (no methods found)'.format(
                            module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'Loading module "{}" failed ({}).'.format(module, err)
                self.notify_user(msg, level='warning')

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # SIGTSTP will be received from i3bar indicating that all output should
        # stop and we should consider py3status suspended.  It is however
        # important that any processes using i3 ipc should continue to receive
        # those events otherwise it can lead to a stall in i3.
        signal(SIGTSTP, self.i3bar_stop)
        # SIGCONT indicates output should be resumed.
        signal(SIGCONT, self.i3bar_start)

        # setup configuration
        self.config = self.get_config()

        if self.config.get('cli_command'):
            self.handle_cli_command(self.config)
            sys.exit()

        if self.config['debug']:
            self.log(
                'py3status started with config {}'.format(self.config))

        # read i3status.conf
        config_path = self.config['i3status_config_path']
        config = process_config(config_path, self)

        # setup i3status thread
        self.i3status_thread = I3status(self, config)

        # If standalone or no i3status modules then use the mock i3status
        # else start i3status thread.
        i3s_modules = self.i3status_thread.config['i3s_modules']
        if self.config['standalone'] or not i3s_modules:
            self.i3status_thread.mock()
            i3s_mode = 'mocked'
        else:
            i3s_mode = 'started'
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    # i3status is having a bad day, so tell the user what went
                    # wrong and do the best we can with just py3status modules.
                    err = self.i3status_thread.error
                    self.notify_user(err)
                    self.i3status_thread.mock()
                    i3s_mode = 'mocked'
                    break
                time.sleep(0.1)
        if self.config['debug']:
            self.log('i3status thread {} with config {}'.format(
                i3s_mode, self.i3status_thread.config))

        # setup input events thread
        self.events_thread = Events(self)
        self.events_thread.start()
        if self.config['debug']:
            self.log('events thread started')

        # suppress modules' ouput wrt issue #20
        if not self.config['debug']:
            sys.stdout = open('/dev/null', 'w')
            sys.stderr = open('/dev/null', 'w')

        # get the list of py3status configured modules
        self.py3_modules = self.i3status_thread.config['py3_modules']

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config['debug']:
            self.log('user_modules={}'.format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def notify_user(self, msg, level='error', rate_limit=None, module_name=''):
        """
        Display notification to user via i3-nagbar or send-notify
        We also make sure to log anything to keep trace of it.

        NOTE: Message should end with a '.' for consistency.
        """
        dbus = self.config.get('dbus_notify')
        if dbus:
            # force msg to be a string
            msg = '{}'.format(msg)
        else:
            msg = 'py3status: {}'.format(msg)
        if level != 'info' and module_name == '':
            fix_msg = '{} Please try to fix this and reload i3wm (Mod+Shift+R)'
            msg = fix_msg.format(msg)
        # Rate limiting. If rate limiting then we need to calculate the time
        # period for which the message should not be repeated.  We just use
        # A simple chunked time model where a message cannot be repeated in a
        # given time period. Messages can be repeated more frequently but must
        # be in different time periods.

        limit_key = ''
        if rate_limit:
            try:
                limit_key = time.time()//rate_limit
            except TypeError:
                pass
        # We use a hash to see if the message is being repeated.  This is crude
        # and imperfect but should work for our needs.
        msg_hash = hash('{}#{}#{}'.format(module_name, limit_key, msg))
        if msg_hash in self.notified_messages:
            return
        else:
            self.log(msg, level)
            self.notified_messages.add(msg_hash)

        try:
            if dbus:
                # fix any html entities
                msg = msg.replace('&', '&amp;')
                msg = msg.replace('<', '&lt;')
                msg = msg.replace('>', '&gt;')
                cmd = ['notify-send', '-u', DBUS_LEVELS.get(level, 'normal'),
                       '-t', '10000', 'py3status', msg]
            else:
                cmd = ['i3-nagbar', '-m', msg, '-t', level]
            Popen(cmd,
                  stdout=open('/dev/null', 'w'),
                  stderr=open('/dev/null', 'w'))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        try:
            self.lock.clear()
            if self.config['debug']:
                self.log('lock cleared, exiting')
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
        except:
            pass

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        so we force i3status to refresh by sending it a SIGUSR1
        and we clear all py3status modules' cache.

        To prevent abuse, we rate limit this function to 100ms.
        """
        if time.time() > (self.last_refresh_ts + 0.1):
            self.log('received USR1, forcing refresh')

            # send SIGUSR1 to i3status
            call(['killall', '-s', 'USR1', 'i3status'])

            # clear the cache of all modules
            self.clear_modules_cache()

            # reset the refresh timestamp
            self.last_refresh_ts = time.time()
        else:
            self.log('received USR1 but rate limit is in effect, calm down')

    def clear_modules_cache(self):
        """
        For every module, reset the 'cached_until' of all its methods.
        """
        for module in self.modules.values():
            module.force_update()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    def notify_update(self, update):
        """
        Name or list of names of modules that have updated.
        """
        if not isinstance(update, list):
            update = [update]
        self.queue.extend(update)

        # find groups that use the modules updated
        module_groups = self.i3status_thread.config['.module_groups']
        groups_to_update = set()
        for item in update:
            if item in module_groups:
                groups_to_update.update(set(module_groups[item]))
        # force groups to update
        for group in groups_to_update:
            group_module = self.output_modules.get(group)
            if group_module:
                group_module['module'].force_update()

    def log(self, msg, level='info'):
        """
        log this information to syslog or user provided logfile.
        """
        if not self.config['log_file']:
            # If level was given as a str then convert to actual level
            level = LOG_LEVELS.get(level, level)
            syslog(level, msg)
        else:
            with open(self.config['log_file'], 'a') as f:
                log_time = time.strftime("%Y-%m-%d %H:%M:%S")
                f.write('{} {} {}\n'.format(log_time, level.upper(), msg))

    def report_exception(self, msg, notify_user=True):
        """
        Report details of an exception to the user.
        This should only be called within an except: block Details of the
        exception are reported eg filename, line number and exception type.

        Because stack trace information outside of py3status or it's modules is
        not helpful in actually finding and fixing the error, we try to locate
        the first place that the exception affected our code.

        NOTE: msg should not end in a '.' for consistency.
        """
        # Get list of paths that our stack trace should be found in.
        py3_paths = [os.path.dirname(__file__)]
        user_paths = self.config['include_paths']
        py3_paths += [os.path.abspath(path) + '/' for path in user_paths]
        traceback = None

        try:
            # We need to make sure to delete tb even if things go wrong.
            exc_type, exc_obj, tb = sys.exc_info()
            stack = extract_tb(tb)
            error_str = '{}: {}\n'.format(exc_type.__name__, exc_obj)
            traceback = [error_str] + format_tb(tb)
            # Find first relevant trace in the stack.
            # it should be in py3status or one of it's modules.
            found = False
            for item in reversed(stack):
                filename = item[0]
                for path in py3_paths:
                    if filename.startswith(path):
                        # Found a good trace
                        filename = os.path.basename(item[0])
                        line_no = item[1]
                        found = True
                        break
                if found:
                    break
            # all done!  create our message.
            msg = '{} ({}) {} line {}.'.format(
                msg, exc_type.__name__, filename, line_no)
        except:
            # something went wrong report what we can.
            msg = '{}.'.format(msg)
        finally:
            # delete tb!
            del tb
        # log the exception and notify user
        self.log(msg, 'warning')
        if traceback and self.config['log_file']:
            self.log(''.join(['Traceback\n'] + traceback))
        if notify_user:
            self.notify_user(msg, level='error')

    def create_output_modules(self):
        """
        Setup our output modules to allow easy updating of py3modules and
        i3status modules allows the same module to be used multiple times.
        """
        config = self.i3status_thread.config
        i3modules = self.i3status_thread.i3modules
        output_modules = self.output_modules
        # position in the bar of the modules
        positions = {}
        for index, name in enumerate(config['order']):
            if name not in positions:
                positions[name] = []
            positions[name].append(index)

        # py3status modules
        for name in self.modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = self.modules[name]
                output_modules[name]['type'] = 'py3status'
        # i3status modules
        for name in i3modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = i3modules[name]
                output_modules[name]['type'] = 'i3status'

        self.output_modules = output_modules

    def i3bar_stop(self, signum, frame):
        self.i3bar_running = False
        # i3status should be stopped
        self.i3status_thread.suspend_i3status()
        self.sleep_modules()

    def i3bar_start(self, signum, frame):
        self.i3bar_running = True
        self.wake_modules()

    def sleep_modules(self):
        # Put all py3modules to sleep so they stop updating
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].sleep()

    def wake_modules(self):
        # Wake up all py3modules.
        for module in self.output_modules.values():
            if module['type'] == 'py3status':
                module['module'].wake()

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        i3status_thread = self.i3status_thread
        config = i3status_thread.config
        self.create_output_modules()

        # update queue populate with all py3modules
        self.queue.extend(self.modules)

        # this will be our output set to the correct length for the number of
        # items in the bar
        output = [None] * len(config['order'])

        interval = self.config['interval']
        last_sec = 0

        # start our output
        header = {
            'version': 1,
            'click_events': True,
            'stop_signal': SIGTSTP
        }
        print_line(dumps(header))
        print_line('[[]')

        # main loop
        while True:
            while not self.i3bar_running:
                time.sleep(0.1)

            sec = int(time.time())

            # only check everything is good each second
            if sec > last_sec:
                last_sec = sec

                # check i3status thread
                if not i3status_thread.is_alive():
                    err = i3status_thread.error
                    if not err:
                        err = 'I3status died horribly.'
                    self.notify_user(err)

                # check events thread
                if not self.events_thread.is_alive():
                    # don't spam the user with i3-nagbar warnings
                    if not hasattr(self.events_thread, 'nagged'):
                        self.events_thread.nagged = True
                        err = 'Events thread died, click events are disabled.'
                        self.notify_user(err, level='warning')

                # update i3status time/tztime items
                if interval == 0 or sec % interval == 0:
                    i3status_thread.update_times()

            # check if an update is needed
            if self.queue:
                while (len(self.queue)):
                    module_name = self.queue.popleft()
                    module = self.output_modules[module_name]
                    for index in module['position']:
                        # store the output as json
                        # modules can have more than one output
                        out = module['module'].get_latest()
                        output[index] = ', '.join([dumps(x) for x in out])

                # build output string
                out = ','.join([x for x in output if x])
                # dump the line to stdout
                print_line(',[{}]'.format(out))

            # sleep a bit before doing this again to avoid killing the CPU
            time.sleep(0.1)

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config['cli_command']
        # aliases
        if cmd[0] in ['mod', 'module', 'modules']:
            cmd[0] = 'modules'

        # allowed cli commands
        if cmd[:2] in (['modules', 'list'], ['modules', 'details']):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (['docstring', 'check'], ['docstring', 'update']):
            if cmd[1] == 'check':
                show_diff = len(cmd) > 2 and cmd[2] == 'diff'
                docstrings.check_docstrings(show_diff, config)
            if cmd[1] == 'update':
                if len(cmd) < 3:
                    print_stderr('Error: you must specify what to update')
                    sys.exit(1)

                if cmd[2] == 'modules':
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (['modules', 'enable'], ['modules', 'disable']):
            # TODO: to be implemented
            pass
        else:
            print_stderr('Error: unknown command')
            sys.exit(1)
Exemple #5
0
class Py3statusWrapper:
    """
    This is the py3status wrapper.
    """

    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.last_refresh_ts = time()
        self.lock = Event()
        self.modules = {}
        self.py3_modules = []

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser("~")

        # defaults
        config = {
            "cache_timeout": 60,
            "include_paths": ["{}/.i3/py3status/".format(home_path)],
            "interval": 1,
            "minimum_interval": 0.1,  # minimum module update interval
        }

        # package version
        try:
            import pkg_resources

            version = pkg_resources.get_distribution("py3status").version
        except:
            version = "unknown"
        config["version"] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            "{}/.i3status.conf".format(home_path),
            "{}/.config/i3status/config".format(os.environ.get("XDG_CONFIG_HOME", home_path)),
            "/etc/i3status.conf",
            "{}/i3status/config".format(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg")),
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = "{}/.i3/i3status.conf".format(home_path)

        # command line options
        parser = argparse.ArgumentParser(description="The agile, python-powered, i3status wrapper")
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument(
            "-c",
            "--config",
            action="store",
            dest="i3status_conf",
            type=str,
            default=i3status_config_file_default,
            help="path to i3status config file",
        )
        parser.add_argument("-d", "--debug", action="store_true", help="be verbose in syslog")
        parser.add_argument(
            "-i",
            "--include",
            action="append",
            dest="include_paths",
            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""",
        )
        parser.add_argument(
            "-n",
            "--interval",
            action="store",
            dest="interval",
            type=float,
            default=config["interval"],
            help="update interval in seconds (default 1 sec)",
        )
        parser.add_argument("-s", "--standalone", action="store_true", help="standalone mode, do not use i3status")
        parser.add_argument(
            "-t",
            "--timeout",
            action="store",
            dest="cache_timeout",
            type=int,
            default=config["cache_timeout"],
            help="""default injection cache timeout in seconds
                            (default 60 sec)""",
        )
        parser.add_argument("-v", "--version", action="store_true", help="""show py3status version and exit""")
        parser.add_argument("cli_command", nargs="*", help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config["cli_command"] = options.cli_command

        # only asked for version
        if options.version:
            from platform import python_version

            print("py3status version {} (python {})".format(config["version"], python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config["cache_timeout"] = options.cache_timeout
        config["debug"] = options.debug
        if options.include_paths:
            config["include_paths"] = options.include_paths
        config["interval"] = int(options.interval)
        config["standalone"] = options.standalone
        config["i3status_config_path"] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in sorted(self.config["include_paths"]):
            include_path = os.path.abspath(include_path) + "/"
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith(".py"):
                    continue
                module_name = f_name[:-3]
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(" ")[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only start and handle modules with available methods
                if my_m.methods:
                    my_m.start()
                    self.modules[module] = my_m
                elif self.config["debug"]:
                    syslog(LOG_INFO, 'ignoring module "{}" (no methods found)'.format(module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'loading module "{}" failed ({})'.format(module, err)
                self.i3_nagbar(msg, level="warning")

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # setup configuration
        self.config = self.get_config()

        if self.config.get("cli_command"):
            self.handle_cli_command(self.config)
            sys.exit()

        if self.config["debug"]:
            syslog(LOG_INFO, "py3status started with config {}".format(self.config))

        # setup i3status thread
        self.i3status_thread = I3status(self.lock, self.config["i3status_config_path"], self.config["standalone"])
        if self.config["standalone"]:
            self.i3status_thread.mock()
        else:
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    err = self.i3status_thread.error
                    raise IOError(err)
                sleep(0.1)
        if self.config["debug"]:
            syslog(
                LOG_INFO,
                "i3status thread {} with config {}".format(
                    "started" if not self.config["standalone"] else "mocked", self.i3status_thread.config
                ),
            )

        # setup input events thread
        self.events_thread = Events(self.lock, self.config, self.modules, self.i3status_thread.config)
        self.events_thread.start()
        if self.config["debug"]:
            syslog(LOG_INFO, "events thread started")

        # suppress modules' ouput wrt issue #20
        if not self.config["debug"]:
            sys.stdout = open("/dev/null", "w")
            sys.stderr = open("/dev/null", "w")

        # get the list of py3status configured modules
        self.py3_modules = self.i3status_thread.config["py3_modules"]

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config["debug"]:
            syslog(LOG_INFO, "user_modules={}".format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def i3_nagbar(self, msg, level="error"):
        """
        Make use of i3-nagbar to display errors and warnings to the user.
        We also make sure to log anything to keep trace of it.
        """
        msg = "py3status: {}. ".format(msg)
        msg += "please try to fix this and reload i3wm (Mod+Shift+R)"
        try:
            log_level = LOG_ERR if level == "error" else LOG_WARNING
            syslog(log_level, msg)
            Popen(["i3-nagbar", "-m", msg, "-t", level], stdout=open("/dev/null", "w"), stderr=open("/dev/null", "w"))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        try:
            self.lock.clear()
            if self.config["debug"]:
                syslog(LOG_INFO, "lock cleared, exiting")
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
            self.i3status_thread.cleanup_tmpfile()
        except:
            pass

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        so we force i3status to refresh by sending it a SIGUSR1
        and we clear all py3status modules' cache.

        To prevent abuse, we rate limit this function to 100ms.
        """
        if time() > (self.last_refresh_ts + 0.1):
            syslog(LOG_INFO, "received USR1, forcing refresh")

            # send SIGUSR1 to i3status
            call(["killall", "-s", "USR1", "i3status"])

            # clear the cache of all modules
            self.clear_modules_cache()

            # reset the refresh timestamp
            self.last_refresh_ts = time()
        else:
            syslog(LOG_INFO, "received USR1 but rate limit is in effect, calm down")

    def clear_modules_cache(self):
        """
        For every module, reset the 'cached_until' of all its methods.
        """
        for module in self.modules.values():
            module.clear_cache()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        delta = 0
        last_delta = -1
        previous_json_list = []

        # main loop
        while True:
            # check i3status thread
            if not self.i3status_thread.is_alive():
                err = self.i3status_thread.error
                if not err:
                    err = "i3status died horribly"
                self.i3_nagbar(err)
                break

            # check events thread
            if not self.events_thread.is_alive():
                # don't spam the user with i3-nagbar warnings
                if not hasattr(self.events_thread, "i3_nagbar"):
                    self.events_thread.i3_nagbar = True
                    err = "events thread died, click events are disabled"
                    self.i3_nagbar(err, level="warning")

            # get output from i3status
            prefix = self.i3status_thread.last_prefix
            json_list = deepcopy(self.i3status_thread.json_list)

            # transform time and tztime outputs from i3status
            # every configured interval seconds
            if (
                self.config["interval"] <= 1
                or int(delta) % self.config["interval"] == 0
                and int(last_delta) != int(delta)
            ):
                delta = 0
                last_delta = 0
                json_list = self.i3status_thread.tick_time_modules(json_list, force=True)
            else:
                json_list = self.i3status_thread.tick_time_modules(json_list, force=False)

            # construct the global output
            if self.modules and self.py3_modules:
                # new style i3status configured ordering
                json_list = self.i3status_thread.get_modules_output(json_list, self.modules)

            # dump the line to stdout only on change
            if json_list != previous_json_list:
                print_line("{}{}".format(prefix, dumps(json_list)))

            # remember the last json list output
            previous_json_list = deepcopy(json_list)

            # reset i3status json_list and json_list_ts
            self.i3status_thread.update_json_list()

            # sleep a bit before doing this again to avoid killing the CPU
            delta += 0.1
            sleep(0.1)

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config["cli_command"]
        # aliases
        if cmd[0] in ["mod", "module", "modules"]:
            cmd[0] = "modules"

        # allowed cli commands
        if cmd[:2] in (["modules", "list"], ["modules", "details"]):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (["docstring", "check"], ["docstring", "update"]):
            if cmd[1] == "check":
                show_diff = len(cmd) > 2 and cmd[2] == "diff"
                docstrings.check_docstrings(show_diff, config)
            if cmd[1] == "update":
                if len(cmd) < 3:
                    print_stderr("Error: you must specify what to update")
                    sys.exit(1)

                if cmd[2] == "modules":
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (["modules", "enable"], ["modules", "disable"]):
            # TODO: to be implemented
            pass
        else:
            print_stderr("Error: unknown command")
            sys.exit(1)
Exemple #6
0
class Py3statusWrapper():
    """
    This is the py3status wrapper.
    """
    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.config = {}
        self.last_refresh_ts = time()
        self.lock = Event()
        self.modules = {}
        self.output_modules = {}
        self.py3_modules = []
        self.queue = deque()

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser('~')

        # defaults
        config = {
            'cache_timeout': 60,
            'include_paths': ['{}/.i3/py3status/'.format(home_path)],
            'interval': 1,
            'minimum_interval': 0.1,  # minimum module update interval
            'dbus_notify': False,
        }

        # package version
        try:
            import pkg_resources
            version = pkg_resources.get_distribution('py3status').version
        except:
            version = 'unknown'
        config['version'] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            '{}/.i3status.conf'.format(home_path),
            '{}/.config/i3status/config'.format(
                os.environ.get('XDG_CONFIG_HOME', home_path)),
            '/etc/i3status.conf', '{}/i3status/config'.format(
                os.environ.get('XDG_CONFIG_DIRS', '/etc/xdg'))
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = '{}/.i3/i3status.conf'.format(
                home_path)

        # command line options
        parser = argparse.ArgumentParser(
            description='The agile, python-powered, i3status wrapper')
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument('-c',
                            '--config',
                            action="store",
                            dest="i3status_conf",
                            type=str,
                            default=i3status_config_file_default,
                            help="path to i3status config file")
        parser.add_argument('-d',
                            '--debug',
                            action="store_true",
                            help="be verbose in syslog")
        parser.add_argument('-b',
                            '--dbus-notify',
                            action="store_true",
                            default=False,
                            dest="dbus_notify",
                            help="use notify-send to send user notifications")
        parser.add_argument('-i',
                            '--include',
                            action="append",
                            dest="include_paths",
                            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""")
        parser.add_argument('-n',
                            '--interval',
                            action="store",
                            dest="interval",
                            type=float,
                            default=config['interval'],
                            help="update interval in seconds (default 1 sec)")
        parser.add_argument('-s',
                            '--standalone',
                            action="store_true",
                            help="standalone mode, do not use i3status")
        parser.add_argument('-t',
                            '--timeout',
                            action="store",
                            dest="cache_timeout",
                            type=int,
                            default=config['cache_timeout'],
                            help="""default injection cache timeout in seconds
                            (default 60 sec)""")
        parser.add_argument('-v',
                            '--version',
                            action="store_true",
                            help="""show py3status version and exit""")
        parser.add_argument('cli_command', nargs='*', help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config['cli_command'] = options.cli_command

        # only asked for version
        if options.version:
            from platform import python_version
            print('py3status version {} (python {})'.format(
                config['version'], python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config['cache_timeout'] = options.cache_timeout
        config['debug'] = options.debug
        config['dbus_notify'] = options.dbus_notify
        if options.include_paths:
            config['include_paths'] = options.include_paths
        config['interval'] = int(options.interval)
        config['standalone'] = options.standalone
        config['i3status_config_path'] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in sorted(self.config['include_paths']):
            include_path = os.path.abspath(include_path) + '/'
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith('.py'):
                    continue
                module_name = f_name[:-3]
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(' ')[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only start and handle modules with available methods
                if my_m.methods:
                    my_m.start()
                    self.modules[module] = my_m
                elif self.config['debug']:
                    syslog(
                        LOG_INFO,
                        'ignoring module "{}" (no methods found)'.format(
                            module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'loading module "{}" failed ({})'.format(module, err)
                self.notify_user(msg, level='warning')

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # setup configuration
        self.config = self.get_config()

        if self.config.get('cli_command'):
            self.handle_cli_command(self.config)
            sys.exit()

        if self.config['debug']:
            syslog(LOG_INFO,
                   'py3status started with config {}'.format(self.config))

        # setup i3status thread
        self.i3status_thread = I3status(self)
        if self.config['standalone']:
            self.i3status_thread.mock()
        else:
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    err = self.i3status_thread.error
                    raise IOError(err)
                sleep(0.1)
        if self.config['debug']:
            syslog(
                LOG_INFO, 'i3status thread {} with config {}'.format(
                    'started' if not self.config['standalone'] else 'mocked',
                    self.i3status_thread.config))

        # setup input events thread
        self.events_thread = Events(self)
        self.events_thread.start()
        if self.config['debug']:
            syslog(LOG_INFO, 'events thread started')

        # suppress modules' ouput wrt issue #20
        if not self.config['debug']:
            sys.stdout = open('/dev/null', 'w')
            sys.stderr = open('/dev/null', 'w')

        # get the list of py3status configured modules
        self.py3_modules = self.i3status_thread.config['py3_modules']

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config['debug']:
            syslog(LOG_INFO, 'user_modules={}'.format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def notify_user(self, msg, level='error'):
        """
        Make use of i3-nagbar to display errors and warnings to the user.
        We also make sure to log anything to keep trace of it.
        """
        dbus = self.config.get('dbus_notify')
        if not dbus:
            msg = 'py3status: {}.'.format(msg)
        else:
            msg = '{}.'.format(msg)
        if level != 'info':
            msg += ' Please try to fix this and reload i3wm (Mod+Shift+R)'
        try:
            log_level = LOG_LEVELS.get(level, LOG_ERR)
            syslog(log_level, msg)
            if dbus:
                # fix any html entities
                msg = msg.replace('&', '&amp;')
                msg = msg.replace('<', '&lt;')
                msg = msg.replace('>', '&gt;')
                cmd = [
                    'notify-send', '-u',
                    DBUS_LEVELS.get(level, 'normal'), '-t', '10000',
                    'py3status', msg
                ]
            else:
                cmd = ['i3-nagbar', '-m', msg, '-t', level]
            Popen(cmd,
                  stdout=open('/dev/null', 'w'),
                  stderr=open('/dev/null', 'w'))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        try:
            self.lock.clear()
            if self.config['debug']:
                syslog(LOG_INFO, 'lock cleared, exiting')
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
            self.i3status_thread.cleanup_tmpfile()
        except:
            pass

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        so we force i3status to refresh by sending it a SIGUSR1
        and we clear all py3status modules' cache.

        To prevent abuse, we rate limit this function to 100ms.
        """
        if time() > (self.last_refresh_ts + 0.1):
            syslog(LOG_INFO, 'received USR1, forcing refresh')

            # send SIGUSR1 to i3status
            call(['killall', '-s', 'USR1', 'i3status'])

            # clear the cache of all modules
            self.clear_modules_cache()

            # reset the refresh timestamp
            self.last_refresh_ts = time()
        else:
            syslog(LOG_INFO,
                   'received USR1 but rate limit is in effect, calm down')

    def clear_modules_cache(self):
        """
        For every module, reset the 'cached_until' of all its methods.
        """
        for module in self.modules.values():
            module.force_update()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    def notify_update(self, update):
        """
        Name or list of names of modules that have updated.
        """
        if not isinstance(update, list):
            update = [update]
        self.queue.extend(update)

        # find groups that use the modules updated
        module_groups = self.i3status_thread.config['.module_groups']
        groups_to_update = set()
        for item in update:
            if item in module_groups:
                groups_to_update.update(set(module_groups[item]))
        # force groups to update
        for group in groups_to_update:
            group_module = self.output_modules.get(group)
            if group_module:
                group_module['module'].force_update()

    def report_exception(self, msg, notify_user=True):
        """
        Report details of an exception to the user.
        This should only be called within an except: block Details of the
        exception are reported eg filename, line number and exception type.
        """
        try:
            # we need to make sure to delete tb even if things go wrong.
            exc_type, exc_obj, tb = sys.exc_info()
            stack = extract_tb(tb)
            filename = os.path.basename(stack[-1][0])
            line_no = stack[-1][1]
            msg = '{} ({}) {} line {}'.format(msg, exc_type.__name__, filename,
                                              line_no)
        except:
            # something went wrong report what we can.
            msg = '{} '.format(msg)
        finally:
            # delete tb!
            del tb
        # log the exception and notify user
        syslog(LOG_WARNING, msg)
        if notify_user:
            self.notify_user(msg, level='error')

    def create_output_modules(self):
        """
        Setup our output modules to allow easy updating of py3modules and
        i3status modules allows the same module to be used multiple times.
        """
        config = self.i3status_thread.config
        i3modules = self.i3status_thread.i3modules
        output_modules = {}
        # position in the bar of the modules
        positions = {}
        for index, name in enumerate(config['order']):
            if name not in positions:
                positions[name] = []
            positions[name].append(index)

        # py3status modules
        for name in self.modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = self.modules[name]
                output_modules[name]['type'] = 'py3status'
        # i3status modules
        for name in i3modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = i3modules[name]
                output_modules[name]['type'] = 'i3status'

        self.output_modules = output_modules

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        i3status_thread = self.i3status_thread
        config = i3status_thread.config
        self.create_output_modules()

        # update queue populate with all py3modules
        self.queue.extend(self.modules)

        # this will be our output set to the correct length for the number of
        # items in the bar
        output = [None] * len(config['order'])

        interval = self.config['interval']
        last_sec = 0

        # main loop
        while True:
            sec = int(time())

            # only check everything is good each second
            if sec > last_sec:
                last_sec = sec

                # check i3status thread
                if not i3status_thread.is_alive():
                    err = i3status_thread.error
                    if not err:
                        err = 'i3status died horribly'
                    self.notify_user(err)
                    break

                # check events thread
                if not self.events_thread.is_alive():
                    # don't spam the user with i3-nagbar warnings
                    if not hasattr(self.events_thread, 'nagged'):
                        self.events_thread.nagged = True
                        err = 'events thread died, click events are disabled'
                        self.notify_user(err, level='warning')

                # update i3status time/tztime items
                if interval == 0 or sec % interval == 0:
                    i3status_thread.update_times()

            # check if an update is needed
            if self.queue:
                while (len(self.queue)):
                    module_name = self.queue.popleft()
                    module = self.output_modules[module_name]
                    for index in module['position']:
                        # store the output as json
                        # modules can have more than one output
                        out = module['module'].get_latest()
                        output[index] = ', '.join([dumps(x) for x in out])

                prefix = i3status_thread.last_prefix
                # build output string
                out = ','.join([x for x in output if x])
                # dump the line to stdout
                print_line('{}[{}]'.format(prefix, out))

            # sleep a bit before doing this again to avoid killing the CPU
            sleep(0.1)

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config['cli_command']
        # aliases
        if cmd[0] in ['mod', 'module', 'modules']:
            cmd[0] = 'modules'

        # allowed cli commands
        if cmd[:2] in (['modules', 'list'], ['modules', 'details']):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (['docstring', 'check'], ['docstring', 'update']):
            if cmd[1] == 'check':
                show_diff = len(cmd) > 2 and cmd[2] == 'diff'
                docstrings.check_docstrings(show_diff, config)
            if cmd[1] == 'update':
                if len(cmd) < 3:
                    print_stderr('Error: you must specify what to update')
                    sys.exit(1)

                if cmd[2] == 'modules':
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (['modules', 'enable'], ['modules', 'disable']):
            # TODO: to be implemented
            pass
        else:
            print_stderr('Error: unknown command')
            sys.exit(1)
Exemple #7
0
class Py3statusWrapper():
    """
    This is the py3status wrapper.
    """

    def __init__(self):
        """
        Useful variables we'll need.
        """
        self.last_refresh_ts = time()
        self.lock = Event()
        self.modules = {}
        self.output_modules = {}
        self.py3_modules = []
        self.queue = deque()

    def get_config(self):
        """
        Create the py3status based on command line options we received.
        """
        # get home path
        home_path = os.path.expanduser('~')

        # defaults
        config = {
            'cache_timeout': 60,
            'include_paths': ['{}/.i3/py3status/'.format(home_path)],
            'interval': 1,
            'minimum_interval': 0.1  # minimum module update interval
        }

        # package version
        try:
            import pkg_resources
            version = pkg_resources.get_distribution('py3status').version
        except:
            version = 'unknown'
        config['version'] = version

        # i3status config file default detection
        # respect i3status' file detection order wrt issue #43
        i3status_config_file_candidates = [
            '{}/.i3status.conf'.format(home_path),
            '{}/.config/i3status/config'.format(os.environ.get(
                'XDG_CONFIG_HOME', home_path)), '/etc/i3status.conf',
            '{}/i3status/config'.format(os.environ.get('XDG_CONFIG_DIRS',
                                                       '/etc/xdg'))
        ]
        for fn in i3status_config_file_candidates:
            if os.path.isfile(fn):
                i3status_config_file_default = fn
                break
        else:
            # if none of the default files exists, we will default
            # to ~/.i3/i3status.conf
            i3status_config_file_default = '{}/.i3/i3status.conf'.format(
                home_path)

        # command line options
        parser = argparse.ArgumentParser(
            description='The agile, python-powered, i3status wrapper')
        parser = argparse.ArgumentParser(add_help=True)
        parser.add_argument('-c',
                            '--config',
                            action="store",
                            dest="i3status_conf",
                            type=str,
                            default=i3status_config_file_default,
                            help="path to i3status config file")
        parser.add_argument('-d',
                            '--debug',
                            action="store_true",
                            help="be verbose in syslog")
        parser.add_argument('-i',
                            '--include',
                            action="append",
                            dest="include_paths",
                            help="""include user-written modules from those
                            directories (default ~/.i3/py3status)""")
        parser.add_argument('-n',
                            '--interval',
                            action="store",
                            dest="interval",
                            type=float,
                            default=config['interval'],
                            help="update interval in seconds (default 1 sec)")
        parser.add_argument('-s',
                            '--standalone',
                            action="store_true",
                            help="standalone mode, do not use i3status")
        parser.add_argument('-t',
                            '--timeout',
                            action="store",
                            dest="cache_timeout",
                            type=int,
                            default=config['cache_timeout'],
                            help="""default injection cache timeout in seconds
                            (default 60 sec)""")
        parser.add_argument('-v',
                            '--version',
                            action="store_true",
                            help="""show py3status version and exit""")
        parser.add_argument('cli_command', nargs='*', help=argparse.SUPPRESS)

        options = parser.parse_args()

        if options.cli_command:
            config['cli_command'] = options.cli_command

        # only asked for version
        if options.version:
            from platform import python_version
            print('py3status version {} (python {})'.format(config['version'],
                                                            python_version()))
            sys.exit(0)

        # override configuration and helper variables
        config['cache_timeout'] = options.cache_timeout
        config['debug'] = options.debug
        if options.include_paths:
            config['include_paths'] = options.include_paths
        config['interval'] = int(options.interval)
        config['standalone'] = options.standalone
        config['i3status_config_path'] = options.i3status_conf

        # all done
        return config

    def get_user_modules(self):
        """
        Search configured include directories for user provided modules.

        user_modules: {
            'weather_yahoo': ('~/i3/py3status/', 'weather_yahoo.py')
        }
        """
        user_modules = {}
        for include_path in sorted(self.config['include_paths']):
            include_path = os.path.abspath(include_path) + '/'
            if not os.path.isdir(include_path):
                continue
            for f_name in sorted(os.listdir(include_path)):
                if not f_name.endswith('.py'):
                    continue
                module_name = f_name[:-3]
                user_modules[module_name] = (include_path, f_name)
        return user_modules

    def get_user_configured_modules(self):
        """
        Get a dict of all available and configured py3status modules
        in the user's i3status.conf.
        """
        user_modules = {}
        if not self.py3_modules:
            return user_modules
        for module_name, module_info in self.get_user_modules().items():
            for module in self.py3_modules:
                if module_name == module.split(' ')[0]:
                    include_path, f_name = module_info
                    user_modules[module_name] = (include_path, f_name)
        return user_modules

    def load_modules(self, modules_list, user_modules):
        """
        Load the given modules from the list (contains instance name) with
        respect to the user provided modules dict.

        modules_list: ['weather_yahoo paris', 'net_rate']
        user_modules: {
            'weather_yahoo': ('/etc/py3status.d/', 'weather_yahoo.py')
        }
        """
        for module in modules_list:
            # ignore already provided modules (prevents double inclusion)
            if module in self.modules:
                continue
            try:
                my_m = Module(module, user_modules, self)
                # only start and handle modules with available methods
                if my_m.methods:
                    my_m.start()
                    self.modules[module] = my_m
                elif self.config['debug']:
                    syslog(LOG_INFO,
                           'ignoring module "{}" (no methods found)'.format(
                               module))
            except Exception:
                err = sys.exc_info()[1]
                msg = 'loading module "{}" failed ({})'.format(module, err)
                self.i3_nagbar(msg, level='warning')

    def setup(self):
        """
        Setup py3status and spawn i3status/events/modules threads.
        """
        # set the Event lock
        self.lock.set()

        # setup configuration
        self.config = self.get_config()

        if self.config.get('cli_command'):
            self.handle_cli_command(self.config)
            sys.exit()

        if self.config['debug']:
            syslog(LOG_INFO,
                   'py3status started with config {}'.format(self.config))

        # setup i3status thread
        self.i3status_thread = I3status(self)
        if self.config['standalone']:
            self.i3status_thread.mock()
        else:
            self.i3status_thread.start()
            while not self.i3status_thread.ready:
                if not self.i3status_thread.is_alive():
                    err = self.i3status_thread.error
                    raise IOError(err)
                sleep(0.1)
        if self.config['debug']:
            syslog(LOG_INFO, 'i3status thread {} with config {}'.format(
                'started' if not self.config['standalone'] else 'mocked',
                self.i3status_thread.config))

        # setup input events thread
        self.events_thread = Events(self.lock, self.config, self.modules,
                                    self.i3status_thread.config)
        self.events_thread.start()
        if self.config['debug']:
            syslog(LOG_INFO, 'events thread started')

        # suppress modules' ouput wrt issue #20
        if not self.config['debug']:
            sys.stdout = open('/dev/null', 'w')
            sys.stderr = open('/dev/null', 'w')

        # get the list of py3status configured modules
        self.py3_modules = self.i3status_thread.config['py3_modules']

        # get a dict of all user provided modules
        user_modules = self.get_user_configured_modules()
        if self.config['debug']:
            syslog(LOG_INFO, 'user_modules={}'.format(user_modules))

        if self.py3_modules:
            # load and spawn i3status.conf configured modules threads
            self.load_modules(self.py3_modules, user_modules)

    def i3_nagbar(self, msg, level='error'):
        """
        Make use of i3-nagbar to display errors and warnings to the user.
        We also make sure to log anything to keep trace of it.
        """
        msg = 'py3status: {}. '.format(msg)
        msg += 'please try to fix this and reload i3wm (Mod+Shift+R)'
        try:
            log_level = LOG_ERR if level == 'error' else LOG_WARNING
            syslog(log_level, msg)
            Popen(['i3-nagbar', '-m', msg, '-t', level],
                  stdout=open('/dev/null', 'w'),
                  stderr=open('/dev/null', 'w'))
        except:
            pass

    def stop(self):
        """
        Clear the Event lock, this will break all threads' loops.
        """
        try:
            self.lock.clear()
            if self.config['debug']:
                syslog(LOG_INFO, 'lock cleared, exiting')
            # run kill() method on all py3status modules
            for module in self.modules.values():
                module.kill()
            self.i3status_thread.cleanup_tmpfile()
        except:
            pass

    def sig_handler(self, signum, frame):
        """
        SIGUSR1 was received, the user asks for an immediate refresh of the bar
        so we force i3status to refresh by sending it a SIGUSR1
        and we clear all py3status modules' cache.

        To prevent abuse, we rate limit this function to 100ms.
        """
        if time() > (self.last_refresh_ts + 0.1):
            syslog(LOG_INFO, 'received USR1, forcing refresh')

            # send SIGUSR1 to i3status
            call(['killall', '-s', 'USR1', 'i3status'])

            # clear the cache of all modules
            self.clear_modules_cache()

            # reset the refresh timestamp
            self.last_refresh_ts = time()
        else:
            syslog(LOG_INFO,
                   'received USR1 but rate limit is in effect, calm down')

    def clear_modules_cache(self):
        """
        For every module, reset the 'cached_until' of all its methods.
        """
        for module in self.modules.values():
            module.clear_cache()

    def terminate(self, signum, frame):
        """
        Received request to terminate (SIGTERM), exit nicely.
        """
        raise KeyboardInterrupt()

    def notify_update(self, update):
        """
        Name or list of names of modules that have updated.
        """
        if not isinstance(update, list):
            update = [update]
        self.queue.extend(update)

        # find groups that use the modules updated
        module_groups = self.i3status_thread.config['.module_groups']
        groups_to_update = set()
        for item in update:
            if item in module_groups:
                groups_to_update.update(set(module_groups[item]))
        # force groups to update
        for group in groups_to_update:
            group_module = self.output_modules.get(group)
            if group_module:
                group_module['module'].clear_cache()
                group_module['module'].run()

    def create_output_modules(self):
        """
        Setup our output modules to allow easy updating of py3modules and
        i3status modules allows the same module to be used multiple times.
        """
        config = self.i3status_thread.config
        i3modules = self.i3status_thread.i3modules
        output_modules = {}
        # position in the bar of the modules
        positions = {}
        for index, name in enumerate(config['order']):
            if name not in positions:
                positions[name] = []
            positions[name].append(index)

        # py3status modules
        for name in self.modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = self.modules[name]
        # i3status modules
        for name in i3modules:
            if name not in output_modules:
                output_modules[name] = {}
                output_modules[name]['position'] = positions.get(name, [])
                output_modules[name]['module'] = i3modules[name]

        self.output_modules = output_modules

    @profile
    def run(self):
        """
        Main py3status loop, continuously read from i3status and modules
        and output it to i3bar for displaying.
        """
        # SIGUSR1 forces a refresh of the bar both for py3status and i3status,
        # this mimics the USR1 signal handling of i3status (see man i3status)
        signal(SIGUSR1, self.sig_handler)
        signal(SIGTERM, self.terminate)

        # initialize usage variables
        i3status_thread = self.i3status_thread
        config = i3status_thread.config
        self.create_output_modules()

        # update queue populate with all py3modules
        self.queue.extend(self.modules)

        # this will be our output set to the correct length for the number of
        # items in the bar
        output = [None] * len(config['order'])

        interval = self.config['interval']
        last_sec = 0

        # main loop
        while True:
            sec = int(time())

            # only check everything is good each second
            if sec > last_sec:
                last_sec = sec

                # check i3status thread
                if not i3status_thread.is_alive():
                    err = i3status_thread.error
                    if not err:
                        err = 'i3status died horribly'
                    self.i3_nagbar(err)
                    break

                # check events thread
                if not self.events_thread.is_alive():
                    # don't spam the user with i3-nagbar warnings
                    if not hasattr(self.events_thread, 'i3_nagbar'):
                        self.events_thread.i3_nagbar = True
                        err = 'events thread died, click events are disabled'
                        self.i3_nagbar(err, level='warning')

                # update i3status time/tztime items
                if interval == 0 or sec % interval == 0:
                    i3status_thread.update_times()

            # check if an update is needed
            if self.queue:
                while (len(self.queue)):
                    module_name = self.queue.popleft()
                    module = self.output_modules[module_name]
                    for index in module['position']:
                        # store the output as json
                        # modules can have more than one output
                        out = module['module'].get_latest()
                        output[index] = ', '.join([dumps(x) for x in out])

                prefix = i3status_thread.last_prefix
                # build output string
                out = ','.join([x for x in output if x])
                # dump the line to stdout
                print_line('{}[{}]'.format(prefix, out))

            # sleep a bit before doing this again to avoid killing the CPU
            sleep(0.1)

    def handle_cli_command(self, config):
        """Handle a command from the CLI.
        """
        cmd = config['cli_command']
        # aliases
        if cmd[0] in ['mod', 'module', 'modules']:
            cmd[0] = 'modules'

        # allowed cli commands
        if cmd[:2] in (['modules', 'list'], ['modules', 'details']):
            docstrings.show_modules(config, cmd[1:])
        # docstring formatting and checking
        elif cmd[:2] in (['docstring', 'check'], ['docstring', 'update']):
            if cmd[1] == 'check':
                show_diff = len(cmd) > 2 and cmd[2] == 'diff'
                docstrings.check_docstrings(show_diff, config)
            if cmd[1] == 'update':
                if len(cmd) < 3:
                    print_stderr('Error: you must specify what to update')
                    sys.exit(1)

                if cmd[2] == 'modules':
                    docstrings.update_docstrings()
                else:
                    docstrings.update_readme_for_modules(cmd[2:])
        elif cmd[:2] in (['modules', 'enable'], ['modules', 'disable']):
            # TODO: to be implemented
            pass
        else:
            print_stderr('Error: unknown command')
            sys.exit(1)