diff --git a/piptools/resolver.py b/piptools/resolver.py index 1910b9182..b6d2e1378 100644 --- a/piptools/resolver.py +++ b/piptools/resolver.py @@ -502,7 +502,18 @@ def __init__( self.unsafe_constraints: set[InstallRequirement] = set() self.existing_constraints = existing_constraints - self._constraints_map = {key_from_ireq(ireq): ireq for ireq in constraints} + + # Categorize InstallRequirements into sets by key + constraints_sets: DefaultDict[ + str, set[InstallRequirement] + ] = collections.defaultdict(set) + for ireq in constraints: + constraints_sets[key_from_ireq(ireq)].add(ireq) + # Collapse each set of InstallRequirements using combine_install_requirements + self._constraints_map = { + ireq_key: combine_install_requirements(ireqs) + for ireq_key, ireqs in constraints_sets.items() + } # Make sure there is no enabled legacy resolver options.deprecated_features_enabled = omit_list_value( @@ -759,9 +770,14 @@ def _get_install_requirement_from_candidate( ireq_key = key_from_ireq(ireq) pinned_ireq._required_by = reverse_dependencies.get(ireq_key, set()) - # Save source for annotation - source_ireq = self._constraints_map.get(ireq_key) - if source_ireq is not None: - pinned_ireq._source_ireqs = [source_ireq] + # Save sources for annotation + constraint_ireq = self._constraints_map.get(ireq_key) + if constraint_ireq is not None: + if hasattr(constraint_ireq, "_source_ireqs"): + # If the constraint is combined (has _source_ireqs), use those + pinned_ireq._source_ireqs = constraint_ireq._source_ireqs + else: + # Otherwise (the constraint is not combined) it is the source + pinned_ireq._source_ireqs = [constraint_ireq] return pinned_ireq diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py index df677d8d6..84345886c 100644 --- a/tests/test_cli_compile.py +++ b/tests/test_cli_compile.py @@ -1909,6 +1909,42 @@ def test_upgrade_package_doesnt_remove_annotation(pip_conf, runner): ) +@pytest.mark.parametrize(("num_inputs"), (2, 3, 10)) +def test_many_inputs_includes_all_annotations(pip_conf, runner, tmp_path, num_inputs): + """ + Tests that an entry required by multiple input files is attributed to all of them in the + annotation. + See: https://github.com/jazzband/pip-tools/issues/1853 + """ + req_ins = [tmp_path / f"requirements{n:02d}.in" for n in range(num_inputs)] + for req_in in req_ins: + req_in.write_text("small-fake-a==0.1\n") + + out = runner.invoke( + cli, + [ + "--output-file", + "-", + "--quiet", + "--no-header", + "--no-emit-find-links", + ] + + [str(r) for r in req_ins], + ) + assert out.exit_code == 0, out.stderr + assert ( + out.stdout + == "\n".join( + [ + "small-fake-a==0.1", + " # via", + ] + + [f" # -r {req_in}" for req_in in req_ins] + ) + + "\n" + ) + + @pytest.mark.parametrize( "options", (