Skip to content

Commit 65b6731

Browse files
authored
feat(cli): Improve validation test coverage and robustness for 'jing' (#79)
Improve validation test coverage and robustness for 'jing' --------- Signed-off-by: sushant-suse <[email protected]>
1 parent 6389a97 commit 65b6731

7 files changed

Lines changed: 149 additions & 65 deletions

File tree

changelog.d/79.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support additional RNG validation with lxml. This provides an alternative when jing is not available or preferred.

src/docbuild/cli/cmd_validate/__init__.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,26 @@
1919
nargs=-1,
2020
type=click.Path(exists=True, dir_okay=False, path_type=Path),
2121
)
22+
@click.option(
23+
'--validation-method',
24+
type=click.Choice(['jing', 'lxml'], case_sensitive=False),
25+
default='jing',
26+
help="Choose validation method: 'jing' or 'lxml'",
27+
)
2228
@click.pass_context
23-
def validate(ctx: click.Context, xmlfiles: tuple | Iterator[Path]) -> None:
29+
def validate(ctx: click.Context, xmlfiles: tuple | Iterator[Path], validation_method: str) -> None:
2430
"""Subcommand to validate XML configuration files.
2531
2632
:param ctx: The Click context object.
33+
:param xmlfiles: XML files to validate, if empty all XMLs in config dir are used.
34+
:param validation_method: Validation method to use, 'jing' or 'lxml'.
2735
"""
2836
context: DocBuildContext = ctx.obj
37+
38+
# Set the chosen validation method in the context for downstream use
39+
context.validation_method = validation_method.lower()
40+
2941
if context.envconfig is None:
30-
# log.critical('No envconfig found in context.')
3142
raise ValueError('No envconfig found in context.')
3243

3344
if (paths := ctx.obj.envconfig.get('paths')) is None:
@@ -44,7 +55,7 @@ def validate(ctx: click.Context, xmlfiles: tuple | Iterator[Path]) -> None:
4455
else:
4556
xml_files_to_process = xmlfiles
4657

47-
log.info('Validating XML configuration files')
58+
log.info(f'Validating XML configuration files using {validation_method} method')
4859

4960
result = asyncio.run(process_mod.process(context, xmlfiles=xml_files_to_process))
5061

src/docbuild/cli/cmd_validate/process.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,48 @@ async def validate_rng(
168168
)
169169

170170

171+
def validate_rng_lxml(
172+
xmlfile: Path,
173+
rng_schema_path: Path = PRODUCT_CONFIG_SCHEMA,
174+
) -> tuple[bool, str]:
175+
"""
176+
Validate an XML file against a RELAX NG (.rng) schema using lxml.
177+
178+
This function uses lxml.etree.RelaxNG, which supports only the XML syntax
179+
of RELAX NG schemas (i.e., .rng files, not .rnc files).
180+
181+
:param xmlfile: Path to the XML file to validate.
182+
:param rng_schema_path: Path to the RELAX NG (.rng) schema file.
183+
:return: Tuple of (is_valid: bool, error_message: str)
184+
"""
185+
try:
186+
# Parse the RELAX NG schema from .rng file
187+
relaxng_doc = etree.parse(rng_schema_path)
188+
relaxng = etree.RelaxNG(relaxng_doc)
189+
190+
# Parse the XML file to validate
191+
xml_doc = etree.parse(xmlfile)
192+
193+
# Perform validation
194+
is_valid = relaxng.validate(xml_doc)
195+
if is_valid:
196+
return True, ''
197+
else:
198+
# Return validation error log as string
199+
return False, str(relaxng.error_log)
200+
201+
# Catch specific exceptions for better error handling
202+
except etree.XMLSyntaxError as e:
203+
# This handles syntax errors in either the XML or the RNG file
204+
return False, f'XML or RNG syntax error: {e}'
205+
except etree.RelaxNGParseError as e:
206+
# This handles errors in parsing the RNG schema itself
207+
return False, f'RELAX NG schema parsing error: {e}'
208+
except Exception as e:
209+
# This catch-all is a fallback for any other unexpected issues
210+
return False, f'An unexpected error occurred during validation: {e}'
211+
212+
171213
async def run_python_checks(
172214
tree: etree._ElementTree,
173215
) -> list[tuple[str, CheckResult]]:
@@ -209,7 +251,26 @@ async def process_file(
209251
# IDEA: Should we replace jing and validate with etree.RelaxNG?
210252

211253
# 1. RNG Validation
212-
rng_success, rng_output = await validate_rng(path_obj)
254+
validation_method = context.validation_method
255+
256+
if validation_method == 'lxml':
257+
# Use lxml-based validator (requires .rng schema)
258+
rng_success, rng_output = await asyncio.to_thread(
259+
validate_rng_lxml,
260+
path_obj,
261+
XMLDATADIR / 'product-config-schema.rng',
262+
)
263+
elif validation_method == 'jing':
264+
# Use existing jing-based validator (.rnc or .rng)
265+
rng_success, rng_output = await validate_rng(path_obj)
266+
else:
267+
console_err.print(
268+
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'
269+
)
270+
console_err.print(f' [bold red]Error:[/] Unknown validation method: {validation_method}')
271+
return 11 # Custom error code for unknown validation method
272+
273+
# Handle validation result
213274
if not rng_success:
214275
console_err.print(
215276
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'

src/docbuild/cli/context.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ class DocBuildContext:
4040

4141
debug: bool = False
4242
"""If set, enable debug mode"""
43+
44+
validation_method: str = "jing"
45+
"""Method used to validate XML files: 'jing' (default) or 'lxml'"""

tests/cli/cmd_validate/test_process.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,20 @@
66
from lxml import etree
77
import pytest
88

9-
# Import the module to allow patching with patch.object
109
from docbuild.cli.cmd_validate import process as process_module
1110
from docbuild.cli.cmd_validate.process import (
1211
process,
1312
process_file,
1413
registry,
1514
run_python_checks,
15+
validate_rng_lxml,
1616
)
1717
from docbuild.cli.context import DocBuildContext
1818
from docbuild.config.xml.checks import CheckResult
1919

2020

2121
@pytest.fixture
2222
def mock_context() -> DocBuildContext:
23-
"""Fixture for a mocked DocBuildContext."""
2423
context = MagicMock(spec=DocBuildContext)
2524
context.verbose = 3
2625
context.envconfig = {
@@ -29,38 +28,29 @@ def mock_context() -> DocBuildContext:
2928
'base_server_cache_dir': '/fake/cache',
3029
}
3130
}
31+
context.validation_method = 'jing'
3232
return context
3333

3434

35-
@patch.object(
36-
process_module, 'validate_rng', new_callable=AsyncMock, return_value=(True, '')
37-
)
38-
@patch.object(process_module.asyncio, 'to_thread')
35+
@patch.object(process_module, 'validate_rng', new_callable=AsyncMock, return_value=(True, ''))
36+
@patch.object(process_module.etree, 'parse', side_effect=ValueError('Generic test error'))
3937
async def test_process_file_with_generic_parsing_error(
40-
mock_to_thread: MagicMock,
41-
mock_validate_rng: AsyncMock,
42-
mock_context: DocBuildContext,
43-
tmp_path: Path,
44-
capsys: pytest.CaptureFixture,
38+
mock_etree_parse, mock_validate_rng, mock_context, tmp_path, capsys
4539
):
46-
"""Test process_file with a generic exception during parsing.
47-
48-
This covers the `except Exception` block.
49-
"""
50-
mock_to_thread.side_effect = ValueError('Generic test error')
5140
xml_file = tmp_path / 'file.xml'
5241
xml_file.touch()
5342

5443
exit_code = await process_file(xml_file, mock_context, max_len=20)
5544

5645
assert exit_code == 200
46+
mock_validate_rng.assert_awaited_once()
47+
mock_etree_parse.assert_called_once()
5748
captured = capsys.readouterr()
5849
assert 'Error:' in captured.err
5950
assert 'Generic test error' in captured.err
6051

6152

62-
async def test_process_no_envconfig(mock_context: DocBuildContext):
63-
"""Test that process raises ValueError if envconfig is missing."""
53+
async def test_process_no_envconfig(mock_context):
6454
mock_context.envconfig = None
6555
with pytest.raises(ValueError, match='No envconfig found in context.'):
6656
await process(mock_context, xmlfiles=(Path('dummy.xml'),))
@@ -69,21 +59,57 @@ async def test_process_no_envconfig(mock_context: DocBuildContext):
6959
@patch.object(process_module, 'process_file', new_callable=AsyncMock, return_value=0)
7060
@patch.object(process_module, 'create_stitchfile', new_callable=AsyncMock)
7161
async def test_process_with_stitchfile_failure(
72-
mock_create_stitchfile: AsyncMock,
73-
mock_process_file: AsyncMock,
74-
mock_context: DocBuildContext,
75-
capsys: pytest.CaptureFixture,
62+
mock_create_stitchfile, mock_process_file, mock_context, capsys
7663
):
77-
"""Test process function when create_stitchfile raises a ValueError.
78-
79-
This covers the `except ValueError` block during stitch-file validation.
80-
"""
8164
mock_create_stitchfile.side_effect = ValueError('Duplicate product IDs found')
82-
xml_files = (Path('/fake/file1.xml'),) # Need at least one successful file
65+
xml_files = (Path('/fake/file1.xml'),)
8366

8467
exit_code = await process(mock_context, xmlfiles=xml_files)
8568

8669
assert exit_code == 1
8770
captured = capsys.readouterr()
8871
assert 'Stitch-file validation failed:' in captured.err
8972
assert 'Duplicate product IDs found' in captured.err
73+
74+
75+
@patch.object(process_module, 'validate_rng_lxml', new_callable=MagicMock, return_value=(True, ''))
76+
@patch.object(process_module, 'etree')
77+
@patch.object(process_module, 'run_python_checks', new_callable=AsyncMock)
78+
async def test_process_file_with_lxml_validation_success(
79+
mock_run_checks, mock_etree, mock_validate_lxml, mock_context, tmp_path
80+
):
81+
xml_file = tmp_path / 'file.xml'
82+
xml_file.touch()
83+
mock_context.validation_method = 'lxml'
84+
mock_etree.parse.return_value = etree.ElementTree(etree.Element('root'))
85+
mock_run_checks.return_value = [(None, CheckResult(success=True))]
86+
87+
exit_code = await process_file(xml_file, mock_context, max_len=20)
88+
89+
assert exit_code == 0
90+
mock_validate_lxml.assert_called_once()
91+
mock_run_checks.assert_awaited_once()
92+
93+
94+
@patch.object(process_module, 'validate_rng_lxml', new_callable=MagicMock, return_value=(False, 'error message'))
95+
async def test_process_file_with_lxml_validation_failure(
96+
mock_validate_rng_lxml, mock_context, tmp_path
97+
):
98+
xml_file = tmp_path / 'file.xml'
99+
xml_file.touch()
100+
mock_context.validation_method = 'lxml'
101+
102+
exit_code = await process_file(xml_file, mock_context, max_len=20)
103+
104+
assert exit_code == 10
105+
mock_validate_rng_lxml.assert_called_once()
106+
107+
108+
async def test_process_file_with_unknown_validation_method(mock_context, tmp_path):
109+
xml_file = tmp_path / 'file.xml'
110+
xml_file.touch()
111+
mock_context.validation_method = 'unknown_method'
112+
113+
exit_code = await process_file(xml_file, mock_context, max_len=20)
114+
115+
assert exit_code == 11

tests/cli/cmd_validate/validate/test_cmd_validate.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def mock_context(self):
8888
context = Mock(spec=DocBuildContext)
8989
context.envconfig = {'paths': {'config_dir': '/test/config'}}
9090
context.verbose = 1
91+
context.validation_method = "jing"
9192
return context
9293

9394
@pytest.fixture

0 commit comments

Comments
 (0)