From 4c60d6eb7e4b6926e8536812fda60420c0ec5ef2 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 21 Sep 2020 16:33:28 +0200 Subject: [PATCH 01/10] add tests add account management --- .gitignore | 2 +- cnctcli/__init__.py | 5 +- cnctcli/actions/accounts.py | 39 ++++++++ cnctcli/ccli.py | 33 ++----- cnctcli/commands/account.py | 84 +++++++++++++++++ cnctcli/commands/product.py | 13 ++- cnctcli/config.py | 72 +++++++++++---- cnctcli/constants.py | 2 + requirements/dev.txt | 2 +- requirements/test.txt | 6 ++ setup.cfg | 4 +- tests/__init__.py | 0 tests/actions/__init__.py | 0 tests/actions/test_accounts.py | 99 ++++++++++++++++++++ tests/commands/__init__.py | 0 tests/commands/test_account.py | 115 ++++++++++++++++++++++++ tests/commands/test_product.py | 0 tests/data.py | 17 ++++ tests/test_config.py | 159 +++++++++++++++++++++++++++++++++ 19 files changed, 594 insertions(+), 58 deletions(-) create mode 100644 cnctcli/actions/accounts.py create mode 100644 cnctcli/commands/account.py create mode 100644 cnctcli/constants.py create mode 100644 requirements/test.txt create mode 100644 tests/__init__.py create mode 100644 tests/actions/__init__.py create mode 100644 tests/actions/test_accounts.py create mode 100644 tests/commands/__init__.py create mode 100644 tests/commands/test_account.py create mode 100644 tests/commands/test_product.py create mode 100644 tests/data.py create mode 100644 tests/test_config.py diff --git a/.gitignore b/.gitignore index 493892b2..258a5b8a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ dist/ .vscode .devcontainer -tests/reports/ +coverage/ .coverage /htmlcov/ docs/_build diff --git a/cnctcli/__init__.py b/cnctcli/__init__.py index 7bf33972..bcc5223e 100644 --- a/cnctcli/__init__.py +++ b/cnctcli/__init__.py @@ -1,7 +1,8 @@ -from setuptools_scm import get_version as scm_version +import pkg_resources + try: - __version__ = scm_version(root='..', relative_to=__file__) + __version__ = pkg_resources.require('connect-cli')[0].version except: # noqa: E722 __version__ = '0.0.1' diff --git a/cnctcli/actions/accounts.py b/cnctcli/actions/accounts.py new file mode 100644 index 00000000..30c44870 --- /dev/null +++ b/cnctcli/actions/accounts.py @@ -0,0 +1,39 @@ +import click +import requests + + +def add_account(config, api_key, endpoint): + headers = { + 'Authorization': api_key, + } + + res = requests.get(f'{endpoint}/accounts', headers=headers) + if res.status_code == 401: + raise click.ClickException('Unauthorized: the provided api key is invalid.') + + if res.status_code == 200: + account_data = res.json()[0] + account_id = account_data['id'] + name = account_data['name'] + config.add_account( + account_id, + name, + api_key, + endpoint, + ) + config.store() + return account_id, name + + raise click.ClickException(f'Unexpected error: {res.status_code} - {res.text}') + + +def activate_account(config, id): + config.activate(id) + config.store() + return config.active + + +def remove_account(config, id): + acc = config.remove_account(id) + config.store() + return acc diff --git a/cnctcli/ccli.py b/cnctcli/ccli.py index 6e7a2b46..1d538414 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -15,6 +15,7 @@ import click from cnctcli import get_version +from cnctcli.commands.account import grp_account from cnctcli.commands.product import grp_product from cnctcli.config import pass_config @@ -35,37 +36,19 @@ def cli(config, config_dir): config.load(config_dir) -@cli.command(short_help='configure the CloudBlue Connect API endpoint' - ' and credentials') -@click.option( - '--url', - '-u', - required=True, - prompt='Enter the API endpoint URL', - help='API endpoint URL', -) -@click.option( - '--key', - '-k', - required=True, - prompt='Enter the API authentication KEY', - help='API key', -) -@pass_config -def configure(config, url, key): - config.api_url = url - config.api_key = key - config.store() - - +cli.add_command(grp_account) cli.add_command(grp_product) -def main(): +def main(): # noqa + print('') try: cli(prog_name='ccli', standalone_mode=False) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter except click.ClickException as ce: - ce.show() + click.echo( + click.style(f'Error: {ce.message}', fg='red') + ) + print('') if __name__ == '__main__': diff --git a/cnctcli/commands/account.py b/cnctcli/commands/account.py new file mode 100644 index 00000000..80910353 --- /dev/null +++ b/cnctcli/commands/account.py @@ -0,0 +1,84 @@ +import click + +from cnctcli.actions.accounts import ( + activate_account, + add_account, + remove_account, +) +from cnctcli.config import pass_config +from cnctcli.constants import DEFAULT_ENDPOINT + + +@click.group(name='account', short_help='account configuration') +def grp_account(): + pass + + +@grp_account.command( + name='add', + short_help='add a new account', +) +@click.argument('api_key', metavar='API_KEY', nargs=1, required=True) # noqa: E304 +@click.option( + '--endpoint', + '-e', + 'endpoint', + default=DEFAULT_ENDPOINT, + help='API endpoint.' +) +@pass_config +def cmd_add_account(config, api_key, endpoint): + account_id, name = add_account(config, api_key, endpoint) + click.echo( + click.style(f'New account added: {account_id} - {name}', fg='green') + ) + + +@grp_account.command( + name='list', + short_help='list configured accounts', +) +@pass_config +def cmd_list_account(config): + for acc in config.accounts.values(): + if acc.id == config.active.id: + click.echo( + click.style( + f'{acc.id} - {acc.name} (active)', + fg='blue', + ), + ) + else: + click.echo(f'{acc.id} - {acc.name}') + + +@grp_account.command( + name='activate', + short_help='set active account', +) +@click.argument('id', metavar='ACCOUNT_ID', nargs=1, required=True) # noqa: E304 +@pass_config +def cmd_activate_account(config, id): + acc = activate_account(config, id) + click.echo( + click.style( + f'Current active account is: {acc.id} - {acc.name}', + fg='green', + ), + ) + + +@grp_account.command( + name='remove', + short_help='remove an account', +) +@click.argument('id', metavar='ACCOUNT_ID', nargs=1, required=True) # noqa: E304 +@pass_config +def cmd_remove_account(config, id): + acc = remove_account(config, id) + click.echo( + click.style( + f'Account removed: {acc.id} - {acc.name}', + fg='green', + ), + ) diff --git a/cnctcli/commands/product.py b/cnctcli/commands/product.py index 91217902..d336ced2 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -15,27 +15,26 @@ def grp_product(): @grp_product.command( - name='dump', - short_help='dump products to an excel file', + name='export', + short_help='export a product to an excel file', ) -@click.argument('product_ids', metavar='product_id', nargs=-1, required=True) # noqa: E304 +@click.argument('product_id', metavar='product_id', nargs=1, required=True) # noqa: E304 @click.option( '--out', '-o', 'output_file', - required=True, type=click.Path(exists=False, file_okay=True, dir_okay=False), help='Path to the output Excel file.' ) @pass_config -def cmd_dump_products(config, product_ids, output_file): - dump_products(config.api_url, config.api_key, product_ids, output_file) +def cmd_dump_products(config, product_id, output_file): + dump_products(config.api_url, config.api_key, product_id, output_file) @grp_product.command( name='sync', - short_help='sync products from an excel file', + short_help='sync a product from an excel file', ) @click.option( # noqa: E304 diff --git a/cnctcli/config.py b/cnctcli/config.py index 5530a2a8..e4658c78 100644 --- a/cnctcli/config.py +++ b/cnctcli/config.py @@ -6,30 +6,59 @@ import json import os -from click import make_pass_decorator +from dataclasses import dataclass + +from click import ClickException, make_pass_decorator + + +from cnctcli.constants import DEFAULT_ENDPOINT + + +@dataclass +class Account: + id: str + name: str + api_key: str + endpoint: str class Config(object): def __init__(self): self._config_path = None - self._api_url = None - self._api_key = None + self._active = None + self._accounts = {} - @property - def api_url(self): - return self._api_url + def add_account(self, id, name, api_key, endpoint=DEFAULT_ENDPOINT): + self._accounts[id] = Account(id, name, api_key, endpoint) + if not self._active: + self._active = self._accounts[id] - @api_url.setter - def api_url(self, value): - self._api_url = value + @property + def active(self): + return self._active @property - def api_key(self): - return self._api_key + def accounts(self): + return self._accounts + + def activate(self, id): + account = self._accounts.get(id) + if account: + self._active = account + return + raise ClickException(f'The account identified by {id} does not exist.') - @api_key.setter - def api_key(self, value): - self._api_key = value + def remove_account(self, id): + if id in self._accounts: + account = self._accounts[id] + del self._accounts[id] + if self._active.id == id: + if self._accounts: + self._active = list(self._accounts.values())[0] + else: + self._active = None + return account + raise ClickException(f'The account identified by {id} does not exist.') def load(self, config_dir): self._config_path = os.path.join(config_dir, 'config.json') @@ -38,18 +67,23 @@ def load(self, config_dir): with open(self._config_path, 'r') as f: data = json.load(f) - self.api_url = data['apiEndpoint'] - self.api_key = data.get('apiKey') + active_account_id = data['active'] + for account_data in data['accounts']: + account = Account(**account_data) + self._accounts[account.id] = account + if account.id == active_account_id: + self._active = account def store(self): with open(self._config_path, 'w') as f: + accounts = [account.__dict__ for account in self._accounts.values()] f.write(json.dumps({ - 'apiEndpoint': self.api_url, - 'apiKey': self.api_key + 'active': self._active.id if self._active else '', + 'accounts': accounts })) def is_valid(self): - pass + return bool(self._accounts and self._active) pass_config = make_pass_decorator(Config, ensure=True) diff --git a/cnctcli/constants.py b/cnctcli/constants.py new file mode 100644 index 00000000..edf66d35 --- /dev/null +++ b/cnctcli/constants.py @@ -0,0 +1,2 @@ +DEFAULT_ENDPOINT = 'https://api.connect.cloudblue.com/public/v1' +DEFAULT_USER_AGENT = 'CloudBlue Connect CLI/' diff --git a/requirements/dev.txt b/requirements/dev.txt index 7a37eba3..8e50e710 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ click==7.1.2 openpyxl>=2.5.14 -setuptools-scm==3.5.0 +setuptools-scm==4.1.2 connect-sdk>=20.3 tqdm==4.48.2 \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 00000000..343311c2 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,6 @@ +coverage +flake8 +pytest +pytest-cov +pytest-mock +requests-mock \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 27b2e82e..95afa653 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,5 @@ ignore = FI1,I100,W503 test = pytest [tool:pytest] -python_paths = ./ junit_family = xunit2 -django_find_project = false -addopts = -p no:cacheprovider --reuse-db --nomigrations --junitxml=tests/reports/out.xml --cov=cnctdocs --cov-report xml:tests/reports/coverage.xml --cov-report term +addopts = -p no:cacheprovider --junitxml=coverage/out.xml --cov=cnctcli --cov-report xml:coverage/coverage.xml --cov-report term diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/actions/__init__.py b/tests/actions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/actions/test_accounts.py b/tests/actions/test_accounts.py new file mode 100644 index 00000000..704a7344 --- /dev/null +++ b/tests/actions/test_accounts.py @@ -0,0 +1,99 @@ +import pytest + +import click + +from cnctcli.actions.accounts import ( + activate_account, + add_account, + remove_account, +) +from cnctcli.config import Config + + +def test_add_account(mocker, requests_mock): + mocker.patch.object(Config, 'store') + config = Config() + requests_mock.get( + 'https://localhost/public/v1/accounts', + json=[ + { + 'id': 'VA-000', + 'name': 'Test account', + }, + ], + ) + + account_id, name = add_account( + config, + 'ApiKey SU-000:xxxx', + 'https://localhost/public/v1', + ) + + assert len(config.accounts) == 1 + assert config.active is not None + assert config.active.id == 'VA-000' + assert account_id == config.active.id + assert name == config.active.name + + +def test_add_account_invalid_api_key(requests_mock): + config = Config() + requests_mock.get( + 'https://localhost/public/v1/accounts', + status_code=401, + ) + + with pytest.raises(click.ClickException) as ex: + add_account( + config, + 'ApiKey SU-000:xxxx', + 'https://localhost/public/v1', + ) + assert ex.value.message == 'Unauthorized: the provided api key is invalid.' + + +def test_add_account_internal_server_error(requests_mock): + config = Config() + requests_mock.get( + 'https://localhost/public/v1/accounts', + status_code=500, + text='Internal Server Error' + ) + + with pytest.raises(click.ClickException) as ex: + add_account( + config, + 'ApiKey SU-000:xxxx', + 'https://localhost/public/v1', + ) + assert ex.value.message == 'Unexpected error: 500 - Internal Server Error' + + +def test_activate_account(mocker): + mock = mocker.patch.object(Config, 'store') + config = Config() + config.add_account('VA-000', 'Account 0', 'Api 0') + config.add_account('VA-001', 'Account 1', 'Api 1') + + assert config.active.id == 'VA-000' + + acc = activate_account(config, 'VA-001') + + assert acc.id == 'VA-001' + assert config.active.id == 'VA-001' + mock.assert_called_once() + + +def test_remove_account(mocker): + mock = mocker.patch.object(Config, 'store') + config = Config() + config.add_account('VA-000', 'Account 0', 'Api 0') + config.add_account('VA-001', 'Account 1', 'Api 1') + + assert config.active.id == 'VA-000' + + acc = remove_account(config, 'VA-000') + + assert acc.id == 'VA-000' + assert config.active.id == 'VA-001' + mock.assert_called_once() diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/test_account.py b/tests/commands/test_account.py new file mode 100644 index 00000000..a36b79c6 --- /dev/null +++ b/tests/commands/test_account.py @@ -0,0 +1,115 @@ +import json + +from click.testing import CliRunner + +from cnctcli.ccli import cli +from cnctcli.config import Account +from cnctcli.constants import DEFAULT_ENDPOINT + +from tests.data import CONFIG_DATA + + +def test_add_account(mocker): + mock = mocker.patch( + 'cnctcli.commands.account.add_account', + return_value=('VA-000', 'Account 0'), + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'account', + 'add', + 'ApiKey XXX:YYY', + ], + ) + assert result.exit_code == 0 + assert mock.mock_calls[0][1][1] == 'ApiKey XXX:YYY' + assert mock.mock_calls[0][1][2] == DEFAULT_ENDPOINT + assert result.output == 'New account added: VA-000 - Account 0\n' + + +def test_add_account_custom_endpoint(mocker): + mock = mocker.patch( + 'cnctcli.commands.account.add_account', + return_value=('VA-000', 'Account 0'), + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'account', + 'add', + 'ApiKey XXX:YYY', + '--endpoint', + 'https://custom_endpoint' + ], + ) + assert result.exit_code == 0 + assert mock.mock_calls[0][1][1] == 'ApiKey XXX:YYY' + assert mock.mock_calls[0][1][2] == 'https://custom_endpoint' + assert result.output == 'New account added: VA-000 - Account 0\n' + + +def test_remove_account(mocker): + mock = mocker.patch( + 'cnctcli.commands.account.remove_account', + side_effect=lambda *args: Account('VA-000', 'Account 0', '', ''), + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'account', + 'remove', + 'VA-000', + ], + ) + + assert result.exit_code == 0 + assert mock.mock_calls[0][1][1] == 'VA-000' + assert result.output == 'Account removed: VA-000 - Account 0\n' + + +def test_activate_account(mocker): + mock = mocker.patch( + 'cnctcli.commands.account.activate_account', + side_effect=lambda *args: Account('VA-000', 'Account 0', '', ''), + ) + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'account', + 'activate', + 'VA-000', + ], + ) + + assert result.exit_code == 0 + assert mock.mock_calls[0][1][1] == 'VA-000' + assert result.output == 'Current active account is: VA-000 - Account 0\n' + + +def test_list_accounts(mocker): + mocker.patch( + 'cnctcli.config.open', + mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), + ) + mocker.patch('os.path.isfile', return_value=True) + + runner = CliRunner() + + result = runner.invoke( + cli, + [ + 'account', + 'list', + ], + ) + + assert result.exit_code == 0 + assert result.output == ( + 'VA-000 - Account 0 (active)\n' + 'VA-001 - Account 1\n' + ) diff --git a/tests/commands/test_product.py b/tests/commands/test_product.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 00000000..e537596c --- /dev/null +++ b/tests/data.py @@ -0,0 +1,17 @@ +CONFIG_DATA = { + 'active': 'VA-000', + 'accounts': [ + { + 'id': 'VA-000', + 'name': 'Account 0', + 'api_key': 'ApiKey XXXX:YYYY', + 'endpoint': 'https://localhost', + }, + { + 'id': 'VA-001', + 'name': 'Account 1', + 'api_key': 'ApiKey ZZZZ:SSSS', + 'endpoint': 'https://localhost', + } + ], +} \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..4a12b584 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,159 @@ +import json + +import click +import pytest + +from cnctcli.config import Config +from cnctcli.constants import DEFAULT_ENDPOINT + +from tests.data import CONFIG_DATA + + +def test_load(mocker): + config = Config() + mocker.patch( + 'cnctcli.config.open', + mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), + ) + mocker.patch('os.path.isfile', return_value=True) + config.load('/tmp') + assert config.active is not None + assert config.active.id == 'VA-000' + assert len(config.accounts) == 2 + + +def test_store(mocker): + mock_open = mocker.mock_open() + mocker.patch( + 'cnctcli.config.open', + mock_open, + ) + + config = Config() + config._config_path = '/tmp' + config.add_account('VA-000', 'Account 1', 'ApiKey XXXX:YYYY') + + config.store() + assert mock_open.mock_calls[0][1][1] == 'w' + assert mock_open.mock_calls[2][1][0] == json.dumps( + { + 'active': 'VA-000', + 'accounts': [ + { + 'id': 'VA-000', + 'name': 'Account 1', + 'api_key': 'ApiKey XXXX:YYYY', + 'endpoint': DEFAULT_ENDPOINT, + }, + ] + } + ) + + +def test_add_account(): + config = Config() + config.add_account('VA-000', 'Account 1', 'ApiKey XXXX:YYYY') + + assert config.active is not None + assert config.active.id == 'VA-000' + assert config.active.name == 'Account 1' + assert config.active.api_key == 'ApiKey XXXX:YYYY' + assert config.active.endpoint == DEFAULT_ENDPOINT + + +def test_add_account_custom_endpoint(): + config = Config() + config.add_account( + 'VA-000', + 'Account 1', + 'ApiKey XXXX:YYYY', + endpoint='https://my_custom_endpoint', + ) + + assert config.active.endpoint == 'https://my_custom_endpoint' + + +def test_activate(mocker): + config = Config() + mocker.patch( + 'cnctcli.config.open', + mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), + ) + mocker.patch('os.path.isfile', return_value=True) + config.load('/tmp') + + assert config.active.id == 'VA-000' + + config.activate('VA-001') + + assert config.active is not None + assert config.active.id == 'VA-001' + assert config.active.name == 'Account 1' + assert config.active.api_key == 'ApiKey ZZZZ:SSSS' + + +def test_activate_non_existent_account(): + config = Config() + + with pytest.raises(click.ClickException) as ex: + config.activate('VA-999') + + assert ex.value.message == 'The account identified by VA-999 does not exist.' + + +def test_remove_account(): + config = Config() + + config.add_account( + 'VA-000', + 'Account 1', + 'ApiKey XXXX:YYYY', + endpoint='https://my_custom_endpoint', + ) + + assert config.active.id == 'VA-000' + assert len(config.accounts) == 1 + + config.remove_account('VA-000') + + assert config.active is None + assert len(config.accounts) == 0 + + +def test_remove_non_existent_account(): + config = Config() + with pytest.raises(click.ClickException) as ex: + config.remove_account('VA-999') + + assert ex.value.message == 'The account identified by VA-999 does not exist.' + + +def test_remove_activate_other(mocker): + config = Config() + mocker.patch( + 'cnctcli.config.open', + mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), + ) + mocker.patch('os.path.isfile', return_value=True) + config.load('/tmp') + + assert config.active.id == 'VA-000' + + config.remove_account('VA-000') + + assert config.active.id == 'VA-001' + + +def test_config_is_valid(): + config = Config() + + assert config.is_valid() is False + + config.add_account( + 'VA-000', + 'Account 1', + 'ApiKey XXXX:YYYY', + endpoint='https://my_custom_endpoint', + ) + + assert config.is_valid() is True From 620e275585f516807468ab18e2195e3d9d864009 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 21 Sep 2020 22:05:08 +0200 Subject: [PATCH 02/10] fix export product -> single product improve coverage --- .travis.yml | 2 ++ cnctcli/__init__.py | 2 +- cnctcli/actions/products.py | 64 +++++++++++++++------------------- cnctcli/ccli.py | 6 ++-- cnctcli/commands/account.py | 2 +- cnctcli/commands/product.py | 20 ++++++++--- cnctcli/config.py | 5 +-- tests/commands/test_account.py | 13 +------ tests/commands/test_product.py | 49 ++++++++++++++++++++++++++ tests/conftest.py | 14 ++++++++ tests/test_config.py | 32 +++++------------ 11 files changed, 128 insertions(+), 81 deletions(-) create mode 100644 tests/conftest.py diff --git a/.travis.yml b/.travis.yml index 84c24a76..6f919efe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,9 +22,11 @@ jobs: - DIST=windows install: - pip3 install -r requirements/dev.txt +- pip3 install -r requirements/test.txt - pip3 install flake8 pyinstaller script: - flake8 +- pytest - ./package.sh deploy: - provider: pypi diff --git a/cnctcli/__init__.py b/cnctcli/__init__.py index bcc5223e..aa78f6bf 100644 --- a/cnctcli/__init__.py +++ b/cnctcli/__init__.py @@ -3,7 +3,7 @@ try: __version__ = pkg_resources.require('connect-cli')[0].version -except: # noqa: E722 +except: # pragma: no cover noqa: E722 __version__ = '0.0.1' diff --git a/cnctcli/actions/products.py b/cnctcli/actions/products.py index f6b24072..3cfcd08a 100644 --- a/cnctcli/actions/products.py +++ b/cnctcli/actions/products.py @@ -2,7 +2,7 @@ # This file is part of the Ingram Micro Cloud Blue Connect product-sync. # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. - +import os from zipfile import BadZipFile import click @@ -51,48 +51,42 @@ def _check_skipped(skipped): click.echo(f'\t{pinfo[0]}: {pinfo[1]}') -def dump_products(api_url, api_key, product_ids, output_file): - skipped = [] +def dump_product(api_url, api_key, product_id, output_file): + if not output_file: + output_file = os.path.abspath( + os.path.join('.', f'{product_id}.xlsx'), + ) config = Config(api_url=api_url, api_key=api_key) products = ProductsResource(config) wb = Workbook() - ids = tqdm(product_ids, position=0) need_save = False - for product_id in ids: - ids.set_description('Processing product {}'.format(product_id)) - try: - items = products.items(product_id).search() - except: # noqa: E722 - skipped.append( - ( - product_id, - f'Product "{product_id}"" does not exist.', - ), - ) - continue - need_save = True - ws = wb.create_sheet(product_id) - _setup_excel_sheet_header(ws) - processing_items = tqdm(items, position=1, leave=None) - for row_idx, item in enumerate(processing_items, start=2): - processing_items.set_description('Processing item {}'.format(item.id)) - ws.cell(row_idx, 1, value=item.display_name) - ws.cell(row_idx, 2, value=item.mpn) - ws.cell(row_idx, 3, value=item.period) - ws.cell(row_idx, 4, value=item.type == 'reservation') - ws.cell(row_idx, 5, value=item.description) - commitment = item.commitment.count == 12 if item.commitment else False - ws.cell(row_idx, 6, value=commitment) - ws.cell(row_idx, 7, value=item.unit.unit) - ws.cell(row_idx, 8, value=item.id) - for i in range(1, 9): - ws.column_dimensions[get_column_letter(i)].auto_size = True + try: + items = products.items(product_id).search() + except Exception as e: # noqa: E722 + raise click.ClickException(f'Cannot export product {product_id}: {str(e)}' ) + + ws = wb.create_sheet(product_id) + _setup_excel_sheet_header(ws) + processing_items = tqdm(items, position=1, leave=None) + for row_idx, item in enumerate(processing_items, start=2): + processing_items.set_description('Processing item {}'.format(item.id)) + ws.cell(row_idx, 1, value=item.display_name) + ws.cell(row_idx, 2, value=item.mpn) + ws.cell(row_idx, 3, value=item.period) + ws.cell(row_idx, 4, value=item.type == 'reservation') + ws.cell(row_idx, 5, value=item.description) + commitment = item.commitment.count == 12 if item.commitment else False + ws.cell(row_idx, 6, value=commitment) + ws.cell(row_idx, 7, value=item.unit.unit) + ws.cell(row_idx, 8, value=item.id) + for i in range(1, 9): + ws.column_dimensions[get_column_letter(i)].auto_size = True if need_save: wb.remove_sheet(wb.worksheets[0]) wb.save(output_file) - _check_skipped(skipped) + return output_file def _validate_sheet(ws): @@ -171,7 +165,7 @@ def _create_product_item(items, data): return items.create(item) -def sync_products(api_url, api_key, input_file): +def sync_product(api_url, api_key, input_file): skipped = [] items_errors = [] need_save = False diff --git a/cnctcli/ccli.py b/cnctcli/ccli.py index 1d538414..5122f9f4 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -7,7 +7,7 @@ import warnings from marshmallow.warnings import ChangedInMarshmallow3Warning warnings.filterwarnings('ignore', category=ChangedInMarshmallow3Warning) -except ImportError: +except ImportError: # pragma: no cover pass import os @@ -40,7 +40,7 @@ def cli(config, config_dir): cli.add_command(grp_product) -def main(): # noqa +def main(): # pragma: no cover print('') try: cli(prog_name='ccli', standalone_mode=False) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter @@ -52,4 +52,4 @@ def main(): # noqa if __name__ == '__main__': - main() + main() # pragma: no cover diff --git a/cnctcli/commands/account.py b/cnctcli/commands/account.py index 80910353..c944bac3 100644 --- a/cnctcli/commands/account.py +++ b/cnctcli/commands/account.py @@ -11,7 +11,7 @@ @click.group(name='account', short_help='account configuration') def grp_account(): - pass + pass # pragma: no cover @grp_account.command( diff --git a/cnctcli/commands/product.py b/cnctcli/commands/product.py index d336ced2..8d4c917a 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -5,13 +5,13 @@ import click -from cnctcli.actions.products import dump_products, sync_products +from cnctcli.actions.products import dump_product, sync_product from cnctcli.config import pass_config @click.group(name='product', short_help='commands related to product management') def grp_product(): - pass + pass # pragma: no cover @grp_product.command( @@ -29,7 +29,19 @@ def grp_product(): ) @pass_config def cmd_dump_products(config, product_id, output_file): - dump_products(config.api_url, config.api_key, product_id, output_file) + config.validate() + outfile = dump_product( + config.active.endpoint, + config.active.api_key, + product_id, + output_file, + ) + click.echo( + click.style( + f'Product {product_id} exported successfully to {outfile}', + fg='green', + ) + ) @grp_product.command( @@ -47,4 +59,4 @@ def cmd_dump_products(config, product_id, output_file): ) @pass_config def cmd_sync_products(config, input_file): - sync_products(config.api_url, config.api_key, input_file) + sync_product(config.active.endpoint, config.active.api_key, input_file) diff --git a/cnctcli/config.py b/cnctcli/config.py index e4658c78..1dff346b 100644 --- a/cnctcli/config.py +++ b/cnctcli/config.py @@ -82,8 +82,9 @@ def store(self): 'accounts': accounts })) - def is_valid(self): - return bool(self._accounts and self._active) + def validate(self): + if not (self._accounts and self._active): + raise ClickException('connect-cli is not properly configured.') pass_config = make_pass_decorator(Config, ensure=True) diff --git a/tests/commands/test_account.py b/tests/commands/test_account.py index a36b79c6..a9f3131d 100644 --- a/tests/commands/test_account.py +++ b/tests/commands/test_account.py @@ -1,13 +1,9 @@ -import json - from click.testing import CliRunner from cnctcli.ccli import cli from cnctcli.config import Account from cnctcli.constants import DEFAULT_ENDPOINT -from tests.data import CONFIG_DATA - def test_add_account(mocker): mock = mocker.patch( @@ -91,15 +87,8 @@ def test_activate_account(mocker): assert result.output == 'Current active account is: VA-000 - Account 0\n' -def test_list_accounts(mocker): - mocker.patch( - 'cnctcli.config.open', - mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), - ) - mocker.patch('os.path.isfile', return_value=True) - +def test_list_accounts(config_mocker, mocker): runner = CliRunner() - result = runner.invoke( cli, [ diff --git a/tests/commands/test_product.py b/tests/commands/test_product.py index e69de29b..22fddfbe 100644 --- a/tests/commands/test_product.py +++ b/tests/commands/test_product.py @@ -0,0 +1,49 @@ +from click.testing import CliRunner + +from cnctcli.ccli import cli + + +def test_export(config_mocker, mocker): + mock = mocker.patch( + 'cnctcli.commands.product.dump_product', + side_effect=lambda *args: 'PRD-000.xlsx', + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'product', + 'export', + 'PRD-000', + ], + ) + mock.assert_called_once() + assert mock.mock_calls[0][1][2] == 'PRD-000' + assert mock.mock_calls[0][1][3] is None + assert result.exit_code == 0 + assert result.output == 'Product PRD-000 exported successfully to PRD-000.xlsx\n' + + +def test_export_custom_file(config_mocker, mocker): + mock = mocker.patch( + 'cnctcli.commands.product.dump_product', + side_effect=lambda *args: '/tmp/my_product.xlsx', + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'product', + 'export', + 'PRD-000', + '-o', + '/tmp/my_product.xlsx' + ], + ) + mock.assert_called_once() + assert mock.mock_calls[0][1][2] == 'PRD-000' + assert mock.mock_calls[0][1][3] == '/tmp/my_product.xlsx' + assert result.exit_code == 0 + assert result.output == 'Product PRD-000 exported successfully to /tmp/my_product.xlsx\n' diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..3ef3a010 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import json + +import pytest + +from tests.data import CONFIG_DATA + + +@pytest.fixture() +def config_mocker(mocker): + mocker.patch('os.path.isfile', return_value=True) + return mocker.patch( + 'cnctcli.config.open', + mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), + ) diff --git a/tests/test_config.py b/tests/test_config.py index 4a12b584..09e2de75 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,16 +6,9 @@ from cnctcli.config import Config from cnctcli.constants import DEFAULT_ENDPOINT -from tests.data import CONFIG_DATA - -def test_load(mocker): +def test_load(config_mocker, mocker): config = Config() - mocker.patch( - 'cnctcli.config.open', - mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), - ) - mocker.patch('os.path.isfile', return_value=True) config.load('/tmp') assert config.active is not None assert config.active.id == 'VA-000' @@ -73,13 +66,8 @@ def test_add_account_custom_endpoint(): assert config.active.endpoint == 'https://my_custom_endpoint' -def test_activate(mocker): +def test_activate(config_mocker, mocker): config = Config() - mocker.patch( - 'cnctcli.config.open', - mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), - ) - mocker.patch('os.path.isfile', return_value=True) config.load('/tmp') assert config.active.id == 'VA-000' @@ -128,13 +116,8 @@ def test_remove_non_existent_account(): assert ex.value.message == 'The account identified by VA-999 does not exist.' -def test_remove_activate_other(mocker): +def test_remove_activate_other(config_mocker, mocker): config = Config() - mocker.patch( - 'cnctcli.config.open', - mocker.mock_open(read_data=json.dumps(CONFIG_DATA)), - ) - mocker.patch('os.path.isfile', return_value=True) config.load('/tmp') assert config.active.id == 'VA-000' @@ -144,10 +127,13 @@ def test_remove_activate_other(mocker): assert config.active.id == 'VA-001' -def test_config_is_valid(): +def test_config_validate(): config = Config() - assert config.is_valid() is False + with pytest.raises(click.ClickException) as ex: + config.validate() + + assert ex.value.message == 'connect-cli is not properly configured.' config.add_account( 'VA-000', @@ -156,4 +142,4 @@ def test_config_is_valid(): endpoint='https://my_custom_endpoint', ) - assert config.is_valid() is True + assert config.validate() is None From dbebbdd28fb31155e997f98b00ee5c0ea275d469 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 21 Sep 2020 22:19:26 +0200 Subject: [PATCH 03/10] fix build --- cnctcli/__init__.py | 2 +- cnctcli/actions/products.py | 2 +- setup.cfg | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cnctcli/__init__.py b/cnctcli/__init__.py index aa78f6bf..506669a0 100644 --- a/cnctcli/__init__.py +++ b/cnctcli/__init__.py @@ -3,7 +3,7 @@ try: __version__ = pkg_resources.require('connect-cli')[0].version -except: # pragma: no cover noqa: E722 +except: # noqa: E722 __version__ = '0.0.1' diff --git a/cnctcli/actions/products.py b/cnctcli/actions/products.py index 3cfcd08a..d081fe5f 100644 --- a/cnctcli/actions/products.py +++ b/cnctcli/actions/products.py @@ -63,7 +63,7 @@ def dump_product(api_url, api_key, product_id, output_file): try: items = products.items(product_id).search() except Exception as e: # noqa: E722 - raise click.ClickException(f'Cannot export product {product_id}: {str(e)}' ) + raise click.ClickException(f'Cannot export product {product_id}: {str(e)}') ws = wb.create_sheet(product_id) _setup_excel_sheet_header(ws) diff --git a/setup.cfg b/setup.cfg index 95afa653..66fab22b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,3 +13,7 @@ test = pytest [tool:pytest] junit_family = xunit2 addopts = -p no:cacheprovider --junitxml=coverage/out.xml --cov=cnctcli --cov-report xml:coverage/coverage.xml --cov-report term + +[coverage:run] +omit = + cnctcli/__init__.py From 980a51552f893c03d931902f314b5a2580b7f712 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 21 Sep 2020 22:27:54 +0200 Subject: [PATCH 04/10] fix copyright header --- cnctcli/__init__.py | 5 +++++ cnctcli/actions/accounts.py | 5 +++++ cnctcli/actions/products.py | 3 ++- cnctcli/ccli.py | 2 +- cnctcli/commands/account.py | 5 +++++ cnctcli/commands/product.py | 2 +- cnctcli/config.py | 2 +- cnctcli/constants.py | 5 +++++ 8 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cnctcli/__init__.py b/cnctcli/__init__.py index 506669a0..bdd30c70 100644 --- a/cnctcli/__init__.py +++ b/cnctcli/__init__.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + import pkg_resources diff --git a/cnctcli/actions/accounts.py b/cnctcli/actions/accounts.py index 30c44870..64d78581 100644 --- a/cnctcli/actions/accounts.py +++ b/cnctcli/actions/accounts.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + import click import requests diff --git a/cnctcli/actions/products.py b/cnctcli/actions/products.py index d081fe5f..c6df090e 100644 --- a/cnctcli/actions/products.py +++ b/cnctcli/actions/products.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -# This file is part of the Ingram Micro Cloud Blue Connect product-sync. +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + import os from zipfile import BadZipFile diff --git a/cnctcli/ccli.py b/cnctcli/ccli.py index 5122f9f4..15495518 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# This file is part of the Ingram Micro Cloud Blue Connect product-sync. +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. try: diff --git a/cnctcli/commands/account.py b/cnctcli/commands/account.py index c944bac3..b9a38b27 100644 --- a/cnctcli/commands/account.py +++ b/cnctcli/commands/account.py @@ -1,3 +1,8 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + import click from cnctcli.actions.accounts import ( diff --git a/cnctcli/commands/product.py b/cnctcli/commands/product.py index 8d4c917a..a4f49908 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# This file is part of the Ingram Micro Cloud Blue Connect product-sync. +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. import click diff --git a/cnctcli/config.py b/cnctcli/config.py index 1dff346b..eed107e5 100644 --- a/cnctcli/config.py +++ b/cnctcli/config.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# This file is part of the Ingram Micro Cloud Blue Connect product-sync. +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. # Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. import json diff --git a/cnctcli/constants.py b/cnctcli/constants.py index edf66d35..0def8dd3 100644 --- a/cnctcli/constants.py +++ b/cnctcli/constants.py @@ -1,2 +1,7 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + DEFAULT_ENDPOINT = 'https://api.connect.cloudblue.com/public/v1' DEFAULT_USER_AGENT = 'CloudBlue Connect CLI/' From 12ec61306fa985cdce90c02cda9ebe36d4772b10 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Tue, 22 Sep 2020 15:45:44 +0200 Subject: [PATCH 05/10] fix sync product -> single product --- cnctcli/actions/products.py | 261 -------------------------- cnctcli/actions/products/__init__.py | 7 + cnctcli/actions/products/constants.py | 15 ++ cnctcli/actions/products/export.py | 116 ++++++++++++ cnctcli/actions/products/sync.py | 122 ++++++++++++ cnctcli/api/__init__.py | 0 cnctcli/api/products.py | 104 ++++++++++ cnctcli/api/utils.py | 43 +++++ cnctcli/ccli.py | 2 +- cnctcli/commands/product.py | 64 ++++++- tests/commands/test_account.py | 10 +- tests/commands/test_product.py | 5 +- 12 files changed, 470 insertions(+), 279 deletions(-) delete mode 100644 cnctcli/actions/products.py create mode 100644 cnctcli/actions/products/__init__.py create mode 100644 cnctcli/actions/products/constants.py create mode 100644 cnctcli/actions/products/export.py create mode 100644 cnctcli/actions/products/sync.py create mode 100644 cnctcli/api/__init__.py create mode 100644 cnctcli/api/products.py create mode 100644 cnctcli/api/utils.py diff --git a/cnctcli/actions/products.py b/cnctcli/actions/products.py deleted file mode 100644 index c6df090e..00000000 --- a/cnctcli/actions/products.py +++ /dev/null @@ -1,261 +0,0 @@ -# -*- coding: utf-8 -*- - -# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. -# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. - -import os -from zipfile import BadZipFile - -import click -from connect.config import Config -from connect.exceptions import ServerError -from connect.resources.product import ProductsResource -from openpyxl import Workbook, load_workbook -from openpyxl.styles import PatternFill -from openpyxl.styles.colors import Color -from openpyxl.utils import get_column_letter -from openpyxl.utils.exceptions import InvalidFileException -from tqdm import tqdm, trange - -_COLS_HEADERS = { - 'A': 'Name', - 'B': 'MPN', - 'C': 'Billing Period', - 'D': 'Reservation', - 'E': 'Description', - 'F': 'Yearly Commitment', - 'G': 'Unit', - 'H': 'Connect Item ID', - 'I': 'Error Code', - 'J': 'Error Message', -} - - -def _setup_excel_sheet_header(ws): - ws.sheet_properties.tabColor = '67389A' - color = Color('d3d3d3') - fill = PatternFill('solid', color) - cels = ws['A1': 'H1'] - for cel in cels[0]: - ws.column_dimensions[cel.column_letter].width = 25 - ws.column_dimensions[cel.column_letter].auto_size = True - cel.fill = fill - cel.value = _COLS_HEADERS[cel.column_letter] - - -def _check_skipped(skipped): - if skipped: - click.echo( - click.style('The following products have been skipped:', fg='yellow') - ) - for pinfo in skipped: - click.echo(f'\t{pinfo[0]}: {pinfo[1]}') - - -def dump_product(api_url, api_key, product_id, output_file): - if not output_file: - output_file = os.path.abspath( - os.path.join('.', f'{product_id}.xlsx'), - ) - config = Config(api_url=api_url, api_key=api_key) - products = ProductsResource(config) - wb = Workbook() - need_save = False - try: - items = products.items(product_id).search() - except Exception as e: # noqa: E722 - raise click.ClickException(f'Cannot export product {product_id}: {str(e)}') - - ws = wb.create_sheet(product_id) - _setup_excel_sheet_header(ws) - processing_items = tqdm(items, position=1, leave=None) - for row_idx, item in enumerate(processing_items, start=2): - processing_items.set_description('Processing item {}'.format(item.id)) - ws.cell(row_idx, 1, value=item.display_name) - ws.cell(row_idx, 2, value=item.mpn) - ws.cell(row_idx, 3, value=item.period) - ws.cell(row_idx, 4, value=item.type == 'reservation') - ws.cell(row_idx, 5, value=item.description) - commitment = item.commitment.count == 12 if item.commitment else False - ws.cell(row_idx, 6, value=commitment) - ws.cell(row_idx, 7, value=item.unit.unit) - ws.cell(row_idx, 8, value=item.id) - for i in range(1, 9): - ws.column_dimensions[get_column_letter(i)].auto_size = True - - if need_save: - wb.remove_sheet(wb.worksheets[0]) - wb.save(output_file) - - return output_file - - -def _validate_sheet(ws): - cels = ws['A1': 'H1'] - for cel in cels[0]: - if cel.value != _COLS_HEADERS[cel.column_letter]: - return _COLS_HEADERS[cel.column_letter] - - -def _report_exception(ws, row_idx, exc): - color = Color('d3d3d3') - fill = PatternFill('solid', color) - cels = ws['I1': 'J1'] - for cel in cels[0]: - ws.column_dimensions[cel.column_letter].width = 25 - ws.column_dimensions[cel.column_letter].auto_size = True - cel.fill = fill - cel.value = _COLS_HEADERS[cel.column_letter] - code = '-' - msg = str(exc) - if isinstance(exc, ServerError): - code = exc.error.error_code - msg = '\n'.join(exc.error.errors) - ws.cell(row_idx, 9, value=code) - ws.cell(row_idx, 10, value=msg) - return code, msg - - -def _get_product_item_by_id(items, item_id): - try: - return items.get(item_id) - except: # noqa: E722 - pass - - -def _get_product_item_by_mpn(items, mpn): - try: - results = items.search(filters={'mpn': mpn}) - return results[0] if results else None - except: # noqa: E722 - pass - - -def _create_product_item(items, data): - if data[3] is True: - # reservation - period = 'monthly' - if data[2].lower() == 'yearly': - period = 'yearly' - if data[2].lower() == 'onetime': - period = 'onetime' - item = { - 'name': data[0], - 'mpn': data[1], - 'description': data[4], - 'ui': {'visibility': True}, - 'commitment': { - 'count': 12 if data[5] is True else 1, - }, - 'unit': {'id': data[6]}, - 'type': 'reservation', - 'period': period, - } - else: - # PPU - item = { - 'name': data[0], - 'mpn': data[1], - 'description': data[4], - 'ui': {'visibility': True}, - 'unit': {'id': data[6]}, - 'type': 'ppu', - 'precision': 'decimal(2)', - } - - return items.create(item) - - -def sync_product(api_url, api_key, input_file): - skipped = [] - items_errors = [] - need_save = False - config = Config(api_url=api_url, api_key=api_key) - products = ProductsResource(config) - wb = None - try: - wb = load_workbook(input_file) - except InvalidFileException as ife: - click.echo( - click.style(str(ife), fg='red') - ) - return - except BadZipFile: - click.echo( - click.style(f'{input_file} is not a valid xlsx file.', fg='red') - ) - return - product_ids = wb.sheetnames - ids = tqdm(product_ids, position=0) - for product_id in ids: - ids.set_description('Syncing product {}'.format(product_id)) - try: - products.get(product_id) - except: # noqa: E722 - skipped.append( - ( - product_id, - f'The product "{product_id}" does not exist.', - ), - ) - continue - items = products.items(product_id) - ws = wb[product_id] - invalid_column = _validate_sheet(ws) - if invalid_column: - skipped.append( - ( - product_id, - f'The worksheet "{product_id}" does not have the column {invalid_column}.', - ), - ) - continue - - row_indexes = trange(2, ws.max_row + 1, position=1, leave=None) - for row_idx in row_indexes: - row_indexes.set_description('Processing row {}'.format(row_idx)) - data = [ws.cell(row_idx, col_idx).value for col_idx in range(1, 9)] - if data[7]: - item = _get_product_item_by_id(items, data[7]) - else: - item = _get_product_item_by_mpn(items, data[1]) - - if item: - row_indexes.set_description('Updating item {}'.format(item.mpn)) - try: - items.update( - item.id, - { - 'name': data[0], - 'mpn': data[1], - 'description': data[4], - 'ui': {'visibility': True}, - }, - ) - except ServerError as se: - code, msg = _report_exception(ws, row_idx, se) - items_errors.append( - f"\t{product_id}: mpn={data[1]}, code={code}, message={msg}" - ) - need_save = True - else: - try: - result = _create_product_item(items, data) - ws.cell(row_idx, 8, value=result.id) - except ServerError as se: - code, msg = _report_exception(ws, row_idx, se) - items_errors.append( - f"\t{product_id}: mpn={data[1]}, code={code}, message={msg}" - ) - need_save = True - if need_save: - wb.save(input_file) - - _check_skipped(skipped) - if items_errors: - click.echo( - click.style('\n\nThe following items have not been synced:', fg='yellow') - ) - - for i in items_errors: - click.echo(i) diff --git a/cnctcli/actions/products/__init__.py b/cnctcli/actions/products/__init__.py new file mode 100644 index 00000000..a35e35f7 --- /dev/null +++ b/cnctcli/actions/products/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +from cnctcli.actions.products.export import dump_product # noqa: F401 +from cnctcli.actions.products.sync import sync_product, validate_input_file # noqa: F401 diff --git a/cnctcli/actions/products/constants.py b/cnctcli/actions/products/constants.py new file mode 100644 index 00000000..3ce50920 --- /dev/null +++ b/cnctcli/actions/products/constants.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +ITEMS_COLS_HEADERS = { + 'A': 'Name', + 'B': 'MPN', + 'C': 'Billing Period', + 'D': 'Reservation', + 'E': 'Description', + 'F': 'Yearly Commitment', + 'G': 'Unit', + 'H': 'Connect Item ID', +} diff --git a/cnctcli/actions/products/export.py b/cnctcli/actions/products/export.py new file mode 100644 index 00000000..6e126611 --- /dev/null +++ b/cnctcli/actions/products/export.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +import os +from datetime import datetime + +from click import ClickException + +from openpyxl import Workbook +from openpyxl.styles import PatternFill, Font, Alignment +from openpyxl.styles.colors import Color, WHITE + +from tqdm import trange + +from cnctcli.actions.products.constants import ITEMS_COLS_HEADERS +from cnctcli.api.products import get_items, get_product + + +def _setup_cover_sheet(ws, product): + ws.title = 'product_info' + ws.column_dimensions['A'].width = 50 + ws.column_dimensions['B'].width = 50 + ws.merge_cells('A1:B1') + cell = ws['A1'] + cell.fill = PatternFill('solid', start_color=Color('1565C0')) + cell.font = Font(sz=24, color=WHITE) + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.value = 'Product information' + for i in range(3, 9): + ws[f'A{i}'].font = Font(sz=14) + ws[f'B{i}'].font = Font(sz=14, bold=True) + ws['A3'].value = 'Account ID' + ws['B3'].value = product['owner']['id'] + ws['A4'].value = 'Account Name' + ws['B4'].value = product['owner']['name'] + ws['A5'].value = 'Product ID' + ws['B5'].value = product['id'] + ws['A6'].value = 'Product Name' + ws['B6'].value = product['name'] + ws['A7'].value = 'Export datetime' + ws['B7'].value = datetime.now().isoformat() + + +def _setup_items_header(ws): + color = Color('d3d3d3') + fill = PatternFill('solid', color) + cels = ws['A1': 'H1'] + for cel in cels[0]: + ws.column_dimensions[cel.column_letter].width = 25 + ws.column_dimensions[cel.column_letter].auto_size = True + cel.fill = fill + cel.value = ITEMS_COLS_HEADERS[cel.column_letter] + + +def _fill_item_row(ws, row_idx, item): + ws.cell(row_idx, 1, value=item['display_name']) + ws.cell(row_idx, 2, value=item['mpn']) + ws.cell(row_idx, 3, value=item['period']) + ws.cell(row_idx, 4, value=item['type'] == 'reservation') + ws.cell(row_idx, 5, value=item['description']) + commitment = item['commitment']['count'] == 12 if item.get('commitment') else False + ws.cell(row_idx, 6, value=commitment) + ws.cell(row_idx, 7, value=item['unit']['unit']) + ws.cell(row_idx, 8, value=item['id']) + + +def _dump_items(ws, api_url, api_key, product_id): + _setup_items_header(ws) + + processed_items = 0 + row_idx = 2 + limit = 2 + offset = 0 + + count, items = get_items(api_url, api_key, product_id, limit, offset) + + if count == 0: + raise ClickException(f"The product {product_id} doesn't have items.") + + items = iter(items) + + progress = trange(0, count, position=0) + + while True: + try: + item = next(items) + progress.set_description(f"Processing item {item['id']}") + progress.update(1) + _fill_item_row(ws, row_idx, item) + processed_items += 1 + row_idx += 1 + except StopIteration: + if processed_items < count: + offset += limit + _, items = get_items(api_url, api_key, product_id, limit, offset) + items = iter(items) + continue + break + + +def dump_product(api_url, api_key, product_id, output_file): + if not output_file: + output_file = os.path.abspath( + os.path.join('.', f'{product_id}.xlsx'), + ) + + product = get_product(api_url, api_key, product_id) + wb = Workbook() + _setup_cover_sheet(wb.active, product) + + _dump_items(wb.create_sheet('product_items'), api_url, api_key, product_id) + wb.save(output_file) + + return output_file diff --git a/cnctcli/actions/products/sync.py b/cnctcli/actions/products/sync.py new file mode 100644 index 00000000..2da06f60 --- /dev/null +++ b/cnctcli/actions/products/sync.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. + +from zipfile import BadZipFile + +from click import ClickException + +from openpyxl import load_workbook +from openpyxl.utils.exceptions import InvalidFileException + +from tqdm import trange + +from cnctcli.actions.products.constants import ITEMS_COLS_HEADERS +from cnctcli.api.products import ( + create_item, + get_item, + get_item_by_mpn, + get_product, + update_item, +) + + +def _open_workbook(input_file): + try: + return load_workbook(input_file) + except InvalidFileException as ife: + raise ClickException(str(ife)) + except BadZipFile: + raise ClickException(f'{input_file} is not a valid xlsx file.') + + +def _validate_item_sheet(ws): + cels = ws['A1': 'H1'] + for cel in cels[0]: + if cel.value != ITEMS_COLS_HEADERS[cel.column_letter]: + raise ClickException( + f'Invalid input file: column {cel.column_letter} ' + f'must be {ITEMS_COLS_HEADERS[cel.column_letter]}' + ) + + +def _get_item_payload(data): + if data[3] is True: + # reservation + period = 'monthly' + if data[2].lower() == 'yearly': + period = 'yearly' + if data[2].lower() == 'onetime': + period = 'onetime' + return { + 'name': data[0], + 'mpn': data[1], + 'description': data[4], + 'ui': {'visibility': True}, + 'commitment': { + 'count': 12 if data[5] is True else 1, + }, + 'unit': {'id': data[6]}, + 'type': 'reservation', + 'period': period, + } + else: + # PPU + return { + 'name': data[0], + 'mpn': data[1], + 'description': data[4], + 'ui': {'visibility': True}, + 'unit': {'id': data[6]}, + 'type': 'ppu', + 'precision': 'decimal(2)', + } + + +def validate_input_file(api_url, api_key, input_file): + wb = _open_workbook(input_file) + if len(wb.sheetnames) != 2: + raise ClickException('Invalid input file: not enough sheets.') + product_id = wb.active['B5'].value + get_product(api_url, api_key, product_id) + + ws = wb[wb.sheetnames[1]] + _validate_item_sheet(ws) + + return product_id, wb + + +def sync_product(api_url, api_key, product_id, wb): + ws = wb[wb.sheetnames[1]] + row_indexes = trange(2, ws.max_row + 1, position=0) + for row_idx in row_indexes: + data = [ws.cell(row_idx, col_idx).value for col_idx in range(1, 9)] + row_indexes.set_description(f'Processing item {data[7] or data[1]}') + if data[7]: + item = get_item(api_url, api_key, product_id, data[7]) + elif data[1]: + item = get_item_by_mpn(api_url, api_key, product_id, data[1]) + else: + raise ClickException( + f'Invalid item at row {row_idx}: ' + 'one between MPN or Connect Item ID must be specified.' + ) + if item: + row_indexes.set_description(f"Updating item {item['id']}") + update_item( + api_url, + api_key, + product_id, + item['id'], + { + 'name': data[0], + 'mpn': data[1], + 'description': data[4], + 'ui': {'visibility': True}, + }, + ) + continue + row_indexes.set_description(f"Creating item {data[1]}") + item = create_item(api_url, api_key, product_id, _get_item_payload(data)) + ws.cell(row_idx, 8, value=item['id']) diff --git a/cnctcli/api/__init__.py b/cnctcli/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cnctcli/api/products.py b/cnctcli/api/products.py new file mode 100644 index 00000000..eb3e25d8 --- /dev/null +++ b/cnctcli/api/products.py @@ -0,0 +1,104 @@ +import click +import requests + + +from cnctcli.api.utils import ( + format_http_status, + get_headers, + handle_http_error, +) + + +def get_products(endpoint, api_key, query, limit, offset): + pass + + +def get_product(endpoint, api_key, product_id): + res = requests.get( + f'{endpoint}/products/{product_id}', + headers=get_headers(api_key), + ) + + if res.status_code == 200: + return res.json() + + status = format_http_status(res.status_code) + + if res.status_code == 404: + raise click.ClickException(f'{status}: Product {product_id} not found.') + + handle_http_error(res) + + +def get_items(endpoint, api_key, product_id, limit=0, offset=100): + + res = requests.get( + f'{endpoint}/products/{product_id}/items', + params={'limit': limit, 'offset': offset}, + headers=get_headers(api_key), + ) + + if res.status_code == 200: + header = res.headers['Content-Range'] + count = int(header.rsplit('/', 1)[-1]) + return count, res.json() + + status = format_http_status(res.status_code) + + if res.status_code == 404: + raise click.ClickException(f'{status}: Product {product_id} not found.') + + handle_http_error(res) + + +def get_item(endpoint, api_key, product_id, item_id): + res = requests.get( + f'{endpoint}/products/{product_id}/items/{item_id}', + headers=get_headers(api_key), + ) + + if res.status_code == 200: + return res.json() + + if res.status_code == 404: + return + + handle_http_error(res) + + +def get_item_by_mpn(endpoint, api_key, product_id, mpn): + res = requests.get( + f'{endpoint}/products/{product_id}/items?eq(mpn,{mpn})', + headers=get_headers(api_key), + ) + + if res.status_code == 200: + results = res.json() + return results[0] if results else None + + if res.status_code == 404: + return + + handle_http_error(res) + + +def create_item(endpoint, api_key, product_id, data): + res = requests.post( + f'{endpoint}/products/{product_id}/items', + headers=get_headers(api_key), + json=data, + ) + if res.status_code == 201: + return res.json() + + handle_http_error(res) + + +def update_item(endpoint, api_key, product_id, item_id, data): + res = requests.put( + f'{endpoint}/products/{product_id}/items/{item_id}', + headers=get_headers(api_key), + json=data, + ) + if res.status_code != 200: + handle_http_error(res) diff --git a/cnctcli/api/utils.py b/cnctcli/api/utils.py new file mode 100644 index 00000000..d6b3acff --- /dev/null +++ b/cnctcli/api/utils.py @@ -0,0 +1,43 @@ +import platform +from http import HTTPStatus + +import click + +from cnctcli import get_version + + +def _get_user_agent(): + version = get_version() + pimpl = platform.python_implementation() + pver = platform.python_version() + sysname = platform.system() + sysver = platform.release() + ua = f'connect-cli/{version} {pimpl}/{pver} {sysname}/{sysver}' + return {'User-Agent': ua} + + +def get_headers(api_key): + headers = {'Authorization': api_key} + headers.update(_get_user_agent()) + return headers + + +def format_http_status(status_code): + status = HTTPStatus(status_code) + description = status.name.replace('_', ' ').title() + return f'{status_code} - {description}' + + +def handle_http_error(res): + status = format_http_status(res.status_code) + + if res.status_code in (401, 403): + raise click.ClickException(f'{status}: please check your credentials.') + + if res.status_code == 400: + error_info = res.json() + code = error_info['error_code'] + message = ','.join(error_info['errors']) + raise click.ClickException(f'{status}: {code} - {message}') + + raise click.ClickException(f'{status}: Unexpected error.') diff --git a/cnctcli/ccli.py b/cnctcli/ccli.py index 15495518..c6e185c9 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -46,7 +46,7 @@ def main(): # pragma: no cover cli(prog_name='ccli', standalone_mode=False) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter except click.ClickException as ce: click.echo( - click.style(f'Error: {ce.message}', fg='red') + click.style(ce.message, fg='red') ) print('') diff --git a/cnctcli/commands/product.py b/cnctcli/commands/product.py index a4f49908..e6faee4f 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -5,7 +5,7 @@ import click -from cnctcli.actions.products import dump_product, sync_product +from cnctcli.actions.products import dump_product, sync_product, validate_input_file from cnctcli.config import pass_config @@ -30,6 +30,14 @@ def grp_product(): @pass_config def cmd_dump_products(config, product_id, output_file): config.validate() + acc_id = config.active.id + acc_name = config.active.name + click.echo( + click.style( + f'Current active account: {acc_id} - {acc_name}\n', + fg='blue', + ) + ) outfile = dump_product( config.active.endpoint, config.active.api_key, @@ -38,7 +46,7 @@ def cmd_dump_products(config, product_id, output_file): ) click.echo( click.style( - f'Product {product_id} exported successfully to {outfile}', + f'\nThe product {product_id} has been successfully exported to {outfile}.', fg='green', ) ) @@ -49,14 +57,50 @@ def cmd_dump_products(config, product_id, output_file): short_help='sync a product from an excel file', ) +@click.argument('input_file', metavar='input_file', nargs=1, required=True) # noqa: E304 @click.option( # noqa: E304 - '--in', - '-i', - 'input_file', - required=True, - type=click.Path(exists=True, file_okay=True, dir_okay=False), - help='Input Excel file for product synchronization.' + '--yes', + '-y', + 'yes', + is_flag=True, + help='Answer yes to all questions.' ) @pass_config -def cmd_sync_products(config, input_file): - sync_product(config.active.endpoint, config.active.api_key, input_file) +def cmd_sync_products(config, input_file, yes): + config.validate() + acc_id = config.active.id + acc_name = config.active.name + click.echo( + click.style( + f'Current active account: {acc_id} - {acc_name}\n', + fg='blue', + ) + ) + product_id, wb = validate_input_file( + config.active.endpoint, + config.active.api_key, + input_file, + ) + + if not yes: + click.confirm( + 'Are you sure you want to synchronize ' + f'the items for the product {product_id} ?', + abort=True, + ) + click.echo('') + sync_product( + config.active.endpoint, + config.active.api_key, + product_id, + wb, + ) + + wb.save(input_file) + + click.echo( + click.style( + f'\nThe product {product_id} has been successfully synchronized.', + fg='green', + ) + ) diff --git a/tests/commands/test_account.py b/tests/commands/test_account.py index a9f3131d..c2026823 100644 --- a/tests/commands/test_account.py +++ b/tests/commands/test_account.py @@ -44,7 +44,7 @@ def test_add_account_custom_endpoint(mocker): assert result.exit_code == 0 assert mock.mock_calls[0][1][1] == 'ApiKey XXX:YYY' assert mock.mock_calls[0][1][2] == 'https://custom_endpoint' - assert result.output == 'New account added: VA-000 - Account 0\n' + assert 'New account added: VA-000 - Account 0\n' in result.output def test_remove_account(mocker): @@ -64,7 +64,7 @@ def test_remove_account(mocker): assert result.exit_code == 0 assert mock.mock_calls[0][1][1] == 'VA-000' - assert result.output == 'Account removed: VA-000 - Account 0\n' + assert 'Account removed: VA-000 - Account 0\n' in result.output def test_activate_account(mocker): @@ -84,7 +84,7 @@ def test_activate_account(mocker): assert result.exit_code == 0 assert mock.mock_calls[0][1][1] == 'VA-000' - assert result.output == 'Current active account is: VA-000 - Account 0\n' + assert 'Current active account is: VA-000 - Account 0\n' in result.output def test_list_accounts(config_mocker, mocker): @@ -98,7 +98,7 @@ def test_list_accounts(config_mocker, mocker): ) assert result.exit_code == 0 - assert result.output == ( + assert ( 'VA-000 - Account 0 (active)\n' 'VA-001 - Account 1\n' - ) + ) in result.output diff --git a/tests/commands/test_product.py b/tests/commands/test_product.py index 22fddfbe..c5800e47 100644 --- a/tests/commands/test_product.py +++ b/tests/commands/test_product.py @@ -22,7 +22,7 @@ def test_export(config_mocker, mocker): assert mock.mock_calls[0][1][2] == 'PRD-000' assert mock.mock_calls[0][1][3] is None assert result.exit_code == 0 - assert result.output == 'Product PRD-000 exported successfully to PRD-000.xlsx\n' + assert 'The product PRD-000 has been successfully exported to PRD-000.xlsx.\n' in result.output def test_export_custom_file(config_mocker, mocker): @@ -46,4 +46,5 @@ def test_export_custom_file(config_mocker, mocker): assert mock.mock_calls[0][1][2] == 'PRD-000' assert mock.mock_calls[0][1][3] == '/tmp/my_product.xlsx' assert result.exit_code == 0 - assert result.output == 'Product PRD-000 exported successfully to /tmp/my_product.xlsx\n' + assert 'The product PRD-000 has been successfully exported to /tmp/my_product.xlsx.\n' \ + in result.output From 0d1cd55ee0b836f76e2cf1174fe8893fae553ed8 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Tue, 22 Sep 2020 17:50:24 +0200 Subject: [PATCH 06/10] improve coverage push coverage info to codecov.io --- .travis.yml | 2 + cnctcli/api/products.py | 2 +- cnctcli/api/utils.py | 2 +- cnctcli/ccli.py | 5 +- tests/api/helpers.py | 5 + tests/api/test_products.py | 314 +++++++++++++++++++++++++++++++++++++ tests/api/test_utils.py | 67 ++++++++ 7 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tests/api/helpers.py create mode 100644 tests/api/test_products.py create mode 100644 tests/api/test_utils.py diff --git a/.travis.yml b/.travis.yml index 6f919efe..0312aa43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,6 +28,8 @@ script: - flake8 - pytest - ./package.sh +after_success: + - bash <(curl -s https://codecov.io/bash) deploy: - provider: pypi skip_cleanup: true diff --git a/cnctcli/api/products.py b/cnctcli/api/products.py index eb3e25d8..e2b8a499 100644 --- a/cnctcli/api/products.py +++ b/cnctcli/api/products.py @@ -30,7 +30,7 @@ def get_product(endpoint, api_key, product_id): handle_http_error(res) -def get_items(endpoint, api_key, product_id, limit=0, offset=100): +def get_items(endpoint, api_key, product_id, limit=100, offset=0): res = requests.get( f'{endpoint}/products/{product_id}/items', diff --git a/cnctcli/api/utils.py b/cnctcli/api/utils.py index d6b3acff..32b4241e 100644 --- a/cnctcli/api/utils.py +++ b/cnctcli/api/utils.py @@ -40,4 +40,4 @@ def handle_http_error(res): message = ','.join(error_info['errors']) raise click.ClickException(f'{status}: {code} - {message}') - raise click.ClickException(f'{status}: Unexpected error.') + raise click.ClickException(f'{status}: unexpected error.') diff --git a/cnctcli/ccli.py b/cnctcli/ccli.py index c6e185c9..022cbddd 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -46,8 +46,11 @@ def main(): # pragma: no cover cli(prog_name='ccli', standalone_mode=False) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter except click.ClickException as ce: click.echo( - click.style(ce.message, fg='red') + click.style(str(ce), fg='red') ) + except click.exceptions.Abort: + pass + print('') diff --git a/tests/api/helpers.py b/tests/api/helpers.py new file mode 100644 index 00000000..82bb58be --- /dev/null +++ b/tests/api/helpers.py @@ -0,0 +1,5 @@ +def assert_request_headers(headers): + assert 'Authorization' in headers + assert headers['Authorization'] == 'ApiKey XXXX:YYYY' + assert 'User-Agent' in headers + assert headers['User-Agent'].startswith('connect-cli/') \ No newline at end of file diff --git a/tests/api/test_products.py b/tests/api/test_products.py new file mode 100644 index 00000000..f553c2ad --- /dev/null +++ b/tests/api/test_products.py @@ -0,0 +1,314 @@ +import pytest +from click import ClickException + +from cnctcli.api.products import ( + create_item, get_item, get_item_by_mpn, + get_items, get_product, update_item, +) + +from tests.api.helpers import assert_request_headers + + +def test_get_product(requests_mock): + product_data = { + 'id': 'PRD-000', + 'name': 'Test product', + } + mocked = requests_mock.get( + 'https://localhost/public/v1/products/PRD-000', + json=product_data, + ) + + p = get_product( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + ) + + assert p == product_data + assert mocked.call_count == 1 + assert_request_headers(mocked.request_history[0].headers) + + +def test_get_product_not_found(requests_mock): + requests_mock.get( + 'https://localhost/public/v1/products/PRD-000', + status_code=404, + ) + + with pytest.raises(ClickException) as e: + get_product( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + ) + + assert str(e.value) == '404 - Not Found: Product PRD-000 not found.' + + +def test_get_product_other_errors(requests_mock): + requests_mock.get( + 'https://localhost/public/v1/products/PRD-000', + status_code=500, + ) + + with pytest.raises(ClickException) as e: + get_product( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + ) + + assert str(e.value) == '500 - Internal Server Error: unexpected error.' + + +def test_get_items(requests_mock): + items_data = [ + { + 'id': 'PRD-000-0000', + 'name': 'Item 0', + }, + { + 'id': 'PRD-000-0001', + 'name': 'Item 1', + }, + ] + mocked = requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items', + json=items_data, + headers={'Content-Range': 'items 0-99/100'}, + ) + + count, items = get_items( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + ) + + assert count == 100 + assert items == items_data + assert mocked.call_count == 1 + params = mocked.request_history[0].qs + assert 'limit' in params + assert params['limit'][0] == '100' + assert 'offset' in params + assert params['offset'][0] == '0' + assert_request_headers(mocked.request_history[0].headers) + + +def test_get_items_product_not_found(requests_mock): + requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items', + status_code=404, + ) + + with pytest.raises(ClickException) as e: + get_items( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + ) + + assert str(e.value) == '404 - Not Found: Product PRD-000 not found.' + + +def test_get_items_other_errors(requests_mock): + requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items', + status_code=500, + ) + + with pytest.raises(ClickException) as e: + get_items( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + ) + + assert str(e.value) == '500 - Internal Server Error: unexpected error.' + + +def test_get_item(requests_mock): + item_data = { + 'id': 'PRD-000-0000', + 'mpn': 'mpn_001', + } + mocked = requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items/PRD-000-0000', + json=item_data, + ) + + i = get_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'PRD-000-0000', + ) + + assert i == item_data + assert mocked.call_count == 1 + assert_request_headers(mocked.request_history[0].headers) + + +def test_get_item_not_found(requests_mock): + mocked = requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items/PRD-000-0000', + status_code=404, + ) + + i = get_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'PRD-000-0000', + ) + assert mocked.call_count == 1 + assert i is None + + +def test_get_item_other_errors(requests_mock): + requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items/PRD-000-0000', + status_code=500, + ) + + with pytest.raises(ClickException) as e: + get_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'PRD-000-0000', + ) + + assert str(e.value) == '500 - Internal Server Error: unexpected error.' + + +def test_get_item_by_mpn(requests_mock): + item_data = [ + { + 'id': 'PRD-000-0000', + 'mpn': 'mpn_001', + }, + ] + mocked = requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items?eq(mpn,mpn_001)', + json=item_data, + ) + + i = get_item_by_mpn( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'mpn_001', + ) + + assert i == item_data[0] + assert mocked.call_count == 1 + assert_request_headers(mocked.request_history[0].headers) + + +def test_get_item_by_mpn_not_found(requests_mock): + mocked = requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items?eq(mpn,mpn_001)', + status_code=404, + ) + + i = get_item_by_mpn( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'mpn_001', + ) + assert mocked.call_count == 1 + assert i is None + + +def test_get_item_by_mpn_other_errors(requests_mock): + requests_mock.get( + 'https://localhost/public/v1/products/PRD-000/items?eq(mpn,mpn_001)', + status_code=500, + ) + + with pytest.raises(ClickException) as e: + get_item_by_mpn( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'mpn_001', + ) + + assert str(e.value) == '500 - Internal Server Error: unexpected error.' + + +def test_create_item(requests_mock): + mocked = requests_mock.post( + 'https://localhost/public/v1/products/PRD-000/items', + status_code=201, + json={'id': 'PRD-000-0000'}, + ) + + i = create_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + {'mpn': 'mpn_001'}, + ) + + assert i == {'id': 'PRD-000-0000'} + assert mocked.call_count == 1 + assert mocked.request_history[0].json() == {'mpn': 'mpn_001'} + assert_request_headers(mocked.request_history[0].headers) + + +def test_create_item_errors(requests_mock): + requests_mock.post( + 'https://localhost/public/v1/products/PRD-000/items', + status_code=500, + ) + + with pytest.raises(ClickException) as e: + create_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + {'mpn': 'mpn_001'}, + ) + + assert str(e.value) == '500 - Internal Server Error: unexpected error.' + + +def test_update_item(requests_mock): + mocked = requests_mock.put( + 'https://localhost/public/v1/products/PRD-000/items/PRD-000-0000', + status_code=200, + ) + + update_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'PRD-000-0000', + {'mpn': 'mpn_001'}, + ) + + assert mocked.call_count == 1 + assert mocked.request_history[0].json() == {'mpn': 'mpn_001'} + assert_request_headers(mocked.request_history[0].headers) + + +def test_update_item_errors(requests_mock): + requests_mock.put( + 'https://localhost/public/v1/products/PRD-000/items/PRD-000-0000', + status_code=500, + ) + + with pytest.raises(ClickException) as e: + update_item( + 'https://localhost/public/v1', + 'ApiKey XXXX:YYYY', + 'PRD-000', + 'PRD-000-0000', + {'mpn': 'mpn_001'}, + ) + + assert str(e.value) == '500 - Internal Server Error: unexpected error.' diff --git a/tests/api/test_utils.py b/tests/api/test_utils.py new file mode 100644 index 00000000..93326e34 --- /dev/null +++ b/tests/api/test_utils.py @@ -0,0 +1,67 @@ +import platform + +import pytest + +from click import ClickException + +from cnctcli import get_version +from cnctcli.api.utils import ( + format_http_status, + get_headers, + handle_http_error, +) + + +def test_get_headers(): + headers = get_headers('MY API KEY') + + assert 'Authorization' in headers + assert headers['Authorization'] == 'MY API KEY' + assert 'User-Agent' in headers + + ua = headers['User-Agent'] + + cli, python, system = ua.split() + + assert cli == f'connect-cli/{get_version()}' + assert python == f'{platform.python_implementation()}/{platform.python_version()}' + assert system == f'{platform.system()}/{platform.release()}' + + +def test_format_http_status(): + assert format_http_status(401) == '401 - Unauthorized' + assert format_http_status(404) == '404 - Not Found' + assert format_http_status(500) == '500 - Internal Server Error' + + with pytest.raises(Exception): + format_http_status(1) + + +def test_handle_http_error_400(mocker): + res = mocker.MagicMock() + res.status_code = 400 + res.json = lambda: {'error_code': 'SYS-000', 'errors': ['error1', 'error2']} + + with pytest.raises(ClickException) as e: + handle_http_error(res) + + assert str(e.value) == '400 - Bad Request: SYS-000 - error1,error2' + + +@pytest.mark.parametrize( + ('code', 'description', 'message'), + ( + (401, 'Unauthorized', 'please check your credentials.'), + (403, 'Forbidden', 'please check your credentials.'), + (500, 'Internal Server Error', 'unexpected error.'), + (502, 'Bad Gateway', 'unexpected error.'), + ) +) +def test_handle_http_error_others(mocker, code, description, message): + res = mocker.MagicMock() + res.status_code = code + + with pytest.raises(ClickException) as e: + handle_http_error(res) + + assert str(e.value) == f'{code} - {description}: {message}' From 4618da48a6df62d8141b6ecfb1770f31756c07ba Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 28 Sep 2020 13:21:06 +0200 Subject: [PATCH 07/10] add list product command update readme --- README.md | 80 +++++++++++++++++++++++++++++++------ cnctcli/api/products.py | 13 +++++- cnctcli/commands/product.py | 54 +++++++++++++++++++++++++ cnctcli/commands/utils.py | 13 ++++++ 4 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 cnctcli/commands/utils.py diff --git a/README.md b/README.md index 94f494e3..3be65c7e 100644 --- a/README.md +++ b/README.md @@ -44,22 +44,22 @@ The preferred way to install `connect-cli` is using a [virtualenv](https://virtu ### Binary distributions -A single executable binary distribution is available for both linux and mac osx (amd64). +A single executable binary distribution is available for windows, linux and mac osx (amd64). You can it from the [Github Releases](https://github.com/cloudblue/connect-cli/releases) page. To install under linux: ``` - $ curl -O -J https://github.com/cloudblue/connect-cli/releases/download/1.2/connect-cli_1.2_linux_amd64.tar.gz - $ tar xvfz connect-cli_1.2_linux_amd64.tar.gz + $ curl -O -J https://github.com/cloudblue/connect-cli/releases/download/21.0/connect-cli_21.0_linux_amd64.tar.gz + $ tar xvfz connect-cli_21.0_linux_amd64.tar.gz $ sudo cp dist/ccli /usr/local/bin/ccli ``` To install under Mac OSX: ``` - $ curl -O -J https://github.com/cloudblue/connect-cli/releases/download/1.2/connect-cli_1.2_osx_amd64.tar.gz - $ tar xvfz connect-cli_1.2_linux_amd64.tar.gz + $ curl -O -J https://github.com/cloudblue/connect-cli/releases/download/21.0/connect-cli_21.0_osx_amd64.tar.gz + $ tar xvfz connect-cli_21.0_osx_amd64.tar.gz $ sudo cp dist/ccli /usr/local/bin/ccli ``` @@ -67,34 +67,88 @@ To install under Mac OSX: > that is listed in the `PATH` variable. +To install under Windows + +Download the windows single executable zipfile from [Github Releases](https://github.com/cloudblue/connect-cli/releases/download/21.0/connect-cli_21.0_windows_amd64.tar.gz), extract it and place it in a folder that is included in your `path` system variable. + + ## Usage -### Configure +### Add a new account + +First of all you need to add an account the `connect-cli` with the CloudBlue Connect API *key*. + +``` + $ ccli account add "ApiKey XXXXX:YYYYY" +``` + +### List configured accounts + +To get a list of all configured account run: + +``` + $ ccli account list +``` + + +### Set the current active account + +To set the current active account run: + +``` + $ ccli account activate VA-000-000 +``` + +### Remove an account + +To remove an account run: + +``` + $ ccli account remove VA-000-000 +``` + +### List available products -First of all you need to configure the `connect-cli` with the CloudBlue Connect API *endpoint* and *key*. +To get a list of available products run: ``` - $ ccli configure --url https://api.connect.cloudblue.com/public/v1 --key "ApiKey XXXXX:YYYYY" + $ ccli product list ``` -### Dump products to Excel +This command will output a list of all products (id and name) available within the current active account. +You can also filter the results by adding the ``--query`` flag followed by a RQL query. +For more information about RQL see the [Resource Query Language](https://connect.cloudblue.com/community/api/rql/) +article in the Connect community documentation portal. -To dump products to Excel run: + +### Export a product to Excel + +To export a product to Excel run: ``` - $ ccli product dump PRD-000-000-000 PRD-000-000-001 PRD-000-000-002 --out my_products.xlsx + $ ccli product export PRD-000-000-000 ``` +This command will generate a excel file named PRD-000-000-000.xlsx in the current working directory. -### Synchronize products -To sync products from Excel run: +### Synchronize a product from Excel + +To synchronize a product from Excel run: ``` $ ccli product sync --in my_products.xlsx ``` +### Getting help + +To get help about the `connect-cli` commands type: + +``` + $ ccli --help +``` + ## License `connect-cli` is released under the [Apache License Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/cnctcli/api/products.py b/cnctcli/api/products.py index e2b8a499..f772a728 100644 --- a/cnctcli/api/products.py +++ b/cnctcli/api/products.py @@ -10,7 +10,18 @@ def get_products(endpoint, api_key, query, limit, offset): - pass + url = f'{endpoint}/products' + if query: + url = f'{url}?{query}' + res = requests.get( + f'{endpoint}/products', + params={'limit': limit, 'offset': offset}, + headers=get_headers(api_key), + ) + if res.status_code == 200: + return res.json() + + handle_http_error(res) def get_product(endpoint, api_key, product_id): diff --git a/cnctcli/commands/product.py b/cnctcli/commands/product.py index e6faee4f..fa7a49e9 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -6,6 +6,8 @@ import click from cnctcli.actions.products import dump_product, sync_product, validate_input_file +from cnctcli.api.products import get_products +from cnctcli.commands.utils import continue_or_quit from cnctcli.config import pass_config @@ -14,6 +16,58 @@ def grp_product(): pass # pragma: no cover +@grp_product.command( + name='list', + short_help='list products', +) +@click.option( + '--query', + '-q', + 'query', + help='RQL query expression', +) +@click.option( + '--page-size', + '-p', + 'page_size', + type=int, + help='Number of products per page', + default=25, +) +@pass_config +def cmd_list_products(config, query, page_size): + acc_id = config.active.id + acc_name = config.active.name + click.echo( + click.style( + f'Current active account: {acc_id} - {acc_name}\n', + fg='blue', + ) + ) + offset = 0 + has_more = True + while has_more: + products = get_products( + config.active.endpoint, + config.active.api_key, + query, + page_size, + offset, + ) + if not products: + break + + for prod in products: + click.echo( + f"{prod['id']} - {prod['name']}" + ) + if not continue_or_quit(): + return + + has_more = len(products) == page_size + offset += page_size + + @grp_product.command( name='export', short_help='export a product to an excel file', diff --git a/cnctcli/commands/utils.py b/cnctcli/commands/utils.py new file mode 100644 index 00000000..13272c3c --- /dev/null +++ b/cnctcli/commands/utils.py @@ -0,0 +1,13 @@ +import click + + +def continue_or_quit(): + while True: + click.echo('') + click.echo("Press 'c' to continue or 'q' to quit ", nl=False) + c = click.getchar() + click.echo() + if c == 'c': + return True + if c == 'q': + return False From 8535043af7e728918fa0107cffb3f6bd1067da58 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 28 Sep 2020 13:22:32 +0200 Subject: [PATCH 08/10] clean dependencies --- requirements/dev.txt | 1 - resources/ccli.spec | 4 +--- resources/config.json | 27 --------------------------- 3 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 resources/config.json diff --git a/requirements/dev.txt b/requirements/dev.txt index 8e50e710..27e8f5b0 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,4 @@ click==7.1.2 openpyxl>=2.5.14 setuptools-scm==4.1.2 -connect-sdk>=20.3 tqdm==4.48.2 \ No newline at end of file diff --git a/resources/ccli.spec b/resources/ccli.spec index 95204671..677d009a 100644 --- a/resources/ccli.spec +++ b/resources/ccli.spec @@ -2,12 +2,10 @@ block_cipher = None -datas=[('./config.json', 'connect/logger')] - a = Analysis(['../cnctcli/ccli.py'], pathex=['/workspaces/product-sync'], binaries=[], - datas=datas, + datas=[], hiddenimports=[], hookspath=[], runtime_hooks=[], diff --git a/resources/config.json b/resources/config.json deleted file mode 100644 index 72c310c3..00000000 --- a/resources/config.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "logging": { - "version": 1, - "disable_existing_loggers": true, - "formatters": { - "single-line": { - "class": "logging.Formatter", - "datefmt": "%Y-%m-%d %H:%M:%S,uuu", - "format": "%(levelname)-6s; %(asctime)s; %(name)-6s; %(module)s:%(funcName)s:line-%(lineno)d: %(message)s" - } - }, - "handlers": { - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "single-line", - "stream": "ext://sys.stdout" - } - }, - "root": { - "handlers": [ - "console" - ], - "level": "ERROR" - } - } -} From a9f8967cd690aed21b80e5e5b487786edb3fd116 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 28 Sep 2020 13:28:53 +0200 Subject: [PATCH 09/10] add codecov badge to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3be65c7e..fc1396e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Connect Command Line Interface -![pyversions](https://img.shields.io/pypi/pyversions/connect-cli.svg) [![PyPi Status](https://img.shields.io/pypi/v/connect-cli.svg)](https://pypi.org/project/connect-cli/) [![Build Status](https://travis-ci.org/cloudblue/connect-cli.svg?branch=master)](https://travis-ci.org/cloudblue/connect-cli) +![pyversions](https://img.shields.io/pypi/pyversions/connect-cli.svg) [![PyPi Status](https://img.shields.io/pypi/v/connect-cli.svg)](https://pypi.org/project/connect-cli/) [![Build Status](https://travis-ci.org/cloudblue/connect-cli.svg?branch=master)](https://travis-ci.org/cloudblue/connect-cli) [![codecov](https://codecov.io/gh/cloudblue/django-cqrs/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudblue/django-cqrs) ## Introduction From 16400e11fa2827f69a778dd714eb21fa2d362ef7 Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Mon, 28 Sep 2020 13:29:35 +0200 Subject: [PATCH 10/10] fix codecov badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fc1396e8..96a3c7c6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Connect Command Line Interface -![pyversions](https://img.shields.io/pypi/pyversions/connect-cli.svg) [![PyPi Status](https://img.shields.io/pypi/v/connect-cli.svg)](https://pypi.org/project/connect-cli/) [![Build Status](https://travis-ci.org/cloudblue/connect-cli.svg?branch=master)](https://travis-ci.org/cloudblue/connect-cli) [![codecov](https://codecov.io/gh/cloudblue/django-cqrs/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudblue/django-cqrs) +![pyversions](https://img.shields.io/pypi/pyversions/connect-cli.svg) [![PyPi Status](https://img.shields.io/pypi/v/connect-cli.svg)](https://pypi.org/project/connect-cli/) [![Build Status](https://travis-ci.org/cloudblue/connect-cli.svg?branch=master)](https://travis-ci.org/cloudblue/connect-cli) [![codecov](https://codecov.io/gh/cloudblue/connect-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/cloudblue/connect-cli) ## Introduction