From 969416d0a6ab024274476b27570ccd201976170d Mon Sep 17 00:00:00 2001 From: Vladimir Grebenschikov Date: Thu, 17 Jun 2021 00:11:52 +0200 Subject: [PATCH] Inital commit of playground feature - support ccli play with options - fixed folder supported yet scripts/ --- connect/cli/core/base.py | 2 +- connect/cli/plugins/play/commands.py | 100 +++++++++++++++++++++++ connect/cli/plugins/play/context.py | 89 ++++++++++++++++++++ connect/cli/plugins/play/save.py | 8 ++ connect/cli/plugins/play/script.py | 49 +++++++++++ pyproject.toml | 1 + tests/plugins/play/context.json | 10 +++ tests/plugins/play/scripts/__init__.py | 0 tests/plugins/play/scripts/data.json | 1 + tests/plugins/play/scripts/script1.py | 33 ++++++++ tests/plugins/play/scripts/script2.py | 32 ++++++++ tests/plugins/play/test_context.py | 92 +++++++++++++++++++++ tests/plugins/play/test_play_commands.py | 31 +++++++ tests/plugins/play/test_script.py | 32 ++++++++ tests/plugins/play/updated.json | 10 +++ 15 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 connect/cli/plugins/play/commands.py create mode 100644 connect/cli/plugins/play/context.py create mode 100644 connect/cli/plugins/play/save.py create mode 100644 connect/cli/plugins/play/script.py create mode 100644 tests/plugins/play/context.json create mode 100644 tests/plugins/play/scripts/__init__.py create mode 100644 tests/plugins/play/scripts/data.json create mode 100644 tests/plugins/play/scripts/script1.py create mode 100644 tests/plugins/play/scripts/script2.py create mode 100644 tests/plugins/play/test_context.py create mode 100644 tests/plugins/play/test_play_commands.py create mode 100644 tests/plugins/play/test_script.py create mode 100644 tests/plugins/play/updated.json diff --git a/connect/cli/core/base.py b/connect/cli/core/base.py index ce1dc527..07612ea4 100644 --- a/connect/cli/core/base.py +++ b/connect/cli/core/base.py @@ -16,7 +16,7 @@ def print_version(ctx, param, value): ctx.exit() -@click.group() +@click.group(context_settings={'help_option_names': ['-h', '--help']}) @click.option( '--version', is_flag=True, diff --git a/connect/cli/plugins/play/commands.py b/connect/cli/plugins/play/commands.py new file mode 100644 index 00000000..e5d28cfe --- /dev/null +++ b/connect/cli/plugins/play/commands.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Ingram Micro Cloud Blue Connect connect-cli. +# Copyright (c) 2021 Ingram Micro. All Rights Reserved. +import os +import sys + +import click + +from connect.cli.core.config import pass_config +from connect.cli.plugins.play.context import Context + + +@click.group(name='play', short_help='Play connect scripts.') +def grp_play(): + pass + + +class PlayOptions: + context_file = 'context.json' + + +class PassArgumentDecorator: + def __init__(self, arg): + self.obj = arg + + def __call__(self, f): + def wrapped(*args, **kwargs): + f(self.obj, *args, **kwargs) + + return wrapped + + +pass_arg = PassArgumentDecorator + + +def setup_script_command(cls): + @pass_config + @pass_arg(cls) + def cmd_play_custom(script_class, config, **kwargs): + config.validate() + + Context.context_file_name = PlayOptions.context_file + ctx = Context.create(**kwargs) + + if 'endpoint' not in ctx or not ctx.endpoint: + ctx.endpoint = config.active.endpoint + + if 'distributor_account_token' not in ctx or not ctx.distributor_account_token: + ctx.distributor_account_token = config.active.api_key + + ctx | script_class() + ctx.save() + + options = cls.options() + for o in options: + cmd_play_custom = click.option(*o.args, **o.kwargs)(cmd_play_custom) + + grp_play.command(name=cls.command(), short_help=cls.help())(cmd_play_custom) + + +def load_one_script(scripts, filename): + modname = filename[0:-3] + + try: + mod = __import__(modname, globals={"__name__": __name__}, fromlist=['*']) + + if not hasattr(mod, '__all__'): + print(f'Warning: {filename} has no __all__ defined', file=sys.stderr) + return + + for cname in mod.__all__: + cls = getattr(mod, cname) + setup_script_command(cls) + + except Exception as e: + print(f'Failed to import {scripts}/{filename}: {e}') + + +def load_scripts_actions(): + scripts = os.environ.get('CCLI_SCRIPTS', 'scripts') + if scripts[0] != '/': + scripts = os.path.join(os.getcwd(), scripts) + + if os.path.isdir(scripts): + print(f'Reading scripts library from {scripts}') + sys.path.append(scripts) + + for filename in sorted(os.listdir(scripts)): + if not filename.endswith('.py') or filename[0] == '_': + continue + + load_one_script(scripts, filename) + + +load_scripts_actions() + + +def get_group(): + return grp_play diff --git a/connect/cli/plugins/play/context.py b/connect/cli/plugins/play/context.py new file mode 100644 index 00000000..3a889825 --- /dev/null +++ b/connect/cli/plugins/play/context.py @@ -0,0 +1,89 @@ +import inspect +import json +import sys + + +class Context(dict): + context_file_name = None + + @classmethod + def create_from_file(cls, filename=context_file_name): + ctx = cls() + try: + ctx.load(filename) + except FileNotFoundError: + pass + + return ctx + + @classmethod + def create(cls, args=None, filename=context_file_name, **kwargs): + ctx = cls() + try: + ctx.load(filename) + except FileNotFoundError: + pass + + if args: + ctx.parse_args(args) + + if kwargs: + for k, v in kwargs.items(): + if v is not None: + ctx[k] = v + + return ctx + + def parse_args(self, args): + for k, v in [a.split('=') for a in args]: + self[k] = v + + def load(self, filename=context_file_name): + if filename: + with open(filename) as f: + print(f'Loading context from {filename}', file=sys.stderr) + self.clear() + for k, v in json.load(f).items(): + self[k] = v + + def save(self, filename=context_file_name): + if filename: + with open(filename, 'w') as f: + print(f'Saving context into {filename}', file=sys.stderr) + json.dump(self, f, indent=4) + + def __str__(self): + return json.dumps(self, indent=4) + + def __getattr__(self, item): + if item in self: + return self[item] + + raise KeyError(item) + + def __setattr__(self, key, value): + self[key] = value + + def __ior__(self, kv): + key, value = kv + if isinstance(value, dict): + if key not in self: + self[key] = {} + self[key].update(value) + else: + if not isinstance(value, list): + value = [value] + + if key not in self: + self[key] = [] + + self[key].extend(value) + + return self + + def __or__(self, step): + if inspect.isclass(step): + step = step() + + step.do(context=self) + return self diff --git a/connect/cli/plugins/play/save.py b/connect/cli/plugins/play/save.py new file mode 100644 index 00000000..d2cf7aba --- /dev/null +++ b/connect/cli/plugins/play/save.py @@ -0,0 +1,8 @@ +from connect.cli.plugins.play.context import Context +from connect.cli.plugins.play.script import Script + + +class Save(Script): + def do(self, filename=Context.context_file_name, context=None): + super().do(context=context) + self.context.save(filename=filename) diff --git a/connect/cli/plugins/play/script.py b/connect/cli/plugins/play/script.py new file mode 100644 index 00000000..df9e3be9 --- /dev/null +++ b/connect/cli/plugins/play/script.py @@ -0,0 +1,49 @@ +import re +from typing import List + +from connect.cli.plugins.play.context import Context +from connect.client import ConnectClient + + +class OptionWrapper: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +class Script: + context: Context = None + endpoint: str = None + + def __init__(self, context=None, **kwargs): + self.context = context if context is not None else Context() + self.context.update(kwargs) + + @classmethod + def command(cls) -> str: + return str(re.sub(r'^([A-Z])', lambda x: x.group(1).lower(), + re.sub(r'([a-z])([A-Z])', lambda x: f'{x.group(1)}-{x.group(2).lower()}', cls.__name__))) + + @classmethod + def help(cls) -> str: + return cls.__doc__ + + @classmethod + def options(cls) -> List[OptionWrapper]: + return [] + + def client(self, token) -> ConnectClient: + return ConnectClient(token, endpoint=self.context.endpoint, use_specs=False) + + @property + def dclient(self) -> ConnectClient: + return self.client(self.context.distributor_account_token) + + @property + def vclient(self) -> ConnectClient: + return self.client(self.context.vendor_account_token) + + def do(self, context=None): + if context: + context.update(self.context) + self.context = context diff --git a/pyproject.toml b/pyproject.toml index e5963a06..fa5d6691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ ccli = 'connect.cli.ccli:main' "product" = "connect.cli.plugins.product.commands:get_group" "project" = "connect.cli.plugins.project.commands:get_group" "report" = "connect.cli.plugins.report.commands:get_group" +"play" = "connect.cli.plugins.play.commands:get_group" [tool.poetry.dependencies] diff --git a/tests/plugins/play/context.json b/tests/plugins/play/context.json new file mode 100644 index 00000000..cb459fbb --- /dev/null +++ b/tests/plugins/play/context.json @@ -0,0 +1,10 @@ +{ + "endpoint": "https://api.cnct.info/public/v1", + "program_agreement_id": "AGP-927-440-678", + "distribution_agreements": [ + "AGD-199-236-391", + "AGD-669-983-907" + ], + "program_contract_id": "CRP-41033-36725-76650", + "vendor_account_id": "VA-677-276" +} diff --git a/tests/plugins/play/scripts/__init__.py b/tests/plugins/play/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plugins/play/scripts/data.json b/tests/plugins/play/scripts/data.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/plugins/play/scripts/data.json @@ -0,0 +1 @@ +{} diff --git a/tests/plugins/play/scripts/script1.py b/tests/plugins/play/scripts/script1.py new file mode 100644 index 00000000..af656f60 --- /dev/null +++ b/tests/plugins/play/scripts/script1.py @@ -0,0 +1,33 @@ + +import random +import sys + +from connect.cli.plugins.play.context import Context +from connect.cli.plugins.play.script import OptionWrapper, Script +from connect.cli.plugins.play.save import Save + + +class Script1(Script): + """CLI help for Script1.""" + + @classmethod + def options(cls): + return [ + OptionWrapper('--some_id', help='Script1 IDs'), + OptionWrapper('--account_token', help='Script1 account token'), + ] + + def do(self, context=None): + super().do(context=context) + print('--- Init Script 1 ---') + + +__all__ = ('Script1',) + +if __name__ == '__main__': + try: + ctx = Context.create(sys.argv[1:]) + ctx | Script1 | Save + print(ctx) + except Exception as e: + print(e) diff --git a/tests/plugins/play/scripts/script2.py b/tests/plugins/play/scripts/script2.py new file mode 100644 index 00000000..2ec67fa5 --- /dev/null +++ b/tests/plugins/play/scripts/script2.py @@ -0,0 +1,32 @@ + +import random +import sys + +from connect.cli.plugins.play.context import Context +from connect.cli.plugins.play.script import OptionWrapper, Script +from connect.cli.plugins.play.save import Save + + +class Script2(Script): + """CLI help for Script2.""" + + @classmethod + def options(cls): + return [ + OptionWrapper('--account_token', help='Script2 account token'), + ] + + def do(self, context=None): + super().do(context=context) + print('--- Init Script 2 ---') + + +# __all__ = ('Script2',) # intentional mistake + +if __name__ == '__main__': + try: + ctx = Context.create(sys.argv[1:]) + ctx | Script2 | Save + print(ctx) + except Exception as e: + print(e) diff --git a/tests/plugins/play/test_context.py b/tests/plugins/play/test_context.py new file mode 100644 index 00000000..ae1bed79 --- /dev/null +++ b/tests/plugins/play/test_context.py @@ -0,0 +1,92 @@ +import pytest +import os + +from connect.cli.plugins.play.context import Context +from connect.cli.plugins.play.save import Save + + +def test_context(fs): + context_src = """{ + "endpoint": "https://api.cnct.info/public/v1", + "program_agreement_id": "AGP-927-440-678", + "distribution_agreements": [ + "AGD-199-236-391", + "AGD-669-983-907" + ], + "program_contract_id": "CRP-41033-36725-76650", + "vendor_account_id": "VA-677-276" +}""" + + dir = os.path.join(fs.root_path, 'play') + os.makedirs(dir, exist_ok=True) + + cfile = os.path.join(dir, 'context.json') + sfile = os.path.join(dir, 'updated.json') + nofile = os.path.join(dir, 'nonexistent.json') + + with open(cfile, 'w') as f: + print(context_src, file=f) + + ctx = Context.create_from_file(filename=cfile) + assert ctx.program_agreement_id == "AGP-927-440-678" + assert ctx.program_contract_id == 'CRP-41033-36725-76650' + assert len(ctx.distribution_agreements) == 2 + assert str(ctx) == context_src + + ctx = Context.create_from_file(filename=nofile) + assert len(ctx) == 0 + + ctx = Context.create(filename=cfile) + assert ctx.program_agreement_id == "AGP-927-440-678" + assert ctx.program_contract_id == 'CRP-41033-36725-76650' + assert len(ctx.distribution_agreements) == 2 + assert str(ctx) == context_src + + ctx = Context.create(filename=nofile) + assert len(ctx) == 0 + + ctx = Context.create(args=['program_agreement_id=AGP-xxx-xxx-xxx'], filename=cfile) + assert ctx.program_agreement_id == "AGP-xxx-xxx-xxx" + assert str(ctx) != context_src + + ctx = Context.create(program_agreement_id='AGP-yyy-yyy-yyy', filename=cfile) + assert ctx.program_agreement_id == "AGP-yyy-yyy-yyy" + assert str(ctx) != context_src + + ctx = Context.create(program_contract_id=None, filename=cfile) + assert ctx.program_contract_id == 'CRP-41033-36725-76650' + assert str(ctx) == context_src + + ctx = Context.create(filename=cfile) + ctx.program_agreement_id = "AGP-123-456-789" + ctx.save(filename=sfile) + + xtx = Context.create(filename=sfile) + assert str(xtx) == str(ctx) + + with pytest.raises(KeyError): + ctx = Context.create(filename=None) + type(ctx.nonexistent) + + ctx = Context.create(filename=cfile) + ctx |= ('distribution_agreements', 'AGD-999-999-999') + assert ctx.distribution_agreements == ['AGD-199-236-391', 'AGD-669-983-907', 'AGD-999-999-999'] + + ctx = Context.create(filename=cfile) + ctx |= ('distribution_agreements_x', 'AGD-999-999-999') + assert ctx.distribution_agreements_x == ['AGD-999-999-999'] + + ctx = Context.create(filename=cfile) + ctx |= ('distribution_agreements_x', ['AGD-999-999-999', 'AGD-999-999-000']) + assert ctx.distribution_agreements_x == ['AGD-999-999-999', 'AGD-999-999-000'] + + ctx = Context.create(filename=cfile) + ctx |= ('kdict', {'a': 'AGD-999-999-999'}) + assert ctx.kdict == {'a': 'AGD-999-999-999'} + ctx |= ('kdict', {'b': 'AGD-999-999-000'}) + assert ctx.kdict == {'a': 'AGD-999-999-999', 'b': 'AGD-999-999-000'} + + ctx = Context.create(filename=cfile) + Context.context_file_name = sfile + ctx | Save + ctx | Save() diff --git a/tests/plugins/play/test_play_commands.py b/tests/plugins/play/test_play_commands.py new file mode 100644 index 00000000..c294c7ca --- /dev/null +++ b/tests/plugins/play/test_play_commands.py @@ -0,0 +1,31 @@ +import os +import sys + + +def unimport(): + for m in ('connect.cli.plugins.play.commands', 'connect.cli.ccli'): + if m in sys.modules: + del sys.modules[m] + + +def test_play_commands(fs, mocker): + os.environ['CCLI_SCRIPTS'] = os.path.join(os.path.dirname(__file__), 'scripts') + + unimport() + from connect.cli.ccli import main + + mock_context = mocker.patch('connect.cli.plugins.play.commands.PlayOptions.context_file', None) + mock_args = mocker.patch('sys.argv', ['cmd', 'play', 'script1']) + main() + + +def test_play_commands_rel(fs, mocker): + + os.environ['CCLI_SCRIPTS'] = 'tests/plugins/play/scripts' + + unimport() + from connect.cli.ccli import main + + mock_context = mocker.patch('connect.cli.plugins.play.commands.PlayOptions.context_file', None) + mock_args = mocker.patch('sys.argv', ['cmd', 'play', 'script1']) + main() diff --git a/tests/plugins/play/test_script.py b/tests/plugins/play/test_script.py new file mode 100644 index 00000000..6bf314e6 --- /dev/null +++ b/tests/plugins/play/test_script.py @@ -0,0 +1,32 @@ + + +from connect.cli.plugins.play.script import OptionWrapper, Script +from connect.cli.plugins.play.context import Context +from connect.client import ConnectClient + + +def test_script(): + + ow = OptionWrapper(1, 2, 3, a=1, b=2, c=3) + assert ow.args == (1, 2, 3) + assert ow.kwargs == {'a': 1, 'b': 2, 'c': 3} + + + class BasicInitScript(Script): + """Some Help Message""" + + assert BasicInitScript.command() == 'basic-init-script' + assert BasicInitScript.help() == 'Some Help Message' + assert BasicInitScript.options() == [] + + ctx = Context() + ctx.endpoint = 'https://api.cnct.info/public/v1' + ctx.distributor_account_token = 'ApiKey v1' + ctx.vendor_account_token = 'ApiKey v2' + + s = BasicInitScript(context=ctx) + assert type(s.dclient) == ConnectClient + assert type(s.vclient) == ConnectClient + + s.do() + s.do(context=dict(endpoint='https://api.cnct.tech/public/v1')) diff --git a/tests/plugins/play/updated.json b/tests/plugins/play/updated.json new file mode 100644 index 00000000..8759117f --- /dev/null +++ b/tests/plugins/play/updated.json @@ -0,0 +1,10 @@ +{ + "endpoint": "https://api.cnct.info/public/v1", + "program_agreement_id": "AGP-123-456-789", + "distribution_agreements": [ + "AGD-199-236-391", + "AGD-669-983-907" + ], + "program_contract_id": "CRP-41033-36725-76650", + "vendor_account_id": "VA-677-276" +} \ No newline at end of file