Repository: jazzband/pip-tools

Issue:
pip-sync errors when merging multiple requirements.txt files that point at the same editable install
pip-sync raises an `AttributeError: 'NoneType' object has no attribute 'specifier'` error when you try and run it with multiple requirements files that each include an editable install for the same package.

#### Environment Versions

1. OS Type: Linux
1. Python version: Python 3.9.11
1. pip version: pip 22.3
1. pip-tools version: pip-compile, version 6.9.0

#### Steps to replicate
1. Create a setup.py, dev_requirements.in, requirements.in file
```
# setup.py
from setuptools import find_packages, setup
setup(name="a", version="0.0.1", packages=find_packages())

# dev_requirements.in
-e file:.

# requirements.in
-e file:.
```
2. Run pip-compile
```
pip-compile requirements.in
pip-compile dev_requirements.in
```
3. Run pip-sync
```
pip-sync requirements.in  dev_requirements.in
```

#### Expected result
I expected the editable package to install once. Similar to running `pip install -r requirements.txt -r dev_requirements.txt`


#### Actual result
`pip-sync` raised an error
Full stack trace
```
➜  scratch pip-sync  requirements.txt dev_requirements.txt 
Traceback (most recent call last):
  File "/home/vivek/.pyenv/versions/3.9.11/bin/pip-sync", line 8, in <module>
    sys.exit(cli())
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/click/core.py", line 1130, in __call__
    return self.main(*args, **kwargs)
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/piptools/scripts/sync.py", line 146, in cli
    merged_requirements = sync.merge(requirements, ignore_conflicts=force)
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/piptools/sync.py", line 115, in merge
    if ireq.specifier != existing_ireq.specifier:
  File "/home/vivek/.pyenv/versions/3.9.11/lib/python3.9/site-packages/pip/_internal/req/req_install.py", line 245, in specifier
    return self.req.specifier
AttributeError: 'NoneType' object has no attribute 'specifier'
```

...



Discussion:
Alas, can't reproduce the bug. Could you show the `pip --version` output? This does look like you're using some old pip version.
```
➜  /src git:(main) pip --version
pip 22.3 from /home/vivek/.pyenv/versions/3.9.11/envs/chrono/lib/python3.9/site-packages/pip (python 3.9)
```

Would it help if I pushed a Docker image with the repro in it?

Resolving patch:
diff --git a/piptools/sync.py b/piptools/sync.py
--- a/piptools/sync.py
+++ b/piptools/sync.py
@@ -104,7 +104,11 @@ def merge(
                 if existing_ireq:
                     # NOTE: We check equality here since we can assume that the
                     # requirements are all pinned
-                    if ireq.specifier != existing_ireq.specifier:
+                    if (
+                        ireq.req
+                        and existing_ireq.req
+                        and ireq.specifier != existing_ireq.specifier
+                    ):
                         raise IncompatibleRequirements(ireq, existing_ireq)
 
             # TODO: Always pick the largest specifier in case of a conflict


Test patch:
diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py
--- a/tests/test_cli_sync.py
+++ b/tests/test_cli_sync.py
@@ -3,6 +3,7 @@
 import os
 import subprocess
 import sys
+from pathlib import Path
 from unittest import mock
 
 import pytest
@@ -128,6 +129,31 @@ def test_merge_error(req_lines, should_raise, runner):
         assert out.exit_code == 1
 
 
+@pytest.mark.parametrize(
+    "req_line",
+    (
+        "file:.",
+        "-e file:.",
+    ),
+)
+@mock.patch("piptools.sync.run")
+def test_merge_no_name_urls(run, req_line, runner):
+    """
+    Test sync succeeds when merging requirements that lack names.
+    """
+    reqs_paths = [
+        Path(name).absolute() for name in ("requirements.txt", "dev_requirements.txt")
+    ]
+
+    for reqs_path in reqs_paths:
+        with reqs_path.open("w") as req_out:
+            req_out.write(f"{req_line} \n")
+
+    out = runner.invoke(cli, [str(path) for path in reqs_paths])
+    assert out.exit_code == 0
+    assert run.call_count == 2
+
+
 @pytest.mark.parametrize(
     ("cli_flags", "expected_install_flags"),
     (
diff --git a/tests/test_sync.py b/tests/test_sync.py
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -115,6 +115,26 @@ def test_merge_urls(from_line):
     )
 
 
+@pytest.mark.parametrize(
+    "install_req",
+    (
+        "from_line",
+        "from_editable",
+    ),
+)
+def test_merge_no_name_urls(install_req, request):
+    install_req = request.getfixturevalue(install_req)
+    url = "file:///example.zip"
+    requirements = [
+        install_req(url),
+        install_req(url),
+    ]
+
+    assert Counter(requirements[1:]) == Counter(
+        merge(requirements, ignore_conflicts=False)
+    )
+
+
 def test_diff_should_do_nothing():
     installed = []  # empty env
     reqs = []  # no requirements