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.generate_3p_gn module.""" 15 16import unittest 17 18from contextlib import AbstractContextManager 19from io import StringIO 20from pathlib import Path 21from tempfile import TemporaryDirectory 22from unittest import mock 23from types import TracebackType 24from typing import Iterator, Type 25 26from pw_build.generate_3p_gn import GnGenerator, write_owners 27from pw_build.gn_config import GnConfig 28from pw_build.gn_writer import GnWriter 29 30 31class GnGeneratorForTest(AbstractContextManager): 32 """Test fixture that creates a generator for a temporary directory.""" 33 34 def __init__(self): 35 self._tmp = TemporaryDirectory() 36 37 def __enter__(self) -> GnGenerator: 38 """Creates a temporary directory and uses it to create a generator.""" 39 tmp = self._tmp.__enter__() 40 generator = GnGenerator() 41 path = Path(tmp) / 'repo' 42 path.mkdir(parents=True) 43 generator.load_workspace(path) 44 return generator 45 46 def __exit__( 47 self, 48 exc_type: Type[BaseException] | None, 49 exc_val: BaseException | None, 50 exc_tb: TracebackType | None, 51 ) -> None: 52 """Removes the temporary directory.""" 53 self._tmp.__exit__(exc_type, exc_val, exc_tb) 54 55 56def mock_return_values(mock_run, retvals: Iterator[str]) -> None: 57 """Mocks the return values of several calls to subprocess.run.""" 58 side_effects = [] 59 for retval in retvals: 60 attr = {'stdout.decode.return_value': retval} 61 side_effects.append(mock.MagicMock(**attr)) 62 mock_run.side_effect = side_effects 63 64 65class TestGenerator(unittest.TestCase): 66 """Tests for generate_3p_gn.GnGenerator.""" 67 68 def test_generate_configs(self): 69 """Tests finding the most common configs.""" 70 generator = GnGenerator() 71 generator.set_repo('test') 72 73 generator.add_target( 74 json='''{ 75 "target_name": "target0", 76 "cflags": ["common"] 77 }''', 78 ) 79 80 generator.add_target( 81 json='''{ 82 "target_name": "target1", 83 "package": "foo", 84 "include_dirs": ["foo"], 85 "cflags": ["common", "foo-flag1"] 86 }''', 87 ) 88 89 generator.add_target( 90 json='''{ 91 "target_name": "target2", 92 "package": "foo", 93 "include_dirs": ["foo"], 94 "cflags": ["common", "foo-flag1", "foo-flag2"] 95 }''', 96 ) 97 98 generator.add_target( 99 json='''{ 100 "target_name": "target3", 101 "package": "foo", 102 "include_dirs": ["foo"], 103 "cflags": ["common", "foo-flag1"] 104 }''', 105 ) 106 107 generator.add_target( 108 json='''{ 109 "target_name": "target4", 110 "package": "bar", 111 "include_dirs": ["bar"], 112 "cflags": ["common", "bar-flag"] 113 }''', 114 ) 115 116 configs_to_add = ['//:added'] 117 configs_to_remove = ['//remove:me'] 118 generator.generate_configs(configs_to_add, configs_to_remove) 119 120 self.assertEqual( 121 generator.configs[''], 122 [ 123 GnConfig( 124 json='''{ 125 "label": "$dir_pw_third_party/test:test_config1", 126 "cflags": ["common"], 127 "usages": 5 128 }''' 129 ) 130 ], 131 ) 132 133 self.assertEqual( 134 generator.configs['foo'], 135 [ 136 GnConfig( 137 json='''{ 138 "label": "$dir_pw_third_party/test/foo:foo_config1", 139 "cflags": ["foo-flag1"], 140 "public": false, 141 "usages": 3 142 }''' 143 ), 144 GnConfig( 145 json='''{ 146 "label": "$dir_pw_third_party/test/foo:foo_public_config1", 147 "include_dirs": ["foo"], 148 "public": true, 149 "usages": 3 150 }''' 151 ), 152 ], 153 ) 154 155 self.assertEqual( 156 generator.configs['bar'], 157 [ 158 GnConfig( 159 json='''{ 160 "label": "$dir_pw_third_party/test/bar:bar_public_config1", 161 "include_dirs": ["bar"], 162 "public": true, 163 "usages": 1 164 }''' 165 ) 166 ], 167 ) 168 169 targets = [ 170 target 171 for targets in generator.targets.values() 172 for target in targets 173 ] 174 targets.sort(key=lambda target: target.name()) 175 176 target0 = targets[0] 177 self.assertFalse(target0.config) 178 self.assertEqual( 179 target0.configs, 180 {'//:added', '$dir_pw_third_party/test:test_config1'}, 181 ) 182 self.assertFalse(target0.public_configs) 183 self.assertEqual(target0.remove_configs, {'//remove:me'}) 184 185 target1 = targets[1] 186 self.assertFalse(target1.config) 187 self.assertEqual( 188 target1.configs, 189 { 190 '//:added', 191 '$dir_pw_third_party/test:test_config1', 192 '$dir_pw_third_party/test/foo:foo_config1', 193 }, 194 ) 195 self.assertEqual( 196 target1.public_configs, 197 {'$dir_pw_third_party/test/foo:foo_public_config1'}, 198 ) 199 self.assertEqual(target1.remove_configs, {'//remove:me'}) 200 201 target2 = targets[2] 202 self.assertEqual( 203 target2.config, 204 GnConfig( 205 json='''{ 206 "cflags": ["foo-flag2"], 207 "public": false, 208 "usages": 0 209 }''' 210 ), 211 ) 212 self.assertEqual( 213 target2.configs, 214 { 215 '//:added', 216 '$dir_pw_third_party/test:test_config1', 217 '$dir_pw_third_party/test/foo:foo_config1', 218 }, 219 ) 220 self.assertEqual( 221 target2.public_configs, 222 {'$dir_pw_third_party/test/foo:foo_public_config1'}, 223 ) 224 self.assertEqual(target2.remove_configs, {'//remove:me'}) 225 226 target3 = targets[3] 227 self.assertFalse(target3.config) 228 self.assertEqual( 229 target3.configs, 230 { 231 '//:added', 232 '$dir_pw_third_party/test:test_config1', 233 '$dir_pw_third_party/test/foo:foo_config1', 234 }, 235 ) 236 self.assertEqual( 237 target3.public_configs, 238 {'$dir_pw_third_party/test/foo:foo_public_config1'}, 239 ) 240 self.assertEqual(target3.remove_configs, {'//remove:me'}) 241 242 target4 = targets[4] 243 self.assertEqual( 244 target4.config, 245 GnConfig( 246 json='''{ 247 "cflags": ["bar-flag"], 248 "public": false, 249 "usages": 0 250 }''' 251 ), 252 ) 253 self.assertEqual( 254 target4.configs, 255 {'//:added', '$dir_pw_third_party/test:test_config1'}, 256 ) 257 self.assertEqual( 258 target4.public_configs, 259 {'$dir_pw_third_party/test/bar:bar_public_config1'}, 260 ) 261 self.assertEqual(target4.remove_configs, {'//remove:me'}) 262 263 def test_write_build_gn(self): 264 """Tests writing a complete BUILD.gn file.""" 265 generator = GnGenerator() 266 generator.set_repo('test') 267 generator.exclude_from_gn_check(bazel='//bar:target3') 268 269 generator.add_configs( 270 '', 271 GnConfig( 272 json='''{ 273 "label": "$dir_pw_third_party/test:test_config1", 274 "cflags": ["common"], 275 "usages": 5 276 }''' 277 ), 278 ) 279 280 generator.add_configs( 281 'foo', 282 GnConfig( 283 json='''{ 284 "label": "$dir_pw_third_party/test/foo:foo_config1", 285 "cflags": ["foo-flag1"], 286 "public": false, 287 "usages": 3 288 }''' 289 ), 290 GnConfig( 291 json='''{ 292 "label": "$dir_pw_third_party/test/foo:foo_public_config1", 293 "include_dirs": ["foo"], 294 "public": true, 295 "usages": 3 296 }''' 297 ), 298 ) 299 300 generator.add_configs( 301 'bar', 302 GnConfig( 303 json='''{ 304 "label": "$dir_pw_third_party/test/bar:bar_public_config1", 305 "include_dirs": ["bar"], 306 "public": true, 307 "usages": 1 308 }''' 309 ), 310 ) 311 312 generator.add_target( 313 json='''{ 314 "target_type": "pw_executable", 315 "target_name": "target0", 316 "configs": ["$dir_pw_third_party/test:test_config1"], 317 "sources": ["$dir_pw_third_party_test/target0.cc"], 318 "deps": [ 319 "$dir_pw_third_party/test/foo:target1", 320 "$dir_pw_third_party/test/foo:target2" 321 ] 322 }''', 323 ) 324 325 generator.add_target( 326 json='''{ 327 "target_type": "pw_source_set", 328 "target_name": "target1", 329 "package": "foo", 330 "public": ["$dir_pw_third_party_test/foo/target1.h"], 331 "sources": ["$dir_pw_third_party_test/foo/target1.cc"], 332 "public_configs": ["$dir_pw_third_party/test/foo:foo_public_config1"], 333 "configs": [ 334 "$dir_pw_third_party/test:test_config1", 335 "$dir_pw_third_party/test/foo:foo_config1" 336 ] 337 }''', 338 ) 339 340 generator.add_target( 341 json='''{ 342 "target_type": "pw_source_set", 343 "target_name": "target2", 344 "package": "foo", 345 "sources": ["$dir_pw_third_party_test/foo/target2.cc"], 346 "public_configs": ["$dir_pw_third_party/test/foo:foo_public_config1"], 347 "configs": [ 348 "$dir_pw_third_party/test:test_config1", 349 "$dir_pw_third_party/test/foo:foo_config1" 350 ], 351 "cflags": ["foo-flag2"], 352 "public_deps": ["$dir_pw_third_party/test/bar:target3"] 353 }''', 354 ) 355 356 generator.add_target( 357 json='''{ 358 "target_type": "pw_source_set", 359 "target_name": "target3", 360 "package": "bar", 361 "include_dirs": ["bar"], 362 "public_configs": ["$dir_pw_third_party/test/bar:bar_public_config1"], 363 "configs": ["$dir_pw_third_party/test:test_config1"], 364 "cflags": ["bar-flag"] 365 }''', 366 ) 367 368 output = StringIO() 369 build_gn = GnWriter(output) 370 generator.write_build_gn('', build_gn) 371 self.assertEqual( 372 output.getvalue(), 373 ''' 374import("//build_overrides/pigweed.gni") 375 376import("$dir_pw_build/target_types.gni") 377import("$dir_pw_docgen/docs.gni") 378import("$dir_pw_third_party/test/test.gni") 379 380if (dir_pw_third_party_test != "") { 381 config("test_config1") { 382 cflags = [ 383 "common", 384 ] 385 } 386 387 # Generated from //:target0 388 pw_executable("target0") { 389 sources = [ 390 "$dir_pw_third_party_test/target0.cc", 391 ] 392 configs = [ 393 ":test_config1", 394 ] 395 deps = [ 396 "foo:target1", 397 "foo:target2", 398 ] 399 } 400} 401 402pw_doc_group("docs") { 403 sources = [ 404 "docs.rst", 405 ] 406} 407'''.lstrip(), 408 ) 409 410 output = StringIO() 411 build_gn = GnWriter(output) 412 generator.write_build_gn('foo', build_gn) 413 self.assertEqual( 414 output.getvalue(), 415 ''' 416import("//build_overrides/pigweed.gni") 417 418import("$dir_pw_build/target_types.gni") 419import("$dir_pw_third_party/test/test.gni") 420 421config("foo_public_config1") { 422 include_dirs = [ 423 "foo", 424 ] 425} 426 427config("foo_config1") { 428 cflags = [ 429 "foo-flag1", 430 ] 431} 432 433# Generated from //foo:target1 434pw_source_set("target1") { 435 public = [ 436 "$dir_pw_third_party_test/foo/target1.h", 437 ] 438 sources = [ 439 "$dir_pw_third_party_test/foo/target1.cc", 440 ] 441 public_configs = [ 442 ":foo_public_config1", 443 ] 444 configs = [ 445 "..:test_config1", 446 ":foo_config1", 447 ] 448} 449 450# Generated from //foo:target2 451pw_source_set("target2") { 452 sources = [ 453 "$dir_pw_third_party_test/foo/target2.cc", 454 ] 455 cflags = [ 456 "foo-flag2", 457 ] 458 public_configs = [ 459 ":foo_public_config1", 460 ] 461 configs = [ 462 "..:test_config1", 463 ":foo_config1", 464 ] 465 public_deps = [ 466 "../bar:target3", 467 ] 468} 469'''.lstrip(), 470 ) 471 472 output = StringIO() 473 build_gn = GnWriter(output) 474 generator.write_build_gn('bar', build_gn) 475 self.assertEqual( 476 output.getvalue(), 477 ''' 478import("//build_overrides/pigweed.gni") 479 480import("$dir_pw_build/target_types.gni") 481import("$dir_pw_third_party/test/test.gni") 482 483config("bar_public_config1") { 484 include_dirs = [ 485 "bar", 486 ] 487} 488 489# Generated from //bar:target3 490pw_source_set("target3") { 491 check_includes = false 492 cflags = [ 493 "bar-flag", 494 ] 495 include_dirs = [ 496 "bar", 497 ] 498 public_configs = [ 499 ":bar_public_config1", 500 ] 501 configs = [ 502 "..:test_config1", 503 ] 504} 505'''.lstrip(), 506 ) 507 508 def test_write_repo_gni(self): 509 """Tests writing the GN import file for a repo.""" 510 output = StringIO() 511 with GnGeneratorForTest() as generator: 512 generator.write_repo_gni(GnWriter(output), 'Repo') 513 514 self.assertEqual( 515 output.getvalue(), 516 ''' 517declare_args() { 518 # If compiling tests with Repo, this variable is set to the path to the Repo 519 # installation. When set, a pw_source_set for the Repo library is created at 520 # "$dir_pw_third_party/repo". 521 dir_pw_third_party_repo = "" 522} 523'''.lstrip(), 524 ) 525 526 @mock.patch('subprocess.run') 527 def test_write_docs_rst(self, mock_run): 528 """Tests writing the reStructuredText docs for a repo.""" 529 mock_return_values( 530 mock_run, 531 [ 532 'https://host/repo.git', 533 'https://host/repo.git', 534 'deadbeeffeedface', 535 ], 536 ) 537 output = StringIO() 538 with GnGeneratorForTest() as generator: 539 generator.write_docs_rst(output, 'Repo') 540 541 self.assertEqual( 542 output.getvalue(), 543 ''' 544.. _module-pw_third_party_repo: 545 546==== 547Repo 548==== 549The ``$dir_pw_third_party/repo/`` module provides build files to allow 550optionally including upstream Repo. 551 552------------------- 553Using upstream Repo 554------------------- 555If you want to use Repo, you must do the following: 556 557Submodule 558========= 559Add Repo to your workspace with the following command. 560 561.. code-block:: sh 562 563 git submodule add https://host/repo.git \\ 564 third_party/repo/src 565 566GN 567== 568* Set the GN var ``dir_pw_third_party_repo`` to the location of the 569 Repo source. 570 571 If you used the command above, this will be 572 ``//third_party/repo/src`` 573 574 This can be set in your args.gn or .gn file like: 575 ``dir_pw_third_party_repo = "//third_party/repo/src"`` 576 577Updating 578======== 579The GN build files are generated from the third-party Bazel build files using 580$dir_pw_build/py/pw_build/generate_3p_gn.py. 581 582The script uses data taken from ``$dir_pw_third_party/repo/repo.json``. 583The schema of ``repo.json`` is described in :ref:`module-pw_build-third-party`. 584 585The script should be re-run whenever the submodule is updated or the JSON file 586is modified. Specify the location of the Bazel repository can be specified using 587the ``-w`` option, e.g. 588 589.. code-block:: sh 590 591 python pw_build/py/pw_build/generate_3p_gn.py \\ 592 -w third_party/repo/src 593 594.. DO NOT EDIT BELOW THIS LINE. Generated section. 595 596Version 597======= 598The update script was last run for revision `deadbeef`_. 599 600.. _deadbeef: https://host/repo/tree/deadbeeffeedface 601'''.lstrip(), 602 ) 603 604 @mock.patch('subprocess.run') 605 def test_update_docs_rst_same_rev(self, mock_run): 606 """Tests updating the docs with the same revision.""" 607 mock_return_values( 608 mock_run, 609 [ 610 'https://host/repo.git', 611 'https://host/repo.git', 612 'deadbeeffeedface', 613 'https://host/repo.git', 614 'deadbeeffeedface', 615 ], 616 ) 617 output = StringIO() 618 with GnGeneratorForTest() as generator: 619 generator.write_docs_rst(output, 'Repo') 620 original = output.getvalue().split('\n') 621 updated = list(generator.update_version(original)) 622 623 self.assertEqual(original, updated) 624 625 @mock.patch('subprocess.run') 626 def test_update_docs_rst_new_rev(self, mock_run): 627 """Tests updating the docs with the different revision.""" 628 mock_return_values( 629 mock_run, 630 [ 631 'https://host/repo.git', 632 'https://host/repo.git', 633 'deadbeeffeedface', 634 'https://host/repo.git', 635 '0123456789abcdef', 636 ], 637 ) 638 output = StringIO() 639 with GnGeneratorForTest() as generator: 640 generator.write_docs_rst(output, 'Repo') 641 contents = output.getvalue() 642 original = contents.split('\n') 643 644 # Convert the contents to a list of lines similar to those returned 645 # by iterating over an open file. In particular, include a newline 646 # at the end of each line. 647 with_newlines = [s + '\n' for s in original] 648 updated = list(generator.update_version(with_newlines)) 649 650 self.assertEqual(original[:-6], updated[:-6]) 651 self.assertEqual( 652 '\n'.join(updated[-6:]), 653 ''' 654Version 655======= 656The update script was last run for revision `01234567`_. 657 658.. _01234567: https://host/repo/tree/0123456789abcdef 659'''.lstrip(), 660 ) 661 662 @mock.patch('subprocess.run') 663 def test_update_docs_rst_no_rev(self, mock_run): 664 """Tests updating docs that do not have a revision.""" 665 mock_return_values( 666 mock_run, 667 [ 668 'https://host/repo.git', 669 '0123456789abcdef', 670 ], 671 ) 672 with GnGeneratorForTest() as generator: 673 updated = list(generator.update_version(['foo', 'bar', ''])) 674 675 self.assertEqual( 676 '\n'.join(updated), 677 ''' 678foo 679bar 680 681.. DO NOT EDIT BELOW THIS LINE. Generated section. 682 683Version 684======= 685The update script was last run for revision `01234567`_. 686 687.. _01234567: https://host/repo/tree/0123456789abcdef 688'''.lstrip(), 689 ) 690 691 def test_update_third_party_docs(self): 692 """Tests adding docs to //docs::third_party_docs.""" 693 with GnGeneratorForTest() as generator: 694 contents = generator.update_third_party_docs( 695 ''' 696group("third_party_docs") { 697 deps = [ 698 "$dir_pigweed/third_party/existing:docs", 699 ] 700} 701''' 702 ) 703 # Formatting is performed separately. 704 self.assertEqual( 705 contents, 706 ''' 707group("third_party_docs") { 708deps = ["$dir_pigweed/third_party/repo:docs", 709 "$dir_pigweed/third_party/existing:docs", 710 ] 711} 712''', 713 ) 714 715 def test_update_third_party_docs_no_target(self): 716 """Tests adding docs to a file without a "third_party_docs" target.""" 717 with GnGeneratorForTest() as generator: 718 with self.assertRaises(ValueError): 719 generator.update_third_party_docs('') 720 721 @mock.patch('subprocess.run') 722 def test_write_extra(self, mock_run): 723 """Tests extra files produced via `bazel run`.""" 724 attr = {'stdout.decode.return_value': 'hello, world!'} 725 mock_run.return_value = mock.MagicMock(**attr) 726 727 output = StringIO() 728 with GnGeneratorForTest() as generator: 729 generator.write_extra(output, 'some_label') 730 self.assertEqual(output.getvalue(), 'hello, world!') 731 732 @mock.patch('subprocess.run') 733 def test_write_owners(self, mock_run): 734 """Tests writing an OWNERS file.""" 735 attr = {'stdout.decode.return_value': 'someone@pigweed.dev'} 736 mock_run.return_value = mock.MagicMock(**attr) 737 738 output = StringIO() 739 write_owners(output) 740 self.assertEqual(output.getvalue(), 'someone@pigweed.dev') 741 742 743if __name__ == '__main__': 744 unittest.main() 745