Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: |
3.9
3.10
3.11
3.12
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ 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",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]

requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = []

[project.scripts]
Expand Down
138 changes: 83 additions & 55 deletions sort_lines.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Empty file added tests/features/__init__.py
Empty file.
101 changes: 101 additions & 0 deletions tests/features/case_sensitivity_test.py
Original file line number Diff line number Diff line change
@@ -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
"""
Loading