Merge pull request #1138 from aurutils/sync-cleanbuild
[aurutils.git] / examples / sync-rebuild
blob776cdf3a941183665b1d770f592167c31c7756d3
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
9 import tempfile
10 import shutil
12 from pwd import getpwnam
13 from decimal import Decimal
14 #from pyalpm import vercmp
15 from srcinfo.parse import parse_srcinfo
16 ARGV0 = 'sync-rebuild'
18 def xdg_cache_home(user=None):
19     """Retrieve XDG_CACHE_HOME from the XDG Base Directory specification
20     """
21     if user is not None:
22         user_home = os.path.expanduser("~" + user)
23     else:
24         user_home = os.path.expanduser("~")
25     cache_home = os.path.join(user_home, '.cache')
27     # Note: this only retrieves `XDG_CACHE_HOME` from the current user
28     # environment regardless if `user` is specified.
29     if 'XDG_CACHE_HOME' in os.environ:
30         return os.getenv('XDG_CACHE_HOME')
32     return cache_home
35 def run_readline(command, check=True, cwd=None):
36     """Run the output from a command line-by-line.
38     `aur` programs typically use newline delimited output. Here, this function
39     is used with `aur repo` to read JSON objects, with each line representing
40     one local repository package.
42     """
43     with subprocess.Popen(command, stdout=subprocess.PIPE, cwd=cwd) as process:
44         while True:
45             output = process.stdout.readline()
46             if output == b'' and process.poll() is not None:
47                 break
48             if output:
49                 yield output.strip()
51         return_code = process.poll()
52         if return_code > 0 and check:
53             raise subprocess.CalledProcessError(return_code, command)
56 def srcinfo_get_version(srcinfo):
57     """Return the full version string from a .SRCINFO file.
59     The `epoch` key is optional, `pkgver` and `pkgrel` are assumed present.
61     """
62     with open(srcinfo, 'r', encoding='utf-8') as file:
63         (data, errors) = parse_srcinfo(file.read())
64         if errors:
65             sys.exit(1)
67         epoch  = data.get('epoch')
68         pkgver = data['pkgver']
69         pkgrel = data['pkgrel']
71         if epoch is not None:
72             return epoch + ':' + pkgver, pkgrel
73         return pkgver, pkgrel
76 def increase_decimal(decimal_number, increment, n_digits=2):
77     """Only increase the fractional part of a number.
78     """
79     # Convert the decimal number and increment to Decimal objects
80     decimal_num = Decimal(str(decimal_number))
81     inc = Decimal(str(increment))
83     # Calculate the increased decimal
84     increased_decimal = decimal_num + inc
86     # Convert the increased decimal to a formatted string with fixed precision
87     precision = '.' + str(n_digits) + 'f'
88     increased_decimal_str = format(increased_decimal, precision)
90     return increased_decimal_str
93 def update_pkgrel(buildscript, pkgrel=None, increment=0.1):
94     """Update pkgrel in a PKGBUILD by a given increment.
96     Modifications assume a single caller and are not thread-safe.
97     """
98     n_digits = sum(ch.isdigit() for ch in str(increment).strip('0'))
99     new_pkgrel = None
101     # Creates PKGBUILD.bak which is deleted when `finput` is closed
102     with fileinput.input(buildscript, inplace=True) as finput:
103         for line in finput:
104             pkgrel_keyword = 'pkgrel='
106             if line.startswith(pkgrel_keyword):
107                 # Extract and update the current pkgrel value
108                 if pkgrel is None:
109                     pkgrel = float(line.split('=')[1])  # Only the last written pkgrel holds
110                 new_pkgrel = increase_decimal(pkgrel, increment, n_digits)
112                 # Replace the pkgrel value in the line
113                 line = f'{pkgrel_keyword}{new_pkgrel}\n'
115             # Write the modified line to stdout (which redirects to the PKGBUILD file)
116             print(line, end='')
118     return new_pkgrel
121 # TODO: use vercmp to ensure rebuilds, abort reverse depends when depends fails (sync--ninja)
122 def rebuild_packages(repo_targets, db_name, start_dir, pkgver=False, fail_fast=False, user=None, *build_args):
123     """Rebuild a series of packages in successive order.
124     """
125     build_cmd  = ['aur', 'build'] + list(*build_args)
126     srcver_cmd = ['aur', 'srcver']
128     if db_name is not None:
129         build_cmd.extend(('--database', db_name))
131     if user is not None:
132         srcver_cmd = ['runuser', '-u', user, '--'] + srcver_cmd
134     # Check that `pkgver` is consistent between local repository and .SRCINFO
135     rebuilds = {}
137     for pkgname, pkg in repo_targets.items():
138         # Only run once per pkgbase
139         if pkgname in rebuilds:
140             continue
142         # Retrieve metdata from local repository entry
143         pkgbase = pkg['PackageBase']
144         pkgver, pkgrel = pkg['Version'].rsplit('-', 1)
145         src_dir = os.path.join(start_dir, pkgbase)
147         # Run pkgver() function for VCS packages
148         if pkgver:
149             print(f'{ARGV0}: updating pkgver with aur-srcver', file=sys.stderr)
150             for n, pkg_str in enumerate(run_readline(srcver_cmd, cwd=src_dir)):
151                 if n > 0:
152                     raise RuntimeError('ambiguous aur-srcver output')
153                 src_pkgver, _ = pkg_str.decode('utf-8').split('\t')[1].rsplit('-', 1)
155         # Use .SRCINFO for other packages (faster)
156         else:
157             src_pkgver, _ = srcinfo_get_version(os.path.join(src_dir, '.SRCINFO'))
159         buildscript = os.path.join(src_dir, 'PKGBUILD')
160         buildscript_backup = None
162         # Increase subrelease level to avoid conflicts with intermediate PKGBUILD updates
163         if src_pkgver == pkgver:
164             # Set backup file for PKGBUILD
165             buildscript_backup = buildscript + '.tmp'
167             # Preserve permissions of PKGBUILD
168             bst = os.stat(buildscript)
169             shutil.copy2(buildscript, buildscript_backup)
170             shutil.chown(buildscript_backup, user=bst.st_uid)
172             new_pkgrel = update_pkgrel(buildscript, pkgrel=float(pkgrel), increment=0.1)
174             # Print bumped pkgrel to standard error
175             print(f'{ARGV0}: {pkgname}: {pkgver}-{pkgrel} -> {pkgver}-{new_pkgrel}',
176                   file=sys.stderr)
177         else:
178             print(f'{ARGV0}: source and local repository version differ', file=sys.stderr)
179             print(f'{ARGV0}: using existing pkgver', file=sys.stderr)
181         failed_rebuilds = {}
183         # Build package with modified pkgrel
184         try:
185             if user is None:
186                 subprocess.run(build_cmd, check=True, cwd=src_dir)
187             else:
188                 # Drop privileges when running as root, see `examples/sync-rebuild`
189                 asroot_env = {
190                     'AUR_ASROOT'       : '1',
191                     'AUR_MAKEPKG'      : f'runuser -u {user} -- makepkg',
192                     'AUR_GPG'          : f'runuser -u {user} -- gpg',
193                     'AUR_REPO_ADD'     : f'runuser -u {user} -- repo-add',
194                     'AUR_BUILD_PKGLIST': f'runuser -u {user} -- aur build--pkglist'
195                 }
196                 subprocess.run([*build_cmd, '--user', user], check=True, cwd=src_dir, 
197                                env=dict(os.environ, **asroot_env))
199             # Build process completed successfully, remove backup PKGBUILD if it
200             # was created above
201             if buildscript_backup is not None:
202                 os.remove(buildscript_backup)
204         except subprocess.CalledProcessError:
205             # Build process failed, revert to unmodified PKGBUILD
206             if buildscript_backup is not None:
207                 print(f'{ARGV0}: build failed, reverting PKGBUILD', file=sys.stderr)
208                 os.replace(buildscript_backup, buildscript)
210             # --fail-fast: if a package failed to build, also consider
211             # remaining targets as failed
212             if fail_fast:
213                 print(f'{ARGV0}: {pkgbase}: build failed, exiting', file=sys.stderr)
214                 return rebuilds, list(set(repo_targets) - set(rebuilds))
216             # Mark rebuild as failure for later reporting to the user
217             failed_rebuilds[pkgname] = pkgbase
219         rebuilds[pkgname] = pkgbase
221         return rebuilds, failed_rebuilds
224 def print_cached_packages(pkgnames):
225     """Print cached packages in `vercmp` order.
226     """
227     name_args = ['--name=' + item for item in pkgnames]
228     pacsift   = ['pacsift', *name_args, '--exact', '--cache']
230     with subprocess.Popen(pacsift, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as p1:
231         with subprocess.Popen(['pacsort'], stdin=p1.stdout, stderr=subprocess.PIPE) as p2:
232             p2.communicate()
235 def main(targets, db_name, start_dir, pkgver, fail_fast, run_sync, chroot, user):
236     # Ensure all sources are available. Only packages are cloned that are
237     # already available in the local repository.
238     sync_cmd = ['aur', 'sync', '--no-build', '--no-ver-argv']
239     repo_cmd = ['aur', 'repo', '--jsonl']
241     if user is not None:
242         sync_cmd = ['runuser', '-u', user, '--'] + sync_cmd
243         repo_cmd = ['runuser', '-u', user, '--'] + repo_cmd
245     if db_name is not None:
246         sync_cmd.extend(('--database', db_name))
247         repo_cmd.extend(('--database', db_name))
249     if chroot:
250         build_args = ['--chroot']
251     else:
252         build_args = ['--syncdeps', '--rmdeps', '--noconfirm']
254     repo_targets = {}
256     # Read repository contents line by line to handle potentially large databases
257     for pkg_str in run_readline(repo_cmd):
258         pkg = json.loads(pkg_str)
259         pkgname = pkg['Name']
261         # Restrict to packages specified on the command-line
262         if pkgname in targets:
263             repo_targets[pkgname] = {
264                 'PackageBase': pkg['PackageBase'], 'Version' : pkg['Version']
265             }
267     # Clone targets that are part of the local repository
268     # TODO: handle "new" AUR targets as usual
269     if len(repo_targets) > 0:
270         sync_cmd.extend(list(repo_targets.keys()))
272         if run_sync:
273             repo_targets_ordered = {}  # `dict` preserves order since python >=3.6
275             # Temporary file for dependency order
276             with tempfile.NamedTemporaryFile() as sync_queue:
277                 # Read access to build user
278                 if user is not None:
279                     shutil.chown(sync_queue.name, user=user)
281                 # Clone AUR targets and retrieve dependency order. Dependencies
282                 # not in the local repository already will be added as targets.
283                 # XXX: requires at least one valid AUR target
284                 subprocess.run([*sync_cmd, '--save', sync_queue.name], check=True)
286                 with open(sync_queue.name, 'r') as f:
287                     for line in f.readlines():
288                         name = os.path.basename(line.rstrip())
289                         repo_targets_ordered[name] = repo_targets[name]
291             # Local repository targets not retrieved by `aur-sync` are missing from AUR
292             # XXX: append to queue if target directories are available
293             not_aur = list(set(repo_targets.keys()) - set(repo_targets_ordered.keys()))
295             # Build in dependency order
296             rebuilds, failed = rebuild_packages(repo_targets_ordered, db_name, start_dir, 
297                                                 pkgver, fail_fast, user, build_args)
298         else:
299             not_aur = []
301             # Build in sequential (argument) order
302             rebuilds, failed = rebuild_packages(repo_targets, db_name, start_dir, 
303                                                 pkgver, fail_fast, user, build_args)
305         if len(not_aur) > 0:
306             print(f'{ARGV0}: the following targets are not in AUR:', file=sys.stderr)
307             print(' '.join(not_aur), file=sys.stderr)
309         if len(failed) > 0:
310             print(f'{ARGV0}: the following targets failed to build:', end=' ', file=sys.stderr)
311             print(' '.join(failed.keys()), file=sys.stderr)
313         rest = list(set(targets) - set(rebuilds.keys()) - set(failed.keys()) - set(not_aur))
314     else:
315         rest = list(targets)
317     if len(rest) > 0:
318         print(f'{ARGV0}: the following targets are unavailable in the local repository',
319               file=sys.stderr)
320         print(' '.join(rest), file=sys.stderr)
322         # Print any stale cached packages
323         print(f'{ARGV0}: with cached entries:', file=sys.stderr)
324         print_cached_packages(rest)
327 # Parse user arguments when run directly
328 if __name__ == '__main__':
329     import argparse
330     parser = argparse.ArgumentParser(prog=f'{ARGV0}', description='rebuild packages')
331     parser.add_argument('-d', '--database')
332     parser.add_argument('-c', '--chroot', action='store_true')
333     parser.add_argument('-U', '--user')
334     parser.add_argument('--pkgver', action='store_true')
335     parser.add_argument('--fail-fast', action='store_true')
336     parser.add_argument('--no-sync', action='store_false')
337     parser.add_argument('targets', nargs='+')
338     args = parser.parse_args()
340     # Verify options
341     if os.geteuid() == 0 and (args.user is None or getpwnam(args.user).pw_uid == 0):
342         print(f'{ARGV0}: unprivileged user required (--user)', file=sys.stderr)
343         sys.exit(1)
345     elif os.getuid() != 0 and args.user is not None:
346         print(f'{ARGV0}: --user requires root', file=sys.stderr)
347         sys.exit(1)
349     elif os.geteuid() == 0 and not args.chroot:
350         raise NotImplementedError('--user requires --chroot')
352     # Get the path to user-specific cache files
353     # Note: this only retrieves `AURDEST` from the current user environment.
354     if 'AURDEST' in os.environ:
355         aurdest = os.getenv('AURDEST')
356     else:
357         aurdest = os.path.join(xdg_cache_home(args.user), 'aurutils/sync')
359     main({i:1 for i in args.targets}, args.database, aurdest, 
360          args.pkgver, args.fail_fast, args.no_sync, args.chroot, args.user)