Repository: DataBiosphere/toil

Issue:
I keep pushing commits that fail CI due to MyPy
I keep pushing commits that fail CI. I would have caught it if I had run MyPy, but I do not run MyPy all the time because it is slow (especially on Courtyard) and I am lazy.

┆Issue is synchronized with this [Jira Story](https://ucsc-cgl.atlassian.net/browse/TOIL-1171)
┆friendlyId: TOIL-1171



Discussion:

Resolving patch:
diff --git a/contrib/hooks/lib.py b/contrib/hooks/lib.py
new file mode 100644
--- /dev/null
+++ b/contrib/hooks/lib.py
@@ -0,0 +1,177 @@
+"""
+Utility functions for hook scripts.
+"""
+
+import sys
+import os
+import subprocess
+import socket
+import functools
+
+from subprocess import CalledProcessError, TimeoutExpired
+from typing import Tuple, Optional
+
+def complain(message):
+    sys.stderr.flush()
+    sys.stderr.write(message)
+    sys.stderr.write('\n')
+    sys.stderr.flush()
+
+def announce(message):
+    sys.stderr.flush()
+    sys.stderr.write(message)
+    sys.stderr.write('\n')
+    sys.stderr.flush()
+
+@functools.lru_cache(maxsize=None)
+def get_hostname() -> str:
+    """
+    Get the current hostname, or fall back to localhost.
+    """
+    try:
+        return socket.getfqdn()
+    except:
+        return 'localhost'
+
+def file_link(path: str, text: Optional[str] = None) -> str:
+    """
+    Produce a string we can print to a terminal to create a hyperlink.
+
+    Uses OSC 8 (operating system command number 8) as documented at
+    <https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda>
+    """
+
+    # Determine link protocol to use
+    if 'SSH_TTY' in os.environ:
+        # If the user is SSH-ing in, link to the file over SFTP so their
+        # desktop's virtual filesystem can go get it.
+        protocol = 'sftp'
+    else:
+        protocol = 'file'
+
+    if not text:
+        # If we didn't get any link text, use the provided path.
+        text = path
+
+    # We always want to link to a full path name
+    realpath = os.path.realpath(path)
+
+    # This is the Operating System Command escape sequence
+    OSC = '\033]'
+    # This is the standard String Terminator for ending arguments to operating
+    # system commands.
+    ST = '\033\\'
+    # This is the command to set a link format on characters you print.
+    # It takes as an argument some optional params, a semicolon, and a URL.
+    # The argument is terminated with the String Terminator.
+    # Set the URL to an empty string to turn off linkification.
+    LINK_COMMAND=f'{OSC}8;'
+
+    # This is the full URL we want to link to.
+    # We include a hostname even for file:// URLs because that's what Gnome
+    # terminal seems to expect. This may or may not work well elsewhere.
+    url = f'{protocol}://{get_hostname()}{realpath}'
+
+    # This is what we print to turn on linking to the URL
+    turn_on_link = f'{LINK_COMMAND};{url}{ST}'
+    # And this is what we print to stop linking.
+    turn_off_link = f'{LINK_COMMAND};{ST}'
+
+    return f'{turn_on_link}{text}{turn_off_link}'
+
+def in_acceptable_environment() -> bool:
+    """
+    Determine if we are in an environment where we ought to be able to run mypy
+    type checking.
+    """
+    try:
+        # We need to be able to get at Toil, and we need to be in a virtual
+        # environment.
+        from toil import inVirtualEnv
+        return inVirtualEnv()
+    except:
+        # If we can't do that, either we're not in a Toil dev environment or
+        # Toil is Very Broken and that will be caught other ways.
+        return False
+
+def get_current_commit() -> str:
+    """
+    Get the currently checked-out commit.
+    """
+    return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip()
+
+def is_rebase():
+    """
+    Return true if we think we are currently rebasing.
+    """
+    git_dir = os.getenv('GIT_DIR', '.git')
+    return os.path.exists(os.path.join(git_dir, 'rebase-merge')) or os.path.exists(os.path.join(git_dir, 'rebase-apply'))
+
+# We have a cache for mypy results so we can compute them in advance.
+CACHE_DIR = '.mypy_toil_result_cache'
+# But we don't want it to last too long.
+USER_DIR = f'/var/run/user/{os.getuid()}'
+if os.path.isdir(USER_DIR):
+    CACHE_DIR = os.path.join(USER_DIR, CACHE_DIR)
+
+def write_cache(commit: str, result: bool, log: str) -> str:
+    """
+    Save the given status and log to the cache for the given commit.
+    Returns cache filename for log text.
+    """
+
+    os.makedirs(CACHE_DIR, exist_ok=True)
+    basename = os.path.join(CACHE_DIR, commit)
+    if os.path.exists(basename + '.fail.txt'):
+        os.unlink(basename + '.fail.txt')
+    if os.path.exists(basename + '.success.txt'):
+        os.unlink(basename + '.success.txt')
+    fullname = basename + ('.success.txt' if result else '.fail.txt')
+    with open(fullname, 'w') as f:
+        f.write(log)
+    return fullname
+
+def read_cache(commit: str) -> Tuple[Optional[bool], Optional[str]]:
+    """
+    Read the status and log from the cache for the given commit.
+    """
+
+    status = None
+    log = None
+
+    basename = os.path.join(CACHE_DIR, commit)
+    fullname = None
+    if os.path.exists(basename + '.fail.txt'):
+        # We have a cached failure.
+        fullname = basename + '.fail.txt'
+        status = False
+    elif os.path.exists(basename + '.success.txt'):
+        # We have a cached success
+        fullname = basename + '.success.txt'
+        status = True
+    if fullname:
+        log = open(fullname).read()
+    return status, log
+
+def check_to_cache(local_object, timeout: float = None) -> Tuple[Optional[bool], Optional[str], Optional[str]]:
+    """
+    Type-check current commit and save result to cache. Return status, log, and log filename, or None, None, None if a timeout is hit.
+    """
+
+    try:
+        # As a hook we know we're in the project root when running.
+        mypy_output = subprocess.check_output(['make', 'mypy'], stderr=subprocess.STDOUT, timeout=timeout)
+        log = mypy_output.decode('utf-8')
+        # If we get here it passed
+        filename = write_cache(local_object, True, log)
+        return True, log, filename
+    except CalledProcessError as e:
+        # It did not work.
+        log = e.output.decode('utf-8')
+        # Save this in a cache
+        filename = write_cache(local_object, False, log)
+        return False, log, filename
+    except TimeoutExpired:
+        return None, None, None
+
+
diff --git a/contrib/hooks/mypy-after-commit.py b/contrib/hooks/mypy-after-commit.py
new file mode 100755
--- /dev/null
+++ b/contrib/hooks/mypy-after-commit.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+
+"""
+mypy-after-commit.py: Git post-commit script to type-check commits in the background.
+
+Install with:
+
+ln -rs ./contrib/hooks/mypy-after-commit.py .git/hooks/post-commit
+"""
+
+import sys
+import subprocess
+import os
+
+from lib import announce, complain, file_link, in_acceptable_environment, check_to_cache, get_current_commit, is_rebase
+
+def main(argc, argv):
+    # No input; we want to run in the background
+    if in_acceptable_environment() and not is_rebase():
+        announce('Type-checking commit')
+        commit = get_current_commit()
+        result, log, filename = check_to_cache(commit, timeout=10)
+        if result is None:
+            # Type checking timed out
+            announce('This is taking a while. Type-checking in the background.')
+            if os.fork() == 0:
+                check_to_cache(commit)
+        elif result:
+            announce('Commit OK')
+        else:
+            complain('Commit did not type-check! ' + file_link(filename, 'See the log for details.'))
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main(len(sys.argv), sys.argv))
diff --git a/contrib/hooks/mypy-before-push.py b/contrib/hooks/mypy-before-push.py
new file mode 100755
--- /dev/null
+++ b/contrib/hooks/mypy-before-push.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+
+"""
+mypy-before-push.py: Git pre-push script to make sure that you know if something you push is going to fail MyPy linting.
+
+Install with:
+
+ln -rs ./contrib/hooks/mypy-before-push.py .git/hooks/pre-push
+"""
+
+import sys
+import subprocess
+import os
+from typing import Tuple, Optional
+
+from lib import complain, announce, in_acceptable_environment, write_cache, read_cache, check_to_cache, get_current_commit
+
+def check_can_run(local_object) -> bool:
+    """
+    Make sure we would be able to run mypy on the given commit.
+    """
+
+    if not in_acceptable_environment():
+        announce('Environment not set up for type checking.')
+        return False
+    try:
+        current_object = get_current_commit()
+    except:
+        announce('Currently checked-out commit cannot be determined.')
+        return False
+    if current_object != local_object:
+        announce(f'Commit being pushed is not currently checked out')
+        return False
+    return True
+
+def check_checked_out_commit(local_object) -> bool:
+    """
+    If the checked-out commit does not type-check, return false. Else, return true.
+    """
+
+    status, log = read_cache(local_object)
+    if status is None:
+        announce('Commit has not been type-checked. Checking now.')
+        status, log, _ = check_to_cache(local_object)
+        from_cache = False
+    else:
+        from_cache = True
+    if status == False:
+        complain('Commit failed type-checking:')
+        sys.stderr.write(log)
+        # Type-check again in the background in case something has actually
+        # changed about the environment.
+        if from_cache:
+            if os.fork() == 0:
+                check_to_cache(local_object)
+                sys.exit(0)
+            else:
+                announce('Re-checking in the background in case something changed.')
+        return False
+    elif status == True:
+        announce('Commit passed type-checking.')
+        return True
+
+def main(argc, argv):
+    for line in sys.stdin:
+        line = line.strip()
+        if line == '':
+            continue
+        parts = line.split()
+        # Pushes will come from standard input as:
+        # <local ref> SP <local object name> SP <remote ref> SP <remote object name> LF
+        # See <https://www.git-scm.com/docs/githooks#_pre_push>
+        local_ref = parts[0]
+        local_object = parts[1]
+        if local_ref == '(delete)':
+            # Deleting a branch. Nothing to do
+            continue
+        if not check_can_run(local_object):
+            announce('Cannot check the commit being pushed.')
+            return 0
+        if not check_checked_out_commit(local_object):
+            complain('You should not push this. CI would fail!')
+            return 1
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main(len(sys.argv), sys.argv))


Test patch: