From 2eea244bdfaeefe7533680adf0514aff864224b0 Mon Sep 17 00:00:00 2001 From: Borjan Tchakaloff Date: Thu, 2 Nov 2023 23:48:28 +0100 Subject: [PATCH] First version --- pyproject.toml | 6 +- src/ysort.py | 93 +++++++++++++++++++++++++++++++ src/ysort/__about__.py | 4 -- src/ysort/__init__.py | 3 - tests/test_ysort.py | 124 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 src/ysort.py delete mode 100644 src/ysort/__about__.py delete mode 100644 src/ysort/__init__.py create mode 100644 tests/test_ysort.py diff --git a/pyproject.toml b/pyproject.toml index 6da96dd..98e7bca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "ysort" dynamic = ["version"] -description = '' +description = 'Experimental sorting linter.' readme = "README.md" requires-python = ">=3.8" license = "MIT" @@ -32,7 +32,7 @@ Issues = "https://sr.ht/docbibi/ysort/issues" Source = "https://sr.ht/docbibi/ysort" [tool.hatch.version] -path = "src/ysort/__about__.py" +path = "src/ysort.py" [tool.hatch.envs.default] dependencies = [ @@ -121,6 +121,8 @@ ignore = [ "S105", "S106", "S107", # Ignore complexity "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", + # Accept print statements + "T201", ] unfixable = [ # Don't touch unused imports diff --git a/src/ysort.py b/src/ysort.py new file mode 100644 index 0000000..914e5a1 --- /dev/null +++ b/src/ysort.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: 2023-present Borjan Tchakaloff +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import argparse +import pathlib +import sys +from typing import Iterable, Sequence + +__version__ = "1.0.0" + + +def refactor(file: pathlib.Path) -> int: + try: + content = file.read_text() + except FileNotFoundError: + print( + f"{file} could not be read. Does the file exist?", + file=sys.stderr, + ) + return 1 + except IsADirectoryError: + print( + f"{file} is a directory. Try passing filenames or use shell globbing instead (`{file}/*.py`).", + file=sys.stderr, + ) + return 1 + except Exception: + print( + f"{file} could not be read.", + file=sys.stderr, + ) + return 1 + + lines = content.split("\n") + + if len(lines) == 1: + # Fallback to carriage returns + lines = content.replace("\n", "").split("\r") + + if drop_directives(lines) == lines: + # No directives were found, ignore file + return 0 + + refactored = apply_directives(lines) + + if refactored != lines: + print(f"{file}: refactoring…") + refactored_content = "\n".join(refactored) + file.replace(file.with_suffix(".py-backup")) + file.write_text(refactored_content) + return 1 + else: + print(f"{file}: OK") + return 0 + + +def drop_directives(lines: Iterable[str]) -> list[str]: + return [line for line in lines if line != "# sort"] + + +def apply_directives(lines: Sequence[str]) -> list[str]: + active_block_starts_at: int | None = None + result = list(lines) + for n, line in enumerate(result): + if active_block_starts_at and (not line or not line.strip()): + if n > active_block_starts_at: + result[active_block_starts_at:n] = apply_sort(result[active_block_starts_at:n]) + active_block_starts_at = None + elif line == "# sort".strip(): + active_block_starts_at = n + 1 + return result + + +def apply_sort(lines: Iterable[str]) -> list[str]: + return sorted(lines) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("files", nargs="*", type=pathlib.Path) + args = parser.parse_args(argv) + + result = 0 + for file in args.files: + result |= refactor(file) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ysort/__about__.py b/src/ysort/__about__.py deleted file mode 100644 index 4948c46..0000000 --- a/src/ysort/__about__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: 2023-present Borjan Tchakaloff -# -# SPDX-License-Identifier: MIT -__version__ = "0.0.1" diff --git a/src/ysort/__init__.py b/src/ysort/__init__.py deleted file mode 100644 index 98d16b0..0000000 --- a/src/ysort/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2023-present Borjan Tchakaloff -# -# SPDX-License-Identifier: MIT diff --git a/tests/test_ysort.py b/tests/test_ysort.py new file mode 100644 index 0000000..bb2e602 --- /dev/null +++ b/tests/test_ysort.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import pathlib + +import pytest + +import ysort + +# TODO: with no files + + +def test_main_with_multiple_files(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]): + """Check entrypoint with multiple files (one alright and one to refactor).""" + good_file = tmp_path / "good.py" + good_file.write_text("pass") + bad_file = tmp_path / "bad.py" + bad_file.write_text("# sort\nb = True\na = True\n") + + exit_code = ysort.main([str(good_file), str(bad_file)]) + value = exit_code, *capsys.readouterr() + expected = (1, f"{bad_file}: refactoring…\n", "") + + assert value == expected + + +def test_does_not_accept_directory(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]): + directory = tmp_path / "nope" + directory.mkdir() + + exit_code = ysort.refactor(directory) + value = exit_code, *capsys.readouterr() + expected = ( + 1, + "", + f"{tmp_path}/nope is a directory. Try passing filenames or use shell globbing instead" + f" (`{tmp_path}/nope/*.py`).\n", + ) + + assert value == expected + + +def test_does_not_accept_non_existing_file(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]): + unknown = tmp_path / "unknown" + + exit_code = ysort.refactor(unknown) + value = exit_code, *capsys.readouterr() + expected = (1, "", f"{tmp_path}/unknown could not be read. Does the file exist?\n") + + assert value == expected + + +def test_does_not_accept_non_readable_file(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]): + unknown = tmp_path / "unknown" + unknown.touch(mode=0o266) + + exit_code = ysort.refactor(unknown) + value = exit_code, *capsys.readouterr() + expected = (1, "", f"{tmp_path}/unknown could not be read.\n") + + assert value == expected + + +@pytest.mark.parametrize( + "src", + ( + pytest.param("", id="empty file"), + pytest.param("a = 1\nb = 2\n", id="sorted + no directive"), + pytest.param("b = 2\na = 1\n", id="not sorted + no directive"), + # TODO: ignore syntax error + ), +) +def test_noop(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], src: str): + tmp = tmp_path / "tmp.py" + tmp.write_text(src) + + exit_code = ysort.refactor(tmp) + value = exit_code, *capsys.readouterr(), tmp.read_bytes().decode() + expected = 0, "", "", src + + assert value == expected + + +@pytest.mark.parametrize( + "src", + ( + pytest.param("# sort\n", id="empty file"), + pytest.param("# sort\na = True\nb = True\n", id="simplest block"), + ), +) +def test_acknowledge_already_sorted_files(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], src: str): + tmp = tmp_path / "tmp.py" + tmp.write_text(src) + + exit_code = ysort.refactor(tmp) + value = exit_code, *capsys.readouterr(), tmp.read_bytes().decode() + expected = 0, f"{tmp}: OK\n", "", src + + assert value == expected + + +@pytest.mark.parametrize( + ("src", "refactored"), + ( + pytest.param("# sort\nb = True\na = True\n", "# sort\na = True\nb = True\n", id="simplest block"), + pytest.param("# sort\rb = True\ra = True\r", "# sort\na = True\nb = True\n", id="simplest block (CR->LF)"), + pytest.param( + "# sort\r\nb = True\r\na = True\r\n", "# sort\na = True\nb = True\n", id="simplest block (CRLF->LF)" + ), + ), +) +def test_refactor_files(tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str], src: str, refactored: str): + tmp = tmp_path / "tmp.py" + tmp.write_text(src) + backup = tmp.with_suffix(".py-backup") + + try: + exit_code = ysort.refactor(tmp) + value = exit_code, *capsys.readouterr(), tmp.read_bytes().decode(), backup.read_bytes().decode() + finally: + pass + # backup.unlink(missing_ok=True) + expected = 1, f"{tmp}: refactoring…\n", "", refactored, src + + assert value == expected -- 2.45.2