1
1
"""
2
- Generate wheels for the Substra algo.
2
+ Utility functions to manage dependencies (building wheels, compiling requirement...)
3
3
"""
4
4
import logging
5
+ import os
6
+ import re
5
7
import shutil
6
8
import subprocess
7
9
import sys
8
10
from pathlib import Path
11
+ from pathlib import PurePosixPath
12
+ from types import ModuleType
9
13
from typing import List
14
+ from typing import Union
15
+
16
+ from substrafl .exceptions import InvalidDependenciesError
17
+ from substrafl .exceptions import InvalidUserModuleError
10
18
11
19
logger = logging .getLogger (__name__ )
12
20
13
21
LOCAL_WHEELS_FOLDER = Path .home () / ".substrafl"
14
22
15
23
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.
21
67
22
68
This allows one user to use custom version of the passed modules.
23
69
24
70
Args:
25
71
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'
28
72
dest_dir (str): relative directory where the wheels are saved
29
73
30
74
Returns:
31
- str: dockerfile command for installing the given modules
75
+ List[ str]: wheel names for the given modules
32
76
"""
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 )
36
79
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 ():
38
83
msg = ", " .join ([lib .__name__ for lib in lib_modules ])
39
84
raise NotImplementedError (
40
85
f"You must install { msg } in editable mode.\n " "eg `pip install -e substra` in the substra directory"
41
86
)
42
87
lib_name = lib_module .__name__
43
- lib_path = Path (lib_module .__file__ ).parents [1 ]
44
88
wheel_name = f"{ lib_name } -{ lib_module .__version__ } -py3-none-any.whl"
45
89
46
90
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
48
92
if wheel_path .exists ():
49
93
logger .warning (
50
94
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
54
98
else :
55
99
# if the right version of substra or substratools is not found, it will search if they are already
56
100
# 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 = []
59
103
if lib_name == "substrafl" :
60
104
extra_args = [
61
105
"--find-links" ,
62
- operation_dir / "dist/ substra" ,
106
+ dest_dir . parent / "substra" ,
63
107
"--find-links" ,
64
- operation_dir / "dist/ substratools" ,
108
+ dest_dir . parent / "substratools" ,
65
109
]
66
110
subprocess .check_output (
67
111
[
@@ -78,65 +122,88 @@ def local_lib_wheels(lib_modules: List, *, operation_dir: Path, python_major_min
78
122
cwd = str (lib_path ),
79
123
)
80
124
81
- shutil .copy (wheel_path , wheels_dir / wheel_name )
125
+ shutil .copy (wheel_path , dest_dir / wheel_name )
126
+ wheel_names .append (wheel_name )
82
127
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
90
129
91
- return "\n " .join (install_cmds )
92
130
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
97
133
98
134
Args:
99
135
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
103
136
104
137
Returns:
105
- str: dockerfile command for installing the given modules
138
+ list( str): list of dependencies to install in the Docker container
106
139
"""
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 ]
110
141
111
- LOCAL_WHEELS_FOLDER .mkdir (exist_ok = True )
112
142
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.
115
145
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"
136
153
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 )
141
155
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 )} \n stdout: { e .stdout } \n stderr: { 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