]> git.scottworley.com Git - pinch/blame - pinch.py
Polymorphic pin()
[pinch] / pinch.py
CommitLineData
2fa9cbea 1from abc import ABC, abstractmethod
0e5e611d 2import argparse
f15e458d 3import configparser
2f96f32a
SW
4import filecmp
5import functools
736c25eb 6import getpass
2f96f32a
SW
7import hashlib
8import operator
9import os
10import os.path
9a78329e 11import shlex
2f96f32a 12import shutil
73bec7e8 13import subprocess
9d7844bb 14import sys
2f96f32a 15import tempfile
89e79125 16import types
2f96f32a
SW
17import urllib.parse
18import urllib.request
19import xml.dom.minidom
20
21from typing import (
2f96f32a
SW
22 Dict,
23 Iterable,
24 List,
73bec7e8 25 NewType,
2f96f32a
SW
26 Tuple,
27)
28
3603dde2
SW
29# Use xdg module when it's less painful to have as a dependency
30
31
32class XDG(types.SimpleNamespace):
33 XDG_CACHE_HOME: str
34
35
36xdg = XDG(
37 XDG_CACHE_HOME=os.getenv(
38 'XDG_CACHE_HOME',
39 os.path.expanduser('~/.cache')))
26125a28
SW
40
41
2f96f32a
SW
42class VerificationError(Exception):
43 pass
44
45
46class Verification:
47
48 def __init__(self) -> None:
49 self.line_length = 0
50
51 def status(self, s: str) -> None:
9d7844bb 52 print(s, end=' ', file=sys.stderr, flush=True)
2f96f32a
SW
53 self.line_length += 1 + len(s) # Unicode??
54
55 @staticmethod
56 def _color(s: str, c: int) -> str:
57 return '\033[%2dm%s\033[00m' % (c, s)
58
59 def result(self, r: bool) -> None:
60 message, color = {True: ('OK ', 92), False: ('FAIL', 91)}[r]
61 length = len(message)
dd1026fe 62 cols = shutil.get_terminal_size().columns or 80
2f96f32a 63 pad = (cols - (self.line_length + length)) % cols
9d7844bb 64 print(' ' * pad + self._color(message, color), file=sys.stderr)
2f96f32a
SW
65 self.line_length = 0
66 if not r:
67 raise VerificationError()
68
69 def check(self, s: str, r: bool) -> None:
70 self.status(s)
71 self.result(r)
72
73 def ok(self) -> None:
74 self.result(True)
75
76
faff8642
SW
77Digest16 = NewType('Digest16', str)
78Digest32 = NewType('Digest32', str)
79
80
81class ChannelTableEntry(types.SimpleNamespace):
82 absolute_url: str
83 digest: Digest16
84 file: str
85 size: int
86 url: str
87
88
2fa9cbea 89class SearchPath(types.SimpleNamespace, ABC):
faff8642
SW
90 release_name: str
91
2fa9cbea
SW
92 @abstractmethod
93 def pin(self, v: Verification, conf: configparser.SectionProxy) -> None:
94 pass
faff8642 95
2fa9cbea
SW
96
97class AliasSearchPath(SearchPath):
faff8642 98 alias_of: str
2fa9cbea
SW
99
100 def pin(self, v: Verification, conf: configparser.SectionProxy) -> None:
101 assert not hasattr(self, 'git_repo')
102
103
a67cfec9
SW
104# (This lint-disable is for pylint bug https://github.com/PyCQA/pylint/issues/179
105# which is fixed in pylint 2.5.)
106class TarrableSearchPath(SearchPath, ABC): # pylint: disable=abstract-method
faff8642
SW
107 channel_html: bytes
108 channel_url: str
109 forwarded_url: str
110 git_ref: str
111 git_repo: str
112 git_revision: str
113 old_git_revision: str
114 table: Dict[str, ChannelTableEntry]
115
93d2dafe
SW
116 def fetch(self, v: Verification, section: str,
117 conf: configparser.SectionProxy) -> str:
118 if 'git_repo' not in conf or 'release_name' not in conf:
119 raise Exception(
120 'Cannot update unpinned channel "%s" (Run "pin" before "update")' %
121 section)
122
123 if 'channel_url' in conf:
124 return fetch_with_nix_prefetch_url(
125 v, conf['tarball_url'], Digest16(
126 conf['tarball_sha256']))
127
128 ensure_git_rev_available(v, self)
129 return git_get_tarball(v, self)
130
faff8642 131
4a82be40 132class GitSearchPath(TarrableSearchPath):
a67cfec9
SW
133 def pin(self, v: Verification, conf: configparser.SectionProxy) -> None:
134 if hasattr(self, 'git_revision'):
135 self.old_git_revision = self.git_revision
136 del self.git_revision
137
138 git_fetch(v, self)
139 conf['release_name'] = git_revision_name(v, self)
140 conf['git_revision'] = self.git_revision
4a82be40
SW
141
142
143class ChannelSearchPath(TarrableSearchPath):
a67cfec9
SW
144 def pin(self, v: Verification, conf: configparser.SectionProxy) -> None:
145 if hasattr(self, 'git_revision'):
146 self.old_git_revision = self.git_revision
147 del self.git_revision
148
149 pin_channel(v, self)
150 conf['release_name'] = self.release_name
151 conf['tarball_url'] = self.table['nixexprs.tar.xz'].absolute_url
152 conf['tarball_sha256'] = self.table['nixexprs.tar.xz'].digest
153 conf['git_revision'] = self.git_revision
4a82be40
SW
154
155
dc038df0 156def compare(a: str, b: str) -> Tuple[List[str], List[str], List[str]]:
2f96f32a
SW
157
158 def throw(error: OSError) -> None:
159 raise error
160
161 def join(x: str, y: str) -> str:
162 return y if x == '.' else os.path.join(x, y)
163
164 def recursive_files(d: str) -> Iterable[str]:
165 all_files: List[str] = []
166 for path, dirs, files in os.walk(d, onerror=throw):
167 rel = os.path.relpath(path, start=d)
168 all_files.extend(join(rel, f) for f in files)
169 for dir_or_link in dirs:
170 if os.path.islink(join(path, dir_or_link)):
171 all_files.append(join(rel, dir_or_link))
172 return all_files
173
174 def exclude_dot_git(files: Iterable[str]) -> Iterable[str]:
175 return (f for f in files if not f.startswith('.git/'))
176
177 files = functools.reduce(
178 operator.or_, (set(
179 exclude_dot_git(
180 recursive_files(x))) for x in [a, b]))
181 return filecmp.cmpfiles(a, b, files, shallow=False)
182
183
7d2c278f 184def fetch(v: Verification, channel: TarrableSearchPath) -> None:
2f96f32a 185 v.status('Fetching channel')
b17def3f 186 request = urllib.request.urlopen(channel.channel_url, timeout=10)
72d3478a
SW
187 channel.channel_html = request.read()
188 channel.forwarded_url = request.geturl()
7c4de64c 189 v.result(request.status == 200) # type: ignore # (for old mypy)
b17def3f 190 v.check('Got forwarded', channel.channel_url != channel.forwarded_url)
2f96f32a
SW
191
192
7d2c278f 193def parse_channel(v: Verification, channel: TarrableSearchPath) -> None:
2f96f32a 194 v.status('Parsing channel description as XML')
72d3478a 195 d = xml.dom.minidom.parseString(channel.channel_html)
2f96f32a
SW
196 v.ok()
197
3e6421c4
SW
198 v.status('Extracting release name:')
199 title_name = d.getElementsByTagName(
200 'title')[0].firstChild.nodeValue.split()[2]
201 h1_name = d.getElementsByTagName('h1')[0].firstChild.nodeValue.split()[2]
202 v.status(title_name)
203 v.result(title_name == h1_name)
72d3478a 204 channel.release_name = title_name
3e6421c4
SW
205
206 v.status('Extracting git commit:')
2f96f32a 207 git_commit_node = d.getElementsByTagName('tt')[0]
61aaf799
SW
208 channel.git_revision = git_commit_node.firstChild.nodeValue
209 v.status(channel.git_revision)
2f96f32a
SW
210 v.ok()
211 v.status('Verifying git commit label')
212 v.result(git_commit_node.previousSibling.nodeValue == 'Git commit ')
213
214 v.status('Parsing table')
72d3478a 215 channel.table = {}
2f96f32a
SW
216 for row in d.getElementsByTagName('tr')[1:]:
217 name = row.childNodes[0].firstChild.firstChild.nodeValue
218 url = row.childNodes[0].firstChild.getAttribute('href')
219 size = int(row.childNodes[1].firstChild.nodeValue)
73bec7e8 220 digest = Digest16(row.childNodes[2].firstChild.firstChild.nodeValue)
dc038df0
SW
221 channel.table[name] = ChannelTableEntry(
222 url=url, digest=digest, size=size)
2f96f32a
SW
223 v.ok()
224
225
dc038df0
SW
226def digest_string(s: bytes) -> Digest16:
227 return Digest16(hashlib.sha256(s).hexdigest())
228
229
73bec7e8
SW
230def digest_file(filename: str) -> Digest16:
231 hasher = hashlib.sha256()
232 with open(filename, 'rb') as f:
233 # pylint: disable=cell-var-from-loop
234 for block in iter(lambda: f.read(4096), b''):
235 hasher.update(block)
236 return Digest16(hasher.hexdigest())
237
238
239def to_Digest16(v: Verification, digest32: Digest32) -> Digest16:
240 v.status('Converting digest to base16')
241 process = subprocess.run(
ba596fc0 242 ['nix', 'to-base16', '--type', 'sha256', digest32], stdout=subprocess.PIPE)
73bec7e8
SW
243 v.result(process.returncode == 0)
244 return Digest16(process.stdout.decode().strip())
245
246
247def to_Digest32(v: Verification, digest16: Digest16) -> Digest32:
248 v.status('Converting digest to base32')
249 process = subprocess.run(
ba596fc0 250 ['nix', 'to-base32', '--type', 'sha256', digest16], stdout=subprocess.PIPE)
73bec7e8
SW
251 v.result(process.returncode == 0)
252 return Digest32(process.stdout.decode().strip())
253
254
255def fetch_with_nix_prefetch_url(
256 v: Verification,
257 url: str,
258 digest: Digest16) -> str:
259 v.status('Fetching %s' % url)
260 process = subprocess.run(
ba596fc0 261 ['nix-prefetch-url', '--print-path', url, digest], stdout=subprocess.PIPE)
73bec7e8
SW
262 v.result(process.returncode == 0)
263 prefetch_digest, path, empty = process.stdout.decode().split('\n')
264 assert empty == ''
265 v.check("Verifying nix-prefetch-url's digest",
266 to_Digest16(v, Digest32(prefetch_digest)) == digest)
267 v.status("Verifying file digest")
268 file_digest = digest_file(path)
269 v.result(file_digest == digest)
7c4de64c 270 return path # type: ignore # (for old mypy)
2f96f32a 271
73bec7e8 272
7d2c278f 273def fetch_resources(v: Verification, channel: TarrableSearchPath) -> None:
2f96f32a 274 for resource in ['git-revision', 'nixexprs.tar.xz']:
72d3478a 275 fields = channel.table[resource]
e434d96d
SW
276 fields.absolute_url = urllib.parse.urljoin(
277 channel.forwarded_url, fields.url)
278 fields.file = fetch_with_nix_prefetch_url(
279 v, fields.absolute_url, fields.digest)
73bec7e8
SW
280 v.status('Verifying git commit on main page matches git commit in table')
281 v.result(
282 open(
61aaf799 283 channel.table['git-revision'].file).read(999) == channel.git_revision)
2f96f32a 284
971d3659 285
9836141c 286def git_cachedir(git_repo: str) -> str:
26125a28
SW
287 return os.path.join(
288 xdg.XDG_CACHE_HOME,
289 'pinch/git',
eb0c6f1b
SW
290 digest_string(git_repo.encode()))
291
292
7d2c278f 293def tarball_cache_file(channel: TarrableSearchPath) -> str:
eb0c6f1b
SW
294 return os.path.join(
295 xdg.XDG_CACHE_HOME,
296 'pinch/git-tarball',
297 '%s-%s-%s' %
298 (digest_string(channel.git_repo.encode()),
299 channel.git_revision,
300 channel.release_name))
971d3659
SW
301
302
7d2c278f 303def verify_git_ancestry(v: Verification, channel: TarrableSearchPath) -> None:
971d3659
SW
304 cachedir = git_cachedir(channel.git_repo)
305 v.status('Verifying rev is an ancestor of ref')
306 process = subprocess.run(['git',
307 '-C',
308 cachedir,
309 'merge-base',
310 '--is-ancestor',
311 channel.git_revision,
312 channel.git_ref])
313 v.result(process.returncode == 0)
314
315 if hasattr(channel, 'old_git_revision'):
316 v.status(
317 'Verifying rev is an ancestor of previous rev %s' %
318 channel.old_git_revision)
319 process = subprocess.run(['git',
320 '-C',
321 cachedir,
322 'merge-base',
323 '--is-ancestor',
324 channel.old_git_revision,
325 channel.git_revision])
326 v.result(process.returncode == 0)
9836141c 327
2f96f32a 328
7d2c278f 329def git_fetch(v: Verification, channel: TarrableSearchPath) -> None:
dc038df0
SW
330 # It would be nice if we could share the nix git cache, but as of the time
331 # of writing it is transitioning from gitv2 (deprecated) to gitv3 (not ready
332 # yet), and trying to straddle them both is too far into nix implementation
333 # details for my comfort. So we re-implement here half of nix.fetchGit.
334 # :(
335
9836141c
SW
336 cachedir = git_cachedir(channel.git_repo)
337 if not os.path.exists(cachedir):
dc038df0
SW
338 v.status("Initializing git repo")
339 process = subprocess.run(
9836141c 340 ['git', 'init', '--bare', cachedir])
dc038df0
SW
341 v.result(process.returncode == 0)
342
971d3659
SW
343 v.status('Fetching ref "%s" from %s' % (channel.git_ref, channel.git_repo))
344 # We don't use --force here because we want to abort and freak out if forced
345 # updates are happening.
346 process = subprocess.run(['git',
347 '-C',
348 cachedir,
349 'fetch',
350 channel.git_repo,
351 '%s:%s' % (channel.git_ref,
352 channel.git_ref)])
353 v.result(process.returncode == 0)
354
8fca6c28 355 if hasattr(channel, 'git_revision'):
971d3659 356 v.status('Verifying that fetch retrieved this rev')
8fca6c28 357 process = subprocess.run(
9836141c 358 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
dc038df0 359 v.result(process.returncode == 0)
971d3659 360 else:
8fca6c28
SW
361 channel.git_revision = open(
362 os.path.join(
9836141c 363 cachedir,
8fca6c28
SW
364 'refs',
365 'heads',
366 channel.git_ref)).read(999).strip()
dc038df0 367
971d3659 368 verify_git_ancestry(v, channel)
dc038df0 369
971d3659 370
7d2c278f
SW
371def ensure_git_rev_available(
372 v: Verification,
373 channel: TarrableSearchPath) -> None:
971d3659
SW
374 cachedir = git_cachedir(channel.git_repo)
375 if os.path.exists(cachedir):
376 v.status('Checking if we already have this rev:')
377 process = subprocess.run(
378 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
379 if process.returncode == 0:
380 v.status('yes')
381 if process.returncode == 1:
382 v.status('no')
383 v.result(process.returncode == 0 or process.returncode == 1)
384 if process.returncode == 0:
385 verify_git_ancestry(v, channel)
386 return
387 git_fetch(v, channel)
7d889b12 388
dc038df0 389
925c801b
SW
390def compare_tarball_and_git(
391 v: Verification,
7d2c278f 392 channel: TarrableSearchPath,
925c801b
SW
393 channel_contents: str,
394 git_contents: str) -> None:
395 v.status('Comparing channel tarball with git checkout')
396 match, mismatch, errors = compare(os.path.join(
397 channel_contents, channel.release_name), git_contents)
398 v.ok()
399 v.check('%d files match' % len(match), len(match) > 0)
400 v.check('%d files differ' % len(mismatch), len(mismatch) == 0)
401 expected_errors = [
402 '.git-revision',
403 '.version-suffix',
404 'nixpkgs',
405 'programs.sqlite',
406 'svn-revision']
407 benign_errors = []
408 for ee in expected_errors:
409 if ee in errors:
410 errors.remove(ee)
411 benign_errors.append(ee)
412 v.check(
413 '%d unexpected incomparable files' %
414 len(errors),
415 len(errors) == 0)
416 v.check(
417 '(%d of %d expected incomparable files)' %
418 (len(benign_errors),
419 len(expected_errors)),
420 len(benign_errors) == len(expected_errors))
421
422
7d2c278f
SW
423def extract_tarball(
424 v: Verification,
425 channel: TarrableSearchPath,
426 dest: str) -> None:
925c801b
SW
427 v.status('Extracting tarball %s' %
428 channel.table['nixexprs.tar.xz'].file)
429 shutil.unpack_archive(
430 channel.table['nixexprs.tar.xz'].file,
431 dest)
432 v.ok()
433
434
7d2c278f
SW
435def git_checkout(
436 v: Verification,
437 channel: TarrableSearchPath,
438 dest: str) -> None:
925c801b
SW
439 v.status('Checking out corresponding git revision')
440 git = subprocess.Popen(['git',
441 '-C',
9836141c 442 git_cachedir(channel.git_repo),
925c801b 443 'archive',
61aaf799 444 channel.git_revision],
925c801b
SW
445 stdout=subprocess.PIPE)
446 tar = subprocess.Popen(
447 ['tar', 'x', '-C', dest, '-f', '-'], stdin=git.stdout)
de68382a
SW
448 if git.stdout:
449 git.stdout.close()
925c801b
SW
450 tar.wait()
451 git.wait()
452 v.result(git.returncode == 0 and tar.returncode == 0)
453
454
7d2c278f 455def git_get_tarball(v: Verification, channel: TarrableSearchPath) -> str:
eb0c6f1b
SW
456 cache_file = tarball_cache_file(channel)
457 if os.path.exists(cache_file):
458 cached_tarball = open(cache_file).read(9999)
459 if os.path.exists(cached_tarball):
460 return cached_tarball
461
736c25eb
SW
462 with tempfile.TemporaryDirectory() as output_dir:
463 output_filename = os.path.join(
464 output_dir, channel.release_name + '.tar.xz')
465 with open(output_filename, 'w') as output_file:
466 v.status(
467 'Generating tarball for git revision %s' %
468 channel.git_revision)
469 git = subprocess.Popen(['git',
470 '-C',
471 git_cachedir(channel.git_repo),
472 'archive',
473 '--prefix=%s/' % channel.release_name,
474 channel.git_revision],
475 stdout=subprocess.PIPE)
476 xz = subprocess.Popen(['xz'], stdin=git.stdout, stdout=output_file)
477 xz.wait()
478 git.wait()
479 v.result(git.returncode == 0 and xz.returncode == 0)
480
481 v.status('Putting tarball in Nix store')
482 process = subprocess.run(
ba596fc0 483 ['nix-store', '--add', output_filename], stdout=subprocess.PIPE)
736c25eb 484 v.result(process.returncode == 0)
eb0c6f1b
SW
485 store_tarball = process.stdout.decode().strip()
486
487 os.makedirs(os.path.dirname(cache_file), exist_ok=True)
488 open(cache_file, 'w').write(store_tarball)
7c4de64c 489 return store_tarball # type: ignore # (for old mypy)
736c25eb
SW
490
491
f9cd7bdc
SW
492def check_channel_metadata(
493 v: Verification,
7d2c278f 494 channel: TarrableSearchPath,
f9cd7bdc
SW
495 channel_contents: str) -> None:
496 v.status('Verifying git commit in channel tarball')
497 v.result(
498 open(
499 os.path.join(
500 channel_contents,
501 channel.release_name,
61aaf799 502 '.git-revision')).read(999) == channel.git_revision)
f9cd7bdc
SW
503
504 v.status(
505 'Verifying version-suffix is a suffix of release name %s:' %
506 channel.release_name)
507 version_suffix = open(
508 os.path.join(
509 channel_contents,
510 channel.release_name,
511 '.version-suffix')).read(999)
512 v.status(version_suffix)
513 v.result(channel.release_name.endswith(version_suffix))
514
515
7d2c278f
SW
516def check_channel_contents(
517 v: Verification,
518 channel: TarrableSearchPath) -> None:
dc038df0
SW
519 with tempfile.TemporaryDirectory() as channel_contents, \
520 tempfile.TemporaryDirectory() as git_contents:
925c801b
SW
521
522 extract_tarball(v, channel, channel_contents)
f9cd7bdc
SW
523 check_channel_metadata(v, channel, channel_contents)
524
925c801b
SW
525 git_checkout(v, channel, git_contents)
526
527 compare_tarball_and_git(v, channel, channel_contents, git_contents)
528
dc038df0 529 v.status('Removing temporary directories')
2f96f32a
SW
530 v.ok()
531
532
7d2c278f 533def pin_channel(v: Verification, channel: TarrableSearchPath) -> None:
8fca6c28
SW
534 fetch(v, channel)
535 parse_channel(v, channel)
536 fetch_resources(v, channel)
971d3659 537 ensure_git_rev_available(v, channel)
8fca6c28
SW
538 check_channel_contents(v, channel)
539
540
7d2c278f 541def git_revision_name(v: Verification, channel: TarrableSearchPath) -> str:
e3cae769
SW
542 v.status('Getting commit date')
543 process = subprocess.run(['git',
544 '-C',
9836141c 545 git_cachedir(channel.git_repo),
bed32182 546 'log',
e3cae769
SW
547 '-n1',
548 '--format=%ct-%h',
549 '--abbrev=11',
88af5903 550 '--no-show-signature',
e3cae769 551 channel.git_revision],
ba596fc0 552 stdout=subprocess.PIPE)
de68382a 553 v.result(process.returncode == 0 and process.stdout != b'')
e3cae769
SW
554 return '%s-%s' % (os.path.basename(channel.git_repo),
555 process.stdout.decode().strip())
556
557
411b705e 558def read_search_path(conf: configparser.SectionProxy) -> SearchPath:
2fa9cbea
SW
559 if 'alias_of' in conf:
560 return AliasSearchPath(**dict(conf.items()))
4a82be40
SW
561 if 'channel_url' in conf:
562 return ChannelSearchPath(**dict(conf.items()))
563 return GitSearchPath(**dict(conf.items()))
f8f5b125
SW
564
565
01ba0eb2
SW
566def read_config(filename: str) -> configparser.ConfigParser:
567 config = configparser.ConfigParser()
568 config.read_file(open(filename), filename)
569 return config
570
571
4603b1a7
SW
572def read_config_files(
573 filenames: Iterable[str]) -> Dict[str, configparser.SectionProxy]:
574 merged_config: Dict[str, configparser.SectionProxy] = {}
575 for file in filenames:
576 config = read_config(file)
577 for section in config.sections():
578 if section in merged_config:
579 raise Exception('Duplicate channel "%s"' % section)
580 merged_config[section] = config[section]
581 return merged_config
582
583
0e5e611d 584def pin(args: argparse.Namespace) -> None:
2f96f32a 585 v = Verification()
01ba0eb2 586 config = read_config(args.channels_file)
5cfa8e11 587 for section in config.sections():
98853153
SW
588 if args.channels and section not in args.channels:
589 continue
736c25eb 590
411b705e 591 sp = read_search_path(config[section])
17906b27 592
411b705e 593 sp.pin(v, config[section])
8fca6c28 594
0e5e611d 595 with open(args.channels_file, 'w') as configfile:
e434d96d 596 config.write(configfile)
2f96f32a
SW
597
598
736c25eb
SW
599def update(args: argparse.Namespace) -> None:
600 v = Verification()
da135b07 601 exprs: Dict[str, str] = {}
4603b1a7
SW
602 config = read_config_files(args.channels_file)
603 for section in config:
b896966b
SW
604 sp = read_search_path(config[section])
605 if isinstance(sp, AliasSearchPath):
4603b1a7
SW
606 assert 'git_repo' not in config[section]
607 continue
4603b1a7
SW
608 tarball = sp.fetch(v, section, config[section])
609 exprs[section] = (
610 'f: f { name = "%s"; channelName = "%%s"; src = builtins.storePath "%s"; }' %
611 (config[section]['release_name'], tarball))
612
613 for section in config:
614 if 'alias_of' in config[section]:
615 exprs[section] = exprs[str(config[section]['alias_of'])]
17906b27 616
9a78329e
SW
617 command = [
618 'nix-env',
619 '--profile',
620 '/nix/var/nix/profiles/per-user/%s/channels' %
621 getpass.getuser(),
622 '--show-trace',
623 '--file',
624 '<nix/unpack-channel.nix>',
625 '--install',
17906b27 626 '--from-expression'] + [exprs[name] % name for name in sorted(exprs.keys())]
9a78329e
SW
627 if args.dry_run:
628 print(' '.join(map(shlex.quote, command)))
629 else:
630 v.status('Installing channels with nix-env')
631 process = subprocess.run(command)
632 v.result(process.returncode == 0)
736c25eb
SW
633
634
0e5e611d
SW
635def main() -> None:
636 parser = argparse.ArgumentParser(prog='pinch')
637 subparsers = parser.add_subparsers(dest='mode', required=True)
638 parser_pin = subparsers.add_parser('pin')
639 parser_pin.add_argument('channels_file', type=str)
98853153 640 parser_pin.add_argument('channels', type=str, nargs='*')
0e5e611d 641 parser_pin.set_defaults(func=pin)
736c25eb 642 parser_update = subparsers.add_parser('update')
9a78329e 643 parser_update.add_argument('--dry-run', action='store_true')
01ba0eb2 644 parser_update.add_argument('channels_file', type=str, nargs='+')
736c25eb 645 parser_update.set_defaults(func=update)
0e5e611d
SW
646 args = parser.parse_args()
647 args.func(args)
648
649
b5964ec3
SW
650if __name__ == '__main__':
651 main()