Skip to content

Commit

Permalink
Support pyproject.toml configuration (#368)
Browse files Browse the repository at this point in the history
  • Loading branch information
JelleZijlstra authored Dec 26, 2021
1 parent 9ebbac0 commit e38b416
Show file tree
Hide file tree
Showing 24 changed files with 1,096 additions and 249 deletions.
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Support configuration in a `pyproject.toml` file (#368)
- Require `typeshed_client` 2.0 (#361)
- Add JSON output for integrating pyanalyze's output with other
tools (#360)
Expand Down
32 changes: 32 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Configuration

The preferred way to configure pyanalyze is using the
`pyproject.toml` configuration file:

```toml
[tool.pyanalyze]
# Paths pyanalyze should check by default
paths = ["my_module/"]
# Paths to import from
import_paths = ["."]

# Enable or disable some checks
possibly_undefined_name = true
duplicate_dict_key = false

# But re-enable it for a specific module
[[tool.pyanalyze.overrides]]
module = "my_module.submodule"
duplicate_dict_key = true
```

It is recommended to always set the following configuration options:

* *paths*: A list of paths (relative to the location of the `pyproject.toml` file) that pyanalyze should check by default.
* *import_paths*: A list of paths (also relative to the configuration file) that pyanalyze should use as roots when trying to import files it is checking. If this is not set, pyanalyze will use entries from `sys.path`, which may produce unexpected results.

Other supported configuration options are listed below.

Almost all configuration options can be overridden for individual modules or packages. To set a module-specific configuration, add an entry to the `tool.pyanalyze.overrides` list (as in the example above), and set the `module` key to the fully qualified name of the module or package.

<!-- TODO figure out a way to dynamically include docs for each option -->
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pyanalyze: A semi-static typechecker
:caption: Contents:

faq
configuration
typesystem
design
glossary
Expand Down
4 changes: 4 additions & 0 deletions pyanalyze/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
from . import implementation
from . import method_return_type
from . import node_visitor
from . import options
from . import reexport
from . import safe
from . import shared_options
from . import signature
from . import stacked_scopes
from . import suggested_type
Expand All @@ -46,3 +48,5 @@
used(reexport)
used(checker)
used(suggested_type)
used(options)
used(shared_options)
182 changes: 161 additions & 21 deletions pyanalyze/arg_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"""

from .options import Options, PyObjectSequenceOption
from .analysis_lib import is_positional_only_arg_name
from .extensions import get_overloads
from .extensions import CustomCheck, get_overloads
from .annotations import Context, type_from_runtime
from .config import Config
from .find_unused import used
Expand All @@ -14,14 +15,14 @@
all_of_type,
is_newtype,
safe_hasattr,
safe_in,
safe_issubclass,
is_typing_name,
safe_isinstance,
)
from .stacked_scopes import Composite, uniq_chain
from .signature import (
ANY_SIGNATURE,
ConcreteSignature,
Impl,
MaybeSignature,
OverloadedSignature,
Expand All @@ -35,15 +36,17 @@
from .value import (
AnySource,
AnyValue,
Extension,
GenericBases,
KVPair,
TypedValue,
GenericValue,
NewTypeValue,
KnownValue,
Value,
VariableNameValue,
TypeVarValue,
extract_typevars,
make_weak,
)

import ast
Expand All @@ -56,16 +59,17 @@
import inspect
import sys
from types import FunctionType, ModuleType
from typing import Any, Sequence, Generic, Iterable, Mapping, Optional, Union
from typing import Any, Callable, Iterator, Sequence, Generic, Mapping, Optional, Union
import typing_inspect
from unittest import mock

# types.MethodWrapperType in 3.7+
MethodWrapperType = type(object().__str__)


@used # exposed as an API
@contextlib.contextmanager
def with_implementation(fn: object, implementation_fn: Impl) -> Iterable[None]:
def with_implementation(fn: object, implementation_fn: Impl) -> Iterator[None]:
"""Temporarily sets the implementation of fn to be implementation_fn.
This is useful for invoking test_scope to aggregate all calls to a particular function. For
Expand All @@ -88,7 +92,9 @@ def _scribe_log_impl(variables, visitor, node):
):
yield
else:
argspec = ArgSpecCache(Config()).get_argspec(fn, impl=implementation_fn)
argspec = ArgSpecCache(Options.from_option_list([], Config())).get_argspec(
fn, impl=implementation_fn
)
if argspec is None:
# builtin or something, just use a generic argspec
argspec = Signature.make(
Expand Down Expand Up @@ -145,17 +151,154 @@ def get_name(self, node: ast.Name) -> Value:
return self.handle_undefined_name(node.id)


class IgnoredCallees(PyObjectSequenceOption[object]):
"""Calls to these aren't checked for argument validity."""

default_value = [
# getargspec gets confused about this subclass of tuple that overrides __new__ and __call__
mock.call,
mock.MagicMock,
mock.Mock,
]
name = "ignored_callees"

@classmethod
def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
return fallback.IGNORED_CALLEES


class ClassesSafeToInstantiate(PyObjectSequenceOption[type]):
"""We will instantiate instances of these classes if we can infer the value of all of
their arguments. This is useful mostly for classes that are commonly instantiated with static
arguments."""

name = "classes_safe_to_instantiate"
default_value = [
CustomCheck,
Value,
Extension,
KVPair,
asynq.ConstFuture,
range,
tuple,
]

@classmethod
def get_value_from_fallback(cls, fallback: Config) -> Sequence[type]:
return fallback.CLASSES_SAFE_TO_INSTANTIATE


class FunctionsSafeToCall(PyObjectSequenceOption[object]):
"""We will instantiate instances of these classes if we can infer the value of all of
their arguments. This is useful mostly for classes that are commonly instantiated with static
arguments."""

name = "functions_safe_to_call"
default_value = [sorted, asynq.asynq, make_weak]

@classmethod
def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
return fallback.FUNCTIONS_SAFE_TO_CALL


_HookReturn = Union[None, ConcreteSignature, inspect.Signature, Callable[..., Any]]
_ConstructorHook = Callable[[type], _HookReturn]


class ConstructorHooks(PyObjectSequenceOption[_ConstructorHook]):
"""Customize the constructor signature for a class.
These hooks may return either a function that pyanalyze will use the signature of, an inspect
Signature object, or a pyanalyze Signature object. The function or signature
should take a self parameter.
"""

name = "constructor_hooks"

@classmethod
def get_value_from_fallback(cls, fallback: Config) -> Sequence[_ConstructorHook]:
return [fallback.get_constructor]

@classmethod
def get_constructor(cls, typ: type, options: Options) -> _HookReturn:
for hook in options.get_value_for(cls):
result = hook(typ)
if result is not None:
return result
return None


_SigProvider = Callable[["ArgSpecCache"], Mapping[object, ConcreteSignature]]


class KnownSignatures(PyObjectSequenceOption[_SigProvider]):
"""Provide hardcoded signatures (and potentially :term:`impl` functions) for
particular objects.
Each entry in the list must be a function that takes an :class:`ArgSpecCache`
instance and returns a mapping from Python object to
:class:`pyanalyze.signature.Signature`.
"""

name = "known_signatures"
default_value = []

@classmethod
def get_value_from_fallback(cls, fallback: Config) -> Sequence[_SigProvider]:
return [fallback.get_known_argspecs]


_Unwrapper = Callable[[type], type]


class UnwrapClass(PyObjectSequenceOption[_Unwrapper]):
"""Provides functions that can unwrap decorated classes.
For example, if your codebase commonly uses a decorator that
wraps classes in a `Wrapper` subclass with a `.wrapped` attribute,
you may define an unwrapper like this:
def unwrap_class(typ: type) -> type:
if issubclass(typ, Wrapper) and typ is not Wrapper:
return typ.wrapped
return typ
"""

name = "unwrap_class"

@classmethod
def get_value_from_fallback(cls, fallback: Config) -> Sequence[_Unwrapper]:
return [fallback.unwrap_cls]

@classmethod
def unwrap(cls, typ: type, options: Options) -> type:
for unwrapper in options.get_value_for(cls):
typ = unwrapper(typ)
return typ


class ArgSpecCache:
DEFAULT_ARGSPECS = implementation.get_default_argspecs()

def __init__(self, config: Config) -> None:
self.config = config
def __init__(
self,
options: Options,
*,
vnv_provider: Callable[[str], Optional[Value]] = lambda _: None,
) -> None:
self.vnv_provider = vnv_provider
self.options = options
self.config = options.fallback
self.ts_finder = TypeshedFinder(verbose=False)
self.known_argspecs = {}
self.generic_bases_cache = {}
self.default_context = AnnotationsContext(self)
default_argspecs = dict(self.DEFAULT_ARGSPECS)
default_argspecs.update(self.config.get_known_argspecs(self))
for provider in options.get_value_for(KnownSignatures):
default_argspecs.update(provider(self))

for obj, argspec in default_argspecs.items():
self.known_argspecs[obj] = argspec
Expand Down Expand Up @@ -221,7 +364,7 @@ def from_signature(
has_return_annotation=has_return_annotation,
is_asynq=is_asynq,
allow_call=allow_call
or safe_in(function_object, self.config.FUNCTIONS_SAFE_TO_CALL),
or FunctionsSafeToCall.contains(function_object, self.options),
)

def _make_sig_parameter(
Expand Down Expand Up @@ -306,9 +449,7 @@ def _get_type_for_parameter(
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
vnv = VariableNameValue.from_varname(
parameter.name, self.config.varname_value_map()
)
vnv = self.vnv_provider(parameter.name)
if vnv is not None:
return vnv
return AnyValue(AnySource.unannotated)
Expand Down Expand Up @@ -413,7 +554,7 @@ def _uncached_get_argspec(
original_fn, impl, is_asynq, in_overload_resolution
)

allow_call = safe_in(obj, self.config.FUNCTIONS_SAFE_TO_CALL)
allow_call = FunctionsSafeToCall.contains(obj, self.options)
argspec = self.ts_finder.get_argspec(obj, allow_call=allow_call)
if argspec is not None:
return argspec
Expand Down Expand Up @@ -463,18 +604,17 @@ def _uncached_get_argspec(
return argspec

if inspect.isclass(obj):
obj = self.config.unwrap_cls(obj)
override = self.config.get_constructor(obj)
obj = UnwrapClass.unwrap(obj, self.options)
override = ConstructorHooks.get_constructor(obj, self.options)
if isinstance(override, Signature):
signature = override
else:
should_ignore = safe_in(obj, self.config.IGNORED_CALLEES)
should_ignore = IgnoredCallees.contains(obj, self.options)
return_type = (
AnyValue(AnySource.error) if should_ignore else TypedValue(obj)
)
allow_call = safe_issubclass(
obj, self.config.CLASSES_SAFE_TO_INSTANTIATE
)
safe = tuple(self.options.get_value_for(ClassesSafeToInstantiate))
allow_call = safe_issubclass(obj, safe)
if isinstance(override, inspect.Signature):
inspect_sig = override
else:
Expand Down Expand Up @@ -553,7 +693,7 @@ def _uncached_get_argspec(
return None

def _make_any_sig(self, obj: object) -> Signature:
if safe_in(obj, self.config.FUNCTIONS_SAFE_TO_CALL):
if FunctionsSafeToCall.contains(obj, self.options):
return Signature.make(
[],
AnyValue(AnySource.inference),
Expand Down
7 changes: 5 additions & 2 deletions pyanalyze/ast_annotator.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,11 @@ def _annotate_module(
Takes the module objects, its AST tree, and its literal code. Modifies the AST object in place.
"""
with ClassAttributeChecker(visitor_cls.config, enabled=True) as attribute_checker:
kwargs = visitor_cls.prepare_constructor_kwargs({})
kwargs = visitor_cls.prepare_constructor_kwargs({})
options = kwargs["checker"].options
with ClassAttributeChecker(
visitor_cls.config, enabled=True, options=options
) as attribute_checker:
visitor = visitor_cls(
filename,
code_str,
Expand Down
Loading

0 comments on commit e38b416

Please sign in to comment.