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