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) | ||
