Skip to content

Commit 0fbd613

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

File tree

6 files changed

+273
-116
lines changed

6 files changed

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