diff --git a/README.md b/README.md index bc17fea..943d599 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,16 @@ $ pip install context-cli ``` $ ctx -h -usage: ctx [-h] [-d DELIMITER_TEXT] [-D DELIMITER_REGEX] - [-s DELIMITER_START_TEXT] [-S DELIMITER_START_REGEX] - [-e DELIMITER_END_TEXT] [-E DELIMITER_END_REGEX] [-x] [-X] - [-i] [-c CONTAINS_TEXT] [-C CONTAINS_REGEX] - [-m MATCHES_TEXT] [-M MATCHES_REGEX] - [-c! NOT_CONTAINS_TEXT] [-C! NOT_CONTAINS_REGEX] - [-m! NOT_MATCHES_TEXT] [-M! NOT_MATCHES_REGEX] - [-l LINE_CONTAINS_TEXT] [-L LINE_CONTAINS_REGEX] - [-l! NOT_LINE_CONTAINS_TEXT] [-L! NOT_LINE_CONTAINS_REGEX] - [-o OUTPUT_DELIMITER] +usage: ctx [-h] [-t TYPE] [-w] [-d DELIMITER_MATCHER] + [-D DELIMITER_MATCHER] [-s START_DELIMITER_MATCHER] + [-S START_DELIMITER_MATCHER] [-e END_DELIMITER_MATCHER] + [-E END_DELIMITER_MATCHER] [-x] [-X] [-i] + [-c CONTAINS_TEXT] [-C CONTAINS_REGEX] [-m MATCHES_TEXT] + [-M MATCHES_REGEX] [-c! NOT_CONTAINS_TEXT] + [-C! NOT_CONTAINS_REGEX] [-m! NOT_MATCHES_TEXT] + [-M! NOT_MATCHES_REGEX] [-l LINE_CONTAINS_TEXT] + [-L LINE_CONTAINS_REGEX] [-l! NOT_LINE_CONTAINS_TEXT] + [-L! NOT_LINE_CONTAINS_REGEX] [-o OUTPUT_DELIMITER] [files [files ...]] A cli tool to search with contexts. @@ -37,17 +37,19 @@ positional arguments: optional arguments: -h, --help show this help message and exit - -d DELIMITER_TEXT, --delimiter-text DELIMITER_TEXT + -t TYPE, --type TYPE type of search as specified in .ctxrc + -w, --write write the current context search to .ctxrc + -d DELIMITER_MATCHER, --delimiter-text DELIMITER_MATCHER delimiter text - -D DELIMITER_REGEX, --delimiter-regex DELIMITER_REGEX + -D DELIMITER_MATCHER, --delimiter-regex DELIMITER_MATCHER delimiter regex - -s DELIMITER_START_TEXT, --delimiter-start-text DELIMITER_START_TEXT + -s START_DELIMITER_MATCHER, --delimiter-start-text START_DELIMITER_MATCHER delimiter start text - -S DELIMITER_START_REGEX, --delimiter-start-regex DELIMITER_START_REGEX + -S START_DELIMITER_MATCHER, --delimiter-start-regex START_DELIMITER_MATCHER delimiter start regex - -e DELIMITER_END_TEXT, --delimiter-end-text DELIMITER_END_TEXT + -e END_DELIMITER_MATCHER, --delimiter-end-text END_DELIMITER_MATCHER delimiter end text - -E DELIMITER_END_REGEX, --delimiter-end-regex DELIMITER_END_REGEX + -E END_DELIMITER_MATCHER, --delimiter-end-regex END_DELIMITER_MATCHER delimiter end regex -x, --exclude-start-delimiter exclude start delimiter from the context @@ -116,4 +118,22 @@ $ ctx -xXi -S '^```$' -E '^```$' -c install -o "========" README.md ``` +### Save common arguments + +If you use `ctx` with the same arguments over and over again, you can save those arguments under a name. In +the following example, we add `my_saved_search` by using the `--type` (or `-t`) and `--write` (or `-w`). + +``` +$ ctx --type my_saved_search -write -xXi -s '-----' -e 'end' -o '==========' +``` + +And then use it (note there is no `--write` anymore) + +``` +$ ctx -t my_saved_search my_file.txt +``` + +The configuration is saved to your home folder in a `.ctxrc` file. + + TODO: Add more examples diff --git a/context_cli/__init__.py b/context_cli/__init__.py index b367977..86da034 100644 --- a/context_cli/__init__.py +++ b/context_cli/__init__.py @@ -2,6 +2,6 @@ A cli tool to search with contexts. """ -__version__ = '0.0.dev3' +__version__ = '0.0.dev4' __author__ = 'Nicolas Mesa' __licence__ = 'MIT' diff --git a/context_cli/__main__.py b/context_cli/__main__.py index 60d2ce8..b4a8168 100644 --- a/context_cli/__main__.py +++ b/context_cli/__main__.py @@ -4,7 +4,7 @@ def main(): import sys try: - from .core import main + from context_cli.core import main sys.exit(main(sys.argv)) except BrokenPipeError: # Prevent any errors from showing up if we get a SIGPIPE (for example ctx ... | head) diff --git a/context_cli/core.py b/context_cli/core.py index 6307aa9..f122028 100644 --- a/context_cli/core.py +++ b/context_cli/core.py @@ -2,6 +2,12 @@ import logging import sys +from pathlib import Path + + +logging.basicConfig(level=logging.ERROR) +logger = logging.getLogger(__name__) + from .context import StartAndEndDelimiterContextFactory, SingleDelimiterContextFactory from .filter import ( # ContextFilters @@ -13,9 +19,7 @@ ContainsTextLineFilter, ContainsRegexLineFilter, NotContainsTextLineFilter, NotContainsRegexLineFilter, ) from .matcher import ContainsTextMatcher, RegexMatcher - -logging.basicConfig(level=logging.ERROR) -logger = logging.getLogger(__name__) +from .util import CtxRc, TypeArgDoesNotExistException def start_and_end_delimiter_context_factory_creator(start_delimiter_matcher, end_delimiter_matcher, exclude_start, exclude_end, ignore_end_delimiter): @@ -135,12 +139,19 @@ def build_pipeline(context_factory, args): return curr + + def construct_arg_parser(): from . import __doc__ ap = argparse.ArgumentParser( description=__doc__ ) + + ap.add_argument('-t', '--type', help='type of search as specified in .ctxrc', type=str) + ap.add_argument('-w', '--write', + help='write the current context search to .ctxrc', action='store_const', const=True, default=False) + ap.add_argument('-d', '--delimiter-text', help="delimiter text", dest='delimiter_matcher', type=ContainsTextMatcher) ap.add_argument('-D', '--delimiter-regex', help="delimiter regex", dest='delimiter_matcher', type=RegexMatcher) @@ -203,13 +214,56 @@ def construct_arg_parser(): return ap +def parse_args(ap, argv): + """ + Parses the arguments. It checks whether the `--type` arg is set, and, if it is, either writes the arguments to the + .ctxrc file or gets the args from there. If `--write` is specified, th ctxrx is written to and then this function + exits the program. + """ + + args = ap.parse_args(argv[1:]) + + if not args.type: + return args + + path = Path.home() / '.ctxrc' + ctxrc = CtxRc.from_path(path) + + if args.write: + # Adding files to types doesn't make sense. Since it's a bit hard to remove the files from the argumnents, we + # add this restriction. + if args.files[0].name != '': + ap.error("Don't specify files when writing a type") + return # We never get here but unit tests keep going since ap.error is mocked + + ctxrc.add_type(args.type, argv[1:]) + ctxrc.save(path) + ap.exit(0) + return # We never get here but unit tests keep going since ap.exit is mocked + + try: + type_argv = ctxrc.get_type_argv(args.type) + except TypeArgDoesNotExistException as e: + ap.error(str(e)) + return None # We never get here + + new_argv = type_argv + argv[1:] + new_args = ap.parse_args(new_argv) + new_args.files = args.files + new_args.write = False + new_args.type = None + return new_args + + def main(argv): """ Main method. """ ap = construct_arg_parser() - args = ap.parse_args(argv[1:]) + + args = parse_args(ap, argv) + context_factory_factory = get_context_factory_from_args(ap, args) first = True diff --git a/context_cli/util.py b/context_cli/util.py index 360435b..fe86673 100644 --- a/context_cli/util.py +++ b/context_cli/util.py @@ -1,9 +1,92 @@ -import re import logging +import json +import re logger = logging.getLogger(__name__) +class CtxRc: + """ + Contains the dict of .ctxrc + + { + "version": 1, + "types": { + "md_code": { + "argv": [ + "-t", + "md_code", + "-w", + "-S", + "hello", + "-E", + "world" + ] + } + } + } + """ + + def __init__(self, ctxrc_dict): + if ctxrc_dict == None: + ctxrc_dict = self.get_default_dict() + self.ctxrc_dict = ctxrc_dict + + @property + def available_types(self): + if not 'types' in self.ctxrc_dict: + return set() + return set(self.ctxrc_dict['types'].keys()) + + def add_type(self, type, argv): + if not 'types' in self.ctxrc_dict: + self.ctxrc_dict['types'] = {} + self.ctxrc_dict['types'][type] = { + 'argv': argv + } + + def get_type_argv(self, type): + try: + return self.ctxrc_dict['types'][type]['argv'] + except KeyError: + raise TypeArgDoesNotExistException(missing_type=type) + + def save(self, path): + with open(path, 'w') as f: + f.write(json.dumps(self.ctxrc_dict)) + + @classmethod + def from_path(cls, path): + try: + with open(path, 'r') as f: + return cls(json.loads(f.read())) + except FileNotFoundError: + return cls(cls.get_default_dict()) + + @staticmethod + def get_default_dict(): + return { + 'version': 1, + 'types': {} + } + + +class FriendlyException(Exception): + """ + Contains a friendly error message + """ + pass + + +class TypeArgDoesNotExistException(FriendlyException): + """ + Exception raised when the type argument passed is not in the .ctxrc + """ + + def __init__(self, missing_type): + super().__init__(f"type {missing_type} doesn't exist in the .ctxrc") + + def build_regexp_if_needed(maybe_regexp): """ Creates a regexp if the `maybe_regexp` is a str. diff --git a/tests/test_core.py b/tests/test_core.py index f235d76..a341d6e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,10 +1,14 @@ from mock import patch, mock, ANY +from pathlib import Path from context_cli.core import ( start_and_end_delimiter_context_factory_creator, single_delimiter_context_factory_creator, - - get_context_factory_from_args, build_pipeline, construct_arg_parser + get_context_factory_from_args, build_pipeline, construct_arg_parser, + parse_args, ) +from context_cli.util import TypeArgDoesNotExistException + +HOME_PATH = '/my/home' @patch('context_cli.context.StartAndEndDelimiterContextFactory.__init__', return_value=None) @@ -306,3 +310,104 @@ def test_build_pipeline_not_contains_regex_line_filter(not_empty_filter_mock, no def test_construct_arg_parser(): ap = construct_arg_parser() assert ap is not None + + +def test_parse_args_no_type(): + args = mock.MagicMock() + ap = mock.MagicMock() + ap.parse_args.return_value = args + args.type = None + + result_args = parse_args(ap, ['ctx', 'argv']) + assert result_args is args + + +@patch('context_cli.core.Path') +@patch('context_cli.core.CtxRc') +def test_parse_args_write_and_files(ctxrc_cls, path_cls): + ctxrc_cls.from_path.return_value = mock.MagicMock() + path_cls.home.return_value = Path(HOME_PATH) + args = mock.MagicMock() + ap = mock.MagicMock() + ap.parse_args.return_value = args + args.type = 'some_type' + args.write = True + file = mock.MagicMock() + file.name = 'not ' + args.files = [ + file + ] + + parse_args(ap, ['ctx', 'argv']) + ap.error.assert_called_once() + + +@patch('context_cli.core.Path') +@patch('context_cli.core.CtxRc') +def test_parse_args_write_and_no_files(ctxrc_cls, path_cls): + ctxrc = mock.MagicMock() + ctxrc_cls.from_path.return_value = ctxrc + path_cls.home.return_value = Path(HOME_PATH) + args = mock.MagicMock() + ap = mock.MagicMock() + ap.parse_args.return_value = args + args.type = 'some_type' + args.write = True + file = mock.MagicMock() + file.name = '' + args.files = [ + file + ] + + parse_args(ap, ['ctx', 'argv']) + ctxrc.add_type.assert_called_once_with('some_type', ['argv']) + ctxrc.save.assert_called_once_with(Path(HOME_PATH) / '.ctxrc') + ap.exit.assert_called_once_with(0) + + +@patch('context_cli.core.Path') +@patch('context_cli.core.CtxRc') +def test_parse_args_arg_does_not_exist(ctxrc_cls, path_cls): + type_arg = 'some_type_doesnt_exist' + exception = TypeArgDoesNotExistException(missing_type=type_arg) + ctxrc = mock.MagicMock() + ctxrc.get_type_argv.side_effect = exception + ctxrc_cls.from_path.return_value = ctxrc + path_cls.home.return_value = Path(HOME_PATH) + args = mock.MagicMock() + args.write = False + ap = mock.MagicMock() + ap.parse_args.return_value = args + args.type = type_arg + + parse_args(ap, ['ctx', 'argv']) + + ap.error.assert_called_once_with(str(exception)) + + +@patch('context_cli.core.Path') +@patch('context_cli.core.CtxRc') +def test_parse_args_arg_exists(ctxrc_cls, path_cls): + type_arg = 'some_arg' + type_argv = ['some', 'argv'] + argv = ['ctx', 'something', 'else'] + ctxrc = mock.MagicMock() + ctxrc.get_type_argv.return_value = type_argv + ctxrc_cls.from_path.return_value = ctxrc + path_cls.home.return_value = Path(HOME_PATH) + args = mock.MagicMock() + args.write = False + args.files = [ + mock.MagicMock(), + mock.MagicMock() + ] + ap = mock.MagicMock() + ap.parse_args.return_value = args + args.type = type_arg + + new_args = parse_args(ap, argv) + ap.parse_args.assert_called_with(type_argv + argv[1:]) + ap.parse_args.assert_called_with(['some', 'argv', 'something', 'else']) + assert new_args.type is None + assert new_args.write is False + assert new_args.files is args.files diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..f0442b4 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,93 @@ +import re +import pytest +from mock import patch, mock + +from context_cli.util import CtxRc, build_regexp_if_needed, TypeArgDoesNotExistException + +REGEX = re.compile('aaa') +JSON_STR = """ + { + "version": 1, + "types": { + "type1": { + "argv": ["something"] + } + } + } +""" + + +def test_build_regexp_if_needed_when_needed(): + regex_str = '[0-9]+' + r = build_regexp_if_needed(regex_str) + assert type(r) is type(REGEX) + + +def test_build_regexp_if_needed_when_not_needed(): + r = build_regexp_if_needed(REGEX) + assert r is REGEX + + +@patch('context_cli.util.open') +def test_ctxrc_from_path(open_mock): + file = mock.MagicMock() + open_mock.return_value = file + file.__enter__ = mock.MagicMock() + read_fn = mock.MagicMock() + file.__enter__.return_value = read_fn + read_fn.read.return_value = JSON_STR + + ctxrc = CtxRc.from_path('/some/path') + open_mock.assert_called_once_with('/some/path', 'r') + assert ctxrc.get_type_argv('type1') == ['something'] + assert ctxrc.ctxrc_dict['version'] == 1 + assert ctxrc.available_types == {'type1'} + + +@patch('context_cli.util.open') +def test_ctxrc_from_path_no_file(open_mock): + open_mock.side_effect = FileNotFoundError() + + ctxrc = CtxRc.from_path('/some/path') + assert ctxrc.ctxrc_dict['version'] == 1 + assert ctxrc.ctxrc_dict['types'] == {} + assert ctxrc.available_types == set() + + +def test_ctxrc_type_doesnt_exist(): + ctxrc = CtxRc({}) + with pytest.raises(TypeArgDoesNotExistException): + ctxrc.get_type_argv('not_existent') + + +def test_ctxrc_add_type(): + ctxrc = CtxRc({}) + ctxrc.add_type('type', ['args']) + assert ctxrc.get_type_argv('type') == ['args'] + assert ctxrc.available_types == {'type'} + + +def test_ctxrc_available_types_no_dict(): + ctxrc = CtxRc(None) + assert ctxrc.available_types == set() + + +def test_ctxrc_available_types_no_types(): + ctxrc = CtxRc({}) + assert ctxrc.available_types == set() + + +@patch('context_cli.util.open') +def test_ctxrc_save(open_mock): + file = mock.MagicMock() + open_mock.return_value = file + file.__enter__ = mock.MagicMock() + write_fn = mock.MagicMock() + file.__enter__.return_value = write_fn + + ctxrc = CtxRc({}) + ctxrc.save('/some/path') + + open_mock.assert_called_once_with('/some/path', 'w') + write_fn.write.assert_called_once_with('{}') +