From 5b071580192a78694280da0681e07b5262801c4e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 6 Jan 2023 18:19:07 +0200 Subject: [PATCH] Stdin/stdout support (#161) * Split _fstringify_file to fstringify_content * Add stdin/stdout support Fixes #160 * Update src/flynt/cli.py Co-authored-by: Ilya Kamen --- README.md | 3 +- src/flynt/api.py | 107 +++++++++++++++++++++-------------- src/flynt/cli.py | 20 ++++++- test/integration/test_api.py | 36 ++++++------ test/integration/test_cli.py | 59 +++++++++++-------- 5 files changed, 135 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index cb10afd..d9d5018 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ usage: flynt [-h] [-v | -q] [--no-multiline | -ll LINE_LENGTH] [-d] flynt v.0.77-beta positional arguments: - src source file(s) or directory + src source file(s) or directory (or a single `-` + to read stdin and output to stdout) options: -h, --help show this help message and exit diff --git a/src/flynt/api.py b/src/flynt/api.py index 3bce0ea..d014461 100644 --- a/src/flynt/api.py +++ b/src/flynt/api.py @@ -1,5 +1,6 @@ import ast import codecs +import dataclasses import logging import os import sys @@ -22,33 +23,63 @@ blacklist = {".tox", "venv", "site-packages", ".eggs"} +@dataclasses.dataclass(frozen=True) +class FstringifyResult: + n_changes: int + original_length: int + new_length: int + content: str + + def _fstringify_file( filename: str, state: State, -) -> Tuple[bool, int, int, int]: +) -> Optional[FstringifyResult]: """ - :return: tuple: (changes_made, n_changes, - length of original code, length of new code) + F-stringify a file, write changes, and return a change result. """ - - def default_result() -> Tuple[bool, int, int, int]: - return False, 0, len(contents), len(contents) - encoding, bom = encoding_by_bom(filename) with open(filename, encoding=encoding, newline="") as f: try: contents = f.read() except UnicodeDecodeError: - contents = "" log.error(f"Exception while reading {filename}", exc_info=True) - return default_result() + return None + + result = fstringify_content( + contents=contents, + state=state, + filename=filename, + ) + + if result and result.n_changes: # success? + new_code = result.content + if state.dry_run: + diff = unified_diff( + contents.split("\n"), + new_code.split("\n"), + fromfile=filename, + ) + print("\n".join(diff)) + else: + with open(filename, "wb") as outf: + if bom is not None: + outf.write(bom) + outf.write(new_code.encode(encoding)) + return result + +def fstringify_content( + contents: str, + state: State, + filename: str = "", +) -> Optional[FstringifyResult]: try: ast_before = ast.parse(contents) except SyntaxError: log.exception(f"Can't parse {filename} as a python file.") - return default_result() + return None try: new_code = contents @@ -79,10 +110,17 @@ def default_result() -> Tuple[bool, int, int, int]: f"Skipping fstrings transform of file {filename} due to {msg}.", exc_info=True, ) - return default_result() + return None - if new_code == contents: - return default_result() + result = FstringifyResult( + n_changes=changes, + original_length=len(contents), + new_length=len(new_code), + content=new_code, + ) + + if result.content == contents: + return result try: ast_after = ast.parse(new_code) @@ -91,29 +129,15 @@ def default_result() -> Tuple[bool, int, int, int]: f"Faulty result during conversion on {filename} - skipping.", exc_info=True, ) - return default_result() + return None if not len(ast_before.body) == len(ast_after.body): log.error( f"Faulty result during conversion on {filename}: " f"statement count has changed, which is not intended - skipping.", ) - return default_result() - - if state.dry_run: - diff = unified_diff( - contents.split("\n"), - new_code.split("\n"), - fromfile=filename, - ) - print("\n".join(diff)) - else: - with open(filename, "wb") as outf: - if bom is not None: - outf.write(bom) - outf.write(new_code.encode(encoding)) - - return True, changes, len(contents), len(new_code) + return None + return result def fstringify_files( @@ -126,22 +150,19 @@ def fstringify_files( total_expressions = 0 start_time = time.time() for path in files: - ( - changed, - count_expressions, - charcount_original, - charcount_new, - ) = _fstringify_file( + result = _fstringify_file( path, state, ) - if changed: - changed_files += 1 - total_expressions += count_expressions - total_charcount_original += charcount_original - total_charcount_new += charcount_new - - status = "modified" if count_expressions else "no change" + if result: + if result.n_changes: + changed_files += 1 + total_expressions += result.n_changes + total_charcount_original += result.original_length + total_charcount_new += result.n_changes + status = "modified" if result.n_changes else "no change" + else: + status = "failed" log.info(f"fstringifying {path}...{status}") total_time = time.time() - start_time diff --git a/src/flynt/cli.py b/src/flynt/cli.py index 6c6aec2..f61f21d 100644 --- a/src/flynt/cli.py +++ b/src/flynt/cli.py @@ -7,7 +7,7 @@ from typing import List, Optional from flynt import __version__ -from flynt.api import fstringify, fstringify_code_by_line +from flynt.api import fstringify, fstringify_code_by_line, fstringify_content from flynt.pyproject_finder import find_pyproject_toml, parse_pyproject_toml from flynt.state import State @@ -140,7 +140,10 @@ def run_flynt_cli(arglist: Optional[List[str]] = None) -> int: "src", action="store", nargs="*", - help="source file(s) or directory", + help=( + "source file(s) or directory " + "(or a single `-` to read stdin and output to stdout)" + ), ) parser.add_argument( @@ -191,7 +194,18 @@ def run_flynt_cli(arglist: Optional[List[str]] = None) -> int: ) print(converted) return 0 - + if "-" in args.src: + if len(args.src) > 1: + parser.error("Cannot use '-' with a list of other paths") + result = fstringify_content( + sys.stdin.read(), + state, + filename="", + ) + if not result: + return 1 + print(result.content) + return 0 salutation = f"Running flynt v.{__version__}" toml_file = find_pyproject_toml(tuple(args.src)) if toml_file: diff --git a/test/integration/test_api.py b/test/integration/test_api.py index 3d9dca1..1e0730a 100644 --- a/test/integration/test_api.py +++ b/test/integration/test_api.py @@ -61,26 +61,24 @@ def test_py2(py2_file): with open(py2_file) as f: content_before = f.read() - modified, _, _, _ = _fstringify_file( - py2_file, state=State(multiline=True, len_limit=1000) - ) + result = _fstringify_file(py2_file, state=State(multiline=True, len_limit=1000)) with open(py2_file) as f: content_after = f.read() - assert not modified + assert not result assert content_after == content_before def test_invalid_unicode(invalid_unicode_file): - modified, _, _, _ = _fstringify_file( + result = _fstringify_file( invalid_unicode_file, state=State(multiline=True, len_limit=1000) ) with open(invalid_unicode_file, "rb") as f: content_after = f.read() - assert not modified + assert not result assert content_after == invalid_unicode @@ -89,14 +87,14 @@ def test_works(formattable_file): with open(formattable_file) as f: content_before = f.read() - modified, _, _, _ = _fstringify_file( + result = _fstringify_file( formattable_file, state=State(multiline=True, len_limit=1000) ) with open(formattable_file) as f: content_after = f.read() - assert modified + assert result.n_changes assert content_after != content_before @@ -110,14 +108,14 @@ def broken_fstringify_by_line(*args, **kwargs): monkeypatch.setattr(api, "fstringify_code_by_line", broken_fstringify_by_line) - modified, _, _, _ = _fstringify_file( + result = _fstringify_file( formattable_file, state=State(multiline=True, len_limit=1000) ) with open(formattable_file) as f: content_after = f.read() - assert not modified + assert not result assert content_after == content_before @@ -131,14 +129,14 @@ def broken_fstringify_by_line(*args, **kwargs): monkeypatch.setattr(api, "fstringify_code_by_line", broken_fstringify_by_line) - modified, _, _, _ = _fstringify_file( + result = _fstringify_file( formattable_file, state=State(multiline=True, len_limit=1000) ) with open(formattable_file) as f: content_after = f.read() - assert not modified + assert not result assert content_after == content_before @@ -146,26 +144,26 @@ def test_dry_run(formattable_file, monkeypatch): with open(formattable_file) as f: content_before = f.read() - modified, _, _, _ = _fstringify_file( + result = _fstringify_file( formattable_file, state=State(multiline=True, len_limit=1000, dry_run=True) ) with open(formattable_file) as f: content_after = f.read() - assert modified + assert result.n_changes assert content_after == content_before def test_mixed_line_endings(mixed_line_endings_file): - modified, _, _, _ = _fstringify_file( + result = _fstringify_file( mixed_line_endings_file, state=State(multiline=True, len_limit=1000) ) with open(mixed_line_endings_file, "rb") as f: content_after = f.read() - assert modified + assert result.n_changes assert content_after == mixed_line_endings_after @@ -184,7 +182,5 @@ def test_bom(bom_file): It's possible to verify that a file has bom using `file` unix utility.""" - modified, _, _, _ = _fstringify_file( - bom_file, state=State(multiline=True, len_limit=1000) - ) - assert modified + result = _fstringify_file(bom_file, state=State(multiline=True, len_limit=1000)) + assert result.n_changes diff --git a/test/integration/test_cli.py b/test/integration/test_cli.py index d16bf7b..a513f00 100644 --- a/test/integration/test_cli.py +++ b/test/integration/test_cli.py @@ -1,3 +1,4 @@ +import io import os import pytest @@ -30,28 +31,26 @@ def test_cli_version(capsys): assert err == "" -# Code snippets for testing the -s/--string argument -cli_string_snippets = pytest.mark.parametrize( - "code_in, code_out", - [ - ("'{}'.format(x) + '{}'.format(y)", "f'{x}' + f'{y}'"), - ( - "['{}={}'.format(key, value) for key, value in x.items()]", - "[f'{key}={value}' for key, value in x.items()]", - ), - ( - '["{}={}".format(key, value) for key, value in x.items()]', - '[f"{key}={value}" for key, value in x.items()]', - ), - ( - "This ! isn't <> valid .. Python $ code", - "This ! isn't <> valid .. Python $ code", - ), - ], -) - - -@cli_string_snippets +valid_snippets = [ + ("'{}'.format(x) + '{}'.format(y)", "f'{x}' + f'{y}'"), + ( + "['{}={}'.format(key, value) for key, value in x.items()]", + "[f'{key}={value}' for key, value in x.items()]", + ), + ( + '["{}={}".format(key, value) for key, value in x.items()]', + '[f"{key}={value}" for key, value in x.items()]', + ), +] +invalid_snippets = [ + ( + "This ! isn't <> valid .. Python $ code", + "This ! isn't <> valid .. Python $ code", + ), +] + + +@pytest.mark.parametrize("code_in, code_out", [*valid_snippets, *invalid_snippets]) def test_cli_string_quoted(capsys, code_in, code_out): """ Tests an invocation with quotes, like: @@ -68,7 +67,7 @@ def test_cli_string_quoted(capsys, code_in, code_out): assert err == "" -@cli_string_snippets +@pytest.mark.parametrize("code_in, code_out", [*valid_snippets, *invalid_snippets]) def test_cli_string_unquoted(capsys, code_in, code_out): """ Tests an invocation with no quotes, like: @@ -85,6 +84,20 @@ def test_cli_string_unquoted(capsys, code_in, code_out): assert err == "" +@pytest.mark.parametrize("code_in, code_out", valid_snippets) +def test_cli_stdin(monkeypatch, capsys, code_in, code_out): + """ + Tests a stdin/stdout invocation, like: + echo snippet | flynt - + """ + monkeypatch.setattr("sys.stdin", io.StringIO(code_in)) + return_code = run_flynt_cli(["-"]) + assert return_code == 0 + out, err = capsys.readouterr() + assert out.strip() == code_out + assert err == "" + + @pytest.mark.parametrize( "sample_file", ["all_named.py", "first_string.py", "percent_dict.py", "multiline_limit.py"],