forked from HypothesisWorks/hypothesis
-
Notifications
You must be signed in to change notification settings - Fork 0
/
cli.py
323 lines (291 loc) · 11.9 KB
/
cli.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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# This file is part of Hypothesis, which may be found at
# https://github.com/HypothesisWorks/hypothesis/
#
# Copyright the Hypothesis Authors.
# Individual contributors are listed in AUTHORS.rst and the git log.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.
"""
.. _hypothesis-cli:
----------------
hypothesis[cli]
----------------
::
$ hypothesis --help
Usage: hypothesis [OPTIONS] COMMAND [ARGS]...
Options:
--version Show the version and exit.
-h, --help Show this message and exit.
Commands:
codemod `hypothesis codemod` refactors deprecated or inefficient code.
fuzz [hypofuzz] runs tests with an adaptive coverage-guided fuzzer.
write `hypothesis write` writes property-based tests for you!
This module requires the :pypi:`click` package, and provides Hypothesis' command-line
interface, for e.g. :doc:`'ghostwriting' tests <ghostwriter>` via the terminal.
It's also where `HypoFuzz <https://hypofuzz.com/>`__ adds the :command:`hypothesis fuzz`
command (`learn more about that here <https://hypofuzz.com/docs/quickstart.html>`__).
"""
import builtins
import importlib
import inspect
import sys
import types
from difflib import get_close_matches
from functools import partial
from multiprocessing import Pool
try:
import pytest
except ImportError:
pytest = None # type: ignore
MESSAGE = """
The Hypothesis command-line interface requires the `{}` package,
which you do not have installed. Run:
python -m pip install --upgrade hypothesis[cli]
and try again.
"""
try:
import click
except ImportError:
def main():
"""If `click` is not installed, tell the user to install it then exit."""
sys.stderr.write(MESSAGE.format("click"))
sys.exit(1)
else:
# Ensure that Python scripts in the current working directory are importable,
# on the principle that Ghostwriter should 'just work' for novice users. Note
# that we append rather than prepend to the module search path, so this will
# never shadow the stdlib or installed packages.
sys.path.append(".")
@click.group(context_settings={"help_option_names": ("-h", "--help")})
@click.version_option()
def main():
pass
def obj_name(s: str) -> object:
"""This "type" imports whatever object is named by a dotted string."""
s = s.strip()
if "/" in s or "\\" in s:
raise click.UsageError(
"Remember that the ghostwriter should be passed the name of a module, not a path."
) from None
try:
return importlib.import_module(s)
except ImportError:
pass
classname = None
if "." not in s:
modulename, module, funcname = "builtins", builtins, s
else:
modulename, funcname = s.rsplit(".", 1)
try:
module = importlib.import_module(modulename)
except ImportError as err:
try:
modulename, classname = modulename.rsplit(".", 1)
module = importlib.import_module(modulename)
except (ImportError, ValueError):
if s.endswith(".py"):
raise click.UsageError(
"Remember that the ghostwriter should be passed the name of a module, not a file."
) from None
raise click.UsageError(
f"Failed to import the {modulename} module for introspection. "
"Check spelling and your Python import path, or use the Python API?"
) from err
def describe_close_matches(
module_or_class: types.ModuleType, objname: str
) -> str:
public_names = [
name for name in vars(module_or_class) if not name.startswith("_")
]
matches = get_close_matches(objname, public_names)
if matches:
return f" Closest matches: {matches!r}"
return ""
if classname is None:
try:
return getattr(module, funcname)
except AttributeError as err:
if funcname == "py":
# Likely attempted to pass a local file (Eg., "myscript.py") instead of a module name
raise click.UsageError(
"Remember that the ghostwriter should be passed the name of a module, not a file."
+ f"\n\tTry: hypothesis write {s[:-3]}"
) from None
raise click.UsageError(
f"Found the {modulename!r} module, but it doesn't have a "
f"{funcname!r} attribute."
+ describe_close_matches(module, funcname)
) from err
else:
try:
func_class = getattr(module, classname)
except AttributeError as err:
raise click.UsageError(
f"Found the {modulename!r} module, but it doesn't have a "
f"{classname!r} class." + describe_close_matches(module, classname)
) from err
try:
return getattr(func_class, funcname)
except AttributeError as err:
if inspect.isclass(func_class):
func_class_is = "class"
else:
func_class_is = "attribute"
raise click.UsageError(
f"Found the {modulename!r} module and {classname!r} {func_class_is}, "
f"but it doesn't have a {funcname!r} attribute."
+ describe_close_matches(func_class, funcname)
) from err
def _refactor(func, fname):
try:
with open(fname) as f:
oldcode = f.read()
except (OSError, UnicodeError) as err:
# Permissions or encoding issue, or file deleted, etc.
return f"skipping {fname!r} due to {err}"
newcode = func(oldcode)
if newcode != oldcode:
with open(fname, mode="w") as f:
f.write(newcode)
@main.command() # type: ignore # Click adds the .command attribute
@click.argument("path", type=str, required=True, nargs=-1)
def codemod(path):
"""`hypothesis codemod` refactors deprecated or inefficient code.
It adapts `python -m libcst.tool`, removing many features and config options
which are rarely relevant for this purpose. If you need more control, we
encourage you to use the libcst CLI directly; if not this one is easier.
PATH is the file(s) or directories of files to format in place, or
"-" to read from stdin and write to stdout.
"""
try:
from libcst.codemod import gather_files
from hypothesis.extra import codemods
except ImportError:
sys.stderr.write(
"You are missing required dependencies for this option. Run:\n\n"
" python -m pip install --upgrade hypothesis[codemods]\n\n"
"and try again."
)
sys.exit(1)
# Special case for stdin/stdout usage
if "-" in path:
if len(path) > 1:
raise Exception(
"Cannot specify multiple paths when reading from stdin!"
)
print("Codemodding from stdin", file=sys.stderr)
print(codemods.refactor(sys.stdin.read()))
return 0
# Find all the files to refactor, and then codemod them
files = gather_files(path)
errors = set()
if len(files) <= 1:
errors.add(_refactor(codemods.refactor, *files))
else:
with Pool() as pool:
for msg in pool.imap_unordered(
partial(_refactor, codemods.refactor), files
):
errors.add(msg)
errors.discard(None)
for msg in errors:
print(msg, file=sys.stderr)
return 1 if errors else 0
@main.command() # type: ignore # Click adds the .command attribute
@click.argument("func", type=obj_name, required=True, nargs=-1)
@click.option(
"--roundtrip",
"writer",
flag_value="roundtrip",
help="start by testing write/read or encode/decode!",
)
@click.option(
"--equivalent",
"writer",
flag_value="equivalent",
help="very useful when optimising or refactoring code",
)
@click.option(
"--errors-equivalent",
"writer",
flag_value="errors-equivalent",
help="--equivalent, but also allows consistent errors",
)
@click.option(
"--idempotent",
"writer",
flag_value="idempotent",
help="check that f(x) == f(f(x))",
)
@click.option(
"--binary-op",
"writer",
flag_value="binary_operation",
help="associativity, commutativity, identity element",
)
# Note: we deliberately omit a --ufunc flag, because the magic()
# detection of ufuncs is both precise and complete.
@click.option(
"--style",
type=click.Choice(["pytest", "unittest"]),
default="pytest" if pytest else "unittest",
help="pytest-style function, or unittest-style method?",
)
@click.option(
"-e",
"--except",
"except_",
type=obj_name,
multiple=True,
help="dotted name of exception(s) to ignore",
)
def write(func, writer, except_, style): # noqa: D301 # \b disables autowrap
"""`hypothesis write` writes property-based tests for you!
Type annotations are helpful but not required for our advanced introspection
and templating logic. Try running the examples below to see how it works:
\b
hypothesis write gzip
hypothesis write numpy.matmul
hypothesis write re.compile --except re.error
hypothesis write --equivalent ast.literal_eval eval
hypothesis write --roundtrip json.dumps json.loads
hypothesis write --style=unittest --idempotent sorted
hypothesis write --binary-op operator.add
"""
# NOTE: if you want to call this function from Python, look instead at the
# ``hypothesis.extra.ghostwriter`` module. Click-decorated functions have
# a different calling convention, and raise SystemExit instead of returning.
kwargs = {"except_": except_ or (), "style": style}
if writer is None:
writer = "magic"
elif writer == "idempotent" and len(func) > 1:
raise click.UsageError("Test functions for idempotence one at a time.")
elif writer == "roundtrip" and len(func) == 1:
writer = "idempotent"
elif "equivalent" in writer and len(func) == 1:
writer = "fuzz"
if writer == "errors-equivalent":
writer = "equivalent"
kwargs["allow_same_errors"] = True
try:
from hypothesis.extra import ghostwriter
except ImportError:
sys.stderr.write(MESSAGE.format("black"))
sys.exit(1)
code = getattr(ghostwriter, writer)(*func, **kwargs)
try:
from rich.console import Console
from rich.syntax import Syntax
from hypothesis.utils.terminal import guess_background_color
except ImportError:
print(code)
else:
try:
theme = "default" if guess_background_color() == "light" else "monokai"
code = Syntax(code, "python", background_color="default", theme=theme)
Console().print(code, soft_wrap=True)
except Exception:
print("# Error while syntax-highlighting code", file=sys.stderr)
print(code)