Skip to content

Commit 81fb393

Browse files
llllvvuuamyreese
andauthored
feat: support linting stdin (#388)
* feat: support `cat foo.py | fixit lint - foo.py` Similarly for `fixit fix` * type fix --------- Co-authored-by: Amethyst Reese <[email protected]>
1 parent 83a5a45 commit 81fb393

File tree

6 files changed

+116
-14
lines changed

6 files changed

+116
-14
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
root = true
22

33
[*.{py,pyi,toml,md}]
4-
charset = "utf-8"
4+
charset = utf-8
55
end_of_line = lf
66
indent_size = 4
77
indent_style = space

docs/guide/commands.rst

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ The following options are available for all commands:
4646
``lint``
4747
^^^^^^^^
4848

49-
Lint one or more paths, and print a list of lint errors.
49+
Lint one or more paths, and print a list of lint errors. If "-" is given as the
50+
first path, then the second given path will be used for configuration lookup
51+
and error messages, and the input read from STDIN.
5052

5153
.. code:: console
5254
@@ -60,7 +62,10 @@ Lint one or more paths, and print a list of lint errors.
6062
``fix``
6163
^^^^^^^
6264

63-
Lint one or more paths, and apply suggested fixes.
65+
Lint one or more paths, and apply suggested fixes. If "-" is given as the
66+
first path, then the second given path will be used for configuration lookup,
67+
the input read from STDIN, and the fixed output printed to STDOUT (ignoring
68+
:attr:`--interactive`).
6469

6570
.. code:: console
6671

src/fixit/api.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# LICENSE file in the root directory of this source tree.
55

66
import logging
7+
import sys
78
import traceback
89
from functools import partial
910
from pathlib import Path
@@ -16,12 +17,14 @@
1617
from .config import collect_rules, generate_config
1718
from .engine import LintRunner
1819
from .format import format_module
19-
from .ftypes import Config, FileContent, LintViolation, Options, Result
20+
from .ftypes import Config, FileContent, LintViolation, Options, Result, STDIN
2021

2122
LOG = logging.getLogger(__name__)
2223

2324

24-
def print_result(result: Result, show_diff: bool = False) -> int:
25+
def print_result(
26+
result: Result, *, show_diff: bool = False, stderr: bool = False
27+
) -> int:
2528
"""
2629
Print linting results in a simple format designed for human eyes.
2730
@@ -44,7 +47,9 @@ def print_result(result: Result, show_diff: bool = False) -> int:
4447
if result.violation.autofixable:
4548
message += " (has autofix)"
4649
click.secho(
47-
f"{path}@{start_line}:{start_col} {rule_name}: {message}", fg="yellow"
50+
f"{path}@{start_line}:{start_col} {rule_name}: {message}",
51+
fg="yellow",
52+
err=stderr,
4853
)
4954
if show_diff and result.violation.diff:
5055
echo_color_precomputed_diff(result.violation.diff)
@@ -53,8 +58,8 @@ def print_result(result: Result, show_diff: bool = False) -> int:
5358
elif result.error:
5459
# An exception occurred while processing a file
5560
error, tb = result.error
56-
click.secho(f"{path}: EXCEPTION: {error}", fg="red")
57-
click.echo(tb.strip())
61+
click.secho(f"{path}: EXCEPTION: {error}", fg="red", err=stderr)
62+
click.echo(tb.strip(), err=stderr)
5863
return True
5964

6065
else:
@@ -117,6 +122,36 @@ def fixit_bytes(
117122
return None
118123

119124

125+
def fixit_stdin(
126+
path: Path,
127+
*,
128+
autofix: bool = False,
129+
options: Optional[Options] = None,
130+
) -> Generator[Result, bool, None]:
131+
"""
132+
Wrapper around :func:`fixit_bytes` for formatting content from STDIN.
133+
134+
The resulting fixed content will be printed to STDOUT.
135+
136+
Requires passing a path that represents the filesystem location matching the
137+
contents to be linted. This will be used to resolve the ``fixit.toml`` config
138+
file(s).
139+
"""
140+
path = path.resolve()
141+
142+
try:
143+
content: FileContent = sys.stdin.buffer.read()
144+
config = generate_config(path, options=options)
145+
146+
updated = yield from fixit_bytes(path, content, config=config, autofix=autofix)
147+
if autofix:
148+
sys.stdout.buffer.write(updated or content)
149+
150+
except Exception as error:
151+
LOG.debug("Exception while fixit_stdin", exc_info=error)
152+
yield Result(path, violation=None, error=(error, traceback.format_exc()))
153+
154+
120155
def fixit_file(
121156
path: Path,
122157
*,
@@ -177,6 +212,16 @@ def fixit_paths(
177212
Yields :class:`Result` objects for each path, lint error, or exception found.
178213
See :func:`fixit_bytes` for semantics.
179214
215+
If the first given path is STDIN (``Path("-")``), then content will be linted
216+
from STDIN using :func:`fixit_stdin`. The fixed content will be written to STDOUT.
217+
A second path argument may be given, which represents the original content's true
218+
path name, and will be used:
219+
- to resolve the ``fixit.toml`` configuration file(s)
220+
- when printing status messages, diffs, or errors.
221+
If no second path argument is given, it will default to "stdin" in the current
222+
working directory.
223+
Any further path names will result in a runtime error.
224+
180225
.. note::
181226
182227
Currently does not support applying individual fixes when ``parallel=True``,
@@ -188,10 +233,25 @@ def fixit_paths(
188233
return
189234

190235
expanded_paths: List[Path] = []
191-
for path in paths:
192-
expanded_paths.extend(trailrunner.walk(path))
193-
194-
if len(expanded_paths) == 1 or not parallel:
236+
is_stdin = False
237+
stdin_path = Path("stdin")
238+
for i, path in enumerate(paths):
239+
if path == STDIN:
240+
if i == 0:
241+
is_stdin = True
242+
else:
243+
LOG.warning("Cannot mix stdin ('-') with normal paths, ignoring")
244+
elif is_stdin:
245+
if i == 1:
246+
stdin_path = path
247+
else:
248+
raise ValueError("too many stdin paths")
249+
else:
250+
expanded_paths.extend(trailrunner.walk(path))
251+
252+
if is_stdin:
253+
yield from fixit_stdin(stdin_path, autofix=autofix, options=options)
254+
elif len(expanded_paths) == 1 or not parallel:
195255
for path in expanded_paths:
196256
yield from fixit_file(path, autofix=autofix, options=options)
197257
else:

src/fixit/cli.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ def lint(
109109
):
110110
"""
111111
lint one or more paths and return suggestions
112+
113+
pass "- <FILENAME>" for STDIN representing <FILENAME>
112114
"""
113115
options: Options = ctx.obj
114116

@@ -142,7 +144,7 @@ def lint(
142144
"-i/-a",
143145
is_flag=True,
144146
default=True,
145-
help="how to apply fixes; interactive by default",
147+
help="how to apply fixes; interactive by default unless STDIN",
146148
)
147149
@click.option("--diff", "-d", is_flag=True, help="show diff even with --automatic")
148150
@click.argument("paths", nargs=-1, type=click.Path(path_type=Path))
@@ -154,12 +156,17 @@ def fix(
154156
):
155157
"""
156158
lint and autofix one or more files and return results
159+
160+
pass "- <FILENAME>" for STDIN representing <FILENAME>;
161+
this will ignore "--interactive" and always use "--automatic"
157162
"""
158163
options: Options = ctx.obj
159164

160165
if not paths:
161166
paths = [Path.cwd()]
162167

168+
is_stdin = bool(paths[0] and str(paths[0]) == "-")
169+
interactive = interactive and not is_stdin
163170
autofix = not interactive
164171
exit_code = 0
165172

@@ -174,7 +181,9 @@ def fix(
174181
)
175182
for result in generator:
176183
visited.add(result.path)
177-
if print_result(result, show_diff=interactive or diff):
184+
# for STDIN, we need STDOUT to equal the fixed content, so
185+
# move everything else to STDERR
186+
if print_result(result, show_diff=interactive or diff, stderr=is_stdin):
178187
dirty.add(result.path)
179188
if autofix and result.violation and result.violation.autofixable:
180189
autofixes += 1

src/fixit/ftypes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
T = TypeVar("T")
3434

35+
STDIN = Path("-")
36+
3537
CodeRange
3638
CodePosition
3739

src/fixit/tests/smoke.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,32 @@ def func():
109109
expected_format, path.read_text(), "unexpected file output"
110110
)
111111

112+
with self.subTest("linting via stdin"):
113+
result = self.runner.invoke(
114+
main,
115+
["lint", "-", path.as_posix()],
116+
input=content,
117+
catch_exceptions=False,
118+
)
119+
120+
self.assertNotEqual(result.output, "")
121+
self.assertNotEqual(result.exit_code, 0)
122+
self.assertRegex(
123+
result.output,
124+
r"file\.py@\d+:\d+ NoRedundantFString: .+ \(has autofix\)",
125+
)
126+
127+
with self.subTest("fixing with formatting via stdin"):
128+
result = self.runner.invoke(
129+
main,
130+
["fix", "-", path.as_posix()],
131+
input=content,
132+
catch_exceptions=False,
133+
)
134+
135+
self.assertEqual(result.exit_code, 0)
136+
self.assertEqual(expected_format, result.output, "unexpected stdout")
137+
112138
def test_this_file_is_clean(self) -> None:
113139
path = Path(__file__).resolve().as_posix()
114140
result = self.runner.invoke(main, ["lint", path], catch_exceptions=False)

0 commit comments

Comments
 (0)