From 5be9279a934a2cd83dc03ce16c609543ecaa9d29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Sun, 21 Apr 2019 14:15:13 +0300 Subject: [PATCH 1/8] Bump pytest-mock from 1.10.3 to 1.10.4 (#19) --- requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-tests.txt b/requirements-tests.txt index 80d81e1..c796275 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,3 +1,3 @@ coverage==4.5.3 pytest==4.4.1 -pytest-mock==1.10.3 +pytest-mock==1.10.4 From a8cef2ae1bd724aa2b3aebc8d4c79c759e9a6098 Mon Sep 17 00:00:00 2001 From: Dmytro Striletskyi Date: Mon, 22 Apr 2019 14:20:10 +0300 Subject: [PATCH 2/8] Solve weekly technical debt (#13) --- .github/delete-merged-branch-config.yml | 3 + .travis.yml | 2 +- README.md | 32 +++-- cli/account/cli.py | 37 ++++-- cli/account/forms.py | 49 +++++++ cli/account/help.py | 2 +- cli/constants.py | 8 +- cli/utils.py | 66 +++++++++- requirements.txt | 1 + scripts/isort-diff.sh | 13 ++ setup.py | 5 +- tests/test_account.py | 167 ++++++++++++++++++++++++ 12 files changed, 352 insertions(+), 33 deletions(-) create mode 100644 .github/delete-merged-branch-config.yml create mode 100644 cli/account/forms.py create mode 100755 scripts/isort-diff.sh create mode 100644 tests/test_account.py 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://github.com/camo/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 From fcf353acfee4e940bfcd3885a923a326c81dff67 Mon Sep 17 00:00:00 2001 From: Dmytro Striletskyi Date: Tue, 23 Apr 2019 17:32:41 +0300 Subject: [PATCH 3/8] Refactor account CLI commands and implementation (#26) --- README.md | 8 ++- cli/account/cli.py | 18 +++--- cli/account/forms.py | 43 ++----------- cli/account/help.py | 2 +- cli/account/interfaces.py | 2 +- cli/account/service.py | 11 +++- cli/constants.py | 1 + cli/generic/__init__.py | 0 cli/generic/forms/__init__.py | 0 cli/generic/forms/fields.py | 62 +++++++++++++++++++ cli/utils.py | 4 +- setup.cfg | 4 +- .../test_get_balance.py} | 58 ++++++++++------- 13 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 cli/generic/__init__.py create mode 100644 cli/generic/forms/__init__.py create mode 100644 cli/generic/forms/fields.py rename tests/{test_account.py => account/test_get_balance.py} (77%) diff --git a/README.md b/README.md index c277fcd..1092b8d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ### Installation -Install the package from the [PypI](https://pypi.org/project/remme-core-cli) through [pip](https://github.com/pypa/pip): +Install the package from the [PyPi](https://pypi.org/project/remme-core-cli) through [pip](https://github.com/pypa/pip): ```bash $ pip3 install remme-core-cli @@ -109,7 +109,11 @@ Get balance of the account by its address — ``remme account get-balance``: $ remme account get-balance \ --address=1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf \ --node-url=node-genesis-testnet.remme.io -368440.0 +{ + "result": { + "balance": 368440.0 + } +} ``` ## Development diff --git a/cli/account/cli.py b/cli/account/cli.py index 75ebefe..bae0761 100644 --- a/cli/account/cli.py +++ b/cli/account/cli.py @@ -1,14 +1,13 @@ """ Provide implementation of the command line interface's account commands. """ -import asyncio import sys 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.help import ADDRESS_ARGUMENT_HELP_MESSAGE from cli.account.service import Account from cli.constants import ( FAILED_EXIT_FROM_COMMAND_CODE, @@ -20,8 +19,6 @@ print_result, ) -loop = asyncio.get_event_loop() - @click.group('account', chain=True) def account_commands(): @@ -31,7 +28,7 @@ def account_commands(): pass -@click.option('--address', type=str, required=True, help=GET_ACCOUNT_BALANCE_ADDRESS_ARGUMENT_HELP_MESSAGE) +@click.option('--address', type=str, required=True, help=ADDRESS_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): @@ -44,7 +41,7 @@ def get_balance(address, node_url): }) if errors: - print_errors(errors) + print_errors(errors=errors) sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) address = arguments.get('address') @@ -54,7 +51,10 @@ def get_balance(address, node_url): 'node_address': str(node_url) + ':8080', }) - account_service = Account(service=remme) - balance = loop.run_until_complete(account_service.get_balance(address=address)) + result, errors = Account(service=remme).get_balance(address=address) + + if errors is not None: + print_errors(errors=errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) - print_result(balance) + print_result(result=result) diff --git a/cli/account/forms.py b/cli/account/forms.py index 1184e16..85be7a7 100644 --- a/cli/account/forms.py +++ b/cli/account/forms.py @@ -1,18 +1,11 @@ """ Provide forms for command line interface's account commands. """ -import re +from marshmallow import Schema -from marshmallow import ( - Schema, - ValidationError, - fields, - validates, -) - -from cli.constants import ( - ADDRESS_REGEXP, - DOMAIN_NAME_REGEXP, +from cli.generic.forms.fields import ( + AccountAddressField, + NodeURLField, ) @@ -21,29 +14,5 @@ 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.') + address = AccountAddressField(required=True) + node_url = NodeURLField(allow_none=True, required=False) diff --git a/cli/account/help.py b/cli/account/help.py index f77a131..36fce4f 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 = 'Account address to get a balance by.' +ADDRESS_ARGUMENT_HELP_MESSAGE = 'Account address to get a balance by.' diff --git a/cli/account/interfaces.py b/cli/account/interfaces.py index 5e7c089..12c2ea9 100644 --- a/cli/account/interfaces.py +++ b/cli/account/interfaces.py @@ -8,7 +8,7 @@ class AccountInterface: Implements account interface. """ - async def get_balance(self, address): + def get_balance(self, address): """ Get balance of the account by its address. """ diff --git a/cli/account/service.py b/cli/account/service.py index c1d076a..0d02f8d 100644 --- a/cli/account/service.py +++ b/cli/account/service.py @@ -1,10 +1,14 @@ """ Provide implementation of the account. """ +import asyncio + from accessify import implements from cli.account.interfaces import AccountInterface +loop = asyncio.get_event_loop() + @implements(AccountInterface) class Account: @@ -21,8 +25,11 @@ def __init__(self, service): """ self.service = service - async def get_balance(self, address): + def get_balance(self, address): """ Get balance of the account by its address. """ - return await self.service.token.get_balance(address=address) + balance = loop.run_until_complete(self.service.token.get_balance(address=address)) + return { + 'balance': balance, + }, None diff --git a/cli/constants.py b/cli/constants.py index 69b1240..f49d571 100644 --- a/cli/constants.py +++ b/cli/constants.py @@ -2,6 +2,7 @@ Provide constants for command line interface. """ ADDRESS_REGEXP = r'^[0-9a-f]{70}$' +HEADER_SIGNATURE_REGEXP = r'^[0-9a-f]{128}$' 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]' PASSED_EXIT_FROM_COMMAND_CODE = 0 diff --git a/cli/generic/__init__.py b/cli/generic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/generic/forms/__init__.py b/cli/generic/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/generic/forms/fields.py b/cli/generic/forms/fields.py new file mode 100644 index 0000000..8606699 --- /dev/null +++ b/cli/generic/forms/fields.py @@ -0,0 +1,62 @@ +""" +Provide implementation of the custom fields. +""" +import re + +from marshmallow import ( + ValidationError, + fields, +) + +from cli.constants import ( + ADDRESS_REGEXP, + DOMAIN_NAME_REGEXP, +) + + +class AccountAddressField(fields.Field): + """ + Implements validation of the account address. + + References: + - https://marshmallow.readthedocs.io/en/3.0/custom_fields.html + """ + + def _deserialize(self, value, attr, obj, **kwargs): + """ + Validate data (account address) that was passed to field. + """ + address = value + + if re.match(pattern=ADDRESS_REGEXP, string=address) is None: + raise ValidationError(f'The following address `{address}` is invalid.') + + return address + + +class NodeURLField(fields.Field): + """ + Implements validation of the node URL. + + If node URL is localhost, it means client didn't passed any URL, so nothing to validate. + + References: + - https://marshmallow.readthedocs.io/en/3.0/custom_fields.html + """ + + def _deserialize(self, value, attr, obj, **kwargs): + """ + Validate data (node URL) that was passed to field. + """ + node_url = value + + if node_url == 'localhost': + return node_url + + 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.') + + return node_url diff --git a/cli/utils.py b/cli/utils.py index 9f3a4d4..9914f48 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -34,7 +34,7 @@ def print_result(result): """ Print successful result to the terminal. """ - return click.echo(dict_to_pretty_json(result)) + return click.echo(dict_to_pretty_json({'result': result})) def print_errors(errors): @@ -47,7 +47,7 @@ def print_errors(errors): References: - https://click.palletsprojects.com/en/7.x/utils/#ansi-colors """ - click.secho(dict_to_pretty_json(errors), blink=True, bold=True, fg='red') + click.secho(dict_to_pretty_json({'errors': errors}), blink=True, bold=True, fg='red') def default_node_url(): diff --git a/setup.cfg b/setup.cfg index 26a3ba0..1318256 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,8 +9,8 @@ combine_as_imports=True max-line-length=120 ignore=D200, D413, D107, D100 per-file-ignores= - */__init__.py: D104, F401, D100, - tests/test_*: D205 + */__init__.py: D104, F401, D100 + */test_*: D205 [coverage:run] omit = diff --git a/tests/test_account.py b/tests/account/test_get_balance.py similarity index 77% rename from tests/test_account.py rename to tests/account/test_get_balance.py index 37b0051..0ab2860 100644 --- a/tests/test_account.py +++ b/tests/account/test_get_balance.py @@ -11,10 +11,7 @@ PASSED_EXIT_FROM_COMMAND_CODE, ) from cli.entrypoint import cli -from cli.utils import ( - dict_to_pretty_json, - return_async_value, -) +from cli.utils import dict_to_pretty_json def test_get_balance(): @@ -32,8 +29,10 @@ def test_get_balance(): NODE_IP_ADDRESS_FOR_TESTING, ]) + balance = json.loads(result.output).get('result').get('balance') + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code - assert isinstance(json.loads(result.output), int) + assert isinstance(balance, int) def test_get_balance_invalid_address(): @@ -54,9 +53,11 @@ def test_get_balance_invalid_address(): ]) expected_error = { - 'address': [ - f'The following address `{invalid_address}` is invalid.', - ], + 'errors': { + 'address': [ + f'The following address `{invalid_address}` is invalid.', + ], + }, } assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code @@ -66,12 +67,12 @@ def test_get_balance_invalid_address(): 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. + Expect: balance is returned from node on localhost. """ - balance_from_localhost = 13500 + balance = 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) + mock_account_get_balance = mocker.patch('cli.account.service.loop.run_until_complete') + mock_account_get_balance.return_value = balance runner = CliRunner() result = runner.invoke(cli, [ @@ -81,9 +82,14 @@ def test_get_balance_without_node_url(mocker): '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf', ]) + expected_result = { + 'result': { + 'balance': 13500, + }, + } + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code - assert isinstance(json.loads(result.output), int) - assert str(balance_from_localhost) in result.output + assert expected_result == json.loads(result.output) def test_get_balance_invalid_node_url(): @@ -104,9 +110,11 @@ def test_get_balance_invalid_node_url(): ]) expected_error = { - 'node_url': [ - f'The following node URL `{invalid_node_url}` is invalid.', - ], + 'errors': { + 'node_url': [ + f'The following node URL `{invalid_node_url}` is invalid.', + ], + }, } assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code @@ -131,9 +139,11 @@ def test_get_balance_node_url_with_http(): ]) expected_error = { - 'node_url': [ - f'Pass the following node URL `{node_url_with_http_protocol}` without protocol (http, https, etc.).', - ], + 'errors': { + '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 @@ -158,9 +168,11 @@ def test_get_balance_node_url_with_https(): ]) expected_error = { - 'node_url': [ - f'Pass the following node URL `{node_url_with_https_protocol}` without protocol (http, https, etc.).', - ], + 'errors': { + '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 From 789714afdcc549872bb908821f7aa7edfeb90db8 Mon Sep 17 00:00:00 2001 From: Dmytro Striletskyi Date: Tue, 23 Apr 2019 19:28:06 +0300 Subject: [PATCH 4/8] Bump remme package version from 1.0.0 to 1.1.0a1 (#27) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7e36fe6..9b4ca17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ accessify==0.3.1 asyncio==3.4.3 click==7.0 -remme==1.0.0 +remme==1.1.0a1 marshmallow==2.19.2 From 027863932f87640828ef0535863f2d5991b5519f Mon Sep 17 00:00:00 2001 From: Dmytro Striletskyi Date: Wed, 24 Apr 2019 14:30:31 +0300 Subject: [PATCH 5/8] Implement transfer tokens to address (#23) --- README.md | 27 ++- cli/account/cli.py | 51 ++++- cli/account/forms.py | 24 ++- cli/account/help.py | 4 + cli/account/interfaces.py | 6 + cli/account/service.py | 11 + cli/constants.py | 4 + cli/generic/forms/fields.py | 23 +- tests/__init__.py | 0 tests/account/__init__.py | 0 tests/account/test_get_balance.py | 2 +- tests/account/test_transfer_tokens.py | 292 ++++++++++++++++++++++++++ tests/conftest.py | 22 ++ tests/generic/__init__.py | 0 tests/generic/forms/__init__.py | 0 tests/generic/forms/test_fields.py | 0 16 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/account/__init__.py create mode 100644 tests/account/test_transfer_tokens.py create mode 100644 tests/generic/__init__.py create mode 100644 tests/generic/forms/__init__.py create mode 100644 tests/generic/forms/test_fields.py diff --git a/README.md b/README.md index 1092b8d..9d56260 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ $ pip3 install remme-core-cli 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`. +- `node-6-testnet.remme.io`, +- `node-1-testnet.remme.io`. ### Configuration file @@ -116,6 +117,28 @@ $ remme account get-balance \ } ``` +Transfer tokens to address — ``remme account transfer-tokens``: + +| Arguments | Type | Required | Description | +| :---------: | :-----: | :-------: | ---------------------------------------------- | +| private-key | String | Yes | Account's private key to transfer tokens from. | +| address-to | String | Yes | Account address to transfer tokens to. | +| amount | Integer | Yes | Amount to transfer. | +| node-url | String | No | Node URL to apply a command to. | + +```bash +$ remme account transfer-tokens \ + --private-key=1067b42e24b4c533706f7c6e62278773c8ec7bf9e78bf570e9feb58ba8274acc \ + --address-to=112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202 \ + --amount=1000 \ + --node-url=node-genesis-testnet.remme.io +{ + "result": { + "batch_identifier": "aac64d7b10be4b93b8c345b5eca1dc870c6b3905485e48a0ca5f58928a88a42b7a404abb4f1027e973314cca95379b1ef375358ad1661d0964c1ded4c212810f" + } +} +``` + ## Development

Requirements

@@ -140,7 +163,7 @@ Run the ``Docker container`` with the project source code in the background mode ```bash $ docker build -t remme-core-cli . -f Dockerfile.development -$ docker run -d -v $PWD:/remme-core-cli --name remme-core-cli remme-core-cli +$ docker run -d --network host -v $PWD:/remme-core-cli --name remme-core-cli remme-core-cli ``` Enter the container bash: diff --git a/cli/account/cli.py b/cli/account/cli.py index bae0761..2009935 100644 --- a/cli/account/cli.py +++ b/cli/account/cli.py @@ -6,8 +6,16 @@ import click from remme import Remme -from cli.account.forms import GetAccountBalanceForm -from cli.account.help import ADDRESS_ARGUMENT_HELP_MESSAGE +from cli.account.forms import ( + GetAccountBalanceForm, + TransferTokensForm, +) +from cli.account.help import ( + ADDRESS_ARGUMENT_HELP_MESSAGE, + ADDRESS_TO_ARGUMENT_HELP_MESSAGE, + AMOUNT_ARGUMENT_HELP_MESSAGE, + PRIVATE_KEY_ARGUMENT_HELP_MESSAGE, +) from cli.account.service import Account from cli.constants import ( FAILED_EXIT_FROM_COMMAND_CODE, @@ -58,3 +66,42 @@ def get_balance(address, node_url): sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) print_result(result=result) + + +@click.option('--private-key', type=str, required=True, help=PRIVATE_KEY_ARGUMENT_HELP_MESSAGE) +@click.option('--address-to', type=str, required=True, help=ADDRESS_TO_ARGUMENT_HELP_MESSAGE) +@click.option('--amount', type=int, required=True, help=AMOUNT_ARGUMENT_HELP_MESSAGE) +@click.option('--node-url', type=str, required=False, help=NODE_URL_ARGUMENT_HELP_MESSAGE, default=default_node_url()) +@account_commands.command('transfer-tokens') +def transfer_tokens(private_key, address_to, amount, node_url): + """ + Transfer tokens to address. + """ + arguments, errors = TransferTokensForm().load({ + 'private_key': private_key, + 'address_to': address_to, + 'amount': amount, + 'node_url': node_url, + }) + + if errors: + print_errors(errors=errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + private_key = arguments.get('private_key') + address_to = arguments.get('address_to') + amount = arguments.get('amount') + node_url = arguments.get('node_url') + + remme = Remme( + account_config={'private_key_hex': private_key}, + network_config={'node_address': str(node_url) + ':8080'}, + ) + + result, errors = Account(service=remme).transfer_tokens(address_to=address_to, amount=amount) + + if errors is not None: + print_errors(errors=errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + print_result(result=result) diff --git a/cli/account/forms.py b/cli/account/forms.py index 85be7a7..a46bfd2 100644 --- a/cli/account/forms.py +++ b/cli/account/forms.py @@ -1,11 +1,16 @@ """ Provide forms for command line interface's account commands. """ -from marshmallow import Schema +from marshmallow import ( + Schema, + fields, + validate, +) from cli.generic.forms.fields import ( AccountAddressField, NodeURLField, + PrivateKeyField, ) @@ -16,3 +21,20 @@ class GetAccountBalanceForm(Schema): address = AccountAddressField(required=True) node_url = NodeURLField(allow_none=True, required=False) + + +class TransferTokensForm(Schema): + """ + Transfer tokens to address form. + """ + + private_key = PrivateKeyField(required=True) + address_to = AccountAddressField(required=True) + amount = fields.Integer( + strict=True, + required=True, + validate=[ + validate.Range(min=1, error='Amount must be greater than 0.'), + ], + ) + node_url = NodeURLField(allow_none=True, required=False) diff --git a/cli/account/help.py b/cli/account/help.py index 36fce4f..db40a5c 100644 --- a/cli/account/help.py +++ b/cli/account/help.py @@ -2,3 +2,7 @@ Provide help messages for command line interface's account commands. """ ADDRESS_ARGUMENT_HELP_MESSAGE = 'Account address to get a balance by.' +ADDRESS_TO_ARGUMENT_HELP_MESSAGE = 'Account address to transfer tokens to.' +AMOUNT_ARGUMENT_HELP_MESSAGE = 'Amount to transfer.' +GET_ACCOUNT_BALANCE_ADDRESS_ARGUMENT_HELP_MESSAGE = 'Account address to get a balance by.' +PRIVATE_KEY_ARGUMENT_HELP_MESSAGE = 'Account\'s private key to transfer tokens from.' diff --git a/cli/account/interfaces.py b/cli/account/interfaces.py index 12c2ea9..e70fad4 100644 --- a/cli/account/interfaces.py +++ b/cli/account/interfaces.py @@ -13,3 +13,9 @@ def get_balance(self, address): Get balance of the account by its address. """ pass + + def transfer_tokens(self, address_to, amount): + """ + Transfer tokens to address. + """ + pass diff --git a/cli/account/service.py b/cli/account/service.py index 0d02f8d..961d2e1 100644 --- a/cli/account/service.py +++ b/cli/account/service.py @@ -30,6 +30,17 @@ def get_balance(self, address): Get balance of the account by its address. """ balance = loop.run_until_complete(self.service.token.get_balance(address=address)) + return { 'balance': balance, }, None + + def transfer_tokens(self, address_to, amount): + """ + Transfer tokens to address. + """ + transaction = loop.run_until_complete(self.service.token.transfer(address_to=address_to, amount=amount)) + + return { + 'batch_identifier': transaction.batch_id, + }, None diff --git a/cli/constants.py b/cli/constants.py index f49d571..aadd660 100644 --- a/cli/constants.py +++ b/cli/constants.py @@ -2,14 +2,18 @@ Provide constants for command line interface. """ ADDRESS_REGEXP = r'^[0-9a-f]{70}$' +BATCH_ID_REGEXP = r'^[0-9a-f]{128}$' +PRIVATE_KEY_REGEXP = r'^[a-f0-9]{64}$' HEADER_SIGNATURE_REGEXP = r'^[0-9a-f]{128}$' 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]' PASSED_EXIT_FROM_COMMAND_CODE = 0 FAILED_EXIT_FROM_COMMAND_CODE = -1 +INCORRECT_ENTERED_COMMAND_CODE = 2 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' +PRIVATE_KEY_FOR_TESTING = 'b03e31d2f310305eab249133b53b5fb3270090fc1692c9b022b81c6b9bb6029b' diff --git a/cli/generic/forms/fields.py b/cli/generic/forms/fields.py index 8606699..0799e05 100644 --- a/cli/generic/forms/fields.py +++ b/cli/generic/forms/fields.py @@ -1,5 +1,5 @@ """ -Provide implementation of the custom fields. +Provide implementation of the generic form fields. """ import re @@ -11,6 +11,7 @@ from cli.constants import ( ADDRESS_REGEXP, DOMAIN_NAME_REGEXP, + PRIVATE_KEY_REGEXP, ) @@ -60,3 +61,23 @@ def _deserialize(self, value, attr, obj, **kwargs): raise ValidationError(f'The following node URL `{node_url}` is invalid.') return node_url + + +class PrivateKeyField(fields.Field): + """ + Implements validation of the private key. + + References: + - https://marshmallow.readthedocs.io/en/3.0/custom_fields.html + """ + + def _deserialize(self, value, attr, data, **kwargs): + """ + Validate data (private key) that was passed to field. + """ + private_key = value + + if re.match(pattern=PRIVATE_KEY_REGEXP, string=private_key) is None: + raise ValidationError(f'The following private key `{private_key}` is invalid.') + + return value diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/account/__init__.py b/tests/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/account/test_get_balance.py b/tests/account/test_get_balance.py index 0ab2860..5a89a11 100644 --- a/tests/account/test_get_balance.py +++ b/tests/account/test_get_balance.py @@ -1,5 +1,5 @@ """ -Provide tests for command line interface's account commands. +Provide tests for command line interface's account get balance command. """ import json diff --git a/tests/account/test_transfer_tokens.py b/tests/account/test_transfer_tokens.py new file mode 100644 index 0000000..e43ecc8 --- /dev/null +++ b/tests/account/test_transfer_tokens.py @@ -0,0 +1,292 @@ +""" +Provide tests for command line interface's account transfer tokens command. +""" +import json +import re + +import pytest +from click.testing import CliRunner + +from cli.constants import ( + BATCH_ID_REGEXP, + FAILED_EXIT_FROM_COMMAND_CODE, + INCORRECT_ENTERED_COMMAND_CODE, + NODE_IP_ADDRESS_FOR_TESTING, + PASSED_EXIT_FROM_COMMAND_CODE, + PRIVATE_KEY_FOR_TESTING, +) +from cli.entrypoint import cli +from cli.utils import dict_to_pretty_json + + +def test_transfer_tokens(): + """ + Case: transfer tokens to address. + Expect: batch identifier is returned. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + '1000', + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + batch_id = json.loads(result.output).get('result').get('batch_identifier') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert re.match(pattern=BATCH_ID_REGEXP, string=batch_id) is not None + + +def test_transfer_tokens_invalid_private_key(): + """ + Case: transfer tokens to address with invalid private key. + Expect: the following private key is invalid error message. + """ + invalid_private_key = 'b03e31d2f310305eab249133b53b5fb327' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'transfer-tokens', + '--private-key', + invalid_private_key, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + '1000', + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + expected_error = { + 'errors': { + 'private_key': [ + f'The following private key `{invalid_private_key}` is invalid.', + ], + }, + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output + + +def test_transfer_tokens_invalid_address_to(): + """ + Case: transfer tokens to invalid address. + Expect: the following address to is invalid error message. + """ + invalid_address_to = '1120076ecf036e857f42129b5830' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + invalid_address_to, + '--amount', + '1000', + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + expected_error = { + 'errors': { + 'address_to': [ + f'The following address `{invalid_address_to}` is invalid.', + ], + }, + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output + + +def test_transfer_tokens_invalid_amount(): + """ + Case: transfer tokens to address with invalid amount. + Expect: amount is not a valid integer error message. + """ + invalid_amount = 'je682' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + invalid_amount, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert INCORRECT_ENTERED_COMMAND_CODE == result.exit_code + assert f'{invalid_amount} is not a valid integer' in result.output + + +@pytest.mark.parametrize('insufficient_amount', [-1, 0]) +def test_transfer_tokens_with_insufficient_amount(insufficient_amount): + """ + Case: transfer tokens to address with insufficient amount. + Expect: amount must be greater than 0 error message. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + insufficient_amount, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + expected_error = { + 'errors': { + 'amount': [ + f'Amount must be greater than 0.', + ], + }, + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output + + +def test_transfer_tokens_without_node_url(mocker, sent_transaction): + """ + Case: transfer tokens to address without passing node URL. + Expect: batch identifier is returned from node on localhost. + """ + mock_account_transfer_tokens = mocker.patch('cli.account.service.loop.run_until_complete') + mock_account_transfer_tokens.return_value = sent_transaction + + runner = CliRunner() + result = runner.invoke(cli, [ + 'account', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + '1000', + ]) + + batch_id = json.loads(result.output).get('result').get('batch_identifier') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert re.match(pattern=BATCH_ID_REGEXP, string=batch_id) is not None + + +def test_transfer_tokens_invalid_node_url(): + """ + Case: transfer tokens to address 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', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + '1000', + '--node-url', + invalid_node_url, + ]) + + expected_error = { + 'errors': { + '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_transfer_tokens_node_url_with_http(): + """ + Case: transfer tokens to address 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', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + '1000', + '--node-url', + node_url_with_http_protocol, + ]) + + expected_error = { + 'errors': { + '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_transfer_tokens_node_url_with_https(): + """ + Case: transfer tokens to address 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', + 'transfer-tokens', + '--private-key', + PRIVATE_KEY_FOR_TESTING, + '--address-to', + '112007d71fa7e120c60fb392a64fd69de891a60c667d9ea9e5d9d9d617263be6c20202', + '--amount', + '1000', + '--node-url', + node_url_with_https_protocol, + ]) + + expected_error = { + 'errors': { + '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 diff --git a/tests/conftest.py b/tests/conftest.py index 818acc8..70f578f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,3 +75,25 @@ def create_empty_config_file(): yield os.remove(path_to_copy_fixture_file_to) + + +class SentTransaction: + """ + Impose transaction data transfer object. + """ + + @property + def batch_id(self): + """ + Get batch identifier of the sent transaction. + """ + return '37809770b004dcbc7dae116fd9f17428255ddddee3304c9b3d14609d2792e78f' \ + '08f5308af03fd4aa18ff1d868f043b12dd7b0a792e141f000a2505acd4b7a956' + + +@pytest.fixture() +def sent_transaction(): + """ + Get sent transaction fixture. + """ + return SentTransaction() diff --git a/tests/generic/__init__.py b/tests/generic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/generic/forms/__init__.py b/tests/generic/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/generic/forms/test_fields.py b/tests/generic/forms/test_fields.py new file mode 100644 index 0000000..e69de29 From 850d3a37340fcfb67c0252bc6b4e7d55f00e6120 Mon Sep 17 00:00:00 2001 From: Anastasiia Bilova Date: Wed, 24 Apr 2019 14:58:46 +0300 Subject: [PATCH 6/8] REM-1366: Integrate configuration file reading to the commands (#21) --- README.md | 2 +- cli/config.py | 10 +++++++--- cli/utils.py | 9 ++++++++- tests/test_config.py | 10 ++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9d56260..521cce7 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ node-url: node-genesis-testnet.remme.io Try it out by downloading the example of the configuration file to the home directory. ```bash -$ curl -L https://git.io/fjYZS > ~/.remme-core-cli.yml +$ curl -L https://git.io/fj3Mi > ~/.remme-core-cli.yml ``` ### Service diff --git a/cli/config.py b/cli/config.py index 4b375a9..c505029 100644 --- a/cli/config.py +++ b/cli/config.py @@ -42,10 +42,14 @@ def read(self, name): """ Read configuration file. - Return dictionary. + Return dictionary if configurations are presented, else None. """ - with open(self.path + '/.' + name + '.yml') as config_file: - return yaml.safe_load(config_file) + try: + with open(self.path + '/.' + name + '.yml') as config_file: + return yaml.safe_load(config_file) + + except FileNotFoundError: + return def parse(self, name=CLI_CONFIG_FILE_NAME): """ diff --git a/cli/utils.py b/cli/utils.py index 9914f48..455840c 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -5,6 +5,8 @@ import click +from cli.config import ConfigFile + def dict_to_pretty_json(data): r""" @@ -54,7 +56,12 @@ def default_node_url(): """ Get default node URL. """ - return 'localhost' + config_parameters = ConfigFile().parse() + + if config_parameters.node_url is None: + return 'localhost' + + return config_parameters.node_url async def return_async_value(value): diff --git a/tests/test_config.py b/tests/test_config.py index e57ead2..0822812 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,3 +35,13 @@ def test_get_node_url_from_empty_file(create_empty_config_file): config = ConfigFile().parse(name=CLI_CONFIG_FILE_NAME_EMPTY_FILE) assert config.node_url is None + + +def test_get_node_url_without_file(): + """ + Case: get node url without configuration file. + Expect: none is returned. + """ + config = ConfigFile().parse() + + assert config.node_url is None From ef7ecd52c1c5505951e9ee201ee8a31db2da0f74 Mon Sep 17 00:00:00 2001 From: Anastasiia Bilova Date: Wed, 24 Apr 2019 17:14:50 +0300 Subject: [PATCH 7/8] Get list of the public keys by account address on which these keys depend (#16) --- README.md | 24 +++ cli/entrypoint.py | 2 + cli/public_key/__init__.py | 0 cli/public_key/cli.py | 60 ++++++ cli/public_key/forms.py | 18 ++ cli/public_key/help.py | 4 + cli/public_key/interfaces.py | 15 ++ cli/public_key/service.py | 42 +++++ tests/public_key/__init__.py | 0 tests/public_key/test_get_public_keys.py | 227 +++++++++++++++++++++++ 10 files changed, 392 insertions(+) create mode 100644 cli/public_key/__init__.py create mode 100644 cli/public_key/cli.py create mode 100644 cli/public_key/forms.py create mode 100644 cli/public_key/help.py create mode 100644 cli/public_key/interfaces.py create mode 100644 cli/public_key/service.py create mode 100644 tests/public_key/__init__.py create mode 100644 tests/public_key/test_get_public_keys.py diff --git a/README.md b/README.md index 521cce7..678cfec 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ * [Configuration file](#configuration-file) * [Service](#service) * [Account](#account) + * [Public key](#public-key) * [Development](#development) * [Requirements](#development-requirements) * [Docker](#docker) @@ -139,6 +140,29 @@ $ remme account transfer-tokens \ } ``` +### Public key + +Get a list of the addresses of the public keys by account address — ``remme public-key get-list``: + +| Arguments | Type | Required | Description | +| :-------: | :----: | :------: | --------------------------------------------------------------------- | +| address | String | Yes | Account address to get a list of the addresses of the public keys by. | +| node-url | String | No | Node URL to apply a command to. | + +```bash +$ remme public-key get-list \ + --address=1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf \ + --node-url=node-genesis-testnet.remme.io +{ + "result": { + "public_key_addresses": [ + "a23be10b3aad1b4a98f338c71d6dcdb2aa2f296c7e31fb400615e335dc10dd1d4f62bf", + "a23be14b362514d624c1985277005327f6fc40413fb090eee6fccb673a32c9809060ff" + ] + } +} +``` + ## Development

Requirements

diff --git a/cli/entrypoint.py b/cli/entrypoint.py index e45563e..4a1a5b6 100644 --- a/cli/entrypoint.py +++ b/cli/entrypoint.py @@ -4,6 +4,7 @@ import click from cli.account.cli import account_commands +from cli.public_key.cli import public_key_commands @click.group() @@ -17,3 +18,4 @@ def cli(): cli.add_command(account_commands) +cli.add_command(public_key_commands) diff --git a/cli/public_key/__init__.py b/cli/public_key/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/public_key/cli.py b/cli/public_key/cli.py new file mode 100644 index 0000000..7daadd2 --- /dev/null +++ b/cli/public_key/cli.py @@ -0,0 +1,60 @@ +""" +Provide implementation of the command line interface's public key commands. +""" +import sys + +import click +from remme import Remme + +from cli.constants import ( + FAILED_EXIT_FROM_COMMAND_CODE, + NODE_URL_ARGUMENT_HELP_MESSAGE, +) +from cli.public_key.forms import GetPublicKeysForm +from cli.public_key.help import ADDRESS_ARGUMENT_HELP_MESSAGE +from cli.public_key.service import PublicKey +from cli.utils import ( + default_node_url, + print_errors, + print_result, +) + + +@click.group('public-key', chain=True) +def public_key_commands(): + """ + Provide commands for working with public key. + """ + pass + + +@click.option('--address', type=str, required=True, help=ADDRESS_ARGUMENT_HELP_MESSAGE) +@click.option('--node-url', type=str, required=False, help=NODE_URL_ARGUMENT_HELP_MESSAGE, default=default_node_url()) +@public_key_commands.command('get-list') +def get_public_keys(address, node_url): + """ + Get a list of the addresses of the public keys by account address. + """ + arguments, errors = GetPublicKeysForm().load({ + 'address': address, + 'node_url': node_url, + }) + + if errors: + print_errors(errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + address = arguments.get('address') + node_url = arguments.get('node_url') + + remme = Remme(network_config={ + 'node_address': str(node_url) + ':8080', + }) + + result, errors = PublicKey(service=remme).get_list(address=address) + + if errors is not None: + print_errors(errors=errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + print_result(result=result) diff --git a/cli/public_key/forms.py b/cli/public_key/forms.py new file mode 100644 index 0000000..c840a8d --- /dev/null +++ b/cli/public_key/forms.py @@ -0,0 +1,18 @@ +""" +Provide forms for command line interface's public key commands. +""" +from marshmallow import Schema + +from cli.generic.forms.fields import ( + AccountAddressField, + NodeURLField, +) + + +class GetPublicKeysForm(Schema): + """ + Get a list of the addresses of the public keys form. + """ + + address = AccountAddressField(required=True) + node_url = NodeURLField(allow_none=True, required=False) diff --git a/cli/public_key/help.py b/cli/public_key/help.py new file mode 100644 index 0000000..a426d5b --- /dev/null +++ b/cli/public_key/help.py @@ -0,0 +1,4 @@ +""" +Provide help messages for command line interface's public key commands. +""" +ADDRESS_ARGUMENT_HELP_MESSAGE = 'Account address to get a list of the addresses of the public keys by.' diff --git a/cli/public_key/interfaces.py b/cli/public_key/interfaces.py new file mode 100644 index 0000000..5687836 --- /dev/null +++ b/cli/public_key/interfaces.py @@ -0,0 +1,15 @@ +""" +Provide implementation of the public key interfaces. +""" + + +class PublicKeyInterface: + """ + Implements public key interface. + """ + + def get_list(self, address): + """ + Get a list of the addresses of the public keys by account address. + """ + pass diff --git a/cli/public_key/service.py b/cli/public_key/service.py new file mode 100644 index 0000000..14e4515 --- /dev/null +++ b/cli/public_key/service.py @@ -0,0 +1,42 @@ +""" +Provide implementation of the public key. +""" +import asyncio + +from accessify import implements + +from cli.public_key.interfaces import PublicKeyInterface + +loop = asyncio.get_event_loop() + + +@implements(PublicKeyInterface) +class PublicKey: + """ + Implements public key. + """ + + def __init__(self, service): + """ + Constructor. + + Arguments: + service: object to interact with Remme core API. + """ + self.service = service + + def get_list(self, address): + """ + Get a list of the addresses of the public keys by account address. + """ + try: + public_key_addresses = loop.run_until_complete( + self.service.public_key_storage.get_account_public_keys(address=address), + ) + + except Exception as error: + return None, str(error) + + return { + 'public_key_addresses': public_key_addresses, + }, None diff --git a/tests/public_key/__init__.py b/tests/public_key/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/public_key/test_get_public_keys.py b/tests/public_key/test_get_public_keys.py new file mode 100644 index 0000000..a9c535f --- /dev/null +++ b/tests/public_key/test_get_public_keys.py @@ -0,0 +1,227 @@ +""" +Provide tests for command line interface's public key get public keys addresses commands. +""" +import json +import re + +from click.testing import CliRunner + +from cli.constants import ( + ADDRESS_REGEXP, + 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 + +ADDRESS_PRESENTED_ON_THE_TEST_NODE = '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf' + + +def test_get_public_keys(): + """ + Case: get a list of the addresses of the public keys by account address. + Expect: list of the addresses of the public keys, each public key address matched regexp checking. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'public-key', + 'get-list', + '--address', + ADDRESS_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + public_key_addresses = json.loads(result.output).get('result').get('public_key_addresses') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + + for public_key in public_key_addresses: + assert re.match(pattern=ADDRESS_REGEXP, string=public_key) is not None + + +def test_get_public_keys_invalid_address(): + """ + Case: get a list of the addresses of the public keys by invalid address. + Expect: the following address is not valid error message. + """ + invalid_address = '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3zz' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'public-key', 'get-list', '--address', invalid_address, '--node-url', NODE_IP_ADDRESS_FOR_TESTING, + ]) + + expected_error = { + 'errors': { + '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_public_keys_without_node_url(mocker): + """ + Case: get a list of the addresses of the public keys without passing node URL. + Expect: list of the addresses of the public keys is returned from node on localhost. + """ + public_key_addresses = [ + 'a23be14785e7b073b50e24f72e086675289795b969a895a7f02202404086946e8ddc5b', + 'a23be17265e8393dd9ae7a46f1be662f86130c434fd54576a7d92b678e5c30de4f677f', + ] + + mock_public_key_get_public_keys = mocker.patch('cli.public_key.service.loop.run_until_complete') + mock_public_key_get_public_keys.return_value = public_key_addresses + + runner = CliRunner() + result = runner.invoke(cli, ['public-key', 'get-list', '--address', ADDRESS_PRESENTED_ON_THE_TEST_NODE]) + + expected_result = { + 'result': { + 'public_key_addresses': public_key_addresses, + }, + } + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert expected_result == json.loads(result.output) + + +def test_get_public_keys_invalid_node_url(): + """ + Case: get a list of the addresses of the public keys by passing invalid node URL. + Expect: the following node URL is invalid error message. + """ + invalid_node_url = 'domainwithoutextention' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'public-key', + 'get-list', + '--address', + ADDRESS_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + invalid_node_url, + ]) + + expected_error = { + 'errors': { + '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_public_keys_node_url_with_http(): + """ + Case: get a list of the addresses of the public keys 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, [ + 'public-key', + 'get-list', + '--address', + ADDRESS_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + node_url_with_http_protocol, + ]) + + expected_error = { + 'errors': { + '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_public_keys_node_url_with_https(): + """ + Case: get a list of the addresses of the public keys 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, [ + 'public-key', + 'get-list', + '--address', + ADDRESS_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + node_url_with_https_protocol, + ]) + + expected_error = { + 'errors': { + '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 + + +def test_get_public_keys_non_existing_address(): + """ + Case: get a list of the addresses of the public keys by passing non existing address. + Expect: empty list of the addresses of the public keys is returned. + """ + non_existing_address = '1120076ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3c1' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'public-key', + 'get-list', + '--address', + non_existing_address, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + public_key_addresses = json.loads(result.output).get('result').get('public_key_addresses') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert isinstance(public_key_addresses, list) + assert public_key_addresses == [] + + +def test_get_public_keys_non_existing_node_url(): + """ + Case: get a list of the addresses of the public keys by passing non existing node URL. + Expect: check if node running at URL error message. + """ + non_existing_node_url = 'non-existing-node.com' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'public-key', + 'get-list', + '--address', + ADDRESS_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + non_existing_node_url, + ]) + + expected_error = { + 'errors': f'Please check if your node running at http://{non_existing_node_url}:8080.', + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output From e036a9c5b1fefff2377f01f71005e0ffffcf4bb8 Mon Sep 17 00:00:00 2001 From: Dmytro Striletskyi Date: Thu, 25 Apr 2019 13:59:28 +0300 Subject: [PATCH 8/8] Implement getting node configurations (#25) --- README.md | 21 +++++ cli/constants.py | 3 + cli/entrypoint.py | 2 + cli/node/__init__.py | 0 cli/node/cli.py | 56 +++++++++++ cli/node/forms.py | 14 +++ cli/node/interfaces.py | 15 +++ cli/node/service.py | 40 ++++++++ tests/conftest.py | 24 +++++ tests/node/__init__.py | 0 tests/node/test_get_configs.py | 168 +++++++++++++++++++++++++++++++++ 11 files changed, 343 insertions(+) create mode 100644 cli/node/__init__.py create mode 100644 cli/node/cli.py create mode 100644 cli/node/forms.py create mode 100644 cli/node/interfaces.py create mode 100644 cli/node/service.py create mode 100644 tests/node/__init__.py create mode 100644 tests/node/test_get_configs.py diff --git a/README.md b/README.md index 678cfec..0c9c139 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ * [Configuration file](#configuration-file) * [Service](#service) * [Account](#account) + * [Node](#node) * [Public key](#public-key) * [Development](#development) * [Requirements](#development-requirements) @@ -140,6 +141,26 @@ $ remme account transfer-tokens \ } ``` +### Node + +Get node configurations — ``remme node get-configs``: + +| Arguments | Type | Required | Description | +| :-------: | :----: | :-------: | ------------------------------- | +| node-url | String | No | Node URL to apply a command to. | + +```bash +$ remme node get-configs --node-url=node-genesis-testnet.remme.io +{ + "result": { + "configurations": { + "node_address": "1168296ecf036e857f42129b58303bcf1e03723764a1702cbe98529802aad8514ee3cf", + "node_public_key": "03738df3f4ac3621ba8e89413d3ff4ad036c3a0a4dbb164b695885aab6aab614ad" + } + } +} +``` + ### Public key Get a list of the addresses of the public keys by account address — ``remme public-key get-list``: diff --git a/cli/constants.py b/cli/constants.py index aadd660..2341fd8 100644 --- a/cli/constants.py +++ b/cli/constants.py @@ -16,4 +16,7 @@ CLI_CONFIG_FILE_NAME = 'remme-core-cli' NODE_IP_ADDRESS_FOR_TESTING = '159.89.104.9' +LATEST_RELEASE_NODE_IP_ADDRESS_FOR_TESTING = '165.22.75.163' +RELEASE_0_9_0_ALPHA_NODE_ADDRESS = '165.227.169.119' + PRIVATE_KEY_FOR_TESTING = 'b03e31d2f310305eab249133b53b5fb3270090fc1692c9b022b81c6b9bb6029b' diff --git a/cli/entrypoint.py b/cli/entrypoint.py index 4a1a5b6..a0e888b 100644 --- a/cli/entrypoint.py +++ b/cli/entrypoint.py @@ -4,6 +4,7 @@ import click from cli.account.cli import account_commands +from cli.node.cli import node_commands from cli.public_key.cli import public_key_commands @@ -18,4 +19,5 @@ def cli(): cli.add_command(account_commands) +cli.add_command(node_commands) cli.add_command(public_key_commands) diff --git a/cli/node/__init__.py b/cli/node/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/node/cli.py b/cli/node/cli.py new file mode 100644 index 0000000..d6d92bd --- /dev/null +++ b/cli/node/cli.py @@ -0,0 +1,56 @@ +""" +Provide implementation of the command line interface's node commands. +""" +import sys + +import click +from remme import Remme + +from cli.constants import ( + FAILED_EXIT_FROM_COMMAND_CODE, + NODE_URL_ARGUMENT_HELP_MESSAGE, +) +from cli.node.forms import GetNodeConfigurationsForm +from cli.node.service import Node +from cli.utils import ( + default_node_url, + print_errors, + print_result, +) + + +@click.group('node', chain=True) +def node_commands(): + """ + Provide commands for working with node. + """ + pass + + +@click.option('--node-url', type=str, required=False, help=NODE_URL_ARGUMENT_HELP_MESSAGE, default=default_node_url()) +@node_commands.command('get-configs') +def get_config(node_url): + """ + Get node configurations. + """ + arguments, errors = GetNodeConfigurationsForm().load({ + 'node_url': node_url, + }) + + if errors: + print_errors(errors=errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + node_url = arguments.get('node_url') + + remme = Remme(network_config={ + 'node_address': str(node_url) + ':8080', + }) + + result, errors = Node(service=remme).get_configs() + + if errors is not None: + print_errors(errors=errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + print_result(result=result) diff --git a/cli/node/forms.py b/cli/node/forms.py new file mode 100644 index 0000000..9964750 --- /dev/null +++ b/cli/node/forms.py @@ -0,0 +1,14 @@ +""" +Provide forms for command line interface's node commands. +""" +from marshmallow import Schema + +from cli.generic.forms.fields import NodeURLField + + +class GetNodeConfigurationsForm(Schema): + """ + Get node configurations. + """ + + node_url = NodeURLField(allow_none=True, required=False) diff --git a/cli/node/interfaces.py b/cli/node/interfaces.py new file mode 100644 index 0000000..6569541 --- /dev/null +++ b/cli/node/interfaces.py @@ -0,0 +1,15 @@ +""" +Provide implementation of the node interfaces. +""" + + +class NodeInterface: + """ + Implements node interface. + """ + + def get_configs(self): + """ + Get node configurations. + """ + pass diff --git a/cli/node/service.py b/cli/node/service.py new file mode 100644 index 0000000..561fa31 --- /dev/null +++ b/cli/node/service.py @@ -0,0 +1,40 @@ +""" +Provide implementation of the node. +""" +import asyncio + +from accessify import implements + +from cli.node.interfaces import NodeInterface + +loop = asyncio.get_event_loop() + + +@implements(NodeInterface) +class Node: + """ + Implements node. + """ + + def __init__(self, service): + """ + Constructor. + + Arguments: + service: object to interact with Remme core API. + """ + self.service = service + + def get_configs(self): + """ + Get node configurations. + """ + try: + configurations = loop.run_until_complete(self.service.node_management.get_node_config()) + + except Exception as error: + return None, str(error) + + return { + 'configurations': configurations.data, + }, None diff --git a/tests/conftest.py b/tests/conftest.py index 70f578f..26de836 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,9 +91,33 @@ def batch_id(self): '08f5308af03fd4aa18ff1d868f043b12dd7b0a792e141f000a2505acd4b7a956' +class NodeConfigurations: + """ + Impose node configurations data transfer object. + """ + + @property + def data(self): + """ + Get node configurations. + """ + return { + 'node_address': '116829f18683f6c30146559c9cb8d5d302545019ff00f2ab72500df99bceb7b81a1dad', + 'node_public_key': '0350e9cf23966ad404dc56438fd01ec11a913446cfd7c4fb8d95586a58718431e7', + } + + @pytest.fixture() def sent_transaction(): """ Get sent transaction fixture. """ return SentTransaction() + + +@pytest.fixture() +def node_configurations(): + """ + Get node configurations fixture. + """ + return NodeConfigurations() diff --git a/tests/node/__init__.py b/tests/node/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/node/test_get_configs.py b/tests/node/test_get_configs.py new file mode 100644 index 0000000..5cbf28e --- /dev/null +++ b/tests/node/test_get_configs.py @@ -0,0 +1,168 @@ +""" +Provide tests for command line interface's node get configurations command. +""" +import json + +from click.testing import CliRunner + +from cli.constants import ( + FAILED_EXIT_FROM_COMMAND_CODE, + PASSED_EXIT_FROM_COMMAND_CODE, + RELEASE_0_9_0_ALPHA_NODE_ADDRESS, +) +from cli.entrypoint import cli +from cli.utils import dict_to_pretty_json + + +def test_get_node_configs(): + """ + Case: get node configurations. + Expect: node public key and address are returned. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'node', + 'get-configs', + '--node-url', + RELEASE_0_9_0_ALPHA_NODE_ADDRESS, + ]) + + expected_node_configurations = { + 'result': { + 'configurations': { + 'node_address': '116829f18683f6c30146559c9cb8d5d302545019ff00f2ab72500df99bceb7b81a1dad', + 'node_public_key': '0350e9cf23966ad404dc56438fd01ec11a913446cfd7c4fb8d95586a58718431e7', + }, + }, + } + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert expected_node_configurations == json.loads(result.output) + + +def test_get_node_configs_without_node_url(mocker, node_configurations): + """ + Case: get node configurations without passing node URL. + Expect: batch identifier is returned from node on localhost. + """ + mock_account_get_balance = mocker.patch('cli.node.service.loop.run_until_complete') + mock_account_get_balance.return_value = node_configurations + + runner = CliRunner() + result = runner.invoke(cli, [ + 'node', + 'get-configs', + ]) + + expected_node_configurations = { + 'result': { + 'configurations': node_configurations.data, + }, + } + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert expected_node_configurations == json.loads(result.output) + + +def test_get_node_configs_invalid_node_url(): + """ + Case: get node configurations by passing invalid node URL. + Expect: the following node URL is invalid error message. + """ + invalid_node_url = 'domainwithoutextention' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'node', + 'get-configs', + '--node-url', + invalid_node_url, + ]) + + expected_error = { + 'errors': { + '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_node_configs_node_url_with_http(): + """ + Case: get node configurations 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, [ + 'node', + 'get-configs', + '--node-url', + node_url_with_http_protocol, + ]) + + expected_error = { + 'errors': { + '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_node_configs_node_url_with_https(): + """ + Case: get node configurations 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, [ + 'node', + 'get-configs', + '--node-url', + node_url_with_https_protocol, + ]) + + expected_error = { + 'errors': { + '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 + + +def test_get_node_configs_non_existing_node_url(): + """ + Case: get node configurations by passing non existing node URL. + Expect: check if node running at URL error message. + """ + non_existing_node_url = 'non-existing-node.com' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'node', + 'get-configs', + '--node-url', + non_existing_node_url, + ]) + + expected_error = { + 'errors': f'Please check if your node running at http://{non_existing_node_url}:8080.', + } + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert dict_to_pretty_json(expected_error) in result.output