Repository: quora/pyanalyze
Pull request #368 (2021-12-26 12:11:20)

Support pyproject.toml configuration


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 13b92e3..af7751a 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -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)
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..d7497b4
--- /dev/null
+++ b/docs/configuration.md
@@ -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 -->
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index bc83d83..5173902 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -11,6 +11,7 @@ pyanalyze: A semi-static typechecker
    :caption: Contents:
 
    faq
+   configuration
    typesystem
    design
    glossary
diff --git a/pyanalyze/__init__.py b/pyanalyze/__init__.py
index 32b2dc7..f08df53 100644
--- a/pyanalyze/__init__.py
+++ b/pyanalyze/__init__.py
@@ -22,8 +22,10 @@ from .find_unused import used as used
 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
@@ -46,3 +48,5 @@ used(value.UNRESOLVED_VALUE)  # keeping it around for now just in case
 used(reexport)
 used(checker)
 used(suggested_type)
+used(options)
+used(shared_options)
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index 1ddce6a..e2d97a6 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -4,8 +4,9 @@ Implementation of extended argument specifications used by test_scope.
 
 """
 
+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
@@ -14,7 +15,6 @@ from .safe import (
     all_of_type,
     is_newtype,
     safe_hasattr,
-    safe_in,
     safe_issubclass,
     is_typing_name,
     safe_isinstance,
@@ -22,6 +22,7 @@ from .safe import (
 from .stacked_scopes import Composite, uniq_chain
 from .signature import (
     ANY_SIGNATURE,
+    ConcreteSignature,
     Impl,
     MaybeSignature,
     OverloadedSignature,
@@ -35,15 +36,17 @@ from .typeshed import TypeshedFinder
 from .value import (
     AnySource,
     AnyValue,
+    Extension,
     GenericBases,
+    KVPair,
     TypedValue,
     GenericValue,
     NewTypeValue,
     KnownValue,
     Value,
-    VariableNameValue,
     TypeVarValue,
     extract_typevars,
+    make_weak,
 )
 
 import ast
@@ -56,8 +59,9 @@ import qcore
 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__)
@@ -65,7 +69,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
@@ -88,7 +92,9 @@ def with_implementation(fn: object, implementation_fn: Impl) -> Iterable[None]:
         ):
             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(
@@ -145,17 +151,154 @@ class AnnotationsContext(Context):
         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
@@ -221,7 +364,7 @@ class ArgSpecCache:
             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(
@@ -306,9 +449,7 @@ class ArgSpecCache:
             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)
@@ -413,7 +554,7 @@ class ArgSpecCache:
                     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
@@ -463,18 +604,17 @@ class ArgSpecCache:
             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:
@@ -553,7 +693,7 @@ class ArgSpecCache:
         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),
diff --git a/pyanalyze/ast_annotator.py b/pyanalyze/ast_annotator.py
index 848d826..00e5c4d 100644
--- a/pyanalyze/ast_annotator.py
+++ b/pyanalyze/ast_annotator.py
@@ -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,
diff --git a/pyanalyze/asynq_checker.py b/pyanalyze/asynq_checker.py
index 1d721f1..3327e89 100644
--- a/pyanalyze/asynq_checker.py
+++ b/pyanalyze/asynq_checker.py
@@ -14,6 +14,8 @@ import inspect
 import types
 from typing import Sequence, Any, Callable, List, Optional, Iterable
 
+from pyanalyze.options import Options, PyObjectSequenceOption, StringSequenceOption
+
 from .config import Config
 from .error_code import ErrorCode
 from .safe import safe_getattr, safe_hasattr
@@ -41,11 +43,43 @@ class FunctionInfo:
     decorators: List[Any]
 
 
+class ClassesCheckedForAsynq(PyObjectSequenceOption[type]):
+    """Normally, asynq calls to asynq functions are only enforced in functions that are already
+    asynq. In subclasses of classes listed here, all asynq functions must be called asynq."""
+
+    name = "classes_checked_for_asynq"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[type]:
+        return list(fallback.BASE_CLASSES_CHECKED_FOR_ASYNQ)
+
+
+class NonAsynqModules(StringSequenceOption):
+    """We ignore _async methods in these modules."""
+
+    name = "non_asynq_modules"
+    default_value = ["multiprocessing"]
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[str]:
+        return list(fallback.NON_ASYNQ_MODULES)
+
+
+class MethodsNotCheckedForAsynq(StringSequenceOption):
+    """Async batching in these component methods isn't checked even when they exist on a class in
+    ClassesCheckedForAsynq."""
+
+    name = "methods_not_checked_for_asynq"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[str]:
+        return list(fallback.METHODS_NOT_CHECKED_FOR_ASYNQ)
+
+
 @dataclass
 class AsynqChecker:
-    config: Config
+    options: Options
     module: Optional[types.ModuleType]
-    lines: Sequence[str]
     on_error: Callable[..., Any]
     log: Callable[..., Any]
     replace_node: Callable[..., Any]
@@ -102,10 +136,9 @@ class AsynqChecker:
             if not safe_hasattr(inner_type, value.attr_name + "_async"):
                 return
             module = safe_getattr(inner_type, "__module__", None)
-            if (
-                isinstance(module, str)
-                and module.split(".")[0] in self.config.NON_ASYNQ_MODULES
-            ):
+            if isinstance(module, str) and module.split(".")[
+                0
+            ] in self.options.get_value_for(NonAsynqModules):
                 return
             if isinstance(node.func, ast.Attribute):
                 func_node = ast.Attribute(
@@ -170,13 +203,15 @@ class AsynqChecker:
             return True
         if self.current_class is None or self.is_classmethod:
             return False
-        if self.current_func_name in self.config.METHODS_NOT_CHECKED_FOR_ASYNQ:
+        if self.current_func_name in self.options.get_value_for(
+            MethodsNotCheckedForAsynq
+        ):
             return False
         return self.should_check_class_for_async(self.current_class)
 
     def should_check_class_for_async(self, cls: type) -> bool:
         """Returns whether we should perform async checks on all methods on this class."""
-        for base_cls in self.config.BASE_CLASSES_CHECKED_FOR_ASYNQ:
+        for base_cls in self.options.get_value_for(ClassesCheckedForAsynq):
             try:
                 if issubclass(cls, base_cls):
                     return True
diff --git a/pyanalyze/attributes.py b/pyanalyze/attributes.py
index 99cb658..3c4317c 100644
--- a/pyanalyze/attributes.py
+++ b/pyanalyze/attributes.py
@@ -61,9 +61,6 @@ class AttrContext:
     def should_ignore_class_attribute(self, obj: Any) -> bool:
         return False
 
-    def get_property_type_from_config(self, obj: Any) -> Value:
-        return AnyValue(AnySource.inference)
-
     def get_property_type_from_argspec(self, obj: Any) -> Value:
         return AnyValue(AnySource.inference)
 
@@ -253,9 +250,6 @@ def _unwrap_value_from_typed(result: Value, typ: type, ctx: AttrContext) -> Valu
     typevars = result.typevars if isinstance(result, KnownValueWithTypeVars) else None
     cls_val = result.val
     if isinstance(cls_val, property):
-        typ = ctx.get_property_type_from_config(cls_val)
-        if not isinstance(typ, AnyValue):
-            return typ
         return ctx.get_property_type_from_argspec(cls_val)
     elif qcore.inspection.is_classmethod(cls_val):
         return result
@@ -295,7 +289,7 @@ def _unwrap_value_from_typed(result: Value, typ: type, ctx: AttrContext) -> Valu
         typeshed_type = ctx.get_attribute_from_typeshed(typ, on_class=False)
         if typeshed_type is not UNINITIALIZED_VALUE:
             return typeshed_type
-        return ctx.get_property_type_from_config(cls_val)
+        return AnyValue(AnySource.inference)
     elif ctx.should_ignore_class_attribute(cls_val):
         return AnyValue(AnySource.error)
     else:
diff --git a/pyanalyze/checker.py b/pyanalyze/checker.py
index 633aa67..da03ce9 100644
--- a/pyanalyze/checker.py
+++ b/pyanalyze/checker.py
@@ -5,12 +5,26 @@ The checker maintains global state that is preserved across different modules.
 """
 import itertools
 from contextlib import contextmanager
-from dataclasses import dataclass, field
+from dataclasses import InitVar, dataclass, field
 import sys
-from typing import Iterable, Iterator, List, Set, Tuple, Union, Dict
-
+from typing import (
+    Callable,
+    Iterable,
+    Iterator,
+    List,
+    Optional,
+    Sequence,
+    Set,
+    Tuple,
+    Union,
+    Dict,
+)
+
+from pyanalyze.shared_options import VariableNameValues
+
+from .options import Options, PyObjectSequenceOption
 from .node_visitor import Failure
-from .value import TypedValue
+from .value import TypedValue, VariableNameValue
 from .arg_spec import ArgSpecCache
 from .config import Config
 from .reexport import ImplicitReexportTracker
@@ -18,10 +32,36 @@ from .safe import is_instance_of_typing_name, is_typing_name, safe_getattr
 from .type_object import TypeObject, get_mro
 from .suggested_type import CallableTracker
 
+_BaseProvider = Callable[[Union[type, super]], Set[type]]
+
+
+class AdditionalBaseProviders(PyObjectSequenceOption[_BaseProvider]):
+    """Sets functions that provide additional (virtual) base classes for a class.
+    These are used for the purpose of type checking.
+
+    For example, if the following is configured to be used as a base provider:
+
+        def provider(typ: type) -> Set[type]:
+            if typ is B:
+                return {A}
+            return set()
+
+    Then to the type checker `B` is a subclass of `A`.
+
+    """
+
+    name = "additional_base_providers"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[_BaseProvider]:
+        return (fallback.get_additional_bases,)
+
 
 @dataclass
 class Checker:
     config: Config
+    raw_options: InitVar[Optional[Options]] = None
+    options: Options = field(init=False)
     arg_spec_cache: ArgSpecCache = field(init=False)
     reexport_tracker: ImplicitReexportTracker = field(init=False)
     callable_tracker: CallableTracker = field(init=False)
@@ -31,17 +71,36 @@ class Checker:
     assumed_compatibilities: List[Tuple[TypeObject, TypeObject]] = field(
         default_factory=list
     )
+    vnv_map: Dict[str, VariableNameValue] = field(default_factory=dict)
 
-    def __post_init__(self) -> None:
-        self.arg_spec_cache = ArgSpecCache(self.config)
-        self.reexport_tracker = ImplicitReexportTracker(self.config)
+    def __post_init__(self, raw_options: Optional[Options]) -> None:
+        if raw_options is None:
+            self.options = Options.from_option_list([], self.config)
+        else:
+            self.options = raw_options
+        self.arg_spec_cache = ArgSpecCache(
+            self.options, vnv_provider=self.maybe_get_variable_name_value
+        )
+        self.reexport_tracker = ImplicitReexportTracker(self.options)
         self.callable_tracker = CallableTracker()
 
+        for vnv in self.options.get_value_for(VariableNameValues):
+            for variable in vnv.varnames:
+                self.vnv_map[variable] = vnv
+
+    def maybe_get_variable_name_value(
+        self, varname: str
+    ) -> Optional[VariableNameValue]:
+        return VariableNameValue.from_varname(varname, self.vnv_map)
+
     def perform_final_checks(self) -> List[Failure]:
         return self.callable_tracker.check()
 
     def get_additional_bases(self, typ: Union[type, super]) -> Set[type]:
-        return self.config.get_additional_bases(typ)
+        bases = set()
+        for provider in self.options.get_value_for(AdditionalBaseProviders):
+            bases |= provider(typ)
+        return bases
 
     def make_type_object(self, typ: Union[type, super, str]) -> TypeObject:
         try:
diff --git a/pyanalyze/config.py b/pyanalyze/config.py
index 103f1da..4a17227 100644
--- a/pyanalyze/config.py
+++ b/pyanalyze/config.py
@@ -2,6 +2,8 @@
 
 Module-specific configurations for test_scope.
 
+This is in the process of being replaced with options.py.
+
 """
 import abc
 import asynq
@@ -29,16 +31,20 @@ class Config(object):
     # Used in several parts of test_scope
     #
 
+    # Deprecated
     # default module to run on, if any
     DEFAULT_BASE_MODULE = None
 
+    # Replaced with options.Paths
     # file paths to run on by default
     DEFAULT_DIRS = ()
 
+    # arg_spec.UnwrapClass
     def unwrap_cls(self, cls: type) -> type:
         """Does any application-specific unwrapping logic for wrapper classes."""
         return cls
 
+    # arg_spec.ConstructorHooks
     def get_constructor(
         self, cls: type
     ) -> Union[None, "ConcreteSignature", inspect.Signature, Callable[..., Any]]:
@@ -55,29 +61,36 @@ class Config(object):
     # Used by name_check_visitor.py
     #
 
+    # Replaced with Options for each error code.
     # Sets of errors that are enabled or disabled. By default,
     # all errors are enabled except those in error_code.DISABLED_BY_DEFAULT.
     ENABLED_ERRORS = set()
     DISABLED_ERRORS = set()
 
+    # Replaced with options.EnforceNoUnused
     # If true, an error is raised when pyanalyze finds any unused objects.
     ENFORCE_NO_UNUSED_OBJECTS = False
 
+    # Replaced with options.ForLoopAlwaysEntered
     # If True, we assume that for loops are always entered at least once,
     # which affects the potentially_undefined_name check. This will miss
     # some bugs but also remove some annoying false positives.
     FOR_LOOP_ALWAYS_ENTERED = False
 
+    # Replaced with options.IgnoreNoneAttributes
     # If True, we ignore None when type checking attribute access on a Union
     # type.
     IGNORE_NONE_ATTRIBUTES = False
 
+    # Replaced with options.IgnoredPaths
     # Attribute accesses on these do not result in errors
     IGNORED_PATHS = []
 
+    # options.ExtraBuiltins
     # Even if these variables are undefined, no errors are shown
     IGNORED_VARIABLES = {"__IPYTHON__"}  # special global defined in IPython
 
+    # Replaced with options.IgnoredEndOfReference
     # When these attributes are accessed but they don't exist, the error is ignored
     IGNORED_END_OF_REFERENCE = {
         # these are created by the mock module
@@ -93,9 +106,11 @@ class Config(object):
         "assert_not_called",
     }
 
+    # options.UnimportableModules
     # Do not attempt to import these modules if they are imported within a function
     UNIMPORTABLE_MODULES = set()
 
+    # options.IgnoredCallees
     # Calls to these aren't checked for argument validity
     IGNORED_CALLEES = [
         # not a set because trying to include a.mocklib.call in a set complains about a dict not
@@ -106,23 +121,15 @@ class Config(object):
         mock.Mock,
     ]
 
+    # Use file-specific overrides instead
     # In these files, we do not check for duplicate dictionary keys
     IGNORED_FILES_FOR_DUPLICATE_DICT_KEYS = set()
 
+    # Use file-specific overrides instead
     # In these files, strings with non-ASCII characters do not require explicit prefixes
     IGNORED_FILES_FOR_EXPLICIT_STRING_LITERALS = set()
 
-    # For these types, we do not check whether actions taken on them (such as subscripting) are
-    # valid
-    IGNORED_TYPES = set()
-
-    # These properties always return values of these types.
-    PROPERTIES_OF_KNOWN_TYPE = {}
-
-    # Any variable or attribute access to these names for which no type can be inferred uses the
-    # hardcoded type instead. Useful if certain variable names are always used for certain types.
-    NAMES_OF_KNOWN_TYPE = {}
-
+    # arg_spec.ClassesSafeToInstantiate
     # test_scope will instantiate instances of these classes if it can infer the value of all of
     # their arguments. This is useful mostly for classes that are commonly instantiated with static
     # arguments.
@@ -136,6 +143,7 @@ class Config(object):
         tuple,
     )
 
+    # arg_spec.FunctionsSafeToCall
     # Similarly, these functions will be called
     FUNCTIONS_SAFE_TO_CALL = (
         # we can't have len here because of code like l = []; l.append('foo'); 3 / len(l)
@@ -145,6 +153,8 @@ class Config(object):
         asynq.asynq,
         value.make_weak,
     )
+
+    # name_check_visitor.SafeDecoratorsForNestedFunctions
     # These decorators can safely be applied to nested functions. If True, they take arguments.
     SAFE_DECORATORS_FOR_NESTED_FUNCTIONS = {
         asynq.asynq: True,
@@ -163,15 +173,20 @@ class Config(object):
 
     # Set of dunder methods (e.g., '{"__lshift__"}') that pyanalyze is not allowed to call on
     # objects.
+    # name_check_visitor.DisallowCallsToDunders
     DISALLOW_CALLS_TO_DUNDERS = set()
 
     # Decorators that are equivalent to asynq.asynq and asynq.async_proxy.
+    # name_check_visitor.AsynqDecorators
     ASYNQ_DECORATORS = {asynq.asynq}
+    # name_check_visitor.AsyncProxyDecorators
     ASYNC_PROXY_DECORATORS = {asynq.async_proxy}
 
+    # name_check_visitor.ComprehensionLengthInferenceLimit
     # If we iterate over something longer than this, we don't try to infer precise
     # types for comprehensions. Increasing this can hurt performance.
     COMPREHENSION_LENGTH_INFERENCE_LIMIT = 25
+    # name_check_visitor.UnionSimplificationLimit
     # We may simplify unions with more than this many values.
     UNION_SIMPLIFICATION_LIMIT = 100
 
@@ -225,13 +240,16 @@ class Config(object):
 
     # Normally, async calls to async functions are only enforced in functions that are already
     # async. In subclasses of classes listed here, all async functions must be called async.
+    # asynq_checker.ClassesCheckedForAsynq
     BASE_CLASSES_CHECKED_FOR_ASYNQ = set()
 
     # Async batching in these component methods isn't checked even when they exist on a class in
     # BASE_CLASSES_CHECKED_FOR_ASYNQ
+    # asynq_checker.MethodsNotCheckedForAsynq
     METHODS_NOT_CHECKED_FOR_ASYNQ = set()
 
     # We ignore _async methdos in these modules.
+    # asynq_checker.NonAsynqModules
     NON_ASYNQ_MODULES = {"multiprocessing"}
 
     #
@@ -279,6 +297,7 @@ class Config(object):
     #
     # Used by arg_spec.py
     #
+    # arg_spec.KnownSignatures
     def get_known_argspecs(
         self, arg_spec_cache: "ArgSpecCache"
     ) -> Dict[object, "ConcreteSignature"]:
@@ -293,6 +312,7 @@ class Config(object):
         """
         return issubclass(cls, enum.Enum)
 
+    # checker.AdditionalBaseProviders
     def get_additional_bases(self, typ: Union[type, super]) -> Set[type]:
         """Return additional classes that should be considered bae classes of typ."""
         return set()
@@ -300,6 +320,7 @@ class Config(object):
     #
     # Used by reexport.py
     #
+    # reexport.ReexportConfig
     def configure_reexports(self, tracker: "ImplicitReexportTracker") -> None:
         """Override this to set some names as explicitly re-exported."""
         pass
diff --git a/pyanalyze/importer.py b/pyanalyze/importer.py
index 05aa1ae..f8b18ae 100644
--- a/pyanalyze/importer.py
+++ b/pyanalyze/importer.py
@@ -9,7 +9,7 @@ import importlib
 import importlib.util
 from pathlib import Path
 import sys
-from typing import List, cast, Tuple
+from typing import Optional, Sequence, cast, Tuple
 from types import ModuleType
 
 
@@ -19,8 +19,8 @@ def directory_has_init(path: Path) -> bool:
 
 
 def load_module_from_file(
-    filename: str, verbose: bool = False
-) -> Tuple[ModuleType, bool]:
+    filename: str, *, verbose: bool = False, import_paths: Sequence[str] = ()
+) -> Tuple[Optional[ModuleType], bool]:
     """Import the Python code in the given file.
 
     Return a tuple (module object, whether it is a compiled file).
@@ -32,7 +32,7 @@ def load_module_from_file(
     # somewhat properly
     abspath = Path(filename).resolve()
     candidate_paths = []
-    path: List[str] = sys.path
+    path: Sequence[str] = import_paths if import_paths else sys.path
     for sys_path_entry in path:
         if not sys_path_entry:
             continue
@@ -95,6 +95,8 @@ def load_module_from_file(
 
     # If all else fails, try to import it under its own name
     # regardless of sys.path.
+    if import_paths:
+        return None, False
     if verbose:
         print(f"falling back to importing {abspath} outside the import path")
     return import_module(str(abspath), abspath), False
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 60e60df..87718c1 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -14,6 +14,7 @@ import enum
 from ast_decompiler import decompile
 import asyncio
 import builtins
+import collections
 import collections.abc
 import contextlib
 from dataclasses import dataclass
@@ -21,7 +22,9 @@ import inspect
 from itertools import chain
 import logging
 import operator
+import os
 import os.path
+from pathlib import Path
 import pickle
 import random
 import re
@@ -62,15 +65,26 @@ from .annotations import (
     type_from_value,
     is_typing_name,
 )
-from .arg_spec import ArgSpecCache, is_dot_asynq_function
+from .arg_spec import ArgSpecCache, is_dot_asynq_function, UnwrapClass, IgnoredCallees
 from .boolability import Boolability, get_boolability
 from .checker import Checker
 from .config import Config
 from .error_code import ErrorCode, DISABLED_BY_DEFAULT, ERROR_DESCRIPTION
 from .extensions import ParameterTypeGuard, overload
 from .find_unused import UnusedObjectFinder, used
+from .options import (
+    ConfigOption,
+    ConcatenatedOption,
+    BooleanOption,
+    InvalidConfigOption,
+    IntegerOption,
+    Options,
+    PyObjectSequenceOption,
+    StringSequenceOption,
+)
+from .shared_options import Paths, ImportPaths, EnforceNoUnused
 from .reexport import ErrorContext, ImplicitReexportTracker
-from .safe import safe_getattr, is_hashable, safe_in, all_of_type
+from .safe import safe_getattr, is_hashable, all_of_type
 from .stacked_scopes import (
     AbstractConstraint,
     Composite,
@@ -140,7 +154,6 @@ from .value import (
     TypedValue,
     MultiValuedValue,
     UnboundMethodValue,
-    VariableNameValue,
     ReferencingValue,
     SubclassValue,
     DictIncompleteValue,
@@ -254,14 +267,6 @@ class _AttrContext(attributes.AttrContext):
     def should_ignore_class_attribute(self, obj: object) -> bool:
         return self.visitor.config.should_ignore_class_attribute(obj)
 
-    def get_property_type_from_config(self, obj: object) -> Value:
-        try:
-            return self.visitor.config.PROPERTIES_OF_KNOWN_TYPE[obj]
-        except (KeyError, TypeError):
-            return AnyValue(
-                AnySource.inference
-            )  # can't figure out what this will return
-
     def get_property_type_from_argspec(self, obj: object) -> Value:
         argspec = self.visitor.arg_spec_cache.get_argspec(obj)
         if argspec is not None:
@@ -306,6 +311,164 @@ _DEFAULT_FUNCTION_INFO = FunctionInfo(
 )
 
 
+class SafeDecoratorsForNestedFunctions(PyObjectSequenceOption[object]):
+    """These decorators can safely be applied to nested functions."""
+
+    name = "safe_decorators_for_nested_functions"
+    default_value = [asynq.asynq, classmethod, staticmethod, asyncio.coroutine]
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
+        return list(fallback.SAFE_DECORATORS_FOR_NESTED_FUNCTIONS)
+
+
+class ComprehensionLengthInferenceLimit(IntegerOption):
+    """If we iterate over something longer than this, we don't try to infer precise
+    types for comprehensions. Increasing this can hurt performance."""
+
+    default_value = 100
+    name = "comprehension_length_inference_limit"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> int:
+        return fallback.COMPREHENSION_LENGTH_INFERENCE_LIMIT
+
+
+class UnionSimplificationLimit(IntegerOption):
+    """We may simplify unions with more than this many values."""
+
+    default_value = 25
+    name = "union_simplification_limit"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> int:
+        return fallback.UNION_SIMPLIFICATION_LIMIT
+
+
+class AsynqDecorators(PyObjectSequenceOption[object]):
+    """Decorators that are equivalent to asynq.asynq."""
+
+    default_value = [asynq.asynq]
+    name = "asynq_decorators"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
+        return list(fallback.ASYNQ_DECORATORS)
+
+
+class AsyncProxyDecorators(PyObjectSequenceOption[object]):
+    """Decorators that are equivalent to asynq.async_proxy."""
+
+    default_value = [asynq.async_proxy]
+    name = "async_proxy_decorators"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[object]:
+        return list(fallback.ASYNC_PROXY_DECORATORS)
+
+
+class DisallowCallsToDunders(StringSequenceOption):
+    """Set of dunder methods (e.g., '{"__lshift__"}') that pyanalyze is not allowed to call on
+    objects."""
+
+    name = "disallow_calls_to_dunders"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[str]:
+        return list(fallback.DISALLOW_CALLS_TO_DUNDERS)
+
+
+class ForLoopAlwaysEntered(BooleanOption):
+    """If True, we assume that for loops are always entered at least once,
+    which affects the potentially_undefined_name check. This will miss
+    some bugs but also remove some annoying false positives."""
+
+    name = "for_loop_always_entered"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> bool:
+        return fallback.FOR_LOOP_ALWAYS_ENTERED
+
+
+class IgnoreNoneAttributes(BooleanOption):
+    """If True, we ignore None when type checking attribute access on a Union
+    type."""
+
+    name = "ignore_none_attributes"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> bool:
+        return fallback.IGNORE_NONE_ATTRIBUTES
+
+
+class UnimportableModules(StringSequenceOption):
+    """Do not attempt to import these modules if they are imported within a function."""
+
+    default_value = []
+    name = "unimportable_modules"
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[str]:
+        return list(fallback.UNIMPORTABLE_MODULES)
+
+
+class ExtraBuiltins(StringSequenceOption):
+    """Even if these variables are undefined, no errors are shown."""
+
+    name = "extra_builtins"
+    default_value = ["__IPYTHON__"]  # special global defined in IPython
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[str]:
+        return list(fallback.IGNORED_VARIABLES)
+
+
+class IgnoredPaths(ConcatenatedOption[Sequence[str]]):
+    """Attribute accesses on these do not result in errors."""
+
+    name = "ignored_paths"
+    default_value = ()
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[Sequence[str]]:
+        return fallback.IGNORED_PATHS
+
+    @classmethod
+    def parse(cls, data: object, source_path: Path) -> Sequence[Sequence[str]]:
+        if not isinstance(data, (list, tuple)):
+            raise InvalidConfigOption.from_parser(cls, "sequence", data)
+        for sublist in data:
+            if not isinstance(sublist, (list, tuple)):
+                raise InvalidConfigOption.from_parser(cls, "sequence", sublist)
+            for elt in sublist:
+                if not isinstance(elt, str):
+                    raise InvalidConfigOption.from_parser(cls, "string", elt)
+        return data
+
+
+class IgnoredEndOfReference(StringSequenceOption):
+    """When these attributes are accessed but they don't exist, the error is ignored."""
+
+    name = "ignored_end_of_reference"
+    default_value = [
+        # these are created by the mock module
+        "call_count",
+        "assert_has_calls",
+        "reset_mock",
+        "called",
+        "assert_called_once",
+        "assert_called_once_with",
+        "assert_called_with",
+        "count",
+        "assert_any_call",
+        "assert_not_called",
+    ]
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[str]:
+        return list(fallback.IGNORED_END_OF_REFERENCE)
+
+
 class ClassAttributeChecker:
     """Helper class to keep track of attributes that are read and set on instances."""
 
@@ -315,7 +478,12 @@ class ClassAttributeChecker:
         enabled: bool = True,
         should_check_unused_attributes: bool = False,
         should_serialize: bool = False,
+        *,
+        options: Optional[Options] = None,
     ) -> None:
+        if options is None:
+            options = Options.from_option_list([], config)
+        self.options = options
         # we might not have examined all parent classes when looking for attributes set
         # we dump them here. incase the callers want to extend coverage.
         self.unexamined_base_classes = set()
@@ -429,10 +597,8 @@ class ClassAttributeChecker:
             name = typ.__name__
             if module not in sys.modules:
                 return None
-            if (
-                self.config.unwrap_cls(safe_getattr(sys.modules[module], name, None))
-                is typ
-            ):
+            actual = safe_getattr(sys.modules[module], name, None)
+            if UnwrapClass.unwrap(actual, self.options) is typ:
                 return (module, name)
         return None
 
@@ -443,7 +609,8 @@ class ClassAttributeChecker:
         if module not in sys.modules:
             __import__(module)
         try:
-            return self.config.unwrap_cls(getattr(sys.modules[module], name))
+            actual = getattr(sys.modules[module], name)
+            return UnwrapClass.unwrap(actual, self.options)
         except AttributeError:
             # We've seen this happen when we import different modules under the same name.
             return None
@@ -723,6 +890,8 @@ class NameCheckVisitor(
     config: ClassVar[
         Config
     ] = Config()  # subclasses may override this with a more specific config
+    config_filename: ClassVar[Optional[str]] = None
+    """Path (relative to this class's file) to a pyproject.toml config file."""
 
     checker: Checker
     arg_spec_cache: ArgSpecCache
@@ -777,6 +946,7 @@ class NameCheckVisitor(
         self.annotate = annotate
         # true if we're in the body of a comprehension's loop
         self.in_comprehension_body = False
+        self.options = checker.options
 
         if module is not None:
             self.module = module
@@ -784,6 +954,10 @@ class NameCheckVisitor(
         else:
             self.module, self.is_compiled = self._load_module()
 
+        if self.module is not None and hasattr(self.module, "__name__"):
+            module_path = tuple(self.module.__name__.split("."))
+            self.options = checker.options.for_module(module_path)
+
         # Data storage objects
         self.unused_finder = unused_finder
         self.attribute_checker = attribute_checker
@@ -797,16 +971,12 @@ class NameCheckVisitor(
             self.attribute_checker.record_module_examined(self.module.__name__)
 
         self.scopes = build_stacked_scopes(
-            self.module, simplification_limit=self.config.UNION_SIMPLIFICATION_LIMIT
+            self.module,
+            simplification_limit=self.options.get_value_for(UnionSimplificationLimit),
         )
         self.node_context = StackedContexts()
         self.asynq_checker = AsynqChecker(
-            self.config,
-            self.module,
-            self._lines,
-            self.show_error,
-            self.log,
-            self.replace_node,
+            self.options, self.module, self.show_error, self.log, self.replace_node
         )
         self.yield_checker = YieldChecker(self)
         self.current_function = None
@@ -883,9 +1053,12 @@ class NameCheckVisitor(
         if not self.filename:
             return None, False
         self.log(logging.INFO, "Checking file", (self.filename, os.getpid()))
+        import_paths = self.options.get_value_for(ImportPaths)
 
         try:
-            return self.load_module(self.filename)
+            return importer.load_module_from_file(
+                self.filename, import_paths=[str(p) for p in import_paths]
+            )
         except KeyboardInterrupt:
             raise
         except BaseException as e:
@@ -906,9 +1079,6 @@ class NameCheckVisitor(
                     traceback.print_exc()
             return None, False
 
-    def load_module(self, filename: str) -> Tuple[Optional[types.ModuleType], bool]:
-        return importer.load_module_from_file(filename)
-
     def check(self, ignore_missing_module: bool = False) -> List[node_visitor.Failure]:
         """Run the visitor on this module."""
         start_time = qcore.utime()
@@ -1122,7 +1292,7 @@ class NameCheckVisitor(
                         defining_scope.scope_object, node.id, value
                     )
         if value is UNINITIALIZED_VALUE:
-            if suppress_errors or node.id in self.config.IGNORED_VARIABLES:
+            if suppress_errors or node.id in self.options.get_value_for(ExtraBuiltins):
                 self.log(logging.INFO, "ignoring undefined name", node.id)
             else:
                 self._maybe_show_missing_import_error(node)
@@ -1281,7 +1451,7 @@ class NameCheckVisitor(
                     cls_obj = possible_values[0]
 
             if isinstance(cls_obj, KnownValue):
-                cls_obj = KnownValue(self.config.unwrap_cls(cls_obj.val))
+                cls_obj = KnownValue(UnwrapClass.unwrap(cls_obj.val, self.options))
                 current_class = cls_obj.val
                 if isinstance(current_class, type):
                     self._record_class_examined(current_class)
@@ -1322,8 +1492,9 @@ class NameCheckVisitor(
 
         if (
             potential_function is not None
-            and self.settings
-            and self.settings[ErrorCode.suggested_parameter_type]
+            and self.options.is_error_code_enabled_anywhere(
+                ErrorCode.suggested_parameter_type
+            )
         ):
             sig = self.signature_from_value(KnownValue(potential_function))
             if isinstance(sig, Signature):
@@ -1472,7 +1643,9 @@ class NameCheckVisitor(
         return KnownValue(potential_function)
 
     def record_call(self, callable: object, arguments: CallArgs) -> None:
-        if self.settings and self.settings[ErrorCode.suggested_parameter_type]:
+        if self.options.is_error_code_enabled_anywhere(
+            ErrorCode.suggested_parameter_type
+        ):
             self.checker.callable_tracker.record_call(callable, arguments)
 
     def _visit_defaults(
@@ -1496,7 +1669,9 @@ class NameCheckVisitor(
             if (
                 not isinstance(decorator, KnownValue)
                 or not isinstance(applied_decorator, KnownValue)
-                or decorator.val not in self.config.SAFE_DECORATORS_FOR_NESTED_FUNCTIONS
+                or not SafeDecoratorsForNestedFunctions.contains(
+                    decorator.val, self.options
+                )
             ):
                 self.log(
                     logging.DEBUG,
@@ -1562,12 +1737,12 @@ class NameCheckVisitor(
                 decorator_value = self.visit(decorator)
                 callee = self.visit(decorator.func)
                 if isinstance(callee, KnownValue):
-                    if safe_in(callee.val, self.config.ASYNQ_DECORATORS):
+                    if AsynqDecorators.contains(callee.val, self.options):
                         if any(kw.arg == "pure" for kw in decorator.keywords):
                             async_kind = AsyncFunctionKind.pure
                         else:
                             async_kind = AsyncFunctionKind.normal
-                    elif safe_in(callee.val, self.config.ASYNC_PROXY_DECORATORS):
+                    elif AsyncProxyDecorators.contains(callee.val, self.options):
                         # @async_proxy(pure=True) is a noop, so don't treat it specially
                         if not any(kw.arg == "pure" for kw in decorator.keywords):
                             async_kind = AsyncFunctionKind.async_proxy
@@ -2018,18 +2193,13 @@ class NameCheckVisitor(
                 self.unused_finder.record(module, alias.name, self.module.__name__)
 
     def _is_unimportable_module(self, node: Union[ast.Import, ast.ImportFrom]) -> bool:
+        unimportable = self.options.get_value_for(UnimportableModules)
         if isinstance(node, ast.ImportFrom):
             # the split is needed for cases like "from foo.bar import baz" if foo is unimportable
-            return (
-                node.module is not None
-                and node.module.split(".")[0] in self.config.UNIMPORTABLE_MODULES
-            )
+            return node.module is not None and node.module.split(".")[0] in unimportable
         else:
             # need the split if the code is "import foo.bar as bar" if foo is unimportable
-            return any(
-                name.name.split(".")[0] in self.config.UNIMPORTABLE_MODULES
-                for name in node.names
-            )
+            return any(name.name.split(".")[0] in unimportable for name in node.names)
 
     def _simulate_import(
         self, node: Union[ast.ImportFrom, ast.Import], *, force_public: bool = False
@@ -2149,7 +2319,8 @@ class NameCheckVisitor(
             iterable_type = self._member_value_of_iterator(node.iter, is_async)
             if not isinstance(iterable_type, Value):
                 iterable_type = unite_and_simplify(
-                    *iterable_type, limit=self.config.UNION_SIMPLIFICATION_LIMIT
+                    *iterable_type,
+                    limit=self.options.get_value_for(UnionSimplificationLimit),
                 )
         with qcore.override(self, "in_comprehension_body", True):
             with qcore.override(self, "being_assigned", iterable_type):
@@ -2206,7 +2377,7 @@ class NameCheckVisitor(
                 and not node.generators[0].ifs
                 and 0
                 < len(iterable_type)
-                <= self.config.COMPREHENSION_LENGTH_INFERENCE_LIMIT
+                <= self.options.get_value_for(ComprehensionLengthInferenceLimit)
             ):
                 generator = node.generators[0]
                 if isinstance(node, ast.DictComp):
@@ -2235,7 +2406,8 @@ class NameCheckVisitor(
                     return SequenceIncompleteValue(typ, elts)
 
             iterable_type = unite_and_simplify(
-                *iterable_type, limit=self.config.UNION_SIMPLIFICATION_LIMIT
+                *iterable_type,
+                limit=self.options.get_value_for(UnionSimplificationLimit),
             )
         # need to visit the generator expression first so that we know of variables
         # created in them
@@ -2508,7 +2680,7 @@ class NameCheckVisitor(
                     values.append(elt)
             if has_unknown_value:
                 arg = unite_and_simplify(
-                    *values, limit=self.config.UNION_SIMPLIFICATION_LIMIT
+                    *values, limit=self.options.get_value_for(UnionSimplificationLimit)
                 )
                 return make_weak(GenericValue(typ, [arg]))
             else:
@@ -2787,7 +2959,7 @@ class NameCheckVisitor(
             imethod,
             rmethod,
         ) = BINARY_OPERATION_TO_DESCRIPTION_AND_METHOD[type(op)]
-        allow_call = method not in self.config.DISALLOW_CALLS_TO_DUNDERS
+        allow_call = method not in self.options.get_value_for(DisallowCallsToDunders)
 
         if is_inplace:
             with self.catch_errors() as inplace_errors:
@@ -3118,7 +3290,7 @@ class NameCheckVisitor(
 
     def visit_For(self, node: ast.For) -> None:
         iterated_value = self._member_value_of_iterator(node.iter)
-        if self.config.FOR_LOOP_ALWAYS_ENTERED:
+        if self.options.get_value_for(ForLoopAlwaysEntered):
             always_entered = True
         elif isinstance(iterated_value, Value):
             iterated_value, present = unannotate_value(
@@ -3129,7 +3301,8 @@ class NameCheckVisitor(
             always_entered = len(iterated_value) > 0
         if not isinstance(iterated_value, Value):
             iterated_value = unite_and_simplify(
-                *iterated_value, limit=self.config.UNION_SIMPLIFICATION_LIMIT
+                *iterated_value,
+                limit=self.options.get_value_for(UnionSimplificationLimit),
             )
         with self.scopes.subscope() as body_scope:
             with self.scopes.loop_scope():
@@ -3538,12 +3711,9 @@ class NameCheckVisitor(
         if force_read or self._is_read_ctx(node.ctx):
             self.yield_checker.record_usage(node.id, node)
             value, origin = self.resolve_name(node)
-            varname_value = VariableNameValue.from_varname(
-                node.id, self.config.varname_value_map()
-            )
+            varname_value = self.checker.maybe_get_variable_name_value(node.id)
             if varname_value is not None and self._should_use_varname_value(value):
                 value = varname_value
-            value = self._maybe_use_hardcoded_type(value, node.id)
             return Composite(value, VarnameWithOrigin(node.id, origin), node)
         elif self._is_write_ctx(node.ctx):
             if self._name_node_to_statement is not None:
@@ -3588,18 +3758,6 @@ class NameCheckVisitor(
             isinstance(value, AnyValue) and value.source is not AnySource.variable_name
         )
 
-    def _maybe_use_hardcoded_type(self, value: Value, name: str) -> Value:
-        """Replaces a value with a name of hardcoded type where applicable."""
-        if not isinstance(value, (AnyValue, MultiValuedValue)):
-            return value
-
-        try:
-            typ = self.config.NAMES_OF_KNOWN_TYPE[name]
-        except KeyError:
-            return value
-        else:
-            return TypedValue(typ)
-
     def visit_Subscript(self, node: ast.Subscript) -> Value:
         return self.composite_from_subscript(node).value
 
@@ -3716,8 +3874,8 @@ class NameCheckVisitor(
                     and isinstance(index, KnownValue)
                     and isinstance(index.val, str)
                 ):
-                    varname_value = VariableNameValue.from_varname(
-                        index.val, self.config.varname_value_map()
+                    varname_value = self.checker.maybe_get_variable_name_value(
+                        index.val
                     )
                     if varname_value is not None:
                         return_value = varname_value
@@ -3750,7 +3908,7 @@ class NameCheckVisitor(
             Composite(lookup_val),
             method_name,
             node,
-            ignore_none=self.config.IGNORE_NONE_ATTRIBUTES,
+            ignore_none=self.options.get_value_for(IgnoreNoneAttributes),
         )
         if method_object is UNINITIALIZED_VALUE:
             self.show_error(
@@ -3781,7 +3939,7 @@ class NameCheckVisitor(
                     for composite in composites
                 ]
             return unite_and_simplify(
-                *values, limit=self.config.UNION_SIMPLIFICATION_LIMIT
+                *values, limit=self.options.get_value_for(UnionSimplificationLimit)
             )
         return self._check_dunder_call_no_mvv(
             node, callee_composite, method_name, args, allow_call
@@ -3868,9 +4026,7 @@ class NameCheckVisitor(
                     )
             value = self._get_attribute_with_fallback(root_composite, node.attr, node)
             if self._should_use_varname_value(value):
-                varname_value = VariableNameValue.from_varname(
-                    node.attr, self.config.varname_value_map()
-                )
+                varname_value = self.checker.maybe_get_variable_name_value(node.attr)
                 if varname_value is not None:
                     return Composite(varname_value, composite, node)
             if (
@@ -3880,7 +4036,6 @@ class NameCheckVisitor(
                 local_value = self._get_composite(composite.get_varname(), node, value)
                 if local_value is not UNINITIALIZED_VALUE:
                     value = local_value
-            value = self._maybe_use_hardcoded_type(value, node.attr)
             return Composite(value, composite, node)
         else:
             self.show_error(node, "Unknown context", ErrorCode.unexpected_node)
@@ -3938,7 +4093,7 @@ class NameCheckVisitor(
     def _get_attribute_with_fallback(
         self, root_composite: Composite, attr: str, node: ast.AST
     ) -> Value:
-        ignore_none = self.config.IGNORE_NONE_ATTRIBUTES
+        ignore_none = self.options.get_value_for(IgnoreNoneAttributes)
         if isinstance(root_composite.value, TypeVarValue):
             root_composite = Composite(
                 value=root_composite.value.get_fallback_value(),
@@ -4086,18 +4241,15 @@ class NameCheckVisitor(
         if node is not None:
             path = self._get_attribute_path(node)
             if path is not None:
-                for ignored_path in self.config.IGNORED_PATHS:
+                ignored_paths = self.options.get_value_for(IgnoredPaths)
+                for ignored_path in ignored_paths:
                     if path[: len(ignored_path)] == ignored_path:
                         return True
-                if path[-1] in self.config.IGNORED_END_OF_REFERENCE:
+                if path[-1] in self.options.get_value_for(IgnoredEndOfReference):
                     self.log(logging.INFO, "Ignoring end of reference", path)
                     return True
         return False
 
-    def _should_ignore_type(self, typ: type) -> bool:
-        """Types for which we do not check whether they support the actions we take on them."""
-        return typ in self.config.IGNORED_TYPES
-
     # Call nodes
 
     def visit_keyword(self, node: ast.keyword) -> Tuple[Optional[str], Composite]:
@@ -4211,7 +4363,8 @@ class NameCheckVisitor(
         allow_call: bool = False,
     ) -> Value:
         if isinstance(callee_wrapped, KnownValue) and any(
-            callee_wrapped.val is ignored for ignored in self.config.IGNORED_CALLEES
+            callee_wrapped.val is ignored
+            for ignored in self.options.get_value_for(IgnoredCallees)
         ):
             self.log(logging.INFO, "Ignoring callee", callee_wrapped)
             return AnyValue(AnySource.error)
@@ -4329,7 +4482,7 @@ class NameCheckVisitor(
                     Composite(value),
                     "__call__",
                     node,
-                    ignore_none=self.config.IGNORE_NONE_ATTRIBUTES,
+                    ignore_none=self.options.get_value_for(IgnoreNoneAttributes),
                 )
                 if method_object is UNINITIALIZED_VALUE:
                     return None
@@ -4364,7 +4517,7 @@ class NameCheckVisitor(
                 call_method = self.get_attribute(
                     Composite(value),
                     "__call__",
-                    ignore_none=self.config.IGNORE_NONE_ATTRIBUTES,
+                    ignore_none=self.options.get_value_for(IgnoreNoneAttributes),
                 )
                 if call_method is UNINITIALIZED_VALUE:
                     return None
@@ -4436,7 +4589,7 @@ class NameCheckVisitor(
             if value.val is self.current_class:
                 return
 
-            inner = self.config.unwrap_cls(value.val)
+            inner = UnwrapClass.unwrap(value.val, self.options)
             if inner is self.current_class:
                 return
 
@@ -4458,6 +4611,11 @@ class NameCheckVisitor(
             default=False,
             help="Find unused class attributes",
         )
+        parser.add_argument(
+            "--config-file",
+            type=Path,
+            help="Path to a pyproject.toml configuration file",
+        )
         return parser
 
     @classmethod
@@ -4478,15 +4636,41 @@ class NameCheckVisitor(
         return (cls.config.DEFAULT_BASE_MODULE,)
 
     @classmethod
-    def get_default_directories(cls) -> Tuple[str, ...]:
+    def get_default_directories(
+        cls, checker: Checker, **kwargs: Any
+    ) -> Tuple[str, ...]:
+        paths = checker.options.get_value_for(Paths)
+        if paths:
+            return tuple(str(path) for path in paths)
         return cls.config.DEFAULT_DIRS
 
+    @classmethod
+    def _get_default_settings(cls) -> Optional[Dict[enum.Enum, bool]]:
+        return {}
+
     @classmethod
     def prepare_constructor_kwargs(cls, kwargs: Mapping[str, Any]) -> Mapping[str, Any]:
         kwargs = dict(kwargs)
-        kwargs.setdefault("checker", Checker(cls.config))
+        instances = []
+        if "settings" in kwargs:
+            for error_code, value in kwargs["settings"].items():
+                option_cls = ConfigOption.registry[error_code.name]
+                instances.append(option_cls(value, from_command_line=True))
+        if "files" in kwargs:
+            instances.append(Paths(kwargs.pop("files"), from_command_line=True))
+        config_file = kwargs.pop("config_file", None)
+        if config_file is None:
+            config_filename = cls.config_filename
+            if config_filename is not None:
+                module_path = Path(sys.modules[cls.__module__].__file__).parent
+                config_file = module_path / config_filename
+        options = Options.from_option_list(instances, cls.config, config_file)
+        kwargs.setdefault("checker", Checker(cls.config, options))
         return kwargs
 
+    def is_enabled(self, error_code: ErrorCode) -> bool:
+        return self.options.is_error_code_enabled(error_code)
+
     @classmethod
     def perform_final_checks(
         cls, kwargs: Mapping[str, Any]
@@ -4498,30 +4682,30 @@ class NameCheckVisitor(
         cls,
         files: List[str],
         *,
+        checker: Checker,
         find_unused: bool = False,
-        settings: Mapping[ErrorCode, bool] = {},
         find_unused_attributes: bool = False,
         attribute_checker: Optional[ClassAttributeChecker] = None,
         unused_finder: Optional[UnusedObjectFinder] = None,
         **kwargs: Any,
     ) -> List[node_visitor.Failure]:
-        if settings is None:
-            attribute_checker_enabled = True
-        else:
-            attribute_checker_enabled = settings[ErrorCode.attribute_is_never_set]
+        attribute_checker_enabled = checker.options.is_error_code_enabled_anywhere(
+            ErrorCode.attribute_is_never_set
+        )
         if attribute_checker is None:
             inner_attribute_checker_obj = attribute_checker = ClassAttributeChecker(
                 cls.config,
                 enabled=attribute_checker_enabled,
                 should_check_unused_attributes=find_unused_attributes,
                 should_serialize=kwargs.get("parallel", False),
+                options=checker.options,
             )
         else:
             inner_attribute_checker_obj = qcore.empty_context
         if unused_finder is None:
             unused_finder = UnusedObjectFinder(
                 cls.config,
-                enabled=find_unused or cls.config.ENFORCE_NO_UNUSED_OBJECTS,
+                enabled=find_unused or checker.options.get_value_for(EnforceNoUnused),
                 print_output=False,
             )
         with inner_attribute_checker_obj as inner_attribute_checker:
@@ -4532,7 +4716,7 @@ class NameCheckVisitor(
                     if attribute_checker is not None
                     else inner_attribute_checker,
                     unused_finder=inner_unused_finder,
-                    settings=settings,
+                    checker=checker,
                     **kwargs,
                 )
         if unused_finder is not None:
diff --git a/pyanalyze/node_visitor.py b/pyanalyze/node_visitor.py
index 12726e6..1801341 100644
--- a/pyanalyze/node_visitor.py
+++ b/pyanalyze/node_visitor.py
@@ -304,7 +304,7 @@ class BaseNodeVisitor(ast.NodeVisitor):
         if "settings" not in kwargs:
             kwargs["settings"] = cls._get_default_settings()
         kwargs = cls.prepare_constructor_kwargs(kwargs)
-        files = cls.get_files_to_check(include_tests)
+        files = cls.get_files_to_check(include_tests, **kwargs)
         all_failures = cls._run_on_files(files, **kwargs)
         if assert_passes:
             assert not all_failures, "".join(
@@ -313,7 +313,7 @@ class BaseNodeVisitor(ast.NodeVisitor):
         return all_failures
 
     @classmethod
-    def get_files_to_check(cls, include_tests: bool) -> List[str]:
+    def get_files_to_check(cls, include_tests: bool, **kwargs: Any) -> List[str]:
         """Produce the list of files to check."""
         if cls.should_check_environ_for_files:
             environ_files = get_files_to_check_from_environ()
@@ -327,7 +327,9 @@ class BaseNodeVisitor(ast.NodeVisitor):
                 and not filename.endswith(".so")
             ]
         else:
-            return sorted(set(cls._get_all_python_files(include_tests=include_tests)))
+            return sorted(
+                set(cls._get_all_python_files(include_tests=include_tests, **kwargs))
+            )
 
     @classmethod
     def prepare_constructor_kwargs(cls, kwargs: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -525,6 +527,11 @@ class BaseNodeVisitor(ast.NodeVisitor):
         for error in errors:
             self.show_error(**error)
 
+    def is_enabled(self, error_code: Enum) -> bool:
+        if self.settings is not None:
+            return self.settings.get(error_code, True)
+        return True
+
     def show_error(
         self,
         node: Union[ast.AST, _FakeNode, None],
@@ -574,9 +581,8 @@ class BaseNodeVisitor(ast.NodeVisitor):
             return None
 
         # check if error was disabled
-        if self.settings is not None and error_code is not None:
-            if not self.settings.get(error_code, True):
-                return None
+        if error_code is not None and not self.is_enabled(error_code):
+            return None
 
         if self.has_file_level_ignore(error_code, ignore_comment):
             return None
@@ -718,9 +724,9 @@ class BaseNodeVisitor(ast.NodeVisitor):
 
     @classmethod
     def _run_on_files_or_all(
-        cls, files: Optional[Sequence[str]], **kwargs: Any
+        cls, files: Optional[Sequence[str]] = None, **kwargs: Any
     ) -> List[Failure]:
-        files = files or cls.get_default_directories()
+        files = files or cls.get_default_directories(**kwargs)
         if not files:
             return cls.check_all_files(**kwargs)
         else:
@@ -807,9 +813,7 @@ class BaseNodeVisitor(ast.NodeVisitor):
             epilog=epilog,
             formatter_class=argparse.RawDescriptionHelpFormatter,
         )
-        parser.add_argument(
-            "files", nargs="*", default=".", help="Files or directories to check"
-        )
+        parser.add_argument("files", nargs="*", help="Files or directories to check")
         parser.add_argument(
             "-v", "--verbose", help="Print more information.", action="count"
         )
@@ -934,12 +938,15 @@ class BaseNodeVisitor(ast.NodeVisitor):
         return ()
 
     @classmethod
-    def get_default_directories(cls) -> Sequence[str]:
-        return ()
+    def get_default_directories(cls, **kwargs: Any) -> Sequence[str]:
+        return (".",)
 
     @classmethod
     def _get_all_python_files(
-        cls, include_tests: bool = False, modules: Optional[Iterable[ModuleType]] = None
+        cls,
+        include_tests: bool = False,
+        modules: Optional[Iterable[ModuleType]] = None,
+        **kwargs: Any,
     ) -> Iterable[str]:
         """Gets Python files inside of the given modules that should be tested.
 
@@ -951,7 +958,7 @@ class BaseNodeVisitor(ast.NodeVisitor):
 
         """
         if modules is None:
-            dirs = cls.get_default_directories()
+            dirs = cls.get_default_directories(**kwargs)
             if dirs:
                 for filename in _get_all_files(dirs):
                     yield filename
diff --git a/pyanalyze/options.py b/pyanalyze/options.py
new file mode 100644
index 0000000..20b76d0
--- /dev/null
+++ b/pyanalyze/options.py
@@ -0,0 +1,304 @@
+"""
+
+Structured configuration options.
+
+"""
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+from typing import (
+    Any,
+    ClassVar,
+    Dict,
+    Iterable,
+    Mapping,
+    Optional,
+    Sequence,
+    Generic,
+    Tuple,
+    Type,
+    TypeVar,
+)
+import qcore
+import tomli
+
+from .find_unused import used
+from .config import Config
+from .error_code import ErrorCode
+from .safe import safe_in
+
+T = TypeVar("T")
+OptionT = TypeVar("OptionT", bound="ConfigOption")
+ModulePath = Tuple[str, ...]
+
+
+class InvalidConfigOption(Exception):
+    """Raised when an invalid config option is encountered."""
+
+    @classmethod
+    def from_parser(
+        cls, option_cls: Type["ConfigOption"], expected: str, value: object
+    ) -> "InvalidConfigOption":
+        return cls(
+            f"Invalid value for option {option_cls.name}: expected {expected} but got"
+            f" {value!r}"
+        )
+
+
+class NotFound(Exception):
+    """Raised if no value is found for an option."""
+
+
+@dataclass
+class ConfigOption(Generic[T]):
+    registry: ClassVar[Dict[str, Type["ConfigOption"]]] = {}
+
+    name: ClassVar[str]
+    is_global: ClassVar[bool] = False
+    default_value: ClassVar[T]
+    value: T
+    applicable_to: ModulePath = ()
+    from_command_line: bool = False
+
+    def __init_subclass__(cls) -> None:
+        if hasattr(cls, "name"):
+            if cls.name in cls.registry:
+                raise ValueError(f"Duplicate option {cls.name}")
+            cls.registry[cls.name] = cls
+
+    @classmethod
+    def parse(cls: "Type[ConfigOption[T]]", data: object, source_path: Path) -> T:
+        raise NotImplementedError
+
+    @classmethod
+    def get_value_from_instances(
+        cls: "Type[ConfigOption[T]]",
+        instances: Sequence["ConfigOption[T]"],
+        module_path: ModulePath,
+    ) -> T:
+        for instance in instances:
+            if instance.is_applicable_to(module_path):
+                return instance.value
+        raise NotFound
+
+    def is_applicable_to(self, module_path: ModulePath) -> bool:
+        return module_path[: len(self.applicable_to)] == self.applicable_to
+
+    def sort_key(self) -> Tuple[object, ...]:
+        """We sort with the most specific option first."""
+        return (
+            not self.from_command_line,  # command line options first
+            -len(self.applicable_to),  # longest options first
+        )
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> T:
+        raise NotFound
+
+    @classmethod
+    def get_fallback_option(cls: Type[OptionT], fallback: Config) -> Optional[OptionT]:
+        try:
+            val = cls.get_value_from_fallback(fallback)
+        except NotFound:
+            return None
+        else:
+            return cls(val)
+
+
+class BooleanOption(ConfigOption[bool]):
+    default_value = False
+
+    @classmethod
+    def parse(cls: "Type[BooleanOption]", data: object, source_path: Path) -> bool:
+        if isinstance(data, bool):
+            return data
+        raise InvalidConfigOption.from_parser(cls, "bool", data)
+
+
+class IntegerOption(ConfigOption[int]):
+    default_value = False
+
+    @classmethod
+    def parse(cls: "Type[IntegerOption]", data: object, source_path: Path) -> int:
+        if isinstance(data, int):
+            return data
+        raise InvalidConfigOption.from_parser(cls, "int", data)
+
+
+class ConcatenatedOption(ConfigOption[Sequence[T]]):
+    """Option for which the value is the concatenation of all the overrides."""
+
+    @classmethod
+    def get_value_from_instances(
+        cls: "Type[ConcatenatedOption[T]]",
+        instances: Sequence["ConcatenatedOption[T]"],
+        module_path: ModulePath,
+    ) -> Sequence[T]:
+        # TODO after we clean up the fallback logic, this should
+        # automatically incorporate the default value too.
+        values = []
+        for instance in instances:
+            if instance.is_applicable_to(module_path):
+                values += instance.value
+        return values
+
+
+class StringSequenceOption(ConcatenatedOption[str]):
+    @classmethod
+    def parse(
+        cls: "Type[StringSequenceOption]", data: object, source_path: Path
+    ) -> Sequence[str]:
+        if isinstance(data, (list, tuple)) and all(
+            isinstance(elt, str) for elt in data
+        ):
+            return data
+        raise InvalidConfigOption.from_parser(cls, "sequence of strings", data)
+
+
+class PathSequenceOption(ConfigOption[Sequence[Path]]):
+    default_value = ()
+
+    @classmethod
+    def parse(
+        cls: "Type[PathSequenceOption]", data: object, source_path: Path
+    ) -> Sequence[str]:
+        if isinstance(data, (list, tuple)) and all(
+            isinstance(elt, str) for elt in data
+        ):
+            return [(source_path.parent / elt).resolve() for elt in data]
+        raise InvalidConfigOption.from_parser(cls, "sequence of strings", data)
+
+
+class PyObjectSequenceOption(ConfigOption[Sequence[T]]):
+    """Represents a sequence of objects parsed as Python objects."""
+
+    default_value = ()
+
+    @classmethod
+    def parse(
+        cls: "Type[PyObjectSequenceOption[T]]", data: object, source_path: Path
+    ) -> Sequence[T]:
+        if not isinstance(data, (list, tuple)):
+            raise InvalidConfigOption.from_parser(
+                cls, "sequence of Python objects", data
+            )
+        final = []
+        for elt in data:
+            try:
+                obj = qcore.object_from_string(elt)
+            except Exception:
+                raise InvalidConfigOption.from_parser(
+                    cls, "path to Python object", elt
+                ) from None
+            used(obj)
+            final.append(elt)
+        return final
+
+    @classmethod
+    def contains(cls, obj: object, options: "Options") -> bool:
+        val = options.get_value_for(cls)
+        return safe_in(obj, val)
+
+
+@dataclass
+class Options:
+    options: Mapping[str, Sequence[ConfigOption]]
+    fallback: Config
+    module_path: ModulePath = ()
+
+    @classmethod
+    def from_option_list(
+        cls,
+        instances: Sequence[ConfigOption],
+        fallback: Config,
+        config_file_path: Optional[Path] = None,
+    ) -> "Options":
+        if config_file_path:
+            instances = [*instances, *parse_config_file(config_file_path)]
+        by_name = defaultdict(list)
+        for instance in instances:
+            by_name[instance.name].append(instance)
+        options = {
+            name: sorted(instances, key=lambda i: i.sort_key())
+            for name, instances in by_name.items()
+        }
+        return Options(options, fallback)
+
+    def for_module(self, module_path: ModulePath) -> "Options":
+        return Options(self.options, self.fallback, module_path)
+
+    def get_value_for(self, option: Type[ConfigOption[T]]) -> T:
+        try:
+            return self._get_value_for_no_default(option)
+        except NotFound:
+            return option.default_value
+
+    def _get_value_for_no_default(self, option: Type[ConfigOption[T]]) -> T:
+        instances = self.options.get(option.name, ())
+        fallback = option.get_fallback_option(self.fallback)
+        if fallback is not None:
+            instances = [*instances, fallback]
+        return option.get_value_from_instances(instances, self.module_path)
+
+    def is_error_code_enabled(self, code: ErrorCode) -> bool:
+        option = ConfigOption.registry[code.name]
+        try:
+            return self._get_value_for_no_default(option)
+        except NotFound:
+            if code in self.fallback.ENABLED_ERRORS:
+                return True
+            if code in self.fallback.DISABLED_ERRORS:
+                return False
+            return option.default_value
+
+    def is_error_code_enabled_anywhere(self, code: ErrorCode) -> bool:
+        option = ConfigOption.registry[code.name]
+        instances = self.options.get(option.name, ())
+        if any(instance.value for instance in instances):
+            return True
+        if code in self.fallback.ENABLED_ERRORS:
+            return True
+        if code in self.fallback.DISABLED_ERRORS:
+            return False
+        return option.default_value
+
+
+def parse_config_file(path: Path) -> Iterable[ConfigOption]:
+    with path.open("rb") as f:
+        data = tomli.load(f)
+    data = data.get("tool", {}).get("pyanalyze", {})
+    yield from _parse_config_section(data, path=path)
+
+
+def _parse_config_section(
+    section: Mapping[str, Any], module_path: ModulePath = (), *, path: Path
+) -> Iterable[ConfigOption]:
+    if "module" in section:
+        if module_path == ():
+            raise InvalidConfigOption(
+                "Top-level configuration should not set module option"
+            )
+    for key, value in section.items():
+        if key == "module":
+            if module_path == ():
+                raise InvalidConfigOption(
+                    "Top-level configuration should not set module option"
+                )
+        elif key == "overrides":
+            if not isinstance(value, (list, tuple)):
+                raise InvalidConfigOption("overrides section must be a list")
+            for override in value:
+                if not isinstance(override, dict):
+                    raise InvalidConfigOption("override value must be a dict")
+                if "module" not in override or not isinstance(override["module"], str):
+                    raise InvalidConfigOption(
+                        "override section must set 'module' to a string"
+                    )
+                override_path = tuple(override["module"].split("."))
+                yield from _parse_config_section(override, override_path, path=path)
+        else:
+            try:
+                option_cls = ConfigOption.registry[key]
+            except KeyError:
+                raise InvalidConfigOption(f"Invalid configuration option {key!r}")
+            yield option_cls(option_cls.parse(value, path), module_path)
diff --git a/pyanalyze/reexport.py b/pyanalyze/reexport.py
index d663f0a..74aec9c 100644
--- a/pyanalyze/reexport.py
+++ b/pyanalyze/reexport.py
@@ -7,12 +7,30 @@ from ast import AST
 from collections import defaultdict
 from dataclasses import InitVar, dataclass, field
 from enum import Enum
-from typing import Any, Dict, List, Optional, Set, Tuple
+from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
+
+from pyanalyze.options import Options, PyObjectSequenceOption
 
 from .node_visitor import Failure
 from .config import Config
 from .error_code import ErrorCode
 
+_ReexportConfigProvider = Callable[["ImplicitReexportTracker"], None]
+
+
+class ReexportConfig(PyObjectSequenceOption[_ReexportConfigProvider]):
+    """Callbacks that can configure the :class:`ImplicitReexportTracker`,
+    usually by setting names as explicitly exported."""
+
+    name = "reexport_config"
+    is_global = True
+
+    @classmethod
+    def get_value_from_fallback(
+        cls, fallback: Config
+    ) -> Sequence[_ReexportConfigProvider]:
+        return (fallback.configure_reexports,)
+
 
 class ErrorContext:
     all_failures: List[Failure]
@@ -32,7 +50,7 @@ class ErrorContext:
 
 @dataclass
 class ImplicitReexportTracker:
-    config: InitVar[Config]
+    options: InitVar[Options]
     completed_modules: Set[str] = field(default_factory=set)
     module_to_reexports: Dict[str, Set[str]] = field(
         default_factory=lambda: defaultdict(set)
@@ -41,8 +59,9 @@ class ImplicitReexportTracker:
         default_factory=lambda: defaultdict(list)
     )
 
-    def __post_init__(self, config: Config) -> None:
-        config.configure_reexports(self)
+    def __post_init__(self, options: Options) -> None:
+        for func in options.get_value_for(ReexportConfig):
+            func(self)
 
     def record_exported_attribute(self, module: str, attr: str) -> None:
         self.module_to_reexports[module].add(attr)
diff --git a/pyanalyze/shared_options.py b/pyanalyze/shared_options.py
new file mode 100644
index 0000000..519aaef
--- /dev/null
+++ b/pyanalyze/shared_options.py
@@ -0,0 +1,66 @@
+"""
+
+Defines some concrete options that cannot easily be placed elsewhere.
+
+"""
+from pathlib import Path
+from typing import Sequence
+
+from pyanalyze.value import VariableNameValue
+
+from .config import Config
+from .error_code import ErrorCode, DISABLED_BY_DEFAULT, ERROR_DESCRIPTION
+from .options import PathSequenceOption, BooleanOption, PyObjectSequenceOption
+
+
+class Paths(PathSequenceOption):
+    """Paths that pyanalyze should type check."""
+
+    name = "paths"
+    is_global = True
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[Path]:
+        return [Path(s) for s in fallback.DEFAULT_DIRS]
+
+
+class ImportPaths(PathSequenceOption):
+    """Directories that pyanalyze may import from."""
+
+    name = "import_paths"
+    is_global = True
+
+
+class EnforceNoUnused(BooleanOption):
+    """If True, an error is raised when pyanalyze finds any unused objects."""
+
+    name = "enforce_no_unused"
+    is_global = True
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> bool:
+        return fallback.ENFORCE_NO_UNUSED_OBJECTS
+
+
+class VariableNameValues(PyObjectSequenceOption[VariableNameValue]):
+    """List of :class:`pyanalyze.value.VariableNameValue` instances that create pseudo-types
+    associated with certain variable names."""
+
+    name = "variable_name_values"
+    is_global = True
+
+    @classmethod
+    def get_value_from_fallback(cls, fallback: Config) -> Sequence[VariableNameValue]:
+        return fallback.VARIABLE_NAME_VALUES
+
+
+for _code in ErrorCode:
+    type(
+        _code.name,
+        (BooleanOption,),
+        {
+            "__doc__": ERROR_DESCRIPTION[_code],
+            "name": _code.name,
+            "default_value": _code not in DISABLED_BY_DEFAULT,
+        },
+    )
diff --git a/pyproject.toml b/pyproject.toml
index 55db7a8..0a0859f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,3 +25,17 @@ requires = [
     "wheel"
 ]
 build-backend = "setuptools.build_meta"
+
+[tool.pyanalyze]
+paths = ["pyanalyze"]
+import_paths = ["."]
+enforce_no_unused = true
+
+possibly_undefined_name = true
+use_fstrings = true
+missing_return_annotation = true
+missing_parameter_annotation = true
+unused_variable = true
+value_always_true = true
+suggested_parameter_type = true
+suggested_return_type = true
diff --git a/setup.py b/setup.py
index fb59311..f98d25d 100644
--- a/setup.py
+++ b/setup.py
@@ -58,6 +58,7 @@ if __name__ == "__main__":
             "mypy_extensions",
             "aenum>=2.2.3",
             "codemod",
+            "tomli>=1.1.0",
         ],
         **setup_kwargs,
     )


Test patch:
diff --git a/pyanalyze/test_arg_spec.py b/pyanalyze/test_arg_spec.py
index 34c2155..21a6d05 100644
--- a/pyanalyze/test_arg_spec.py
+++ b/pyanalyze/test_arg_spec.py
@@ -113,10 +113,12 @@ def test_get_argspec():
                 callable=ClassWithCall.normal_classmethod.__func__,
             ),
             Composite(KnownValue(ClassWithCall)),
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.normal_classmethod)
+        ) == ArgSpecCache(checker.options).get_argspec(ClassWithCall.normal_classmethod)
         assert Signature.make(
             [SigParameter("arg")], callable=ClassWithCall.normal_staticmethod
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.normal_staticmethod)
+        ) == ArgSpecCache(checker.options).get_argspec(
+            ClassWithCall.normal_staticmethod
+        )
 
         assert Signature.make(
             [
@@ -126,19 +128,19 @@ def test_get_argspec():
                 SigParameter("proechimys", ParameterKind.VAR_KEYWORD),
             ],
             callable=function,
-        ) == ArgSpecCache(config).get_argspec(function)
+        ) == ArgSpecCache(checker.options).get_argspec(function)
 
         assert Signature.make(
             [SigParameter("x"), SigParameter("y")],
             callable=async_function.fn,
             is_asynq=True,
-        ) == ArgSpecCache(config).get_argspec(async_function)
+        ) == ArgSpecCache(checker.options).get_argspec(async_function)
 
         assert Signature.make(
             [SigParameter("x"), SigParameter("y")],
             callable=async_function.fn,
             is_asynq=True,
-        ) == ArgSpecCache(config).get_argspec(async_function.asynq)
+        ) == ArgSpecCache(checker.options).get_argspec(async_function.asynq)
 
         instance = ClassWithCall(1)
 
@@ -149,7 +151,7 @@ def test_get_argspec():
                 is_asynq=True,
             ),
             Composite(KnownValue(instance)),
-        ) == ArgSpecCache(config).get_argspec(instance.async_method)
+        ) == ArgSpecCache(checker.options).get_argspec(instance.async_method)
 
         assert BoundMethodSignature(
             Signature.make(
@@ -158,19 +160,21 @@ def test_get_argspec():
                 is_asynq=True,
             ),
             Composite(KnownValue(instance)),
-        ) == ArgSpecCache(config).get_argspec(instance.async_method.asynq)
+        ) == ArgSpecCache(checker.options).get_argspec(instance.async_method.asynq)
 
         assert Signature.make(
             [SigParameter("y")],
             callable=ClassWithCall.async_staticmethod.fn,
             is_asynq=True,
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.async_staticmethod)
+        ) == ArgSpecCache(checker.options).get_argspec(ClassWithCall.async_staticmethod)
 
         assert Signature.make(
             [SigParameter("y")],
             callable=ClassWithCall.async_staticmethod.fn,
             is_asynq=True,
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.async_staticmethod.asynq)
+        ) == ArgSpecCache(checker.options).get_argspec(
+            ClassWithCall.async_staticmethod.asynq
+        )
 
         assert BoundMethodSignature(
             Signature.make(
@@ -179,7 +183,7 @@ def test_get_argspec():
                 is_asynq=True,
             ),
             Composite(KnownValue(ClassWithCall)),
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.async_classmethod)
+        ) == ArgSpecCache(checker.options).get_argspec(ClassWithCall.async_classmethod)
 
         assert BoundMethodSignature(
             Signature.make(
@@ -188,7 +192,9 @@ def test_get_argspec():
                 is_asynq=True,
             ),
             Composite(KnownValue(ClassWithCall)),
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.async_classmethod.asynq)
+        ) == ArgSpecCache(checker.options).get_argspec(
+            ClassWithCall.async_classmethod.asynq
+        )
 
         assert BoundMethodSignature(
             Signature.make(
@@ -196,7 +202,9 @@ def test_get_argspec():
                 callable=ClassWithCall.pure_async_classmethod.decorator.fn,
             ),
             Composite(KnownValue(ClassWithCall)),
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.pure_async_classmethod)
+        ) == ArgSpecCache(checker.options).get_argspec(
+            ClassWithCall.pure_async_classmethod
+        )
 
         # This behaves differently in 3.9 (decorator) than in 3.6-3.8 (__func__). Not
         # sure why.
@@ -212,7 +220,9 @@ def test_get_argspec():
                 is_asynq=True,
             ),
             Composite(KnownValue(ClassWithCall)),
-        ) == ArgSpecCache(config).get_argspec(ClassWithCall.classmethod_before_async)
+        ) == ArgSpecCache(checker.options).get_argspec(
+            ClassWithCall.classmethod_before_async
+        )
 
         assert Signature.make(
             [
@@ -221,7 +231,7 @@ def test_get_argspec():
             ],
             KnownValue(None),
             callable=wrapped,
-        ) == ArgSpecCache(config).get_argspec(wrapped)
+        ) == ArgSpecCache(checker.options).get_argspec(wrapped)
         decorated = decorator(wrapped)
         assert Signature.make(
             [
@@ -237,7 +247,7 @@ def test_get_argspec():
                 ),
             ],
             callable=decorated,
-        ) == ArgSpecCache(config).get_argspec(decorated)
+        ) == ArgSpecCache(checker.options).get_argspec(decorated)
         assert Signature.make(
             [
                 SigParameter(
@@ -246,7 +256,7 @@ def test_get_argspec():
             ],
             NewTypeValue(NT),
             callable=NT,
-        ) == ArgSpecCache(config).get_argspec(NT)
+        ) == ArgSpecCache(checker.options).get_argspec(NT)
 
 
 def test_positional_only():
@@ -261,7 +271,7 @@ def test_positional_only():
             def f(self, __x, _Y__x):
                 pass
 
-    asc = ArgSpecCache(ConfiguredNameCheckVisitor.config)
+    asc = Checker(ConfiguredNameCheckVisitor.config).arg_spec_cache
     assert asc.get_argspec(f) == Signature.make(
         [
             SigParameter("__x", ParameterKind.POSITIONAL_ONLY),
diff --git a/pyanalyze/test_config.py b/pyanalyze/test_config.py
index 71b85f3..277f52b 100644
--- a/pyanalyze/test_config.py
+++ b/pyanalyze/test_config.py
@@ -35,10 +35,6 @@ class TestConfig(Config):
             "should_return_list": list,
         }
     }
-    PROPERTIES_OF_KNOWN_TYPE = {
-        tests.PropertyObject.string_property: value.TypedValue(str)
-    }
-    NAMES_OF_KNOWN_TYPE = {"proper_capybara": tests.PropertyObject}
 
     VARIABLE_NAME_VALUES = [
         value.VariableNameValue(["uid"]),
diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py
index 0b69484..c682b46 100644
--- a/pyanalyze/test_name_check_visitor.py
+++ b/pyanalyze/test_name_check_visitor.py
@@ -83,10 +83,13 @@ class TestNameCheckVisitorBase(test_node_visitor.BaseNodeVisitorTester):
             default_settings.update(settings)
         verbosity = int(os.environ.get("ANS_TEST_SCOPE_VERBOSITY", 0))
         mod = _make_module(code_str)
+        kwargs["settings"] = default_settings
         kwargs = self.visitor_cls.prepare_constructor_kwargs(kwargs)
         new_code = ""
         with ClassAttributeChecker(
-            self.visitor_cls.config, enabled=check_attributes
+            self.visitor_cls.config,
+            enabled=check_attributes,
+            options=kwargs["checker"].options,
         ) as attribute_checker:
             visitor = self.visitor_cls(
                 mod.__name__,
@@ -94,7 +97,6 @@ class TestNameCheckVisitorBase(test_node_visitor.BaseNodeVisitorTester):
                 tree,
                 module=mod,
                 attribute_checker=attribute_checker,
-                settings=default_settings,
                 verbosity=verbosity,
                 **kwargs,
             )
@@ -1181,37 +1183,6 @@ assert_eq(1, 1)
             assert_is_value(assert_eq, KnownValue(_assert_eq))
 
 
-class TestNameOfKnownValue(TestNameCheckVisitorBase):
-    @assert_passes()
-    def test_varname(self):
-        from pyanalyze.tests import PropertyObject
-
-        def capybara(proper_capybara):
-            assert_is_value(proper_capybara, TypedValue(PropertyObject))
-
-        def hutia(arg):
-            proper_capybara = 1
-            assert_is_value(proper_capybara, KnownValue(1))
-
-    @assert_passes()
-    def test_attribute(self):
-        from pyanalyze.tests import PropertyObject
-
-        class Capybara(object):
-            def __init__(self, foo, bar):
-                self.proper_capybara = foo
-
-            def eat(self):
-                assert_is_value(self.proper_capybara, TypedValue(PropertyObject))
-
-        class Hutia(object):
-            def __init__(self, arg):
-                self.proper_capybara = int(arg)
-
-            def eat(self):
-                assert_is_value(self.proper_capybara, TypedValue(int))
-
-
 class TestComprehensions(TestNameCheckVisitorBase):
     @assert_fails(ErrorCode.undefined_name)
     def test_scoping_in_list_py3(self):
diff --git a/pyanalyze/test_self.py b/pyanalyze/test_self.py
index 1f725be..be38c55 100644
--- a/pyanalyze/test_self.py
+++ b/pyanalyze/test_self.py
@@ -3,30 +3,12 @@
 Runs pyanalyze on itself.
 
 """
-import os.path
 import pyanalyze
-from pyanalyze.error_code import ErrorCode
-
-
-class PyanalyzeConfig(pyanalyze.config.Config):
-    DEFAULT_DIRS = (str(os.path.dirname(__file__)),)
-    DEFAULT_BASE_MODULE = pyanalyze
-    ENFORCE_NO_UNUSED_OBJECTS = True
-    ENABLED_ERRORS = {
-        ErrorCode.possibly_undefined_name,
-        ErrorCode.use_fstrings,
-        ErrorCode.missing_return_annotation,
-        ErrorCode.missing_parameter_annotation,
-        ErrorCode.unused_variable,
-        ErrorCode.value_always_true,
-        ErrorCode.suggested_parameter_type,
-        ErrorCode.suggested_return_type,
-    }
 
 
 class PyanalyzeVisitor(pyanalyze.name_check_visitor.NameCheckVisitor):
-    config = PyanalyzeConfig()
     should_check_environ_for_files = False
+    config_filename = "../pyproject.toml"
 
 
 def test_all() -> None:
diff --git a/pyanalyze/test_typeshed.py b/pyanalyze/test_typeshed.py
index f8d4aff..adec73b 100644
--- a/pyanalyze/test_typeshed.py
+++ b/pyanalyze/test_typeshed.py
@@ -17,6 +17,7 @@ import urllib.parse
 from .test_config import TestConfig
 from .test_name_check_visitor import TestNameCheckVisitorBase
 from .test_node_visitor import assert_passes
+from .options import Options
 from .signature import SigParameter, Signature
 from .arg_spec import ArgSpecCache
 from .test_arg_spec import ClassWithCall
@@ -162,7 +163,7 @@ class GenericChild(Parent[T]):
 
 class TestGetGenericBases:
     def setup(self) -> None:
-        arg_spec_cache = ArgSpecCache(TestConfig())
+        arg_spec_cache = ArgSpecCache(Options.from_option_list([], TestConfig()))
         self.get_generic_bases = arg_spec_cache.get_generic_bases
 
     def test_runtime(self):
diff --git a/pyanalyze/tests.py b/pyanalyze/tests.py
index 12351ac..34a3433 100644
--- a/pyanalyze/tests.py
+++ b/pyanalyze/tests.py
@@ -103,7 +103,7 @@ class PropertyObject(object):
         pass
 
     @property
-    def string_property(self):
+    def string_property(self) -> str:
         return str(self.poid)
 
     @property