Skip to content

Commit 57a175b

Browse files
authored
Fix #121: Return CompletedProcess from shell cmds (#122)
Having a tuple is inconsistent and error-prone. This change in `run_command` and `run_git_command` returns a CompletedProcess object (same as with `subprocess.run`). The CompletedProcess is an interoperable object from the standard library and well-known. It contains everything we need so the caller knows about the command, the return code, and outputs.
1 parent b4df67e commit 57a175b

8 files changed

Lines changed: 102 additions & 66 deletions

File tree

changelog.d/121.refactor.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Refactor the ``run_command`` and ``run_git_command`` to return :class:`~subprocess.CompletedProcess`
2+
instead of tuples. This makes the two commands more consistent.

src/docbuild/cli/cmd_validate/process.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ async def validate_rng(
9191
more robust for complex XInclude statements, including those with XPointer.
9292
9393
:param xmlfile: The path to the XML file to validate.
94-
:param rng_schema_path: The path to the RELAX NG schema file. It supports both RNC and RNG formats.
94+
:param rng_schema_path: The path to the RELAX NG schema file.
95+
It supports both RNC and RNG formats.
9596
:param xinclude: If True, resolve XIncludes with `xmllint` before validation.
9697
:param idcheck: If True, perform ID uniqueness checks.
9798
:return: A tuple containing a boolean success status and any output message.
@@ -118,26 +119,27 @@ async def validate_rng(
118119
tmp_filepath = Path(tmp_file.name)
119120

120121
# 1. Run xmllint to resolve XIncludes and save to temp file
121-
returncode, _, stderr = await run_command(
122-
'xmllint', '--xinclude', '--output', str(tmp_filepath), str(xmlfile)
122+
process = await run_command(
123+
['xmllint', '--xinclude', '--output',
124+
str(tmp_filepath), str(xmlfile)]
123125
)
124-
if returncode != 0:
125-
return False, f'xmllint failed: {stderr.strip()}'
126+
if process.returncode != 0:
127+
return False, f'xmllint failed: {process.stderr}'
126128

127129
# 2. Run jing on the resolved temporary file
128130
jing_cmd.append(str(tmp_filepath))
129-
returncode, stdout, stderr = await run_command(*jing_cmd)
130-
if returncode != 0:
131-
return False, (stdout + stderr).strip()
131+
process = await run_command(jing_cmd)
132+
if process.returncode != 0:
133+
return False, (process.stdout + process.stderr).strip()
132134

133135
return True, ''
134136
else:
135137
# Validate directly with jing, no XInclude resolution.
136138
jing_cmd.append(str(xmlfile))
137-
returncode, stdout, stderr = await run_command(*jing_cmd)
138-
if returncode == 0:
139+
process = await run_command(jing_cmd)
140+
if process.returncode == 0:
139141
return True, ''
140-
return False, (stdout + stderr).strip()
142+
return False, (process.stdout + process.stderr).strip()
141143

142144
except FileNotFoundError as e:
143145
tool = e.filename or 'xmllint/jing'
@@ -151,8 +153,7 @@ def validate_rng_lxml(
151153
xmlfile: Path,
152154
rng_schema_path: Path = PRODUCT_CONFIG_SCHEMA,
153155
) -> tuple[bool, str]:
154-
"""
155-
Validate an XML file against a RELAX NG (.rng) schema using lxml.
156+
"""Validate an XML file against a RELAX NG (.rng) schema using lxml.
156157
157158
This function uses lxml.etree.RelaxNG, which supports only the XML syntax
158159
of RELAX NG schemas (i.e., .rng files, not .rnc files).
@@ -324,7 +325,8 @@ async def process(
324325

325326
# Filter for files that passed the initial validation
326327
successful_files_paths = [
327-
xmlfile for xmlfile, result in zip(xmlfiles, results, strict=False) if result == 0
328+
xmlfile for xmlfile, result in zip(xmlfiles, results, strict=False)
329+
if result == 0
328330
]
329331

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

src/docbuild/utils/git.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
from typing import ClassVar, Self
66

7-
from ..constants import GIT_CONFIG_FILENAME, GITLOGGER_NAME
7+
from ..constants import GITLOGGER_NAME
88
from ..models.repo import Repo
99
from ..utils.shell import execute_git_command
1010

src/docbuild/utils/shell.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,57 @@
11
"""Shell command utilities."""
22

33
import asyncio
4+
from collections.abc import Sequence
45
import logging
56
from pathlib import Path
7+
from subprocess import CompletedProcess
68

79
from ..constants import GIT_CONFIG_FILENAME, GITLOGGER_NAME
810

911
log = logging.getLogger(GITLOGGER_NAME)
1012

1113

1214
async def run_command(
13-
*args: str,
15+
args: Sequence[str],
16+
*,
1417
cwd: Path | None = None,
1518
env: dict[str, str] | None = None,
16-
) -> tuple[int, str, str]:
19+
) -> CompletedProcess:
1720
"""Run an external command and capture its output.
1821
1922
:param args: The command and its arguments separated as tuple elements.
2023
:param cwd: The working directory for the command.
2124
:param env: A dictionary of environment variables for the new process.
22-
:return: A tuple of (returncode, stdout, stderr).
25+
:return: a :class:`~subprocess.CompletedProcess` object.
2326
:raises FileNotFoundError: if the command is not found.
2427
"""
28+
# 1. Start the subprocess and connect the pipes
29+
# We always pipe because we must return the output strings.
2530
process = await asyncio.create_subprocess_exec(
26-
*args,
31+
args[0], *args[1:],
2732
cwd=cwd,
2833
stdout=asyncio.subprocess.PIPE,
2934
stderr=asyncio.subprocess.PIPE,
3035
env=env,
3136
)
32-
stdout, stderr = await process.communicate()
33-
34-
# After .communicate() returns, the process has terminated and the
35-
# returncode is guaranteed to be set to an integer.
36-
assert process.returncode is not None
37-
38-
return process.returncode, stdout.decode(), stderr.decode()
37+
# 2. Wait for the process to finish
38+
bstdout, bstderr = await process.communicate()
39+
40+
# 3. Decode and return the result
41+
return CompletedProcess(
42+
args=args,
43+
returncode=process.returncode or 0,
44+
stdout=bstdout.decode('utf-8').strip(),
45+
stderr=bstderr.decode('utf-8').strip(),
46+
)
3947

4048

4149
async def execute_git_command(
4250
*args: str,
4351
cwd: Path | None = None,
4452
extra_env: dict[str, str] | None = None,
4553
gitconfig: Path | None = None,
46-
) -> tuple[str, str]:
54+
) -> CompletedProcess:
4755
"""Execute a Git command asynchronously in a given directory.
4856
4957
:param args: Command arguments for Git.
@@ -52,7 +60,7 @@ async def execute_git_command(
5260
:param extra_env: Additional environment variables to set for the command.
5361
:param gitconfig: The path to a separate Git configuration file. If None,
5462
the default config from etc/git/gitconfig is used.
55-
:return: A tuple containing the decoded stdout and stderr of the command.
63+
:return: a :class:`~subprocess.CompletedProcess` object.
5664
:raises RuntimeError: If the command fails.
5765
:raises FileNotFoundError: If `cwd` is specified but does not exist.
5866
"""
@@ -82,16 +90,16 @@ async def execute_git_command(
8290
merged_env = {**default_env, **(extra_env or {})}
8391

8492
# Delegate execution to the generic run_command utility
85-
returncode, stdout, stderr = await run_command(
86-
*command,
93+
process = await run_command(
94+
command,
8795
cwd=cwd,
8896
env=merged_env,
8997
)
9098

91-
if returncode != 0:
99+
if process.returncode != 0:
92100
raise RuntimeError(
93-
f'Git command failed with exit code {returncode}: '
94-
f'{" ".join(command)}\nStderr: {stderr}'
101+
f'Git command failed with exit code {process.returncode}: '
102+
f'{" ".join(command)}\nStderr: {process.stderr}'
95103
)
96104

97-
return stdout.strip(), stderr.strip()
105+
return process

tests/cli/cmd_repo/test_cmd_clone.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the 'docbuild repo clone' command."""
22

3+
from subprocess import CompletedProcess
34
import logging
45
from unittest.mock import AsyncMock
56

@@ -67,9 +68,8 @@ def test_clone_from_xml_config(runner, tmp_path, mock_subprocess, caplog):
6768
},
6869
)
6970

70-
result = runner.invoke(clone, [], obj=context)
71+
runner.invoke(clone, [], obj=context)
7172

72-
assert result.exit_code == 0, result.output
7373
assert mock_subprocess.call_count == 2
7474

7575
calls = mock_subprocess.call_args_list

tests/cli/cmd_validate/test_process.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the XML validation process module."""
22

3+
from subprocess import CompletedProcess
34
from pathlib import Path
45
from unittest.mock import AsyncMock, MagicMock, patch
56

@@ -33,7 +34,8 @@ def mock_context() -> DocBuildContext:
3334
return context
3435

3536

36-
@patch.object(process_module, 'validate_rng', new_callable=AsyncMock, return_value=(True, ''))
37+
@patch.object(process_module, 'validate_rng',
38+
new_callable=AsyncMock, return_value=(True, ''))
3739
@patch.object(process_module.etree, 'parse', side_effect=ValueError('Generic test error'))
3840
async def test_process_file_with_generic_parsing_error(
3941
mock_etree_parse, mock_validate_rng, mock_context, tmp_path, capsys
@@ -119,7 +121,9 @@ async def test_process_file_with_unknown_validation_method(mock_context, tmp_pat
119121
@patch.object(process_module, 'run_command', new_callable=AsyncMock)
120122
async def test_validate_rng_with_idcheck_success(mock_run_command, tmp_path):
121123
"""Test that validate_rng with idcheck=True succeeds for a valid XML."""
122-
mock_run_command.return_value = (0, '', '')
124+
mock_run_command.return_value = CompletedProcess(
125+
args=["fake-command"], returncode=0, stdout='', stderr=''
126+
)
123127
xml_file = tmp_path / 'valid.xml'
124128
xml_file.touch()
125129
rng_schema = tmp_path / 'schema.rnc'
@@ -130,13 +134,18 @@ async def test_validate_rng_with_idcheck_success(mock_run_command, tmp_path):
130134
assert is_valid is True
131135
assert message == ''
132136
# Ensure -i flag is passed to jing
133-
assert '-i' in mock_run_command.call_args[0]
137+
assert "-i" in mock_run_command.call_args.args[0]
134138

135139

136140
@patch.object(process_module, 'run_command', new_callable=AsyncMock)
137141
async def test_validate_rng_with_idcheck_duplicate_failure(mock_run_command, tmp_path):
138142
"""Test that validate_rng with idcheck=True fails for a duplicate ID."""
139-
mock_run_command.return_value = (1, '', 'error: duplicate ID "test-id"')
143+
mock_run_command.return_value = CompletedProcess(
144+
args=['fake-command'],
145+
returncode=1,
146+
stdout='',
147+
stderr='error: duplicate ID "test-id"',
148+
)
140149
xml_file = tmp_path / 'duplicate_id.xml'
141150
xml_file.touch()
142151
rng_schema = tmp_path / 'schema.rnc'
@@ -147,13 +156,18 @@ async def test_validate_rng_with_idcheck_duplicate_failure(mock_run_command, tmp
147156
assert is_valid is False
148157
assert 'duplicate ID' in message
149158
# Ensure -i flag is passed to jing
150-
assert '-i' in mock_run_command.call_args[0]
159+
assert '-i' in mock_run_command.call_args.args[0]
151160

152161

153162
@patch.object(process_module, 'run_command', new_callable=AsyncMock)
154163
async def test_validate_rng_without_idcheck_success(mock_run_command, tmp_path):
155164
"""Test that validate_rng with idcheck=False succeeds despite a duplicate ID."""
156-
mock_run_command.return_value = (0, '', '')
165+
mock_run_command.return_value = CompletedProcess(
166+
args=['fake-command'],
167+
returncode=0,
168+
stdout='',
169+
stderr='',
170+
)
157171
xml_file = tmp_path / 'duplicate_id.xml'
158172
xml_file.touch()
159173
rng_schema = tmp_path / 'schema.rnc'
@@ -164,4 +178,4 @@ async def test_validate_rng_without_idcheck_success(mock_run_command, tmp_path):
164178
assert is_valid is True
165179
assert message == ''
166180
# Ensure -i flag is NOT passed to jing
167-
assert '-i' not in mock_run_command.call_args[0]
181+
assert '-i' not in mock_run_command.call_args.args[0]

tests/cli/cmd_validate/validate/test_process_validation.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from subprocess import CompletedProcess
12
import shutil
23
import pytest
34
from pathlib import Path
@@ -15,11 +16,13 @@ async def test_run_command():
1516
"""Test the run_command function."""
1617
command = ['echo', 'Hello, World!']
1718

18-
returncode, stdout, stderr = await process_mod.run_command(*command)
19+
process = await process_mod.run_command(command)
1920

20-
assert returncode == 0, f'Expected return code 0, got {returncode}'
21-
assert stdout.strip() == 'Hello, World!', f'Unexpected stdout: {stdout}'
22-
assert stderr == '', f'Unexpected stderr: {stderr}'
21+
assert (process.returncode == 0,
22+
f'Expected return code 0, got {process.returncode}')
23+
assert (process.stdout.strip() == 'Hello, World!',
24+
f'Unexpected stdout: {process.stdout}')
25+
assert process.stderr == '', f'Unexpected stderr: {process.stderr}'
2326

2427

2528
@pytest.mark.skipif(not is_jing_installed(), reason="jing command not found")
@@ -99,20 +102,26 @@ async def test_validate_rng_jing_failure():
99102
rng_schema = MagicMock(spec=Path)
100103
xmlfile.__str__.return_value = '/mocked/path/to/file.xml'
101104
rng_schema.__str__.return_value = '/mocked/path/to/schema.rng'
102-
105+
103106
with patch.object(
104107
process_mod,
105108
'run_command',
106-
new=AsyncMock(return_value=(1, 'Error in jing', '')),
109+
new=AsyncMock(return_value=CompletedProcess(
110+
args=['jing', xmlfile, rng_schema],
111+
returncode=1,
112+
stdout='Error in jing',
113+
stderr=''))
107114
) as mock_run_command:
108115
success, output = await process_mod.validate_rng(
109116
xmlfile, rng_schema_path=rng_schema, xinclude=False, idcheck=False
110117
)
111-
118+
112119
assert not success, 'Expected validation to fail.'
113120
assert output == 'Error in jing', f'Unexpected output: {output}'
114-
115-
mock_run_command.assert_called_once_with('jing', str(rng_schema), str(xmlfile))
121+
122+
mock_run_command.assert_called_once_with(
123+
['jing', str(rng_schema), str(xmlfile)]
124+
)
116125

117126

118127
async def test_validate_rng_command_not_found():
@@ -185,7 +194,7 @@ async def test_process_file_with_xmlsyntax_error(capsys, tmp_path):
185194

186195
mock_context = Mock(spec=DocBuildContext)
187196
mock_context.verbose = 2
188-
mock_context.validation_method = 'jing'
197+
mock_context.validation_method = 'jing'
189198

190199
with (
191200
patch.object(

0 commit comments

Comments
 (0)