summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMike Frysinger <vapier@google.com>2019-06-18 17:23:39 -0400
committerMike Frysinger <vapier@google.com>2019-07-27 01:10:40 +0000
commitf7c51606f0eae56f0cf52c54713f7816d8e9f97c (patch)
tree0ed377b68aa43d1440d738cb4fe176e08bca218d
parent745be2ede1e67421275afc00c04d996d9d6908ee (diff)
downloadgit-repo-f7c51606f0eae56f0cf52c54713f7816d8e9f97c.tar.gz
hooks: support external hooks running different Python version
As we convert repo to support Python 3, the version of Python that we use might not be the version that repo hooks users have written for. Since repo upgrades are not immediate, and not easily under direct control of end users (relative to the projects maintaining the hook code), allow hook authors to declare the version of Python that they want to use. Now repo will read the shebang from the hook script and compare it against the version of Python repo itself is running under. If they differ, we'll try to execute a separate instance of Python and have it load & execute the hook. If things are compatible, then we still use the inprocess execution logic that we have today. This allows repo hook users to upgrade on their own schedule (they could even upgrade to Python 3 ahead of us) without having to worry about their supported version being exactly in sync with repo's. Bug: https://crbug.com/gerrit/10418 Change-Id: I97c7c96b64fb2ee465c39b90e9bdcc76394a146a Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/228432 Reviewed-by: David Pursehouse <dpursehouse@collab.net> Tested-by: Mike Frysinger <vapier@google.com>
-rw-r--r--docs/python-support.md15
-rw-r--r--docs/repo-hooks.md25
-rwxr-xr-xproject.py147
-rw-r--r--tests/test_project.py58
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.
28If the system has an older version of Python 3, then users will have to select 28If the system has an older version of Python 3, then users will have to select
29the legacy Python 2 branch instead. 29the legacy Python 2 branch instead.
30 30
31### repo hooks
31 32
33Projects that use [repo hooks] run on independent schedules.
34They might migrate to Python 3 earlier or later than us.
35To support them, we'll probe the shebang of the hook script and if we find an
36interpreter in there that indicates a different version than repo is currently
37running under, we'll attempt to reexec ourselves under that.
38
39For example, a hook with a header like `#!/usr/bin/python2` will have repo
40execute `/usr/bin/python2` to execute the hook code specifically if repo is
41currently running Python 3.
42
43For 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
83the user. Although user interaction is discouraged in the common case, it can 83the user. Although user interaction is discouraged in the common case, it can
84be useful when deploying automatic fixes. 84be useful when deploying automatic fixes.
85 85
86### Shebang Handling
87
88*** note
89This is intended as a transitional feature. Hooks are expected to eventually
90migrate to Python 3 only as Python 2 is EOL & deprecated.
91***
92
93If the hook is written against a specific version of Python (either 2 or 3),
94the script can declare that explicitly. Repo will then attempt to execute it
95under the right version of Python regardless of the version repo itself might
96be executing under.
97
98Here 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
107If no shebang is detected, or does not match the forms above, we assume that the
108hook is compatible with both Python 2 & Python 3 as if `#!/usr/bin/python` was
109used.
110
86## Hooks 111## Hooks
87 112
88Here are all the points available for hooking. 113Here are all the points available for hooking.
diff --git a/project.py b/project.py
index 9702e9da..58942514 100755
--- a/project.py
+++ b/project.py
@@ -18,6 +18,7 @@ from __future__ import print_function
18import errno 18import errno
19import filecmp 19import filecmp
20import glob 20import glob
21import json
21import os 22import os
22import random 23import random
23import re 24import 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 = """
593import json, os, sys
594path = '''%(path)s'''
595kwargs = json.loads('''%(kwargs)s''')
596context = json.loads('''%(context)s''')
597sys.path.insert(0, os.path.dirname(path))
598data = open(path).read()
599exec(compile(data, path, 'exec'), context)
600context['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
17import unittest
18
19import project
20
21
22class 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)