diff options
Diffstat (limited to 'python/mozbuild/mozbuild/test/frontend/test_context.py')
-rw-r--r-- | python/mozbuild/mozbuild/test/frontend/test_context.py | 736 |
1 files changed, 736 insertions, 0 deletions
diff --git a/python/mozbuild/mozbuild/test/frontend/test_context.py b/python/mozbuild/mozbuild/test/frontend/test_context.py new file mode 100644 index 0000000000..fbf35e1c8c --- /dev/null +++ b/python/mozbuild/mozbuild/test/frontend/test_context.py @@ -0,0 +1,736 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import unittest + +import six +from mozpack import path as mozpath +from mozunit import main + +from mozbuild.frontend.context import ( + FUNCTIONS, + SPECIAL_VARIABLES, + SUBCONTEXTS, + VARIABLES, + AbsolutePath, + Context, + ContextDerivedTypedHierarchicalStringList, + ContextDerivedTypedList, + ContextDerivedTypedListWithItems, + ContextDerivedTypedRecord, + Files, + ObjDirPath, + Path, + SourcePath, +) +from mozbuild.util import StrictOrderingOnAppendListWithFlagsFactory + + +class TestContext(unittest.TestCase): + def test_defaults(self): + test = Context( + { + "foo": (int, int, ""), + "bar": (bool, bool, ""), + "baz": (dict, dict, ""), + } + ) + + self.assertEqual(list(test), []) + + self.assertEqual(test["foo"], 0) + + self.assertEqual(set(test.keys()), {"foo"}) + + self.assertEqual(test["bar"], False) + + self.assertEqual(set(test.keys()), {"foo", "bar"}) + + self.assertEqual(test["baz"], {}) + + self.assertEqual(set(test.keys()), {"foo", "bar", "baz"}) + + with self.assertRaises(KeyError): + test["qux"] + + self.assertEqual(set(test.keys()), {"foo", "bar", "baz"}) + + def test_type_check(self): + test = Context( + { + "foo": (int, int, ""), + "baz": (dict, list, ""), + } + ) + + test["foo"] = 5 + + self.assertEqual(test["foo"], 5) + + with self.assertRaises(ValueError): + test["foo"] = {} + + self.assertEqual(test["foo"], 5) + + with self.assertRaises(KeyError): + test["bar"] = True + + test["baz"] = [("a", 1), ("b", 2)] + + self.assertEqual(test["baz"], {"a": 1, "b": 2}) + + def test_update(self): + test = Context( + { + "foo": (int, int, ""), + "bar": (bool, bool, ""), + "baz": (dict, list, ""), + } + ) + + self.assertEqual(list(test), []) + + with self.assertRaises(ValueError): + test.update(bar=True, foo={}) + + self.assertEqual(list(test), []) + + test.update(bar=True, foo=1) + + self.assertEqual(set(test.keys()), {"foo", "bar"}) + self.assertEqual(test["foo"], 1) + self.assertEqual(test["bar"], True) + + test.update([("bar", False), ("foo", 2)]) + self.assertEqual(test["foo"], 2) + self.assertEqual(test["bar"], False) + + test.update([("foo", 0), ("baz", {"a": 1, "b": 2})]) + self.assertEqual(test["foo"], 0) + self.assertEqual(test["baz"], {"a": 1, "b": 2}) + + test.update([("foo", 42), ("baz", [("c", 3), ("d", 4)])]) + self.assertEqual(test["foo"], 42) + self.assertEqual(test["baz"], {"c": 3, "d": 4}) + + def test_context_paths(self): + test = Context() + + # Newly created context has no paths. + self.assertIsNone(test.main_path) + self.assertIsNone(test.current_path) + self.assertEqual(test.all_paths, set()) + self.assertEqual(test.source_stack, []) + + foo = os.path.abspath("foo") + test.add_source(foo) + + # Adding the first source makes it the main and current path. + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, foo) + self.assertEqual(test.all_paths, set([foo])) + self.assertEqual(test.source_stack, [foo]) + + bar = os.path.abspath("bar") + test.add_source(bar) + + # Adding the second source makes leaves main and current paths alone. + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, foo) + self.assertEqual(test.all_paths, set([bar, foo])) + self.assertEqual(test.source_stack, [foo]) + + qux = os.path.abspath("qux") + test.push_source(qux) + + # Pushing a source makes it the current path + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, qux) + self.assertEqual(test.all_paths, set([bar, foo, qux])) + self.assertEqual(test.source_stack, [foo, qux]) + + hoge = os.path.abspath("hoge") + test.push_source(hoge) + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, hoge) + self.assertEqual(test.all_paths, set([bar, foo, hoge, qux])) + self.assertEqual(test.source_stack, [foo, qux, hoge]) + + fuga = os.path.abspath("fuga") + + # Adding a source after pushing doesn't change the source stack + test.add_source(fuga) + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, hoge) + self.assertEqual(test.all_paths, set([bar, foo, fuga, hoge, qux])) + self.assertEqual(test.source_stack, [foo, qux, hoge]) + + # Adding a source twice doesn't change anything + test.add_source(qux) + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, hoge) + self.assertEqual(test.all_paths, set([bar, foo, fuga, hoge, qux])) + self.assertEqual(test.source_stack, [foo, qux, hoge]) + + last = test.pop_source() + + # Popping a source returns the last pushed one, not the last added one. + self.assertEqual(last, hoge) + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, qux) + self.assertEqual(test.all_paths, set([bar, foo, fuga, hoge, qux])) + self.assertEqual(test.source_stack, [foo, qux]) + + last = test.pop_source() + self.assertEqual(last, qux) + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, foo) + self.assertEqual(test.all_paths, set([bar, foo, fuga, hoge, qux])) + self.assertEqual(test.source_stack, [foo]) + + # Popping the main path is allowed. + last = test.pop_source() + self.assertEqual(last, foo) + self.assertEqual(test.main_path, foo) + self.assertIsNone(test.current_path) + self.assertEqual(test.all_paths, set([bar, foo, fuga, hoge, qux])) + self.assertEqual(test.source_stack, []) + + # Popping past the main path asserts. + with self.assertRaises(AssertionError): + test.pop_source() + + # Pushing after the main path was popped asserts. + with self.assertRaises(AssertionError): + test.push_source(foo) + + test = Context() + test.push_source(foo) + test.push_source(bar) + + # Pushing the same file twice is allowed. + test.push_source(bar) + test.push_source(foo) + self.assertEqual(last, foo) + self.assertEqual(test.main_path, foo) + self.assertEqual(test.current_path, foo) + self.assertEqual(test.all_paths, set([bar, foo])) + self.assertEqual(test.source_stack, [foo, bar, bar, foo]) + + def test_context_dirs(self): + class Config(object): + pass + + config = Config() + config.topsrcdir = mozpath.abspath(os.curdir) + config.topobjdir = mozpath.abspath("obj") + test = Context(config=config) + foo = mozpath.abspath("foo") + test.push_source(foo) + + self.assertEqual(test.srcdir, config.topsrcdir) + self.assertEqual(test.relsrcdir, "") + self.assertEqual(test.objdir, config.topobjdir) + self.assertEqual(test.relobjdir, "") + + foobar = os.path.abspath("foo/bar") + test.push_source(foobar) + self.assertEqual(test.srcdir, mozpath.join(config.topsrcdir, "foo")) + self.assertEqual(test.relsrcdir, "foo") + self.assertEqual(test.objdir, config.topobjdir) + self.assertEqual(test.relobjdir, "") + + +class TestSymbols(unittest.TestCase): + def _verify_doc(self, doc): + # Documentation should be of the format: + # """SUMMARY LINE + # + # EXTRA PARAGRAPHS + # """ + + self.assertNotIn("\r", doc) + + lines = doc.split("\n") + + # No trailing whitespace. + for line in lines[0:-1]: + self.assertEqual(line, line.rstrip()) + + self.assertGreater(len(lines), 0) + self.assertGreater(len(lines[0].strip()), 0) + + # Last line should be empty. + self.assertEqual(lines[-1].strip(), "") + + def test_documentation_formatting(self): + for typ, inp, doc in VARIABLES.values(): + self._verify_doc(doc) + + for attr, args, doc in FUNCTIONS.values(): + self._verify_doc(doc) + + for func, typ, doc in SPECIAL_VARIABLES.values(): + self._verify_doc(doc) + + for name, cls in SUBCONTEXTS.items(): + self._verify_doc(cls.__doc__) + + for name, v in cls.VARIABLES.items(): + self._verify_doc(v[2]) + + +class TestPaths(unittest.TestCase): + @classmethod + def setUpClass(cls): + class Config(object): + pass + + cls.config = config = Config() + config.topsrcdir = mozpath.abspath(os.curdir) + config.topobjdir = mozpath.abspath("obj") + + def test_path(self): + config = self.config + ctxt1 = Context(config=config) + ctxt1.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + ctxt2 = Context(config=config) + ctxt2.push_source(mozpath.join(config.topsrcdir, "bar", "moz.build")) + + path1 = Path(ctxt1, "qux") + self.assertIsInstance(path1, SourcePath) + self.assertEqual(path1, "qux") + self.assertEqual(path1.full_path, mozpath.join(config.topsrcdir, "foo", "qux")) + + path2 = Path(ctxt2, "../foo/qux") + self.assertIsInstance(path2, SourcePath) + self.assertEqual(path2, "../foo/qux") + self.assertEqual(path2.full_path, mozpath.join(config.topsrcdir, "foo", "qux")) + + self.assertEqual(path1, path2) + + self.assertEqual( + path1.join("../../bar/qux").full_path, + mozpath.join(config.topsrcdir, "bar", "qux"), + ) + + path1 = Path(ctxt1, "/qux/qux") + self.assertIsInstance(path1, SourcePath) + self.assertEqual(path1, "/qux/qux") + self.assertEqual(path1.full_path, mozpath.join(config.topsrcdir, "qux", "qux")) + + path2 = Path(ctxt2, "/qux/qux") + self.assertIsInstance(path2, SourcePath) + self.assertEqual(path2, "/qux/qux") + self.assertEqual(path2.full_path, mozpath.join(config.topsrcdir, "qux", "qux")) + + self.assertEqual(path1, path2) + + path1 = Path(ctxt1, "!qux") + self.assertIsInstance(path1, ObjDirPath) + self.assertEqual(path1, "!qux") + self.assertEqual(path1.full_path, mozpath.join(config.topobjdir, "foo", "qux")) + + path2 = Path(ctxt2, "!../foo/qux") + self.assertIsInstance(path2, ObjDirPath) + self.assertEqual(path2, "!../foo/qux") + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "foo", "qux")) + + self.assertEqual(path1, path2) + + path1 = Path(ctxt1, "!/qux/qux") + self.assertIsInstance(path1, ObjDirPath) + self.assertEqual(path1, "!/qux/qux") + self.assertEqual(path1.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + path2 = Path(ctxt2, "!/qux/qux") + self.assertIsInstance(path2, ObjDirPath) + self.assertEqual(path2, "!/qux/qux") + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + self.assertEqual(path1, path2) + + path1 = Path(ctxt1, path1) + self.assertIsInstance(path1, ObjDirPath) + self.assertEqual(path1, "!/qux/qux") + self.assertEqual(path1.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + path2 = Path(ctxt2, path2) + self.assertIsInstance(path2, ObjDirPath) + self.assertEqual(path2, "!/qux/qux") + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + self.assertEqual(path1, path2) + + path1 = Path(path1) + self.assertIsInstance(path1, ObjDirPath) + self.assertEqual(path1, "!/qux/qux") + self.assertEqual(path1.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + self.assertEqual(path1, path2) + + path2 = Path(path2) + self.assertIsInstance(path2, ObjDirPath) + self.assertEqual(path2, "!/qux/qux") + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + self.assertEqual(path1, path2) + + def test_source_path(self): + config = self.config + ctxt = Context(config=config) + ctxt.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + + path = SourcePath(ctxt, "qux") + self.assertEqual(path, "qux") + self.assertEqual(path.full_path, mozpath.join(config.topsrcdir, "foo", "qux")) + self.assertEqual(path.translated, mozpath.join(config.topobjdir, "foo", "qux")) + + path = SourcePath(ctxt, "../bar/qux") + self.assertEqual(path, "../bar/qux") + self.assertEqual(path.full_path, mozpath.join(config.topsrcdir, "bar", "qux")) + self.assertEqual(path.translated, mozpath.join(config.topobjdir, "bar", "qux")) + + path = SourcePath(ctxt, "/qux/qux") + self.assertEqual(path, "/qux/qux") + self.assertEqual(path.full_path, mozpath.join(config.topsrcdir, "qux", "qux")) + self.assertEqual(path.translated, mozpath.join(config.topobjdir, "qux", "qux")) + + with self.assertRaises(ValueError): + SourcePath(ctxt, "!../bar/qux") + + with self.assertRaises(ValueError): + SourcePath(ctxt, "!/qux/qux") + + path = SourcePath(path) + self.assertIsInstance(path, SourcePath) + self.assertEqual(path, "/qux/qux") + self.assertEqual(path.full_path, mozpath.join(config.topsrcdir, "qux", "qux")) + self.assertEqual(path.translated, mozpath.join(config.topobjdir, "qux", "qux")) + + path = Path(path) + self.assertIsInstance(path, SourcePath) + + def test_objdir_path(self): + config = self.config + ctxt = Context(config=config) + ctxt.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + + path = ObjDirPath(ctxt, "!qux") + self.assertEqual(path, "!qux") + self.assertEqual(path.full_path, mozpath.join(config.topobjdir, "foo", "qux")) + + path = ObjDirPath(ctxt, "!../bar/qux") + self.assertEqual(path, "!../bar/qux") + self.assertEqual(path.full_path, mozpath.join(config.topobjdir, "bar", "qux")) + + path = ObjDirPath(ctxt, "!/qux/qux") + self.assertEqual(path, "!/qux/qux") + self.assertEqual(path.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + with self.assertRaises(ValueError): + path = ObjDirPath(ctxt, "../bar/qux") + + with self.assertRaises(ValueError): + path = ObjDirPath(ctxt, "/qux/qux") + + path = ObjDirPath(path) + self.assertIsInstance(path, ObjDirPath) + self.assertEqual(path, "!/qux/qux") + self.assertEqual(path.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + path = Path(path) + self.assertIsInstance(path, ObjDirPath) + + def test_absolute_path(self): + config = self.config + ctxt = Context(config=config) + ctxt.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + + path = AbsolutePath(ctxt, "%/qux") + self.assertEqual(path, "%/qux") + self.assertEqual(path.full_path, "/qux") + + with self.assertRaises(ValueError): + path = AbsolutePath(ctxt, "%qux") + + def test_path_with_mixed_contexts(self): + config = self.config + ctxt1 = Context(config=config) + ctxt1.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + ctxt2 = Context(config=config) + ctxt2.push_source(mozpath.join(config.topsrcdir, "bar", "moz.build")) + + path1 = Path(ctxt1, "qux") + path2 = Path(ctxt2, path1) + self.assertEqual(path2, path1) + self.assertEqual(path2, "qux") + self.assertEqual(path2.context, ctxt1) + self.assertEqual(path2.full_path, mozpath.join(config.topsrcdir, "foo", "qux")) + + path1 = Path(ctxt1, "../bar/qux") + path2 = Path(ctxt2, path1) + self.assertEqual(path2, path1) + self.assertEqual(path2, "../bar/qux") + self.assertEqual(path2.context, ctxt1) + self.assertEqual(path2.full_path, mozpath.join(config.topsrcdir, "bar", "qux")) + + path1 = Path(ctxt1, "/qux/qux") + path2 = Path(ctxt2, path1) + self.assertEqual(path2, path1) + self.assertEqual(path2, "/qux/qux") + self.assertEqual(path2.context, ctxt1) + self.assertEqual(path2.full_path, mozpath.join(config.topsrcdir, "qux", "qux")) + + path1 = Path(ctxt1, "!qux") + path2 = Path(ctxt2, path1) + self.assertEqual(path2, path1) + self.assertEqual(path2, "!qux") + self.assertEqual(path2.context, ctxt1) + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "foo", "qux")) + + path1 = Path(ctxt1, "!../bar/qux") + path2 = Path(ctxt2, path1) + self.assertEqual(path2, path1) + self.assertEqual(path2, "!../bar/qux") + self.assertEqual(path2.context, ctxt1) + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "bar", "qux")) + + path1 = Path(ctxt1, "!/qux/qux") + path2 = Path(ctxt2, path1) + self.assertEqual(path2, path1) + self.assertEqual(path2, "!/qux/qux") + self.assertEqual(path2.context, ctxt1) + self.assertEqual(path2.full_path, mozpath.join(config.topobjdir, "qux", "qux")) + + def test_path_typed_list(self): + config = self.config + ctxt1 = Context(config=config) + ctxt1.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + ctxt2 = Context(config=config) + ctxt2.push_source(mozpath.join(config.topsrcdir, "bar", "moz.build")) + + paths = [ + "!../bar/qux", + "!/qux/qux", + "!qux", + "../bar/qux", + "/qux/qux", + "qux", + ] + + MyList = ContextDerivedTypedList(Path) + l = MyList(ctxt1) + l += paths + + for p_str, p_path in zip(paths, l): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt1, p_str)) + self.assertEqual( + p_path.join("foo"), Path(ctxt1, mozpath.join(p_str, "foo")) + ) + + l2 = MyList(ctxt2) + l2 += paths + + for p_str, p_path in zip(paths, l2): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt2, p_str)) + + # Assigning with Paths from another context doesn't rebase them + l2 = MyList(ctxt2) + l2 += l + + for p_str, p_path in zip(paths, l2): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt1, p_str)) + + MyListWithFlags = ContextDerivedTypedListWithItems( + Path, + StrictOrderingOnAppendListWithFlagsFactory( + { + "foo": bool, + } + ), + ) + l = MyListWithFlags(ctxt1) + l += paths + + for p in paths: + l[p].foo = True + + for p_str, p_path in zip(paths, l): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt1, p_str)) + self.assertEqual(l[p_str].foo, True) + self.assertEqual(l[p_path].foo, True) + + def test_path_typed_hierarchy_list(self): + config = self.config + ctxt1 = Context(config=config) + ctxt1.push_source(mozpath.join(config.topsrcdir, "foo", "moz.build")) + ctxt2 = Context(config=config) + ctxt2.push_source(mozpath.join(config.topsrcdir, "bar", "moz.build")) + + paths = [ + "!../bar/qux", + "!/qux/qux", + "!qux", + "../bar/qux", + "/qux/qux", + "qux", + ] + + MyList = ContextDerivedTypedHierarchicalStringList(Path) + l = MyList(ctxt1) + l += paths + l.subdir += paths + + for _, files in l.walk(): + for p_str, p_path in zip(paths, files): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt1, p_str)) + self.assertEqual( + p_path.join("foo"), Path(ctxt1, mozpath.join(p_str, "foo")) + ) + + l2 = MyList(ctxt2) + l2 += paths + l2.subdir += paths + + for _, files in l2.walk(): + for p_str, p_path in zip(paths, files): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt2, p_str)) + + # Assigning with Paths from another context doesn't rebase them + l2 = MyList(ctxt2) + l2 += l + + for _, files in l2.walk(): + for p_str, p_path in zip(paths, files): + self.assertEqual(p_str, p_path) + self.assertEqual(p_path, Path(ctxt1, p_str)) + + +class TestTypedRecord(unittest.TestCase): + def test_fields(self): + T = ContextDerivedTypedRecord(("field1", six.text_type), ("field2", list)) + inst = T(None) + self.assertEqual(inst.field1, "") + self.assertEqual(inst.field2, []) + + inst.field1 = "foo" + inst.field2 += ["bar"] + + self.assertEqual(inst.field1, "foo") + self.assertEqual(inst.field2, ["bar"]) + + with self.assertRaises(AttributeError): + inst.field3 = [] + + def test_coercion(self): + T = ContextDerivedTypedRecord(("field1", six.text_type), ("field2", list)) + inst = T(None) + inst.field1 = 3 + inst.field2 += ("bar",) + self.assertEqual(inst.field1, "3") + self.assertEqual(inst.field2, ["bar"]) + + with self.assertRaises(TypeError): + inst.field2 = object() + + +class TestFiles(unittest.TestCase): + def test_aggregate_empty(self): + c = Context({}) + + files = {"moz.build": Files(c, "**")} + + self.assertEqual( + Files.aggregate(files), + { + "bug_component_counts": [], + "recommended_bug_component": None, + }, + ) + + def test_single_bug_component(self): + c = Context({}) + f = Files(c, "**") + f["BUG_COMPONENT"] = ("Product1", "Component1") + + files = {"moz.build": f} + self.assertEqual( + Files.aggregate(files), + { + "bug_component_counts": [(("Product1", "Component1"), 1)], + "recommended_bug_component": ("Product1", "Component1"), + }, + ) + + def test_multiple_bug_components(self): + c = Context({}) + f1 = Files(c, "**") + f1["BUG_COMPONENT"] = ("Product1", "Component1") + + f2 = Files(c, "**") + f2["BUG_COMPONENT"] = ("Product2", "Component2") + + files = {"a": f1, "b": f2, "c": f1} + self.assertEqual( + Files.aggregate(files), + { + "bug_component_counts": [ + (("Product1", "Component1"), 2), + (("Product2", "Component2"), 1), + ], + "recommended_bug_component": ("Product1", "Component1"), + }, + ) + + def test_no_recommended_bug_component(self): + """If there is no clear count winner, we don't recommend a bug component.""" + c = Context({}) + f1 = Files(c, "**") + f1["BUG_COMPONENT"] = ("Product1", "Component1") + + f2 = Files(c, "**") + f2["BUG_COMPONENT"] = ("Product2", "Component2") + + files = {"a": f1, "b": f2} + self.assertEqual( + Files.aggregate(files), + { + "bug_component_counts": [ + (("Product1", "Component1"), 1), + (("Product2", "Component2"), 1), + ], + "recommended_bug_component": None, + }, + ) + + def test_multiple_patterns(self): + c = Context({}) + f1 = Files(c, "a/**") + f1["BUG_COMPONENT"] = ("Product1", "Component1") + f2 = Files(c, "b/**", "a/bar") + f2["BUG_COMPONENT"] = ("Product2", "Component2") + + files = {"a/foo": f1, "a/bar": f2, "b/foo": f2} + self.assertEqual( + Files.aggregate(files), + { + "bug_component_counts": [ + (("Product2", "Component2"), 2), + (("Product1", "Component1"), 1), + ], + "recommended_bug_component": ("Product2", "Component2"), + }, + ) + + +if __name__ == "__main__": + main() |