Merge pull request #1090 from AladW/repo-default-status
[aurutils.git] / examples / sync-rebuild
blob6ff80de0e7067cf980628666c970bd521a56fa65
1 #!/usr/bin/env python3
2 """ Rebuild AUR packages against newer dependencies
3 """
4 import json
5 import fileinput
6 import sys
7 import os
8 import subprocess
10 from srcinfo.parse import parse_srcinfo
11 from decimal import Decimal
12 #from pyalpm import vercmp
13 from xdg import BaseDirectory
15 # Get the path to user-specific cache files
16 ARGV0 = 'sync-rebuild'
17 cache_dir = BaseDirectory.xdg_cache_home
18 aurdest = os.path.join(cache_dir, 'aurutils/sync')
20 if 'AURDEST' in os.environ:
21     aurdest = os.getenv('AURDEST')
24 def run_readline(arg_list):
25     """Run the output from a command line-by-line.
27     `aur` programs typically use newline delimited output. Here, this function
28     is used with `aur repo` to read JSON objects, with each line representing
29     one local repository package.
31     """
32     with subprocess.Popen(arg_list, stdout=subprocess.PIPE) as process:
33         while True:
34             output = process.stdout.readline()
35             if output == b'':
36                 break
37             if output:
38                 yield output.strip()
40         return_code = process.poll()
41         if return_code > 0:
42             sys.exit(return_code)
45 def srcinfo_get_version(srcinfo):
46     """Return the full version string from a .SRCINFO file.
48     The `epoch` key is optional, `pkgver` and `pkgrel` are assumed present.
50     """
51     with open(srcinfo, 'r', encoding='utf-8') as file:
52         (data, errors) = parse_srcinfo(file.read())
53         if errors:
54             sys.exit(1)
56         epoch  = data.get('epoch')
57         pkgver = data['pkgver']
58         pkgrel = data['pkgrel']
60         if epoch is not None:
61             return epoch + ':' + pkgver, pkgrel
62         return pkgver, pkgrel
65 def increase_decimal(decimal_number, increment, n_digits=2):
66     """Only increase the fractional part of a number.
67     """
68     # Convert the decimal number and increment to Decimal objects
69     decimal_num = Decimal(str(decimal_number))
70     inc = Decimal(str(increment))
71     
72     # Calculate the increased decimal
73     increased_decimal = decimal_num + inc
75     # Convert the increased decimal to a formatted string with fixed precision
76     precision = '.' + str(n_digits) + 'f'
77     increased_decimal_str = format(increased_decimal, precision)
79     return increased_decimal_str
82 def update_pkgrel(buildscript, backup, pkgname, pkgrel=None, increment=0.1):
83     """Update pkgrel in a PKGBUILD by a given increment.
85     The original PKGBUILD is backed up before modification. Modifications assume
86     a single caller and are not thread-safe.
88     """
89     n_digits = sum(ch.isdigit() for ch in str(increment).strip('0'))
91     with fileinput.input(buildscript, inplace=True, backup=backup) as finput:
92         for line in finput:
93             pkgrel_keyword = 'pkgrel='
95             if line.startswith(pkgrel_keyword):
96                 # Extract and update the current pkgrel value
97                 if pkgrel is None:
98                     pkgrel = float(line.split('=')[1])  # Only the last written pkgrel holds
99                 new_pkgrel = increase_decimal(pkgrel, increment, n_digits)
101                 # Print bumped pkgrel to standard error
102                 print(f'{ARGV0}: {pkgname}: {pkgrel} -> {new_pkgrel}', file=sys.stderr)
104                 # Replace the pkgrel value in the line
105                 line = f'{pkgrel_keyword}{new_pkgrel}\n'
107             # Write the modified line to stdout (which redirects to the PKGBUILD file)
108             print(line, end='')
111 # TODO: use vercmp to ensure rebuilds
112 # XXX: perform rebuilds in dependency order, abort reverse depends when depends fails (sync--ninja)
113 def rebuild_packages(repo_targets, db_name, fail_fast=False):
114     """Rebuild a series of packages in succession
115     """
116     # TODO: user-specified build arguments (e.g. --chroot)
117     build_cmd = ['aur', 'build', '-srn']
119     if db_name is not None:
120         build_cmd.extend(('--database', db_name))
122     # Check that `pkgver` is consistent between local repository and .SRCINFO
123     rebuilds = {}
125     for pkgname, pkg in repo_targets.items():
126         # Only run once per pkgbase
127         if pkgname in rebuilds:
128             continue
130         # Retrieve metdata from local repository entry
131         pkgbase = pkg['PackageBase']
132         pkgver, pkgrel = pkg['Version'].rsplit('-', 1)
134         # Retrieve metadata from .SRCINFO
135         src_dir = os.path.join(aurdest, pkgbase)
136         src_pkgver, _ = srcinfo_get_version(os.path.join(src_dir, '.SRCINFO'))
138         # Set backup file for PKGBUILD
139         buildscript = os.path.join(src_dir, 'PKGBUILD')
140         backup = '.tmp'
141         remove_backup = False
143         # Increase subrelease level to avoid conflicts with intermediate
144         # PKGBUILD updates
145         if src_pkgver == pkgver:
146             update_pkgrel(buildscript, backup, pkgname, pkgrel=float(pkgrel), increment=0.1)
147             remove_backup = True
148         else:
149             print(f'{ARGV0}: source and local repository version differ', file=sys.stderr)
150             print(f'{ARGV0}: using existing pkgver', file=sys.stderr)
152         failed_rebuilds = {}
154         # Build package with modified pkgrel
155         try:
156             subprocess.run(build_cmd, check=True, cwd=src_dir)
158             # Build process completed successfully, remove backup PKGBUILD if it
159             # was created above
160             if remove_backup:
161                 os.remove(buildscript + backup)
163         except subprocess.CalledProcessError:
164             # Build process failed, revert to unmodified PKGBUILD
165             print(f'{ARGV0}: {pkgbase}: build failed, reverting PKGBUILD', file=sys.stderr)
166             os.replace(buildscript + backup, buildscript)
168             # --fail-fast: if a package failed to build, also consider remaining targets as failed
169             if fail_fast:
170                 print(f'{ARGV0}: {pkgbase}: build failed, exiting', file=sys.stderr)
171                 return rebuilds, list(set(repo_targets) - set(rebuilds))
173             # Mark rebuild as failure for later reporting to the user
174             failed_rebuilds[pkgname] = pkgbase
176         rebuilds[pkgname] = pkgbase
178         return rebuilds, failed_rebuilds
181 def print_cached_packages(args):
182     """Print cached packages in `vercmp` order
183     """
184     name_args = ['--name=' + item for item in args]
185     pacsift   = ['pacsift', *name_args, '--exact', '--cache']
187     p1 = subprocess.Popen(pacsift, stdout=subprocess.PIPE)
188     p2 = subprocess.Popen(['pacsort'], stdin=p1.stdout, stderr=subprocess.PIPE)
189     p2.communicate()  # wait for the pipeline to finish
192 def main(targets, db_name, fail_fast, run_sync):
193     # Ensure all sources are available. Only packages are cloned that are
194     # already available in the local repository.
195     sync_cmd  = ['aur', 'sync', '--no-build', '--no-ver-argv']
196     repo_cmd  = ['aur', 'repo', '--jsonl']
198     if db_name is not None:
199         sync_cmd.extend(('--database', db_name))
200         repo_cmd.extend(('--database', db_name))
202     repo_targets = {}
204     # Read repository contents line by line to handle potentially large databases
205     for pkg_str in run_readline(repo_cmd):
206         pkg = json.loads(pkg_str)
207         pkgname = pkg['Name']
209         # Restrict to packages specified on the command-line
210         if pkgname in targets:
211             repo_targets[pkgname] = {
212                 'PackageBase': pkg['PackageBase'], 'Version' : pkg['Version']
213             }
215     # Clone targets that are part of the local repository
216     if len(repo_targets) > 0:
217         sync_cmd.extend(list(repo_targets.keys()))
218         if run_sync:
219             subprocess.run(sync_cmd, check=True)
220     else:
221         print(f'{ARGV0}: no targets in local repository', file=sys.stderr)
222         sys.exit(1)
224     rebuilds, failed = rebuild_packages(repo_targets, db_name, fail_fast)
226     if len(failed) > 0:
227         print(f'{ARGV0}: the following targets failed to build:', file=sys.stderr)
228         print('\n'.join(rebuilds.keys()), file=sys.stderr)
230     rest = list(set(targets) - set(rebuilds.keys()) - set(failed.keys()))
232     if len(rest) > 0:
233         print(f'{ARGV0}: the following targets are unavailable in the local repository',
234               file=sys.stderr)
235         print('\n'.join(rest), file=sys.stderr)
237         # Print any stale cached packages
238         print(f'\n{ARGV0}: with cached entries:', file=sys.stderr)
239         print_cached_packages(rest)
242 # Parse user arguments when run directly
243 if __name__ == '__main__':
244     import argparse
245     parser = argparse.ArgumentParser(prog=f'{ARGV0}', description='rebuild packages')
246     parser.add_argument('-d', '--database')
247     parser.add_argument('--fail-fast', action='store_true')
248     parser.add_argument('--no-sync', action='store_false')
249     parser.add_argument('targets', nargs='+')
250     args = parser.parse_args()
252     main({i:1 for i in args.targets}, args.database, args.fail_fast, args.no_sync)