diff options
-rw-r--r-- | error.py | 106 | ||||
-rw-r--r-- | git_command.py | 74 | ||||
-rw-r--r-- | subcmds/__init__.py | 2 | ||||
-rw-r--r-- | tests/test_error.py | 18 | ||||
-rw-r--r-- | tests/test_git_command.py | 51 |
5 files changed, 218 insertions, 33 deletions
@@ -12,8 +12,51 @@ | |||
12 | # See the License for the specific language governing permissions and | 12 | # See the License for the specific language governing permissions and |
13 | # limitations under the License. | 13 | # limitations under the License. |
14 | 14 | ||
15 | from typing import List | ||
15 | 16 | ||
16 | class ManifestParseError(Exception): | 17 | |
18 | class BaseRepoError(Exception): | ||
19 | """All repo specific exceptions derive from BaseRepoError.""" | ||
20 | |||
21 | |||
22 | class RepoError(BaseRepoError): | ||
23 | """Exceptions thrown inside repo that can be handled.""" | ||
24 | |||
25 | def __init__(self, *args, project: str = None) -> None: | ||
26 | super().__init__(*args) | ||
27 | self.project = project | ||
28 | |||
29 | |||
30 | class RepoExitError(BaseRepoError): | ||
31 | """Exception thrown that result in termination of repo program. | ||
32 | - Should only be handled in main.py | ||
33 | """ | ||
34 | |||
35 | def __init__( | ||
36 | self, | ||
37 | *args, | ||
38 | exit_code: int = 1, | ||
39 | aggregate_errors: List[Exception] = None, | ||
40 | **kwargs, | ||
41 | ) -> None: | ||
42 | super().__init__(*args, **kwargs) | ||
43 | self.exit_code = exit_code | ||
44 | self.aggregate_errors = aggregate_errors | ||
45 | |||
46 | |||
47 | class RepoUnhandledExceptionError(RepoExitError): | ||
48 | """Exception that maintains error as reason for program exit.""" | ||
49 | |||
50 | def __init__( | ||
51 | self, | ||
52 | error: BaseException, | ||
53 | **kwargs, | ||
54 | ) -> None: | ||
55 | super().__init__(error, **kwargs) | ||
56 | self.error = error | ||
57 | |||
58 | |||
59 | class ManifestParseError(RepoExitError): | ||
17 | """Failed to parse the manifest file.""" | 60 | """Failed to parse the manifest file.""" |
18 | 61 | ||
19 | 62 | ||
@@ -25,11 +68,11 @@ class ManifestInvalidPathError(ManifestParseError): | |||
25 | """A path used in <copyfile> or <linkfile> is incorrect.""" | 68 | """A path used in <copyfile> or <linkfile> is incorrect.""" |
26 | 69 | ||
27 | 70 | ||
28 | class NoManifestException(Exception): | 71 | class NoManifestException(RepoExitError): |
29 | """The required manifest does not exist.""" | 72 | """The required manifest does not exist.""" |
30 | 73 | ||
31 | def __init__(self, path, reason): | 74 | def __init__(self, path, reason, **kwargs): |
32 | super().__init__(path, reason) | 75 | super().__init__(path, reason, **kwargs) |
33 | self.path = path | 76 | self.path = path |
34 | self.reason = reason | 77 | self.reason = reason |
35 | 78 | ||
@@ -37,55 +80,64 @@ class NoManifestException(Exception): | |||
37 | return self.reason | 80 | return self.reason |
38 | 81 | ||
39 | 82 | ||
40 | class EditorError(Exception): | 83 | class EditorError(RepoError): |
41 | """Unspecified error from the user's text editor.""" | 84 | """Unspecified error from the user's text editor.""" |
42 | 85 | ||
43 | def __init__(self, reason): | 86 | def __init__(self, reason, **kwargs): |
44 | super().__init__(reason) | 87 | super().__init__(reason, **kwargs) |
45 | self.reason = reason | 88 | self.reason = reason |
46 | 89 | ||
47 | def __str__(self): | 90 | def __str__(self): |
48 | return self.reason | 91 | return self.reason |
49 | 92 | ||
50 | 93 | ||
51 | class GitError(Exception): | 94 | class GitError(RepoError): |
52 | """Unspecified internal error from git.""" | 95 | """Unspecified git related error.""" |
53 | 96 | ||
54 | def __init__(self, command): | 97 | def __init__(self, message, command_args=None, **kwargs): |
55 | super().__init__(command) | 98 | super().__init__(message, **kwargs) |
56 | self.command = command | 99 | self.message = message |
100 | self.command_args = command_args | ||
57 | 101 | ||
58 | def __str__(self): | 102 | def __str__(self): |
59 | return self.command | 103 | return self.message |
60 | 104 | ||
61 | 105 | ||
62 | class UploadError(Exception): | 106 | class UploadError(RepoError): |
63 | """A bundle upload to Gerrit did not succeed.""" | 107 | """A bundle upload to Gerrit did not succeed.""" |
64 | 108 | ||
65 | def __init__(self, reason): | 109 | def __init__(self, reason, **kwargs): |
66 | super().__init__(reason) | 110 | super().__init__(reason, **kwargs) |
67 | self.reason = reason | 111 | self.reason = reason |
68 | 112 | ||
69 | def __str__(self): | 113 | def __str__(self): |
70 | return self.reason | 114 | return self.reason |
71 | 115 | ||
72 | 116 | ||
73 | class DownloadError(Exception): | 117 | class DownloadError(RepoExitError): |
74 | """Cannot download a repository.""" | 118 | """Cannot download a repository.""" |
75 | 119 | ||
76 | def __init__(self, reason): | 120 | def __init__(self, reason, **kwargs): |
77 | super().__init__(reason) | 121 | super().__init__(reason, **kwargs) |
78 | self.reason = reason | 122 | self.reason = reason |
79 | 123 | ||
80 | def __str__(self): | 124 | def __str__(self): |
81 | return self.reason | 125 | return self.reason |
82 | 126 | ||
83 | 127 | ||
84 | class NoSuchProjectError(Exception): | 128 | class SyncError(RepoExitError): |
129 | """Cannot sync repo.""" | ||
130 | |||
131 | |||
132 | class UpdateManifestError(RepoExitError): | ||
133 | """Cannot update manifest.""" | ||
134 | |||
135 | |||
136 | class NoSuchProjectError(RepoExitError): | ||
85 | """A specified project does not exist in the work tree.""" | 137 | """A specified project does not exist in the work tree.""" |
86 | 138 | ||
87 | def __init__(self, name=None): | 139 | def __init__(self, name=None, **kwargs): |
88 | super().__init__(name) | 140 | super().__init__(**kwargs) |
89 | self.name = name | 141 | self.name = name |
90 | 142 | ||
91 | def __str__(self): | 143 | def __str__(self): |
@@ -94,11 +146,11 @@ class NoSuchProjectError(Exception): | |||
94 | return self.name | 146 | return self.name |
95 | 147 | ||
96 | 148 | ||
97 | class InvalidProjectGroupsError(Exception): | 149 | class InvalidProjectGroupsError(RepoExitError): |
98 | """A specified project is not suitable for the specified groups""" | 150 | """A specified project is not suitable for the specified groups""" |
99 | 151 | ||
100 | def __init__(self, name=None): | 152 | def __init__(self, name=None, **kwargs): |
101 | super().__init__(name) | 153 | super().__init__(**kwargs) |
102 | self.name = name | 154 | self.name = name |
103 | 155 | ||
104 | def __str__(self): | 156 | def __str__(self): |
@@ -107,7 +159,7 @@ class InvalidProjectGroupsError(Exception): | |||
107 | return self.name | 159 | return self.name |
108 | 160 | ||
109 | 161 | ||
110 | class RepoChangedException(Exception): | 162 | class RepoChangedException(BaseRepoError): |
111 | """Thrown if 'repo sync' results in repo updating its internal | 163 | """Thrown if 'repo sync' results in repo updating its internal |
112 | repo or manifest repositories. In this special case we must | 164 | repo or manifest repositories. In this special case we must |
113 | use exec to re-execute repo with the new code and manifest. | 165 | use exec to re-execute repo with the new code and manifest. |
@@ -118,7 +170,7 @@ class RepoChangedException(Exception): | |||
118 | self.extra_args = extra_args or [] | 170 | self.extra_args = extra_args or [] |
119 | 171 | ||
120 | 172 | ||
121 | class HookError(Exception): | 173 | class HookError(RepoError): |
122 | """Thrown if a 'repo-hook' could not be run. | 174 | """Thrown if a 'repo-hook' could not be run. |
123 | 175 | ||
124 | The common case is that the file wasn't present when we tried to run it. | 176 | The common case is that the file wasn't present when we tried to run it. |
diff --git a/git_command.py b/git_command.py index c7245ade..588a64fd 100644 --- a/git_command.py +++ b/git_command.py | |||
@@ -40,6 +40,10 @@ GIT_DIR = "GIT_DIR" | |||
40 | 40 | ||
41 | LAST_GITDIR = None | 41 | LAST_GITDIR = None |
42 | LAST_CWD = None | 42 | LAST_CWD = None |
43 | DEFAULT_GIT_FAIL_MESSAGE = "git command failure" | ||
44 | # Common line length limit | ||
45 | GIT_ERROR_STDOUT_LINES = 1 | ||
46 | GIT_ERROR_STDERR_LINES = 1 | ||
43 | 47 | ||
44 | 48 | ||
45 | class _GitCall(object): | 49 | class _GitCall(object): |
@@ -237,6 +241,7 @@ class GitCommand(object): | |||
237 | cwd=None, | 241 | cwd=None, |
238 | gitdir=None, | 242 | gitdir=None, |
239 | objdir=None, | 243 | objdir=None, |
244 | verify_command=False, | ||
240 | ): | 245 | ): |
241 | if project: | 246 | if project: |
242 | if not cwd: | 247 | if not cwd: |
@@ -244,6 +249,10 @@ class GitCommand(object): | |||
244 | if not gitdir: | 249 | if not gitdir: |
245 | gitdir = project.gitdir | 250 | gitdir = project.gitdir |
246 | 251 | ||
252 | self.project = project | ||
253 | self.cmdv = cmdv | ||
254 | self.verify_command = verify_command | ||
255 | |||
247 | # Git on Windows wants its paths only using / for reliability. | 256 | # Git on Windows wants its paths only using / for reliability. |
248 | if platform_utils.isWindows(): | 257 | if platform_utils.isWindows(): |
249 | if objdir: | 258 | if objdir: |
@@ -332,7 +341,11 @@ class GitCommand(object): | |||
332 | stderr=stderr, | 341 | stderr=stderr, |
333 | ) | 342 | ) |
334 | except Exception as e: | 343 | except Exception as e: |
335 | raise GitError("%s: %s" % (command[1], e)) | 344 | raise GitCommandError( |
345 | message="%s: %s" % (command[1], e), | ||
346 | project=project.name if project else None, | ||
347 | command_args=cmdv, | ||
348 | ) | ||
336 | 349 | ||
337 | if ssh_proxy: | 350 | if ssh_proxy: |
338 | ssh_proxy.add_client(p) | 351 | ssh_proxy.add_client(p) |
@@ -366,4 +379,61 @@ class GitCommand(object): | |||
366 | return env | 379 | return env |
367 | 380 | ||
368 | def Wait(self): | 381 | def Wait(self): |
369 | return self.rc | 382 | if not self.verify_command or self.rc == 0: |
383 | return self.rc | ||
384 | |||
385 | stdout = ( | ||
386 | "\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES]) | ||
387 | if self.stdout | ||
388 | else None | ||
389 | ) | ||
390 | |||
391 | stderr = ( | ||
392 | "\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES]) | ||
393 | if self.stderr | ||
394 | else None | ||
395 | ) | ||
396 | project = self.project.name if self.project else None | ||
397 | raise GitCommandError( | ||
398 | project=project, | ||
399 | command_args=self.cmdv, | ||
400 | git_rc=self.rc, | ||
401 | git_stdout=stdout, | ||
402 | git_stderr=stderr, | ||
403 | ) | ||
404 | |||
405 | |||
406 | class GitCommandError(GitError): | ||
407 | """ | ||
408 | Error raised from a failed git command. | ||
409 | Note that GitError can refer to any Git related error (e.g. branch not | ||
410 | specified for project.py 'UploadForReview'), while GitCommandError is | ||
411 | raised exclusively from non-zero exit codes returned from git commands. | ||
412 | """ | ||
413 | |||
414 | def __init__( | ||
415 | self, | ||
416 | message: str = DEFAULT_GIT_FAIL_MESSAGE, | ||
417 | git_rc: int = None, | ||
418 | git_stdout: str = None, | ||
419 | git_stderr: str = None, | ||
420 | **kwargs, | ||
421 | ): | ||
422 | super().__init__( | ||
423 | message, | ||
424 | **kwargs, | ||
425 | ) | ||
426 | self.git_rc = git_rc | ||
427 | self.git_stdout = git_stdout | ||
428 | self.git_stderr = git_stderr | ||
429 | |||
430 | def __str__(self): | ||
431 | args = "[]" if not self.command_args else " ".join(self.command_args) | ||
432 | error_type = type(self).__name__ | ||
433 | return f"""{error_type}: {self.message} | ||
434 | Project: {self.project} | ||
435 | Args: {args} | ||
436 | Stdout: | ||
437 | {self.git_stdout} | ||
438 | Stderr: | ||
439 | {self.git_stderr}""" | ||
diff --git a/subcmds/__init__.py b/subcmds/__init__.py index 4e41afc0..0754f708 100644 --- a/subcmds/__init__.py +++ b/subcmds/__init__.py | |||
@@ -16,6 +16,7 @@ import os | |||
16 | 16 | ||
17 | # A mapping of the subcommand name to the class that implements it. | 17 | # A mapping of the subcommand name to the class that implements it. |
18 | all_commands = {} | 18 | all_commands = {} |
19 | all_modules = [] | ||
19 | 20 | ||
20 | my_dir = os.path.dirname(__file__) | 21 | my_dir = os.path.dirname(__file__) |
21 | for py in os.listdir(my_dir): | 22 | for py in os.listdir(my_dir): |
@@ -42,6 +43,7 @@ for py in os.listdir(my_dir): | |||
42 | name = name.replace("_", "-") | 43 | name = name.replace("_", "-") |
43 | cmd.NAME = name | 44 | cmd.NAME = name |
44 | all_commands[name] = cmd | 45 | all_commands[name] = cmd |
46 | all_modules.append(mod) | ||
45 | 47 | ||
46 | # Add 'branch' as an alias for 'branches'. | 48 | # Add 'branch' as an alias for 'branches'. |
47 | all_commands["branch"] = all_commands["branches"] | 49 | all_commands["branch"] = all_commands["branches"] |
diff --git a/tests/test_error.py b/tests/test_error.py index 784e2d57..2733ab8c 100644 --- a/tests/test_error.py +++ b/tests/test_error.py | |||
@@ -19,6 +19,15 @@ import pickle | |||
19 | import unittest | 19 | import unittest |
20 | 20 | ||
21 | import error | 21 | import error |
22 | import project | ||
23 | import git_command | ||
24 | from subcmds import all_modules | ||
25 | |||
26 | imports = all_modules + [ | ||
27 | error, | ||
28 | project, | ||
29 | git_command, | ||
30 | ] | ||
22 | 31 | ||
23 | 32 | ||
24 | class PickleTests(unittest.TestCase): | 33 | class PickleTests(unittest.TestCase): |
@@ -26,10 +35,11 @@ class PickleTests(unittest.TestCase): | |||
26 | 35 | ||
27 | def getExceptions(self): | 36 | def getExceptions(self): |
28 | """Return all our custom exceptions.""" | 37 | """Return all our custom exceptions.""" |
29 | for name in dir(error): | 38 | for entry in imports: |
30 | cls = getattr(error, name) | 39 | for name in dir(entry): |
31 | if isinstance(cls, type) and issubclass(cls, Exception): | 40 | cls = getattr(entry, name) |
32 | yield cls | 41 | if isinstance(cls, type) and issubclass(cls, Exception): |
42 | yield cls | ||
33 | 43 | ||
34 | def testExceptionLookup(self): | 44 | def testExceptionLookup(self): |
35 | """Make sure our introspection logic works.""" | 45 | """Make sure our introspection logic works.""" |
diff --git a/tests/test_git_command.py b/tests/test_git_command.py index c4c3a4c5..1e8beabc 100644 --- a/tests/test_git_command.py +++ b/tests/test_git_command.py | |||
@@ -16,6 +16,7 @@ | |||
16 | 16 | ||
17 | import re | 17 | import re |
18 | import os | 18 | import os |
19 | import subprocess | ||
19 | import unittest | 20 | import unittest |
20 | 21 | ||
21 | try: | 22 | try: |
@@ -65,6 +66,56 @@ class GitCommandTest(unittest.TestCase): | |||
65 | ) | 66 | ) |
66 | 67 | ||
67 | 68 | ||
69 | class GitCommandWaitTest(unittest.TestCase): | ||
70 | """Tests the GitCommand class .Wait()""" | ||
71 | |||
72 | def setUp(self): | ||
73 | class MockPopen(object): | ||
74 | rc = 0 | ||
75 | |||
76 | def communicate( | ||
77 | self, input: str = None, timeout: float = None | ||
78 | ) -> [str, str]: | ||
79 | """Mock communicate fn.""" | ||
80 | return ["", ""] | ||
81 | |||
82 | def wait(self, timeout=None): | ||
83 | return self.rc | ||
84 | |||
85 | self.popen = popen = MockPopen() | ||
86 | |||
87 | def popen_mock(*args, **kwargs): | ||
88 | return popen | ||
89 | |||
90 | def realpath_mock(val): | ||
91 | return val | ||
92 | |||
93 | mock.patch.object(subprocess, "Popen", side_effect=popen_mock).start() | ||
94 | |||
95 | mock.patch.object( | ||
96 | os.path, "realpath", side_effect=realpath_mock | ||
97 | ).start() | ||
98 | |||
99 | def tearDown(self): | ||
100 | mock.patch.stopall() | ||
101 | |||
102 | def test_raises_when_verify_non_zero_result(self): | ||
103 | self.popen.rc = 1 | ||
104 | r = git_command.GitCommand(None, ["status"], verify_command=True) | ||
105 | with self.assertRaises(git_command.GitCommandError): | ||
106 | r.Wait() | ||
107 | |||
108 | def test_returns_when_no_verify_non_zero_result(self): | ||
109 | self.popen.rc = 1 | ||
110 | r = git_command.GitCommand(None, ["status"], verify_command=False) | ||
111 | self.assertEqual(1, r.Wait()) | ||
112 | |||
113 | def test_default_returns_non_zero_result(self): | ||
114 | self.popen.rc = 1 | ||
115 | r = git_command.GitCommand(None, ["status"]) | ||
116 | self.assertEqual(1, r.Wait()) | ||
117 | |||
118 | |||
68 | class GitCallUnitTest(unittest.TestCase): | 119 | class GitCallUnitTest(unittest.TestCase): |
69 | """Tests the _GitCall class (via git_command.git).""" | 120 | """Tests the _GitCall class (via git_command.git).""" |
70 | 121 | ||