claude-dev-env 1.63.0 → 1.64.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/code_rules_dead_argparse_argument.py +554 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +40 -11
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_argparse_argument.py +534 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +40 -0
- package/hooks/hooks_constants/dead_argparse_argument_constants.py +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Dead argparse-argument check: an optional CLI flag whose value is never read."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
8
|
+
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if _blocking_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, _blocking_directory)
|
|
11
|
+
if _hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, _hooks_directory)
|
|
13
|
+
|
|
14
|
+
from code_rules_shared import ( # noqa: E402
|
|
15
|
+
is_migration_file,
|
|
16
|
+
is_test_file,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from hooks_constants.dead_argparse_argument_constants import ( # noqa: E402
|
|
20
|
+
ACTION_KEYWORD_NAME,
|
|
21
|
+
ADD_ARGUMENT_METHOD_NAME,
|
|
22
|
+
ALL_PARSE_METHOD_NAMES,
|
|
23
|
+
ALL_SUPPRESSED_ACTION_NAMES,
|
|
24
|
+
ATTRGETTER_FUNCTION_NAME,
|
|
25
|
+
DEAD_ARGPARSE_ARGUMENT_GUIDANCE,
|
|
26
|
+
DEST_KEYWORD_NAME,
|
|
27
|
+
DEST_WORD_JOINER,
|
|
28
|
+
DEST_WORD_SEPARATOR,
|
|
29
|
+
EXPORTED_NAMES_ATTRIBUTE,
|
|
30
|
+
GETATTR_FUNCTION_NAME,
|
|
31
|
+
GETATTR_NAME_ARGUMENT_MINIMUM,
|
|
32
|
+
LONG_OPTION_PREFIX,
|
|
33
|
+
MAX_DEAD_ARGPARSE_ARGUMENT_ISSUES,
|
|
34
|
+
NAMESPACE_DICT_ATTRIBUTE_NAME,
|
|
35
|
+
NAMESPACE_KEYWORD_NAME,
|
|
36
|
+
OPTION_PREFIX,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _string_constant_literal(node: ast.expr) -> str | None:
|
|
41
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
42
|
+
return node.value
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _keyword_string_literal(call_node: ast.Call, keyword_name: str) -> str | None:
|
|
47
|
+
"""Return the string literal of a keyword argument, or None when absent."""
|
|
48
|
+
for each_keyword in call_node.keywords:
|
|
49
|
+
if each_keyword.arg == keyword_name:
|
|
50
|
+
return _string_constant_literal(each_keyword.value)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _keyword_value_node(call_node: ast.Call, keyword_name: str) -> ast.expr | None:
|
|
55
|
+
"""Return the value node of a keyword argument, or None when absent."""
|
|
56
|
+
for each_keyword in call_node.keywords:
|
|
57
|
+
if each_keyword.arg == keyword_name:
|
|
58
|
+
return each_keyword.value
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _option_string_arguments(call_node: ast.Call) -> list[str]:
|
|
63
|
+
"""Return the leading positional string-literal arguments of an add_argument call."""
|
|
64
|
+
option_strings: list[str] = []
|
|
65
|
+
for each_argument in call_node.args:
|
|
66
|
+
literal_value = _string_constant_literal(each_argument)
|
|
67
|
+
if literal_value is None:
|
|
68
|
+
break
|
|
69
|
+
option_strings.append(literal_value)
|
|
70
|
+
return option_strings
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _dest_from_option_string(option_string: str) -> str:
|
|
74
|
+
return option_string.lstrip(OPTION_PREFIX).replace(DEST_WORD_SEPARATOR, DEST_WORD_JOINER)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _has_keyword_argument(call_node: ast.Call, keyword_name: str) -> bool:
|
|
78
|
+
"""Return whether the call passes a keyword argument with the given name."""
|
|
79
|
+
return any(each_keyword.arg == keyword_name for each_keyword in call_node.keywords)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _argument_dest_name(call_node: ast.Call) -> str | None:
|
|
83
|
+
"""Return the dest name an optional add_argument call declares, or None.
|
|
84
|
+
|
|
85
|
+
A ``dest=`` keyword determines the dest outright: a string-literal value
|
|
86
|
+
(``dest="name"``) names it, while a non-literal ``dest=`` (a variable or
|
|
87
|
+
expression whose value the static scan cannot resolve) makes the real dest
|
|
88
|
+
unknowable, so the argument is skipped (None). Without a ``dest=`` keyword the
|
|
89
|
+
dest derives from the first long option (``--repo`` -> ``repo``, ``--dry-run``
|
|
90
|
+
-> ``dry_run``) and falls back to the first short option when no long option
|
|
91
|
+
is present. A bare positional argument (no leading dash) is a required
|
|
92
|
+
positional, never an optional flag, and yields None so the caller skips it.
|
|
93
|
+
"""
|
|
94
|
+
if _has_keyword_argument(call_node, DEST_KEYWORD_NAME):
|
|
95
|
+
return _keyword_string_literal(call_node, DEST_KEYWORD_NAME)
|
|
96
|
+
option_strings = _option_string_arguments(call_node)
|
|
97
|
+
if not option_strings:
|
|
98
|
+
return None
|
|
99
|
+
if not option_strings[0].startswith(OPTION_PREFIX):
|
|
100
|
+
return None
|
|
101
|
+
for each_option in option_strings:
|
|
102
|
+
if each_option.startswith(LONG_OPTION_PREFIX):
|
|
103
|
+
return _dest_from_option_string(each_option)
|
|
104
|
+
return _dest_from_option_string(option_strings[0])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _has_suppressed_action(call_node: ast.Call) -> bool:
|
|
108
|
+
action_name = _keyword_string_literal(call_node, ACTION_KEYWORD_NAME)
|
|
109
|
+
return action_name in ALL_SUPPRESSED_ACTION_NAMES
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_add_argument_call(node: ast.AST) -> bool:
|
|
113
|
+
return (
|
|
114
|
+
isinstance(node, ast.Call)
|
|
115
|
+
and isinstance(node.func, ast.Attribute)
|
|
116
|
+
and node.func.attr == ADD_ARGUMENT_METHOD_NAME
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _argument_dest_definitions(tree: ast.Module) -> list[tuple[str, int]]:
|
|
121
|
+
"""Return (dest_name, line) for each optional add_argument call in the module.
|
|
122
|
+
|
|
123
|
+
Positional arguments (a bare name with no leading dash) and the argparse
|
|
124
|
+
auto-actions ``help`` and ``version`` are excluded, since their parsed value
|
|
125
|
+
is never a meaningful readable dest.
|
|
126
|
+
"""
|
|
127
|
+
dest_definitions: list[tuple[str, int]] = []
|
|
128
|
+
for each_node in ast.walk(tree):
|
|
129
|
+
if not _is_add_argument_call(each_node):
|
|
130
|
+
continue
|
|
131
|
+
assert isinstance(each_node, ast.Call)
|
|
132
|
+
if _has_suppressed_action(each_node):
|
|
133
|
+
continue
|
|
134
|
+
dest_name = _argument_dest_name(each_node)
|
|
135
|
+
if dest_name is None:
|
|
136
|
+
continue
|
|
137
|
+
dest_definitions.append((dest_name, each_node.lineno))
|
|
138
|
+
return dest_definitions
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _attribute_read_names(tree: ast.Module) -> set[str]:
|
|
142
|
+
"""Return every attribute name read or augmented-assigned in the module.
|
|
143
|
+
|
|
144
|
+
A read in Load context (``parsed_args.repo``) names the attribute, and an
|
|
145
|
+
augmented assignment (``parsed_args.count += 1``) reads the attribute before
|
|
146
|
+
writing it, so both count as a read of the dest name.
|
|
147
|
+
"""
|
|
148
|
+
read_names: set[str] = set()
|
|
149
|
+
for each_node in ast.walk(tree):
|
|
150
|
+
if isinstance(each_node, ast.Attribute) and isinstance(each_node.ctx, ast.Load):
|
|
151
|
+
read_names.add(each_node.attr)
|
|
152
|
+
if isinstance(each_node, ast.AugAssign) and isinstance(each_node.target, ast.Attribute):
|
|
153
|
+
read_names.add(each_node.target.attr)
|
|
154
|
+
return read_names
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _dynamic_read_names(tree: ast.Module) -> set[str]:
|
|
158
|
+
"""Return literal attribute names read via ``getattr`` and ``attrgetter``.
|
|
159
|
+
|
|
160
|
+
``getattr(namespace, "repo")`` names its attribute in the second positional
|
|
161
|
+
argument, while ``attrgetter("a", "b")`` names one attribute per positional
|
|
162
|
+
argument; each literal string contributes a read name.
|
|
163
|
+
"""
|
|
164
|
+
literal_names: set[str] = set()
|
|
165
|
+
for each_node in ast.walk(tree):
|
|
166
|
+
if not isinstance(each_node, ast.Call):
|
|
167
|
+
continue
|
|
168
|
+
function_name = None
|
|
169
|
+
if isinstance(each_node.func, ast.Name):
|
|
170
|
+
function_name = each_node.func.id
|
|
171
|
+
elif isinstance(each_node.func, ast.Attribute):
|
|
172
|
+
function_name = each_node.func.attr
|
|
173
|
+
if function_name == GETATTR_FUNCTION_NAME:
|
|
174
|
+
if len(each_node.args) >= GETATTR_NAME_ARGUMENT_MINIMUM:
|
|
175
|
+
literal_name = _string_constant_literal(each_node.args[1])
|
|
176
|
+
if literal_name is not None:
|
|
177
|
+
literal_names.add(literal_name)
|
|
178
|
+
elif function_name == ATTRGETTER_FUNCTION_NAME:
|
|
179
|
+
for each_argument in each_node.args:
|
|
180
|
+
literal_name = _string_constant_literal(each_argument)
|
|
181
|
+
if literal_name is not None:
|
|
182
|
+
literal_names.add(literal_name)
|
|
183
|
+
return literal_names
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _exported_names(tree: ast.Module) -> set[str]:
|
|
187
|
+
"""Return names listed in a module-level ``__all__`` literal."""
|
|
188
|
+
exported: set[str] = set()
|
|
189
|
+
for each_node in tree.body:
|
|
190
|
+
if not isinstance(each_node, ast.Assign):
|
|
191
|
+
continue
|
|
192
|
+
targets_all = any(
|
|
193
|
+
isinstance(each_target, ast.Name) and each_target.id == EXPORTED_NAMES_ATTRIBUTE
|
|
194
|
+
for each_target in each_node.targets
|
|
195
|
+
)
|
|
196
|
+
if not targets_all:
|
|
197
|
+
continue
|
|
198
|
+
if isinstance(each_node.value, (ast.List, ast.Tuple, ast.Set)):
|
|
199
|
+
for each_element in each_node.value.elts:
|
|
200
|
+
literal_name = _string_constant_literal(each_element)
|
|
201
|
+
if literal_name is not None:
|
|
202
|
+
exported.add(literal_name)
|
|
203
|
+
return exported
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _target_namespace_names(assign_target: ast.expr) -> set[str]:
|
|
207
|
+
"""Return names an assignment target binds the namespace to.
|
|
208
|
+
|
|
209
|
+
A bare ``ast.Name`` target (``parsed = parse_args()``) binds one namespace
|
|
210
|
+
name. A tuple- or list-unpack target (``parsed, remaining =
|
|
211
|
+
parse_known_args()``) binds the namespace to its first ``ast.Name`` element,
|
|
212
|
+
matching the documented ``(namespace, remaining)`` return shape.
|
|
213
|
+
"""
|
|
214
|
+
if isinstance(assign_target, ast.Name):
|
|
215
|
+
return {assign_target.id}
|
|
216
|
+
if isinstance(assign_target, (ast.Tuple, ast.List)):
|
|
217
|
+
for each_element in assign_target.elts:
|
|
218
|
+
if isinstance(each_element, ast.Name):
|
|
219
|
+
return {each_element.id}
|
|
220
|
+
return set()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _is_parse_method_call(node: ast.AST | None) -> bool:
|
|
224
|
+
"""Return whether a node is a ``parse_args``/``parse_known_args`` method call.
|
|
225
|
+
|
|
226
|
+
The call's function is an attribute access (``parser.parse_args``) whose
|
|
227
|
+
attribute name is one of the tracked parse methods.
|
|
228
|
+
"""
|
|
229
|
+
return (
|
|
230
|
+
isinstance(node, ast.Call)
|
|
231
|
+
and isinstance(node.func, ast.Attribute)
|
|
232
|
+
and node.func.attr in ALL_PARSE_METHOD_NAMES
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parse_result_binding_targets(node: ast.AST) -> list[ast.expr] | None:
|
|
237
|
+
"""Return the targets a statement binds a parse-method result to, or None.
|
|
238
|
+
|
|
239
|
+
A plain ``ast.Assign`` (``parsed = parse_args()``) or an annotated
|
|
240
|
+
``ast.AnnAssign`` (``parsed: Namespace = parse_args()``) whose value is a
|
|
241
|
+
parse-method call binds the namespace; the returned list holds its target nodes,
|
|
242
|
+
one for an annotated assignment and one or more for a plain assignment. A node
|
|
243
|
+
that is not such a binding, including an annotated assignment with no value
|
|
244
|
+
(``parsed: Namespace``), returns None.
|
|
245
|
+
"""
|
|
246
|
+
if isinstance(node, ast.Assign):
|
|
247
|
+
binding_targets: list[ast.expr] = node.targets
|
|
248
|
+
bound_value: ast.expr | None = node.value
|
|
249
|
+
elif isinstance(node, ast.AnnAssign):
|
|
250
|
+
binding_targets = [node.target]
|
|
251
|
+
bound_value = node.value
|
|
252
|
+
else:
|
|
253
|
+
return None
|
|
254
|
+
if not _is_parse_method_call(bound_value):
|
|
255
|
+
return None
|
|
256
|
+
return binding_targets
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parse_call_namespace_names(tree: ast.Module) -> set[str]:
|
|
260
|
+
"""Return names bound to a plain or annotated ``parse_args``/``parse_known_args`` result."""
|
|
261
|
+
parse_call_names: set[str] = set()
|
|
262
|
+
for each_node in ast.walk(tree):
|
|
263
|
+
binding_targets = _parse_result_binding_targets(each_node)
|
|
264
|
+
if binding_targets is None:
|
|
265
|
+
continue
|
|
266
|
+
for each_target in binding_targets:
|
|
267
|
+
parse_call_names |= _target_namespace_names(each_target)
|
|
268
|
+
return parse_call_names
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _alias_namespace_names(tree: ast.Module, all_parse_call_names: set[str]) -> set[str]:
|
|
272
|
+
"""Return names bound by re-assigning an existing namespace variable.
|
|
273
|
+
|
|
274
|
+
A simple ``alias = parsed_arguments`` re-binding aliases the namespace, and a
|
|
275
|
+
chain (``second = alias``) aliases it again, so the set grows to a fixed point
|
|
276
|
+
before it is returned.
|
|
277
|
+
"""
|
|
278
|
+
all_namespace_names = set(all_parse_call_names)
|
|
279
|
+
keeps_growing = True
|
|
280
|
+
while keeps_growing:
|
|
281
|
+
keeps_growing = False
|
|
282
|
+
for each_node in ast.walk(tree):
|
|
283
|
+
if not isinstance(each_node, ast.Assign):
|
|
284
|
+
continue
|
|
285
|
+
if not isinstance(each_node.value, ast.Name):
|
|
286
|
+
continue
|
|
287
|
+
if each_node.value.id not in all_namespace_names:
|
|
288
|
+
continue
|
|
289
|
+
for each_target in each_node.targets:
|
|
290
|
+
if isinstance(each_target, ast.Name) and each_target.id not in all_namespace_names:
|
|
291
|
+
all_namespace_names.add(each_target.id)
|
|
292
|
+
keeps_growing = True
|
|
293
|
+
return all_namespace_names
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _namespace_keyword_argument_names(tree: ast.Module) -> set[str]:
|
|
297
|
+
"""Return names passed as the ``namespace=`` keyword to a parse-method call.
|
|
298
|
+
|
|
299
|
+
``parser.parse_args(namespace=options)`` populates the pre-existing
|
|
300
|
+
``options`` object, so its attribute reads name dests even though the parse
|
|
301
|
+
result is never bound; the keyword's ``ast.Name`` value names that namespace.
|
|
302
|
+
"""
|
|
303
|
+
keyword_names: set[str] = set()
|
|
304
|
+
for each_node in ast.walk(tree):
|
|
305
|
+
if not isinstance(each_node, ast.Call):
|
|
306
|
+
continue
|
|
307
|
+
function_node = each_node.func
|
|
308
|
+
if not isinstance(function_node, ast.Attribute):
|
|
309
|
+
continue
|
|
310
|
+
if function_node.attr not in ALL_PARSE_METHOD_NAMES:
|
|
311
|
+
continue
|
|
312
|
+
keyword_value = _keyword_value_node(each_node, NAMESPACE_KEYWORD_NAME)
|
|
313
|
+
if isinstance(keyword_value, ast.Name):
|
|
314
|
+
keyword_names.add(keyword_value.id)
|
|
315
|
+
return keyword_names
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _namespace_variable_names(tree: ast.Module) -> set[str]:
|
|
319
|
+
"""Return names bound to a parse result, a ``namespace=`` object, or an alias.
|
|
320
|
+
|
|
321
|
+
A direct binding plain or annotated (``parsed = parse_args()`` or
|
|
322
|
+
``parsed: Namespace = parse_args()``), the first element of a tuple-unpack of the
|
|
323
|
+
documented ``(namespace, remaining)`` return (``parsed, remaining =
|
|
324
|
+
parse_known_args()``), a ``namespace=`` keyword object
|
|
325
|
+
(``parse_args(namespace=options)``), and a simple re-binding chain
|
|
326
|
+
(``alias = parsed``) each name the same namespace.
|
|
327
|
+
"""
|
|
328
|
+
seed_names = _parse_call_namespace_names(tree) | _namespace_keyword_argument_names(tree)
|
|
329
|
+
return _alias_namespace_names(tree, seed_names)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _attribute_value_name_ids(tree: ast.Module) -> set[int]:
|
|
333
|
+
"""Return ``id()`` of every Name that is the object of an attribute access.
|
|
334
|
+
|
|
335
|
+
The Name in ``namespace.repo`` is the ``.value`` of an ``ast.Attribute``, so it
|
|
336
|
+
is a per-attribute read rather than a use of the namespace object itself.
|
|
337
|
+
"""
|
|
338
|
+
return {
|
|
339
|
+
id(each_node.value)
|
|
340
|
+
for each_node in ast.walk(tree)
|
|
341
|
+
if isinstance(each_node, ast.Attribute) and isinstance(each_node.value, ast.Name)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _namespace_keyword_value_name_ids(tree: ast.Module) -> set[int]:
|
|
346
|
+
"""Return ``id()`` of every Name passed as the ``namespace=`` keyword to a parse call.
|
|
347
|
+
|
|
348
|
+
``parse_args(namespace=options)`` binds the namespace at this position rather
|
|
349
|
+
than consuming it, so this Name is excluded from the escape scan the way an
|
|
350
|
+
attribute-read object is.
|
|
351
|
+
"""
|
|
352
|
+
keyword_value_ids: set[int] = set()
|
|
353
|
+
for each_node in ast.walk(tree):
|
|
354
|
+
if not isinstance(each_node, ast.Call):
|
|
355
|
+
continue
|
|
356
|
+
function_node = each_node.func
|
|
357
|
+
if not isinstance(function_node, ast.Attribute):
|
|
358
|
+
continue
|
|
359
|
+
if function_node.attr not in ALL_PARSE_METHOD_NAMES:
|
|
360
|
+
continue
|
|
361
|
+
keyword_value = _keyword_value_node(each_node, NAMESPACE_KEYWORD_NAME)
|
|
362
|
+
if isinstance(keyword_value, ast.Name):
|
|
363
|
+
keyword_value_ids.add(id(keyword_value))
|
|
364
|
+
return keyword_value_ids
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _namespace_used_as_value(tree: ast.Module, all_namespace_names: set[str]) -> bool:
|
|
368
|
+
"""Return whether a tracked namespace Name is used as a value rather than an attribute read.
|
|
369
|
+
|
|
370
|
+
A tracked namespace Name read in Load context anywhere in the module -- passed
|
|
371
|
+
to a call, returned, aliased, double-star unpacked, or nested inside a container
|
|
372
|
+
literal -- uses the namespace object itself and hides which attributes are read.
|
|
373
|
+
Two positions are excluded: the object of an attribute access (``namespace.repo``
|
|
374
|
+
is a per-attribute read) and a Name passed as the ``namespace=`` keyword to a
|
|
375
|
+
parse call (a binding site, not a consumption).
|
|
376
|
+
"""
|
|
377
|
+
excluded_name_ids = _attribute_value_name_ids(tree) | _namespace_keyword_value_name_ids(tree)
|
|
378
|
+
for each_node in ast.walk(tree):
|
|
379
|
+
if not isinstance(each_node, ast.Name):
|
|
380
|
+
continue
|
|
381
|
+
if not isinstance(each_node.ctx, ast.Load):
|
|
382
|
+
continue
|
|
383
|
+
if each_node.id not in all_namespace_names:
|
|
384
|
+
continue
|
|
385
|
+
if id(each_node) in excluded_name_ids:
|
|
386
|
+
continue
|
|
387
|
+
return True
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _namespace_dict_accessed(tree: ast.Module, all_namespace_names: set[str]) -> bool:
|
|
392
|
+
"""Return whether a tracked namespace exposes all attributes via ``__dict__``."""
|
|
393
|
+
for each_node in ast.walk(tree):
|
|
394
|
+
if (
|
|
395
|
+
isinstance(each_node, ast.Attribute)
|
|
396
|
+
and each_node.attr == NAMESPACE_DICT_ATTRIBUTE_NAME
|
|
397
|
+
and isinstance(each_node.value, ast.Name)
|
|
398
|
+
and each_node.value.id in all_namespace_names
|
|
399
|
+
):
|
|
400
|
+
return True
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _namespace_escapes(tree: ast.Module, all_namespace_names: set[str]) -> bool:
|
|
405
|
+
"""Return whether a namespace is consumed in a way that hides which dests are read.
|
|
406
|
+
|
|
407
|
+
A namespace whose attributes the static scan cannot enumerate makes any single
|
|
408
|
+
dest unprovably dead, so the check suppresses. Each of the following is such an
|
|
409
|
+
escape: a ``parse_args``/``parse_known_args`` bound method referenced as an
|
|
410
|
+
aliased value rather than called inline; a parse result bound to an attribute-
|
|
411
|
+
or subscript-target the scan cannot track; a parse result consumed inline within
|
|
412
|
+
a larger expression rather than bound to an assignment or a bare statement; a
|
|
413
|
+
tracked namespace read through ``__dict__``; and a tracked namespace Name used as
|
|
414
|
+
a value rather than an attribute read (passed to a call, returned, aliased,
|
|
415
|
+
double-star unpacked, or nested inside a container literal), excluding the object
|
|
416
|
+
of an attribute access and a Name passed as the ``namespace=`` keyword to a parse
|
|
417
|
+
call.
|
|
418
|
+
"""
|
|
419
|
+
if _parse_method_is_aliased(tree):
|
|
420
|
+
return True
|
|
421
|
+
if _parse_result_binds_untracked_target(tree):
|
|
422
|
+
return True
|
|
423
|
+
if _parse_call_consumed_inline(tree):
|
|
424
|
+
return True
|
|
425
|
+
if _namespace_dict_accessed(tree, all_namespace_names):
|
|
426
|
+
return True
|
|
427
|
+
return _namespace_used_as_value(tree, all_namespace_names)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _parse_method_is_aliased(tree: ast.Module) -> bool:
|
|
431
|
+
"""Return whether a ``parse_args``/``parse_known_args`` method is referenced uncalled.
|
|
432
|
+
|
|
433
|
+
The bound method is aliased when its attribute (``parser.parse_args``) appears
|
|
434
|
+
as a value rather than as the ``func`` of an enclosing call — assigned to a
|
|
435
|
+
name or passed around — so the resulting namespace is produced by a call the
|
|
436
|
+
scan cannot follow back to ``parse_args``.
|
|
437
|
+
"""
|
|
438
|
+
all_called_function_ids = {
|
|
439
|
+
id(each_node.func) for each_node in ast.walk(tree) if isinstance(each_node, ast.Call)
|
|
440
|
+
}
|
|
441
|
+
for each_node in ast.walk(tree):
|
|
442
|
+
if not isinstance(each_node, ast.Attribute):
|
|
443
|
+
continue
|
|
444
|
+
if each_node.attr not in ALL_PARSE_METHOD_NAMES:
|
|
445
|
+
continue
|
|
446
|
+
if id(each_node) not in all_called_function_ids:
|
|
447
|
+
return True
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _parse_result_binds_untracked_target(tree: ast.Module) -> bool:
|
|
452
|
+
"""Return whether a parse-method result binds to a target the scan cannot track.
|
|
453
|
+
|
|
454
|
+
A ``parse_args``/``parse_known_args`` result bound by a plain or annotated
|
|
455
|
+
assignment to a plain ``ast.Name`` target, or to a tuple/list whose first
|
|
456
|
+
element is a plain ``ast.Name``, is tracked as a namespace variable. A result
|
|
457
|
+
bound to an attribute target (``self.args``) or a subscript target binds the
|
|
458
|
+
namespace where the scan cannot follow its attribute reads, so it escapes.
|
|
459
|
+
"""
|
|
460
|
+
for each_node in ast.walk(tree):
|
|
461
|
+
binding_targets = _parse_result_binding_targets(each_node)
|
|
462
|
+
if binding_targets is None:
|
|
463
|
+
continue
|
|
464
|
+
for each_target in binding_targets:
|
|
465
|
+
if not _target_namespace_names(each_target):
|
|
466
|
+
return True
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _parse_call_consumed_inline(tree: ast.Module) -> bool:
|
|
471
|
+
"""Return whether a parse-method result is consumed inline rather than bound to a target.
|
|
472
|
+
|
|
473
|
+
A ``parse_args``/``parse_known_args`` call is statically trackable only when its
|
|
474
|
+
result is the value of a plain assignment (``parsed = parser.parse_args()``), an
|
|
475
|
+
annotated assignment (``parsed: Namespace = parser.parse_args()``), or a bare
|
|
476
|
+
expression statement (``parser.parse_args(namespace=options)``, whose result is
|
|
477
|
+
discarded after populating the keyword object). Consumed inside a larger
|
|
478
|
+
expression instead -- returned directly, passed to ``vars``, double-star
|
|
479
|
+
unpacked, or bound by a walrus -- the namespace never reaches a tracked name, so
|
|
480
|
+
the check suppresses.
|
|
481
|
+
"""
|
|
482
|
+
statement_bound_call_ids: set[int] = set()
|
|
483
|
+
for each_node in ast.walk(tree):
|
|
484
|
+
if not isinstance(each_node, (ast.Assign, ast.AnnAssign, ast.Expr)):
|
|
485
|
+
continue
|
|
486
|
+
if _is_parse_method_call(each_node.value):
|
|
487
|
+
statement_bound_call_ids.add(id(each_node.value))
|
|
488
|
+
for each_node in ast.walk(tree):
|
|
489
|
+
if _is_parse_method_call(each_node) and id(each_node) not in statement_bound_call_ids:
|
|
490
|
+
return True
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def check_dead_argparse_arguments(
|
|
495
|
+
content: str, file_path: str, full_file_content: str | None = None
|
|
496
|
+
) -> list[str]:
|
|
497
|
+
"""Flag an optional argparse flag whose parsed value the same file never reads.
|
|
498
|
+
|
|
499
|
+
An optional ``add_argument("--flag", ...)`` is dead when its dest name never
|
|
500
|
+
appears as an attribute read, an augmented-assignment target, or a literal
|
|
501
|
+
``getattr``/``attrgetter`` access anywhere in the file, the name is not listed
|
|
502
|
+
in a module-level ``__all__``, and no parsed namespace escapes static view. A
|
|
503
|
+
namespace escapes when a ``parse_args``/``parse_known_args`` bound method is
|
|
504
|
+
referenced as an aliased value rather than called inline, when a parse result
|
|
505
|
+
binds to an attribute- or subscript-target the scan cannot track, when a parse
|
|
506
|
+
result is consumed inline within a larger expression rather than bound to an
|
|
507
|
+
assignment or a bare statement, when a tracked namespace is read through
|
|
508
|
+
``__dict__``, or when a tracked namespace Name is used as a value rather than an
|
|
509
|
+
attribute read (passed to a call, returned, aliased, double-star unpacked, or
|
|
510
|
+
nested inside a container literal), excluding the object of an attribute access
|
|
511
|
+
and a Name passed as the ``namespace=`` keyword to a parse call. A namespace
|
|
512
|
+
name is tracked when it binds a parse result, a tuple-unpacked
|
|
513
|
+
parse result, a ``namespace=`` keyword object, or an alias of one of these.
|
|
514
|
+
Positional arguments and the argparse ``help``/``version`` auto-actions are never
|
|
515
|
+
flagged. Whole-file analysis runs against ``full_file_content`` when supplied so
|
|
516
|
+
an Edit fragment is judged against the reconstructed post-edit file.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
content: The new content under validation (Edit fragment or whole file).
|
|
520
|
+
file_path: The destination path, used for the test/migration exemptions.
|
|
521
|
+
full_file_content: The reconstructed post-edit whole-file content for an
|
|
522
|
+
Edit, or None for a Write where ``content`` is already the whole file.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
One violation message per dead optional argument, capped at the configured
|
|
526
|
+
maximum.
|
|
527
|
+
"""
|
|
528
|
+
if is_test_file(file_path):
|
|
529
|
+
return []
|
|
530
|
+
if is_migration_file(file_path):
|
|
531
|
+
return []
|
|
532
|
+
effective_content = content if full_file_content is None else full_file_content
|
|
533
|
+
try:
|
|
534
|
+
tree = ast.parse(effective_content)
|
|
535
|
+
except SyntaxError:
|
|
536
|
+
return []
|
|
537
|
+
dest_definitions = _argument_dest_definitions(tree)
|
|
538
|
+
if not dest_definitions:
|
|
539
|
+
return []
|
|
540
|
+
all_namespace_names = _namespace_variable_names(tree)
|
|
541
|
+
if _namespace_escapes(tree, all_namespace_names):
|
|
542
|
+
return []
|
|
543
|
+
read_names = _attribute_read_names(tree) | _dynamic_read_names(tree) | _exported_names(tree)
|
|
544
|
+
issues: list[str] = []
|
|
545
|
+
for each_dest_definition in dest_definitions:
|
|
546
|
+
dest_name, dest_line = each_dest_definition
|
|
547
|
+
if dest_name in read_names:
|
|
548
|
+
continue
|
|
549
|
+
issues.append(
|
|
550
|
+
f"Line {dest_line}: CLI argument {dest_name!r} - {DEAD_ARGPARSE_ARGUMENT_GUIDANCE}"
|
|
551
|
+
)
|
|
552
|
+
if len(issues) >= MAX_DEAD_ARGPARSE_ARGUMENT_ISSUES:
|
|
553
|
+
return issues
|
|
554
|
+
return issues
|
|
@@ -217,6 +217,31 @@ def _constructed_class_names(tree: ast.Module) -> set[str]:
|
|
|
217
217
|
return constructed
|
|
218
218
|
|
|
219
219
|
|
|
220
|
+
def _module_level_singleton_class_names(tree: ast.Module) -> set[str]:
|
|
221
|
+
"""Return class names constructed by a module-level assignment.
|
|
222
|
+
|
|
223
|
+
A module-level assignment such as ``CURRENT_OS = CurrentOsConfig()`` binds a
|
|
224
|
+
singleton that importer modules read across files, beyond this file's view.
|
|
225
|
+
The in-file read scan cannot observe those cross-module reads, so a field on
|
|
226
|
+
such a class is never provably dead from this file alone. A construction that
|
|
227
|
+
sits inside a function or class body is not collected, so a dataclass built
|
|
228
|
+
for local use stays in scope for the check.
|
|
229
|
+
"""
|
|
230
|
+
singleton_class_names: set[str] = set()
|
|
231
|
+
for each_statement in tree.body:
|
|
232
|
+
if not isinstance(each_statement, (ast.Assign, ast.AnnAssign)):
|
|
233
|
+
continue
|
|
234
|
+
assigned_value = each_statement.value
|
|
235
|
+
if assigned_value is None:
|
|
236
|
+
continue
|
|
237
|
+
for each_node in ast.walk(assigned_value):
|
|
238
|
+
if isinstance(each_node, ast.Call) and isinstance(
|
|
239
|
+
each_node.func, ast.Name
|
|
240
|
+
):
|
|
241
|
+
singleton_class_names.add(each_node.func.id)
|
|
242
|
+
return singleton_class_names
|
|
243
|
+
|
|
244
|
+
|
|
220
245
|
def _is_whole_instance_stringify_call(node: ast.AST) -> bool:
|
|
221
246
|
"""Return whether a call stringifies a whole instance via ``str``/``repr``/``format``."""
|
|
222
247
|
if not isinstance(node, ast.Call):
|
|
@@ -256,17 +281,18 @@ def check_dead_dataclass_fields(
|
|
|
256
281
|
) -> list[str]:
|
|
257
282
|
"""Flag a @dataclass field that the same file constructs but never reads.
|
|
258
283
|
|
|
259
|
-
A field is dead when its dataclass is instantiated
|
|
260
|
-
(so the class is live)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
284
|
+
A field is dead when its dataclass is instantiated by a call in the file
|
|
285
|
+
(so the class is live) but never bound to a module-level singleton (whose
|
|
286
|
+
fields importer modules read across files, beyond this file's view), the
|
|
287
|
+
field name never appears as an attribute read, an augmented-assignment
|
|
288
|
+
target, a class-pattern keyword, or a literal ``getattr``/``attrgetter``
|
|
289
|
+
access anywhere in the file, and the file contains no non-literal dynamic
|
|
290
|
+
access, reflective whole-instance consumer (``asdict``, ``astuple``,
|
|
291
|
+
``fields``, ``replace``, ``vars``), ``__dict__`` read, or auto-generated
|
|
292
|
+
dataclass dunder field read (comparison, set/dict membership, or
|
|
293
|
+
whole-instance stringification) that could read it indirectly. Whole-file
|
|
294
|
+
analysis runs against ``full_file_content`` when supplied so an Edit
|
|
295
|
+
fragment is judged against the reconstructed post-edit file.
|
|
270
296
|
|
|
271
297
|
Args:
|
|
272
298
|
content: The new content under validation (Edit fragment or whole file).
|
|
@@ -300,12 +326,15 @@ def check_dead_dataclass_fields(
|
|
|
300
326
|
| _exported_names(tree)
|
|
301
327
|
)
|
|
302
328
|
constructed_class_names = _constructed_class_names(tree)
|
|
329
|
+
singleton_class_names = _module_level_singleton_class_names(tree)
|
|
303
330
|
issues: list[str] = []
|
|
304
331
|
for each_node in ast.walk(tree):
|
|
305
332
|
if not isinstance(each_node, ast.ClassDef) or not _is_dataclass(each_node):
|
|
306
333
|
continue
|
|
307
334
|
if each_node.name not in constructed_class_names:
|
|
308
335
|
continue
|
|
336
|
+
if each_node.name in singleton_class_names:
|
|
337
|
+
continue
|
|
309
338
|
for each_field_definition in _dataclass_field_definitions(each_node):
|
|
310
339
|
field_name, field_line = each_field_definition
|
|
311
340
|
if field_name in read_names:
|
|
@@ -52,6 +52,9 @@ from code_rules_constants_config import ( # noqa: E402
|
|
|
52
52
|
check_constants_outside_config_advisory,
|
|
53
53
|
check_file_global_constants_use_count,
|
|
54
54
|
)
|
|
55
|
+
from code_rules_dead_argparse_argument import ( # noqa: E402
|
|
56
|
+
check_dead_argparse_arguments,
|
|
57
|
+
)
|
|
55
58
|
from code_rules_dead_config_field import ( # noqa: E402
|
|
56
59
|
check_dead_config_dataclass_fields,
|
|
57
60
|
)
|
|
@@ -282,6 +285,9 @@ def validate_content(
|
|
|
282
285
|
all_issues.extend(
|
|
283
286
|
check_dead_dataclass_fields(content, file_path, full_file_content)
|
|
284
287
|
)
|
|
288
|
+
all_issues.extend(
|
|
289
|
+
check_dead_argparse_arguments(content, file_path, full_file_content)
|
|
290
|
+
)
|
|
285
291
|
all_issues.extend(
|
|
286
292
|
check_dead_config_dataclass_fields(content, file_path, full_file_content)
|
|
287
293
|
)
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
7
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
8
|
+
assert specification is not None and specification.loader is not None
|
|
9
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
10
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
11
|
+
|
|
12
|
+
PRODUCTION_FILE_PATH = "packages/app/services/render_report.py"
|
|
13
|
+
TEST_FILE_PATH = "packages/app/services/test_render_report.py"
|
|
14
|
+
MIGRATION_FILE_PATH = "packages/app/migrations/0001_initial.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
18
|
+
return code_rules_enforcer.check_dead_argparse_arguments(source, file_path)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_should_flag_optional_argument_whose_dest_is_never_read() -> None:
|
|
22
|
+
source = (
|
|
23
|
+
"import argparse\n"
|
|
24
|
+
"\n"
|
|
25
|
+
"def build() -> None:\n"
|
|
26
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
27
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
28
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
29
|
+
" print('done')\n"
|
|
30
|
+
)
|
|
31
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
32
|
+
assert any("'repo'" in each_issue for each_issue in issues), (
|
|
33
|
+
f"Expected dead '--repo' argument flagged, got: {issues}"
|
|
34
|
+
)
|
|
35
|
+
assert any("Line 5" in each_issue for each_issue in issues), (
|
|
36
|
+
f"Expected the add_argument line reported, got: {issues}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_should_not_flag_when_dest_is_read_via_attribute_access() -> None:
|
|
41
|
+
source = (
|
|
42
|
+
"import argparse\n"
|
|
43
|
+
"\n"
|
|
44
|
+
"def build() -> str:\n"
|
|
45
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
46
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
47
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
48
|
+
" return parsed_arguments.repo\n"
|
|
49
|
+
)
|
|
50
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_should_not_flag_a_positional_argument() -> None:
|
|
54
|
+
source = (
|
|
55
|
+
"import argparse\n"
|
|
56
|
+
"\n"
|
|
57
|
+
"def build() -> None:\n"
|
|
58
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
59
|
+
" argument_parser.add_argument('path')\n"
|
|
60
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
61
|
+
" print('done')\n"
|
|
62
|
+
)
|
|
63
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_should_not_flag_help_action_argument() -> None:
|
|
67
|
+
source = (
|
|
68
|
+
"import argparse\n"
|
|
69
|
+
"\n"
|
|
70
|
+
"def build() -> None:\n"
|
|
71
|
+
" argument_parser = argparse.ArgumentParser(add_help=False)\n"
|
|
72
|
+
" argument_parser.add_argument('--help', action='help')\n"
|
|
73
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
74
|
+
" print('done')\n"
|
|
75
|
+
)
|
|
76
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_should_not_flag_version_action_argument() -> None:
|
|
80
|
+
source = (
|
|
81
|
+
"import argparse\n"
|
|
82
|
+
"\n"
|
|
83
|
+
"def build() -> None:\n"
|
|
84
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
85
|
+
" argument_parser.add_argument('--version', action='version', version='1.0')\n"
|
|
86
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
87
|
+
" print('done')\n"
|
|
88
|
+
)
|
|
89
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_should_respect_explicit_dest_when_unread() -> None:
|
|
93
|
+
source = (
|
|
94
|
+
"import argparse\n"
|
|
95
|
+
"\n"
|
|
96
|
+
"def build() -> None:\n"
|
|
97
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
98
|
+
" argument_parser.add_argument('--repo', dest='repository')\n"
|
|
99
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
100
|
+
" print('done')\n"
|
|
101
|
+
)
|
|
102
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
103
|
+
assert any("'repository'" in each_issue for each_issue in issues), (
|
|
104
|
+
f"Expected the explicit dest flagged, got: {issues}"
|
|
105
|
+
)
|
|
106
|
+
assert not any("'repo'" in each_issue for each_issue in issues), (
|
|
107
|
+
f"The option string must not be flagged when dest is explicit, got: {issues}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_should_respect_explicit_dest_when_read() -> None:
|
|
112
|
+
source = (
|
|
113
|
+
"import argparse\n"
|
|
114
|
+
"\n"
|
|
115
|
+
"def build() -> str:\n"
|
|
116
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
117
|
+
" argument_parser.add_argument('--repo', dest='repository')\n"
|
|
118
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
119
|
+
" return parsed_arguments.repository\n"
|
|
120
|
+
)
|
|
121
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_should_skip_argument_with_non_literal_dest() -> None:
|
|
125
|
+
source = (
|
|
126
|
+
"import argparse\n"
|
|
127
|
+
"\n"
|
|
128
|
+
"DEST_NAME = 'repository'\n"
|
|
129
|
+
"\n"
|
|
130
|
+
"def build() -> str:\n"
|
|
131
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
132
|
+
" argument_parser.add_argument('--repo', dest=DEST_NAME)\n"
|
|
133
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
134
|
+
" return parsed_arguments.repository\n"
|
|
135
|
+
)
|
|
136
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_derive_dest_from_dashed_long_option() -> None:
|
|
140
|
+
source = (
|
|
141
|
+
"import argparse\n"
|
|
142
|
+
"\n"
|
|
143
|
+
"def build() -> bool:\n"
|
|
144
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
145
|
+
" argument_parser.add_argument('--dry-run', action='store_true')\n"
|
|
146
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
147
|
+
" return parsed_arguments.dry_run\n"
|
|
148
|
+
)
|
|
149
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_flag_dashed_long_option_when_dest_unread() -> None:
|
|
153
|
+
source = (
|
|
154
|
+
"import argparse\n"
|
|
155
|
+
"\n"
|
|
156
|
+
"def build() -> None:\n"
|
|
157
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
158
|
+
" argument_parser.add_argument('--dry-run', action='store_true')\n"
|
|
159
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
160
|
+
" print('done')\n"
|
|
161
|
+
)
|
|
162
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
163
|
+
assert any("'dry_run'" in each_issue for each_issue in issues), (
|
|
164
|
+
f"Expected the dashed long option dest flagged, got: {issues}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_should_derive_dest_from_long_option_when_short_precedes() -> None:
|
|
169
|
+
source = (
|
|
170
|
+
"import argparse\n"
|
|
171
|
+
"\n"
|
|
172
|
+
"def build() -> None:\n"
|
|
173
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
174
|
+
" argument_parser.add_argument('-r', '--repo')\n"
|
|
175
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
176
|
+
" print('done')\n"
|
|
177
|
+
)
|
|
178
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
179
|
+
assert any("'repo'" in each_issue for each_issue in issues), (
|
|
180
|
+
f"Expected dest derived from the long option, got: {issues}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_should_suppress_when_namespace_is_forwarded_to_a_call() -> None:
|
|
185
|
+
source = (
|
|
186
|
+
"import argparse\n"
|
|
187
|
+
"\n"
|
|
188
|
+
"def run(parsed_arguments: argparse.Namespace) -> None:\n"
|
|
189
|
+
" print('run')\n"
|
|
190
|
+
"\n"
|
|
191
|
+
"def build() -> None:\n"
|
|
192
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
193
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
194
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
195
|
+
" run(parsed_arguments)\n"
|
|
196
|
+
)
|
|
197
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_should_suppress_when_tuple_unpacked_namespace_is_forwarded() -> None:
|
|
201
|
+
source = (
|
|
202
|
+
"import argparse\n"
|
|
203
|
+
"\n"
|
|
204
|
+
"def main(parsed_arguments: argparse.Namespace) -> None:\n"
|
|
205
|
+
" print('run')\n"
|
|
206
|
+
"\n"
|
|
207
|
+
"def build() -> None:\n"
|
|
208
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
209
|
+
" argument_parser.add_argument('--verbose', action='store_true')\n"
|
|
210
|
+
" parsed_arguments, remaining = argument_parser.parse_known_args()\n"
|
|
211
|
+
" main(parsed_arguments)\n"
|
|
212
|
+
)
|
|
213
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_should_suppress_when_aliased_namespace_is_forwarded() -> None:
|
|
217
|
+
source = (
|
|
218
|
+
"import argparse\n"
|
|
219
|
+
"\n"
|
|
220
|
+
"def run(parsed_arguments: argparse.Namespace) -> None:\n"
|
|
221
|
+
" print('run')\n"
|
|
222
|
+
"\n"
|
|
223
|
+
"def build() -> None:\n"
|
|
224
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
225
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
226
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
227
|
+
" alias = parsed_arguments\n"
|
|
228
|
+
" run(alias)\n"
|
|
229
|
+
)
|
|
230
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_should_suppress_when_namespace_consumed_by_vars() -> None:
|
|
234
|
+
source = (
|
|
235
|
+
"import argparse\n"
|
|
236
|
+
"\n"
|
|
237
|
+
"def build() -> dict:\n"
|
|
238
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
239
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
240
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
241
|
+
" return vars(parsed_arguments)\n"
|
|
242
|
+
)
|
|
243
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_should_suppress_when_namespace_dict_accessed() -> None:
|
|
247
|
+
source = (
|
|
248
|
+
"import argparse\n"
|
|
249
|
+
"\n"
|
|
250
|
+
"def build() -> dict:\n"
|
|
251
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
252
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
253
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
254
|
+
" return parsed_arguments.__dict__\n"
|
|
255
|
+
)
|
|
256
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_should_be_silent_for_test_files() -> None:
|
|
260
|
+
source = (
|
|
261
|
+
"import argparse\n"
|
|
262
|
+
"\n"
|
|
263
|
+
"def build() -> None:\n"
|
|
264
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
265
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
266
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
267
|
+
" print('done')\n"
|
|
268
|
+
)
|
|
269
|
+
assert _check(source, TEST_FILE_PATH) == []
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_should_be_silent_for_migration_files() -> None:
|
|
273
|
+
source = (
|
|
274
|
+
"import argparse\n"
|
|
275
|
+
"\n"
|
|
276
|
+
"def build() -> None:\n"
|
|
277
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
278
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
279
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
280
|
+
" print('done')\n"
|
|
281
|
+
)
|
|
282
|
+
assert _check(source, MIGRATION_FILE_PATH) == []
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_should_tolerate_syntax_error() -> None:
|
|
286
|
+
assert _check("def broken(:\n", PRODUCTION_FILE_PATH) == []
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_should_return_empty_when_no_add_argument_present() -> None:
|
|
290
|
+
source = "def build() -> int:\n return 1\n"
|
|
291
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_should_suppress_when_namespace_unpacked_with_double_star() -> None:
|
|
295
|
+
source = (
|
|
296
|
+
"import argparse\n"
|
|
297
|
+
"\n"
|
|
298
|
+
"def run(repo: str) -> None:\n"
|
|
299
|
+
" print(repo)\n"
|
|
300
|
+
"\n"
|
|
301
|
+
"def build() -> None:\n"
|
|
302
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
303
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
304
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
305
|
+
" run(**vars(parsed_arguments))\n"
|
|
306
|
+
)
|
|
307
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_should_not_flag_when_dest_read_via_literal_getattr() -> None:
|
|
311
|
+
source = (
|
|
312
|
+
"import argparse\n"
|
|
313
|
+
"\n"
|
|
314
|
+
"def build() -> str:\n"
|
|
315
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
316
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
317
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
318
|
+
" return getattr(parsed_arguments, 'repo')\n"
|
|
319
|
+
)
|
|
320
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_should_suppress_when_namespace_stored_on_attribute_target() -> None:
|
|
324
|
+
source = (
|
|
325
|
+
"import argparse\n"
|
|
326
|
+
"\n"
|
|
327
|
+
"class App:\n"
|
|
328
|
+
" def build(self) -> None:\n"
|
|
329
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
330
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
331
|
+
" argument_parser.add_argument('--verbose', action='store_true')\n"
|
|
332
|
+
" self.parsed_arguments = argument_parser.parse_args()\n"
|
|
333
|
+
"\n"
|
|
334
|
+
" def run(self) -> dict:\n"
|
|
335
|
+
" return dict(**vars(self.parsed_arguments))\n"
|
|
336
|
+
)
|
|
337
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_should_suppress_when_parse_method_is_aliased() -> None:
|
|
341
|
+
source = (
|
|
342
|
+
"import argparse\n"
|
|
343
|
+
"\n"
|
|
344
|
+
"def run(parsed_arguments: argparse.Namespace) -> None:\n"
|
|
345
|
+
" print('run')\n"
|
|
346
|
+
"\n"
|
|
347
|
+
"def build() -> None:\n"
|
|
348
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
349
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
350
|
+
" parse = argument_parser.parse_args\n"
|
|
351
|
+
" parsed_arguments = parse()\n"
|
|
352
|
+
" run(parsed_arguments)\n"
|
|
353
|
+
)
|
|
354
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_should_suppress_when_namespace_forwarded_inside_container() -> None:
|
|
358
|
+
source = (
|
|
359
|
+
"import argparse\n"
|
|
360
|
+
"\n"
|
|
361
|
+
"def run(payload: dict) -> None:\n"
|
|
362
|
+
" print('run')\n"
|
|
363
|
+
"\n"
|
|
364
|
+
"def build() -> None:\n"
|
|
365
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
366
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
367
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
368
|
+
" run({'args': parsed_arguments})\n"
|
|
369
|
+
)
|
|
370
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_should_suppress_when_namespace_keyword_object_is_consumed() -> None:
|
|
374
|
+
source = (
|
|
375
|
+
"import argparse\n"
|
|
376
|
+
"\n"
|
|
377
|
+
"def build() -> dict:\n"
|
|
378
|
+
" options = argparse.Namespace()\n"
|
|
379
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
380
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
381
|
+
" argument_parser.parse_args(namespace=options)\n"
|
|
382
|
+
" return vars(options)\n"
|
|
383
|
+
)
|
|
384
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_should_suppress_when_namespace_keyword_object_is_forwarded() -> None:
|
|
388
|
+
source = (
|
|
389
|
+
"import argparse\n"
|
|
390
|
+
"\n"
|
|
391
|
+
"def run(parsed_arguments: argparse.Namespace) -> None:\n"
|
|
392
|
+
" print('run')\n"
|
|
393
|
+
"\n"
|
|
394
|
+
"def build() -> None:\n"
|
|
395
|
+
" options = argparse.Namespace()\n"
|
|
396
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
397
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
398
|
+
" argument_parser.parse_args(namespace=options)\n"
|
|
399
|
+
" run(options)\n"
|
|
400
|
+
)
|
|
401
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_should_flag_when_namespace_keyword_object_only_attribute_read() -> None:
|
|
405
|
+
source = (
|
|
406
|
+
"import argparse\n"
|
|
407
|
+
"\n"
|
|
408
|
+
"def build() -> str:\n"
|
|
409
|
+
" options = argparse.Namespace()\n"
|
|
410
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
411
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
412
|
+
" argument_parser.add_argument('--verbose', action='store_true')\n"
|
|
413
|
+
" argument_parser.parse_args(namespace=options)\n"
|
|
414
|
+
" return options.repo\n"
|
|
415
|
+
)
|
|
416
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
417
|
+
assert any("'verbose'" in each_issue for each_issue in issues), (
|
|
418
|
+
f"Expected the unread '--verbose' flag flagged, got: {issues}"
|
|
419
|
+
)
|
|
420
|
+
assert not any("'repo'" in each_issue for each_issue in issues), (
|
|
421
|
+
f"The read namespace= attribute must not be flagged, got: {issues}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_should_suppress_when_parse_result_consumed_by_vars_inline() -> None:
|
|
426
|
+
source = (
|
|
427
|
+
"import argparse\n"
|
|
428
|
+
"\n"
|
|
429
|
+
"def build() -> dict:\n"
|
|
430
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
431
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
432
|
+
" return vars(argument_parser.parse_args())\n"
|
|
433
|
+
)
|
|
434
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_should_suppress_when_parse_result_returned_inline() -> None:
|
|
438
|
+
source = (
|
|
439
|
+
"import argparse\n"
|
|
440
|
+
"\n"
|
|
441
|
+
"def build() -> argparse.Namespace:\n"
|
|
442
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
443
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
444
|
+
" return argument_parser.parse_args()\n"
|
|
445
|
+
)
|
|
446
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def test_should_suppress_when_parse_result_double_star_unpacked_inline() -> None:
|
|
450
|
+
source = (
|
|
451
|
+
"import argparse\n"
|
|
452
|
+
"\n"
|
|
453
|
+
"def run(repo: str) -> None:\n"
|
|
454
|
+
" print(repo)\n"
|
|
455
|
+
"\n"
|
|
456
|
+
"def build() -> None:\n"
|
|
457
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
458
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
459
|
+
" run(**vars(argument_parser.parse_args()))\n"
|
|
460
|
+
)
|
|
461
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_should_flag_when_annotated_namespace_dest_is_unread() -> None:
|
|
465
|
+
source = (
|
|
466
|
+
"import argparse\n"
|
|
467
|
+
"\n"
|
|
468
|
+
"def build() -> None:\n"
|
|
469
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
470
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
471
|
+
" parsed_arguments: argparse.Namespace = argument_parser.parse_args()\n"
|
|
472
|
+
" print('done')\n"
|
|
473
|
+
)
|
|
474
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
475
|
+
assert any("'repo'" in each_issue for each_issue in issues), (
|
|
476
|
+
f"Expected the dead flag flagged through the annotated binding, got: {issues}"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_should_not_flag_when_annotated_namespace_dest_is_read() -> None:
|
|
481
|
+
source = (
|
|
482
|
+
"import argparse\n"
|
|
483
|
+
"\n"
|
|
484
|
+
"def build() -> str:\n"
|
|
485
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
486
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
487
|
+
" parsed_arguments: argparse.Namespace = argument_parser.parse_args()\n"
|
|
488
|
+
" return parsed_arguments.repo\n"
|
|
489
|
+
)
|
|
490
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_should_suppress_when_annotated_namespace_is_forwarded() -> None:
|
|
494
|
+
source = (
|
|
495
|
+
"import argparse\n"
|
|
496
|
+
"\n"
|
|
497
|
+
"def run(parsed_arguments: argparse.Namespace) -> None:\n"
|
|
498
|
+
" print('run')\n"
|
|
499
|
+
"\n"
|
|
500
|
+
"def build() -> None:\n"
|
|
501
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
502
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
503
|
+
" parsed_arguments: argparse.Namespace = argument_parser.parse_args()\n"
|
|
504
|
+
" run(parsed_arguments)\n"
|
|
505
|
+
)
|
|
506
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def test_should_suppress_when_parse_result_bound_by_walrus() -> None:
|
|
510
|
+
source = (
|
|
511
|
+
"import argparse\n"
|
|
512
|
+
"\n"
|
|
513
|
+
"def build() -> dict:\n"
|
|
514
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
515
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
516
|
+
" return vars(parsed_arguments := argument_parser.parse_args())\n"
|
|
517
|
+
)
|
|
518
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def test_validate_content_runs_dead_argparse_check() -> None:
|
|
522
|
+
source = (
|
|
523
|
+
"import argparse\n"
|
|
524
|
+
"\n"
|
|
525
|
+
"def build() -> None:\n"
|
|
526
|
+
" argument_parser = argparse.ArgumentParser()\n"
|
|
527
|
+
" argument_parser.add_argument('--repo', default='.')\n"
|
|
528
|
+
" parsed_arguments = argument_parser.parse_args()\n"
|
|
529
|
+
" print('done')\n"
|
|
530
|
+
)
|
|
531
|
+
issues = code_rules_enforcer.validate_content(source, PRODUCTION_FILE_PATH)
|
|
532
|
+
assert any("'repo'" in each_issue for each_issue in issues), (
|
|
533
|
+
f"Expected the enforcer dispatch to surface the dead argument, got: {issues}"
|
|
534
|
+
)
|
|
@@ -465,3 +465,43 @@ def test_should_evaluate_full_file_content_when_supplied() -> None:
|
|
|
465
465
|
assert not any(
|
|
466
466
|
"'number'" in each_issue for each_issue in issues
|
|
467
467
|
), f"Read field 'number' must not be flagged, got: {issues}"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_should_not_flag_field_on_module_level_singleton() -> None:
|
|
471
|
+
source = (
|
|
472
|
+
"from dataclasses import dataclass\n"
|
|
473
|
+
"\n"
|
|
474
|
+
"@dataclass(frozen=True)\n"
|
|
475
|
+
"class AppConfig:\n"
|
|
476
|
+
" timeout: int\n"
|
|
477
|
+
" retries: int\n"
|
|
478
|
+
"\n"
|
|
479
|
+
"SETTINGS = AppConfig(timeout=30, retries=3)\n"
|
|
480
|
+
)
|
|
481
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_should_flag_local_dataclass_but_not_module_level_singleton() -> None:
|
|
485
|
+
source = (
|
|
486
|
+
"from dataclasses import dataclass\n"
|
|
487
|
+
"\n"
|
|
488
|
+
"@dataclass(frozen=True)\n"
|
|
489
|
+
"class AppConfig:\n"
|
|
490
|
+
" timeout: int\n"
|
|
491
|
+
"\n"
|
|
492
|
+
"@dataclass\n"
|
|
493
|
+
"class LocalRow:\n"
|
|
494
|
+
" url: str\n"
|
|
495
|
+
"\n"
|
|
496
|
+
"SETTINGS = AppConfig(timeout=30)\n"
|
|
497
|
+
"\n"
|
|
498
|
+
"def build() -> LocalRow:\n"
|
|
499
|
+
" return LocalRow(url='x')\n"
|
|
500
|
+
)
|
|
501
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
502
|
+
assert any(
|
|
503
|
+
"'url'" in each_issue and "LocalRow" in each_issue for each_issue in issues
|
|
504
|
+
), f"Locally-constructed dead field must still be flagged, got: {issues}"
|
|
505
|
+
assert not any(
|
|
506
|
+
"AppConfig" in each_issue for each_issue in issues
|
|
507
|
+
), f"Module-level singleton fields must not be flagged, got: {issues}"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Constants for the dead argparse-argument detector in ``code_rules_enforcer``.
|
|
2
|
+
|
|
3
|
+
Lives under the hooks-tree ``hooks_constants`` package so module-level
|
|
4
|
+
UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
|
|
5
|
+
requirement and share a home with the other hook-tree configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
ADD_ARGUMENT_METHOD_NAME: str = "add_argument"
|
|
9
|
+
ALL_PARSE_METHOD_NAMES: frozenset[str] = frozenset({"parse_args", "parse_known_args"})
|
|
10
|
+
DEST_KEYWORD_NAME: str = "dest"
|
|
11
|
+
NAMESPACE_KEYWORD_NAME: str = "namespace"
|
|
12
|
+
ACTION_KEYWORD_NAME: str = "action"
|
|
13
|
+
ALL_SUPPRESSED_ACTION_NAMES: frozenset[str] = frozenset({"help", "version"})
|
|
14
|
+
GETATTR_FUNCTION_NAME: str = "getattr"
|
|
15
|
+
GETATTR_NAME_ARGUMENT_MINIMUM: int = 2
|
|
16
|
+
ATTRGETTER_FUNCTION_NAME: str = "attrgetter"
|
|
17
|
+
NAMESPACE_DICT_ATTRIBUTE_NAME: str = "__dict__"
|
|
18
|
+
OPTION_PREFIX: str = "-"
|
|
19
|
+
LONG_OPTION_PREFIX: str = "--"
|
|
20
|
+
DEST_WORD_SEPARATOR: str = "-"
|
|
21
|
+
DEST_WORD_JOINER: str = "_"
|
|
22
|
+
EXPORTED_NAMES_ATTRIBUTE: str = "__all__"
|
|
23
|
+
MAX_DEAD_ARGPARSE_ARGUMENT_ISSUES: int = 25
|
|
24
|
+
DEAD_ARGPARSE_ARGUMENT_GUIDANCE: str = (
|
|
25
|
+
"optional CLI flag whose parsed value is never read in this file - remove the"
|
|
26
|
+
" add_argument call (argparse silently accepts an unused flag), or read the"
|
|
27
|
+
" parsed value where it is needed (CODE_RULES §9.8)"
|
|
28
|
+
)
|