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
:
70 eprint('Cancelling…')
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
])
95 yield (target_directory
, branch_name
)
97 subprocess
.run(['git', 'worktree', 'remove', '--force', target_directory
])
98 subprocess
.run(['git', 'branch', '-D', branch_name
])
100 async def commit_changes(name
: str, merge_lock
: asyncio
.Lock
, worktree
: str, branch
: str, changes
: List
[Dict
]) -> None:
101 for change
in changes
:
102 # Git can only handle a single index operation at a time
103 async with merge_lock
:
104 await check_subprocess('git', 'add', *change
['files'], cwd
=worktree
)
105 commit_message
= '{attrPath}: {oldVersion} -> {newVersion}'.format(**change
)
106 if 'commitMessage' in change
:
107 commit_message
= change
['commitMessage']
108 elif 'commitBody' in change
:
109 commit_message
= commit_message
+ '\n\n' + change
['commitBody']
110 await check_subprocess('git', 'commit', '--quiet', '-m', commit_message
, cwd
=worktree
)
111 await check_subprocess('git', 'cherry-pick', branch
)
113 async def check_changes(package
: Dict
, worktree
: str, update_info
: str):
114 if 'commit' in package
['supportedFeatures']:
115 changes
= json
.loads(update_info
)
119 # Try to fill in missing attributes when there is just a single change.
120 if len(changes
) == 1:
121 # Dynamic data from updater take precedence over static data from passthru.updateScript.
122 if 'attrPath' not in changes
[0]:
123 # update.nix is always passing attrPath
124 changes
[0]['attrPath'] = package
['attrPath']
126 if 'oldVersion' not in changes
[0]:
127 # update.nix is always passing oldVersion
128 changes
[0]['oldVersion'] = package
['oldVersion']
130 if 'newVersion' not in changes
[0]:
131 attr_path
= changes
[0]['attrPath']
132 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
)
133 changes
[0]['newVersion'] = json
.loads((await obtain_new_version_process
.stdout
.read()).decode('utf-8'))
135 if 'files' not in changes
[0]:
136 changed_files_process
= await check_subprocess('git', 'diff', '--name-only', 'HEAD', stdout
=asyncio
.subprocess
.PIPE
, cwd
=worktree
)
137 changed_files
= (await changed_files_process
.stdout
.read()).splitlines()
138 changes
[0]['files'] = changed_files
140 if len(changed_files
) == 0:
145 async def merge_changes(merge_lock
: asyncio
.Lock
, package
: Dict
, update_info
: str, temp_dir
: Optional
[Tuple
[str, str]]) -> None:
146 if temp_dir
is not None:
147 worktree
, branch
= temp_dir
148 changes
= await check_changes(package
, worktree
, update_info
)
151 await commit_changes(package
['name'], merge_lock
, worktree
, branch
, changes
)
153 eprint(f
" - {package['name']}: DONE, no changes.")
155 eprint(f
" - {package['name']}: DONE.")
157 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):
159 package
= await packages_to_update
.get()
161 # A sentinel received, we are done.
164 if not ('commit' in package
['supportedFeatures'] or 'attrPath' in package
):
167 await run_update_script(nixpkgs_root
, merge_lock
, temp_dir
, package
, keep_going
)
169 async def start_updates(max_workers
: int, keep_going
: bool, commit
: bool, packages
: List
[Dict
]):
170 merge_lock
= asyncio
.Lock()
171 packages_to_update
: asyncio
.Queue
[Optional
[Dict
]] = asyncio
.Queue()
173 with contextlib
.ExitStack() as stack
:
174 temp_dirs
: List
[Optional
[Tuple
[str, str]]] = []
176 # Do not create more workers than there are packages.
177 num_workers
= min(max_workers
, len(packages
))
179 nixpkgs_root_process
= await check_subprocess('git', 'rev-parse', '--show-toplevel', stdout
=asyncio
.subprocess
.PIPE
)
180 nixpkgs_root
= (await nixpkgs_root_process
.stdout
.read()).decode('utf-8').strip()
182 # Set up temporary directories when using auto-commit.
183 for i
in range(num_workers
):
184 temp_dir
= stack
.enter_context(make_worktree()) if commit
else None
185 temp_dirs
.append(temp_dir
)
187 # Fill up an update queue,
188 for package
in packages
:
189 await packages_to_update
.put(package
)
191 # Add sentinels, one for each worker.
192 # A workers will terminate when it gets sentinel from the queue.
193 for i
in range(num_workers
):
194 await packages_to_update
.put(None)
196 # Prepare updater workers for each temp_dir directory.
197 # At most `num_workers` instances of `run_update_script` will be running at one time.
198 updaters
= asyncio
.gather(*[updater(nixpkgs_root
, temp_dir
, merge_lock
, packages_to_update
, keep_going
, commit
) for temp_dir
in temp_dirs
])
201 # Start updater workers.
203 except asyncio
.exceptions
.CancelledError
:
204 # When one worker is cancelled, cancel the others too.
206 except UpdateFailedException
as e
:
207 # When one worker fails, cancel the others, as this exception is only thrown when keep_going is false.
212 def main(max_workers
: int, keep_going
: bool, commit
: bool, packages_path
: str, skip_prompt
: bool) -> None:
213 with
open(packages_path
) as f
:
214 packages
= json
.load(f
)
217 eprint('Going to be running update for following packages:')
218 for package
in packages
:
219 eprint(f
" - {package['name']}")
222 confirm
= '' if skip_prompt
else input('Press Enter key to continue...')
226 eprint('Running update for:')
228 asyncio
.run(start_updates(max_workers
, keep_going
, commit
, packages
))
231 eprint('Packages updated!')
237 parser
= argparse
.ArgumentParser(description
='Update packages')
238 parser
.add_argument('--max-workers', '-j', dest
='max_workers', type=int, help='Number of updates to run concurrently', nargs
='?', default
=4)
239 parser
.add_argument('--keep-going', '-k', dest
='keep_going', action
='store_true', help='Do not stop after first failure')
240 parser
.add_argument('--commit', '-c', dest
='commit', action
='store_true', help='Commit the changes')
241 parser
.add_argument('packages', help='JSON file containing the list of package names and their update scripts')
242 parser
.add_argument('--skip-prompt', '-s', dest
='skip_prompt', action
='store_true', help='Do not stop for prompts')
244 if __name__
== '__main__':
245 args
= parser
.parse_args()
248 main(args
.max_workers
, args
.keep_going
, args
.commit
, args
.packages
, args
.skip_prompt
)
249 except KeyboardInterrupt as e
:
250 # Let’s cancel outside of the main loop too.