From 2278d8379095e505d4f0ebaf35657fa077602e5c Mon Sep 17 00:00:00 2001 From: zghp Date: Mon, 22 Jun 2026 12:21:58 +0100 Subject: [PATCH] add xts validate command functionality --- README.md | 10 ++++ src/xts_core/xts.py | 99 +++++++++++++++++++++++++++++++++++++- test/test_xts_all_cases.py | 50 +++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 81fa457..24c5891 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/xts_core/xts.py b/src/xts_core/xts.py index 4940bfc..86728f2 100755 --- a/src/xts_core/xts.py +++ b/src/xts_core/xts.py @@ -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", ...]. @@ -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:]) + alias_name = remaining_args[0] resolved_xts_path = xts_alias.resolve_alias_to_xts_path(alias_name) @@ -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' + ) + + 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}]') + + 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}') + + 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. diff --git a/test/test_xts_all_cases.py b/test/test_xts_all_cases.py index 7fbcd3b..16a00cd 100644 --- a/test/test_xts_all_cases.py +++ b/test/test_xts_all_cases.py @@ -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, @@ -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()