Skip to content

Commit 00a9816

Browse files
committed
[feature] Add interactive recovery for docstrfmt errors
1 parent 5d233d4 commit 00a9816

File tree

4 files changed

+168
-24
lines changed

4 files changed

+168
-24
lines changed

openwisp_utils/releaser/changelog.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import sys
66
import tempfile
77

8+
import questionary
9+
810

911
def find_cliff_config():
1012
# Locates the cliff.toml file packaged within 'openwisp_utils'.
@@ -175,12 +177,29 @@ def format_rst_block(content):
175177
).strip()
176178
return formatted_block
177179

178-
except (subprocess.CalledProcessError, FileNotFoundError) as e:
179-
print("\nWarning: Could not format content with `docstrfmt`.", file=sys.stderr)
180-
if isinstance(e, subprocess.CalledProcessError):
181-
print(f"{e.stderr}", file=sys.stderr)
182-
# Return original content if formatting fails
183-
return content.strip()
180+
except subprocess.CalledProcessError as e:
181+
print(
182+
"\n❌ ERROR: Could not format changelog content with `docstrfmt`.",
183+
file=sys.stderr,
184+
)
185+
print("Linter output:", file=sys.stderr)
186+
indented_stderr = "\n".join(
187+
[f" {line}" for line in e.stderr.strip().split("\n")]
188+
)
189+
print(indented_stderr, file=sys.stderr)
190+
print(
191+
"\nThis is likely due to a syntax error in the generated changelog from git-cliff."
192+
)
193+
decision = questionary.confirm(
194+
"Continue with the unformatted changelog? You can fix the syntax manually later.",
195+
default=True,
196+
).ask()
197+
198+
if decision:
199+
return content.strip()
200+
else:
201+
print("\n❌ Release process aborted by user.")
202+
sys.exit(1)
184203
finally:
185204
if temp_file_path and os.path.exists(temp_file_path):
186205
os.remove(temp_file_path)

openwisp_utils/releaser/tests/test_changelog.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,42 @@ def test_run_git_cliff_called_process_error(mock_run, mock_find_config):
148148
run_git_cliff()
149149

150150

151-
@patch(
152-
"subprocess.run",
153-
side_effect=subprocess.CalledProcessError(1, "cmd", stderr="error"),
154-
)
155-
def test_format_rst_block_failure(mock_run):
156-
original_content = "Some content"
157-
formatted_content = format_rst_block(original_content)
158-
assert formatted_content == original_content.strip()
151+
@patch("openwisp_utils.releaser.changelog.questionary.confirm")
152+
@patch("openwisp_utils.releaser.changelog.subprocess.run")
153+
def test_format_rst_block_failure_user_continues(mock_subprocess, mock_questionary):
154+
"""Tests that if formatting fails, the user can choose to continue with the unformatted content."""
155+
# Simulate user choosing to continue
156+
mock_questionary.return_value.ask.return_value = True
157+
# Simulate a docstrfmt failure
158+
mock_subprocess.side_effect = subprocess.CalledProcessError(
159+
1, "docstrfmt", stderr="Syntax error on line 5."
160+
)
161+
original_content = "* a list"
162+
163+
result = format_rst_block(original_content)
164+
165+
# The function should return the original, unformatted content
166+
assert result == original_content
167+
# The user should have been prompted
168+
mock_questionary.assert_called_once()
169+
170+
171+
@patch("openwisp_utils.releaser.changelog.questionary.confirm")
172+
@patch("openwisp_utils.releaser.changelog.subprocess.run")
173+
def test_format_rst_block_failure_user_aborts(mock_subprocess, mock_questionary):
174+
"""Tests that if formatting fails, the user can choose to abort the release process."""
175+
# Simulate user choosing to abort
176+
mock_questionary.return_value.ask.return_value = False
177+
# Simulate a docstrfmt failure
178+
mock_subprocess.side_effect = subprocess.CalledProcessError(
179+
1, "docstrfmt", stderr="Syntax error on line 5."
180+
)
181+
182+
with pytest.raises(SystemExit):
183+
format_rst_block("* a list")
184+
185+
# The user should have been prompted
186+
mock_questionary.assert_called_once()
159187

160188

161189
SAMPLE_CHANGELOG = """Changelog

openwisp_utils/releaser/tests/test_utils.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import subprocess
12
from unittest.mock import MagicMock, mock_open, patch
23

34
import pytest
@@ -155,8 +156,8 @@ def test_get_release_block_from_md_file(mock_file):
155156

156157
@patch("openwisp_utils.releaser.utils.subprocess.run")
157158
@patch("builtins.print")
158-
def test_format_file_with_docstrfmt(mock_print, mock_subprocess):
159-
"""Tests the `format_file_with_docstrfmt` function."""
159+
def test_format_file_with_docstrfmt_success(mock_print, mock_subprocess):
160+
"""Tests the successful `format_file_with_docstrfmt` function."""
160161
file_path = "CHANGES.rst"
161162
format_file_with_docstrfmt(file_path)
162163

@@ -178,6 +179,66 @@ def test_format_file_with_docstrfmt(mock_print, mock_subprocess):
178179
mock_print.assert_called_once_with(f"✅ Formatted {file_path} successfully.")
179180

180181

182+
@patch("openwisp_utils.releaser.utils.questionary.select")
183+
@patch("openwisp_utils.releaser.utils.subprocess.run")
184+
def test_format_file_interactive_retry_success(mock_subprocess, mock_questionary):
185+
# Tests the interactive retry loop in `format_file_with_docstrfmt`.
186+
# The first format attempt fails, the user "fixes" it, and the second succeeds.
187+
188+
# First call fails, second call succeeds
189+
mock_subprocess.side_effect = [
190+
subprocess.CalledProcessError(1, "docstrfmt", stderr="Syntax error"),
191+
MagicMock(), # Represents a successful run
192+
]
193+
# User chooses to retry
194+
mock_questionary.return_value.ask.return_value = "I have fixed the file, try again."
195+
196+
format_file_with_docstrfmt("CHANGES.rst")
197+
198+
# subprocess.run should have been called twice
199+
assert mock_subprocess.call_count == 2
200+
mock_questionary.assert_called_once()
201+
202+
203+
@patch("openwisp_utils.releaser.utils.questionary.select")
204+
@patch("openwisp_utils.releaser.utils.subprocess.run")
205+
def test_format_file_interactive_skip(mock_subprocess, mock_questionary):
206+
"""Tests that the user can skip formatting in `format_file_with_docstrfmt`."""
207+
# The call to docstrfmt fails
208+
mock_subprocess.side_effect = subprocess.CalledProcessError(
209+
1, "docstrfmt", stderr="Syntax error"
210+
)
211+
# User chooses to skip
212+
mock_questionary.return_value.ask.return_value = (
213+
"Skip formatting and continue with the unformatted file."
214+
)
215+
216+
format_file_with_docstrfmt("CHANGES.rst")
217+
218+
# subprocess.run should only have been called once
219+
assert mock_subprocess.call_count == 1
220+
mock_questionary.assert_called_once()
221+
222+
223+
@patch("openwisp_utils.releaser.utils.questionary.select")
224+
@patch("openwisp_utils.releaser.utils.subprocess.run")
225+
def test_format_file_interactive_abort(mock_subprocess, mock_questionary):
226+
"""Tests that the user can abort the release in `format_file_with_docstrfmt`."""
227+
# The call to docstrfmt fails
228+
mock_subprocess.side_effect = subprocess.CalledProcessError(
229+
1, "docstrfmt", stderr="Syntax error"
230+
)
231+
# User chooses to abort
232+
mock_questionary.return_value.ask.return_value = "Abort the release process."
233+
234+
with pytest.raises(SystemExit):
235+
format_file_with_docstrfmt("CHANGES.rst")
236+
237+
# subprocess.run should have been called once before aborting
238+
assert mock_subprocess.call_count == 1
239+
mock_questionary.assert_called_once()
240+
241+
181242
@patch("requests.request")
182243
def test_retryable_request_success(mock_request):
183244
"""Tests that the function returns a response on the first successful call."""

openwisp_utils/releaser/utils.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,47 @@ def rst_to_markdown(text):
9595

9696
def format_file_with_docstrfmt(file_path):
9797
"""Format a file using `docstrfmt`."""
98-
subprocess.run(
99-
["docstrfmt", "--ignore-cache", "--line-length", "74", file_path],
100-
check=True,
101-
capture_output=True,
102-
text=True,
103-
encoding="utf-8",
104-
)
105-
print(f"✅ Formatted {file_path} successfully.")
98+
while True:
99+
try:
100+
subprocess.run(
101+
["docstrfmt", "--ignore-cache", "--line-length", "74", file_path],
102+
check=True,
103+
capture_output=True,
104+
text=True,
105+
encoding="utf-8",
106+
)
107+
print(f"✅ Formatted {file_path} successfully.")
108+
break
109+
except subprocess.CalledProcessError as e:
110+
print(
111+
f"\n❌ ERROR: `docstrfmt` failed to format '{file_path}'.",
112+
file=sys.stderr,
113+
)
114+
print("Linter output:", file=sys.stderr)
115+
# Indent the error for readability
116+
indented_stderr = "\n".join(
117+
[f" {line}" for line in e.stderr.strip().split("\n")]
118+
)
119+
print(indented_stderr, file=sys.stderr)
120+
121+
print(
122+
f"\nThis is likely caused by a syntax error in '{file_path}'.\n"
123+
"Please open the file in another terminal, fix the issue, and save it."
124+
)
125+
decision = questionary.select(
126+
"How would you like to proceed?",
127+
choices=[
128+
"I have fixed the file, try again.",
129+
"Skip formatting and continue with the unformatted file.",
130+
"Abort the release process.",
131+
],
132+
).ask()
133+
134+
if decision == "I have fixed the file, try again.":
135+
continue
136+
elif decision == "Skip formatting and continue with the unformatted file.":
137+
print(f"⚠️ Skipped formatting for '{file_path}'.")
138+
break
139+
else: # Abort or None
140+
print("\n❌ Release process aborted by user.")
141+
sys.exit(1)

0 commit comments

Comments
 (0)