~docbibi/ysort

2eea244bdfaeefe7533680adf0514aff864224b0 — Borjan Tchakaloff 11 months ago f5052e0 main
First version
5 files changed, 221 insertions(+), 9 deletions(-)

M pyproject.toml
A src/ysort.py
D src/ysort/__about__.py
D src/ysort/__init__.py
A tests/test_ysort.py
M pyproject.toml => pyproject.toml +4 -2
@@ 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

A src/ysort.py => src/ysort.py +93 -0
@@ 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())

D src/ysort/__about__.py => src/ysort/__about__.py +0 -4
@@ 1,4 0,0 @@
# SPDX-FileCopyrightText: 2023-present Borjan Tchakaloff <borjan@tchakaloff.fr>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"

D src/ysort/__init__.py => src/ysort/__init__.py +0 -3
@@ 1,3 0,0 @@
# SPDX-FileCopyrightText: 2023-present Borjan Tchakaloff <borjan@tchakaloff.fr>
#
# SPDX-License-Identifier: MIT

A tests/test_ysort.py => tests/test_ysort.py +124 -0
@@ 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