@@ 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
@@ 0,0 1,93 @@
+# SPDX-FileCopyrightText: 2023-present Borjan Tchakaloff <borjan@tchakaloff.fr>
+#
+# 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())
@@ 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