Repository: asottile/setup-cfg-fmt

Issue:
sort install_requires / setup_requires
probably:

1. sort any non-version-qualified ones
2. group + sort by version qualifier



Comments:

Patch:
diff --git a/setup_cfg_fmt.py b/setup_cfg_fmt.py
index 8a8da69..678c851 100644
--- a/setup_cfg_fmt.py
+++ b/setup_cfg_fmt.py
@@ -196,6 +196,55 @@ def _python_requires(
         return _format_python_requires(minimum, excluded)
 
 
+def _install_requires(cfg: configparser.ConfigParser) -> List[str]:
+    raw = cfg.get('options', 'install_requires', fallback='')
+
+    install_requires = raw.strip().splitlines()
+
+    normalized = sorted(
+        (_normalize_req(req) for req in install_requires),
+        key=lambda req: (';' in req, req),
+    )
+
+    if len(normalized) > 1:
+        normalized.insert(0, '')
+
+    return normalized
+
+
+def _normalize_req(req: str) -> str:
+    lib, _, envs = req.partition(';')
+    normalized = _normalize_lib(lib)
+
+    envs = envs.strip()
+    if not envs:
+        return normalized
+
+    return f'{normalized};{envs}'
+
+
+BASE_NAME_REGEX = re.compile(r'[^!=><\s]+')
+REQ_REGEX = re.compile(r'(===|==|!=|~=|>=?|<=?)\s*([^,]+)')
+
+
+def _normalize_lib(lib: str) -> str:
+    basem = re.match(BASE_NAME_REGEX, lib)
+    assert basem
+    base = basem.group(0)
+
+    conditions = ','.join(
+        sorted(
+            (
+                f'{m.group(1)}{m.group(2)}'
+                for m in REQ_REGEX.finditer(lib)
+            ),
+            key=lambda c: ('<' in c, '>' in 'c', c),
+        ),
+    )
+
+    return f'{base}{conditions}'
+
+
 def _py_classifiers(
         python_requires: Optional[str], *, max_py_version: Tuple[int, int],
 ) -> Optional[str]:
@@ -308,6 +357,10 @@ def format_file(
             cfg.add_section('options')
         cfg['options']['python_requires'] = requires
 
+    install_requires = _install_requires(cfg)
+    if install_requires:
+        cfg['options']['install_requires'] = '\n'.join(install_requires)
+
     py_classifiers = _py_classifiers(requires, max_py_version=max_py_version)
     if py_classifiers:
         cfg['metadata']['classifiers'] = (


Test patch:
diff --git a/tests/setup_cfg_fmt_test.py b/tests/setup_cfg_fmt_test.py
index 7de86ab..9454631 100644
--- a/tests/setup_cfg_fmt_test.py
+++ b/tests/setup_cfg_fmt_test.py
@@ -3,6 +3,7 @@ import argparse
 import pytest
 
 from setup_cfg_fmt import _case_insensitive_glob
+from setup_cfg_fmt import _normalize_lib
 from setup_cfg_fmt import _ver_type
 from setup_cfg_fmt import main
 
@@ -56,6 +57,67 @@ def test_noop(tmpdir):
 @pytest.mark.parametrize(
     ('input_s', 'expected'),
     (
+        pytest.param(
+            '[metadata]\n'
+            'version = 1.0\n'
+            'name = pkg\n'
+            '[options]\n'
+            'install_requires =\n'
+            '    req03\n'
+            '    req05 <= 2,!=1\n'
+            '    req06 ;python_version==2.7\n'
+            '    req07 ;os_version!=windows\n'
+            '    req13 !=2, >= 7\n'
+            '    req14 <=2, >= 1\n'
+            '    req01\n'
+            '       req02\n'
+            '    req09 ~= 7\n'
+            '    req10 === 8\n'
+            '    req11; python_version=="2.7"\n'
+            '    req08    ==    2\n'
+            '    req12;\n'
+            '    req04 >= 1\n',
+
+            '[metadata]\n'
+            'name = pkg\n'
+            'version = 1.0\n'
+            '\n'
+            '[options]\n'
+            'install_requires =\n'
+            '    req01\n'
+            '    req02\n'
+            '    req03\n'
+            '    req04>=1\n'
+            '    req05!=1,<=2\n'
+            '    req08==2\n'
+            '    req09~=7\n'
+            '    req10===8\n'
+            '    req12\n'
+            '    req13!=2,>=7\n'
+            '    req14>=1,<=2\n'
+            '    req06;python_version==2.7\n'
+            '    req07;os_version!=windows\n'
+            '    req11;python_version=="2.7"\n',
+
+            id='normalizes install_requires',
+        ),
+        pytest.param(
+            '[metadata]\n'
+            'version = 1.0\n'
+            'name = pkg\n'
+            '[options]\n'
+            'install_requires =\n'
+            '    req03\n',
+
+            '[metadata]\n'
+            'name = pkg\n'
+            'version = 1.0\n'
+            '\n'
+            '[options]\n'
+            'install_requires = req03\n',
+
+            id='normalize single install_requires req to one line',
+        ),
         pytest.param(
             '[bdist_wheel]\n'
             'universal = true\n'
@@ -114,6 +176,20 @@ def test_rewrite(input_s, expected, tmpdir):
     assert setup_cfg.read() == expected
 
 
+@pytest.mark.parametrize(
+    ('lib', 'expected'),
+    (
+        pytest.param('req01', 'req01', id='no conditions'),
+        pytest.param('req04 >= 1', 'req04>=1', id='whitespace stripped'),
+        pytest.param('req05 <= 2,!=1', 'req05!=1,<=2', id='<= cond at end'),
+        pytest.param('req13 !=2, >= 7', 'req13!=2,>=7', id='>= cond at end'),
+        pytest.param('req14 <=2, >= 1', 'req14>=1,<=2', id='b/w conds sorted'),
+    ),
+)
+def test_normalize_lib(lib, expected):
+    assert _normalize_lib(lib) == expected
+
+
 @pytest.mark.parametrize(
     ('filename', 'content_type'),
     (