From 5fb6c5596a717105904da921479c070fc9f168bb Mon Sep 17 00:00:00 2001 From: Bracey Summers <35816572+bsummers-tc@users.noreply.github.com> Date: Wed, 10 Apr 2024 17:10:47 -0500 Subject: [PATCH] Update to package and new migrate command - APP-4439 - [PACKAGE] Changed appId creation logic to used UUID4 instead of UUID5 for App Builder - APP-4440 - [MIGRATE] Added new command to assist in migration of TcEx 3 Apps to TcEx 4 --- README.md | 2 +- pyproject.toml | 2 +- release_notes.md | 4 +- tcex_cli/app/config | 2 +- tcex_cli/cli/cli.py | 2 + tcex_cli/cli/migrate/__init__.py | 1 + tcex_cli/cli/migrate/migrate.py | 34 ++ tcex_cli/cli/migrate/migrate_cli.py | 419 ++++++++++++++++++ tcex_cli/cli/model/app_metadata_model.py | 1 + tcex_cli/cli/package/package_cli.py | 2 + tcex_cli/cli/run/launch_service_api.py | 11 +- .../cli/run/launch_service_custom_trigger.py | 11 +- .../cli/run/launch_service_webhook_trigger.py | 11 +- tcex_cli/cli/run/model/service_model.py | 2 +- tcex_cli/cli/run/run.py | 2 +- tcex_cli/cli/run/run_cli.py | 10 +- tcex_cli/util | 2 +- 17 files changed, 491 insertions(+), 27 deletions(-) create mode 100644 tcex_cli/cli/migrate/__init__.py create mode 100644 tcex_cli/cli/migrate/migrate.py create mode 100644 tcex_cli/cli/migrate/migrate_cli.py diff --git a/README.md b/README.md index bdb1f00..69e9dff 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The TcEx CLI package provides functionality for creating ThreatConnect Apps and * arrow (https://pypi.python.org/pypi/arrow) * black (https://pypi.org/project/black/) - * inflect (https://pypi.python.org/pypi/inflect) + * inflection (https://pypi.python.org/pypi/inflection) * isort (https://pypi.org/project/isort/) * paho-mqtt (https://pypi.org/project/paho-mqtt/) * pyaes (https://pypi.org/project/pyaes/) diff --git a/pyproject.toml b/pyproject.toml index e8f7660..32cc748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "arrow", "black", "debugpy", - "inflect", + "inflection", "isort", "paho-mqtt<2.0.0", "pyaes", diff --git a/release_notes.md b/release_notes.md index 55fe0f6..ca87b10 100644 --- a/release_notes.md +++ b/release_notes.md @@ -2,7 +2,9 @@ ## 1.0.3 -- APP-4397 [CONFIG] Update to make runtimeVariable feature a default feature only for playbook apps. +- APP-4397 - [PACKAGE] Updated feature generation logic to make runtimeVariable on be added for Playbook Apps +- APP-4439 - [PACKAGE] Changed appId creation logic to used UUID4 instead of UUID5 for App Builder +- APP-4440 - [MIGRATE] Added new command to assist in migration of TcEx 3 Apps to TcEx 4 ## 1.0.2 diff --git a/tcex_cli/app/config b/tcex_cli/app/config index 79ab616..25acda5 160000 --- a/tcex_cli/app/config +++ b/tcex_cli/app/config @@ -1 +1 @@ -Subproject commit 79ab61680087851df45f17afbc9c898fb4410fbd +Subproject commit 25acda5aea23e7e455bc0d1cd33504003193baa6 diff --git a/tcex_cli/cli/cli.py b/tcex_cli/cli/cli.py index 86de7dc..cad0340 100644 --- a/tcex_cli/cli/cli.py +++ b/tcex_cli/cli/cli.py @@ -12,6 +12,7 @@ # first-party from tcex_cli.cli.deploy import deploy from tcex_cli.cli.deps import deps +from tcex_cli.cli.migrate import migrate from tcex_cli.cli.package import package from tcex_cli.cli.run import run from tcex_cli.cli.spec_tool import spec_tool @@ -93,6 +94,7 @@ def version_callback( app.command('deps')(deps.command) app.command('init')(init.command) app.command('list')(list_.command) +app.command('migrate')(migrate.command) app.command('package')(package.command) app.command('run')(run.command) app.command('spec-tool')(spec_tool.command) diff --git a/tcex_cli/cli/migrate/__init__.py b/tcex_cli/cli/migrate/__init__.py new file mode 100644 index 0000000..558af89 --- /dev/null +++ b/tcex_cli/cli/migrate/__init__.py @@ -0,0 +1 @@ +"""TcEx Framework Module""" diff --git a/tcex_cli/cli/migrate/migrate.py b/tcex_cli/cli/migrate/migrate.py new file mode 100644 index 0000000..af853d5 --- /dev/null +++ b/tcex_cli/cli/migrate/migrate.py @@ -0,0 +1,34 @@ +"""TcEx Framework Module""" + +# standard library +from typing import Optional + +# third-party +import typer + +# first-party +from tcex_cli.cli.migrate.migrate_cli import MigrateCli +from tcex_cli.render.render import Render + +# typer does not yet support PEP 604, but pyupgrade will enforce +# PEP 604. this is a temporary workaround until support is added. +IntOrNone = Optional[int] +StrOrNone = Optional[str] + + +def command( + forward_ref: bool = typer.Option( + True, help='If true, show typing forward lookup reference that require updates.' + ), + update_code: bool = typer.Option(True, help='If true, apply code replacements.'), +): + """Migrate App to TcEx 4 from TcEx 2/3.""" + cli = MigrateCli( + forward_ref, + update_code, + ) + try: + cli.walk_code() + except Exception as ex: + cli.log.exception('Failed to run "tcex deps" command.') + Render.panel.failure(f'Exception: {ex}') diff --git a/tcex_cli/cli/migrate/migrate_cli.py b/tcex_cli/cli/migrate/migrate_cli.py new file mode 100644 index 0000000..b2bed62 --- /dev/null +++ b/tcex_cli/cli/migrate/migrate_cli.py @@ -0,0 +1,419 @@ +"""TcEx Framework Module""" + +# standard library +import ast +import logging +import re +from functools import cached_property +from pathlib import Path + +# first-party +from tcex_cli.cli.cli_abc import CliABC +from tcex_cli.render.render import Render + +# get logger +_logger = logging.getLogger(__name__.split('.', maxsplit=1)[0]) + + +class MigrateCli(CliABC): + """Migration Module.""" + + def __init__( + self, + forward_ref: bool, + update_code: bool, + ): + """Initialize instance properties.""" + super().__init__() + self.forward_ref = forward_ref + self.update_code = update_code + + def _replace_string(self, filename: Path, string: str, replacement: str): + """Replace string in file.""" + file_changed = False + new_file = [] + for line_no, line in enumerate(filename.open(mode='r', encoding='utf-8'), start=1): + line = line.rstrip('\n') + if string in line: + Render.table.key_value( + 'Replace Code', + { + 'File Link': f'{filename}:{line_no}', + 'Current Line': f'{line}', + 'New Line': f'{line.replace(string, replacement)}', + }, + ) + response = Render.prompt.input( + 'Replace line:', + prompt_default=f' (Default: [{self.accent}]yes[/{self.accent}])', + ) + if response in ('', 'y', 'yes'): + line = line.replace(string, replacement) + file_changed = True + new_file.append(line) + + if file_changed is True: + with filename.open(mode='w', encoding='utf-8') as fh: + fh.write('\n'.join(new_file) + '\n') + + @cached_property + def _skip_directories(self): + return [ + '.history', + 'deps', + 'deps_tests', + 'target', + ] + + @cached_property + def _skip_files(self): + return [ + '__init__.py', + ] + + @cached_property + def _code_replacements(self): + """Combined Code Replacements""" + _code_replacements = self._tcex_app_testing_code_replacements + _code_replacements.update(self._tcex_code_replacements) + _code_replacements.update(self._tcex_import_replacements) + _code_replacements.update(self._misc_code_replacements) + return _code_replacements + + @property + def _misc_code_replacements(self): + """Replace TcEx code.""" + return { + r'utils = Utils\(\)': { + 'replacement': 'utils = Util()', + }, + r'Utils\(': { + 'replacement': 'Util(', + }, + r'Utils\.': { + 'replacement': 'Util.', + }, + } + + @property + def _tcex_app_testing_code_replacements(self): + """Replace TcEx code.""" + return { + r'test_feature\.tcex\.playbook\.read\(': { + 'replacement': 'test_feature.aux.playbook.read.any(', + }, + r'test_feature\.aux\.util\.file_operation\.write_temp_file\(': { + 'replacement': 'test_feature.aux.app.file_operation.write_temp_file(', + }, + r'test_feature\.tcex\.log\.debug\(': { + 'replacement': 'test_feature.log.debug(', + }, + r'test_feature\.test_case_feature': { + 'replacement': 'test_feature.aux.config_model.test_case_feature', + }, + r'test_feature\.session': { + 'replacement': 'test_feature.aux.session_tc', + }, + r'test_feature\.aux\.config_model\.profile\.data\.get\(': { + 'replacement': 'test_feature.aux.profile_runner.contents.get(', + }, + # imports + r'from tcex_testing\.env_store import EnvStore': { + 'replacement': 'from tcex_app_testing.env_store import EnvStore' + }, + } + + @property + def _tcex_code_replacements(self): + """Replace TcEx code.""" + return { + r'self\.ij\.model': { + 'replacement': 'self.tcex.app.ij.model', + }, + r'self\.inputs\.model\.': { + 'replacement': 'self.in_.', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.playbook\.output\.create\.variable\(': { + 'replacement': 'self.out.variable(', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.tcex\.file_operations\.': { + 'replacement': 'self.tcex.app.file_operation.', + }, + r'self\.tcex\.get_session_external\(': { + 'replacement': 'self.tcex.requests_external.get_session(', + }, + r'self\.tcex\.ij': { + 'replacement': 'self.tcex.app.ij', + }, + r'self\.tcex\.inputs.model\.': { + 'replacement': 'self.in_.', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.tcex\.inputs.model_unresolved\.': { + 'replacement': 'self.in_unresolved.', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.tcex\.exit\(': { + 'replacement': 'self.tcex.exit.exit(', + }, + r'self\.tcex\.lj': { + 'replacement': 'self.tcex.app.lj', + }, + r'self\.tcex\.log\.': { + 'replacement': 'self.log.', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.tcex\.playbook.create.variable\(': { + 'replacement': 'self.out.variable(', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.tcex\.playbook.exit\(': { + 'replacement': 'self.tcex.exit.exit(', + }, + # playbook catch-all + r'self\.tcex\.playbook': { + 'replacement': 'self.tcex.app.playbook', + 'in_file': ['app.py'], # replacement only works in app.py + }, + r'self\.tcex\.results_tc': { + 'replacement': 'self.tcex.app.results_tc', + }, + r'self\.tcex\.service\.': { + 'replacement': 'self.tcex.app.service.', + }, + r'self\.tcex\.session_external\.': { + 'replacement': 'self.tcex.session.external.', + }, + r'self\.tcex\.utils\.': { + 'replacement': 'self.tcex.util.', + }, + r'self\.tcex\.v2\.': { + 'replacement': 'self.tcex.api.tc.v2.', + }, + r'self\.tcex\.v3\.': { + 'replacement': 'self.tcex.api.tc.v3.', + }, + } + + @property + def _tcex_import_replacements(self): + """Replace TcEx code.""" + return { + ( + r'from\stcex\simport' + r'((?:\s)(?:OnException|OnSuccess|ReadArg)(?:,)?' + r'(?:(?:\s)(?:OnException|OnSuccess|ReadArg)(?:,)?)?' + r'(?:(?:\s)(?:OnException|OnSuccess|ReadArg)(?:,)?)?' + r'(?:(?:\s)(?:OnException|OnSuccess|ReadArg)(?:,)?)?' + r'(?:(?:\s)(?:OnException|OnSuccess|ReadArg)(?:,)?)?)' + ): { + 'capture_group': 1, + 'replacement': 'from tcex.app import ', + }, + r'from tcex\.app\.playbook\.advanced_request': { + 'replacement': 'from tcex.app.playbook.advanced_request', + }, + r'from tcex\.backports import cached_property': { + 'replacement': 'from tcex.pleb.cached_property import cached_property', + }, + r'from tcex\.input\.field_types': { + 'replacement': 'from tcex.input.field_type', + }, + r'from tcex\.input\.models': { + 'replacement': 'from tcex.input.model', + }, + r'from tcex\.decorators': { + 'replacement': 'from tcex.app.decorator', + }, + r'from tcex\.playbook': { + 'replacement': 'from tcex.app.playbook', + }, + r'from tcex\.sessions\.tc_session import TcSession': { + 'replacement': 'from tcex.requests_tc.tc_session import TcSession' + }, + r'from tcex\.session_external': { + 'replacement': 'from tcex.session.external', + }, + r'from tcex\.utils import Utils': { + 'replacement': 'from tcex.util import Util', + }, + r'from tcex\.v2\.datastore': { + 'replacement': 'from tcex.api.tc.v2.datastore', + }, + r'from tcex\.v3\.ti\.ti_utils\.indicator_types': { + 'replacement': 'from tcex.api.tc.utils.indicator_types', + }, + } + + def _handle_constant_annotation( + self, + filename: Path, + annotation: ast.Constant, + imported_packages: dict[str, list[str]], + ): + """.""" + # handle Forward Ref + if annotation.value: + package = annotation.value.split('.')[0] + if package in imported_packages['standard']: + # _logger.debug(f'Forward Ref: {arg.annotation.value}') + self._replace_string( + filename, + f'\'{annotation.value}\'', + annotation.value, + ) + + def parse_ast_body( + self, + filename: Path, + body: list, + imports_: dict[str, list[str]], + in_typing_imports: bool = False, + ): + """.""" + for item in body: + match item: + case ast.Import() | ast.ImportFrom(): + import_type = 'typing' if in_typing_imports else 'standard' + imports_[import_type].extend([n.name for n in item.names]) + + case ast.AnnAssign(): + match item.annotation: + case ast.Constant(): + self._handle_constant_annotation(filename, item.annotation, imports_) + + case ast.Assign(): + pass + + case ast.ClassDef(): + # _logger.debug('Found Class') + self.parse_ast_body(filename, item.body, imports_) + + case ast.Expr(): + # _logger.debug(f'Found Expr: {type(item.value)}') + pass + + case ast.For(): + pass + + case ast.If(): + # _logger.debug('Found If') + if isinstance(item.test, ast.Name): + if item.test.id == 'TYPE_CHECKING': + self.parse_ast_body(filename, item.body, imports_, True) + + case ast.Name(): + pass + + case ast.FunctionDef(): + # _logger.debug(f'Found function: {item.name}') + for arg in item.args.args: + # _logger.debug(f'ARG: Name={arg.arg}') + match arg.annotation: + case ast.Constant(): + self._handle_constant_annotation(filename, arg.annotation, imports_) + + case ast.BinOp(): + # _logger.debug(f'ARG: BinOp -> {arg.annotation.op}') + pass + + case _: + # _logger.debug(f'ARG: other type -> {type(arg.annotation)}') + pass + + match item.returns: + case ast.Constant(): + self._handle_constant_annotation(filename, item.returns, imports_) + ## # handle Forward Ref + ## if item.returns.value in imports_['standard']: + ## # _logger.debug(f'Forward Ref: {item.returns.value}') + ## # _logger.debug(f'{filename}:{item.lineno}') + + case ast.Name(): + # _logger.debug(f'Found Return: {item.returns.id}') + pass + + # parse nested data + self.parse_ast_body(filename, item.body, imports_, True) + + case ast.Try(): + pass + + case ast.Return(): + pass + + case ast.While(): + pass + + # case _: + # _logger.debug(f'Unknown Type: {type(item)}') + + def run_update_code(self, filename: Path): + """Run replace code logic.""" + file_changed = False + new_file = [] + for line_no, line in enumerate(filename.open(mode='r', encoding='utf-8'), start=1): + line = line.rstrip('\n') + + for pattern, data in self._code_replacements.items(): + match_pattern = re.compile(pattern) + match_data = list(match_pattern.finditer(line)) + in_file = data.get('in_file') or [] + if match_data and (not in_file or filename.name in in_file): + match_data = list(match_data)[0] + new_line = re.sub(pattern, data['replacement'], line) + + Render.table.key_value( + 'Replace Code', + { + 'File Link': f'{filename}:{line_no}:{match_data.start() + 1}', + 'Current Line': f'{line}', + 'New Line': f'{new_line}', + }, + ) + response = Render.prompt.input( + 'Replace line:', + prompt_default=f' (Default: [{self.accent}]yes[/{self.accent}])', + ) + if response in ('', 'y', 'yes'): + line = re.sub(pattern, data['replacement'], line) + file_changed = True + + new_file.append(line) + + if file_changed is True: + with filename.open(mode='w', encoding='utf-8') as fh: + fh.write('\n'.join(new_file) + '\n') + + def walk_code(self): + """.""" + for item in Path.cwd().rglob('*.py'): + # skip directories + parents = item.relative_to(Path.cwd()).parents + if len(parents) > 1: + parent_name = parents[-2].name + if parent_name in self._skip_directories: + continue + + # skip files + if item.name in self._skip_files: + continue + + Render.panel.info(f'FILE: {item}') + + # run simple regex replacements + if self.update_code is True: + self.run_update_code(item) + + if self.forward_ref is True: + with item.open(mode='r', encoding='utf-8') as fh: + code = fh.read() + + imports = { + 'standard': [], + 'typing': [], + } + parsed_code = ast.parse(code) + self.parse_ast_body(item, parsed_code.body, imports) diff --git a/tcex_cli/cli/model/app_metadata_model.py b/tcex_cli/cli/model/app_metadata_model.py index 4717b1d..f10e8a3 100644 --- a/tcex_cli/cli/model/app_metadata_model.py +++ b/tcex_cli/cli/model/app_metadata_model.py @@ -7,6 +7,7 @@ class AppMetadataModel(BaseModel): """Model Definition""" + features: str name: str package_name: str template_directory: str diff --git a/tcex_cli/cli/package/package_cli.py b/tcex_cli/cli/package/package_cli.py index 89d0f53..bab9c64 100644 --- a/tcex_cli/cli/package/package_cli.py +++ b/tcex_cli/cli/package/package_cli.py @@ -1,4 +1,5 @@ """TcEx Framework Module""" + # standard library import fnmatch import json @@ -162,6 +163,7 @@ def package(self): package_name=package_name, template_directory=self.template_fqpn.name, version=str(self.app.ij.model.program_version), + features=', '.join(ij_template.model.features), ) # cleanup build directory diff --git a/tcex_cli/cli/run/launch_service_api.py b/tcex_cli/cli/run/launch_service_api.py index 7252d16..a562b85 100644 --- a/tcex_cli/cli/run/launch_service_api.py +++ b/tcex_cli/cli/run/launch_service_api.py @@ -224,7 +224,7 @@ def process_server_channel(self, client, userdata, message): # pylint: disable= self.event.set() - def setup(self): + def setup(self, debug: bool = False): """Configure the API Web Server.""" # setup web server self.api_web_server.setup() @@ -251,7 +251,8 @@ def setup(self): ) # start live display - self.display_thread = Thread( - target=self.live_data_display, name='LiveDataDisplay', daemon=True - ) - self.display_thread.start() + if debug is False: + self.display_thread = Thread( + target=self.live_data_display, name='LiveDataDisplay', daemon=True + ) + self.display_thread.start() diff --git a/tcex_cli/cli/run/launch_service_custom_trigger.py b/tcex_cli/cli/run/launch_service_custom_trigger.py index 51258cc..f34d50a 100644 --- a/tcex_cli/cli/run/launch_service_custom_trigger.py +++ b/tcex_cli/cli/run/launch_service_custom_trigger.py @@ -46,7 +46,7 @@ def live_data_display(self): layout['commands'].update(self.live_data_commands()) self.event.clear() - def setup(self): + def setup(self, debug: bool = False): """Configure the API Web Server.""" # start keyboard listener kl = Thread(target=self.keyboard_listener, name='KeyboardListener', daemon=True) @@ -70,7 +70,8 @@ def setup(self): ) # start live display - self.display_thread = Thread( - target=self.live_data_display, name='LiveDataDisplay', daemon=True - ) - self.display_thread.start() + if debug is False: + self.display_thread = Thread( + target=self.live_data_display, name='LiveDataDisplay', daemon=True + ) + self.display_thread.start() diff --git a/tcex_cli/cli/run/launch_service_webhook_trigger.py b/tcex_cli/cli/run/launch_service_webhook_trigger.py index 6dc78e8..f007492 100644 --- a/tcex_cli/cli/run/launch_service_webhook_trigger.py +++ b/tcex_cli/cli/run/launch_service_webhook_trigger.py @@ -87,7 +87,7 @@ def live_data_header(self) -> Panel: title_align='left', ) - def setup(self): + def setup(self, debug: bool = False): """Configure the API Web Server.""" # setup web server self.api_web_server.setup() @@ -114,7 +114,8 @@ def setup(self): ) # start live display - self.display_thread = Thread( - target=self.live_data_display, name='LiveDataDisplay', daemon=True - ) - self.display_thread.start() + if debug is False: + self.display_thread = Thread( + target=self.live_data_display, name='LiveDataDisplay', daemon=True + ) + self.display_thread.start() diff --git a/tcex_cli/cli/run/model/service_model.py b/tcex_cli/cli/run/model/service_model.py index 834aad1..7348efc 100644 --- a/tcex_cli/cli/run/model/service_model.py +++ b/tcex_cli/cli/run/model/service_model.py @@ -19,7 +19,7 @@ class ServiceModel(BaseSettings): tc_svc_broker_timeout: int = 60 tc_svc_broker_token: Sensitive | None = None tc_svc_client_topic: str = 'tcex-app-testing-client-topic' - tc_svc_hb_timeout_seconds: int = 600 + tc_svc_hb_timeout_seconds: int = 3600 tc_svc_id: int | None = None tc_svc_server_topic: str = 'tcex-app-testing-server-topic' diff --git a/tcex_cli/cli/run/run.py b/tcex_cli/cli/run/run.py index a1a883b..a105879 100644 --- a/tcex_cli/cli/run/run.py +++ b/tcex_cli/cli/run/run.py @@ -33,7 +33,7 @@ def command( cli.debug(debug_port) # run the App - cli.run(config_json) + cli.run(config_json, debug) except Exception as ex: cli.log.exception('Failed to run "tcex run" command.') diff --git a/tcex_cli/cli/run/run_cli.py b/tcex_cli/cli/run/run_cli.py index 70c2d3b..3d5cf6b 100644 --- a/tcex_cli/cli/run/run_cli.py +++ b/tcex_cli/cli/run/run_cli.py @@ -67,7 +67,7 @@ def exit_cli(self, exit_code): Render.panel.info(f'{exit_code}', f'[{self.panel_title}]Exit Code[/]') sys.exit(exit_code) - def run(self, config_json: Path): + def run(self, config_json: Path, debug: bool = False): """Run the App""" match self.ij.model.runtime_level.lower(): @@ -75,7 +75,7 @@ def run(self, config_json: Path): Render.panel.info('Launching API Service', f'[{self.panel_title}]Running App[/]') launch_app = LaunchServiceApi(config_json) self._display_api_settings(launch_app.model.inputs) - launch_app.setup() + launch_app.setup(debug) exit_code = launch_app.launch() self.exit_cli(exit_code) @@ -84,7 +84,7 @@ def run(self, config_json: Path): 'Launching Feed API Service', f'[{self.panel_title}]Running App[/]' ) launch_app = LaunchServiceApi(config_json) - launch_app.setup() + launch_app.setup(debug) exit_code = launch_app.launch() self.exit_cli(exit_code) @@ -109,7 +109,7 @@ def run(self, config_json: Path): 'Launching Trigger Service', f'[{self.panel_title}]Running App[/]' ) launch_app = LaunchServiceCustomTrigger(config_json) - launch_app.setup() + launch_app.setup(debug) exit_code = launch_app.launch() self.exit_cli(exit_code) @@ -119,6 +119,6 @@ def run(self, config_json: Path): ) launch_app = LaunchServiceWebhookTrigger(config_json) self._display_api_settings(launch_app.model.inputs) - launch_app.setup() + launch_app.setup(debug) exit_code = launch_app.launch() self.exit_cli(exit_code) diff --git a/tcex_cli/util b/tcex_cli/util index 4c017ae..a40604b 160000 --- a/tcex_cli/util +++ b/tcex_cli/util @@ -1 +1 @@ -Subproject commit 4c017aea3f8a429364e408d98ddf4ceed1d00069 +Subproject commit a40604b42685628723dee60b698c38e2a5153057