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