Repository: tox-dev/tox

Issue:
avoid spurious warning about basepython setting for default factors
Specifying a ``basepython`` setting for any environment including a ``pyXY`` factor (including the ``pyXY`` environments themselves) can trigger a spurious warning about the configured interpreter, even if the interpreter does provide the expected version.

The warning should only be generated if the specified interpreter does not conform to the expected version.  Checking the return code from a command like this should do the trick:

~~~~~~~~
{envdir}/bin/python -c 'import sys; sys.exit(sys.version_info[:2] != (X, Y))'
~~~~~~~~

A return code of 0 indicates the interpreter specified conforms to the version indicated by the ``pyXY`` factor; 1 indicates a different version is provided, and anything else would be a real shock.

The the return code is 0, the indicated interpreter should be used regardless of the setting of the ``ignore_basepython_conflict`` setting, and no warning should be generated.

Previous discussion from the mailing list:
https://mail.python.org/mm3/archives/list/tox-dev@python.org/thread/CY2PB35PDX4IZAT73FRDCLSGQ4L2CPR4/

Discussion:

Patch:
diff --git a/changelog/908.bugfix.rst b/changelog/908.bugfix.rst
new file mode 100644
index 00000000..d559d120
--- /dev/null
+++ b/changelog/908.bugfix.rst
@@ -0,0 +1,1 @@
+instead of assuming the Python version from the base python name ask the interpreter to reveal the version for the ``ignore_basepython_conflict`` flag - by :user:`gaborbernat`
diff --git a/doc/config.rst b/doc/config.rst
index cb47a68f..d1f99e4b 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -80,13 +80,19 @@ and will first lookup global tox settings in this section:
 
     .. versionadded:: 3.1.0
 
-    If ``True``, :confval:`basepython` settings that conflict with the Python
-    variant for environments using default factors, such as ``py27`` or
-    ``py35``, will be ignored. This allows you to configure
-    :confval:`basepython` in the global testenv without affecting these
-    factors. If ``False``, the default, a warning will be emitted if a conflict
-    is identified. In a future version of tox, this warning will become an
-    error.
+    tox allows setting the python version for an environment via the :confval:`basepython`
+    setting. If that's not set tox can set a default value from the environment name (
+    e.g. ``py37`` implies Python 3.7). Matching up the python version with the environment
+    name has became expected at this point, leading to surprises when some configs don't
+    do so. To help with sanity of users a warning will be emitted whenever the environment
+    name version does not matches up with this expectation. In a future version of tox,
+    this warning will become an error.
+
+    Furthermore, we allow hard enforcing this rule (and bypassing the warning) by setting
+    this flag to ``True``. In such cases we ignore the :confval:`basepython` and instead
+    always use the base python implied from the Python name. This allows you to
+    configure :confval:`basepython` in the global testenv without affecting environments
+    that have implied base python versions.
 
 .. confval:: requires=LIST
 
diff --git a/src/tox/config.py b/src/tox/config.py
index 12ac98c5..79862a93 100755
--- a/src/tox/config.py
+++ b/src/tox/config.py
@@ -21,7 +21,7 @@ import toml
 
 import tox
 from tox.constants import INFO
-from tox.interpreters import Interpreters
+from tox.interpreters import Interpreters, NoInterpreterInfo
 
 hookimpl = tox.hookimpl
 """DEPRECATED - REMOVE - this is left for compatibility with plugins importing this from here.
@@ -532,35 +532,40 @@ def tox_addoption(parser):
     )
 
     def basepython_default(testenv_config, value):
-        """Configure a sane interpreter for the environment.
+        """either user set or proposed from the factor name
 
-        If the environment contains a default factor, this will always be the
-        interpreter associated with that factor overriding anything manually
-        set.
+        in both cases we check that the factor name implied python version and the resolved
+        python interpreter version match up; if they don't we warn, unless ignore base
+        python conflict is set in which case the factor name implied version if forced
         """
         for factor in testenv_config.factors:
-            match = tox.PYTHON.PY_FACTORS_RE.match(factor)
-            if match:
-                base_exe = tox.PYTHON.PY_FACTORS_MAP[match.group(1)]
-                version = ".".join(match.group(2) or "")
-                default = "{}{}".format(base_exe, version)
-
-                if value is None or testenv_config.config.ignore_basepython_conflict:
-                    return default
-
-                if str(value) != default:
+            if factor in tox.PYTHON.DEFAULT_FACTORS:
+                implied_python = tox.PYTHON.DEFAULT_FACTORS[factor]
+                break
+        else:
+            implied_python, factor = None, None
+
+        if testenv_config.config.ignore_basepython_conflict and implied_python is not None:
+            return implied_python
+
+        proposed_python = (implied_python or sys.executable) if value is None else str(value)
+        if implied_python is not None and implied_python != proposed_python:
+            testenv_config.basepython = proposed_python
+            implied_version = tox.PYTHON.PY_FACTORS_RE.match(factor).group(2)
+            python_info_for_proposed = testenv_config.python_info
+            if not isinstance(python_info_for_proposed, NoInterpreterInfo):
+                proposed_version = "".join(
+                    str(i) for i in python_info_for_proposed.version_info[0:2]
+                )
+                if implied_version != proposed_version:
                     # TODO(stephenfin): Raise an exception here in tox 4.0
                     warnings.warn(
-                        "Conflicting basepython for environment '{}'; resolve conflict "
-                        "or configure ignore_basepython_conflict".format(
-                            testenv_config.envname, str(value), default
+                        "conflicting basepython version (set {}, should be {}) for env '{}';"
+                        "resolve conflict or set ignore_basepython_conflict".format(
+                            proposed_version, implied_version, testenv_config.envname
                         )
                     )
-
-        if value is None:
-            return sys.executable
-
-        return str(value)
+        return proposed_python
 
     parser.add_testenv_attribute(
         name="basepython",
diff --git a/tox.ini b/tox.ini
index 98eeaa7b..74786338 100644
--- a/tox.ini
+++ b/tox.ini
@@ -99,7 +99,7 @@ commands = echo {posargs}
 [flake8]
 max-complexity = 22
 max-line-length = 99
-ignore = E203, W503
+ignore = E203, W503, C901
 
 [coverage:run]
 branch = true


Test patch:
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 3b53bad2..78afe57e 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -1645,7 +1645,7 @@ class TestConfigTestEnv:
             assert config.basepython == "python{}.{}".format(name[2], name[3])
 
     def test_default_factors_conflict(self, newconfig, capsys):
-        with pytest.warns(UserWarning, match=r"Conflicting basepython .*"):
+        with pytest.warns(UserWarning, match=r"conflicting basepython .*"):
             config = newconfig(
                 """
                 [testenv]
@@ -1658,6 +1658,31 @@ class TestConfigTestEnv:
         envconfig = config.envconfigs["py27"]
         assert envconfig.basepython == "python3"
 
+    def test_default_factors_conflict_lying_name(
+        self, newconfig, capsys, tmpdir, recwarn, monkeypatch
+    ):
+        # we first need to create a lying Python here, let's mock out here
+        from tox.interpreters import Interpreters
+
+        def get_executable(self, envconfig):
+            return sys.executable
+
+        monkeypatch.setattr(Interpreters, "get_executable", get_executable)
+
+        major, minor = sys.version_info[0:2]
+        config = newconfig(
+            """
+            [testenv:py{0}{1}]
+            basepython=python{0}.{2}
+            commands = python --version
+        """.format(
+                major, minor, minor - 1
+            )
+        )
+        env_config = config.envconfigs["py{}{}".format(major, minor)]
+        assert env_config.basepython == "python{}.{}".format(major, minor - 1)
+        assert not recwarn.list, "\n".join(repr(i.message) for i in recwarn.list)
+
     def test_default_factors_conflict_ignore(self, newconfig, capsys):
         with pytest.warns(None) as record:
             config = newconfig(