1# Copyright 2023 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Tests for the pw_build.bazel_query module.""" 15 16import json 17import unittest 18 19from collections import deque 20from typing import Any, Deque 21from unittest import mock 22 23from pw_build.bazel_query import ( 24 ParseError, 25 BazelLabel, 26 BazelRule, 27 BazelWorkspace, 28) 29 30# Test fixtures. 31 32 33class MockWorkspace: 34 """Helper class for mocking subprocesses run by BazelWorkspace.""" 35 36 def __init__(self, source_dir: str) -> None: 37 """Creates a workspace mock helper.""" 38 self._args_list: Deque[list[Any]] = deque() 39 self._kwargs_list: Deque[dict[str, Any]] = deque() 40 self._results: list[dict[str, str]] = [] 41 self._source_dir = source_dir 42 43 def source_dir(self) -> str: 44 """Returns the source directory commands would be run in.""" 45 return self._source_dir 46 47 def add_call(self, *args: str, result=None) -> None: 48 """Registers an expected subprocess call.""" 49 self._args_list.append(list(args)) 50 self._results.append({'stdout.decode.return_value': result}) 51 52 def finalize(self, mock_run) -> None: 53 """Add the registered call results to a subprocess mock.""" 54 mock_run.side_effect = [ 55 mock.MagicMock(**attr) for attr in self._results 56 ] 57 58 def pop_args(self) -> list[Any]: 59 """Returns the next set of saved args.""" 60 return self._args_list.popleft() 61 62 def num_args(self) -> int: 63 """Returns the number of remaining saved args.""" 64 return len(self._args_list) 65 66 67# Unit tests 68 69 70class TestBazelLabel(unittest.TestCase): 71 """Tests for bazel_query.BazelLabel.""" 72 73 def test_label_with_repo_package_target(self): 74 """Tests a label with a repo, package, and target.""" 75 label = BazelLabel('@repo1//foo/bar:baz', repo='repo2', package='qux') 76 self.assertEqual(label.repo(), 'repo1') 77 self.assertEqual(label.package(), 'foo/bar') 78 self.assertEqual(label.target(), 'baz') 79 self.assertEqual(str(label), '@repo1//foo/bar:baz') 80 81 def test_label_with_repo_package(self): 82 """Tests a label with a repo and package.""" 83 label = BazelLabel('@repo1//foo/bar', repo='repo2', package='qux') 84 self.assertEqual(label.repo(), 'repo1') 85 self.assertEqual(label.package(), 'foo/bar') 86 self.assertEqual(label.target(), 'bar') 87 self.assertEqual(str(label), '@repo1//foo/bar:bar') 88 89 def test_label_with_repo_target(self): 90 """Tests a label with a repo and target.""" 91 label = BazelLabel('@repo1//:baz', repo='repo2', package='qux') 92 self.assertEqual(label.repo(), 'repo1') 93 self.assertEqual(label.package(), '') 94 self.assertEqual(label.target(), 'baz') 95 self.assertEqual(str(label), '@repo1//:baz') 96 97 def test_label_with_repo_only(self): 98 """Tests a label with a repo only.""" 99 with self.assertRaises(ParseError): 100 BazelLabel('@repo1', repo='repo2', package='qux') 101 102 def test_label_with_package_target(self): 103 """Tests a label with a package and target.""" 104 label = BazelLabel('//foo/bar:baz', repo='repo2', package='qux') 105 self.assertEqual(label.repo(), 'repo2') 106 self.assertEqual(label.package(), 'foo/bar') 107 self.assertEqual(label.target(), 'baz') 108 self.assertEqual(str(label), '@repo2//foo/bar:baz') 109 110 def test_label_with_package_only(self): 111 """Tests a label with a package only.""" 112 label = BazelLabel('//foo/bar', repo='repo2', package='qux') 113 self.assertEqual(label.repo(), 'repo2') 114 self.assertEqual(label.package(), 'foo/bar') 115 self.assertEqual(label.target(), 'bar') 116 self.assertEqual(str(label), '@repo2//foo/bar:bar') 117 118 def test_label_with_target_only(self): 119 """Tests a label with a target only.""" 120 label = BazelLabel(':baz', repo='repo2', package='qux') 121 self.assertEqual(label.repo(), 'repo2') 122 self.assertEqual(label.package(), 'qux') 123 self.assertEqual(label.target(), 'baz') 124 self.assertEqual(str(label), '@repo2//qux:baz') 125 126 def test_label_with_none(self): 127 """Tests an empty label.""" 128 with self.assertRaises(ParseError): 129 BazelLabel('', repo='repo2', package='qux') 130 131 def test_label_invalid_no_repo(self): 132 """Tests a label with an invalid (non-absolute) package name.""" 133 with self.assertRaises(AssertionError): 134 BazelLabel('//foo/bar:baz') 135 136 def test_label_invalid_relative(self): 137 """Tests a label with an invalid (non-absolute) package name.""" 138 with self.assertRaises(ParseError): 139 BazelLabel('../foo/bar:baz') 140 141 def test_label_invalid_double_colon(self): 142 """Tests a label with an invalid (non-absolute) package name.""" 143 with self.assertRaises(ParseError): 144 BazelLabel('//foo:bar:baz') 145 146 147class TestBazelRule(unittest.TestCase): 148 """Tests for bazel_query.BazelRule.""" 149 150 def test_rule_parse_invalid(self): 151 """Test for parsing invalid rule attributes.""" 152 rule = BazelRule('kind', '@repo//package:target') 153 with self.assertRaises(ParseError): 154 rule.parse_attrs( 155 json.loads( 156 '''[{ 157 "name": "invalid_attr", 158 "type": "ESOTERIC", 159 "intValue": 0, 160 "stringValue": "false", 161 "explicitlySpecified": true, 162 "booleanValue": false 163 }]''' 164 ) 165 ) 166 167 def test_rule_parse_boolean_unspecified(self): 168 """Test parsing an unset boolean rule attribute.""" 169 rule = BazelRule('kind', '@repo//package:target') 170 rule.parse_attrs( 171 json.loads( 172 '''[{ 173 "name": "bool_attr", 174 "type": "BOOLEAN", 175 "intValue": 0, 176 "stringValue": "false", 177 "explicitlySpecified": false, 178 "booleanValue": false 179 }]''' 180 ) 181 ) 182 self.assertFalse(rule.has_attr('bool_attr')) 183 184 def test_rule_parse_boolean_false(self): 185 """Tests parsing boolean rule attribute set to false.""" 186 rule = BazelRule('kind', '@repo//package:target') 187 rule.parse_attrs( 188 json.loads( 189 '''[{ 190 "name": "bool_attr", 191 "type": "BOOLEAN", 192 "intValue": 0, 193 "stringValue": "false", 194 "explicitlySpecified": true, 195 "booleanValue": false 196 }]''' 197 ) 198 ) 199 self.assertTrue(rule.has_attr('bool_attr')) 200 self.assertFalse(rule.get_bool('bool_attr')) 201 202 def test_rule_parse_boolean_true(self): 203 """Tests parsing a boolean rule attribute set to true.""" 204 rule = BazelRule('kind', '@repo//package:target') 205 rule.parse_attrs( 206 json.loads( 207 '''[{ 208 "name": "bool_attr", 209 "type": "BOOLEAN", 210 "intValue": 1, 211 "stringValue": "true", 212 "explicitlySpecified": true, 213 "booleanValue": true 214 }]''' 215 ) 216 ) 217 self.assertTrue(rule.has_attr('bool_attr')) 218 self.assertTrue(rule.get_bool('bool_attr')) 219 220 def test_rule_parse_integer_unspecified(self): 221 """Tests parsing an unset integer rule attribute.""" 222 rule = BazelRule('kind', '@repo//package:target') 223 rule.parse_attrs( 224 json.loads( 225 '''[{ 226 "name": "int_attr", 227 "type": "INTEGER", 228 "intValue": 0, 229 "explicitlySpecified": false 230 }]''' 231 ) 232 ) 233 self.assertFalse(rule.has_attr('int_attr')) 234 235 def test_rule_parse_integer(self): 236 """Tests parsing an integer rule attribute.""" 237 rule = BazelRule('kind', '@repo//package:target') 238 rule.parse_attrs( 239 json.loads( 240 '''[{ 241 "name": "int_attr", 242 "type": "INTEGER", 243 "intValue": 100, 244 "explicitlySpecified": true 245 }]''' 246 ) 247 ) 248 self.assertTrue(rule.has_attr('int_attr')) 249 self.assertEqual(rule.get_int('int_attr'), 100) 250 251 def test_rule_parse_string_unspecified(self): 252 """Tests parsing an unset string rule attribute.""" 253 rule = BazelRule('kind', '@repo//package:target') 254 rule.parse_attrs( 255 json.loads( 256 '''[{ 257 "name": "string_attr", 258 "type": "STRING", 259 "stringValue": "", 260 "explicitlySpecified": false 261 }]''' 262 ) 263 ) 264 self.assertFalse(rule.has_attr('string_attr')) 265 266 def test_rule_parse_string(self): 267 """Tests parsing a string rule attribute.""" 268 rule = BazelRule('kind', '@repo//package:target') 269 rule.parse_attrs( 270 json.loads( 271 '''[{ 272 "name": "string_attr", 273 "type": "STRING", 274 "stringValue": "hello, world!", 275 "explicitlySpecified": true 276 }]''' 277 ) 278 ) 279 self.assertTrue(rule.has_attr('string_attr')) 280 self.assertEqual(rule.get_str('string_attr'), 'hello, world!') 281 282 def test_rule_parse_string_list_unspecified(self): 283 """Tests parsing an unset string list rule attribute.""" 284 rule = BazelRule('kind', '@repo//package:target') 285 rule.parse_attrs( 286 json.loads( 287 '''[{ 288 "name": "string_list_attr", 289 "type": "STRING_LIST", 290 "stringListValue": [], 291 "explicitlySpecified": false 292 }]''' 293 ) 294 ) 295 self.assertFalse(rule.has_attr('string_list_attr')) 296 297 def test_rule_parse_string_list(self): 298 """Tests parsing a string list rule attribute.""" 299 rule = BazelRule('kind', '@repo//package:target') 300 rule.parse_attrs( 301 json.loads( 302 '''[{ 303 "name": "string_list_attr", 304 "type": "STRING_LIST", 305 "stringListValue": [ "hello", "world!" ], 306 "explicitlySpecified": true 307 }]''' 308 ) 309 ) 310 self.assertTrue(rule.has_attr('string_list_attr')) 311 self.assertEqual(rule.get_list('string_list_attr'), ['hello', 'world!']) 312 313 def test_rule_parse_label_list_unspecified(self): 314 """Tests parsing an unset label list rule attribute.""" 315 rule = BazelRule('kind', '@repo//package:target') 316 rule.parse_attrs( 317 json.loads( 318 '''[{ 319 "name": "label_list_attr", 320 "type": "LABEL_LIST", 321 "stringListValue": [], 322 "explicitlySpecified": false 323 }]''' 324 ) 325 ) 326 self.assertFalse(rule.has_attr('label_list_attr')) 327 328 def test_rule_parse_label_list(self): 329 """Tests parsing a label list rule attribute.""" 330 rule = BazelRule('kind', '@repo//package:target') 331 rule.parse_attrs( 332 json.loads( 333 '''[{ 334 "name": "label_list_attr", 335 "type": "LABEL_LIST", 336 "stringListValue": [ "hello", "world!" ], 337 "explicitlySpecified": true 338 }]''' 339 ) 340 ) 341 self.assertTrue(rule.has_attr('label_list_attr')) 342 self.assertEqual(rule.get_list('label_list_attr'), ['hello', 'world!']) 343 344 def test_rule_parse_string_dict_unspecified(self): 345 """Tests parsing an unset string dict rule attribute.""" 346 rule = BazelRule('kind', '@repo//package:target') 347 rule.parse_attrs( 348 json.loads( 349 '''[{ 350 "name": "string_dict_attr", 351 "type": "LABEL_LIST", 352 "stringDictValue": [], 353 "explicitlySpecified": false 354 }]''' 355 ) 356 ) 357 self.assertFalse(rule.has_attr('string_dict_attr')) 358 359 def test_rule_parse_string_dict(self): 360 """Tests parsing a string dict rule attribute.""" 361 rule = BazelRule('kind', '@repo//package:target') 362 rule.parse_attrs( 363 json.loads( 364 '''[{ 365 "name": "string_dict_attr", 366 "type": "STRING_DICT", 367 "stringDictValue": [ 368 { 369 "key": "foo", 370 "value": "hello" 371 }, 372 { 373 "key": "bar", 374 "value": "world" 375 } 376 ], 377 "explicitlySpecified": true 378 }]''' 379 ) 380 ) 381 string_dict_attr = rule.get_dict('string_dict_attr') 382 self.assertTrue(rule.has_attr('string_dict_attr')) 383 self.assertEqual(string_dict_attr['foo'], 'hello') 384 self.assertEqual(string_dict_attr['bar'], 'world') 385 386 387class TestWorkspace(unittest.TestCase): 388 """Test for bazel_query.Workspace.""" 389 390 def setUp(self) -> None: 391 self.mock = MockWorkspace('path/to/repo') 392 393 def verify_mock(self, mock_run) -> None: 394 """Asserts that the calls to the mock object match those registered.""" 395 for mocked_call in mock_run.call_args_list: 396 self.assertEqual(mocked_call.args[0], self.mock.pop_args()) 397 self.assertEqual(mocked_call.kwargs['cwd'], 'path/to/repo') 398 self.assertTrue(mocked_call.kwargs['capture_output']) 399 self.assertEqual(self.mock.num_args(), 0) 400 401 @mock.patch('subprocess.run') 402 def test_workspace_get_http_archives_no_generate(self, mock_run): 403 """Tests querying a workspace for its external dependencies.""" 404 self.mock.add_call('git', 'fetch') 405 self.mock.finalize(mock_run) 406 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 407 workspace.generate = False 408 deps = list(workspace.get_http_archives()) 409 self.assertEqual(deps, []) 410 self.verify_mock(mock_run) 411 412 @mock.patch('subprocess.run') 413 def test_workspace_get_http_archives(self, mock_run): 414 """Tests querying a workspace for its external dependencies.""" 415 self.mock.add_call('git', 'fetch') 416 rules = [ 417 { 418 'type': 'RULE', 419 'rule': { 420 'name': '//external:foo', 421 'ruleClass': 'http_archive', 422 'attribute': [ 423 { 424 'name': 'url', 425 'type': 'STRING', 426 'explicitlySpecified': True, 427 'stringValue': 'http://src/deadbeef.tgz', 428 } 429 ], 430 }, 431 }, 432 { 433 'type': 'RULE', 434 'rule': { 435 'name': '//external:bar', 436 'ruleClass': 'http_archive', 437 'attribute': [ 438 { 439 'name': 'urls', 440 'type': 'STRING_LIST', 441 'explicitlySpecified': True, 442 'stringListValue': ['http://src/feedface.zip'], 443 } 444 ], 445 }, 446 }, 447 ] 448 results = [json.dumps(rule) for rule in rules] 449 self.mock.add_call( 450 'bazel', 451 'query', 452 'kind(http_archive, //external:*)', 453 '--output=streamed_jsonproto', 454 '--noshow_progress', 455 result='\n'.join(results), 456 ) 457 self.mock.finalize(mock_run) 458 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 459 (foo_rule, bar_rule) = list(workspace.get_http_archives()) 460 self.assertEqual(foo_rule.get_str('url'), 'http://src/deadbeef.tgz') 461 self.assertEqual( 462 bar_rule.get_list('urls'), 463 [ 464 'http://src/feedface.zip', 465 ], 466 ) 467 self.verify_mock(mock_run) 468 469 @mock.patch('subprocess.run') 470 def test_workspace_get_rules(self, mock_run): 471 """Tests querying a workspace for a rule.""" 472 self.mock.add_call('git', 'fetch') 473 rule_data = { 474 'results': [ 475 { 476 'target': { 477 'rule': { 478 'name': '//pkg:target', 479 'ruleClass': 'cc_library', 480 'attribute': [ 481 { 482 'explicitlySpecified': True, 483 'name': 'hdrs', 484 'type': 'string_list', 485 'stringListValue': ['foo/include/bar.h'], 486 }, 487 { 488 'explicitlySpecified': True, 489 'name': 'srcs', 490 'type': 'string_list', 491 'stringListValue': ['foo/bar.cc'], 492 }, 493 { 494 'name': 'additional_linker_inputs', 495 'type': 'string_list', 496 'stringListValue': ['implicit'], 497 }, 498 { 499 'explicitlySpecified': True, 500 'name': 'include_dirs', 501 'type': 'string_list', 502 'stringListValue': ['foo/include'], 503 }, 504 { 505 'explicitlySpecified': True, 506 'name': 'copts', 507 'type': 'string_list', 508 'stringListValue': ['-Wall', '-Werror'], 509 }, 510 { 511 'explicitlySpecified': False, 512 'name': 'linkopts', 513 'type': 'string_list', 514 'stringListValue': ['implicit'], 515 }, 516 { 517 'explicitlySpecified': True, 518 'name': 'defines', 519 'type': 'string_list', 520 'stringListValue': ['-DFILTERED', '-DKEPT'], 521 }, 522 { 523 'explicitlySpecified': True, 524 'name': 'local_defines', 525 'type': 'string_list', 526 'stringListValue': ['-DALSO_FILTERED'], 527 }, 528 { 529 'explicitlySpecified': True, 530 'name': 'deps', 531 'type': 'string_list', 532 'stringListValue': [':baz'], 533 }, 534 { 535 'explicitlySpecified': True, 536 'name': 'implementation_deps', 537 'type': 'string_list', 538 'stringListValue': [], 539 }, 540 ], 541 } 542 } 543 } 544 ] 545 } 546 self.mock.add_call( 547 'bazel', 548 'cquery', 549 '//pkg:target', 550 '--@repo//pkg:use_optional=True', 551 '--output=jsonproto', 552 '--noshow_progress', 553 result=json.dumps(rule_data), 554 ) 555 self.mock.finalize(mock_run) 556 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 557 workspace.defaults = { 558 'defines': ['-DFILTERED'], 559 'local_defines': ['-DALSO_FILTERED'], 560 } 561 workspace.options = {'@repo//pkg:use_optional': True} 562 labels = [BazelLabel('@repo//pkg:target')] 563 rules = list(workspace.get_rules(labels)) 564 rule = rules[0] 565 self.assertEqual(rule.get_list('hdrs'), ['foo/include/bar.h']) 566 self.assertEqual(rule.get_list('srcs'), ['foo/bar.cc']) 567 self.assertEqual(rule.get_list('additional_linker_inputs'), []) 568 self.assertEqual(rule.get_list('include_dirs'), ['foo/include']) 569 self.assertEqual(rule.get_list('copts'), ['-Wall', '-Werror']) 570 self.assertEqual(rule.get_list('linkopts'), []) 571 self.assertEqual(rule.get_list('defines'), ['-DKEPT']) 572 self.assertEqual(rule.get_list('local_defines'), []) 573 self.assertEqual(rule.get_list('deps'), [':baz']) 574 self.assertEqual(rule.get_list('implementation_deps'), []) 575 576 # Rules are cached, so a second call doesn't invoke Bazel again. 577 rule = workspace.get_rules(labels) 578 self.verify_mock(mock_run) 579 580 @mock.patch('subprocess.run') 581 def test_workspace_get_rule_no_generate(self, mock_run): 582 """Tests querying a workspace for a rule.""" 583 self.mock.add_call('git', 'fetch') 584 self.mock.finalize(mock_run) 585 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 586 workspace.generate = False 587 labels = [BazelLabel('@repo//pkg:target')] 588 rules = list(workspace.get_rules(labels)) 589 self.assertEqual(rules, []) 590 self.verify_mock(mock_run) 591 592 @mock.patch('subprocess.run') 593 def test_workspace_revision(self, mock_run): 594 """Tests querying a workspace for its git revision.""" 595 self.mock.add_call('git', 'fetch') 596 self.mock.add_call('git', 'rev-parse', 'HEAD', result='deadbeef') 597 self.mock.finalize(mock_run) 598 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 599 self.assertEqual(workspace.revision(), 'deadbeef') 600 self.verify_mock(mock_run) 601 602 @mock.patch('subprocess.run') 603 def test_workspace_timestamp(self, mock_run): 604 """Tests querying a workspace for its commit timestamp.""" 605 self.mock.add_call('git', 'fetch') 606 self.mock.add_call( 607 'git', 'show', '--no-patch', '--format=%ci', 'HEAD', result='0123' 608 ) 609 self.mock.add_call( 610 'git', 611 'show', 612 '--no-patch', 613 '--format=%ci', 614 'deadbeef', 615 result='4567', 616 ) 617 self.mock.finalize(mock_run) 618 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 619 self.assertEqual(workspace.timestamp('HEAD'), '0123') 620 self.assertEqual(workspace.timestamp('deadbeef'), '4567') 621 self.verify_mock(mock_run) 622 623 @mock.patch('subprocess.run') 624 def test_workspace_url(self, mock_run): 625 """Tests querying a workspace for its git URL.""" 626 self.mock.add_call('git', 'fetch') 627 self.mock.add_call( 628 'git', 'remote', 'get-url', 'origin', result='http://foo/bar.git' 629 ) 630 self.mock.finalize(mock_run) 631 workspace = BazelWorkspace('@repo', self.mock.source_dir()) 632 self.assertEqual(workspace.url(), 'http://foo/bar.git') 633 self.verify_mock(mock_run) 634 635 636if __name__ == '__main__': 637 unittest.main() 638