Skip to content

Commit 6f2929b

Browse files
authored
refinements to update -- determine which packages may need upgrading (#6300)
* Fix issues affecting upgrade path, adding duplicate source entry, and when Pipfile.lock does not exist. Adds tests.
1 parent 7a7b342 commit 6f2929b

File tree

8 files changed

+411
-100
lines changed

8 files changed

+411
-100
lines changed

news/6299.bugfix.rst

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- **Bugfix:** Fixed Regression with the ``pipenv update`` routine to allow for package upgrades without requiring an existing lockfile. This change improves the flexibility of the update process by determining which packages require updating and handling cases where the lockfile is absent or partially defined.
2+
- **Tests Added:** Comprehensive integration tests for the updated functionality, covering scenarios like updating packages without a lockfile, detecting modified entries, handling VCS changes, and verifying the correct application of extras during installation.

pipenv/project.py

+12-7
Original file line numberDiff line numberDiff line change
@@ -765,11 +765,6 @@ def lockfile(self, categories=None):
765765
lock_section = lockfile.get(category)
766766
if lock_section is None:
767767
lockfile[category] = lock_section = {}
768-
for key in list(lock_section.keys()):
769-
norm_key = pep423_name(key)
770-
specifier = lock_section[key]
771-
del lock_section[key]
772-
lockfile[category][norm_key] = specifier
773768

774769
return lockfile
775770

@@ -1013,6 +1008,11 @@ def get_index_by_name(self, index_name):
10131008
if source.get("name") == index_name:
10141009
return source
10151010

1011+
def get_index_by_url(self, index_url):
1012+
for source in self.pipfile_sources():
1013+
if source.get("url") == index_url:
1014+
return source
1015+
10161016
@property
10171017
def sources(self):
10181018
if self.lockfile_exists and hasattr(self.lockfile_content, "keys"):
@@ -1145,7 +1145,9 @@ def remove_packages_from_pipfile(self, packages):
11451145
del parsed[category][pkg_name]
11461146
self.write_toml(parsed)
11471147

1148-
def generate_package_pipfile_entry(self, package, pip_line, category=None):
1148+
def generate_package_pipfile_entry(
1149+
self, package, pip_line, category=None, index_name=None
1150+
):
11491151
"""Generate a package entry from pip install line
11501152
given the installreq package and the pip line that generated it.
11511153
"""
@@ -1203,7 +1205,10 @@ def generate_package_pipfile_entry(self, package, pip_line, category=None):
12031205
break
12041206
else:
12051207
entry["version"] = specifier
1206-
if hasattr(package, "index"):
1208+
1209+
if index_name:
1210+
entry["index"] = index_name
1211+
elif hasattr(package, "index"):
12071212
entry["index"] = package.index
12081213

12091214
if len(entry) == 1 and "version" in entry:

pipenv/routines/graph.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def do_graph(project, bare=False, json=False, json_tree=False, reverse=False):
3434
sys.exit(1)
3535

3636
# Build command arguments list
37-
cmd_args = [python_path, pipdeptree_path, "-l"]
37+
cmd_args = [python_path, str(pipdeptree_path), "-l"]
3838

3939
# Add flags as needed - multiple flags now supported
4040
if json:

pipenv/routines/update.py

+159-89
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
from pipenv.routines.outdated import do_outdated
1212
from pipenv.routines.sync import do_sync
1313
from pipenv.utils import err
14+
from pipenv.utils.constants import VCS_LIST
1415
from pipenv.utils.dependencies import (
1516
expansive_install_req_from_line,
17+
get_lockfile_section_using_pipfile_category,
1618
get_pipfile_category_using_lockfile_section,
1719
)
1820
from pipenv.utils.processes import run_command
@@ -59,16 +61,17 @@ def do_update(
5961

6062
if not outdated:
6163
# Pre-sync packages for pipdeptree resolution to avoid conflicts
62-
do_sync(
63-
project,
64-
dev=dev,
65-
categories=categories,
66-
python=python,
67-
bare=bare,
68-
clear=clear,
69-
pypi_mirror=pypi_mirror,
70-
extra_pip_args=extra_pip_args,
71-
)
64+
if project.lockfile_exists:
65+
do_sync(
66+
project,
67+
dev=dev,
68+
categories=categories,
69+
python=python,
70+
bare=bare,
71+
clear=clear,
72+
pypi_mirror=pypi_mirror,
73+
extra_pip_args=extra_pip_args,
74+
)
7275
upgrade(
7376
project,
7477
pre=pre,
@@ -106,12 +109,11 @@ def get_reverse_dependencies(project) -> Dict[str, Set[Tuple[str, str]]]:
106109
"""Get reverse dependencies using pipdeptree."""
107110
pipdeptree_path = Path(pipdeptree.__file__).parent
108111
python_path = project.python()
109-
cmd_args = [python_path, pipdeptree_path, "-l", "--reverse", "--json-tree"]
112+
cmd_args = [python_path, str(pipdeptree_path), "-l", "--reverse", "--json-tree"]
110113

111114
c = run_command(cmd_args, is_verbose=project.s.is_verbose())
112115
if c.returncode != 0:
113116
raise PipenvCmdError(c.err, c.out, c.returncode)
114-
115117
try:
116118
dep_tree = json.loads(c.stdout.strip())
117119
except json.JSONDecodeError:
@@ -137,7 +139,12 @@ def process_tree_node(n, parents=None):
137139

138140
# Start processing the tree from the root nodes
139141
for node in dep_tree:
140-
process_tree_node(node)
142+
try:
143+
process_tree_node(node)
144+
except Exception as e: # noqa: PERF203
145+
err.print(
146+
f"[red bold]Warning[/red bold]: Unable to analyze dependencies: {str(e)}"
147+
)
141148

142149
return reverse_deps
143150

@@ -166,13 +173,76 @@ def check_version_conflicts(
166173
specifier_set = SpecifierSet(req_version)
167174
if not specifier_set.contains(new_version_obj):
168175
conflicts.add(dependent)
169-
except Exception:
176+
except Exception: # noqa: PERF203
170177
# If we can't parse the version requirement, assume it's a conflict
171178
conflicts.add(dependent)
172179

173180
return conflicts
174181

175182

183+
def get_modified_pipfile_entries(project, pipfile_categories):
184+
"""
185+
Detect Pipfile entries that have been modified since the last lock.
186+
Returns a dict mapping categories to sets of InstallRequirement objects.
187+
"""
188+
modified = defaultdict(dict)
189+
lockfile = project.lockfile()
190+
191+
for pipfile_category in pipfile_categories:
192+
lockfile_category = get_lockfile_section_using_pipfile_category(pipfile_category)
193+
pipfile_packages = project.parsed_pipfile.get(pipfile_category, {})
194+
locked_packages = lockfile.get(lockfile_category, {})
195+
196+
for package_name, pipfile_entry in pipfile_packages.items():
197+
if package_name not in locked_packages:
198+
# New package
199+
modified[lockfile_category][package_name] = pipfile_entry
200+
continue
201+
202+
locked_entry = locked_packages[package_name]
203+
is_modified = False
204+
205+
# For string entries, compare directly
206+
if isinstance(pipfile_entry, str):
207+
if pipfile_entry != locked_entry.get("version", ""):
208+
is_modified = True
209+
210+
# For dict entries, need to compare relevant fields
211+
elif isinstance(pipfile_entry, dict):
212+
if "version" in pipfile_entry:
213+
if pipfile_entry["version"] != locked_entry.get("version", ""):
214+
is_modified = True
215+
216+
# Compare VCS fields
217+
for key in VCS_LIST:
218+
if key in pipfile_entry:
219+
if (
220+
key not in locked_entry
221+
or pipfile_entry[key] != locked_entry[key]
222+
):
223+
is_modified = True
224+
225+
# Compare ref for VCS packages
226+
if "ref" in pipfile_entry:
227+
if (
228+
"ref" not in locked_entry
229+
or pipfile_entry["ref"] != locked_entry["ref"]
230+
):
231+
is_modified = True
232+
233+
# Compare extras
234+
if "extras" in pipfile_entry:
235+
pipfile_extras = set(pipfile_entry["extras"])
236+
locked_extras = set(locked_entry.get("extras", []))
237+
if pipfile_extras != locked_extras:
238+
is_modified = True
239+
240+
if is_modified:
241+
modified[lockfile_category][package_name] = pipfile_entry
242+
243+
return modified
244+
245+
176246
def upgrade(
177247
project,
178248
pre=False,
@@ -206,17 +276,14 @@ def upgrade(
206276
categories.insert(0, "default")
207277

208278
# Get current dependency graph
209-
try:
210-
reverse_deps = get_reverse_dependencies(project)
211-
except Exception as e:
212-
err.print(
213-
f"[red bold]Warning[/red bold]: Unable to analyze dependencies: {str(e)}"
214-
)
215-
reverse_deps = {}
279+
reverse_deps = get_reverse_dependencies(project)
216280

217281
index_name = None
218282
if index_url:
219-
index_name = add_index_to_pipfile(project, index_url)
283+
if project.get_index_by_url(index_url):
284+
index_name = project.get_index_by_url(index_url)["name"]
285+
else:
286+
index_name = add_index_to_pipfile(project, index_url)
220287

221288
if extra_pip_args:
222289
os.environ["PIPENV_EXTRA_PIP_ARGS"] = json.dumps(extra_pip_args)
@@ -245,76 +312,71 @@ def upgrade(
245312
)
246313
sys.exit(1)
247314

248-
requested_install_reqs = defaultdict(dict)
315+
# Create clean package_args first
316+
has_package_args = False
317+
if package_args:
318+
has_package_args = True
319+
249320
requested_packages = defaultdict(dict)
250321
for category in categories:
251322
pipfile_category = get_pipfile_category_using_lockfile_section(category)
252323

324+
# Get modified entries if no explicit packages specified
325+
if not package_args and project.lockfile_exists:
326+
modified_entries = get_modified_pipfile_entries(project, [pipfile_category])
327+
for name, entry in modified_entries[category].items():
328+
requested_packages[pipfile_category][name] = entry
329+
330+
# Process each package arg
253331
for package in package_args[:]:
254332
install_req, _ = expansive_install_req_from_line(package, expand_env=True)
255-
if index_name:
256-
install_req.index = index_name
257333

258334
name, normalized_name, pipfile_entry = project.generate_package_pipfile_entry(
259-
install_req, package, category=pipfile_category
260-
)
261-
project.add_pipfile_entry_to_pipfile(
262-
name, normalized_name, pipfile_entry, category=pipfile_category
335+
install_req, package, category=pipfile_category, index_name=index_name
263336
)
337+
338+
# Only add to Pipfile if explicitly requested
339+
if has_package_args:
340+
project.add_pipfile_entry_to_pipfile(
341+
name, normalized_name, pipfile_entry, category=pipfile_category
342+
)
343+
264344
requested_packages[pipfile_category][normalized_name] = pipfile_entry
265-
requested_install_reqs[pipfile_category][normalized_name] = install_req
266345

267-
# Consider reverse packages in reverse_deps
346+
# Handle reverse dependencies
268347
if normalized_name in reverse_deps:
269-
for dependency, req_version in reverse_deps[normalized_name]:
270-
if req_version == "Any":
271-
package_args.append(dependency)
272-
pipfile_entry = project.get_pipfile_entry(
273-
dependency, category=pipfile_category
274-
)
275-
requested_packages[pipfile_category][dependency] = (
276-
pipfile_entry if pipfile_entry else "*"
277-
)
348+
for dependency, _ in reverse_deps[normalized_name]:
349+
pipfile_entry = project.get_pipfile_entry(
350+
dependency, category=pipfile_category
351+
)
352+
if not pipfile_entry:
353+
requested_packages[pipfile_category][dependency] = {
354+
normalized_name: "*"
355+
}
278356
continue
357+
requested_packages[pipfile_category][dependency] = pipfile_entry
279358

280-
try: # Otherwise we have a specific version requirement
281-
specifier_set = SpecifierSet(req_version)
282-
package_args.append(f"{dependency}=={specifier_set}")
283-
pipfile_entry = project.get_pipfile_entry(
284-
dependency, category=pipfile_category
285-
)
286-
requested_packages[pipfile_category][dependency] = (
287-
pipfile_entry if pipfile_entry else "*"
288-
)
289-
290-
except Exception as e:
291-
err.print(
292-
f"[bold][yellow]Warning:[/yellow][/bold] "
293-
f"Unable to parse version specifier for {dependency}: {str(e)}"
294-
)
295-
296-
if not package_args:
297-
err.print("Nothing to upgrade!")
298-
return
299-
else:
359+
# When packages are not provided we simply perform full resolution
360+
upgrade_lock_data = None
361+
if requested_packages[pipfile_category]:
300362
err.print(
301363
f"[bold][green]Upgrading[/bold][/green] {', '.join(package_args)} in [{category}] dependencies."
302364
)
303365

304-
# Resolve package to generate constraints of new package data
305-
upgrade_lock_data = venv_resolve_deps(
306-
requested_packages[pipfile_category],
307-
which=project._which,
308-
project=project,
309-
lockfile={},
310-
pipfile_category=pipfile_category,
311-
pre=pre,
312-
allow_global=system,
313-
pypi_mirror=pypi_mirror,
314-
)
315-
if not upgrade_lock_data:
316-
err.print("Nothing to upgrade!")
317-
return
366+
# Resolve package to generate constraints of new package data
367+
upgrade_lock_data = venv_resolve_deps(
368+
requested_packages[pipfile_category],
369+
which=project._which,
370+
project=project,
371+
lockfile={},
372+
pipfile_category=pipfile_category,
373+
pre=pre,
374+
allow_global=system,
375+
pypi_mirror=pypi_mirror,
376+
)
377+
if not upgrade_lock_data:
378+
err.print("Nothing to upgrade!")
379+
return
318380

319381
complete_packages = project.parsed_pipfile.get(pipfile_category, {})
320382

@@ -329,21 +391,29 @@ def upgrade(
329391
pypi_mirror=pypi_mirror,
330392
)
331393

332-
# Verify no conflicts were introduced during resolution
333-
for package_name, package_data in full_lock_resolution.items():
334-
if package_name in upgrade_lock_data:
335-
version = package_data.get("version", "").replace("==", "")
336-
if not version:
337-
# Either vcs or file package
338-
continue
339-
340-
# Update lockfile with verified resolution data
341-
for package_name in upgrade_lock_data:
342-
correct_package_lock = full_lock_resolution.get(package_name)
343-
if correct_package_lock:
344-
if category not in lockfile:
345-
lockfile[category] = {}
346-
lockfile[category][package_name] = correct_package_lock
394+
if upgrade_lock_data is None:
395+
for package_name, package_data in full_lock_resolution.items():
396+
lockfile[category][package_name] = package_data
397+
else: # Upgrade a subset of packages
398+
# Verify no conflicts were introduced during resolution
399+
for package_name, package_data in full_lock_resolution.items():
400+
if package_name in upgrade_lock_data:
401+
version = package_data.get("version", "").replace("==", "")
402+
if not version:
403+
# Either vcs or file package
404+
continue
405+
406+
# Update lockfile with verified resolution data
407+
for package_name in upgrade_lock_data:
408+
correct_package_lock = full_lock_resolution.get(package_name)
409+
if correct_package_lock:
410+
if category not in lockfile:
411+
lockfile[category] = {}
412+
lockfile[category][package_name] = correct_package_lock
413+
414+
# reset package args in case of multiple categories being processed
415+
if has_package_args is False:
416+
package_args = []
347417

348418
lockfile.update({"_meta": project.get_lockfile_meta()})
349419
project.write_lockfile(lockfile)

0 commit comments

Comments
 (0)