Skip to content

Commit

Permalink
Merge pull request #27 from LachlanMarnham/basic-cli
Browse files Browse the repository at this point in the history
Basic cli
  • Loading branch information
Lachlan Marnham authored Jan 23, 2022
2 parents f16d0db + a56852f commit 730a495
Show file tree
Hide file tree
Showing 14 changed files with 402 additions and 36 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,15 @@
[![image](https://img.shields.io/pypi/pyversions/begin-cli.svg)](https://pypi.org/project/begin-cli/)
![tests](https://github.com/LachlanMarnham/begin/actions/workflows/tests.yml/badge.svg?branch=master)
![flake8](https://github.com/LachlanMarnham/begin/actions/workflows/flake8.yml/badge.svg?branch=master)
[![codecov](https://codecov.io/gh/LachlanMarnham/begin/branch/master/graph/badge.svg)](https://codecov.io/gh/LachlanMarnham/begin)
[![codecov](https://codecov.io/gh/LachlanMarnham/begin/branch/master/graph/badge.svg)](https://codecov.io/gh/LachlanMarnham/begin)


## Usage
```bash
begin <target_name>@<registry_name> [<key>:<value>]
```
1. Arguments to be passed to targets should take the form `<arg_name>:<arg_value>`
2. Registry names must not contain a colon
3. Target names must not contain a colon or an `@`
4. If a target name, registry name or argument value contains whitespace, it must be
wrapped in single quotes.
3 changes: 3 additions & 0 deletions begin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
from begin.registry import Registry # noqa: F401


__version__ = '0.3.0'
VERSION = __version__
15 changes: 11 additions & 4 deletions begin/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
NoReturn,
)

from begin.cli.parser import (
ParsedCommand,
parse_command,
)
from begin.exceptions import BeginError
from begin.registry import (
Registry,
Expand Down Expand Up @@ -59,12 +63,15 @@ def load_registries() -> List[Registry]:


def _main():
requested_target = sys.argv[1]
requested_namespace = sys.argv[2]
parsed_command: ParsedCommand = parse_command()
registries = load_registries()
manager = RegistryManager.create(registries)
target = manager.get_target(requested_target, requested_namespace)
target.execute()
for request in parsed_command.requests:
target = manager.get_target(
request.target_name,
request.registry_namespace,
)
target.execute(**request.options)


def main() -> NoReturn:
Expand Down
98 changes: 98 additions & 0 deletions begin/cli/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from argparse import ArgumentParser
from dataclasses import dataclass
from typing import (
Dict,
List,
)

from begin.constants import DEFAULT_REGISTRY_NAME


class Request:
def __init__(self, target_identifier: str) -> None:
target_name, _, registry_namespace = target_identifier.partition('@')
self._target_name = target_name
self._registry_namespace = registry_namespace or DEFAULT_REGISTRY_NAME
self._options: Dict[str, str] = {}

def add_option(self, param_identifier: str) -> None:
key, _, value = param_identifier.partition(':')
self._options[key] = value

@property
def target_name(self) -> str:
return self._target_name

@property
def registry_namespace(self) -> str:
return self._registry_namespace

@property
def options(self) -> Dict[str, str]:
return self._options


@dataclass
class OptionalArg:
short: str
long: str
default: str
help: str


OPTIONAL_ARGS = [
OptionalArg(
short='-e',
long='--extension',
default='*targets.py', # TODO get this from settings
help='The suffix to match target file patterns against.',
),
OptionalArg(
short='-g',
long='--global-dir',
default='~/.begin', # TODO get this from settings
help='The location of the directory holding global targets files.',
),
]


@dataclass
class ParsedCommand:
extension: str
global_dir: str
requests: List[Request]


def _parse_requests(args: List[str]) -> List[Request]:
requests = []
request = None
for arg in args:
if ':' not in arg:
# Not a key:value argument pair, must be either target or target@namespace
if request is not None:
requests.append(request)
request = Request(arg)
else:
request.add_option(arg)
requests.append(request)
return requests


def parse_command():
parser = ArgumentParser(description='A utility for running targets in a targets.py file.')

for optional_arg in OPTIONAL_ARGS:
parser.add_argument(
optional_arg.short,
optional_arg.long,
default=optional_arg.default,
help=optional_arg.help,
)

optional_args, request_args = parser.parse_known_args()
requests = _parse_requests(request_args)
return ParsedCommand(
extension=optional_args.extension,
global_dir=optional_args.global_dir,
requests=requests,
)
3 changes: 3 additions & 0 deletions begin/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ class ExitCodeEnum(Enum):
SUCCESS = 0
UNSPECIFIED_FAILURE = 1
REGISTRY_NAME_COLLISION = 3


DEFAULT_REGISTRY_NAME = 'default'
7 changes: 4 additions & 3 deletions begin/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Set,
)

from begin.constants import DEFAULT_REGISTRY_NAME
from begin.exceptions import RegistryNameCollisionError


Expand All @@ -29,8 +30,8 @@ def registry_namespace(self) -> str:
def function_name(self) -> str:
return self._function.__name__

def execute(self) -> None:
self._function()
def execute(self, **options) -> None:
self._function(**options)

def __repr__(self) -> str:
class_name = f'{self.__class__.__module__}.{self.__class__.__name__}'
Expand All @@ -42,7 +43,7 @@ def __hash__(self) -> int:

class Registry:

def __init__(self, name: str = 'default') -> None:
def __init__(self, name: str = DEFAULT_REGISTRY_NAME) -> None:
self.name: str = name
self.targets: Set[Target] = set()
self.path: Path = self._get_calling_context_path()
Expand Down
1 change: 1 addition & 0 deletions release_notes/15.feat
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enable invocation of several chained recipes
1 change: 1 addition & 0 deletions release_notes/6.feat
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enable targets with parameters
1 change: 1 addition & 0 deletions release_notes/7.feat
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add basic CLI
6 changes: 3 additions & 3 deletions targets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from begin.registry import Registry
from begin import Registry


registry = Registry()
Expand All @@ -12,8 +12,8 @@ def install():


@registry.register_target(name='tests')
def tests():
print('default tests')
def tests(str_1, str_2):
print(f'{str_1}, {str_2}!')


@registry.register_target
Expand Down
60 changes: 47 additions & 13 deletions tests/begin/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
import pytest

from begin.cli import cli
from begin.cli.parser import ParsedCommand
from begin.constants import ExitCodeEnum
from begin.exceptions import BeginError


class TestCli:
class TestMainPublic:
""" These are tests for begin.cli.cli.main. The tests for begin.cli.cli._main are in
TestMainPrivate. """

@mock.patch('begin.cli.cli._main')
def test_main_failure(self, mock_private_main, make_random_string, caplog):
Expand Down Expand Up @@ -56,19 +59,50 @@ def test_main_success(self, mock_private_main, caplog):
# The process should exit with code 0
assert e_info.value.code is ExitCodeEnum.SUCCESS.value

@mock.patch('begin.cli.cli.Path.home')
@mock.patch('begin.cli.cli.Path.cwd')
def test_collect_target_file_paths(self, mock_cwd, mock_home, target_file_tmp_tree):
mock_cwd.return_value = target_file_tmp_tree.cwd_dir
mock_home.return_value = target_file_tmp_tree.home_dir
target_paths_gen = cli.collect_target_file_paths()

# target_paths_gen should be a generator
assert inspect.isgenerator(target_paths_gen)

# collect_target_file_paths should collect the correct paths
target_paths = set(target_paths_gen)
assert target_paths == set(target_file_tmp_tree.expected_target_files)
class TestMainPrivate:

def test_main(self, resource_factory):
registries = resource_factory.registry.create_multi()
requests = resource_factory.request.create_multi()
parsed_command = ParsedCommand(
extension='*recipes.py',
global_dir='~/.recipes',
requests=requests,
)
with mock.patch('begin.cli.cli.load_registries', return_value=registries) as mock_load_registries:
with mock.patch('begin.cli.cli.parse_command', return_value=parsed_command) as mock_parse_command:
with mock.patch('begin.cli.cli.RegistryManager') as MockRegistryManager:
cli._main()

mock_manager = MockRegistryManager.create.return_value
assert mock_parse_command.call_args_list == [mock.call()]
assert mock_load_registries.call_args_list == [mock.call()]
assert MockRegistryManager.create.call_args_list == [mock.call(registries)]
assert mock_manager.get_target.call_count == len(requests)

for i in range(len(requests)):
request = requests[i]
get_target_call = mock_manager.get_target.call_args_list[i]
assert get_target_call == mock.call(
request.target_name,
request.registry_namespace,
)


@mock.patch('begin.cli.cli.Path.home')
@mock.patch('begin.cli.cli.Path.cwd')
def test_collect_target_file_paths(mock_cwd, mock_home, target_file_tmp_tree):
mock_cwd.return_value = target_file_tmp_tree.cwd_dir
mock_home.return_value = target_file_tmp_tree.home_dir
target_paths_gen = cli.collect_target_file_paths()

# target_paths_gen should be a generator
assert inspect.isgenerator(target_paths_gen)

# collect_target_file_paths should collect the correct paths
target_paths = set(target_paths_gen)
assert target_paths == set(target_file_tmp_tree.expected_target_files)


def test_load_module_from_path(target_file_tmp_tree):
Expand Down
Loading

0 comments on commit 730a495

Please sign in to comment.