Repository: jazzband/pip-tools

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
```


Discussion:
Hello @AndydeCleyre,

Thanks for the report! That's a good catch!
I'll submit a pull request for this today. Would it be preferable for the writer line iterator to simply always yield a final extra newline, or to actually keep track of whether or not it has yielded any real lines along the way, and only add the newline in that case?

@atugushev 

Resolving patch:
diff --git a/piptools/writer.py b/piptools/writer.py
--- a/piptools/writer.py
+++ b/piptools/writer.py
@@ -146,10 +146,14 @@ def _iter_lines(
         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 @@ def _iter_lines(
         )
         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 @@ def _iter_lines(
                 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
--- 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() == ""