3 # Copyright (c) 2011-2014 David Bremner <david@tethera.net>
4 # W. Trevor King <wking@tremily.us>
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see https://www.gnu.org/licenses/ .
20 Manage notmuch tags with Git
23 from __future__ import print_function
24 from __future__ import unicode_literals
26 import codecs as _codecs
27 import collections as _collections
28 import functools as _functools
29 import inspect as _inspect
30 import locale as _locale
31 import logging as _logging
34 import shutil as _shutil
35 import subprocess as _subprocess
37 import tempfile as _tempfile
38 import textwrap as _textwrap
39 from urllib.parse import quote as _quote
40 from urllib.parse import unquote as _unquote
42 _LOG = _logging.getLogger('nmbug')
43 _LOG.setLevel(_logging.WARNING)
44 _LOG.addHandler(_logging.StreamHandler())
46 NOTMUCH_GIT_DIR = None
49 _HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}')
50 _TAG_DIRECTORY = 'tags/'
51 _TAG_FILE_REGEX = _re.compile(_TAG_DIRECTORY + '(?P<id>[^/]*)/(?P<tag>[^/]*)')
53 # magic hash for Git (git hash-object -t blob /dev/null)
54 _EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
56 def _hex_quote(string, safe='+@=:,'):
58 quote('abc def') -> 'abc%20def'.
60 Wrap urllib.parse.quote with additional safe characters (in
61 addition to letters, digits, and '_.-') and lowercase hex digits
62 (e.g. '%3a' instead of '%3A').
64 uppercase_escapes = _quote(string, safe)
65 return _HEX_ESCAPE_REGEX.sub(
66 lambda match: match.group(0).lower(),
69 def _xapian_quote(string):
71 Quote a string for Xapian's QueryParser.
73 Xapian uses double-quotes for quoting strings. You can escape
74 internal quotes by repeating them [1,2,3].
76 [1]: https://trac.xapian.org/ticket/128#comment:2
77 [2]: https://trac.xapian.org/ticket/128#comment:17
78 [3]: https://trac.xapian.org/changeset/13823/svn
80 return '"{0}"'.format(string.replace('"', '""'))
83 def _xapian_unquote(string):
85 Unquote a Xapian-quoted string.
87 if string.startswith('"') and string.endswith('"'):
88 return string[1:-1].replace('""', '"')
92 class SubprocessError(RuntimeError):
93 "A subprocess exited with a nonzero status"
94 def __init__(self, args, status, stdout=None, stderr=None):
98 msg = '{args} exited with {status}'.format(args=args, status=status)
100 msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr)
101 super(SubprocessError, self).__init__(msg)
104 class _SubprocessContextManager(object):
106 PEP 343 context manager for subprocesses.
108 'expect' holds a tuple of acceptable exit codes, otherwise we'll
109 raise a SubprocessError in __exit__.
111 def __init__(self, process, args, expect=(0,)):
112 self._process = process
114 self._expect = expect
119 def __exit__(self, type, value, traceback):
120 for name in ['stdin', 'stdout', 'stderr']:
121 stream = getattr(self._process, name)
124 setattr(self._process, name, None)
125 status = self._process.wait()
127 'collect {args} with status {status} (expected {expect})'.format(
128 args=self._args, status=status, expect=self._expect))
129 if status not in self._expect:
130 raise SubprocessError(args=self._args, status=status)
133 return self._process.wait()
136 def _spawn(args, input=None, additional_env=None, wait=False, stdin=None,
137 stdout=None, stderr=None, encoding=_locale.getpreferredencoding(),
138 expect=(0,), **kwargs):
139 """Spawn a subprocess, and optionally wait for it to finish.
141 This wrapper around subprocess.Popen has two modes, depending on
142 the truthiness of 'wait'. If 'wait' is true, we use p.communicate
143 internally to write 'input' to the subprocess's stdin and read
144 from it's stdout/stderr. If 'wait' is False, we return a
145 _SubprocessContextManager instance for fancier handling
146 (e.g. piping between processes).
148 For 'wait' calls when you want to write to the subprocess's stdin,
149 you only need to set 'input' to your content. When 'input' is not
150 None but 'stdin' is, we'll automatically set 'stdin' to PIPE
151 before calling Popen. This avoids having the subprocess
152 accidentally inherit the launching process's stdin.
154 _LOG.debug('spawn {args} (additional env. var.: {env})'.format(
155 args=args, env=additional_env))
156 if not stdin and input is not None:
157 stdin = _subprocess.PIPE
159 if not kwargs.get('env'):
160 kwargs['env'] = dict(_os.environ)
161 kwargs['env'].update(additional_env)
162 p = _subprocess.Popen(
163 args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs)
165 if hasattr(input, 'encode'):
166 input = input.encode(encoding)
167 (stdout, stderr) = p.communicate(input=input)
170 'collect {args} with status {status} (expected {expect})'.format(
171 args=args, status=status, expect=expect))
172 if stdout is not None:
173 stdout = stdout.decode(encoding)
174 if stderr is not None:
175 stderr = stderr.decode(encoding)
176 if status not in expect:
177 raise SubprocessError(
178 args=args, status=status, stdout=stdout, stderr=stderr)
179 return (status, stdout, stderr)
180 if p.stdin and not stdin:
184 p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin)
185 stream_reader = _codecs.getreader(encoding=encoding)
187 p.stdout = stream_reader(stream=p.stdout)
189 p.stderr = stream_reader(stream=p.stderr)
190 return _SubprocessContextManager(args=args, process=p, expect=expect)
193 def _git(args, **kwargs):
194 args = ['git', '--git-dir', NOTMUCH_GIT_DIR] + list(args)
195 return _spawn(args=args, **kwargs)
198 def _get_current_branch():
199 """Get the name of the current branch.
201 Return 'None' if we're not on a branch.
204 (status, branch, stderr) = _git(
205 args=['symbolic-ref', '--short', 'HEAD'],
206 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
207 except SubprocessError as e:
208 if 'not a symbolic ref' in e:
211 return branch.strip()
215 "Get the default remote for the current branch."
216 local_branch = _get_current_branch()
217 (status, remote, stderr) = _git(
218 args=['config', 'branch.{0}.remote'.format(local_branch)],
219 stdout=_subprocess.PIPE, wait=True)
220 return remote.strip()
223 def get_tags(prefix=None):
224 "Get a list of tags with a given prefix."
227 (status, stdout, stderr) = _spawn(
228 args=['notmuch', 'search', '--output=tags', '*'],
229 stdout=_subprocess.PIPE, wait=True)
230 return [tag for tag in stdout.splitlines() if tag.startswith(prefix)]
233 def archive(treeish='HEAD', args=()):
235 Dump a tar archive of the current nmbug tag set.
239 Each tag $tag for message with Message-Id $id is written to
242 tags/encode($id)/encode($tag)
244 The encoding preserves alphanumerics, and the characters
245 "+-_@=.:," (not the quotes). All other octets are replaced with
246 '%' followed by a two digit hex number.
248 _git(args=['archive', treeish] + list(args), wait=True)
251 def clone(repository):
253 Create a local nmbug repository from a remote source.
255 This wraps 'git clone', adding some options to avoid creating a
256 working tree while preserving remote-tracking branches and
259 with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir:
262 'git', 'clone', '--no-checkout', '--separate-git-dir', NOTMUCH_GIT_DIR,
263 repository, workdir],
265 _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5))
266 _git(args=['config', 'core.bare', 'true'], wait=True)
267 (status, stdout, stderr) = _git(args=['show-ref', '--verify',
269 'refs/remotes/origin/config'],
273 _git(args=['branch', 'config', 'origin/config'], wait=True)
274 existing_tags = get_tags()
277 'Not checking out to avoid clobbering existing tags: {}'.format(
278 ', '.join(existing_tags)))
283 def _is_committed(status):
284 return len(status['added']) + len(status['deleted']) == 0
287 def commit(treeish='HEAD', message=None):
289 Commit prefix-matching tags from the notmuch database to Git.
291 status = get_status()
293 if _is_committed(status=status):
294 _LOG.warning('Nothing to commit')
297 _git(args=['read-tree', '--empty'], wait=True)
298 _git(args=['read-tree', treeish], wait=True)
300 _update_index(status=status)
303 stdout=_subprocess.PIPE,
305 (_, parent, _) = _git(
306 args=['rev-parse', treeish],
307 stdout=_subprocess.PIPE,
309 (_, commit, _) = _git(
310 args=['commit-tree', tree.strip(), '-p', parent.strip()],
312 stdout=_subprocess.PIPE,
315 args=['update-ref', treeish, commit.strip()],
316 stdout=_subprocess.PIPE,
318 except Exception as e:
319 _git(args=['read-tree', '--empty'], wait=True)
320 _git(args=['read-tree', treeish], wait=True)
323 def _update_index(status):
325 args=['update-index', '--index-info'],
326 stdin=_subprocess.PIPE) as p:
327 for id, tags in status['deleted'].items():
328 for line in _index_tags_for_message(id=id, status='D', tags=tags):
330 for id, tags in status['added'].items():
331 for line in _index_tags_for_message(id=id, status='A', tags=tags):
335 def fetch(remote=None):
337 Fetch changes from the remote repository.
339 See 'merge' to bring those changes into notmuch.
344 _git(args=args, wait=True)
347 def init(remote=None):
349 Create an empty nmbug repository.
351 This wraps 'git init' with a few extra steps to support subsequent
352 status and commit commands.
354 _spawn(args=['git', '--git-dir', NOTMUCH_GIT_DIR, 'init',
355 '--initial-branch=master', '--quiet', '--bare'], wait=True)
356 _git(args=['config', 'core.logallrefupdates', 'true'], wait=True)
357 # create an empty blob (e69de29bb2d1d6434b8b29ae775ad8c2e48c5391)
358 _git(args=['hash-object', '-w', '--stdin'], input='', wait=True)
361 'commit', '--allow-empty', '-m', 'Start a new nmbug repository'
363 additional_env={'GIT_WORK_TREE': NOTMUCH_GIT_DIR},
369 Update the notmuch database from Git.
371 This is mainly useful to discard your changes in notmuch relative
374 status = get_status()
376 args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p:
377 for id, tags in status['added'].items():
378 p.stdin.write(_batch_line(action='-', id=id, tags=tags))
379 for id, tags in status['deleted'].items():
380 p.stdin.write(_batch_line(action='+', id=id, tags=tags))
383 def _batch_line(action, id, tags):
385 'notmuch tag --batch' line for adding/removing tags.
387 Set 'action' to '-' to remove a tag or '+' to add the tags to a
390 tag_string = ' '.join(
391 '{action}{prefix}{tag}'.format(
392 action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag))
394 line = '{tags} -- id:{id}\n'.format(
395 tags=tag_string, id=_xapian_quote(string=id))
399 def _insist_committed():
400 "Die if the the notmuch tags don't match the current HEAD."
401 status = get_status()
402 if not _is_committed(status=status):
403 _LOG.error('\n'.join([
404 'Uncommitted changes to {prefix}* tags in notmuch',
406 "For a summary of changes, run 'nmbug status'",
407 "To save your changes, run 'nmbug commit' before merging/pull",
408 "To discard your changes, run 'nmbug checkout'",
409 ]).format(prefix=TAG_PREFIX))
413 def pull(repository=None, refspecs=None):
415 Pull (merge) remote repository changes to notmuch.
417 'pull' is equivalent to 'fetch' followed by 'merge'. We use the
418 Git-configured repository for your current branch
419 (branch.<name>.repository, likely 'origin', and
420 branch.<name>.merge, likely 'master').
423 if refspecs and not repository:
424 repository = _get_remote()
427 args.append(repository)
429 args.extend(refspecs)
430 with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir:
436 additional_env={'GIT_WORK_TREE': workdir},
441 def merge(reference='@{upstream}'):
443 Merge changes from 'reference' into HEAD and load the result into notmuch.
445 The default reference is '@{upstream}'.
448 with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir:
451 ['merge', reference]]:
454 additional_env={'GIT_WORK_TREE': workdir},
461 A simple wrapper for 'git log'.
463 After running 'nmbug fetch', you can inspect the changes with
464 'nmbug log HEAD..@{upstream}'.
466 # we don't want output trapping here, because we want the pager.
467 args = ['log', '--name-status', '--no-renames'] + list(args)
468 with _git(args=args, expect=(0, 1, -13)) as p:
472 def push(repository=None, refspecs=None):
473 "Push the local nmbug Git state to a remote repository."
474 if refspecs and not repository:
475 repository = _get_remote()
478 args.append(repository)
480 args.extend(refspecs)
481 _git(args=args, wait=True)
486 Show pending updates in notmuch or git repo.
488 Prints lines of the form
492 where n is a single character representing notmuch database status
496 Tag is present in notmuch database, but not committed to nmbug
497 (equivalently, tag has been deleted in nmbug repo, e.g. by a
498 pull, but not restored to notmuch database).
502 Tag is present in nmbug repo, but not restored to notmuch
503 database (equivalently, tag has been deleted in notmuch).
507 Message is unknown (missing from local notmuch database).
509 The second character (if present) represents a difference between
510 local and upstream branches. Typically 'nmbug fetch' needs to be
515 Tag is present in upstream, but not in the local Git branch.
519 Tag is present in local Git branch, but not upstream.
521 status = get_status()
522 # 'output' is a nested defaultdict for message status:
523 # * The outer dict is keyed by message id.
524 # * The inner dict is keyed by tag name.
525 # * The inner dict values are status strings (' a', 'Dd', ...).
526 output = _collections.defaultdict(
527 lambda : _collections.defaultdict(lambda : ' '))
528 for id, tags in status['added'].items():
530 output[id][tag] = 'A'
531 for id, tags in status['deleted'].items():
533 output[id][tag] = 'D'
534 for id, tags in status['missing'].items():
536 output[id][tag] = 'U'
538 for id, tag in _diff_refs(filter='A'):
539 output[id][tag] += 'a'
540 for id, tag in _diff_refs(filter='D'):
541 output[id][tag] += 'd'
542 for id, tag_status in sorted(output.items()):
543 for tag, status in sorted(tag_status.items()):
544 print('{status}\t{id}\t{tag}'.format(
545 status=status, id=id, tag=tag))
548 def _is_unmerged(ref='@{upstream}'):
550 (status, fetch_head, stderr) = _git(
551 args=['rev-parse', ref],
552 stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True)
553 except SubprocessError as e:
554 if 'No upstream configured' in e.stderr:
557 (status, base, stderr) = _git(
558 args=['merge-base', 'HEAD', ref],
559 stdout=_subprocess.PIPE, wait=True)
560 return base != fetch_head
568 index = _index_tags()
569 maybe_deleted = _diff_index(index=index, filter='D')
570 for id, tags in maybe_deleted.items():
571 (_, stdout, stderr) = _spawn(
572 args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)],
573 stdout=_subprocess.PIPE,
576 status['deleted'][id] = tags
578 status['missing'][id] = tags
579 status['added'] = _diff_index(index=index, filter='A')
585 "Write notmuch tags to the nmbug.index."
586 path = _os.path.join(NOTMUCH_GIT_DIR, 'nmbug.index')
587 query = ' '.join('tag:"{tag}"'.format(tag=tag) for tag in get_tags())
588 prefix = '+{0}'.format(_ENCODED_TAG_PREFIX)
590 args=['read-tree', '--empty'],
591 additional_env={'GIT_INDEX_FILE': path}, wait=True)
593 args=['notmuch', 'dump', '--format=batch-tag', '--', query],
594 stdout=_subprocess.PIPE) as notmuch:
596 args=['update-index', '--index-info'],
597 stdin=_subprocess.PIPE,
598 additional_env={'GIT_INDEX_FILE': path}) as git:
599 for line in notmuch.stdout:
600 if line.strip().startswith('#'):
602 (tags_string, id) = [_.strip() for _ in line.split(' -- id:')]
604 _unquote(tag[len(prefix):])
605 for tag in tags_string.split()
606 if tag.startswith(prefix)]
607 id = _xapian_unquote(string=id)
608 for line in _index_tags_for_message(
609 id=id, status='A', tags=tags):
610 git.stdin.write(line)
614 def _index_tags_for_message(id, status, tags):
616 Update the Git index to either create or delete an empty file.
618 Neither 'id' nor the tags in 'tags' should be encoded/escaped.
625 hash = '0000000000000000000000000000000000000000'
628 path = 'tags/{id}/{tag}'.format(
629 id=_hex_quote(string=id), tag=_hex_quote(string=tag))
630 yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path)
633 def _diff_index(index, filter):
635 Get an {id: {tag, ...}} dict for a given filter.
637 For example, use 'A' to find added tags, and 'D' to find deleted tags.
639 s = _collections.defaultdict(set)
642 'diff-index', '--cached', '--diff-filter', filter,
643 '--name-only', 'HEAD'],
644 additional_env={'GIT_INDEX_FILE': index},
645 stdout=_subprocess.PIPE) as p:
646 # Once we drop Python < 3.3, we can use 'yield from' here
647 for id, tag in _unpack_diff_lines(stream=p.stdout):
652 def _diff_refs(filter, a='HEAD', b='@{upstream}'):
654 args=['diff', '--diff-filter', filter, '--name-only', a, b],
655 stdout=_subprocess.PIPE) as p:
656 # Once we drop Python < 3.3, we can use 'yield from' here
657 for id, tag in _unpack_diff_lines(stream=p.stdout):
661 def _unpack_diff_lines(stream):
662 "Iterate through (id, tag) tuples in a diff stream."
664 match = _TAG_FILE_REGEX.match(line.strip())
666 message = 'non-tag line in diff: {!r}'.format(line.strip())
667 if line.startswith(_TAG_DIRECTORY):
668 raise ValueError(message)
671 id = _unquote(match.group('id'))
672 tag = _unquote(match.group('tag'))
676 def _help(parser, command=None):
678 Show help for an nmbug command.
680 Because some folks prefer:
686 $ nmbug COMMAND --help
689 parser.parse_args([command, '--help'])
691 parser.parse_args(['--help'])
694 if __name__ == '__main__':
697 parser = argparse.ArgumentParser(
698 description=__doc__.strip(),
699 formatter_class=argparse.RawDescriptionHelpFormatter)
701 '-C', '--git-dir', metavar='REPO',
702 help='Git repository to operate on.')
704 '-p', '--tag-prefix', metavar='PREFIX',
705 default = _os.getenv('NOTMUCH_GIT_PREFIX', 'notmuch::'),
706 help='Prefix of tags to operate on.')
709 choices=['critical', 'error', 'warning', 'info', 'debug'],
710 help='Log verbosity. Defaults to {!r}.'.format(
711 _logging.getLevelName(_LOG.level).lower()))
713 help = _functools.partial(_help, parser=parser)
714 help.__doc__ = _help.__doc__
715 subparsers = parser.add_subparsers(
718 'For help on a particular command, run: '
719 "'%(prog)s ... <command> --help'."))
734 func = locals()[command]
735 doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%')
736 subparser = subparsers.add_parser(
738 help=doc.splitlines()[0],
740 formatter_class=argparse.RawDescriptionHelpFormatter)
741 subparser.set_defaults(func=func)
742 if command == 'archive':
743 subparser.add_argument(
744 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD',
746 'The tree or commit to produce an archive for. Defaults '
748 subparser.add_argument(
749 'args', metavar='ARG', nargs='*',
751 "Argument passed through to 'git archive'. Set anything "
752 'before <tree-ish>, see git-archive(1) for details.'))
753 elif command == 'clone':
754 subparser.add_argument(
757 'The (possibly remote) repository to clone from. See the '
758 'URLS section of git-clone(1) for more information on '
759 'specifying repositories.'))
760 elif command == 'commit':
761 subparser.add_argument(
762 'message', metavar='MESSAGE', default='', nargs='?',
763 help='Text for the commit message.')
764 elif command == 'fetch':
765 subparser.add_argument(
766 'remote', metavar='REMOTE', nargs='?',
768 'Override the default configured in branch.<name>.remote '
769 'to fetch from a particular remote repository (e.g. '
771 elif command == 'help':
772 subparser.add_argument(
773 'command', metavar='COMMAND', nargs='?',
774 help='The command to show help for.')
775 elif command == 'log':
776 subparser.add_argument(
777 'args', metavar='ARG', nargs='*',
778 help="Additional argument passed through to 'git log'.")
779 elif command == 'merge':
780 subparser.add_argument(
781 'reference', metavar='REFERENCE', default='@{upstream}',
784 'Reference, usually other branch heads, to merge into '
785 "our branch. Defaults to '@{upstream}'."))
786 elif command == 'pull':
787 subparser.add_argument(
788 'repository', metavar='REPOSITORY', default=None, nargs='?',
790 'The "remote" repository that is the source of the pull. '
791 'This parameter can be either a URL (see the section GIT '
792 'URLS in git-pull(1)) or the name of a remote (see the '
793 'section REMOTES in git-pull(1)).'))
794 subparser.add_argument(
795 'refspecs', metavar='REFSPEC', default=None, nargs='*',
797 'Refspec (usually a branch name) to fetch and merge. See '
798 'the <refspec> entry in the OPTIONS section of '
799 'git-pull(1) for other possibilities.'))
800 elif command == 'push':
801 subparser.add_argument(
802 'repository', metavar='REPOSITORY', default=None, nargs='?',
804 'The "remote" repository that is the destination of the '
805 'push. This parameter can be either a URL (see the '
806 'section GIT URLS in git-push(1)) or the name of a remote '
807 '(see the section REMOTES in git-push(1)).'))
808 subparser.add_argument(
809 'refspecs', metavar='REFSPEC', default=None, nargs='*',
811 'Refspec (usually a branch name) to push. See '
812 'the <refspec> entry in the OPTIONS section of '
813 'git-push(1) for other possibilities.'))
815 args = parser.parse_args()
818 NOTMUCH_GIT_DIR = args.git_dir
820 NOTMUCH_GIT_DIR = _os.path.expanduser(
821 _os.getenv('NOTMUCH_GIT_DIR', _os.path.join('~', '.nmbug')))
822 _NOTMUCH_GIT_DIR = _os.path.join(NOTMUCH_GIT_DIR, '.git')
823 if _os.path.isdir(_NOTMUCH_GIT_DIR):
824 NOTMUCH_GIT_DIR = _NOTMUCH_GIT_DIR
826 TAG_PREFIX = args.tag_prefix
827 _ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':'
830 level = getattr(_logging, args.log_level.upper())
834 for var in ['NOTMUCH_GIT_DIR', 'NOTMUCH_GIT_PREFIX', 'NOTMUCH_PROFILE', 'NOTMUCH_CONFIG' ]:
835 _LOG.debug('env {:s} = {:s}'.format(var, _os.getenv(var,'%None%')))
837 if not getattr(args, 'func', None):
841 if args.func == help:
842 arg_names = ['command']
844 (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__)
845 kwargs = {key: getattr(args, key) for key in arg_names if key in args}
848 except SubprocessError as e:
849 if _LOG.level == _logging.DEBUG:
850 raise # don't mask the traceback