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