Skip to content

Commit 0e046b4

Browse files
committed
Extract all dependencies from poetry show to divide into direct & transitive
1 parent 11c8bce commit 0e046b4

File tree

6 files changed

+274
-116
lines changed

6 files changed

+274
-116
lines changed

exasol/toolbox/util/dependencies.py

Lines changed: 0 additions & 69 deletions
This file was deleted.

exasol/toolbox/util/dependencies/__init__.py

Whitespace-only changes.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import subprocess
5+
from pathlib import Path
6+
from typing import Optional
7+
8+
import tomlkit
9+
from pydantic import (
10+
BaseModel,
11+
model_validator,
12+
)
13+
from tomlkit import TOMLDocument
14+
15+
16+
class PoetryGroup(BaseModel):
17+
name: str
18+
toml_section: str
19+
20+
class Config:
21+
frozen = True
22+
23+
24+
class Package(BaseModel):
25+
name: str
26+
version: str
27+
28+
class Config:
29+
frozen = True
30+
31+
@property
32+
def normalized_name(self) -> str:
33+
return self.name.lower().replace("_", "-")
34+
35+
36+
class PoetryDependency(Package):
37+
name: str
38+
version: str
39+
group: PoetryGroup | None
40+
41+
42+
class PoetryToml(BaseModel):
43+
working_directory: Path
44+
45+
@model_validator(mode="before")
46+
def read_content(cls, values):
47+
file_path = values["working_directory"] / "pyproject.toml"
48+
if not file_path.exists():
49+
raise ValueError(f"File not found: {file_path}")
50+
51+
try:
52+
text = file_path.read_text()
53+
cls._content: TOMLDocument = tomlkit.loads(text)
54+
except Exception as e:
55+
raise ValueError(f"Error reading file: {str(e)}")
56+
return values
57+
58+
def get_section_dict(self, section: str) -> dict | None:
59+
current = self._content.copy()
60+
for section in section.split("."):
61+
if section not in current:
62+
return None
63+
current = current[section]
64+
return current
65+
66+
@property
67+
def groups(self) -> tuple[PoetryGroup, ...]:
68+
groups = []
69+
70+
main_key = "project.dependencies"
71+
if self.get_section_dict(main_key):
72+
groups.append(PoetryGroup(name="main", toml_section=main_key))
73+
74+
main_dynamic_key = "tool.poetry.dependencies"
75+
if self.get_section_dict(main_dynamic_key):
76+
groups.append(PoetryGroup(name="main", toml_section=main_dynamic_key))
77+
78+
group_key = "tool.poetry.group"
79+
if group_dict := self.get_section_dict(group_key):
80+
for group, content in group_dict.items():
81+
if "dependencies" in content:
82+
groups.append(
83+
PoetryGroup(
84+
name=group,
85+
toml_section=f"{group_key}.{group}.dependencies",
86+
)
87+
)
88+
return tuple(groups)
89+
90+
91+
class PoetryDependencies(BaseModel):
92+
groups: tuple[PoetryGroup, ...]
93+
working_directory: Path
94+
95+
@staticmethod
96+
def _extract_from_line(line: str, group: PoetryGroup | None) -> PoetryDependency:
97+
pattern = r"\s+(\d+(?:\.\d+)*)\s+"
98+
match = re.split(pattern, line)
99+
return PoetryDependency(name=match[0], version=match[1], group=group)
100+
101+
def _extract_from_poetry_show(
102+
self, output_text: str, group: PoetryGroup | None
103+
) -> list[PoetryDependency]:
104+
return [
105+
self._extract_from_line(line, group=group)
106+
for line in output_text.splitlines()
107+
]
108+
109+
@property
110+
def direct_dependencies(self) -> dict[str, list[PoetryDependency]]:
111+
dependencies = {}
112+
for group in self.groups:
113+
command = ("poetry", "show", "--top-level", f"--only={group.name}")
114+
output = subprocess.run(
115+
command, capture_output=True, text=True, cwd=self.working_directory
116+
)
117+
result = self._extract_from_poetry_show(
118+
output_text=output.stdout, group=group
119+
)
120+
dependencies[group.name] = result
121+
return dependencies
122+
123+
@property
124+
def all_dependencies(self) -> dict[str, list[PoetryDependency]]:
125+
command = ("poetry", "show")
126+
output = subprocess.run(
127+
command, capture_output=True, text=True, cwd=self.working_directory
128+
)
129+
130+
direct_dependencies = self.direct_dependencies.copy()
131+
transitive_dependencies = []
132+
names_direct_dependencies = {
133+
dep.name
134+
for group_list in direct_dependencies.values()
135+
for dep in group_list
136+
}
137+
for line in output.stdout.splitlines():
138+
dep = self._extract_from_line(line=line, group=None)
139+
if dep.name not in names_direct_dependencies:
140+
transitive_dependencies.append(dep)
141+
142+
return direct_dependencies | {"transitive": transitive_dependencies}

test/unit/dependencies_test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ def test_dependencies():
2222
autoimport = "^1.4.0"
2323
"""
2424

25-
2625
actual = _dependencies(toml)
2726
assert actual == {"project": ["pytest", "python"], "dev": ["autoimport"]}
2827

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import subprocess
2+
3+
import pytest
4+
from toolbox.util.dependencies.poetry_dependencies import (
5+
PoetryDependencies,
6+
PoetryDependency,
7+
PoetryGroup,
8+
PoetryToml,
9+
)
10+
11+
MAIN_GROUP = PoetryGroup(name="main", toml_section="project.dependencies")
12+
DEV_GROUP = PoetryGroup(name="dev", toml_section="tool.poetry.group.dev.dependencies")
13+
ANALYSIS_GROUP = PoetryGroup(
14+
name="analysis", toml_section="tool.poetry.group.analysis.dependencies"
15+
)
16+
17+
DIRECT_DEPENDENCIES = {
18+
MAIN_GROUP.name: [
19+
PoetryDependency(name="numpy", version="2.2.6", group=MAIN_GROUP),
20+
PoetryDependency(name="pylint", version="3.3.7", group=MAIN_GROUP),
21+
],
22+
DEV_GROUP.name: [PoetryDependency(name="isort", version="6.0.1", group=DEV_GROUP)],
23+
ANALYSIS_GROUP.name: [
24+
PoetryDependency(name="black", version="25.1.0", group=ANALYSIS_GROUP)
25+
],
26+
}
27+
28+
29+
@pytest.fixture(scope="module")
30+
def cwd(tmp_path_factory):
31+
return tmp_path_factory.mktemp("test")
32+
33+
34+
@pytest.fixture(scope="module")
35+
def project_name():
36+
return "project"
37+
38+
39+
@pytest.fixture(scope="module")
40+
def project_path(cwd, project_name):
41+
return cwd / project_name
42+
43+
44+
@pytest.fixture(scope="module")
45+
def create_poetry_project(cwd, project_name, project_path):
46+
subprocess.run(["poetry", "new", project_name], cwd=cwd)
47+
subprocess.run(["poetry", "add", "numpy"], cwd=project_path)
48+
subprocess.run(["poetry", "add", "pylint"], cwd=project_path)
49+
subprocess.run(["poetry", "add", "--group", "dev", "isort"], cwd=project_path)
50+
subprocess.run(["poetry", "add", "--group", "analysis", "black"], cwd=project_path)
51+
52+
53+
@pytest.fixture(scope="module")
54+
def pyproject_toml(project_path, create_poetry_project):
55+
return PoetryToml(working_directory=project_path)
56+
57+
58+
class TestPackage:
59+
@staticmethod
60+
@pytest.mark.parametrize(
61+
"name,expected",
62+
[
63+
("numpy", "numpy"),
64+
("sphinxcontrib-applehelp", "sphinxcontrib-applehelp"),
65+
("Imaginary_package", "imaginary-package"),
66+
],
67+
)
68+
def test_normalized_name(name, expected):
69+
dep = PoetryDependency(name=name, version="0.1.0", group=None)
70+
assert dep.normalized_name == expected
71+
72+
73+
class TestPoetryToml:
74+
@staticmethod
75+
def test_get_section_dict_exists(pyproject_toml):
76+
result = pyproject_toml.get_section_dict("project")
77+
assert result is not None
78+
79+
@staticmethod
80+
def test_get_section_dict_does_not_exist(pyproject_toml):
81+
result = pyproject_toml.get_section_dict("test")
82+
assert result is None
83+
84+
@staticmethod
85+
def test_groups(pyproject_toml):
86+
assert pyproject_toml.groups == (MAIN_GROUP, DEV_GROUP, ANALYSIS_GROUP)
87+
88+
89+
class TestPoetryDependencies:
90+
@staticmethod
91+
@pytest.mark.parametrize(
92+
"line,expected",
93+
[
94+
(
95+
"coverage 7.8.0 Code coverage measurement for Python",
96+
PoetryDependency(name="coverage", version="7.8.0", group=MAIN_GROUP),
97+
),
98+
(
99+
"furo 2024.8.6 A clean customisable Sphinx documentation theme.",
100+
PoetryDependency(name="furo", version="2024.8.6", group=MAIN_GROUP),
101+
),
102+
(
103+
"import-linter 2.3 Enforces rules for the imports within and between Python packages.",
104+
PoetryDependency(name="import-linter", version="2.3", group=MAIN_GROUP),
105+
),
106+
],
107+
)
108+
def test_extract_from_line(line, expected):
109+
result = PoetryDependencies._extract_from_line(line=line, group=MAIN_GROUP)
110+
assert result == expected
111+
112+
@staticmethod
113+
def test_direct_dependencies(create_poetry_project, project_path):
114+
poetry_dep = PoetryDependencies(
115+
groups=(MAIN_GROUP, DEV_GROUP, ANALYSIS_GROUP),
116+
working_directory=project_path,
117+
)
118+
assert poetry_dep.direct_dependencies == DIRECT_DEPENDENCIES
119+
120+
@staticmethod
121+
def test_all_dependencies(create_poetry_project, project_path):
122+
# set_direct_deps = set(DIRECT_DEPENDENCIES)
123+
124+
poetry_dep = PoetryDependencies(
125+
groups=(MAIN_GROUP, DEV_GROUP, ANALYSIS_GROUP),
126+
working_directory=project_path,
127+
)
128+
result = poetry_dep.all_dependencies
129+
130+
transitive = result.pop("transitive")
131+
assert len(transitive) > 0
132+
assert result == DIRECT_DEPENDENCIES

0 commit comments

Comments
 (0)