Repository: HypothesisWorks/hypothesis

Issue:
Error if `alphabet=` strategy passed to `st.text()` generates an invalid value
If you pass an `alphabet=` strategy to `st.text()`, we document that [it must only generate length-one strings](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.text).  We check the elements if you pass `alphabet=some_collection`, but [as per this StackOverflow question](https://stackoverflow.com/questions/74336794/hypothesis-strategy-from-regex-does-not-respect-min-size-and-max-size), there's no such validation for alphabet strategies!

https://github.com/HypothesisWorks/hypothesis/blob/47b35ce128376d362c636d76479b68f2524c37d6/hypothesis-python/src/hypothesis/strategies/_internal/core.py#L647-L666

So I propose adding a `.map(_check_is_single_character)` to this strategy which errors if not a length-one string, and otherwise passes the generated value through unchanged.  To mitigate any performance cost, we should skip this for known-safe strategies - I think just `characters()`, which is happily the most common case here.
Internal error from test that raises a DeprecationWarning (IndexError in escalation.py)
When testing a NumPy function call that replaces an IndexError with a DeprecationWarning, along with `filterwarnings = error`, hypothesis gets an internal error. I was unable to replicate this with a pure Python variant that just raises IndexError and replaces it with a warning, so there might be more going on here. Reproducer:

```py
# test.py
import numpy as np

from hypothesis import given
from hypothesis.strategies import integers

@given(integers())
def test(x):
    np.empty((0, 0, 1))[[[x]]]
```

```py
# pytest.ini
[pytest]
filterwarnings = error
```

```
$ pytest test.py
========================================================================== test session starts ==========================================================================
platform darwin -- Python 3.8.13, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/aaronmeurer/Documents/test, configfile: pytest.ini
plugins: pudb-0.7.0, hypothesis-6.56.4, flakes-4.0.5, cov-3.0.0, xonsh-0.12.4
collected 1 item

test.py F                                                                                                                                                         [100%]

=============================================================================== FAILURES ================================================================================
_________________________________________________________________________________ test __________________________________________________________________________________
IndexError: index 0 is out of bounds for axis 0 with size 0

During handling of the above exception, another exception occurred:

self = <hypothesis.core.StateForActualGivenExecution object at 0x7f7e98f8ad00>, data = ConjectureData(VALID, 3 bytes, frozen)

    def _execute_once_for_engine(self, data):
        """Wrapper around ``execute_once`` that intercepts test failure
        exceptions and single-test control exceptions, and turns them into
        appropriate method calls to `data` instead.

        This allows the engine to assume that any exception other than
        ``StopTest`` must be a fatal error, and should stop the entire engine.
        """
        try:
            trace = frozenset()
            if (
                self.failed_normally
                and not self.failed_due_to_deadline
                and Phase.shrink in self.settings.phases
                and Phase.explain in self.settings.phases
                and sys.gettrace() is None
                and not PYPY
            ):  # pragma: no cover
                # This is in fact covered by our *non-coverage* tests, but due to the
                # settrace() contention *not* by our coverage tests.  Ah well.
                tracer = Tracer()
                try:
                    sys.settrace(tracer.trace)
                    result = self.execute_once(data)
                    if data.status == Status.VALID:
                        self.explain_traces[None].add(frozenset(tracer.branches))
                finally:
                    sys.settrace(None)
                    trace = frozenset(tracer.branches)
            else:
>               result = self.execute_once(data)

../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/core.py:752:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <hypothesis.core.StateForActualGivenExecution object at 0x7f7e98f8ad00>, data = ConjectureData(VALID, 3 bytes, frozen), print_example = False, is_final = False
expected_failure = None

    def execute_once(
        self, data, print_example=False, is_final=False, expected_failure=None
    ):
        """Run the test function once, using ``data`` as input.

        If the test raises an exception, it will propagate through to the
        caller of this method. Depending on its type, this could represent
        an ordinary test failure, or a fatal error, or a control exception.

        If this method returns normally, the test might have passed, or
        it might have placed ``data`` in an unsuccessful state and then
        swallowed the corresponding control exception.
        """

        self.ever_executed = True
        data.is_find = self.is_find

        text_repr = None
        if self.settings.deadline is None:
            test = self.test
        else:

            @proxies(self.test)
            def test(*args, **kwargs):
                self.__test_runtime = None
                initial_draws = len(data.draw_times)
                start = time.perf_counter()
                result = self.test(*args, **kwargs)
                finish = time.perf_counter()
                internal_draw_time = sum(data.draw_times[initial_draws:])
                runtime = datetime.timedelta(
                    seconds=finish - start - internal_draw_time
                )
                self.__test_runtime = runtime
                current_deadline = self.settings.deadline
                if not is_final:
                    current_deadline = (current_deadline // 4) * 5
                if runtime >= current_deadline:
                    raise DeadlineExceeded(runtime, self.settings.deadline)
                return result

        def run(data):
            # Set up dynamic context needed by a single test run.
            with local_settings(self.settings):
                with deterministic_PRNG():
                    with BuildContext(data, is_final=is_final):

                        # Generate all arguments to the test function.
                        args, kwargs = data.draw(self.search_strategy)
                        if expected_failure is not None:
                            nonlocal text_repr
                            text_repr = repr_call(test, args, kwargs)

                        if print_example or current_verbosity() >= Verbosity.verbose:
                            output = StringIO()

                            printer = RepresentationPrinter(output)
                            if print_example:
                                printer.text("Falsifying example:")
                            else:
                                printer.text("Trying example:")

                            if self.print_given_args:
                                printer.text(" ")
                                printer.text(test.__name__)
                                with printer.group(indent=4, open="(", close=""):
                                    printer.break_()
                                    for v in args:
                                        printer.pretty(v)
                                        # We add a comma unconditionally because
                                        # generated arguments will always be kwargs,
                                        # so there will always be more to come.
                                        printer.text(",")
                                        printer.breakable()

                                    for i, (k, v) in enumerate(kwargs.items()):
                                        printer.text(k)
                                        printer.text("=")
                                        printer.pretty(v)
                                        printer.text(",")
                                        if i + 1 < len(kwargs):
                                            printer.breakable()
                                printer.break_()
                                printer.text(")")
                            printer.flush()
                            report(output.getvalue())
                        return test(*args, **kwargs)

        # Run the test function once, via the executor hook.
        # In most cases this will delegate straight to `run(data)`.
>       result = self.test_runner(data, run)

../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/core.py:691:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

data = ConjectureData(VALID, 3 bytes, frozen), function = <function StateForActualGivenExecution.execute_once.<locals>.run at 0x7f7e98ff6a60>

    def default_new_style_executor(data, function):
>       return function(data)

../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/executors.py:47:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

data = ConjectureData(VALID, 3 bytes, frozen)

    def run(data):
        # Set up dynamic context needed by a single test run.
        with local_settings(self.settings):
            with deterministic_PRNG():
                with BuildContext(data, is_final=is_final):

                    # Generate all arguments to the test function.
                    args, kwargs = data.draw(self.search_strategy)
                    if expected_failure is not None:
                        nonlocal text_repr
                        text_repr = repr_call(test, args, kwargs)

                    if print_example or current_verbosity() >= Verbosity.verbose:
                        output = StringIO()

                        printer = RepresentationPrinter(output)
                        if print_example:
                            printer.text("Falsifying example:")
                        else:
                            printer.text("Trying example:")

                        if self.print_given_args:
                            printer.text(" ")
                            printer.text(test.__name__)
                            with printer.group(indent=4, open="(", close=""):
                                printer.break_()
                                for v in args:
                                    printer.pretty(v)
                                    # We add a comma unconditionally because
                                    # generated arguments will always be kwargs,
                                    # so there will always be more to come.
                                    printer.text(",")
                                    printer.breakable()

                                for i, (k, v) in enumerate(kwargs.items()):
                                    printer.text(k)
                                    printer.text("=")
                                    printer.pretty(v)
                                    printer.text(",")
                                    if i + 1 < len(kwargs):
                                        printer.breakable()
                            printer.break_()
                            printer.text(")")
                        printer.flush()
                        report(output.getvalue())
>                   return test(*args, **kwargs)

../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/core.py:687:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

x = 0

    @given(integers())
>   def test(x):

test.py:7:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

args = (0,), kwargs = {}, initial_draws = 1, start = 1.438075408

    @proxies(self.test)
    def test(*args, **kwargs):
        self.__test_runtime = None
        initial_draws = len(data.draw_times)
        start = time.perf_counter()
>       result = self.test(*args, **kwargs)

../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/core.py:628:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

x = 0

    @given(integers())
    def test(x):
>       np.empty((0, 0, 1))[[[x]]]
E       DeprecationWarning: Out of bound index found. This was previously ignored when the indexing result contained no elements. In the future the index error will be raised. This error occurs either due to an empty slice, or if an array has zero elements even before indexing.
E       (Use `warnings.simplefilter('error')` to turn this DeprecationWarning into an error and get more details on the invalid index.)

test.py:8: DeprecationWarning

During handling of the above exception, another exception occurred:

    @given(integers())
>   def test(x):

test.py:7:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:474: in run
    self._run()
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:878: in _run
    self.reuse_existing_examples()
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:537: in reuse_existing_examples
    data = self.cached_test_function(existing)
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:1059: in cached_test_function
    self.test_function(data)
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:209: in test_function
    self.__stoppable_test_function(data)
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:185: in __stoppable_test_function
    self._test_function(data)
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/core.py:798: in _execute_once_for_engine
    interesting_origin = get_interesting_origin(e)
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/escalation.py:121: in get_interesting_origin
    get_interesting_origin(exception.__context__) if exception.__context__ else (),
../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/escalation.py:113: in get_interesting_origin
    tb = get_trimmed_traceback(exception)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

exception = IndexError('index 0 is out of bounds for axis 0 with size 0')

    def get_trimmed_traceback(exception=None):
        """Return the current traceback, minus any frames added by Hypothesis."""
        if exception is None:
            _, exception, tb = sys.exc_info()
        else:
            tb = exception.__traceback__
        # Avoid trimming the traceback if we're in verbose mode, or the error
        # was raised inside Hypothesis
        if hypothesis.settings.default.verbosity >= hypothesis.Verbosity.debug or (
>           is_hypothesis_file(traceback.extract_tb(tb)[-1][0])
            and not isinstance(exception, _Trimmable)
        ):
E       IndexError: list index out of range

../../anaconda3/envs/ndindex/lib/python3.8/site-packages/hypothesis/internal/escalation.py:91: IndexError
======================================================================== short test summary info ========================================================================
FAILED test.py::test - IndexError: list index out of range
=========================================================================== 1 failed in 0.64s ===========================================================================
```


Discussion:

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/extra/array_api.py b/hypothesis-python/src/hypothesis/extra/array_api.py
--- a/hypothesis-python/src/hypothesis/extra/array_api.py
+++ b/hypothesis-python/src/hypothesis/extra/array_api.py
@@ -8,8 +8,12 @@
 # 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/.
 
-import math
 import sys
+
+if sys.version_info[:2] < (3, 8):
+    raise RuntimeError("The Array API standard requires Python 3.8 or later")
+
+import math
 from numbers import Real
 from types import SimpleNamespace
 from typing import (
@@ -902,7 +906,7 @@ def make_strategies_namespace(
         check_argument(
             isinstance(xp.__array_api_version__, str)
             and xp.__array_api_version__ in RELEASED_VERSIONS,
-            f"{xp.__array_api_version__=}, but xp.__array_api_version__ must "
+            f"xp.__array_api_version__={xp.__array_api_version__!r}, but it must "
             f"be a valid version string {RELEASED_VERSIONS}. {not_available_msg}",
         )
         api_version = xp.__array_api_version__
@@ -910,8 +914,8 @@ def make_strategies_namespace(
     else:
         check_argument(
             isinstance(api_version, str) and api_version in NOMINAL_VERSIONS,
-            f"{api_version=}, but api_version must be None, or a valid version "
-            f"string {RELEASED_VERSIONS}. {not_available_msg}",
+            f"api_version={api_version!r}, but it must be None, or a valid version "
+            f"string in {RELEASED_VERSIONS}. {not_available_msg}",
         )
         inferred_version = False
     try:
diff --git a/hypothesis-python/src/hypothesis/internal/escalation.py b/hypothesis-python/src/hypothesis/internal/escalation.py
--- a/hypothesis-python/src/hypothesis/internal/escalation.py
+++ b/hypothesis-python/src/hypothesis/internal/escalation.py
@@ -67,12 +67,12 @@ def escalate_hypothesis_internal_error():
     if PREVENT_ESCALATION:
         return
 
-    error_type, e, tb = sys.exc_info()
+    _, e, tb = sys.exc_info()
 
     if getattr(e, "hypothesis_internal_never_escalate", False):
         return
 
-    filepath = traceback.extract_tb(tb)[-1][0]
+    filepath = None if tb is None else traceback.extract_tb(tb)[-1][0]
     if is_hypothesis_file(filepath) and not isinstance(
         e, (HypothesisException,) + HYPOTHESIS_CONTROL_EXCEPTIONS
     ):
@@ -87,8 +87,10 @@ def get_trimmed_traceback(exception=None):
         tb = exception.__traceback__
     # Avoid trimming the traceback if we're in verbose mode, or the error
     # was raised inside Hypothesis
-    if hypothesis.settings.default.verbosity >= hypothesis.Verbosity.debug or (
-        is_hypothesis_file(traceback.extract_tb(tb)[-1][0])
+    if (
+        tb is None
+        or hypothesis.settings.default.verbosity >= hypothesis.Verbosity.debug
+        or is_hypothesis_file(traceback.extract_tb(tb)[-1][0])
         and not isinstance(exception, _Trimmable)
     ):
         return tb
@@ -111,7 +113,10 @@ def get_interesting_origin(exception):
     # blocks and understand the __cause__ (`raise x from y`) or __context__ that
     # first raised an exception as well as PEP-654 exception groups.
     tb = get_trimmed_traceback(exception)
-    filename, lineno, *_ = traceback.extract_tb(tb)[-1]
+    if tb is None:
+        filename, lineno = None, None
+    else:
+        filename, lineno, *_ = traceback.extract_tb(tb)[-1]
     return (
         type(exception),
         filename,
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
@@ -18,7 +18,7 @@
 import typing
 from decimal import Context, Decimal, localcontext
 from fractions import Fraction
-from functools import reduce
+from functools import lru_cache, reduce
 from inspect import Parameter, Signature, isabstract, isclass, signature
 from types import FunctionType
 from typing import (
@@ -92,7 +92,7 @@
 )
 from hypothesis.strategies._internal.deferred import DeferredStrategy
 from hypothesis.strategies._internal.functions import FunctionStrategy
-from hypothesis.strategies._internal.lazy import LazyStrategy
+from hypothesis.strategies._internal.lazy import LazyStrategy, unwrap_strategies
 from hypothesis.strategies._internal.misc import just, none, nothing
 from hypothesis.strategies._internal.numbers import (
     IntegersStrategy,
@@ -615,6 +615,19 @@ def characters(
     )
 
 
+# Cache size is limited by sys.maxunicode, but passing None makes it slightly faster.
+@lru_cache(maxsize=None)
+def _check_is_single_character(c):
+    # In order to mitigate the performance cost of this check, we use a shared cache,
+    # even at the cost of showing the culprit strategy in the error message.
+    if not isinstance(c, str):
+        type_ = get_pretty_function_description(type(c))
+        raise InvalidArgument(f"Got non-string {c!r} (type {type_})")
+    if len(c) != 1:
+        raise InvalidArgument(f"Got {c!r} (length {len(c)} != 1)")
+    return c
+
+
 @cacheable
 @defines_strategy(force_reusable_values=True)
 def text(
@@ -645,7 +658,13 @@ def text(
     """
     check_valid_sizes(min_size, max_size)
     if isinstance(alphabet, SearchStrategy):
-        char_strategy = alphabet
+        char_strategy = unwrap_strategies(alphabet)
+        if isinstance(char_strategy, SampledFromStrategy):
+            # Check this via the up-front validation logic below, and incidentally
+            # convert into a `characters()` strategy for standard text shrinking.
+            return text(char_strategy.elements, min_size=min_size, max_size=max_size)
+        elif not isinstance(char_strategy, OneCharStringStrategy):
+            char_strategy = char_strategy.map(_check_is_single_character)
     else:
         non_string = [c for c in alphabet if not isinstance(c, str)]
         if non_string:


Test patch:
diff --git a/hypothesis-python/tests/cover/test_direct_strategies.py b/hypothesis-python/tests/cover/test_direct_strategies.py
--- a/hypothesis-python/tests/cover/test_direct_strategies.py
+++ b/hypothesis-python/tests/cover/test_direct_strategies.py
@@ -122,6 +122,12 @@ def fn_ktest(*fnkwargs):
     (ds.text, {"min_size": 10, "max_size": 9}),
     (ds.text, {"alphabet": [1]}),
     (ds.text, {"alphabet": ["abc"]}),
+    (ds.text, {"alphabet": ds.just("abc")}),
+    (ds.text, {"alphabet": ds.sampled_from(["abc", "def"])}),
+    (ds.text, {"alphabet": ds.just(123)}),
+    (ds.text, {"alphabet": ds.sampled_from([123, 456])}),
+    (ds.text, {"alphabet": ds.builds(lambda: "abc")}),
+    (ds.text, {"alphabet": ds.builds(lambda: 123)}),
     (ds.binary, {"min_size": 10, "max_size": 9}),
     (ds.floats, {"min_value": math.nan}),
     (ds.floats, {"min_value": "0"}),
@@ -247,7 +253,9 @@ def test_validates_keyword_arguments(fn, kwargs):
     (ds.text, {"alphabet": "abc"}),
     (ds.text, {"alphabet": set("abc")}),
     (ds.text, {"alphabet": ""}),
+    (ds.text, {"alphabet": ds.just("a")}),
     (ds.text, {"alphabet": ds.sampled_from("abc")}),
+    (ds.text, {"alphabet": ds.builds(lambda: "a")}),
     (ds.characters, {"whitelist_categories": ["N"]}),
     (ds.characters, {"blacklist_categories": []}),
     (ds.ip_addresses, {}),
diff --git a/hypothesis-python/tests/cover/test_escalation.py b/hypothesis-python/tests/cover/test_escalation.py
--- a/hypothesis-python/tests/cover/test_escalation.py
+++ b/hypothesis-python/tests/cover/test_escalation.py
@@ -70,3 +70,7 @@ def test_multiplefailures_deprecation():
     with pytest.warns(errors.HypothesisDeprecationWarning):
         exc = errors.MultipleFailures
     assert exc is BaseExceptionGroup
+
+
+def test_handles_null_traceback():
+    esc.get_interesting_origin(Exception())
diff --git a/hypothesis-python/tests/cover/test_validation.py b/hypothesis-python/tests/cover/test_validation.py
--- a/hypothesis-python/tests/cover/test_validation.py
+++ b/hypothesis-python/tests/cover/test_validation.py
@@ -9,6 +9,7 @@
 # obtain one at https://mozilla.org/MPL/2.0/.
 
 import functools
+import sys
 
 import pytest
 
@@ -274,3 +275,13 @@ def test_check_strategy_might_suggest_sampled_from():
     with pytest.raises(InvalidArgument, match="such as st.sampled_from"):
         check_strategy_((1, 2, 3))
     check_strategy_(integers(), "passes for our custom coverage check")
+
+
+@pytest.mark.skipif(sys.version_info[:2] >= (3, 8), reason="only on 3.7")
+def test_explicit_error_from_array_api_before_py38():
+    with pytest.raises(
+        RuntimeError, match=r"The Array API standard requires Python 3\.8 or later"
+    ):
+        from hypothesis.extra import array_api
+
+        assert array_api  # avoid unused-name lint error