diff options
-rw-r--r-- | docs/python-support.md | 15 | ||||
-rw-r--r-- | docs/repo-hooks.md | 25 | ||||
-rwxr-xr-x | project.py | 147 | ||||
-rw-r--r-- | tests/test_project.py | 58 |
4 files changed, 224 insertions, 21 deletions
diff --git a/docs/python-support.md b/docs/python-support.md index af19cd05..35806de7 100644 --- a/docs/python-support.md +++ b/docs/python-support.md | |||
@@ -28,5 +28,20 @@ The master branch will require Python 3.6 at a minimum. | |||
28 | If the system has an older version of Python 3, then users will have to select | 28 | If the system has an older version of Python 3, then users will have to select |
29 | the legacy Python 2 branch instead. | 29 | the legacy Python 2 branch instead. |
30 | 30 | ||
31 | ### repo hooks | ||
31 | 32 | ||
33 | Projects that use [repo hooks] run on independent schedules. | ||
34 | They might migrate to Python 3 earlier or later than us. | ||
35 | To support them, we'll probe the shebang of the hook script and if we find an | ||
36 | interpreter in there that indicates a different version than repo is currently | ||
37 | running under, we'll attempt to reexec ourselves under that. | ||
38 | |||
39 | For example, a hook with a header like `#!/usr/bin/python2` will have repo | ||
40 | execute `/usr/bin/python2` to execute the hook code specifically if repo is | ||
41 | currently running Python 3. | ||
42 | |||
43 | For more details, consult the [repo hooks] documentation. | ||
44 | |||
45 | |||
46 | [repo hooks]: ./repo-hooks.md | ||
32 | [repo launcher]: ../repo | 47 | [repo launcher]: ../repo |
diff --git a/docs/repo-hooks.md b/docs/repo-hooks.md index e198b390..7c37c30e 100644 --- a/docs/repo-hooks.md +++ b/docs/repo-hooks.md | |||
@@ -83,6 +83,31 @@ then check it directly. Hooks should not normally modify the active git repo | |||
83 | the user. Although user interaction is discouraged in the common case, it can | 83 | the user. Although user interaction is discouraged in the common case, it can |
84 | be useful when deploying automatic fixes. | 84 | be useful when deploying automatic fixes. |
85 | 85 | ||
86 | ### Shebang Handling | ||
87 | |||
88 | *** note | ||
89 | This is intended as a transitional feature. Hooks are expected to eventually | ||
90 | migrate to Python 3 only as Python 2 is EOL & deprecated. | ||
91 | *** | ||
92 | |||
93 | If the hook is written against a specific version of Python (either 2 or 3), | ||
94 | the script can declare that explicitly. Repo will then attempt to execute it | ||
95 | under the right version of Python regardless of the version repo itself might | ||
96 | be executing under. | ||
97 | |||
98 | Here are the shebangs that are recognized. | ||
99 | |||
100 | * `#!/usr/bin/env python` & `#!/usr/bin/python`: The hook is compatible with | ||
101 | Python 2 & Python 3. For maximum compatibility, these are recommended. | ||
102 | * `#!/usr/bin/env python2` & `#!/usr/bin/python2`: The hook requires Python 2. | ||
103 | Version specific names like `python2.7` are also recognized. | ||
104 | * `#!/usr/bin/env python3` & `#!/usr/bin/python3`: The hook requires Python 3. | ||
105 | Version specific names like `python3.6` are also recognized. | ||
106 | |||
107 | If no shebang is detected, or does not match the forms above, we assume that the | ||
108 | hook is compatible with both Python 2 & Python 3 as if `#!/usr/bin/python` was | ||
109 | used. | ||
110 | |||
86 | ## Hooks | 111 | ## Hooks |
87 | 112 | ||
88 | Here are all the points available for hooking. | 113 | Here are all the points available for hooking. |
@@ -18,6 +18,7 @@ from __future__ import print_function | |||
18 | import errno | 18 | import errno |
19 | import filecmp | 19 | import filecmp |
20 | import glob | 20 | import glob |
21 | import json | ||
21 | import os | 22 | import os |
22 | import random | 23 | import random |
23 | import re | 24 | import re |
@@ -544,6 +545,105 @@ class RepoHook(object): | |||
544 | prompt % (self._GetMustVerb(), self._script_fullpath), | 545 | prompt % (self._GetMustVerb(), self._script_fullpath), |
545 | 'Scripts have changed since %s was allowed.' % (self._hook_type,)) | 546 | 'Scripts have changed since %s was allowed.' % (self._hook_type,)) |
546 | 547 | ||
548 | @staticmethod | ||
549 | def _ExtractInterpFromShebang(data): | ||
550 | """Extract the interpreter used in the shebang. | ||
551 | |||
552 | Try to locate the interpreter the script is using (ignoring `env`). | ||
553 | |||
554 | Args: | ||
555 | data: The file content of the script. | ||
556 | |||
557 | Returns: | ||
558 | The basename of the main script interpreter, or None if a shebang is not | ||
559 | used or could not be parsed out. | ||
560 | """ | ||
561 | firstline = data.splitlines()[:1] | ||
562 | if not firstline: | ||
563 | return None | ||
564 | |||
565 | # The format here can be tricky. | ||
566 | shebang = firstline[0].strip() | ||
567 | m = re.match(r'^#!\s*([^\s]+)(?:\s+([^\s]+))?', shebang) | ||
568 | if not m: | ||
569 | return None | ||
570 | |||
571 | # If the using `env`, find the target program. | ||
572 | interp = m.group(1) | ||
573 | if os.path.basename(interp) == 'env': | ||
574 | interp = m.group(2) | ||
575 | |||
576 | return interp | ||
577 | |||
578 | def _ExecuteHookViaReexec(self, interp, context, **kwargs): | ||
579 | """Execute the hook script through |interp|. | ||
580 | |||
581 | Note: Support for this feature should be dropped ~Jun 2021. | ||
582 | |||
583 | Args: | ||
584 | interp: The Python program to run. | ||
585 | context: Basic Python context to execute the hook inside. | ||
586 | kwargs: Arbitrary arguments to pass to the hook script. | ||
587 | |||
588 | Raises: | ||
589 | HookError: When the hooks failed for any reason. | ||
590 | """ | ||
591 | # This logic needs to be kept in sync with _ExecuteHookViaImport below. | ||
592 | script = """ | ||
593 | import json, os, sys | ||
594 | path = '''%(path)s''' | ||
595 | kwargs = json.loads('''%(kwargs)s''') | ||
596 | context = json.loads('''%(context)s''') | ||
597 | sys.path.insert(0, os.path.dirname(path)) | ||
598 | data = open(path).read() | ||
599 | exec(compile(data, path, 'exec'), context) | ||
600 | context['main'](**kwargs) | ||
601 | """ % { | ||
602 | 'path': self._script_fullpath, | ||
603 | 'kwargs': json.dumps(kwargs), | ||
604 | 'context': json.dumps(context), | ||
605 | } | ||
606 | |||
607 | # We pass the script via stdin to avoid OS argv limits. It also makes | ||
608 | # unhandled exception tracebacks less verbose/confusing for users. | ||
609 | cmd = [interp, '-c', 'import sys; exec(sys.stdin.read())'] | ||
610 | proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) | ||
611 | proc.communicate(input=script.encode('utf-8')) | ||
612 | if proc.returncode: | ||
613 | raise HookError('Failed to run %s hook.' % (self._hook_type,)) | ||
614 | |||
615 | def _ExecuteHookViaImport(self, data, context, **kwargs): | ||
616 | """Execute the hook code in |data| directly. | ||
617 | |||
618 | Args: | ||
619 | data: The code of the hook to execute. | ||
620 | context: Basic Python context to execute the hook inside. | ||
621 | kwargs: Arbitrary arguments to pass to the hook script. | ||
622 | |||
623 | Raises: | ||
624 | HookError: When the hooks failed for any reason. | ||
625 | """ | ||
626 | # Exec, storing global context in the context dict. We catch exceptions | ||
627 | # and convert to a HookError w/ just the failing traceback. | ||
628 | try: | ||
629 | exec(compile(data, self._script_fullpath, 'exec'), context) | ||
630 | except Exception: | ||
631 | raise HookError('%s\nFailed to import %s hook; see traceback above.' % | ||
632 | (traceback.format_exc(), self._hook_type)) | ||
633 | |||
634 | # Running the script should have defined a main() function. | ||
635 | if 'main' not in context: | ||
636 | raise HookError('Missing main() in: "%s"' % self._script_fullpath) | ||
637 | |||
638 | # Call the main function in the hook. If the hook should cause the | ||
639 | # build to fail, it will raise an Exception. We'll catch that convert | ||
640 | # to a HookError w/ just the failing traceback. | ||
641 | try: | ||
642 | context['main'](**kwargs) | ||
643 | except Exception: | ||
644 | raise HookError('%s\nFailed to run main() for %s hook; see traceback ' | ||
645 | 'above.' % (traceback.format_exc(), self._hook_type)) | ||
646 | |||
547 | def _ExecuteHook(self, **kwargs): | 647 | def _ExecuteHook(self, **kwargs): |
548 | """Actually execute the given hook. | 648 | """Actually execute the given hook. |
549 | 649 | ||
@@ -568,19 +668,8 @@ class RepoHook(object): | |||
568 | # hooks can't import repo files. | 668 | # hooks can't import repo files. |
569 | sys.path = [os.path.dirname(self._script_fullpath)] + sys.path[1:] | 669 | sys.path = [os.path.dirname(self._script_fullpath)] + sys.path[1:] |
570 | 670 | ||
571 | # Exec, storing global context in the context dict. We catch exceptions | 671 | # Initial global context for the hook to run within. |
572 | # and convert to a HookError w/ just the failing traceback. | ||
573 | context = {'__file__': self._script_fullpath} | 672 | context = {'__file__': self._script_fullpath} |
574 | try: | ||
575 | exec(compile(open(self._script_fullpath).read(), | ||
576 | self._script_fullpath, 'exec'), context) | ||
577 | except Exception: | ||
578 | raise HookError('%s\nFailed to import %s hook; see traceback above.' % | ||
579 | (traceback.format_exc(), self._hook_type)) | ||
580 | |||
581 | # Running the script should have defined a main() function. | ||
582 | if 'main' not in context: | ||
583 | raise HookError('Missing main() in: "%s"' % self._script_fullpath) | ||
584 | 673 | ||
585 | # Add 'hook_should_take_kwargs' to the arguments to be passed to main. | 674 | # Add 'hook_should_take_kwargs' to the arguments to be passed to main. |
586 | # We don't actually want hooks to define their main with this argument-- | 675 | # We don't actually want hooks to define their main with this argument-- |
@@ -592,15 +681,31 @@ class RepoHook(object): | |||
592 | kwargs = kwargs.copy() | 681 | kwargs = kwargs.copy() |
593 | kwargs['hook_should_take_kwargs'] = True | 682 | kwargs['hook_should_take_kwargs'] = True |
594 | 683 | ||
595 | # Call the main function in the hook. If the hook should cause the | 684 | # See what version of python the hook has been written against. |
596 | # build to fail, it will raise an Exception. We'll catch that convert | 685 | data = open(self._script_fullpath).read() |
597 | # to a HookError w/ just the failing traceback. | 686 | interp = self._ExtractInterpFromShebang(data) |
598 | try: | 687 | reexec = False |
599 | context['main'](**kwargs) | 688 | if interp: |
600 | except Exception: | 689 | prog = os.path.basename(interp) |
601 | raise HookError('%s\nFailed to run main() for %s hook; see traceback ' | 690 | if prog.startswith('python2') and sys.version_info.major != 2: |
602 | 'above.' % (traceback.format_exc(), | 691 | reexec = True |
603 | self._hook_type)) | 692 | elif prog.startswith('python3') and sys.version_info.major == 2: |
693 | reexec = True | ||
694 | |||
695 | # Attempt to execute the hooks through the requested version of Python. | ||
696 | if reexec: | ||
697 | try: | ||
698 | self._ExecuteHookViaReexec(interp, context, **kwargs) | ||
699 | except OSError as e: | ||
700 | if e.errno == errno.ENOENT: | ||
701 | # We couldn't find the interpreter, so fallback to importing. | ||
702 | reexec = False | ||
703 | else: | ||
704 | raise | ||
705 | |||
706 | # Run the hook by importing directly. | ||
707 | if not reexec: | ||
708 | self._ExecuteHookViaImport(data, context, **kwargs) | ||
604 | finally: | 709 | finally: |
605 | # Restore sys.path and CWD. | 710 | # Restore sys.path and CWD. |
606 | sys.path = orig_syspath | 711 | sys.path = orig_syspath |
diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 00000000..1d9cde45 --- /dev/null +++ b/tests/test_project.py | |||
@@ -0,0 +1,58 @@ | |||
1 | # -*- coding:utf-8 -*- | ||
2 | # | ||
3 | # Copyright (C) 2019 The Android Open Source Project | ||
4 | # | ||
5 | # Licensed under the Apache License, Version 2.0 (the "License"); | ||
6 | # you may not use this file except in compliance with the License. | ||
7 | # You may obtain a copy of the License at | ||
8 | # | ||
9 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
10 | # | ||
11 | # Unless required by applicable law or agreed to in writing, software | ||
12 | # distributed under the License is distributed on an "AS IS" BASIS, | ||
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
14 | # See the License for the specific language governing permissions and | ||
15 | # limitations under the License. | ||
16 | |||
17 | import unittest | ||
18 | |||
19 | import project | ||
20 | |||
21 | |||
22 | class RepoHookShebang(unittest.TestCase): | ||
23 | """Check shebang parsing in RepoHook.""" | ||
24 | |||
25 | def test_no_shebang(self): | ||
26 | """Lines w/out shebangs should be rejected.""" | ||
27 | DATA = ( | ||
28 | '', | ||
29 | '# -*- coding:utf-8 -*-\n', | ||
30 | '#\n# foo\n', | ||
31 | '# Bad shebang in script\n#!/foo\n' | ||
32 | ) | ||
33 | for data in DATA: | ||
34 | self.assertIsNone(project.RepoHook._ExtractInterpFromShebang(data)) | ||
35 | |||
36 | def test_direct_interp(self): | ||
37 | """Lines whose shebang points directly to the interpreter.""" | ||
38 | DATA = ( | ||
39 | ('#!/foo', '/foo'), | ||
40 | ('#! /foo', '/foo'), | ||
41 | ('#!/bin/foo ', '/bin/foo'), | ||
42 | ('#! /usr/foo ', '/usr/foo'), | ||
43 | ('#! /usr/foo -args', '/usr/foo'), | ||
44 | ) | ||
45 | for shebang, interp in DATA: | ||
46 | self.assertEqual(project.RepoHook._ExtractInterpFromShebang(shebang), | ||
47 | interp) | ||
48 | |||
49 | def test_env_interp(self): | ||
50 | """Lines whose shebang launches through `env`.""" | ||
51 | DATA = ( | ||
52 | ('#!/usr/bin/env foo', 'foo'), | ||
53 | ('#!/bin/env foo', 'foo'), | ||
54 | ('#! /bin/env /bin/foo ', '/bin/foo'), | ||
55 | ) | ||
56 | for shebang, interp in DATA: | ||
57 | self.assertEqual(project.RepoHook._ExtractInterpFromShebang(shebang), | ||
58 | interp) | ||