Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/6378.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix for parsing and using the star specifier in install and update/upgrade commands.
62 changes: 45 additions & 17 deletions pipenv/routines/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,22 +160,44 @@ def check_version_conflicts(
Returns set of conflicting packages.
"""
conflicts = set()
try:
new_version_obj = Version(new_version)
except InvalidVersion:
new_version_obj = SpecifierSet(new_version)

# Handle various wildcard patterns
if new_version == "*":
# Full wildcard - matches any version
# We'll use a very permissive specifier
new_version_obj = SpecifierSet(">=0.0.0")
elif new_version.endswith(".*"):
# Major version wildcard like '2.*'
try:
major = int(new_version[:-2])
new_version_obj = SpecifierSet(f">={major},<{major+1}")
except (ValueError, TypeError):
# If we can't parse the major version, use a permissive specifier
new_version_obj = SpecifierSet(">=0.0.0")
else:
try:
new_version_obj = Version(new_version)
except InvalidVersion:
try:
# Try to parse as a specifier set
new_version_obj = SpecifierSet(new_version)
except Exception: # noqa: PERF203
# If we can't parse the version at all, return no conflicts
# This allows the installation to proceed and let pip handle it
return conflicts

for dependent, req_version in reverse_deps.get(package_name, set()):
if req_version == "Any":
continue

try:
specifier_set = SpecifierSet(req_version)
specifier_set = SpecifierSet(req_version)
# For Version objects, we check if the specifier contains the version
# For SpecifierSet objects, we need to check compatibility differently
if isinstance(new_version_obj, Version):
if not specifier_set.contains(new_version_obj):
conflicts.add(dependent)
except Exception: # noqa: PERF203
# If we can't parse the version requirement, assume it's a conflict
conflicts.add(dependent)
# Otherwise this is a complex case where we have a specifier vs specifier ...
# We'll let the resolver figure those out

return conflicts

Expand Down Expand Up @@ -296,15 +318,21 @@ def _detect_conflicts(package_args, reverse_deps, lockfile):
"""Detect version conflicts in package arguments."""
conflicts_found = False
for package in package_args:
# Handle both == and = version specifiers
if "==" in package:
name, version = package.split("==")
conflicts = check_version_conflicts(name, version, reverse_deps, lockfile)
if conflicts:
conflicts_found = True
err.print(
f"[red bold]Error[/red bold]: Updating [bold]{name}[/bold] "
f"to version {version} would create conflicts with: {', '.join(sorted(conflicts))}"
)
name, version = package.split("==", 1) # Split only on the first occurrence
elif "=" in package and not package.startswith("-e"): # Avoid matching -e flag
name, version = package.split("=", 1) # Split only on the first occurrence
else:
continue # Skip packages without version specifiers

conflicts = check_version_conflicts(name, version, reverse_deps, lockfile)
if conflicts:
conflicts_found = True
err.print(
f"[red bold]Error[/red bold]: Updating [bold]{name}[/bold] "
f"to version {version} would create conflicts with: {', '.join(sorted(conflicts))}"
)

return conflicts_found

Expand Down
88 changes: 88 additions & 0 deletions tests/integration/test_install_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,91 @@ def test_install_uri_with_extras(pipenv_instance_pypi):
assert c.returncode == 0
assert "plette" in p.lockfile["default"]
assert "cerberus" in p.lockfile["default"]


@pytest.mark.star
@pytest.mark.install
def test_install_major_version_star_specifier(pipenv_instance_pypi):
"""Test that major version star specifiers like '1.*' work correctly."""
with pipenv_instance_pypi() as p:
with open(p.pipfile_path, "w") as f:
contents = f"""
[[source]]
url = "{p.index_url}"
verify_ssl = true
name = "pypi"

[packages]
six = "==1.*"
"""
f.write(contents)
c = p.pipenv("install")
assert c.returncode == 0
assert "six" in p.lockfile["default"]


@pytest.mark.star
@pytest.mark.install
def test_install_full_wildcard_specifier(pipenv_instance_pypi):
"""Test that full wildcard specifiers '*' work correctly."""
with pipenv_instance_pypi() as p:
with open(p.pipfile_path, "w") as f:
contents = f"""
[[source]]
url = "{p.index_url}"
verify_ssl = true
name = "pypi"

[packages]
requests = "*"
"""
f.write(contents)
c = p.pipenv("install")
assert c.returncode == 0
assert "requests" in p.lockfile["default"]


@pytest.mark.star
@pytest.mark.install
def test_install_single_equals_star_specifier(pipenv_instance_pypi):
"""Test that single equals star specifiers like '=8.*' work correctly."""
with pipenv_instance_pypi() as p:
with open(p.pipfile_path, "w") as f:
contents = f"""
[[source]]
url = "{p.index_url}"
verify_ssl = true
name = "pypi"

[packages]
requests = "==2.*"
"""
f.write(contents)
c = p.pipenv("install")
assert c.returncode == 0
assert "requests" in p.lockfile["default"]
assert p.lockfile["default"]["requests"]["version"].startswith("==2.")


@pytest.mark.star
@pytest.mark.install
def test_install_command_with_star_specifier(pipenv_instance_pypi):
"""Test that star specifiers work when used in the install command."""
with pipenv_instance_pypi() as p:
# Initialize pipfile first
with open(p.pipfile_path, "w") as f:
contents = f"""
[[source]]
url = "{p.index_url}"
verify_ssl = true
name = "pypi"

[packages]
"""
f.write(contents)

# Test with single equals and star specifier
c = p.pipenv("install urllib3==1.*")
assert c.returncode == 0
assert "urllib3" in p.lockfile["default"]