Repository: jazzband/pip-tools

Issue:
Output --no-binary and --only-binary options to preserve pip behavior
<!-- Describe the issue briefly here. -->

#### Environment Versions

1. OS Type: Linux
1. Python version: 3.12.3
1. pip version: 24.0
1. pip-tools version: 7.4.1

#### Steps to replicate

1. Compile using `--pip-args='--only-binary=:all: --no-binary=library'
2. See that compiled requirements list the `--no-binary=library` option first, `--only-binary=:all:` option second.
3. When attempting to install from these requirements the `--no-binary` is wiped out by the `--only-binary=:all:`

#### Expected result

The resulting requirements contain `--no-binary` and `--only-binary` options that have the same behavior as input options.

#### Actual result

Requirements don't have the same behavior as input options.

This improvement matters because using --no-binary/--only-binary is the best way for users to control the amount of potential code execution that happens during the installation process. I have a local fix that I plan on creating a PR.



Discussion:
Are you talking about the comment headers in the resulting constraint files?
I'm talking about a [file like this one](https://github.com/python/release-tools/blob/master/requirements.txt), I discovered this while using `--only-binary=:all:` for python/release-tools:

```
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
#    pip-compile --generate-hashes --output-file=requirements.txt --pip-args='--only-binary=:all: --no-binary=grapheme,grpclib' requirements.in
#
--no-binary grapheme
--no-binary grpclib
--only-binary :all:

... (all the requirements)
```

Note the order of `--pip-args` and that the combination of `--no-binary` and `--only-binary` in the resulting `requirements.txt` don't produce the same behavior in pip.

Resolving patch:
diff --git a/piptools/writer.py b/piptools/writer.py
--- a/piptools/writer.py
+++ b/piptools/writer.py
@@ -148,9 +148,27 @@ def write_trusted_hosts(self) -> Iterator[str]:
                 yield f"--trusted-host {trusted_host}"
 
     def write_format_controls(self) -> Iterator[str]:
-        for nb in dedup(sorted(self.format_control.no_binary)):
+        # The ordering of output needs to preserve the behavior of pip's
+        # FormatControl.get_allowed_formats(). The behavior is the following:
+        #
+        #   * Parsing of CLI options happens first to last.
+        #   * --only-binary takes precedence over --no-binary
+        #   * Package names take precedence over :all:
+        #   * We'll never see :all: in both due to mutual exclusion.
+        #
+        # So in summary, we want to emit :all: first and then package names later.
+        no_binary = self.format_control.no_binary.copy()
+        only_binary = self.format_control.only_binary.copy()
+
+        if ":all:" in no_binary:
+            yield "--no-binary :all:"
+            no_binary.remove(":all:")
+        if ":all:" in only_binary:
+            yield "--only-binary :all:"
+            only_binary.remove(":all:")
+        for nb in dedup(sorted(no_binary)):
             yield f"--no-binary {nb}"
-        for ob in dedup(sorted(self.format_control.only_binary)):
+        for ob in dedup(sorted(only_binary)):
             yield f"--only-binary {ob}"
 
     def write_find_links(self) -> Iterator[str]:


Test patch:
diff --git a/tests/test_writer.py b/tests/test_writer.py
--- a/tests/test_writer.py
+++ b/tests/test_writer.py
@@ -301,6 +301,41 @@ def test_write_format_controls(writer):
     assert lines == expected_lines
 
 
+@pytest.mark.parametrize(
+    ("no_binary", "only_binary", "expected_lines"),
+    (
+        (
+            [":all:"],
+            ["django"],
+            [
+                "--no-binary :all:",
+                "--only-binary django",
+            ],
+        ),
+        (
+            ["django"],
+            [":all:"],
+            [
+                "--only-binary :all:",
+                "--no-binary django",
+            ],
+        ),
+    ),
+)
+def test_write_format_controls_all(writer, no_binary, only_binary, expected_lines):
+    """
+    Tests --no-binary/--only-binary options
+    with the value of :all:. We want to preserve
+    the FormatControl behavior so we emit :all:
+    first before packages.
+    """
+
+    writer.format_control = FormatControl(no_binary=no_binary, only_binary=only_binary)
+    lines = list(writer.write_format_controls())
+
+    assert lines == expected_lines
+
+
 @pytest.mark.parametrize(
     ("index_urls", "expected_lines"),
     (