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