Skip to content

Commit fdcc04a

Browse files
committed
Ability to write the pylock.yaml
1 parent e5711ba commit fdcc04a

File tree

7 files changed

+659
-4
lines changed

7 files changed

+659
-4
lines changed

docs/pylock.md

+22-3
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,34 @@ hashes = {sha256 = 'b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b
5656
- **Flexibility**: pylock.toml can support both single-use and multi-use lock files, allowing for more complex dependency scenarios.
5757
- **Interoperability**: pylock.toml can be used by different tools, reducing vendor lock-in.
5858

59+
## Writing pylock.toml Files
60+
61+
Pipenv can generate pylock.toml files alongside Pipfile.lock files. To enable this feature, add the following to your Pipfile:
62+
63+
```toml
64+
[pipenv]
65+
use_pylock = true
66+
```
67+
68+
With this setting, whenever Pipenv updates the Pipfile.lock file (e.g., when running `pipenv lock`), it will also generate a pylock.toml file in the same directory.
69+
70+
You can also specify a custom name for the pylock.toml file:
71+
72+
```toml
73+
[pipenv]
74+
use_pylock = true
75+
pylock_name = "dev" # This will generate pylock.dev.toml
76+
```
77+
5978
## Limitations
6079

61-
- Currently, Pipenv only supports reading pylock.toml files, not writing them.
6280
- Some advanced features of pylock.toml, such as environment markers for extras and dependency groups, are not fully supported yet.
81+
- The generated pylock.toml files are simplified versions of what a full PEP 751 implementation might produce.
6382

6483
## Future Plans
6584

6685
In future releases, Pipenv plans to add support for:
6786

68-
- Writing pylock.toml files
6987
- Full support for environment markers for extras and dependency groups
70-
- Converting between Pipfile.lock and pylock.toml formats
88+
- More comprehensive conversion between Pipfile.lock and pylock.toml formats
89+
- Command-line options for generating pylock.toml files

examples/Pipfile.with_pylock

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[[source]]
2+
url = "https://pypi.org/simple"
3+
verify_ssl = true
4+
name = "pypi"
5+
6+
[packages]
7+
requests = "*"
8+
9+
[dev-packages]
10+
pytest = "*"
11+
12+
[requires]
13+
python_version = "3.8"
14+
15+
[pipenv]
16+
# Enable pylock.toml generation
17+
use_pylock = true
18+
19+
# Optional: Specify a custom name for the pylock file
20+
# This will generate pylock.dev.toml instead of pylock.toml
21+
# pylock_name = "dev"

news/7751.feature.rst

+5-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
Added support for reading PEP 751 pylock.toml files. When both a Pipfile.lock and a pylock.toml file exist, Pipenv will prioritize the pylock.toml file.
1+
Added support for PEP 751 pylock.toml files:
2+
3+
- Reading: When both a Pipfile.lock and a pylock.toml file exist, Pipenv will prioritize the pylock.toml file.
4+
- Writing: Add ``use_pylock = true`` to the ``[pipenv]`` section of your Pipfile to generate pylock.toml files alongside Pipfile.lock.
5+
- Customization: Use ``pylock_name = "name"`` in the ``[pipenv]`` section to generate named pylock files (e.g., pylock.name.toml).

pipenv/project.py

+30
Original file line numberDiff line numberDiff line change
@@ -1007,8 +1007,22 @@ def write_toml(self, data, path=None):
10071007
with open(path, "w", newline=newlines) as f:
10081008
f.write(formatted_data)
10091009

1010+
@property
1011+
def use_pylock(self) -> bool:
1012+
"""Returns True if pylock.toml should be generated."""
1013+
return self.settings.get("use_pylock", False)
1014+
1015+
@property
1016+
def pylock_output_path(self) -> str:
1017+
"""Returns the path where pylock.toml should be written."""
1018+
pylock_name = self.settings.get("pylock_name")
1019+
if pylock_name:
1020+
return str(Path(self.project_directory) / f"pylock.{pylock_name}.toml")
1021+
return str(Path(self.project_directory) / "pylock.toml")
1022+
10101023
def write_lockfile(self, content):
10111024
"""Write out the lockfile."""
1025+
# Always write the Pipfile.lock
10121026
s = self._lockfile_encoder.encode(content)
10131027
open_kwargs = {"newline": self._lockfile_newlines, "encoding": "utf-8"}
10141028
with atomic_open_for_write(self.lockfile_location, **open_kwargs) as f:
@@ -1018,6 +1032,22 @@ def write_lockfile(self, content):
10181032
if not s.endswith("\n"):
10191033
f.write("\n")
10201034

1035+
# If use_pylock is enabled, also write a pylock.toml file
1036+
if self.use_pylock:
1037+
try:
1038+
from pipenv.utils.pylock import PylockFile
1039+
1040+
pylock = PylockFile.from_lockfile(
1041+
lockfile_path=self.lockfile_location,
1042+
pylock_path=self.pylock_output_path,
1043+
)
1044+
pylock.write()
1045+
err.print(
1046+
f"[bold green]Generated pylock.toml at {self.pylock_output_path}[/bold green]"
1047+
)
1048+
except Exception as e:
1049+
err.print(f"[bold red]Error generating pylock.toml: {e}[/bold red]")
1050+
10211051
def pipfile_sources(self, expand_vars=True):
10221052
if self.pipfile_is_empty or "source" not in self.parsed_pipfile:
10231053
sources = [self.default_source]

pipenv/utils/pylock.py

+188
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
installation reproducibility).
77
"""
88

9+
import datetime
10+
import json
911
import os
1012
from dataclasses import dataclass, field
1113
from pathlib import Path
1214
from typing import Any, Dict, List, Optional, Set, Union
1315

16+
from pipenv.utils import err
17+
from pipenv.utils.locking import atomic_open_for_write
1418
from pipenv.utils.toml import tomlkit_value_to_python
1519
from pipenv.vendor import tomlkit
1620

@@ -40,6 +44,122 @@ class PylockFile:
4044
path: Path
4145
data: Dict[str, Any] = field(default_factory=dict)
4246

47+
@classmethod
48+
def from_lockfile(
49+
cls, lockfile_path: Union[str, Path], pylock_path: Union[str, Path] = None
50+
) -> "PylockFile":
51+
"""Create a PylockFile from a Pipfile.lock file.
52+
53+
Args:
54+
lockfile_path: Path to the Pipfile.lock file
55+
pylock_path: Path to save the pylock.toml file, defaults to pylock.toml in the same directory
56+
57+
Returns:
58+
A PylockFile instance
59+
60+
Raises:
61+
FileNotFoundError: If the Pipfile.lock file doesn't exist
62+
ValueError: If the Pipfile.lock file is invalid
63+
"""
64+
if isinstance(lockfile_path, str):
65+
lockfile_path = Path(lockfile_path)
66+
67+
if not lockfile_path.exists():
68+
raise FileNotFoundError(f"Pipfile.lock not found: {lockfile_path}")
69+
70+
if pylock_path is None:
71+
pylock_path = lockfile_path.parent / "pylock.toml"
72+
elif isinstance(pylock_path, str):
73+
pylock_path = Path(pylock_path)
74+
75+
try:
76+
with open(lockfile_path, encoding="utf-8") as f:
77+
lockfile_data = json.load(f)
78+
except Exception as e:
79+
raise ValueError(f"Invalid Pipfile.lock file: {e}")
80+
81+
# Create the basic pylock.toml structure
82+
pylock_data = {
83+
"lock-version": "1.0",
84+
"environments": [],
85+
"extras": [],
86+
"dependency-groups": [],
87+
"default-groups": [],
88+
"created-by": "pipenv",
89+
"packages": [],
90+
}
91+
92+
# Add Python version requirement if present
93+
meta = lockfile_data.get("_meta", {})
94+
requires = meta.get("requires", {})
95+
if "python_version" in requires:
96+
pylock_data["requires-python"] = f">={requires['python_version']}"
97+
elif "python_full_version" in requires:
98+
pylock_data["requires-python"] = f"=={requires['python_full_version']}"
99+
100+
# Add sources
101+
sources = meta.get("sources", [])
102+
if sources:
103+
pylock_data["sources"] = sources
104+
105+
# Process packages
106+
for section in ["default", "develop"]:
107+
packages = lockfile_data.get(section, {})
108+
for name, package_data in packages.items():
109+
package = {"name": name}
110+
111+
# Add version if present
112+
if "version" in package_data:
113+
version = package_data["version"]
114+
if version.startswith("=="):
115+
package["version"] = version[2:]
116+
else:
117+
package["version"] = version
118+
119+
# Add markers if present
120+
if "markers" in package_data:
121+
# For develop packages, add dependency_groups marker
122+
if section == "develop":
123+
if "markers" in package_data:
124+
package["marker"] = (
125+
f"dependency_groups in ('dev', 'test') and ({package_data['markers']})"
126+
)
127+
else:
128+
package["marker"] = "dependency_groups in ('dev', 'test')"
129+
else:
130+
package["marker"] = package_data["markers"]
131+
elif section == "develop":
132+
package["marker"] = "dependency_groups in ('dev', 'test')"
133+
134+
# Add hashes if present
135+
if "hashes" in package_data:
136+
wheels = []
137+
for hash_value in package_data["hashes"]:
138+
if hash_value.startswith("sha256:"):
139+
hash_value = hash_value[7:] # Remove "sha256:" prefix
140+
wheel = {
141+
"name": f"{name}-{package.get('version', '0.0.0')}-py3-none-any.whl",
142+
"hashes": {"sha256": hash_value},
143+
}
144+
wheels.append(wheel)
145+
if wheels:
146+
package["wheels"] = wheels
147+
148+
pylock_data["packages"].append(package)
149+
150+
# Add tool.pipenv section with metadata
151+
pylock_data["tool"] = {
152+
"pipenv": {
153+
"generated_from": "Pipfile.lock",
154+
"generation_date": datetime.datetime.now(
155+
datetime.timezone.utc
156+
).isoformat(),
157+
}
158+
}
159+
160+
instance = cls(path=pylock_path, data=pylock_data)
161+
return instance
162+
43163
@classmethod
44164
def from_path(cls, path: Union[str, Path]) -> "PylockFile":
45165
"""Load a pylock.toml file from the given path.
@@ -80,6 +200,74 @@ def from_path(cls, path: Union[str, Path]) -> "PylockFile":
80200

81201
return cls(path=path, data=tomlkit_value_to_python(data))
82202

203+
def write(self) -> None:
204+
"""Write the pylock.toml file to disk.
205+
206+
Raises:
207+
OSError: If there is an error writing the file
208+
"""
209+
try:
210+
# Convert the data to a TOML document
211+
doc = tomlkit.document()
212+
213+
# Add top-level keys in a specific order for readability
214+
for key in [
215+
"lock-version",
216+
"environments",
217+
"requires-python",
218+
"extras",
219+
"dependency-groups",
220+
"default-groups",
221+
"created-by",
222+
]:
223+
if key in self.data:
224+
doc[key] = self.data[key]
225+
226+
# Add packages
227+
if "packages" in self.data:
228+
doc["packages"] = []
229+
for package in self.data["packages"]:
230+
pkg_table = tomlkit.table()
231+
for k, v in package.items():
232+
if k not in {"wheels", "sdist"}:
233+
pkg_table[k] = v
234+
235+
# Add wheels as an array of tables
236+
if "wheels" in package:
237+
pkg_table["wheels"] = []
238+
for wheel in package["wheels"]:
239+
wheel_table = tomlkit.inline_table()
240+
for k, v in wheel.items():
241+
wheel_table[k] = v
242+
pkg_table["wheels"].append(wheel_table)
243+
244+
# Add sdist as a table
245+
if "sdist" in package:
246+
sdist_table = tomlkit.table()
247+
for k, v in package["sdist"].items():
248+
sdist_table[k] = v
249+
pkg_table["sdist"] = sdist_table
250+
251+
doc["packages"].append(pkg_table)
252+
253+
# Add tool section
254+
if "tool" in self.data:
255+
tool_table = tomlkit.table()
256+
for tool_name, tool_data in self.data["tool"].items():
257+
tool_section = tomlkit.table()
258+
for k, v in tool_data.items():
259+
tool_section[k] = v
260+
tool_table[tool_name] = tool_section
261+
doc["tool"] = tool_table
262+
263+
# Write the document to the file
264+
with atomic_open_for_write(self.path, encoding="utf-8") as f:
265+
f.write(tomlkit.dumps(doc))
266+
267+
except Exception as e:
268+
err.print(f"[bold red]Error writing pylock.toml: {e}[/bold red]")
269+
raise OSError(f"Error writing pylock.toml: {e}")
270+
83271
@property
84272
def lock_version(self) -> str:
85273
"""Get the lock-version."""

0 commit comments

Comments
 (0)