diff --git a/docs/guide/commands.rst b/docs/guide/commands.rst index 026f8247..1d3d3830 100644 --- a/docs/guide/commands.rst +++ b/docs/guide/commands.rst @@ -43,6 +43,16 @@ The following options are available for all commands: two tags. +.. attribute:: --output-format / -o FORMAT_TYPE + + Override how Fixit prints violations to the terminal. + + See :attr:`output-format` for available formats. + +.. attribute:: --output-template TEMPLATE + + Override the python formatting template to use with ``output-format = 'custom'``. + ``lint`` ^^^^^^^^ @@ -72,7 +82,7 @@ the input read from STDIN, and the fixed output printed to STDOUT (ignoring $ fixit fix [--interactive | --automatic [--diff]] [PATH ...] .. attribute:: --interactive / -i - + Interactively prompt the user to apply or decline suggested fixes for each auto-fix available. *default* diff --git a/docs/guide/configuration.rst b/docs/guide/configuration.rst index e2aecfeb..16327e76 100644 --- a/docs/guide/configuration.rst +++ b/docs/guide/configuration.rst @@ -99,7 +99,7 @@ The main configuration table. .. code-block:: toml - root = True + root = true enable-root-import = "src" enable = ["orange.rules"] @@ -129,7 +129,7 @@ The main configuration table. .. code-block:: toml python-version = "3.10" - + Defaults to the currently active version of Python. Set to empty string ``""`` to disable target version checking. @@ -151,6 +151,51 @@ The main configuration table. Alternative formatting styles can be added by implementing the :class:`~fixit.Formatter` interface. +.. attribute:: output-format + :type: str + + Choose one of the presets for terminal output formatting. + This option is inferred based on the current working directory or from + an explicity specified config file -- subpath overrides will be ignored. + + Can be one of: + + - ``custom``: Specify your own format using the :attr:`output-template` + option below. + - ``fixit``: Fixit's default output format. + - ``vscode``: A format that provides clickable paths for Visual Studio Code. + + .. note:: + + The default output format is planned to change to ``vscode`` in + the next feature release, expected as part of ``v2.3`` or ``v3.0``. + If you are sensitive to output formats changing, specify your preferred + format in your project configs accordingly. + +.. attribute:: output-template + :type: str + + Sets the format of output printed to terminal. + Python formatting is used in the background to fill in data. + Only active with :attr:`output-format` set to ``custom``. + + This option is inferred based on the current working directory or from + an explicity specified config file -- subpath overrides will be ignored. + + Supported variables: + + - ``message``: Message emitted by the applied rule. + + - ``path``: Path to affected file. + + - ``result``: Raw :class:`~fixit.Result` object. + + - ``rule_name``: Name of the applied rule. + + - ``start_col``: Start column of affected code. + + - ``start_line``: Start line of affected code. + .. _rule-options: diff --git a/docs/guide/integrations.rst b/docs/guide/integrations.rst index e2c99dc3..4f64d3dc 100644 --- a/docs/guide/integrations.rst +++ b/docs/guide/integrations.rst @@ -75,3 +75,9 @@ your repository. To read more about how you can customize your pre-commit configuration, see the `pre-commit docs `__. + + +VSCode +^^^^^^ +For better integration with Visual Studio Code setting ``output-format`` can be set to ``vscode``. +That way VSCode opens the editor at the right position when clicking on code locations in Fixit's terminal output. diff --git a/src/fixit/api.py b/src/fixit/api.py index be1f588b..d9fb7420 100644 --- a/src/fixit/api.py +++ b/src/fixit/api.py @@ -17,13 +17,26 @@ from .config import collect_rules, generate_config from .engine import LintRunner from .format import format_module -from .ftypes import Config, FileContent, LintViolation, Options, Result, STDIN +from .ftypes import ( + Config, + FileContent, + LintViolation, + Options, + OutputFormat, + Result, + STDIN, +) LOG = logging.getLogger(__name__) def print_result( - result: Result, *, show_diff: bool = False, stderr: bool = False + result: Result, + *, + show_diff: bool = False, + stderr: bool = False, + output_format: OutputFormat = OutputFormat.fixit, + output_template: str = "", ) -> int: """ Print linting results in a simple format designed for human eyes. @@ -46,11 +59,24 @@ def print_result( message = result.violation.message if result.violation.autofixable: message += " (has autofix)" - click.secho( - f"{path}@{start_line}:{start_col} {rule_name}: {message}", - fg="yellow", - err=stderr, - ) + + if output_format == OutputFormat.fixit: + line = f"{path}@{start_line}:{start_col} {rule_name}: {message}" + elif output_format == OutputFormat.vscode: + line = f"{path}:{start_line}:{start_col} {rule_name}: {message}" + elif output_format == OutputFormat.custom: + line = output_template.format( + message=message, + path=path, + result=result, + rule_name=rule_name, + start_col=start_col, + start_line=start_line, + ) + else: + raise NotImplementedError(f"output-format = {output_format!r}") + click.secho(line, fg="yellow", err=stderr) + if show_diff and result.violation.diff: echo_color_precomputed_diff(result.violation.diff) return True diff --git a/src/fixit/cli.py b/src/fixit/cli.py index b9c9b341..f36cb0f9 100644 --- a/src/fixit/cli.py +++ b/src/fixit/cli.py @@ -15,7 +15,7 @@ from .api import fixit_paths, print_result from .config import collect_rules, generate_config, parse_rule -from .ftypes import Config, LSPOptions, Options, QualifiedRule, Tags +from .ftypes import Config, LSPOptions, Options, OutputFormat, QualifiedRule, Tags from .rule import LintRule from .testing import generate_lint_rule_test_cases from .util import capture @@ -72,12 +72,28 @@ def f(v: int) -> str: default="", help="Override configured rules", ) +@click.option( + "--output-format", + "-o", + type=click.Choice([o.name for o in OutputFormat], case_sensitive=False), + show_choices=True, + default=None, + help="Select output format type", +) +@click.option( + "--output-template", + type=str, + default="", + help="Python format template to use with output format 'custom'", +) def main( ctx: click.Context, debug: Optional[bool], config_file: Optional[Path], tags: str, rules: str, + output_format: Optional[OutputFormat], + output_template: str, ) -> None: level = logging.WARNING if debug is not None: @@ -95,6 +111,8 @@ def main( if r } ), + output_format=output_format, + output_template=output_template, ) @@ -121,10 +139,15 @@ def lint( visited: Set[Path] = set() dirty: Set[Path] = set() autofixes = 0 + config = generate_config(options=options) for result in fixit_paths(paths, options=options): visited.add(result.path) - - if print_result(result, show_diff=diff): + if print_result( + result, + show_diff=diff, + output_format=config.output_format, + output_template=config.output_template, + ): dirty.add(result.path) if result.violation: exit_code |= 1 @@ -179,11 +202,18 @@ def fix( generator = capture( fixit_paths(paths, autofix=autofix, options=options, parallel=False) ) + config = generate_config(options=options) for result in generator: visited.add(result.path) # for STDIN, we need STDOUT to equal the fixed content, so # move everything else to STDERR - if print_result(result, show_diff=interactive or diff, stderr=is_stdin): + if print_result( + result, + show_diff=interactive or diff, + stderr=is_stdin, + output_format=config.output_format, + output_template=config.output_template, + ): dirty.add(result.path) if autofix and result.violation and result.violation.autofixable: autofixes += 1 diff --git a/src/fixit/config.py b/src/fixit/config.py index 29c64012..b2a31944 100644 --- a/src/fixit/config.py +++ b/src/fixit/config.py @@ -10,6 +10,7 @@ import platform import sys from contextlib import contextmanager, ExitStack + from pathlib import Path from types import ModuleType from typing import ( @@ -37,6 +38,7 @@ is_collection, is_sequence, Options, + OutputFormat, QualifiedRule, QualifiedRuleRegex, RawConfig, @@ -55,6 +57,7 @@ FIXIT_CONFIG_FILENAMES = ("fixit.toml", ".fixit.toml", "pyproject.toml") FIXIT_LOCAL_MODULE = "fixit.local" + log = logging.getLogger(__name__) @@ -402,6 +405,8 @@ def merge_configs( rule_options: RuleOptionsTable = {} target_python_version: Optional[Version] = Version(platform.python_version()) target_formatter: Optional[str] = None + output_format: OutputFormat = OutputFormat.fixit + output_template: str = "" def process_subpath( subpath: Path, @@ -483,6 +488,17 @@ def process_subpath( else: enable_root_import = True + if value := data.pop("output-format", ""): + try: + output_format = OutputFormat(value) + except ValueError as e: + raise ConfigError( + "output-format: unknown value {value!r}", config=config + ) from e + + if value := data.pop("output-template", ""): + output_template = value + process_subpath( config.path.parent, enable=get_sequence(config, "enable"), @@ -524,16 +540,21 @@ def process_subpath( options=rule_options, python_version=target_python_version, formatter=target_formatter, + output_format=output_format, + output_template=output_template, ) def generate_config( - path: Path, root: Optional[Path] = None, *, options: Optional[Options] = None + path: Optional[Path] = None, + root: Optional[Path] = None, + *, + options: Optional[Options] = None, ) -> Config: """ Given a file path, walk upwards looking for and applying cascading configs """ - path = path.resolve() + path = (path or Path.cwd()).resolve() if root is not None: root = root.resolve() @@ -554,4 +575,10 @@ def generate_config( config.enable = list(options.rules) config.disable = [] + if options.output_format: + config.output_format = options.output_format + + if options.output_template: + config.output_template = options.output_template + return config diff --git a/src/fixit/ftypes.py b/src/fixit/ftypes.py index e7989338..9e02aef1 100644 --- a/src/fixit/ftypes.py +++ b/src/fixit/ftypes.py @@ -6,6 +6,7 @@ import platform import re from dataclasses import dataclass, field +from enum import Enum from pathlib import Path from typing import ( Any, @@ -52,6 +53,13 @@ VisitHook = Callable[[str], ContextManager[None]] +class OutputFormat(str, Enum): + custom = "custom" + fixit = "fixit" + # json = "json" # TODO + vscode = "vscode" + + @dataclass(frozen=True) class Invalid: code: str @@ -177,10 +185,12 @@ class Options: Command-line options to affect runtime behavior """ - debug: Optional[bool] - config_file: Optional[Path] + debug: Optional[bool] = None + config_file: Optional[Path] = None tags: Optional[Tags] = None rules: Sequence[QualifiedRule] = () + output_format: Optional[OutputFormat] = None + output_template: str = "" @dataclass @@ -223,6 +233,10 @@ class Config: # post-run processing formatter: Optional[str] = None + # output formatting options + output_format: OutputFormat = OutputFormat.fixit + output_template: str = "" + def __post_init__(self) -> None: self.path = self.path.resolve() self.root = self.root.resolve() diff --git a/src/fixit/tests/config.py b/src/fixit/tests/config.py index 35fcdeaa..77defd9e 100644 --- a/src/fixit/tests/config.py +++ b/src/fixit/tests/config.py @@ -10,9 +10,21 @@ from typing import List, Sequence, Tuple, Type from unittest import TestCase +from click.testing import CliRunner + from .. import config -from ..ftypes import Config, QualifiedRule, RawConfig, Tags, Version +from ..cli import main +from ..ftypes import ( + Config, + Options, + OutputFormat, + QualifiedRule, + RawConfig, + Tags, + Version, +) from ..rule import LintRule +from ..util import chdir class ConfigTest(TestCase): @@ -340,11 +352,12 @@ def test_merge_configs(self) -> None: self.assertEqual(expected, actual) def test_generate_config(self) -> None: - for name, path, root, expected in ( + for name, path, root, options, expected in ( ( "inner", self.inner / "foo.py", None, + None, Config( path=self.inner / "foo.py", root=self.inner, @@ -360,6 +373,7 @@ def test_generate_config(self) -> None: "outer", self.outer / "foo.py", None, + None, Config( path=self.outer / "foo.py", root=self.tdp, @@ -379,6 +393,7 @@ def test_generate_config(self) -> None: "outer with root", self.outer / "foo.py", self.outer, + None, Config( path=self.outer / "foo.py", root=self.outer, @@ -390,6 +405,7 @@ def test_generate_config(self) -> None: "other", self.tdp / "other" / "foo.py", None, + None, Config( path=self.tdp / "other" / "foo.py", root=self.tdp, @@ -411,6 +427,21 @@ def test_generate_config(self) -> None: "root", self.tdp / "foo.py", None, + None, + Config( + path=self.tdp / "foo.py", + root=self.tdp, + enable_root_import=True, + enable=[QualifiedRule("fixit.rules"), QualifiedRule("more.rules")], + disable=[QualifiedRule("fixit.rules.SomethingSpecific")], + python_version=Version("3.8"), + ), + ), + ( + "root with options", + self.tdp / "foo.py", + None, + Options(output_format=OutputFormat.custom, output_template="foo-bar"), Config( path=self.tdp / "foo.py", root=self.tdp, @@ -418,11 +449,13 @@ def test_generate_config(self) -> None: enable=[QualifiedRule("fixit.rules"), QualifiedRule("more.rules")], disable=[QualifiedRule("fixit.rules.SomethingSpecific")], python_version=Version("3.8"), + output_format=OutputFormat.custom, + output_template="foo-bar", ), ), ): with self.subTest(name): - actual = config.generate_config(path, root) + actual = config.generate_config(path, root, options=options) self.assertDictEqual(asdict(expected), asdict(actual)) def test_invalid_config(self) -> None: @@ -435,6 +468,15 @@ def test_invalid_config(self) -> None: with self.assertRaisesRegex(config.ConfigError, "enable-root-import"): config.generate_config(self.tdp / "outer" / "foo.py") + with self.subTest("inner output-format"): + (self.tdp / "pyproject.toml").write_text("[tool.fixit]\nroot = true\n") + (self.tdp / "outer" / "pyproject.toml").write_text( + "[tool.fixit]\noutput-format = 'this is some weird format'\n" + ) + + with self.assertRaisesRegex(config.ConfigError, "output-format"): + config.generate_config(self.tdp / "outer" / "foo.py") + def test_collect_rules(self) -> None: from fixit.rules.avoid_or_in_except import AvoidOrInExcept from fixit.rules.cls_in_classmethod import UseClsInClassmethod @@ -542,3 +584,81 @@ def collect_types(cfg: Config) -> List[Type[LintRule]]: ) ) self.assertListEqual([UseTypesFromTyping], rules) + + def test_format_output(self) -> None: + with chdir(self.tdp): + (self.tdp / "pyproject.toml").write_text( + dedent( + """ + [tool.fixit] + output-format = "vscode" + """ + ) + ) + + runner = CliRunner(mix_stderr=False) + content = "name = '{name}'.format(name='Jane Doe')" + filepath = self.tdp / "f_string.py" + filepath.write_text(content) + output_format_regex = r".*f_string\.py:\d+:\d+ UseFstring: .+" + + with self.subTest("linting vscode"): + result = runner.invoke( + main, ["lint", filepath.as_posix()], catch_exceptions=False + ) + self.assertRegex(result.output, output_format_regex) + + with self.subTest("fixing vscode"): + result = runner.invoke( + main, ["fix", filepath.as_posix()], catch_exceptions=False + ) + self.assertRegex(result.output, output_format_regex) + + custom_output_format_regex = r".*f_string\.py|\d+|\d+ UseFstring: .+" + custom_output_format = ( + "{path}|{start_line}|{start_col} {rule_name}: {message}" + ) + (self.tdp / "pyproject.toml").write_text( + dedent( + f""" + [tool.fixit] + output-format = 'custom' + output-template = '{custom_output_format}' + """ + ) + ) + + with self.subTest("linting custom"): + result = runner.invoke( + main, ["lint", filepath.as_posix()], catch_exceptions=False + ) + self.assertRegex(result.output, custom_output_format_regex) + + with self.subTest("fixing custom"): + result = runner.invoke( + main, ["fix", filepath.as_posix()], catch_exceptions=False + ) + self.assertRegex(result.output, custom_output_format_regex) + + with self.subTest("override output-format"): + result = runner.invoke( + main, + ["--output-format", "vscode", "lint", filepath.as_posix()], + catch_exceptions=True, + ) + self.assertRegex(result.output, output_format_regex) + + with self.subTest("override output-template"): + result = runner.invoke( + main, + [ + "--output-template", + "file {path} line {start_line} rule {rule_name}", + "lint", + filepath.as_posix(), + ], + catch_exceptions=True, + ) + self.assertRegex( + result.output, r"file .*f_string\.py line \d+ rule UseFstring" + ) diff --git a/src/fixit/util.py b/src/fixit/util.py index 613cdb81..44ce518f 100644 --- a/src/fixit/util.py +++ b/src/fixit/util.py @@ -3,6 +3,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +import os import sys from contextlib import contextmanager from pathlib import Path @@ -87,3 +88,13 @@ def append_sys_path(path: Path) -> Generator[None, None, None]: # already there: do nothing, and don't remove it later else: yield + + +@contextmanager +def chdir(path: Path) -> Generator[None, None, None]: + cwd = Path.cwd() + try: + os.chdir(path) + yield + finally: + os.chdir(cwd)