Repository: tox-dev/tox

Issue:
Possible UnicodeError caused by missing encoding="utf-8"
https://github.com/tox-dev/tox/blob/555f3f13b18da1470b6518be654e3e4e2fdec654/src/tox/config/__init__.py#L304-L306

toml must be encoded by UTF-8. Please add `encoding="utf-8"` here.
It may cause UnicodeDecodeError when pyproject.toml contains non-ASCII character and locale encoding is not UTF-8 (e.g. Windows).

https://github.com/tox-dev/tox/blob/555f3f13b18da1470b6518be654e3e4e2fdec654/src/tox/util/stdlib.py#L52-L55

On Windows, stdout is UTF-8 encoded when it is console (e.g. _WinConsoleIO), but the default encoding of TemporaryFile() is legacy encoding. So UTF-8 is better encoding for this purpose.

This is not a real bug because this function is used only here:
https://github.com/tox-dev/tox/blob/cfe38bb853ff8573817c640a158b87eaad5b6e01/src/tox/session/__init__.py#L54

Comments:

Patch:
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
index 1e085ba7..55dd6d1b 100644
--- a/CONTRIBUTORS
+++ b/CONTRIBUTORS
@@ -4,6 +4,7 @@ Alexander Loechel
 Alexander Schepanovski
 Alexandre Conrad
 Allan Feldman
+Andrey Bienkowski
 Andrii Soldatenko
 Andrzej Klajnert
 Anthon van der Neuth
diff --git a/docs/changelog/1921.feature.rst b/docs/changelog/1921.feature.rst
new file mode 100644
index 00000000..64ea8c15
--- /dev/null
+++ b/docs/changelog/1921.feature.rst
@@ -0,0 +1,6 @@
+tox can now be invoked with a new ``--no-provision`` flag that prevents provision,
+if :conf:`requires` or :conf:`minversion` are not satisfied,
+tox will fail;
+if a path is specified as an argument to the flag
+(e.g. as ``tox --no-provision missing.json``) and provision is prevented,
+provision metadata are written as JSON to that path - by :user:`hroncok`
diff --git a/docs/config.rst b/docs/config.rst
index 01bc9b7d..31e96331 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -38,6 +38,11 @@ Global settings are defined under the ``tox`` section as:
    than this the tool will create an environment and provision it with a version of
    tox that satisfies this under :conf:`provision_tox_env`.
 
+   .. versionchanged:: 3.23.0
+
+   When tox is invoked with the ``--no-provision`` flag,
+   the provision won't be attempted, tox will fail instead.
+
 .. conf:: requires ^ LIST of PEP-508
 
     .. versionadded:: 3.2.0
@@ -54,6 +59,11 @@ Global settings are defined under the ``tox`` section as:
         requires = tox-pipenv
                    setuptools >= 30.0.0
 
+    .. versionchanged:: 3.23.0
+
+    When tox is invoked with the ``--no-provision`` flag,
+    the provision won't be attempted, tox will fail instead.
+
 .. conf:: provision_tox_env ^ string ^ .tox
 
     .. versionadded:: 3.8.0
@@ -61,6 +71,11 @@ Global settings are defined under the ``tox`` section as:
     Name of the virtual environment used to provision a tox having all dependencies specified
     inside :conf:`requires` and :conf:`minversion`.
 
+    .. versionchanged:: 3.23.0
+
+    When tox is invoked with the ``--no-provision`` flag,
+    the provision won't be attempted, tox will fail instead.
+
 .. conf:: toxworkdir ^ PATH ^ {toxinidir}/.tox
 
    Directory for tox to generate its environments into, will be created if it does not exist.
diff --git a/src/tox/config/__init__.py b/src/tox/config/__init__.py
index 10bc9bef..278d4b2b 100644
--- a/src/tox/config/__init__.py
+++ b/src/tox/config/__init__.py
@@ -1,7 +1,9 @@
 from __future__ import print_function
 
 import argparse
+import io
 import itertools
+import json
 import os
 import random
 import re
@@ -302,7 +304,7 @@ def parseconfig(args, plugins=()):
 
 
 def get_py_project_toml(path):
-    with open(str(path)) as file_handler:
+    with io.open(str(path), encoding="UTF-8") as file_handler:
         config_data = toml.load(file_handler)
         return config_data
 
@@ -572,6 +574,16 @@ def tox_addoption(parser):
         action="store_true",
         help="override alwayscopy setting to True in all envs",
     )
+    parser.add_argument(
+        "--no-provision",
+        action="store",
+        nargs="?",
+        default=False,
+        const=True,
+        metavar="REQUIRES_JSON",
+        help="do not perform provision, but fail and if a path was provided "
+        "write provision metadata as JSON to it",
+    )
 
     cli_skip_missing_interpreter(parser)
     parser.add_argument("--workdir", metavar="PATH", help="tox working directory")
@@ -1318,8 +1330,8 @@ class ParseIni(object):
         # raise on unknown args
         self.config._parser.parse_cli(args=self.config.args, strict=True)
 
-    @staticmethod
-    def ensure_requires_satisfied(config, requires, min_version):
+    @classmethod
+    def ensure_requires_satisfied(cls, config, requires, min_version):
         missing_requirements = []
         failed_to_parse = False
         deps = []
@@ -1346,12 +1358,33 @@ class ParseIni(object):
                 missing_requirements.append(str(requirements.Requirement(require)))
         if failed_to_parse:
             raise tox.exception.BadRequirement()
+        if config.option.no_provision and missing_requirements:
+            msg = "provisioning explicitly disabled within {}, but missing {}"
+            if config.option.no_provision is not True:  # it's a path
+                msg += " and wrote to {}"
+                cls.write_requires_to_json_file(config)
+            raise tox.exception.Error(
+                msg.format(sys.executable, missing_requirements, config.option.no_provision)
+            )
         if WITHIN_PROVISION and missing_requirements:
             msg = "break infinite loop provisioning within {} missing {}"
             raise tox.exception.Error(msg.format(sys.executable, missing_requirements))
         config.run_provision = bool(len(missing_requirements))
         return deps
 
+    @staticmethod
+    def write_requires_to_json_file(config):
+        requires_dict = {
+            "minversion": config.minversion,
+            "requires": config.requires,
+        }
+        try:
+            with open(config.option.no_provision, "w", encoding="utf-8") as outfile:
+                json.dump(requires_dict, outfile, indent=4)
+        except TypeError:  # Python 2
+            with open(config.option.no_provision, "w") as outfile:
+                json.dump(requires_dict, outfile, indent=4, encoding="utf-8")
+
     def parse_build_isolation(self, config, reader):
         config.isolated_build = reader.getbool("isolated_build", False)
         config.isolated_build_env = reader.getstring("isolated_build_env", ".package")


Test patch:
diff --git a/src/tox/_pytestplugin.py b/src/tox/_pytestplugin.py
index c9176348..d0c87033 100644
--- a/src/tox/_pytestplugin.py
+++ b/src/tox/_pytestplugin.py
@@ -491,7 +491,14 @@ def create_files(base, filedefs):
             create_files(base.ensure(key, dir=1), value)
         elif isinstance(value, six.string_types):
             s = textwrap.dedent(value)
-            base.join(key).write(s)
+
+            if not isinstance(s, six.text_type):
+                if not isinstance(s, six.binary_type):
+                    s = str(s)
+                else:
+                    s = six.ensure_text(s)
+
+            base.join(key).write_text(s, encoding="UTF-8")
 
 
 @pytest.fixture()
diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py
index fe11224c..b4bd24fb 100644
--- a/tests/unit/config/test_config.py
+++ b/tests/unit/config/test_config.py
@@ -1,3 +1,4 @@
+# coding=utf-8
 import os
 import re
 import sys
@@ -3556,7 +3557,10 @@ def test_config_via_pyproject_legacy(initproj):
     initproj(
         "config_via_pyproject_legacy-0.5",
         filedefs={
-            "pyproject.toml": '''
+            "pyproject.toml": u'''
+                [project]
+                description = "Factory ⸻ A code generator 🏭"
+                authors = [{name = "Łukasz Langa"}]
                 [tool.tox]
                 legacy_tox_ini = """
                 [tox]
diff --git a/tests/unit/config/test_config_parallel.py b/tests/unit/config/test_config_parallel.py
index 785b7710..0e42a2c5 100644
--- a/tests/unit/config/test_config_parallel.py
+++ b/tests/unit/config/test_config_parallel.py
@@ -38,7 +38,7 @@ def test_parallel_number_negative(newconfig, capsys):
     assert "value must be positive" in err
 
 
-def test_depends(newconfig, capsys):
+def test_depends(newconfig):
     config = newconfig(
         """\
         [tox]
@@ -49,7 +49,7 @@ def test_depends(newconfig, capsys):
     assert config.envconfigs["py"].depends == ("py37", "py36")
 
 
-def test_depends_multi_row_facotr(newconfig, capsys):
+def test_depends_multi_row_facotr(newconfig):
     config = newconfig(
         """\
         [tox]
@@ -61,7 +61,7 @@ def test_depends_multi_row_facotr(newconfig, capsys):
     assert config.envconfigs["py"].depends == ("py37", "py36-a", "py36-b")
 
 
-def test_depends_factor(newconfig, capsys):
+def test_depends_factor(newconfig):
     config = newconfig(
         """\
         [tox]
diff --git a/tests/unit/session/test_provision.py b/tests/unit/session/test_provision.py
index cb7bd9b5..cf2ded10 100644
--- a/tests/unit/session/test_provision.py
+++ b/tests/unit/session/test_provision.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import, unicode_literals
 
+import json
 import os
 import shutil
 import subprocess
@@ -185,6 +186,99 @@ def test_provision_cli_args_not_ignored_if_provision_false(cmd, initproj):
     result.assert_fail(is_run_test_env=False)
 
 
+parametrize_json_path = pytest.mark.parametrize("json_path", [None, "missing.json"])
+
+
+@parametrize_json_path
+def test_provision_does_not_fail_with_no_provision_no_reason(cmd, initproj, json_path):
+    p = initproj("test-0.1", {"tox.ini": "[tox]"})
+    result = cmd("--no-provision", *([json_path] if json_path else []))
+    result.assert_success(is_run_test_env=True)
+    assert not (p / "missing.json").exists()
+
+
+@parametrize_json_path
+def test_provision_fails_with_no_provision_next_tox(cmd, initproj, next_tox_major, json_path):
+    p = initproj(
+        "test-0.1",
+        {
+            "tox.ini": """\
+                             [tox]
+                             minversion = {}
+                             """.format(
+                next_tox_major,
+            )
+        },
+    )
+    result = cmd("--no-provision", *([json_path] if json_path else []))
+    result.assert_fail(is_run_test_env=False)
+    if json_path:
+        missing = json.loads((p / json_path).read_text("utf-8"))
+        assert missing["minversion"] == next_tox_major
+
+
+@parametrize_json_path
+def test_provision_fails_with_no_provision_missing_requires(cmd, initproj, json_path):
+    p = initproj(
+        "test-0.1",
+        {
+            "tox.ini": """\
+                             [tox]
+                             requires =
+                                 virtualenv > 99999999
+                             """
+        },
+    )
+    result = cmd("--no-provision", *([json_path] if json_path else []))
+    result.assert_fail(is_run_test_env=False)
+    if json_path:
+        missing = json.loads((p / json_path).read_text("utf-8"))
+        assert missing["requires"] == ["virtualenv > 99999999"]
+
+
+@parametrize_json_path
+def test_provision_does_not_fail_with_satisfied_requires(cmd, initproj, next_tox_major, json_path):
+    p = initproj(
+        "test-0.1",
+        {
+            "tox.ini": """\
+                             [tox]
+                             minversion = 0
+                             requires =
+                                 setuptools > 2
+                                 pip > 3
+                             """
+        },
+    )
+    result = cmd("--no-provision", *([json_path] if json_path else []))
+    result.assert_success(is_run_test_env=True)
+    assert not (p / "missing.json").exists()
+
+
+@parametrize_json_path
+def test_provision_fails_with_no_provision_combined(cmd, initproj, next_tox_major, json_path):
+    p = initproj(
+        "test-0.1",
+        {
+            "tox.ini": """\
+                             [tox]
+                             minversion = {}
+                             requires =
+                                 setuptools > 2
+                                 pip > 3
+                             """.format(
+                next_tox_major,
+            )
+        },
+    )
+    result = cmd("--no-provision", *([json_path] if json_path else []))
+    result.assert_fail(is_run_test_env=False)
+    if json_path:
+        missing = json.loads((p / json_path).read_text("utf-8"))
+        assert missing["minversion"] == next_tox_major
+        assert missing["requires"] == ["setuptools > 2", "pip > 3"]
+
+
 @pytest.fixture(scope="session")
 def wheel(tmp_path_factory):
     """create a wheel for a project"""