From cb9910e0b09efe92ffe5c64717771e699a67b22d Mon Sep 17 00:00:00 2001 From: zghp Date: Mon, 22 Jun 2026 13:18:38 +0100 Subject: [PATCH 1/2] add xts demo functionality --- README.md | 9 ++++ src/xts_core/xts.py | 84 ++++++++++++++++++++++++++++++++++---- test/test_xts_all_cases.py | 50 ++++++++++++++++++++++- 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2b56a8c..e635829 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,15 @@ xts validate /path/to/file.xts ``` This command reports syntax errors clearly and exits with code `0` for valid files or `1` for invalid files. +### Interactive Demo + +A new interactive demo command is available to guide first-time users through alias setup and execution: + +```sh +xts demo +``` + +It will add a sample alias from `examples/hello_world.xts`, list the alias, and execute the example `hello_world` command. ## Example .xts File diff --git a/src/xts_core/xts.py b/src/xts_core/xts.py index fd2dc74..fafe670 100755 --- a/src/xts_core/xts.py +++ b/src/xts_core/xts.py @@ -4,10 +4,7 @@ # * If not stated otherwise in this file or this component's LICENSE file the # * following copyright and licenses apply: # * -# * Copyright 2024 RDK Management -# * -# * Licensed under the Apache License, Version 2.0 (the "License"); -# * you may not use this file except in compliance with the License. + # * You may obtain a copy of the License at # * # * @@ -38,6 +35,8 @@ import re import shlex import sys +import json +from pathlib import Path import yaml try: @@ -61,8 +60,10 @@ try: from . import xts_alias + from .demo import run_demo except ImportError: from xts_core import xts_alias + from xts_core.demo import run_demo try: from .xts_arg_parser import XTSArgumentParser @@ -173,7 +174,7 @@ def _setup_first_parser(self): """ Parse CLI arguments and set up argparse for all commands. The first argument must be either: - - a built-in options ("alias" or "validate") + - a built-in option ("alias", "validate", or "demo") - an alias name (resolved via ~/.xts/aliases.json to an .xts file path) Returns: @@ -201,6 +202,12 @@ def _setup_first_parser(self): add_help=False, ) validate_parser.add_argument('path', nargs='?', help='Path to the .xts file to validate') + + first_arg_subparsers.add_parser( + 'demo', + help='Run the interactive XTS demo', + add_help=False, + ) return first_arg_parser def _validate_command_value(self, value, path: str): @@ -241,8 +248,7 @@ def _validate_xts_structure(self, node, path: str = 'root'): elif isinstance(node, list): raise ValueError( f'Invalid .xts structure at "{path}": root-level lists are not supported ' - 'in xts configuration' - ) + 'in xts configuration') def _run_validate_command(self, argv: list[str]): """ @@ -353,12 +359,76 @@ def run(self): raise SystemExit(xts_alias.run_alias_builtin(alias_subparser)) case 'validate': self._run_validate_command(remaining_args if remaining_args else [args.get('path', '')]) + case 'demo': + self._run_demo() + raise SystemExit(0) case None|'alias_name': parser.print_help() raise SystemExit(0) case _: self._run_yaml_runner(alias_name, remaining_args) + try: + try: + yaml_runner = YamlRunner( + self._command_sections, + program='xts', + hierarchical=True, + fail_fast=True, + parser_class=XTSArgumentParser + ) + except TypeError: + yaml_runner = YamlRunner( + self._command_sections, + program='xts', + hierarchical=True, + fail_fast=True + ) + + _, _, exit_code = yaml_runner.run(args) + sys.exit(sorted(exit_code)[-1]) + + except Exception as e: + error( + 'An unrecognised command caused an error\n\n' + f'Command Args: [{" ".join(args)}]\n\n' + f'{str(e)}' + ) + + def _find_demo_example_config(self) -> str: + """Locate the example XTS config used by the interactive demo.""" + package_root = Path(__file__).resolve().parents[2] + example_config = package_root / 'examples' / 'hello_world.xts' + if example_config.exists(): + return str(example_config) + + alt_example = Path.cwd() / 'examples' / 'hello_world.xts' + if alt_example.exists(): + return str(alt_example) + + error( + 'Could not locate demo example config. Ensure examples/hello_world.xts exists.' + ) + + def _collect_command_paths(self, section: dict, prefix: list[str] | None = None) -> list[list[str]]: + """Recursively collect leaf command paths from a command section.""" + prefix = prefix or [] + paths: list[list[str]] = [] + + for key, value in section.items(): + if not isinstance(value, dict): + continue + + if 'command' in value: + paths.append(prefix + [key]) + + paths.extend(self._collect_command_paths(value, prefix + [key])) + + return paths + + def _run_demo(self) -> None: + """Run the interactive XTS demo built-in command.""" + run_demo(self) def main(): XTS().run() diff --git a/test/test_xts_all_cases.py b/test/test_xts_all_cases.py index 07e6ab8..062d0ab 100644 --- a/test/test_xts_all_cases.py +++ b/test/test_xts_all_cases.py @@ -4,7 +4,7 @@ from unittest.mock import patch from io import StringIO -sys.path.append(os.path.join(os.path.dirname(__file__), '../')) +sys.path.append(os.path.join(os.path.dirname(__file__), '../src')) from xts_core.xts import XTS from xts_core.xts_alias import ( @@ -83,6 +83,54 @@ def test_missing_alias(monkeypatch, mock_alias_config): output = mock_stdout.getvalue() assert "Unknown alias" in output or "error" in output.lower() + +def test_demo_builtin_runs_interactive_demo(monkeypatch, tmp_path): + """Test that xts demo runs the interactive demo flow.""" + demo_file = tmp_path / 'hello_world.xts' + demo_file.write_text('run:\n hello_world:\n command: echo "hello world"\n', encoding='utf-8') + + monkeypatch.setattr('xts_core.xts_alias.add_alias_from_input', lambda path, name: [(name, str(path))]) + monkeypatch.setattr('xts_core.xts_alias.list_aliases', lambda: None) + monkeypatch.setattr('xts_core.xts_alias.refresh_alias', lambda name: (name, str(demo_file))) + monkeypatch.setattr('xts_core.xts_alias.remove_alias', lambda name: True) + + class FakeRunner: + def __init__(self, *args, **kwargs): + pass + + def run(self, args): + assert args == ['run', 'hello_world'] + return (None, None, [0]) + + monkeypatch.setattr('xts_core.xts.YamlRunner', FakeRunner) + monkeypatch.setattr('builtins.input', lambda prompt='': '') + monkeypatch.setattr('xts_core.xts.XTS._find_demo_example_config', lambda self: str(demo_file)) + + with patch('sys.stdout', new_callable=StringIO) as mock_stdout: + sys.argv = ['xts', 'demo'] + from xts_core.xts import XTS + with pytest.raises(SystemExit) as excinfo: + XTS().run() + assert excinfo.value.code == 0 + output = mock_stdout.getvalue() + assert 'Welcome to the XTS interactive demo' in output + assert 'Section 1: Alias help command.' in output + assert 'Command: xts --alias --help' in output + assert 'Section 2: Add a demo alias for the example file.' in output + assert 'Command: xts --alias --add' in output + assert 'Section 3: List available aliases.' in output + assert 'Command: xts --alias --list' in output + assert 'Section 4: Run the demo alias command.' in output + assert 'Command: xts demo-example run hello_world' in output + assert 'Section 5: Remove the demo alias.' in output + assert 'Command: xts --alias --remove demo-example' in output + assert 'Section 6: Refresh the demo alias.' in output + assert 'Command: xts --alias --refresh demo-example' in output + assert 'demo-example ->' in output + assert 'Removed alias: demo-example' in output + assert 'Demo finished. You can now add your own aliases' in output + + def test_malformed_xts_file(monkeypatch, mock_alias_config, tmp_path): """Test registering and using a malformed .xts file.""" malformed_file = tmp_path / "bad.xts" From e07a989340a08f95153d9bf4bd2547ee8d1ba3bb Mon Sep 17 00:00:00 2001 From: zghp Date: Mon, 3 Aug 2026 12:22:09 +0100 Subject: [PATCH 2/2] separate demo logic into new file --- src/xts_core/demo.py | 112 +++++++++++++++++++++++++++++++++++++++++++ src/xts_core/xts.py | 3 +- 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/xts_core/demo.py diff --git a/src/xts_core/demo.py b/src/xts_core/demo.py new file mode 100644 index 0000000..41d529c --- /dev/null +++ b/src/xts_core/demo.py @@ -0,0 +1,112 @@ +"""Reusable interactive demo helpers for the XTS CLI.""" + +import sys + +from yaml_runner import YamlRunner + +try: + from . import xts_alias +except ImportError: + from xts_core import xts_alias + +try: + from .xts_arg_parser import XTSArgumentParser +except ImportError: + from xts_core.xts_arg_parser import XTSArgumentParser + +try: + from .utils import info, error +except ImportError: + from xts_core.utils import info, error + + +def run_demo_alias_builtin(argv: list[str]) -> int: + """Execute the alias built-in flow with a parser object matching the CLI contract.""" + alias_parser = XTSArgumentParser(prog='xts alias') + xts_alias.setup_alias_parser(alias_parser) + original_argv = sys.argv[:] + try: + sys.argv = ['xts', 'alias', *argv] + return xts_alias.run_alias_builtin(alias_parser) + finally: + sys.argv = original_argv + + +def run_demo(xts_instance) -> None: + """Run the interactive XTS demo built-in command.""" + example_url = ( + 'https://raw.githubusercontent.com/rdkcentral/xts_core/refs/heads/master/' + 'examples/hello_world.xts' + ) + alias_name = 'example' + + info('Welcome to the XTS interactive demo!') + info('This demo will add an alias from the public example URL, show the alias list, and run the example command.') + print() + + add_command = f'xts alias add --name {alias_name} {example_url}' + list_command = 'xts alias list' + run_command = f'xts {alias_name} run hello_world' + remove_command = f'xts alias remove {alias_name}' + + print() + info('Section 1: Add a demo alias from the public example URL.') + info(f' Command: {add_command}') + input('Press Enter to execute this command and continue... ') + try: + run_demo_alias_builtin(['add', '--name', alias_name, example_url]) + except Exception as exc: + error(f'Failed to add demo alias: {exc}') + + print() + info('Section 2: List available aliases.') + info(f' Command: {list_command}') + input('Press Enter to execute this command and continue... ') + run_demo_alias_builtin(['list']) + + resolved_xts_path = xts_alias.resolve_alias_to_xts_path(alias_name) + if resolved_xts_path is None: + error(f'Failed to resolve alias for demo execution: {alias_name}') + return + + xts_instance.xts_config = resolved_xts_path + try: + yaml_runner = YamlRunner( + xts_instance._command_sections, + program='xts', + hierarchical=True, + fail_fast=True, + parser_class=XTSArgumentParser + ) + except TypeError: + yaml_runner = YamlRunner( + xts_instance._command_sections, + program='xts', + hierarchical=True, + fail_fast=True + ) + + print() + info('Section 3: Run the demo alias command.') + info(f' Command: {run_command}') + input('Press Enter to execute this command and continue... ') + try: + _, _, exit_code = yaml_runner.run(['run', 'hello_world']) + if exit_code and any(int(code) != 0 for code in exit_code): + info(f'Command failed: {run_command}') + except SystemExit: + pass + except Exception as exc: + error(f'Failed to run demo alias command: {exc}') + + print() + info('Section 4: Remove the demo alias.') + info(f' Command: {remove_command}') + input('Press Enter to execute this command and continue... ') + try: + run_demo_alias_builtin(['remove', alias_name]) + except Exception as exc: + error(f'Failed to remove demo alias: {exc}') + + print() + info('Demo finished. You can now add your own aliases with xts alias add --name .') diff --git a/src/xts_core/xts.py b/src/xts_core/xts.py index fafe670..04884b9 100755 --- a/src/xts_core/xts.py +++ b/src/xts_core/xts.py @@ -434,4 +434,5 @@ def main(): XTS().run() if __name__ == "__main__": - main() \ No newline at end of file + main() + \ No newline at end of file