-
-
Notifications
You must be signed in to change notification settings - Fork 934
/
Copy pathtest_cmd_git.py
309 lines (245 loc) · 11.7 KB
/
test_cmd_git.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""Tests for static and dynamic characteristics of Git class and instance attributes.
Currently this all relates to the deprecated :attr:`Git.USE_SHELL` class attribute,
which can also be accessed through instances. Some tests directly verify its behavior,
including deprecation warnings, while others verify that other aspects of attribute
access are not inadvertently broken by mechanisms introduced to issue the warnings.
A note on multiprocessing
=========================
Because USE_SHELL has no instance state, this module does not include tests of pickling
and multiprocessing:
- Just as with a simple class attribute, when a class attribute with custom logic is
later set to a new value, it may have either its initial value or the new value when
accessed from a worker process, depending on the process start method. With "fork",
changes are preserved. With "spawn" or "forkserver", re-importing the modules causes
initial values to be set. Then the value in the parent at the time it dispatches the
task is only set in the children if the parent has the task set it, or if it is set as
a side effect of importing needed modules, or of unpickling objects passed to the
child (for example, if it is set in a top-level statement of the module that defines
the function submitted for the child worker process to call).
- When an attribute gains new logic provided by a property or custom descriptor, and the
attribute involves instance-level state, incomplete or corrupted pickling can break
multiprocessing. (For example, if an instance attribute is reimplemented using a
descriptor that stores data in a global WeakKeyDictionary, pickled instances should be
tested to ensure they are still working correctly.) But nothing like that applies
here, because instance state is not involved. Although the situation is inherently
complex as described above, it is independent of the attribute implementation.
- That USE_SHELL cannot be set on instances, and that when retrieved on instances it
always gives the same value as on the class, is covered in the tests here.
A note on metaclass conflicts
=============================
The most important DeprecationWarning is for the code ``Git.USE_SHELL = True``, which is
a security risk. But this warning may not be possible to implement without a custom
metaclass. This is because a descriptor in a class can customize all forms of attribute
access on its instances, but can only customize getting an attribute on the class.
Retrieving a descriptor from a class calls its ``__get__`` method (if defined), but
replacing or deleting it does not call its ``__set__`` or ``__delete__`` methods.
Adding a metaclass is a potentially breaking change. This is because derived classes
that use an unrelated metaclass, whether directly or by inheriting from a class such as
abc.ABC that uses one, will raise TypeError when defined. These would have to be
modified to use a newly introduced metaclass that is a lower bound of both. Subclasses
remain unbroken in the far more typical case that they use no custom metaclass.
The tests in this module do not establish whether the danger of setting Git.USE_SHELL to
True is high enough, and applications of deriving from Git and using an unrelated custom
metaclass marginal enough, to justify introducing a metaclass to issue the warnings.
"""
import contextlib
import sys
from typing import Generator
import warnings
if sys.version_info >= (3, 11):
from typing import assert_type
else:
from typing_extensions import assert_type
import pytest
from pytest import WarningsRecorder
from git.cmd import Git
_USE_SHELL_DEPRECATED_FRAGMENT = "Git.USE_SHELL is deprecated"
"""Text contained in all USE_SHELL deprecation warnings, and starting most of them."""
_USE_SHELL_DANGEROUS_FRAGMENT = "Setting Git.USE_SHELL to True is unsafe and insecure"
"""Beginning text of USE_SHELL deprecation warnings when USE_SHELL is set True."""
@contextlib.contextmanager
def _suppress_deprecation_warning() -> Generator[None, None, None]:
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
yield
@pytest.fixture
def restore_use_shell_state() -> Generator[None, None, None]:
"""Fixture to attempt to restore state associated with the ``USE_SHELL`` attribute.
This is used to decrease the likelihood of state changes leaking out and affecting
other tests. But the goal is not to assert that ``_USE_SHELL`` is used, nor anything
about how or when it is used, which is an implementation detail subject to change.
This is possible but inelegant to do with pytest's monkeypatch fixture, which only
restores attributes that it has previously been used to change, create, or remove.
"""
no_value = object()
try:
old_backing_value = Git._USE_SHELL
except AttributeError:
old_backing_value = no_value
try:
with _suppress_deprecation_warning():
old_public_value = Git.USE_SHELL
# This doesn't have its own try-finally because pytest catches exceptions raised
# during the yield. (The outer try-finally catches exceptions in this fixture.)
yield
with _suppress_deprecation_warning():
Git.USE_SHELL = old_public_value
finally:
if old_backing_value is no_value:
with contextlib.suppress(AttributeError):
del Git._USE_SHELL
else:
Git._USE_SHELL = old_backing_value
def test_cannot_access_undefined_on_git_class() -> None:
"""Accessing a bogus attribute on the Git class remains a dynamic and static error.
This differs from Git instances, where most attribute names will dynamically
synthesize a "bound method" that runs a git subcommand when called.
"""
with pytest.raises(AttributeError):
Git.foo # type: ignore[attr-defined]
def test_get_use_shell_on_class_default() -> None:
"""USE_SHELL can be read as a class attribute, defaulting to False and warning."""
with pytest.deprecated_call() as ctx:
use_shell = Git.USE_SHELL
(message,) = [str(entry.message) for entry in ctx] # Exactly one warning.
assert message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
assert_type(use_shell, bool)
# This comes after the static assertion, just in case it would affect the inference.
assert not use_shell
def test_get_use_shell_on_instance_default() -> None:
"""USE_SHELL can be read as an instance attribute, defaulting to False and warning.
This is the same as test_get_use_shell_on_class_default above, but for instances.
The test is repeated, instead of using parametrization, for clearer static analysis.
"""
instance = Git()
with pytest.deprecated_call() as ctx:
use_shell = instance.USE_SHELL
(message,) = [str(entry.message) for entry in ctx] # Exactly one warning.
assert message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
assert_type(use_shell, bool)
# This comes after the static assertion, just in case it would affect the inference.
assert not use_shell
def _assert_use_shell_full_results(
set_value: bool,
reset_value: bool,
setting: WarningsRecorder,
checking: WarningsRecorder,
resetting: WarningsRecorder,
rechecking: WarningsRecorder,
) -> None:
# The attribute should take on the values set to it.
assert set_value is True
assert reset_value is False
# Each access should warn exactly once.
(set_message,) = [str(entry.message) for entry in setting]
(check_message,) = [str(entry.message) for entry in checking]
(reset_message,) = [str(entry.message) for entry in resetting]
(recheck_message,) = [str(entry.message) for entry in rechecking]
# Setting it to True should produce the special warning for that.
assert _USE_SHELL_DEPRECATED_FRAGMENT in set_message
assert set_message.startswith(_USE_SHELL_DANGEROUS_FRAGMENT)
# All other operations should produce a usual warning.
assert check_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
assert reset_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
assert recheck_message.startswith(_USE_SHELL_DEPRECATED_FRAGMENT)
def test_use_shell_set_and_get_on_class(restore_use_shell_state: None) -> None:
"""USE_SHELL can be set and re-read as a class attribute, always warning."""
with pytest.deprecated_call() as setting:
Git.USE_SHELL = True
with pytest.deprecated_call() as checking:
set_value = Git.USE_SHELL
with pytest.deprecated_call() as resetting:
Git.USE_SHELL = False
with pytest.deprecated_call() as rechecking:
reset_value = Git.USE_SHELL
_assert_use_shell_full_results(
set_value,
reset_value,
setting,
checking,
resetting,
rechecking,
)
def test_use_shell_set_on_class_get_on_instance(restore_use_shell_state: None) -> None:
"""USE_SHELL can be set on the class and read on an instance, always warning.
This is like test_use_shell_set_and_get_on_class but it performs reads on an
instance. There is some redundancy here in assertions about warnings when the
attribute is set, but it is a separate test so that any bugs where a read on the
class (or an instance) is needed first before a read on an instance (or the class)
are detected.
"""
instance = Git()
with pytest.deprecated_call() as setting:
Git.USE_SHELL = True
with pytest.deprecated_call() as checking:
set_value = instance.USE_SHELL
with pytest.deprecated_call() as resetting:
Git.USE_SHELL = False
with pytest.deprecated_call() as rechecking:
reset_value = instance.USE_SHELL
_assert_use_shell_full_results(
set_value,
reset_value,
setting,
checking,
resetting,
rechecking,
)
@pytest.mark.parametrize("value", [False, True])
def test_use_shell_cannot_set_on_instance(
value: bool,
restore_use_shell_state: None, # In case of a bug where it does set USE_SHELL.
) -> None:
instance = Git()
with pytest.raises(AttributeError):
instance.USE_SHELL = value
_EXPECTED_DIR_SUBSET = {
"cat_file_all",
"cat_file_header",
"GIT_PYTHON_TRACE",
"USE_SHELL", # The attribute we get deprecation warnings for.
"GIT_PYTHON_GIT_EXECUTABLE",
"refresh",
"is_cygwin",
"polish_url",
"check_unsafe_protocols",
"check_unsafe_options",
"AutoInterrupt",
"CatFileContentStream",
"__init__",
"__getattr__",
"set_persistent_git_options",
"working_dir",
"version_info",
"execute",
"environment",
"update_environment",
"custom_environment",
"transform_kwarg",
"transform_kwargs",
"__call__",
"_call_process", # Not currently considered public, but unlikely to change.
"get_object_header",
"get_object_data",
"stream_object_data",
"clear_cache",
}
"""Some stable attributes dir() should include on the Git class and its instances.
This is intentionally incomplete, but includes substantial variety. Most importantly, it
includes both ``USE_SHELL`` and a wide sampling of other attributes.
"""
def test_class_dir() -> None:
"""dir() on the Git class includes its statically known attributes.
This tests that the mechanism that adds dynamic behavior to USE_SHELL accesses so
that all accesses issue warnings does not break dir() for the class, neither for
USE_SHELL nor for ordinary (non-deprecated) attributes.
"""
actual = set(dir(Git))
assert _EXPECTED_DIR_SUBSET <= actual
def test_instance_dir() -> None:
"""dir() on Git objects includes its statically known attributes.
This is like test_class_dir, but for Git instance rather than the class itself.
"""
instance = Git()
actual = set(dir(instance))
assert _EXPECTED_DIR_SUBSET <= actual