Skip to content

Commit a80d0a4

Browse files
authored
Merge pull request #125 from openSUSE/toms/validate_rng
Refactor `validate_rng` to return `CompletedProcess`
2 parents ab153e6 + 2400eb9 commit a80d0a4

6 files changed

Lines changed: 87 additions & 71 deletions

File tree

.github/agents/docbuild.agent.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ Docbuild builds documentation from DocBook 5/ASCIIDoc, manages XML configs, clon
3333

3434
Run tests:
3535

36-
* Complete tests with `upytest` from the alias script.
37-
* Single tests with `upytest tests/path/to/test_file.py::test_function_name`
36+
* Run the complete test suite with `uv run --frozen pytest`.
37+
* Single tests with `uv run --frozen pytest tests/path/to/test_file.py::test_function_name`.
3838

3939

4040
## Constraints & Safety

changelog.d/125.refactor.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
In function :fun:`validate_rng`, use ``CompletedProcess`` as return object.
2+
Replace tuple with CompletedProcess. This is used in other functions too and this change makes it consistent with other.

src/docbuild/cli/cmd_validate/process.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from lxml import etree
1111
from rich.console import Console
12+
from subprocess import CompletedProcess
1213

1314
from ...config.xml.checks import CheckResult, register_check
1415
from ...config.xml.stitch import create_stitchfile
@@ -83,7 +84,7 @@ async def validate_rng(
8384
*,
8485
xinclude: bool = True,
8586
idcheck: bool = True
86-
) -> tuple[bool, str]:
87+
) -> CompletedProcess:
8788
"""Validate an XML file against a RELAX NG schema using jing.
8889
8990
If `xinclude` is True (the default), this function resolves XIncludes by
@@ -107,8 +108,6 @@ async def validate_rng(
107108
try:
108109
if xinclude:
109110
# Use a temporary file to store the output of xmllint.
110-
# This is more robust than piping, especially if jing doesn't
111-
# correctly handle stdin (the command "jing schema.rng -" does NOT work.)
112111
with tempfile.NamedTemporaryFile(
113112
prefix='jing-validation',
114113
suffix='.xml',
@@ -119,33 +118,33 @@ async def validate_rng(
119118
tmp_filepath = Path(tmp_file.name)
120119

121120
# 1. Run xmllint to resolve XIncludes and save to temp file
122-
process = await run_command(
123-
['xmllint', '--xinclude', '--output',
124-
str(tmp_filepath), str(xmlfile)]
121+
xmllint_proc = await run_command(
122+
['xmllint', '--xinclude', '--output', str(tmp_filepath), str(xmlfile)]
125123
)
126-
if process.returncode != 0:
127-
return False, f'xmllint failed: {process.stderr}'
124+
if xmllint_proc.returncode != 0:
125+
# Construct a CompletedProcess representing failure from xmllint
126+
return CompletedProcess(
127+
args=['xmllint', str(xmlfile)],
128+
returncode=xmllint_proc.returncode,
129+
stdout=xmllint_proc.stdout,
130+
stderr=f'xmllint failed: {xmllint_proc.stderr}',
131+
)
128132

129133
# 2. Run jing on the resolved temporary file
130134
jing_cmd.append(str(tmp_filepath))
131-
process = await run_command(jing_cmd)
132-
if process.returncode != 0:
133-
return False, (process.stdout + process.stderr).strip()
134-
135-
return True, ''
135+
return await run_command(jing_cmd)
136136
else:
137137
# Validate directly with jing, no XInclude resolution.
138138
jing_cmd.append(str(xmlfile))
139-
process = await run_command(jing_cmd)
140-
if process.returncode == 0:
141-
return True, ''
142-
return False, (process.stdout + process.stderr).strip()
139+
return await run_command(jing_cmd)
143140

144141
except FileNotFoundError as e:
145142
tool = e.filename or 'xmllint/jing'
146-
return (
147-
False,
148-
f'{tool} command not found. Please install it to run validation.',
143+
return CompletedProcess(
144+
args=[tool],
145+
returncode=1,
146+
stdout='',
147+
stderr=f'{tool} command not found. Please install it to run validation.',
149148
)
150149

151150

@@ -203,6 +202,7 @@ async def run_python_checks(
203202
try:
204203
result = await asyncio.to_thread(check, tree)
205204
check_results.append((check.__name__, result))
205+
206206
except Exception as e:
207207
error_result = CheckResult(success=False, messages=[f'error: {e}'])
208208
check_results.append((check.__name__, error_result))
@@ -229,7 +229,7 @@ async def process_file(
229229
)
230230

231231
# IDEA: Should we replace jing and validate with etree.RelaxNG?
232-
232+
#
233233
# 1. RNG Validation
234234
validation_method = context.validation_method
235235

@@ -242,22 +242,34 @@ async def process_file(
242242
)
243243
elif validation_method == 'jing':
244244
# Use existing jing-based validator (.rnc or .rng)
245-
rng_success, rng_output = await validate_rng(path_obj, idcheck=True)
245+
jing_result = await validate_rng(path_obj, idcheck=True)
246246
else:
247247
console_err.print(
248248
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'
249249
)
250250
console_err.print(f' [bold red]Error:[/] Unknown validation method: {validation_method}')
251251
return 11 # Custom error code for unknown validation method
252252

253-
# Handle validation result
254-
if not rng_success:
255-
console_err.print(
256-
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'
257-
)
258-
if rng_output:
259-
console_err.print(f' [bold red]Error:[/] {rng_output}')
260-
return 10 # Specific error code for RNG failure
253+
# Handle validation result for jing
254+
if validation_method == 'jing':
255+
if jing_result.returncode != 0:
256+
console_err.print(
257+
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'
258+
)
259+
output = (jing_result.stdout or '') + (jing_result.stderr or '')
260+
if output:
261+
console_err.print(f' [bold red]Error:[/] {output.strip()}')
262+
return 10 # Specific error code for RNG failure
263+
264+
# Handle validation result for lxml
265+
if validation_method == 'lxml':
266+
if not rng_success:
267+
console_err.print(
268+
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'
269+
)
270+
if rng_output:
271+
console_err.print(f' [bold red]Error:[/] {rng_output}')
272+
return 10
261273

262274
# 2. Python-based checks
263275
try:

tests/cli/cmd_validate/test_process.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ def mock_context() -> DocBuildContext:
3535

3636

3737
@patch.object(process_module, 'validate_rng',
38-
new_callable=AsyncMock, return_value=(True, ''))
38+
new_callable=AsyncMock,
39+
return_value=CompletedProcess(args=['jing'],
40+
returncode=0, stdout='', stderr='')
41+
)
3942
@patch.object(process_module.etree, 'parse', side_effect=ValueError('Generic test error'))
4043
async def test_process_file_with_generic_parsing_error(
4144
mock_etree_parse, mock_validate_rng, mock_context, tmp_path, capsys
@@ -129,10 +132,10 @@ async def test_validate_rng_with_idcheck_success(mock_run_command, tmp_path):
129132
rng_schema = tmp_path / 'schema.rnc'
130133
rng_schema.touch()
131134

132-
is_valid, message = await validate_rng(xml_file, rng_schema, xinclude=False, idcheck=True)
135+
proc = await validate_rng(xml_file, rng_schema, xinclude=False, idcheck=True)
133136

134-
assert is_valid is True
135-
assert message == ''
137+
assert proc.returncode == 0
138+
assert proc.stdout == '' and proc.stderr == ''
136139
# Ensure -i flag is passed to jing
137140
assert "-i" in mock_run_command.call_args.args[0]
138141

@@ -151,10 +154,10 @@ async def test_validate_rng_with_idcheck_duplicate_failure(mock_run_command, tmp
151154
rng_schema = tmp_path / 'schema.rnc'
152155
rng_schema.touch()
153156

154-
is_valid, message = await validate_rng(xml_file, rng_schema, xinclude=False, idcheck=True)
157+
proc = await validate_rng(xml_file, rng_schema, xinclude=False, idcheck=True)
155158

156-
assert is_valid is False
157-
assert 'duplicate ID' in message
159+
assert proc.returncode != 0
160+
assert 'duplicate ID' in proc.stderr
158161
# Ensure -i flag is passed to jing
159162
assert '-i' in mock_run_command.call_args.args[0]
160163

@@ -173,9 +176,9 @@ async def test_validate_rng_without_idcheck_success(mock_run_command, tmp_path):
173176
rng_schema = tmp_path / 'schema.rnc'
174177
rng_schema.touch()
175178

176-
is_valid, message = await validate_rng(xml_file, rng_schema, xinclude=False, idcheck=False)
179+
proc = await validate_rng(xml_file, rng_schema, xinclude=False, idcheck=False)
177180

178-
assert is_valid is True
179-
assert message == ''
181+
assert proc.returncode == 0
182+
assert proc.stdout == '' and proc.stderr == ''
180183
# Ensure -i flag is NOT passed to jing
181184
assert '-i' not in mock_run_command.call_args.args[0]

tests/cli/cmd_validate/validate/test_cmd_validate.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from os import PathLike
55
from pathlib import Path
66
import tempfile
7+
from subprocess import CompletedProcess
78
from unittest.mock import AsyncMock, Mock, patch
89

910
import pytest
@@ -161,7 +162,7 @@ async def test_process_check_exception(
161162
self, mock_validate_rng, mock_registry, mock_context, valid_xml_file: PathLike
162163
):
163164
"""Test processing when a check raises an exception."""
164-
mock_validate_rng.return_value = (True, '')
165+
mock_validate_rng.return_value = CompletedProcess(args=['jing'], returncode=0, stdout='', stderr='')
165166
# Create a mock check that raises an exception
166167
mock_check = Mock()
167168
mock_check.__name__ = 'failing_check'
@@ -178,7 +179,7 @@ async def test_process_successful_checks(
178179
self, mock_validate_rng, mock_registry, mock_context, valid_xml_file
179180
):
180181
"""Test processing with successful checks returns 0."""
181-
mock_validate_rng.return_value = (True, '')
182+
mock_validate_rng.return_value = CompletedProcess(args=['jing'], returncode=0, stdout='', stderr='')
182183
# Create a mock check that succeeds
183184
mock_check = Mock()
184185
mock_check.__name__ = 'passing_check'
@@ -195,7 +196,7 @@ async def test_process_failed_checks(
195196
self, mock_validate_rng, mock_registry, mock_context, valid_xml_file
196197
):
197198
"""Test processing with failed checks returns 1."""
198-
mock_validate_rng.return_value = (True, '')
199+
mock_validate_rng.return_value = CompletedProcess(args=['jing'], returncode=0, stdout='', stderr='')
199200
# Create a mock check that fails
200201
mock_check = Mock()
201202
mock_check.__name__ = 'failing_check'
@@ -211,7 +212,7 @@ async def test_process_shortname_generation(
211212
self, mock_validate_rng, mock_context, valid_xml_file
212213
):
213214
"""Test that shortname is generated correctly for display."""
214-
mock_validate_rng.return_value = (True, '')
215+
mock_validate_rng.return_value = CompletedProcess(args=['jing'], returncode=0, stdout='', stderr='')
215216
with patch.object(process_mod, 'registry') as mock_registry:
216217
mock_registry.registry = []
217218

@@ -226,7 +227,7 @@ async def test_process_with_multiple_files_and_iterator(
226227
self, mock_validate_rng, mock_context, valid_xml_file
227228
):
228229
"""Test that processing multiple files works with an iterator."""
229-
mock_validate_rng.return_value = (True, '')
230+
mock_validate_rng.return_value = CompletedProcess(args=['jing'], returncode=0, stdout='', stderr='')
230231
with patch.object(process_mod, 'registry') as mock_registry:
231232
mock_registry.registry = []
232233

@@ -241,7 +242,7 @@ async def test_process_stitch_validation_fails_on_duplicates(
241242
self, mock_validate_rng, mock_context, tmp_path, capsys
242243
):
243244
"""Test `process` fails if stitch validation finds duplicate product IDs."""
244-
mock_validate_rng.return_value = (True, '')
245+
mock_validate_rng.return_value = CompletedProcess(args=['jing'], returncode=0, stdout='', stderr='')
245246

246247
file1: Path = tmp_path / 'file1.xml'
247248
file1.write_text('<product productid="sles"><docset setid="15-sp4"/></product>')

tests/cli/cmd_validate/validate/test_process_validation.py

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ async def test_validate_rng_with_rng_suffix(tmp_path: Path):
3737
<start><element name="root"><text/></element></start>
3838
</grammar>""")
3939

40-
returncode, _ = await process_mod.validate_rng(xmlfile, rng_schema)
40+
proc = await process_mod.validate_rng(xmlfile, rng_schema)
4141

42-
assert returncode, f'Expected True, got {returncode}'
42+
assert proc.returncode == 0, f'Expected return code 0, got {proc.returncode}'
4343

4444

4545
@pytest.mark.skipif(not is_jing_installed(), reason="jing command not found")
@@ -54,9 +54,9 @@ async def test_validate_rng_with_invalid_xml(tmp_path: Path):
5454
<start><element name="root"><text/></element></start>
5555
</grammar>""")
5656

57-
returncode, message = await process_mod.validate_rng(xmlfile, rng_schema)
58-
assert returncode is False, f'Expected False, got {returncode}'
59-
assert 'error: element "wrong_root"' in message
57+
proc = await process_mod.validate_rng(xmlfile, rng_schema)
58+
assert proc.returncode != 0, f'Expected non-zero return code, got {proc.returncode}'
59+
assert 'error: element "wrong_root"' in (proc.stdout + proc.stderr)
6060

6161

6262
@pytest.mark.skipif(not is_jing_installed(), reason="jing command not found")
@@ -71,9 +71,9 @@ async def test_validate_rng_without_xinclude(tmp_path: Path):
7171
<start><element name="root"><text/></element></start>
7272
</grammar>""")
7373

74-
returncode, _ = await process_mod.validate_rng(xmlfile, rng_schema, xinclude=False)
74+
proc = await process_mod.validate_rng(xmlfile, rng_schema, xinclude=False)
7575

76-
assert returncode, f'Expected True, got {returncode}'
76+
assert proc.returncode == 0, f'Expected return code 0, got {proc.returncode}'
7777

7878

7979
@pytest.mark.skipif(not is_jing_installed(), reason="jing command not found")
@@ -88,12 +88,10 @@ async def test_validate_rng_with_invalid_xml_without_xinclude(tmp_path: Path):
8888
<start><element name="root"><text/></element></start>
8989
</grammar>""")
9090

91-
returncode, message = await process_mod.validate_rng(
92-
xmlfile, rng_schema, xinclude=False
93-
)
91+
proc = await process_mod.validate_rng(xmlfile, rng_schema, xinclude=False)
9492

95-
assert returncode is False, f'Expected False, got {returncode}'
96-
assert 'element "wrong_root" not allowed anywhere' in message
93+
assert proc.returncode != 0, f'Expected non-zero return code, got {proc.returncode}'
94+
assert 'element "wrong_root" not allowed anywhere' in (proc.stdout + proc.stderr)
9795

9896

9997
async def test_validate_rng_jing_failure():
@@ -112,12 +110,12 @@ async def test_validate_rng_jing_failure():
112110
stdout='Error in jing',
113111
stderr=''))
114112
) as mock_run_command:
115-
success, output = await process_mod.validate_rng(
113+
proc = await process_mod.validate_rng(
116114
xmlfile, rng_schema_path=rng_schema, xinclude=False, idcheck=False
117115
)
118116

119-
assert not success, 'Expected validation to fail.'
120-
assert output == 'Error in jing', f'Unexpected output: {output}'
117+
assert proc.returncode != 0, 'Expected validation to fail.'
118+
assert proc.stdout == 'Error in jing', f'Unexpected stdout: {proc.stdout}'
121119

122120
mock_run_command.assert_called_once_with(
123121
['jing', str(rng_schema), str(xmlfile)]
@@ -137,12 +135,12 @@ async def test_validate_rng_command_not_found():
137135
with patch.object(
138136
process_mod, 'run_command', new_callable=AsyncMock, side_effect=error
139137
):
140-
success, output = await process_mod.validate_rng(
138+
proc = await process_mod.validate_rng(
141139
xmlfile, rng_schema_path=rng_schema, xinclude=False
142140
)
143141

144-
assert not success, 'Expected validation to fail.'
145-
assert output == 'jing command not found. Please install it to run validation.'
142+
assert proc.returncode != 0, 'Expected validation to fail.'
143+
assert proc.stderr == 'jing command not found. Please install it to run validation.'
146144

147145

148146
async def test_validate_rng_command_not_found_no_filename():
@@ -155,21 +153,21 @@ async def test_validate_rng_command_not_found_no_filename():
155153
with patch.object(
156154
process_mod, 'run_command', new_callable=AsyncMock, side_effect=error
157155
):
158-
success, output = await process_mod.validate_rng(
156+
proc = await process_mod.validate_rng(
159157
xmlfile, rng_schema, xinclude=False
160158
)
161159

162-
assert not success, 'Expected validation to fail.'
160+
assert proc.returncode != 0, 'Expected validation to fail.'
163161
assert (
164-
output == 'xmllint/jing command not found. Please install it to run validation.'
162+
proc.stderr == 'xmllint/jing command not found. Please install it to run validation.'
165163
)
166164

167165

168166
async def test_process_file_with_validation_issues(capsys, tmp_path):
169167
with patch.object(
170168
process_mod,
171169
'validate_rng',
172-
new=AsyncMock(return_value=(False, 'Validation error')),
170+
new=AsyncMock(return_value=CompletedProcess(args=['jing'], returncode=1, stdout='', stderr='Validation error')),
173171
) as mock_validate_rng:
174172
dir_path = tmp_path / 'path' / 'to'
175173
dir_path.mkdir(parents=True)
@@ -207,7 +205,7 @@ async def test_process_file_with_xmlsyntax_error(capsys, tmp_path):
207205
),
208206
) as mock_etree_parse,
209207
patch.object(
210-
process_mod, 'validate_rng', new=AsyncMock(return_value=(True, ''))
208+
process_mod, 'validate_rng', new=AsyncMock(return_value=CompletedProcess(args=['jing'], returncode=0, stdout='', stderr=''))
211209
) as mock_validate_rng,
212210
):
213211
result = await process_mod.process_file(xmlfile, mock_context, 40)

0 commit comments

Comments
 (0)