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/.travis.yml b/.travis.yml index 84c24a76..0312aa43 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,10 +22,14 @@ 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 +after_success: + - bash <(curl -s https://codecov.io/bash) deploy: - provider: pypi skip_cleanup: true diff --git a/README.md b/README.md index 94f494e3..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) +![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 @@ -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/__init__.py b/cnctcli/__init__.py index 7bf33972..bdd30c70 100644 --- a/cnctcli/__init__.py +++ b/cnctcli/__init__.py @@ -1,8 +1,14 @@ -from setuptools_scm import get_version as scm_version +# -*- 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 + try: - __version__ = scm_version(root='..', relative_to=__file__) -except: # noqa: E722 + __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..64d78581 --- /dev/null +++ b/cnctcli/actions/accounts.py @@ -0,0 +1,44 @@ +# -*- 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 + + +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/actions/products.py b/cnctcli/actions/products.py deleted file mode 100644 index f6b24072..00000000 --- a/cnctcli/actions/products.py +++ /dev/null @@ -1,266 +0,0 @@ -# -*- coding: utf-8 -*- - -# This file is part of the Ingram Micro Cloud Blue Connect product-sync. -# Copyright (c) 2019-2020 Ingram Micro. All Rights Reserved. - -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_products(api_url, api_key, product_ids, output_file): - skipped = [] - 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 - - if need_save: - wb.remove_sheet(wb.worksheets[0]) - wb.save(output_file) - - _check_skipped(skipped) - - -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_products(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..f772a728 --- /dev/null +++ b/cnctcli/api/products.py @@ -0,0 +1,115 @@ +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): + 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): + 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=100, offset=0): + + 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..32b4241e --- /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 6e7a2b46..022cbddd 100644 --- a/cnctcli/ccli.py +++ b/cnctcli/ccli.py @@ -1,13 +1,13 @@ # -*- 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: import warnings from marshmallow.warnings import ChangedInMarshmallow3Warning warnings.filterwarnings('ignore', category=ChangedInMarshmallow3Warning) -except ImportError: +except ImportError: # pragma: no cover pass import os @@ -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,38 +36,23 @@ 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(): # pragma: no cover + 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(str(ce), fg='red') + ) + except click.exceptions.Abort: + pass + + print('') if __name__ == '__main__': - main() + main() # pragma: no cover diff --git a/cnctcli/commands/account.py b/cnctcli/commands/account.py new file mode 100644 index 00000000..b9a38b27 --- /dev/null +++ b/cnctcli/commands/account.py @@ -0,0 +1,89 @@ +# -*- 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 ( + 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 # pragma: no cover + + +@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..fa7a49e9 100644 --- a/cnctcli/commands/product.py +++ b/cnctcli/commands/product.py @@ -1,51 +1,160 @@ # -*- 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 -from cnctcli.actions.products import dump_products, sync_products +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 @click.group(name='product', short_help='commands related to product management') def grp_product(): - pass + pass # pragma: no cover @grp_product.command( - name='dump', - short_help='dump products to an excel file', + 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 -@click.argument('product_ids', metavar='product_id', nargs=-1, required=True) # noqa: E304 + +@grp_product.command( + name='export', + short_help='export a product to an excel file', +) + +@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): + 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, + product_id, + output_file, + ) + click.echo( + click.style( + f'\nThe product {product_id} has been successfully exported to {outfile}.', + fg='green', + ) + ) @grp_product.command( name='sync', - short_help='sync products from an excel 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_products(config.api_url, config.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/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 diff --git a/cnctcli/config.py b/cnctcli/config.py index 5530a2a8..eed107e5 100644 --- a/cnctcli/config.py +++ b/cnctcli/config.py @@ -1,35 +1,64 @@ # -*- 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 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,24 @@ 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 + 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/cnctcli/constants.py b/cnctcli/constants.py new file mode 100644 index 00000000..0def8dd3 --- /dev/null +++ b/cnctcli/constants.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. + +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..27e8f5b0 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,4 @@ click==7.1.2 openpyxl>=2.5.14 -setuptools-scm==3.5.0 -connect-sdk>=20.3 +setuptools-scm==4.1.2 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/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" - } - } -} diff --git a/setup.cfg b/setup.cfg index 27b2e82e..66fab22b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,9 @@ 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 + +[coverage:run] +omit = + cnctcli/__init__.py 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/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}' 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..c2026823 --- /dev/null +++ b/tests/commands/test_account.py @@ -0,0 +1,104 @@ +from click.testing import CliRunner + +from cnctcli.ccli import cli +from cnctcli.config import Account +from cnctcli.constants import DEFAULT_ENDPOINT + + +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 'New account added: VA-000 - Account 0\n' in result.output + + +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 'Account removed: VA-000 - Account 0\n' in result.output + + +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 'Current active account is: VA-000 - Account 0\n' in result.output + + +def test_list_accounts(config_mocker, mocker): + runner = CliRunner() + result = runner.invoke( + cli, + [ + 'account', + 'list', + ], + ) + + assert result.exit_code == 0 + 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 new file mode 100644 index 00000000..c5800e47 --- /dev/null +++ b/tests/commands/test_product.py @@ -0,0 +1,50 @@ +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 'The product PRD-000 has been successfully exported to PRD-000.xlsx.\n' in result.output + + +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 'The product PRD-000 has been successfully exported to /tmp/my_product.xlsx.\n' \ + in result.output 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/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..09e2de75 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,145 @@ +import json + +import click +import pytest + +from cnctcli.config import Config +from cnctcli.constants import DEFAULT_ENDPOINT + + +def test_load(config_mocker, mocker): + config = Config() + 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(config_mocker, mocker): + config = Config() + 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(config_mocker, mocker): + config = Config() + config.load('/tmp') + + assert config.active.id == 'VA-000' + + config.remove_account('VA-000') + + assert config.active.id == 'VA-001' + + +def test_config_validate(): + config = Config() + + with pytest.raises(click.ClickException) as ex: + config.validate() + + assert ex.value.message == 'connect-cli is not properly configured.' + + config.add_account( + 'VA-000', + 'Account 1', + 'ApiKey XXXX:YYYY', + endpoint='https://my_custom_endpoint', + ) + + assert config.validate() is None