diff --git a/.github/delete-merged-branch-config.yml b/.github/delete-merged-branch-config.yml new file mode 100644 index 0000000..dca2d98 --- /dev/null +++ b/.github/delete-merged-branch-config.yml @@ -0,0 +1,3 @@ +exclude: + - develop + - master diff --git a/.travis.yml b/.travis.yml index 59c8ffb..fe45748 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ script: - pypi-version check - cat requirements.txt requirements-tests.txt requirements-dev.txt | safety check --stdin - radon cc cli -nb --total-average - - isort -rc cli --diff && isort -rc tests --diff + - ./scripts/isort-diff.sh - flake8 cli && flake8 tests - coverage run -m pytest -vv tests diff --git a/README.md b/README.md index 6640345..c277fcd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ * [Requirements](#getting-started-requirements) * [Installation](#installation) * [Usage](#usage) + * [Nodes](#nodes) * [Configuration file](#configuration-file) * [Service](#service) * [Account](#account) @@ -39,6 +40,13 @@ $ pip3 install remme-core-cli ## Usage +### Nodes + +You can use the following list of the addresses of the nodes to execute commands to: + +- `node-genesis-testnet.remme.io`, +- `node-6-testnet.remme.io`. + ### Configuration file Using the command line interface, you will have an option to declare the `node URL` to send commands to as illustrated below: @@ -92,10 +100,10 @@ Options: Get balance of the account by its address — ``remme account get-balance``: -| Arguments | Type | Required | Description | -| :-------: | :----: | :-------: | --------------------------------------------------- | -| address | String | Yes | Get balance of the account by its address. | -| node-url | String | No | Apply the command to the specified node by its URL. | +| Arguments | Type | Required | Description | +| :-------: | :----: | :-------: | ------------------------------------ | +| address | String | Yes | Account address to get a balance by. | +| node-url | String | No | Node URL to apply a command to. | ```bash $ remme account get-balance \ @@ -187,7 +195,11 @@ $ git clone https://github.com/Remmeauth/remme-core-cli && cd remme-core-cli $ pip3 install -r requirements.txt -r requirements-dev.txt -r requirements-tests.txt ``` -When you make changes, ensure your code pass [the checkers](https://github.com/Remmeauth/remme-core-cli/blob/develop/.travis.yml#L16) and is covered by tests using [pytest](https://docs.pytest.org/en/latest). +When you make changes, ensure your code: + +* pass [the checkers](https://github.com/Remmeauth/remme-core-cli/blob/develop/.travis.yml#L16), +* is covered by tests using [pytest](https://docs.pytest.org/en/latest), +* follow team [code style](https://github.com/dmytrostriletskyi/nimble-python-code-style-guide). If you are new for the contribution, please read: @@ -198,10 +210,6 @@ If you are new for the contribution, please read: ### Request pull request's review If you want to your pull request to be review, ensure you: -- `have wrote the description of the pull request`, -- `have added at least 2 reviewers`, -- `continuous integration has been passed`. - -![Example of the description and reviewers](https://habrastorage.org/webt/t1/py/cu/t1pycu1bxjslyojlpy50mxb5yie.png) - -![Example of the CI which passed](https://habrastorage.org/webt/oz/fl/-n/ozfl-nl-jynrh7ofz8yuz9_gapy.png) +1. [Branch isn't out-of-date with the base branch](https://habrastorage.org/webt/ux/gi/wm/uxgiwmnft08fubvjfd6d-8pw2wq.png). +2. [Have written the description of the pull request and have added at least 2 reviewers](https://camo.githubusercontent.com/55c309334a8b61a4848a6ef25f9b0fb3751ae5e9/68747470733a2f2f686162726173746f726167652e6f72672f776562742f74312f70792f63752f7431707963753162786a736c796f6a6c707935306d7862357969652e706e67). +3. [Continuous integration has been passed](https://habrastorage.org/webt/oz/fl/-n/ozfl-nl-jynrh7ofz8yuz9_gapy.png). diff --git a/cli/account/cli.py b/cli/account/cli.py index 106a784..75ebefe 100644 --- a/cli/account/cli.py +++ b/cli/account/cli.py @@ -1,20 +1,24 @@ """ Provide implementation of the command line interface's account commands. """ +import asyncio import sys -import re -import asyncio import click from remme import Remme +from cli.account.forms import GetAccountBalanceForm from cli.account.help import GET_ACCOUNT_BALANCE_ADDRESS_ARGUMENT_HELP_MESSAGE from cli.account.service import Account from cli.constants import ( - ADDRESS_REGEXP, - FAILED_EXIT_FROM_COMMAND, + FAILED_EXIT_FROM_COMMAND_CODE, NODE_URL_ARGUMENT_HELP_MESSAGE, ) +from cli.utils import ( + default_node_url, + print_errors, + print_result, +) loop = asyncio.get_event_loop() @@ -28,24 +32,29 @@ def account_commands(): @click.option('--address', type=str, required=True, help=GET_ACCOUNT_BALANCE_ADDRESS_ARGUMENT_HELP_MESSAGE) -@click.option('--node-url', type=str, help=NODE_URL_ARGUMENT_HELP_MESSAGE) +@click.option('--node-url', type=str, required=False, help=NODE_URL_ARGUMENT_HELP_MESSAGE, default=default_node_url()) @account_commands.command('get-balance') def get_balance(address, node_url): """ Get balance of the account by its address. """ - if re.match(pattern=ADDRESS_REGEXP, string=address) is None: - click.echo('The following address `{address}` is not valid.'.format(address=address)) - sys.exit(FAILED_EXIT_FROM_COMMAND) + arguments, errors = GetAccountBalanceForm().load({ + 'address': address, + 'node_url': node_url, + }) + + if errors: + print_errors(errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) - if node_url is None: - node_url = 'localhost' + address = arguments.get('address') + node_url = arguments.get('node_url') - remme = Remme(private_key_hex=None, network_config={ + remme = Remme(network_config={ 'node_address': str(node_url) + ':8080', }) - account = Account(service=remme) - balance = loop.run_until_complete(account.get_balance(address=address)) + account_service = Account(service=remme) + balance = loop.run_until_complete(account_service.get_balance(address=address)) - click.echo(balance) + print_result(balance) diff --git a/cli/account/forms.py b/cli/account/forms.py new file mode 100644 index 0000000..1184e16 --- /dev/null +++ b/cli/account/forms.py @@ -0,0 +1,49 @@ +""" +Provide forms for command line interface's account commands. +""" +import re + +from marshmallow import ( + Schema, + ValidationError, + fields, + validates, +) + +from cli.constants import ( + ADDRESS_REGEXP, + DOMAIN_NAME_REGEXP, +) + + +class GetAccountBalanceForm(Schema): + """ + Get balance of the account form. + """ + + address = fields.String(required=True) + node_url = fields.String(allow_none=True, required=False) + + @validates('address') + def validate_address(self, address): + """ + Validate account address. + """ + if re.match(pattern=ADDRESS_REGEXP, string=address) is None: + raise ValidationError(f'The following address `{address}` is invalid.') + + @validates('node_url') + def validate_node_url(self, node_url): + """ + Validate node URL. + + If node URL is localhost, it means client didn't passed any URL, so nothing to validate. + """ + if node_url == 'localhost': + return + + if 'http' in node_url or 'https' in node_url: + raise ValidationError(f'Pass the following node URL `{node_url}` without protocol (http, https, etc.).') + + if re.match(pattern=DOMAIN_NAME_REGEXP, string=node_url) is None: + raise ValidationError(f'The following node URL `{node_url}` is invalid.') diff --git a/cli/account/help.py b/cli/account/help.py index dedd14f..f77a131 100644 --- a/cli/account/help.py +++ b/cli/account/help.py @@ -1,4 +1,4 @@ """ Provide help messages for command line interface's account commands. """ -GET_ACCOUNT_BALANCE_ADDRESS_ARGUMENT_HELP_MESSAGE = 'Get balance of the account by its address.' +GET_ACCOUNT_BALANCE_ADDRESS_ARGUMENT_HELP_MESSAGE = 'Account address to get a balance by.' diff --git a/cli/constants.py b/cli/constants.py index 87013ee..69b1240 100644 --- a/cli/constants.py +++ b/cli/constants.py @@ -1,10 +1,14 @@ """ Provide constants for command line interface. """ -ADDRESS_REGEXP = '[0-9a-f]{70}' +ADDRESS_REGEXP = r'^[0-9a-f]{70}$' +DOMAIN_NAME_REGEXP = r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]' -FAILED_EXIT_FROM_COMMAND = -1 +PASSED_EXIT_FROM_COMMAND_CODE = 0 +FAILED_EXIT_FROM_COMMAND_CODE = -1 NODE_URL_ARGUMENT_HELP_MESSAGE = 'Apply the command to the specified node by its URL.' CLI_CONFIG_FILE_NAME = 'remme-core-cli' + +NODE_IP_ADDRESS_FOR_TESTING = '159.89.104.9' diff --git a/cli/utils.py b/cli/utils.py index 684b002..9f3a4d4 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -3,9 +3,71 @@ """ import json +import click + def dict_to_pretty_json(data): - """ - Convert dictionary to string with indents as human readable text. + r""" + Convert dictionary to json with indents (human readable string). + + From the following code: + { + "address": [ + "The following address `1120076ecf036e857f42129b5830` is invalid." + ] + } + + It creates: + "{\n \"address\": [\n \"The following address `1120076ecf036e857f42129b5830` is invalid.\" ]\n}\n" + + Notes: + - `r` symbol at the start of the documentation is presented because of PEP257. + + References: + - https://www.python.org/dev/peps/pep-0257/#id15 + - https://stackoverflow.com/a/33734332/9632462 """ return json.dumps(data, indent=4, sort_keys=True) + + +def print_result(result): + """ + Print successful result to the terminal. + """ + return click.echo(dict_to_pretty_json(result)) + + +def print_errors(errors): + """ + Print error messages to the terminal. + + Arguments: + errors (dict): dictionary with error messages. + + References: + - https://click.palletsprojects.com/en/7.x/utils/#ansi-colors + """ + click.secho(dict_to_pretty_json(errors), blink=True, bold=True, fg='red') + + +def default_node_url(): + """ + Get default node URL. + """ + return 'localhost' + + +async def return_async_value(value): + """ + Asynchronous function return value impostor. + + Using for mock particular asynchronous function with specified return value. + + Example of usage in code: + mock_account_get_balance = mock.patch('cli.account.service.Account.get_balance') + mock_account_get_balance.return_value = return_async_value(13500) + + References: + - https://github.com/pytest-dev/pytest-mock/issues/60 + """ + return value diff --git a/requirements.txt b/requirements.txt index 44223d4..7e36fe6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ accessify==0.3.1 asyncio==3.4.3 click==7.0 remme==1.0.0 +marshmallow==2.19.2 diff --git a/scripts/isort-diff.sh b/scripts/isort-diff.sh new file mode 100755 index 0000000..6544762 --- /dev/null +++ b/scripts/isort-diff.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Check if isort show difference. + +ISORT_OUTPUT=$(isort -rc . --diff) +IS_ISORT_DIFF=$(echo ${ISORT_OUTPUT} | grep '+') + +if [ -z "$IS_ISORT_DIFF" ] +then + exit 0 +else + echo "${ISORT_OUTPUT}" + exit 1 +fi diff --git a/setup.py b/setup.py index 6535405..3cdae08 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,10 @@ """ Setup the package. """ -from setuptools import find_packages, setup +from setuptools import ( + find_packages, + setup, +) with open('README.md', 'r') as read_me: long_description = read_me.read() diff --git a/tests/test_account.py b/tests/test_account.py new file mode 100644 index 0000000..37b0051 --- /dev/null +++ b/tests/test_account.py @@ -0,0 +1,167 @@ +""" +Provide tests for command line interface's account commands. +""" +import json + +from click.testing import CliRunner + +from cli.constants import ( + FAILED_EXIT_FROM_COMMAND_CODE, + NODE_IP_ADDRESS_FOR_TESTING, + PASSED_EXIT_FROM_COMMAND_CODE, +) +from cli.entrypoint import cli +from cli.utils import ( + dict_to_pretty_json, + return_async_value, +) + + +def test_get_balance(): + """ + Case: get a balance of an account by address. + Expect: balance is returned. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'get-balance', + '--address', + '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf', + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert isinstance(json.loads(result.output), int) + + +def test_get_balance_invalid_address(): + """ + Case: get a balance of an account by invalid address. + Expect: the following address is invalid error message. + """ + invalid_address = '1120076ecf036e857f42129b5830' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'get-balance', + '--address', + invalid_address, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + expected_error = { + 'address': [ + f'The following address `{invalid_address}` is invalid.', + ], + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output + + +def test_get_balance_without_node_url(mocker): + """ + Case: get a balance of an account by address without passing node URL. + Expect: balance is returned. + """ + balance_from_localhost = 13500 + + mock_account_get_balance = mocker.patch('cli.account.service.Account.get_balance') + mock_account_get_balance.return_value = return_async_value(balance_from_localhost) + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'get-balance', + '--address', + '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf', + ]) + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert isinstance(json.loads(result.output), int) + assert str(balance_from_localhost) in result.output + + +def test_get_balance_invalid_node_url(): + """ + Case: get a balance of an account by passing invalid node URL. + Expect: the following node URL is invalid error message. + """ + invalid_node_url = 'domainwithoutextention' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'get-balance', + '--address', + '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf', + '--node-url', + invalid_node_url, + ]) + + expected_error = { + 'node_url': [ + f'The following node URL `{invalid_node_url}` is invalid.', + ], + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output + + +def test_get_balance_node_url_with_http(): + """ + Case: get a balance of an account by passing node URL with explicit HTTP protocol. + Expect: the following node URL contains protocol error message. + """ + node_url_with_http_protocol = 'http://masternode.com' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'get-balance', + '--address', + '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf', + '--node-url', + node_url_with_http_protocol, + ]) + + expected_error = { + 'node_url': [ + f'Pass the following node URL `{node_url_with_http_protocol}` without protocol (http, https, etc.).', + ], + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output + + +def test_get_balance_node_url_with_https(): + """ + Case: get a balance of an account by passing node URL with explicit HTTPS protocol. + Expect: the following node URL contains protocol error message. + """ + node_url_with_https_protocol = 'https://masternode.com' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'get-balance', + '--address', + '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf', + '--node-url', + node_url_with_https_protocol, + ]) + + expected_error = { + 'node_url': [ + f'Pass the following node URL `{node_url_with_https_protocol}` without protocol (http, https, etc.).', + ], + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output