Repository: quora/pyanalyze
Pull request #772 (2024-05-15 01:28:06)

Improve ParamSpec support


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index deb9563..4c9a85c 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+- Improve `ParamSpec` support (#772)
 - Fix handling of stub functions with positional-only parameters with
   defaults (#769)
 - Recognize exhaustive pattern matching (#766)
diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py
index fe9a934..a4939fa 100644
--- a/pyanalyze/annotations.py
+++ b/pyanalyze/annotations.py
@@ -1020,24 +1020,9 @@ class _Visitor(ast.NodeVisitor):
         # class is unused in 3.9
         return self.visit(node.value)  # static analysis: ignore[undefined_attribute]
 
-    def visit_Ellipsis(self, node: ast.Ellipsis) -> Value:
-        return KnownValue(Ellipsis)
-
     def visit_Constant(self, node: ast.Constant) -> Value:
         return KnownValue(node.value)
 
-    def visit_NameConstant(self, node: ast.NameConstant) -> Value:
-        return KnownValue(node.value)
-
-    def visit_Num(self, node: ast.Num) -> Value:
-        return KnownValue(node.n)
-
-    def visit_Str(self, node: ast.Str) -> Value:
-        return KnownValue(node.s)
-
-    def visit_Bytes(self, node: ast.Bytes) -> Value:
-        return KnownValue(node.s)
-
     def visit_Expr(self, node: ast.Expr) -> Value:
         return self.visit(node.value)
 
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index 12b0ef2..563adf3 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -84,6 +84,8 @@ from .value import (
     KnownValue,
     KVPair,
     NewTypeValue,
+    ParamSpecArgsValue,
+    ParamSpecKwargsValue,
     SubclassValue,
     TypedDictEntry,
     TypedDictValue,
@@ -444,15 +446,25 @@ class ArgSpecCache:
                 returns = make_coro_type(returns)
 
         parameters = []
+        seen_paramspec_args: Optional[ParamSpecArgsValue] = None
         for i, parameter in enumerate(sig.parameters.values()):
-            param, make_everything_pos_only = self._make_sig_parameter(
-                parameter, func_globals, function_object, is_wrapped, i
+            param, make_everything_pos_only, new_ps_args = self._make_sig_parameter(
+                parameter,
+                func_globals,
+                function_object,
+                is_wrapped,
+                i,
+                seen_paramspec_args,
             )
             if make_everything_pos_only:
                 parameters = [
                     replace(param, kind=ParameterKind.POSITIONAL_ONLY)
                     for param in parameters
                 ]
+            if new_ps_args is not None:
+                seen_paramspec_args = new_ps_args
+            if param is None:
+                continue
             parameters.append(param)
 
         return Signature.make(
@@ -473,7 +485,8 @@ class ArgSpecCache:
         function_object: Optional[object],
         is_wrapped: bool,
         index: int,
-    ) -> Tuple[SigParameter, bool]:
+        seen_paramspec_args: Optional[ParamSpecArgsValue],
+    ) -> Tuple[Optional[SigParameter], bool, Optional[ParamSpecArgsValue]]:
         """Given an inspect.Parameter, returns a Parameter object."""
         if is_wrapped:
             typ = AnyValue(AnySource.inference)
@@ -481,6 +494,11 @@ class ArgSpecCache:
             typ = self._get_type_for_parameter(
                 parameter, func_globals, function_object, index
             )
+        if (
+            isinstance(typ, ParamSpecArgsValue)
+            and parameter.kind is inspect.Parameter.VAR_POSITIONAL
+        ):
+            return (None, False, typ)
         if parameter.default is inspect.Parameter.empty:
             default = None
         else:
@@ -496,9 +514,18 @@ class ArgSpecCache:
         else:
             kind = ParameterKind(parameter.kind)
             make_everything_pos_only = False
+        if (
+            seen_paramspec_args is not None
+            and kind is ParameterKind.VAR_KEYWORD
+            and isinstance(typ, ParamSpecKwargsValue)
+            and seen_paramspec_args.param_spec is typ.param_spec
+        ):
+            kind = ParameterKind.PARAM_SPEC
+            typ = TypeVarValue(typ.param_spec)
         return (
             SigParameter(parameter.name, kind, default=default, annotation=typ),
             make_everything_pos_only,
+            None,
         )
 
     def _get_type_for_parameter(
diff --git a/pyanalyze/boolability.py b/pyanalyze/boolability.py
index 4ff9f40..ad9788f 100644
--- a/pyanalyze/boolability.py
+++ b/pyanalyze/boolability.py
@@ -141,6 +141,8 @@ def _get_boolability_no_mvv(value: Value) -> Boolability:
         # not handling that for now.
         return Boolability.type_always_true
     elif isinstance(value, KnownValue):
+        if value.val is NotImplemented:
+            return Boolability.erroring_bool
         try:
             boolean_value = bool(value.val)
         except Exception:
diff --git a/pyanalyze/format_strings.py b/pyanalyze/format_strings.py
index 8caeffa..2d1b515 100644
--- a/pyanalyze/format_strings.py
+++ b/pyanalyze/format_strings.py
@@ -459,12 +459,12 @@ def maybe_replace_with_fstring(
     parts = []
     for raw_piece, substitution in zip(fs.raw_pieces, substitutions):
         if raw_piece:
-            parts.append(ast.Str(s=raw_piece))
+            parts.append(ast.Constant(value=raw_piece))
         parts.append(
             ast.FormattedValue(value=substitution, conversion=-1, format_spec=None)
         )
     if fs.raw_pieces[-1]:
-        parts.append(ast.Str(s=fs.raw_pieces[-1]))
+        parts.append(ast.Constant(value=fs.raw_pieces[-1]))
     return ast.JoinedStr(values=parts)
 
 
diff --git a/pyanalyze/functions.py b/pyanalyze/functions.py
index 68448b3..c3826b2 100644
--- a/pyanalyze/functions.py
+++ b/pyanalyze/functions.py
@@ -30,6 +30,8 @@ from .value import (
     CanAssignError,
     GenericValue,
     KnownValue,
+    ParamSpecArgsValue,
+    ParamSpecKwargsValue,
     SubclassValue,
     TypedValue,
     TypeVarValue,
@@ -250,6 +252,7 @@ def compute_parameters(
     params = []
     tv_index = 1
 
+    seen_paramspec_args: Optional[Tuple[ast.arg, ParamSpecArgsValue]] = None
     for idx, (param, default) in enumerate(zip_longest(args, defaults)):
         assert param is not None, "must have more args than defaults"
         (kind, arg) = param
@@ -301,6 +304,38 @@ def compute_parameters(
                 value = unite_values(value, default)
 
         value = translate_vararg_type(kind, value, ctx, error_ctx=ctx, node=arg)
+        if isinstance(value, ParamSpecArgsValue):
+            if kind is ParameterKind.VAR_POSITIONAL:
+                seen_paramspec_args = (arg, value)
+            else:
+                ctx.show_error(
+                    arg,
+                    f"ParamSpec.args must be used on *args, not {arg.arg}",
+                    error_code=ErrorCode.invalid_annotation,
+                )
+        elif isinstance(value, ParamSpecKwargsValue):
+            if kind is ParameterKind.VAR_KEYWORD:
+                if seen_paramspec_args is not None:
+                    _, ps_args = seen_paramspec_args
+                    if ps_args.param_spec is not value.param_spec:
+                        ctx.show_error(
+                            arg,
+                            "The same ParamSpec must be used on *args and **kwargs",
+                            error_code=ErrorCode.invalid_annotation,
+                        )
+                else:
+                    ctx.show_error(
+                        arg,
+                        "ParamSpec.kwargs must be used together with ParamSpec.args",
+                        error_code=ErrorCode.invalid_annotation,
+                    )
+            else:
+                ctx.show_error(
+                    arg,
+                    f"ParamSpec.kwargs must be used on **kwargs, not {arg.arg}",
+                    error_code=ErrorCode.invalid_annotation,
+                )
+
         param = SigParameter(arg.arg, kind, default, value)
         info = ParamInfo(param, arg, is_self)
         params.append(info)
@@ -326,6 +361,8 @@ def translate_vararg_type(
                     )
                 return AnyValue(AnySource.error)
             return typ.value
+        elif isinstance(typ, ParamSpecArgsValue):
+            return typ
         else:
             return GenericValue(tuple, [typ])
     elif kind is ParameterKind.VAR_KEYWORD:
@@ -339,6 +376,8 @@ def translate_vararg_type(
                     )
                 return AnyValue(AnySource.error)
             return typ.value
+        elif isinstance(typ, ParamSpecKwargsValue):
+            return typ
         else:
             return GenericValue(dict, [TypedValue(str), typ])
     return typ
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 99627de..1f39d10 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -3162,7 +3162,6 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
         return KnownValue(output)
 
     def visit_Constant(self, node: ast.Constant) -> Value:
-        # replaces Num, Str, etc. in 3.8+
         if isinstance(node.value, str):
             self._maybe_show_missing_f_error(node, node.value)
         return KnownValue(node.value)
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index 2561039..04ab3b0 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -70,6 +70,7 @@ from .value import (
     AsyncTaskIncompleteValue,
     BoundsMap,
     CallableValue,
+    CallValue,
     CanAssign,
     CanAssignContext,
     CanAssignError,
@@ -411,7 +412,10 @@ KIND_TO_ALLOWED_PREVIOUS = {
         ParameterKind.VAR_POSITIONAL,
         ParameterKind.KEYWORD_ONLY,
     },
-    ParameterKind.PARAM_SPEC: {ParameterKind.POSITIONAL_ONLY},
+    ParameterKind.PARAM_SPEC: {
+        ParameterKind.POSITIONAL_ONLY,
+        ParameterKind.POSITIONAL_OR_KEYWORD,
+    },
     ParameterKind.ELLIPSIS: {ParameterKind.POSITIONAL_ONLY},
 }
 CAN_HAVE_DEFAULT = {
@@ -1079,8 +1083,28 @@ class Signature:
                     )
                     bound_args[param.name] = KWARGS, composite
                 else:
-                    self.show_call_error("Callable requires a ParamSpec argument", ctx)
-                    return None
+                    new_actuals = ActualArguments(
+                        positionals=actual_args.positionals[positional_index:],
+                        star_args=(
+                            actual_args.star_args if not star_args_consumed else None
+                        ),
+                        keywords={
+                            key: value
+                            for key, value in actual_args.keywords.items()
+                            if key not in keywords_consumed
+                        },
+                        star_kwargs=(
+                            actual_args.star_kwargs
+                            if not star_kwargs_consumed
+                            else None
+                        ),
+                        kwargs_required=actual_args.kwargs_required,
+                        pos_or_keyword_params=actual_args.pos_or_keyword_params,
+                    )
+                    star_args_consumed = True
+                    star_kwargs_consumed = True
+                    val = CallValue(new_actuals)
+                    bound_args[param.name] = UNKNOWN, Composite(val)
             else:
                 assert False, f"unhandled param {param.kind}"
 
@@ -1973,14 +1997,27 @@ def preprocess_args(
     # Step 1: Split up args and kwargs if possible.
     processed_args: List[Argument] = []
     kwargs_requireds = []
+    param_spec = None
+    param_spec_star_arg = None
+    seen_param_spec_kwargs = False
     for arg, label in args:
         if label is ARGS:
+            if isinstance(arg.value, ParamSpecArgsValue):
+                if param_spec is not None:
+                    ctx.on_error(
+                        "Only a single ParamSpec.args can be passed", node=arg.node
+                    )
+                param_spec = TypeVarValue(arg.value.param_spec)
+                param_spec_star_arg = arg
+                continue
             concrete_values = concrete_values_from_iterable(
                 arg.value, ctx.can_assign_ctx
             )
             if isinstance(concrete_values, CanAssignError):
                 ctx.on_error(
-                    f"{arg.value} is not iterable", detail=str(concrete_values)
+                    f"{arg.value} is not iterable",
+                    detail=str(concrete_values),
+                    node=arg.node,
                 )
                 return None
             elif isinstance(concrete_values, Value):
@@ -1995,6 +2032,23 @@ def preprocess_args(
                 for subval in concrete_values:
                     processed_args.append((Composite(subval), None))
         elif label is KWARGS:
+            if isinstance(arg.value, ParamSpecKwargsValue):
+                if param_spec is None:
+                    ctx.on_error(
+                        "ParamSpec.kwargs cannot be passed without ParamSpec.args",
+                        node=arg.node,
+                    )
+                elif param_spec.typevar is not arg.value.param_spec:
+                    ctx.on_error(
+                        "ParamSpec.args and ParamSpec.kwargs must use the same ParamSpec",
+                        node=arg.node,
+                    )
+                elif seen_param_spec_kwargs:
+                    ctx.on_error(
+                        "Only a single ParamSpec.kwargs can be passed", node=arg.node
+                    )
+                seen_param_spec_kwargs = True
+                continue
             items = {}
             extra_values = []
             if arg.value is NO_RETURN_VALUE:
@@ -2036,6 +2090,11 @@ def preprocess_args(
                 processed_args.append((new_composite, KWARGS))
         else:
             processed_args.append((arg, label))
+    if param_spec_star_arg is not None and not seen_param_spec_kwargs:
+        ctx.on_error(
+            "ParamSpec.args cannot be passed without ParamSpec.kwargs",
+            node=param_spec_star_arg.node,
+        )
 
     # Step 2: enforce invariants about ARGS and KWARGS placement. We dump
     # any single arguments that come after *args into *args, and we merge all *args.
@@ -2097,6 +2156,9 @@ def preprocess_args(
             more_processed_kwargs[label.name] = (label.is_required, arg)
             more_processed_args.append((label.is_required, arg))
         elif isinstance(label, TypeVarValue):
+            if param_spec is not None:
+                ctx.on_error("Multiple ParamSpecs passed")
+                continue
             param_spec = label
         elif label is ELLIPSIS:
             is_ellipsis = True
@@ -2276,7 +2338,6 @@ class OverloadedSignature:
         actual_args = preprocess_args(args, ctx)
         if actual_args is None:
             return AnyValue(AnySource.error)
-
         # We first bind the arguments for each overload, to get the obvious errors
         # out of the way first.
         errors_per_overload = []
@@ -2703,6 +2764,21 @@ def decompose_union(
     return None
 
 
+def check_call_preprocessed(
+    sig: ConcreteSignature, args: ActualArguments, ctx: CanAssignContext
+) -> CanAssign:
+    if isinstance(sig, Signature):
+        check_ctx = _CanAssignBasedContext(ctx)
+        sig.check_call_preprocessed(args, check_ctx)
+        if check_ctx.errors:
+            return CanAssignError(
+                "Incompatible callable", [CanAssignError(e) for e in check_ctx.errors]
+            )
+        return {}
+    else:
+        return CanAssignError("Overloads are not supported")
+
+
 def _extract_known_value(val: Value) -> Optional[KnownValue]:
     if isinstance(val, AnnotatedValue):
         val = val.value
diff --git a/pyanalyze/type_evaluation.py b/pyanalyze/type_evaluation.py
index 33af51c..29bcd50 100644
--- a/pyanalyze/type_evaluation.py
+++ b/pyanalyze/type_evaluation.py
@@ -398,7 +398,7 @@ class ConditionEvaluator(ast.NodeVisitor):
             for keyword in node.keywords:
                 if keyword.arg == "exclude_any":
                     if isinstance(
-                        keyword.value, ast.NameConstant
+                        keyword.value, ast.Constant
                     ) and keyword.value.value in (True, False):
                         exclude_any = keyword.value.value
                     else:
@@ -717,12 +717,13 @@ class EvaluateVisitor(ast.NodeVisitor):
         if len(call.args) != 1:
             self.add_invalid("show_error() takes exactly one positional argument", call)
             return None
-        if not isinstance(call.args[0], ast.Str):
+        arg = call.args[0]
+        if not isinstance(arg, ast.Constant) or not isinstance(arg.value, str):
             self.add_invalid(
                 "show_error() message must be a string literal", call.args[0]
             )
             return None
-        message = call.args[0].s
+        message = arg.value
         argument = None
         for keyword in call.keywords:
             if keyword.arg == "argument":
diff --git a/pyanalyze/typevar.py b/pyanalyze/typevar.py
index 769e049..a1309b7 100644
--- a/pyanalyze/typevar.py
+++ b/pyanalyze/typevar.py
@@ -8,7 +8,7 @@ from typing import Iterable, Sequence, Tuple, Union
 
 import qcore
 
-from .safe import all_of_type
+from .safe import all_of_type, is_instance_of_typing_name
 from .value import (
     AnySource,
     AnyValue,
@@ -40,7 +40,11 @@ def resolve_bounds_map(
     errors = []
     for tv, bounds in bounds_map.items():
         bounds = tuple(dict.fromkeys(bounds))
-        solution = solve(bounds, ctx)
+        if is_instance_of_typing_name(tv, "ParamSpec"):
+            # For ParamSpec, we use a simpler approach
+            solution = solve_paramspec(bounds, ctx)
+        else:
+            solution = solve(bounds, ctx)
         if isinstance(solution, CanAssignError):
             errors.append(solution)
             solution = AnyValue(AnySource.error)
@@ -48,6 +52,31 @@ def resolve_bounds_map(
     return tv_map, errors
 
 
+def solve_paramspec(
+    bounds: Sequence[Bound], ctx: CanAssignContext
+) -> Union[Value, CanAssignError]:
+    if not bounds:
+        return CanAssignError("Unsupported ParamSpec")
+    bound = bounds[0]
+    if not isinstance(bound, LowerBound):
+        return CanAssignError("Unsupported ParamSpec")
+    solution = bound.value
+    for i, bound in enumerate(bounds):
+        if i == 0:
+            continue
+        if isinstance(bound, LowerBound):
+            can_assign = solution.can_assign(bound.value, ctx)
+            if isinstance(can_assign, CanAssignError):
+                return can_assign
+        elif isinstance(bound, UpperBound):
+            can_assign = bound.value.can_assign(solution, ctx)
+            if isinstance(can_assign, CanAssignError):
+                return can_assign
+        else:
+            return CanAssignError("Unsupported ParamSpec bound")
+    return solution
+
+
 def solve(
     bounds: Iterable[Bound], ctx: CanAssignContext
 ) -> Union[Value, CanAssignError]:
@@ -68,6 +97,7 @@ def solve(
                 pass
             else:
                 # New bound is separate. We have to satisfy both.
+                # TODO shouldn't this use intersection?
                 bottom = unite_values(bottom, bound.value)
         elif isinstance(bound, UpperBound):
             if top is TOP or top.is_assignable(bound.value, ctx):
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 483c940..f0908fa 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -47,7 +47,7 @@ from typing import (
 )
 
 import qcore
-from typing_extensions import Literal, ParamSpec, Protocol
+from typing_extensions import ParamSpec, Protocol
 
 import pyanalyze
 from pyanalyze.error_code import Error
@@ -1567,6 +1567,10 @@ class CallableValue(TypedValue):
         return CallableValue(sig, self.typ)
 
     def can_assign(self, other: Value, ctx: CanAssignContext) -> CanAssign:
+        if isinstance(other, CallValue):
+            return pyanalyze.signature.check_call_preprocessed(
+                self.signature, other.args, ctx
+            )
         if not isinstance(other, (MultiValuedValue, AnyValue, AnnotatedValue)):
             signature = ctx.signature_from_value(other)
             if signature is None:
@@ -1784,7 +1788,7 @@ class MultiValuedValue(Value):
             return self
         return MultiValuedValue([val.get_type_value() for val in self.vals])
 
-    def __eq__(self, other: Value) -> Union[bool, Literal[NotImplemented]]:
+    def __eq__(self, other: Value) -> bool:
         if not isinstance(other, MultiValuedValue):
             return NotImplemented
         if self.vals == other.vals:
@@ -2446,6 +2450,16 @@ class VariableNameValue(AnyValue):
         return None
 
 
+@dataclass(frozen=True)
+class CallValue(Value):
+    """Represents the arguments to a call."""
+
+    args: "pyanalyze.signature.ActualArguments"
+
+    def __hash__(self) -> int:
+        return id(self)
+
+
 def is_union(val: Value) -> bool:
     return isinstance(val, MultiValuedValue) or (
         isinstance(val, AnnotatedValue) and isinstance(val.value, MultiValuedValue)


Test patch:
diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py
index fd35cdd..7ef21df 100644
--- a/pyanalyze/test_annotations.py
+++ b/pyanalyze/test_annotations.py
@@ -1825,6 +1825,41 @@ class TestParamSpec(TestNameCheckVisitorBase):
             func(1, "A")
             func(1, 2)  # E: incompatible_argument
 
+    @assert_passes()
+    def test_apply(self):
+        from typing import Callable, TypeVar
+
+        from typing_extensions import ParamSpec, assert_type
+
+        P = ParamSpec("P")
+        T = TypeVar("T")
+
+        def apply(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
+            return func(*args, **kwargs)
+
+        def sample(x: int) -> str:
+            return str(x)
+
+        def capybara() -> None:
+            assert_type(apply(sample, 1), str)
+            apply(sample, "x")  # E: incompatible_call
+
+    @assert_passes()
+    def test_param_spec_errors(self):
+        from typing import Callable, TypeVar
+
+        from typing_extensions import ParamSpec
+
+        P = ParamSpec("P")
+        T = TypeVar("T")
+
+        def apply(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> None:
+            func(*args)  # E: incompatible_call
+            func(*args, *args, **kwargs)  # E: incompatible_call
+            func(**kwargs)  # E: incompatible_call
+            func(*args, **kwargs, **kwargs)  # E: incompatible_call
+            func(*args, **kwargs)
+
 
 class TestTypeAlias(TestNameCheckVisitorBase):
     @assert_passes()
diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py
index ed68845..60618e6 100644
--- a/pyanalyze/test_name_check_visitor.py
+++ b/pyanalyze/test_name_check_visitor.py
@@ -341,7 +341,8 @@ class TestNameCheckVisitor(TestNameCheckVisitorBase):
     @assert_passes()
     def test_display(self):
         def run():
-            print([1, 2]())  # E: not_callable
+            x = [1, 2]
+            print(x())  # E: not_callable
 
     @assert_passes()
     def test_set_display(self):
@@ -1285,7 +1286,8 @@ class TestSubscripting(TestNameCheckVisitorBase):
     @assert_passes()
     def test_failure(self):
         def capybara():
-            return [1, 2][3.0]  # E: unsupported_operation
+            x = [1, 2]
+            return x[3.0]  # E: unsupported_operation
 
     @assert_passes()
     def test_union(self):
@@ -1337,7 +1339,7 @@ class TestNonlocal(TestNameCheckVisitorBase):
             return running
 
 
-class TestingCallSiteCollector(object):
+class SampleCallSiteCollector(object):
     """Records as string instead of actual reference so can be tested.
 
     Replaces name_check_visitor.py:CallSiteCollector, since that class records
@@ -1360,7 +1362,7 @@ class TestCallSiteCollection(TestNameCheckVisitorBase):
     """Base class for testing call site collection."""
 
     def run_and_get_call_map(self, code_str, **kwargs):
-        collector = TestingCallSiteCollector()
+        collector = SampleCallSiteCollector()
         self._run_str(code_str, collector=collector, **kwargs)
         return collector.map
 
diff --git a/pyanalyze/test_node_visitor.py b/pyanalyze/test_node_visitor.py
index 9c607a6..84c7d79 100644
--- a/pyanalyze/test_node_visitor.py
+++ b/pyanalyze/test_node_visitor.py
@@ -178,12 +178,13 @@ class ErrorCode(enum.Enum):
 
 
 class VeryStrictVisitor(BaseNodeVisitor):
-    def visit_Str(self, node):
-        self.show_error(
-            node, "Strings are not allowed", error_code=ErrorCode.no_strings
-        )
+    def visit_Constant(self, node: ast.Constant) -> None:
+        if isinstance(node.value, str):
+            self.show_error(
+                node, "Strings are not allowed", error_code=ErrorCode.no_strings
+            )
 
-    def visit_Dict(self, node):
+    def visit_Dict(self, node: ast.Dict) -> None:
         self.show_error(
             node,
             "Dicts are not allowed",