Repository: oppia/oppia

Issue:
Make Backend Code Typed M1.3
This issue is a part of GSoC Project ['Make Backend Code Typed'](https://docs.google.com/document/d/1t9HOmOX6Q7hH94OQCSfHj2v-MmY9AECJpiV8OCj2TnI/edit?usp=sharing). 

Aim: 
1. Add pre-push checks for mypy.
2. Add --skip-install flag to mypy script.


Discussion:

Resolving patch:
diff --git a/scripts/pre_push_hook.py b/scripts/pre_push_hook.py
--- a/scripts/pre_push_hook.py
+++ b/scripts/pre_push_hook.py
@@ -55,6 +55,7 @@
 
 # CAUTION: __file__ is here *OPPIA/.git/hooks* and not in *OPPIA/scripts*.
 LINTER_MODULE = 'scripts.linters.pre_commit_linter'
+MYPY_TYPE_CHECK_MODULE = 'scripts.run_mypy_checks'
 FILE_DIR = os.path.abspath(os.path.dirname(__file__))
 OPPIA_DIR = os.path.join(FILE_DIR, os.pardir, os.pardir)
 LINTER_FILE_FLAG = '--files'
@@ -328,6 +329,18 @@ def start_linter(files):
     return task.returncode
 
 
+def execute_mypy_checks():
+    """Executes the mypy type checks.
+
+    Returns:
+        int. The return code from mypy checks.
+    """
+    task = subprocess.Popen(
+        [PYTHON_CMD, '-m', MYPY_TYPE_CHECK_MODULE, '--skip-install'])
+    task.communicate()
+    return task.returncode
+
+
 def run_script_and_get_returncode(cmd_list):
     """Runs script and returns the returncode of the task.
 
@@ -509,6 +522,13 @@ def main(args=None):
                         'Push failed, please correct the linting issues above.')
                     sys.exit(1)
 
+            mypy_check_status = execute_mypy_checks()
+            if mypy_check_status != 0:
+                python_utils.PRINT(
+                    'Push failed, please correct the mypy type annotation '
+                    'issues above.')
+                sys.exit(mypy_check_status)
+
             typescript_checks_status = 0
             if does_diff_include_ts_files(files_to_lint):
                 typescript_checks_status = run_script_and_get_returncode(
diff --git a/scripts/run_mypy_checks.py b/scripts/run_mypy_checks.py
--- a/scripts/run_mypy_checks.py
+++ b/scripts/run_mypy_checks.py
@@ -25,14 +25,9 @@
 import subprocess
 import sys
 
-
+import python_utils
 from scripts import common
 from scripts import install_third_party_libs
-# This installs third party libraries before importing other files or importing
-# libraries that use the builtins python module (e.g. build, python_utils).
-install_third_party_libs.main()
-
-import python_utils # isort:skip  pylint: disable=wrong-import-position, wrong-import-order
 
 
 # List of directories whose files won't be type-annotated ever.
@@ -751,6 +746,11 @@
     description='Python type checking using mypy script.'
 )
 
+_PARSER.add_argument(
+    '--skip-install',
+    help='If true, skips installing dependencies. The default value is false.',
+    action='store_true')
+
 _PARSER.add_argument(
     '--files',
     help='Files to type-check',
@@ -759,6 +759,16 @@
 )
 
 
+def install_third_party_libraries(skip_install):
+    """Run the installation script.
+
+    Args:
+        skip_install: bool. Whether to skip running the installation script.
+    """
+    if not skip_install:
+        install_third_party_libs.main()
+
+
 def get_mypy_cmd(files):
     """Return the appropriate command to be run.
 
@@ -796,6 +806,13 @@ def main(args=None):
     """Runs the MyPy type checks."""
     parsed_args = _PARSER.parse_args(args=args)
 
+    for directory in common.DIRS_TO_ADD_TO_SYS_PATH:
+        # The directories should only be inserted starting at index 1. See
+        # https://stackoverflow.com/a/10095099 and
+        # https://stackoverflow.com/q/10095037 for more details.
+        sys.path.insert(1, directory)
+
+    install_third_party_libraries(parsed_args.skip_install)
     common.fix_third_party_imports()
 
     python_utils.PRINT('Installing Mypy and stubs for third party libraries.')


Test patch:
diff --git a/scripts/pre_push_hook_test.py b/scripts/pre_push_hook_test.py
--- a/scripts/pre_push_hook_test.py
+++ b/scripts/pre_push_hook_test.py
@@ -63,6 +63,9 @@ def mock_check_output(unused_cmd_tokens):
         self.linter_code = 0
         def mock_start_linter(unused_files_to_lint):
             return self.linter_code
+        self.mypy_check_code = 0
+        def mock_execute_mypy_checks():
+            return self.mypy_check_code
         self.does_diff_include_js_or_ts_files = False
         def mock_does_diff_include_js_or_ts_files(unused_diff_files):
             return self.does_diff_include_js_or_ts_files
@@ -96,6 +99,8 @@ def mock_check_backend_python_library_for_inconsistencies():
             subprocess, 'check_output', mock_check_output)
         self.start_linter_swap = self.swap(
             pre_push_hook, 'start_linter', mock_start_linter)
+        self.execute_mypy_checks_swap = self.swap(
+            pre_push_hook, 'execute_mypy_checks', mock_execute_mypy_checks)
         self.js_or_ts_swap = self.swap(
             pre_push_hook, 'does_diff_include_js_or_ts_files',
             mock_does_diff_include_js_or_ts_files)
@@ -403,6 +408,10 @@ def test_start_linter(self):
         with self.popen_swap:
             self.assertEqual(pre_push_hook.start_linter(['files']), 0)
 
+    def test_execute_mypy_checks(self):
+        with self.popen_swap:
+            self.assertEqual(pre_push_hook.execute_mypy_checks(), 0)
+
     def test_run_script_and_get_returncode(self):
         with self.popen_swap:
             self.assertEqual(
@@ -625,13 +634,27 @@ def test_lint_failure(self):
         with self.get_remote_name_swap, self.get_refs_swap, self.print_swap:
             with self.collect_files_swap, self.uncommitted_files_swap:
                 with self.check_output_swap, self.start_linter_swap:
-                    with self.assertRaisesRegexp(SystemExit, '1'):
-                        with self.swap_check_backend_python_libs:
-                            pre_push_hook.main(args=[])
+                    with self.execute_mypy_checks_swap:
+                        with self.assertRaisesRegexp(SystemExit, '1'):
+                            with self.swap_check_backend_python_libs:
+                                pre_push_hook.main(args=[])
         self.assertTrue(
             'Push failed, please correct the linting issues above.'
             in self.print_arr)
 
+    def test_mypy_check_failure(self):
+        self.mypy_check_code = 1
+        with self.get_remote_name_swap, self.get_refs_swap, self.print_swap:
+            with self.collect_files_swap, self.uncommitted_files_swap:
+                with self.check_output_swap, self.start_linter_swap:
+                    with self.execute_mypy_checks_swap:
+                        with self.assertRaisesRegexp(SystemExit, '1'):
+                            with self.swap_check_backend_python_libs:
+                                pre_push_hook.main(args=[])
+        self.assertIn(
+            'Push failed, please correct the mypy type annotation issues '
+            'above.', self.print_arr)
+
     def test_typescript_check_failiure(self):
         self.does_diff_include_ts_files = True
         def mock_run_script_and_get_returncode(unused_script):
@@ -643,9 +666,10 @@ def mock_run_script_and_get_returncode(unused_script):
             with self.collect_files_swap, self.uncommitted_files_swap:
                 with self.check_output_swap, self.start_linter_swap:
                     with self.ts_swap, run_script_and_get_returncode_swap:
-                        with self.assertRaisesRegexp(SystemExit, '1'):
-                            with self.swap_check_backend_python_libs:
-                                pre_push_hook.main(args=[])
+                        with self.execute_mypy_checks_swap:
+                            with self.assertRaisesRegexp(SystemExit, '1'):
+                                with self.swap_check_backend_python_libs:
+                                    pre_push_hook.main(args=[])
         self.assertTrue(
             'Push aborted due to failing typescript checks.' in self.print_arr)
 
@@ -663,9 +687,10 @@ def mock_run_script_and_get_returncode(script):
             with self.collect_files_swap, self.uncommitted_files_swap:
                 with self.check_output_swap, self.start_linter_swap:
                     with self.ts_swap, run_script_and_get_returncode_swap:
-                        with self.assertRaisesRegexp(SystemExit, '1'):
-                            with self.swap_check_backend_python_libs:
-                                pre_push_hook.main(args=[])
+                        with self.execute_mypy_checks_swap:
+                            with self.assertRaisesRegexp(SystemExit, '1'):
+                                with self.swap_check_backend_python_libs:
+                                    pre_push_hook.main(args=[])
         self.assertTrue(
             'Push aborted due to failing typescript checks in '
             'strict mode.' in self.print_arr)
@@ -681,9 +706,10 @@ def mock_run_script_and_get_returncode(unused_script):
             with self.collect_files_swap, self.uncommitted_files_swap:
                 with self.check_output_swap, self.start_linter_swap:
                     with self.js_or_ts_swap, run_script_and_get_returncode_swap:
-                        with self.assertRaisesRegexp(SystemExit, '1'):
-                            with self.swap_check_backend_python_libs:
-                                pre_push_hook.main(args=[])
+                        with self.execute_mypy_checks_swap:
+                            with self.assertRaisesRegexp(SystemExit, '1'):
+                                with self.swap_check_backend_python_libs:
+                                    pre_push_hook.main(args=[])
         self.assertTrue(
             'Push aborted due to failing frontend tests.' in self.print_arr)
 
@@ -700,9 +726,10 @@ def mock_run_script_and_get_returncode(unused_script):
                 with self.check_output_swap, self.start_linter_swap:
                     with run_script_and_get_returncode_swap:
                         with self.ci_config_or_js_files_swap:
-                            with self.assertRaisesRegexp(SystemExit, '1'):
-                                with self.swap_check_backend_python_libs:
-                                    pre_push_hook.main(args=[])
+                            with self.execute_mypy_checks_swap:
+                                with self.assertRaisesRegexp(SystemExit, '1'):
+                                    with self.swap_check_backend_python_libs:
+                                        pre_push_hook.main(args=[])
         self.assertTrue(
             'Push aborted due to failing e2e test configuration check.'
             in self.print_arr)
@@ -723,8 +750,9 @@ def test_main_without_install_arg_and_errors(self):
             with self.collect_files_swap, self.uncommitted_files_swap:
                 with self.check_output_swap, self.start_linter_swap:
                     with self.js_or_ts_swap:
-                        with self.swap_check_backend_python_libs:
-                            pre_push_hook.main(args=[])
+                        with self.execute_mypy_checks_swap:
+                            with self.swap_check_backend_python_libs:
+                                pre_push_hook.main(args=[])
 
     def test_main_exits_when_mismatches_exist_in_backend_python_libs(self):
         """Test that main exits with correct error message when mismatches are
diff --git a/scripts/run_mypy_checks_test.py b/scripts/run_mypy_checks_test.py
--- a/scripts/run_mypy_checks_test.py
+++ b/scripts/run_mypy_checks_test.py
@@ -22,18 +22,28 @@
 import subprocess
 
 from core.tests import test_utils
+from scripts import install_third_party_libs
 from scripts import run_mypy_checks
 
 PYTHON_CMD = 'python3'
 MYPY_SCRIPT_MODULE = 'scripts.run_mypy_checks'
 
 
+def mock_install_third_party_libs_main():
+    """Mock for install_third_party_libs."""
+    return
+
+
 class MypyScriptChecks(test_utils.GenericTestBase):
     """Tests for MyPy type check runner script."""
 
     def setUp(self):
         super(MypyScriptChecks, self).setUp()
 
+        self.install_swap = self.swap_with_checks(
+            install_third_party_libs, 'main',
+            mock_install_third_party_libs_main)
+
         process_success = subprocess.Popen(
             ['echo', 'test'], stdout=subprocess.PIPE)
         def mock_popen_success(
@@ -64,6 +74,13 @@ def mock_install_mypy_prerequisites_success():
             run_mypy_checks, 'install_mypy_prerequisites',
             mock_install_mypy_prerequisites_success)
 
+    def test_install_third_party_libraries_with_skip_install_as_true(self):
+        run_mypy_checks.install_third_party_libraries(True)
+
+    def test_install_third_party_libraries_with_skip_install_as_false(self):
+        with self.install_swap:
+            run_mypy_checks.install_third_party_libraries(False)
+
     def test_get_mypy_cmd_without_files(self):
         expected_cmd = [
             'mypy', '--exclude', 'file1.py|file2.py|dir1/|dir2/',
@@ -83,62 +100,62 @@ def test_get_mypy_cmd_with_files(self):
                 cmd = run_mypy_checks.get_mypy_cmd(['file1.py', 'file2.py'])
                 self.assertEqual(cmd, expected_cmd)
 
-    def test_install_mypy_prerequisites_success(self):
+    def test_install_mypy_prerequisites(self):
         with self.popen_swap_success:
             code = run_mypy_checks.install_mypy_prerequisites()
             self.assertEqual(code, 0)
 
-    def test_install_mypy_prerequisites_failure(self):
+    def test_install_mypy_prerequisites_with_wrong_script(self):
         with self.popen_swap_failure:
             with self.swap(
                 run_mypy_checks, 'MYPY_REQUIREMENTS_PATH', 'scripts.wrong'):
                 code = run_mypy_checks.install_mypy_prerequisites()
                 self.assertEqual(code, 1)
 
-    def test_running_script_with_success(self):
+    def test_running_script_without_mypy_errors(self):
         with self.popen_swap_success:
             process = subprocess.Popen(
                 [PYTHON_CMD, '-m', MYPY_SCRIPT_MODULE], stdout=subprocess.PIPE)
             output = process.communicate()
             self.assertEqual(output[0], 'test\n')
 
-    def test_running_script_with_failure(self):
+    def test_running_script_with_mypy_errors(self):
         with self.popen_swap_failure:
             process = subprocess.Popen(
                 [PYTHON_CMD, '-m', MYPY_SCRIPT_MODULE], stdout=subprocess.PIPE)
             output = process.communicate()
             self.assertEqual(output[0], '')
 
-    def test_main_with_files_success(self):
+    def test_main_with_files_without_mypy_errors(self):
         with self.popen_swap_success:
-            with self.swap_install_success:
+            with self.swap_install_success, self.install_swap:
                 process = run_mypy_checks.main(args=['--files', 'file1.py'])
                 self.assertEqual(process, 0)
 
-    def test_main_success(self):
+    def test_main_without_mypy_errors(self):
         with self.popen_swap_success:
-            with self.swap_install_success:
+            with self.swap_install_success, self.install_swap:
                 process = run_mypy_checks.main(args=[])
                 self.assertEqual(process, 0)
 
-    def test_main_with_files_failure(self):
+    def test_main_with_files_with_mypy_errors(self):
         with self.popen_swap_failure:
-            with self.swap_install_success:
+            with self.swap_install_success, self.install_swap:
                 with self.assertRaisesRegexp(SystemExit, '1'):
                     run_mypy_checks.main(args=['--files', 'file1.py'])
 
-    def test_main_failure(self):
+    def test_main_failure_due_to_mypy_errors(self):
         with self.popen_swap_failure:
-            with self.swap_install_success:
+            with self.swap_install_success, self.install_swap:
                 with self.assertRaisesRegexp(SystemExit, '1'):
                     run_mypy_checks.main(args=[])
 
-    def test_main_install_prerequisites_success(self):
-        with self.popen_swap_success:
+    def test_main_with_install_prerequisites_success(self):
+        with self.popen_swap_success, self.install_swap:
             process = run_mypy_checks.main(args=[])
             self.assertEqual(process, 0)
 
-    def test_main_install_prerequisites_failure(self):
-        with self.popen_swap_failure:
+    def test_main_with_install_prerequisites_failure(self):
+        with self.popen_swap_failure, self.install_swap:
             with self.assertRaisesRegexp(SystemExit, '1'):
                 run_mypy_checks.main(args=[])