From 7d8426dbcc2fda927064ac81d5b99f332539e64d Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Mon, 27 Dec 2021 06:26:12 +0100 Subject: [PATCH 1/3] create command-line options for each config option --- docs/configuration.md | 11 +++ pyanalyze/name_check_visitor.py | 26 ++++++- pyanalyze/options.py | 116 ++++++++++++++++++++++++++++++++ pyanalyze/shared_options.py | 2 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d7497b40..b6b35e2c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,4 +29,15 @@ 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. +To see the current value of all configuration options, pass the `--display-options` command-line option: + +``` +$ python -m pyanalyze --config-file pyproject.toml --display-options +Options: + add_import (value: True) + ... +``` + +Most configuration options can also be set on the command line. + \ No newline at end of file diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py index b9adb7e4..03f8175c 100644 --- a/pyanalyze/name_check_visitor.py +++ b/pyanalyze/name_check_visitor.py @@ -81,6 +81,7 @@ Options, PyObjectSequenceOption, StringSequenceOption, + add_arguments, ) from .shared_options import Paths, ImportPaths, EnforceNoUnused from .reexport import ErrorContext, ImplicitReexportTracker @@ -429,6 +430,9 @@ class IgnoredPaths(ConcatenatedOption[Sequence[str]]): name = "ignored_paths" default_value = () + # too complicated and this option isn't too useful anyway + should_create_command_line_option = False + @classmethod def get_value_from_fallback(cls, fallback: Config) -> Sequence[Sequence[str]]: return fallback.IGNORED_PATHS @@ -4625,6 +4629,13 @@ def _get_argument_parser(cls) -> ArgumentParser: type=Path, help="Path to a pyproject.toml configuration file", ) + parser.add_argument( + "--display-options", + action="store_true", + default=False, + help="Display the options used for this check, then exit", + ) + add_arguments(parser) return parser @classmethod @@ -4665,8 +4676,16 @@ def prepare_constructor_kwargs(cls, kwargs: Mapping[str, Any]) -> Mapping[str, A 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)) + files = kwargs.pop("files", []) + if files: + instances.append(Paths(files, from_command_line=True)) + for name, option_cls in ConfigOption.registry.items(): + if not option_cls.should_create_command_line_option: + continue + if name not in kwargs: + continue + value = kwargs.pop(name) + instances.append(option_cls(value, from_command_line=True)) config_file = kwargs.pop("config_file", None) if config_file is None: config_filename = cls.config_filename @@ -4674,6 +4693,9 @@ def prepare_constructor_kwargs(cls, kwargs: Mapping[str, Any]) -> Mapping[str, A 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) + if kwargs.pop("display_options", False): + options.display() + sys.exit(0) kwargs.setdefault("checker", Checker(cls.config, options)) return kwargs diff --git a/pyanalyze/options.py b/pyanalyze/options.py index 20b76d08..75a0ce7e 100644 --- a/pyanalyze/options.py +++ b/pyanalyze/options.py @@ -3,9 +3,11 @@ Structured configuration options. """ +import argparse from collections import defaultdict from dataclasses import dataclass from pathlib import Path +import pathlib from typing import ( Any, ClassVar, @@ -27,6 +29,37 @@ from .error_code import ErrorCode from .safe import safe_in +try: + from argparse import BooleanOptionalAction +except ImportError: + # 3.8 and lower (modified from CPython) + class BooleanOptionalAction(argparse.Action): + def __init__(self, option_strings: Sequence[str], **kwargs: Any) -> None: + + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith("--"): + option_string = "--no-" + option_string[2:] + _option_strings.append(option_string) + + super().__init__(option_strings=_option_strings, nargs=0, **kwargs) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: object, + option_string: Optional[str] = None, + ) -> None: + if option_string is not None and option_string in self.option_strings: + setattr(namespace, self.dest, not option_string.startswith("--no-")) + + def format_usage(self) -> str: + return " | ".join(self.option_strings) + + T = TypeVar("T") OptionT = TypeVar("OptionT", bound="ConfigOption") ModulePath = Tuple[str, ...] @@ -56,6 +89,7 @@ class ConfigOption(Generic[T]): name: ClassVar[str] is_global: ClassVar[bool] = False default_value: ClassVar[T] + should_create_command_line_option: ClassVar[bool] = True value: T applicable_to: ModulePath = () from_command_line: bool = False @@ -104,6 +138,10 @@ def get_fallback_option(cls: Type[OptionT], fallback: Config) -> Optional[Option else: return cls(val) + @classmethod + def create_command_line_option(cls, parser: argparse.ArgumentParser) -> None: + raise NotImplementedError(cls) + class BooleanOption(ConfigOption[bool]): default_value = False @@ -114,6 +152,15 @@ def parse(cls: "Type[BooleanOption]", data: object, source_path: Path) -> bool: return data raise InvalidConfigOption.from_parser(cls, "bool", data) + @classmethod + def create_command_line_option(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + f"--{cls.name.replace('_', '-')}", + action=BooleanOptionalAction, + help=cls.__doc__, + default=argparse.SUPPRESS, + ) + class IntegerOption(ConfigOption[int]): default_value = False @@ -124,6 +171,15 @@ def parse(cls: "Type[IntegerOption]", data: object, source_path: Path) -> int: return data raise InvalidConfigOption.from_parser(cls, "int", data) + @classmethod + def create_command_line_option(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + f"--{cls.name.replace('_', '-')}", + type=int, + help=cls.__doc__, + default=argparse.SUPPRESS, + ) + class ConcatenatedOption(ConfigOption[Sequence[T]]): """Option for which the value is the concatenation of all the overrides.""" @@ -154,6 +210,15 @@ def parse( return data raise InvalidConfigOption.from_parser(cls, "sequence of strings", data) + @classmethod + def create_command_line_option(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + f"--{cls.name.replace('_', '-')}", + action="append", + help=cls.__doc__, + default=argparse.SUPPRESS, + ) + class PathSequenceOption(ConfigOption[Sequence[Path]]): default_value = () @@ -168,6 +233,16 @@ def parse( return [(source_path.parent / elt).resolve() for elt in data] raise InvalidConfigOption.from_parser(cls, "sequence of strings", data) + @classmethod + def create_command_line_option(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + f"--{cls.name.replace('_', '-')}", + action="append", + type=pathlib.Path, + help=cls.__doc__, + default=argparse.SUPPRESS, + ) + class PyObjectSequenceOption(ConfigOption[Sequence[T]]): """Represents a sequence of objects parsed as Python objects.""" @@ -199,6 +274,16 @@ def contains(cls, obj: object, options: "Options") -> bool: val = options.get_value_for(cls) return safe_in(obj, val) + @classmethod + def create_command_line_option(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + f"--{cls.name.replace('_', '-')}", + action="append", + type=qcore.object_from_string, + help=cls.__doc__, + default=argparse.SUPPRESS, + ) + @dataclass class Options: @@ -262,6 +347,37 @@ def is_error_code_enabled_anywhere(self, code: ErrorCode) -> bool: return False return option.default_value + def display(self) -> None: + print("Options:") + prefix = " " * 8 + for name, option_cls in sorted(ConfigOption.registry.items()): + current_value = self.get_value_for(option_cls) + print(f" {name} (value: {current_value})") + instances = self.options.get(name, []) + for instance in instances: + pieces = [] + if instance.applicable_to: + pieces.append(f"module: {'.'.join(instance.applicable_to)}") + if instance.from_command_line: + pieces.append("from command line") + else: + pieces.append("from config file") + if pieces: + suffix = f" ({', '.join(pieces)})" + else: + suffix = "" + print(f"{prefix}{instance.value}{suffix}") + print(f"Fallback: {self.fallback}") + if self.module_path: + print(f"For module: {'.'.join(self.module_path)}") + + +def add_arguments(parser: argparse.ArgumentParser) -> None: + for cls in ConfigOption.registry.values(): + if not cls.should_create_command_line_option: + continue + cls.create_command_line_option(parser) + def parse_config_file(path: Path) -> Iterable[ConfigOption]: with path.open("rb") as f: diff --git a/pyanalyze/shared_options.py b/pyanalyze/shared_options.py index 519aaef2..5ae68f5a 100644 --- a/pyanalyze/shared_options.py +++ b/pyanalyze/shared_options.py @@ -18,6 +18,7 @@ class Paths(PathSequenceOption): name = "paths" is_global = True + should_create_command_line_option = False @classmethod def get_value_from_fallback(cls, fallback: Config) -> Sequence[Path]: @@ -62,5 +63,6 @@ def get_value_from_fallback(cls, fallback: Config) -> Sequence[VariableNameValue "__doc__": ERROR_DESCRIPTION[_code], "name": _code.name, "default_value": _code not in DISABLED_BY_DEFAULT, + "should_create_command_line_option": False, }, ) From da2473d2f13e774d50054f12289f94e9f0f7655f Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Dec 2021 12:20:47 +0100 Subject: [PATCH 2/3] changelog --- docs/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.md b/docs/changelog.md index 73889e75..c12be5d5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,7 @@ ## Unreleased +- Create command-line options for each config option (#373) - Overhaul treatment of function definitions (#372) - Support positional-only arguments - Infer more precise types for lambda functions From e72382a5fe3101b188c7376a72dec367533ab519 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 28 Dec 2021 12:29:26 +0100 Subject: [PATCH 3/3] fix self check, improve some __str__ methods --- pyanalyze/options.py | 5 +---- pyanalyze/stacked_scopes.py | 9 +++++++++ pyanalyze/value.py | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/pyanalyze/options.py b/pyanalyze/options.py index 9dfb61b0..62860a35 100644 --- a/pyanalyze/options.py +++ b/pyanalyze/options.py @@ -362,10 +362,7 @@ def display(self) -> None: pieces.append("from command line") else: pieces.append("from config file") - if pieces: - suffix = f" ({', '.join(pieces)})" - else: - suffix = "" + suffix = f" ({', '.join(pieces)})" print(f"{prefix}{instance.value}{suffix}") print(f"Fallback: {self.fallback}") if self.module_path: diff --git a/pyanalyze/stacked_scopes.py b/pyanalyze/stacked_scopes.py index 54d7041d..c01b2c06 100644 --- a/pyanalyze/stacked_scopes.py +++ b/pyanalyze/stacked_scopes.py @@ -153,6 +153,15 @@ def get_varname(self) -> Varname: ) return self.varname + def __str__(self) -> str: + pieces = [self.varname] + for index, _ in self.indices: + if isinstance(index, str): + pieces.append(f".{index}") + else: + pieces.append(f"[{index.val!r}]") + return "".join(pieces) + SubScope = Dict[Varname, List[Node]] diff --git a/pyanalyze/value.py b/pyanalyze/value.py index e648e2ba..6604192b 100644 --- a/pyanalyze/value.py +++ b/pyanalyze/value.py @@ -1699,6 +1699,9 @@ class ConstraintExtension(Extension): def __hash__(self) -> int: return id(self) + def __str__(self) -> str: + return str(self.constraint) + @dataclass(frozen=True, eq=False) class NoReturnConstraintExtension(Extension):