Repository: tox-dev/tox

Issue:
Regression for usage of pip options
## Issue

Since version 4.0 tox fails when encountering pip options as part of the dependencies.

The following example works fine with tox <4.0, but doesn't work with tox >=4.0 anymore.

```
$ cat requirements.txt
--no-binary pydantic
$ cat tox.ini 
[tox]
envlist = py3

[testenv]
deps = -r requirements.txt

allowlist_externals = echo

commands = echo "hello world"
$ tox --version
4.2.1 from /home/user/.local/lib/python3.10/site-packages/tox/__init__.py
$ tox -r
py3: remove tox env folder /dir/.tox/py3
py3: failed with argument --no-binary: invalid choice: 'pydantic' (choose from ':all:', ':none:') for tox env py within deps
  py3: FAIL code 1 (0.22 seconds)
  evaluation failed :( (0.33 seconds)
```

Discussion:

Patch:
diff --git a/docs/changelog/2814.bugfix.rst b/docs/changelog/2814.bugfix.rst
new file mode 100644
index 00000000..40d2f9c0
--- /dev/null
+++ b/docs/changelog/2814.bugfix.rst
@@ -0,0 +1,1 @@
+Handle properly pip ``--no-binary`` / ``--only-binary`` options in requirements.txt format files.
diff --git a/src/tox/tox_env/python/pip/req/args.py b/src/tox/tox_env/python/pip/req/args.py
index 23f98ec3..3fd1f677 100644
--- a/src/tox/tox_env/python/pip/req/args.py
+++ b/src/tox/tox_env/python/pip/req/args.py
@@ -33,8 +33,8 @@ def _global_options(parser: ArgumentParser) -> None:
     parser.add_argument("-r", "--requirement", action=AddUniqueAction, dest="requirements")
     parser.add_argument("-e", "--editable", action=AddUniqueAction, dest="editables")
     parser.add_argument("-f", "--find-links", action=AddUniqueAction)
-    parser.add_argument("--no-binary", choices=[":all:", ":none:"])  # TODO: colon separated package names
-    parser.add_argument("--only-binary", choices=[":all:", ":none:"])  # TODO: colon separated package names
+    parser.add_argument("--no-binary")
+    parser.add_argument("--only-binary")
     parser.add_argument("--prefer-binary", action="store_true", default=False)
     parser.add_argument("--require-hashes", action="store_true", default=False)
     parser.add_argument("--pre", action="store_true", default=False)
diff --git a/src/tox/tox_env/python/pip/req/file.py b/src/tox/tox_env/python/pip/req/file.py
index d8b29558..495ef194 100644
--- a/src/tox/tox_env/python/pip/req/file.py
+++ b/src/tox/tox_env/python/pip/req/file.py
@@ -15,7 +15,7 @@ import chardet
 from packaging.requirements import InvalidRequirement, Requirement
 
 from .args import build_parser
-from .util import VCS, get_url_scheme, is_url, url_to_path
+from .util import VCS, get_url_scheme, handle_binary_option, is_url, url_to_path
 
 # Matches environment variable-style values in '${MY_VARIABLE_1}' with the variable name consisting of only uppercase
 # letters, digits or the '_' (underscore). This follows the POSIX standard defined in IEEE Std 1003.1, 2013 Edition.
@@ -341,10 +341,17 @@ class RequirementsFile:
                 base_opt.trusted_hosts = []
             if host not in base_opt.trusted_hosts:
                 base_opt.trusted_hosts.append(host)
+
+        no_binary = base_opt.no_binary if hasattr(base_opt, "no_binary") else set()
+        only_binary = base_opt.only_binary if hasattr(base_opt, "only_binary") else set()
         if opt.no_binary:
-            base_opt.no_binary = opt.no_binary
+            handle_binary_option(opt.no_binary, no_binary, only_binary)
         if opt.only_binary:
-            base_opt.only_binary = opt.only_binary
+            handle_binary_option(opt.only_binary, only_binary, no_binary)
+        if no_binary:
+            base_opt.no_binary = no_binary
+        if only_binary:
+            base_opt.only_binary = only_binary
 
     @staticmethod
     def _break_args_options(line: str) -> tuple[str, str]:
diff --git a/src/tox/tox_env/python/pip/req/util.py b/src/tox/tox_env/python/pip/req/util.py
index 9914d111..2a110a1d 100644
--- a/src/tox/tox_env/python/pip/req/util.py
+++ b/src/tox/tox_env/python/pip/req/util.py
@@ -4,6 +4,8 @@ from __future__ import annotations
 from urllib.parse import urlsplit
 from urllib.request import url2pathname
 
+from packaging.utils import canonicalize_name
+
 VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"]
 VALID_SCHEMAS = ["http", "https", "file"] + VCS
 
@@ -26,3 +28,21 @@ def url_to_path(url: str) -> str:
         raise ValueError(f"non-local file URIs are not supported on this platform: {url!r}")
     path = url2pathname(netloc + path)
     return path
+
+
+def handle_binary_option(value: str, target: set[str], other: set[str]) -> None:
+    new = value.split(",")
+    while ":all:" in new:
+        other.clear()
+        target.clear()
+        target.add(":all:")
+        del new[: new.index(":all:") + 1]
+        if ":none:" not in new:
+            return
+    for name in new:
+        if name == ":none:":
+            target.clear()
+            continue
+        name = canonicalize_name(name)
+        other.discard(name)
+        target.add(name)


Test patch:
diff --git a/tests/tox_env/python/pip/req/test_file.py b/tests/tox_env/python/pip/req/test_file.py
index 9e4e51a2..d947510c 100644
--- a/tests/tox_env/python/pip/req/test_file.py
+++ b/tests/tox_env/python/pip/req/test_file.py
@@ -140,16 +140,43 @@ _REQ_FILE_TEST_CASES = [
         ["--use-feature", "2020-resolver", "--use-feature", "fast-deps"],
         id="use-feature multiple duplicate different line",
     ),
-    pytest.param("--no-binary :all:", {"no_binary": ":all:"}, [], ["--no-binary", ":all:"], id="no-binary all"),
-    pytest.param("--no-binary :none:", {"no_binary": ":none:"}, [], ["--no-binary", ":none:"], id="no-binary none"),
-    pytest.param("--only-binary :all:", {"only_binary": ":all:"}, [], ["--only-binary", ":all:"], id="only-binary all"),
+    pytest.param("--no-binary :all:", {"no_binary": {":all:"}}, [], ["--no-binary", {":all:"}], id="no-binary all"),
+    pytest.param("--no-binary :none:", {"no_binary": {":none:"}}, [], [], id="no-binary none"),
+    pytest.param(
+        "--only-binary :all:",
+        {"only_binary": {":all:"}},
+        [],
+        ["--only-binary", {":all:"}],
+        id="only-binary all",
+    ),
     pytest.param(
         "--only-binary :none:",
-        {"only_binary": ":none:"},
+        {"only_binary": {":none:"}},
+        [],
         [],
-        ["--only-binary", ":none:"],
         id="only-binary none",
     ),
+    pytest.param(
+        "--no-binary=foo --only-binary=foo",
+        {"only_binary": {"foo"}},
+        [],
+        ["--only-binary", {"foo"}],
+        id="no-binary-and-only-binary",
+    ),
+    pytest.param(
+        "--no-binary=foo --no-binary=:none:",
+        {},
+        [],
+        [],
+        id="no-binary-none-last",
+    ),
+    pytest.param(
+        "--only-binary=:none: --no-binary=foo",
+        {"no_binary": {"foo"}},
+        [],
+        ["--no-binary", {"foo"}],
+        id="no-binary-none-first",
+    ),
     pytest.param("####### example-requirements.txt #######", {}, [], [], id="comment"),
     pytest.param("\t##### Requirements without Version Specifiers ######", {}, [], [], id="tab and comment"),
     pytest.param("  # start", {}, [], [], id="space and comment"),
@@ -289,7 +316,7 @@ def test_req_file(tmp_path: Path, req: str, opts: dict[str, Any], requirements:
     req_file = RequirementsFile(requirements_txt, constraint=False)
     assert req_file.as_root_args == as_args
     assert str(req_file) == f"-r {requirements_txt}"
-    assert vars(req_file.options) == opts
+    assert vars(req_file.options) == (opts if {":none:"} not in opts.values() else {})
     found = [str(i) for i in req_file.requirements]
     assert found == requirements