Repository: jazzband/pip-tools (python)

Issue:
pip-compile --no-header <blank requirements.in> neither creates nor overwrites requirements.txt
If `requirements.in` is empty, running `pip-compile` will create or clobber `requirements.txt`, thereby removing all requirements, but if `--no-header` is passed to `pip-compile`, `requirements.txt` will not be affected in any way, no matter its state.

#### Environment Versions

1. Linux
1. Python version: `3.7.4`
1. pip version:  `19.2.3`
1. pip-tools version: `4.1.0`

#### Steps to replicate

```bash
touch requirements.in
pip-compile --no-header
# no txt file created (unexpected)
pip-compile
# txt file created (as expected)
echo plumbum > requirements.in
pip-compile
echo > requirements.in
pip-compile --no-header
cat requirements.txt
```
full, unexpected:
```python
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile
#
plumbum==1.6.7
```

Pull request description:
Ensure pip-compile --no-header <blank requirements.in> creates/overwrites requirements.txt
Before, in the case where the writer should generate a blank output document (e.g. `requirements.txt`), no new file would be created, and any existing file with that name would be left unchanged. With this change, a blank document will created and overwritten if it exists.

This is implemented by having the writer keep track of whether it yields any lines, and if not, yielding an empty string (where before it would yield nothing).

A simpler solution would be to always yield an extra empty string at the end of `_iter_lines`, but that would result in a bit of awkward space at the bottom of all the `txt`s, and if that were fixed it would involve leaking the composition logic outside of `_iter_lines`.

**Changelog-friendly one-liner**: `pip-compile --no-header <blank requirements.in>` now creates/overwrites `requirements.txt`.

Fixes #900.

@atugushev I hereby request a review. If this does not satisfy the "Requested a review from another contributor" requirement, please tell me what does. Thanks!

##### Contributor checklist

- [X] Provided the tests for the changes.
- [X] Requested a review from another contributor.
- [X] Gave a clear one-line description in the PR (that the maintainers can add to CHANGELOG.md on release).
- [X] Assign the PR to an existing or new milestone for the target version (following [Semantic Versioning](https://blog.versioneye.com/2014/01/16/semantic-versioning/)).

Patch:
diff --git a/piptools/writer.py b/piptools/writer.py
index f318e96..f54b58f 100644
--- a/piptools/writer.py
+++ b/piptools/writer.py
@@ -146,10 +146,14 @@ class OutputWriter(object):
         warn_uninstallable = False
         has_hashes = hashes and any(hash for hash in hashes.values())
 
+        yielded = False
+
         for line in self.write_header():
             yield line
+            yielded = True
         for line in self.write_flags():
             yield line
+            yielded = True
 
         unsafe_requirements = (
             {r for r in results if r.name in UNSAFE_PACKAGES}
@@ -158,24 +162,26 @@ class OutputWriter(object):
         )
         packages = {r for r in results if r.name not in UNSAFE_PACKAGES}
 
-        packages = sorted(packages, key=self._sort_key)
-
-        for ireq in packages:
-            if has_hashes and not hashes.get(ireq):
-                yield MESSAGE_UNHASHED_PACKAGE
-                warn_uninstallable = True
-            line = self._format_requirement(
-                ireq,
-                reverse_dependencies,
-                primary_packages,
-                markers.get(key_from_ireq(ireq)),
-                hashes=hashes,
-            )
-            yield line
+        if packages:
+            packages = sorted(packages, key=self._sort_key)
+            for ireq in packages:
+                if has_hashes and not hashes.get(ireq):
+                    yield MESSAGE_UNHASHED_PACKAGE
+                    warn_uninstallable = True
+                line = self._format_requirement(
+                    ireq,
+                    reverse_dependencies,
+                    primary_packages,
+                    markers.get(key_from_ireq(ireq)),
+                    hashes=hashes,
+                )
+                yield line
+            yielded = True
 
         if unsafe_requirements:
             unsafe_requirements = sorted(unsafe_requirements, key=self._sort_key)
             yield ""
+            yielded = True
             if has_hashes and not self.allow_unsafe:
                 yield MESSAGE_UNSAFE_PACKAGES_UNPINNED
                 warn_uninstallable = True
@@ -195,6 +201,10 @@ class OutputWriter(object):
                 else:
                     yield req
 
+        # Yield even when there's no real content, so that blank files are written
+        if not yielded:
+            yield ""
+
         if warn_uninstallable:
             log.warning(MESSAGE_UNINSTALLABLE)
 


Test patch:
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
index 800c100..a44b6e2 100644
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -801,3 +801,29 @@ def test_dry_run_doesnt_touch_output_file(
     # The output file must not be touched
     after_compile_mtime = os.stat("requirements.txt").st_mtime
     assert after_compile_mtime == before_compile_mtime
+
+
+@pytest.mark.parametrize(
+    "empty_input_pkg, prior_output_pkg",
+    [
+        ("", ""),
+        ("", "small-fake-a==0.1\n"),
+        ("# Nothing to see here", ""),
+        ("# Nothing to see here", "small-fake-a==0.1\n"),
+    ],
+)
+def test_empty_input_file_no_header(runner, empty_input_pkg, prior_output_pkg):
+    """
+    Tests pip-compile creates an empty requirements.txt file,
+    given --no-header and empty requirements.in
+    """
+    with open("requirements.in", "w") as req_in:
+        req_in.write(empty_input_pkg)  # empty input file
+
+    with open("requirements.txt", "w") as req_txt:
+        req_txt.write(prior_output_pkg)
+
+    runner.invoke(cli, ["--no-header", "requirements.in"])
+
+    with open("requirements.txt", "r") as req_txt:
+        assert req_txt.read().strip() == ""