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