Skip to content

Commit 8432e57

Browse files
committed
Refactor CondaPackageHelper: cached_property, better typing, no state
1 parent de8a134 commit 8432e57

File tree

3 files changed

+77
-118
lines changed

3 files changed

+77
-118
lines changed

tests/by_image/docker-stacks-foundation/test_outdated.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
LOGGER = logging.getLogger(__name__)
1111

1212

13+
@pytest.mark.parametrize("requested_only", [True, False])
1314
@pytest.mark.info
14-
def test_outdated_packages(
15-
container: TrackedContainer, requested_only: bool = True
16-
) -> None:
15+
def test_outdated_packages(container: TrackedContainer, requested_only: bool) -> None:
1716
"""Getting the list of updatable packages"""
1817
LOGGER.info(f"Checking outdated packages in {container.image_name} ...")
1918
pkg_helper = CondaPackageHelper(container)
20-
pkg_helper.check_updatable_packages(requested_only)
21-
LOGGER.info(pkg_helper.get_outdated_summary(requested_only))
22-
LOGGER.info(f"\n{pkg_helper.get_outdated_table()}\n")
19+
updatable = pkg_helper.find_updatable_packages(requested_only)
20+
LOGGER.info(pkg_helper.get_outdated_summary(updatable, requested_only))
21+
LOGGER.info(
22+
f"Outdated packages table:\n{pkg_helper.get_outdated_table(updatable)}\n"
23+
)

tests/by_image/docker-stacks-foundation/test_packages.py

+27-50
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717
This means that it does not check dependencies.
1818
This choice is a tradeoff to cover the main requirements while achieving a reasonable test duration.
1919
However, it could be easily changed (or completed) to cover dependencies as well.
20-
Use `package_helper.installed_packages()` instead of `package_helper.requested_packages()`.
20+
Use `package_helper.installed_packages` instead of `package_helper.requested_packages`.
2121
"""
2222

2323
import logging
24-
from collections.abc import Callable, Iterable
24+
from collections.abc import Callable
2525

2626
import pytest # type: ignore
2727

@@ -67,18 +67,6 @@
6767
]
6868

6969

70-
@pytest.fixture(scope="function")
71-
def package_helper(container: TrackedContainer) -> CondaPackageHelper:
72-
"""Return a package helper object that can be used to perform tests on installed packages"""
73-
return CondaPackageHelper(container)
74-
75-
76-
@pytest.fixture(scope="function")
77-
def requested_packages(package_helper: CondaPackageHelper) -> dict[str, set[str]]:
78-
"""Return the list of requested packages (i.e. packages explicitly installed excluding dependencies)"""
79-
return package_helper.requested_packages()
80-
81-
8270
def is_r_package(package: str) -> bool:
8371
"""Check if a package is an R package"""
8472
return package.startswith("r-")
@@ -91,71 +79,60 @@ def get_package_import_name(package: str) -> str:
9179
return PACKAGE_MAPPING.get(package, package)
9280

9381

94-
def check_import_python_package(
95-
package_helper: CondaPackageHelper, package: str
96-
) -> None:
82+
def check_import_python_package(container: TrackedContainer, package: str) -> None:
9783
"""Try to import a Python package from the command line"""
98-
package_helper.container.exec_cmd(f'python -c "import {package}"')
84+
container.exec_cmd(f'python -c "import {package}"')
9985

10086

101-
def check_import_r_package(package_helper: CondaPackageHelper, package: str) -> None:
87+
def check_import_r_package(container: TrackedContainer, package: str) -> None:
10288
"""Try to import an R package from the command line"""
103-
package_helper.container.exec_cmd(f"R --slave -e library({package})")
89+
container.exec_cmd(f"R --slave -e library({package})")
10490

10591

10692
def _check_import_packages(
107-
package_helper: CondaPackageHelper,
108-
packages_to_check: Iterable[str],
109-
check_function: Callable[[CondaPackageHelper, str], None],
93+
container: TrackedContainer,
94+
packages_to_check: list[str],
95+
check_function: Callable[[TrackedContainer, str], None],
11096
) -> None:
111-
"""Test if packages can be imported
112-
113-
Note: using a list of packages instead of a fixture for the list of packages
114-
since pytest prevents the use of multiple yields
115-
"""
97+
"""Test if packages can be imported"""
11698
failed_imports = []
11799
LOGGER.info("Testing the import of packages ...")
118100
for package in packages_to_check:
119101
LOGGER.info(f"Trying to import {package}")
120102
try:
121-
check_function(package_helper, package)
103+
check_function(container, package)
122104
except AssertionError as err:
123105
failed_imports.append(package)
124106
LOGGER.error(f"Failed to import package: {package}, output:\n {err}")
125107
if failed_imports:
126108
pytest.fail(f"following packages are not import-able: {failed_imports}")
127109

128110

129-
@pytest.fixture(scope="function")
130-
def r_packages(requested_packages: dict[str, set[str]]) -> Iterable[str]:
131-
"""Return an iterable of R packages"""
132-
return (
111+
def get_r_packages(package_helper: CondaPackageHelper) -> list[str]:
112+
"""Return a list of R packages"""
113+
return [
133114
get_package_import_name(pkg)
134-
for pkg in requested_packages
115+
for pkg in package_helper.requested_packages
135116
if is_r_package(pkg) and pkg not in EXCLUDED_PACKAGES
136-
)
117+
]
137118

138119

139-
def test_r_packages(
140-
package_helper: CondaPackageHelper, r_packages: Iterable[str]
141-
) -> None:
120+
def test_r_packages(container: TrackedContainer) -> None:
142121
"""Test the import of specified R packages"""
143-
_check_import_packages(package_helper, r_packages, check_import_r_package)
122+
r_packages = get_r_packages(CondaPackageHelper(container))
123+
_check_import_packages(container, r_packages, check_import_r_package)
144124

145125

146-
@pytest.fixture(scope="function")
147-
def python_packages(requested_packages: dict[str, set[str]]) -> Iterable[str]:
148-
"""Return an iterable of Python packages"""
149-
return (
126+
def get_python_packages(package_helper: CondaPackageHelper) -> list[str]:
127+
"""Return a list of Python packages"""
128+
return [
150129
get_package_import_name(pkg)
151-
for pkg in requested_packages
130+
for pkg in package_helper.requested_packages
152131
if not is_r_package(pkg) and pkg not in EXCLUDED_PACKAGES
153-
)
132+
]
154133

155134

156-
def test_python_packages(
157-
package_helper: CondaPackageHelper,
158-
python_packages: Iterable[str],
159-
) -> None:
135+
def test_python_packages(container: TrackedContainer) -> None:
160136
"""Test the import of specified python packages"""
161-
_check_import_packages(package_helper, python_packages, check_import_python_package)
137+
python_packages = get_python_packages(CondaPackageHelper(container))
138+
_check_import_packages(container, python_packages, check_import_python_package)

tests/utils/conda_package_helper.py

+43-62
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
import logging
2727
import re
2828
from collections import defaultdict
29+
from functools import cached_property
2930
from itertools import chain
30-
from typing import Any
3131

3232
from tabulate import tabulate
3333

@@ -43,28 +43,21 @@ def __init__(self, container: TrackedContainer):
4343
self.container = container
4444
self.container.run_detached(command=["sleep", "infinity"])
4545

46-
self.requested: dict[str, set[str]] | None = None
47-
self.installed: dict[str, set[str]] | None = None
48-
self.available: dict[str, set[str]] | None = None
49-
self.comparison: list[dict[str, str]] = []
50-
46+
@cached_property
5147
def installed_packages(self) -> dict[str, set[str]]:
5248
"""Return the installed packages"""
53-
if self.installed is None:
54-
LOGGER.info("Grabbing the list of installed packages ...")
55-
env_export = self.container.exec_cmd("mamba env export --no-build --json")
56-
self.installed = CondaPackageHelper._parse_package_versions(env_export)
57-
return self.installed
49+
LOGGER.info("Grabbing the list of installed packages ...")
50+
env_export = self.container.exec_cmd("mamba env export --no-build --json")
51+
return self._parse_package_versions(env_export)
5852

53+
@cached_property
5954
def requested_packages(self) -> dict[str, set[str]]:
6055
"""Return the requested package (i.e. `mamba install <package>`)"""
61-
if self.requested is None:
62-
LOGGER.info("Grabbing the list of manually requested packages ...")
63-
env_export = self.container.exec_cmd(
64-
"mamba env export --no-build --json --from-history"
65-
)
66-
self.requested = CondaPackageHelper._parse_package_versions(env_export)
67-
return self.requested
56+
LOGGER.info("Grabbing the list of manually requested packages ...")
57+
env_export = self.container.exec_cmd(
58+
"mamba env export --no-build --json --from-history"
59+
)
60+
return self._parse_package_versions(env_export)
6861

6962
@staticmethod
7063
def _parse_package_versions(env_export: str) -> dict[str, set[str]]:
@@ -91,20 +84,16 @@ def _parse_package_versions(env_export: str) -> dict[str, set[str]]:
9184
packages_dict[package] = version
9285
return packages_dict
9386

87+
@cached_property
9488
def available_packages(self) -> dict[str, set[str]]:
9589
"""Return the available packages"""
96-
if self.available is None:
97-
LOGGER.info(
98-
"Grabbing the list of available packages (can take a while) ..."
99-
)
100-
# Keeping command line output since `mamba search --outdated --json` is way too long ...
101-
self.available = CondaPackageHelper._extract_available(
102-
self.container.exec_cmd("conda search --outdated --quiet")
103-
)
104-
return self.available
90+
LOGGER.info("Grabbing the list of available packages (can take a while) ...")
91+
return self._extract_available(
92+
self.container.exec_cmd("conda search --outdated --quiet")
93+
)
10594

10695
@staticmethod
107-
def _extract_available(lines: str) -> dict[str, set[str]]:
96+
def _extract_available(lines: str) -> defaultdict[str, set[str]]:
10897
"""Extract packages and versions from the lines returned by the list of packages"""
10998
ddict = defaultdict(set)
11099
for line in lines.splitlines()[2:]:
@@ -114,39 +103,28 @@ def _extract_available(lines: str) -> dict[str, set[str]]:
114103
ddict[pkg].add(version)
115104
return ddict
116105

117-
def check_updatable_packages(
118-
self, requested_only: bool = True
119-
) -> list[dict[str, str]]:
106+
def find_updatable_packages(self, requested_only: bool) -> list[dict[str, str]]:
120107
"""Check the updatable packages including or not dependencies"""
121-
requested = self.requested_packages()
122-
installed = self.installed_packages()
123-
available = self.available_packages()
124-
self.comparison = []
125-
for pkg, inst_vs in installed.items():
126-
if not requested_only or pkg in requested:
127-
avail_vs = sorted(
128-
list(available[pkg]), key=CondaPackageHelper.semantic_cmp
129-
)
130-
if not avail_vs:
131-
continue
132-
current = min(inst_vs, key=CondaPackageHelper.semantic_cmp)
133-
newest = avail_vs[-1]
134-
if (
135-
avail_vs
136-
and current != newest
137-
and CondaPackageHelper.semantic_cmp(current)
138-
< CondaPackageHelper.semantic_cmp(newest)
139-
):
140-
self.comparison.append(
141-
{"Package": pkg, "Current": current, "Newest": newest}
142-
)
143-
return self.comparison
108+
updatable = []
109+
for pkg, inst_vs in self.installed_packages.items():
110+
avail_vs = self.available_packages[pkg]
111+
if (requested_only and pkg not in self.requested_packages) or (
112+
not avail_vs
113+
):
114+
continue
115+
newest = sorted(avail_vs, key=CondaPackageHelper.semantic_cmp)[-1]
116+
current = min(inst_vs, key=CondaPackageHelper.semantic_cmp)
117+
if CondaPackageHelper.semantic_cmp(
118+
current
119+
) < CondaPackageHelper.semantic_cmp(newest):
120+
updatable.append({"Package": pkg, "Current": current, "Newest": newest})
121+
return updatable
144122

145123
@staticmethod
146-
def semantic_cmp(version_string: str) -> Any:
124+
def semantic_cmp(version_string: str) -> tuple[int, ...]:
147125
"""Manage semantic versioning for comparison"""
148126

149-
def my_split(string: str) -> list[Any]:
127+
def my_split(string: str) -> list[list[str]]:
150128
def version_substrs(x: str) -> list[str]:
151129
return re.findall(r"([A-z]+|\d+)", x)
152130

@@ -168,15 +146,18 @@ def try_int(version_str: str) -> int:
168146
mss = list(chain(*my_split(version_string)))
169147
return tuple(map(try_int, mss))
170148

171-
def get_outdated_summary(self, requested_only: bool = True) -> str:
149+
def get_outdated_summary(
150+
self, updatable: list[dict[str, str]], requested_only: bool
151+
) -> str:
172152
"""Return a summary of outdated packages"""
173-
packages = self.requested if requested_only else self.installed
174-
assert packages is not None
153+
packages = (
154+
self.requested_packages if requested_only else self.installed_packages
155+
)
175156
nb_packages = len(packages)
176-
nb_updatable = len(self.comparison)
157+
nb_updatable = len(updatable)
177158
updatable_ratio = nb_updatable / nb_packages
178159
return f"{nb_updatable}/{nb_packages} ({updatable_ratio:.0%}) packages could be updated"
179160

180-
def get_outdated_table(self) -> str:
161+
def get_outdated_table(self, updatable: list[dict[str, str]]) -> str:
181162
"""Return a table of outdated packages"""
182-
return tabulate(self.comparison, headers="keys")
163+
return tabulate(updatable, headers="keys")

0 commit comments

Comments
 (0)