Repository: HypothesisWorks/hypothesis

Issue:
Better error messages for `hypothesis write <some_filename>`
The [Ghostwriter CLI](https://hypothesis.readthedocs.io/en/latest/ghostwriter.html) expects to be passed a module name (e.g. `hypothesis write gzip`), or dotted path to a callable (e.g. `hypothesis write numpy.matmul`).  We also include the current directory in the python path, so that you can pass e.g. `myscript`.

If you try passing a filename instead, this doesn't work - whether `myscript.py` or `path/to/myscript.py`.  I don't *want* to make that work, but we could have better error messages:

- [ ] `hypothesis write myscript.py` fails with `Error: Found the 'script' module, but it doesn't have a 'py' attribute.`  In this case, we should add "\n    Remember that the ghostwriter should be passed the name of a module, not a file"
- [ ] `hypothesis write src/myscript.py` fails with `Error: Invalid value for 'FUNC...': my/script.py`.  We should add a similar explicit error message.


Discussion:

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/extra/cli.py b/hypothesis-python/src/hypothesis/extra/cli.py
--- a/hypothesis-python/src/hypothesis/extra/cli.py
+++ b/hypothesis-python/src/hypothesis/extra/cli.py
@@ -82,6 +82,10 @@ def main():
     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:
@@ -97,7 +101,11 @@ def obj_name(s: str) -> object:
                 try:
                     modulename, classname = modulename.rsplit(".", 1)
                     module = importlib.import_module(modulename)
-                except ImportError:
+                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?"
@@ -119,6 +127,12 @@ def describe_close_matches(
             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."


Test patch:
diff --git a/hypothesis-python/tests/ghostwriter/test_ghostwriter.py b/hypothesis-python/tests/ghostwriter/test_ghostwriter.py
--- a/hypothesis-python/tests/ghostwriter/test_ghostwriter.py
+++ b/hypothesis-python/tests/ghostwriter/test_ghostwriter.py
@@ -16,7 +16,9 @@
 import unittest
 import unittest.mock
 from decimal import Decimal
-from types import ModuleType
+from pathlib import Path
+from textwrap import dedent
+from types import FunctionType, ModuleType
 from typing import (
     Any,
     FrozenSet,
@@ -32,10 +34,11 @@
 )
 
 import attr
+import click
 import pytest
 
 from hypothesis.errors import InvalidArgument, MultipleFailures, Unsatisfiable
-from hypothesis.extra import ghostwriter
+from hypothesis.extra import cli, ghostwriter
 from hypothesis.strategies import builds, from_type, just, lists
 from hypothesis.strategies._internal.lazy import LazyStrategy
 
@@ -425,3 +428,75 @@ def test_unrepr_identity_elem():
 )
 def test_get_imports_for_strategy(strategy, imports):
     assert ghostwriter._imports_for_strategy(strategy) == imports
+
+
+@pytest.fixture
+def temp_script_file():
+    """Fixture to yield a Path to a temporary file in the local directory. File name will end
+    in .py and will include an importable function.
+    """
+    p = Path("my_temp_script.py")
+    if p.exists():
+        raise FileExistsError(f"Did not expect {p} to exist during testing")
+    p.write_text(
+        dedent(
+            """
+            def say_hello():
+                print("Hello world!")
+            """
+        )
+    )
+    yield p
+    p.unlink()
+
+
+@pytest.fixture
+def temp_script_file_with_py_function():
+    """Fixture to yield a Path to a temporary file in the local directory. File name will end
+    in .py and will include an importable function named "py"
+    """
+    p = Path("my_temp_script_with_py_function.py")
+    if p.exists():
+        raise FileExistsError(f"Did not expect {p} to exist during testing")
+    p.write_text(
+        dedent(
+            """
+            def py():
+                print('A function named "py" has been called')
+            """
+        )
+    )
+    yield p
+    p.unlink()
+
+
+def test_obj_name(temp_script_file, temp_script_file_with_py_function):
+    # Module paths (strings including a "/") should raise a meaningful UsageError
+    with pytest.raises(click.exceptions.UsageError) as e:
+        cli.obj_name("mydirectory/myscript.py")
+    assert e.match(
+        "Remember that the ghostwriter should be passed the name of a module, not a path."
+    )
+    # Windows paths (strings including a "\") should also raise a meaningful UsageError
+    with pytest.raises(click.exceptions.UsageError) as e:
+        cli.obj_name("mydirectory\\myscript.py")
+    assert e.match(
+        "Remember that the ghostwriter should be passed the name of a module, not a path."
+    )
+    # File names of modules (strings ending in ".py") should raise a meaningful UsageError
+    with pytest.raises(click.exceptions.UsageError) as e:
+        cli.obj_name("myscript.py")
+    assert e.match(
+        "Remember that the ghostwriter should be passed the name of a module, not a file."
+    )
+    # File names of modules (strings ending in ".py") that exist should get a suggestion
+    with pytest.raises(click.exceptions.UsageError) as e:
+        cli.obj_name(str(temp_script_file))
+    assert e.match(
+        "Remember that the ghostwriter should be passed the name of a module, not a file."
+        + f"\n\tTry: hypothesis write {temp_script_file.stem}"
+    )
+    # File names of modules (strings ending in ".py") that define a py function should succeed
+    assert isinstance(
+        cli.obj_name(str(temp_script_file_with_py_function)), FunctionType
+    )