def select(self): """Toggles selection of the current label.""" LOGGER.debug('Select command triggered.') # get current label label, cur_y = self.get_current_label() # toggle selection if label not in self.selection: LOGGER.info("Adding '%s' to the selection.", label) self.selection.add(label) # Note, that we use an additional two spaces to attempt to uniquely identify the label # in the list mode. Otherwise it might be possible that the same text (as used for the # label) can occur elsewhere in the buffer. # We do not need this outside of the list view because then the line indexed by `cur_y` # will surely only include the one label which we actually want to operate on. offset = ' ' if self.list_mode == -1 else '' self.buffer.replace( cur_y, label + offset, CONFIG.get_ansi_color('selection') + label + '\x1b[0m' + offset) else: LOGGER.info("Removing '%s' from the selection.", label) self.selection.remove(label) self.buffer.replace( cur_y, CONFIG.get_ansi_color('selection') + label + '\x1b[0m', label) # update buffer view self.buffer.view(self.viewport, self.visible, self.width - 1, ansi_map=self.ANSI_MAP)
def test_add_overwrite_label(): """Test add command while specifying a label manually. Regression test against #4. """ # use temporary config tmp_config = "[DATABASE]\nfile=/tmp/cobib_test_database.yaml\n" with open('/tmp/cobib_test_config.ini', 'w') as file: file.write(tmp_config) CONFIG.set_config(Path('/tmp/cobib_test_config.ini')) # ensure database file exists and is empty open('/tmp/cobib_test_database.yaml', 'w').close() # freshly read in database to overwrite anything that was read in during setup() read_database(fresh=True) # add some data commands.AddCommand().execute(['-b', './test/example_literature.bib']) # add potentially duplicate entry commands.AddCommand().execute([ '-b', './test/example_duplicate_entry.bib', '--label', 'duplicate_resolver' ]) # compare with reference file with open('./test/example_literature.yaml', 'r') as expected: true_lines = expected.readlines() with open('./test/example_duplicate_entry.yaml', 'r') as extra: true_lines += extra.readlines() with open('/tmp/cobib_test_database.yaml', 'r') as file: for line, truth in zip_longest(file, true_lines): assert line == truth # clean up file system os.remove('/tmp/cobib_test_database.yaml') os.remove('/tmp/cobib_test_config.ini')
def test_tui_quit_prompt(setting, keys): """Test the prompt_before_quit setting of the TUI.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) # set prompt_before_quit setting CONFIG.config['TUI']['prompt_before_quit'] = setting read_database() test_tui(None, keys, assert_quit, {'prompt': setting})
def test_tui_scrolling(keys, assertion, assertion_kwargs): """Test TUI scrolling behavior.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) # overwrite database file CONFIG.config['DATABASE']['file'] = './test/scrolling_database.yaml' read_database() test_tui(None, keys, assertion, assertion_kwargs)
def setup(): """Setup.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) # NOTE: normally you would never trigger an Add command before reading the database but in this # controlled testing scenario we can be certain that this is fine AddCommand().execute(['-b', './test/dummy_scrolling_entry.bib']) read_database() yield setup DeleteCommand().execute(['dummy_entry_for_scroll_testing'])
def test_set_config(setup): """Test config setting. Args: setup: runs pytest fixture. """ # from setup assert CONFIG.config['DATABASE'][ 'file'] == './test/example_literature.yaml' # change back to default CONFIG.set_config() assert CONFIG.config['DATABASE']['file'] == \ os.path.expanduser('~/.local/share/cobib/literature.yaml')
def test_tui_open_menu(): """Test the open prompt menu for multiple associated files.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) # NOTE: normally you would never trigger an Add command before reading the database but in this # controlled testing scenario we can be certain that this is fine AddCommand().execute(['-b', './test/dummy_multi_file_entry.bib']) read_database() try: test_tui(None, 'o', assert_open, {}) finally: DeleteCommand().execute(['dummy_multi_file_entry'])
def test_tui_config_color(): """Test TUI color configuration.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) # overwrite color configuration CONFIG.config['COLORS']['top_statusbar_bg'] = 'red' CONFIG.config['COLORS']['top_statusbar_fg'] = 'blue' CONFIG.config['COLORS']['bottom_statusbar_bg'] = 'green' CONFIG.config['COLORS']['bottom_statusbar_fg'] = 'magenta' CONFIG.config['COLORS']['cursor_line_bg'] = 'white' CONFIG.config['COLORS']['cursor_line_fg'] = 'black' read_database() test_tui(None, '', assert_config_color, {'colors': CONFIG.config['COLORS']})
def list_tags(args=None): """List all tags. Args: args (dict, optional): dictionary of keyword arguments. Returns: A list of all available tags in the database. """ if not args: args = {} CONFIG.set_config(args.get('config', None)) read_database() tags = list(CONFIG.config['BIB_DATA'].keys()) return tags
def tui(tui): """See base class.""" LOGGER.debug('Show command triggered from TUI.') # get current label label, cur_y = tui.get_current_label() # populate buffer with entry data LOGGER.debug('Clearing current buffer contents.') tui.buffer.clear() ShowCommand().execute([label], out=tui.buffer) tui.buffer.split() if label in tui.selection: LOGGER.debug('Current entry is selected. Applying highlighting.') tui.buffer.replace(0, label, CONFIG.get_ansi_color('selection') + label + '\x1b[0m') LOGGER.debug('Populating buffer with ShowCommand result.') tui.buffer.view(tui.viewport, tui.visible, tui.width-1, ansi_map=tui.ANSI_MAP) # reset current cursor position tui.top_line = 0 tui.current_line = 0 # update top statusbar tui.topstatus = "CoBib v{} - {}".format(__version__, label) tui.statusbar(tui.topbar, tui.topstatus) # enter show menu tui.list_mode = cur_y tui.inactive_commands = ['Add', 'Filter', 'Search', 'Show', 'Sort']
def test_tui_config_keys(command, key): """Test TUI key binding configuration.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) # overwrite key binding configuration CONFIG.config['KEY_BINDINGS'][command] = key # NOTE: normally you would never trigger an Add command before reading the database but in this # controlled testing scenario we can be certain that this is fine AddCommand().execute(['-b', './test/dummy_scrolling_entry.bib']) read_database() try: test_tui(None, key, assert_show, {}) finally: DeleteCommand().execute(['dummy_entry_for_scroll_testing'])
def test_init(): """Test init command.""" # use temporary config tmp_config = "[DATABASE]\nfile=/tmp/cobib_test_database.yaml\n" with open('/tmp/cobib_test_config.ini', 'w') as file: file.write(tmp_config) CONFIG.set_config(Path('/tmp/cobib_test_config.ini')) # store current time now = float(datetime.now().timestamp()) commands.InitCommand().execute({}) # check creation time of temporary database file ctime = os.stat('/tmp/cobib_test_database.yaml').st_ctime # assert these times are close assert ctime - now < 0.1 or now - ctime < 0.1 # clean up file system os.remove('/tmp/cobib_test_database.yaml') os.remove('/tmp/cobib_test_config.ini')
def test_init_force(): """Test init can be forced when database file exists.""" # use temporary config tmp_config = "[DATABASE]\nfile=/tmp/cobib_test_database.yaml\n" with open('/tmp/cobib_test_config.ini', 'w') as file: file.write(tmp_config) CONFIG.set_config(Path('/tmp/cobib_test_config.ini')) # fill database file with open('/tmp/cobib_test_database.yaml', 'w') as file: file.write('test') # try running init commands.InitCommand().execute(['-f']) # check init was forced and database file was overwritten assert os.stat('/tmp/cobib_test_database.yaml').st_size == 0 # clean up file system os.remove('/tmp/cobib_test_database.yaml') os.remove('/tmp/cobib_test_config.ini')
def list_filters(args=None): """List all filters. Args: args (dict, optional): dictionary of keyword arguments. Returns: A list of all field names available for filtering. """ if not args: args = {} CONFIG.set_config(args.get('config', None)) read_database() filters = set() for entry in CONFIG.config['BIB_DATA'].values(): filters.update(entry.data.keys()) return filters
def test_init_safe(): """Test init aborts when database file exists.""" # use temporary config tmp_config = "[DATABASE]\nfile=/tmp/cobib_test_database.yaml\n" with open('/tmp/cobib_test_config.ini', 'w') as file: file.write(tmp_config) CONFIG.set_config(Path('/tmp/cobib_test_config.ini')) # fill database file with open('/tmp/cobib_test_database.yaml', 'w') as file: file.write('test') # try running init commands.InitCommand().execute({}) # check init aborted and database file still contains 'test' with open('/tmp/cobib_test_database.yaml', 'r') as file: assert file.read() == 'test' # clean up file system os.remove('/tmp/cobib_test_database.yaml') os.remove('/tmp/cobib_test_config.ini')
def test_add(): """Test add command.""" # use temporary config tmp_config = "[DATABASE]\nfile=/tmp/cobib_test_database.yaml\n" with open('/tmp/cobib_test_config.ini', 'w') as file: file.write(tmp_config) CONFIG.set_config(Path('/tmp/cobib_test_config.ini')) # ensure database file exists and is empty open('/tmp/cobib_test_database.yaml', 'w').close() # freshly read in database to overwrite anything that was read in during setup() read_database(fresh=True) # add some data commands.AddCommand().execute(['-b', './test/example_literature.bib']) # compare with reference file with open('/tmp/cobib_test_database.yaml', 'r') as file: with open('./test/example_literature.yaml', 'r') as expected: for line, truth in zip_longest(file, expected): assert line == truth # clean up file system os.remove('/tmp/cobib_test_database.yaml') os.remove('/tmp/cobib_test_config.ini')
def test_delete(labels): """Test delete command.""" # use temporary config tmp_config = "[DATABASE]\nfile=/tmp/cobib_test_database.yaml\n" with open('/tmp/cobib_test_config.ini', 'w') as file: file.write(tmp_config) CONFIG.set_config(Path('/tmp/cobib_test_config.ini')) # copy example database to configured location copyfile(Path('./test/example_literature.yaml'), Path('/tmp/cobib_test_database.yaml')) # delete some data # NOTE: for testing simplicity we delete the last entry commands.DeleteCommand().execute(labels) with open('/tmp/cobib_test_database.yaml', 'r') as file: with open('./test/example_literature.yaml', 'r') as expected: # NOTE: do NOT use zip_longest to omit last entry (thus, we deleted the last one) for line, truth in zip(file, expected): assert line == truth with pytest.raises(StopIteration): file.__next__() # clean up file system os.remove('/tmp/cobib_test_database.yaml') os.remove('/tmp/cobib_test_config.ini')
def tui(tui): """See base class.""" LOGGER.debug('Search command triggered from TUI.') tui.buffer.clear() # handle input via prompt command, results = tui.execute_command('search', out=tui.buffer) if tui.buffer.lines and results is not None: hits, labels = results tui.list_mode, _ = tui.viewport.getyx() tui.buffer.split() LOGGER.debug('Applying selection highlighting in search results.') for label in labels: if label not in tui.selection: continue # we match the label including its 'search_label' highlight to ensure that we really # only match this specific occurrence of whatever the label may be tui.buffer.replace(range(tui.buffer.height), CONFIG.get_ansi_color('search_label') + label + '\x1b[0m', CONFIG.get_ansi_color('search_label') + CONFIG.get_ansi_color('selection') + label + '\x1b[0m\x1b[0m') LOGGER.debug('Populating viewport with search results.') tui.buffer.view(tui.viewport, tui.visible, tui.width-1, ansi_map=tui.ANSI_MAP) # reset current cursor position LOGGER.debug('Resetting cursor position to top.') tui.top_line = 0 tui.current_line = 0 # update top statusbar tui.topstatus = "CoBib v{} - {} hit{}".format(__version__, hits, "s" if hits > 1 else "") tui.statusbar(tui.topbar, tui.topstatus) tui.inactive_commands = ['Add', 'Filter', 'Sort'] elif command[1:]: msg = f"No search hits for '{shlex.join(command[1:])}'!" LOGGER.info(msg) tui.prompt_print(msg) tui.update_list()
def colors(): """Initialize the color pairs for the curses TUI.""" # Start colors in curses curses.start_color() # parse user color configuration color_cfg = CONFIG.config['COLORS'] colors = {col: {} for col in TUI.COLOR_NAMES} for attr, col in color_cfg.items(): if attr in TUI.COLOR_VALUES.keys(): if not curses.can_change_color(): # cannot change curses default colors LOGGER.warning( 'Curses cannot change the default colors. Skipping color setup.' ) continue # update curses-internal color with HEX-color rgb_color = tuple( int(col.strip('#')[i:i + 2], 16) for i in (0, 2, 4)) # curses colors range from 0 to 1000 curses_color = tuple(col * 1000 // 255 for col in rgb_color) curses.init_color(TUI.COLOR_VALUES[attr], *curses_color) else: if attr[:-3] not in TUI.COLOR_NAMES: LOGGER.warning( 'Detected unknown TUI color name specification: %s', attr[:-3]) continue colors[attr[:-3]][attr[-2:]] = col # initialize color pairs for TUI elements for idx, attr in enumerate(TUI.COLOR_NAMES): foreground = colors[attr].get('fg', 'white') background = colors[attr].get('bg', 'black') LOGGER.debug('Initiliazing color pair %d for %s', idx + 1, attr) curses.init_pair(idx + 1, TUI.COLOR_VALUES[foreground], TUI.COLOR_VALUES[background]) LOGGER.debug('Adding ANSI color code for %s', attr) TUI.ANSI_MAP[CONFIG.get_ansi_color( attr)] = TUI.COLOR_NAMES.index(attr) + 1
def update_list(self): """Updates the default list view.""" LOGGER.debug('Re-populating the viewport with the list command.') self.buffer.clear() labels = commands.ListCommand().execute(self.list_args, out=self.buffer) labels = labels or [] # convert to empty list if labels is None # populate buffer with the list if self.list_mode >= 0: self.current_line = self.list_mode self.list_mode = -1 # reset viewport self.top_line = 0 self.left_edge = 0 self.inactive_commands = [] # highlight current selection for label in self.selection: # Note: the two spaces are explained in the `select()` method. # Also: this step may become a performance bottleneck because we replace inside the # whole buffer for each selected label! self.buffer.replace( range(self.buffer.height), label + ' ', CONFIG.get_ansi_color('selection') + label + '\x1b[0m ') # display buffer in viewport self.buffer.view(self.viewport, self.visible, self.width - 1, ansi_map=self.ANSI_MAP) # update top statusbar self.topstatus = "CoBib v{} - {} Entries".format( __version__, len(labels)) self.statusbar(self.topbar, self.topstatus) # if cursor position is out-of-view (due to e.g. top-line reset in Show command), reset the # top-line such that the current line becomes visible again if self.current_line > self.top_line + self.visible: self.top_line = min(self.current_line, self.buffer.height - self.visible)
def execute(self, args, out=sys.stdout): """Search database. Searches the database recursively (i.e. including any associated files) using `grep` for a query string. Args: See base class. """ LOGGER.debug('Starting Search command.') parser = ArgumentParser(prog="search", description="Search subcommand parser.") parser.add_argument("query", type=str, help="text to search for") parser.add_argument("-c", "--context", type=int, default=1, help="number of context lines to provide for each match") parser.add_argument("-i", "--ignore-case", action="store_true", help="ignore case for searching") parser.add_argument('list_arg', nargs='*', help="Any arguments for the List subcommand." + "\nUse this to add filters to specify a subset of searched entries." + "\nYou can add a '--' before the List arguments to ensure separation." + "\nSee also `list --help` for more information on the List arguments.") if not args: parser.print_usage(sys.stderr) sys.exit(1) try: largs = parser.parse_intermixed_args(args) except argparse.ArgumentError as exc: print("{}: {}".format(exc.argument_name, exc.message), file=sys.stderr) return None labels = ListCommand().execute(largs.list_arg, out=open(os.devnull, 'w')) LOGGER.debug('Available entries to search: %s', labels) ignore_case = CONFIG.config['DATABASE'].getboolean('search_ignore_case', False) or \ largs.ignore_case re_flags = re.IGNORECASE if ignore_case else 0 LOGGER.debug('The search will be performed case %ssensitive', 'in' if ignore_case else '') hits = 0 output = [] for label in labels.copy(): entry = CONFIG.config['BIB_DATA'][label] matches = entry.search(largs.query, largs.context, ignore_case) if not matches: labels.remove(label) continue hits += len(matches) LOGGER.debug('Entry "%s" includes %d hits.', label, hits) title = f"{label} - {len(matches)} match" + ("es" if len(matches) > 1 else "") title = title.replace(label, CONFIG.get_ansi_color('search_label') + label + '\x1b[0m') output.append(title) for idx, match in enumerate(matches): for line in match: line = re.sub(rf'({largs.query})', CONFIG.get_ansi_color('search_query') + r'\1' + '\x1b[0m', line, flags=re_flags) output.append(f"[{idx+1}]\t".expandtabs(8) + line) print('\n'.join(output), file=out) return (hits, labels)
def main(): """Main executable. CoBib's main function used to parse optional keyword arguments and subcommands. """ if len(sys.argv) > 1 and any([a[0] == '_' for a in sys.argv]): # zsh helper function called zsh_main() sys.exit() # initialize logging log_to_stream() subcommands = [cmd.split(':')[0] for cmd in zsh_helper.list_commands()] parser = argparse.ArgumentParser(prog='CoBib', description=""" Cobib input arguments. If no arguments are given, the TUI will start as a default. """) parser.add_argument("--version", action="version", version="%(prog)s v{}".format(__version__)) parser.add_argument('--verbose', '-v', action='count', default=0) parser.add_argument("-l", "--logfile", type=argparse.FileType('w'), help="Alternative log file") parser.add_argument("-c", "--config", type=argparse.FileType('r'), help="Alternative config file") parser.add_argument('command', help="subcommand to be called", choices=subcommands, nargs='?') parser.add_argument('args', nargs=argparse.REMAINDER) args = parser.parse_args() if args.logfile: LOGGER.info('Switching to FileHandler logger in %s', args.logfile.name) log_to_file('DEBUG' if args.verbose > 1 else 'INFO', logfile=args.logfile.name) # set logging verbosity level if args.verbose == 1: logging.getLogger().setLevel(logging.INFO) LOGGER.info('Logging level set to INFO.') elif args.verbose > 1: logging.getLogger().setLevel(logging.DEBUG) LOGGER.info('Logging level set to DEBUG.') CONFIG.set_config(args.config) try: CONFIG.validate() except RuntimeError as exc: LOGGER.error(exc) sys.exit(1) if args.command == 'init': # the database file may not exist yet, thus we ensure to execute the command before trying # to read the database file subcmd = getattr(commands, 'InitCommand')() subcmd.execute(args.args) return read_database() if not args.command: if args.logfile is None: LOGGER.info('Switching to FileHandler logger in %s', '/tmp/cobib.log') log_to_file('DEBUG' if args.verbose > 1 else 'INFO') else: LOGGER.info( 'Already logging to %s. NOT switching to "/tmp/cobib.log"', args.logfile) tui() else: subcmd = getattr(commands, args.command.title() + 'Command')() subcmd.execute(args.args)
def test_valid_tui_colors(setup, color): """Test curses color specification validation.""" with pytest.raises(RuntimeError) as exc_info: CONFIG.config.get('COLORS', {})[color] = 'test' CONFIG.validate() assert str(exc_info.value) == 'Unknown color specification: test'
def test_database_section(setup, section, field): """Test raised RuntimeError for missing config fields.""" with pytest.raises(RuntimeError) as exc_info: del CONFIG.config.get(section, {})[field] CONFIG.validate() assert f'{section}/{field}' in str(exc_info.value)
def test_missing_section(setup, section): """Test raised RuntimeError for missing configuration section.""" with pytest.raises(RuntimeError) as exc_info: del CONFIG.config[section] CONFIG.validate() assert section in str(exc_info.value)
def test_base_config(setup): """Test the initial configuration passes all validation checks.""" CONFIG.validate()
def setup(): """Setup.""" # ensure configuration is empty CONFIG.config = {} root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini'))
def setup(): """Setup.""" root = os.path.abspath(os.path.dirname(__file__)) CONFIG.set_config(Path(root + '/../cobib/docs/debug.ini')) read_database()