xref: /aosp_15_r20/external/pigweed/pw_build/py/bazel_query_test.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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