Skip to content
This repository was archived by the owner on Jun 15, 2025. It is now read-only.

Commit 164aa47

Browse files
committed
Introduce ProcessError exception class
Until now we used the built in ChildProcessError exception class for our error reporting. This class essentially has two flaws: first, the attributes it uses (derived from OSError) do not match our intention. Instead of an 'errno' value we have a status code. The error string ('strerror') is the failed process' command line instead, and the 'filename' in our case is the gathered stderr output. Second, and much more importantly, using the 'filename' attribute for transferring the standard error message to the user has a significant drawback: it does not interpret newline characters as line breaks but rather prints the string '\n' instead. This is not only ugly but also unreadable for longer outputs such as Python exception backtraces. This change introduces a new exception class that we use throughout the program: ProcessError. The class provides a string formatting method that properly interprets newline characters, resulting in much nicer backtraces. On the downside, the exception is not built-in and has to be made available explicitly. However, since it is derived from ChildProcessError (as opposed to OSError) it can at the very least be used instead of the latter, which should be specific enough to not catch any unwanted exceptions. import subrepo ./:execute at 5d3a418640120eb0dedad672c1c62cb0f5c33eed import subrepo ./:cleanup at 30129de755f5f7a5b9647b792ca1373b94e15dcb
1 parent 036ea8b commit 164aa47

File tree

7 files changed

+104
-23
lines changed

7 files changed

+104
-23
lines changed

btrfs-backup/src/deso/btrfs/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
from deso.btrfs.commands import (
3030
checkFileString,
3131
)
32+
from deso.execute import (
33+
ProcessError,
34+
)
3235
from sys import (
3336
argv as sysargv,
3437
stderr,
@@ -83,7 +86,7 @@ def run(method, subvolumes, src_repo, dst_repo, debug=False, **kwargs):
8386
program = Program(subvolumes, src_repo, dst_repo)
8487
method(program)(**kwargs)
8588
return 0
86-
except ChildProcessError as e:
89+
except ProcessError as e:
8790
if debug:
8891
raise
8992
print("Execution failure:\n\"%s\"" % e, file=stderr)

btrfs-backup/src/deso/btrfs/repository.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
)
5151
from deso.execute import (
5252
execute,
53+
ProcessError,
5354
)
5455
from os import (
5556
curdir,
@@ -190,7 +191,7 @@ def isDir(directory):
190191

191192
execute(*cmd, stderr=repository.stderr)
192193
return True
193-
except ChildProcessError:
194+
except ProcessError:
194195
return False
195196

196197
# TODO: We might want to move this invocation into _findRoot to avoid
@@ -210,7 +211,7 @@ def isDir(directory):
210211
# actually succeeds), and in case of the root directory it will be
211212
# matched here.
212213
return len(output) == 1 and output[0].endswith(_SHOW_IS_ROOT)
213-
except ChildProcessError:
214+
except ProcessError:
214215
# Starting with btrfs-progs-3.17.3 the show command will return an
215216
# error code in case the supplied directory is not a btrfs
216217
# subvolume which will manifest as an exception here.

btrfs-backup/src/deso/btrfs/test/testMain.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
execute,
5555
findCommand,
5656
pipeline,
57+
ProcessError,
5758
)
5859
from os import (
5960
environ,
@@ -105,7 +106,7 @@ def testDurationParsingFailure(self):
105106
def testInvokeNoArguments(self):
106107
"""Verify the intended output is printed when the program is run without arguments."""
107108
regex = "the following arguments are required"
108-
with self.assertRaisesRegex(ChildProcessError, regex):
109+
with self.assertRaisesRegex(ProcessError, regex):
109110
execute(executable, "-m", "deso.btrfs.main")
110111

111112

@@ -139,7 +140,7 @@ def runAndTest(*args):
139140
runMain(*args)
140141
# The command should fail due to missing arguments.
141142
self.assertFalse(True)
142-
except ChildProcessError as e:
143+
except ProcessError as e:
143144
string = str(e)
144145

145146
self.assertRegex(string, "Usage:")
@@ -175,15 +176,15 @@ def runMain(*args):
175176
execute(executable, "-m", "deso.btrfs.main", *args)
176177

177178
regex = r"Extension must not start"
178-
with self.assertRaisesRegex(ChildProcessError, regex):
179+
with self.assertRaisesRegex(ProcessError, regex):
179180
runMain("backup", "--snapshot-ext=%senc" % extsep)
180181

181182
regex = r"The last receive filter must contain"
182-
with self.assertRaisesRegex(ChildProcessError, regex):
183+
with self.assertRaisesRegex(ProcessError, regex):
183184
runMain("backup", "--snapshot-ext=gz", "--recv-filter", "/bin/gzip")
184185

185186
regex = r"The first send filter must contain"
186-
with self.assertRaisesRegex(ChildProcessError, regex):
187+
with self.assertRaisesRegex(ProcessError, regex):
187188
runMain("restore", "--snapshot-ext=gz", "--send-filter", "/bin/gzip")
188189

189190
# TODO: We should likely add a test to verify that the {file}
@@ -194,12 +195,12 @@ def runMain(*args):
194195
# snapshot-ext option but other arguments are missing so we still
195196
# bail out.
196197
regex = r"arguments are required"
197-
with self.assertRaisesRegex(ChildProcessError, regex):
198+
with self.assertRaisesRegex(ProcessError, regex):
198199
runMain("backup", "--snapshot-ext=gz", "--recv-filter=/bin/gzip",
199200
"--recv-filter", "/bin/dd of={file}")
200201

201202
regex = r"arguments are required"
202-
with self.assertRaisesRegex(ChildProcessError, regex):
203+
with self.assertRaisesRegex(ProcessError, regex):
203204
runMain("restore", "--snapshot-ext=gz",
204205
"--send-filter=/bin/dd if={file}",
205206
"--send-filter", "/bin/gzip")
@@ -363,7 +364,7 @@ def testNoStderrRead(self):
363364
# followed by an error string containing the program's stderr
364365
# output.
365366
regex = r"subvol-2015-01-29_20:59:00$"
366-
with self.assertRaisesRegex(ChildProcessError, regex):
367+
with self.assertRaisesRegex(ProcessError, regex):
367368
btrfsMain([argv[0]] + args.split())
368369

369370

btrfs-backup/src/deso/btrfs/test/testRepository.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from deso.execute import (
6161
execute,
6262
findCommand,
63+
ProcessError,
6364
)
6465
from glob import (
6566
glob,
@@ -431,7 +432,7 @@ def testRepositorySyncFailsForNonExistentSubvolume(self):
431432
src = Repository(snaps)
432433
dst = Repository(backup)
433434

434-
with self.assertRaisesRegex(ChildProcessError, regex):
435+
with self.assertRaisesRegex(ProcessError, regex):
435436
syncRepos([m.path(directory)], src, dst)
436437

437438
self.assertEqual(glob(join(snaps, "*")), [])
@@ -755,7 +756,7 @@ def syncAndRestore(send_filters=None, recv_filters=None):
755756
# decompressed by bzip2 which is bound to fail. This is our
756757
# way of verifying that actual compression and decompression
757758
# happens.
758-
with self.assertRaises(ChildProcessError):
759+
with self.assertRaises(ProcessError):
759760
syncAndRestore(send_filters, list(reversed(recv_filters)))
760761

761762
# Case 3) Sync and restore with properly ordered filters. Everything
@@ -787,7 +788,7 @@ def testRepositoryRestoreFailExists(self):
787788
make(m, "root", data=b"root")
788789

789790
regex = r"%s.*exists and it is not a directory" % m.path("root")
790-
with self.assertRaisesRegex(ChildProcessError, regex):
791+
with self.assertRaisesRegex(ProcessError, regex):
791792
restore([m.path("root")], bak, src)
792793

793794

execute/src/deso/execute/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
execute,
2525
formatCommands,
2626
pipeline,
27+
ProcessError,
2728
spring,
2829
)
2930
from deso.execute.util import (

execute/src/deso/execute/execute_.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,56 @@
8383
)
8484

8585

86+
class ProcessError(ChildProcessError):
87+
"""A class enhancing OSError with proper attributes for our use case.
88+
89+
The OSError attributes errno, filename, and filename2 do not really
90+
describe our use case. Most importantly, however, OSError does not
91+
interpret the filename arguments in any way, meaning newline
92+
characters will be printed directly as '\n' instead of resulting in
93+
a line break.
94+
"""
95+
def __init__(self, status, name, stderr=None):
96+
super().__init__()
97+
98+
self._status = status
99+
self._name = name
100+
self._stderr = stderr
101+
102+
103+
def __str__(self):
104+
"""Convert the error into a human readable string."""
105+
s = "[Status {status:d}] {name}"
106+
if self._stderr:
107+
s += ": '{stderr}'"
108+
109+
# Note that even if _stderr is None (and the string does not contain
110+
# the {stderr} string) we can apply the formatting for this key
111+
# anyways.
112+
s = s.format(status=self._status,
113+
name=self._name,
114+
stderr=self._stderr)
115+
return s
116+
117+
118+
@property
119+
def status(self):
120+
"""Retrieve the status code of the failed process."""
121+
return self._status
122+
123+
124+
@property
125+
def name(self):
126+
"""Retrieve the name/command of the process that failed."""
127+
return self._name
128+
129+
130+
@property
131+
def stderr(self):
132+
"""Retrieve the stderr output, if any, of the process that failed."""
133+
return self._stderr
134+
135+
86136
def execute(*args, stdin=None, stdout=None, stderr=b""):
87137
"""Execute a program synchronously."""
88138
# Note that 'args' is a tuple. We do not want that so explicitly
@@ -242,7 +292,7 @@ def _wait(pids, commands, data_err, failed=None):
242292

243293
if failed:
244294
error = data_err.decode("utf-8") if data_err else None
245-
raise ChildProcessError(status, failed, error)
295+
raise ProcessError(status, failed, error)
246296

247297

248298
def _write(data):

execute/src/deso/execute/test/testExecute.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
findCommand,
2525
formatCommands,
2626
pipeline as pipeline_,
27+
ProcessError,
2728
spring as spring_
2829
)
2930
from deso.execute.execute_ import (
@@ -86,6 +87,29 @@ def spring(commands, stdout=None, stderr=None):
8687

8788
class TestExecute(TestCase):
8889
"""A test case for command execution functionality."""
90+
def testProcessErrorNoStderr(self):
91+
"""Verify that when not reading stderr output we get a rather generic error report."""
92+
tmp_file = mktemp()
93+
regex = r"^\[Status [0-9]+\] %s %s$" % (_CAT, tmp_file)
94+
95+
with self.assertRaisesRegex(ProcessError, regex):
96+
# Do not read from stderr here.
97+
execute(_CAT, tmp_file)
98+
99+
100+
def testProcessErrorFormattingStderr(self):
101+
"""Verify that the ProcessError class properly handles new lines."""
102+
tmp_file = mktemp()
103+
# Note that when using ChildProcessError the newline would not
104+
# appear as a true newline but rather as the actual text '\n' (i.e.,
105+
# \\n in a string). With ProcessError we are supposed to get a
106+
# properly interpreted newline.
107+
regex = r".*No such file or directory\n"
108+
109+
with self.assertRaisesRegex(ProcessError, regex):
110+
execute(_CAT, tmp_file, stderr=b"")
111+
112+
89113
def testExecuteErrorEventToStringSingle(self):
90114
"""Verify that our event to string conversion works as expected."""
91115
self.assertEqual(eventToString(POLLERR), "ERR")
@@ -152,7 +176,7 @@ def testExecuteAndRedirectError(self):
152176
regex = r"No such file or directory"
153177

154178
with TemporaryFile() as file_:
155-
with self.assertRaises(ChildProcessError):
179+
with self.assertRaises(ProcessError):
156180
execute(_CAT, path, stderr=file_.fileno())
157181

158182
file_.seek(0)
@@ -184,7 +208,7 @@ def testExecuteRedirectAll(self):
184208

185209
def testExecuteThrowsOnCommandFailure(self):
186210
"""Verify that a failing command causes an exception to be raised."""
187-
with self.assertRaises(ChildProcessError):
211+
with self.assertRaises(ProcessError):
188212
execute(_FALSE)
189213

190214

@@ -196,14 +220,14 @@ def testExecuteThrowsAndReportsError(self):
196220
with self.assertRaises(AssertionError):
197221
# This command should fail the assertion because reading from
198222
# standard error is not turned on and as a result the error message
199-
# printed on stderr will not be contained in the ChildProcessError
223+
# printed on stderr will not be contained in the ProcessError
200224
# error message.
201-
with self.assertRaisesRegex(ChildProcessError, regex):
225+
with self.assertRaisesRegex(ProcessError, regex):
202226
execute(_CAT, path)
203227

204228
# When reading from stderr is turned on the exception must contain
205229
# the above phrase.
206-
with self.assertRaisesRegex(ChildProcessError, regex):
230+
with self.assertRaisesRegex(ProcessError, regex):
207231
execute(_CAT, path, stderr=b"")
208232

209233

@@ -222,7 +246,7 @@ def testPipelineThrowsForFirstFailure(self):
222246
[cmd],
223247
]
224248

225-
with self.assertRaisesRegex(ChildProcessError, regex):
249+
with self.assertRaisesRegex(ProcessError, regex):
226250
pipeline(commands, stderr=b"")
227251

228252

@@ -367,7 +391,7 @@ def testPipelineWithFailingCommand(self):
367391
# Run twice, once with failing command at the end and once somewhere
368392
# in the middle.
369393
for _ in range(2):
370-
with self.assertRaises(ChildProcessError):
394+
with self.assertRaises(ProcessError):
371395
pipeline(commands)
372396

373397
commands += [identity]
@@ -431,7 +455,7 @@ def testSpringError(self):
431455
cmd2,
432456
]
433457

434-
with self.assertRaisesRegex(ChildProcessError, regex):
458+
with self.assertRaisesRegex(ProcessError, regex):
435459
spring(commands, stderr=b"")
436460

437461

0 commit comments

Comments
 (0)