Refactor eclipse/project.py to make it testable
Move all the logic of tools/eclipse/project.py so it can be tested and
add a few tests.
Add a py_test target so one can run the tests from bazel using:
bazel test --test_output=streamed //tools/eclipse:test
Add a bunch of tests and a helper to generate a coverage report. The
resulting coverage report can be checked by opening
`tools/eclipse/htmlcov/project_py.html`.
Change-Id: I03582d70a5ec28117ab6d3b4c098d84e12e3d8dc
diff --git a/tools/eclipse/.gitignore b/tools/eclipse/.gitignore
new file mode 100644
index 0000000..1bc5aa1
--- /dev/null
+++ b/tools/eclipse/.gitignore
@@ -0,0 +1 @@
+/.coverage
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 789a607..451f22d 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -1,4 +1,4 @@
-load("@rules_python//python:defs.bzl", "py_binary")
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
package(
default_visibility = ["//visibility:public"],
@@ -9,3 +9,16 @@
srcs = ["project.py"],
main = "project.py",
)
+
+py_test(
+ name = "test_project",
+ srcs = ["test_project.py"],
+ deps = [":project"],
+)
+
+test_suite(
+ name = "test",
+ tests = [
+ "test_project",
+ ]
+)
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index a24266f..93eeb3a 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -28,77 +28,98 @@
'JavaSE-11',
])
-opts = argparse.ArgumentParser("Create Eclipse Project")
-opts.add_argument('-r', '--root', help='Root directory entry', required=True)
-opts.add_argument('-n', '--name', help='Project name')
-opts.add_argument('-x', '--exclude', action='append', help='Exclude paths')
-opts.add_argument('-b', '--batch', action='store_true',
- dest='batch', help='Bazel batch option')
-opts.add_argument('--bazel',
- help=('name of the bazel executable. Defaults to using'
- ' bazelisk if found, or bazel if bazelisk is not'
- ' found.'),
- action='store', default=None, dest='bazel_exe')
-args = opts.parse_args()
+class EclipseProject():
-root = args.root
-ROOT = os.path.abspath(root)
-while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')):
- if ROOT == '/':
- print('Could not find root of project: no WORKSPACE file found',
- file=sys.stderr)
- sys.exit(1)
- ROOT = os.path.dirname(ROOT)
+ # Path to the Bazel/Bazelisk binary
+ bazel_exe = None
+ # Root of the Bazel project (holding WORKSPACE file)
+ ROOT = None
-batch_option = '--batch' if args.batch else None
+ def main(self):
+ self.bazel_exe = self.find_bazel(self.args.bazel_exe)
-def find_bazel():
- if args.bazel_exe:
+ self.ROOT = self.find_root(self.args.root)
+
+ project_name = (self.args.name if self.args.name
+ else os.path.basename(self.ROOT))
+
+ self.gen_project(project_name, root=self.ROOT)
+ self.gen_classpath(self.retrieve_ext_location().decode('utf-8'))
+
+ def _get_argument_parser(self):
+ opts = argparse.ArgumentParser("Create Eclipse Project")
+ opts.add_argument('-r', '--root', help='Root directory entry',
+ required=True)
+ opts.add_argument('-n', '--name', help='Project name')
+ opts.add_argument('-x', '--exclude', action='append', help='Exclude paths')
+ opts.add_argument('-b', '--batch', action='store_true',
+ dest='batch', help='Bazel batch option')
+ opts.add_argument('--bazel',
+ help=('name of the bazel executable. Defaults to using'
+ ' bazelisk if found, or bazel if bazelisk is not'
+ ' found.'),
+ action='store', default=None, dest='bazel_exe')
+ return opts
+
+ def parse_args(self, args):
+ self.args = self._get_argument_parser().parse_args(args)
+ return self.args
+
+ def find_root(self, root):
+ ROOT = os.path.abspath(root)
+ while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')):
+ if ROOT == '/':
+ raise Exception(
+ 'Could not find root of project: no WORKSPACE file found')
+ ROOT = os.path.dirname(ROOT)
+ return ROOT
+
+ def find_bazel(self, bazel_exe=None):
+ if bazel_exe:
+ try:
+ return subprocess.check_output(
+ ['which', bazel_exe]).strip().decode('UTF-8')
+ except subprocess.CalledProcessError:
+ raise Exception('Bazel command: %s not found' % bazel_exe)
try:
return subprocess.check_output(
- ['which', args.bazel_exe]).strip().decode('UTF-8')
+ ['which', 'bazelisk']).strip().decode('UTF-8')
except subprocess.CalledProcessError:
- print('Bazel command: %s not found' % args.bazel_exe, file=sys.stderr)
- sys.exit(1)
- try:
+ try:
+ return subprocess.check_output(
+ ['which', 'bazel']).strip().decode('UTF-8')
+ except subprocess.CalledProcessError:
+ raise Exception(
+ "Neither bazelisk nor bazel found. Please see"
+ " Documentation/dev-bazel for instructions on installing"
+ " one of them.")
+
+ def _build_bazel_cmd(self, *args):
+ cmd = [self.bazel_exe]
+ if self.args.batch:
+ cmd.append('--batch')
+ for arg in args:
+ cmd.append(arg)
+ return cmd
+
+ def retrieve_ext_location(self):
return subprocess.check_output(
- ['which', 'bazelisk']).strip().decode('UTF-8')
- except subprocess.CalledProcessError:
+ self._build_bazel_cmd('info', 'output_base')).strip()
+
+ def _query_classpath(self):
+ t = '//tools/eclipse:main_classpath_collect'
try:
- return subprocess.check_output(
- ['which', 'bazel']).strip().decode('UTF-8')
+ cmd = self._build_bazel_cmd('build', t)
+ subprocess.check_call(cmd)
except subprocess.CalledProcessError:
- print("Neither bazelisk nor bazel found. Please see"
- " Documentation/dev-bazel for instructions on installing"
- " one of them.")
- sys.exit(1)
+ raise Exception("Could not query classpath with:" % cmd)
+ name = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + '.runtime_classpath'
+ return [line.rstrip('\n') for line in open(name)]
-bazel_exe = find_bazel()
-
-def _build_bazel_cmd(*args):
- cmd = [bazel_exe]
- if batch_option:
- cmd.append('--batch')
- for arg in args:
- cmd.append(arg)
- return cmd
-
-def retrieve_ext_location():
- return subprocess.check_output(_build_bazel_cmd('info', 'output_base')).strip()
-
-def _query_classpath():
- t = '//tools/eclipse:main_classpath_collect'
- try:
- subprocess.check_call(_build_bazel_cmd('build', t))
- except subprocess.CalledProcessError:
- exit(1)
- name = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + '.runtime_classpath'
- return [line.rstrip('\n') for line in open(name)]
-
-def gen_project(name, root=ROOT):
- p = os.path.join(root, '.project')
- with open(p, 'w') as fd:
- print("""\
+ def gen_project(name, root):
+ p = os.path.join(root, '.project')
+ with open(p, 'w') as fd:
+ print("""\
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>%(name)s</name>
@@ -113,107 +134,111 @@
</projectDescription>\
""" % {"name": name}, file=fd)
-def gen_classpath(ext):
- def make_classpath():
- impl = xml.dom.minidom.getDOMImplementation()
- return impl.createDocument(None, 'classpath', None)
+ def gen_classpath(self, ext):
+ def make_classpath():
+ impl = xml.dom.minidom.getDOMImplementation()
+ return impl.createDocument(None, 'classpath', None)
- def classpathentry(kind, path, src=None, out=None, exported=None):
- e = doc.createElement('classpathentry')
- e.setAttribute('kind', kind)
- # TODO(davido): Remove this and other exclude BUILD files hack
- # when this Bazel bug is fixed:
- # https://github.com/bazelbuild/bazel/issues/1083
- if kind == 'src':
- e.setAttribute('excluding', '**/BUILD')
- e.setAttribute('path', path)
- if src:
- e.setAttribute('sourcepath', src)
- if out:
- e.setAttribute('output', out)
- if exported:
- e.setAttribute('exported', 'true')
- doc.documentElement.appendChild(e)
-
- doc = make_classpath()
- src = set()
- lib = set()
-
- java_library = re.compile('bazel-out/(?:.*)-fastbuild/bin(.*)/[^/]+[.]jar$')
- srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar')
- for p in _query_classpath():
- m = java_library.match(p)
- if m:
- src.add(m.group(1).lstrip('/'))
- else:
- if ext is not None and p.startswith("external"):
- p = os.path.join(ext, p)
- lib.add(p)
-
- src_paths = {}
- for s in sorted(src):
- out = None
-
- if s.startswith('lib/'):
- out = 'eclipse-out/lib'
-
- p = os.path.join(s, 'java')
- if os.path.exists(p):
- classpathentry('src', p, out=out)
- continue
-
- for env in ['main', 'test', 'java', 'javatests']:
- o = None
+ def classpathentry(kind, path, src=None, out=None, exported=None):
+ e = doc.createElement('classpathentry')
+ e.setAttribute('kind', kind)
+ # TODO(davido): Remove this and other exclude BUILD files hack
+ # when this Bazel bug is fixed:
+ # https://github.com/bazelbuild/bazel/issues/1083
+ if kind == 'src':
+ e.setAttribute('excluding', '**/BUILD')
+ e.setAttribute('path', path)
+ if src:
+ e.setAttribute('sourcepath', src)
if out:
- o = out + '/' + env
- elif env == 'test' or env == 'javatests':
- o = 'eclipse-out/test'
+ e.setAttribute('output', out)
+ if exported:
+ e.setAttribute('exported', 'true')
+ doc.documentElement.appendChild(e)
- if s.startswith(env + '/'):
- src_paths[env] = o
- continue
+ doc = make_classpath()
+ src = set()
+ lib = set()
- for srctype in ['java', 'resources']:
- p = os.path.join(s, 'src', env, srctype)
- if os.path.exists(p):
- src_paths[p] = o
-
- for s in src_paths:
- classpathentry('src', s, out=src_paths[s])
-
- for libs in [lib]:
- for j in sorted(libs):
- if excluded(j):
- continue
- s = None
- m = srcs.match(j)
+ java_library = re.compile('bazel-out/(?:.*)-fastbuild/bin(.*)/[^/]+[.]jar$')
+ srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar')
+ for p in self._query_classpath():
+ m = java_library.match(p)
if m:
- prefix = m.group(1)
- suffix = m.group(2)
- p = os.path.join(prefix, "src", "%s-src.jar" % suffix)
- if os.path.exists(p):
- s = p
- classpathentry('lib', j, s)
+ src.add(m.group(1).lstrip('/'))
+ else:
+ if ext is not None and p.startswith("external"):
+ p = os.path.join(ext, p)
+ lib.add(p)
- classpathentry('con', JRE)
- classpathentry('output', 'eclipse-out/classes')
+ src_paths = {}
+ for s in sorted(src):
+ out = None
- p = os.path.join(ROOT, '.classpath')
- with open(p, 'w') as fd:
- doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
+ if s.startswith('lib/'):
+ out = 'eclipse-out/lib'
-def excluded(lib):
- if args.exclude:
- for x in args.exclude:
- if x in lib:
- return True
- return False
+ p = os.path.join(s, 'java')
+ if os.path.exists(p):
+ classpathentry('src', p, out=out)
+ continue
-try:
- name = args.name if args.name else os.path.basename(ROOT)
- gen_project(name)
- gen_classpath(retrieve_ext_location().decode('utf-8'))
+ for env in ['main', 'test', 'java', 'javatests']:
+ o = None
+ if out:
+ o = out + '/' + env
+ elif env == 'test' or env == 'javatests':
+ o = 'eclipse-out/test'
-except KeyboardInterrupt:
- print('Interrupted by user', file=sys.stderr)
- exit(1)
+ if s.startswith(env + '/'):
+ src_paths[env] = o
+ continue
+
+ for srctype in ['java', 'resources']:
+ p = os.path.join(s, 'src', env, srctype)
+ if os.path.exists(p):
+ src_paths[p] = o
+
+ for s in src_paths:
+ classpathentry('src', s, out=src_paths[s])
+
+ for libs in [lib]:
+ for j in sorted(libs):
+ if self.excluded(j):
+ continue
+ s = None
+ m = srcs.match(j)
+ if m:
+ prefix = m.group(1)
+ suffix = m.group(2)
+ p = os.path.join(prefix, "src", "%s-src.jar" % suffix)
+ if os.path.exists(p):
+ s = p
+ classpathentry('lib', j, s)
+
+ classpathentry('con', JRE)
+ classpathentry('output', 'eclipse-out/classes')
+
+ p = os.path.join(self.ROOT, '.classpath')
+ with open(p, 'w') as fd:
+ doc.writexml(fd, addindent='\t', newl='\n', encoding='UTF-8')
+
+ def excluded(self, lib):
+ if self.args.exclude:
+ for x in self.args.exclude:
+ if x in lib:
+ return True
+ return False
+
+def main():
+ try:
+ ec = EclipseProject()
+ ec.parse_args(args=sys.argv[1:])
+ ec.main()
+ except KeyboardInterrupt:
+ print('Interrupted by user', file=sys.stderr)
+ exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/eclipse/test_project.py b/tools/eclipse/test_project.py
new file mode 100755
index 0000000..be73474
--- /dev/null
+++ b/tools/eclipse/test_project.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+#
+# You can run tests using:
+#
+# bazel test --test_output=streamed //tools/eclipse:test
+#
+# Or directly:
+#
+# ./tools/eclipse/test_project.py
+#
+
+from io import StringIO
+import unittest
+from unittest.mock import call, patch
+from subprocess import CalledProcessError
+
+import project
+from project import EclipseProject
+
+class EclipseProjectTestCase(unittest.TestCase):
+
+ @patch.object(project, 'EclipseProject')
+ def test_script_entrypoint(self, ep):
+ with patch.object(project.sys, 'argv', ['project.py', '-r', '/dev/null']):
+ project.main()
+ ep.assert_has_calls([
+ call(),
+ call().parse_args(args=['-r', '/dev/null']),
+ call().main(),
+ ])
+
+ @patch('sys.stderr', new_callable=StringIO)
+ def test_script_entrypoint_handles_control_c(self, stderr):
+
+ with self.assertRaises(SystemExit) as c:
+ with patch.object(project.EclipseProject, 'parse_args',
+ side_effect=KeyboardInterrupt):
+ project.main()
+
+ self.assertIn('Interrupted by user\n', stderr.getvalue())
+ self.assertEquals(c.exception.code, 1)
+
+ @patch('sys.stderr', new_callable=StringIO)
+ def test_requires_root_option(self, stderr):
+ with self.assertRaises(SystemExit) as c:
+ ep = EclipseProject()
+ ep.parse_args([])
+ self.assertEqual(c.exception.code, 2)
+ self.assertIn(
+ 'the following arguments are required: -r/--root',
+ stderr.getvalue()
+ )
+
+ def test_batch_option_is_passed_to_bazel(self):
+ ep = EclipseProject()
+ ep.parse_args(['-r', '/dev/null'])
+ ep.bazel_exe = 'my_bazel'
+ self.assertEquals(ep._build_bazel_cmd(), ['my_bazel'])
+
+ ep.parse_args(['-r', '/dev/null', '--batch'])
+ self.assertEquals(ep._build_bazel_cmd(), ['my_bazel', '--batch'])
+
+ def test_find_root_raises_when_no_WORKSPACE_found(self):
+ with patch('os.path.exists') as exists:
+ exists.return_value = False
+ with self.assertRaises(Exception):
+ EclipseProject().find_root('/tmp/path/to')
+ exists.assert_has_calls([
+ call('/tmp/path/to/WORKSPACE'),
+ call('/tmp/path/WORKSPACE'),
+ call('/tmp/WORKSPACE'),
+ ])
+
+ def test_find_root_in_grandparent_directory(self):
+ with patch('os.path.exists') as exists:
+ exists.side_effect = [False, False, True, False]
+ root = EclipseProject().find_root('/tmp/path/to/foo')
+ exists.assert_has_calls([
+ call('/tmp/path/to/foo/WORKSPACE'),
+ call('/tmp/path/to/WORKSPACE'),
+ call('/tmp/path/WORKSPACE'),
+ ])
+ self.assertEqual('/tmp/path', root)
+
+ @patch('subprocess.check_output')
+ def test_find_bazel_finds_bazelisk_first(self, check_output):
+ check_output.return_value = b'/path/to/bazelisk'
+ self.assertEqual(EclipseProject().find_bazel(), '/path/to/bazelisk')
+
+ @patch('subprocess.check_output')
+ def test_find_bazel_fallback_to_bazel(self, check_output):
+ check_output.side_effect = [
+ CalledProcessError(1, 'which bazelisk',
+ '', '[TEST] bazelisk not found'),
+ b'/path/to/bazel',
+ ]
+ self.assertEqual(EclipseProject().find_bazel(), '/path/to/bazel')
+
+ @patch('subprocess.check_output')
+ def test_find_bazel_raise_without_bazel_and_bazelisk(self, check_output):
+ check_output.side_effect = [
+ CalledProcessError(1, 'which bazelisk',
+ '', '[TEST] bazelisk not found'),
+ CalledProcessError(1, 'which bazel',
+ '', '[TEST] bazel not found'),
+ ]
+ with self.assertRaisesRegex(Exception, "Neither bazelisk nor bazel found"):
+ EclipseProject().find_bazel()
+
+ @patch('subprocess.check_output')
+ def test_find_given_existing_bazel_exe(self, check_output):
+ check_output.return_value = b'/path/to/bin/mybazel'
+ self.assertEqual(EclipseProject().find_bazel('my_bazel'),
+ '/path/to/bin/mybazel')
+
+ @patch('subprocess.check_output')
+ def test_find_given_not_existing_bazel_exe(self, check_output):
+ check_output.side_effect = CalledProcessError(
+ 1, 'which mybazel', '', '[TEST] mybazel not found')
+ with self.assertRaisesRegex(Exception, 'Bazel command: mybazel not found'):
+ EclipseProject().find_bazel('mybazel')
+
+ @patch('subprocess.check_output')
+ def test_retrieve_ext_location_strips_newline(self, check_output):
+ check_output.return_value = '/path/to/.cache/bazel/xxxx\n'
+ ep = EclipseProject()
+ ep.parse_args(['-r' '/dev/null', '--bazel', 'my_bazel'])
+ assert not ep.retrieve_ext_location().endswith('\n')
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/tools/eclipse/test_project_coverage b/tools/eclipse/test_project_coverage
new file mode 100755
index 0000000..fa3e819
--- /dev/null
+++ b/tools/eclipse/test_project_coverage
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+COVERAGE="python3 -m coverage"
+
+$COVERAGE run --branch --include=project.py test_project.py
+$COVERAGE html