Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment on lines +141 to +148
## Example .xts File

Expand Down
112 changes: 112 additions & 0 deletions src/xts_core/demo.py
Original file line number Diff line number Diff line change
@@ -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 <alias> <path-or-url>.')
87 changes: 79 additions & 8 deletions src/xts_core/xts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 4 to 8
# *
# *
Expand Down Expand Up @@ -38,6 +35,8 @@
import re
import shlex
import sys
import json
from pathlib import Path

import yaml
try:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]):
"""
Expand Down Expand Up @@ -353,15 +359,80 @@ 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
)

Comment on lines +372 to 387

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this all about? Is the TypeError just because you hadn't got the XTSArgumentParser imported?

_, _, 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.'
)

Comment on lines +400 to +412

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First part of the demo should be getting the user to run xts alias add --name example https://raw.githubusercontent.com/rdkcentral/xts_core/refs/heads/master/examples/hello_world.xts. So there should be no need for xts to find the example file.

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
Comment on lines +413 to +427

def _run_demo(self) -> None:
"""Run the interactive XTS demo built-in command."""
run_demo(self)

def main():
XTS().run()

if __name__ == "__main__":
main()
main()

50 changes: 49 additions & 1 deletion test/test_xts_all_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Comment on lines +124 to +131


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"
Expand Down
Loading