1 from __future__
import annotations
2 from typing
import Dict
, Generator
, List
, Optional
, Tuple
13 class CalledProcessError(Exception):
14 process
: asyncio
.subprocess
.Process
16 class UpdateFailedException(Exception):
19 def eprint(*args
, **kwargs
):
20 print(*args
, file=sys
.stderr
, **kwargs
)
22 async def check_subprocess(*args
, **kwargs
):
24 Emulate check argument of subprocess.run function.
26 process
= await asyncio
.create_subprocess_exec(*args
, **kwargs
)
27 returncode
= await process
.wait()
30 error
= CalledProcessError()
31 error
.process
= process
37 async def run_update_script(nixpkgs_root
: str, merge_lock
: asyncio
.Lock
, temp_dir
: Optional
[Tuple
[str, str]], package
: Dict
, keep_going
: bool):
38 worktree
: Optional
[str] = None
40 update_script_command
= package
['updateScript']
42 if temp_dir
is not None:
43 worktree
, _branch
= temp_dir
45 # Ensure the worktree is clean before update.
46 await check_subprocess('git', 'reset', '--hard', '--quiet', 'HEAD', cwd
=worktree
)
48 # Update scripts can use $(dirname $0) to get their location but we want to run
49 # their clones in the git worktree, not in the main nixpkgs repo.
50 update_script_command
= map(lambda arg
: re
.sub(r
'^{0}'.format(re
.escape(nixpkgs_root
)), worktree
, arg
), update_script_command
)
52 eprint(f
" - {package['name']}: UPDATING ...")
55 update_process
= await check_subprocess(
57 f
"UPDATE_NIX_NAME={package['name']}",
58 f
"UPDATE_NIX_PNAME={package['pname']}",
59 f
"UPDATE_NIX_OLD_VERSION={package['oldVersion']}",
60 f
"UPDATE_NIX_ATTR_PATH={package['attrPath']}",
61 *update_script_command
,
62 stdout
=asyncio
.subprocess
.PIPE
,
63 stderr
=asyncio
.subprocess
.PIPE
,
66 update_info
= await update_process
.stdout
.read()
68 await merge_changes(merge_lock
, package
, update_info
, temp_dir
)
69 except KeyboardInterrupt as e
:
71 raise asyncio
.exceptions
.CancelledError()
72 except CalledProcessError
as e
:
73 eprint(f
" - {package['name']}: ERROR")
75 eprint(f
"--- SHOWING ERROR LOG FOR {package['name']} ----------------------")
77 stderr
= await e
.process
.stderr
.read()
78 eprint(stderr
.decode('utf-8'))
79 with
open(f
"{package['pname']}.log", 'wb') as logfile
:
82 eprint(f
"--- SHOWING ERROR LOG FOR {package['name']} ----------------------")
85 raise UpdateFailedException(f
"The update script for {package['name']} failed with exit code {e.process.returncode}")
87 @contextlib.contextmanager
88 def make_worktree() -> Generator
[Tuple
[str, str], None, None]:
89 with tempfile
.TemporaryDirectory() as wt
:
90 branch_name
= f
'update-{os.path.basename(wt)}'
91 target_directory
= f
'{wt}/nixpkgs'
93 subprocess
.run(['git', 'worktree', 'add', '-b', branch_name
, target_directory
])
94 yield (target_directory
, branch_name
)
95 subprocess
.run(['git', 'worktree', 'remove', '--force', target_directory
])
96 subprocess
.run(['git', 'branch', '-D', branch_name
])
98 async def commit_changes(name
: str, merge_lock
: asyncio
.Lock
, worktree
: str, branch
: str, changes
: List
[Dict
]) -> None:
99 for change
in changes
:
100 # Git can only handle a single index operation at a time
101 async with merge_lock
:
102 await check_subprocess('git', 'add', *change
['files'], cwd
=worktree
)
103 commit_message
= '{attrPath}: {oldVersion} → {newVersion}'.format(**change
)
104 if 'commitMessage' in change
:
105 commit_message
= change
['commitMessage']
106 elif 'commitBody' in change
:
107 commit_message
= commit_message
+ '\n\n' + change
['commitBody']
108 await check_subprocess('git', 'commit', '--quiet', '-m', commit_message
, cwd
=worktree
)
109 await check_subprocess('git', 'cherry-pick', branch
)
111 async def check_changes(package
: Dict
, worktree
: str, update_info
: str):
112 if 'commit' in package
['supportedFeatures']:
113 changes
= json
.loads(update_info
)
117 # Try to fill in missing attributes when there is just a single change.
118 if len(changes
) == 1:
119 # Dynamic data from updater take precedence over static data from passthru.updateScript.
120 if 'attrPath' not in changes
[0]:
121 # update.nix is always passing attrPath
122 changes
[0]['attrPath'] = package
['attrPath']
124 if 'oldVersion' not in changes
[0]:
125 # update.nix is always passing oldVersion
126 changes
[0]['oldVersion'] = package
['oldVersion']
128 if 'newVersion' not in changes
[0]:
129 attr_path
= changes
[0]['attrPath']
130 obtain_new_version_process
= await check_subprocess('nix-instantiate', '--expr', f
'with import ./. {{}}; lib.getVersion {attr_path}', '--eval', '--strict', '--json', stdout
=asyncio
.subprocess
.PIPE
, stderr
=asyncio
.subprocess
.PIPE
, cwd
=worktree
)
131 changes
[0]['newVersion'] = json
.loads((await obtain_new_version_process
.stdout
.read()).decode('utf-8'))
133 if 'files' not in changes
[0]:
134 changed_files_process
= await check_subprocess('git', 'diff', '--name-only', 'HEAD', stdout
=asyncio
.subprocess
.PIPE
, cwd
=worktree
)
135 changed_files
= (await changed_files_process
.stdout
.read()).splitlines()
136 changes
[0]['files'] = changed_files
138 if len(changed_files
) == 0:
143 async def merge_changes(merge_lock
: asyncio
.Lock
, package
: Dict
, update_info
: str, temp_dir
: Optional
[Tuple
[str, str]]) -> None:
144 if temp_dir
is not None:
145 worktree
, branch
= temp_dir
146 changes
= await check_changes(package
, worktree
, update_info
)
149 await commit_changes(package
['name'], merge_lock
, worktree
, branch
, changes
)
151 eprint(f
" - {package['name']}: DONE, no changes.")
153 eprint(f
" - {package['name']}: DONE.")
155 async def updater(nixpkgs_root
: str, temp_dir
: Optional
[Tuple
[str, str]], merge_lock
: asyncio
.Lock
, packages_to_update
: asyncio
.Queue
[Optional
[Dict
]], keep_going
: bool, commit
: bool):
157 package
= await packages_to_update
.get()
159 # A sentinel received, we are done.
162 if not ('commit' in package
['supportedFeatures'] or 'attrPath' in package
):
165 await run_update_script(nixpkgs_root
, merge_lock
, temp_dir
, package
, keep_going
)
167 async def start_updates(max_workers
: int, keep_going
: bool, commit
: bool, packages
: List
[Dict
]):
168 merge_lock
= asyncio
.Lock()
169 packages_to_update
: asyncio
.Queue
[Optional
[Dict
]] = asyncio
.Queue()
171 with contextlib
.ExitStack() as stack
:
172 temp_dirs
: List
[Optional
[Tuple
[str, str]]] = []
174 # Do not create more workers than there are packages.
175 num_workers
= min(max_workers
, len(packages
))
177 nixpkgs_root_process
= await check_subprocess('git', 'rev-parse', '--show-toplevel', stdout
=asyncio
.subprocess
.PIPE
)
178 nixpkgs_root
= (await nixpkgs_root_process
.stdout
.read()).decode('utf-8').strip()
180 # Set up temporary directories when using auto-commit.
181 for i
in range(num_workers
):
182 temp_dir
= stack
.enter_context(make_worktree()) if commit
else None
183 temp_dirs
.append(temp_dir
)
185 # Fill up an update queue,
186 for package
in packages
:
187 await packages_to_update
.put(package
)
189 # Add sentinels, one for each worker.
190 # A workers will terminate when it gets sentinel from the queue.
191 for i
in range(num_workers
):
192 await packages_to_update
.put(None)
194 # Prepare updater workers for each temp_dir directory.
195 # At most `num_workers` instances of `run_update_script` will be running at one time.
196 updaters
= asyncio
.gather(*[updater(nixpkgs_root
, temp_dir
, merge_lock
, packages_to_update
, keep_going
, commit
) for temp_dir
in temp_dirs
])
199 # Start updater workers.
201 except asyncio
.exceptions
.CancelledError
:
202 # When one worker is cancelled, cancel the others too.
204 except UpdateFailedException
as e
:
205 # When one worker fails, cancel the others, as this exception is only thrown when keep_going is false.
210 def main(max_workers
: int, keep_going
: bool, commit
: bool, packages_path
: str) -> None:
211 with
open(packages_path
) as f
:
212 packages
= json
.load(f
)
215 eprint('Going to be running update for following packages:')
216 for package
in packages
:
217 eprint(f
" - {package['name']}")
220 confirm
= input('Press Enter key to continue...')
223 eprint('Running update for:')
225 asyncio
.run(start_updates(max_workers
, keep_going
, commit
, packages
))
228 eprint('Packages updated!')
234 parser
= argparse
.ArgumentParser(description
='Update packages')
235 parser
.add_argument('--max-workers', '-j', dest
='max_workers', type=int, help='Number of updates to run concurrently', nargs
='?', default
=4)
236 parser
.add_argument('--keep-going', '-k', dest
='keep_going', action
='store_true', help='Do not stop after first failure')
237 parser
.add_argument('--commit', '-c', dest
='commit', action
='store_true', help='Commit the changes')
238 parser
.add_argument('packages', help='JSON file containing the list of package names and their update scripts')
240 if __name__
== '__main__':
241 args
= parser
.parse_args()
244 main(args
.max_workers
, args
.keep_going
, args
.commit
, args
.packages
)
245 except KeyboardInterrupt as e
:
246 # Let’s cancel outside of the main loop too.