diff --git a/src/poetry/console/commands/add.py b/src/poetry/console/commands/add.py index 81daf97407e..3707f9df265 100644 --- a/src/poetry/console/commands/add.py +++ b/src/poetry/console/commands/add.py @@ -126,6 +126,7 @@ class AddCommand(InstallerCommand, InitCommand): def handle(self) -> int: from poetry.core.constraints.version import parse_constraint + from poetry.core.constraints.version import VersionUnion from tomlkit import array from tomlkit import inline_table from tomlkit import nl @@ -318,29 +319,47 @@ def handle(self) -> int: ) self.poetry.package.add_dependency(dependency) + # Check if the constraint is a union (e.g., ^4|^6) which cannot be + # represented in PEP 508 format + is_version_union = isinstance(dependency.constraint, VersionUnion) + if use_project_section or use_groups_section: pep_section = ( project_section if use_project_section else groups_content[group] ) - try: - index = project_dependency_names.index(canonical_constraint_name) - except ValueError: - pep_section.append(dependency.to_pep_508()) + + if is_version_union: + # Union constraints (e.g., ^4|^6) cannot be represented in PEP 508 + # Only write to tool.poetry.dependencies + self.line_error( + f"The constraint {constraint} for" + f" {constraint_name} uses union syntax (|) which cannot" + " be represented in PEP 508 format." + ) + self.line_error( + "Adding to [tool.poetry.dependencies] only." + ) + poetry_constraint = constraint else: - pep_section[index] = dependency.to_pep_508() - - # create a second constraint for tool.poetry.dependencies with keys - # that cannot be stored in the project section - poetry_constraint: dict[str, Any] = inline_table() - if not isinstance(constraint, str): - for key in ["allow-prereleases", "develop", "source"]: - if value := constraint.get(key): - poetry_constraint[key] = value - if poetry_constraint: - # add marker related keys to avoid ambiguity - for key in ["python", "platform"]: + try: + index = project_dependency_names.index(canonical_constraint_name) + except ValueError: + pep_section.append(dependency.to_pep_508()) + else: + pep_section[index] = dependency.to_pep_508() + + # create a second constraint for tool.poetry.dependencies with keys + # that cannot be stored in the project section + poetry_constraint = inline_table() + if not isinstance(constraint, str): + for key in ["allow-prereleases", "develop", "source"]: if value := constraint.get(key): poetry_constraint[key] = value + if poetry_constraint: + # add marker related keys to avoid ambiguity + for key in ["python", "platform"]: + if value := constraint.get(key): + poetry_constraint[key] = value else: poetry_constraint = constraint diff --git a/tests/console/commands/test_add.py b/tests/console/commands/test_add.py index 9c2fdeab9e2..9ce367948a1 100644 --- a/tests/console/commands/test_add.py +++ b/tests/console/commands/test_add.py @@ -1998,3 +1998,165 @@ def test_add_poetry_dependencies_if_necessary( "allow-prereleases": True, } } + + +def test_add_union_constraint_skips_project_dependencies( + project_factory: ProjectFactory, + repo: TestRepository, + command_tester_factory: CommandTesterFactory, +) -> None: + """ + Test that union constraints (e.g., ^0.1|^0.3) are NOT written to + project.dependencies since PEP 508 does not support the || operator. + Instead, they should only be written to tool.poetry.dependencies. + + Note: We use non-adjacent versions (^0.1|^0.3) because adjacent versions + like ^0.1|^0.2 get merged into a single range (>=0.1,<0.3). + + Regression test for issue #10569. + """ + pyproject_content = """\ + [project] + name = "simple-project" + version = "1.2.3" + dependencies = [ + "tomlkit >= 0.5", + ] + """ + + poetry = project_factory(name="simple-project", pyproject_content=pyproject_content) + + # Add non-adjacent versions so they don't get merged into a single range + repo.add_package(get_package("cachy", "0.1.0")) + repo.add_package(get_package("cachy", "0.3.0")) + + tester = command_tester_factory("add", poetry=poetry) + tester.execute("cachy@^0.1|^0.3") + + # Verify the warning is shown + error_output = tester.io.fetch_error() + assert "union syntax" in error_output + assert "cannot be represented in PEP 508" in error_output + assert "[tool.poetry.dependencies]" in error_output + + updated_pyproject: dict[str, Any] = poetry.file.read() + + # The union constraint should NOT be in project.dependencies + project_deps = updated_pyproject["project"]["dependencies"] + assert len(project_deps) == 1 # Only the original tomlkit dependency + assert "tomlkit >= 0.5" in project_deps[0] + + # The union constraint SHOULD be in tool.poetry.dependencies + assert "cachy" in updated_pyproject["tool"]["poetry"]["dependencies"] + assert updated_pyproject["tool"]["poetry"]["dependencies"]["cachy"] == "^0.1|^0.3" + + +def test_add_union_constraint_skips_dependency_groups( + project_factory: ProjectFactory, + repo: TestRepository, + command_tester_factory: CommandTesterFactory, +) -> None: + """ + Test that union constraints are NOT written to dependency-groups (PEP 735) + since PEP 508 does not support the || operator. + Instead, they should only be written to tool.poetry.group..dependencies. + + Note: We use non-adjacent versions (^0.1|^0.3) because adjacent versions + like ^0.1|^0.2 get merged into a single range. + + Regression test for issue #10569. + """ + pyproject_content = """\ + [project] + name = "simple-project" + version = "1.2.3" + + [dependency-groups] + dev = [ + "tomlkit >= 0.5", + ] + """ + + poetry = project_factory(name="simple-project", pyproject_content=pyproject_content) + + # Add non-adjacent versions so they don't get merged into a single range + repo.add_package(get_package("cachy", "0.1.0")) + repo.add_package(get_package("cachy", "0.3.0")) + + tester = command_tester_factory("add", poetry=poetry) + tester.execute("cachy@^0.1|^0.3 --group dev") + + # Verify the warning is shown + error_output = tester.io.fetch_error() + assert "union syntax" in error_output + assert "cannot be represented in PEP 508" in error_output + + updated_pyproject: dict[str, Any] = poetry.file.read() + + # The union constraint should NOT be in dependency-groups + dev_deps = updated_pyproject["dependency-groups"]["dev"] + assert len(dev_deps) == 1 # Only the original tomlkit dependency + assert "tomlkit >= 0.5" in dev_deps[0] + + # The union constraint SHOULD be in tool.poetry.group.dev.dependencies + assert "cachy" in updated_pyproject["tool"]["poetry"]["group"]["dev"]["dependencies"] + assert ( + updated_pyproject["tool"]["poetry"]["group"]["dev"]["dependencies"]["cachy"] + == "^0.1|^0.3" + ) + + +@pytest.mark.parametrize( + "constraint", + [ + "^0.1|^0.3", # No spaces around pipe + "^0.1 | ^0.3", # Spaces around pipe + "^0.1 | ^0.3", # Multiple spaces around pipe + "^0.1| ^0.3", # Space only on right + "^0.1 |^0.3", # Space only on left + ], +) +def test_add_union_constraint_with_various_spacing( + constraint: str, + project_factory: ProjectFactory, + repo: TestRepository, + command_tester_factory: CommandTesterFactory, +) -> None: + """ + Test that union constraints with various spacing patterns around the pipe + operator are all correctly detected and skipped from project.dependencies. + + Regression test for issue #10569. + """ + pyproject_content = """\ + [project] + name = "simple-project" + version = "1.2.3" + dependencies = [ + "tomlkit >= 0.5", + ] + """ + + poetry = project_factory(name="simple-project", pyproject_content=pyproject_content) + + # Add non-adjacent versions so they don't get merged into a single range + repo.add_package(get_package("cachy", "0.1.0")) + repo.add_package(get_package("cachy", "0.3.0")) + + tester = command_tester_factory("add", poetry=poetry) + # Quote the constraint to handle spaces in the version string + tester.execute(f'"cachy@{constraint}"') + + # Verify the warning is shown + error_output = tester.io.fetch_error() + assert "union syntax" in error_output + assert "cannot be represented in PEP 508" in error_output + + updated_pyproject: dict[str, Any] = poetry.file.read() + + # The union constraint should NOT be in project.dependencies + project_deps = updated_pyproject["project"]["dependencies"] + assert len(project_deps) == 1 # Only the original tomlkit dependency + + # The union constraint SHOULD be in tool.poetry.dependencies + assert "cachy" in updated_pyproject["tool"]["poetry"]["dependencies"]