Skip to content

Commit 6fcfe97

Browse files
authored
Merge pull request #4701 from lexming/sync-20241110
sync with develop (2024-11-10)
2 parents 48bdb94 + 90544c8 commit 6fcfe97

File tree

3 files changed

+135
-90
lines changed

3 files changed

+135
-90
lines changed
Lines changed: 118 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
#!/usr/bin/env python
22

3+
"""
4+
Find Python dependencies for a given Python package after loading dependencies specified in an EasyConfig.
5+
This is intended for writing or updating PythonBundle EasyConfigs:
6+
1. Create a EasyConfig with at least 'Python' as a dependency.
7+
When updating to a new toolchain it is a good idea to reduce the dependencies to a minimum
8+
as e.g. the new "Python" module might have different packages included.
9+
2. Run this script
10+
3. For each dependency found by this script search existing EasyConfigs for ones providing that Python package.
11+
E.g many are contained in Python-bundle-PyPI. Some can be updated from an earlier toolchain.
12+
4. Add those EasyConfigs as dependencies to your new EasyConfig.
13+
5. Rerun this script so it takes the newly provided packages into account.
14+
You can do steps 3-5 iteratively adding EasyConfig-dependencies one-by-one.
15+
6. Finally you copy the packages found by this script as "exts_list" into the new EasyConfig.
16+
You usually want the list printed as "in install order", the format is already suitable to be copied as-is.
17+
"""
18+
319
import argparse
420
import json
521
import os
@@ -8,12 +24,13 @@
824
import subprocess
925
import sys
1026
import tempfile
27+
import textwrap
1128
from contextlib import contextmanager
1229
from pprint import pprint
1330
try:
1431
import pkg_resources
1532
except ImportError as e:
16-
print('pkg_resources could not be imported: %s\nYou might need to install setuptools!' % e)
33+
print(f'pkg_resources could not be imported: {e}\nYou might need to install setuptools!')
1734
sys.exit(1)
1835

1936
try:
@@ -22,6 +39,7 @@
2239
_canonicalize_regex = re.compile(r"[-_.]+")
2340

2441
def canonicalize_name(name):
42+
"""Fallback if the import doesn't work with same behavior."""
2543
return _canonicalize_regex.sub("-", name).lower()
2644

2745

@@ -36,16 +54,16 @@ def temporary_directory(*args, **kwargs):
3654

3755

3856
def extract_pkg_name(package_spec):
39-
return re.split('<|>|=|~', args.package, 1)[0]
57+
"""Get the package name from a specification such as 'package>=3.42'"""
58+
return re.split('<|>|=|~', package_spec, 1)[0]
4059

4160

42-
def can_run(cmd, argument):
61+
def can_run(cmd, *arguments):
4362
"""Check if the given cmd and argument can be run successfully"""
44-
with open(os.devnull, 'w') as FNULL:
45-
try:
46-
return subprocess.call([cmd, argument], stdout=FNULL, stderr=subprocess.STDOUT) == 0
47-
except (subprocess.CalledProcessError, OSError):
48-
return False
63+
try:
64+
return subprocess.call([cmd, *arguments], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) == 0
65+
except (subprocess.CalledProcessError, OSError):
66+
return False
4967

5068

5169
def run_shell_cmd(arguments, action_desc, capture_stderr=True, **kwargs):
@@ -59,13 +77,13 @@ def run_shell_cmd(arguments, action_desc, capture_stderr=True, **kwargs):
5977
if p.returncode != 0:
6078
if err:
6179
err = "\nSTDERR:\n" + err
62-
raise RuntimeError('Failed to %s: %s%s' % (action_desc, out, err))
80+
raise RuntimeError(f'Failed to {action_desc}: {out}{err}')
6381
return out
6482

6583

6684
def run_in_venv(cmd, venv_path, action_desc):
6785
"""Run the given command in the virtualenv at the given path"""
68-
cmd = 'source %s/bin/activate && %s' % (venv_path, cmd)
86+
cmd = f'source {venv_path}/bin/activate && {cmd}'
6987
return run_shell_cmd(cmd, action_desc, shell=True, executable='/bin/bash')
7088

7189

@@ -78,17 +96,19 @@ def get_dep_tree(package_spec, verbose):
7896
venv_dir = os.path.join(tmp_dir, 'venv')
7997
if verbose:
8098
print('Creating virtualenv at ' + venv_dir)
81-
run_shell_cmd(['virtualenv', '--system-site-packages', venv_dir], action_desc='create virtualenv')
99+
run_shell_cmd(
100+
[sys.executable, '-m', 'venv', '--system-site-packages', venv_dir], action_desc='create virtualenv'
101+
)
82102
if verbose:
83103
print('Updating pip in virtualenv')
84104
run_in_venv('pip install --upgrade pip', venv_dir, action_desc='update pip')
85105
if verbose:
86-
print('Installing %s into virtualenv' % package_spec)
87-
out = run_in_venv('pip install "%s"' % package_spec, venv_dir, action_desc='install ' + package_spec)
88-
print('%s installed: %s' % (package_spec, out))
106+
print(f'Installing {package_spec} into virtualenv')
107+
out = run_in_venv(f'pip install "{package_spec}"', venv_dir, action_desc='install ' + package_spec)
108+
print(f'{package_spec} installed: {out}')
89109
# install pipdeptree, figure out dependency tree for installed package
90110
run_in_venv('pip install pipdeptree', venv_dir, action_desc='install pipdeptree')
91-
dep_tree = run_in_venv('pipdeptree -j -p "%s"' % package_name,
111+
dep_tree = run_in_venv(f'pipdeptree -j -p "{package_name}"',
92112
venv_dir, action_desc='collect dependencies')
93113
return json.loads(dep_tree)
94114

@@ -108,26 +128,27 @@ def find_deps(pkgs, dep_tree):
108128
for orig_pkg in cur_pkgs:
109129
count += 1
110130
if count > MAX_PACKAGES:
111-
raise RuntimeError("Aborting after checking %s packages. Possibly cycle detected!" % MAX_PACKAGES)
131+
raise RuntimeError(f"Aborting after checking {MAX_PACKAGES} packages. Possibly cycle detected!")
112132
pkg = canonicalize_name(orig_pkg)
113133
matching_entries = [entry for entry in dep_tree
114134
if pkg in (entry['package']['package_name'], entry['package']['key'])]
115135
if not matching_entries:
116136
matching_entries = [entry for entry in dep_tree
117137
if orig_pkg in (entry['package']['package_name'], entry['package']['key'])]
118138
if not matching_entries:
119-
raise RuntimeError("Found no installed package for '%s' in %s" % (pkg, dep_tree))
139+
raise RuntimeError(f"Found no installed package for '{pkg}' in {dep_tree}")
120140
if len(matching_entries) > 1:
121-
raise RuntimeError("Found multiple installed packages for '%s' in %s" % (pkg, dep_tree))
141+
raise RuntimeError(f"Found multiple installed packages for '{pkg}' in {dep_tree}")
122142
entry = matching_entries[0]
123-
res.append((entry['package']['package_name'], entry['package']['installed_version']))
143+
res.append(entry['package'])
124144
# Add dependencies to list of packages to check next
125145
# Could call this function recursively but that might exceed the max recursion depth
126146
next_pkgs.update(dep['package_name'] for dep in entry['dependencies'])
127147
return res
128148

129149

130150
def print_deps(package, verbose):
151+
"""Print dependencies of the given package that are not installed yet in a format usable as 'exts_list'"""
131152
if verbose:
132153
print('Getting dep tree of ' + package)
133154
dep_tree = get_dep_tree(package, verbose)
@@ -144,82 +165,92 @@ def print_deps(package, verbose):
144165
res = []
145166
handled = set()
146167
for dep in reversed(deps):
147-
if dep not in handled:
148-
handled.add(dep)
149-
if dep[0] in installed_modules:
168+
# Tuple as we need it for exts_list
169+
dep_entry = (dep['package_name'], dep['installed_version'])
170+
if dep_entry not in handled:
171+
handled.add(dep_entry)
172+
# Need to check for key and package_name as naming is not consistent. E.g.:
173+
# "PyQt5-sip": 'key': 'pyqt5-sip', 'package_name': 'PyQt5-sip'
174+
# "jupyter-core": 'key': 'jupyter-core', 'package_name': 'jupyter_core'
175+
if dep['key'] in installed_modules or dep['package_name'] in installed_modules:
150176
if verbose:
151-
print("Skipping installed module '%s'" % dep[0])
177+
print(f"Skipping installed module '{dep['package_name']}'")
152178
else:
153-
res.append(dep)
179+
res.append(dep_entry)
154180

155181
print("List of dependencies in (likely) install order:")
156182
pprint(res, indent=4)
157183
print("Sorted list of dependencies:")
158184
pprint(sorted(res), indent=4)
159185

160186

161-
examples = [
162-
'Example usage with EasyBuild (after installing dependency modules):',
163-
'\t' + sys.argv[0] + ' --ec TensorFlow-2.3.4.eb tensorflow==2.3.4',
164-
'Which is the same as:',
165-
'\t' + ' && '.join(['eb TensorFlow-2.3.4.eb --dump-env',
166-
'source TensorFlow-2.3.4.env',
167-
sys.argv[0] + ' tensorflow==2.3.4',
168-
]),
169-
]
170-
parser = argparse.ArgumentParser(
171-
description='Find dependencies of Python packages by installing it in a temporary virtualenv. ',
172-
epilog='\n'.join(examples),
173-
formatter_class=argparse.RawDescriptionHelpFormatter
174-
)
175-
parser.add_argument('package', metavar='python-pkg-spec',
176-
help='Python package spec, e.g. tensorflow==2.3.4')
177-
parser.add_argument('--ec', metavar='easyconfig', help='EasyConfig to use as the build environment. '
178-
'You need to have dependency modules installed already!')
179-
parser.add_argument('--verbose', help='Verbose output', action='store_true')
180-
args = parser.parse_args()
181-
182-
if args.ec:
183-
if not can_run('eb', '--version'):
184-
print('EasyBuild not found or executable. Make sure it is in your $PATH when using --ec!')
185-
sys.exit(1)
186-
if args.verbose:
187-
print('Checking with EasyBuild for missing dependencies')
188-
missing_dep_out = run_shell_cmd(['eb', args.ec, '--missing'],
189-
capture_stderr=False,
190-
action_desc='Get missing dependencies')
191-
excluded_dep = '(%s)' % os.path.basename(args.ec)
192-
missing_deps = [dep for dep in missing_dep_out.split('\n')
193-
if dep.startswith('*') and excluded_dep not in dep
194-
]
195-
if missing_deps:
196-
print('You need to install all modules on which %s depends first!' % args.ec)
197-
print('\n\t'.join(['Missing:'] + missing_deps))
198-
sys.exit(1)
199-
200-
# If the --ec argument is a (relative) existing path make it absolute so we can find it after the chdir
201-
ec_arg = os.path.abspath(args.ec) if os.path.exists(args.ec) else args.ec
202-
with temporary_directory() as tmp_dir:
203-
old_dir = os.getcwd()
204-
os.chdir(tmp_dir)
205-
if args.verbose:
206-
print('Running EasyBuild to get build environment')
207-
run_shell_cmd(['eb', ec_arg, '--dump-env', '--force'], action_desc='Dump build environment')
208-
os.chdir(old_dir)
187+
def main():
188+
"""Entrypoint of the script"""
189+
examples = textwrap.dedent(f"""
190+
Example usage with EasyBuild (after installing dependency modules):
191+
{sys.argv[0]} --ec TensorFlow-2.3.4.eb tensorflow==2.3.4
192+
Which is the same as:
193+
eb TensorFlow-2.3.4.eb --dump-env && source TensorFlow-2.3.4.env && {sys.argv[0]} tensorflow==2.3.4
194+
Using the '--ec' parameter is recommended as the latter requires manually updating the .env file
195+
after each change to the EasyConfig.
196+
""")
197+
parser = argparse.ArgumentParser(
198+
description='Find dependencies of Python packages by installing it in a temporary virtualenv. ',
199+
epilog='\n'.join(examples),
200+
formatter_class=argparse.RawDescriptionHelpFormatter
201+
)
202+
parser.add_argument('package', metavar='python-pkg-spec',
203+
help='Python package spec, e.g. tensorflow==2.3.4')
204+
parser.add_argument('--ec', metavar='easyconfig', help='EasyConfig to use as the build environment. '
205+
'You need to have dependency modules installed already!')
206+
parser.add_argument('--verbose', help='Verbose output', action='store_true')
207+
args = parser.parse_args()
209208

210-
cmd = "source %s/*.env && python %s '%s'" % (tmp_dir, sys.argv[0], args.package)
209+
if args.ec:
210+
if not can_run('eb', '--version'):
211+
print('EasyBuild not found or executable. Make sure it is in your $PATH when using --ec!')
212+
sys.exit(1)
211213
if args.verbose:
212-
cmd += ' --verbose'
213-
print('Restarting script in new build environment')
214-
215-
out = run_shell_cmd(cmd, action_desc='Run in new environment', shell=True, executable='/bin/bash')
216-
print(out)
217-
else:
218-
if not can_run('virtualenv', '--version'):
219-
print('Virtualenv not found or executable. ' +
220-
'Make sure it is installed (e.g. in the currently loaded Python module)!')
221-
sys.exit(1)
222-
if 'PIP_PREFIX' in os.environ:
223-
print("$PIP_PREFIX is set. Unsetting it as it doesn't work well with virtualenv.")
224-
del os.environ['PIP_PREFIX']
225-
print_deps(args.package, args.verbose)
214+
print('Checking with EasyBuild for missing dependencies')
215+
missing_dep_out = run_shell_cmd(['eb', args.ec, '--missing'],
216+
capture_stderr=False,
217+
action_desc='Get missing dependencies')
218+
excluded_dep = f'({os.path.basename(args.ec)})'
219+
missing_deps = [dep for dep in missing_dep_out.split('\n')
220+
if dep.startswith('*') and excluded_dep not in dep
221+
]
222+
if missing_deps:
223+
print(f'You need to install all modules on which {args.ec} depends first!')
224+
print('\n\t'.join(['Missing:'] + missing_deps))
225+
sys.exit(1)
226+
227+
# If the --ec argument is a (relative) existing path make it absolute so we can find it after the chdir
228+
ec_arg = os.path.abspath(args.ec) if os.path.exists(args.ec) else args.ec
229+
with temporary_directory() as tmp_dir:
230+
old_dir = os.getcwd()
231+
os.chdir(tmp_dir)
232+
if args.verbose:
233+
print('Running EasyBuild to get build environment')
234+
run_shell_cmd(['eb', ec_arg, '--dump-env', '--force'], action_desc='Dump build environment')
235+
os.chdir(old_dir)
236+
237+
cmd = f"source {tmp_dir}/*.env && python {sys.argv[0]} '{args.package}'"
238+
if args.verbose:
239+
cmd += ' --verbose'
240+
print('Restarting script in new build environment')
241+
242+
out = run_shell_cmd(cmd, action_desc='Run in new environment', shell=True, executable='/bin/bash')
243+
print(out)
244+
else:
245+
if not can_run(sys.executable, '-m', 'venv', '-h'):
246+
print("'venv' module not found. This should be available in Python 3.3+.")
247+
sys.exit(1)
248+
if 'PIP_PREFIX' in os.environ:
249+
print("$PIP_PREFIX is set. Unsetting it as it doesn't work well with virtualenv.")
250+
del os.environ['PIP_PREFIX']
251+
os.environ['PYTHONNOUSERSITE'] = '1'
252+
print_deps(args.package, args.verbose)
253+
254+
255+
if __name__ == "__main__":
256+
main()

easybuild/tools/options.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2090,6 +2090,7 @@ def opts_dict_to_eb_opts(args_dict):
20902090
:return: a list of strings representing command-line options for the 'eb' command
20912091
"""
20922092

2093+
allow_multiple_calls = ['amend', 'try-amend']
20932094
_log.debug("Converting dictionary %s to argument list" % args_dict)
20942095
args = []
20952096
for arg in sorted(args_dict):
@@ -2099,14 +2100,18 @@ def opts_dict_to_eb_opts(args_dict):
20992100
prefix = '--'
21002101
option = prefix + str(arg)
21012102
value = args_dict[arg]
2102-
if isinstance(value, (list, tuple)):
2103-
value = ','.join(str(x) for x in value)
21042103

2105-
if value in [True, None]:
2104+
if str(arg) in allow_multiple_calls:
2105+
if not isinstance(value, (list, tuple)):
2106+
value = [value]
2107+
args.extend(option + '=' + str(x) for x in value)
2108+
elif value in [True, None]:
21062109
args.append(option)
21072110
elif value is False:
21082111
args.append('--disable-' + option[2:])
21092112
elif value is not None:
2113+
if isinstance(value, (list, tuple)):
2114+
value = ','.join(str(x) for x in value)
21102115
args.append(option + '=' + str(value))
21112116

21122117
_log.debug("Converted dictionary %s to argument list %s" % (args_dict, args))

test/framework/options.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7334,6 +7334,15 @@ def test_opts_dict_to_eb_opts(self):
73347334
]
73357335
self.assertEqual(opts_dict_to_eb_opts(opts_dict), expected)
73367336

7337+
# multi-call options
7338+
opts_dict = {'try-amend': ['a=1', 'b=2', 'c=3']}
7339+
expected = ['--try-amend=a=1', '--try-amend=b=2', '--try-amend=c=3']
7340+
self.assertEqual(opts_dict_to_eb_opts(opts_dict), expected)
7341+
7342+
opts_dict = {'amend': ['a=1', 'b=2', 'c=3']}
7343+
expected = ['--amend=a=1', '--amend=b=2', '--amend=c=3']
7344+
self.assertEqual(opts_dict_to_eb_opts(opts_dict), expected)
7345+
73377346

73387347
def suite():
73397348
""" returns all the testcases in this module """

0 commit comments

Comments
 (0)