1#!/usr/bin/env nix-shell
2#!nix-shell update-octave-shell.nix -i python3
3
4"""
5Update a Octave 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-octave-libraries ../../pkgs/development/octave-modules/**/default.nix
11``
12to update all non-pinned libraries in that folder.
13"""
14
15import argparse
16import os
17import pathlib
18import re
19import requests
20import yaml
21from concurrent.futures import ThreadPoolExecutor as Pool
22from packaging.version import Version as _Version
23from packaging.version import InvalidVersion
24from packaging.specifiers import SpecifierSet
25import collections
26import subprocess
27import tempfile
28
29INDEX = "https://raw.githubusercontent.com/gnu-octave/packages/main/packages"
30"""url of Octave packages' source on GitHub"""
31
32EXTENSIONS = ['tar.gz', 'tar.bz2', 'tar', 'zip']
33"""Permitted file extensions. These are evaluated from left to right and the first occurance is returned."""
34
35PRERELEASES = False
36
37GIT = "git"
38
39NIXPGKS_ROOT = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]).decode('utf-8').strip()
40
41import logging
42logging.basicConfig(level=logging.INFO)
43
44
45class Version(_Version, collections.abc.Sequence):
46
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
52
53 def __getitem__(self, i):
54 return self._version.release[i]
55
56 def __len__(self):
57 return len(self._version.release)
58
59 def __iter__(self):
60 yield from self._version.release
61
62
63def _get_values(attribute, text):
64 """Match attribute in text and return all matches.
65
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
72
73def _get_unique_value(attribute, text):
74 """Match attribute in text and return unique match.
75
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))
86
87def _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))
99
100
101def _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
107
108
109def _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))
115
116
117def _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)
123
124 if r.status_code == requests.codes.ok:
125 return r.json()
126 else:
127 raise ValueError("request for {} failed".format(url))
128
129
130SEMVER = {
131 'major' : 0,
132 'minor' : 1,
133 'patch' : 2,
134}
135
136
137def _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)
141
142 def _parse_versions(versions):
143 for v in versions:
144 try:
145 yield Version(v)
146 except InvalidVersion:
147 pass
148
149 versions = _parse_versions(versions)
150
151 index = SEMVER[target]
152
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)))
159
160 # We do not want prereleases
161 versions = SpecifierSet(prereleases=PRERELEASES).filter(versions)
162
163 if ceiling is not None:
164 versions = SpecifierSet(f"<{ceiling}").filter(versions)
165
166 return (max(sorted(versions))).raw_version
167
168
169def _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)
173
174 versions = list(map(lambda pv: pv['id'], yaml['versions']))
175 version = _determine_latest_version(current_version, target, versions)
176
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
188
189
190def _get_latest_version_github(package, extension, current_version, target):
191 def strip_prefix(tag):
192 return re.sub("^[^0-9]*", "", tag)
193
194 def get_prefix(string):
195 matches = re.findall(r"^([^0-9]*)", string)
196 return next(iter(matches), "")
197
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("/")
209
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))
213
214 if len(releases) == 0:
215 raise ValueError(f"{homepage} does not contain any stable releases")
216
217 versions = map(lambda x: strip_prefix(x['tag_name']), releases)
218 version = _determine_latest_version(current_version, target, versions)
219
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()
230
231
232 return version, sha256, prefix
233
234def _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}")
243
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
249
250
251FETCHERS = {
252 'fetchFromGitHub' : _get_latest_version_github,
253 'fetchurl' : _get_latest_version_octave_packages,
254 'fetchgit' : _get_latest_version_git,
255}
256
257
258DEFAULT_SETUPTOOLS_EXTENSION = 'tar.gz'
259
260
261FORMATS = {
262 'setuptools' : DEFAULT_SETUPTOOLS_EXTENSION,
263}
264
265def _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
277
278
279def _determine_extension(text, fetcher):
280 """Determine what extension is used in the expression.
281
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]
290
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"
295
296 return extension
297
298
299def _update_package(path, target):
300
301 # Read the expression
302 with open(path, 'r') as f:
303 text = f.read()
304
305 # Determine pname. Many files have more than one pname
306 pnames = _get_values('pname', text)
307
308 # Determine version.
309 version = _get_unique_value('version', text)
310
311 # First we check how many fetchers are mentioned.
312 fetcher = _determine_fetcher(text)
313
314 extension = _determine_extension(text, fetcher)
315
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
329
330 if not successful_fetch:
331 raise ValueError(f"Unable to find correct package using these pnames: {pnames}")
332
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))
340
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()
345
346
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)
352
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)
360
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;')
369
370 with open(path, 'w') as f:
371 f.write(text)
372
373 logging.info("Path {}: updated {} from {} to {}".format(path, pname, version, new_version))
374
375 result = {
376 'path' : path,
377 'target': target,
378 'pname': pname,
379 'old_version' : version,
380 'new_version' : new_version,
381 #'fetcher' : fetcher,
382 }
383
384 return result
385
386
387def _update(path, target):
388
389 # We need to read and modify a Nix expression.
390 if os.path.isdir(path):
391 path = os.path.join(path, 'default.nix')
392
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
397
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
402
403 try:
404 return _update_package(path, target)
405 except ValueError as e:
406 logging.warning("Path {}: {}".format(path, e))
407 return False
408
409
410def _commit(path, pname, old_version, new_version, pkgs_prefix="octave: ", **kwargs):
411 """Commit result.
412 """
413
414 msg = f'{pkgs_prefix}{pname}: {old_version} -> {new_version}'
415
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
422
423 return True
424
425
426def main():
427
428 epilog = """
429environment 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')
437
438 args = parser.parse_args()
439 target = args.target
440
441 packages = list(map(os.path.abspath, args.package))
442
443 logging.info("Updating packages...")
444
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)))
448
449 logging.info("Finished updating packages.")
450
451 commit_options = {}
452 if args.use_pkgs_prefix:
453 logging.info("Using octavePackages. prefix for commits")
454 commit_options["pkgs_prefix"] = "octavePackages."
455
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")
462
463 count = len(results)
464 logging.info("{} package(s) updated".format(count))
465
466
467if __name__ == '__main__':
468 main()