2 """ Rebuild AUR packages against newer dependencies
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
22 user_home = os.path.expanduser("~" + user)
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')
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.
43 with subprocess.Popen(command, stdout=subprocess.PIPE, cwd=cwd) as process:
45 output = process.stdout.readline()
46 if output == b'' and process.poll() is not None:
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.
62 with open(srcinfo, 'r', encoding='utf-8') as file:
63 (data, errors) = parse_srcinfo(file.read())
67 epoch = data.get('epoch')
68 pkgver = data['pkgver']
69 pkgrel = data['pkgrel']
72 return epoch + ':' + pkgver, pkgrel
76 def increase_decimal(decimal_number, increment, n_digits=2):
77 """Only increase the fractional part of a number.
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.
98 n_digits = sum(ch.isdigit() for ch in str(increment).strip('0'))
101 # Creates PKGBUILD.bak which is deleted when `finput` is closed
102 with fileinput.input(buildscript, inplace=True) as finput:
104 pkgrel_keyword = 'pkgrel='
106 if line.startswith(pkgrel_keyword):
107 # Extract and update the current pkgrel value
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)
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.
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))
132 srcver_cmd = ['runuser', '-u', user, '--'] + srcver_cmd
134 # Check that `pkgver` is consistent between local repository and .SRCINFO
137 for pkgname, pkg in repo_targets.items():
138 # Only run once per pkgbase
139 if pkgname in rebuilds:
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
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)):
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)
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}',
178 print(f'{ARGV0}: source and local repository version differ', file=sys.stderr)
179 print(f'{ARGV0}: using existing pkgver', file=sys.stderr)
183 # Build package with modified pkgrel
186 subprocess.run(build_cmd, check=True, cwd=src_dir)
188 # Drop privileges when running as root, see `examples/sync-rebuild`
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'
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
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
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.
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:
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']
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))
250 build_args = ['--chroot']
252 build_args = ['--syncdeps', '--rmdeps', '--noconfirm']
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']
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()))
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
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)
301 # Build in sequential (argument) order
302 rebuilds, failed = rebuild_packages(repo_targets, db_name, start_dir,
303 pkgver, fail_fast, user, build_args)
306 print(f'{ARGV0}: the following targets are not in AUR:', file=sys.stderr)
307 print(' '.join(not_aur), file=sys.stderr)
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))
318 print(f'{ARGV0}: the following targets are unavailable in the local repository',
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__':
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()
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)
345 elif os.getuid() != 0 and args.user is not None:
346 print(f'{ARGV0}: --user requires root', file=sys.stderr)
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')
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)