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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ To see available commands for an alias:
xts myalias --help
```

### Validate an .xts File

Use the built-in validator to check YAML syntax for an `.xts` file without running it:

```sh
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.

## Example .xts File

```yaml
Expand Down
99 changes: 98 additions & 1 deletion src/xts_core/xts.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ def _parse_first_arg(self):
Parse CLI arguments and set up argparse for all commands.
The first argument must be either:
- a built-in option starting with '--' (currently supported: --alias)
- the built-in validate command
- an alias name (resolved via ~/.xts/aliases.json to an .xts file path)
Direct .xts file usage from cwd or as the first argument is not supported.

Returns:
list[str]: Remaining args starting with the command name, e.g. ["run", ...].
Expand All @@ -190,6 +190,9 @@ def _parse_first_arg(self):
first_arg_parser.print_help()
raise SystemExit(0)

if remaining_args[0] == 'validate':
self._run_validate_command(remaining_args[1:])

Comment on lines +193 to +195
alias_name = remaining_args[0]
resolved_xts_path = xts_alias.resolve_alias_to_xts_path(alias_name)

Expand All @@ -205,6 +208,100 @@ def _parse_first_arg(self):

return remaining_args[1:]

def _run_validate_command(self, argv):
"""Run the built-in xts validate command."""
validate_parser = XTSArgumentParser(prog='xts validate', add_help=True)
validate_parser.add_argument('xts_file',
action='store',
help='Path to a .xts file to validate')

args = validate_parser.parse_args(argv)
self.validate_xts_file(args.xts_file)
raise SystemExit(0)

def _validate_command_string(self, value, path):
for quote_char in ('"', "'"):
if value.count(quote_char) % 2 == 1:
error(
f'Invalid .xts command string at "{path}": '
f'unbalanced {quote_char} quote'
)

Comment on lines +222 to +229
def _validate_command_value(self, value, path):
if isinstance(value, str):
self._validate_command_string(value, path)
elif isinstance(value, list):
for index, item in enumerate(value):
if not isinstance(item, str):
error(
f'Invalid .xts command value at "{path}[{index}]": '
'command list items must be strings'
)
self._validate_command_string(item, f'{path}[{index}]')
else:
error(
f'Invalid .xts command value at "{path}": '
'command must be a string or list of strings'
)

def _validate_xts_structure(self, node, path='root'):
if isinstance(node, dict):
for key, value in node.items():
if key == 'command':
self._validate_command_value(value, f'{path}/command')
elif isinstance(value, list):
error(
f'Invalid .xts structure at "{path}/{key}": '
'lists are not supported in XTS configuration '
'sections'
)
else:
self._validate_xts_structure(value, f'{path}/{key}')
elif isinstance(node, list):
for index, item in enumerate(node):
self._validate_xts_structure(item, f'{path}[{index}]')

Comment on lines +247 to +263

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.

We should add a warning to show sections of the xts file that will be ignored by xts, because they do not contain a command key.

def _has_command_section(self, data):
if not isinstance(data, dict):
return False

def _contains_command_section(node):
if isinstance(node, dict):
if 'command' in node:
return True
return any(_contains_command_section(value) for value in node.values())
elif isinstance(node, list):
return any(_contains_command_section(item) for item in node)
return False

return any(_contains_command_section(value) for value in data.values())

def validate_xts_file(self, xts_file):
"""Validate an .xts file for correct YAML syntax and XTS structure."""
if not os.path.exists(xts_file):
error(f'xts file does not exist: {xts_file}')

Comment on lines +281 to +283
if not re.search(r'\.xts$', xts_file, re.IGNORECASE):
error('xts file specified must have a .xts extension')

try:
with open(xts_file, 'r', encoding='utf-8') as config_stream:
data = yaml.load(config_stream, SafeLoader)
except PermissionError:
error(f'Could not read xts file: {xts_file}')
except yaml.YAMLError as e:
error(f'The xts file is incorrectly formatted: {xts_file}\n{e}')

if not isinstance(data, dict):
error('The xts file must define a mapping at the root level')

self._validate_xts_structure(data)

if not self._has_command_section(data):
error(f'No command sections found in xts file: {xts_file}')

info(f'"{xts_file}" is a valid .xts file.')

def run(self):
"""Run the XTS app.

Expand Down
50 changes: 50 additions & 0 deletions test/test_xts_all_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

sys.path.append(os.path.join(os.path.dirname(__file__), '../'))

from xts_core.xts import XTS
from xts_core.xts_alias import (
add_alias,
remove_alias,
Expand Down Expand Up @@ -102,6 +103,55 @@ def test_multiple_aliases(mock_alias_config, tmp_path):
aliases = load_aliases()
assert "alias1" in aliases and "alias2" in aliases


def test_validate_xts_file_success(tmp_path):
valid_file = tmp_path / "valid.xts"
valid_file.write_text(
"run:\n"
" hello_world:\n"
" command: echo \"hello world\"\n"
)

with pytest.raises(SystemExit) as excinfo:
XTS()._run_validate_command([str(valid_file)])

assert excinfo.value.code == 0


def test_validate_xts_file_syntax_error(tmp_path):
invalid_file = tmp_path / "invalid.xts"
invalid_file.write_text("not: [valid: yaml")

with patch('sys.stdout', new=StringIO()) as mock_stdout:
with pytest.raises(SystemExit) as excinfo:
XTS()._run_validate_command([str(invalid_file)])

assert excinfo.value.code == 1
assert "incorrectly formatted" in mock_stdout.getvalue().lower()


def test_validate_xts_file_missing(tmp_path):
missing_file = tmp_path / "missing.xts"
with patch('sys.stdout', new=StringIO()) as mock_stdout:
with pytest.raises(SystemExit) as excinfo:
XTS()._run_validate_command([str(missing_file)])

assert excinfo.value.code == 1
assert "does not exist" in mock_stdout.getvalue().lower()


def test_validate_xts_file_invalid_structure(tmp_path):
invalid_file = tmp_path / "invalid.xts"
invalid_file.write_text("run:\n - name: hello\n command: echo \"hello\"")

with patch('sys.stdout', new=StringIO()) as mock_stdout:
with pytest.raises(SystemExit) as excinfo:
XTS()._run_validate_command([str(invalid_file)])

assert excinfo.value.code == 1
assert "lists are not supported" in mock_stdout.getvalue().lower()


def test_allocator_add_slot_missing_args(monkeypatch):
"""Test add-slot with missing required arguments."""
client = XTSAllocatorClient()
Expand Down
Loading