Skip to content

Commit b6fa09f

Browse files
committed
feat: unify dependencies
Signed-off-by: ThibaultFy <[email protected]>
1 parent c40a7b2 commit b6fa09f

File tree

17 files changed

+637
-208
lines changed

17 files changed

+637
-208
lines changed

CHANGELOG.md

+14
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## \[Unreleased\]
99

10+
- Check the Python version used before generating the Dockerfile ([#155])(<https://github.com/Substra/substrafl/pull/155>)).
11+
- Python dependencies can be resolved using pip compile during function registration by setting `compile` to `True`
12+
in the `Dependency` object ([#155])(<https://github.com/Substra/substrafl/pull/155>)).
13+
14+
```py
15+
Dependency(
16+
pypi_dependencies=["pytest", "numpy"],
17+
compile=True,
18+
)
19+
```
20+
21+
- `Dependency` objects are now computed at initialization in a cache directory, accessible through the `copy_compute_dir` method. The cache directory is deleted at the `Dependency` object deletion. ([#155])(<https://github.com/Substra/substrafl/pull/155>))
22+
- BREAKING: local_installable_dependencies are now limited to local modules or Python wheels (no support for bdist, sdist...)([#155])(<https://github.com/Substra/substrafl/pull/155>)).
23+
1024
### Changed
1125

1226
- BREAKING: Rename `generate_wheel.py` to `manage_dependencies.py` ([#156](https://github.com/Substra/substrafl/pull/156))

benchmark/camelyon/workflows.py

+1
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def substrafl_fed_avg(
9191
local_code=[base / "common", base / "weldon_fedavg.py"],
9292
# Keeping editable_mode=True to ensure nightly test benchmarks are ran against main substrafl git ref
9393
editable_mode=True,
94+
compile=True,
9495
)
9596

9697
# Custom Strategy used for the data loading (from custom_torch_function.py file)

docs/conf.py

+2
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@
193193
("py:class", "substra.sdk.schemas.FunctionOutputSpec"),
194194
("py:class", "substra.sdk.schemas.FunctionInputSpec"),
195195
("py:class", "ComputePlan"),
196+
("py:class", "Path"),
197+
("py:class", "module"),
196198
]
197199

198200
html_css_files = [

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"wheel",
5050
"six",
5151
"packaging",
52+
"pip-tools",
5253
],
5354
extras_require={
5455
"dev": [

substrafl/constants.py

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
TMP_SUBSTRAFL_PREFIX = "tmp_substrafl"
2+
SUBSTRAFL_FOLDER = "substrafl_internal"
+134-67
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,94 @@
11
"""
2-
Generate wheels for the Substra algo.
2+
Utility functions to manage dependencies (building wheels, compiling requirement...)
33
"""
44
import logging
5+
import os
6+
import re
57
import shutil
68
import subprocess
79
import sys
810
from pathlib import Path
11+
from pathlib import PurePosixPath
12+
from types import ModuleType
913
from typing import List
14+
from typing import Union
15+
16+
from substrafl.exceptions import InvalidDependenciesError
17+
from substrafl.exceptions import InvalidUserModuleError
1018

1119
logger = logging.getLogger(__name__)
1220

1321
LOCAL_WHEELS_FOLDER = Path.home() / ".substrafl"
1422

1523

16-
def local_lib_wheels(lib_modules: List, *, operation_dir: Path, python_major_minor: str, dest_dir: str) -> str:
17-
"""Prepares the private modules from lib_modules list to be installed in a Docker image and generates the
18-
appropriated install command for a dockerfile. It first creates the wheel for each library. Each of the
19-
libraries must be already installed in the correct version locally. Use command:
20-
``pip install -e library-name`` in the directory of each library.
24+
def build_user_dependency_wheel(lib_path: Path, dest_dir: Path) -> str:
25+
"""Build the wheel for user dependencies passed as a local module.
26+
Delete the local module when the build is done.
27+
28+
Args:
29+
lib_path (Path): where the module is located.
30+
dest_dir (Path): where the wheel needs to be copied.
31+
32+
Returns:
33+
str: the filename of the wheel
34+
"""
35+
# sys.executable takes the current Python interpreter instead of the default one on the computer
36+
try:
37+
ret = subprocess.run(
38+
[
39+
sys.executable,
40+
"-m",
41+
"pip",
42+
"wheel",
43+
str(lib_path) + os.sep,
44+
"--no-deps",
45+
],
46+
cwd=str(dest_dir),
47+
check=True,
48+
capture_output=True,
49+
text=True,
50+
)
51+
52+
# Delete the folder when the wheel is computed
53+
shutil.rmtree(dest_dir / lib_path) # delete directory
54+
except subprocess.CalledProcessError as e:
55+
raise InvalidUserModuleError from e
56+
wheel_name = re.findall(r"filename=(\S*)", ret.stdout)[0]
57+
58+
return wheel_name
59+
60+
61+
def local_lib_wheels(lib_modules: List[ModuleType], *, dest_dir: Path) -> List[str]:
62+
"""Generate wheels for the private modules from lib_modules list and returns the list of names for each wheel.
63+
64+
It first creates the wheel for each library. Each of the libraries must be already installed in the correct
65+
version locally. Use command: ``pip install -e library-name`` in the directory of each library.
66+
Then it copies the wheels to the given directory.
2167
2268
This allows one user to use custom version of the passed modules.
2369
2470
Args:
2571
lib_modules (list): list of modules to be installed.
26-
operation_dir (pathlib.Path): Path to the operation directory
27-
python_major_minor (str): version which is to be used in the dockerfile. Eg: '3.8'
2872
dest_dir (str): relative directory where the wheels are saved
2973
3074
Returns:
31-
str: dockerfile command for installing the given modules
75+
List[str]: wheel names for the given modules
3276
"""
33-
install_cmds = []
34-
wheels_dir = operation_dir / dest_dir
35-
wheels_dir.mkdir(exist_ok=True, parents=True)
77+
wheel_names = []
78+
dest_dir.mkdir(exist_ok=True, parents=True)
3679
for lib_module in lib_modules:
37-
if not (Path(lib_module.__file__).parents[1] / "setup.py").exists():
80+
lib_path = Path(lib_module.__file__).parents[1]
81+
# this function is in practice only called on substra libraries, and we know we use a setup.py
82+
if not (lib_path / "setup.py").exists():
3883
msg = ", ".join([lib.__name__ for lib in lib_modules])
3984
raise NotImplementedError(
4085
f"You must install {msg} in editable mode.\n" "eg `pip install -e substra` in the substra directory"
4186
)
4287
lib_name = lib_module.__name__
43-
lib_path = Path(lib_module.__file__).parents[1]
4488
wheel_name = f"{lib_name}-{lib_module.__version__}-py3-none-any.whl"
4589

4690
wheel_path = LOCAL_WHEELS_FOLDER / wheel_name
47-
# Recreate the wheel only if it does not exist
91+
# Recreate the wheel only if it does not exist
4892
if wheel_path.exists():
4993
logger.warning(
5094
f"Existing wheel {wheel_path} will be used to build {lib_name}. "
@@ -54,14 +98,14 @@ def local_lib_wheels(lib_modules: List, *, operation_dir: Path, python_major_min
5498
else:
5599
# if the right version of substra or substratools is not found, it will search if they are already
56100
# installed in 'dist' and take them from there.
57-
# sys.executable takes the Python interpreter run by the code and not the default one on the computer
58-
extra_args: list = list()
101+
# sys.executable takes the current Python interpreter instead of the default one on the computer
102+
extra_args: list = []
59103
if lib_name == "substrafl":
60104
extra_args = [
61105
"--find-links",
62-
operation_dir / "dist/substra",
106+
dest_dir.parent / "substra",
63107
"--find-links",
64-
operation_dir / "dist/substratools",
108+
dest_dir.parent / "substratools",
65109
]
66110
subprocess.check_output(
67111
[
@@ -78,65 +122,88 @@ def local_lib_wheels(lib_modules: List, *, operation_dir: Path, python_major_min
78122
cwd=str(lib_path),
79123
)
80124

81-
shutil.copy(wheel_path, wheels_dir / wheel_name)
125+
shutil.copy(wheel_path, dest_dir / wheel_name)
126+
wheel_names.append(wheel_name)
82127

83-
# Necessary command to install the wheel in the docker image
84-
force_reinstall = "--force-reinstall " if lib_name in ["substratools", "substra"] else ""
85-
install_cmd = (
86-
f"COPY {dest_dir}/{wheel_name} . \n"
87-
+ f"RUN python{python_major_minor} -m pip install {force_reinstall}{wheel_name}\n"
88-
)
89-
install_cmds.append(install_cmd)
128+
return wheel_names
90129

91-
return "\n".join(install_cmds)
92130

93-
94-
def pypi_lib_wheels(lib_modules: List, *, operation_dir: Path, python_major_minor: str, dest_dir: str) -> str:
95-
"""Retrieves lib_modules' wheels to be installed in a Docker image and generates
96-
the appropriated install command for a dockerfile.
131+
def get_pypi_dependencies_versions(lib_modules: List) -> List[str]:
132+
"""Retrieve the version of the PyPI libraries installed to generate the dependency list
97133
98134
Args:
99135
lib_modules (list): list of modules to be installed.
100-
operation_dir (pathlib.Path): Path to the operation directory
101-
python_major_minor (str): version which is to be used in the dockerfile. Eg: '3.8'
102-
dest_dir (str): relative directory where the wheels are saved
103136
104137
Returns:
105-
str: dockerfile command for installing the given modules
138+
list(str): list of dependencies to install in the Docker container
106139
"""
107-
install_cmds = []
108-
wheels_dir = operation_dir / dest_dir
109-
wheels_dir.mkdir(exist_ok=True, parents=True)
140+
return [f"{lib_module.__name__}=={lib_module.__version__}" for lib_module in lib_modules]
110141

111-
LOCAL_WHEELS_FOLDER.mkdir(exist_ok=True)
112142

113-
for lib_module in lib_modules:
114-
wheel_name = f"{lib_module.__name__}-{lib_module.__version__}-py3-none-any.whl"
143+
def write_requirements(dependency_list: List[Union[str, Path]], *, dest_dir: Path) -> None:
144+
"""Writes down a `requirements.txt` file with the list of explicit dependencies.
115145
116-
# Download only if exists
117-
if not ((LOCAL_WHEELS_FOLDER / wheel_name).exists()):
118-
subprocess.check_output(
119-
[
120-
sys.executable,
121-
"-m",
122-
"pip",
123-
"download",
124-
"--only-binary",
125-
":all:",
126-
"--python-version",
127-
python_major_minor,
128-
"--no-deps",
129-
"--implementation",
130-
"py",
131-
"-d",
132-
LOCAL_WHEELS_FOLDER,
133-
f"{lib_module.__name__}=={lib_module.__version__}",
134-
]
135-
)
146+
Args:
147+
dependency_list: list of dependencies to install; acceptable formats are library names (eg "substrafl"),
148+
any constraint expression accepted by pip("substrafl==0.36.0" or "substrafl < 1.0") or wheel names
149+
("substrafl-0.36.0-py3-none-any.whl")
150+
dest_dir: path to the directory where to write the ``requirements.txt``.
151+
"""
152+
requirements_txt = dest_dir / "requirements.txt"
136153

137-
# Get wheel name based on current version
138-
shutil.copy(LOCAL_WHEELS_FOLDER / wheel_name, wheels_dir / wheel_name)
139-
install_cmd = f"COPY {dest_dir}/{wheel_name} . \n RUN python{python_major_minor} -m pip install {wheel_name}\n"
140-
install_cmds.append(install_cmd)
154+
_write_requirements_file(dependency_list=dependency_list, file=requirements_txt)
141155

142-
return "\n".join(install_cmds)
156+
157+
def compile_requirements(dependency_list: List[Union[str, Path]], *, dest_dir: Path) -> None:
158+
"""Compile a list of requirements using pip-compile to generate a set of fully pinned third parties requirements
159+
160+
Writes down a `requirements.in` file with the list of explicit dependencies, then generates a `requirements.txt`
161+
file using pip-compile. The `requirements.txt` file contains a set of fully pinned dependencies, including indirect
162+
dependencies.
163+
164+
Args:
165+
dependency_list: list of dependencies to install; acceptable formats are library names (eg "substrafl"),
166+
any constraint expression accepted by pip("substrafl==0.36.0" or "substrafl < 1.0") or wheel names
167+
("substrafl-0.36.0-py3-none-any.whl")
168+
dest_dir: path to the directory where to write the ``requirements.in`` and ``requirements.txt``.
169+
170+
Raises:
171+
InvalidDependenciesError: if pip-compile does not find a set of compatible dependencies
172+
173+
"""
174+
requirements_in = dest_dir / "requirements.in"
175+
176+
_write_requirements_file(dependency_list=dependency_list, file=requirements_in)
177+
178+
command = [
179+
sys.executable,
180+
"-m",
181+
"piptools",
182+
"compile",
183+
"--resolver=backtracking",
184+
str(requirements_in),
185+
]
186+
try:
187+
logger.info("Compiling dependencies.")
188+
subprocess.run(
189+
command,
190+
cwd=dest_dir,
191+
check=True,
192+
capture_output=True,
193+
text=True,
194+
)
195+
except subprocess.CalledProcessError as e:
196+
raise InvalidDependenciesError(
197+
f"Error in command {' '.join(command)}\nstdout: {e.stdout}\nstderr: {e.stderr}"
198+
) from e
199+
200+
201+
def _write_requirements_file(dependency_list: List[Union[str, Path]], *, file: Path) -> None:
202+
requirements = ""
203+
for dependency in dependency_list:
204+
if str(dependency).endswith(".whl"):
205+
# Require '/', even on windows. The double conversion resolves that.
206+
requirements += f"file:{PurePosixPath(Path(dependency))}\n"
207+
else:
208+
requirements += f"{dependency}\n"
209+
file.write_text(requirements)

0 commit comments

Comments
 (0)