Repository: asottile/setup-cfg-fmt

Issue:
Add Implementation :: tags
```
    Programming Language :: Python :: Implementation :: CPython
    Programming Language :: Python :: Implementation :: PyPy
```

can probably use `tox.ini` as a heuristic

Discussion:

Patch:
diff --git a/setup_cfg_fmt.py b/setup_cfg_fmt.py
index d12d92c..44d3cb3 100644
--- a/setup_cfg_fmt.py
+++ b/setup_cfg_fmt.py
@@ -4,7 +4,9 @@ import glob
 import io
 import os.path
 import re
+import string
 from typing import Dict
+from typing import Generator
 from typing import List
 from typing import Match
 from typing import Optional
@@ -69,6 +71,11 @@ LICENSE_TO_CLASSIFIER = {
     'Zlib': 'License :: OSI Approved :: zlib/libpng License',
 }
 
+TOX_TO_CLASSIFIERS = {
+    'py': 'Programming Language :: Python :: Implementation :: CPython',
+    'pypy': 'Programming Language :: Python :: Implementation :: PyPy',
+}
+
 
 def _adjacent_filename(setup_cfg: str, filename: str) -> str:
     return os.path.join(os.path.dirname(setup_cfg), filename)
@@ -145,6 +152,19 @@ def _parse_python_requires(
     return minimum, excluded
 
 
+def _tox_envlist(setup_cfg: str) -> Generator[str, None, None]:
+    tox_ini = _adjacent_filename(setup_cfg, 'tox.ini')
+    if os.path.exists(tox_ini):
+        cfg = configparser.ConfigParser()
+        cfg.read(tox_ini)
+
+        envlist = cfg.get('tox', 'envlist', fallback='')
+        if envlist:
+            for env in envlist.split(','):
+                env, _, _ = env.strip().partition('-')  # py36-foo
+                yield env
+
+
 def _python_requires(
         setup_cfg: str, *, min_py3_version: Tuple[int, int],
 ) -> Optional[str]:
@@ -158,24 +178,15 @@ def _python_requires(
     except UnknownVersionError:  # assume they know what's up with weird things
         return current_value
 
-    tox_ini = _adjacent_filename(setup_cfg, 'tox.ini')
-    if os.path.exists(tox_ini):
-        cfg = configparser.ConfigParser()
-        cfg.read(tox_ini)
-
-        envlist = cfg.get('tox', 'envlist', fallback='')
-        if envlist:
-            for env in envlist.split(','):
-                env = env.strip()
-                env, _, _ = env.partition('-')  # py36-foo
-                if (
-                        env.startswith('py') and
-                        len(env) == 4 and
-                        env[2:].isdigit()
-                ):
-                    version = _to_ver('.'.join(env[2:]))
-                    if minimum is None or version < minimum[:2]:
-                        minimum = version
+    for env in _tox_envlist(setup_cfg):
+        if (
+                env.startswith('py') and
+                len(env) == 4 and
+                env[2:].isdigit()
+        ):
+            version = _to_ver('.'.join(env[2:]))
+            if minimum is None or version < minimum[:2]:
+                minimum = version
 
     for classifier in classifiers.strip().splitlines():
         if classifier.startswith('Programming Language :: Python ::'):
@@ -316,6 +327,18 @@ def _trim_py_classifiers(
     return [s for s in classifiers if _is_ok_classifier(s)]
 
 
+def _imp_classifiers(setup_cfg: str) -> str:
+    classifiers = set()
+
+    for env in _tox_envlist(setup_cfg):
+        # remove trailing digits: py39-django31
+        classifier = TOX_TO_CLASSIFIERS.get(env.rstrip(string.digits))
+        if classifier is not None:
+            classifiers.add(classifier)
+
+    return '\n'.join(sorted(classifiers))
+
+
 def format_file(
         filename: str, *,
         min_py3_version: Tuple[int, int],
@@ -386,6 +409,13 @@ def format_file(
             f'\n{py_classifiers}'
         )
 
+    imp_classifiers = _imp_classifiers(filename)
+    if imp_classifiers:
+        cfg['metadata']['classifiers'] = (
+            cfg['metadata'].get('classifiers', '').rstrip() +
+            f'\n{imp_classifiers}'
+        )
+
     # sort the classifiers if present
     if 'classifiers' in cfg['metadata']:
         classifiers = sorted(set(cfg['metadata']['classifiers'].split('\n')))


Test patch:
diff --git a/tests/setup_cfg_fmt_test.py b/tests/setup_cfg_fmt_test.py
index 9cfa690..097f081 100644
--- a/tests/setup_cfg_fmt_test.py
+++ b/tests/setup_cfg_fmt_test.py
@@ -482,6 +482,8 @@ def test_guess_python_requires_python2_tox_ini(tmpdir):
         '    Programming Language :: Python :: 3.5\n'
         '    Programming Language :: Python :: 3.6\n'
         '    Programming Language :: Python :: 3.7\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
+        '    Programming Language :: Python :: Implementation :: PyPy\n'
         '\n'
         '[options]\n'
         'python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*\n'
@@ -508,6 +510,7 @@ def test_guess_python_requires_tox_ini_dashed_name(tmpdir):
         '    Programming Language :: Python :: 3\n'
         '    Programming Language :: Python :: 3 :: Only\n'
         '    Programming Language :: Python :: 3.7\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
         '\n'
         '[options]\n'
         'python_requires = >=3.7\n'
@@ -520,7 +523,9 @@ def test_guess_python_requires_ignores_insufficient_version_envs(tmpdir):
     setup_cfg.write(
         '[metadata]\n'
         'name = pkg\n'
-        'version = 1.0\n',
+        'version = 1.0\n'
+        'classifiers =\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n',
     )
 
     assert not main((
@@ -531,6 +536,8 @@ def test_guess_python_requires_ignores_insufficient_version_envs(tmpdir):
         '[metadata]\n'
         'name = pkg\n'
         'version = 1.0\n'
+        'classifiers =\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
     )
 
 
@@ -703,6 +710,7 @@ def test_python_requires_with_patch_version(tmpdir):
         '    Programming Language :: Python :: 3.6\n'
         '    Programming Language :: Python :: 3.7\n'
         '    Programming Language :: Python :: 3.8\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
         '\n'
         '[options]\n'
         'python_requires = >=3.6.1\n'
@@ -772,6 +780,7 @@ def test_min_py3_version_less_than_minimum(tmpdir):
         '    Programming Language :: Python :: 3 :: Only\n'
         '    Programming Language :: Python :: 3.6\n'
         '    Programming Language :: Python :: 3.7\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
         '\n'
         '[options]\n'
         'python_requires = >=3.6\n'
@@ -808,3 +817,106 @@ def test_rewrite_extras(tmpdir):
         '    hypothesis\n'
         '    pytest\n'
     )
+
+
+def test_imp_classifiers_from_tox_ini(tmpdir):
+    tmpdir.join('tox.ini').write('[tox]\nenvlist = py39-django31,pypy3,docs\n')
+    setup_cfg = tmpdir.join('setup.cfg')
+    setup_cfg.write(
+        '[metadata]\n'
+        'name = test\n'
+        'classifiers =\n'
+        '    License :: OSI Approved :: MIT License\n'
+        '    Programming Language :: Python :: 3\n'
+        '    Programming Language :: Python :: 3 :: Only\n'
+        '    Programming Language :: Python :: 3.9\n',
+    )
+
+    args = (str(setup_cfg), '--min-py3-version=3.9', '--max-py-version=3.9')
+    assert main(args)
+
+    assert setup_cfg.read() == (
+        '[metadata]\n'
+        'name = test\n'
+        'classifiers =\n'
+        '    License :: OSI Approved :: MIT License\n'
+        '    Programming Language :: Python :: 3\n'
+        '    Programming Language :: Python :: 3 :: Only\n'
+        '    Programming Language :: Python :: 3.9\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
+        '    Programming Language :: Python :: Implementation :: PyPy\n'
+        '\n'
+        '[options]\n'
+        'python_requires = >=3.9\n'
+    )
+
+
+def test_imp_classifiers_no_change(tmpdir):
+    tmpdir.join('tox.ini').write('[tox]\nenvlist = py39,pypy3-django31\n')
+    setup_cfg = tmpdir.join('setup.cfg')
+    setup_cfg.write(
+        '[metadata]\n'
+        'name = test\n'
+        'classifiers =\n'
+        '    License :: OSI Approved :: MIT License\n'
+        '    Programming Language :: Python :: 3\n'
+        '    Programming Language :: Python :: 3 :: Only\n'
+        '    Programming Language :: Python :: 3.9\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
+        '    Programming Language :: Python :: Implementation :: PyPy\n'
+        '\n'
+        '[options]\n'
+        'python_requires = >=3.9\n',
+    )
+
+    args = (str(setup_cfg), '--min-py3-version=3.9', '--max-py-version=3.9')
+    assert not main(args)
+
+    assert setup_cfg.read() == (
+        '[metadata]\n'
+        'name = test\n'
+        'classifiers =\n'
+        '    License :: OSI Approved :: MIT License\n'
+        '    Programming Language :: Python :: 3\n'
+        '    Programming Language :: Python :: 3 :: Only\n'
+        '    Programming Language :: Python :: 3.9\n'
+        '    Programming Language :: Python :: Implementation :: CPython\n'
+        '    Programming Language :: Python :: Implementation :: PyPy\n'
+        '\n'
+        '[options]\n'
+        'python_requires = >=3.9\n'
+    )
+
+
+def test_imp_classifiers_pypy_only(tmpdir):
+    tmpdir.join('tox.ini').write('[tox]\nenvlist = pypy3\n')
+    setup_cfg = tmpdir.join('setup.cfg')
+    setup_cfg.write(
+        '[metadata]\n'
+        'name = test\n'
+        'classifiers =\n'
+        '    License :: OSI Approved :: MIT License\n'
+        '    Programming Language :: Python :: 3\n'
+        '    Programming Language :: Python :: 3 :: Only\n'
+        '    Programming Language :: Python :: 3.9\n'
+        '\n'
+        '[options]\n'
+        'python_requires = >=3.9\n',
+    )
+
+    args = (str(setup_cfg), '--min-py3-version=3.9', '--max-py-version=3.9')
+    assert main(args)
+
+    assert setup_cfg.read() == (
+        '[metadata]\n'
+        'name = test\n'
+        'classifiers =\n'
+        '    License :: OSI Approved :: MIT License\n'
+        '    Programming Language :: Python :: 3\n'
+        '    Programming Language :: Python :: 3 :: Only\n'
+        '    Programming Language :: Python :: 3.9\n'
+        '    Programming Language :: Python :: Implementation :: PyPy\n'
+        '\n'
+        '[options]\n'
+        'python_requires = >=3.9\n'
+    )