1# This file is part of Hypothesis, which may be found at
2# https://github.com/HypothesisWorks/hypothesis/
3#
4# Most of this work is copyright (C) 2013-2021 David R. MacIver
5# (david@drmaciver.com), but it contains contributions by others. See
6# CONTRIBUTING.rst for a full list of people who may hold copyright, and
7# consult the git log if you need to determine who owns an individual
8# contribution.
9#
10# This Source Code Form is subject to the terms of the Mozilla Public License,
11# v. 2.0. If a copy of the MPL was not distributed with this file, You can
12# obtain one at https://mozilla.org/MPL/2.0/.
13#
14# END HEADER
15
16"""
17.. _hypothesis-cli:
18
19----------------
20hypothesis[cli]
21----------------
22
23::
24
25    $ hypothesis --help
26    Usage: hypothesis [OPTIONS] COMMAND [ARGS]...
27
28    Options:
29      --version   Show the version and exit.
30      -h, --help  Show this message and exit.
31
32    Commands:
33      codemod  `hypothesis codemod` refactors deprecated or inefficient code.
34      fuzz     [hypofuzz] runs tests with an adaptive coverage-guided fuzzer.
35      write    `hypothesis write` writes property-based tests for you!
36
37This module requires the :pypi:`click` package, and provides Hypothesis' command-line
38interface, for e.g. :doc:`'ghostwriting' tests <ghostwriter>` via the terminal.
39It's also where `HypoFuzz <https://hypofuzz.com/>`__ adds the :command:`hypothesis fuzz`
40command (`learn more about that here <https://hypofuzz.com/docs/quickstart.html>`__).
41"""
42
43import builtins
44import importlib
45import sys
46from difflib import get_close_matches
47from functools import partial
48from multiprocessing import Pool
49
50try:
51    import pytest
52except ImportError:
53    pytest = None  # type: ignore
54
55MESSAGE = """
56The Hypothesis command-line interface requires the `{}` package,
57which you do not have installed.  Run:
58
59    python -m pip install --upgrade hypothesis[cli]
60
61and try again.
62"""
63
64try:
65    import click
66except ImportError:
67
68    def main():
69        """If `click` is not installed, tell the user to install it then exit."""
70        sys.stderr.write(MESSAGE.format("click"))
71        sys.exit(1)
72
73
74else:
75    # Ensure that Python scripts in the current working directory are importable,
76    # on the principle that Ghostwriter should 'just work' for novice users.  Note
77    # that we append rather than prepend to the module search path, so this will
78    # never shadow the stdlib or installed packages.
79    sys.path.append(".")
80
81    @click.group(context_settings={"help_option_names": ("-h", "--help")})
82    @click.version_option()
83    def main():
84        pass
85
86    def obj_name(s: str) -> object:
87        """This "type" imports whatever object is named by a dotted string."""
88        s = s.strip()
89        try:
90            return importlib.import_module(s)
91        except ImportError:
92            pass
93        if "." not in s:
94            modulename, module, funcname = "builtins", builtins, s
95        else:
96            modulename, funcname = s.rsplit(".", 1)
97            try:
98                module = importlib.import_module(modulename)
99            except ImportError as err:
100                raise click.UsageError(
101                    f"Failed to import the {modulename} module for introspection.  "
102                    "Check spelling and your Python import path, or use the Python API?"
103                ) from err
104        try:
105            return getattr(module, funcname)
106        except AttributeError as err:
107            public_names = [name for name in vars(module) if not name.startswith("_")]
108            matches = get_close_matches(funcname, public_names)
109            raise click.UsageError(
110                f"Found the {modulename!r} module, but it doesn't have a "
111                f"{funcname!r} attribute."
112                + (f"  Closest matches: {matches!r}" if matches else "")
113            ) from err
114
115    def _refactor(func, fname):
116        try:
117            with open(fname) as f:
118                oldcode = f.read()
119        except (OSError, UnicodeError) as err:
120            # Permissions or encoding issue, or file deleted, etc.
121            return f"skipping {fname!r} due to {err}"
122        newcode = func(oldcode)
123        if newcode != oldcode:
124            with open(fname, mode="w") as f:
125                f.write(newcode)
126
127    @main.command()  # type: ignore  # Click adds the .command attribute
128    @click.argument("path", type=str, required=True, nargs=-1)
129    def codemod(path):
130        """`hypothesis codemod` refactors deprecated or inefficient code.
131
132        It adapts `python -m libcst.tool`, removing many features and config options
133        which are rarely relevant for this purpose.  If you need more control, we
134        encourage you to use the libcst CLI directly; if not this one is easier.
135
136        PATH is the file(s) or directories of files to format in place, or
137        "-" to read from stdin and write to stdout.
138        """
139        try:
140            from libcst.codemod import gather_files
141
142            from hypothesis.extra import codemods
143        except ImportError:
144            sys.stderr.write(
145                "You are missing required dependencies for this option.  Run:\n\n"
146                "    python -m pip install --upgrade hypothesis[codemods]\n\n"
147                "and try again."
148            )
149            sys.exit(1)
150
151        # Special case for stdin/stdout usage
152        if "-" in path:
153            if len(path) > 1:
154                raise Exception(
155                    "Cannot specify multiple paths when reading from stdin!"
156                )
157            print("Codemodding from stdin", file=sys.stderr)
158            print(codemods.refactor(sys.stdin.read()))
159            return 0
160
161        # Find all the files to refactor, and then codemod them
162        files = gather_files(path)
163        errors = set()
164        if len(files) <= 1:
165            errors.add(_refactor(codemods.refactor, *files))
166        else:
167            with Pool() as pool:
168                for msg in pool.imap_unordered(
169                    partial(_refactor, codemods.refactor), files
170                ):
171                    errors.add(msg)
172        errors.discard(None)
173        for msg in errors:
174            print(msg, file=sys.stderr)
175        return 1 if errors else 0
176
177    @main.command()  # type: ignore  # Click adds the .command attribute
178    @click.argument("func", type=obj_name, required=True, nargs=-1)
179    @click.option(
180        "--roundtrip",
181        "writer",
182        flag_value="roundtrip",
183        help="start by testing write/read or encode/decode!",
184    )
185    @click.option(
186        "--equivalent",
187        "writer",
188        flag_value="equivalent",
189        help="very useful when optimising or refactoring code",
190    )
191    @click.option("--idempotent", "writer", flag_value="idempotent")
192    @click.option("--binary-op", "writer", flag_value="binary_operation")
193    # Note: we deliberately omit a --ufunc flag, because the magic()
194    # detection of ufuncs is both precise and complete.
195    @click.option(
196        "--style",
197        type=click.Choice(["pytest", "unittest"]),
198        default="pytest" if pytest else "unittest",
199        help="pytest-style function, or unittest-style method?",
200    )
201    @click.option(
202        "-e",
203        "--except",
204        "except_",
205        type=obj_name,
206        multiple=True,
207        help="dotted name of exception(s) to ignore",
208    )
209    def write(func, writer, except_, style):  # noqa: D301  # \b disables autowrap
210        """`hypothesis write` writes property-based tests for you!
211
212        Type annotations are helpful but not required for our advanced introspection
213        and templating logic.  Try running the examples below to see how it works:
214
215        \b
216            hypothesis write gzip
217            hypothesis write numpy.matmul
218            hypothesis write re.compile --except re.error
219            hypothesis write --equivalent ast.literal_eval eval
220            hypothesis write --roundtrip json.dumps json.loads
221            hypothesis write --style=unittest --idempotent sorted
222            hypothesis write --binary-op operator.add
223        """
224        # NOTE: if you want to call this function from Python, look instead at the
225        # ``hypothesis.extra.ghostwriter`` module.  Click-decorated functions have
226        # a different calling convention, and raise SystemExit instead of returning.
227        if writer is None:
228            writer = "magic"
229        elif writer == "idempotent" and len(func) > 1:
230            raise click.UsageError("Test functions for idempotence one at a time.")
231        elif writer == "roundtrip" and len(func) == 1:
232            writer = "idempotent"
233        elif writer == "equivalent" and len(func) == 1:
234            writer = "fuzz"
235
236        try:
237            from hypothesis.extra import ghostwriter
238        except ImportError:
239            sys.stderr.write(MESSAGE.format("black"))
240            sys.exit(1)
241
242        code = getattr(ghostwriter, writer)(*func, except_=except_ or (), style=style)
243        try:
244            from rich.console import Console
245            from rich.syntax import Syntax
246
247            from hypothesis.utils.terminal import guess_background_color
248        except ImportError:
249            print(code)
250        else:
251            code = Syntax(
252                code,
253                lexer_name="python",
254                background_color="default",
255                theme="default" if guess_background_color() == "light" else "monokai",
256            )
257            Console().print(code, soft_wrap=True)
258