From 580848dfaab395c65189aea1501981b8d100e798 Mon Sep 17 00:00:00 2001 From: Paul McCarthy <pauldmccarthy@gmail.com> Date: Wed, 29 Dec 2021 18:23:02 +0000 Subject: [PATCH] RF: Do not support installation of new packages at this point in time - only upgrade of already-installed packages. --- share/fsl/sbin/update_fsl_package | 262 +++++++++++++++++------------- 1 file changed, 153 insertions(+), 109 deletions(-) diff --git a/share/fsl/sbin/update_fsl_package b/share/fsl/sbin/update_fsl_package index 5886ae1..b4cb764 100755 --- a/share/fsl/sbin/update_fsl_package +++ b/share/fsl/sbin/update_fsl_package @@ -1,20 +1,24 @@ #!/usr/bin/env fslpython -"""Install/update one or more FSL packages using conda. +"""Update one or more FSL packages using conda. This script is only intended to be used within conda-based FSL installations that have been created with the fslinstaller.py script. It is not intended to be used within conda environments that have had FSL packages installed into them - in this scenario, conda should be used directly. +Currently this script cannot be used to install new packages - it is limited to +updating packages that are already installed. To install new packages, conda +should be used directly. + The script performs the following steps: 1. Parses command line arguments (primarily the list of packages to - install/update). + update). 2. Queries the FSL conda channel(s) to find the latest available versions of the requested packages. - 4. Runs "conda install" to install/update the packages. + 4. Runs "conda install" to update the packages. """ # Note: Conda does have a Python API: # @@ -42,7 +46,7 @@ import urllib.parse as urlparse import urllib.request as urlrequest from collections import defaultdict -from typing import Dict, List, Union, Tuple, Optional, Sequence +from typing import Dict, List, Union, Tuple, Optional, Sequence, Any log = logging.getLogger(__name__) @@ -52,17 +56,23 @@ PUBLIC_FSL_CHANNEL = 'https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/public/ INTERNAL_FSL_CHANNEL = 'https://fsl.fmrib.ox.ac.uk/fsldownloads/fslconda/internal/' -def conda(cmd : str) -> str: +def conda(cmd : str, capture_output=True, **kwargs) -> str: """Runs the conda command with the given arguments, returning its standard output as a string. + + Keyword arguments are passed through to subprocess.run. """ fsldir = os.environ['FSLDIR'] condabin = op.join(fsldir, 'bin', 'conda') log.debug(f'Running {condabin} {cmd}') + kwargs['check'] = False + kwargs['text'] = True + kwargs['capture_output'] = capture_output + cmd = [condabin] + shlex.split(cmd) - result = sp.run(cmd, check=False, capture_output=True, text=True) + result = sp.run(cmd, **kwargs) log.debug(f'Exit code: {result.returncode}') @@ -94,16 +104,41 @@ def identify_platform() -> str: key = (system, cpu) if key not in platforms: - supported = ', '.join(['[{}, {}]' for s, c in platforms]) - raise Exception('This platform [{}, {}] is unrecognised or ' - 'unsupported! Supported platforms: {}'.format( - system, cpu, supported)) + supported = ', '.join([f'[{s}, {c}]' for s, c in platforms]) + raise Exception(f'This platform [{system}, {cpu}] is unrecognised or ' + f'unsupported! Supported platforms: {supported}') log.debug(f'Detected platform: {platforms[key]}') return platforms[key] +def http_request(url : str, + username : str = None, + password : str = None) -> Any: + """Download JSON data from the given URL. """ + + if username is not None: + urlbase = urlparse.urlparse(url).netloc + pwdmgr = urlrequest.HTTPPasswordMgrWithDefaultRealm() + pwdmgr.add_password(None, urlbase, username, password) + handler = urlrequest.HTTPBasicAuthHandler(pwdmgr) + opener = urlrequest.build_opener(handler) + opener.open(url) + urlrequest.install_opener(opener) + + log.debug(f'Downloading {url} ...') + + request = urlrequest.Request(url, method='GET') + response = urlrequest.urlopen(request) + payload = response.read() + + if len(payload) == 0: payload = {} + else: payload = json.loads(payload) + + return payload + + @ft.total_ordering class Version: """Class for parsing/comparing version strings. """ @@ -140,56 +175,6 @@ class Version: return self.parsed_version == other.parsed_version -def http_request(url : str, - username : str = None, - password : str = None): - """Download JSON data from the given URL. """ - - if username is not None: - urlbase = urlparse.urlparse(url).netloc - pwdmgr = urlrequest.HTTPPasswordMgrWithDefaultRealm() - pwdmgr.add_password(None, urlbase, username, password) - handler = urlrequest.HTTPBasicAuthHandler(pwdmgr) - opener = urlrequest.build_opener(handler) - opener.open(url) - urlrequest.install_opener(opener) - - log.debug(f'Downloading {url} ...') - - request = urlrequest.Request(url, method='GET') - response = urlrequest.urlopen(request) - payload = response.read() - - if len(payload) == 0: payload = {} - else: payload = json.loads(payload) - - return payload - - -@ft.lru_cache -def query_installed_packages() -> Dict[str, str]: - """Uses conda to find out the versions of all packages installed in - $FSLDIR, which are sourced from the FSL conda channels. - """ - - channels = [PUBLIC_FSL_CHANNEL .rstrip('/'), - INTERNAL_FSL_CHANNEL.rstrip('/')] - - # conda info returns a list of dicts, - # one per package. We re-arrange this - # into a dict of {pkgname : version} - # mappings. - fsldir = os.environ['FSLDIR'] - info = json.loads(conda(f'list -p {fsldir} --json')) - pkgs = {} - - for pkg in info: - if pkg['base_url'].rstrip() in channels: - pkgs[pkg['name']] = pkg['version'] - - return pkgs - - @ft.total_ordering @dataclasses.dataclass class Package: @@ -202,34 +187,27 @@ class Package: name : str """Package name.""" + version : str + """Package version string.""" + channel : str """URL of the channel which hosts the package.""" platform : str """Platform identifier.""" - version : str - """Package version string.""" - - dependencies : List[str] + dependencies : List[str] = None """References to all packages which this package depends on. Stored as "package[ version-constraint]" strings. """ + @property def development(self) -> bool: """Return True if this is a development version of the package. """ return 'dev' in self.version - @property - def installed_version(self) -> str: - """Return the version of this package which is currently installed, or '-' - if not installed. - """ - return query_installed_packages().get(self.name, '-') - - def __lt__(self, pkg): """Only valid when comparing another Package with the same name and platform. @@ -244,6 +222,39 @@ class Package: return Version(self.version) == Version(pkg.version) +@ft.lru_cache +def query_installed_packages() -> Dict[str, Package]: + """Uses conda to find out the versions of all packages installed in + $FSLDIR, which are sourced from the FSL conda channels. + + Returns a dict of {pkgname : Package} mappings. The "dependencies" + attributes of the package objects are not populated. + """ + + channels = [PUBLIC_FSL_CHANNEL .rstrip('/'), + INTERNAL_FSL_CHANNEL.rstrip('/')] + + # conda info returns a list of dicts, + # one per package. We re-arrange this + # into a dict of {pkgname : version} + # mappings. + fsldir = os.environ['FSLDIR'] + info = json.loads(conda(f'list -p {fsldir} --json')) + pkgs = {} + + # We are only interested in packages + # hosted on the FSL conda channels + for pkg in info: + if pkg['base_url'].rstrip() in channels: + pkgs[pkg['name']] = Package(pkg['name'], + pkg['version'], + pkg['channel'], + pkg['platform']) + + return pkgs + + +@ft.lru_cache def download_channel_metadata(channel_url : str, **kwargs) -> Tuple[Dict, Dict]: """Downloads information about packages hosted at the given conda channel. @@ -296,14 +307,19 @@ def download_channel_metadata(channel_url : str, **kwargs) -> Tuple[Dict, Dict]: return chandata, platdata -def identify_packages(channeldata : List[Tuple[Dict, Dict]], - pkgnames : Sequence[str], - development : bool) -> Dict[str, List[Package]]: +def identify_packages( + channeldata : List[Tuple[Dict, Dict]], + pkgnames : Sequence[str], + development : bool +) -> Dict[str, List[Package]]: """Return metadata about the requested packages. Loads channel and platform metadata from the conda channels. Parses the - metadata, and creates a Package object for every requested package. Returns - a dict of {name : Package} mappings. + metadata, and creates a Package object for every requested package. + + Returns a dict of {name : [Package]} mappings, where each entry contains + Package objects for all available versions of the packae, ordered from + oldest (first) to newest (last). channeldata: Sequence of channel data from one or more conda channels, as returned by the download_channel_metadata function. @@ -338,7 +354,7 @@ def identify_packages(channeldata : List[Tuple[Dict, Dict]], for pkgfile in pdata[platform][pkgname]: version = pkgfile['version'] depends = pkgfile['depends'] - pkg = Package(pkgname, curl, platform, version, depends) + pkg = Package(pkgname, version, curl, platform, depends) if pkg.development and (not development): continue bisect.insort(packages[pkgname], pkg) @@ -346,15 +362,57 @@ def identify_packages(channeldata : List[Tuple[Dict, Dict]], return packages +def filter_packages(packages : Dict[str, List[Package]]) -> List[Package]: + """Identifies the versions of packages that should be installed. + + Removes packages that are not installed, or that are already up to date. + + Returns a list of Package objects representing the packages/versions to + install. + """ + + filtered = [] + + for pkgname, pkgs in packages.items(): + + # Find the Package object corresponding + # to the installed version + installed = query_installed_packages().get(pkgname, None) + + if not installed: + log.debug(f'Package {pkgname} is not installed - ignoring') + continue + + # select the newest available + # version of the package as + # the installation candidate + pkg = pkgs[-1] + + if pkg <= installed: + log.debug(f'{pkg.name} is already up to date (available: ' + f'{pkg.version}, installed: {installed.version}) ' + '- ignoring.') + else: + filtered.append(pkg) + + return filtered + + def confirm_installation(packages : Sequence[Package], yes : bool) -> bool: """Asks the user for confirmation, before installing/updating the requested packages. """ + rows = [('Package name', 'Currently installed', 'Updating to'), ('------------', '-------------------', '-----------')] + # Currently all requested packages should already + # be installed, so a package should never be "n/a". + # This might change in the future if this script + # is enhanced to allow installation of new packages. for pkg in packages: - rows.append((pkg.name, pkg.installed_version, pkg.version)) + installed = query_installed_packages().get(pkg.name, 'n/a') + rows.append((pkg.name, installed.version, pkg.version)) len0 = max(len(r[0]) for r in rows) len1 = max(len(r[1]) for r in rows) @@ -383,22 +441,22 @@ def install_packages(packages : Sequence[Package]): cmd = f'install --no-deps -p {fsldir} -y ' + ' '.join(packages) print('\nInstalling packages...') - conda(cmd) + conda(cmd, False) def parse_args(argv : Optional[Sequence[str]]) -> argparse.Namespace: """Parses command-line arguments, returning an argparse.Namespace object. """ parser = argparse.ArgumentParser( - 'update_fsl_package', description='Install/update FSL packages') + 'update_fsl_package', description='Update FSL packages') parser.add_argument('package', nargs='*', - help='Package[s] to install/update') + help='Package[s] to update') parser.add_argument('-d', '--development', action='store_true', help='Install development versions of packages if available') parser.add_argument('-y', '--yes', action='store_true', help='Install package[s] without prompting for confirmation') parser.add_argument('-a', '--all', action='store_true', - help='Install/update all installed FSL packages') + help='Update all installed FSL packages') parser.add_argument('--internal', help=argparse.SUPPRESS) parser.add_argument('--username', help=argparse.SUPPRESS) @@ -408,8 +466,8 @@ def parse_args(argv : Optional[Sequence[str]]) -> argparse.Namespace: args = parser.parse_args(argv) if len(args.package) == 0 and not args.all: - parser.error('Specify at least one package, or use --all to update ' - 'all installed FSL packages.') + parser.error('Specify at least one package, or use --all ' + 'to update all installed FSL packages.') return args @@ -427,7 +485,8 @@ def main(argv : Sequence[str] = None): argv = sys.argv[1:] args = parse_args(argv) - logging.basicConfig() + logging.basicConfig(format='%(funcName)s:%(lineno)d: %(message)s') + logging.getLogger().setLevel(logging.CRITICAL) if args.verbose: log.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) @@ -442,37 +501,22 @@ def main(argv : Sequence[str] = None): username=args.username, password=args.password)) - if args.all: - packages = list(query_installed_packages().keys()) - else: - packages = args.package + print('Building FSL package list ...') + if args.all: packages = list(query_installed_packages().keys()) + else: packages = args.package # Identify the versions that are # available for the packages the # user has requested. - packages = identify_packages(channeldata, - packages, - args.development) - - to_install = [] - for pkgs in packages.values(): - # select the newest available - # versions of all packages - pkg = pkgs[-1] - - if Version(pkg.version) <= Version(pkg.installed_version): - log.debug(f'{pkg.name} is already up to date (available: ' - f'{pkg.version}, installed: {pkg.installed_version}) ' - '- ignoring.') - else: - to_install.append(pkg) + packages = identify_packages(channeldata, packages, args.development) + packages = filter_packages(packages) - if len(to_install) == 0: + if len(packages) == 0: print('\nNo packages need updating.') sys.exit(0) - if confirm_installation(to_install, args.yes): - install_packages(to_install) + if confirm_installation(packages, args.yes): + install_packages(packages) else: print('Aborting update') -- GitLab