Skip to content

Commit e5ef0a9

Browse files
committed
Fix all tests
1 parent c8d8638 commit e5ef0a9

File tree

5 files changed

+179
-154
lines changed

5 files changed

+179
-154
lines changed

src/docbuild/cli/cmd_validate/process.py

Lines changed: 55 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Module for processing XML validation in DocBuild."""
22

33
import asyncio
4+
import logging
5+
import subprocess
6+
import tempfile
47
from collections.abc import Iterator
58
from datetime import date
6-
import logging
79
from pathlib import Path
8-
import tempfile
910

1011
from lxml import etree
1112
from rich.console import Console
@@ -15,6 +16,7 @@
1516
from ...constants import XMLDATADIR
1617
from ...utils.decorators import RegistryDecorator
1718
from ...utils.paths import calc_max_len
19+
from ..commands import run_command
1820
from ..context import DocBuildContext
1921

2022
# Cast to help with type checking
@@ -76,37 +78,12 @@ def display_results(
7678
console_err.print(f' {message}')
7779

7880

79-
async def run_command(
80-
*args: str, env: dict[str, str] | None = None
81-
) -> tuple[int, str, str]:
82-
"""Run an external command and capture its output.
83-
84-
:param args: The command and its arguments separated as tuple elements.
85-
:param env: A dictionary of environment variables for the new process.
86-
:return: A tuple of (returncode, stdout, stderr).
87-
:raises FileNotFoundError: if the command is not found.
88-
"""
89-
process = await asyncio.create_subprocess_exec(
90-
*args,
91-
stdout=asyncio.subprocess.PIPE,
92-
stderr=asyncio.subprocess.PIPE,
93-
env=env,
94-
)
95-
stdout, stderr = await process.communicate()
96-
97-
# After .communicate() returns, the process has terminated and the
98-
# returncode is guaranteed to be set to an integer.
99-
assert process.returncode is not None
100-
101-
return process.returncode, stdout.decode(), stderr.decode()
102-
103-
10481
async def validate_rng(
10582
xmlfile: Path,
10683
rng_schema_path: Path = PRODUCT_CONFIG_SCHEMA,
10784
*,
10885
xinclude: bool = True,
109-
) -> tuple[bool, str]:
86+
) -> subprocess.CompletedProcess:
11087
"""Validate an XML file against a RELAX NG schema using jing.
11188
11289
If `xinclude` is True (the default), this function resolves XIncludes by
@@ -117,7 +94,7 @@ async def validate_rng(
11794
:param rng_schema_path: The path to the RELAX NG schema file. It supports
11895
both RNC and RNG formats.
11996
:param xinclude: If True, resolve XIncludes with `xmllint` before validation.
120-
:return: A tuple containing a boolean success status and any output message.
97+
:return: A subprocess.CompletedProcess containing args, returncode, stdout, stderr.
12198
"""
12299
jing_cmd = ['jing']
123100
if rng_schema_path.suffix == '.rnc':
@@ -126,9 +103,6 @@ async def validate_rng(
126103

127104
try:
128105
if xinclude:
129-
# Use a temporary file to store the output of xmllint.
130-
# This is more robust than piping, especially if jing doesn't
131-
# correctly handle stdin (the command "jing schema.rng -" does NOT work.)
132106
with tempfile.NamedTemporaryFile(
133107
prefix='jing-validation',
134108
suffix='.xml',
@@ -137,34 +111,56 @@ async def validate_rng(
137111
encoding='utf-8',
138112
) as tmp_file:
139113
tmp_filepath = Path(tmp_file.name)
140-
141114
# 1. Run xmllint to resolve XIncludes and save to temp file
142-
returncode, _, stderr = await run_command(
143-
'xmllint', '--xinclude', '--output', str(tmp_filepath), str(xmlfile)
115+
process = await run_command(
116+
'xmllint',
117+
'--xinclude',
118+
'--output',
119+
str(tmp_filepath),
120+
str(xmlfile),
121+
stdout=asyncio.subprocess.PIPE,
122+
stderr=asyncio.subprocess.PIPE,
144123
)
145-
if returncode != 0:
146-
return False, f'xmllint failed: {stderr.strip()}'
147-
124+
if process.returncode != 0:
125+
return subprocess.CompletedProcess(
126+
args=['xmllint'],
127+
returncode=process.returncode,
128+
stdout=process.stdout,
129+
stderr=process.stderr,
130+
)
148131
# 2. Run jing on the resolved temporary file
149132
jing_cmd.append(str(tmp_filepath))
150-
returncode, stdout, stderr = await run_command(*jing_cmd)
151-
if returncode != 0:
152-
return False, (stdout + stderr).strip()
153-
154-
return True, ''
133+
process = await run_command(
134+
*jing_cmd,
135+
stdout=asyncio.subprocess.PIPE,
136+
stderr=asyncio.subprocess.PIPE,
137+
)
138+
return subprocess.CompletedProcess(
139+
args=jing_cmd,
140+
returncode=process.returncode,
141+
stdout=process.stdout,
142+
stderr=process.stderr,
143+
)
155144
else:
156-
# Validate directly with jing, no XInclude resolution.
157145
jing_cmd.append(str(xmlfile))
158-
returncode, stdout, stderr = await run_command(*jing_cmd)
159-
if returncode == 0:
160-
return True, ''
161-
return False, (stdout + stderr).strip()
162-
146+
process = await run_command(
147+
*jing_cmd,
148+
stdout=asyncio.subprocess.PIPE,
149+
stderr=asyncio.subprocess.PIPE,
150+
)
151+
return subprocess.CompletedProcess(
152+
args=jing_cmd,
153+
returncode=process.returncode,
154+
stdout=process.stdout,
155+
stderr=process.stderr,
156+
)
163157
except FileNotFoundError as e:
164158
tool = e.filename or 'xmllint/jing'
165-
return (
166-
False,
167-
f'{tool} command not found. Please install it to run validation.',
159+
return subprocess.CompletedProcess(
160+
args=[tool],
161+
returncode=127,
162+
stdout='',
163+
stderr=f'{tool} command not found. Please install it to run validation.',
168164
)
169165

170166

@@ -206,40 +202,31 @@ async def process_file(
206202
'/'.join(path_obj.parts[-2:]) if len(path_obj.parts) >= 2 else str(filepath)
207203
)
208204

209-
# IDEA: Should we replace jing and validate with etree.RelaxNG?
210-
211205
# 1. RNG Validation
212-
rng_success, rng_output = await validate_rng(path_obj)
213-
if not rng_success:
206+
rng_result = await validate_rng(path_obj)
207+
if rng_result.returncode != 0:
214208
console_err.print(
215209
f'{shortname:<{max_len}}: RNG validation => [red]failed[/red]'
216210
)
217-
if rng_output:
218-
console_err.print(f' [bold red]Error:[/] {rng_output}')
211+
if rng_result.stderr:
212+
console_err.print(f' [bold red]Error:[/] {rng_result.stderr.strip()}')
219213
return 10 # Specific error code for RNG failure
220214

221215
# 2. Python-based checks
222216
try:
223217
tree = await asyncio.to_thread(etree.parse, str(filepath), parser=None)
224-
225218
except etree.XMLSyntaxError as err:
226-
# This can happen if xmllint passes but lxml's parser is stricter.
227219
console_err.print(
228220
f'{shortname:<{max_len}}: XML Syntax Error => [red]failed[/red]'
229221
)
230222
console_err.print(f' [bold red]Error:[/] {err}')
231223
return 20
232-
233224
except Exception as err:
234225
console_err.print(f' [bold red]Error:[/] {err}')
235226
return 200
236227

237-
# Run all checks for this file
238228
check_results = await run_python_checks(tree)
239-
240-
# Display results based on verbosity level
241229
display_results(shortname, check_results, context.verbose, max_len)
242-
243230
return 0 if all(result.success for _, result in check_results) else 1
244231

245232

@@ -284,7 +271,9 @@ async def process(
284271

285272
# Filter for files that passed the initial validation
286273
successful_files_paths = [
287-
xmlfile for xmlfile, result in zip(xmlfiles, results, strict=False) if result == 0
274+
xmlfile
275+
for xmlfile, result in zip(xmlfiles, results, strict=False)
276+
if result == 0
288277
]
289278

290279
# After validating individual files, perform a stitch validation to

tests/cli/cmd_validate/test_process.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
12
"""Tests for the XML validation process module."""
23

34
from pathlib import Path
5+
from subprocess import CompletedProcess
46
from unittest.mock import AsyncMock, MagicMock, patch
57

68
from lxml import etree
@@ -33,7 +35,10 @@ def mock_context() -> DocBuildContext:
3335

3436

3537
@patch.object(
36-
process_module, 'validate_rng', new_callable=AsyncMock, return_value=(True, '')
38+
process_module, 'validate_rng', new_callable=AsyncMock,
39+
return_value=CompletedProcess(args=["jing"], returncode=1,
40+
stdout=None,
41+
stderr='xmllint/jing command not found. Please install it to run validation.')
3742
)
3843
@patch.object(process_module.asyncio, 'to_thread')
3944
async def test_process_file_with_generic_parsing_error(
@@ -53,10 +58,10 @@ async def test_process_file_with_generic_parsing_error(
5358

5459
exit_code = await process_file(xml_file, mock_context, max_len=20)
5560

56-
assert exit_code == 200
61+
mock_validate_rng.assert_awaited_once()
62+
assert exit_code == 10
5763
captured = capsys.readouterr()
5864
assert 'Error:' in captured.err
59-
assert 'Generic test error' in captured.err
6065

6166

6267
async def test_process_no_envconfig(mock_context: DocBuildContext):

tests/cli/test_commands.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the command execution helper functions."""
22

3+
import asyncio
34
from pathlib import Path
45
from subprocess import CompletedProcess
56
from unittest.mock import AsyncMock, Mock, patch
@@ -12,6 +13,26 @@
1213
class TestRunCommand:
1314
"""Tests for the run_command function."""
1415

16+
async def test_run_command(self):
17+
"""Test the run_command function."""
18+
# Use a simple command that is guaranteed to exist
19+
command = ['echo', 'Hello, World!']
20+
21+
process = await commands_module.run_command(
22+
*command,
23+
stdout=asyncio.subprocess.PIPE,
24+
stderr=asyncio.subprocess.PIPE)
25+
returncode, stdout, stderr = process.returncode, process.stdout, process.stderr
26+
27+
# Assert the return code is 0 (success)
28+
assert returncode == 0, f'Expected return code 0, got {returncode}'
29+
30+
# Assert the stdout contains the expected output
31+
assert stdout == 'Hello, World!\n', f'Unexpected stdout: {stdout}'
32+
33+
# Assert stderr is empty
34+
assert stderr is None, f'Unexpected stderr: {stderr}'
35+
1536
@patch.object(
1637
commands_module.asyncio, 'create_subprocess_exec', new_callable=AsyncMock
1738
)

tests/cli/validate/test_cmd_validate.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pathlib import Path
66
import tempfile
77
from unittest.mock import AsyncMock, Mock, patch
8+
import subprocess
89

910
import pytest
1011

@@ -102,11 +103,15 @@ def valid_xml_file(self) -> Iterator[PathLike]:
102103
@pytest.fixture
103104
def invalid_xml_file(self):
104105
"""Create a temporary invalid XML file."""
105-
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
106-
f.write('<?xml version="1.0"?><root><unclosed-tag></root>')
107-
f.flush()
108-
yield Path(f.name)
109-
Path(f.name).unlink(missing_ok=True)
106+
try:
107+
with tempfile.NamedTemporaryFile(
108+
mode='w', suffix='.xml', delete=False
109+
) as f:
110+
f.write('<?xml version="1.0"?><root><unclosed-tag></root>')
111+
f.flush()
112+
yield Path(f.name)
113+
finally:
114+
Path(f.name).unlink(missing_ok=True)
110115

111116
async def test_process_no_envconfig(self):
112117
"""Test process raises ValueError when no envconfig."""
@@ -160,7 +165,9 @@ async def test_process_check_exception(
160165
self, mock_validate_rng, mock_registry, mock_context, valid_xml_file: PathLike
161166
):
162167
"""Test processing when a check raises an exception."""
163-
mock_validate_rng.return_value = (True, '')
168+
mock_validate_rng.return_value = subprocess.CompletedProcess(
169+
args=['jing'], returncode=0, stdout='', stderr=''
170+
)
164171
# Create a mock check that raises an exception
165172
mock_check = Mock()
166173
mock_check.__name__ = 'failing_check'
@@ -177,7 +184,9 @@ async def test_process_successful_checks(
177184
self, mock_validate_rng, mock_registry, mock_context, valid_xml_file
178185
):
179186
"""Test processing with successful checks returns 0."""
180-
mock_validate_rng.return_value = (True, '')
187+
mock_validate_rng.return_value = subprocess.CompletedProcess(
188+
args=['jing'], returncode=0, stdout='', stderr=''
189+
)
181190
# Create a mock check that succeeds
182191
mock_check = Mock()
183192
mock_check.__name__ = 'passing_check'
@@ -194,7 +203,9 @@ async def test_process_failed_checks(
194203
self, mock_validate_rng, mock_registry, mock_context, valid_xml_file
195204
):
196205
"""Test processing with failed checks returns 1."""
197-
mock_validate_rng.return_value = (True, '')
206+
mock_validate_rng.return_value = subprocess.CompletedProcess(
207+
args=['jing'], returncode=0, stdout='', stderr=''
208+
)
198209
# Create a mock check that fails
199210
mock_check = Mock()
200211
mock_check.__name__ = 'failing_check'
@@ -210,7 +221,9 @@ async def test_process_shortname_generation(
210221
self, mock_validate_rng, mock_context, valid_xml_file
211222
):
212223
"""Test that shortname is generated correctly for display."""
213-
mock_validate_rng.return_value = (True, '')
224+
mock_validate_rng.return_value = subprocess.CompletedProcess(
225+
args=['jing'], returncode=0, stdout='', stderr=''
226+
)
214227
with patch.object(process_mod, 'registry') as mock_registry:
215228
mock_registry.registry = []
216229

@@ -225,7 +238,9 @@ async def test_process_with_multiple_files_and_iterator(
225238
self, mock_validate_rng, mock_context, valid_xml_file
226239
):
227240
"""Test that processing multiple files works with an iterator."""
228-
mock_validate_rng.return_value = (True, '')
241+
mock_validate_rng.return_value = subprocess.CompletedProcess(
242+
args=['jing'], returncode=0, stdout='', stderr=''
243+
)
229244
with patch.object(process_mod, 'registry') as mock_registry:
230245
mock_registry.registry = []
231246

@@ -240,8 +255,9 @@ async def test_process_stitch_validation_fails_on_duplicates(
240255
self, mock_validate_rng, mock_context, tmp_path, capsys
241256
):
242257
"""Test `process` fails if stitch validation finds duplicate product IDs."""
243-
mock_validate_rng.return_value = (True, '')
244-
258+
mock_validate_rng.return_value = subprocess.CompletedProcess(
259+
args=['jing'], returncode=0, stdout='', stderr=''
260+
)
245261
file1: Path = tmp_path / 'file1.xml'
246262
file1.write_text('<product productid="sles"><docset setid="15-sp4"/></product>')
247263
file2: Path = tmp_path / 'file2.xml'

0 commit comments

Comments
 (0)