diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d35e450..6685530 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,7 +19,6 @@ jobs: - uses: actions/setup-python@v6 with: python-version: | - 3.9 3.10 3.11 3.12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a6248..9e2c317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Removed + +- Support for Python 3.9. + ## v0.3.0 (2024-06-13) ### Changed diff --git a/pyproject.toml b/pyproject.toml index 4ce80d9..cc8c601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ license-files = ["LICENSE"] classifiers = [ # pragma: alphabetize "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -19,7 +18,7 @@ classifiers = [ # pragma: alphabetize "Programming Language :: Python :: 3.14", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [] [project.scripts] diff --git a/sort_lines.py b/sort_lines.py index d8dd71f..d176f03 100644 --- a/sort_lines.py +++ b/sort_lines.py @@ -1,87 +1,115 @@ from __future__ import annotations import argparse +import dataclasses +import enum +import sys from collections.abc import Iterator from collections.abc import Sequence from typing import Protocol - -class Sorter(Protocol): - def __call__(self, lines: Sequence[str]) -> Sequence[str]: ... - - -def _get_sorter(line: str, default_sorter: Sorter) -> Sorter: - _, _, options = line.rstrip().partition('# pragma: alphabetize') - if options == '': - return default_sorter - if options in {'[case-sensitive]', '[cs]'}: - return _case_sensitive_sort - elif options in {'[case-insensitive]', '[ci]'}: - return _case_insensitive_sort - else: - raise ValueError(f'unrecognised options: {options!r}') - - -def _case_sensitive_sort(lines: Sequence[str]) -> list[str]: - return sorted(lines) - - -def _case_insensitive_sort(lines: Sequence[str]) -> list[str]: - return sorted(lines, key=str.casefold) - - -def sort_lines( - lines: Sequence[str], default_sorter: Sorter = _case_sensitive_sort, -) -> Iterator[str]: - sorter: Sorter | None = None - indentation: str | None = None - to_sort: list[str] = [] - for line in lines: - if sorter: - if indentation is None: # first line - indentation = line.removesuffix(line.lstrip()) - to_sort.append(line) - continue - elif line.strip() and line.startswith(indentation): # line to sort - to_sort.append(line) - continue - else: # sorting has ended +print(sys.version_info) +if sys.version_info < (3, 11): + from typing import NoReturn + + def assert_never(arg: NoReturn) -> None: + raise AssertionError(arg) # pragma: no cover +else: + from typing import assert_never + + +class CaseSensitivity(enum.Enum): + CASE_INSENSITIVE = enum.auto() + CASE_SENSITIVE = enum.auto() + + +@dataclasses.dataclass(frozen=True) +class NaiveSorter: + case: CaseSensitivity = CaseSensitivity.CASE_SENSITIVE + + class _SortFn(Protocol): + def __call__(self, lines: Sequence[str]) -> Sequence[str]: ... + + def __call__( + self, lines: Sequence[str], + ) -> Iterator[str]: + sorter: NaiveSorter._SortFn | None = None + indentation: str | None = None + to_sort: list[str] = [] + for line in lines: + if sorter: + if indentation is None: # first line + indentation = line.removesuffix(line.lstrip()) + to_sort.append(line) + continue + elif line.strip() and line.startswith(indentation): # line to sort + to_sort.append(line) + continue + else: # sorting has ended + yield from sorter(to_sort) + sorter = None + + if '# pragma: alphabetize' in line: # start sorting + sorter = self._get_sorter(line) + to_sort = [] + indentation = None # not known yet + + yield line + else: + if sorter: # file ended during sorting yield from sorter(to_sort) - sorter = None - if '# pragma: alphabetize' in line: # start sorting - sorter = _get_sorter(line, default_sorter) - to_sort = [] - indentation = None # not known yet - - yield line - else: - if sorter: # file ended during sorting - yield from sorter(to_sort) + def _get_sorter(self, line: str) -> NaiveSorter._SortFn: + _, _, options = line.rstrip().partition('# pragma: alphabetize') + if options == '': + if self.case is CaseSensitivity.CASE_INSENSITIVE: + return self._case_insensitive_sort + elif self.case is CaseSensitivity.CASE_SENSITIVE: + return self._case_sensitive_sort + else: + assert_never(self.case) + if options in {'[case-sensitive]', '[cs]'}: + return self._case_sensitive_sort + elif options in {'[case-insensitive]', '[ci]'}: + return self._case_insensitive_sort + else: + raise ValueError(f'unrecognised options: {options!r}') + + @staticmethod + def _case_sensitive_sort(lines: Sequence[str]) -> list[str]: + return sorted(lines) + + @staticmethod + def _case_insensitive_sort(lines: Sequence[str]) -> list[str]: + return sorted(lines, key=str.casefold) def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument('filenames', nargs='*') case_mutex = parser.add_mutually_exclusive_group() - case_mutex.set_defaults(default_sorter=_case_sensitive_sort) + case_mutex.set_defaults(case_sensitivity=CaseSensitivity.CASE_SENSITIVE) case_mutex.add_argument( '--case-sensitive', help='sort lines case-sensitively by default (default)', - action='store_const', dest='default_sorter', const=_case_sensitive_sort, + action='store_const', dest='case_sensitivity', + const=CaseSensitivity.CASE_SENSITIVE, ) case_mutex.add_argument( '--case-insensitive', help='sort lines case-insensitively by default', - action='store_const', dest='default_sorter', const=_case_insensitive_sort, + action='store_const', dest='case_sensitivity', + const=CaseSensitivity.CASE_INSENSITIVE, ) args = parser.parse_args(argv) + sorter = NaiveSorter(case=args.case_sensitivity) + for filename in args.filenames: with open(filename) as f: src_lines = f.readlines() - sorted_lines = list(sort_lines(src_lines, default_sorter=args.default_sorter)) + sorted_lines = list(sorter(src_lines)) if sorted_lines != src_lines: with open(filename, 'w') as f: diff --git a/tests/features/__init__.py b/tests/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/features/case_sensitivity_test.py b/tests/features/case_sensitivity_test.py new file mode 100644 index 0000000..8c1ed5c --- /dev/null +++ b/tests/features/case_sensitivity_test.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +from sort_lines import main + + +def test_case_sensitivity(tmp_path: Path) -> None: + file = tmp_path / 't.txt' + file.write_text( + """\ +# a list already sorted +names: # pragma: alphabetize + Alice + Bob + Charlie + David + Eve + +# a list to keep unsorted +families: + Wren + Lark + Tarrk + +# a list to sort +animals: # pragma: alphabetize + Tiger + Hyena + Elephant + Lion + +# a list to sort with default sorting (case-insensitive) +scientists: # pragma: alphabetize + von Neumann + Mandelbrot + Cantor + Willbanks + +# a list to sort case-sensitive +scientists: # pragma: alphabetize[case-sensitive] + von Neumann + Mandelbrot + Cantor + Willbanks + +# a list to sort case-insensitive +scientists: # pragma: alphabetize[case-insensitive] + von Neumann + Mandelbrot + Cantor + Willbanks +""", + ) + + ret = main([str(file), '--case-insensitive']) + + assert ret == 0 + assert file.read_text() == """\ +# a list already sorted +names: # pragma: alphabetize + Alice + Bob + Charlie + David + Eve + +# a list to keep unsorted +families: + Wren + Lark + Tarrk + +# a list to sort +animals: # pragma: alphabetize + Elephant + Hyena + Lion + Tiger + +# a list to sort with default sorting (case-insensitive) +scientists: # pragma: alphabetize + Cantor + Mandelbrot + von Neumann + Willbanks + +# a list to sort case-sensitive +scientists: # pragma: alphabetize[case-sensitive] + Cantor + Mandelbrot + Willbanks + von Neumann + +# a list to sort case-insensitive +scientists: # pragma: alphabetize[case-insensitive] + Cantor + Mandelbrot + von Neumann + Willbanks +""" diff --git a/tests/features/python_test.py b/tests/features/python_test.py new file mode 100644 index 0000000..8fe442e --- /dev/null +++ b/tests/features/python_test.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from pathlib import Path + +from sort_lines import main + + +def test_file_needs_no_sorting(tmp_path: Path) -> None: + file = tmp_path / 't.py' + file.write_text( + """\ +# a list to keep unsorted +families = [ + 'Wren', + 'Lark', + 'Tarrk', +] +""", + ) + + ret = main([str(file)]) + + assert ret == 0 + assert file.read_text() == """\ +# a list to keep unsorted +families = [ + 'Wren', + 'Lark', + 'Tarrk', +] +""" + + +def test_file_already_sorted(tmp_path: Path) -> None: + file = tmp_path / 't.py' + file.write_text( + """\ +# a list already sorted +names = [ # pragma: alphabetize + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', +] + +# a list to keep unsorted +families = [ + 'Wren', + 'Lark', + 'Tarrk', +] +""", + ) + + ret = main([str(file)]) + + assert ret == 0 + assert file.read_text() == """\ +# a list already sorted +names = [ # pragma: alphabetize + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', +] + +# a list to keep unsorted +families = [ + 'Wren', + 'Lark', + 'Tarrk', +] +""" + + +def test_file_changed(tmp_path: Path) -> None: + file = tmp_path / 't.py' + file.write_text( + """\ +# a list already sorted +names = [ # pragma: alphabetize + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', +] + +# a list to keep unsorted +families = [ + 'Wren', + 'Lark', + 'Tarrk', +] + +# a list to sort +animals = [ # pragma: alphabetize + 'Tiger', + 'Hyena' + 'Elephant', + 'Lion', +] + +# a list to sort with default sorting (case-sensitive) +scientists = [ # pragma: alphabetize + 'von Neumann', + 'Mandelbrot' + 'Cantor', + 'Willbanks', +] + +# a list to sort case-sensitive +scientists = [ # pragma: alphabetize[case-sensitive] + 'Cantor', + 'von Neumann', + 'Mandelbrot' + 'Willbanks', +] + +# a list to sort case-insensitive +scientists = [ # pragma: alphabetize[case-insensitive] + 'von Neumann', + 'Mandelbrot' + 'Cantor', + 'Willbanks', +] +""", + ) + + ret = main([str(file)]) + + assert ret == 0 + assert file.read_text() == """\ +# a list already sorted +names = [ # pragma: alphabetize + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', +] + +# a list to keep unsorted +families = [ + 'Wren', + 'Lark', + 'Tarrk', +] + +# a list to sort +animals = [ # pragma: alphabetize + 'Elephant', + 'Hyena' + 'Lion', + 'Tiger', +] + +# a list to sort with default sorting (case-sensitive) +scientists = [ # pragma: alphabetize + 'Cantor', + 'Mandelbrot' + 'Willbanks', + 'von Neumann', +] + +# a list to sort case-sensitive +scientists = [ # pragma: alphabetize[case-sensitive] + 'Cantor', + 'Mandelbrot' + 'Willbanks', + 'von Neumann', +] + +# a list to sort case-insensitive +scientists = [ # pragma: alphabetize[case-insensitive] + 'Cantor', + 'Mandelbrot' + 'von Neumann', + 'Willbanks', +] +""" + + +def test_multiple_files(tmp_path: Path) -> None: + unchanged_file = tmp_path / 'unchanged.py' + unchanged_file.write_text( + """\ +# a list already sorted +names = [ # pragma: alphabetize + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', +] +""", + ) + changed_file = tmp_path / 'changed.py' + changed_file.write_text( + """\ +# a list to sort +animals = [ # pragma: alphabetize + 'Tiger', + 'Hyena' + 'Elephant', + 'Lion', +] +""", + ) + + ret = main([str(unchanged_file), str(changed_file)]) + + assert ret == 0 + assert unchanged_file.read_text() == """\ +# a list already sorted +names = [ # pragma: alphabetize + 'Alice', + 'Bob', + 'Charlie', + 'David', + 'Eve', +] +""" + assert changed_file.read_text() == """\ +# a list to sort +animals = [ # pragma: alphabetize + 'Elephant', + 'Hyena' + 'Lion', + 'Tiger', +] +""" diff --git a/tests/sort_lines_test.py b/tests/sort_lines_test.py index f68013d..a077691 100644 --- a/tests/sort_lines_test.py +++ b/tests/sort_lines_test.py @@ -1,22 +1,12 @@ from __future__ import annotations -from collections.abc import Sequence -from pathlib import Path - import pytest -from sort_lines import _case_insensitive_sort -from sort_lines import _case_sensitive_sort -from sort_lines import _get_sorter -from sort_lines import main -from sort_lines import sort_lines - +from sort_lines import CaseSensitivity +from sort_lines import NaiveSorter -def _default_sorter(lines: Sequence[str]) -> list[str]: - raise RuntimeError # should never use this # pragma: no cover - -class TestGetSorter: +class TestNaiveSorter_GetSorter: @pytest.mark.parametrize( 'line', ( @@ -25,9 +15,24 @@ class TestGetSorter: 'some_values = [ # pragma: alphabetize\n', ), ) - def test_default_sorter(self, line: str) -> None: - sorter = _get_sorter(line, _default_sorter) - assert sorter is _default_sorter + @pytest.mark.parametrize( + ('case_sensitivity', 'expected_sorter'), + ( + pytest.param( + CaseSensitivity.CASE_INSENSITIVE, NaiveSorter._case_insensitive_sort, + id='case-insensitive', + ), + pytest.param( + CaseSensitivity.CASE_SENSITIVE, NaiveSorter._case_sensitive_sort, + id='case-sensitive', + ), + ), + ) + def test_default_sorter( + self, case_sensitivity: CaseSensitivity, line: str, expected_sorter: object, + ) -> None: + sorter = NaiveSorter(case=case_sensitivity)._get_sorter(line) + assert sorter is expected_sorter @pytest.mark.parametrize( 'line', @@ -41,8 +46,8 @@ def test_default_sorter(self, line: str) -> None: ), ) def test_case_sensitive(self, line: str) -> None: - sorter = _get_sorter(line, _default_sorter) - assert sorter is _case_sensitive_sort + sorter = NaiveSorter()._get_sorter(line) + assert sorter is NaiveSorter._case_sensitive_sort @pytest.mark.parametrize( 'line', @@ -56,18 +61,18 @@ def test_case_sensitive(self, line: str) -> None: ), ) def test_case_insensitive(self, line: str) -> None: - sorter = _get_sorter(line, _default_sorter) - assert sorter is _case_insensitive_sort + sorter = NaiveSorter()._get_sorter(line) + assert sorter is NaiveSorter._case_insensitive_sort def test_unknown_options(self) -> None: with pytest.raises( ValueError, match=r"unrecognised options: '\[sort-lines\]'", ): - _get_sorter('# pragma: alphabetize[sort-lines]\n', _default_sorter) + NaiveSorter()._get_sorter('# pragma: alphabetize[sort-lines]\n') -class TestSortLines: +class TestNaiveSorter: def test_sort_lines(self) -> None: lines = [ '# pragma: alphabetize\n', @@ -78,7 +83,8 @@ def test_sort_lines(self) -> None: 'Charlie\n', ] - sorted_lines = list(sort_lines(lines)) + sort = NaiveSorter() + sorted_lines = list(sort(lines)) assert sorted_lines == [ '# pragma: alphabetize\n', @@ -99,7 +105,8 @@ def test_case_insensitive(self) -> None: 'charlie\n', ] - sorted_lines = list(sort_lines(lines)) + sort = NaiveSorter() + sorted_lines = list(sort(lines)) assert sorted_lines == [ '# pragma: alphabetize[case-insensitive]\n', @@ -120,7 +127,8 @@ def test_already_sorted(self) -> None: 'Eve\n', ] - sorted_lines = list(sort_lines(lines)) + sort = NaiveSorter() + sorted_lines = list(sort(lines)) assert sorted_lines == lines @@ -134,7 +142,8 @@ def test_sorting_stops_when_indentation_changes(self) -> None: 'Charlie\n', ] - sorted_lines = list(sort_lines(lines)) + sort = NaiveSorter() + sorted_lines = list(sort(lines)) assert sorted_lines == [ '# pragma: alphabetize\n', @@ -156,7 +165,8 @@ def test_sorting_stops_at_empty_line(self) -> None: 'Charlie\n', ] - sorted_lines = list(sort_lines(lines)) + sort = NaiveSorter() + sorted_lines = list(sort(lines)) assert sorted_lines == [ '# pragma: alphabetize\n', @@ -167,340 +177,3 @@ def test_sorting_stops_at_empty_line(self) -> None: 'David\n', 'Charlie\n', ] - - -def test_file_needs_no_sorting(tmp_path: Path) -> None: - file = tmp_path / 't.py' - file.write_text( - """\ -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] -""", - ) - - ret = main([str(file)]) - - assert ret == 0 - assert file.read_text() == """\ -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] -""" - - -def test_file_already_sorted(tmp_path: Path) -> None: - file = tmp_path / 't.py' - file.write_text( - """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] - -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] -""", - ) - - ret = main([str(file)]) - - assert ret == 0 - assert file.read_text() == """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] - -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] -""" - - -def test_file_changed(tmp_path: Path) -> None: - file = tmp_path / 't.py' - file.write_text( - """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] - -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] - -# a list to sort -animals = [ # pragma: alphabetize - 'Tiger', - 'Hyena' - 'Elephant', - 'Lion', -] - -# a list to sort with default sorting (case-sensitive) -scientists = [ # pragma: alphabetize - 'von Neumann', - 'Mandelbrot' - 'Cantor', - 'Willbanks', -] - -# a list to sort case-sensitive -scientists = [ # pragma: alphabetize[case-sensitive] - 'Cantor', - 'von Neumann', - 'Mandelbrot' - 'Willbanks', -] - -# a list to sort case-insensitive -scientists = [ # pragma: alphabetize[case-insensitive] - 'von Neumann', - 'Mandelbrot' - 'Cantor', - 'Willbanks', -] -""", - ) - - ret = main([str(file)]) - - assert ret == 0 - assert file.read_text() == """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] - -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] - -# a list to sort -animals = [ # pragma: alphabetize - 'Elephant', - 'Hyena' - 'Lion', - 'Tiger', -] - -# a list to sort with default sorting (case-sensitive) -scientists = [ # pragma: alphabetize - 'Cantor', - 'Mandelbrot' - 'Willbanks', - 'von Neumann', -] - -# a list to sort case-sensitive -scientists = [ # pragma: alphabetize[case-sensitive] - 'Cantor', - 'Mandelbrot' - 'Willbanks', - 'von Neumann', -] - -# a list to sort case-insensitive -scientists = [ # pragma: alphabetize[case-insensitive] - 'Cantor', - 'Mandelbrot' - 'von Neumann', - 'Willbanks', -] -""" - - -def test_case_insensitive(tmp_path: Path) -> None: - file = tmp_path / 't.py' - file.write_text( - """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] - -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] - -# a list to sort -animals = [ # pragma: alphabetize - 'Tiger', - 'Hyena' - 'Elephant', - 'Lion', -] - -# a list to sort with default sorting (case-insensitive) -scientists = [ # pragma: alphabetize - 'von Neumann', - 'Mandelbrot' - 'Cantor', - 'Willbanks', -] - -# a list to sort case-sensitive -scientists = [ # pragma: alphabetize[case-sensitive] - 'von Neumann', - 'Mandelbrot' - 'Cantor', - 'Willbanks', -] - -# a list to sort case-insensitive -scientists = [ # pragma: alphabetize[case-insensitive] - 'von Neumann', - 'Mandelbrot' - 'Cantor', - 'Willbanks', -] -""", - ) - - ret = main([str(file), '--case-insensitive']) - - assert ret == 0 - assert file.read_text() == """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] - -# a list to keep unsorted -families = [ - 'Wren', - 'Lark', - 'Tarrk', -] - -# a list to sort -animals = [ # pragma: alphabetize - 'Elephant', - 'Hyena' - 'Lion', - 'Tiger', -] - -# a list to sort with default sorting (case-insensitive) -scientists = [ # pragma: alphabetize - 'Cantor', - 'Mandelbrot' - 'von Neumann', - 'Willbanks', -] - -# a list to sort case-sensitive -scientists = [ # pragma: alphabetize[case-sensitive] - 'Cantor', - 'Mandelbrot' - 'Willbanks', - 'von Neumann', -] - -# a list to sort case-insensitive -scientists = [ # pragma: alphabetize[case-insensitive] - 'Cantor', - 'Mandelbrot' - 'von Neumann', - 'Willbanks', -] -""" - - -def test_multiple_files(tmp_path: Path) -> None: - unchanged_file = tmp_path / 'unchanged.py' - unchanged_file.write_text( - """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] -""", - ) - changed_file = tmp_path / 'changed.py' - changed_file.write_text( - """\ -# a list to sort -animals = [ # pragma: alphabetize - 'Tiger', - 'Hyena' - 'Elephant', - 'Lion', -] -""", - ) - - ret = main([str(unchanged_file), str(changed_file)]) - - assert ret == 0 - assert unchanged_file.read_text() == """\ -# a list already sorted -names = [ # pragma: alphabetize - 'Alice', - 'Bob', - 'Charlie', - 'David', - 'Eve', -] -""" - assert changed_file.read_text() == """\ -# a list to sort -animals = [ # pragma: alphabetize - 'Elephant', - 'Hyena' - 'Lion', - 'Tiger', -] -""" diff --git a/tox.ini b/tox.ini index cca02bf..dced327 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{9,10,11,12,13,14},coverage,mypy +envlist = mypy,py3{10,11,12,13,14},coverage [testenv] # Install wheels instead of source distributions for faster execution.