From b1f6f9c74048c99c773a1b524c0d54d3747192af Mon Sep 17 00:00:00 2001 From: Bracey Summers <35816572+bsummers-tc@users.noreply.github.com> Date: Fri, 25 Aug 2023 12:34:46 -0500 Subject: [PATCH] Fix for App Builder and other updates (#14) * APP-3915 - [CONFIG] Added validation to ensure displayPath is always in the install.json for API Services * APP-4060 - [CLI] Updated proxy inputs to use environment variables * APP-4077 - [SPEC-TOOL] Updated spec-tool to create an example app_input.py file and to display a mismatch report * APP-4112 - [CONFIG] Updated config submodule (tcex.json model) to support legacy App Builder Apps * APP-4113 - [CONFIG] Updated App Spec model to normalize App features --- pyproject.toml | 3 +- release_notes.md | 20 ++ tcex_cli/__metadata__.py | 2 +- tcex_cli/app/config | 2 +- tcex_cli/cli/cli_abc.py | 25 ++ tcex_cli/cli/deploy/deploy.py | 6 + tcex_cli/cli/deploy/deploy_cli.py | 18 +- tcex_cli/cli/deps/deps.py | 9 +- tcex_cli/cli/deps/deps_cli.py | 26 +- tcex_cli/cli/package/package_cli.py | 9 +- tcex_cli/cli/run/launch_abc.py | 1 - tcex_cli/cli/run/run.py | 4 + tcex_cli/cli/spec_tool/gen_app_input.py | 242 ++++++------- .../cli/spec_tool/gen_app_input_static.py | 320 +++++++++++++----- tcex_cli/cli/spec_tool/gen_install_json.py | 2 +- tcex_cli/cli/spec_tool/spec_tool.py | 7 +- tcex_cli/cli/spec_tool/spec_tool_cli.py | 10 +- tcex_cli/cli/template/init.py | 6 + tcex_cli/cli/template/list_.py | 6 + tcex_cli/cli/template/template_cli.py | 23 +- tcex_cli/cli/template/update.py | 8 +- tcex_cli/render/render.py | 58 ++++ tcex_cli/util | 2 +- 23 files changed, 519 insertions(+), 290 deletions(-) create mode 100644 release_notes.md diff --git a/pyproject.toml b/pyproject.toml index 666c3f1..ca7c2cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "pydantic<2.0.0", "python-dateutil", "PyYAML", - "redis", + "redis<5.0.0", "requests", "rich", "semantic_version", @@ -121,6 +121,7 @@ disable = [ "too-many-arguments", "too-many-branches", "too-many-instance-attributes", + "too-many-lines", "too-many-locals", "too-many-public-methods", "too-many-return-statements", diff --git a/release_notes.md b/release_notes.md new file mode 100644 index 0000000..69233b1 --- /dev/null +++ b/release_notes.md @@ -0,0 +1,20 @@ +# Release Notes + +### 1.0.1 + +- APP-3915 - [CONFIG] Added validation to ensure displayPath is always in the install.json for API Services +- APP-4060 - [CLI] Updated proxy inputs to use environment variables +- APP-4077 - [SPEC-TOOL] Updated spec-tool to create an example app_input.py file and to display a mismatch report +- APP-4112 - [CONFIG] Updated config submodule (tcex.json model) to support legacy App Builder Apps +- APP-4113 - [CONFIG] Updated App Spec model to normalize App features + + +### 1.0.0 + +- APP-3926 - Split CLI module of TcEx into tcex-cli project +- APP-3912 - [CLI] Updated `tcex` command to use "project.scripts" setting in pyproject.toml +- APP-3913 - [DEPS] Updated `deps` command to build **"deps"** or **"lib_"** depending on TcEx version +- APP-3819 - [LIST] Updated the `list` to choose the appropriate template branch depending on TcEx version +- APP-3820 - [DEPS] Updated the `deps` to choose the appropriate template branch depending on TcEx version +- APP-4053 - [CLI] Updated CLI tools to work with changes in App Builder released in ThreatConnect version 7.2 +- APP-4059 - [CLI] Added proxy support to commands where applicable diff --git a/tcex_cli/__metadata__.py b/tcex_cli/__metadata__.py index cd0cdc7..3327b70 100644 --- a/tcex_cli/__metadata__.py +++ b/tcex_cli/__metadata__.py @@ -1,3 +1,3 @@ """TcEx Framework metadata.""" __license__ = 'Apache-2.0' -__version__ = '1.0.0' +__version__ = '1.0.1' diff --git a/tcex_cli/app/config b/tcex_cli/app/config index c719563..7c596ca 160000 --- a/tcex_cli/app/config +++ b/tcex_cli/app/config @@ -1 +1 @@ -Subproject commit c71956343abff9562060962e61670c2be6750f97 +Subproject commit 7c596ca553f816f3a63c83277054644615f06a3d diff --git a/tcex_cli/cli/cli_abc.py b/tcex_cli/cli/cli_abc.py index 2c6dad9..5f7063d 100644 --- a/tcex_cli/cli/cli_abc.py +++ b/tcex_cli/cli/cli_abc.py @@ -12,6 +12,7 @@ # first-party from tcex_cli.app.app import App +from tcex_cli.input.field_type.sensitive import Sensitive from tcex_cli.registry import registry from tcex_cli.util import Util @@ -40,6 +41,30 @@ def __init__(self): # register commands registry.add_service(App, self.app) + def _process_proxy_host(self, proxy_host: str | None) -> str | None: + """Process proxy host.""" + os_proxy_host = os.getenv('TC_PROXY_HOST') + return proxy_host if proxy_host else os_proxy_host + + def _process_proxy_pass(self, proxy_pass: Sensitive | str | None) -> Sensitive | None: + """Process proxy password.""" + os_proxy_pass = os.getenv('TC_PROXY_PASS') or os.getenv('TC_PROXY_PASSWORD') + proxy_pass = proxy_pass if proxy_pass else os_proxy_pass + if proxy_pass is not None and not isinstance(proxy_pass, Sensitive): + return Sensitive(proxy_pass) + return proxy_pass + + def _process_proxy_port(self, proxy_port: int | str | None) -> int | None: + """Process proxy port.""" + os_proxy_port = os.getenv('TC_PROXY_PORT') + port = proxy_port if proxy_port else os_proxy_port + return int(port) if port is not None else None + + def _process_proxy_user(self, proxy_user: str | None) -> str | None: + """Process proxy user.""" + os_proxy_user = os.getenv('TC_PROXY_USER') or os.getenv('TC_PROXY_USERNAME') + return proxy_user if proxy_user else os_proxy_user + @cached_property def app(self) -> App: """Return instance of App.""" diff --git a/tcex_cli/cli/deploy/deploy.py b/tcex_cli/cli/deploy/deploy.py index e7e2bbf..c1d2a25 100644 --- a/tcex_cli/cli/deploy/deploy.py +++ b/tcex_cli/cli/deploy/deploy.py @@ -38,6 +38,12 @@ def command( * TC_API_PATH * TC_API_ACCESS_ID * TC_API_SECRET_KEY + + Optional environment variables include:\n + * PROXY_HOST\n + * PROXY_PORT\n + * PROXY_USER\n + * PROXY_PASS\n """ cli = DeployCli( server, diff --git a/tcex_cli/cli/deploy/deploy_cli.py b/tcex_cli/cli/deploy/deploy_cli.py index 88c796a..7b344b6 100644 --- a/tcex_cli/cli/deploy/deploy_cli.py +++ b/tcex_cli/cli/deploy/deploy_cli.py @@ -31,16 +31,12 @@ def __init__( self._app_file = app_file self.allow_all_orgs = allow_all_orgs self.allow_distribution = allow_distribution - self.proxy_host = proxy_host - self.proxy_port = proxy_port - self.proxy_user = proxy_user + self.proxy_host = self._process_proxy_host(proxy_host) + self.proxy_port = self._process_proxy_port(proxy_port) + self.proxy_user = self._process_proxy_user(proxy_user) self.proxy_pass = self._process_proxy_pass(proxy_pass) self.server = server - def _process_proxy_pass(self, proxy_pass: str | None) -> Sensitive | None: - """Process proxy password.""" - return None if proxy_pass is None else Sensitive(proxy_pass) - def _check_file(self): """Return True if file exists.""" if self._app_file and not os.path.isfile(self._app_file): @@ -110,7 +106,13 @@ def deploy_app(self): ) else: - response_data = response.json()[0] + try: + response_data = response.json()[0] + except IndexError as err: + Render.panel.failure( + f'Unexpected response from ThreatConnect API. Failed to deploy App: {err}' + ) + Render.table.key_value( 'Successfully Deployed App', { diff --git a/tcex_cli/cli/deps/deps.py b/tcex_cli/cli/deps/deps.py index 1ee8e09..b7723a8 100644 --- a/tcex_cli/cli/deps/deps.py +++ b/tcex_cli/cli/deps/deps.py @@ -37,7 +37,14 @@ def command( proxy_user: StrOrNone = typer.Option(None, help='(Advanced) Username for the proxy server.'), proxy_pass: StrOrNone = typer.Option(None, help='(Advanced) Password for the proxy server.'), ): - """Install dependencies defined in the requirements.txt file.""" + r"""Install dependencies defined in the requirements.txt file. + + Optional environment variables include:\n + * PROXY_HOST\n + * PROXY_PORT\n + * PROXY_USER\n + * PROXY_PASS\n + """ cli = DepsCli( app_builder, branch, diff --git a/tcex_cli/cli/deps/deps_cli.py b/tcex_cli/cli/deps/deps_cli.py index d8e13a3..0b2e856 100644 --- a/tcex_cli/cli/deps/deps_cli.py +++ b/tcex_cli/cli/deps/deps_cli.py @@ -7,7 +7,7 @@ import sys from functools import cached_property from pathlib import Path -from urllib.parse import quote, urlsplit +from urllib.parse import quote # third-party from semantic_version import Version @@ -41,17 +41,10 @@ def __init__( self.branch = branch self.no_cache_dir = no_cache_dir self.pre = pre - self.proxy_host = proxy_host - self.proxy_port = proxy_port - self.proxy_user = proxy_user - self.proxy_pass = proxy_pass - - if not self.proxy_host and os.environ.get('https_proxy'): - parsed_proxy_url = urlsplit(os.environ.get('https_proxy')) - self.proxy_host = parsed_proxy_url.hostname - self.proxy_port = parsed_proxy_url.port - self.proxy_user = parsed_proxy_url.username - self.proxy_pass = parsed_proxy_url.password + self.proxy_host = self._process_proxy_host(proxy_host) + self.proxy_port = self._process_proxy_port(proxy_port) + self.proxy_user = self._process_proxy_user(proxy_user) + self.proxy_pass = self._process_proxy_pass(proxy_pass) # properties self.deps_dir_tests = self.app_path / 'deps_tests' @@ -124,13 +117,16 @@ def configure_proxy(self): if self.proxy_host is not None and self.proxy_port is not None: # proxy url without auth + proxy_pass_ = None + if self.proxy_pass is not None and hasattr(self.proxy_pass, 'value'): + proxy_pass_ = self.proxy_pass.value proxy_url = f'{self.proxy_host}:{self.proxy_port}' - if self.proxy_user is not None and self.proxy_pass is not None: + if self.proxy_user is not None and proxy_pass_ is not None: proxy_user = quote(self.proxy_user, safe='~') - proxy_pass = quote(self.proxy_pass, safe='~') + proxy_pass_ = quote(proxy_pass_, safe='~') # proxy url with auth - proxy_url = f'{proxy_user}:{proxy_pass}@{proxy_url}' + proxy_url = f'{proxy_user}:{proxy_pass_}@{proxy_url}' # update proxy properties self.proxy_enabled = True diff --git a/tcex_cli/cli/package/package_cli.py b/tcex_cli/cli/package/package_cli.py index 9ae4593..89d0f53 100644 --- a/tcex_cli/cli/package/package_cli.py +++ b/tcex_cli/cli/package/package_cli.py @@ -132,9 +132,12 @@ def package(self): # IMPORTANT: # The name of the folder in the zip is the *key* for an App. This # value must remain consistent for the App to upgrade successfully. - app_name_version = ( - f'{self.app.tj.model.package.app_name}_{self.app.ij.model.package_version}' - ) + # Normal behavior should be to use the major version with a "v" prefix. + # However, some older Apps got released with a non-standard version + # (e.g., v2.0). For these Apps the version can be overridden by defining + # the "package.app_version" field in the tcex.json file. + app_version = self.app.tj.model.package.app_version or self.app.ij.model.package_version + app_name_version = f'{self.app.tj.model.package.app_name}_{app_version}' # build app directory app_path_fqpn = self.build_fqpn / app_name_version diff --git a/tcex_cli/cli/run/launch_abc.py b/tcex_cli/cli/run/launch_abc.py index 33d2680..37346a0 100644 --- a/tcex_cli/cli/run/launch_abc.py +++ b/tcex_cli/cli/run/launch_abc.py @@ -167,7 +167,6 @@ def redis_client(self) -> redis.Redis: ) ) - # TODO: [bcs] fix model name :( @cached_property def session(self) -> TcSession: """Return requests Session object for TC admin account.""" diff --git a/tcex_cli/cli/run/run.py b/tcex_cli/cli/run/run.py index aa01ef5..a1a883b 100644 --- a/tcex_cli/cli/run/run.py +++ b/tcex_cli/cli/run/run.py @@ -24,6 +24,10 @@ def command( try: cli.update_system_path() + # validate config.json + if not config_json.is_file(): + Render.panel.failure(f'Config file not found [{config_json}]') + # run in debug mode if debug is True: cli.debug(debug_port) diff --git a/tcex_cli/cli/spec_tool/gen_app_input.py b/tcex_cli/cli/spec_tool/gen_app_input.py index e1cfb3c..f71c26d 100644 --- a/tcex_cli/cli/spec_tool/gen_app_input.py +++ b/tcex_cli/cli/spec_tool/gen_app_input.py @@ -5,7 +5,6 @@ import re # first-party -import tcex_cli.input.field_type as FieldTypes from tcex_cli.app.config.model.install_json_model import ParamsModel # TYPE-CHECKING from tcex_cli.cli.cli_abc import CliABC from tcex_cli.cli.spec_tool.gen_app_input_static import GenAppInputStatic @@ -27,7 +26,7 @@ def __init__(self): # properties self._app_inputs_data: dict | None = None self.class_model_map = {} - self.field_type_modules = set() + self.field_type_modules = self._get_current_field_types() self.filename = 'app_inputs.py' self.input_static = GenAppInputStatic() self.log = _logger @@ -72,7 +71,7 @@ def _code_app_inputs_data(self): base_class = 'AppBaseModel' class_comment = self.class_comment(class_name) if class_name in ['AppBaseModel', 'ServiceConfigModel']: - base_class = 'BaseModel' + base_class = self.input_static.app_base_model_class elif class_name == 'TriggerConfigModel': base_class = 'CreateConfigModel' @@ -145,7 +144,7 @@ def _extract_type_from_definition(self, input_name: str, type_definition: str) - """Extract the type from the type definition. string_allow_multiple: String | list[String] -> String | list[String] - string_intel_type: String | None -> str | None + string_intel_type: str | None -> str | None """ input_extract_pattern = ( # match beginning white space on line @@ -163,44 +162,6 @@ def _extract_type_from_definition(self, input_name: str, type_definition: str) - return current_type.group(1).strip() return None - def _extract_type_from_list(self, type_data: str) -> str: - """Extract type data from list[].""" - # extract the type from List - list_extract_pattern = r'^list\[(.*)\]' - extract_from_list = re.search(list_extract_pattern, type_data) - if extract_from_list is not None: - self.log.debug(f'action=extract-type-from-list, type-data={type_data}') - return extract_from_list.group(1) - return type_data - - def _extract_type_from_method(self, type_data: str) -> str: - """Extract type data from method (e.g. binary(min_length=2) -> binary).""" - remove_args_pattern = r'\([^\)]*\)' - type_data = re.sub(remove_args_pattern, '', type_data) - self.log.debug(f'action=remove-args-pattern, types-data={type_data}') - return type_data - - def _extract_type_from_optional(self, type_data: str) -> str: - """Extract type data from Optional[].""" - optional_extract_pattern = r'Optional\[?(.*)\]' - extracted_data = re.search(optional_extract_pattern, type_data) - if extracted_data is not None: - type_data = extracted_data.group(1) - self.log.debug(f'action=extract-type-from-optional, type-data={type_data}') - return extracted_data.group(1) - return type_data - - def _extract_type_from_union(self, type_data: str) -> str: - """Extract type data from Union[].""" - union_extract_pattern = r'Union\[(\((.+)\)|.+)\]' - extracted_data = re.search(union_extract_pattern, type_data) - if extracted_data is not None: - # return the last matched group that is not None - type_data = [g for g in extracted_data.groups() if g is not None][-1] - self.log.debug(f'action=extract-type-from-union, type-data={type_data}') - return type_data - return type_data - def _generate_app_inputs_to_action(self): """Generate App Input dict from install.json and layout.json.""" if self.app.ij.model.is_trigger_app is True: @@ -249,11 +210,49 @@ def _gen_tc_action_class_name(tc_action: str | None) -> str | None: tc_action = re.sub(r'[^a-zA-Z0-9]', '', tc_action) return tc_action + def _gen_type_compare( + self, calculated_type: str, current_type: str | None, input_name: str + ) -> str: + """Retrieve the current type data for the current input.""" + if current_type is not None and calculated_type != current_type: + self.report_mismatch.append( + {'input': input_name, 'calculated': calculated_type, 'current': current_type} + ) + self.log.warning( + f'input={input_name}, current-type={current_type}, ' + f'calculated-type={calculated_type}, using=current-type' + ) + return current_type + return calculated_type + def _gen_type(self, class_name: str, input_data: ParamsModel) -> str: """Determine the type value for the current input.""" + # calculate the field type for the current input + calculated_type, field_types = self._get_type_calculated(input_data) + current_type = self._get_type_current(class_name, input_data.name) + + # if the current type is not None, use the current type defined by the developer + type_ = self._gen_type_compare(calculated_type, current_type, input_data.name) + + # add field types for import + if type_ == calculated_type: + for field_type in field_types: + if field_type is not None: + # TODO: [low] should we define supported field types? + self.field_type_modules.add(field_type) + + # # only add the field type if it is a defined field type + # if hasattr(FieldTypes, field_type): + # self.field_type_modules.add(field_type) + + return type_ + + def _get_type_calculated(self, input_data: ParamsModel) -> tuple[str, list]: + """Calculate the type value for the current input.""" + # a list of field types that will be added to the import (e.g. DateTime, integer, String) field_types = [] - # get map lookup key + # calculate the lookup key for looking in GenAppInputStatic.type_map lookup_key = input_data.type required_key = 'optional' if input_data.required is False else 'required' if input_data.encrypt is True: @@ -263,6 +262,7 @@ def _gen_type(self, class_name: str, input_data: ParamsModel) -> str: standard_name_type = self._standard_field_to_type_map(input_data.name) # get the type value and field type + type_: str if standard_name_type is not None: type_ = standard_name_type['type'] field_types.append(standard_name_type.get('field_type')) @@ -283,105 +283,47 @@ def _gen_type(self, class_name: str, input_data: ParamsModel) -> str: except (AttributeError, KeyError) as ex: Render.panel.failure(f'Failed looking up type data for {input_data.type} ({ex}).') - # wrap type data in Optional for non-required inputs + # for non-required inputs, make optional if input_data.required is False and input_data.type not in ('Boolean'): - type_ = f'Optional[{type_}]' + type_ = f'{type_} | None' # append default if one exists - # if input_data.default is not None and input_data.type in ('Choice', 'String'): - # type_ += f' = \'{input_data.default}\'' if input_data.type == 'Boolean': type_ += f' = {input_data.default or False}' - # get the current value from the app_inputs.py file - current_type = self._get_current_type(class_name, input_data.name) - if current_type is not None and current_type != type_: - self.report_mismatch.append( - f'{input_data.name} -> calculated-type="{type_}" does ' - f'not match current-type="{current_type}"' - ) - self.log.warning( - f'input={input_data.name}, current-type={current_type}, ' - f'calculated-type={type_}, using=current-type' - ) - field_types = self._get_current_field_types(current_type) - type_ = current_type + return type_, field_types - # add field types for import - for field_type in field_types: - if field_type is not None: - # only add the field type if it is a defined field type - if hasattr(FieldTypes, field_type): - self.field_type_modules.add(field_type) - - # add import data - # self._add_typing_import_module(type_) - - return type_ - - def _gen_type_from_playbook_data_type( - self, required_key: str, playbook_data_types: list[str] - ) -> tuple[str, list[str]]: - """Return type based on playbook data type.""" - # TODO: what to do with Any Playbook Data Type? - if 'Any' in playbook_data_types: - self.typing_modules.add('Any') - - if len(playbook_data_types) == 1: - _field_types = [ - self.input_static.type_map[playbook_data_types[0]][required_key]['field_type'] - ] - _types = self.input_static.type_map[playbook_data_types[0]][required_key]['type'] - else: - _types = [] - _field_types = [] - for lookup_key in playbook_data_types: - _field_types.append( - self.input_static.type_map[lookup_key][required_key]['field_type'] - ) - _types.append(self.input_static.type_map[lookup_key][required_key]['type']) - _types = f'''Union[{', '.join(_types)}]''' - - return _types, _field_types - - def _get_current_type(self, class_name: str, input_name: str) -> str | None: + def _get_type_current(self, class_name: str, input_name: str) -> str | None: """Return the type from the current app_input.py file if found.""" - # Try to capture the value from the specific class first. If not - # found, search the entire app_inputs.py file. + # parsing the previous app_inputs.py file for the type definition, this is a bit tricky + # because the type definition can be in a number of different formats, so we need to + # search for it in a number of different ways. + # first, search for the input name in the class definition, if not found, search for the + # type definition in the entire file. this is best effort, if we can't find the type + # definition, we'll just use the calculated type. type_definition = CodeOperation.find_line_in_code( needle=rf'\s+{input_name}: ', code=self.app_inputs_contents, trigger_start=rf'^class {class_name}', trigger_stop=r'^class ', ) + + # if we didn't find the type definition in the class definition, search the entire file if type_definition is None: type_definition = CodeOperation.find_line_in_code( - needle=f'{input_name}: ', code=self.app_inputs_contents + needle=fr'\s+{input_name}: ', code=self.app_inputs_contents ) - # type_definition -> "string_encrypt: Optional[Sensitive]" + # type_definition -> "string_encrypt: Sensitive | None" self.log.debug( - f'action=find-definition, input-name={input_name}, type-definition={type_definition}' + f'action=find-definition, input-name={input_name}, ' + f'class-name={class_name}, type-definition={type_definition}' ) # parse out the actual type if type_definition is not None: current_type = self._extract_type_from_definition(input_name, type_definition) if current_type is not None: - if 'Union' in current_type: - # the find method will return Union[(String, Optional[String])] that has - # the types as a tuple. this may be valid, but to keep the type - # consistent this bit of code will remove the extra ()/tuple. this - # needs to cover all cases, even the more complicated ones - # (e.g., "Optional[Union[(String, List[String])]]") - _types_data = self._extract_type_from_union(current_type) - union_original = f'Union[({_types_data})]' - union_replace = f'Union[{_types_data}]' - current_type = current_type.replace(union_original, union_replace) - - self.log.debug( - f'action=get-type, input-name={input_name}, current-type={current_type}' - ) return current_type self.log.warning( @@ -391,29 +333,47 @@ def _get_current_type(self, class_name: str, input_name: str) -> str | None: return None - def _get_current_field_types(self, current_type_data: str) -> list[str]: - """Return the current type from the type data (e.g., integer(gt=2) -> integer).""" - types_data = current_type_data - - # extract the type from Union (e.g. Union[binary(), List[binary()]]) - types_data = self._extract_type_from_union(types_data) + def _gen_type_from_playbook_data_type( + self, required_key: str, playbook_data_types: list[str] + ) -> tuple[str, list[str]]: + """Return type based on playbook data type.""" + # TODO: [low] does anything special need to be done for Any type? + if 'Any' in playbook_data_types: + self.typing_modules.add('Any') - # extract type from method (e.g. binary(min_length=2) -> binary) - types_data = self._extract_type_from_method(types_data) + if len(playbook_data_types) == 1: + _field_types = [ + self.input_static.type_map[playbook_data_types[0]][required_key]['field_type'] + ] + _types = self.input_static.type_map[playbook_data_types[0]][required_key]['type'] + else: + _types = [] + _field_types = [] + for lookup_key in playbook_data_types: + _field_types.append( + self.input_static.type_map[lookup_key][required_key]['field_type'] + ) + _types.append(self.input_static.type_map[lookup_key][required_key]['type']) + _types = f'''{' | '.join(_types)}''' - types = [] - for type_ in types_data.split(', '): - # extract the type from List[] - type_ = self._extract_type_from_list(type_) + return _types, _field_types - # extract the type from Optional[] - type_ = self._extract_type_from_optional(type_) + def _get_current_field_types(self) -> set[str]: + """Return the current type from the type data - # append types - types.append(type_) + imports: integer, String + returns: ['integer', 'String'] + """ + types = [] - self.log.debug(f'current-field-types={types}, current-type-data={current_type_data}') - return types + needle = 'from tcex.input.field_type import' + type_definition = CodeOperation.find_line_in_code( + needle=rf'^{needle}', + code=self.app_inputs_contents, + ) + if type_definition is not None: + types = type_definition.replace(needle, '').strip().split(', ') + return set(types) @staticmethod def _standard_field_to_type_map(input_name: str) -> dict | None: @@ -457,8 +417,9 @@ def _validator_always_array(self, always_array: list[str]) -> list[str]: '', f'{self.i1}# ensure inputs that take single and array types always return an array', ( - f'{self.i1}_always_array = validator({_always_array}, ' - 'allow_reuse=True)(always_array())' + f'{self.i1}_always_array = validator({_always_array}, allow_reuse=True, pre=True)' + '(always_array(allow_empty=True, include_empty=False, ' + 'include_null=False, split_csv=True))' ), ] @@ -517,7 +478,7 @@ def app_inputs_data(self) -> dict: def generate(self): """Generate App Config File""" - self.log.debug('--- generate: AppConfig ---') + self.log.debug('--- generate: App Inputs ---') # generate the App Inputs self._generate_app_inputs_to_action() @@ -526,14 +487,13 @@ def generate(self): _code_inputs = self._code_app_inputs_data # create the app_inputs.py code - code = self.input_static.template_app_inputs_prefix( + code = self.input_static.template_app_imports( self.field_type_modules, self.pydantic_modules, self.typing_modules ) code.extend(_code_inputs) if self.app.ij.model.get_param('tc_action') is None: - code.extend(self.input_static.template_app_inputs_class) + code.extend(self.input_static.template_app_inputs_class()) else: # the App support tc_action and should use the tc_action input class - self.typing_modules.add('Optional') code.extend(self.input_static.template_app_inputs_class_tc_action(self.class_model_map)) return code diff --git a/tcex_cli/cli/spec_tool/gen_app_input_static.py b/tcex_cli/cli/spec_tool/gen_app_input_static.py index e811088..9582b21 100644 --- a/tcex_cli/cli/spec_tool/gen_app_input_static.py +++ b/tcex_cli/cli/spec_tool/gen_app_input_static.py @@ -1,8 +1,14 @@ """TcEx Framework Module""" +# standard library +from functools import cached_property + # first-party from tcex_cli.app.config.install_json import InstallJson +# from tcex_cli.pleb.cached_property import cached_property +from tcex_cli.render.render import Render + class GenAppInputStatic: """Generate App Input code""" @@ -13,39 +19,60 @@ def __init__(self): # class properties self.i1 = ' ' * 4 self.i2 = ' ' * 8 + self.i3 = ' ' * 12 + self.i4 = ' ' * 16 self.ij = InstallJson() - @property - def template_app_inputs_class(self) -> list: - """Return app_inputs.py AppInput class.""" - app_model = 'AppBaseModel' - if self.ij.model.is_trigger_app: - app_model = 'ServiceConfigModel' - - return [ - '''class AppInputs:''', - f'''{self.i1}"""App Inputs"""''', - '', - f'''{self.i1}def __init__(self, inputs: 'BaseModel'):''', - f'''{self.i2}"""Initialize instance properties."""''', - f'''{self.i2}self.inputs = inputs''', - '', - f'''{self.i1}def update_inputs(self):''', - f'''{self.i2}"""Add custom App model to inputs.''', - '', - f'''{self.i2}Input will be validate when the model is added an any exceptions will''', - f'''{self.i2}cause the App to exit with a status code of 1.''', - f'''{self.i2}"""''', - f'''{self.i2}self.inputs.add_model({app_model})''', - '', - '', - ] + @cached_property + def app_base_model_class(self) -> str: + """Return App Base Model class.""" + _class, _ = self.app_base_model_data + return _class @property def app_base_model_class_comment(self) -> str: """Return Service Config Model class.""" return f'{self.i1}"""Base model for the App containing any common inputs."""' + @cached_property + def app_base_model_data(self) -> tuple[str, str]: + """Return App Base Model data.""" + match self.ij.model.runtime_level.lower(): + case 'apiservice': + _class = 'AppApiServiceModel' + _file = 'app_api_service_model' + + case 'feedapiservice': + _class = 'AppFeedApiServiceModel' + _file = 'app_feed_api_service_model' + + case 'organization': + _class = 'AppOrganizationModel' + _file = 'app_organization_model' + + case 'playbook': + _class = 'AppPlaybookModel' + _file = 'app_playbook_model' + + case 'triggerservice': + _class = 'AppTriggerServiceModel' + _file = 'app_trigger_service_model' + + case 'webhooktriggerservice': + _class = 'AppWebhookTriggerServiceModel' + _file = 'app_webhook_trigger_service_model' + + case _: + Render.panel.failure(f'Invalid runtime level ({self.ij.model.runtime_level}).') + + return _class, _file + + @cached_property + def app_base_model_import(self) -> str: + """Return App Base Model import.""" + _class, _file = self.app_base_model_data + return f'from tcex.input.model.{_file} import {_class}' + @property def service_config_model_class_comment(self) -> str: """Return Service Config Model class.""" @@ -64,88 +91,197 @@ def service_config_model_class_comment(self) -> str: ] ) - @property - def trigger_config_model_class_comment(self) -> str: - """Return Trigger Config Model class.""" - return '\n'.join( + def template_app_imports( + self, + field_type_modules: set[str], + pydantic_modules: set[str], + typing_modules: set[str], + ) -> list: + """Return app_inputs.py import data.""" + field_types_modules_ = ', '.join(sorted(list(field_type_modules))) + pydantic_modules_ = ', '.join(sorted(list(pydantic_modules))) + typing_modules_ = ', '.join(sorted(list(typing_modules))) + + # defined imports + _imports = ['"""App Inputs"""'] + + # add pyright ignore for field_type + _imports.append('# pyright: reportGeneralTypeIssues=false\n') + + # add typing imports + if typing_modules: + _imports.append(f'from typing import {typing_modules_}') + + # add pydantic imports + if pydantic_modules: + _imports.append(f'from pydantic import {pydantic_modules_}') + + # add tcex Input + _imports.append('from tcex.input.input import Input') + + # add field_type imports + if field_types_modules_: + _imports.append(f'from tcex.input.field_type import {field_types_modules_}') + + # add base model import + _imports.append(self.app_base_model_import) + + # add import for service trigger Apps + if self.ij.model.is_trigger_app: + _imports.append('from tcex.input.model import CreateConfigModel') + + # add new lines + _imports.extend( [ - f'''{self.i1}"""Base model for Trigger (playbook) config.''', '', - f'''{self.i1}Trigger Playbook inputs do not take playbookDataType.''', '', - f'''{self.i1}This is the configuration input that gets sent to the service''', - f'''{self.i1}when a Playbook is enabled (createConfig).''', - f'''{self.i1}"""''', + ] + ) + return _imports + + def template_app_inputs_class(self) -> list: + """Return app_inputs.py AppInput class.""" + app_model = 'AppBaseModel' + if self.ij.model.is_trigger_app: + app_model = 'ServiceConfigModel' + + _code = [ + '''class AppInputs:''', + f'''{self.i1}"""App Inputs"""''', + '', + ] + + # add __init__ method + _code.extend( + [ + f'''{self.i1}def __init__(self, inputs: Input):''', + f'''{self.i2}"""Initialize instance properties."""''', + f'''{self.i2}self.inputs = inputs''', + '', + ] + ) + + # add update_inputs method + _code.extend( + [ + '', + f'''{self.i1}def update_inputs(self):''', + f'''{self.i2}"""Add custom App model to inputs.''', + '', + ( + f'''{self.i2}Input will be validate when the ''' + '''model is added an any exceptions will''' + ), + f'''{self.i2}cause the App to exit with a status code of 1.''', + f'''{self.i2}"""''', + f'''{self.i2}self.inputs.add_model({app_model})''', '', '', ] ) + return _code def template_app_inputs_class_tc_action(self, class_model_map: dict) -> list: """Return app_inputs.py AppInput class for App with tc_action.""" cmm = '' for action, class_name in class_model_map.items(): - cmm += f'\'{action}\': {class_name},' + action_name = action.lower().replace(' ', '_') + cmm += f'\'{action_name}\': {class_name},' cmm = f'{{{cmm}}}' - return [ + _code = [ '''class AppInputs:''', f'''{self.i1}"""App Inputs"""''', '', - f'''{self.i1}def __init__(self, inputs: BaseModel):''', - f'''{self.i2}"""Initialize instance properties."""''', - f'''{self.i2}self.inputs = inputs''', - '', - f'''{self.i1}def get_model(self, tc_action: str | None = None) -> BaseModel:''', - f'''{self.i2}"""Return the model based on the current action."""''', - f'''{self.i2}tc_action = tc_action or self.inputs.model_unresolved.tc_action''', - f'''{self.i2}action_model_map = {cmm}''', - f'''{self.i2}return action_model_map.get(tc_action)''', - '', - f'''{self.i1}def update_inputs(self):''', - f'''{self.i2}"""Add custom App model to inputs.''', - '', - f'''{self.i2}Input will be validate when the model is added an any exceptions will''', - f'''{self.i2}cause the App to exit with a status code of 1.''', - f'''{self.i2}"""''', - f'''{self.i2}self.inputs.add_model(self.get_model())''', - '', - '', ] - def template_app_inputs_prefix( - self, - field_type_modules: set[str], - pydantic_modules: set[str], - typing_modules: set[str], - ) -> list: - """Return app_inputs.py prefix data.""" - field_types_modules_ = ', '.join(sorted(list(field_type_modules))) - pydantic_modules_ = ', '.join(sorted(list(pydantic_modules))) - typing_modules_ = ', '.join(sorted(list(typing_modules))) - _imports = [ - '"""App Inputs"""', - '# pylint: disable=no-self-argument', - '# standard library', - f'from typing import {typing_modules_}', + # add __init__ method + _code.extend( + [ + f'''{self.i1}def __init__(self, inputs: Input):''', + f'''{self.i2}"""Initialize instance properties."""''', + f'''{self.i2}self.inputs = inputs''', + '', + ] + ) + + # add action_model_map method + _code.extend(self.template_app_inputs_class_tc_action_model_map(cmm)) + + # add get_model method + _code.extend( + [ + ( + f'''{self.i1}def get_model(self, tc_action: str ''' + '''| None = None) -> type[BaseModel]:''' + ), + f'''{self.i2}"""Return the model based on the current action."""''', + ( + f'''{self.i2}tc_action = tc_action or self.inputs.model_unresolved.tc_action''' + ''' # type: ignore''' + ), + f'''{self.i2}if tc_action is None:''', + f'''{self.i3}raise RuntimeError('No action (tc_action) found in inputs.')''', + '', + f'''{self.i2}action_model = self.action_model_map(tc_action.lower())''', + f'''{self.i2}if action_model is None:''', + f'''{self.i3}# pylint: disable=broad-exception-raised''', + f'''{self.i3}raise RuntimeError(''', + f'''{self.i4}\'No model found for action: \'''', + f'''{self.i4}f'{{self.inputs.model_unresolved.tc_action}}' # type: ignore''', + f'''{self.i3})''', + '', + f'''{self.i2}return action_model''', + '', + ] + ) + + # add update_inputs method + _code.extend( + [ + f'''{self.i1}def update_inputs(self):''', + f'''{self.i2}"""Add custom App model to inputs.''', + '', + ( + f'''{self.i2}Input will be validate when the model ''' + '''is added an any exceptions will''' + ), + f'''{self.i2}cause the App to exit with a status code of 1.''', + f'''{self.i2}"""''', + f'''{self.i2}self.inputs.add_model(self.get_model())''', + '', + '', + ] + ) + return _code + + def template_app_inputs_class_tc_action_model_map(self, cmm: str) -> list: + """Return the model map method""" + return [ + f'''{self.i1}def action_model_map(self, tc_action: str) -> type[BaseModel]:''', + f'''{self.i2}"""Return action model map."""''', + f'''{self.i2}_action_model_map = {cmm}''', + f'''{self.i2}tc_action_key = tc_action.lower().replace(' ', '_')''', + f'''{self.i2}return _action_model_map.get(tc_action_key)''', '', - '# third-party', - f'from pydantic import {pydantic_modules_}', - f'from tcex.input.field_types import {field_types_modules_}', ] - # add import for service trigger Apps - if self.ij.model.is_trigger_app: - _imports.append('from tcex.input.model import CreateConfigModel') - - # add new lines - _imports.extend( + @property + def trigger_config_model_class_comment(self) -> str: + """Return Trigger Config Model class.""" + return '\n'.join( [ + f'''{self.i1}"""Base model for Trigger (playbook) config.''', + '', + f'''{self.i1}Trigger Playbook inputs do not take playbookDataType.''', + '', + f'''{self.i1}This is the configuration input that gets sent to the service''', + f'''{self.i1}when a Playbook is enabled (createConfig).''', + f'''{self.i1}"""''', '', '', ] ) - return _imports @property def type_map(self): @@ -160,8 +296,8 @@ def type_map(self): 'required': {'type': 'binary(allow_empty=False)', 'field_type': 'binary'}, }, 'BinaryArray': { - 'optional': {'type': 'List[Binary]', 'field_type': 'Binary'}, - 'required': {'type': 'List[binary(allow_empty=False)]', 'field_type': 'binary'}, + 'optional': {'type': 'list[Binary]', 'field_type': 'Binary'}, + 'required': {'type': 'list[binary(allow_empty=False)]', 'field_type': 'binary'}, }, 'Choice': { 'optional': {'type': 'Choice', 'field_type': 'Choice'}, @@ -180,31 +316,31 @@ def type_map(self): 'required': {'type': 'KeyValue', 'field_type': 'KeyValue'}, }, 'KeyValueArray': { - 'optional': {'type': 'List[KeyValue]', 'field_type': 'KeyValue'}, - 'required': {'type': 'List[KeyValue]', 'field_type': 'KeyValue'}, + 'optional': {'type': 'list[KeyValue]', 'field_type': 'KeyValue'}, + 'required': {'type': 'list[KeyValue]', 'field_type': 'KeyValue'}, }, 'KeyValueList': { - 'optional': {'type': 'List[KeyValue]', 'field_type': 'KeyValue'}, - 'required': {'type': 'List[KeyValue]', 'field_type': 'KeyValue'}, + 'optional': {'type': 'list[KeyValue]', 'field_type': 'KeyValue'}, + 'required': {'type': 'list[KeyValue]', 'field_type': 'KeyValue'}, }, 'MultiChoice': { - 'optional': {'type': 'List[Choice]', 'field_type': 'Choice'}, - 'required': {'type': 'List[Choice]', 'field_type': 'Choice'}, + 'optional': {'type': 'list[Choice]', 'field_type': 'Choice'}, + 'required': {'type': 'list[Choice]', 'field_type': 'Choice'}, }, 'String': { 'optional': {'type': 'String', 'field_type': 'String'}, 'required': {'type': 'string(allow_empty=False)', 'field_type': 'string'}, }, 'StringArray': { - 'optional': {'type': 'List[String]', 'field_type': 'String'}, - 'required': {'type': 'List[string(allow_empty=False)]', 'field_type': 'string'}, + 'optional': {'type': 'list[String]', 'field_type': 'String'}, + 'required': {'type': 'list[string(allow_empty=False)]', 'field_type': 'string'}, }, 'TCEntity': { 'optional': {'type': 'TCEntity', 'field_type': 'TCEntity'}, 'required': {'type': 'TCEntity', 'field_type': 'TCEntity'}, }, 'TCEntityArray': { - 'optional': {'type': 'List[TCEntity]', 'field_type': 'TCEntity'}, - 'required': {'type': 'List[TCEntity]', 'field_type': 'TCEntity'}, + 'optional': {'type': 'list[TCEntity]', 'field_type': 'TCEntity'}, + 'required': {'type': 'list[TCEntity]', 'field_type': 'TCEntity'}, }, } diff --git a/tcex_cli/cli/spec_tool/gen_install_json.py b/tcex_cli/cli/spec_tool/gen_install_json.py index 715d64e..28dfc9b 100644 --- a/tcex_cli/cli/spec_tool/gen_install_json.py +++ b/tcex_cli/cli/spec_tool/gen_install_json.py @@ -44,7 +44,7 @@ def _add_standard_fields(self, install_json_data: dict): 'programMain': self.asy.model.program_main, 'programVersion': str(self.asy.model.program_version), 'runtimeLevel': self.asy.model.runtime_level, - 'sdkVersion': tcex_version, + 'sdkVersion': tcex_version or self.asy.model.sdk_version, } ) diff --git a/tcex_cli/cli/spec_tool/spec_tool.py b/tcex_cli/cli/spec_tool/spec_tool.py index 959bb11..0bbacca 100644 --- a/tcex_cli/cli/spec_tool/spec_tool.py +++ b/tcex_cli/cli/spec_tool/spec_tool.py @@ -10,8 +10,7 @@ def command( all_: bool = typer.Option(False, '--all', help='Generate all configuration files.'), - # TODO: the app_input logic needs to be updated for Python 3.11 - # app_input: bool = typer.Option(False, help='Generate app_input.py.'), + app_input: bool = typer.Option(False, help='Generate app_input.py.'), app_spec: bool = typer.Option(False, help='Generate app_spec.yml.'), install_json: bool = typer.Option(False, help='Generate install.json.'), job_json: bool = typer.Option(False, help='Generate job.json.'), @@ -41,8 +40,8 @@ def command( if tcex_json is True or all_ is True: cli.generate_tcex_json() - # if app_input is True or all_ is True: - # cli.generate_app_input() + if app_input is True or all_ is True: + cli.generate_app_input() if readme_md is True or all_ is True: cli.generate_readme_md() diff --git a/tcex_cli/cli/spec_tool/spec_tool_cli.py b/tcex_cli/cli/spec_tool/spec_tool_cli.py index 5de649f..ddc6cc7 100644 --- a/tcex_cli/cli/spec_tool/spec_tool_cli.py +++ b/tcex_cli/cli/spec_tool/spec_tool_cli.py @@ -90,15 +90,7 @@ def generate_app_input(self): code = gen.generate() self.write_app_file(gen.filename, CodeOperation.format_code('\n'.join(code))) if gen.report_mismatch: - _reports = [] - for r in gen.report_mismatch: - _reports.append( - { - 'key': 'Mismatch', - 'value': r, - } - ) - self.report_data['Mismatch Report'] = _reports + Render.table_mismatch('Mismatch Report', data=gen.report_mismatch) def generate_app_spec(self): """Generate the app_spec.yml file.""" diff --git a/tcex_cli/cli/template/init.py b/tcex_cli/cli/template/init.py index bd687d7..ce14cd4 100644 --- a/tcex_cli/cli/template/init.py +++ b/tcex_cli/cli/template/init.py @@ -43,6 +43,12 @@ def command( """Initialize a new App from a template. Templates can be found at: https://github.com/ThreatConnect-Inc/tcex-app-templates + + Optional environment variables include:\n + * PROXY_HOST\n + * PROXY_PORT\n + * PROXY_USER\n + * PROXY_PASS\n """ cli = TemplateCli( proxy_host, diff --git a/tcex_cli/cli/template/list_.py b/tcex_cli/cli/template/list_.py index cefebf6..e0ca9b7 100644 --- a/tcex_cli/cli/template/list_.py +++ b/tcex_cli/cli/template/list_.py @@ -34,6 +34,12 @@ def command( The template name will be pulled from tcex.json by default. If the template option is provided it will be used instead of the value in the tcex.json file. The tcex.json file will also be updated with new values. + + Optional environment variables include:\n + * PROXY_HOST\n + * PROXY_PORT\n + * PROXY_USER\n + * PROXY_PASS\n """ cli = TemplateCli( proxy_host, diff --git a/tcex_cli/cli/template/template_cli.py b/tcex_cli/cli/template/template_cli.py index 02b9a4a..6ff3914 100644 --- a/tcex_cli/cli/template/template_cli.py +++ b/tcex_cli/cli/template/template_cli.py @@ -18,7 +18,6 @@ from tcex_cli.cli.cli_abc import CliABC from tcex_cli.cli.model.file_metadata_model import FileMetadataModel from tcex_cli.cli.template.model.template_config_model import TemplateConfigModel -from tcex_cli.input.field_type.sensitive import Sensitive from tcex_cli.pleb.cached_property import cached_property from tcex_cli.pleb.proxies import proxies from tcex_cli.render.render import Render @@ -50,10 +49,10 @@ def __init__( self.template_data: dict[str, list[TemplateConfigModel]] = {} self.template_manifest = {} self.template_manifest_fqfn = Path('.template_manifest.json') - self.proxy_host = proxy_host - self.proxy_port = proxy_port - self.proxy_user = proxy_user - self.proxy_pass = proxy_pass + self.proxy_host = self._process_proxy_host(proxy_host) + self.proxy_port = self._process_proxy_port(proxy_port) + self.proxy_user = self._process_proxy_user(proxy_user) + self.proxy_pass = self._process_proxy_pass(proxy_pass) # load current template manifest self.load_template_manifest() @@ -161,7 +160,7 @@ def download_template_file(self, item: FileMetadataModel): if item.download_url is None: return - r: Response = self.session.get( + r = self.session.get( item.download_url, allow_redirects=True, headers={'Cache-Control': 'no-cache'} ) if not r.ok: @@ -468,7 +467,7 @@ def session(self) -> Session: proxy_host=self.proxy_host, proxy_port=self.proxy_port, proxy_user=self.proxy_user, - proxy_pass=Sensitive(self.proxy_pass), + proxy_pass=self.proxy_pass, ) # add auth if set (typically not require since default site is public) @@ -583,8 +582,12 @@ def update( # retrieve ALL template contents data: dict[str, FileMetadataModel] = {} + + # for App builder, both template_name and template_type were made optional in the + # model, but in reality these fields are required. This is a temporary fix to + # allow App Builder to work with older Apps that do not have these fields set. for template_parent_name in self.template_parents( - self.app.tj.model.template_name, self.app.tj.model.template_type, branch + self.app.tj.model.template_name, self.app.tj.model.template_type, branch # type: ignore ): # template_parent_name is both the name and the path self.get_template_contents( @@ -592,7 +595,7 @@ def update( data, template_parent_name, template_parent_name, - self.app.tj.model.template_type, + self.app.tj.model.template_type, # type: ignore False, ) @@ -634,7 +637,7 @@ def update_item_check_hash(self, fqfn: Path, item: FileMetadataModel) -> bool: def update_item_prompt(self, branch: str, item: FileMetadataModel) -> bool: """Update the prompt value for the provided item.""" template_config = self.get_template_config( - item.template_name, self.app.tj.model.template_type, branch + item.template_name, self.app.tj.model.template_type, branch # type: ignore ) # enforce prompt if template config can't be found diff --git a/tcex_cli/cli/template/update.py b/tcex_cli/cli/template/update.py index 0591aaa..dafaee0 100644 --- a/tcex_cli/cli/template/update.py +++ b/tcex_cli/cli/template/update.py @@ -36,13 +36,19 @@ def command( proxy_user: StrOrNone = typer.Option(None, help='(Advanced) Username for the proxy server.'), proxy_pass: StrOrNone = typer.Option(None, help='(Advanced) Password for the proxy server.'), ): - """Update a project with the latest template files. + r"""Update a project with the latest template files. Templates can be found at: https://github.com/ThreatConnect-Inc/tcex-app-templates The template name will be pulled from tcex.json by default. If the template option is provided it will be used instead of the value in the tcex.json file. The tcex.json file will also be updated with new values. + + Optional environment variables include:\n + * PROXY_HOST\n + * PROXY_PORT\n + * PROXY_USER\n + * PROXY_PASS\n """ # external Apps do not support update if not Path('tcex.json').is_file(): diff --git a/tcex_cli/render/render.py b/tcex_cli/render/render.py index 60f3037..45140d8 100644 --- a/tcex_cli/render/render.py +++ b/tcex_cli/render/render.py @@ -47,6 +47,64 @@ def progress_text_column(cls) -> TextColumn: """Return a progress bar column.""" return TextColumn('{task.description}', table_column=Column(ratio=1)) + @classmethod + def table_mismatch( + cls, + title: str, + data: list[dict[str, str]], + border_style: str = '', + key_style: str = 'dodger_blue1', + key_width: int = 20, + value_style: str = 'bold', + value_width: int = 80, + ): + """Render key/value table. + + Accepts the following structuresL + [ + { + 'input': '', + 'calculated': '' + 'current': '' + } + ] + """ + table = Table( + border_style=border_style, + expand=True, + show_edge=False, + show_header=True, + ) + + table.add_column( + 'input', + justify='left', + max_width=key_width, + min_width=key_width, + style=key_style, + ) + table.add_column( + 'calculated', + justify='left', + max_width=value_width, + min_width=value_width, + style=value_style, + ) + table.add_column( + 'current', + justify='left', + max_width=value_width, + min_width=value_width, + style=value_style, + ) + + for item in data: + table.add_row(item['input'], item['calculated'], item['current']) + + # render panel->table + if data: + print(Panel(table, border_style=border_style, title=title, title_align=cls.title_align)) + @classmethod def table_package_summary(cls, title: str, summary_data: AppMetadataModel): """Render package summary table.""" diff --git a/tcex_cli/util b/tcex_cli/util index bf28434..4c017ae 160000 --- a/tcex_cli/util +++ b/tcex_cli/util @@ -1 +1 @@ -Subproject commit bf28434dbc53a8acbf7a30edaf9d4f4daaf36a57 +Subproject commit 4c017aea3f8a429364e408d98ddf4ceed1d00069