at 17.09-beta 243 lines 6.9 kB view raw
1#! /usr/bin/env nix-shell 2#! nix-shell -i python3 -p 'python3.withPackages(ps: with ps; [ requests toolz ])' 3 4""" 5Update a Python package expression by passing in the `.nix` file, or the directory containing it. 6You can pass in multiple files or paths. 7 8You'll likely want to use 9`` 10 $ ./update-python-libraries ../../pkgs/development/python-modules/* 11`` 12to update all libraries in that folder. 13""" 14 15import argparse 16import logging 17import os 18import re 19import requests 20import toolz 21 22INDEX = "https://pypi.io/pypi" 23"""url of PyPI""" 24 25EXTENSIONS = ['tar.gz', 'tar.bz2', 'tar', 'zip', '.whl'] 26"""Permitted file extensions. These are evaluated from left to right and the first occurance is returned.""" 27 28import logging 29logging.basicConfig(level=logging.INFO) 30 31 32def _get_values(attribute, text): 33 """Match attribute in text and return all matches. 34 35 :returns: List of matches. 36 """ 37 regex = '{}\s+=\s+"(.*)";'.format(attribute) 38 regex = re.compile(regex) 39 values = regex.findall(text) 40 return values 41 42def _get_unique_value(attribute, text): 43 """Match attribute in text and return unique match. 44 45 :returns: Single match. 46 """ 47 values = _get_values(attribute, text) 48 n = len(values) 49 if n > 1: 50 raise ValueError("found too many values for {}".format(attribute)) 51 elif n == 1: 52 return values[0] 53 else: 54 raise ValueError("no value found for {}".format(attribute)) 55 56def _get_line_and_value(attribute, text): 57 """Match attribute in text. Return the line and the value of the attribute.""" 58 regex = '({}\s+=\s+"(.*)";)'.format(attribute) 59 regex = re.compile(regex) 60 value = regex.findall(text) 61 n = len(value) 62 if n > 1: 63 raise ValueError("found too many values for {}".format(attribute)) 64 elif n == 1: 65 return value[0] 66 else: 67 raise ValueError("no value found for {}".format(attribute)) 68 69 70def _replace_value(attribute, value, text): 71 """Search and replace value of attribute in text.""" 72 old_line, old_value = _get_line_and_value(attribute, text) 73 new_line = old_line.replace(old_value, value) 74 new_text = text.replace(old_line, new_line) 75 return new_text 76 77def _fetch_page(url): 78 r = requests.get(url) 79 if r.status_code == requests.codes.ok: 80 return r.json() 81 else: 82 raise ValueError("request for {} failed".format(url)) 83 84def _get_latest_version_pypi(package, extension): 85 """Get latest version and hash from PyPI.""" 86 url = "{}/{}/json".format(INDEX, package) 87 json = _fetch_page(url) 88 89 version = json['info']['version'] 90 for release in json['releases'][version]: 91 if release['filename'].endswith(extension): 92 # TODO: In case of wheel we need to do further checks! 93 sha256 = release['digests']['sha256'] 94 break 95 else: 96 sha256 = None 97 return version, sha256 98 99 100def _get_latest_version_github(package, extension): 101 raise ValueError("updating from GitHub is not yet supported.") 102 103 104FETCHERS = { 105 'fetchFromGitHub' : _get_latest_version_github, 106 'fetchPypi' : _get_latest_version_pypi, 107 'fetchurl' : _get_latest_version_pypi, 108} 109 110 111DEFAULT_SETUPTOOLS_EXTENSION = 'tar.gz' 112 113 114FORMATS = { 115 'setuptools' : DEFAULT_SETUPTOOLS_EXTENSION, 116 'wheel' : 'whl' 117} 118 119def _determine_fetcher(text): 120 # Count occurences of fetchers. 121 nfetchers = sum(text.count('src = {}'.format(fetcher)) for fetcher in FETCHERS.keys()) 122 if nfetchers == 0: 123 raise ValueError("no fetcher.") 124 elif nfetchers > 1: 125 raise ValueError("multiple fetchers.") 126 else: 127 # Then we check which fetcher to use. 128 for fetcher in FETCHERS.keys(): 129 if 'src = {}'.format(fetcher) in text: 130 return fetcher 131 132 133def _determine_extension(text, fetcher): 134 """Determine what extension is used in the expression. 135 136 If we use: 137 - fetchPypi, we check if format is specified. 138 - fetchurl, we determine the extension from the url. 139 - fetchFromGitHub we simply use `.tar.gz`. 140 """ 141 if fetcher == 'fetchPypi': 142 try: 143 format = _get_unique_value('format', text) 144 except ValueError as e: 145 format = None # format was not given 146 147 try: 148 extension = _get_unique_value('extension', text) 149 except ValueError as e: 150 extension = None # extension was not given 151 152 if extension is None: 153 if format is None: 154 format = 'setuptools' 155 extension = FORMATS[format] 156 157 elif fetcher == 'fetchurl': 158 url = _get_unique_value('url', text) 159 extension = os.path.splitext(url)[1] 160 if 'pypi' not in url: 161 raise ValueError('url does not point to PyPI.') 162 163 elif fetcher == 'fetchFromGitHub': 164 raise ValueError('updating from GitHub is not yet implemented.') 165 166 return extension 167 168 169def _update_package(path): 170 171 172 173 # Read the expression 174 with open(path, 'r') as f: 175 text = f.read() 176 177 # Determine pname. 178 pname = _get_unique_value('pname', text) 179 180 # Determine version. 181 version = _get_unique_value('version', text) 182 183 # First we check how many fetchers are mentioned. 184 fetcher = _determine_fetcher(text) 185 186 extension = _determine_extension(text, fetcher) 187 188 new_version, new_sha256 = _get_latest_version_pypi(pname, extension) 189 190 if new_version == version: 191 logging.info("Path {}: no update available for {}.".format(path, pname)) 192 return False 193 if not new_sha256: 194 raise ValueError("no file available for {}.".format(pname)) 195 196 text = _replace_value('version', new_version, text) 197 text = _replace_value('sha256', new_sha256, text) 198 199 with open(path, 'w') as f: 200 f.write(text) 201 202 logging.info("Path {}: updated {} from {} to {}".format(path, pname, version, new_version)) 203 204 return True 205 206 207def _update(path): 208 209 # We need to read and modify a Nix expression. 210 if os.path.isdir(path): 211 path = os.path.join(path, 'default.nix') 212 213 # If a default.nix does not exist, we quit. 214 if not os.path.isfile(path): 215 logging.info("Path {}: does not exist.".format(path)) 216 return False 217 218 # If file is not a Nix expression, we quit. 219 if not path.endswith(".nix"): 220 logging.info("Path {}: does not end with `.nix`.".format(path)) 221 return False 222 223 try: 224 return _update_package(path) 225 except ValueError as e: 226 logging.warning("Path {}: {}".format(path, e)) 227 return False 228 229def main(): 230 231 parser = argparse.ArgumentParser() 232 parser.add_argument('package', type=str, nargs='+') 233 234 args = parser.parse_args() 235 236 packages = map(os.path.abspath, args.package) 237 238 count = list(map(_update, packages)) 239 240 logging.info("{} package(s) updated".format(sum(count))) 241 242if __name__ == '__main__': 243 main()