• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 25403087079

05 May 2026 09:23PM UTC coverage: 92.903% (-0.04%) from 92.944%
25403087079

Pull #23319

github

web-flow
Merge 17479f77c into f46dc7805
Pull Request #23319: [pants_ng] Scaffolding for a pants_ng mode.

25 of 76 new or added lines in 9 files covered. (32.89%)

10 existing lines in 4 files now uncovered.

91968 of 98994 relevant lines covered (92.9%)

4.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.64
/src/python/pants/init/extension_loader_test.py
1
# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
import importlib.metadata
1✔
5
import logging
1✔
6
import sys
1✔
7
import types
1✔
8
import unittest
1✔
9
import uuid
1✔
10
from collections.abc import Generator
1✔
11
from contextlib import contextmanager
1✔
12
from dataclasses import dataclass
1✔
13
from importlib.metadata import Distribution, DistributionFinder
1✔
14
from typing import Any
1✔
15

16
from pants.base.exceptions import BuildConfigurationError
1✔
17
from pants.build_graph.build_configuration import BuildConfiguration
1✔
18
from pants.build_graph.build_file_aliases import BuildFileAliases
1✔
19
from pants.engine.rules import rule
1✔
20
from pants.engine.target import COMMON_TARGET_FIELDS, Target
1✔
21
from pants.init.extension_loader import (
1✔
22
    PluginLoadOrderError,
23
    PluginNotFound,
24
    load_backend,
25
    load_backends_and_plugins,
26
    load_plugins,
27
)
28
from pants.option.subsystem import Subsystem
1✔
29
from pants.util.frozendict import FrozenDict
1✔
30
from pants.util.memo import memoized_method
1✔
31
from pants.util.ordered_set import FrozenOrderedSet
1✔
32

33
logger = logging.getLogger(__name__)
1✔
34

35

36
class MockDistribution(Distribution):
1✔
37
    def __init__(self, metadata: dict[str, str]) -> None:
1✔
38
        self._mocked_metadata = metadata
1✔
39

40
    @memoized_method
1✔
41
    def _text_for_metadata(self) -> str:
1✔
42
        mocked_metadata_text = "Metadata-Version: 2.1\n"
1✔
43
        for key, value in self._mocked_metadata.items():
1✔
44
            title_case_key = key[0].upper() + key[1:]
1✔
45
            mocked_metadata_text += f"{title_case_key}: {value}\n"
1✔
46
        return mocked_metadata_text
1✔
47

48
    def read_text(self, filename):
1✔
49
        if filename == "entry_points.txt":
1✔
50
            return self._mocked_metadata.get("entry_points.txt")
1✔
51
        if filename == "METADATA":
1✔
52
            return self._text_for_metadata()
1✔
53
        return None
×
54

55
    def locate_file(self):
1✔
56
        return None
×
57

58

59
class MockDistributionFinder(DistributionFinder):
1✔
60
    def __init__(self, dist: MockDistribution) -> None:
1✔
61
        self._dist = dist
1✔
62

63
    def find_distributions(
1✔
64
        self, context: DistributionFinder.Context = DistributionFinder.Context()
65
    ):
66
        if context.name is None or self._dist.name == context.name:
1✔
67
            return [self._dist]
1✔
68
        return []
×
69

70
    def find_spec(self, fullname, path, target=None):
1✔
UNCOV
71
        return None
×
72

73

74
class DummySubsystem(Subsystem):
1✔
75
    options_scope = "dummy-subsystem"
1✔
76

77

78
class DummyTarget(Target):
1✔
79
    alias = "dummy_tgt"
1✔
80
    core_fields = COMMON_TARGET_FIELDS
1✔
81

82

83
class DummyTarget2(Target):
1✔
84
    alias = "dummy_tgt2"
1✔
85
    core_fields = ()
1✔
86

87

88
class DummyObject1:
1✔
89
    pass
1✔
90

91

92
class DummyObject2:
1✔
93
    pass
1✔
94

95

96
@dataclass(frozen=True)
1✔
97
class RootType:
1✔
98
    value: Any
99

100

101
@dataclass(frozen=True)
1✔
102
class WrapperType:
1✔
103
    value: Any
104

105

106
@rule
1✔
107
async def example_rule(root_type: RootType) -> WrapperType:
1✔
108
    return WrapperType(root_type.value)
×
109

110

111
class PluginProduct:
1✔
112
    pass
1✔
113

114

115
@rule
1✔
116
async def example_plugin_rule(root_type: RootType) -> PluginProduct:
1✔
117
    return PluginProduct()
×
118

119

120
class LoaderTest(unittest.TestCase):
1✔
121
    def setUp(self):
1✔
122
        self.bc_builder = BuildConfiguration.Builder()
1✔
123

124
    @contextmanager
1✔
125
    def create_register(
1✔
126
        self,
127
        build_file_aliases=None,
128
        rules=None,
129
        target_types=None,
130
        module_name="register",
131
    ):
132
        package_name = f"__test_package_{uuid.uuid4().hex}"
1✔
133
        self.assertFalse(package_name in sys.modules)
1✔
134

135
        package_module = types.ModuleType(package_name)
1✔
136
        sys.modules[package_name] = package_module
1✔
137
        try:
1✔
138
            register_module_fqn = f"{package_name}.{module_name}"
1✔
139
            register_module = types.ModuleType(register_module_fqn)
1✔
140
            setattr(package_module, module_name, register_module)
1✔
141
            sys.modules[register_module_fqn] = register_module
1✔
142

143
            def register_entrypoint(function_name, function):
1✔
144
                if function:
1✔
145
                    setattr(register_module, function_name, function)
1✔
146

147
            register_entrypoint("build_file_aliases", build_file_aliases)
1✔
148
            register_entrypoint("rules", rules)
1✔
149
            register_entrypoint("target_types", target_types)
1✔
150

151
            yield package_name
1✔
152
        finally:
153
            del sys.modules[package_name]
1✔
154

155
    def assert_empty(self):
1✔
156
        build_configuration = self.bc_builder.create()
1✔
157
        registered_aliases = build_configuration.registered_aliases
1✔
158
        self.assertEqual(0, len(registered_aliases.objects))
1✔
159
        self.assertEqual(0, len(registered_aliases.context_aware_object_factories))
1✔
160
        self.assertEqual(build_configuration.subsystem_to_providers, FrozenDict())
1✔
161
        self.assertEqual(0, len(build_configuration.rules))
1✔
162
        self.assertEqual(0, len(build_configuration.target_types))
1✔
163

164
    def test_load_valid_empty(self):
1✔
165
        with self.create_register() as backend_package:
1✔
166
            load_backend(self.bc_builder, backend_package)
1✔
167
            self.assert_empty()
1✔
168

169
    def test_load_valid_partial_aliases(self):
1✔
170
        aliases = BuildFileAliases(objects={"obj1": DummyObject1, "obj2": DummyObject2})
1✔
171
        with self.create_register(build_file_aliases=lambda: aliases) as backend_package:
1✔
172
            load_backend(self.bc_builder, backend_package)
1✔
173
            build_configuration = self.bc_builder.create()
1✔
174
            registered_aliases = build_configuration.registered_aliases
1✔
175
            self.assertEqual(DummyObject1, registered_aliases.objects["obj1"])
1✔
176
            self.assertEqual(DummyObject2, registered_aliases.objects["obj2"])
1✔
177

178
    def test_load_invalid_entrypoint(self):
1✔
179
        def build_file_aliases(bad_arg):
1✔
180
            return BuildFileAliases()
×
181

182
        with self.create_register(build_file_aliases=build_file_aliases) as backend_package:
1✔
183
            with self.assertRaises(BuildConfigurationError):
1✔
184
                load_backend(self.bc_builder, backend_package)
1✔
185

186
    def test_load_invalid_module(self):
1✔
187
        with self.create_register(module_name="register2") as backend_package:
1✔
188
            with self.assertRaises(BuildConfigurationError):
1✔
189
                load_backend(self.bc_builder, backend_package)
1✔
190

191
    def test_load_missing_plugin(self):
1✔
192
        with self.assertRaises(PluginNotFound):
1✔
193
            self.load_plugins(["Foobar"])
1✔
194

195
    @contextmanager
1✔
196
    def with_mock_plugin(
1✔
197
        self, name, version, reg=None, alias=None, after=None, rules=None, target_types=None
198
    ) -> Generator[str]:
199
        """Make a fake Distribution (optionally with entry points)
200

201
        Note the entry points do not actually point to code in the returned distribution --
202
        the distribution does not even have a location and does not contain any code, just metadata.
203

204
        A module is synthesized on the fly and installed into sys.modules under a random name.
205
        If optional entry point callables are provided, those are added as methods to the module and
206
        their name (foo/bar/baz in fake module) is added as the requested entry point to the mocked
207
        metadata added to the returned dist.
208

209
        :param string name: project_name for distribution (see pkg_resources)
210
        :param string version: version for distribution (see pkg_resources)
211
        :param callable reg: Optional callable for goal registration entry point
212
        :param callable alias: Optional callable for build_file_aliases entry point
213
        :param callable after: Optional callable for load_after list entry point
214
        :param callable rules: Optional callable for rules entry point
215
        :param callable target_types: Optional callable for target_types entry point
216
        """
217

218
        plugin_pkg = f"demoplugin{uuid.uuid4().hex}"
1✔
219
        pkg = types.ModuleType(plugin_pkg)
1✔
220
        sys.modules[plugin_pkg] = pkg
1✔
221
        module_name = f"{plugin_pkg}.demo"
1✔
222
        plugin = types.ModuleType(module_name)
1✔
223
        setattr(pkg, "demo", plugin)
1✔
224
        sys.modules[module_name] = plugin
1✔
225

226
        metadata = {}
1✔
227
        entry_lines = []
1✔
228

229
        if reg is not None:
1✔
230
            setattr(plugin, "foo", reg)
×
231
            entry_lines.append(f"register_goals = {module_name}:foo\n")
×
232

233
        if alias is not None:
1✔
234
            setattr(plugin, "bar", alias)
1✔
235
            entry_lines.append(f"build_file_aliases = {module_name}:bar\n")
1✔
236

237
        if after is not None:
1✔
238
            setattr(plugin, "baz", after)
1✔
239
            entry_lines.append(f"load_after = {module_name}:baz\n")
1✔
240

241
        if rules is not None:
1✔
242
            setattr(plugin, "qux", rules)
1✔
243
            entry_lines.append(f"rules = {module_name}:qux\n")
1✔
244

245
        if target_types is not None:
1✔
246
            setattr(plugin, "tofu", target_types)
1✔
247
            entry_lines.append(f"target_types = {module_name}:tofu\n")
1✔
248

249
        metadata = {"name": name, "version": version}
1✔
250
        if entry_lines:
1✔
251
            entry_data = "[pantsbuild.plugin]\n{}\n".format("\n".join(entry_lines))
1✔
252
            metadata["entry_points.txt"] = entry_data
1✔
253

254
        try:
1✔
255
            orig_sys_meta_path = sys.meta_path[:]
1✔
256
            sys.meta_path.insert(0, MockDistributionFinder(MockDistribution(metadata)))
1✔
257
            importlib.invalidate_caches()
1✔
258
            yield module_name
1✔
259
        finally:
260
            sys.meta_path = orig_sys_meta_path
1✔
261
            del sys.modules[module_name]
1✔
262

263
    def load_plugins(self, plugins):
1✔
264
        load_plugins(self.bc_builder, plugins)
1✔
265

266
    def test_plugin_load_and_order(self):
1✔
267
        with (
1✔
268
            self.with_mock_plugin("demo1", "0.0.1", after=lambda: ["demo2"]),
269
        ):
270
            # Attempting to load 'demo1' then 'demo2' should fail as 'demo1' requires 'after'=['demo2'].
271
            with self.assertRaises(PluginLoadOrderError):
1✔
272
                self.load_plugins(["demo1", "demo2"])
1✔
273

274
        with (
1✔
275
            self.with_mock_plugin("demo1", "0.0.1", after=lambda: ["demo2"]),
276
        ):
277
            # Attempting to load 'demo2' first should fail as it is not (yet) installed.
278
            with self.assertRaises(PluginNotFound):
1✔
279
                self.load_plugins(["demo2", "demo1"])
1✔
280

281
        # Installing demo2 and then loading in correct order should work though.
282
        with (
1✔
283
            self.with_mock_plugin("demo2", "0.0.3"),
284
            self.with_mock_plugin("demo1", "0.0.1", after=lambda: ["demo2"]),
285
        ):
286
            self.load_plugins(["demo2>=0.0.2", "demo1"])
1✔
287

288
            # But asking for a bad (not installed) version fails.
289
            with self.assertRaises(PluginNotFound):
1✔
290
                self.load_plugins(["demo2>=0.0.5"])
1✔
291

292
    def test_plugin_installs_alias(self):
1✔
293
        def reg_alias():
1✔
294
            return BuildFileAliases(
1✔
295
                objects={"FROMPLUGIN1": DummyObject1, "FROMPLUGIN2": DummyObject2},
296
            )
297

298
        with self.with_mock_plugin("aliasdemo", "0.0.1", alias=reg_alias):
1✔
299
            # Start with no aliases.
300
            self.assert_empty()
1✔
301

302
            # Now load the plugin which defines aliases.
303
            self.load_plugins(["aliasdemo"])
1✔
304

305
            # Aliases now exist.
306
            build_configuration = self.bc_builder.create()
1✔
307
            registered_aliases = build_configuration.registered_aliases
1✔
308
            self.assertEqual(DummyObject1, registered_aliases.objects["FROMPLUGIN1"])
1✔
309
            self.assertEqual(DummyObject2, registered_aliases.objects["FROMPLUGIN2"])
1✔
310

311
    def test_rules(self):
1✔
312
        def backend_rules():
1✔
313
            return [example_rule]
1✔
314

315
        with self.create_register(rules=backend_rules) as backend_package:
1✔
316
            load_backend(self.bc_builder, backend_package)
1✔
317
            self.assertEqual(self.bc_builder.create().rules, FrozenOrderedSet([example_rule.rule]))
1✔
318

319
        def plugin_rules():
1✔
320
            return [example_plugin_rule]
1✔
321

322
        with self.with_mock_plugin("this-plugin-rules", "0.0.1", rules=plugin_rules):
1✔
323
            self.load_plugins(["this-plugin-rules"])
1✔
324
            self.assertEqual(
1✔
325
                self.bc_builder.create().rules,
326
                FrozenOrderedSet([example_rule.rule, example_plugin_rule.rule]),
327
            )
328

329
    def test_target_types(self):
1✔
330
        def target_types():
1✔
331
            return [DummyTarget, DummyTarget2]
1✔
332

333
        with self.create_register(target_types=target_types) as backend_package:
1✔
334
            load_backend(self.bc_builder, backend_package)
1✔
335
            assert self.bc_builder.create().target_types == (DummyTarget, DummyTarget2)
1✔
336

337
        class PluginTarget(Target):
1✔
338
            alias = "plugin_tgt"
1✔
339
            core_fields = ()
1✔
340

341
        def plugin_targets():
1✔
342
            return [PluginTarget]
1✔
343

344
        with self.with_mock_plugin("new-targets", "0.0.1", target_types=plugin_targets):
1✔
345
            self.load_plugins(["new-targets"])
1✔
346
            assert self.bc_builder.create().target_types == (
1✔
347
                DummyTarget,
348
                DummyTarget2,
349
                PluginTarget,
350
            )
351

352
    def test_backend_plugin_ordering(self):
1✔
353
        def reg_alias():
1✔
UNCOV
354
            return BuildFileAliases(objects={"override-alias": DummyObject2})
×
355

356
        with self.with_mock_plugin("pluginalias", "0.0.1", alias=reg_alias):
1✔
357
            plugins = ["pluginalias==0.0.1"]
1✔
358
            aliases = BuildFileAliases(objects={"override-alias": DummyObject1})
1✔
359
            with self.create_register(build_file_aliases=lambda: aliases) as backend_module:
1✔
360
                backends = [backend_module]
1✔
361
                build_configuration = load_backends_and_plugins(
1✔
362
                    plugins, backends, pants_ng=False, bc_builder=self.bc_builder
363
                )
364
            # The backend should load first, then the plugins, therefore the alias registered in
365
            # the plugin will override the alias registered by the backend
UNCOV
366
            registered_aliases = build_configuration.registered_aliases
×
UNCOV
367
            self.assertEqual(DummyObject2, registered_aliases.objects["override-alias"])
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc