summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorMike Frysinger <vapier@google.com>2026-03-18 11:17:12 -0400
committerLUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-03-18 11:13:26 -0700
commitf24bc7aed59a8fb4b89afa06b82eae1aad067983 (patch)
tree8d9c02a8296910ea4126574112c667cdeb2bc6af /tests
parent83b8ebdbbed5ec894a2f89eb5e4bcf5d0bb14300 (diff)
downloadgit-repo-f24bc7aed59a8fb4b89afa06b82eae1aad067983.tar.gz
tests: switch some test modules to pytest
Change-Id: I524b5ff2d77f8232f94e21921b00ba4027d2ac4f Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/563081 Tested-by: Mike Frysinger <vapier@google.com> Reviewed-by: Gavin Mak <gavinmak@google.com> Commit-Queue: Mike Frysinger <vapier@google.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/test_color.py102
-rw-r--r--tests/test_editor.py47
-rw-r--r--tests/test_error.py65
-rw-r--r--tests/test_git_config.py397
-rw-r--r--tests/test_hooks.py75
-rw-r--r--tests/test_platform_utils.py50
-rw-r--r--tests/test_repo_logging.py148
-rw-r--r--tests/test_repo_trace.py67
-rw-r--r--tests/test_ssh.py134
-rw-r--r--tests/test_subcmds.py264
-rw-r--r--tests/test_subcmds_init.py55
-rw-r--r--tests/test_update_manpages.py13
12 files changed, 717 insertions, 700 deletions
diff --git a/tests/test_color.py b/tests/test_color.py
index 91a1bf250..923f7e355 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -15,60 +15,66 @@
15"""Unittests for the color.py module.""" 15"""Unittests for the color.py module."""
16 16
17import os 17import os
18import unittest 18
19import pytest
19 20
20import color 21import color
21import git_config 22import git_config
22 23
23 24
24def fixture(*paths): 25def fixture(*paths: str) -> str:
25 """Return a path relative to test/fixtures.""" 26 """Return a path relative to test/fixtures."""
26 return os.path.join(os.path.dirname(__file__), "fixtures", *paths) 27 return os.path.join(os.path.dirname(__file__), "fixtures", *paths)
27 28
28 29
29class ColoringTests(unittest.TestCase): 30@pytest.fixture
30 """tests of the Coloring class.""" 31def coloring() -> color.Coloring:
31 32 """Create a Coloring object for testing."""
32 def setUp(self): 33 config_fixture = fixture("test.gitconfig")
33 """Create a GitConfig object using the test.gitconfig fixture.""" 34 config = git_config.GitConfig(config_fixture)
34 config_fixture = fixture("test.gitconfig") 35 color.SetDefaultColoring("true")
35 self.config = git_config.GitConfig(config_fixture) 36 return color.Coloring(config, "status")
36 color.SetDefaultColoring("true") 37
37 self.color = color.Coloring(self.config, "status") 38
38 39def test_Color_Parse_all_params_none(coloring: color.Coloring) -> None:
39 def test_Color_Parse_all_params_none(self): 40 """all params are None"""
40 """all params are None""" 41 val = coloring._parse(None, None, None, None)
41 val = self.color._parse(None, None, None, None) 42 assert val == ""
42 self.assertEqual("", val) 43
43 44
44 def test_Color_Parse_first_parameter_none(self): 45def test_Color_Parse_first_parameter_none(coloring: color.Coloring) -> None:
45 """check fg & bg & attr""" 46 """check fg & bg & attr"""
46 val = self.color._parse(None, "black", "red", "ul") 47 val = coloring._parse(None, "black", "red", "ul")
47 self.assertEqual("\x1b[4;30;41m", val) 48 assert val == "\x1b[4;30;41m"
48 49
49 def test_Color_Parse_one_entry(self): 50
50 """check fg""" 51def test_Color_Parse_one_entry(coloring: color.Coloring) -> None:
51 val = self.color._parse("one", None, None, None) 52 """check fg"""
52 self.assertEqual("\033[33m", val) 53 val = coloring._parse("one", None, None, None)
53 54 assert val == "\033[33m"
54 def test_Color_Parse_two_entry(self): 55
55 """check fg & bg""" 56
56 val = self.color._parse("two", None, None, None) 57def test_Color_Parse_two_entry(coloring: color.Coloring) -> None:
57 self.assertEqual("\033[35;46m", val) 58 """check fg & bg"""
58 59 val = coloring._parse("two", None, None, None)
59 def test_Color_Parse_three_entry(self): 60 assert val == "\033[35;46m"
60 """check fg & bg & attr""" 61
61 val = self.color._parse("three", None, None, None) 62
62 self.assertEqual("\033[4;30;41m", val) 63def test_Color_Parse_three_entry(coloring: color.Coloring) -> None:
63 64 """check fg & bg & attr"""
64 def test_Color_Parse_reset_entry(self): 65 val = coloring._parse("three", None, None, None)
65 """check reset entry""" 66 assert val == "\033[4;30;41m"
66 val = self.color._parse("reset", None, None, None) 67
67 self.assertEqual("\033[m", val) 68
68 69def test_Color_Parse_reset_entry(coloring: color.Coloring) -> None:
69 def test_Color_Parse_empty_entry(self): 70 """check reset entry"""
70 """check empty entry""" 71 val = coloring._parse("reset", None, None, None)
71 val = self.color._parse("none", "blue", "white", "dim") 72 assert val == "\033[m"
72 self.assertEqual("\033[2;34;47m", val) 73
73 val = self.color._parse("empty", "green", "white", "bold") 74
74 self.assertEqual("\033[1;32;47m", val) 75def test_Color_Parse_empty_entry(coloring: color.Coloring) -> None:
76 """check empty entry"""
77 val = coloring._parse("none", "blue", "white", "dim")
78 assert val == "\033[2;34;47m"
79 val = coloring._parse("empty", "green", "white", "bold")
80 assert val == "\033[1;32;47m"
diff --git a/tests/test_editor.py b/tests/test_editor.py
index 8f5d160e2..d51765dd1 100644
--- a/tests/test_editor.py
+++ b/tests/test_editor.py
@@ -14,43 +14,32 @@
14 14
15"""Unittests for the editor.py module.""" 15"""Unittests for the editor.py module."""
16 16
17import unittest 17import pytest
18 18
19from editor import Editor 19from editor import Editor
20 20
21 21
22class EditorTestCase(unittest.TestCase): 22@pytest.fixture(autouse=True)
23def reset_editor() -> None:
23 """Take care of resetting Editor state across tests.""" 24 """Take care of resetting Editor state across tests."""
25 Editor._editor = None
26 yield
27 Editor._editor = None
24 28
25 def setUp(self):
26 self.setEditor(None)
27 29
28 def tearDown(self): 30def test_basic() -> None:
29 self.setEditor(None) 31 """Basic checking of _GetEditor."""
32 Editor._editor = ":"
33 assert Editor._GetEditor() == ":"
30 34
31 @staticmethod
32 def setEditor(editor):
33 Editor._editor = editor
34 35
36def test_no_editor() -> None:
37 """Check behavior when no editor is available."""
38 Editor._editor = ":"
39 assert Editor.EditString("foo") == "foo"
35 40
36class GetEditor(EditorTestCase):
37 """Check GetEditor behavior."""
38 41
39 def test_basic(self): 42def test_cat_editor() -> None:
40 """Basic checking of _GetEditor.""" 43 """Check behavior when editor is `cat`."""
41 self.setEditor(":") 44 Editor._editor = "cat"
42 self.assertEqual(":", Editor._GetEditor()) 45 assert Editor.EditString("foo") == "foo"
43
44
45class EditString(EditorTestCase):
46 """Check EditString behavior."""
47
48 def test_no_editor(self):
49 """Check behavior when no editor is available."""
50 self.setEditor(":")
51 self.assertEqual("foo", Editor.EditString("foo"))
52
53 def test_cat_editor(self):
54 """Check behavior when editor is `cat`."""
55 self.setEditor("cat")
56 self.assertEqual("foo", Editor.EditString("foo"))
diff --git a/tests/test_error.py b/tests/test_error.py
index a0ff32c31..3eb82835e 100644
--- a/tests/test_error.py
+++ b/tests/test_error.py
@@ -16,7 +16,9 @@
16 16
17import inspect 17import inspect
18import pickle 18import pickle
19import unittest 19from typing import Iterator, Type
20
21import pytest
20 22
21import command 23import command
22import error 24import error
@@ -26,7 +28,7 @@ import project
26from subcmds import all_modules 28from subcmds import all_modules
27 29
28 30
29imports = all_modules + [ 31_IMPORTS = all_modules + [
30 error, 32 error,
31 project, 33 project,
32 git_command, 34 git_command,
@@ -35,36 +37,35 @@ imports = all_modules + [
35] 37]
36 38
37 39
38class PickleTests(unittest.TestCase): 40def get_exceptions() -> Iterator[Type[Exception]]:
39 """Make sure all our custom exceptions can be pickled.""" 41 """Return all our custom exceptions."""
42 for entry in _IMPORTS:
43 for name in dir(entry):
44 cls = getattr(entry, name)
45 if isinstance(cls, type) and issubclass(cls, Exception):
46 yield cls
47
40 48
41 def getExceptions(self): 49def test_exception_lookup() -> None:
42 """Return all our custom exceptions.""" 50 """Make sure our introspection logic works."""
43 for entry in imports: 51 classes = list(get_exceptions())
44 for name in dir(entry): 52 assert error.HookError in classes
45 cls = getattr(entry, name) 53 # Don't assert the exact number to avoid being a change-detector test.
46 if isinstance(cls, type) and issubclass(cls, Exception): 54 assert len(classes) > 10
47 yield cls
48 55
49 def testExceptionLookup(self):
50 """Make sure our introspection logic works."""
51 classes = list(self.getExceptions())
52 self.assertIn(error.HookError, classes)
53 # Don't assert the exact number to avoid being a change-detector test.
54 self.assertGreater(len(classes), 10)
55 56
56 def testPickle(self): 57@pytest.mark.parametrize("cls", get_exceptions())
57 """Try to pickle all the exceptions.""" 58def test_pickle(cls: Type[Exception]) -> None:
58 for cls in self.getExceptions(): 59 """Try to pickle all the exceptions."""
59 args = inspect.getfullargspec(cls.__init__).args[1:] 60 args = inspect.getfullargspec(cls.__init__).args[1:]
60 obj = cls(*args) 61 obj = cls(*args)
61 p = pickle.dumps(obj) 62 p = pickle.dumps(obj)
62 try: 63 try:
63 newobj = pickle.loads(p) 64 newobj = pickle.loads(p)
64 except Exception as e: # pylint: disable=broad-except 65 except Exception as e:
65 self.fail( 66 pytest.fail(
66 "Class %s is unable to be pickled: %s\n" 67 f"Class {cls} is unable to be pickled: {e}\n"
67 "Incomplete super().__init__(...) call?" % (cls, e) 68 "Incomplete super().__init__(...) call?"
68 ) 69 )
69 self.assertIsInstance(newobj, cls) 70 assert isinstance(newobj, cls)
70 self.assertEqual(str(obj), str(newobj)) 71 assert str(obj) == str(newobj)
diff --git a/tests/test_git_config.py b/tests/test_git_config.py
index e1604bd76..496d97141 100644
--- a/tests/test_git_config.py
+++ b/tests/test_git_config.py
@@ -15,200 +15,219 @@
15"""Unittests for the git_config.py module.""" 15"""Unittests for the git_config.py module."""
16 16
17import os 17import os
18import tempfile 18from pathlib import Path
19import unittest 19from typing import Any
20
21import pytest
20 22
21import git_config 23import git_config
22 24
23 25
24def fixture(*paths): 26def fixture_path(*paths: str) -> str:
25 """Return a path relative to test/fixtures.""" 27 """Return a path relative to test/fixtures."""
26 return os.path.join(os.path.dirname(__file__), "fixtures", *paths) 28 return os.path.join(os.path.dirname(__file__), "fixtures", *paths)
27 29
28 30
29class GitConfigReadOnlyTests(unittest.TestCase): 31@pytest.fixture
30 """Read-only tests of the GitConfig class.""" 32def readonly_config() -> git_config.GitConfig:
31 33 """Create a GitConfig object using the test.gitconfig fixture."""
32 def setUp(self): 34 config_fixture = fixture_path("test.gitconfig")
33 """Create a GitConfig object using the test.gitconfig fixture.""" 35 return git_config.GitConfig(config_fixture)
34 config_fixture = fixture("test.gitconfig") 36
35 self.config = git_config.GitConfig(config_fixture) 37
36 38def test_get_string_with_empty_config_values(
37 def test_GetString_with_empty_config_values(self): 39 readonly_config: git_config.GitConfig,
38 """ 40) -> None:
39 Test config entries with no value. 41 """Test config entries with no value.
40 42
41 [section] 43 [section]
42 empty 44 empty
43 45
44 """ 46 """
45 val = self.config.GetString("section.empty") 47 val = readonly_config.GetString("section.empty")
46 self.assertEqual(val, None) 48 assert val is None
47 49
48 def test_GetString_with_true_value(self): 50
49 """ 51def test_get_string_with_true_value(
50 Test config entries with a string value. 52 readonly_config: git_config.GitConfig,
51 53) -> None:
52 [section] 54 """Test config entries with a string value.
53 nonempty = true 55
54 56 [section]
55 """ 57 nonempty = true
56 val = self.config.GetString("section.nonempty") 58
57 self.assertEqual(val, "true") 59 """
58 60 val = readonly_config.GetString("section.nonempty")
59 def test_GetString_from_missing_file(self): 61 assert val == "true"
60 """ 62
61 Test missing config file 63
62 """ 64def test_get_string_from_missing_file() -> None:
63 config_fixture = fixture("not.present.gitconfig") 65 """Test missing config file."""
64 config = git_config.GitConfig(config_fixture) 66 config_fixture = fixture_path("not.present.gitconfig")
65 val = config.GetString("empty") 67 config = git_config.GitConfig(config_fixture)
66 self.assertEqual(val, None) 68 val = config.GetString("empty")
67 69 assert val is None
68 def test_GetBoolean_undefined(self): 70
69 """Test GetBoolean on key that doesn't exist.""" 71
70 self.assertIsNone(self.config.GetBoolean("section.missing")) 72def test_get_boolean_undefined(readonly_config: git_config.GitConfig) -> None:
71 73 """Test GetBoolean on key that doesn't exist."""
72 def test_GetBoolean_invalid(self): 74 assert readonly_config.GetBoolean("section.missing") is None
73 """Test GetBoolean on invalid boolean value.""" 75
74 self.assertIsNone(self.config.GetBoolean("section.boolinvalid")) 76
75 77def test_get_boolean_invalid(readonly_config: git_config.GitConfig) -> None:
76 def test_GetBoolean_true(self): 78 """Test GetBoolean on invalid boolean value."""
77 """Test GetBoolean on valid true boolean.""" 79 assert readonly_config.GetBoolean("section.boolinvalid") is None
78 self.assertTrue(self.config.GetBoolean("section.booltrue")) 80
79 81
80 def test_GetBoolean_false(self): 82def test_get_boolean_true(readonly_config: git_config.GitConfig) -> None:
81 """Test GetBoolean on valid false boolean.""" 83 """Test GetBoolean on valid true boolean."""
82 self.assertFalse(self.config.GetBoolean("section.boolfalse")) 84 assert readonly_config.GetBoolean("section.booltrue") is True
83 85
84 def test_GetInt_undefined(self): 86
85 """Test GetInt on key that doesn't exist.""" 87def test_get_boolean_false(readonly_config: git_config.GitConfig) -> None:
86 self.assertIsNone(self.config.GetInt("section.missing")) 88 """Test GetBoolean on valid false boolean."""
87 89 assert readonly_config.GetBoolean("section.boolfalse") is False
88 def test_GetInt_invalid(self): 90
89 """Test GetInt on invalid integer value.""" 91
90 self.assertIsNone(self.config.GetBoolean("section.intinvalid")) 92def test_get_int_undefined(readonly_config: git_config.GitConfig) -> None:
91 93 """Test GetInt on key that doesn't exist."""
92 def test_GetInt_valid(self): 94 assert readonly_config.GetInt("section.missing") is None
93 """Test GetInt on valid integers.""" 95
94 TESTS = ( 96
95 ("inthex", 16), 97def test_get_int_invalid(readonly_config: git_config.GitConfig) -> None:
96 ("inthexk", 16384), 98 """Test GetInt on invalid integer value."""
97 ("int", 10), 99 assert readonly_config.GetInt("section.intinvalid") is None
98 ("intk", 10240), 100
99 ("intm", 10485760), 101
100 ("intg", 10737418240), 102@pytest.mark.parametrize(
101 ) 103 "key, expected",
102 for key, value in TESTS: 104 (
103 self.assertEqual(value, self.config.GetInt(f"section.{key}")) 105 ("inthex", 16),
104 106 ("inthexk", 16384),
105 107 ("int", 10),
106class GitConfigReadWriteTests(unittest.TestCase): 108 ("intk", 10240),
107 """Read/write tests of the GitConfig class.""" 109 ("intm", 10485760),
108 110 ("intg", 10737418240),
109 def setUp(self): 111 ),
110 self.tmpfile = tempfile.NamedTemporaryFile() 112)
111 self.config = self.get_config() 113def test_get_int_valid(
112 114 readonly_config: git_config.GitConfig, key: str, expected: int
113 def get_config(self): 115) -> None:
114 """Get a new GitConfig instance.""" 116 """Test GetInt on valid integers."""
115 return git_config.GitConfig(self.tmpfile.name) 117 assert readonly_config.GetInt(f"section.{key}") == expected
116 118
117 def test_SetString(self): 119
118 """Test SetString behavior.""" 120@pytest.fixture
119 # Set a value. 121def rw_config_file(tmp_path: Path) -> Path:
120 self.assertIsNone(self.config.GetString("foo.bar")) 122 """Return a path to a temporary config file."""
121 self.config.SetString("foo.bar", "val") 123 return tmp_path / "config"
122 self.assertEqual("val", self.config.GetString("foo.bar")) 124
123 125
124 # Make sure the value was actually written out. 126def test_set_string(rw_config_file: Path) -> None:
125 config = self.get_config() 127 """Test SetString behavior."""
126 self.assertEqual("val", config.GetString("foo.bar")) 128 config = git_config.GitConfig(str(rw_config_file))
127 129
128 # Update the value. 130 # Set a value.
129 self.config.SetString("foo.bar", "valll") 131 assert config.GetString("foo.bar") is None
130 self.assertEqual("valll", self.config.GetString("foo.bar")) 132 config.SetString("foo.bar", "val")
131 config = self.get_config() 133 assert config.GetString("foo.bar") == "val"
132 self.assertEqual("valll", config.GetString("foo.bar")) 134
133 135 # Make sure the value was actually written out.
134 # Delete the value. 136 config2 = git_config.GitConfig(str(rw_config_file))
135 self.config.SetString("foo.bar", None) 137 assert config2.GetString("foo.bar") == "val"
136 self.assertIsNone(self.config.GetString("foo.bar")) 138
137 config = self.get_config() 139 # Update the value.
138 self.assertIsNone(config.GetString("foo.bar")) 140 config.SetString("foo.bar", "valll")
139 141 assert config.GetString("foo.bar") == "valll"
140 def test_SetBoolean(self): 142 config3 = git_config.GitConfig(str(rw_config_file))
141 """Test SetBoolean behavior.""" 143 assert config3.GetString("foo.bar") == "valll"
142 # Set a true value. 144
143 self.assertIsNone(self.config.GetBoolean("foo.bar")) 145 # Delete the value.
144 for val in (True, 1): 146 config.SetString("foo.bar", None)
145 self.config.SetBoolean("foo.bar", val) 147 assert config.GetString("foo.bar") is None
146 self.assertTrue(self.config.GetBoolean("foo.bar")) 148 config4 = git_config.GitConfig(str(rw_config_file))
147 149 assert config4.GetString("foo.bar") is None
148 # Make sure the value was actually written out. 150
149 config = self.get_config() 151
150 self.assertTrue(config.GetBoolean("foo.bar")) 152def test_set_boolean(rw_config_file: Path) -> None:
151 self.assertEqual("true", config.GetString("foo.bar")) 153 """Test SetBoolean behavior."""
152 154 config = git_config.GitConfig(str(rw_config_file))
153 # Set a false value. 155
154 for val in (False, 0): 156 # Set a true value.
155 self.config.SetBoolean("foo.bar", val) 157 assert config.GetBoolean("foo.bar") is None
156 self.assertFalse(self.config.GetBoolean("foo.bar")) 158 for val in (True, 1):
157 159 config.SetBoolean("foo.bar", val)
158 # Make sure the value was actually written out. 160 assert config.GetBoolean("foo.bar") is True
159 config = self.get_config() 161
160 self.assertFalse(config.GetBoolean("foo.bar")) 162 # Make sure the value was actually written out.
161 self.assertEqual("false", config.GetString("foo.bar")) 163 config2 = git_config.GitConfig(str(rw_config_file))
162 164 assert config2.GetBoolean("foo.bar") is True
163 # Delete the value. 165 assert config2.GetString("foo.bar") == "true"
164 self.config.SetBoolean("foo.bar", None) 166
165 self.assertIsNone(self.config.GetBoolean("foo.bar")) 167 # Set a false value.
166 config = self.get_config() 168 for val in (False, 0):
167 self.assertIsNone(config.GetBoolean("foo.bar")) 169 config.SetBoolean("foo.bar", val)
168 170 assert config.GetBoolean("foo.bar") is False
169 def test_SetInt(self): 171
170 """Test SetInt behavior.""" 172 # Make sure the value was actually written out.
171 # Set a value. 173 config3 = git_config.GitConfig(str(rw_config_file))
172 self.assertIsNone(self.config.GetInt("foo.bar")) 174 assert config3.GetBoolean("foo.bar") is False
173 self.config.SetInt("foo.bar", 10) 175 assert config3.GetString("foo.bar") == "false"
174 self.assertEqual(10, self.config.GetInt("foo.bar")) 176
175 177 # Delete the value.
176 # Make sure the value was actually written out. 178 config.SetBoolean("foo.bar", None)
177 config = self.get_config() 179 assert config.GetBoolean("foo.bar") is None
178 self.assertEqual(10, config.GetInt("foo.bar")) 180 config4 = git_config.GitConfig(str(rw_config_file))
179 self.assertEqual("10", config.GetString("foo.bar")) 181 assert config4.GetBoolean("foo.bar") is None
180 182
181 # Update the value. 183
182 self.config.SetInt("foo.bar", 20) 184def test_set_int(rw_config_file: Path) -> None:
183 self.assertEqual(20, self.config.GetInt("foo.bar")) 185 """Test SetInt behavior."""
184 config = self.get_config() 186 config = git_config.GitConfig(str(rw_config_file))
185 self.assertEqual(20, config.GetInt("foo.bar")) 187
186 188 # Set a value.
187 # Delete the value. 189 assert config.GetInt("foo.bar") is None
188 self.config.SetInt("foo.bar", None) 190 config.SetInt("foo.bar", 10)
189 self.assertIsNone(self.config.GetInt("foo.bar")) 191 assert config.GetInt("foo.bar") == 10
190 config = self.get_config() 192
191 self.assertIsNone(config.GetInt("foo.bar")) 193 # Make sure the value was actually written out.
192 194 config2 = git_config.GitConfig(str(rw_config_file))
193 def test_GetSyncAnalysisStateData(self): 195 assert config2.GetInt("foo.bar") == 10
194 """Test config entries with a sync state analysis data.""" 196 assert config2.GetString("foo.bar") == "10"
195 superproject_logging_data = {} 197
196 superproject_logging_data["test"] = False 198 # Update the value.
197 options = type("options", (object,), {})() 199 config.SetInt("foo.bar", 20)
198 options.verbose = "true" 200 assert config.GetInt("foo.bar") == 20
199 options.mp_update = "false" 201 config3 = git_config.GitConfig(str(rw_config_file))
200 TESTS = ( 202 assert config3.GetInt("foo.bar") == 20
201 ("superproject.test", "false"), 203
202 ("options.verbose", "true"), 204 # Delete the value.
203 ("options.mpupdate", "false"), 205 config.SetInt("foo.bar", None)
204 ("main.version", "1"), 206 assert config.GetInt("foo.bar") is None
205 ) 207 config4 = git_config.GitConfig(str(rw_config_file))
206 self.config.UpdateSyncAnalysisState(options, superproject_logging_data) 208 assert config4.GetInt("foo.bar") is None
207 sync_data = self.config.GetSyncAnalysisStateData() 209
208 for key, value in TESTS: 210
209 self.assertEqual( 211def test_get_sync_analysis_state_data(rw_config_file: Path) -> None:
210 sync_data[f"{git_config.SYNC_STATE_PREFIX}{key}"], value 212 """Test config entries with a sync state analysis data."""
211 ) 213 config = git_config.GitConfig(str(rw_config_file))
212 self.assertTrue( 214 superproject_logging_data: dict[str, Any] = {"test": False}
213 sync_data[f"{git_config.SYNC_STATE_PREFIX}main.synctime"] 215
214 ) 216 class Options:
217 """Container for testing."""
218
219 options = Options()
220 options.verbose = "true"
221 options.mp_update = "false"
222
223 TESTS = (
224 ("superproject.test", "false"),
225 ("options.verbose", "true"),
226 ("options.mpupdate", "false"),
227 ("main.version", "1"),
228 )
229 config.UpdateSyncAnalysisState(options, superproject_logging_data)
230 sync_data = config.GetSyncAnalysisStateData()
231 for key, value in TESTS:
232 assert sync_data[f"{git_config.SYNC_STATE_PREFIX}{key}"] == value
233 assert sync_data[f"{git_config.SYNC_STATE_PREFIX}main.synctime"]
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
index 76e928f7a..9d52d1849 100644
--- a/tests/test_hooks.py
+++ b/tests/test_hooks.py
@@ -14,42 +14,47 @@
14 14
15"""Unittests for the hooks.py module.""" 15"""Unittests for the hooks.py module."""
16 16
17import unittest 17import pytest
18 18
19import hooks 19import hooks
20 20
21 21
22class RepoHookShebang(unittest.TestCase): 22@pytest.mark.parametrize(
23 """Check shebang parsing in RepoHook.""" 23 "data",
24 24 (
25 def test_no_shebang(self): 25 "",
26 """Lines w/out shebangs should be rejected.""" 26 "#\n# foo\n",
27 DATA = ("", "#\n# foo\n", "# Bad shebang in script\n#!/foo\n") 27 "# Bad shebang in script\n#!/foo\n",
28 for data in DATA: 28 ),
29 self.assertIsNone(hooks.RepoHook._ExtractInterpFromShebang(data)) 29)
30 30def test_no_shebang(data: str) -> None:
31 def test_direct_interp(self): 31 """Lines w/out shebangs should be rejected."""
32 """Lines whose shebang points directly to the interpreter.""" 32 assert hooks.RepoHook._ExtractInterpFromShebang(data) is None
33 DATA = ( 33
34 ("#!/foo", "/foo"), 34
35 ("#! /foo", "/foo"), 35@pytest.mark.parametrize(
36 ("#!/bin/foo ", "/bin/foo"), 36 "shebang, interp",
37 ("#! /usr/foo ", "/usr/foo"), 37 (
38 ("#! /usr/foo -args", "/usr/foo"), 38 ("#!/foo", "/foo"),
39 ) 39 ("#! /foo", "/foo"),
40 for shebang, interp in DATA: 40 ("#!/bin/foo ", "/bin/foo"),
41 self.assertEqual( 41 ("#! /usr/foo ", "/usr/foo"),
42 hooks.RepoHook._ExtractInterpFromShebang(shebang), interp 42 ("#! /usr/foo -args", "/usr/foo"),
43 ) 43 ),
44 44)
45 def test_env_interp(self): 45def test_direct_interp(shebang: str, interp: str) -> None:
46 """Lines whose shebang launches through `env`.""" 46 """Lines whose shebang points directly to the interpreter."""
47 DATA = ( 47 assert hooks.RepoHook._ExtractInterpFromShebang(shebang) == interp
48 ("#!/usr/bin/env foo", "foo"), 48
49 ("#!/bin/env foo", "foo"), 49
50 ("#! /bin/env /bin/foo ", "/bin/foo"), 50@pytest.mark.parametrize(
51 ) 51 "shebang, interp",
52 for shebang, interp in DATA: 52 (
53 self.assertEqual( 53 ("#!/usr/bin/env foo", "foo"),
54 hooks.RepoHook._ExtractInterpFromShebang(shebang), interp 54 ("#!/bin/env foo", "foo"),
55 ) 55 ("#! /bin/env /bin/foo ", "/bin/foo"),
56 ),
57)
58def test_env_interp(shebang: str, interp: str) -> None:
59 """Lines whose shebang launches through `env`."""
60 assert hooks.RepoHook._ExtractInterpFromShebang(shebang) == interp
diff --git a/tests/test_platform_utils.py b/tests/test_platform_utils.py
index aab8a52ab..e89965391 100644
--- a/tests/test_platform_utils.py
+++ b/tests/test_platform_utils.py
@@ -14,39 +14,35 @@
14 14
15"""Unittests for the platform_utils.py module.""" 15"""Unittests for the platform_utils.py module."""
16 16
17import os 17from pathlib import Path
18import tempfile
19import unittest
20 18
21import platform_utils 19import pytest
22 20
21import platform_utils
23 22
24class RemoveTests(unittest.TestCase):
25 """Check remove() helper."""
26 23
27 def testMissingOk(self): 24def test_remove_missing_ok(tmp_path: Path) -> None:
28 """Check missing_ok handling.""" 25 """Check missing_ok handling."""
29 with tempfile.TemporaryDirectory() as tmpdir: 26 path = tmp_path / "test"
30 path = os.path.join(tmpdir, "test")
31 27
32 # Should not fail. 28 # Should not fail.
33 platform_utils.remove(path, missing_ok=True) 29 platform_utils.remove(path, missing_ok=True)
34 30
35 # Should fail. 31 # Should fail.
36 self.assertRaises(OSError, platform_utils.remove, path) 32 with pytest.raises(OSError):
37 self.assertRaises( 33 platform_utils.remove(path)
38 OSError, platform_utils.remove, path, missing_ok=False 34 with pytest.raises(OSError):
39 ) 35 platform_utils.remove(path, missing_ok=False)
40 36
41 # Should not fail if it exists. 37 # Should not fail if it exists.
42 open(path, "w").close() 38 path.touch()
43 platform_utils.remove(path, missing_ok=True) 39 platform_utils.remove(path, missing_ok=True)
44 self.assertFalse(os.path.exists(path)) 40 assert not path.exists()
45 41
46 open(path, "w").close() 42 path.touch()
47 platform_utils.remove(path) 43 platform_utils.remove(path)
48 self.assertFalse(os.path.exists(path)) 44 assert not path.exists()
49 45
50 open(path, "w").close() 46 path.touch()
51 platform_utils.remove(path, missing_ok=False) 47 platform_utils.remove(path, missing_ok=False)
52 self.assertFalse(os.path.exists(path)) 48 assert not path.exists()
diff --git a/tests/test_repo_logging.py b/tests/test_repo_logging.py
index e072039ed..c5bba6b8e 100644
--- a/tests/test_repo_logging.py
+++ b/tests/test_repo_logging.py
@@ -12,90 +12,96 @@
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"""Unit test for repo_logging module.""" 15"""Unittests for the repo_logging.py module."""
16 16
17import contextlib 17import contextlib
18import io 18import io
19import logging 19import logging
20import unittest 20import re
21from unittest import mock 21from unittest import mock
22 22
23import pytest
24
23from color import SetDefaultColoring 25from color import SetDefaultColoring
24from error import RepoExitError 26from error import RepoExitError
25from repo_logging import RepoLogger 27from repo_logging import RepoLogger
26 28
27 29
28class TestRepoLogger(unittest.TestCase): 30@mock.patch.object(RepoLogger, "error")
29 @mock.patch.object(RepoLogger, "error") 31def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
30 def test_log_aggregated_errors_logs_aggregated_errors(self, mock_error): 32 """Test if log_aggregated_errors logs a list of aggregated errors."""
31 """Test if log_aggregated_errors logs a list of aggregated errors.""" 33 logger = RepoLogger(__name__)
32 logger = RepoLogger(__name__) 34 logger.log_aggregated_errors(
33 logger.log_aggregated_errors( 35 RepoExitError(
34 RepoExitError( 36 aggregate_errors=[
35 aggregate_errors=[ 37 Exception("foo"),
36 Exception("foo"), 38 Exception("bar"),
37 Exception("bar"), 39 Exception("baz"),
38 Exception("baz"), 40 Exception("hello"),
39 Exception("hello"), 41 Exception("world"),
40 Exception("world"), 42 Exception("test"),
41 Exception("test"),
42 ]
43 )
44 )
45
46 mock_error.assert_has_calls(
47 [
48 mock.call("=" * 80),
49 mock.call(
50 "Repo command failed due to the following `%s` errors:",
51 "RepoExitError",
52 ),
53 mock.call("foo\nbar\nbaz\nhello\nworld"),
54 mock.call("+%d additional errors...", 1),
55 ] 43 ]
56 ) 44 )
45 )
57 46
58 @mock.patch.object(RepoLogger, "error") 47 mock_error.assert_has_calls(
59 def test_log_aggregated_errors_logs_single_error(self, mock_error): 48 [
60 """Test if log_aggregated_errors logs empty aggregated_errors.""" 49 mock.call("=" * 80),
61 logger = RepoLogger(__name__) 50 mock.call(
62 logger.log_aggregated_errors(RepoExitError()) 51 "Repo command failed due to the following `%s` errors:",
52 "RepoExitError",
53 ),
54 mock.call("foo\nbar\nbaz\nhello\nworld"),
55 mock.call("+%d additional errors...", 1),
56 ]
57 )
63 58
64 mock_error.assert_has_calls(
65 [
66 mock.call("=" * 80),
67 mock.call("Repo command failed: %s", "RepoExitError"),
68 ]
69 )
70 59
71 def test_log_with_format_string(self): 60@mock.patch.object(RepoLogger, "error")
72 """Test different log levels with format strings.""" 61def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
73 62 """Test if log_aggregated_errors logs empty aggregated_errors."""
74 # Set color output to "always" for consistent test results. 63 logger = RepoLogger(__name__)
75 # This ensures the logger's behavior is uniform across different 64 logger.log_aggregated_errors(RepoExitError())
76 # environments and git configurations. 65
77 SetDefaultColoring("always") 66 mock_error.assert_has_calls(
78 67 [
79 # Regex pattern to match optional ANSI color codes. 68 mock.call("=" * 80),
80 # \033 - Escape character 69 mock.call("Repo command failed: %s", "RepoExitError"),
81 # \[ - Opening square bracket 70 ]
82 # [0-9;]* - Zero or more digits or semicolons 71 )
83 # m - Ending 'm' character 72
84 # ? - Makes the entire group optional 73
85 opt_color = r"(\033\[[0-9;]*m)?" 74@pytest.mark.parametrize(
86 75 "level",
87 for level in (logging.INFO, logging.WARN, logging.ERROR): 76 (
88 name = logging.getLevelName(level) 77 logging.INFO,
89 78 logging.WARN,
90 with self.subTest(level=level, name=name): 79 logging.ERROR,
91 output = io.StringIO() 80 ),
92 81)
93 with contextlib.redirect_stderr(output): 82def test_log_with_format_string(level: int) -> None:
94 logger = RepoLogger(__name__) 83 """Test different log levels with format strings."""
95 logger.log(level, "%s", "100% pass") 84 name = logging.getLevelName(level)
96 85
97 self.assertRegex( 86 # Set color output to "always" for consistent test results.
98 output.getvalue().strip(), 87 # This ensures the logger's behavior is uniform across different
99 f"^{opt_color}100% pass{opt_color}$", 88 # environments and git configurations.
100 f"failed for level {name}", 89 SetDefaultColoring("always")
101 ) 90
91 # Regex pattern to match optional ANSI color codes.
92 # \033 - Escape character
93 # \[ - Opening square bracket
94 # [0-9;]* - Zero or more digits or semicolons
95 # m - Ending 'm' character
96 # ? - Makes the entire group optional
97 opt_color = r"(\033\[[0-9;]*m)?"
98
99 output = io.StringIO()
100
101 with contextlib.redirect_stderr(output):
102 logger = RepoLogger(__name__)
103 logger.log(level, "%s", "100% pass")
104
105 assert re.search(
106 f"^{opt_color}100% pass{opt_color}$", output.getvalue().strip()
107 ), f"failed for level {name}"
diff --git a/tests/test_repo_trace.py b/tests/test_repo_trace.py
index 2ea341025..3ec540b25 100644
--- a/tests/test_repo_trace.py
+++ b/tests/test_repo_trace.py
@@ -15,46 +15,37 @@
15"""Unittests for the repo_trace.py module.""" 15"""Unittests for the repo_trace.py module."""
16 16
17import os 17import os
18import unittest 18
19from unittest import mock 19import pytest
20 20
21import repo_trace 21import repo_trace
22 22
23 23
24class TraceTests(unittest.TestCase): 24def test_trace_max_size_enforced(monkeypatch: pytest.MonkeyPatch) -> None:
25 """Check Trace behavior.""" 25 """Check Trace behavior."""
26 26 content = "git chicken"
27 def testTrace_MaxSizeEnforced(self): 27
28 content = "git chicken" 28 with repo_trace.Trace(content, first_trace=True):
29 29 pass
30 with repo_trace.Trace(content, first_trace=True): 30 first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
31 pass 31
32 first_trace_size = os.path.getsize(repo_trace._TRACE_FILE) 32 with repo_trace.Trace(content):
33 33 pass
34 with repo_trace.Trace(content): 34 assert os.path.getsize(repo_trace._TRACE_FILE) > first_trace_size
35 pass 35
36 self.assertGreater( 36 # Check we clear everything if the last chunk is larger than _MAX_SIZE.
37 os.path.getsize(repo_trace._TRACE_FILE), first_trace_size 37 monkeypatch.setattr(repo_trace, "_MAX_SIZE", 0)
38 ) 38 with repo_trace.Trace(content, first_trace=True):
39 39 pass
40 # Check we clear everything is the last chunk is larger than _MAX_SIZE. 40 assert os.path.getsize(repo_trace._TRACE_FILE) == first_trace_size
41 with mock.patch("repo_trace._MAX_SIZE", 0): 41
42 with repo_trace.Trace(content, first_trace=True): 42 # Check we only clear the chunks we need to.
43 pass 43 new_max = (first_trace_size + 1) / (1024 * 1024)
44 self.assertEqual( 44 monkeypatch.setattr(repo_trace, "_MAX_SIZE", new_max)
45 first_trace_size, os.path.getsize(repo_trace._TRACE_FILE) 45 with repo_trace.Trace(content, first_trace=True):
46 ) 46 pass
47 47 assert os.path.getsize(repo_trace._TRACE_FILE) == first_trace_size * 2
48 # Check we only clear the chunks we need to. 48
49 repo_trace._MAX_SIZE = (first_trace_size + 1) / (1024 * 1024) 49 with repo_trace.Trace(content, first_trace=True):
50 with repo_trace.Trace(content, first_trace=True): 50 pass
51 pass 51 assert os.path.getsize(repo_trace._TRACE_FILE) == first_trace_size * 2
52 self.assertEqual(
53 first_trace_size * 2, os.path.getsize(repo_trace._TRACE_FILE)
54 )
55
56 with repo_trace.Trace(content, first_trace=True):
57 pass
58 self.assertEqual(
59 first_trace_size * 2, os.path.getsize(repo_trace._TRACE_FILE)
60 )
diff --git a/tests/test_ssh.py b/tests/test_ssh.py
index ce65fac45..6bb2c2f4e 100644
--- a/tests/test_ssh.py
+++ b/tests/test_ssh.py
@@ -16,72 +16,84 @@
16 16
17import multiprocessing 17import multiprocessing
18import subprocess 18import subprocess
19import unittest 19from typing import Tuple
20from unittest import mock 20from unittest import mock
21 21
22import pytest
23
22import ssh 24import ssh
23 25
24 26
25class SshTests(unittest.TestCase): 27@pytest.fixture(autouse=True)
26 """Tests the ssh functions.""" 28def clear_ssh_version_cache() -> None:
29 """Clear the ssh version cache before each test."""
30 ssh.version.cache_clear()
27 31
28 def setUp(self) -> None:
29 super().setUp()
30 ssh.version.cache_clear()
31 32
32 def test_parse_ssh_version(self): 33@pytest.mark.parametrize(
33 """Check _parse_ssh_version() handling.""" 34 "input_str, expected",
34 ver = ssh._parse_ssh_version("Unknown\n") 35 (
35 self.assertEqual(ver, ()) 36 ("Unknown\n", ()),
36 ver = ssh._parse_ssh_version("OpenSSH_1.0\n") 37 ("OpenSSH_1.0\n", (1, 0)),
37 self.assertEqual(ver, (1, 0)) 38 (
38 ver = ssh._parse_ssh_version( 39 "OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13, OpenSSL 1.0.1f 6 Jan 2014\n",
39 "OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13, OpenSSL 1.0.1f 6 Jan 2014\n" 40 (6, 6, 1),
40 ) 41 ),
41 self.assertEqual(ver, (6, 6, 1)) 42 (
42 ver = ssh._parse_ssh_version( 43 "OpenSSH_7.6p1 Ubuntu-4ubuntu0.3, OpenSSL 1.0.2n 7 Dec 2017\n",
43 "OpenSSH_7.6p1 Ubuntu-4ubuntu0.3, OpenSSL 1.0.2n 7 Dec 2017\n" 44 (7, 6),
44 ) 45 ),
45 self.assertEqual(ver, (7, 6)) 46 ("OpenSSH_9.0p1, LibreSSL 3.3.6\n", (9, 0)),
46 ver = ssh._parse_ssh_version("OpenSSH_9.0p1, LibreSSL 3.3.6\n") 47 ),
47 self.assertEqual(ver, (9, 0)) 48)
48 49def test_parse_ssh_version(input_str: str, expected: Tuple[int, ...]) -> None:
49 def test_version(self): 50 """Check _parse_ssh_version() handling."""
50 """Check version() handling.""" 51 assert ssh._parse_ssh_version(input_str) == expected
51 with mock.patch("ssh._run_ssh_version", return_value="OpenSSH_1.2\n"): 52
52 self.assertEqual(ssh.version(), (1, 2)) 53
53 54def test_version() -> None:
54 def test_context_manager_empty(self): 55 """Check version() handling."""
55 """Verify context manager with no clients works correctly.""" 56 with mock.patch("ssh._run_ssh_version", return_value="OpenSSH_1.2\n"):
56 with multiprocessing.Manager() as manager: 57 assert ssh.version() == (1, 2)
57 with ssh.ProxyManager(manager): 58
58 pass 59
59 60def test_context_manager_empty() -> None:
60 def test_context_manager_child_cleanup(self): 61 """Verify context manager with no clients works correctly."""
61 """Verify orphaned clients & masters get cleaned up.""" 62 with multiprocessing.Manager() as manager:
62 with multiprocessing.Manager() as manager: 63 with ssh.ProxyManager(manager):
63 with mock.patch("ssh.version", return_value=(1, 2)): 64 pass
64 with ssh.ProxyManager(manager) as ssh_proxy: 65
65 client = subprocess.Popen(["sleep", "964853320"]) 66
66 ssh_proxy.add_client(client) 67def test_context_manager_child_cleanup() -> None:
67 master = subprocess.Popen(["sleep", "964853321"]) 68 """Verify orphaned clients & masters get cleaned up."""
68 ssh_proxy.add_master(master) 69 with multiprocessing.Manager() as manager:
69 # If the process still exists, these will throw timeout errors. 70 with mock.patch("ssh.version", return_value=(1, 2)):
70 client.wait(0) 71 with ssh.ProxyManager(manager) as ssh_proxy:
71 master.wait(0) 72 client = subprocess.Popen(["sleep", "964853320"])
72 73 ssh_proxy.add_client(client)
73 def test_ssh_sock(self): 74 master = subprocess.Popen(["sleep", "964853321"])
74 """Check sock() function.""" 75 ssh_proxy.add_master(master)
75 manager = multiprocessing.Manager() 76 # If the process still exists, these will throw timeout errors.
77 client.wait(0)
78 master.wait(0)
79
80
81def test_ssh_sock(monkeypatch: pytest.MonkeyPatch) -> None:
82 """Check sock() function."""
83 with multiprocessing.Manager() as manager:
76 proxy = ssh.ProxyManager(manager) 84 proxy = ssh.ProxyManager(manager)
77 with mock.patch("tempfile.mkdtemp", return_value="/tmp/foo"): 85 monkeypatch.setattr(
78 # Old ssh version uses port. 86 "tempfile.mkdtemp", lambda *args, **kwargs: "/tmp/foo"
79 with mock.patch("ssh.version", return_value=(6, 6)): 87 )
80 with proxy as ssh_proxy: 88
81 self.assertTrue(ssh_proxy.sock().endswith("%p")) 89 # Old ssh version uses port.
82 90 with mock.patch("ssh.version", return_value=(6, 6)):
83 proxy._sock_path = None 91 with proxy as ssh_proxy:
84 # New ssh version uses hash. 92 assert ssh_proxy.sock().endswith("%p")
85 with mock.patch("ssh.version", return_value=(6, 7)): 93
86 with proxy as ssh_proxy: 94 proxy._sock_path = None
87 self.assertTrue(ssh_proxy.sock().endswith("%C")) 95 # New ssh version uses hash.
96 with mock.patch("ssh.version", return_value=(6, 7)):
97 with proxy as ssh_proxy:
98 assert ssh_proxy.sock().endswith("%C")
99 proxy._sock_path = None
diff --git a/tests/test_subcmds.py b/tests/test_subcmds.py
index 3382d822d..12bbb2700 100644
--- a/tests/test_subcmds.py
+++ b/tests/test_subcmds.py
@@ -15,170 +15,164 @@
15"""Unittests for the subcmds module (mostly __init__.py than subcommands).""" 15"""Unittests for the subcmds module (mostly __init__.py than subcommands)."""
16 16
17import optparse 17import optparse
18import unittest 18from typing import Type
19
20import subcmds
21 19
20import pytest
22 21
23class AllCommands(unittest.TestCase): 22from command import Command
24 """Check registered all_commands.""" 23import subcmds
25 24
26 def test_required_basic(self):
27 """Basic checking of registered commands."""
28 # NB: We don't test all subcommands as we want to avoid "change
29 # detection" tests, so we just look for the most common/important ones
30 # here that are unlikely to ever change.
31 for cmd in {"cherry-pick", "help", "init", "start", "sync", "upload"}:
32 self.assertIn(cmd, subcmds.all_commands)
33 25
34 def test_naming(self): 26# NB: We don't test all subcommands as we want to avoid "change detection"
35 """Verify we don't add things that we shouldn't.""" 27# tests, so we just look for the most common/important ones here that are
36 for cmd in subcmds.all_commands: 28# unlikely to ever change.
37 # Reject filename suffixes like "help.py". 29@pytest.mark.parametrize(
38 self.assertNotIn(".", cmd) 30 "cmd", ("cherry-pick", "help", "init", "start", "sync", "upload")
31)
32def test_required_basic(cmd: str) -> None:
33 """Basic checking of registered commands."""
34 assert cmd in subcmds.all_commands
35
36
37@pytest.mark.parametrize("name", subcmds.all_commands.keys())
38def test_naming(name: str) -> None:
39 """Verify we don't add things that we shouldn't."""
40 # Reject filename suffixes like "help.py".
41 assert "." not in name
42
43 # Make sure all '_' were converted to '-'.
44 assert "_" not in name
45
46 # Reject internal python paths like "__init__".
47 assert not name.startswith("__")
48
49
50@pytest.mark.parametrize("name, cls", subcmds.all_commands.items())
51def test_help_desc_style(name: str, cls: Type[Command]) -> None:
52 """Force some consistency in option descriptions.
53
54 Python's optparse & argparse has a few default options like --help.
55 Their option description text uses lowercase sentence fragments, so
56 enforce our options follow the same style so UI is consistent.
57
58 We enforce:
59 * Text starts with lowercase.
60 * Text doesn't end with period.
61 """
62 cmd = cls()
63 parser = cmd.OptionParser
64 for option in parser.option_list:
65 if option.help == optparse.SUPPRESS_HELP or not option.help:
66 continue
67
68 c = option.help[0]
69 assert c.lower() == c, (
70 f"subcmds/{name}.py: {option.get_opt_string()}: "
71 f'help text should start with lowercase: "{option.help}"'
72 )
73
74 assert option.help[-1] != ".", (
75 f"subcmds/{name}.py: {option.get_opt_string()}: "
76 f'help text should not end in a period: "{option.help}"'
77 )
78
79
80@pytest.mark.parametrize("name, cls", subcmds.all_commands.items())
81def test_cli_option_style(name: str, cls: Type[Command]) -> None:
82 """Force some consistency in option flags."""
83 cmd = cls()
84 parser = cmd.OptionParser
85 for option in parser.option_list:
86 for opt in option._long_opts:
87 assert "_" not in opt, (
88 f"subcmds/{name}.py: {opt}: only use dashes in "
89 "options, not underscores"
90 )
39 91
40 # Make sure all '_' were converted to '-'.
41 self.assertNotIn("_", cmd)
42 92
43 # Reject internal python paths like "__init__". 93def test_cli_option_dest() -> None:
44 self.assertFalse(cmd.startswith("__")) 94 """Block redundant dest= arguments."""
95 bad_opts: list[tuple[str, str]] = []
45 96
46 def test_help_desc_style(self): 97 def _check_dest(opt: optparse.Option) -> None:
47 """Force some consistency in option descriptions. 98 """Check the dest= setting."""
99 # If the destination is not set, nothing to check.
100 # If long options are not set, then there's no implicit destination.
101 # If callback is used, then a destination might be needed because
102 # optparse cannot assume a value is always stored.
103 if opt.dest is None or not opt._long_opts or opt.callback:
104 return
48 105
49 Python's optparse & argparse has a few default options like --help. 106 long = opt._long_opts[0]
50 Their option description text uses lowercase sentence fragments, so 107 assert long.startswith("--")
51 enforce our options follow the same style so UI is consistent. 108 # This matches optparse's behavior.
109 implicit_dest = long[2:].replace("-", "_")
110 if implicit_dest == opt.dest:
111 bad_opts.append((str(opt), opt.dest))
52 112
53 We enforce: 113 # Hook the option check list.
54 * Text starts with lowercase. 114 optparse.Option.CHECK_METHODS.insert(0, _check_dest)
55 * Text doesn't end with period.
56 """
57 for name, cls in subcmds.all_commands.items():
58 cmd = cls()
59 parser = cmd.OptionParser
60 for option in parser.option_list:
61 if option.help == optparse.SUPPRESS_HELP:
62 continue
63
64 c = option.help[0]
65 self.assertEqual(
66 c.lower(),
67 c,
68 msg=f"subcmds/{name}.py: {option.get_opt_string()}: "
69 f'help text should start with lowercase: "{option.help}"',
70 )
71
72 self.assertNotEqual(
73 option.help[-1],
74 ".",
75 msg=f"subcmds/{name}.py: {option.get_opt_string()}: "
76 f'help text should not end in a period: "{option.help}"',
77 )
78
79 def test_cli_option_style(self):
80 """Force some consistency in option flags."""
81 for name, cls in subcmds.all_commands.items():
82 cmd = cls()
83 parser = cmd.OptionParser
84 for option in parser.option_list:
85 for opt in option._long_opts:
86 self.assertNotIn(
87 "_",
88 opt,
89 msg=f"subcmds/{name}.py: {opt}: only use dashes in "
90 "options, not underscores",
91 )
92
93 def test_cli_option_dest(self):
94 """Block redundant dest= arguments."""
95
96 def _check_dest(opt):
97 """Check the dest= setting."""
98 # If the destination is not set, nothing to check.
99 # If long options are not set, then there's no implicit destination.
100 # If callback is used, then a destination might be needed because
101 # optparse cannot assume a value is always stored.
102 if opt.dest is None or not opt._long_opts or opt.callback:
103 return
104
105 long = opt._long_opts[0]
106 assert long.startswith("--")
107 # This matches optparse's behavior.
108 implicit_dest = long[2:].replace("-", "_")
109 if implicit_dest == opt.dest:
110 bad_opts.append((str(opt), opt.dest))
111
112 # Hook the option check list.
113 optparse.Option.CHECK_METHODS.insert(0, _check_dest)
114 115
116 try:
115 # Gather all the bad options up front so people can see all bad options 117 # Gather all the bad options up front so people can see all bad options
116 # instead of failing at the first one. 118 # instead of failing at the first one.
117 all_bad_opts = {} 119 all_bad_opts: dict[str, list[tuple[str, str]]] = {}
118 for name, cls in subcmds.all_commands.items(): 120 for name, cls in subcmds.all_commands.items():
119 bad_opts = all_bad_opts[name] = [] 121 bad_opts = []
120 cmd = cls() 122 cmd = cls()
121 # Trigger construction of parser. 123 # Trigger construction of parser.
122 cmd.OptionParser 124 _ = cmd.OptionParser
125 all_bad_opts[name] = bad_opts
123 126
124 errmsg = None 127 errmsg = ""
125 for name, bad_opts in sorted(all_bad_opts.items()): 128 for name, bad_opts_list in sorted(all_bad_opts.items()):
126 if bad_opts: 129 if bad_opts_list:
127 if not errmsg: 130 if not errmsg:
128 errmsg = "Omit redundant dest= when defining options.\n" 131 errmsg = "Omit redundant dest= when defining options.\n"
129 errmsg += f"\nSubcommand {name} (subcmds/{name}.py):\n" 132 errmsg += f"\nSubcommand {name} (subcmds/{name}.py):\n"
130 errmsg += "".join( 133 errmsg += "".join(
131 f" {opt}: dest='{dest}'\n" for opt, dest in bad_opts 134 f" {opt}: dest='{dest}'\n" for opt, dest in bad_opts_list
132 ) 135 )
133 if errmsg: 136 if errmsg:
134 self.fail(errmsg) 137 pytest.fail(errmsg)
135 138 finally:
136 # Make sure we aren't popping the wrong stuff. 139 # Make sure we aren't popping the wrong stuff.
137 assert optparse.Option.CHECK_METHODS.pop(0) is _check_dest 140 assert optparse.Option.CHECK_METHODS.pop(0) is _check_dest
138 141
139 def test_common_validate_options(self):
140 """Verify CommonValidateOptions sets up expected fields."""
141 for name, cls in subcmds.all_commands.items():
142 cmd = cls()
143 opts, args = cmd.OptionParser.parse_args([])
144 142
145 # Verify the fields don't exist yet. 143@pytest.mark.parametrize("name, cls", subcmds.all_commands.items())
146 self.assertFalse( 144def test_common_validate_options(name: str, cls: Type[Command]) -> None:
147 hasattr(opts, "verbose"), 145 """Verify CommonValidateOptions sets up expected fields."""
148 msg=f"{name}: has verbose before validation", 146 cmd = cls()
149 ) 147 opts, args = cmd.OptionParser.parse_args([])
150 self.assertFalse(
151 hasattr(opts, "quiet"),
152 msg=f"{name}: has quiet before validation",
153 )
154 148
155 cmd.CommonValidateOptions(opts, args) 149 # Verify the fields don't exist yet.
150 assert not hasattr(
151 opts, "verbose"
152 ), f"{name}: has verbose before validation"
153 assert not hasattr(opts, "quiet"), f"{name}: has quiet before validation"
154
155 cmd.CommonValidateOptions(opts, args)
156
157 # Verify the fields exist now.
158 assert hasattr(opts, "verbose"), f"{name}: missing verbose after validation"
159 assert hasattr(opts, "quiet"), f"{name}: missing quiet after validation"
160 assert hasattr(
161 opts, "outer_manifest"
162 ), f"{name}: missing outer_manifest after validation"
156 163
157 # Verify the fields exist now.
158 self.assertTrue(
159 hasattr(opts, "verbose"),
160 msg=f"{name}: missing verbose after validation",
161 )
162 self.assertTrue(
163 hasattr(opts, "quiet"),
164 msg=f"{name}: missing quiet after validation",
165 )
166 self.assertTrue(
167 hasattr(opts, "outer_manifest"),
168 msg=f"{name}: missing outer_manifest after validation",
169 )
170 164
171 def test_attribute_error_repro(self): 165def test_attribute_error_repro() -> None:
172 """Confirm that accessing verbose before CommonValidateOptions fails.""" 166 """Confirm that accessing verbose before CommonValidateOptions fails."""
173 from subcmds.sync import Sync 167 from subcmds.sync import Sync
174 168
175 cmd = Sync() 169 cmd = Sync()
176 opts, args = cmd.OptionParser.parse_args([]) 170 opts, args = cmd.OptionParser.parse_args([])
177 171
178 # This confirms that without the fix in main.py, an AttributeError 172 # This confirms that without the fix in main.py, an AttributeError
179 # would be raised because CommonValidateOptions hasn't been called yet. 173 # would be raised because CommonValidateOptions hasn't been called yet.
180 with self.assertRaises(AttributeError): 174 with pytest.raises(AttributeError):
181 _ = opts.verbose 175 _ = opts.verbose
182 176
183 cmd.CommonValidateOptions(opts, args) 177 cmd.CommonValidateOptions(opts, args)
184 self.assertTrue(hasattr(opts, "verbose")) 178 assert hasattr(opts, "verbose")
diff --git a/tests/test_subcmds_init.py b/tests/test_subcmds_init.py
index 25e5be567..960fb93fb 100644
--- a/tests/test_subcmds_init.py
+++ b/tests/test_subcmds_init.py
@@ -14,33 +14,36 @@
14 14
15"""Unittests for the subcmds/init.py module.""" 15"""Unittests for the subcmds/init.py module."""
16 16
17import unittest 17from typing import List
18
19import pytest
18 20
19from subcmds import init 21from subcmds import init
20 22
21 23
22class InitCommand(unittest.TestCase): 24@pytest.mark.parametrize(
23 """Check registered all_commands.""" 25 "argv",
24 26 ([],),
25 def setUp(self): 27)
26 self.cmd = init.Init() 28def test_cli_parser_good(argv: List[str]) -> None:
27 29 """Check valid command line options."""
28 def test_cli_parser_good(self): 30 cmd = init.Init()
29 """Check valid command line options.""" 31 opts, args = cmd.OptionParser.parse_args(argv)
30 ARGV = ([],) 32 cmd.ValidateOptions(opts, args)
31 for argv in ARGV: 33
32 opts, args = self.cmd.OptionParser.parse_args(argv) 34
33 self.cmd.ValidateOptions(opts, args) 35@pytest.mark.parametrize(
34 36 "argv",
35 def test_cli_parser_bad(self): 37 (
36 """Check invalid command line options.""" 38 # Too many arguments.
37 ARGV = ( 39 ["url", "asdf"],
38 # Too many arguments. 40 # Conflicting options.
39 ["url", "asdf"], 41 ["--mirror", "--archive"],
40 # Conflicting options. 42 ),
41 ["--mirror", "--archive"], 43)
42 ) 44def test_cli_parser_bad(argv: List[str]) -> None:
43 for argv in ARGV: 45 """Check invalid command line options."""
44 opts, args = self.cmd.OptionParser.parse_args(argv) 46 cmd = init.Init()
45 with self.assertRaises(SystemExit): 47 opts, args = cmd.OptionParser.parse_args(argv)
46 self.cmd.ValidateOptions(opts, args) 48 with pytest.raises(SystemExit):
49 cmd.ValidateOptions(opts, args)
diff --git a/tests/test_update_manpages.py b/tests/test_update_manpages.py
index 06e5d3f5f..f52f8575f 100644
--- a/tests/test_update_manpages.py
+++ b/tests/test_update_manpages.py
@@ -14,15 +14,10 @@
14 14
15"""Unittests for the update_manpages module.""" 15"""Unittests for the update_manpages module."""
16 16
17import unittest
18
19from release import update_manpages 17from release import update_manpages
20 18
21 19
22class UpdateManpagesTest(unittest.TestCase): 20def test_replace_regex() -> None:
23 """Tests the update-manpages code.""" 21 """Check that replace_regex works."""
24 22 data = "\n\033[1mSummary\033[m\n"
25 def test_replace_regex(self): 23 assert update_manpages.replace_regex(data) == "\nSummary\n"
26 """Check that replace_regex works."""
27 data = "\n\033[1mSummary\033[m\n"
28 self.assertEqual(update_manpages.replace_regex(data), "\nSummary\n")