Skip to content

Commit aebb18b

Browse files
authored
Support parsing/writing pyproject.toml with poetry (#646)
* pyproject parser can parse poetry * pyproject writer can add to poetry dependencies * test pyproject writer with both [project] and poetry * handle pyproject writer w/o poetry dependencies declared * requirement parser can handle ^ caret syntax * add newline
1 parent a8efa7e commit aebb18b

File tree

5 files changed

+382
-17
lines changed

5 files changed

+382
-17
lines changed

src/codemodder/dependency_management/pyproject_writer.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,34 @@ def add_to_file(
2121
pyproject = self._parse_file()
2222
original = deepcopy(pyproject)
2323

24-
try:
25-
pyproject["project"]["dependencies"].extend(
26-
[f"{dep.requirement}" for dep in dependencies]
27-
)
28-
except tomlkit.exceptions.NonExistentKey:
29-
logger.debug("Unable to add dependencies to pyproject.toml file.")
30-
return None
24+
if poetry_data := pyproject.get("tool", {}).get("poetry", {}):
25+
add_newline = False
26+
# It's unlikely and bad practice to declare dependencies under [project].dependencies
27+
# and [tool.poetry.dependencies] but if it happens, we will give priority to poetry
28+
# and add dependencies under its system.
29+
if poetry_data.get("dependencies") is None:
30+
pyproject["tool"]["poetry"].append("dependencies", {})
31+
add_newline = True
32+
33+
for dep in dependencies:
34+
try:
35+
pyproject["tool"]["poetry"]["dependencies"].append(
36+
dep.requirement.name, str(dep.requirement.specifier)
37+
)
38+
except tomlkit.exceptions.KeyAlreadyPresent:
39+
pass
40+
41+
if add_newline:
42+
pyproject["tool"]["poetry"]["dependencies"].add(tomlkit.nl())
43+
44+
else:
45+
try:
46+
pyproject["project"]["dependencies"].extend(
47+
[f"{dep.requirement}" for dep in dependencies]
48+
)
49+
except tomlkit.exceptions.NonExistentKey:
50+
logger.debug("Unable to add dependencies to pyproject.toml file.")
51+
return None
3152

3253
diff, added_line_nums = create_diff_and_linenums(
3354
tomlkit.dumps(original).split("\n"), tomlkit.dumps(pyproject).split("\n")

src/codemodder/project_analysis/file_parsers/package_store.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from enum import Enum
33
from pathlib import Path
44

5+
from packaging.requirements import InvalidRequirement
6+
57
from codemodder.dependency import Requirement
68

79

@@ -29,10 +31,38 @@ def __init__(
2931
self.type = type
3032
self.file = file
3133
self.dependencies = {
32-
dep if isinstance(dep, Requirement) else Requirement(dep)
33-
for dep in dependencies
34+
x for x in {parse_requirement(dep) for dep in dependencies} if x
3435
}
3536
self.py_versions = py_versions
3637

3738
def has_requirement(self, requirement: Requirement) -> bool:
3839
return requirement.name in {dep.name for dep in self.dependencies}
40+
41+
42+
def parse_requirement(requirement: str | Requirement) -> Requirement:
43+
match requirement:
44+
case Requirement():
45+
return requirement
46+
case _:
47+
try:
48+
return Requirement(convert_py_version(requirement))
49+
except (InvalidRequirement, ValueError):
50+
return None
51+
52+
53+
def convert_py_version(py_version: str) -> str:
54+
"""
55+
Convert any dependency with ^ syntax to equivalent >=, < syntax.
56+
packaging.requirements does not support parsing dependencies with `^` syntax
57+
which is common in Poetry. For our internal representation we will convert it to
58+
59+
`pandas^1.2.31` is equivalent to `pandas>=1.2.3,< 2.0.0`
60+
"""
61+
if "^" in py_version:
62+
try:
63+
name, version = py_version.split("^")
64+
next_major_version = int(version.strip()[0]) + 1
65+
return f"{name}>={version},<{next_major_version}.0.0"
66+
except Exception:
67+
raise ValueError
68+
return py_version

src/codemodder/project_analysis/file_parsers/pyproject_toml_file_parser.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,35 @@ def file_type(self):
1616
return FileType.TOML
1717

1818
def _parse_file(self, file: Path) -> PackageStore | None:
19+
"""
20+
Parse a pyproject.toml file which may or may not use poetry.
21+
"""
1922
data = toml.load(file)
23+
project_data = data.get("project")
24+
poetry_data = data.get("tool", {}).get("poetry")
2025

21-
if not (project := data.get("project")):
26+
if not project_data and not poetry_data:
2227
return None
2328

24-
dependencies = project.get("dependencies", [])
25-
version = project.get("requires-python", None)
29+
version = None
30+
project_dependencies, poetry_dependencies = [], []
31+
if project_data:
32+
project_dependencies = project_data.get("dependencies", [])
33+
version = project_data.get("requires-python", None)
34+
35+
if poetry_data:
36+
poetry_dependencies = [
37+
f"{name}{version}"
38+
for name, version in poetry_data.get("dependencies", {}).items()
39+
if name != "python"
40+
]
41+
42+
# In poetry python version is declared within `[tool.poetry.dependencies]`
43+
version = poetry_data.get("dependencies", {}).get("python", None)
2644

2745
return PackageStore(
2846
type=self.file_type,
2947
file=file,
30-
dependencies=set(dependencies),
48+
dependencies=set(project_dependencies + poetry_dependencies),
3149
py_versions=[version] if version else [],
3250
)

tests/dependency_management/test_pyproject_writer.py

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_update_pyproject_dependencies(tmpdir, dry_run):
3636
pyproject_toml.write(dedent(orig_pyproject))
3737

3838
store = PackageStore(
39-
type=FileType.REQ_TXT,
39+
type=FileType.TOML,
4040
file=pyproject_toml,
4141
dependencies=set(),
4242
py_versions=[">=3.10.0"],
@@ -127,7 +127,7 @@ def test_add_same_dependency_only_once(tmpdir):
127127
pyproject_toml.write(dedent(orig_pyproject))
128128

129129
store = PackageStore(
130-
type=FileType.REQ_TXT,
130+
type=FileType.TOML,
131131
file=pyproject_toml,
132132
dependencies=set(),
133133
py_versions=[">=3.10.0"],
@@ -181,7 +181,7 @@ def test_dont_add_existing_dependency(tmpdir):
181181
pyproject_toml.write(dedent(orig_pyproject))
182182

183183
store = PackageStore(
184-
type=FileType.REQ_TXT,
184+
type=FileType.TOML,
185185
file=pyproject_toml,
186186
dependencies=set([Security.requirement]),
187187
py_versions=[">=3.10.0"],
@@ -207,7 +207,7 @@ def test_pyproject_no_dependencies(tmpdir):
207207
pyproject_toml.write(dedent(orig_pyproject))
208208

209209
store = PackageStore(
210-
type=FileType.REQ_TXT,
210+
type=FileType.TOML,
211211
file=pyproject_toml,
212212
dependencies=set(),
213213
py_versions=[">=3.10.0"],
@@ -219,3 +219,192 @@ def test_pyproject_no_dependencies(tmpdir):
219219
writer.write(dependencies)
220220

221221
assert pyproject_toml.read() == dedent(orig_pyproject)
222+
223+
224+
def test_pyproject_poetry(tmpdir):
225+
orig_pyproject = """\
226+
[tool.poetry]
227+
name = "example-project"
228+
version = "0.1.0"
229+
description = "An example project to demonstrate Poetry configuration."
230+
authors = ["Your Name <[email protected]>"]
231+
232+
[build-system]
233+
requires = ["poetry-core>=1.0.0"]
234+
build-backend = "poetry.core.masonry.api"
235+
236+
[tool.poetry.dependencies]
237+
python = "~=3.11.0"
238+
requests = ">=2.25.1,<3.0.0"
239+
pandas = "^1.2.3"
240+
libcst = ">1.0"
241+
"""
242+
243+
pyproject_toml = tmpdir.join("pyproject.toml")
244+
pyproject_toml.write(dedent(orig_pyproject))
245+
246+
store = PackageStore(
247+
type=FileType.TOML,
248+
file=pyproject_toml,
249+
dependencies=set(),
250+
py_versions=["~=3.11.0"],
251+
)
252+
253+
writer = PyprojectWriter(store, tmpdir)
254+
dependencies = [Security, DefusedXML]
255+
writer.write(dependencies)
256+
257+
updated_pyproject = f"""\
258+
[tool.poetry]
259+
name = "example-project"
260+
version = "0.1.0"
261+
description = "An example project to demonstrate Poetry configuration."
262+
authors = ["Your Name <[email protected]>"]
263+
264+
[build-system]
265+
requires = ["poetry-core>=1.0.0"]
266+
build-backend = "poetry.core.masonry.api"
267+
268+
[tool.poetry.dependencies]
269+
python = "~=3.11.0"
270+
requests = ">=2.25.1,<3.0.0"
271+
pandas = "^1.2.3"
272+
libcst = ">1.0"
273+
{Security.requirement.name} = "{ str(Security.requirement.specifier)}"
274+
{DefusedXML.requirement.name} = "{ str(DefusedXML.requirement.specifier)}"
275+
"""
276+
277+
assert pyproject_toml.read() == dedent(updated_pyproject)
278+
279+
280+
def test_pyproject_poetry_existing_dependency(tmpdir):
281+
orig_pyproject = """\
282+
[tool.poetry]
283+
name = "example-project"
284+
version = "0.1.0"
285+
description = "An example project to demonstrate Poetry configuration."
286+
authors = ["Your Name <[email protected]>"]
287+
288+
[build-system]
289+
requires = ["poetry-core>=1.0.0"]
290+
build-backend = "poetry.core.masonry.api"
291+
292+
[tool.poetry.dependencies]
293+
python = "~=3.11.0"
294+
requests = ">=2.25.1,<3.0.0"
295+
pandas = "^1.2.3"
296+
libcst = ">1.0"
297+
defusedxml = "^0.7"
298+
"""
299+
300+
pyproject_toml = tmpdir.join("pyproject.toml")
301+
pyproject_toml.write(dedent(orig_pyproject))
302+
303+
store = PackageStore(
304+
type=FileType.TOML,
305+
file=pyproject_toml,
306+
dependencies=set([DefusedXML.requirement]),
307+
py_versions=["~=3.11.0"],
308+
)
309+
310+
writer = PyprojectWriter(store, tmpdir)
311+
dependencies = [DefusedXML]
312+
writer.write(dependencies)
313+
314+
assert pyproject_toml.read() == dedent(orig_pyproject)
315+
316+
317+
def test_pyproject_poetry_no_deps_section(tmpdir):
318+
orig_pyproject = """\
319+
[tool.poetry]
320+
name = "example-project"
321+
version = "0.1.0"
322+
description = "An example project to demonstrate Poetry configuration."
323+
authors = ["Your Name <[email protected]>"]
324+
325+
[build-system]
326+
requires = ["poetry-core>=1.0.0"]
327+
build-backend = "poetry.core.masonry.api"
328+
"""
329+
330+
pyproject_toml = tmpdir.join("pyproject.toml")
331+
pyproject_toml.write(dedent(orig_pyproject))
332+
333+
store = PackageStore(
334+
type=FileType.TOML,
335+
file=pyproject_toml,
336+
dependencies=set(),
337+
py_versions=[],
338+
)
339+
340+
writer = PyprojectWriter(store, tmpdir)
341+
dependencies = [Security, DefusedXML]
342+
writer.write(dependencies)
343+
344+
updated_pyproject = f"""\
345+
[tool.poetry]
346+
name = "example-project"
347+
version = "0.1.0"
348+
description = "An example project to demonstrate Poetry configuration."
349+
authors = ["Your Name <[email protected]>"]
350+
351+
[tool.poetry.dependencies]
352+
{Security.requirement.name} = "{str(Security.requirement.specifier)}"
353+
{DefusedXML.requirement.name} = "{str(DefusedXML.requirement.specifier)}"
354+
355+
[build-system]
356+
requires = ["poetry-core>=1.0.0"]
357+
build-backend = "poetry.core.masonry.api"
358+
"""
359+
360+
assert pyproject_toml.read() == dedent(updated_pyproject)
361+
362+
363+
def test_pyproject_poetry_no_declared_deps(tmpdir):
364+
orig_pyproject = """\
365+
[tool.poetry]
366+
name = "example-project"
367+
version = "0.1.0"
368+
description = "An example project to demonstrate Poetry configuration."
369+
authors = ["Your Name <[email protected]>"]
370+
371+
[build-system]
372+
requires = ["poetry-core>=1.0.0"]
373+
build-backend = "poetry.core.masonry.api"
374+
375+
[tool.poetry.dependencies]
376+
python = "^3.11"
377+
"""
378+
379+
pyproject_toml = tmpdir.join("pyproject.toml")
380+
pyproject_toml.write(dedent(orig_pyproject))
381+
382+
store = PackageStore(
383+
type=FileType.TOML,
384+
file=pyproject_toml,
385+
dependencies=set(),
386+
py_versions=["~=3.11.0"],
387+
)
388+
389+
writer = PyprojectWriter(store, tmpdir)
390+
dependencies = [Security, DefusedXML]
391+
writer.write(dependencies)
392+
393+
updated_pyproject = f"""\
394+
[tool.poetry]
395+
name = "example-project"
396+
version = "0.1.0"
397+
description = "An example project to demonstrate Poetry configuration."
398+
authors = ["Your Name <[email protected]>"]
399+
400+
[build-system]
401+
requires = ["poetry-core>=1.0.0"]
402+
build-backend = "poetry.core.masonry.api"
403+
404+
[tool.poetry.dependencies]
405+
python = "^3.11"
406+
{Security.requirement.name} = "{str(Security.requirement.specifier)}"
407+
{DefusedXML.requirement.name} = "{str(DefusedXML.requirement.specifier)}"
408+
"""
409+
410+
assert pyproject_toml.read() == dedent(updated_pyproject)

0 commit comments

Comments
 (0)