Skip to content

Commit 9a81fc0

Browse files
committed
Mostly performance minded but does fix a bug upgrading packages with markers on CLI
1 parent 1dc28bd commit 9a81fc0

File tree

4 files changed

+259
-199
lines changed

4 files changed

+259
-199
lines changed

pipenv/project.py

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
system_which,
7070
)
7171
from pipenv.utils.toml import cleanup_toml, convert_toml_outline_tables
72+
from pipenv.vendor.tomlkit.toml_document import TOMLDocument
7273
from pipenv.vendor import plette, tomlkit
7374

7475
try:
@@ -138,6 +139,53 @@ class SourceNotFound(KeyError):
138139
pass
139140

140141

142+
class PipfileReader:
143+
_instance = None
144+
_instances = {} # path -> instance mapping
145+
146+
def __new__(cls, pipfile_path: str | Path):
147+
path_key = str(Path(pipfile_path).resolve())
148+
if path_key not in cls._instances:
149+
cls._instances[path_key] = super().__new__(cls)
150+
return cls._instances[path_key]
151+
152+
def __init__(self, pipfile_path: str | Path):
153+
# Only initialize if this is a new instance
154+
if not hasattr(self, "pipfile_path"): # Check if already initialized
155+
self.pipfile_path = Path(pipfile_path)
156+
self._cached_mtime = None
157+
self._cached_content = None
158+
159+
def _is_cache_valid(self) -> bool:
160+
"""Check if the cached content is still valid based on mtime"""
161+
if self._cached_mtime is None or self._cached_content is None:
162+
return False
163+
164+
current_mtime = os.path.getmtime(self.pipfile_path)
165+
return current_mtime == self._cached_mtime
166+
167+
def read_pipfile(self) -> str:
168+
"""Read the raw contents of the Pipfile"""
169+
return self.pipfile_path.read_text()
170+
171+
def _parse_pipfile(self, contents: str) -> TOMLDocument | TPipfile:
172+
"""Parse the TOML contents"""
173+
return tomlkit.parse(contents)
174+
175+
@property
176+
def parsed_pipfile(self) -> TOMLDocument | TPipfile:
177+
"""Parse Pipfile into a TOMLFile with caching based on mtime"""
178+
if self._is_cache_valid():
179+
return self._cached_content
180+
181+
# Cache is invalid or doesn't exist, reload the file
182+
contents = self.read_pipfile()
183+
self._cached_content = self._parse_pipfile(contents)
184+
self._cached_mtime = os.path.getmtime(self.pipfile_path)
185+
186+
return self._cached_content
187+
188+
141189
class Project:
142190
"""docstring for Project"""
143191

@@ -208,6 +256,8 @@ def __init__(self, python_version=None, chdir=True):
208256
default_sources_toml += f"\n\n[[source]]\n{tomlkit.dumps(pip_conf_index)}"
209257
plette.pipfiles.DEFAULT_SOURCE_TOML = default_sources_toml
210258

259+
self._pipfile_reader = PipfileReader(self.pipfile_location)
260+
211261
# Hack to skip this during pipenv run, or -r.
212262
if ("run" not in sys.argv) and chdir:
213263
with contextlib.suppress(TypeError, AttributeError):
@@ -666,49 +716,7 @@ def requirements_location(self) -> str | None:
666716
@property
667717
def parsed_pipfile(self) -> tomlkit.toml_document.TOMLDocument | TPipfile:
668718
"""Parse Pipfile into a TOMLFile"""
669-
contents = self.read_pipfile()
670-
return self._parse_pipfile(contents)
671-
672-
def read_pipfile(self) -> str:
673-
# Open the pipfile, read it into memory.
674-
if not self.pipfile_exists:
675-
return ""
676-
with open(self.pipfile_location) as f:
677-
contents = f.read()
678-
self._pipfile_newlines = preferred_newlines(f)
679-
680-
return contents
681-
682-
def _parse_pipfile(
683-
self, contents: str
684-
) -> tomlkit.toml_document.TOMLDocument | TPipfile:
685-
try:
686-
return tomlkit.parse(contents)
687-
except Exception:
688-
# We lose comments here, but it's for the best.)
689-
# Fallback to toml parser, for large files.
690-
return toml.loads(contents)
691-
692-
def _read_pyproject(self) -> None:
693-
pyproject = self.path_to("pyproject.toml")
694-
if os.path.exists(pyproject):
695-
self._pyproject = toml.load(pyproject)
696-
build_system = self._pyproject.get("build-system", None)
697-
if not os.path.exists(self.path_to("setup.py")):
698-
if not build_system or not build_system.get("requires"):
699-
build_system = {
700-
"requires": ["setuptools>=40.8.0", "wheel"],
701-
"build-backend": get_default_pyproject_backend(),
702-
}
703-
self._build_system = build_system
704-
705-
@property
706-
def build_requires(self) -> list[str]:
707-
return self._build_system.get("requires", ["setuptools>=40.8.0", "wheel"])
708-
709-
@property
710-
def build_backend(self) -> str:
711-
return self._build_system.get("build-backend", get_default_pyproject_backend())
719+
return self._pipfile_reader.parsed_pipfile
712720

713721
@property
714722
def settings(self) -> tomlkit.items.Table | dict[str, str | bool]:
@@ -828,8 +836,7 @@ def dev_packages(self):
828836
def pipfile_is_empty(self):
829837
if not self.pipfile_exists:
830838
return True
831-
832-
if not self.read_pipfile():
839+
if not self.pipfile_exists:
833840
return True
834841

835842
return False

pipenv/routines/update.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,13 @@ def upgrade(
290290
# Early conflict detection
291291
conflicts_found = False
292292
for package in package_args:
293-
if "==" in package:
294-
name, version = package.split("==")
293+
package_parts = [package]
294+
if ";" in package:
295+
package_parts = package.split(";")
296+
# Not using markers here for now
297+
# markers = ";".join(package_parts[1:]) if len(package_parts) > 1 else None
298+
if "==" in package_parts[0]:
299+
name, version = package_parts[0].split("==")
295300
conflicts = check_version_conflicts(name, version, reverse_deps, lockfile)
296301
if conflicts:
297302
conflicts_found = True

pipenv/utils/developers.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import cProfile
2+
import functools
3+
import os
4+
import pstats
5+
from datetime import datetime
6+
from pstats import SortKey
7+
8+
9+
def profile_method(output_dir="profiles"):
10+
"""
11+
Decorator to profile pipenv method execution with focus on file reads.
12+
"""
13+
14+
def decorator(func):
15+
@functools.wraps(func)
16+
def wrapper(*args, **kwargs):
17+
os.makedirs(output_dir, exist_ok=True)
18+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
19+
profile_name = f"{func.__name__}_{timestamp}"
20+
profile_path = os.path.join(output_dir, f"{profile_name}.prof")
21+
22+
profiler = cProfile.Profile()
23+
profiler.enable()
24+
25+
try:
26+
result = func(*args, **kwargs)
27+
return result
28+
finally:
29+
profiler.disable()
30+
31+
# Save and analyze stats
32+
stats = pstats.Stats(profiler)
33+
stats.sort_stats(SortKey.CUMULATIVE)
34+
stats.dump_stats(profile_path)
35+
print(f"\nProfile saved to: {profile_path}")
36+
37+
# Analyze file reads specifically
38+
print("\nAnalyzing file read operations:")
39+
print("-" * 50)
40+
41+
# Get all entries involving file read operations
42+
read_stats = stats.stats
43+
for (file, line, name), (_, _, tt, _, callers) in read_stats.items():
44+
if "read" in str(name):
45+
# Print the call stack for this read operation
46+
print(f"\nFile read at: {file}:{line}")
47+
print(f"Function: {name}")
48+
print(f"Time: {tt:.6f}s")
49+
print("Called by:")
50+
for caller in callers:
51+
caller_file, caller_line, caller_name = caller
52+
print(f" {caller_name} in {caller_file}:{caller_line}")
53+
print("-" * 30)
54+
55+
# Print overall stats
56+
print("\nTop 20 overall calls:")
57+
stats.print_stats(20)
58+
59+
return wrapper
60+
61+
return decorator

0 commit comments

Comments
 (0)