Merge pull request #329823 from ExpidusOS/fix/pkgsllvm/elfutils
[NixPkgs.git] / maintainers / scripts / update-octave-packages
blob00a1646184d52d376f07141a9a6d6005702ac5b1
1 #!/usr/bin/env nix-shell
2 #!nix-shell update-octave-shell.nix -i python3
4 """
5 Update a Octave package expression by passing in the `.nix` file, or the directory containing it.
6 You can pass in multiple files or paths.
8 You'll likely want to use
9 ``
10   $ ./update-octave-libraries ../../pkgs/development/octave-modules/**/default.nix
12 to update all non-pinned libraries in that folder.
13 """
15 import argparse
16 import os
17 import pathlib
18 import re
19 import requests
20 import yaml
21 from concurrent.futures import ThreadPoolExecutor as Pool
22 from packaging.version import Version as _Version
23 from packaging.version import InvalidVersion
24 from packaging.specifiers import SpecifierSet
25 import collections
26 import subprocess
27 import tempfile
29 INDEX = "https://raw.githubusercontent.com/gnu-octave/packages/main/packages"
30 """url of Octave packages' source on GitHub"""
32 EXTENSIONS = ['tar.gz', 'tar.bz2', 'tar', 'zip']
33 """Permitted file extensions. These are evaluated from left to right and the first occurance is returned."""
35 PRERELEASES = False
37 GIT = "git"
39 NIXPGKS_ROOT = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]).decode('utf-8').strip()
41 import logging
42 logging.basicConfig(level=logging.INFO)
45 class Version(_Version, collections.abc.Sequence):
47     def __init__(self, version):
48         super().__init__(version)
49         # We cannot use `str(Version(0.04.21))` because that becomes `0.4.21`
50         # https://github.com/avian2/unidecode/issues/13#issuecomment-354538882
51         self.raw_version = version
53     def __getitem__(self, i):
54         return self._version.release[i]
56     def __len__(self):
57         return len(self._version.release)
59     def __iter__(self):
60         yield from self._version.release
63 def _get_values(attribute, text):
64     """Match attribute in text and return all matches.
66     :returns: List of matches.
67     """
68     regex = '{}\s+=\s+"(.*)";'.format(attribute)
69     regex = re.compile(regex)
70     values = regex.findall(text)
71     return values
73 def _get_unique_value(attribute, text):
74     """Match attribute in text and return unique match.
76     :returns: Single match.
77     """
78     values = _get_values(attribute, text)
79     n = len(values)
80     if n > 1:
81         raise ValueError("found too many values for {}".format(attribute))
82     elif n == 1:
83         return values[0]
84     else:
85         raise ValueError("no value found for {}".format(attribute))
87 def _get_line_and_value(attribute, text):
88     """Match attribute in text. Return the line and the value of the attribute."""
89     regex = '({}\s+=\s+"(.*)";)'.format(attribute)
90     regex = re.compile(regex)
91     value = regex.findall(text)
92     n = len(value)
93     if n > 1:
94         raise ValueError("found too many values for {}".format(attribute))
95     elif n == 1:
96         return value[0]
97     else:
98         raise ValueError("no value found for {}".format(attribute))
101 def _replace_value(attribute, value, text):
102     """Search and replace value of attribute in text."""
103     old_line, old_value = _get_line_and_value(attribute, text)
104     new_line = old_line.replace(old_value, value)
105     new_text = text.replace(old_line, new_line)
106     return new_text
109 def _fetch_page(url):
110     r = requests.get(url)
111     if r.status_code == requests.codes.ok:
112         return list(yaml.safe_load_all(r.content))[0]
113     else:
114         raise ValueError("request for {} failed".format(url))
117 def _fetch_github(url):
118     headers = {}
119     token = os.environ.get('GITHUB_API_TOKEN')
120     if token:
121         headers["Authorization"] = f"token {token}"
122     r = requests.get(url, headers=headers)
124     if r.status_code == requests.codes.ok:
125         return r.json()
126     else:
127         raise ValueError("request for {} failed".format(url))
130 SEMVER = {
131     'major' : 0,
132     'minor' : 1,
133     'patch' : 2,
137 def _determine_latest_version(current_version, target, versions):
138     """Determine latest version, given `target`, returning the more recent version.
139     """
140     current_version = Version(current_version)
142     def _parse_versions(versions):
143         for v in versions:
144             try:
145                 yield Version(v)
146             except InvalidVersion:
147                 pass
149     versions = _parse_versions(versions)
151     index = SEMVER[target]
153     ceiling = list(current_version[0:index])
154     if len(ceiling) == 0:
155         ceiling = None
156     else:
157         ceiling[-1]+=1
158         ceiling = Version(".".join(map(str, ceiling)))
160     # We do not want prereleases
161     versions = SpecifierSet(prereleases=PRERELEASES).filter(versions)
163     if ceiling is not None:
164         versions = SpecifierSet(f"<{ceiling}").filter(versions)
166     return (max(sorted(versions))).raw_version
169 def _get_latest_version_octave_packages(package, extension, current_version, target):
170     """Get latest version and hash from Octave Packages."""
171     url = "{}/{}.yaml".format(INDEX, package)
172     yaml = _fetch_page(url)
174     versions = list(map(lambda pv: pv['id'], yaml['versions']))
175     version = _determine_latest_version(current_version, target, versions)
177     try:
178         releases = [v if v['id'] == version else None for v in yaml['versions']]
179     except KeyError as e:
180         raise KeyError('Could not find version {} for {}'.format(version, package)) from e
181     for release in releases:
182         if release['url'].endswith(extension):
183             sha256 = release['sha256']
184             break
185     else:
186         sha256 = None
187     return version, sha256, None
190 def _get_latest_version_github(package, extension, current_version, target):
191     def strip_prefix(tag):
192         return re.sub("^[^0-9]*", "", tag)
194     def get_prefix(string):
195         matches = re.findall(r"^([^0-9]*)", string)
196         return next(iter(matches), "")
198     # when invoked as an updateScript, UPDATE_NIX_ATTR_PATH will be set
199     # this allows us to work with packages which live outside of octave-modules
200     attr_path = os.environ.get("UPDATE_NIX_ATTR_PATH", f"octavePackages.{package}")
201     try:
202         homepage = subprocess.check_output(
203             ["nix", "eval", "-f", f"{NIXPGKS_ROOT}/default.nix", "--raw", f"{attr_path}.src.meta.homepage"])\
204             .decode('utf-8')
205     except Exception as e:
206         raise ValueError(f"Unable to determine homepage: {e}")
207     owner_repo = homepage[len("https://github.com/"):]  # remove prefix
208     owner, repo = owner_repo.split("/")
210     url = f"https://api.github.com/repos/{owner}/{repo}/releases"
211     all_releases = _fetch_github(url)
212     releases = list(filter(lambda x: not x['prerelease'], all_releases))
214     if len(releases) == 0:
215         raise ValueError(f"{homepage} does not contain any stable releases")
217     versions = map(lambda x: strip_prefix(x['tag_name']), releases)
218     version = _determine_latest_version(current_version, target, versions)
220     release = next(filter(lambda x: strip_prefix(x['tag_name']) == version, releases))
221     prefix = get_prefix(release['tag_name'])
222     try:
223         sha256 = subprocess.check_output(["nix-prefetch-url", "--type", "sha256", "--unpack", f"{release['tarball_url']}"], stderr=subprocess.DEVNULL)\
224             .decode('utf-8').strip()
225     except:
226         # this may fail if they have both a branch and a tag of the same name, attempt tag name
227         tag_url = str(release['tarball_url']).replace("tarball","tarball/refs/tags")
228         sha256 = subprocess.check_output(["nix-prefetch-url", "--type", "sha256", "--unpack", tag_url], stderr=subprocess.DEVNULL)\
229             .decode('utf-8').strip()
232     return version, sha256, prefix
234 def _get_latest_version_git(package, extension, current_version, target):
235     """NOTE: Unimplemented!"""
236     # attr_path = os.environ.get("UPDATE_NIX_ATTR_PATH", f"octavePackages.{package}")
237     # try:
238     #     download_url = subprocess.check_output(
239     #         ["nix", "--extra-experimental-features", "nix-command", "eval", "-f", f"{NIXPGKS_ROOT}/default.nix", "--raw", f"{attr_path}.src.url"])\
240     #         .decode('utf-8')
241     # except Exception as e:
242     #     raise ValueError(f"Unable to determine download link: {e}")
244     # with tempfile.TemporaryDirectory(prefix=attr_path) as new_clone_location:
245     #     subprocess.run(["git", "clone", download_url, new_clone_location])
246     #     newest_commit = subprocess.check_output(
247     #         ["git" "rev-parse" "$(git branch -r)" "|" "tail" "-n" "1"]).decode('utf-8')
248     pass
251 FETCHERS = {
252     'fetchFromGitHub'   :   _get_latest_version_github,
253     'fetchurl'          :   _get_latest_version_octave_packages,
254     'fetchgit'          :   _get_latest_version_git,
258 DEFAULT_SETUPTOOLS_EXTENSION = 'tar.gz'
261 FORMATS = {
262     'setuptools'        :   DEFAULT_SETUPTOOLS_EXTENSION,
265 def _determine_fetcher(text):
266     # Count occurrences of fetchers.
267     nfetchers = sum(text.count('src = {}'.format(fetcher)) for fetcher in FETCHERS.keys())
268     if nfetchers == 0:
269         raise ValueError("no fetcher.")
270     elif nfetchers > 1:
271         raise ValueError("multiple fetchers.")
272     else:
273         # Then we check which fetcher to use.
274         for fetcher in FETCHERS.keys():
275             if 'src = {}'.format(fetcher) in text:
276                 return fetcher
279 def _determine_extension(text, fetcher):
280     """Determine what extension is used in the expression.
282     If we use:
283     - fetchPypi, we check if format is specified.
284     - fetchurl, we determine the extension from the url.
285     - fetchFromGitHub we simply use `.tar.gz`.
286     """
287     if fetcher == 'fetchurl':
288         url = _get_unique_value('url', text)
289         extension = os.path.splitext(url)[1]
291     elif fetcher == 'fetchFromGitHub' or fetcher == 'fetchgit':
292         if "fetchSubmodules" in text:
293             raise ValueError("fetchFromGitHub fetcher doesn't support submodules")
294         extension = "tar.gz"
296     return extension
299 def _update_package(path, target):
301     # Read the expression
302     with open(path, 'r') as f:
303         text = f.read()
305     # Determine pname. Many files have more than one pname
306     pnames = _get_values('pname', text)
308     # Determine version.
309     version = _get_unique_value('version', text)
311     # First we check how many fetchers are mentioned.
312     fetcher = _determine_fetcher(text)
314     extension = _determine_extension(text, fetcher)
316     # Attempt a fetch using each pname, e.g. backports-zoneinfo vs backports.zoneinfo
317     successful_fetch = False
318     for pname in pnames:
319         if fetcher == "fetchgit":
320             logging.warning(f"You must update {pname} MANUALLY!")
321             return { 'path': path, 'target': target, 'pname': pname,
322                      'old_version': version, 'new_version': version }
323         try:
324             new_version, new_sha256, prefix = FETCHERS[fetcher](pname, extension, version, target)
325             successful_fetch = True
326             break
327         except ValueError:
328             continue
330     if not successful_fetch:
331         raise ValueError(f"Unable to find correct package using these pnames: {pnames}")
333     if new_version == version:
334         logging.info("Path {}: no update available for {}.".format(path, pname))
335         return False
336     elif Version(new_version) <= Version(version):
337         raise ValueError("downgrade for {}.".format(pname))
338     if not new_sha256:
339         raise ValueError("no file available for {}.".format(pname))
341     text = _replace_value('version', new_version, text)
342     # hashes from pypi are 16-bit encoded sha256's, normalize it to sri to avoid merge conflicts
343     # sri hashes have been the default format since nix 2.4+
344     sri_hash = subprocess.check_output(["nix", "--extra-experimental-features", "nix-command", "hash", "to-sri", "--type", "sha256", new_sha256]).decode('utf-8').strip()
347     # fetchers can specify a sha256, or a sri hash
348     try:
349         text = _replace_value('sha256', sri_hash, text)
350     except ValueError:
351         text = _replace_value('hash', sri_hash, text)
353     if fetcher == 'fetchFromGitHub':
354         # in the case of fetchFromGitHub, it's common to see `rev = version;` or `rev = "v${version}";`
355         # in which no string value is meant to be substituted. However, we can just overwrite the previous value.
356         regex = '(rev\s+=\s+[^;]*;)'
357         regex = re.compile(regex)
358         matches = regex.findall(text)
359         n = len(matches)
361         if n == 0:
362             raise ValueError("Unable to find rev value for {}.".format(pname))
363         else:
364             # forcefully rewrite rev, incase tagging conventions changed for a release
365             match = matches[0]
366             text = text.replace(match, f'rev = "refs/tags/{prefix}${{version}}";')
367             # incase there's no prefix, just rewrite without interpolation
368             text = text.replace('"${version}";', 'version;')
370     with open(path, 'w') as f:
371         f.write(text)
373         logging.info("Path {}: updated {} from {} to {}".format(path, pname, version, new_version))
375     result = {
376         'path'  : path,
377         'target': target,
378         'pname': pname,
379         'old_version'   : version,
380         'new_version'   : new_version,
381         #'fetcher'       : fetcher,
382         }
384     return result
387 def _update(path, target):
389     # We need to read and modify a Nix expression.
390     if os.path.isdir(path):
391         path = os.path.join(path, 'default.nix')
393     # If a default.nix does not exist, we quit.
394     if not os.path.isfile(path):
395         logging.info("Path {}: does not exist.".format(path))
396         return False
398     # If file is not a Nix expression, we quit.
399     if not path.endswith(".nix"):
400         logging.info("Path {}: does not end with `.nix`.".format(path))
401         return False
403     try:
404         return _update_package(path, target)
405     except ValueError as e:
406         logging.warning("Path {}: {}".format(path, e))
407         return False
410 def _commit(path, pname, old_version, new_version, pkgs_prefix="octave: ", **kwargs):
411     """Commit result.
412     """
414     msg = f'{pkgs_prefix}{pname}: {old_version} -> {new_version}'
416     try:
417         subprocess.check_call([GIT, 'add', path])
418         subprocess.check_call([GIT, 'commit', '-m', msg])
419     except subprocess.CalledProcessError as e:
420         subprocess.check_call([GIT, 'checkout', path])
421         raise subprocess.CalledProcessError(f'Could not commit {path}') from e
423     return True
426 def main():
428     epilog = """
429 environment variables:
430   GITHUB_API_TOKEN\tGitHub API token used when updating github packages
431     """
432     parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, epilog=epilog)
433     parser.add_argument('package', type=str, nargs='+')
434     parser.add_argument('--target', type=str, choices=SEMVER.keys(), default='major')
435     parser.add_argument('--commit', action='store_true', help='Create a commit for each package update')
436     parser.add_argument('--use-pkgs-prefix', action='store_true', help='Use octavePackages.${pname}: instead of octave: ${pname}: when making commits')
438     args = parser.parse_args()
439     target = args.target
441     packages = list(map(os.path.abspath, args.package))
443     logging.info("Updating packages...")
445     # Use threads to update packages concurrently
446     with Pool() as p:
447         results = list(filter(bool, p.map(lambda pkg: _update(pkg, target), packages)))
449     logging.info("Finished updating packages.")
451     commit_options = {}
452     if args.use_pkgs_prefix:
453         logging.info("Using octavePackages. prefix for commits")
454         commit_options["pkgs_prefix"] = "octavePackages."
456     # Commits are created sequentially.
457     if args.commit:
458         logging.info("Committing updates...")
459         # list forces evaluation
460         list(map(lambda x: _commit(**x, **commit_options), results))
461         logging.info("Finished committing updates")
463     count = len(results)
464     logging.info("{} package(s) updated".format(count))
467 if __name__ == '__main__':
468     main()